diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 74bd071a..378902ed 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -43,7 +43,7 @@ all of them.
┌───────── Frontends (in-process Go, one binary) ─────────┐
Go ────► x/tools SSA ───┐
Python ──► python3 ast ──┤
- JS ────► goja AST ──┤
+ JS ────► esbuild AST ──┤
Java ────► JVM bytecode ──┤─► lower ─► gIR (core + intrinsics, canonical FQNs)
Rust ────► rustc MIR ──┤ │
Ruby ────► ruby Ripper ──┤ │
@@ -177,14 +177,16 @@ Python/Java/Rust/Ruby shelling out to a toolchain on `PATH`.
lives in `http.HandleFunc` closures. Emits `go:` names.
- **Python** (`converters/python/`) — shells out to `python3` for an `ast` JSON
dump, then lowers it to a real CFG (`ssabuild`). Emits `py:` names; requires `python3`.
-- **JavaScript** (`converters/javascript/`) — pure-Go parse via **goja**, then
- lowers. TS/JSX/ESM are stripped/lowered in-process by esbuild (no Node), with source
- maps remapping positions back; a plain `.js` is handed to goja first and only falls
- back to esbuild when the parse fails, since one extension covers four dialects.
- Flow, which esbuild cannot load at all, is blanked in place beforehand
- (`flowstrip.go`) so byte offsets — and therefore positions — are preserved. `.vue`/`.svelte` SFCs are also
- handled (`sfc.go`): the `
+
+
+ rendered
+
diff --git a/test/js/crossfile_sfc_import/app.js b/test/js/crossfile_sfc_import/app.js
new file mode 100644
index 00000000..da083ae5
--- /dev/null
+++ b/test/js/crossfile_sfc_import/app.js
@@ -0,0 +1,13 @@
+// Cross-file taint into a sink inside a .vue component, imported by a specifier
+// that keeps its extension (see expected.yaml).
+import express from "express";
+import render from "./Renderer.vue";
+
+const app = express();
+
+app.get("/r", function (req, res) {
+ const id = req.query.id; // source
+ res.send(render("SELECT * FROM users WHERE id = " + id)); // cross-file call into Renderer.vue
+});
+
+export default app;
diff --git a/test/js/crossfile_sfc_import/expected.yaml b/test/js/crossfile_sfc_import/expected.yaml
new file mode 100644
index 00000000..32490b60
--- /dev/null
+++ b/test/js/crossfile_sfc_import/expected.yaml
@@ -0,0 +1,10 @@
+# Expected findings for this sample (see test/README.md).
+# Source in app.js, sink in Renderer.vue: fires only if `./Renderer.vue` resolves
+# to the module the walk named `Renderer`. When it does not, both files still
+# convert and coverage still reads ok, so the file+line pin is the only alarm.
+findings:
+ - rule: js-sqli
+ min: 1
+ max: 1
+ line: 6
+ sink: "js:some-db.query"
diff --git a/test/js/decorators_for_await/expected.yaml b/test/js/decorators_for_await/expected.yaml
new file mode 100644
index 00000000..9d97682c
--- /dev/null
+++ b/test/js/decorators_for_await/expected.yaml
@@ -0,0 +1,9 @@
+# Expected findings for this sample (see test/README.md).
+# A file whose syntax no single ladder rung reads by extension. Both flows are
+# intra-procedural, so a count of 2 means the whole file parsed -- this pins
+# dialect coverage and nothing else. If it drops to 0, a rung stopped matching.
+findings:
+ - rule: js-code-injection
+ min: 2
+ max: 2
+ sink: "js:eval"
diff --git a/test/js/goja_gaps/service.js b/test/js/decorators_for_await/service.js
similarity index 51%
rename from test/js/goja_gaps/service.js
rename to test/js/decorators_for_await/service.js
index d63802e3..f077bc75 100644
--- a/test/js/goja_gaps/service.js
+++ b/test/js/decorators_for_await/service.js
@@ -1,7 +1,7 @@
-// Decorators and `for await` are the two constructs goja's parser cannot spell,
-// and a .js file carrying either was previously lost WHOLE -- not analyzed with
-// reduced precision, but skipped. This sample is the end-to-end proof that the
-// esbuild downlevel recovers the flows inside them.
+// Decorators, `for await` and ESM syntax in one .js file: no single rung of the
+// dialect ladder reads all three by extension, so this pins that the ladder
+// keeps trying until one does. Both flows are intra-procedural on purpose --
+// the assertion is that the file is analyzed AT ALL, not how deeply.
import Service from '@ember/service';
export default class SessionService extends Service {
diff --git a/test/js/esm_module/app.js b/test/js/esm_module/app.js
index ebbb43ef..0abb50f7 100644
--- a/test/js/esm_module/app.js
+++ b/test/js/esm_module/app.js
@@ -1,7 +1,7 @@
// ES-module syntax in a plain .js file, as Babel-transpiled Node projects and
-// anything with "type": "module" write it. goja cannot parse the import, so
-// judging the esbuild path by extension alone dropped the whole file -- and the
-// language still reported coverage=ok because other files in the tree converted.
+// anything with "type": "module" write it. The extension says nothing about the
+// dialect, so only trying the ladder's rungs finds this one -- and losing the
+// file would be SILENT, since a sibling converting still reports coverage=ok.
import express from "express";
import { exec } from "child_process";
diff --git a/test/js/esmodule/app.mjs b/test/js/esmodule/app.mjs
index ab9136bd..b134051a 100644
--- a/test/js/esmodule/app.mjs
+++ b/test/js/esmodule/app.mjs
@@ -1,7 +1,6 @@
-// ES module (import/export) command injection: goja cannot parse top-level
-// import, so esbuild lowers it to CommonJS first. The named-import call
-// execSync(cmd) becomes an interop call (0, import_child_process.execSync)(cmd);
-// the sink must still be recognized.
+// ES module command injection. A named import binds its sink through the
+// import-alias table (aliases.go), not through a require, so the call must still
+// canonicalize to js:child_process.execSync.
import { execSync } from "child_process";
export function run(req) {
diff --git a/test/js/goja_gaps/expected.yaml b/test/js/goja_gaps/expected.yaml
deleted file mode 100644
index c4521bb3..00000000
--- a/test/js/goja_gaps/expected.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Expected findings for this sample (see test/README.md).
-# Both flows sit in a file goja cannot parse, so before the downlevel this file
-# contributed NOTHING -- the assertion is that it is analyzed at all. Precision
-# inside a downleveled class is reduced (__decorateClass breaks method-to-method
-# resolution), which is why the flows here are intra-procedural: they pin the
-# recovery without pinning the loss.
-findings:
- - rule: js-code-injection
- min: 2
- max: 2
- sink: "js:eval"
diff --git a/test/js/loop_header_callback/app.js b/test/js/loop_header_callback/app.js
new file mode 100644
index 00000000..a4e60c29
--- /dev/null
+++ b/test/js/loop_header_callback/app.js
@@ -0,0 +1,15 @@
+// Command injection reached from a function literal in a loop HEADER, not its
+// body (see expected.yaml).
+const { exec } = require("child_process");
+
+function handleBatch(req, res) {
+ for (const row of req.query.rows.filter((v) => v.ok)) {
+ exec(row.cmd);
+ }
+ for (let i = 0, run = (c) => exec(c); i < 1; i++) {
+ run(req.query.cmd);
+ }
+ res.send("ok");
+}
+
+module.exports = handleBatch;
diff --git a/test/js/loop_header_callback/expected.yaml b/test/js/loop_header_callback/expected.yaml
new file mode 100644
index 00000000..a2320804
--- /dev/null
+++ b/test/js/loop_header_callback/expected.yaml
@@ -0,0 +1,10 @@
+# Expected findings for this sample (see test/README.md).
+# The flow is ordinary; what this pins is that the loop HEADERS are collected. If
+# they are not, this finding still fires -- the header literals just go unnamed
+# and lower to js.unsupported, which only TestNoUnsupportedInstructions sees.
+findings:
+ - rule: js-command-injection
+ min: 1
+ max: 1
+ line: 7
+ sink: "js:child_process.exec"
diff --git a/test/js/typescript/app.ts b/test/js/typescript/app.ts
index 86b8fa5e..932a8fa7 100644
--- a/test/js/typescript/app.ts
+++ b/test/js/typescript/app.ts
@@ -1,6 +1,6 @@
// TypeScript command injection: type annotations (: string, : void, the Request
-// interface) must be stripped by esbuild so goja can parse it, and the finding
-// must point at THIS .ts file at the correct line.
+// interface) are erased by the ladder's TS rung, and the finding must still point
+// at THIS .ts file at the correct line -- an erased annotation shifts no offset.
const cp = require("child_process");
interface Req {