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/00-project-state.md b/docs/ts-pbt/00-project-state.md
new file mode 100644
index 0000000000..81abb7ed47
--- /dev/null
+++ b/docs/ts-pbt/00-project-state.md
@@ -0,0 +1,106 @@
+# Project state: hybrid PBT + symbolic execution for TS (handoff document)
+
+> Last updated: 2026-07-08. **Read this first** when resuming work.
+> Context: prototype for the PhD "Гибридные методы анализа динамических
+> языков программирования" and the accompanying paper.
+
+## 1. Branch map (all pushed to origin)
+
+| Branch | Purpose | Contents |
+|---|---|---|
+| `caelmbleidd/ts_pbt` (**PR #341**) | The PBT/hybrid prototype | `usvm-ts-pbt` module, minimal usvm-ts extensions that belong to the feature (input type hints in `getInitialState`, `TsTestResolver` promotion + `resolveInputs`), benchmark infra, research notes `docs/ts-pbt/0*` |
+| `caelmbleidd/pbt_article` | The paper | = ts_pbt + `docs/ts-pbt/paper/` (acmart LaTeX; `latexmk -pdf main.tex`) |
+| `caelmbleidd/ts-interpreter-fixes` | Engine fixes, **kept separate from PBT by agreement** (base: master @ dab6d432) | mock unresolved virtual calls instead of killing the state; inc/dec unary expressions in TsExprResolver |
+
+Policy: PBT work goes to `ts_pbt`; engine (usvm-ts machine) fixes go ONLY to
+`ts-interpreter-fixes`; paper text goes to `pbt_article` (rebase it on ts_pbt
+as needed). Never mix engine fixes into ts_pbt.
+
+## 2. Current results (reproducible via benchmarks/run-project.sh)
+
+TheAlgorithms/TypeScript `maths` (62 methods, 264 branch edges, seed 0,
+pbt-iterations 1000, target-timeout 10 s, ts-frontend provider):
+
+| Mode | Branch % | Wall s | Notes |
+|---|---|---|---|
+| PBT only | 78.0 | 6.3 | |
+| Symbolic only | 63.1 → 64.7 | ~306 | second number = with the inc/dec engine fix |
+| Hybrid | **83.0** | 156 | 13/58 residual targets reached |
+| Hybrid + hints | 83.0 | 307 | 45 fallbacks (residual is engine-hard, hints can't help there) |
+
+loiane/javascript-datastructures-algorithms `src` (307 methods, 670 edges):
+PBT 62.5% @ 1.7 s; symbolic-only 57.2% @ 427 s; hybrid **75.1%** @ 243 s.
+Known issue there: replay-confirmed ≈ 0 for instance methods (see backlog).
+
+History: hybrid on maths was 64% before the engine mock-fix + interpreter
+extensions (notes 02/03 tell the diagnosis story — useful for the paper).
+
+## 3. In-flight / external dependencies
+
+- **usvm PR #341** (ts_pbt): CI was fixed (validateProjectList + @types/node
+ pin for the AA build + `:usvm-ts-pbt:check` in ci-ts) but the run was NOT
+ re-verified after the last force-pushes — check `gh pr checks 341`.
+- **jacodb PR #361** (native ts-frontend): usvm-ts-pbt is verified compatible
+ (note 04). Until merged + usvm pin bump, use
+ `./gradlew -PuseLocalJacodb=` and `ETS_IR_PROVIDER=ts-frontend`.
+- Local toolchain expectations: pinned ArkAnalyzer build (`neo/2025-09-03`)
+ at `~/Programming/arkanalyzer-neo-2025-09-03`; jacodb checkout with the
+ ts-frontend at `~/Programming/jacodb` (branch `caelmbleidd/ts_native_parser`).
+
+## 4. Backlog
+
+**PBT side (ts_pbt):**
+1. Replay confirmation for instance methods: solver-synthesized `this`
+ objects rarely replay (TsClass -> VObject decode gaps) — investigate on
+ the datastructures corpus (`replay-confirmed: 1` out of 84 reached).
+2. Project-level scenes (load whole project instead of per-file) to resolve
+ cross-file imports — the largest `Unsupported` bucket on real corpora.
+3. Symbolic->PBT feedback loop: reached inputs as seeds for a mutation round.
+4. Fallback budgeting for type hints (skip the retry when the hinted run
+ timed out rather than proved UNSAT) — motivated by the ablation numbers.
+5. Mutation-based bug seeding over `org.jacodb.ets.dsl` for a mutation-score
+ experiment (paper).
+
+**Engine side (ts-interpreter-fixes only):**
+1. Unbounded recursion `StrictEq.resolveFakeObject <-> resolveBinaryOperator`
+ (stack overflow on fake-object comparisons; seen on corpus runs).
+2. The NaN hole of the compare-to-zero truthiness idiom: engine evaluates
+ `x != 0` numerically, so `undefined` becomes truthy (note 04 §2);
+ candidate fix: `mkTruthyExpr` for compare-to-zero on non-fp operands.
+3. `+` on references is approximated numerically (`null + {}` -> NaN instead
+ of string concat) — differential finding, whitelisted.
+4. Lt/logical operators on mixed fake-object sorts (whitelisted findings).
+5. Strings: `TsTestStateResolver` returns placeholder strings -> lossy replay.
+
+**Front-end findings to report upstream:**
+- ArkAnalyzer lowers `const old = x++` as `x := x + 1; old := x` (old gets the
+ NEW value) — lowering bug, documented in the ts-interpreter-fixes sample.
+- The `if (x)` -> `x != 0` idiom is ambiguous IR (indistinguishable from a
+ genuine loose comparison); suggested a dedicated truthiness ConditionExpr
+ for jacodb #361.
+
+## 5. Docs index
+
+- `usvm-ts-pbt/README.md` — module overview, architecture, env, pitfalls.
+- `usvm-ts-pbt/benchmarks/README.md` — one-command measurement pipeline.
+- `docs/ts-pbt/01-arkanalyzer-and-ets-ir.md` — front ends, EtsIR contract.
+- `docs/ts-pbt/02-concrete-interpreter-and-differential-findings.md` —
+ interpreter design; AA successor-order drift (the branch-inversion story).
+- `docs/ts-pbt/03-hybrid-pipeline.md` — pipeline, usvm-ts touch points,
+ stop/collect race, first corpus numbers.
+- `docs/ts-pbt/04-jacodb-native-parser-compat.md` — ts-frontend compat,
+ truthiness-idiom ambiguity, how to run with the native parser.
+- `docs/ts-pbt/paper/` (branch `pbt_article`) — the paper; numbers in
+ Table 1 come from `benchmarks/results/maths2-*.json`-style runs.
+
+## 6. Quick sanity commands
+
+```bash
+export ARKANALYZER_DIR=~/Programming/arkanalyzer-neo-2025-09-03
+./gradlew :usvm-ts-pbt:test --rerun-tasks # 29 tests, expect 0 failures
+./gradlew :usvm-ts:test --rerun-tasks # 422+ tests, expect 0 failures
+cd usvm-ts-pbt/benchmarks && ./run-project.sh TheAlgorithms-TypeScript --include maths
+```
+
+Remember `--rerun-tasks` whenever env vars (providers) change — Gradle does
+not track them.
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]