diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 34faf3df14..311c42e3f5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -149,10 +149,16 @@ jobs:
cd $DEST_DIR
npm install
+ # Transitively hoisted @types/node >= 24 uses TypeScript syntax that the
+ # ArkAnalyzer toolchain cannot parse; pin a compatible version. Both
+ # --no-save packages must be installed in one command: each `npm install`
+ # re-resolves the tree and drops previous --no-save additions
+ # (ohos-typescript is installed --no-save by the postinstall script).
+ npm install --no-save @types/node@22 arktools/lib/ohos-typescript-4.9.5-r4-OpenHarmony-v5.0.0-Release.tgz
npm run build
- name: Run TS tests
- run: ./gradlew :usvm-ts:check :usvm-ts-dataflow:check
+ run: ./gradlew :usvm-ts:check :usvm-ts-dataflow:check :usvm-ts-pbt:check
- name: Upload Gradle reports
if: (!cancelled())
diff --git a/build.gradle.kts b/build.gradle.kts
index 8223024eeb..8f611c8543 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -22,6 +22,7 @@ tasks.register("validateProjectList") {
project(":usvm-python"),
project(":usvm-ts"),
project(":usvm-ts-dataflow"),
+ project(":usvm-ts-pbt"),
)
// Gather the actual subprojects from the current root project.
diff --git a/detekt/baselines/usvm-ts-pbt-Main.yml b/detekt/baselines/usvm-ts-pbt-Main.yml
new file mode 100644
index 0000000000..0f1807e20d
--- /dev/null
+++ b/detekt/baselines/usvm-ts-pbt-Main.yml
@@ -0,0 +1,87 @@
+
+
+
+
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$("cannot set property '${lhv.field.name}' of ${JsSemantics.toStringJs(target)}")
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(JsSemantics.toInt32(eval(left, frame)), JsSemantics.toInt32(eval(right, frame)))
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(eval(left, frame))
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(eval(right, frame))
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(left, frame)
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(op(JsSemantics.toInt32(eval(left, frame)), JsSemantics.toInt32(eval(right, frame))).toDouble())
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(right, frame)
+ ArgumentListWrapping:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$(target)
+ BracesOnWhenStatements:ConstantMiner.kt$MinedConstants.Companion$when
+ BracesOnWhenStatements:Conversions.kt$when
+ BracesOnWhenStatements:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$when
+ BracesOnWhenStatements:Generators.kt$InputGenerator$when
+ BracesOnWhenStatements:Intrinsics.kt$Intrinsics$when
+ BracesOnWhenStatements:JsSemantics.kt$JsSemantics$when
+ BracesOnWhenStatements:Main.kt$when
+ BracesOnWhenStatements:PbtPhase.kt$PbtPhase$when
+ EmptyFunctionBlock:CoverageTracker.kt$CoverageTracker${}
+ Filename:ConstantMiner.kt$org.usvm.ts.pbt.gen.ConstantMiner.kt
+ Filename:Generators.kt$org.usvm.ts.pbt.gen.Generators.kt
+ ForbiddenMethodCall:Main.kt$println( " ${m.method}: stmt=%.1f%%, branch=%.1f%% (pbt: %s, symbolic: %s), %d ms".format( m.stmtCoverage * 100, m.branchCoverage * 100, m.pbt?.let { "${it.executions} runs, ${it.failures.size} failures" } ?: "-", m.symbolic?.let { "${it.reached}/${it.targets.size} targets" } ?: "-", m.totalWallMs, ) )
+ ForbiddenMethodCall:Main.kt$println("Analyzing ${methods.size} method(s) in mode $mode...")
+ ForbiddenMethodCall:Main.kt$println("Loading ${files.size} file(s) via ArkAnalyzer...")
+ ForbiddenMethodCall:Main.kt$println("Report written to $out")
+ ForbiddenMethodCall:Main.kt$println("Unknown option: ${args[i]}\n$USAGE")
+ ForbiddenMethodCall:Main.kt$println(USAGE)
+ LoopWithTooManyJumpStatements:PbtPhase.kt$PbtPhase$while
+ MagicNumber:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$31
+ MagicNumber:Generators.kt$InputGenerator$0.0
+ MagicNumber:Generators.kt$InputGenerator$0.6
+ MagicNumber:Generators.kt$InputGenerator$10
+ MagicNumber:Generators.kt$InputGenerator$100
+ MagicNumber:Generators.kt$InputGenerator$1000
+ MagicNumber:Generators.kt$InputGenerator$1001
+ MagicNumber:Generators.kt$InputGenerator$101
+ MagicNumber:Generators.kt$InputGenerator$11
+ MagicNumber:Generators.kt$InputGenerator$1e6
+ MagicNumber:Generators.kt$InputGenerator$3
+ MagicNumber:Generators.kt$InputGenerator$4
+ MagicNumber:Generators.kt$InputGenerator$5
+ MagicNumber:Generators.kt$InputGenerator$6
+ MagicNumber:Generators.kt$InputGenerator$8
+ MagicNumber:Intrinsics.kt$Intrinsics$9007199254740991.0
+ MagicNumber:JsSemantics.kt$JsSemantics$0xFFFFFFFFL
+ MagicNumber:JsSemantics.kt$JsSemantics$16
+ MagicNumber:JsSemantics.kt$JsSemantics$1e21
+ MagicNumber:JsSemantics.kt$JsSemantics$8
+ MagicNumber:JsSemantics.kt$JsSemantics$9.007199254740992E15
+ MagicNumber:Main.kt$100
+ MagicNumber:Main.kt$20
+ MagicNumber:Main.kt$2_000
+ MagicNumber:PbtPhase.kt$PbtPhase$100
+ MagicNumber:SymbolicPhase.kt$SymbolicPhase$1_000_000
+ MatchingDeclarationName:ConstantMiner.kt$MinedConstants
+ MatchingDeclarationName:Generators.kt$InputGenerator
+ MaxLineLength:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$)
+ MaxLineLength:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$throw typeError("cannot set property '${lhv.field.name}' of ${JsSemantics.toStringJs(target)}")
+ MaximumLineLength:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$
+ MultiLineIfElse:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$VNamespace(e.name)
+ MultiLineIfElse:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$VString(array.value[i.toInt()].toString())
+ MultiLineIfElse:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$VUndefined
+ MultiLineIfElse:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$throw UnsupportedFeatureSignal("global ref: ${e.name}")
+ MultiLineIfElse:Generators.kt$InputGenerator$genAny(depth)
+ MultiLineIfElse:Generators.kt$InputGenerator$generate(type.types[random.nextInt(type.types.size)], depth)
+ MultiLineIfElse:Generators.kt$InputGenerator$random.nextDouble(-1e6, 1e6)
+ MultiLineIfElse:Generators.kt$InputGenerator$random.nextInt(-100, 101) + random.nextDouble()
+ MultiLineIfElse:JsSemantics.kt$JsSemantics$d.toLong().toString()
+ MultiLineIfElse:JsSemantics.kt$JsSemantics$java.math.BigDecimal(d).toBigInteger().toString()
+ MultiLineIfElse:PbtPhase.kt$PbtPhase$args
+ NestedBlockDepth:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$fun runMethod(method: EtsMethod, thisValue: VValue, args: List<VValue>): VValue
+ NestedBlockDepth:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$private fun assign(lhv: EtsLValue, value: VValue, frame: Frame)
+ NestedBlockDepth:Shrinker.kt$Shrinker$fun shrink(args: List<VValue>, stillFails: (List<VValue>) -> Boolean): List<VValue>
+ NoMultipleSpaces:Generators.kt$InputGenerator$
+ SwallowedException:EtsConcreteInterpreter.kt$EtsConcreteInterpreter$e: StackOverflowError
+ ThrowsCount:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$fun evalCall(expr: EtsCallExpr, frame: Frame): VValue
+ ThrowsCount:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$fun runMethod(method: EtsMethod, thisValue: VValue, args: List<VValue>): VValue
+ ThrowsCount:EtsConcreteInterpreter.kt$EtsConcreteInterpreter.Execution$private fun assign(lhv: EtsLValue, value: VValue, frame: Frame)
+ TooGenericExceptionCaught:SymbolicPhase.kt$SymbolicPhase$e: Throwable
+ UnderscoresInNumericLiterals:Intrinsics.kt$Intrinsics$9007199254740991.0
+ Wrapping:Generators.kt$InputGenerator$;
+ Wrapping:Intrinsics.kt$Intrinsics$(
+ Wrapping:Intrinsics.kt$Intrinsics$;
+
+
diff --git a/detekt/baselines/usvm-ts-pbt-Test.yml b/detekt/baselines/usvm-ts-pbt-Test.yml
new file mode 100644
index 0000000000..cb0ac5472e
--- /dev/null
+++ b/detekt/baselines/usvm-ts-pbt-Test.yml
@@ -0,0 +1,27 @@
+
+
+
+
+ ArgumentListWrapping:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$("[$className] compared=${verdict.compared}, skipped=${verdict.skipped}, mismatches=${verdict.mismatches.size}")
+ ArgumentListWrapping:HybridE2eTest.kt$HybridE2eTest$( scene, m, coverage, hints = hints, hintFallback = false, )
+ ArgumentListWrapping:HybridE2eTest.kt$HybridE2eTest$( scene, m, coverage, hints = pbt.typeProfiler.toHints(), )
+ BracesOnWhenStatements:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$when
+ CommentSpacing:LoadEts.kt$//-----------------------------------------------------------------------------
+ ForbiddenMethodCall:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$println(" MISMATCH: $it")
+ ForbiddenMethodCall:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$println("[$className] compared=${verdict.compared}, skipped=${verdict.skipped}, mismatches=${verdict.mismatches.size}")
+ ForbiddenMethodCall:HybridE2eTest.kt$HybridE2eTest$println( "[ablation] with hints: steps=${withHints.steps}, reached=${withHints.reached}, " + "wallMs=${withHints.wallMs}; without: steps=${withoutHints.steps}, " + "reached=${withoutHints.reached}, wallMs=${withoutHints.wallMs}" )
+ ImportOrdering:ConcreteInterpreterDslTest.kt$import org.jacodb.ets.dsl.add import org.jacodb.ets.dsl.and import org.jacodb.ets.dsl.const import org.jacodb.ets.dsl.eqq import org.jacodb.ets.dsl.gt import org.jacodb.ets.dsl.local import org.jacodb.ets.dsl.lt import org.jacodb.ets.dsl.mul import org.jacodb.ets.dsl.neg import org.jacodb.ets.dsl.param import org.jacodb.ets.dsl.program import org.jacodb.ets.dsl.sub import org.jacodb.ets.dsl.toBlockCfg import org.jacodb.ets.dsl.ProgramBuilder import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsMethodImpl import org.jacodb.ets.model.EtsMethodParameter import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.toEtsBlockCfg import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test
+ MaxLineLength:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$println("[$className] compared=${verdict.compared}, skipped=${verdict.skipped}, mismatches=${verdict.mismatches.size}")
+ MaximumLineLength:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$
+ MultiLineIfElse:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$compared++
+ MultiLineIfElse:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$mismatches += "$ctx: concrete threw ${result.value}, symbolic returned ${test.returnValue}"
+ NestedBlockDepth:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$private fun runDifferential(resourcePath: String, className: String): Verdict
+ NoMultipleSpaces:JsSemanticsTest.kt$JsSemanticsTest$
+ SwallowedException:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$e: Throwable
+ UnderscoresInNumericLiterals:HybridE2eTest.kt$HybridE2eTest$49382.0
+ UnderscoresInNumericLiterals:JsSemanticsTest.kt$JsSemanticsTest$4294967295.0
+ UnderscoresInNumericLiterals:JsSemanticsTest.kt$JsSemanticsTest$4294967295L
+ Wrapping:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$(
+ Wrapping:ConcreteVsSymbolicDifferentialTest.kt$ConcreteVsSymbolicDifferentialTest$;
+
+
diff --git a/docs/ts-pbt/01-arkanalyzer-and-ets-ir.md b/docs/ts-pbt/01-arkanalyzer-and-ets-ir.md
new file mode 100644
index 0000000000..950e5dd74f
--- /dev/null
+++ b/docs/ts-pbt/01-arkanalyzer-and-ets-ir.md
@@ -0,0 +1,358 @@
+# ArkAnalyzer, the EtsIR, and how USVM-TS is wired to them
+
+> Research note for the **Property-Based Testing (PBT) for ArkTS/TS** effort.
+> Branch: `caelmbleidd/ts_pbt`. Date: 2026-06-19.
+>
+> Goal of this document: explain *what ArkAnalyzer is*, *how this repository
+> depends on it*, *what the IR looks like*, and *whether we should keep relying
+> on it or write our own `.ts`/`.js` front-end* that produces the same IR.
+
+---
+
+## 1. TL;DR
+
+* `usvm-ts` (the symbolic execution engine for ArkTS/TS) does **not** parse
+ TypeScript itself. It consumes an already-lowered, three-address,
+ basic-block IR called **EtsIR**, exposed as Kotlin classes
+ (`EtsScene` / `EtsFile` / `EtsMethod` / `EtsStmt` …) from the external
+ library **`jacodb-ets`**.
+* That IR is produced by **ArkAnalyzer** — a *TypeScript/Node.js* static
+ analysis framework for ArkTS/OpenHarmony. ArkAnalyzer parses the source,
+ lowers it to its own "ArkIR", and **serializes it to JSON**. `jacodb-ets`
+ then **deserializes the JSON** into `EtsFileDto` and **lifts** it to the
+ `EtsFile` model.
+* The full pipeline is therefore:
+
+ ```
+ *.ts / *.ets / *.js
+ │ ArkAnalyzer (Node): src/save/serializeArkIR.js
+ ▼
+ EtsIR JSON (one *.ts.json per source file)
+ │ jacodb-ets: EtsFileDto.loadFromJson() (kotlinx.serialization)
+ ▼
+ EtsFileDto (1:1 mirror of the JSON)
+ │ jacodb-ets: EtsFileDto.toEtsFile() / EtsMethodBuilder
+ ▼
+ EtsFile / EtsScene ← "the IR you see in the project"
+ │ usvm-ts: TsMachine / TsInterpreter / TsExprResolver
+ ▼
+ symbolic execution → TsTest (org.usvm.api.TsTest)
+ ```
+
+* **Recommendation (see §6):** for PBT we should build our **own front-end**
+ `.ts`/`.js → EtsIR`, because ArkAnalyzer is a heavyweight, version-coupled,
+ per-file-subprocess external dependency that is tuned for ArkTS/OHOS (not
+ plain JS) and is opaque/hard to control. We can target the exact same IR
+ contract so that all of `usvm-ts` keeps working unchanged.
+
+---
+
+## 2. What is ArkAnalyzer?
+
+**ArkAnalyzer** (sometimes written "Ark Analyzer") is an open-source static
+analysis framework for **ArkTS** — the application language of Huawei's
+**OpenHarmony / HarmonyOS**. ArkTS is a constrained dialect of TypeScript.
+
+* Upstream: `https://gitcode.com/openharmony-sig/arkanalyzer` (OpenHarmony SIG).
+* It is written **in TypeScript** and runs on **Node.js**.
+* It builds an SSA-ish, Jimple-like **three-address IR** ("ArkIR") out of TS
+ sources: `ArkFile`, `ArkClass`, `ArkMethod`, `ArkAssignStmt`,
+ `ArkInstanceInvokeExpr`, etc. It also resolves imports/exports, models
+ namespaces, decorators, and ships handling for the OHOS SDK. It can run
+ **type inference** over the IR.
+
+### The fork we actually use
+
+This project does **not** use upstream ArkAnalyzer directly. It uses a
+**fork maintained by `Lipen`** (a jacodb maintainer) that is kept in lock-step
+with jacodb's deserializer:
+
+* Fork: `https://gitcode.com/Lipen/arkanalyzer` (mirror: `gitee.com/Lipenx/arkanalyzer`).
+* You must checkout a branch named `neo/` that matches the current
+ jacodb DTO schema. The version coupling is real: the JSON schema emitted by
+ ArkAnalyzer must match the `*Dto` classes in `jacodb-ets`.
+* CI currently pins branch **`neo/2025-09-03`** (see `.github/workflows/ci.yml`).
+ `jacodb-ets/ARKANALYZER.md` mentions `neo/2025-02-24`; `usvm-ts-dataflow/README.md`
+ mentions `neo/2024-10-31`. **These drift over time** — always read CI for the
+ source of truth.
+
+### The serializer entry point
+
+The relevant script is **`src/save/serializeArkIR.ts`** (built to
+`out/src/save/serializeArkIR.js`):
+
+```text
+Usage: serializeArkIR [options]