From 99758f5a3f34ccc46e00f84906cd7d6fff77b4bf Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Mon, 12 Oct 2026 18:51:46 +0300 Subject: [PATCH 01/15] Separate captured environments from PBT source arguments --- .../ts/pbt/mapping/EtsEntryPointResolver.kt | 48 ++++++++-- .../usvm/ts/pbt/mapping/EtsMappingModel.kt | 7 ++ .../PropertyEtsExportResolutionTest.kt | 87 ++++++++++++++++++- .../mapping/exports/CapturedBuiltinArrow.ts | 19 ++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts 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 12cc28be18..8787c9e4c9 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,11 @@ 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() } + val hasUnmatchedSourceParameters = methodsWithBindings.any { (_, parameters) -> + parameters.inputs.size != manifest.inputs.size + } + if (hasUnmatchedSourceParameters) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED, message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", @@ -63,10 +69,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 +240,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 +276,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 5d0b3f9310..7fc3a5ca98 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 401ce47bec..8afa178bfc 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,6 +3,7 @@ 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.EtsLexicalEnvType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticFieldRef @@ -18,9 +19,90 @@ 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.assertNull 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(expected = 0, actual = hiddenCapture.index) + assertEquals(expected = 1, actual = lexicalEnvironment.stackSlot) + assertEquals(listOf("Number", "Error"), captureType.closures.map { closure -> closure.name }) + assertEquals(expected = "value", actual = input.parameter.name) + assertEquals(expected = 1, actual = input.parameter.index) + assertEquals(expected = 2, actual = 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(expected = 1, actual = lexicalEnvironment.stackSlot) + assertEquals(expected = 2, actual = bindings.inputs.single().stackSlot) + } + + @Test + fun `captured expression arrow preserves multiple source inputs in order`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + val manifest = manifest( + module = source.fileName.toString(), + exportName = "capturedBuiltinsWithTwoInputs", + ).copy( + inputs = listOf( + PropertyInput(name = "first", domain = IntegerDomain()), + PropertyInput(name = "second", domain = IntegerDomain()), + ), + ) + + val artifact = mapper.map(manifest) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val bindings = artifact.predicate.targets.single().bindings + assertNotNull(bindings.lexicalEnvironment) + assertEquals(listOf("first", "second"), bindings.inputs.map { input -> input.propertyInputName }) + assertEquals(listOf("left", "right"), bindings.inputs.map { input -> input.parameter.name }) + assertEquals(listOf(2, 3), bindings.inputs.map { input -> input.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") @@ -116,9 +198,12 @@ class PropertyEtsExportResolutionTest { val artifact = mapper.map(manifest(module = source.fileName.toString(), exportName = "arrowPredicate")) assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) - val method = artifact.predicate.targets.single().method + val target = artifact.predicate.targets.single() + val method = target.method assertTrue(method.name.startsWith("%AM")) assertEquals(listOf("value"), method.parameters.map { parameter -> parameter.name }) + assertNull(target.bindings.lexicalEnvironment) + assertEquals(expected = 1, actual = target.bindings.inputs.single().stackSlot) } @Test 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 0000000000..026cd06536 --- /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 } From ca00fd2be86117d25f1100bf104226a7747959d7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Mon, 12 Oct 2026 23:57:09 +0300 Subject: [PATCH 02/15] Bind Calls inputs and preserve AST semantics during source replay --- .../org/usvm/ts/calls/CallsExperiment.kt | 61 +- .../org/usvm/ts/calls/CallsSourceReplay.kt | 121 +++- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 277 +++++++++ .../ts/calls/CallsUnknownCallTelemetry.kt | 154 +++++ .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 525 ++++++++++++++---- .../org/usvm/ts/calls/CallsExperimentTest.kt | 118 ++++ .../usvm/ts/calls/CallsSourceReplayTest.kt | 237 +++++++- .../ts/calls/CallsUnknownCallTelemetryTest.kt | 85 +++ .../calls/CurrentTsCallsSymbolicEngineTest.kt | 516 +++++++++++++++++ .../calls/SourceTargetReplayFixture.ts | 84 +++ .../fast-check-adapter/package.json | 2 +- .../src/source-inspector-cli.ts | 47 ++ .../fast-check-adapter/src/source-replay.ts | 156 ++++++ .../test/source-replay.test.ts | 75 +++ .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 3 + .../fastcheck/TypeScriptSourceInspector.kt | 104 ++++ .../main/kotlin/org/usvm/machine/TsMachine.kt | 8 +- .../usvm/machine/call/TsBuiltinGlobalType.kt | 3 +- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 10 +- .../machine/expr/TsCapturedInstanceCall.kt | 4 +- .../usvm/machine/interpreter/TsInterpreter.kt | 3 + .../kotlin/org/usvm/machine/state/TsState.kt | 11 + .../main/kotlin/org/usvm/util/ArrayStorage.kt | 40 ++ .../kotlin/org/usvm/util/StringStorage.kt | 22 + 24 files changed, 2525 insertions(+), 141 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/fast-check-adapter/src/source-replay.ts create mode 100644 usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts create mode 100644 usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt 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 606dab1886..d2a524db11 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, @@ -269,6 +273,7 @@ internal class CallsExperimentRunner( private val symbolicEngine: CallsSymbolicEngine, private val targetReplayer: CallsTargetReplayer, private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision, + private val eventRecordWriter: ((Path, CallsRawRecord) -> Unit)? = null, ) { fun run( manifest: CallsExperimentManifest, @@ -306,6 +311,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, ) } } @@ -379,6 +385,10 @@ internal class CallsExperimentRunner( target = target, seed = seed, profile = profile, + appendUnknownCall = { event -> + val writer = eventRecordWriter + if (writer == null) append(rawOutput, event) else writer(rawOutput, event) + }, ) append(rawOutput, result) @@ -395,20 +405,30 @@ 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 eventWrites = CallsEventWriteTracker() + val unknownCallSink = callsUnknownCallEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ) 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 = eventWrites.track(unknownCallSink), ), ) + eventWrites.requireComplete() val replay = symbolic.inputs?.let { inputs -> targetReplayer.replay( sourceRoots = listOf(sourceRoot), @@ -427,6 +447,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, profile = profile, seed = seed, symbolicStatus = symbolic.status, @@ -456,6 +477,23 @@ internal class CallsExperimentRunner( } } +private class CallsEventWriteTracker { + private var firstFailure: Throwable? = null + + fun track(sink: (T) -> Unit): (T) -> Unit = { event -> + runCatching { sink(event) }.getOrElse { error -> + if (firstFailure == null) firstFailure = error + throw error + } + } + + fun requireComplete() { + firstFailure?.let { error -> + throw IllegalStateException("Could not persist a Calls event", error) + } + } +} + internal data class CallsValidatedRawResults( val metadata: CallsRunMetadata, val results: List, @@ -497,7 +535,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 748a22c1a6..9957ea9723 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,17 @@ 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 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 +57,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 +72,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 +161,22 @@ 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 instrumentation = TypeScriptSourceInspector.instrumentReplay( + source = resolved.source, + mode = target.mode.name, + startOffset = target.startOffset, + endOffset = target.endOffset, + expressionStartOffset = target.returnExpressionStartOffset, + expressionEndOffset = target.returnExpressionEndOffset, + marker = marker, + ) + val instrumented = instrumentation.source + if (instrumented == null) { + return CallsSourceReplayResult( + status = CallsReplayStatus.UNSUPPORTED, + reason = instrumentation.reason, + ) + } createOverlay( sourceRoot = resolved.sourceRoot, overlayRoot = overlayRoot, @@ -154,6 +192,8 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName = entryPoint.exportName, marker = marker, resultPath = resultPath, + targetMode = target.mode, + sourceArgumentCount = inputs.size, ), ) val replayRoots = sourceRoots.mapIndexed { index, root -> @@ -163,7 +203,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), ) @@ -211,12 +251,17 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { if (!candidate.startsWith(sourceRoot) || !Files.isRegularFile(candidate)) { null } else { - ResolvedTarget( - sourceRootIndex = index, - sourceRoot = sourceRoot, - relativeTarget = relativeTarget, - source = candidate.toRealPath(), - ) + val canonicalSource = candidate.toRealPath() + if (!canonicalSource.startsWith(sourceRoot)) { + null + } else { + ResolvedTarget( + sourceRootIndex = index, + sourceRoot = sourceRoot, + relativeTarget = relativeTarget, + source = canonicalSource, + ) + } } } require(matches.size == 1) { "Target source path resolved to ${matches.size} files" } @@ -225,15 +270,8 @@ 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" - } - require(sourcePositionAt(source = source, offset = target.endOffset) == target.end) { - "Target end coordinate does not match its source offset" - } + val diagnostic = callsTargetCoordinateDiagnostic(source = source, target = target) + require(diagnostic == null) { requireNotNull(diagnostic) } } private fun createOverlay( @@ -291,6 +329,8 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName: String, marker: String, resultPath: Path, + targetMode: CallsSourceTargetMode, + sourceArgumentCount: Int, ): String = """ import { writeFileSync } from 'node:fs'; import * as targetModule from ${jsString("./$sourcePath")}; @@ -308,7 +348,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { let invocation: 'returned' | 'threw' = 'returned'; let caught: unknown; try { - const result = callable(...args); + const result = callable(...args.slice(0, $sourceArgumentCount)); if (result !== null && (typeof result === 'object' || typeof result === 'function') && typeof (result as { then?: unknown }).then === 'function') { void Promise.resolve(result).catch(() => undefined); @@ -318,7 +358,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 +386,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 0000000000..3d53c7cea9 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt @@ -0,0 +1,277 @@ +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.EtsType +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.api.initializeArrayLength +import org.usvm.api.makeSymbolicPrimitive +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.allocateBoundedString +import org.usvm.util.markDenseInputArray +import org.usvm.util.mkArrayIndexLValue +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 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 length: UExpr = makeSymbolicPrimitive(sizeSort) + val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } + + constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) + val stringRef = allocateBoundedString(length = length, codeUnits = codeUnits) + 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) { + val lengthBounds = mkAnd( + mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)), + mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)), + ) + pathConstraints += lengthBounds +} + +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 0000000000..e110d0a2b9 --- /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 5cb013e8e3..1387aef608 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,9 @@ 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.EtsLocal 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 +12,93 @@ 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.isBuiltinDateGlobalType +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.ArrayDomain +import org.usvm.ts.pbt.model.StringDomain 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 +108,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) @@ -135,22 +146,36 @@ internal class CurrentTsCallsSymbolicEngine( stopOnTargetsReached = false, throwExceptionOnStepFailure = true, ) + val inputCapacity = request.function.inputs.maxOfOrNull { input -> + when (val domain = input.domain) { + is ArrayDomain -> domain.maxLength + is StringDomain -> domain.maxLength + else -> 0 + } + } ?: 0 val tsOptions = TsOptions( + maxArraySize = maxOf(TsOptions().maxArraySize, inputCapacity), 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, + machineObserver = targetObserver, + observer = interpreterObserver, ).use { machine -> - val outcome = machine.analyzeWithOutcome(methods = listOf(method)) + val outcome = machine.analyzeWithOutcome( + methods = listOf(method), + configureInitialState = { _, state -> symbolicInputs.initialize(state) }, + ) MachineResult( - states = entryObserver.reachedStates, + states = targetObserver.reachedStates, stopReason = outcome.stopReason, unsupportedPaths = outcome.unsupportedPaths, ) @@ -179,7 +204,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 } } @@ -196,11 +221,169 @@ 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 + ?.filterNot(::isSupportedBuiltinCapture) + ?.map { closure -> closure.name } + .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, + ) + } + + private fun isSupportedBuiltinCapture(capture: EtsLocal): Boolean { + if (capture.name == "Date") { + return capture.type.isBuiltinDateGlobalType() + } + + return capture.name in SUPPORTED_BUILTIN_CAPTURES + } + private fun exactTargetCandidates(method: EtsMethod, target: CallsSourceTarget): List = method.cfg.stmts.filter { statement -> val origin = statement.location.origin ?: return@filter false @@ -212,29 +395,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( @@ -294,18 +525,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, @@ -314,9 +608,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, @@ -398,3 +718,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 d4307c6080..3c17bc6916 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,118 @@ 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 `runner does not publish completion after an observer write failure`(@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 event = TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + decision = TsUnknownCallDecision.ResidualFallback(policy = TsResidualCallPolicy.STOP_PATH), + ) + val engine = CallsSymbolicEngine { request -> + runCatching { checkNotNull(request.unknownCallEventSink).invoke(event) } + result(status = CallsSymbolicStatus.UNREACHED) + } + val rawOutput = directory.resolve("results.jsonl") + + val failure = assertFailsWith { + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + eventRecordWriter = { _, _ -> error("Event write failed") }, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(17L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + } + + assertTrue(failure.message.orEmpty().contains("Calls event")) + assertFalse(Files.exists(rawOutput)) + } + + @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 7f764c3a7c..303f846ed6 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 @@ -10,6 +10,55 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull class CallsSourceReplayTest { + @Test + fun `replays a zero-input export without the FastCheck placeholder`() { + val fixture = fixture() + val zeroArguments = fixture.target(functionName = "checksArgumentCount", statement = "return 0;") + val extraArgument = fixture.target(functionName = "checksArgumentCount", statement = "return 1;") + + val reached = fixture.replay(exportName = "checksArgumentCount", inputs = emptyList(), target = zeroArguments) + val notReached = fixture.replay( + exportName = "checksArgumentCount", + inputs = emptyList(), + target = extraArgument, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, reached.status, reached.toString()) + assertEquals(CallsReplayStatus.REJECTED, notReached.status, notReached.toString()) + } + + @Test + fun `completed return is rejected when finally overrides it or the branch is untaken`() { + val fixture = fixture() + val overridden = fixture.target( + functionName = "finallyOverridesCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + val untaken = fixture.target( + functionName = "bareConditionalCompletedReturn", + statement = "return;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + + val overriddenReplay = fixture.replay( + exportName = "finallyOverridesCompletedReturn", + inputs = emptyList(), + target = overridden, + ) + val untakenReplay = fixture.replay( + exportName = "bareConditionalCompletedReturn", + inputs = listOf(number(-1.0)), + target = untaken, + ) + + assertEquals(CallsReplayStatus.REJECTED, overriddenReplay.status, overriddenReplay.toString()) + assertEquals(CallsReplayStatus.REJECTED, untakenReplay.status, untakenReplay.toString()) + assertEquals(false, overriddenReplay.invocation?.targetHit) + assertEquals(false, untakenReplay.invocation?.targetHit) + } + @Test fun `confirms only the exact source statement reached by original TypeScript`() { val fixture = fixture() @@ -53,6 +102,176 @@ class CallsSourceReplayTest { assertEquals(true, invoked.invocation?.targetHit) } + @Test + fun `completed expression return is confirmed after evaluating its exact expression`() { + 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(true, replay.invocation?.targetHit) + } + + @Test + fun `throwing expression never completes the selected return`() { + 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(false, replay.invocation?.targetHit) + } + + @Test + fun `throwing finally cancels the selected return completion`() { + 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(false, replay.invocation?.targetHit) + } + + @Test + fun `completed bare return is confirmed without evaluating an expression`() { + 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(true, replay.invocation?.targetHit) + } + + @Test + fun `entry instrumentation preserves unbraced conditional and loop bodies`() { + val fixture = fixture() + val cases = listOf( + "unbracedConditional" to "return 1;", + "unbracedLoop" to "value += 0;", + "labeledLoop" to "continue outer;", + ) + + for ((functionName, statement) in cases) { + val target = fixture.target(functionName = functionName, statement = statement) + val untaken = fixture.replay(exportName = functionName, inputs = listOf(number(0.0)), target = target) + val taken = fixture.replay(exportName = functionName, inputs = listOf(number(1.0)), target = target) + + assertEquals(CallsReplayStatus.REJECTED, untaken.status, "$functionName: $untaken") + assertEquals(false, untaken.invocation?.targetHit) + assertEquals(CallsReplayStatus.CONFIRMED, taken.status, "$functionName: $taken") + assertEquals(true, taken.invocation?.targetHit) + } + } + + @Test + fun `completed conditional return preserves its taken and untaken branches`() { + val fixture = fixture() + val target = fixture.target( + functionName = "bareConditionalCompletedReturn", + statement = "return;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + + val taken = fixture.replay( + exportName = "bareConditionalCompletedReturn", + inputs = listOf(number(1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, taken.status, taken.toString()) + assertEquals(true, taken.invocation?.targetHit) + } + + @Test + fun `completed return evaluates side effects once and survives normally completing finally`() { + val fixture = fixture() + val target = fixture.target( + functionName = "returnExpressionRunsOnce", + statement = "return once();", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "once()", + ) + + val replay = fixture.replay(exportName = "returnExpressionRunsOnce", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("returned", replay.invocation?.invocation) + } + + @Test + fun `finally break and escaping throw cancel pending return before implicit completion`() { + val fixture = fixture() + + for (functionName in listOf("finallyBreakCancelsReturn", "finallyThrowCaughtOutside")) { + val target = fixture.target( + functionName = functionName, + statement = "return 1;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "1", + ) + val replay = fixture.replay(exportName = functionName, inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, "$functionName: $replay") + assertEquals("returned", replay.invocation?.invocation) + assertEquals(false, replay.invocation?.targetHit) + } + } + + @Test + fun `rejects a source target symlink that escapes its declared root`() { + val workspace = Files.createTempDirectory("calls-replay-root-boundary-") + try { + val sourceRoot = Files.createDirectories(workspace.resolve("root")) + val source = "export function target(): number { return 7; }" + val outside = Files.writeString(workspace.resolve("outside.ts"), source) + val sourcePath = Files.createSymbolicLink(sourceRoot.resolve("Target.ts"), outside) + val fixture = Fixture(sourceRoot = sourceRoot, sourcePath = sourcePath, source = source) + val target = fixture.target(functionName = "target", statement = "return 7;") + + val replay = fixture.replay(exportName = "target", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.TOOL_ERROR, replay.status, replay.toString()) + assertEquals("Target source path resolved to 0 files", replay.message) + } finally { + workspace.toFile().deleteRecursively() + } + } + private fun fixture(): Fixture { val sourcePath = resourcePath("/calls/SourceTargetReplayFixture.ts") @@ -76,11 +295,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 +322,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 0000000000..82004909cf --- /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 b8923279e3..64fb3c9e00 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,28 @@ 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.JsNumber +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 +49,501 @@ 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 searches a normal arrow but replay awaits exact instrumentation`() { + 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()) + val replay = OriginalTypeScriptTargetReplayer().replay( + sourceRoots = listOf(fixture.sourceRoot), + entryPoint = fixture.function.entryPoint, + inputs = inputs, + target = fixture.target, + timeoutMillis = 10_000L, + ) + + assertEquals(CallsReplayStatus.UNSUPPORTED, replay.status, replay.toString()) + } + + @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) + } + + @Test + fun `input domain capacity preserves existing slots beyond the default machine limit`() { + val fixture = fixture( + source = """ + export function existingHighSlot(values: number[]): boolean { + values[1001] = 7; + if (values.length === 1002 && values[1001] === 7) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "existingHighSlot", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain( + element = NumberDomain( + min = JsNumber.finite(0.0), + max = JsNumber.finite(0.0), + allowNaN = false, + ), + minLength = 1002, + maxLength = 1002, + ), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals(1002, assertIs(inputs.single()).elements.size) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `genuine captured Date reaches calls fallback and replays with native owner identity`() { + val fixture = fixture( + source = """ + export const nativeDate = (value: number): boolean => { + if (value === 0 && Date.now() > 0) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "nativeDate", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val unknownCalls = mutableListOf() + val result = fixture.search( + modelIds = emptySet(), + profile = CallsExperimentProfile.FROZEN_FRESH, + unknownCallEventSink = unknownCalls::add, + ) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + assertTrue(unknownCalls.any { event -> event.callee.name == "now" }) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + + @Test + fun `user captured object named Date is not admitted as a native global`() { + val fixture = fixture( + source = """ + const Date = { now: (): number => 7 }; + export const userDate = (value: number): boolean => { + if (value === 0 && Date.now() > 0) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "userDate", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, preflight.reasonCode) + } + + 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, + profile: CallsExperimentProfile = CallsExperimentProfile.FROZEN_STOP, + ): CallsSymbolicSearchResult = engine.search( + CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = profile, + 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 dd20e17a49..78fd7e8c5b 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -4,6 +4,25 @@ export function throwsAtTarget(): never { throw new Error('expected'); } +export function checksArgumentCount(): number { + if (arguments.length !== 0) { + return 1; + } + return 0; +} + +export function bareConditionalCompletedReturn(value: number): void { + if (value > 0) return; +} + +export function finallyOverridesCompletedReturn(): number { + try { + return 11; + } finally { + return 22; + } +} + export function importOnlyTarget(): number { return 7; } @@ -13,3 +32,68 @@ 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 */ +} + +export function unbracedConditional(value: number): number { + if (value > 0) return 1; + return 0; +} + +export function unbracedLoop(value: number): void { + for (let index = 0; index < value; index++) value += 0; +} + +export function labeledLoop(value: number): void { + outer: for (let index = 0; index < value; index++) continue outer; +} + +export function returnExpressionRunsOnce(): number { + let executions = 0; + const once = (): number => { executions++; return 7; }; + try { + return once(); + } finally { + while (true) { break; } + try { throw new Error('locally caught'); } catch { /* pending return survives */ } + if (executions !== 1) throw new Error('expression evaluated more than once'); + } +} + +export function finallyBreakCancelsReturn(): number | undefined { + while (true) { + try { + return 1; + } finally { + break; + } + } +} + +export function finallyThrowCaughtOutside(): number | undefined { + try { + try { return 1; } finally { throw new Error('cancel return'); } + } catch { /* implicit function completion does not complete the selected return */ } +} diff --git a/usvm-ts-fast-check/fast-check-adapter/package.json b/usvm-ts-fast-check/fast-check-adapter/package.json index fdac679842..b30dda7272 100644 --- a/usvm-ts-fast-check/fast-check-adapter/package.json +++ b/usvm-ts-fast-check/fast-check-adapter/package.json @@ -9,7 +9,7 @@ "build": "tsc --project tsconfig.json", "pretest": "npm run build", "test": "npm run test:compiled", - "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js" + "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js dist/test/source-replay.test.js" }, "dependencies": { "c8": "10.1.3", 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 0000000000..0f2bf04d8a --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { instrumentSourceReplay } from './source-replay.js' + +const [operation, sourcePath, ...arguments_] = process.argv.slice(2) +if (sourcePath === undefined) throw new Error('Expected a TypeScript source path') +const source = readFileSync(sourcePath, 'utf8') +const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) +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) throw new Error(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')) + +if (operation === 'expression-arrow') { + const [exportName, startText, endText] = arguments_ + const start = Number(startText) + const end = Number(endText) + const matches = sourceFile.statements.filter(ts.isVariableStatement).flatMap((statement) => { + const exported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + if (!exported) return [] + return statement.declarationList.declarations.filter((declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === exportName + && declaration.initializer !== undefined && ts.isArrowFunction(declaration.initializer) + && !ts.isBlock(declaration.initializer.body) + && declaration.initializer.body.getStart(sourceFile) === start && declaration.initializer.body.getEnd() === end, + ) + }) + process.stdout.write(matches.length === 1 ? 'true' : 'false') +} else if (operation === 'instrument-replay') { + const [mode, startText, endText, expressionStartText, expressionEndText, marker] = arguments_ + if ((mode !== 'ENTRY' && mode !== 'COMPLETED_RETURN') || marker === undefined) { + throw new Error('Expected replay mode, offsets and marker') + } + const result = instrumentSourceReplay(sourceFile, { + mode, + start: Number(startText), + end: Number(endText), + expressionStart: expressionStartText === '-' ? null : Number(expressionStartText), + expressionEnd: expressionEndText === '-' ? null : Number(expressionEndText), + marker, + }) + process.stdout.write(JSON.stringify(result)) +} else { + throw new Error(`Unknown source inspection operation: ${operation}`) +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts b/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts new file mode 100644 index 0000000000..04190e1929 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts @@ -0,0 +1,156 @@ +import ts from 'typescript' + +export interface SourceReplayTarget { + mode: 'ENTRY' | 'COMPLETED_RETURN' + start: number + end: number + expressionStart: number | null + expressionEnd: number | null + marker: string +} + +interface Edit { start: number; end: number; text: string } +export interface SourceReplayInstrumentation { source: string | null; reason: string | null } + +export function instrumentSourceReplay(file: ts.SourceFile, target: SourceReplayTarget): SourceReplayInstrumentation { + const nodes: ts.Node[] = [] + const collect = (node: ts.Node): void => { + const matchesFrozenRange = node.getStart(file) === target.start && node.getEnd() <= target.end + && triviaOnly(file.text.slice(node.getEnd(), target.end)) + if (matchesFrozenRange) nodes.push(node) + ts.forEachChild(node, collect) + } + collect(file) + const statement = nodes.find(ts.isStatement) + const arrowBody = nodes.find((node) => ts.isArrowFunction(node.parent) && node.parent.body === node) + const marker = `(globalThis as Record)[${JSON.stringify(target.marker)}]` + + if (target.mode === 'ENTRY') { + if (statement !== undefined) return { source: instrumentEntry(file, statement, marker), reason: null } + if (arrowBody !== undefined && !ts.isBlock(arrowBody)) { + const expression = file.text.slice(arrowBody.getStart(file), arrowBody.getEnd()) + return { source: applyEdits(file.text, [{ start: arrowBody.getStart(file), end: arrowBody.getEnd(), + text: `(${marker} = true, (${expression}))` }]), reason: null } + } + return { source: null, reason: 'Source target does not identify an exact statement or arrow body' } + } + + if (statement !== undefined && ts.isReturnStatement(statement)) { + const expression = statement.expression + const expressionMatches = expression === undefined + ? target.expressionStart === null && target.expressionEnd === null + : expression.getStart(file) === target.expressionStart && expression.getEnd() === target.expressionEnd + if (!expressionMatches) return { source: null, reason: 'Return expression does not match its frozen AST range' } + return { source: instrumentCompletedReturn(file, statement, marker, target.marker), reason: null } + } + const matchesArrowExpression = arrowBody !== undefined && !ts.isBlock(arrowBody) + && target.expressionStart === arrowBody.getStart(file) && target.expressionEnd === arrowBody.getEnd() + if (matchesArrowExpression && arrowBody !== undefined) { + const temporary = uniqueIdentifier(file, target.marker) + const expression = file.text.slice(arrowBody.getStart(file), arrowBody.getEnd()) + return { source: applyEdits(file.text, [{ start: arrowBody.getStart(file), end: arrowBody.getEnd(), + text: `{ const ${temporary} = (${expression}); ${marker} = true; return ${temporary}; }` }]), reason: null } + } + return { source: null, reason: 'Completed-return target does not identify an exact return or arrow body' } +} + +function triviaOnly(text: string): boolean { + const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, text) + for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) { + if (kind !== ts.SyntaxKind.WhitespaceTrivia && kind !== ts.SyntaxKind.NewLineTrivia + && kind !== ts.SyntaxKind.SingleLineCommentTrivia && kind !== ts.SyntaxKind.MultiLineCommentTrivia) return false + } + return true +} + +function instrumentEntry(file: ts.SourceFile, statement: ts.Statement, marker: string): string { + let anchor = statement + // Keep labels attached to their iteration statement, so continue label still names a loop. + while (ts.isLabeledStatement(anchor.parent)) anchor = anchor.parent + const parent = anchor.parent + const statementList = ts.isSourceFile(parent) || ts.isBlock(parent) || ts.isCaseClause(parent) || ts.isDefaultClause(parent) + const markerStatement = `${marker} = true;\n` + if (statementList) { + const directives = parent.statements.filter((candidate, index, statements) => + ts.isExpressionStatement(candidate) && ts.isStringLiteral(candidate.expression) + && statements.slice(0, index).every((previous) => ts.isExpressionStatement(previous) && ts.isStringLiteral(previous.expression)), + ) + if (directives.includes(anchor)) { + const end = directives[directives.length - 1]!.getEnd() + return applyEdits(file.text, [{ start: end, end, text: `\n${markerStatement}` }]) + } + return applyEdits(file.text, [{ start: anchor.getStart(file), end: anchor.getStart(file), text: markerStatement }]) + } + const original = file.text.slice(anchor.getStart(file), anchor.getEnd()) + return applyEdits(file.text, [{ start: anchor.getStart(file), end: anchor.getEnd(), text: `{ ${markerStatement}${original} }` }]) +} + +function instrumentCompletedReturn(file: ts.SourceFile, selected: ts.ReturnStatement, marker: string, name: string): string { + let owner: ts.Node = selected.parent + while (!ts.isFunctionLike(owner)) owner = owner.parent + const finalizers: ts.Block[] = [] + for (let ancestor: ts.Node = selected; ancestor !== owner; ancestor = ancestor.parent) { + const parent = ancestor.parent + if (ts.isTryStatement(parent) && parent.finallyBlock !== undefined && ancestor !== parent.finallyBlock) { + finalizers.push(parent.finallyBlock) + } + } + const temporary = uniqueIdentifier(file, name) + const edits: Edit[] = [] + const visit = (node: ts.Node): void => { + if (node !== owner && ts.isFunctionLike(node)) return + if (ts.isReturnStatement(node)) { + const expression = node.expression === undefined ? null : file.text.slice(node.expression.getStart(file), node.expression.getEnd()) + const value = node === selected ? 'true' : 'false' + const result = expression === null ? `${marker} = ${value}; return;` + : `const ${temporary} = (${expression}); ${marker} = ${value}; return ${temporary};` + edits.push({ start: node.getStart(file), end: node.getEnd(), text: `{ ${result} }` }) + return + } + if ((ts.isBreakStatement(node) || ts.isContinueStatement(node)) && exitsFinalizer(node, finalizers)) { + const original = file.text.slice(node.getStart(file), node.getEnd()) + edits.push({ start: node.getStart(file), end: node.getEnd(), text: `{ ${marker} = false; ${original} }` }) + } + ts.forEachChild(node, visit) + } + visit(owner) + // Only an exception escaping a finalizer cancels the pending selected return; locally caught throws do not. + for (const finalizer of finalizers) { + edits.push({ start: finalizer.getStart(file) + 1, end: finalizer.getStart(file) + 1, text: 'try {' }) + edits.push({ start: finalizer.getEnd() - 1, end: finalizer.getEnd() - 1, + text: `} catch (${temporary}) { ${marker} = false; throw ${temporary}; }` }) + } + return applyEdits(file.text, edits) +} + +function exitsFinalizer(node: ts.BreakStatement | ts.ContinueStatement, finalizers: ts.Block[]): boolean { + const enclosing = finalizers.filter((block) => block.pos <= node.pos && block.end >= node.end) + if (enclosing.length === 0) return false + let destination: ts.Node | undefined + for (let ancestor = node.parent; ancestor !== undefined && !ts.isFunctionLike(ancestor); ancestor = ancestor.parent) { + if (node.label !== undefined) { + if (ts.isLabeledStatement(ancestor) && ancestor.label.text === node.label.text) { destination = ancestor; break } + } else if (isLoop(ancestor) || (ts.isBreakStatement(node) && ts.isSwitchStatement(ancestor))) { + destination = ancestor; break + } + } + return destination !== undefined && enclosing.some((block) => destination!.pos < block.pos || destination!.end > block.end) +} + +function isLoop(node: ts.Node): boolean { + return ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isForStatement(node) + || ts.isForInStatement(node) || ts.isForOfStatement(node) +} + +function uniqueIdentifier(file: ts.SourceFile, marker: string): string { + let name = `__usvm_${marker.replace(/[^a-zA-Z0-9_]/g, '_')}_value` + while (file.text.includes(name)) name += '_' + return name +} + +function applyEdits(source: string, edits: Edit[]): string { + const ordered = edits.sort((left, right) => right.start - left.start || right.end - left.end) + let result = source + for (const edit of ordered) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end) + return result +} diff --git a/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts new file mode 100644 index 0000000000..c03dc696c4 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import vm from 'node:vm' +import ts from 'typescript' +import { instrumentSourceReplay } from '../src/source-replay.js' +import type { SourceReplayTarget } from '../src/source-replay.js' + +function replay(source: string, statement: string, mode: SourceReplayTarget['mode'], input: number, + expression: string | null = null): { value: unknown; marker: boolean } { + const file = ts.createSourceFile('fixture.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const start = source.indexOf(statement) + const expressionStart = expression === null ? null : source.indexOf(expression, start) + const result = instrumentSourceReplay(file, { + mode, start, end: start + statement.length, marker: 'hit', + expressionStart, expressionEnd: expressionStart === null ? null : expressionStart + expression!.length, + }) + assert.equal(result.reason, null) + assert.notEqual(result.source, null) + const context = vm.createContext({ hit: false }) + const compiled = ts.transpileModule(result.source!, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + vm.runInContext(compiled, context) + const value = vm.runInContext(`fixture(${input})`, context) + return { value, marker: vm.runInContext('hit', context) as boolean } +} + +test('unbraced if entry keeps untaken and taken statements under their condition', () => { + const source = 'function fixture(value: number) { if (value > 0) return 1; return 0; }' + assert.deepEqual(replay(source, 'return 1;', 'ENTRY', 0), { value: 0, marker: false }) + assert.deepEqual(replay(source, 'return 1;', 'ENTRY', 1), { value: 1, marker: true }) +}) + +test('labeled iteration statement retains its continue target', () => { + const source = 'function fixture(value: number) { outer: for (let i = 0; i < value; i++) continue outer; return value; }' + const target = 'for (let i = 0; i < value; i++) continue outer;' + assert.deepEqual(replay(source, target, 'ENTRY', 2), { value: 2, marker: true }) +}) + +test('completed return evaluates its operand once and preserves normal finally', () => { + const source = `function fixture(value: number) { + let calls = 0; + const once = () => { calls++; return value; }; + try { return once(); } finally { + while (true) { break; } + try { throw 1; } catch {} + if (calls !== 1) throw 2; + } + }` + assert.deepEqual(replay(source, 'return once();', 'COMPLETED_RETURN', 7, 'once()'), { value: 7, marker: true }) +}) + +test('finally competing return overrides pending completion', () => { + const source = 'function fixture(value: number) { try { return value; } finally { return 2; } }' + assert.deepEqual(replay(source, 'return value;', 'COMPLETED_RETURN', 1, 'value'), { value: 2, marker: false }) +}) + +test('finally break cancels return before implicit completion', () => { + const source = 'function fixture(value: number) { while (true) { try { return value; } finally { break; } } }' + assert.deepEqual(replay(source, 'return value;', 'COMPLETED_RETURN', 1, 'value'), { value: undefined, marker: false }) +}) + +test('finally escaping throw cancels return even if an outer catch resumes', () => { + const source = 'function fixture(value: number) { try { try { return value; } finally { throw 2; } } catch {} }' + assert.deepEqual(replay(source, 'return value;', 'COMPLETED_RETURN', 1, 'value'), { value: undefined, marker: false }) +}) + +test('entry on a strict directive preserves directive semantics', () => { + const source = `function fixture(value: number) { 'use strict'; return this === undefined; }` + assert.deepEqual(replay(source, "'use strict';", 'ENTRY', 0), { value: true, marker: true }) +}) + +test('completed expression arrow retains lexical this and arguments', () => { + const source = 'function outer(value: number) { return () => arguments[0]; } const fixture = outer(7);' + const target = 'arguments[0]' + assert.deepEqual(replay(source, target, 'COMPLETED_RETURN', 1, target), { value: 7, marker: true }) +}) 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 c193295000..bd2fc4f135 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 @@ -6,6 +6,8 @@ import java.nio.file.Path /** Locates the private fast-check runtime in development and installed distributions. */ internal object FastCheckRuntime { + fun sourceInspectorEntryPoint(): Path = locateEntryPoint(SOURCE_INSPECTOR_CLI) + fun executionEntryPoint(): Path = locateEntryPoint(EXECUTION_CLI) fun projectionEntryPoint(): Path = locateEntryPoint(PROJECTION_CLI) @@ -46,6 +48,7 @@ internal object FastCheckRuntime { private const val RUNTIME_DIRECTORY_PROPERTY = "org.usvm.ts.pbt.fastcheck.runtime" private const val ENTRY_POINT_DIRECTORY = "dist/src" + private const val SOURCE_INSPECTOR_CLI = "source-inspector-cli.js" private const val EXECUTION_CLI = "execution-cli.js" private const val PROJECTION_CLI = "projection-cli.js" private const val PROCESS_SUPERVISOR = "process-supervisor.js" 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 0000000000..fa782b4a63 --- /dev/null +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt @@ -0,0 +1,104 @@ +package org.usvm.ts.pbt.fastcheck + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import org.usvm.ts.pbt.manifest.PropertyManifestJson +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +/** Uses the native TypeScript AST when lowered source ranges need validation or replay instrumentation. */ +object TypeScriptSourceInspector { + fun isExportedExpressionArrowBody( + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + nodeExecutable: String = "node", + ): Boolean { + val output = inspect( + source = source, + operation = "expression-arrow", + arguments = listOf(exportName, startOffset.toString(), endOffset.toString()), + nodeExecutable = nodeExecutable, + ) + + return when (output) { + "true" -> true + "false" -> false + else -> error("TypeScript source inspection returned an invalid response: $output") + } + } + + fun instrumentReplay( + source: Path, + mode: String, + startOffset: Int, + endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, + marker: String, + ): TypeScriptReplayInstrumentation { + val output = inspect( + source = source, + operation = "instrument-replay", + arguments = listOf( + mode, + startOffset.toString(), + endOffset.toString(), + expressionStartOffset?.toString() ?: "-", + expressionEndOffset?.toString() ?: "-", + marker, + ), + ) + + return PropertyManifestJson.json.decodeFromString(output) + } + + private fun inspect( + source: Path, + operation: String, + arguments: List, + nodeExecutable: String = "node", + ): String { + // Instrumented source may exceed a pipe buffer. Capture to disk while the child runs. + val outputPath = Files.createTempFile("usvm-ts-source-inspection-", ".json") + try { + val command = listOf( + nodeExecutable, + FastCheckRuntime.sourceInspectorEntryPoint().toString(), + operation, + source.toString(), + ) + arguments + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(outputPath.toFile()) + .start() + if (!process.waitFor(SOURCE_INSPECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + process.waitFor() + error("TypeScript source inspection timed out") + } + + val output = Files.readString(outputPath).trim() + require(process.exitValue() == 0) { + "TypeScript source inspection failed with exit ${process.exitValue()}: $output" + } + return output + } finally { + Files.deleteIfExists(outputPath) + } + } + + private const val SOURCE_INSPECTION_TIMEOUT_SECONDS: Long = 10 +} + +@Serializable +data class TypeScriptReplayInstrumentation( + val source: String?, + val reason: String?, +) { + init { + require((source == null) != (reason == null)) { "Source replay instrumentation needs source or a reason" } + } +} 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 3ad6c0632f..e3c1ff64e8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -116,8 +116,9 @@ class TsMachine( fun analyzeWithOutcome( methods: List, targets: List = emptyList(), + configureInitialState: (EtsMethod, TsState) -> Unit = { _, _ -> }, ): TsAnalysisResult { - val (initialStates, unsupportedPaths) = createInitialStates(methods, targets) + val (initialStates, unsupportedPaths) = createInitialStates(methods, targets, configureInitialState) if (initialStates.isEmpty()) { return TsAnalysisResult(emptyList(), TsAnalysisStopReason.EXHAUSTED, unsupportedPaths.toList()) @@ -250,6 +251,7 @@ class TsMachine( private fun createInitialStates( methods: List, targets: List, + configureInitialState: (EtsMethod, TsState) -> Unit, ): Pair, MutableSet> { val initialStates = mutableMapOf() val unsupportedPaths = mutableSetOf() @@ -260,7 +262,9 @@ class TsMachine( if (unsupportedReason != null) { unsupportedPaths += unsupportedReason } else { - initialStates[method] = interpreter.getInitialState(method, targets) + initialStates[method] = interpreter.getInitialState(method, targets) { + configureInitialState(method, this) + } } } 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 index f4d943c552..14f5c46d78 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt @@ -54,7 +54,8 @@ internal fun TsContext.isBuiltinArrayConstructor(value: UExpr<*>): Boolean { return signature.name == "Array" && signature.file == EtsHierarchy.OBJECT_CLASS.signature.file } -internal fun EtsType.isBuiltinDateGlobalType(): Boolean = this is EtsFunctionType && +/** Native frontend signature of the genuine Date global, independent of a lexical capture name. */ +fun EtsType.isBuiltinDateGlobalType(): Boolean = this is EtsFunctionType && signature.enclosingClass == EtsClassSignature.UNKNOWN && signature.name.isEmpty() && signature.parameters.isEmpty() && 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 b77c5bb9b4..1e92acb9fc 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.machine.call.TsEtsIrUnknownCallModel import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard @@ -12,6 +13,7 @@ import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact import org.usvm.machine.call.resolvedInstanceInputs import org.usvm.util.arrayStorageType +import org.usvm.util.isUnmodifiedDenseInputArray /** Built-in Array algorithms implemented by ordinary TypeScript bodies. */ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { @@ -47,22 +49,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/expr/TsCapturedInstanceCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt index 29dbe97939..0c94b12f75 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsCapturedInstanceCall.kt @@ -160,7 +160,9 @@ private fun isImplicitModeledGlobal(source: EtsLocal, callee: EtsMethodSignature return true } - if (source.type.isBuiltinDateGlobalType()) return true + if (source.type.isBuiltinDateGlobalType()) { + return true + } val modeled = when (source.name) { "Logger" -> 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 1f07f0062e..b10498dee7 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 @@ -759,6 +759,7 @@ class TsInterpreter( fun getInitialState( method: EtsMethod, targets: List, + configureState: TsState.() -> Unit = {}, ): TsState = with(ctx) { val state = TsState( ctx = ctx, @@ -852,6 +853,8 @@ class TsInterpreter( } } + state.configureState() + 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/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 25d56aac73..49dcb8e4ae 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 @@ -1,5 +1,6 @@ package org.usvm.machine.state +import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsBlockCfg import org.jacodb.ets.model.EtsClass import org.jacodb.ets.model.EtsFile @@ -31,6 +32,7 @@ import org.usvm.machine.expr.TsCapturedInstanceCall 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 @@ -101,6 +103,7 @@ class TsState( var writtenConcreteFields: Set> = emptySet(), var initializedTypedArrayOverrides: Set = emptySet(), var writtenObjectLiteralFieldSorts: UPersistentHashMap, USort> = persistentHashMapOf(), + internal val denseInputArrays: MutableMap = mutableMapOf(), private val activeUnknownCallModels: MutableList> = mutableListOf(), ) : UState( ctx = ctx, @@ -350,6 +353,7 @@ class TsState( writtenConcreteFields = writtenConcreteFields, initializedTypedArrayOverrides = initializedTypedArrayOverrides, writtenObjectLiteralFieldSorts = writtenObjectLiteralFieldSorts, + denseInputArrays = denseInputArrays.toMutableMap(), activeUnknownCallModels = activeUnknownCallModels.toMutableList(), ) } @@ -357,3 +361,10 @@ class TsState( override val isExceptional: Boolean get() = methodResult is TsMethodResult.TsException } + +/** Snapshot of the regions that establish dense Calls input-array contents. */ +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 3496177c9f..60350e1c80 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) { @@ -81,3 +84,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 index f6c3255b5d..13921b907b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -1,5 +1,6 @@ package org.usvm.util +import io.ksmt.sort.KBv16Sort import io.ksmt.sort.KBv16Sort import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr @@ -68,3 +69,24 @@ internal fun TsState.stringFromUtf16CodeUnit(codeUnit: UExpr): UConcr ) result } + +/** Initialize the private UTF-16 backing of one bounded symbolic string input. */ +fun TsState.allocateBoundedString( + length: UExpr, + codeUnits: List>, +): UConcreteHeapRef = with(ctx) { + val maxLength = codeUnits.size + val nonnegativeLength = mkBvSignedGreaterOrEqualExpr(length, mkBv(0)) + val boundedLength = mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)) + pathConstraints += mkAnd(nonnegativeLength, boundedLength) + val (string, characters) = allocateString(length = length, maxLength = maxLength) + codeUnits.forEachIndexed { index, codeUnit -> + val position = mkBv(index) + val liveIndex = mkBvSignedLessExpr(position, length) + val element = mkStringBackingElementLValue(ref = characters, index = position) + + memory.write(element, codeUnit, guard = liveIndex) + } + + string +} From 76e9eec48c557b86979f0f95806bb732c5f5b0e1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 00:07:11 +0300 Subject: [PATCH 03/15] Track outer replay completion and finalize all dense input snapshots --- .../org/usvm/ts/calls/CallsSourceReplay.kt | 5 ++ .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 22 +++++-- .../usvm/ts/calls/CallsSourceReplayTest.kt | 38 ++++++++++++ .../calls/CurrentTsCallsSymbolicEngineTest.kt | 31 ++++++++++ .../calls/SourceTargetReplayFixture.ts | 10 ++++ .../fast-check-adapter/src/source-replay.ts | 58 ++++++++----------- .../test/source-replay.test.ts | 11 ++++ 7 files changed, 137 insertions(+), 38 deletions(-) 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 9957ea9723..cf53a6874a 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 @@ -345,6 +345,11 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { value: false, writable: true, }); + Object.defineProperty(globalThis, ${jsString("$marker:depth")}, { + configurable: true, + value: 0, + writable: true, + }); let invocation: 'returned' | 'threw' = 'returned'; let caught: unknown; try { 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 3d53c7cea9..46e15ca188 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 @@ -9,6 +9,7 @@ import org.jacodb.ets.model.EtsBooleanType import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsType import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.api.initializeArrayLength import org.usvm.api.makeSymbolicPrimitive @@ -66,12 +67,16 @@ internal class CallsSymbolicInputs( fun initialize(state: TsState) { check(snapshots == null) { "Calls symbolic inputs were initialized more than once" } - snapshots = boundInputs.map { (input, binding) -> + val initializedSnapshots = boundInputs.map { (input, binding) -> state.initializeInput( stackSlot = binding.stackSlot, domain = input.domain, ) } + initializedSnapshots.filterIsInstance().forEach { snapshot -> + snapshot.markDenseInput(state) + } + snapshots = initializedSnapshots } fun resolve(state: TsState): List { @@ -195,9 +200,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( @@ -265,7 +273,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/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt index 303f846ed6..055ffa7261 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 @@ -252,6 +252,44 @@ class CallsSourceReplayTest { } } + @Test + fun `completed replay observes only the outer selected invocation during recursion`() { + val fixture = fixture() + val recursive = fixture.target( + functionName = "recursiveOuterFallthrough", + statement = "return 1;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "1", + ) + val outerReturn = fixture.target( + functionName = "recursiveOuterReturn", + statement = "return 1;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "1", + ) + + val innerOnly = fixture.replay( + exportName = "recursiveOuterFallthrough", + inputs = listOf(number(1.0)), + target = recursive, + ) + val direct = fixture.replay( + exportName = "recursiveOuterFallthrough", + inputs = listOf(number(0.0)), + target = recursive, + ) + val completedOuter = fixture.replay( + exportName = "recursiveOuterReturn", + inputs = listOf(number(1.0)), + target = outerReturn, + ) + + assertEquals(CallsReplayStatus.REJECTED, innerOnly.status, innerOnly.toString()) + assertEquals(false, innerOnly.invocation?.targetHit) + assertEquals(CallsReplayStatus.CONFIRMED, direct.status, direct.toString()) + assertEquals(CallsReplayStatus.CONFIRMED, completedOuter.status, completedOuter.toString()) + } + @Test fun `rejects a source target symlink that escapes its declared root`() { val workspace = Files.createTempDirectory("calls-replay-root-boundary-") 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 64fb3c9e00..881769a0d6 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 @@ -424,6 +424,37 @@ class CurrentTsCallsSymbolicEngineTest { assertEquals(CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, preflight.reasonCode) } + @Test + fun `all same-sort array inputs retain dense snapshots after initialization`() { + val zeroNumbers = NumberDomain( + min = JsNumber.finite(0.0), + max = JsNumber.finite(0.0), + allowNaN = false, + ) + val arrays = ArrayDomain(element = zeroNumbers, minLength = 1, maxLength = 2) + val fixture = fixture( + source = """ + export function bothArraysContainZero(first: number[], second: number[]): boolean { + if (first.includes(0) && second.includes(0)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "bothArraysContainZero", + inputs = listOf( + PropertyInput(name = "first", domain = arrays), + PropertyInput(name = "second", domain = arrays), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = setOf("ts.array.includes", "ts.math.floor")) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + private fun fixture( source: String, exportName: String, diff --git a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts index 78fd7e8c5b..58eac3cae7 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -97,3 +97,13 @@ export function finallyThrowCaughtOutside(): number | undefined { try { return 1; } finally { throw new Error('cancel return'); } } catch { /* implicit function completion does not complete the selected return */ } } + +export function recursiveOuterFallthrough(value: number): number | undefined { + if (value === 0) return 1; + recursiveOuterFallthrough(0); +} + +export function recursiveOuterReturn(value: number): number { + if (value > 0) recursiveOuterReturn(0); + return 1; +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts b/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts index 04190e1929..6b69e1af62 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-replay.ts @@ -47,9 +47,11 @@ export function instrumentSourceReplay(file: ts.SourceFile, target: SourceReplay && target.expressionStart === arrowBody.getStart(file) && target.expressionEnd === arrowBody.getEnd() if (matchesArrowExpression && arrowBody !== undefined) { const temporary = uniqueIdentifier(file, target.marker) + const depth = depthStorage(target.marker) const expression = file.text.slice(arrowBody.getStart(file), arrowBody.getEnd()) return { source: applyEdits(file.text, [{ start: arrowBody.getStart(file), end: arrowBody.getEnd(), - text: `{ const ${temporary} = (${expression}); ${marker} = true; return ${temporary}; }` }]), reason: null } + text: `{ ${depth} = (${depth} ?? 0) + 1; try { const ${temporary} = (${expression}); ` + + `if (${depth} === 1) ${marker} = true; return ${temporary}; } finally { ${depth}--; } }` }]), reason: null } } return { source: null, reason: 'Completed-return target does not identify an exact return or arrow body' } } @@ -88,13 +90,9 @@ function instrumentEntry(file: ts.SourceFile, statement: ts.Statement, marker: s function instrumentCompletedReturn(file: ts.SourceFile, selected: ts.ReturnStatement, marker: string, name: string): string { let owner: ts.Node = selected.parent while (!ts.isFunctionLike(owner)) owner = owner.parent - const finalizers: ts.Block[] = [] - for (let ancestor: ts.Node = selected; ancestor !== owner; ancestor = ancestor.parent) { - const parent = ancestor.parent - if (ts.isTryStatement(parent) && parent.finallyBlock !== undefined && ancestor !== parent.finallyBlock) { - finalizers.push(parent.finallyBlock) - } - } + const body = functionBody(owner) + if (body === undefined) throw new Error('Completed return must belong to a function body') + const depth = depthStorage(name) const temporary = uniqueIdentifier(file, name) const edits: Edit[] = [] const visit = (node: ts.Node): void => { @@ -102,44 +100,36 @@ function instrumentCompletedReturn(file: ts.SourceFile, selected: ts.ReturnState if (ts.isReturnStatement(node)) { const expression = node.expression === undefined ? null : file.text.slice(node.expression.getStart(file), node.expression.getEnd()) const value = node === selected ? 'true' : 'false' - const result = expression === null ? `${marker} = ${value}; return;` - : `const ${temporary} = (${expression}); ${marker} = ${value}; return ${temporary};` + const completion = `if (${depth} === 1) ${marker} = ${value};` + const result = expression === null ? `${completion} return;` + : `const ${temporary} = (${expression}); ${completion} return ${temporary};` edits.push({ start: node.getStart(file), end: node.getEnd(), text: `{ ${result} }` }) return } - if ((ts.isBreakStatement(node) || ts.isContinueStatement(node)) && exitsFinalizer(node, finalizers)) { - const original = file.text.slice(node.getStart(file), node.getEnd()) - edits.push({ start: node.getStart(file), end: node.getEnd(), text: `{ ${marker} = false; ${original} }` }) - } ts.forEachChild(node, visit) } visit(owner) - // Only an exception escaping a finalizer cancels the pending selected return; locally caught throws do not. - for (const finalizer of finalizers) { - edits.push({ start: finalizer.getStart(file) + 1, end: finalizer.getStart(file) + 1, text: 'try {' }) - edits.push({ start: finalizer.getEnd() - 1, end: finalizer.getEnd() - 1, - text: `} catch (${temporary}) { ${marker} = false; throw ${temporary}; }` }) - } + const firstBodyStatement = body.statements.find((statement) => + !ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression), + ) + const guardedStart = firstBodyStatement?.getFullStart() ?? body.getEnd() - 1 + edits.push({ start: guardedStart, end: guardedStart, text: `\n${depth} = (${depth} ?? 0) + 1; try {\n` }) + edits.push({ start: body.getEnd() - 1, end: body.getEnd() - 1, + text: `\nif (${depth} === 1) ${marker} = false; } finally { ${depth}--; }\n` }) return applyEdits(file.text, edits) } -function exitsFinalizer(node: ts.BreakStatement | ts.ContinueStatement, finalizers: ts.Block[]): boolean { - const enclosing = finalizers.filter((block) => block.pos <= node.pos && block.end >= node.end) - if (enclosing.length === 0) return false - let destination: ts.Node | undefined - for (let ancestor = node.parent; ancestor !== undefined && !ts.isFunctionLike(ancestor); ancestor = ancestor.parent) { - if (node.label !== undefined) { - if (ts.isLabeledStatement(ancestor) && ancestor.label.text === node.label.text) { destination = ancestor; break } - } else if (isLoop(ancestor) || (ts.isBreakStatement(node) && ts.isSwitchStatement(ancestor))) { - destination = ancestor; break - } +function functionBody(node: ts.Node): ts.Block | undefined { + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isMethodDeclaration(node) + || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)) { + return node.body } - return destination !== undefined && enclosing.some((block) => destination!.pos < block.pos || destination!.end > block.end) + if (ts.isArrowFunction(node) && ts.isBlock(node.body)) return node.body + return undefined } -function isLoop(node: ts.Node): boolean { - return ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isForStatement(node) - || ts.isForInStatement(node) || ts.isForOfStatement(node) +function depthStorage(marker: string): string { + return `(globalThis as Record)[${JSON.stringify(`${marker}:depth`)}]` } function uniqueIdentifier(file: ts.SourceFile, marker: string): string { diff --git a/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts index c03dc696c4..9a86139085 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/source-replay.test.ts @@ -73,3 +73,14 @@ test('completed expression arrow retains lexical this and arguments', () => { const target = 'arguments[0]' assert.deepEqual(replay(source, target, 'COMPLETED_RETURN', 1, target), { value: 7, marker: true }) }) + +test('nested completed returns do not confirm an outer invocation that falls through', () => { + const source = 'function fixture(value: number) { if (value === 0) return 1; fixture(0); }' + assert.deepEqual(replay(source, 'return 1;', 'COMPLETED_RETURN', 1, '1'), { value: undefined, marker: false }) + assert.deepEqual(replay(source, 'return 1;', 'COMPLETED_RETURN', 0, '1'), { value: 1, marker: true }) +}) + +test('outer selected return completes after a recursive invocation', () => { + const source = 'function fixture(value: number) { if (value > 0) fixture(0); return 1; }' + assert.deepEqual(replay(source, 'return 1;', 'COMPLETED_RETURN', 1, '1'), { value: 1, marker: true }) +}) From e85d5a9d7cefe3f90921a11d79293b25b924179a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 00:37:33 +0300 Subject: [PATCH 04/15] Preserve source provenance and values for Calls captures --- .../org/usvm/ts/calls/CallsCapturedValues.kt | 147 +++++++++++++++ .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 5 + .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 51 +++--- .../calls/CurrentTsCallsSymbolicEngineTest.kt | 111 ++++++++++- .../fast-check-adapter/package.json | 2 +- .../fast-check-adapter/src/source-captures.ts | 173 ++++++++++++++++++ .../src/source-inspector-cli.ts | 9 +- .../test/source-captures.test.ts | 72 ++++++++ .../fastcheck/TypeScriptSourceInspector.kt | 29 +++ 9 files changed, 567 insertions(+), 32 deletions(-) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/source-captures.ts create mode 100644 usvm-ts-fast-check/fast-check-adapter/test/source-captures.test.ts diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt new file mode 100644 index 0000000000..168abd1e74 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt @@ -0,0 +1,147 @@ +package org.usvm.ts.calls + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsType +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.api.allocateConcreteRef +import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsState +import org.usvm.machine.types.mkFakeValue +import org.usvm.ts.pbt.fastcheck.TypeScriptCaptureKind +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceCapture +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding +import org.usvm.util.mkFieldLValue +import org.usvm.util.mkRegisterStackLValue + +internal fun callsCapturePreflight( + scene: EtsScene, + binding: EtsLexicalEnvironmentBinding, + captures: List, +): String? { + val environment = binding.parameter.type as? EtsLexicalEnvType + ?: return "Mapped capture parameter does not have a lexical-environment type" + val valuesByName = captures.associateBy(TypeScriptSourceCapture::name) + for (local in environment.closures) { + val capture = valuesByName[local.name] ?: return "Missing source provenance for capture ${local.name}" + val reason = capture.captureLimitation(scene, local.type) + if (reason != null) return "Capture ${local.name}: $reason" + } + + return null +} + +internal fun TsState.initializeCallsCaptures( + binding: EtsLexicalEnvironmentBinding, + captures: List, +) = with(ctx) { + val environment = binding.parameter.type as EtsLexicalEnvType + val environmentRef = allocateConcreteRef() + val valuesByName = captures.associateBy(TypeScriptSourceCapture::name) + val objectReferences = mutableMapOf() + for (local in environment.closures) { + val payload = resolveCapturedValue(valuesByName.getValue(local.name), local.type, objectReferences) + val stored = captureStorageValue(payload, local.type) + memory.write(mkFieldLValue(stored.sort, environmentRef, local.name), stored.asExpr(stored.sort), guard = trueExpr) + } + memory.write(mkRegisterStackLValue(addressSort, binding.stackSlot), environmentRef, guard = trueExpr) + saveSortForLocal(binding.stackSlot, addressSort) +} + +private fun TypeScriptSourceCapture.captureLimitation(scene: EtsScene, type: EtsType): String? = when (kind) { + TypeScriptCaptureKind.UNSUPPORTED -> reason ?: "Source binding requires runtime initialization" + TypeScriptCaptureKind.BUILTIN -> { + if (value in SUPPORTED_CAPTURE_OWNERS) null else "Global builtin $value has no supported Calls owner" + } + TypeScriptCaptureKind.FUNCTION -> { + if (sourceFunction(scene, type) == null) "Source function has unsupported captures or no unique body" else null + } + TypeScriptCaptureKind.OBJECT -> objectCaptureLimitation(scene, type) + else -> null +} + +private fun TypeScriptSourceCapture.objectCaptureLimitation(scene: EtsScene, type: EtsType): String? { + val objectType = type as? EtsClassType ?: return "Object capture lacks its source class identity" + val owner = scene.projectClasses.singleOrNull { clazz -> clazz.signature == objectType.signature } + ?: return "Object capture source class is not unique" + for (field in fields) { + val declaration = owner.fields.singleOrNull { candidate -> candidate.name == field.name } + ?: return "Object capture field ${field.name} has no unique source declaration" + val reason = field.captureLimitation(scene, declaration.type) + if (reason != null) return reason + } + + return null +} + +private fun sourceFunction(scene: EtsScene, type: EtsType): EtsMethod? { + val signature = (type as? EtsFunctionType)?.signature ?: return null + return scene.projectClasses.asSequence() + .filter { clazz -> clazz.signature == signature.enclosingClass } + .flatMap { clazz -> clazz.methods.asSequence() } + .filter { method -> method.signature == signature && method.cfg.stmts.isNotEmpty() } + .filter { method -> method.parameters.firstOrNull()?.type !is EtsLexicalEnvType } + .singleOrNull() +} + +private fun TsState.resolveCapturedValue( + capture: TypeScriptSourceCapture, + type: EtsType, + objectReferences: MutableMap, +): UExpr<*> = with(ctx) { + when (capture.kind) { + TypeScriptCaptureKind.NUMBER -> when (val value = requireNotNull(capture.value)) { + "NaN" -> mkFp64NaN() + "Infinity" -> mkFpInf(signBit = false, sort = fp64Sort) + "-Infinity" -> mkFpInf(signBit = true, sort = fp64Sort) + else -> mkFp64(value.toDouble()) + } + TypeScriptCaptureKind.STRING -> mkInitializedStringConstant(requireNotNull(capture.value)) + TypeScriptCaptureKind.BOOLEAN -> mkBool(requireNotNull(capture.value).toBooleanStrict()) + TypeScriptCaptureKind.NULL -> mkTsNullValue() + TypeScriptCaptureKind.UNDEFINED -> mkUndefinedValue() + TypeScriptCaptureKind.BUILTIN -> allocateConcreteRef() + TypeScriptCaptureKind.FUNCTION -> { + val method = requireNotNull(sourceFunction(scene, type)) + getMethodRef(method = method) + } + TypeScriptCaptureKind.OBJECT -> { + val objectType = type as EtsClassType + val owner = scene.projectClasses.single { clazz -> clazz.signature == objectType.signature } + val origin = requireNotNull(capture.origin) + val existing = objectReferences[origin] + if (existing != null) return existing + + val ref = memory.allocConcrete(objectType) + objectReferences[origin] = ref + for (field in capture.fields) { + val fieldType = owner.fields.single { declaration -> declaration.name == field.name }.type + val payload = resolveCapturedValue(field, fieldType, objectReferences) + val stored = captureStorageValue(payload, fieldType) + memory.write(mkFieldLValue(stored.sort, ref, field.name), stored.asExpr(stored.sort), guard = trueExpr) + writtenConcreteFields += ref to field.name + } + ref + } + TypeScriptCaptureKind.UNSUPPORTED -> error("Unsupported capture passed preflight: ${capture.name}") + } +} + +private fun TsState.captureStorageValue(payload: UExpr<*>, type: EtsType): UExpr<*> = with(ctx) { + val declaredSort = typeToSort(type) + if (declaredSort !is TsUnresolvedSort) return payload + + when (payload.sort) { + boolSort -> mkFakeValue(scope = null, boolValue = payload.asExpr(boolSort)) + fp64Sort -> mkFakeValue(scope = null, fpValue = payload.asExpr(fp64Sort)) + addressSort -> mkFakeValue(scope = null, refValue = payload.asExpr(addressSort)) + else -> error("Unsupported captured payload sort: ${payload.sort}") + } +} + +private val SUPPORTED_CAPTURE_OWNERS = setOf("Array", "Boolean", "Date", "Error", "Math", "Number", "Object", "String") 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 46e15ca188..20ff11e2ae 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 @@ -20,6 +20,8 @@ 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.fastcheck.TypeScriptSourceCapture +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding import org.usvm.ts.pbt.mapping.EtsInputBinding import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.BooleanDomain @@ -49,6 +51,8 @@ internal fun callsSymbolicInputPreflight(inputs: List): String? { internal class CallsSymbolicInputs( inputs: List, bindings: List, + private val lexicalEnvironment: EtsLexicalEnvironmentBinding?, + private val captures: List, ) { private val boundInputs: List> private var snapshots: List? = null @@ -67,6 +71,7 @@ internal class CallsSymbolicInputs( fun initialize(state: TsState) { check(snapshots == null) { "Calls symbolic inputs were initialized more than once" } + lexicalEnvironment?.let { binding -> state.initializeCallsCaptures(binding, captures) } val initializedSnapshots = boundInputs.map { (input, binding) -> state.initializeInput( stackSlot = binding.stackSlot, 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 1387aef608..f5e3835a1c 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,7 +1,6 @@ package org.usvm.ts.calls import org.jacodb.ets.model.EtsLexicalEnvType -import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene @@ -15,13 +14,14 @@ 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.isBuiltinDateGlobalType 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.TypeScriptSourceInspector +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceCapture +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding import org.usvm.ts.pbt.manifest.PropertyManifest import org.usvm.ts.pbt.mapping.EtsInputBinding import org.usvm.ts.pbt.mapping.EtsMappingStatus @@ -161,6 +161,8 @@ internal class CurrentTsCallsSymbolicEngine( val symbolicInputs = CallsSymbolicInputs( inputs = request.function.inputs, bindings = prepared.inputBindings, + lexicalEnvironment = prepared.lexicalEnvironment, + captures = prepared.captures, ) val interpreterObserver = request.unknownCallEventSink?.let(::UnknownCallEventSinkObserver) val analysis = TsMachine( @@ -307,17 +309,24 @@ internal class CurrentTsCallsSymbolicEngine( ) } - val unsupportedCaptures = (lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType) - ?.closures - ?.filterNot(::isSupportedBuiltinCapture) - ?.map { closure -> closure.name } - .orEmpty() - if (unsupportedCaptures.isNotEmpty()) { + val sourceCaptures = if (lexicalEnvironment == null) { + emptyList() + } else { + val environment = lexicalEnvironment.parameter.type as EtsLexicalEnvType + TypeScriptSourceInspector.captures( + source = source, + exportName = request.function.entryPoint.exportName, + names = environment.closures.map { closure -> closure.name }, + ) + } + val captureLimitation = lexicalEnvironment?.let { binding -> + callsCapturePreflight(scene, binding, sourceCaptures) + } + if (captureLimitation != null) { return CallsTargetPreparation.Rejected( status = CallsSymbolicStatus.UNSUPPORTED, reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, - diagnostic = "Symbolic entry point has unsupported runtime captures: " + - unsupportedCaptures.joinToString(), + diagnostic = captureLimitation, ) } @@ -371,19 +380,13 @@ internal class CurrentTsCallsSymbolicEngine( scene = scene, method = method, inputBindings = mappedTarget.bindings.inputs, + lexicalEnvironment = lexicalEnvironment, + captures = sourceCaptures, targetObservation = targetObservation, exactTargetCandidateCount = exactTargetCandidates.size, ) } - private fun isSupportedBuiltinCapture(capture: EtsLocal): Boolean { - if (capture.name == "Date") { - return capture.type.isBuiltinDateGlobalType() - } - - return capture.name in SUPPORTED_BUILTIN_CAPTURES - } - private fun exactTargetCandidates(method: EtsMethod, target: CallsSourceTarget): List = method.cfg.stmts.filter { statement -> val origin = statement.location.origin ?: return@filter false @@ -530,6 +533,8 @@ internal class CurrentTsCallsSymbolicEngine( val scene: EtsScene, val method: EtsMethod, val inputBindings: List, + val lexicalEnvironment: EtsLexicalEnvironmentBinding?, + val captures: List, val targetObservation: SourceTargetObservation, val exactTargetCandidateCount: Int, ) : CallsTargetPreparation @@ -609,15 +614,7 @@ 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", - ) + } } 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 881769a0d6..413ec92cf2 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 @@ -402,7 +402,7 @@ class CurrentTsCallsSymbolicEngineTest { } @Test - fun `user captured object named Date is not admitted as a native global`() { + fun `source Date object keeps its project method and is not replaced by a builtin`() { val fixture = fixture( source = """ const Date = { now: (): number => 7 }; @@ -419,9 +419,11 @@ class CurrentTsCallsSymbolicEngineTest { ) val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) - assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status, preflight.toString()) - assertEquals(CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, preflight.reasonCode) + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) } @Test @@ -455,6 +457,109 @@ class CurrentTsCallsSymbolicEngineTest { fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) } + @Test + fun `immutable module captures named Infinity and NaN preserve source numbers`() { + val fixture = fixture( + source = """ + const Infinity = 7; + const NaN = 8; + export const sourceNumbers = (value: number): boolean => { + if (value === 0 && Infinity === 7 && NaN === 8) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "sourceNumbers", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + + @Test + fun `project Math function and literal capture aliases keep runtime values`() { + val fixture = fixture( + source = """ + const Math = (value: number): number => value + 7; + const threshold = 7; + const alias = threshold; + export const sourceMath = (value: number): boolean => { + if (value === 0 && Math(value) === alias) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "sourceMath", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + + @Test + fun `source object capture aliases preserve reference identity`() { + val fixture = fixture( + source = """ + const source = { value: 7 }; + const alias = source; + export const objectAlias = (value: number): boolean => { + if (value === 0 && source === alias && source.value === 7) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "objectAlias", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + + @Test + fun `genuine SDK Number owner and Infinity NaN values retain their original identities`() { + val fixture = fixture( + source = """ + export const sdkValues = (value: number): boolean => { + if (value === 0 && Infinity > 7 && Number.isNaN(NaN)) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "sdkValues", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = setOf("ts.number.isNaN")) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + private fun fixture( source: String, exportName: String, diff --git a/usvm-ts-fast-check/fast-check-adapter/package.json b/usvm-ts-fast-check/fast-check-adapter/package.json index b30dda7272..0d0aa6d3dd 100644 --- a/usvm-ts-fast-check/fast-check-adapter/package.json +++ b/usvm-ts-fast-check/fast-check-adapter/package.json @@ -9,7 +9,7 @@ "build": "tsc --project tsconfig.json", "pretest": "npm run build", "test": "npm run test:compiled", - "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js dist/test/source-replay.test.js" + "test:compiled": "node --test dist/test/entry-point.test.js dist/test/execute-property.test.js dist/test/execution-cli.test.js dist/test/js-value.test.js dist/test/process-group-shutdown.test.js dist/test/process-supervisor.test.js dist/test/project-domain.test.js dist/test/projection-cli.test.js dist/test/source-replay.test.js dist/test/source-captures.test.js" }, "dependencies": { "c8": "10.1.3", diff --git a/usvm-ts-fast-check/fast-check-adapter/src/source-captures.ts b/usvm-ts-fast-check/fast-check-adapter/src/source-captures.ts new file mode 100644 index 0000000000..55d34e1aac --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-captures.ts @@ -0,0 +1,173 @@ +import ts from 'typescript' + +export interface SourceCapture { + name: string + kind: 'BUILTIN' | 'NUMBER' | 'STRING' | 'BOOLEAN' | 'NULL' | 'UNDEFINED' | 'FUNCTION' | 'OBJECT' | 'UNSUPPORTED' + value: string | null + fields: SourceCapture[] + reason: string | null + origin: string | null +} + +export function inspectSourceCaptures(sourcePath: string, exportName: string, names: string[]): SourceCapture[] { + const program = ts.createProgram([sourcePath], { target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext }) + const file = program.getSourceFile(sourcePath) + if (file === undefined) throw new Error('Capture source file is missing') + const checker = program.getTypeChecker() + const moduleSymbol = checker.getSymbolAtLocation(file) + const exported = moduleSymbol === undefined ? undefined + : checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === exportName) + const symbol = exported === undefined ? undefined : resolveAlias(checker, exported) + const owner = symbol === undefined ? undefined : findCallable(file, checker, symbol) + if (owner === undefined) return names.map((name) => capture(name, 'UNSUPPORTED', null, [], 'Export has no unique callable scope')) + + return names.map((name) => { + const binding = checker.getSymbolsInScope(owner, ts.SymbolFlags.Value).find((candidate) => candidate.name === name) + if (binding === undefined) return capture(name, 'UNSUPPORTED', null, [], 'Capture has no source binding') + const resolved = inspectBinding(name, binding, checker, program, new Set()) + const unsupportedBuiltinValue = resolved.kind === 'BUILTIN' && !usedOnlyAsCallOwner(owner, binding, checker) + return unsupportedBuiltinValue + ? capture(name, 'UNSUPPORTED', null, [], 'Builtin capture is used as a runtime value beyond supported call ownership') + : resolved + }) +} + +function findCallable(file: ts.SourceFile, checker: ts.TypeChecker, symbol: ts.Symbol): ts.FunctionLikeDeclaration | undefined { + const owners: ts.FunctionLikeDeclaration[] = [] + for (const declaration of symbol.declarations ?? []) { + if (ts.isFunctionDeclaration(declaration) && declaration.body !== undefined) owners.push(declaration) + if (ts.isVariableDeclaration(declaration) && declaration.initializer !== undefined + && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) { + owners.push(declaration.initializer) + } + } + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isIdentifier(node.left) && checker.getSymbolAtLocation(node.left) === symbol + && (ts.isArrowFunction(node.right) || ts.isFunctionExpression(node.right))) owners.push(node.right) + ts.forEachChild(node, visit) + } + visit(file) + return owners.length === 1 ? owners[0] : undefined +} + +function inspectBinding(name: string, raw: ts.Symbol, checker: ts.TypeChecker, program: ts.Program, + visited: Set): SourceCapture { + const symbol = resolveAlias(checker, raw) + if (visited.has(symbol)) return capture(name, 'UNSUPPORTED', null, [], 'Cyclic capture initializer') + const declarations = symbol.declarations ?? [] + const fromDefaultLibrary = declarations.length > 0 + && declarations.every((declaration) => program.isSourceFileDefaultLibrary(declaration.getSourceFile())) + if (fromDefaultLibrary) { + if (symbol.name === 'Infinity' || symbol.name === 'NaN') return capture(name, 'NUMBER', symbol.name) + if (symbol.name === 'undefined') return capture(name, 'UNDEFINED') + return capture(name, 'BUILTIN', symbol.name) + } + const declaration = declarations.length === 1 ? declarations[0] : undefined + if (declaration !== undefined && ts.isFunctionDeclaration(declaration) && declaration.body !== undefined) { + return capture(name, 'FUNCTION', 'function') + } + if (declaration === undefined || !ts.isVariableDeclaration(declaration) || declaration.initializer === undefined + || !ts.isVariableDeclarationList(declaration.parent) || !(declaration.parent.flags & ts.NodeFlags.Const)) { + return capture(name, 'UNSUPPORTED', null, [], 'Capture is not an immutable initialized source binding') + } + const result = inspectInitializer(name, declaration.initializer, checker, program, new Set([...visited, symbol])) + const unsafeObject = result.kind === 'OBJECT' && objectRequiresModuleEvaluation(symbol, checker, new Set()) + return unsafeObject + ? capture(name, 'UNSUPPORTED', null, [], 'Capture object is mutated or escapes during module initialization') + : result +} + +function inspectInitializer(name: string, node: ts.Expression, checker: ts.TypeChecker, program: ts.Program, + visited: Set): SourceCapture { + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { + return inspectInitializer(name, node.expression, checker, program, visited) + } + if (ts.isNumericLiteral(node)) return capture(name, 'NUMBER', String(Number(node.text))) + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return capture(name, 'STRING', node.text) + if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) { + return capture(name, 'BOOLEAN', node.kind === ts.SyntaxKind.TrueKeyword ? 'true' : 'false') + } + if (node.kind === ts.SyntaxKind.NullKeyword) return capture(name, 'NULL') + if (ts.isPrefixUnaryExpression(node) && (node.operator === ts.SyntaxKind.PlusToken || node.operator === ts.SyntaxKind.MinusToken)) { + const operand = inspectInitializer(name, node.operand, checker, program, visited) + if (operand.kind === 'NUMBER') { + const value = node.operator === ts.SyntaxKind.MinusToken ? -Number(operand.value) : Number(operand.value) + return capture(name, 'NUMBER', Object.is(value, -0) ? '-0' : String(value)) + } + } + if (ts.isIdentifier(node)) { + const symbol = checker.getSymbolAtLocation(node) + if (symbol !== undefined) return inspectBinding(name, symbol, checker, program, visited) + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return capture(name, 'FUNCTION', ts.isArrowFunction(node) ? 'arrow' : 'function') + } + if (ts.isObjectLiteralExpression(node)) { + const fields: SourceCapture[] = [] + for (const property of node.properties) { + if (ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) { + fields.push(inspectInitializer(property.name.text, property.initializer, checker, program, visited)) + } else if (ts.isMethodDeclaration(property) && ts.isIdentifier(property.name) && property.body !== undefined) { + fields.push(capture(property.name.text, 'FUNCTION', 'method')) + } else { + return capture(name, 'UNSUPPORTED', null, [], 'Capture object requires unsupported property initialization') + } + } + const unsupported = fields.find((field) => field.kind === 'UNSUPPORTED') + return unsupported === undefined ? capture(name, 'OBJECT', null, fields, null, `${node.getSourceFile().fileName}:${node.pos}`) + : capture(name, 'UNSUPPORTED', null, [], unsupported.reason) + } + return capture(name, 'UNSUPPORTED', null, [], 'Capture initializer requires runtime evaluation') +} + +function objectRequiresModuleEvaluation(symbol: ts.Symbol, checker: ts.TypeChecker, visited: Set): boolean { + if (visited.has(symbol)) return false + visited.add(symbol) + let unsafe = false + for (const declaration of symbol.declarations ?? []) { + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node)) return + if (ts.isIdentifier(node) && checker.getSymbolAtLocation(node) === symbol) { + const parent = node.parent + const ownDeclaration = ts.isVariableDeclaration(parent) && parent.name === node + const immutableAlias = ts.isVariableDeclaration(parent) && parent.initializer === node + && ts.isVariableDeclarationList(parent.parent) && !!(parent.parent.flags & ts.NodeFlags.Const) + if (immutableAlias) { + const alias = checker.getSymbolAtLocation(parent.name) + if (alias === undefined || objectRequiresModuleEvaluation(alias, checker, visited)) unsafe = true + } else if (!ownDeclaration) { + unsafe = true + } + } + ts.forEachChild(node, visit) + } + visit(declaration.getSourceFile()) + } + return unsafe +} + +function usedOnlyAsCallOwner(owner: ts.FunctionLikeDeclaration, binding: ts.Symbol, checker: ts.TypeChecker): boolean { + let supported = true + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && checker.getSymbolAtLocation(node) === binding) { + const parent = node.parent + const directCall = (ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node + const propertyCall = ts.isPropertyAccessExpression(parent) && parent.expression === node + && ts.isCallExpression(parent.parent) && parent.parent.expression === parent + if (!directCall && !propertyCall) supported = false + } + ts.forEachChild(node, visit) + } + visit(owner) + return supported +} + +function resolveAlias(checker: ts.TypeChecker, symbol: ts.Symbol): ts.Symbol { + return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol +} + +function capture(name: string, kind: SourceCapture['kind'], value: string | null = null, + fields: SourceCapture[] = [], reason: string | null = null, origin: string | null = null): SourceCapture { + return { name, kind, value, fields, reason, origin } +} 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 0f2bf04d8a..c1aa2a4cd3 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,6 +1,7 @@ import { readFileSync } from 'node:fs' import ts from 'typescript' import { instrumentSourceReplay } from './source-replay.js' +import { inspectSourceCaptures } from './source-captures.js' const [operation, sourcePath, ...arguments_] = process.argv.slice(2) if (sourcePath === undefined) throw new Error('Expected a TypeScript source path') @@ -13,7 +14,13 @@ const diagnostics = ts.transpileModule(source, { }).diagnostics?.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) ?? [] if (diagnostics.length > 0) throw new Error(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')) -if (operation === 'expression-arrow') { +if (operation === 'inspect-captures') { + const [exportName, namesText] = arguments_ + if (exportName === undefined || namesText === undefined) throw new Error('Expected export and capture names') + const names: unknown = JSON.parse(namesText) + if (!Array.isArray(names) || !names.every((name) => typeof name === 'string')) throw new Error('Invalid capture names') + process.stdout.write(JSON.stringify(inspectSourceCaptures(sourcePath, exportName, names as string[]))) +} else if (operation === 'expression-arrow') { const [exportName, startText, endText] = arguments_ const start = Number(startText) const end = Number(endText) diff --git a/usvm-ts-fast-check/fast-check-adapter/test/source-captures.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/source-captures.test.ts new file mode 100644 index 0000000000..954318fd6c --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/test/source-captures.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { inspectSourceCaptures } from '../src/source-captures.js' + +function inspect(source: string, names: string[]): ReturnType { + const directory = mkdtempSync(join(tmpdir(), 'usvm-source-captures-')) + try { + const path = join(directory, 'fixture.ts') + writeFileSync(path, source) + return inspectSourceCaptures(path, 'fixture', names) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +test('source constants named Infinity and NaN preserve their own numeric values', () => { + const captures = inspect('const Infinity = 7; const NaN = 8; export const fixture = () => Infinity + NaN;', ['Infinity', 'NaN']) + assert.deepEqual(captures.map(({ name, kind, value }) => ({ name, kind, value })), [ + { name: 'Infinity', kind: 'NUMBER', value: '7' }, + { name: 'NaN', kind: 'NUMBER', value: '8' }, + ]) +}) + +test('genuine SDK globals and primitive globals have default-library provenance', () => { + const captures = inspect('export const fixture = () => Math.floor(7) + Date.now() + Infinity + NaN;', + ['Math', 'Date', 'Infinity', 'NaN']) + assert.deepEqual(captures.map(({ name, kind, value }) => ({ name, kind, value })), [ + { name: 'Math', kind: 'BUILTIN', value: 'Math' }, + { name: 'Date', kind: 'BUILTIN', value: 'Date' }, + { name: 'Infinity', kind: 'NUMBER', value: 'Infinity' }, + { name: 'NaN', kind: 'NUMBER', value: 'NaN' }, + ]) +}) + +test('project Math method keeps its source object and function rather than builtin identity', () => { + const captures = inspect('const Math = { floor: (value: number) => 7 }; export const fixture = () => Math.floor(1);', ['Math']) + assert.equal(captures[0]?.kind, 'OBJECT') + assert.equal(captures[0]?.fields[0]?.name, 'floor') + assert.equal(captures[0]?.fields[0]?.kind, 'FUNCTION') +}) + +test('immutable source aliases keep builtin provenance and negative zero payloads', () => { + const captures = inspect('const clock = Date; const zero = -0; export const fixture = () => clock.now() + zero;', ['clock', 'zero']) + assert.equal(captures[0]?.kind, 'BUILTIN') + assert.equal(captures[0]?.value, 'Date') + assert.equal(captures[1]?.value, '-0') +}) + +test('builtin value observations are explicit until real runtime values can be initialized', () => { + const captures = inspect('export const fixture = () => typeof Date;', ['Date']) + assert.equal(captures[0]?.kind, 'UNSUPPORTED') +}) + + +test('object aliases retain canonical identity', () => { + const captures = inspect('const object = { value: 7 }; const alias = object; export const fixture = () => object === alias;', + ['object', 'alias']) + assert.equal(captures[0]?.kind, 'OBJECT') + assert.equal(captures[1]?.origin, captures[0]?.origin) + assert.notEqual(captures[0]?.origin, null) +}) + +test('module mutations and escapes reject stale object reconstruction', () => { + const mutated = inspect('const object = { value: 7 }; const alias = object; alias.value = 8; export const fixture = () => object.value;', ['object']) + assert.equal(mutated[0]?.kind, 'UNSUPPORTED') + + const escaped = inspect('const object = { value: 7 }; consume(object); export const fixture = () => object.value;', ['object']) + assert.equal(escaped[0]?.kind, 'UNSUPPORTED') +}) 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 fa782b4a63..d10202806c 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 @@ -2,6 +2,7 @@ package org.usvm.ts.pbt.fastcheck import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import org.usvm.ts.pbt.manifest.PropertyManifestJson import java.nio.file.Files import java.nio.file.Path @@ -30,6 +31,20 @@ object TypeScriptSourceInspector { } } + fun captures( + source: Path, + exportName: String, + names: List, + ): List { + val output = inspect( + source = source, + operation = "inspect-captures", + arguments = listOf(exportName, PropertyManifestJson.json.encodeToString(names)), + ) + + return PropertyManifestJson.json.decodeFromString(output) + } + fun instrumentReplay( source: Path, mode: String, @@ -97,8 +112,22 @@ object TypeScriptSourceInspector { data class TypeScriptReplayInstrumentation( val source: String?, val reason: String?, + val origin: String? = null, ) { init { require((source == null) != (reason == null)) { "Source replay instrumentation needs source or a reason" } } } + +@Serializable +enum class TypeScriptCaptureKind { BUILTIN, NUMBER, STRING, BOOLEAN, NULL, UNDEFINED, FUNCTION, OBJECT, UNSUPPORTED } + +@Serializable +data class TypeScriptSourceCapture( + val name: String, + val kind: TypeScriptCaptureKind, + val value: String?, + val fields: List, + val reason: String?, + val origin: String? = null, +) From 0e3fbc57ac71b56190120925833de70647ea92b6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 00:41:14 +0300 Subject: [PATCH 05/15] Remove duplicate UTF-16 sort import after sequence rebase --- usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt | 1 - 1 file changed, 1 deletion(-) 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 13921b907b..84b518474b 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -1,6 +1,5 @@ package org.usvm.util -import io.ksmt.sort.KBv16Sort import io.ksmt.sort.KBv16Sort import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr From 39df91268fda16666c8339f32df8ad50cb24250f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:15:27 +0300 Subject: [PATCH 06/15] Validate Calls input types and preserve initial solver outcomes --- .../org/usvm/ts/calls/CallsCapturedValues.kt | 60 +++++-- .../org/usvm/ts/calls/CallsExperiment.kt | 1 + .../ts/calls/CallsInputTypeCompatibility.kt | 111 +++++++++++++ .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 52 ++++-- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 36 ++++- .../org/usvm/ts/calls/CallsExperimentTest.kt | 40 +++++ .../calls/CurrentTsCallsInputDomainsTest.kt | 131 +++++++++++++++ .../calls/CurrentTsCallsSymbolicEngineTest.kt | 153 ++---------------- ...CurrentTsCallsSymbolicEngineTestSupport.kt | 139 ++++++++++++++++ .../TsInitialStateSolverUnknownException.kt | 8 + .../usvm/machine/interpreter/TsInterpreter.kt | 12 +- .../kotlin/org/usvm/machine/state/TsState.kt | 2 +- .../usvm/machine/types/TsInitialInputTypes.kt | 27 ++++ .../machine/types/TsInitialInputTypesTest.kt | 51 ++++++ 14 files changed, 639 insertions(+), 184 deletions(-) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsInputDomainsTest.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialStateSolverUnknownException.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/types/TsInitialInputTypes.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/types/TsInitialInputTypesTest.kt diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt index 168abd1e74..ea41a02fee 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsCapturedValues.kt @@ -47,22 +47,36 @@ internal fun TsState.initializeCallsCaptures( for (local in environment.closures) { val payload = resolveCapturedValue(valuesByName.getValue(local.name), local.type, objectReferences) val stored = captureStorageValue(payload, local.type) - memory.write(mkFieldLValue(stored.sort, environmentRef, local.name), stored.asExpr(stored.sort), guard = trueExpr) + memory.write( + mkFieldLValue(stored.sort, environmentRef, local.name), + stored.asExpr(stored.sort), + guard = trueExpr, + ) } - memory.write(mkRegisterStackLValue(addressSort, binding.stackSlot), environmentRef, guard = trueExpr) + memory.write( + mkRegisterStackLValue(addressSort, binding.stackSlot), + environmentRef.asExpr(addressSort), + guard = trueExpr, + ) saveSortForLocal(binding.stackSlot, addressSort) } private fun TypeScriptSourceCapture.captureLimitation(scene: EtsScene, type: EtsType): String? = when (kind) { - TypeScriptCaptureKind.UNSUPPORTED -> reason ?: "Source binding requires runtime initialization" + TypeScriptCaptureKind.UNSUPPORTED -> { + reason ?: "Source binding requires runtime initialization" + } TypeScriptCaptureKind.BUILTIN -> { if (value in SUPPORTED_CAPTURE_OWNERS) null else "Global builtin $value has no supported Calls owner" } TypeScriptCaptureKind.FUNCTION -> { if (sourceFunction(scene, type) == null) "Source function has unsupported captures or no unique body" else null } - TypeScriptCaptureKind.OBJECT -> objectCaptureLimitation(scene, type) - else -> null + TypeScriptCaptureKind.OBJECT -> { + objectCaptureLimitation(scene, type) + } + else -> { + null + } } private fun TypeScriptSourceCapture.objectCaptureLimitation(scene: EtsScene, type: EtsType): String? { @@ -95,17 +109,29 @@ private fun TsState.resolveCapturedValue( objectReferences: MutableMap, ): UExpr<*> = with(ctx) { when (capture.kind) { - TypeScriptCaptureKind.NUMBER -> when (val value = requireNotNull(capture.value)) { - "NaN" -> mkFp64NaN() - "Infinity" -> mkFpInf(signBit = false, sort = fp64Sort) - "-Infinity" -> mkFpInf(signBit = true, sort = fp64Sort) - else -> mkFp64(value.toDouble()) + TypeScriptCaptureKind.NUMBER -> { + when (val value = requireNotNull(capture.value)) { + "NaN" -> mkFp64NaN() + "Infinity" -> mkFpInf(signBit = false, sort = fp64Sort) + "-Infinity" -> mkFpInf(signBit = true, sort = fp64Sort) + else -> mkFp64(value.toDouble()) + } + } + TypeScriptCaptureKind.STRING -> { + mkInitializedStringConstant(requireNotNull(capture.value)) + } + TypeScriptCaptureKind.BOOLEAN -> { + mkBool(requireNotNull(capture.value).toBooleanStrict()) + } + TypeScriptCaptureKind.NULL -> { + mkTsNullValue() + } + TypeScriptCaptureKind.UNDEFINED -> { + mkUndefinedValue() + } + TypeScriptCaptureKind.BUILTIN -> { + memory.allocConcrete(type) } - TypeScriptCaptureKind.STRING -> mkInitializedStringConstant(requireNotNull(capture.value)) - TypeScriptCaptureKind.BOOLEAN -> mkBool(requireNotNull(capture.value).toBooleanStrict()) - TypeScriptCaptureKind.NULL -> mkTsNullValue() - TypeScriptCaptureKind.UNDEFINED -> mkUndefinedValue() - TypeScriptCaptureKind.BUILTIN -> allocateConcreteRef() TypeScriptCaptureKind.FUNCTION -> { val method = requireNotNull(sourceFunction(scene, type)) getMethodRef(method = method) @@ -128,7 +154,9 @@ private fun TsState.resolveCapturedValue( } ref } - TypeScriptCaptureKind.UNSUPPORTED -> error("Unsupported capture passed preflight: ${capture.name}") + TypeScriptCaptureKind.UNSUPPORTED -> { + error("Unsupported capture passed preflight: ${capture.name}") + } } } 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 d2a524db11..b3e34a5537 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 @@ -117,6 +117,7 @@ internal enum class CallsSymbolicStatus { UNREPRESENTABLE, UNSUPPORTED, TIMEOUT, + UNKNOWN, TOOL_ERROR, UNMAPPED, AMBIGUOUS, diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt new file mode 100644 index 0000000000..c0e2cced03 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt @@ -0,0 +1,111 @@ +package org.usvm.ts.calls + +import org.jacodb.ets.model.EtsAliasType +import org.jacodb.ets.model.EtsAnyType +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsGenericType +import org.jacodb.ets.model.EtsIntersectionType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberLiteralType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringLiteralType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnionType +import org.jacodb.ets.model.EtsUnknownType +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.NumberDomain +import org.usvm.ts.pbt.model.PropertyDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain + +/** Rejects bindings whose frozen domain would replace a source parameter with an incompatible runtime kind. */ +internal fun callsInputTypePreflight( + inputs: List, + bindings: List, + method: EtsMethod, +): String? { + val domainsByName = inputs.associate { input -> input.name to input.domain } + val genericParameters = method.typeParameters.filterIsInstance().associateBy { it.typeName } + for (binding in bindings) { + val limitation = domainsByName.getValue(binding.propertyInputName).inputTypeLimitation( + type = binding.parameter.type, + genericParameters = genericParameters, + path = "inputs.${binding.propertyInputName}", + ) + if (limitation != null) { + return "$limitation (source parameter ${binding.parameter.name}, stack slot ${binding.stackSlot})" + } + } + + return null +} + +private fun PropertyDomain.inputTypeLimitation( + type: EtsType, + genericParameters: Map, + path: String, + visitedGenerics: Set = emptySet(), +): String? { + val compatible = when (type) { + EtsAnyType, EtsUnknownType -> { + true + } + EtsBooleanType -> { + this == BooleanDomain + } + EtsNumberType -> { + this is NumberDomain + } + EtsStringType -> { + this is StringDomain + } + is EtsNumberLiteralType -> { + this is NumberDomain && !allowNaN && min.toDouble() == type.value && max.toDouble() == type.value + } + is EtsStringLiteralType -> { + this is StringDomain && minLength == 0 && maxLength == 0 && type.value.isEmpty() + } + is EtsGenericType -> { + if (type.typeName in visitedGenerics) { + return "$path: recursive generic bound ${type.typeName} is not supported" + } + + val upperBound = type.constraint ?: genericParameters[type.typeName]?.constraint ?: EtsUnknownType + return inputTypeLimitation(upperBound, genericParameters, path, visitedGenerics + type.typeName) + } + is EtsAliasType -> { + return inputTypeLimitation(type.originalType, genericParameters, path, visitedGenerics) + } + is EtsArrayType -> { + val isSupportedArray = this is ArrayDomain && type.dimensions == 1 + if (isSupportedArray) { + return (this as ArrayDomain).element.inputTypeLimitation( + type = type.elementType, + genericParameters = genericParameters, + path = "$path[]", + visitedGenerics = visitedGenerics, + ) + } + false + } + is EtsUnionType -> { + type.types.any { member -> inputTypeLimitation(member, genericParameters, path, visitedGenerics) == null } + } + is EtsIntersectionType -> { + for (member in type.types) { + val limitation = inputTypeLimitation(member, genericParameters, path, visitedGenerics) + if (limitation != null) return limitation + } + true + } + else -> { + false + } + } + + return if (compatible) null else "$path: frozen domain $this is not supported for declared type $type" +} 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 20ff11e2ae..869358c10f 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 @@ -21,8 +21,8 @@ import org.usvm.machine.state.TsState import org.usvm.model.UModelBase import org.usvm.sizeSort import org.usvm.ts.pbt.fastcheck.TypeScriptSourceCapture -import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding 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 @@ -84,11 +84,11 @@ internal class CallsSymbolicInputs( snapshots = initializedSnapshots } - fun resolve(state: TsState): List { + fun resolve(state: TsState): Sequence> { val initializedSnapshots = checkNotNull(snapshots) { "Calls symbolic inputs were not initialized" } - val model = state.models.single() - - return initializedSnapshots.map { snapshot -> snapshot.resolve(model) } + return state.models.asSequence().map { model -> + initializedSnapshots.map { snapshot -> snapshot.resolve(model) } + } } } @@ -109,8 +109,7 @@ private fun TsState.initializeInput( } is NumberDomain -> { - val value: UExpr = makeSymbolicPrimitive(fp64Sort) - pathConstraints += numberDomainConstraint(value, domain) + val value = initializeNumberInput(domain) memory.write( mkRegisterStackLValue(fp64Sort, stackSlot), value.asExpr(fp64Sort), @@ -138,10 +137,9 @@ private fun TsState.initializeStringInput( stackSlot: Int, domain: StringDomain, ): StringInputSnapshot = with(ctx) { - val length: UExpr = makeSymbolicPrimitive(sizeSort) + val length = initializeLength(minLength = domain.minLength, maxLength = domain.maxLength) val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } - constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) val stringRef = allocateBoundedString(length = length, codeUnits = codeUnits) memory.write( mkRegisterStackLValue(addressSort, stackSlot), @@ -164,9 +162,8 @@ private fun TsState.initializeArrayInput( } val descriptor = arrayDescriptorOf(runtimeType) val arrayRef = memory.allocConcrete(descriptor) - val length: UExpr = makeSymbolicPrimitive(sizeSort) + val length = initializeLength(minLength = domain.minLength, maxLength = domain.maxLength) - constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) memory.initializeArrayLength( arrayHeapRef = arrayRef, type = descriptor, @@ -186,9 +183,8 @@ private fun TsState.initializeArrayInput( } is NumberDomain -> List(domain.maxLength) { index -> - val value = makeSymbolicPrimitive(fp64Sort) + val value = initializeNumberInput(elementDomain) val liveIndex = mkBvSignedLessExpr(mkBv(index), length) - pathConstraints += numberDomainConstraint(value, elementDomain) memory.write( mkArrayIndexLValue(fp64Sort, arrayRef, mkBv(index), runtimeType), value, @@ -213,16 +209,40 @@ private fun TsState.initializeArrayInput( ) } -private fun TsState.constrainLength( - length: UExpr, +private fun TsState.initializeLength( minLength: Int, maxLength: Int, -) = with(ctx) { +): UExpr = with(ctx) { + if (minLength == maxLength) return mkBv(minLength) + + val length: UExpr = makeSymbolicPrimitive(sizeSort) val lengthBounds = mkAnd( mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)), mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)), ) pathConstraints += lengthBounds + length +} + +private fun TsState.initializeNumberInput(domain: NumberDomain): UExpr = with(ctx) { + val lowerBound = domain.min.toDouble() + val upperBound = domain.max.toDouble() + val hasSingleNumericValue = lowerBound == upperBound && !domain.allowNaN + if (hasSingleNumericValue) { + // Numeric equality admits both signs of zero. Keep that choice symbolic even for [0, 0]. + return if (lowerBound == 0.0) { + val positiveSign: UBoolExpr = makeSymbolicPrimitive(boolSort) + val positiveZero = mkFp64(0.0) + val negativeZero = mkFpNegationExpr(positiveZero) + mkIte(positiveSign, positiveZero, negativeZero) + } else { + mkFp64(lowerBound) + } + } + + val value: UExpr = makeSymbolicPrimitive(fp64Sort) + pathConstraints += numberDomainConstraint(value, domain) + value } private fun TsState.numberDomainConstraint( 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 f5e3835a1c..214591ebc9 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,5 +1,6 @@ package org.usvm.ts.calls +import mu.KotlinLogging import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsReturnStmt @@ -16,23 +17,26 @@ 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.interpreter.TsInitialStateSolverUnknownException 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.fastcheck.TypeScriptSourceCapture -import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding +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.ArrayDomain -import org.usvm.ts.pbt.model.StringDomain import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.StringDomain import org.usvm.ts.pbt.model.contains import java.nio.file.Path import kotlin.time.TimeSource +private val logger = KotlinLogging.logger {} + internal data class CallsSymbolicPreflightRequest( val sourceRoot: Path, val project: CallsProjectCase, @@ -100,8 +104,13 @@ internal class CurrentTsCallsSymbolicEngine( prepared = preparation as CallsTargetPreparation.Eligible, ) }.getOrElse { error -> + logger.warn(error) { "Calls symbolic search did not produce a result" } result( - status = CallsSymbolicStatus.TOOL_ERROR, + status = if (error is TsInitialStateSolverUnknownException) { + CallsSymbolicStatus.UNKNOWN + } else { + CallsSymbolicStatus.TOOL_ERROR + }, startedAt = startedAt, diagnostic = error.message ?: error::class.java.name, ) @@ -206,7 +215,7 @@ internal class CurrentTsCallsSymbolicEngine( } val inputs = states.asSequence() - .map(symbolicInputs::resolve) + .flatMap(symbolicInputs::resolve) .firstOrNull { candidate -> candidate.zip(request.function.inputs).all { (value, input) -> value in input.domain } } @@ -300,6 +309,20 @@ internal class CurrentTsCallsSymbolicEngine( } val mappedTarget = mapping.predicate.targets.single() + val method = mappedTarget.method + val inputTypeLimitation = callsInputTypePreflight( + inputs = request.function.inputs, + bindings = mappedTarget.bindings.inputs, + method = method, + ) + if (inputTypeLimitation != null) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, + diagnostic = inputTypeLimitation, + ) + } + val lexicalEnvironment = mappedTarget.bindings.lexicalEnvironment if (lexicalEnvironment != null && lexicalEnvironment.parameter.type !is EtsLexicalEnvType) { return CallsTargetPreparation.Rejected( @@ -330,7 +353,6 @@ internal class CurrentTsCallsSymbolicEngine( ) } - val method = mappedTarget.method val exactTargetCandidates = exactTargetCandidates(method, request.target) val targetObservation = when (request.target.mode) { CallsSourceTargetMode.ENTRY -> { @@ -613,8 +635,6 @@ internal class CurrentTsCallsSymbolicEngine( private companion object { const val BUNDLED_FRONTEND_PREFIX: String = "bundled:" - - } } 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 3c17bc6916..08a1a5d754 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 @@ -468,6 +468,46 @@ class CallsExperimentTest { assertEquals(2, summary.byProfile.getValue(CallsExperimentProfile.FROZEN_FRESH).toolErrors) } + @Test + fun `solver unknown survives raw serialization and summary without becoming unreached`(@TempDir directory: Path) { + val rawOutput = directory.resolve("results.jsonl") + val diagnostic = "Solver returned UNKNOWN while checking initial constraints" + val engine = CallsSymbolicEngine { + CallsSymbolicSearchResult( + status = CallsSymbolicStatus.UNKNOWN, + elapsedMillis = 7L, + diagnostic = diagnostic, + ) + } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(0L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + val records = readRecords(rawOutput).filterIsInstance() + val summary = CallsExperimentAggregator.summarize(rawOutput) + + assertEquals(4, records.size) + records.forEach { record -> + assertEquals(CallsSymbolicStatus.UNKNOWN, record.symbolicStatus) + assertFalse(record.solverReached) + assertFalse(record.inputExtracted) + assertNull(record.replayStatus) + assertEquals(diagnostic, record.diagnostic) + } + summary.byProfile.values.forEach { profile -> + assertEquals(1, profile.symbolicStatuses.getValue(CallsSymbolicStatus.UNKNOWN)) + assertEquals(0, profile.symbolicStatuses.getValue(CallsSymbolicStatus.UNREACHED)) + assertEquals(0, profile.timeouts) + assertEquals(0, profile.toolErrors) + } + } + private fun manifest(sourceRoot: String, seeds: List) = CallsExperimentManifest( schemaVersion = CallsExperimentManifest.SCHEMA_VERSION, experimentId = "fixture", diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsInputDomainsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsInputDomainsTest.kt new file mode 100644 index 0000000000..d8add601ba --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsInputDomainsTest.kt @@ -0,0 +1,131 @@ +package org.usvm.ts.calls + +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.JsNumber +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.PropertyInput +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +internal class CurrentTsCallsInputDomainsTest : CurrentTsCallsSymbolicEngineTestSupport() { + @Test + fun `singleton zero input domain preserves a negative zero witness`() { + val fixture = fixture( + source = """ + export function negativeZero(value: number): boolean { + if (value === 0 && 1 / value < 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "negativeZero", + inputs = listOf( + PropertyInput( + name = "value", + domain = NumberDomain( + min = JsNumber.finite(0.0), + max = JsNumber.finite(0.0), + allowNaN = false, + ), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + val value = assertIs(inputs.single()).toDouble() + assertEquals((-0.0).toRawBits(), value.toRawBits()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `number domain cannot replace a declared string parameter`() { + val fixture = fixture( + source = """ + export function text(value: string): boolean { + if (value.length > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "text", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, preflight.reasonCode) + assertTrue(preflight.diagnostic.orEmpty().contains("inputs.value")) + assertTrue(preflight.diagnostic.orEmpty().contains("stack slot 1")) + assertEquals(CallsSymbolicStatus.UNSUPPORTED, result.status, result.toString()) + } + + @Test + fun `generic array bounds reject an incompatible element domain before solving`() { + val fixture = fixture( + source = """ + export function bounded(values: T[]): boolean { + if (values.length > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "bounded", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 2), + ), + ), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, preflight.reasonCode) + assertTrue(preflight.diagnostic.orEmpty().contains("inputs.values[]")) + assertTrue(preflight.diagnostic.orEmpty().contains("string")) + assertEquals(CallsSymbolicStatus.UNSUPPORTED, result.status, result.toString()) + } + + @Test + fun `any parameter preserves a numeric domain runtime kind and replays source`() { + val fixture = fixture( + source = """ + export function actualNumber(value: any): boolean { + if (typeof value === "number" && value === 7) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "actualNumber", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals(7.0, assertIs(inputs.single()).toDouble()) + fixture.assertReplayConfirmed(inputs) + } +} 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 413ec92cf2..c7951b7e82 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,28 +1,20 @@ 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.JsNumber import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.JsNumber 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 +internal class CurrentTsCallsSymbolicEngineTest : CurrentTsCallsSymbolicEngineTestSupport() { @Test fun `bundled frontend accepts only the revision baked into the running build`() { val engine = CurrentTsCallsSymbolicEngine( @@ -140,7 +132,6 @@ class CurrentTsCallsSymbolicEngineTest { modelIds = setOf( "ts.string.charCodeAt", "ts.string.primitive.codeUnitAt", - "ts.string.primitive.length", ), ) @@ -177,7 +168,7 @@ class CurrentTsCallsSymbolicEngineTest { } @Test - fun `completed return searches a normal arrow but replay awaits exact instrumentation`() { + fun `completed return searches and replays a normal arrow`() { val expression = "value > 0" val fixture = fixture( source = "export const isPositive = (value: number): boolean => $expression;", @@ -201,7 +192,7 @@ class CurrentTsCallsSymbolicEngineTest { timeoutMillis = 10_000L, ) - assertEquals(CallsReplayStatus.UNSUPPORTED, replay.status, replay.toString()) + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) } @Test @@ -280,7 +271,7 @@ class CurrentTsCallsSymbolicEngineTest { } @Test - fun `rejects arbitrary lexical runtime capture before search`() { + fun `supports immutable literal captures from a block`() { val fixture = fixture( source = """ let capturesThreshold: (value: number) => boolean; @@ -293,13 +284,16 @@ class CurrentTsCallsSymbolicEngineTest { exportName = "capturesThreshold", inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), targetStatement = "value > threshold", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value > threshold", ) - val result = fixture.preflight() + val preflight = fixture.preflight() + val result = fixture.search(modelIds = emptySet()) - assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) - assertEquals(CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, result.reasonCode) - assertTrue(result.diagnostic.orEmpty().contains("threshold")) + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + fixture.assertReplayConfirmed(inputs) } @Test @@ -559,127 +553,4 @@ class CurrentTsCallsSymbolicEngineTest { assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) } - - 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, - profile: CallsExperimentProfile = CallsExperimentProfile.FROZEN_STOP, - ): CallsSymbolicSearchResult = engine.search( - CallsSymbolicSearchRequest( - sourceRoot = sourceRoot, - project = project, - function = function, - target = target, - profile = profile, - 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/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt new file mode 100644 index 0000000000..7f097fc6b3 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt @@ -0,0 +1,139 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.io.TempDir +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 +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +internal abstract class CurrentTsCallsSymbolicEngineTestSupport { + @TempDir + lateinit var directory: Path + + protected 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 + } + + protected 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, + profile: CallsExperimentProfile = CallsExperimentProfile.FROZEN_STOP, + ): CallsSymbolicSearchResult = engine.search( + CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = profile, + 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/src/main/kotlin/org/usvm/machine/interpreter/TsInitialStateSolverUnknownException.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialStateSolverUnknownException.kt new file mode 100644 index 0000000000..ac166559ea --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInitialStateSolverUnknownException.kt @@ -0,0 +1,8 @@ +package org.usvm.machine.interpreter + +import org.jacodb.ets.model.EtsMethod + +/** Solver uncertainty during initialization is distinct from invalid input constraints or an engine failure. */ +class TsInitialStateSolverUnknownException(method: EtsMethod) : IllegalStateException( + "Solver returned UNKNOWN while checking initial constraints for ${method.signature}", +) 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 b10498dee7..32fcb2e103 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 @@ -13,6 +13,7 @@ import org.jacodb.ets.model.EtsClosureFieldRef import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsDefineAccessorStmt import org.jacodb.ets.model.EtsDefineDataPropertyStmt +import org.jacodb.ets.model.EtsGenericType import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsLValue @@ -82,9 +83,11 @@ import org.usvm.machine.state.parametersWithThisCount import org.usvm.machine.state.prepareRefinedStringBackings import org.usvm.machine.state.returnValue import org.usvm.machine.state.symbolicStringBackingConstraint +import org.usvm.machine.types.initialInputType import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.toAuxiliaryType import org.usvm.sizeSort +import org.usvm.solver.UUnknownResult import org.usvm.targets.UTargetsSet import org.usvm.types.TypesResult import org.usvm.types.single @@ -786,6 +789,7 @@ class TsInterpreter( // TODO not equal but subtype for abstract/interfaces state.pathConstraints += state.memory.types.evalTypeEquals(thisRef, method.enclosingClass!!.type) + val genericParameters = method.typeParameters.filterIsInstance().associateBy { it.typeName } method.parameters.forEachIndexed { i, param -> val idx = i + 1 // +1 because 0 is reserved for `this` val ref by lazy { @@ -793,7 +797,7 @@ class TsInterpreter( state.memory.read(lValue).asExpr(addressSort) } - val parameterType = param.type + val parameterType = param.type.initialInputType(genericParameters) run { if (parameterType !is EtsRefType) return@run @@ -856,7 +860,11 @@ class TsInterpreter( state.configureState() val solver = solver() - val model = solver.check(state.pathConstraints).ensureSat().model + val solverResult = solver.check(state.pathConstraints) + if (solverResult is UUnknownResult) { + throw TsInitialStateSolverUnknownException(method) + } + val model = solverResult.ensureSat().model state.models = listOf(model) state 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 49dcb8e4ae..a75c6b7526 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 @@ -32,8 +32,8 @@ import org.usvm.machine.expr.TsCapturedInstanceCall 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 diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsInitialInputTypes.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsInitialInputTypes.kt new file mode 100644 index 0000000000..af94e7baa3 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsInitialInputTypes.kt @@ -0,0 +1,27 @@ +package org.usvm.machine.types + +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsGenericType +import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnknownType + +/** Uses the generic upper bound for input constraints; a default is not a bound on inferred arguments. */ +internal fun EtsType.initialInputType( + genericParameters: Map, + visitedGenerics: Set = emptySet(), +): EtsType = when (this) { + is EtsGenericType -> { + if (typeName in visitedGenerics) { + this + } else { + val upperBound = constraint ?: genericParameters[typeName]?.constraint ?: EtsUnknownType + upperBound.initialInputType(genericParameters, visitedGenerics + typeName) + } + } + + is EtsArrayType -> { + val resolvedElementType = elementType.initialInputType(genericParameters, visitedGenerics) + copy(elementType = resolvedElementType) + } + else -> this +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsInitialInputTypesTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsInitialInputTypesTest.kt new file mode 100644 index 0000000000..a12a960b8b --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/types/TsInitialInputTypesTest.kt @@ -0,0 +1,51 @@ +package org.usvm.machine.types + +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsGenericType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.model.EtsStringType +import org.junit.jupiter.api.Test +import org.usvm.util.EtsHierarchy +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class TsInitialInputTypesTest { + private val scene = EtsScene(projectFiles = emptyList()) + private val typeSystem = TsTypeSystem(scene, typeOperationsTimeout = 1.seconds, hierarchy = EtsHierarchy(scene)) + + @Test + fun `unconstrained generic array has instantiable primitive witnesses`() { + val arrayType = EtsArrayType(EtsGenericType(typeName = "T"), dimensions = 1) + + val inputType = arrayType.initialInputType(genericParameters = emptyMap()) + + assertTrue(typeSystem.isInstantiable(inputType)) + assertTrue(typeSystem.isSupertype(inputType, EtsArrayType(EtsNumberType, dimensions = 1))) + assertTrue(typeSystem.isSupertype(inputType, EtsArrayType(EtsStringType, dimensions = 1))) + } + + @Test + fun `generic array preserves its declared upper bound`() { + val generic = EtsGenericType(typeName = "T", constraint = EtsNumberType) + val arrayType = EtsArrayType(EtsGenericType(typeName = "T"), dimensions = 1) + + val inputType = arrayType.initialInputType(genericParameters = mapOf("T" to generic)) + + assertTrue(typeSystem.isInstantiable(inputType)) + assertTrue(typeSystem.isSupertype(inputType, EtsArrayType(EtsNumberType, dimensions = 1))) + assertFalse(typeSystem.isSupertype(inputType, EtsArrayType(EtsStringType, dimensions = 1))) + } + + @Test + fun `generic default does not restrict an inferred input type`() { + val generic = EtsGenericType(typeName = "T", defaultType = EtsNumberType) + val arrayType = EtsArrayType(generic, dimensions = 1) + + val inputType = arrayType.initialInputType(genericParameters = mapOf("T" to generic)) + + assertTrue(typeSystem.isInstantiable(inputType)) + assertTrue(typeSystem.isSupertype(inputType, EtsArrayType(EtsStringType, dimensions = 1))) + } +} From 6279dc512713c907a6b7c7d0e6c9352d6634b514 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:21:29 +0300 Subject: [PATCH 07/15] Use a concrete shared Calls test fixture --- .../usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt index 7f097fc6b3..07741b88d0 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTestSupport.kt @@ -10,7 +10,7 @@ import java.nio.file.Path import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds -internal abstract class CurrentTsCallsSymbolicEngineTestSupport { +internal open class CurrentTsCallsSymbolicEngineTestSupport { @TempDir lateinit var directory: Path From 69046de36a82aa039ce55d25011a2a13c48405e9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:45:48 +0300 Subject: [PATCH 08/15] Extend Calls symbolic primitive and optional input domains --- .../usvm/ts/calls/CallsInputMaterializer.kt | 219 ++++++++++++++ .../org/usvm/ts/calls/CallsInputSnapshot.kt | 77 +++++ .../ts/calls/CallsInputTypeCompatibility.kt | 40 ++- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 277 +++--------------- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 10 +- .../CurrentTsCallsExtendedInputDomainsTest.kt | 170 +++++++++++ 6 files changed, 537 insertions(+), 256 deletions(-) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt new file mode 100644 index 0000000000..1367b0176d --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt @@ -0,0 +1,219 @@ +package org.usvm.ts.calls + +import io.ksmt.expr.KFpRoundingMode +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.api.initializeArrayLength +import org.usvm.api.makeSymbolicPrimitive +import org.usvm.machine.TsSizeSort +import org.usvm.machine.state.TsState +import org.usvm.machine.types.EtsFakeType +import org.usvm.machine.types.extractValue +import org.usvm.machine.types.mkFakeValue +import org.usvm.sizeSort +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.IntegerDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.OptionalDomain +import org.usvm.ts.pbt.model.PropertyDomain +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.util.allocateBoundedString +import org.usvm.util.markDenseInputArray +import org.usvm.util.mkArrayIndexLValue + +internal data class MaterializedCallsInput(val value: UExpr<*>, val snapshot: CallsInputSnapshot) + +internal fun TsState.materializeCallsInput(domain: PropertyDomain): MaterializedCallsInput = with(ctx) { + when (domain) { + BooleanDomain -> { + val value = makeSymbolicPrimitive(boolSort) + MaterializedCallsInput(value, BooleanInputSnapshot(value)) + } + is IntegerDomain -> { + val value = initializeIntegerInput(domain) + MaterializedCallsInput(value, NumberInputSnapshot(value)) + } + is NumberDomain -> { + val value = initializeNumberInput(domain) + MaterializedCallsInput(value, NumberInputSnapshot(value)) + } + is StringDomain -> { + val length = initializeLength(minLength = domain.minLength, maxLength = domain.maxLength) + val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } + val reference = allocateBoundedString(length = length, codeUnits = codeUnits) + MaterializedCallsInput(reference, StringInputSnapshot(length = length, codeUnits = codeUnits)) + } + is ConstantDomain -> MaterializedCallsInput( + value = materializeCallsConstant(domain.value), + snapshot = ConstantInputSnapshot(domain.value), + ) + is OptionalDomain -> materializeCallsOptional(domain) + is ArrayDomain -> materializeCallsArray(domain) + else -> error("Unsupported Calls input domain: $domain") + } +} + +private fun TsState.initializeIntegerInput(domain: IntegerDomain): UExpr = with(ctx) { + if (domain.min == domain.max) return mkFp64(domain.min.toDouble()) + + val value: UExpr = makeSymbolicPrimitive(fp64Sort) + val rounded = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), + value = value, + ) + val negativeZero = mkAnd(mkFpIsZeroExpr(value), mkFpIsNegativeExpr(value)) + pathConstraints += mkAnd( + mkFpEqualExpr(value, rounded), + mkNot(negativeZero), + mkFpLessOrEqualExpr(mkFp64(domain.min.toDouble()), value), + mkFpLessOrEqualExpr(value, mkFp64(domain.max.toDouble())), + ) + value +} + +private fun TsState.materializeCallsConstant(value: JsConcreteValue): UExpr<*> = with(ctx) { + when (value) { + is JsConcreteValue.Boolean -> mkBool(value.value) + is JsConcreteValue.Number -> mkFp64(value.toDouble()) + is JsConcreteValue.String -> mkInitializedStringConstant(value.value) + JsConcreteValue.Null -> mkTsNullValue() + JsConcreteValue.Undefined -> mkUndefinedValue() + is JsConcreteValue.Array -> error("Array constants are not primitive domains") + } +} + +private fun TsState.materializeCallsOptional(domain: OptionalDomain): MaterializedCallsInput = with(ctx) { + val nested = materializeCallsInput(domain.value) + val nil = materializeCallsConstant(domain.nil) + val chooseValue: UBoolExpr = makeSymbolicPrimitive(boolSort) + val nestedBool = extractValue(nested.value, boolSort, ::getIntermediateBoolLValue) + val nestedNumber = extractValue(nested.value, fp64Sort, ::getIntermediateFpLValue) + val nestedReference = extractValue(nested.value, addressSort, ::getIntermediateRefLValue) + val reference = mkIte(chooseValue, nestedReference.first ?: mkUndefinedValue(), nil.asExpr(addressSort)) + val valueType = EtsFakeType( + boolTypeExpr = mkAnd(chooseValue, nestedBool.second), + fpTypeExpr = mkAnd(chooseValue, nestedNumber.second), + refTypeExpr = mkOr(mkNot(chooseValue), mkAnd(chooseValue, nestedReference.second)), + ) + val value = mkFakeValue( + scope = null, + boolValue = nestedBool.first ?: falseExpr, + fpValue = nestedNumber.first ?: mkFp64(0.0), + refValue = reference, + valueType = valueType, + ) + MaterializedCallsInput(value, OptionalInputSnapshot(chooseValue, nested.snapshot, domain.nil)) +} + +private fun PropertyDomain.callsRuntimeType(): EtsType = when (this) { + BooleanDomain -> EtsBooleanType + is IntegerDomain, is NumberDomain -> EtsNumberType + is StringDomain -> EtsStringType + is ConstantDomain -> when (value) { + is JsConcreteValue.Boolean -> EtsBooleanType + is JsConcreteValue.Number -> EtsNumberType + is JsConcreteValue.String -> EtsStringType + else -> error("Array elements require a concrete runtime kind") + } + else -> error("Unsupported array element domain: $this") +} + +private fun TsState.materializeCallsArray(domain: ArrayDomain): MaterializedCallsInput = with(ctx) { + val runtimeType = EtsArrayType(domain.element.callsRuntimeType(), dimensions = 1) + val descriptor = arrayDescriptorOf(runtimeType) + val arrayRef = memory.allocConcrete(runtimeType) + val length = initializeLength(minLength = domain.minLength, maxLength = domain.maxLength) + memory.initializeArrayLength( + arrayHeapRef = arrayRef, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + val elementSort = typeToSort(runtimeType.elementType) + val elements = List(domain.maxLength) { index -> + val element = materializeCallsInput(domain.element) + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + when (elementSort) { + boolSort -> memory.write( + mkArrayIndexLValue(boolSort, arrayRef, mkBv(index), runtimeType), + element.value.asExpr(boolSort), + guard = liveIndex, + ) + fp64Sort -> memory.write( + mkArrayIndexLValue(fp64Sort, arrayRef, mkBv(index), runtimeType), + element.value.asExpr(fp64Sort), + guard = liveIndex, + ) + addressSort -> memory.write( + mkArrayIndexLValue(addressSort, arrayRef, mkBv(index), runtimeType), + element.value.asExpr(addressSort), + guard = liveIndex, + ) + else -> error("Unsupported Calls element sort: $elementSort") + } + element.snapshot + } + markDenseInputArray(array = arrayRef, type = runtimeType) + MaterializedCallsInput(arrayRef, ArrayInputSnapshot(length = length, elements = elements)) +} + +private fun TsState.initializeLength( + minLength: Int, + maxLength: Int, +): UExpr = with(ctx) { + if (minLength == maxLength) return mkBv(minLength) + + val length: UExpr = makeSymbolicPrimitive(sizeSort) + val lengthBounds = mkAnd( + mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)), + mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)), + ) + pathConstraints += lengthBounds + length +} + +private fun TsState.initializeNumberInput(domain: NumberDomain): UExpr = with(ctx) { + val lowerBound = domain.min.toDouble() + val upperBound = domain.max.toDouble() + val hasSingleNumericValue = lowerBound == upperBound && !domain.allowNaN + if (hasSingleNumericValue) { + // Numeric equality admits both signs of zero. Keep that choice symbolic even for [0, 0]. + return if (lowerBound == 0.0) { + val positiveSign: UBoolExpr = makeSymbolicPrimitive(boolSort) + val positiveZero = mkFp64(0.0) + val negativeZero = mkFpNegationExpr(positiveZero) + mkIte(positiveSign, positiveZero, negativeZero) + } else { + mkFp64(lowerBound) + } + } + + val value: UExpr = makeSymbolicPrimitive(fp64Sort) + pathConstraints += numberDomainConstraint(value, domain) + value +} + +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 +} diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt new file mode 100644 index 0000000000..ba8d5707c8 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt @@ -0,0 +1,77 @@ +package org.usvm.ts.calls + +import io.ksmt.expr.KBitVec16Value +import io.ksmt.sort.KBv16Sort +import io.ksmt.sort.KFp64Sort +import org.jacodb.ets.model.EtsType +import org.usvm.UBoolExpr +import org.usvm.UExpr +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.model.UModelBase +import org.usvm.ts.pbt.model.JsConcreteValue + +internal sealed interface CallsInputSnapshot { + fun resolve(model: UModelBase): JsConcreteValue +} + +internal data class BooleanInputSnapshot( + val value: UBoolExpr, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + JsConcreteValue.Boolean(model.eval(value).toConcreteBoolValue()) +} + +internal data class NumberInputSnapshot( + val value: UExpr, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + JsConcreteValue.number(model.eval(value).extractDouble()) +} + +internal 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) + } +} + +internal 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) }) + } +} + +internal data class ConstantInputSnapshot(val value: JsConcreteValue) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = value +} + +internal data class OptionalInputSnapshot( + val chooseValue: UBoolExpr, + val nested: CallsInputSnapshot, + val nil: JsConcreteValue, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + if (model.eval(chooseValue).toConcreteBoolValue()) nested.resolve(model) else nil +} + +private const val UTF16_CODE_UNIT_MASK = 0xffff diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt index c0e2cced03..4ebad290d8 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt @@ -3,21 +3,28 @@ package org.usvm.ts.calls 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.EtsGenericType import org.jacodb.ets.model.EtsIntersectionType 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.EtsStringLiteralType import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUndefinedType import org.jacodb.ets.model.EtsUnionType import org.jacodb.ets.model.EtsUnknownType 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.ConstantDomain +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.OptionalDomain import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.StringDomain @@ -50,24 +57,47 @@ private fun PropertyDomain.inputTypeLimitation( path: String, visitedGenerics: Set = emptySet(), ): String? { + if (this is OptionalDomain) { + val valueLimitation = value.inputTypeLimitation(type, genericParameters, path, visitedGenerics) + if (valueLimitation != null) return valueLimitation + + return ConstantDomain(nil).inputTypeLimitation(type, genericParameters, "$path.nil", visitedGenerics) + } + + val constant = (this as? ConstantDomain)?.value val compatible = when (type) { EtsAnyType, EtsUnknownType -> { true } EtsBooleanType -> { - this == BooleanDomain + this == BooleanDomain || constant is JsConcreteValue.Boolean } EtsNumberType -> { - this is NumberDomain + this is NumberDomain || this is IntegerDomain || constant is JsConcreteValue.Number } EtsStringType -> { - this is StringDomain + this is StringDomain || constant is JsConcreteValue.String } is EtsNumberLiteralType -> { - this is NumberDomain && !allowNaN && min.toDouble() == type.value && max.toDouble() == type.value + when { + this is NumberDomain -> !allowNaN && min.toDouble() == type.value && max.toDouble() == type.value + this is IntegerDomain -> min.toDouble() == type.value && max.toDouble() == type.value + constant is JsConcreteValue.Number -> constant.toDouble() == type.value + else -> false + } } is EtsStringLiteralType -> { - this is StringDomain && minLength == 0 && maxLength == 0 && type.value.isEmpty() + (this is StringDomain && minLength == 0 && maxLength == 0 && type.value.isEmpty()) || + (constant is JsConcreteValue.String && constant.value == type.value) + } + is EtsBooleanLiteralType -> { + constant is JsConcreteValue.Boolean && constant.value == type.value + } + EtsNullType -> { + constant == JsConcreteValue.Null + } + EtsUndefinedType -> { + constant == JsConcreteValue.Undefined } is EtsGenericType -> { if (type.typeName in visitedGenerics) { 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 869358c10f..3291a2c04a 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 @@ -1,46 +1,44 @@ 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.EtsType -import org.usvm.UBoolExpr -import org.usvm.UConcreteHeapRef -import org.usvm.UExpr -import org.usvm.api.initializeArrayLength -import org.usvm.api.makeSymbolicPrimitive -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.fastcheck.TypeScriptSourceCapture 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.ConstantDomain +import org.usvm.ts.pbt.model.IntegerDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.OptionalDomain 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.allocateBoundedString -import org.usvm.util.markDenseInputArray -import org.usvm.util.mkArrayIndexLValue 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 + BooleanDomain, is IntegerDomain, is NumberDomain, is StringDomain -> true + is ConstantDomain -> value !is JsConcreteValue.Array + is OptionalDomain -> value.isSupportedCallsSymbolicDomain() && + (nil == JsConcreteValue.Null || nil == JsConcreteValue.Undefined) + is ArrayDomain -> when (val elementDomain = element) { + BooleanDomain, is IntegerDomain, is NumberDomain, is StringDomain -> true + is ConstantDomain -> elementDomain.value is JsConcreteValue.Boolean || + elementDomain.value is JsConcreteValue.Number || elementDomain.value is JsConcreteValue.String + else -> false + } else -> false } +internal fun PropertyDomain.callsInputCapacity(): Int = when (this) { + is StringDomain -> maxLength + is ConstantDomain -> (value as? JsConcreteValue.String)?.value?.length ?: 0 + is OptionalDomain -> value.callsInputCapacity() + is ArrayDomain -> maxOf(maxLength, element.callsInputCapacity()) + else -> 0 +} + internal fun callsSymbolicInputPreflight(inputs: List): String? { val unsupportedInput = inputs.firstOrNull { input -> !input.domain.isSupportedCallsSymbolicDomain() } ?: return null @@ -78,9 +76,6 @@ internal class CallsSymbolicInputs( domain = input.domain, ) } - initializedSnapshots.filterIsInstance().forEach { snapshot -> - snapshot.markDenseInput(state) - } snapshots = initializedSnapshots } @@ -96,221 +91,19 @@ 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 = initializeNumberInput(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 length = initializeLength(minLength = domain.minLength, maxLength = domain.maxLength) - val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } - - val stringRef = allocateBoundedString(length = length, codeUnits = codeUnits) - 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 = initializeLength(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 = initializeNumberInput(elementDomain) - val liveIndex = mkBvSignedLessExpr(mkBv(index), length) - 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) - ArrayInputSnapshot( - length = length, - elements = elements, - arrayRef = arrayRef, - runtimeType = runtimeType, - ) -} - -private fun TsState.initializeLength( - minLength: Int, - maxLength: Int, -): UExpr = with(ctx) { - if (minLength == maxLength) return mkBv(minLength) - - val length: UExpr = makeSymbolicPrimitive(sizeSort) - val lengthBounds = mkAnd( - mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)), - mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)), - ) - pathConstraints += lengthBounds - length -} - -private fun TsState.initializeNumberInput(domain: NumberDomain): UExpr = with(ctx) { - val lowerBound = domain.min.toDouble() - val upperBound = domain.max.toDouble() - val hasSingleNumericValue = lowerBound == upperBound && !domain.allowNaN - if (hasSingleNumericValue) { - // Numeric equality admits both signs of zero. Keep that choice symbolic even for [0, 0]. - return if (lowerBound == 0.0) { - val positiveSign: UBoolExpr = makeSymbolicPrimitive(boolSort) - val positiveZero = mkFp64(0.0) - val negativeZero = mkFpNegationExpr(positiveZero) - mkIte(positiveSign, positiveZero, negativeZero) - } else { - mkFp64(lowerBound) - } + val input = materializeCallsInput(domain) + when (input.value.sort) { + boolSort -> memory.write( + mkRegisterStackLValue(boolSort, stackSlot), input.value.asExpr(boolSort), guard = trueExpr, + ) + fp64Sort -> memory.write( + mkRegisterStackLValue(fp64Sort, stackSlot), input.value.asExpr(fp64Sort), guard = trueExpr, + ) + addressSort -> memory.write( + mkRegisterStackLValue(addressSort, stackSlot), input.value.asExpr(addressSort), guard = trueExpr, + ) + else -> error("Unsupported Calls input sort: ${input.value.sort}") } - - val value: UExpr = makeSymbolicPrimitive(fp64Sort) - pathConstraints += numberDomainConstraint(value, domain) - value -} - -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()) + saveSortForLocal(stackSlot, input.value.sort) + input.snapshot } - -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, - 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" } - - 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/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 214591ebc9..d14219f3f6 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 @@ -28,9 +28,7 @@ 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.ArrayDomain import org.usvm.ts.pbt.model.JsConcreteValue -import org.usvm.ts.pbt.model.StringDomain import org.usvm.ts.pbt.model.contains import java.nio.file.Path import kotlin.time.TimeSource @@ -155,13 +153,7 @@ internal class CurrentTsCallsSymbolicEngine( stopOnTargetsReached = false, throwExceptionOnStepFailure = true, ) - val inputCapacity = request.function.inputs.maxOfOrNull { input -> - when (val domain = input.domain) { - is ArrayDomain -> domain.maxLength - is StringDomain -> domain.maxLength - else -> 0 - } - } ?: 0 + val inputCapacity = request.function.inputs.maxOfOrNull { input -> input.domain.callsInputCapacity() } ?: 0 val tsOptions = TsOptions( maxArraySize = maxOf(TsOptions().maxArraySize, inputCapacity), unknownCallModelSelection = modelSelection, diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt new file mode 100644 index 0000000000..457761c931 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt @@ -0,0 +1,170 @@ +package org.usvm.ts.calls + +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.ConstantDomain +import org.usvm.ts.pbt.model.IntegerDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.OptionalDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +internal class CurrentTsCallsExtendedInputDomainsTest : CurrentTsCallsSymbolicEngineTestSupport() { + @Test + fun `integer domain reaches an integral witness and replays source`() { + val fixture = fixture( + source = """ + export function integral(value: number): boolean { + if (value === 7) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "integral", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain(min = -10, max = 10))), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals(7.0, assertIs(inputs.single()).toDouble()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `integer domain cannot reach negative zero`() { + val fixture = fixture( + source = """ + export function integerZero(value: number): boolean { + if (value === 0 && 1 / value < 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "integerZero", + inputs = listOf(PropertyInput(name = "value", domain = IntegerDomain(min = -1, max = 1))), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.UNREACHED, result.status, result.toString()) + } + + @Test + fun `primitive singleton constants retain exact runtime kinds and source replay`() { + val fixture = fixture( + source = """ + export function singleton(value: any): boolean { + if (typeof value === "string" && value === "xy") { + return true; + } + return false; + } + """.trimIndent(), + exportName = "singleton", + inputs = listOf(PropertyInput(name = "value", domain = ConstantDomain(JsConcreteValue.String("xy")))), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals(JsConcreteValue.String("xy"), inputs.single()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `optional numeric domain preserves both nullish and numeric branches`() { + for (targetValue in listOf(JsConcreteValue.Undefined, JsConcreteValue.number(7.0))) { + val condition = if (targetValue == JsConcreteValue.Undefined) "value === undefined" else "value === 7" + val fixture = fixture( + source = """ + export function optional(value: number | undefined): boolean { + if ($condition) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "optional", + inputs = listOf( + PropertyInput(name = "value", domain = OptionalDomain(IntegerDomain(min = 0, max = 10))), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals(targetValue, inputs.single()) + fixture.assertReplayConfirmed(inputs) + } + } + + @Test + fun `optional domain cannot inject undefined into a required numeric parameter`() { + val fixture = fixture( + source = """ + export function required(value: number): boolean { + if (value === 7) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "required", + inputs = listOf(PropertyInput(name = "value", domain = OptionalDomain(IntegerDomain()))), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status, result.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, result.reasonCode) + assertTrue(result.diagnostic.orEmpty().contains("inputs.value.nil")) + } + + @Test + fun `string array values retain symbolic UTF16 backing and source replay`() { + val fixture = fixture( + source = """ + export function stringArray(values: string[]): boolean { + if (values.length === 1 && values[0] === "xy") { + return true; + } + return false; + } + """.trimIndent(), + exportName = "stringArray", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain( + element = StringDomain(minLength = 0, maxLength = 2), + minLength = 1, + maxLength = 2, + ), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals(listOf(JsConcreteValue.String("xy")), assertIs(inputs.single()).elements) + fixture.assertReplayConfirmed(inputs) + } +} From 58fcab0e26da5680b0fbaca83a5e16647627d326 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:53:48 +0300 Subject: [PATCH 09/15] Preserve immediate component storage for multidimensional arrays --- .../src/main/kotlin/org/usvm/machine/TsContext.kt | 9 +++++---- .../org/usvm/machine/expr/CallApproximations.kt | 13 +++++++------ .../main/kotlin/org/usvm/machine/expr/ReadArray.kt | 3 ++- .../main/kotlin/org/usvm/machine/expr/WriteArray.kt | 3 ++- .../kotlin/org/usvm/machine/types/TsTypeSystem.kt | 5 +++-- .../src/main/kotlin/org/usvm/util/ArrayStorage.kt | 10 +++++----- usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt | 8 ++++++++ .../src/test/kotlin/org/usvm/util/TsTestResolver.kt | 2 +- 8 files changed, 33 insertions(+), 20 deletions(-) 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 8d64a927a5..aa88b4f32f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -2,6 +2,8 @@ package org.usvm.machine import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract import org.jacodb.ets.model.EtsAliasType import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType @@ -54,9 +56,8 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.types.EtsFakeType import org.usvm.memory.UReadOnlyMemory import org.usvm.types.single +import org.usvm.util.componentType import org.usvm.util.mkFieldLValue -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract typealias TsSizeSort = UBv32Sort @@ -193,10 +194,10 @@ class TsContext( } fun arrayDescriptorOf(type: EtsArrayType): EtsType { - return when (type.elementType) { + val storageElement = type.componentType + return when (storageElement) { is EtsBooleanType -> EtsArrayType(EtsBooleanType, dimensions = 1) is EtsNumberType -> EtsArrayType(EtsNumberType, dimensions = 1) - is EtsArrayType -> TODO("Unsupported yet: $type") else -> EtsArrayType(EtsUnknownType, dimensions = 1) } } 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 41d028beb7..f25e722c75 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 @@ -41,6 +41,7 @@ import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort import org.usvm.util.arrayStorageType +import org.usvm.util.componentType import org.usvm.util.copyArrayElements import org.usvm.util.forEachArrayPayloadRegion import org.usvm.util.initializeArrayKind @@ -175,7 +176,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( val instanceType = scope.calcOnState { arrayStorageType(array, expr.instance.type) } if (instanceType is EtsArrayType) { - val elementSort = typeToSort(instanceType.elementType) + val elementSort = typeToSort(instanceType.componentType) .takeIf { it !is TsUnresolvedSort } ?: addressSort @@ -565,7 +566,7 @@ private fun TsExprResolver.removeArrayElement( arrayType: EtsArrayType, first: Boolean, ): UExpr<*>? = with(ctx) { - val storageSort = typeToSort(arrayType.elementType) + val storageSort = typeToSort(arrayType.componentType) val lengthLValue = mkArrayLengthLValue(array, arrayType) val length = scope.calcOnState { memory.read(lengthLValue) } val nonEmpty = mkNot(mkEq(length, mkBv(0))) @@ -635,7 +636,7 @@ private fun TsExprResolver.handleArrayFill( "Array.fill() should have 1 to 3 arguments, but got ${expr.args.size}" } val resolvedValue = resolve(expr.args[0]) ?: return null - val value = if (typeToSort(arrayType.elementType) is TsUnresolvedSort) { + val value = if (typeToSort(arrayType.componentType) is TsUnresolvedSort) { resolvedValue.toFakeObject(scope) } else { resolvedValue @@ -992,7 +993,7 @@ private fun TsExprResolver.handleArrayConcat( arrayType: EtsArrayType, array: UHeapRef, ): TsExprApproximationResult = with(ctx) { - val elementSort = typeToSort(arrayType.elementType) + val elementSort = typeToSort(arrayType.componentType) val arrayTypes = stmt.args.mapIndexed { index, arg -> if (arg.sort == addressSort) { val ref = arg.asExpr(addressSort) @@ -1007,7 +1008,7 @@ private fun TsExprResolver.handleArrayConcat( } if (arrayTypes.withIndex().any { (index, type) -> if (type != null) { - typeToSort(type.elementType) != elementSort + typeToSort(type.componentType) != elementSort } else { elementSort !is TsUnresolvedSort && stmt.args[index].sort != elementSort } @@ -1213,7 +1214,7 @@ private fun TsExprResolver.handleArrayReverse( } memory.initializeArray(reversedArray, region, sort, sizeSort, contents) } - if (typeToSort(arrayType.elementType) is TsUnresolvedSort) { + if (typeToSort(arrayType.componentType) is TsUnresolvedSort) { TsUnresolvedArrayKind.entries.forEach { kind -> val contents = reversedIndices.map { index -> memory.readArrayIndex(array, index, kind, boolSort) } initializeArrayKind(reversedArray, kind, contents) 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 0225867606..8512ba59b4 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 @@ -22,6 +22,7 @@ import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort import org.usvm.util.arrayStorageType +import org.usvm.util.componentType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.readTypedArrayElement @@ -143,7 +144,7 @@ private fun TsContext.readArrayElement( arrayType: EtsArrayType, ): UExpr<*> { // Determine the element sort. - val sort = typeToSort(arrayType.elementType) + val sort = typeToSort(arrayType.componentType) // If the element type is known, we can read it directly. if (sort !is TsUnresolvedSort) { 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 7305401f41..512e8dafc6 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 @@ -18,6 +18,7 @@ import org.usvm.sizeSort import org.usvm.util.arrayStorageType import org.usvm.util.boxArrayValue import org.usvm.util.initializeTypedArrayOverrides +import org.usvm.util.componentType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.typedArrayBoxedLValue @@ -143,7 +144,7 @@ fun TsContext.assignToArrayIndex( } scope.assert(indexIsInRange) ?: return null - val elementSort = typeToSort(arrayType.elementType) + val elementSort = typeToSort(arrayType.componentType) // If the element sort is known, write directly. if (elementSort !is TsUnresolvedSort) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt index ccfeba6150..ffb1a17021 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt @@ -1,5 +1,6 @@ package org.usvm.machine.types +import kotlin.time.Duration import org.jacodb.ets.model.EtsAliasType import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType @@ -32,11 +33,11 @@ import org.usvm.types.UTypeSystem import org.usvm.util.EtsFieldName import org.usvm.util.EtsHierarchy import org.usvm.util.EtsMethodName +import org.usvm.util.componentType import org.usvm.util.getAllFields import org.usvm.util.getAllProperties import org.usvm.util.getAllPropertiesCombined import org.usvm.util.type -import kotlin.time.Duration // TODO this is draft, should be replaced with real implementation class TsTypeSystem( @@ -157,7 +158,7 @@ class TsTypeSystem( // Arrays are unsoundly covariant in TS: ElementType[] <: ElementType[] if T <: S if (unwrappedSupertype is EtsArrayType && unwrappedType is EtsArrayType) { // array covariance (JS unsound) - return isSupertype(unwrappedSupertype.elementType, unwrappedType.elementType) + return isSupertype(unwrappedSupertype.componentType, unwrappedType.componentType) } // Tuple types 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 60350e1c80..136c13370c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt @@ -25,7 +25,7 @@ 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) { - val elementSort = typeToSort(arrayType.elementType) + val elementSort = typeToSort(arrayType.componentType) if (elementSort !is TsUnresolvedSort) { action(arrayDescriptorOf(arrayType), elementSort) return @@ -48,14 +48,14 @@ internal fun TsState.copyArrayElements( memory.memcpy(srcRef, dstRef, region, sort, fromSrc, fromDst, length) } with(ctx) { - if (typeToSort(arrayType.elementType) !is TsUnresolvedSort) { + if (typeToSort(arrayType.componentType) !is TsUnresolvedSort) { initializeTypedArrayOverrides(srcRef) initializeTypedArrayOverrides(dstRef) memory.memcpy(srcRef, dstRef, TsTypedArrayStorage.HAS_BOXED_VALUE, boolSort, fromSrc, fromDst, length) memory.memcpy(srcRef, dstRef, TsTypedArrayStorage.BOXED_VALUE, addressSort, fromSrc, fromDst, length) } } - if (ctx.typeToSort(arrayType.elementType) is TsUnresolvedSort) { + if (ctx.typeToSort(arrayType.componentType) is TsUnresolvedSort) { TsUnresolvedArrayKind.entries.forEach { kind -> memory.memcpy(srcRef, dstRef, kind, ctx.boolSort, fromSrc, fromDst, length) } @@ -91,7 +91,7 @@ fun TsState.markDenseInputArray( type: EtsArrayType, ) = with(ctx) { val descriptor = arrayDescriptorOf(type) - val elementSort = typeToSort(type.elementType) + val elementSort = typeToSort(type.componentType) require(elementSort !is TsUnresolvedSort) { "A dense input array needs a concrete element sort" } val lengthRegionId = UArrayLengthsRegionId(sizeSort, descriptor) @@ -112,7 +112,7 @@ internal fun TsState.isUnmodifiedDenseInputArray( if (snapshot.type != type) return false val descriptor = arrayDescriptorOf(type) - val elementSort = typeToSort(type.elementType) + val elementSort = typeToSort(type.componentType) if (elementSort is TsUnresolvedSort) return false val lengthRegionId = UArrayLengthsRegionId(sizeSort, descriptor) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt index 2dca78e1bd..b391903cf8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/LValueUtil.kt @@ -102,3 +102,11 @@ fun mkRegisterStackLValue( sort: Sort, idx: Int, ): URegisterStackLValue = URegisterStackLValue(sort, idx) + +/** The immediate array element retains every remaining TypeScript array dimension. */ +val EtsArrayType.componentType: EtsType + get() = if (dimensions > 1) { + EtsArrayType(elementType = elementType, dimensions = dimensions - 1) + } else { + elementType + } 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 25f4746236..81a3c82ea0 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -320,7 +320,7 @@ open class TsTestStateResolver( val values = (0 until length.number.toInt()).map { i -> val index = mkSizeExpr(i) - val sort = typeToSort(type.elementType) + val sort = typeToSort(type.componentType) if (sort is TsUnresolvedSort) { val value = readUnresolvedArrayElement(memory, heapRef, index) From 36a7216cac45393412c40dec16967ec19d2e9665 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:53:48 +0300 Subject: [PATCH 10/15] Materialize nested Calls arrays and defer recursive dense snapshots --- .../usvm/ts/calls/CallsInputMaterializer.kt | 23 ++++- .../org/usvm/ts/calls/CallsInputSnapshot.kt | 15 +++ .../ts/calls/CallsInputTypeCompatibility.kt | 5 +- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 2 + .../CurrentTsCallsExtendedInputDomainsTest.kt | 91 +++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt index 1367b0176d..5739b96411 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt @@ -29,7 +29,7 @@ import org.usvm.ts.pbt.model.OptionalDomain import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.StringDomain import org.usvm.util.allocateBoundedString -import org.usvm.util.markDenseInputArray +import org.usvm.util.componentType import org.usvm.util.mkArrayIndexLValue internal data class MaterializedCallsInput(val value: UExpr<*>, val snapshot: CallsInputSnapshot) @@ -120,6 +120,14 @@ private fun PropertyDomain.callsRuntimeType(): EtsType = when (this) { BooleanDomain -> EtsBooleanType is IntegerDomain, is NumberDomain -> EtsNumberType is StringDomain -> EtsStringType + is ArrayDomain -> { + val nestedType = element.callsRuntimeType() + if (nestedType is EtsArrayType) { + nestedType.copy(dimensions = nestedType.dimensions + 1) + } else { + EtsArrayType(elementType = nestedType, dimensions = 1) + } + } is ConstantDomain -> when (value) { is JsConcreteValue.Boolean -> EtsBooleanType is JsConcreteValue.Number -> EtsNumberType @@ -130,7 +138,7 @@ private fun PropertyDomain.callsRuntimeType(): EtsType = when (this) { } private fun TsState.materializeCallsArray(domain: ArrayDomain): MaterializedCallsInput = with(ctx) { - val runtimeType = EtsArrayType(domain.element.callsRuntimeType(), dimensions = 1) + val runtimeType = domain.callsRuntimeType() as EtsArrayType val descriptor = arrayDescriptorOf(runtimeType) val arrayRef = memory.allocConcrete(runtimeType) val length = initializeLength(minLength = domain.minLength, maxLength = domain.maxLength) @@ -140,7 +148,7 @@ private fun TsState.materializeCallsArray(domain: ArrayDomain): MaterializedCall sizeSort = sizeSort, count = length, ) - val elementSort = typeToSort(runtimeType.elementType) + val elementSort = typeToSort(runtimeType.componentType) val elements = List(domain.maxLength) { index -> val element = materializeCallsInput(domain.element) val liveIndex = mkBvSignedLessExpr(mkBv(index), length) @@ -164,8 +172,13 @@ private fun TsState.materializeCallsArray(domain: ArrayDomain): MaterializedCall } element.snapshot } - markDenseInputArray(array = arrayRef, type = runtimeType) - MaterializedCallsInput(arrayRef, ArrayInputSnapshot(length = length, elements = elements)) + val snapshot = ArrayInputSnapshot( + length = length, + elements = elements, + reference = arrayRef, + runtimeType = runtimeType, + ) + MaterializedCallsInput(arrayRef, snapshot) } private fun TsState.initializeLength( diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt index ba8d5707c8..2222bd4dc8 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt @@ -3,18 +3,24 @@ package org.usvm.ts.calls import io.ksmt.expr.KBitVec16Value import io.ksmt.sort.KBv16Sort import io.ksmt.sort.KFp64Sort +import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsType import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef import org.usvm.UExpr 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.ts.pbt.model.JsConcreteValue +import org.usvm.util.markDenseInputArray internal sealed interface CallsInputSnapshot { fun resolve(model: UModelBase): JsConcreteValue + + fun markDenseInputs(state: TsState) = Unit } internal data class BooleanInputSnapshot( @@ -52,7 +58,14 @@ internal data class StringInputSnapshot( internal data class ArrayInputSnapshot( val length: UExpr, val elements: List, + val reference: UConcreteHeapRef, + val runtimeType: EtsArrayType, ) : CallsInputSnapshot { + override fun markDenseInputs(state: TsState) { + elements.forEach { element -> element.markDenseInputs(state) } + state.markDenseInputArray(array = reference, 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" } @@ -72,6 +85,8 @@ internal data class OptionalInputSnapshot( ) : CallsInputSnapshot { override fun resolve(model: UModelBase): JsConcreteValue = if (model.eval(chooseValue).toConcreteBoolValue()) nested.resolve(model) else nil + + override fun markDenseInputs(state: TsState) = nested.markDenseInputs(state) } private const val UTF16_CODE_UNIT_MASK = 0xffff diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt index 4ebad290d8..5880dfcaed 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputTypeCompatibility.kt @@ -28,6 +28,7 @@ import org.usvm.ts.pbt.model.OptionalDomain 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.componentType /** Rejects bindings whose frozen domain would replace a source parameter with an incompatible runtime kind. */ internal fun callsInputTypePreflight( @@ -111,10 +112,10 @@ private fun PropertyDomain.inputTypeLimitation( return inputTypeLimitation(type.originalType, genericParameters, path, visitedGenerics) } is EtsArrayType -> { - val isSupportedArray = this is ArrayDomain && type.dimensions == 1 + val isSupportedArray = this is ArrayDomain && type.dimensions >= 1 if (isSupportedArray) { return (this as ArrayDomain).element.inputTypeLimitation( - type = type.elementType, + type = type.componentType, genericParameters = genericParameters, path = "$path[]", visitedGenerics = visitedGenerics, 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 3291a2c04a..202df8beb6 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 @@ -23,6 +23,7 @@ internal fun PropertyDomain.isSupportedCallsSymbolicDomain(): Boolean = when (th is OptionalDomain -> value.isSupportedCallsSymbolicDomain() && (nil == JsConcreteValue.Null || nil == JsConcreteValue.Undefined) is ArrayDomain -> when (val elementDomain = element) { + is ArrayDomain -> elementDomain.isSupportedCallsSymbolicDomain() BooleanDomain, is IntegerDomain, is NumberDomain, is StringDomain -> true is ConstantDomain -> elementDomain.value is JsConcreteValue.Boolean || elementDomain.value is JsConcreteValue.Number || elementDomain.value is JsConcreteValue.String @@ -76,6 +77,7 @@ internal class CallsSymbolicInputs( domain = input.domain, ) } + initializedSnapshots.forEach { snapshot -> snapshot.markDenseInputs(state) } snapshots = initializedSnapshots } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt index 457761c931..19f0872b80 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt @@ -167,4 +167,95 @@ internal class CurrentTsCallsExtendedInputDomainsTest : CurrentTsCallsSymbolicEn assertEquals(listOf(JsConcreteValue.String("xy")), assertIs(inputs.single()).elements) fixture.assertReplayConfirmed(inputs) } + + @Test + fun `nested numeric arrays preserve dimensions and replay actual source`() { + val fixture = fixture( + source = """ + export function matrix(values: number[][]): boolean { + if (values.length === 1 && values[0].length === 1 && values[0][0] === 7) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "matrix", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain( + element = ArrayDomain(element = IntegerDomain(min = 0, max = 10), minLength = 1, maxLength = 2), + minLength = 1, + maxLength = 2, + ), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + val outer = assertIs(inputs.single()) + val inner = assertIs(outer.elements.single()) + assertEquals(listOf(JsConcreteValue.number(7.0)), inner.elements) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `optional nested arrays register every dense region after all inputs are initialized`() { + val rows = ArrayDomain(element = IntegerDomain(min = 0, max = 0), minLength = 1, maxLength = 2) + val matrices = ArrayDomain(element = rows, minLength = 1, maxLength = 2) + val fixture = fixture( + source = """ + export function optionalMatrices(first: number[][] | undefined, second: number[][]): boolean { + if (first !== undefined && first[0].includes(0) && second[0].includes(0)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "optionalMatrices", + inputs = listOf( + PropertyInput(name = "first", domain = OptionalDomain(matrices)), + PropertyInput(name = "second", domain = matrices), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = setOf("ts.array.includes", "ts.math.floor")) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + fixture.assertReplayConfirmed(assertNotNull(result.inputs, result.toString())) + } + + @Test + fun `nested domains reject a missing source array dimension`() { + val fixture = fixture( + source = """ + export function flat(values: number[]): boolean { + if (values.length === 1) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "flat", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = ArrayDomain(element = IntegerDomain())), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status, result.toString()) + assertEquals(CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, result.reasonCode) + assertTrue(result.diagnostic.orEmpty().contains("inputs.values[]")) + } + } From cdc8bef298438f62f4992e7f39b6ce2c2ccc0a3f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 01:55:15 +0300 Subject: [PATCH 11/15] Preserve optional runtime kinds inside recursive Calls arrays --- .../usvm/ts/calls/CallsInputMaterializer.kt | 39 +++++++++++++++---- .../org/usvm/ts/calls/CallsInputSnapshot.kt | 7 +++- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 8 +--- .../CurrentTsCallsExtendedInputDomainsTest.kt | 37 ++++++++++++++++++ 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt index 5739b96411..4651389ef0 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt @@ -6,14 +6,18 @@ 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.EtsNullType import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUndefinedType +import org.jacodb.ets.model.EtsUnionType import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.api.initializeArrayLength import org.usvm.api.makeSymbolicPrimitive import org.usvm.machine.TsSizeSort +import org.usvm.machine.expr.TsUnresolvedSort import org.usvm.machine.state.TsState import org.usvm.machine.types.EtsFakeType import org.usvm.machine.types.extractValue @@ -73,11 +77,13 @@ private fun TsState.initializeIntegerInput(domain: IntegerDomain): UExpr { + val nestedType = value.callsRuntimeType() + val nilType = ConstantDomain(nil).callsRuntimeType() + EtsUnionType(types = listOf(nestedType, nilType)) + } is ConstantDomain -> when (value) { is JsConcreteValue.Boolean -> EtsBooleanType is JsConcreteValue.Number -> EtsNumberType is JsConcreteValue.String -> EtsStringType - else -> error("Array elements require a concrete runtime kind") + JsConcreteValue.Null -> EtsNullType + JsConcreteValue.Undefined -> EtsUndefinedType + is JsConcreteValue.Array -> error("Array constants are not primitive domains") } else -> error("Unsupported array element domain: $this") } @@ -168,6 +184,11 @@ private fun TsState.materializeCallsArray(domain: ArrayDomain): MaterializedCall element.value.asExpr(addressSort), guard = liveIndex, ) + is TsUnresolvedSort -> memory.write( + mkArrayIndexLValue(addressSort, arrayRef, mkBv(index), runtimeType), + element.value.asExpr(addressSort), + guard = liveIndex, + ) else -> error("Unsupported Calls element sort: $elementSort") } element.snapshot @@ -188,9 +209,11 @@ private fun TsState.initializeLength( if (minLength == maxLength) return mkBv(minLength) val length: UExpr = makeSymbolicPrimitive(sizeSort) + val minimum = mkBv(minLength) + val maximum = mkBv(maxLength) val lengthBounds = mkAnd( - mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)), - mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)), + mkBvSignedGreaterOrEqualExpr(length, minimum), + mkBvSignedLessOrEqualExpr(length, maximum), ) pathConstraints += lengthBounds length diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt index 2222bd4dc8..c53e2018f9 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputSnapshot.kt @@ -9,12 +9,14 @@ import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.machine.TsSizeSort +import org.usvm.machine.expr.TsUnresolvedSort 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.ts.pbt.model.JsConcreteValue +import org.usvm.util.componentType import org.usvm.util.markDenseInputArray internal sealed interface CallsInputSnapshot { @@ -63,7 +65,10 @@ internal data class ArrayInputSnapshot( ) : CallsInputSnapshot { override fun markDenseInputs(state: TsState) { elements.forEach { element -> element.markDenseInputs(state) } - state.markDenseInputArray(array = reference, type = runtimeType) + val elementSort = state.ctx.typeToSort(runtimeType.componentType) + if (elementSort !is TsUnresolvedSort) { + state.markDenseInputArray(array = reference, type = runtimeType) + } } override fun resolve(model: UModelBase): JsConcreteValue { 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 202df8beb6..b98e398a35 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 @@ -22,13 +22,7 @@ internal fun PropertyDomain.isSupportedCallsSymbolicDomain(): Boolean = when (th is ConstantDomain -> value !is JsConcreteValue.Array is OptionalDomain -> value.isSupportedCallsSymbolicDomain() && (nil == JsConcreteValue.Null || nil == JsConcreteValue.Undefined) - is ArrayDomain -> when (val elementDomain = element) { - is ArrayDomain -> elementDomain.isSupportedCallsSymbolicDomain() - BooleanDomain, is IntegerDomain, is NumberDomain, is StringDomain -> true - is ConstantDomain -> elementDomain.value is JsConcreteValue.Boolean || - elementDomain.value is JsConcreteValue.Number || elementDomain.value is JsConcreteValue.String - else -> false - } + is ArrayDomain -> element.isSupportedCallsSymbolicDomain() else -> false } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt index 19f0872b80..1cc15a3d93 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt @@ -258,4 +258,41 @@ internal class CurrentTsCallsExtendedInputDomainsTest : CurrentTsCallsSymbolicEn assertTrue(result.diagnostic.orEmpty().contains("inputs.values[]")) } + + @Test + fun `optional array slots preserve independent numeric and undefined values`() { + val fixture = fixture( + source = """ + export function mixedSlots(values: (number | undefined)[]): boolean { + if (values.length === 2 && values[0] === 7 && values[1] === undefined) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "mixedSlots", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain( + element = OptionalDomain(IntegerDomain(min = 0, max = 10)), + minLength = 2, + maxLength = 2, + ), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + assertEquals(CallsSymbolicStatus.REACHED, result.status, result.toString()) + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals( + listOf(JsConcreteValue.number(7.0), JsConcreteValue.Undefined), + assertIs(inputs.single()).elements, + ) + fixture.assertReplayConfirmed(inputs) + } + } From cb199d73f6ccdb769b3ac2c794f87c996016e202 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 02:14:55 +0300 Subject: [PATCH 12/15] Give optional domain replay fixtures distinct source roots --- .../ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt index 1cc15a3d93..8f5ab4cd45 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt @@ -86,17 +86,19 @@ internal class CurrentTsCallsExtendedInputDomainsTest : CurrentTsCallsSymbolicEn @Test fun `optional numeric domain preserves both nullish and numeric branches`() { for (targetValue in listOf(JsConcreteValue.Undefined, JsConcreteValue.number(7.0))) { - val condition = if (targetValue == JsConcreteValue.Undefined) "value === undefined" else "value === 7" + val undefinedTarget = targetValue == JsConcreteValue.Undefined + val condition = if (undefinedTarget) "value === undefined" else "value === 7" + val exportName = if (undefinedTarget) "optionalUndefined" else "optionalNumber" val fixture = fixture( source = """ - export function optional(value: number | undefined): boolean { + export function $exportName(value: number | undefined): boolean { if ($condition) { return true; } return false; } """.trimIndent(), - exportName = "optional", + exportName = exportName, inputs = listOf( PropertyInput(name = "value", domain = OptionalDomain(IntegerDomain(min = 0, max = 10))), ), From ff40071fa3afb12c91ed522e8f810abc0f4a1f4e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 02:18:42 +0300 Subject: [PATCH 13/15] Format recursive Calls input materialization without lint suppressions --- .../usvm/ts/calls/CallsInputMaterializer.kt | 56 ++++++++++++------- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 12 +++- .../usvm/ts/calls/MaterializedCallsInput.kt | 5 ++ .../CurrentTsCallsExtendedInputDomainsTest.kt | 1 - .../main/kotlin/org/usvm/machine/TsContext.kt | 4 +- .../org/usvm/machine/types/TsTypeSystem.kt | 2 +- 6 files changed, 53 insertions(+), 27 deletions(-) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/MaterializedCallsInput.kt diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt index 4651389ef0..936aa91c2d 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsInputMaterializer.kt @@ -36,8 +36,6 @@ import org.usvm.util.allocateBoundedString import org.usvm.util.componentType import org.usvm.util.mkArrayIndexLValue -internal data class MaterializedCallsInput(val value: UExpr<*>, val snapshot: CallsInputSnapshot) - internal fun TsState.materializeCallsInput(domain: PropertyDomain): MaterializedCallsInput = with(ctx) { when (domain) { BooleanDomain -> { @@ -58,13 +56,21 @@ internal fun TsState.materializeCallsInput(domain: PropertyDomain): Materialized val reference = allocateBoundedString(length = length, codeUnits = codeUnits) MaterializedCallsInput(reference, StringInputSnapshot(length = length, codeUnits = codeUnits)) } - is ConstantDomain -> MaterializedCallsInput( - value = materializeCallsConstant(domain.value), - snapshot = ConstantInputSnapshot(domain.value), - ) - is OptionalDomain -> materializeCallsOptional(domain) - is ArrayDomain -> materializeCallsArray(domain) - else -> error("Unsupported Calls input domain: $domain") + is ConstantDomain -> { + MaterializedCallsInput( + value = materializeCallsConstant(domain.value), + snapshot = ConstantInputSnapshot(domain.value), + ) + } + is OptionalDomain -> { + materializeCallsOptional(domain) + } + is ArrayDomain -> { + materializeCallsArray(domain) + } + else -> { + error("Unsupported Calls input domain: $domain") + } } } @@ -126,9 +132,15 @@ private fun TsState.materializeCallsOptional(domain: OptionalDomain): Materializ } private fun PropertyDomain.callsRuntimeType(): EtsType = when (this) { - BooleanDomain -> EtsBooleanType - is IntegerDomain, is NumberDomain -> EtsNumberType - is StringDomain -> EtsStringType + BooleanDomain -> { + EtsBooleanType + } + is IntegerDomain, is NumberDomain -> { + EtsNumberType + } + is StringDomain -> { + EtsStringType + } is ArrayDomain -> { val nestedType = element.callsRuntimeType() if (nestedType is EtsArrayType) { @@ -142,15 +154,19 @@ private fun PropertyDomain.callsRuntimeType(): EtsType = when (this) { val nilType = ConstantDomain(nil).callsRuntimeType() EtsUnionType(types = listOf(nestedType, nilType)) } - is ConstantDomain -> when (value) { - is JsConcreteValue.Boolean -> EtsBooleanType - is JsConcreteValue.Number -> EtsNumberType - is JsConcreteValue.String -> EtsStringType - JsConcreteValue.Null -> EtsNullType - JsConcreteValue.Undefined -> EtsUndefinedType - is JsConcreteValue.Array -> error("Array constants are not primitive domains") + is ConstantDomain -> { + when (value) { + is JsConcreteValue.Boolean -> EtsBooleanType + is JsConcreteValue.Number -> EtsNumberType + is JsConcreteValue.String -> EtsStringType + JsConcreteValue.Null -> EtsNullType + JsConcreteValue.Undefined -> EtsUndefinedType + is JsConcreteValue.Array -> error("Array constants are not primitive domains") + } + } + else -> { + error("Unsupported array element domain: $this") } - else -> error("Unsupported array element domain: $this") } private fun TsState.materializeCallsArray(domain: ArrayDomain): MaterializedCallsInput = with(ctx) { 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 b98e398a35..749f1be17a 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 @@ -90,13 +90,19 @@ private fun TsState.initializeInput( val input = materializeCallsInput(domain) when (input.value.sort) { boolSort -> memory.write( - mkRegisterStackLValue(boolSort, stackSlot), input.value.asExpr(boolSort), guard = trueExpr, + mkRegisterStackLValue(boolSort, stackSlot), + input.value.asExpr(boolSort), + guard = trueExpr, ) fp64Sort -> memory.write( - mkRegisterStackLValue(fp64Sort, stackSlot), input.value.asExpr(fp64Sort), guard = trueExpr, + mkRegisterStackLValue(fp64Sort, stackSlot), + input.value.asExpr(fp64Sort), + guard = trueExpr, ) addressSort -> memory.write( - mkRegisterStackLValue(addressSort, stackSlot), input.value.asExpr(addressSort), guard = trueExpr, + mkRegisterStackLValue(addressSort, stackSlot), + input.value.asExpr(addressSort), + guard = trueExpr, ) else -> error("Unsupported Calls input sort: ${input.value.sort}") } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/MaterializedCallsInput.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/MaterializedCallsInput.kt new file mode 100644 index 0000000000..e2cbe3290f --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/MaterializedCallsInput.kt @@ -0,0 +1,5 @@ +package org.usvm.ts.calls + +import org.usvm.UExpr + +internal data class MaterializedCallsInput(val value: UExpr<*>, val snapshot: CallsInputSnapshot) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt index 8f5ab4cd45..ed578267c4 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsExtendedInputDomainsTest.kt @@ -296,5 +296,4 @@ internal class CurrentTsCallsExtendedInputDomainsTest : CurrentTsCallsSymbolicEn ) fixture.assertReplayConfirmed(inputs) } - } 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 aa88b4f32f..15fb6ec19d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -2,8 +2,6 @@ package org.usvm.machine import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract import org.jacodb.ets.model.EtsAliasType import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType @@ -58,6 +56,8 @@ import org.usvm.memory.UReadOnlyMemory import org.usvm.types.single import org.usvm.util.componentType import org.usvm.util.mkFieldLValue +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract typealias TsSizeSort = UBv32Sort diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt index ffb1a17021..34f428777a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/types/TsTypeSystem.kt @@ -1,6 +1,5 @@ package org.usvm.machine.types -import kotlin.time.Duration import org.jacodb.ets.model.EtsAliasType import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType @@ -38,6 +37,7 @@ import org.usvm.util.getAllFields import org.usvm.util.getAllProperties import org.usvm.util.getAllPropertiesCombined import org.usvm.util.type +import kotlin.time.Duration // TODO this is draft, should be replaced with real implementation class TsTypeSystem( From 9fe52b71cecb45503219290dd4beab57389504c9 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 22:20:01 +0300 Subject: [PATCH 14/15] Extract initial solver model setup to keep interpreter within size limit --- .../usvm/machine/interpreter/TsInterpreter.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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 32fcb2e103..e6e1cdd8f3 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 @@ -858,14 +858,7 @@ class TsInterpreter( } state.configureState() - - val solver = solver() - val solverResult = solver.check(state.pathConstraints) - if (solverResult is UUnknownResult) { - throw TsInitialStateSolverUnknownException(method) - } - val model = solverResult.ensureSat().model - state.models = listOf(model) + state.initializeModel(method) state } @@ -874,3 +867,14 @@ class TsInterpreter( private val EtsStmt.nextStmt: EtsStmt? get() = graph.successors(this).firstOrNull() } + +private fun TsState.initializeModel(method: EtsMethod) { + val solver = ctx.solver() + val solverResult = solver.check(pathConstraints) + if (solverResult is UUnknownResult) { + throw TsInitialStateSolverUnknownException(method) + } + + val model = solverResult.ensureSat().model + models = listOf(model) +} From d82a0c783bc3c91109fb85c32123bf5bc4bf1a19 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Tue, 13 Oct 2026 22:33:07 +0300 Subject: [PATCH 15/15] Use immediate array component type for boxed storage reads --- usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt | 2 +- usvm-ts/src/main/kotlin/org/usvm/util/TypedArrayStorage.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 512e8dafc6..6cdb528374 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 @@ -17,8 +17,8 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort import org.usvm.util.arrayStorageType import org.usvm.util.boxArrayValue -import org.usvm.util.initializeTypedArrayOverrides import org.usvm.util.componentType +import org.usvm.util.initializeTypedArrayOverrides import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.typedArrayBoxedLValue diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/TypedArrayStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/TypedArrayStorage.kt index add8b146a7..ba019fb896 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/TypedArrayStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/TypedArrayStorage.kt @@ -45,7 +45,7 @@ internal fun TsState.readTypedArrayElement( arrayType: EtsArrayType, ): TsTypedArrayElement = with(ctx) { initializeTypedArrayOverrides(array) - val sort = typeToSort(arrayType.elementType) + val sort = typeToSort(arrayType.componentType) val nativeValue = memory.read(mkArrayIndexLValue(sort, array, index, arrayType)) val native = TsUnresolvedValue( boolValue = if (sort == boolSort) nativeValue.asExpr(boolSort) else falseExpr,