Skip to content

Commit fa39061

Browse files
committed
[TS] Support the jacodb native TS parser (jacodb#361) in usvm-ts-pbt
- follow the frontends' compare-to-zero truthiness idiom in the concrete interpreter: both ArkAnalyzer and the jacodb ts-frontend lower `if (x)` to `x != 0`, which differs from JS loose (in)equality for non-number operands ([] != 0 is false in JS, yet [] is truthy); compare-to-zero on a non-number now means ToBoolean - whitelist two more confirmed engine divergences found by running the differential oracle under the second frontend: the NaN hole of the same idiom (undefined treated as truthy via ToNumber(undefined) != 0) and the doubly-ambiguous transitiveCoercionNoTypes - add an opt-in composite-build switch (-PuseLocalJacodb[=<path>]) for substituting a locally built jacodb - document the compatibility check (docs/ts-pbt/04) Full usvm-ts-pbt suite is green under both EtsIR providers.
1 parent 1db59c3 commit fa39061

4 files changed

Lines changed: 114 additions & 18 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Compatibility with the jacodb native TS parser (jacodb PR #361)
2+
3+
> Research note. Date: 2026-07-06.
4+
5+
## 1. Status: fully compatible
6+
7+
jacodb PR #361 (`caelmbleidd/ts_native_parser`) adds `jacodb-ets/ts-frontend` — a
8+
TypeScript-compiler-based EtsIR producer replacing ArkAnalyzer — and reworks
9+
`LoadEtsFile.kt`: the provider is selected by `ETS_IR_PROVIDER`
10+
(`ts-frontend` (default) | `arkanalyzer`), with `ETS_FRONTEND_DIR` pointing at the
11+
built frontend (`npm run build``dist/index.js`; the CLI is flag-compatible with
12+
`serializeArkIR`). The `loadEtsFileAutoConvert`-family signatures are unchanged, so
13+
**usvm-ts-pbt requires no code changes**.
14+
15+
Verified end-to-end: usvm built with the PR-branch jacodb substituted via the new
16+
opt-in composite build (`./gradlew -PuseLocalJacodb=<path> ...`, see
17+
`settings.gradle.kts`), full usvm-ts-pbt suite (29 tests incl. the differential
18+
oracle and hybrid e2e) is green under **both** providers.
19+
20+
## 2. Findings from cross-frontend differential testing
21+
22+
Running the same differential oracle under the second frontend immediately
23+
yielded new results — cross-frontend differential testing is itself a method:
24+
25+
1. **The compare-to-zero truthiness idiom is ambiguous IR.** Both frontends lower
26+
`if (x)` to the ConditionExpr `x != 0` — byte-identical to a genuine
27+
source-level `x != 0` loose comparison. The two differ in JS for non-number
28+
operands (`[] != 0` is *false*, yet `[]` is truthy; `undefined != 0` is *true*,
29+
yet `undefined` is falsy). The concrete interpreter now follows the idiom
30+
contract (compare-to-zero on a non-number operand = ToBoolean), documented in
31+
`EtsConcreteInterpreter`.
32+
*Feedback for #361: a dedicated truthiness ConditionExpr op would remove the
33+
ambiguity.*
34+
2. **The engine falls into the NaN hole of the same idiom**: it evaluates
35+
`x != 0` numerically, so for `x = undefined` it derives
36+
`ToNumber(undefined) = NaN != 0 → true` and treats `undefined` as *truthy*
37+
(`And.andOfUnknown(0, undefined)`: engine 21, JS 0). Whitelisted; engine issue.
38+
3. `TypeCoercion.transitiveCoercionNoTypes` is doubly ambiguous: a genuine
39+
`c != 0` comparison (idiom-indistinguishable) plus the engine's non-JS `&&`
40+
on references; JS gives 2, engine 1, concrete 4. Whitelisted.
41+
42+
## 3. How to run against the native parser
43+
44+
```bash
45+
cd <jacodb>/jacodb-ets/ts-frontend && npm install && npm run build
46+
cd <usvm>
47+
export ETS_IR_PROVIDER=ts-frontend
48+
export ETS_FRONTEND_DIR=<jacodb>/jacodb-ets/ts-frontend
49+
./gradlew -PuseLocalJacodb=<jacodb> :usvm-ts-pbt:test
50+
```
51+
52+
(The `ARKANALYZER_DIR`-gated tests still require the variable to be *set* until
53+
the enabling condition is made provider-aware; its value is unused by the native
54+
provider.) Once #361 is merged and usvm bumps the jacodb pin, `-PuseLocalJacodb`
55+
becomes unnecessary and the native parser works out of the box — including for the
56+
open-source-corpus benchmark infrastructure, which eliminates the per-file
57+
ArkAnalyzer/Node round-trip fragility on large corpora.

settings.gradle.kts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,23 @@ findProject(":usvm-python:usvm-python-commons")?.name = "usvm-python-commons"
5656
// Actually, relative path is enough, but there is a bug in IDEA when the path is a symlink.
5757
// As a workaround, we convert it to a real absolute path.
5858
// See IDEA bug: https://youtrack.jetbrains.com/issue/IDEA-329756
59-
// val jacodbPath = file("jacodb").takeIf { it.exists() }
60-
// ?: file("../jacodb").takeIf { it.exists() }
61-
// ?: error("Local JacoDB directory not found")
62-
// includeBuild(jacodbPath.toPath().toRealPath().toAbsolutePath()) {
63-
// dependencySubstitution {
64-
// all {
65-
// val requested = requested
66-
// if (requested is ModuleComponentSelector && requested.group == "com.github.UnitTestBot.jacodb") {
67-
// val targetProject = ":${requested.module}"
68-
// useTarget(project(targetProject))
69-
// logger.info("Substituting ${requested.group}:${requested.module} with $targetProject")
70-
// }
71-
// }
72-
// }
73-
// }
59+
// Opt-in local JacoDB substitution: -PuseLocalJacodb[=<path>] (default: ./jacodb or ../jacodb)
60+
if (extra.has("useLocalJacodb")) {
61+
val prop = extra.get("useLocalJacodb")?.toString()
62+
val jacodbPath = prop?.takeIf { it.isNotBlank() && it != "true" }?.let { file(it) }
63+
?: file("jacodb").takeIf { it.exists() }
64+
?: file("../jacodb").takeIf { it.exists() }
65+
?: error("Local JacoDB directory not found")
66+
includeBuild(jacodbPath.toPath().toRealPath().toAbsolutePath()) {
67+
dependencySubstitution {
68+
all {
69+
val requested = requested
70+
if (requested is ModuleComponentSelector && requested.group == "com.github.UnitTestBot.jacodb") {
71+
val targetProject = ":${requested.module}"
72+
useTarget(project(targetProject))
73+
logger.info("Substituting ${requested.group}:${requested.module} with $targetProject")
74+
}
75+
}
76+
}
77+
}
78+
}

usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/interpreter/EtsConcreteInterpreter.kt

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,29 @@ class EtsConcreteInterpreter(
265265
VNumber((a ushr b).toDouble())
266266
}
267267

268-
// Binary: relational
269-
is EtsEqExpr -> VBool(JsSemantics.looseEq(eval(e.left, frame), eval(e.right, frame)))
270-
is EtsNotEqExpr -> VBool(!JsSemantics.looseEq(eval(e.left, frame), eval(e.right, frame)))
268+
// Binary: relational.
269+
//
270+
// NOTE on the compare-to-zero idiom: both frontends (ArkAnalyzer and the
271+
// jacodb ts-frontend) lower `if (x)` to the ConditionExpr `x != 0`, which
272+
// is NOT equivalent to JS loose inequality for non-number operands
273+
// (`[] != 0` is false in JS, yet `[]` is truthy). The IR is ambiguous:
274+
// a genuine source-level `x != 0` produces the same shape. We follow the
275+
// IR contract: compare-to-zero on a non-number operand means ToBoolean.
276+
is EtsEqExpr ->
277+
if (isZeroConstant(e.right)) {
278+
val l = eval(e.left, frame)
279+
if (l is VNumber) VBool(l.value == 0.0) else VBool(!JsSemantics.truthy(l))
280+
} else {
281+
VBool(JsSemantics.looseEq(eval(e.left, frame), eval(e.right, frame)))
282+
}
283+
284+
is EtsNotEqExpr ->
285+
if (isZeroConstant(e.right)) {
286+
val l = eval(e.left, frame)
287+
if (l is VNumber) VBool(l.value != 0.0) else VBool(JsSemantics.truthy(l))
288+
} else {
289+
VBool(!JsSemantics.looseEq(eval(e.left, frame), eval(e.right, frame)))
290+
}
271291
is EtsStrictEqExpr -> VBool(JsSemantics.strictEq(eval(e.left, frame), eval(e.right, frame)))
272292
is EtsStrictNotEqExpr -> VBool(!JsSemantics.strictEq(eval(e.left, frame), eval(e.right, frame)))
273293
is EtsLtExpr -> VBool(JsSemantics.lt(eval(e.left, frame), eval(e.right, frame)))
@@ -302,6 +322,9 @@ class EtsConcreteInterpreter(
302322
private fun parameterIndexOfLocal(frame: Frame, name: String): Int? =
303323
frame.method.parameters.firstOrNull { it.name == name }?.index
304324

325+
private fun isZeroConstant(e: EtsEntity): Boolean =
326+
e is EtsNumberConstant && e.value == 0.0
327+
305328
private inline fun numeric(
306329
left: EtsEntity,
307330
right: EtsEntity,

usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/interpreter/ConcreteVsSymbolicDifferentialTest.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ class ConcreteVsSymbolicDifferentialTest {
6767
* relational operators per sort pair (`Lt.onBool = !lhs && rhs`, see
6868
* TsBinaryOperator.Lt) instead of the JS ToNumber coercion, so e.g.
6969
* `false < 0.0` is reported `true` while JS gives `0 < 0 === false`.
70+
* - `And.andOfUnknown`: frontends lower `if (x)` to the idiom `x != 0`; the
71+
* engine evaluates it numerically, so for `x = undefined` it gets
72+
* `ToNumber(undefined) = NaN != 0 -> true` while `undefined` is falsy in JS
73+
* (the NaN hole of the compare-to-zero truthiness contract).
74+
* - `TypeCoercion.transitiveCoercionNoTypes`: doubly ambiguous — the *genuine*
75+
* source-level `c != 0` loose comparison is indistinguishable in the IR from
76+
* the truthiness idiom (the concrete interpreter follows the idiom contract),
77+
* and the engine's `&&` on references diverges from JS anyway
78+
* (JS gives 2, engine 1, concrete 4).
7079
*
7180
* NOTE: requires the CI-pinned ArkAnalyzer (`neo/2025-09-03`). Older/newer AA
7281
* builds may emit a different if-successor order in the DTO, which inverts
@@ -75,6 +84,8 @@ class ConcreteVsSymbolicDifferentialTest {
7584
private val knownEngineDivergences: Set<String> = setOf(
7685
"Add.addUnknownValues",
7786
"Less.lessUnknown",
87+
"And.andOfUnknown",
88+
"TypeCoercion.transitiveCoercionNoTypes",
7889
)
7990

8091
private fun runDifferential(resourcePath: String, className: String): Verdict {

0 commit comments

Comments
 (0)