diff --git a/.gitignore b/.gitignore
index c22b97610..941e685b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,6 +44,8 @@ node_modules
!/benches/js/results/report.conformance.bun.md
!/benches/js/results/report.json
!/benches/js/results/report.md
+!/benches/js/results/report.tsc-conformance.json
+!/benches/js/results/report.tsc-conformance.md
# Env
.env*
diff --git a/CLAUDE.md b/CLAUDE.md
index 8d7f0a915..9d371a3d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -151,7 +151,7 @@ cargo run -p tsv_cli format --content '
x
' --parser svelte # forma
```bash
deno task check # full committed-tree gate: fmt, audits, typecheck, tests, clippy (benches/js/CLAUDE.md §Gate map)
-deno task doctor # one-pass setup check: runtimes, canonical pins + checkout alignment, node_modules freshness, oracle checkouts, corpus entries, build artifacts. Exit 1 only on MISLEADING state (pin drift, skew, stale deps); absences are warnings (--strict promotes warnings to failures)
+deno task doctor # one-pass setup check: runtimes, canonical pins + checkout alignment, node_modules freshness, oracle checkouts, corpus entries, build artifacts. Exit 1 only on MISLEADING state (pin drift, skew, stale deps); absences are warnings (--strict promotes warnings to failures) — except in the explicitly optional experimental-typechecker tier, whose absences are informational at any strictness (a BROKEN checkout there still warns)
deno task typecheck # cargo check
deno task test # cargo test
deno task lint # cargo clippy
@@ -331,11 +331,11 @@ deno task conformance:ts-repo # tsv's TS parser vs the tsc corpus (../t
# The three gates above accept: -v, --json, .
-deno task conformance # the pre-release aggregate: the three gates above + corpus:compare:parse
-# --all + corpus:compare:format --all, in ONE process (benches/js/conformance.ts; oracle modules load
-# once, fail-fast, corpus FFI built once), then render:audit over the version-pinned checkouts (a
-# subprocess — it drives its own sidecar). The external-oracle correctness gates that can't live in
-# `deno task check`. The format leg's prettier calls ride a content-addressed cache
+deno task conformance # the pre-release aggregate: the three gates above +
+# corpus:compare:parse --all + corpus:compare:format --all, in ONE process (benches/js/conformance.ts;
+# oracle modules load once, fail-fast, corpus FFI built once), then render:audit over the version-pinned
+# checkouts (a subprocess — it drives its own sidecar). The external-oracle correctness gates that
+# can't live in `deno task check`. The format leg's prettier calls ride a content-addressed cache
# (benches/js/lib/prettier_cache.ts; TSV_PRETTIER_CACHE=0 disables).
deno task conformance:test262 # tsv's JS parser vs test262 POSITIVES (pure Rust, `test262 --gate`);
@@ -514,6 +514,7 @@ tsv/
│ ├── tsv_css/ # CSS: parse(), format(), convert_ast_json_bytes()
│ ├── tsv_svelte/ # Svelte: parse(), format(), convert_ast_json_bytes()
│ ├── tsv_svelte_compile/ # Svelte→JS compiler (Svelte's compile() oracle) + JS canonicalizer (intent-erased reprint); consumed by tsv_debug — no shipped artifact links it
+│ ├── tsv_check/ # EXPERIMENTAL TypeScript binder + checker — may never ship (tsgo-conformance target; consumed only by tsv_debug — no shipped artifact links it)
│ ├── tsv_cli/ # Production CLI (binary: tsv) - pure Rust
│ ├── tsv_debug/ # Dev utilities (binary: tsv_debug) - uses Deno
│ ├── tsv_ffi/ # C FFI bindings (Deno's native path)
@@ -867,10 +868,28 @@ cargo run -p tsv_debug test262 language/expressions # filter by path pattern
See ./docs/conformance_test262.md (command interface; §Differential for the tsv-vs-oxc comparison).
+**Typechecker conformance (`tsc_conformance`) — EXPERIMENTAL, may never ship.**
+`tsv_check` is a from-scratch TypeScript binder + checker in development; no shipped
+artifact links it (`cargo tree -i tsv_check` → only `tsv_debug`), and the parser and
+formatter are never modified in service of it. `tsv_debug tsc_conformance` grades it
+against tsgo's committed `.errors.txt` baselines (`../typescript-go`, pin `168e7015`),
+surfaced as **on-demand** tasks:
+
+```bash
+deno task conformance:tsc-roundtrip # baseline parse → re-render → byte-compare (zero checker code)
+deno task conformance:tsc-check # the tsv_check conformance sweep + committed report
+deno task conformance:tsc-check:update # re-pin the run's snapshot counts after deliberate drift
+```
+
+None is in `deno task check`, in `deno task conformance`, or release-gating, and
+`../typescript-go` is not a release-required oracle — until the typechecker ships, no
+ordinary dev or release flow pays for it. Full reference: ./docs/typechecker.md.
+
**Performance Profiling Commands** (all pure Rust, no Deno — full reference: ./docs/performance.md):
```bash
cargo run -p tsv_debug profile ~/dev/zzz/src/lib # parse vs format phase timing (--iterations, --json)
+cargo run -p tsv_debug profile --bind ~/dev/zzz/src # parse vs lower+bind timing (TS-only) + peak RSS (§1)
cargo run --release -p tsv_debug -- json_profile ~/dev/zzz/src/lib # FFI parse path: parse vs the wire-JSON write (§2)
cargo run -p tsv_debug buffer_sizes ~/dev/zzz/src ~/dev/gro/src # printer SmallVec sizing histograms (§8)
cargo run -p tsv_debug arena_stats ~/dev/zzz/src/lib # DocArena node-population + memory audit (§7; --reuse, --list-errors)
@@ -1059,13 +1078,13 @@ cases; prettier, oxfmt and biome all get the JSDoc-cast paren binding wrong —
### Rust Crates (minimal deps)
- `serde_json` — wire-JSON emission: the writer's exact string-escape / `f64` formatting, and reparsing bytes to a `Value` (CLI `--pretty`, tests). The language crates no longer depend on `serde` directly (only transitively, without its `derive`); `serde`'s derive is dev-tooling only (`tsv_debug` / `tsv_cli`)
-- `smallvec` — Stack-allocated vectors
+- `smallvec` — Stack-allocated vectors (printers + `tsv_check`)
- `thiserror` — Error type derivation
- `phf` — Compile-time perfect hash maps (keywords, entities)
- `unicode-ident` — Unicode XID_Start/XID_Continue for identifiers
- `unicode-segmentation` — Grapheme clustering for visual width measurement
- `unicode-width` — Character display width (CJK, zero-width)
-- `bumpalo` — Bump arena for the internal AST (and, via the `tsv_arena` crate, the bindings' per-thread `reset()` reuse — `tsv_ffi`/`tsv_napi`/`tsv_wasm`)
+- `bumpalo` — Bump arena for the internal AST (and, via the `tsv_arena` crate, the bindings' per-thread `reset()` reuse — `tsv_ffi`/`tsv_napi`/`tsv_wasm`; `tsv_check`'s caller-owned check arenas follow the same contract)
- `talc` — WASM global allocator (`tsv_wasm` only, wasm32-only target dep): pure-Rust `no_std` allocator replacing std's default dlmalloc; the `WasmGrowAndExtend` source keeps the warm instance's linear-memory high-water at dlmalloc parity. Pulls `lock_api` + `allocator-api2` (+ `scopeguard`) into the wasm32 graph only; native builds unaffected
- `napi` / `napi-derive` / `napi-build` — N-API bindings for `tsv_napi` (Node/Bun native addon; tsv-scoped carve-out)
@@ -1111,6 +1130,7 @@ formatting behavior. Key files: `src/language-js/print/assignment.js` (assignmen
- ./docs/comments.md - the detached comment model: ownership, the three axes, hazards, emitters
- ./docs/compile_tooling.md - the sidecar-dependent compiler harnesses: corpus compare, compile fuzz, erase census
- ./docs/compile_validation_ratchet.md - the validation-suite ratchet: snapshot, kinds, verdict, triage
+- ./docs/typechecker.md - the experimental `tsv_check` typechecker (may never ship) + its on-demand tsgo-conformance harness
- ./docs/performance.md - profiling methodology, tooling, and results tracking
- ./docs/workflow_corpus.md - corpus-driven formatting conformance workflow
- ./docs/workflow_test262.md - test262 conformance workflow
diff --git a/Cargo.lock b/Cargo.lock
index 1255eee43..c504da1b9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -642,6 +642,16 @@ dependencies = [
"tsv_lang",
]
+[[package]]
+name = "tsv_check"
+version = "0.2.0"
+dependencies = [
+ "bumpalo",
+ "smallvec",
+ "tsv_lang",
+ "tsv_ts",
+]
+
[[package]]
name = "tsv_cli"
version = "0.2.0"
@@ -681,6 +691,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
+ "tsv_check",
"tsv_cli",
"tsv_css",
"tsv_html",
diff --git a/Cargo.toml b/Cargo.toml
index a373998a0..6e93e8e62 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ members = [
"crates/tsv_css",
"crates/tsv_svelte",
"crates/tsv_svelte_compile",
+ "crates/tsv_check",
"crates/tsv_cli",
"crates/tsv_debug",
"crates/tsv_wasm",
@@ -49,6 +50,11 @@ tsv_svelte = { path = "crates/tsv_svelte", default-features = false }
# no shipped artifact links it yet.
tsv_svelte_compile = { path = "crates/tsv_svelte_compile" }
+# Experimental TypeScript type-checking. A consumer crate of tsv_ts's concrete
+# types; referenced only by the tsv_debug harness so it never touches the
+# shipped format/parse artifacts (a crate-boundary zero-cost exclusion).
+tsv_check = { path = "crates/tsv_check" }
+
# External dependencies
argh = "0.1"
# N-API (Node.js/Bun) bindings — the native path for the `tsv_napi` crate, the
diff --git a/README.md b/README.md
index 1130f4f6a..c74c035ee 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ Future features (unknown order):
- svelte-check replacement
- LSP
- linter - type aware, initially focused on serializable data-only plugins for extensibility
+ - includes an experimental first-party typechecker `tsv_check` (which may never ship)
- bundling is probably out of scope
- [discussion](https://github.com/fuzdev/tsv/discussions) welcome
@@ -260,6 +261,7 @@ tsv/
│ ├── tsv_ts/ # TypeScript parser/formatter (standalone)
│ ├── tsv_css/ # CSS parser/formatter (standalone)
│ ├── tsv_svelte/ # Svelte parser/formatter (uses tsv_ts + tsv_css)
+│ ├── tsv_check/ # experimental TypeScript binder/checker (may never ship; not in any shipped artifact)
│ ├── tsv_cli/ # unified CLI (binary: `tsv`)
│ ├── tsv_debug/ # dev utilities (binary: `tsv_debug`, uses Deno)
│ ├── tsv_ffi/ # C FFI bindings
diff --git a/benches/js/CLAUDE.md b/benches/js/CLAUDE.md
index 03fbb5940..e73430835 100644
--- a/benches/js/CLAUDE.md
+++ b/benches/js/CLAUDE.md
@@ -454,23 +454,27 @@ green-skipping (the baselines are the oracle; publish Step 3b's probe is the
tolerance point). Full-corpus runs freshness-check `KNOWN_GAPS` (stale entries fail).
**Pre-release aggregate — `deno task conformance` (+ `conformance:test262` = `conformance:all`).** The three parse-conformance
-gates (svelte-fixtures, ts-fixtures, ts-repo), plus
-`corpus:compare:parse --all` and `corpus:compare:format --all`, are the
-release-cadence correctness gates that run against external JS oracles (and so can't
-live in `deno task check`). `deno task conformance:all` runs the pure-Rust **test262
+gates (svelte-fixtures, ts-fixtures, ts-repo), plus `corpus:compare:parse --all`
+and `corpus:compare:format --all`, are the release-cadence correctness gates that
+run against external oracles (and so can't live in `deno task check`). The
+typechecker's `tsc_conformance` tasks are deliberately NOT legs here — `tsv_check`
+is experimental and may never ship, so its gates stay on-demand (see
+../../docs/typechecker.md). `deno task conformance:all` runs the pure-Rust **test262
positive gate** FIRST (`conformance:test262` — `tsv_debug test262 --gate`, gating the
exact positive-pass count; the ~2.5k negatives are the deferred early-error frontier,
reported not gated), THEN the aggregate — fail-fast, so a positive-parse regression
trips the ~1-min gate before the multi-minute FFI legs run. That superset is what
publish Step 3b runs. `deno task conformance` builds the corpus FFI once and
-runs all five legs in **ONE process** (`conformance.ts`, the driver): the canonical
+runs all six legs in **ONE process** (`conformance.ts`, the driver): the canonical
oracle modules (prettier, the svelte plugin, svelte/compiler, acorn, acorn-ts) load
-once via the module cache instead of once per leg, each leg gets a timing line, and
-failure semantics match a `&&` chain exactly (every leg exits the process on a
-finding — fail-fast). The driver takes no arguments; the per-leg tasks remain the
-scoped/triage entries. `conformance:all` is wired into `scripts/publish.ts` **Step 3b**
-(skipped by `--no-check`). Step 3b preflights the oracles (`../svelte`,
-`../acorn-typescript`, `../typescript`, `../test262`, this dir's `node_modules`): a missing one
+once via the module cache instead of once per leg (`render:audit`, the lone non-JS
+leg, is a `cargo` subprocess — which is why the task carries `--allow-run=cargo`),
+each leg gets a timing line, and failure semantics match a `&&` chain exactly
+(every leg exits the process on a finding — fail-fast). The driver takes no
+arguments; the per-leg tasks remain the scoped/triage entries. `conformance:all`
+is wired into `scripts/publish.ts` **Step 3b** (skipped by `--no-check`). Step 3b
+preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`,
+`../test262`, this dir's `node_modules`): a missing one
**FAILS a `--wetrun`** (only the explicit `--no-check` releases without gates),
warn-and-skips a dry-run, and any skip is re-warned in the run's final summary.
The gates themselves fail closed on a missing checkout (0 scanned = FAIL), so a
@@ -510,6 +514,17 @@ every real move in a number is a deliberate, visible edit.
test262 (discovered + graded-manifest), `fixtures_validate` (total fixtures —
protects the primary gate against a discovery collapse), and `swallow_audit`
(formatted files — closes its vacuous-pass).
+- `tsc_conformance` (the largest set) splits its pins by what they mean, and
+ gates the ON-DEMAND experimental-typechecker tasks, not a release leg. The
+ drifting tsv-side counts (denominators, parse-divergence census, family
+ partitions, carve-outs) live in the machine-regenerated snapshot
+ `crates/tsv_debug/src/cli/commands/tsc_conformance_pins.txt`, rewritten by
+ `deno task conformance:tsc-check:update`. The oracle-side pins
+ (baseline/roundtrip/pretty + the `INDEX_*` denominators) and the
+ semantically-zero invariant gates stay hand-edited consts in
+ `cli/commands/tsc_conformance/pins.rs`; the crash-exclusion count sits beside its
+ ledger in `tsc_conformance/runner/grade.rs`. Re-pin ritual:
+ ../../docs/typechecker.md.
**Semantics — three pin categories, chosen per surface:**
@@ -793,9 +808,9 @@ deno task bench:deno:run -- --compare-baseline # Compare against saved baseline
# Wipe local-only bench state (gitignored): baseline.json, timestamped
# results pairs, and the harvest caches (benches/js/.cache). Preserves the
-# committed `report..{json,md}` / `report.conformance.node.*`
-# because the glob is anchored on a leading digit (timestamped files start
-# with a year).
+# committed `report..{json,md}` / `report.conformance.node.*` /
+# `report.tsc-conformance.{json,md}` because the glob is anchored on a
+# leading digit (timestamped files start with a year).
deno task bench:clean
# Environment variables (apply to any runtime's :run)
@@ -1232,7 +1247,7 @@ benches/js/
├── install_deps.ts # `bench:install`: npm install + force-fetch the oxc wasi binding
├── harvest_test262.ts # `bench:harvest:test262`: graded positives → .cache/test262_files.json (Deno-only)
├── bench.ts # Benchmark entry point (runtime-neutral — runs under Deno AND Node)
-├── conformance.ts # Single-process pre-release aggregate driver (deno task conformance): all five legs, one module cache
+├── conformance.ts # Single-process pre-release aggregate driver (deno task conformance): all six legs, one module cache
├── smoke.ts # Smoke test for formatters and parsers (runtime-neutral: smoke / smoke:node / smoke:bun)
├── compose_reports.ts # Fold report.{deno,node,bun}.json → combined report.{json,md} (bench:compose)
├── idempotency_sweep.ts # F1 sweep over the `perf` corpus view — format(format(x)) == format(x) on real code (deno task idempotency:sweep; drives tsv_debug `fuzz --iterations 0`)
diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json
new file mode 100644
index 000000000..34563572e
--- /dev/null
+++ b/benches/js/results/report.tsc-conformance.json
@@ -0,0 +1,104 @@
+{
+ "oracle": "tsgo committed .errors.txt baselines (bind + merge + flow family)",
+ "denominators": {
+ "in_scope_tests": 9389,
+ "in_scope_variants": 9888,
+ "expect_clean_graded": 4436,
+ "clean_pass": 4436,
+ "baselined_parsed": 4464,
+ "family_graded_variants": 4085,
+ "family_positive_variants": 140
+ },
+ "family": {
+ "match": 573,
+ "dup_match": 539,
+ "flow_match": 34,
+ "missing": {
+ "total": 37,
+ "dup": 11,
+ "flow": 26,
+ "merge_path": 0,
+ "lib_conflict": 0,
+ "late_bound": 11,
+ "cfa": 26,
+ "other": 0
+ },
+ "extra": 0,
+ "span_mismatch": 0
+ },
+ "per_code": {
+ "match": {
+ "2300": 415,
+ "2397": 4,
+ "2451": 56,
+ "2528": 35,
+ "2567": 26,
+ "2664": 3,
+ "7027": 31,
+ "7028": 3
+ },
+ "missing": {
+ "2300": 11,
+ "7027": 26
+ }
+ },
+ "related": {
+ "match": 51,
+ "missing": 0,
+ "extra": 0,
+ "span_mismatch": 0
+ },
+ "carve_outs": {
+ "recovery_ast_rule_a": 379,
+ "recovery_ast_rule_a_family": 11,
+ "module_detection_variants": 1
+ },
+ "census": {
+ "parse_rejected_total": 988,
+ "parse_rejected_no_baseline": 44,
+ "parse_rejected_ts1xxx_only": 456,
+ "parse_rejected_other": 488,
+ "script_retry": 25,
+ "crash_excluded": 0
+ },
+ "lib": {
+ "files_bound": 107,
+ "sets_folded": 50
+ },
+ "pins": {
+ "in_scope_tests": 9389,
+ "in_scope_variants": 9888,
+ "expect_clean": 4436,
+ "baselined_parsed": 4464,
+ "parse_rejected": 988,
+ "parse_rejected_no_baseline": 44,
+ "parse_rejected_ts1xxx_only": 456,
+ "parse_rejected_other": 488,
+ "family_graded": 4085,
+ "family_positive": 140,
+ "family_match": 573,
+ "family_missing": 37,
+ "dup_match": 539,
+ "dup_missing": 11,
+ "flow_match": 34,
+ "flow_missing": 26,
+ "missing_merge": 0,
+ "missing_lib": 0,
+ "missing_deferred_late_bound": 11,
+ "missing_deferred_cfa": 26,
+ "missing_other": 0,
+ "family_extra": 0,
+ "family_span_mismatch": 0,
+ "related_match": 51,
+ "related_missing": 0,
+ "related_extra": 0,
+ "related_span_mismatch": 0,
+ "carve_out_rule_a": 379,
+ "carve_out_rule_a_family": 11,
+ "module_detection": 1,
+ "script_retry": 25,
+ "crash_excluded": 0,
+ "lib_files_bound": 107,
+ "lib_sets": 50
+ }
+}
diff --git a/benches/js/results/report.tsc-conformance.md b/benches/js/results/report.tsc-conformance.md
new file mode 100644
index 000000000..512a6ac32
--- /dev/null
+++ b/benches/js/results/report.tsc-conformance.md
@@ -0,0 +1,51 @@
+# tsc_conformance run — committed report
+
+Oracle: tsgo committed `.errors.txt` baselines (bind + merge + flow family). Deterministic — wall-clock excluded.
+
+## Denominators
+
+- in-scope tests: 9389
+- in-scope variants: 9888
+- expect-clean graded / clean pass: 4436 / 4436
+- baselined + parsed: 4464
+- family graded / family-positive: 4085 / 140
+
+## Family (dup 2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671; flow 7027 / 7028)
+
+- match: 573 (dup 539, flow 34)
+- missing: 37 (dup 11, flow 26)
+ - by cause: merge-path 0, lib-conflict 0, late-bound 11, cfa 26, other 0
+- extra (GATE=0): 0
+- span mismatch: 0
+
+## Per-code table
+
+| code | match | missing |
+| --- | --- | --- |
+| TS2300 | 415 | 11 |
+| TS2397 | 4 | 0 |
+| TS2451 | 56 | 0 |
+| TS2528 | 35 | 0 |
+| TS2567 | 26 | 0 |
+| TS2664 | 3 | 0 |
+| TS7027 | 31 | 26 |
+| TS7028 | 3 | 0 |
+
+## Related-info channel (matched primaries)
+
+- match / missing / extra / span-mismatch: 51 / 0 / 0 / 0
+
+## Carve-outs
+
+- recovery-AST rule (a): 379 (family-positive 11)
+- moduleDetection variants (inert for family): 1
+
+## Parse-divergence census
+
+- parse-rejected: 988 (no baseline 44, TS1xxx-only 456, other 488)
+- script-goal retries: 25
+- crash-excluded (tracked): 0
+
+## Lib base
+
+- lib files bound / sets folded: 107 / 50
diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md
new file mode 100644
index 000000000..3e1b26a2e
--- /dev/null
+++ b/crates/tsv_check/CLAUDE.md
@@ -0,0 +1,266 @@
+# tsv_check
+
+> **EXPERIMENTAL — may never ship.** TypeScript binder + checker targeting
+> exact TS7/tsgo error conformance — early scaffolding: the pipeline skeleton
+> is real (parse → lower+bind → check → sort/dedup), the semantic phases are
+> landing family by family.
+
+## Position & invariants
+
+- **Experimental, and it may never ship.** This is a research crate, not a
+ committed product surface. Nothing tsv publishes depends on it, its
+ conformance gates are on-demand only (not in `deno task check`, not in
+ `deno task conformance`, not release-gating — see ../../docs/typechecker.md),
+ and the bet is allowed to come out negative.
+- **Zero cost to shipped artifacts.** No format/parse artifact links this
+ crate — `tsv_cli`/`tsv_ffi`/`tsv_wasm`/`tsv_napi` never reference it; the
+ only consumer is `tsv_debug` (the conformance harness). Verify with
+ `cargo tree -i tsv_check`. The existing parser and formatter are never
+ modified in service of checking.
+- **Faithful semantics, novel engine.** Observable behavior (which
+ diagnostics exist, their codes/spans/order, budget limits, circularity
+ outcomes) is ported from tsgo — the reference checkout is a pinned
+ `../typescript-go` — while representation (dense u32 ids, SoA side
+ columns, arena borrowing) is tsv's own.
+- **Reference anchors.** Semantic-core functions carry a
+ `// tsgo: ` comment tying them to their counterpart
+ in the pinned checkout. A deliberate structural departure is documented
+ at the departure site; drift from the reference is always intentional,
+ never incidental.
+- **The oracle is tsgo's committed `.errors.txt` baselines** over the tsc
+ test corpus, graded by `tsv_debug tsc_conformance` (see
+ ../../docs/typechecker.md).
+- `unsafe_code = "forbid"` (workspace lints inherited).
+
+## Module map
+
+- `lib.rs` — public API surface.
+- `program.rs` — pipeline assembly, ported from tsgo
+ `harnessutil.go CompileFilesEx` (the baseline-oracle **parity path**):
+ per-unit parse with the goal rule (`Goal::Module` first, `Goal::Script`
+ retry on failure, both-fail = that unit parse-rejects), then the
+ **unconditional** per-unit bind-then-check concatenation — a rejected unit
+ contributes nothing (no AST), but never suppresses a sibling's
+ diagnostics — and the final sort+dedup. The **product-mode short-circuit**
+ (`program.go GetDiagnosticsOfAnyProgram`: syntactic errors ⇒ skip semantic
+ program-wide) is a deliberate mode distinction deferred to the `tsv check`
+ path, NOT modelled here (see the module header).
+- `merge.rs` — the single-threaded globals merge between bind and check,
+ ported from tsgo `initializeChecker`'s phase order (script locals +
+ globalThis check → global augmentations → undefined check → deferred
+ ambient modules → module augmentations), with `mergeSymbol` /
+ `reportMergeSymbolError` (the same three-way conflict selection as the
+ binder cascade), `getExcludedSymbolFlags`, and the `lookupOrIssueError`
+ dedup. Operates on scripts' file locals and `declare global` /
+ ambient-module augmentation exports — never an external module's locals.
+ Per-file `FileMerge` inputs are program-independent (owned strings,
+ declaration-order iteration — never map order). Also owns the lib layer:
+ `LibFile` (a lib's bound product), `LibBase::build` (fold a resolved lib
+ set in priority order into an immutable global base, `globalThis`
+ seeded), and the base-aware merge (`merge_symbol_against_base` — programs
+ consult overlay-then-base; test symbols never mutate the base; base decls
+ translate into program FileId space only on a conflict).
+- `binder/` — **two cooperating walks per file** (deliberately NOT one
+ fused walk — functions-first symbol binding reorders symbol creation
+ within a statement list, which would break strict pre-order id
+ intervals; see `binder/mod.rs`'s header), plus a **third flow walk**
+ (`flow/`) invoked separately from `program.rs`:
+ - `mod.rs` — the SoA lowering walk: dense 1-based `NodeId`s, side columns
+ (`parents`/`kinds`/`spans`/`subtree_end` — the latter makes descendant
+ tests O(1) interval checks), the address→NodeId map, per-file facts
+ (module-ness via the `isAnExternalModuleIndicatorNode` port). The
+ `SoaWalk` struct, its `add`/`close`/`leaf` id-recording primitives, and
+ `bind_file` live here; the per-node visitor methods live in `lower/`
+ (`statement.rs`/`expression.rs`/`types.rs` — additional `impl SoaWalk`
+ blocks, split by the AST shape each visitor descends), unchanged in
+ responsibility.
+ - `sym/` — the container-threaded, functions-first symbol-bind walk:
+ `getContainerFlags`, `declareSymbolEx` + the duplicate/conflict cascade
+ (TS2451/2300/2567/2528 with per-prior-declaration related info),
+ internal-name mangling (incl. private `#` names), the dual local/export
+ collapse (documented at the site; revisited at multi-file). A
+ directory-module split by concern (unchanged responsibilities): `mod.rs`
+ (the `SymbolBinder` struct, its lifecycle — `new`/`bind_program`/`finish`
+ — the table/symbol/atom primitives every descendant shares, the
+ member-key resolver, the functions-first statement-list driver
+ (`bind_statement_list`), and the scope helpers
+ (`with_function_scope`/`with_block_scope`) every descendant calls into),
+ `statement.rs` (the statement-shaped bind-descent — `visit_statement` and
+ every `bind_*_statement`, the param/binding helpers, and the
+ module/import/export-specifier binds), `expression.rs`
+ (`visit_expression` and `bind_object_expression`), `members.rs` (the
+ class/interface/type-literal/enum member and type descents), and
+ `declare.rs` (the `declareSymbolEx` cascade and the container routing).
+ - `symbols.rs` — `Symbol`, `SymbolFlags` + the `*Excludes` conflict-mask
+ const tables (ported bit-for-bit from tsgo's `symbolflags.go`), pooled
+ declaration lists, `TableId` symbol tables.
+ - `atoms.rs` — the checker's own **per-file** name interner (a small
+ hand-rolled table over `tsv_lang`'s `FxHashMap`, no external interning
+ crate; the parser is span-identity and holds no interner), reserved
+ internal-name atoms. Atoms are file-local (bind products stay relocatable);
+ cross-file identity is reconciled at merge via owned name strings, with a
+ merge-time atom-remap as the planned multi-file replacement.
+ - `flow/` — the **third walk**, a directory-module split by concern:
+ `flags.rs` (the `FlowFlags` bitset), `graph.rs` (`FlowGraph`'s SoA
+ storage + read API, plus the `FlowSwitchClause`/`FlowReduceLabel`
+ payload types), `product.rs` (the owned `FlowProduct`/`FlowStats` +
+ the `render_flow_dot` DOT renderer), `build/` (`FlowBuilder` + the
+ `pub fn build_flow` entry point, itself a directory-module split by the
+ AST shape each visitor descends: `mod.rs` holds the struct and the
+ flow-node/container/statement-list driver, `statements.rs` and
+ `expressions.rs` each contribute an `impl FlowBuilder` block of per-node
+ visitors, and `predicates.rs` holds the pure AST predicates the walk
+ dispatches on), and `tests/` (a directory-module split mirroring
+ `build/`'s own: `mod.rs` holds the shared fixtures/helpers plus the
+ flow-node/container/label-pool tests that exercise `build/mod.rs` itself,
+ `statements.rs` and `expressions.rs` cover the tests for their `build/`
+ namesakes, and `predicates.rs` covers `build/predicates.rs`). The
+ per-file control-flow graph
+ (`build_flow`) is a faithful port of tsgo's binder flow construction
+ (`bind`/`bindContainer`/`bindChildren` + the per-statement flow shapers).
+ A `FlowGraph` in SoA form (`u16` `FlowFlags`, kind-discriminated
+ `subject`/`antecedent`, length-prefixed pool runs, switch/reduce payload
+ side tables; the `unreachableFlow` singleton is `FlowNodeId` 1) plus
+ `flow_of_node`, the `node_flags` `Unreachable` bit, and the
+ `end`/`return`/`fallthrough` anchors. Covers conditions/if/loops/switch
+ (clauses reachable from the head)/try-finally (ReduceLabels)/IIFE
+ inlining/initializer forks/labeled statements, and renders DOT for
+ `check-test --dump-flow`. Invoked from `program.rs`'s per-unit loop (NOT
+ `bind_file`) so **lib files skip flow construction** (recorded deviation).
+ NodeId resolution has **two paths by design**: the flow builder resolves
+ through the SoA walk's strict `require_node_id` (a miss aborts — a silent
+ fallback would mis-attribute graph edges), while the unreachable candidate
+ walk uses the lenient lookup (a miss just means "not a candidate"). The
+ flow product rides `BoundUnit`,
+ consumed by `check/unreachable.rs` (TS7027/7028) and, later, the CFA type
+ engine. The address map keys on `(address, NodeKind)`, so the one offset-0
+ collision pair — a `MethodDefinition` and its inline
+ `value: FunctionExpression` (same address) — stays distinctly resolvable
+ (pinned by `method_and_value_resolve_distinctly`); the kind disambiguates,
+ and no same-kind collisions exist.
+
+ **Borrow-only discipline**: visitors take
+ `&'arena` references and never clone AST nodes — the AST derives `Clone`,
+ and one accidental `.clone()` silently mints differently-addressed copies
+ that break the address map; nothing type-level enforces this, so it is a
+ reviewed convention — enforced by `tests/clone_discipline.rs`, which fails on
+ any clone-shaped call in `src/` that isn't in its reviewed non-AST allow-list
+ (and on any allow-list entry gone stale).
+- `check/` — the post-bind **syntactic** check pass (`check_file_members`), a
+ standalone `CheckWalk` over `&Program` that never consults the binder's
+ symbol tables (walking the shared interface member table would break
+ declaration-merging). It descends every syntactic position a type literal or
+ type-parameter list can hide — class / interface / type-literal bodies, every
+ type-annotation / assertion / predicate / function-type site (a general
+ `TSType` recursion), class/interface heritage type arguments, decorators
+ (class / member / parameter), and template-literal-type interpolations — and
+ runs the per-node check-time checks: `duplicate_members.rs` ports
+ `checkObjectTypeForDuplicateDeclarations` (the two-map property/accessor
+ state machine → TS2300, disjoint from the bind cascade by construction) and
+ `checkTypeParameters` (per-declaration duplicate type-param identity). Its
+ output folds into each file's diagnostics in `program.rs` before the
+ program-wide sort/dedup. The traversal's `visit_type_params` is the seam
+ future per-node checks hook into. `check/` also holds `unreachable.rs` — the
+ TS7027 (unreachable-code) + TS7028 (unused-label) **flow shim**, a separate
+ consumer of the binder's flow product (`binder/flow/`): it reads the
+ fast-path `NODE_FLAGS_UNREACHABLE` bit (tsgo's binder-set-bit branch of
+ `checkSourceElementUnreachable`), building a bind-time, variant-independent
+ candidate table (so `BoundProgram` stays owned/relocatable) that per-variant
+ emit filters by the `allowUnreachableCode` / `allowUnusedLabels` /
+ `preserveConstEnums` options — routing explicit-`False` runs to `diagnostics`
+ and the default (suggestion) runs to a separate `suggestions` sink. The
+ type-dependent `isReachableFlowNode` fallback (never-returning signatures,
+ assertion predicates, exhaustive switches) is out of scope — deferred to the
+ CFA type engine.
+- `diag.rs` — `Diagnostic` (code, file, span, category, message + args,
+ nested chain + related-info) and the canonical ordering kernels, ported
+ from tsgo `internal/ast/diagnostic.go`: `compare_diagnostics`
+ (path → start → end → code → args → chain size → chain content →
+ related-info), `equal_no_related_info` (full-chain equality, related-info
+ excluded), `sort_and_deduplicate` (+ related-info merge). Pure kernels,
+ unit-tested per comparator leg.
+- `ids.rs` — `NodeId` / `FlowNodeId` (`NonZeroU32`, 1-based; `Option`
+ niche-packs to 4 bytes) and `FileId` newtypes.
+- `options.rs` — the checker's option surface (tsv_check's first): `Tristate`
+ (`Unknown`/`False`/`True`, mirroring `core.Tristate`, default `Unknown`) and
+ `CheckOptions { allow_unreachable_code, allow_unused_labels,
+ preserve_const_enums }`, threaded into `check_bound`. Default everywhere
+ outside the conformance harness.
+- Hashing has no module here — the tables use `tsv_lang`'s `FxHashMap` (the
+ workspace's one dep-free multiply-xor hasher, shared with the printers and the
+ wire writer). The address map, symbol tables and flow-label scratch are
+ integer-keyed; the atom interner and merge globals key on **names (`str`)**,
+ so unlike the printer/writer tables they exercise the hasher's byte path.
+ ⚠️ Its contract is the
+ constraint to preserve: **substituting it for SipHash is behavior-preserving
+ only while every consumer stays order-free.** Every table here honors that —
+ the atom interner, the address map, the symbol tables, the merge globals and
+ the duplicate-member state machine are used through
+ `get`/`insert`/`entry`/`contains_key` alone, and the two sites that *do*
+ iterate sort first, each on a **total** order so no tie can fall back to the
+ map's order (`SymbolBinder::resolve_table` by first-declaration span then
+ name, `FlowBuilder::finish`'s label flush by `FlowNodeId`). A future consumer that
+ let a map's iteration order reach a diagnostic's order, span, or the flow
+ pool layout would make the hasher observable — the canonical
+ `compare_diagnostics` sort is the backstop, not a license. The former
+ crate-private hasher documented a wasm/native hash-equality property; that
+ note is retired. `tsv_lang`'s integer methods widen to a 64-bit word exactly
+ as it did (so integer keys are target-independent regardless) and its byte
+ path folds native-endian words rather than little-endian ones — a difference
+ only a big-endian target could observe, and one no output depends on, since
+ hashes never leave the process. (Where tsgo reaches for xxh3-128 —
+ variable-arity list hashing — the Fx fold is tsv's dep-free substitute.)
+
+## Public API
+
+```rust
+let arena = bumpalo::Bump::new();
+let units = [SourceUnit::new("a.ts", source)];
+let result: CheckResult = check_program(&units, &arena, &CheckOptions::default());
+// result.diagnostics — canonically sorted + deduplicated (error category)
+// result.suggestions — suggestion-category diagnostics (default-option TS7027/8),
+// a SEPARATE sink the parity/expect-clean grading never reads
+// result.files[i].parse — ParseReport::Parsed(ParsedFacts) | Rejected
+// result.parse_rejected — the short-circuit fired
+```
+
+The caller owns the arena (the same contract as `tsv_ts::parse`); the
+result is fully owned — nothing borrows out. For lib-aware checking:
+`bind_program` (parse+bind+flow once, variant-independent, fully owned) →
+`check_bound(&bound, Some(&lib_base), &options)`; `bind_lib` produces a cacheable
+`LibFile`; `check_program_with_lib` is the one-shot form. `CheckOptions` (the two
+unreachable/unused-label tri-states + `preserve_const_enums`) is `default()` for
+every non-conformance caller.
+
+## Which tool answers which question
+
+- `tsv_debug tsc_conformance run` — the conformance sweep: sweeps the in-scope
+ corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades
+ expect-clean variants AND two graded families as codes+spans multisets — the
+ **duplicate-conflict** family (`dup`: TS2300/2451/2567/2528 + merge-path
+ TS2397/2649/2664/2671) from bind + merge + lib **and** the check-time TS2300
+ subset (duplicate members / type parameters, from the `check` pass), plus the
+ **flow** family (`flow`: TS7027 unreachable code + TS7028 unused label) from
+ the `check/unreachable` shim. `--family {dup,flow,all}` isolates a sub-family
+ for triage. extra = 0 is a hard gate; a missing is classified `merge` / `lib` /
+ `deferred_late_bound` (an exact pin — the type-engine-dependent `lateBindMember`
+ residual) / `deferred_cfa` (an exact pin — the type-engine-dependent
+ `isReachableFlowNode` residual: never-returning signatures / assertion
+ predicates / switch exhaustiveness / structural reachability fallback) /
+ `other` (a HARD-zero invariant — any unclassified family miss fails the run).
+ It also grades related-info on matched primaries as its own pinned channel
+ and publishes the parse-divergence census. The exact pins split by meaning:
+ the drifting counts live in the machine-regenerated snapshot
+ `tsc_conformance_pins.txt` (`--update` rewrites it; it refuses a narrowed or
+ red run), the zero-valued invariant gates and the oracle-side pins stay Rust
+ consts. Triage filters (`--test`/`--code`/`--variant`/`--family`) skip the pins;
+ `--emit-manifest` and `--report` (the committed
+ `benches/js/results/report.tsc-conformance.{json,md}`) serve tooling.
+ On-demand only — `deno task conformance:tsc-check`, never a release leg.
+- `tsv_debug profile --bind ` — parse vs lower+bind timing + peak
+ RSS (VmHWM); the binder's standing perf anchor form.
+- `tsv_debug tsc_conformance check-test [--variant k=v] [--json]` —
+ the inner dev loop: one test, our diagnostics vs the baseline summary.
+- `tsv_debug tsc_conformance query|roundtrip|index` — the oracle-side
+ surfaces (baseline aggregations; parser/renderer byte-identity; corpus
+ index self-checks).
diff --git a/crates/tsv_check/Cargo.toml b/crates/tsv_check/Cargo.toml
new file mode 100644
index 000000000..f23d9146e
--- /dev/null
+++ b/crates/tsv_check/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "tsv_check"
+version.workspace = true
+edition.workspace = true
+description = "Experimental TypeScript type-checking for tsv (the tsv_check checker crate)"
+
+[dependencies]
+# Foundation (spans, comments, diagnostics substrate) + the TypeScript parser
+# whose internal AST the checker binds and checks. No convert feature — the
+# checker consumes the internal AST directly, never the wire JSON.
+tsv_lang = { workspace = true }
+tsv_ts = { workspace = true }
+# The per-file parse arena the caller owns (caller-owns-arena, the tsv_ts
+# contract). Already a workspace dependency.
+bumpalo = { workspace = true }
+# The per-symbol pooled declaration list. A vetted workspace dep. (The binder's
+# own name interner is a hand-rolled table over `tsv_lang`'s FxHashMap — no
+# external interning crate.)
+smallvec = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/crates/tsv_check/src/binder/atoms.rs b/crates/tsv_check/src/binder/atoms.rs
new file mode 100644
index 000000000..8ead1b772
--- /dev/null
+++ b/crates/tsv_check/src/binder/atoms.rs
@@ -0,0 +1,104 @@
+//! The binder's per-file name interner.
+//!
+//! Binding needs cross-occurrence name identity: two `x` at different spans must
+//! resolve to one symbol-table key. Span-identity identifier names give equality
+//! only per occurrence, so at bind time each declared name resolves to a dense
+//! [`Atom`] through one interner pass — the common case slices `source[name_span]`
+//! (no allocation beyond the interner's own copy), escaped names go through the
+//! parser's decoded channel.
+//!
+//! **Scope: one file's bind.** Each `bind_file` runs a fresh instance, so an
+//! [`Atom`] is comparable only within its own file — a deliberate deviation from
+//! a program-scoped interner, keeping every bind product program-independent
+//! (relocatable/cacheable across programs and lib folds). Cross-file name
+//! identity is reconciled at merge time, today by resolving atoms to owned name
+//! strings in `FileMerge`; a merge-time atom-remap table (old→new integer map)
+//! is the planned replacement when multi-file volume makes the string bridge
+//! measurable.
+//!
+//! This is the checker's **own** interner — a small hand-rolled table over
+//! `tsv_lang`'s `FxHashMap` (no external interning crate; the parser is
+//! span-identity and holds no interner). The reserved internal names tsgo
+//! mangles (`"default"`, `"export="`, `"__constructor"`, ambient-module
+//! `"name"`, private `\xFE#…`) intern through the same table on demand; the hot
+//! reserved ones are pre-interned so their [`Atom`]s are `const`-cheap to
+//! compare.
+//
+// tsgo: internal/ast/symbol.go InternalSymbolName* (the mangled reserved names)
+
+use tsv_lang::FxHashMap;
+
+/// A dense interned name identity, valid within one file's bind.
+///
+/// Equal atoms mean equal names — the symbol-table key. Wraps a `u32` index so
+/// distinct-name lookups are integer compares. Atoms from different files never
+/// compare (each `bind_file` has its own interner).
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+pub struct Atom(u32);
+
+/// The binder's per-file name interner (its own hand-rolled table).
+pub struct Atoms {
+ /// `Atom` index → owned name (the resolve channel). Owned rather than
+ /// source-borrowed so bind products stay relocatable across programs/folds.
+ names: Vec>,
+ /// name → `Atom` (the get-or-intern channel).
+ lookup: FxHashMap, Atom>,
+ /// tsgo's `InternalSymbolNameDefault` — the forced name of every default
+ /// export, so multiple default exports collide under one table key.
+ default: Atom,
+ /// tsgo's `InternalSymbolNameExportEquals` — the `export =` self-merge name.
+ export_equals: Atom,
+}
+
+impl Atoms {
+ /// Build the interner and pre-intern the hot reserved names.
+ #[must_use]
+ pub fn new() -> Atoms {
+ let mut atoms = Atoms {
+ names: Vec::new(),
+ lookup: FxHashMap::default(),
+ default: Atom(0),
+ export_equals: Atom(0),
+ };
+ atoms.default = atoms.intern("default");
+ atoms.export_equals = atoms.intern("export=");
+ atoms
+ }
+
+ /// Intern a name to its [`Atom`] — one string hash, then integer identity.
+ pub fn intern(&mut self, name: &str) -> Atom {
+ if let Some(&atom) = self.lookup.get(name) {
+ return atom;
+ }
+ let atom = Atom(self.names.len() as u32);
+ let owned: Box = name.into();
+ self.names.push(owned.clone());
+ self.lookup.insert(owned, atom);
+ atom
+ }
+
+ /// Resolve an [`Atom`] back to its name (for diagnostic display).
+ #[must_use]
+ pub fn resolve(&self, atom: Atom) -> &str {
+ // Sound: every `Atom` was minted by this interner's `intern`.
+ self.names.get(atom.0 as usize).map_or("", |name| &**name)
+ }
+
+ /// The forced-default-export name atom (`"default"`).
+ #[must_use]
+ pub fn default_export(&self) -> Atom {
+ self.default
+ }
+
+ /// The `export =` self-merge name atom (`"export="`).
+ #[must_use]
+ pub fn export_equals(&self) -> Atom {
+ self.export_equals
+ }
+}
+
+impl Default for Atoms {
+ fn default() -> Atoms {
+ Atoms::new()
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/build/expressions.rs b/crates/tsv_check/src/binder/flow/build/expressions.rs
new file mode 100644
index 000000000..9ffcd41e8
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/build/expressions.rs
@@ -0,0 +1,664 @@
+//! The expression-shaped flow visitors — `visit_expression`, the `bindCondition`
+//! machinery (conditions / logical / conditional expressions), the function /
+//! class-expression containers, and the pattern visitors. Contributes its own
+//! `impl FlowBuilder` block; the struct and traversal driver live in the parent
+//! module. Purely a locality split — no behavior distinction.
+
+use super::super::*;
+use super::FlowBuilder;
+use super::predicates::*;
+use crate::binder::{NodeKind, addr_of, expression_addr_kind};
+use tsv_ts::ast::internal::{
+ ArrowFunctionBody, AssignmentOperator, BinaryExpression, BinaryOperator, ClassExpression,
+ ConditionalExpression, Decorator, Expression, FunctionExpression, Identifier,
+ ObjectPatternProperty, ObjectProperty, Property, UnaryOperator,
+};
+
+impl<'a> FlowBuilder<'a> {
+ /// `maybeBindExpressionFlowIfCall` (binder.go:2143): a top-level dotted-name
+ /// (non-`super`) call is a potential assertion → `createFlowCall`.
+ pub(super) fn maybe_bind_expression_flow_if_call(&mut self, expr: &Expression<'_>) {
+ if let Expression::CallExpression(c) = expr
+ && !matches!(c.callee, Expression::Super(_))
+ && is_dotted_name(c.callee)
+ {
+ let call_id = self.require(addr_of(c), NodeKind::CallExpression);
+ self.current_flow = self.create_flow_call(self.current_flow, call_id);
+ }
+ }
+
+ // --- condition binding (the bindCondition machinery) ------------------
+
+ /// `doWithConditionalBranches` (binder.go:1789) — bind `value` with the given
+ /// true/false targets installed, restored on exit. A `None` value is the
+ /// nil-node no-op (`for (;;)`).
+ fn do_with_conditional_branches(
+ &mut self,
+ value: Option<&Expression<'_>>,
+ true_target: FlowNodeId,
+ false_target: FlowNodeId,
+ ) {
+ let saved_true = self.current_true_target;
+ let saved_false = self.current_false_target;
+ self.current_true_target = Some(true_target);
+ self.current_false_target = Some(false_target);
+ if let Some(v) = value {
+ self.visit_expression(v);
+ }
+ self.current_true_target = saved_true;
+ self.current_false_target = saved_false;
+ }
+
+ /// `bindCondition` (binder.go:1799) — bind the condition through the
+ /// true/false targets, then, **only for an atomic condition** (not a logical
+ /// `&&`/`||`/`??` or logical compound-assignment, whose sub-binder already
+ /// wired the targets), add the true/false condition edges. `parent_is_nullish`
+ /// is the caller-supplied `IsNullishCoalesce(parent)` guard (the operands of a
+ /// `??`); `is_optional_chain_root` is always `false` here (optional chains are
+ /// atomic — their dedicated short-circuit machinery is F2).
+ pub(super) fn bind_condition(
+ &mut self,
+ node: Option<&Expression<'_>>,
+ true_target: FlowNodeId,
+ false_target: FlowNodeId,
+ parent_is_nullish: bool,
+ ) {
+ self.do_with_conditional_branches(node, true_target, false_target);
+ if node.is_none_or(|e| !is_logical_condition(e)) {
+ let with_id = node.map(|e| (e, self.expr_id(e)));
+ let is_narrowing = node.is_some_and(is_narrowing_expression);
+ let tc = self.create_flow_condition(
+ FlowFlags::TRUE_CONDITION,
+ self.current_flow,
+ with_id,
+ is_narrowing,
+ false,
+ parent_is_nullish,
+ );
+ self.add_antecedent(true_target, tc);
+ let fc = self.create_flow_condition(
+ FlowFlags::FALSE_CONDITION,
+ self.current_flow,
+ with_id,
+ is_narrowing,
+ false,
+ parent_is_nullish,
+ );
+ self.add_antecedent(false_target, fc);
+ }
+ }
+
+ /// `bindBinaryExpressionFlow`'s logical branch (binder.go:2219) — decides
+ /// value-context (top-level → a `hasFlowEffects` post-label materializes only
+ /// if the subtree had flow effects) vs condition-context (nested → the
+ /// enclosing true/false targets). The pointer-free
+ /// `isTopLevelLogicalExpression` test is `current_true_target.is_none()` (see
+ /// the module header). `assign_target` is the LHS for a logical
+ /// compound-assignment (`&&=`/`||=`/`??=`), else `None`.
+ fn bind_binary_expression_flow(
+ &mut self,
+ node: &Expression<'_>,
+ left: &Expression<'_>,
+ right: &Expression<'_>,
+ is_and: bool,
+ is_nullish: bool,
+ assign_target: Option<&Expression<'_>>,
+ ) {
+ if self.current_true_target.is_none() {
+ let post = self.create_branch_label();
+ let saved_flow = self.current_flow;
+ let saved_effects = self.has_flow_effects;
+ self.has_flow_effects = false;
+ self.bind_logical_like_expression(
+ node,
+ left,
+ right,
+ is_and,
+ is_nullish,
+ assign_target,
+ post,
+ post,
+ );
+ self.current_flow = if self.has_flow_effects {
+ self.finish_flow_label(post)
+ } else {
+ saved_flow
+ };
+ self.has_flow_effects = self.has_flow_effects || saved_effects;
+ } else {
+ let t = self.current_true_target.unwrap_or(self.unreachable_flow);
+ let f = self.current_false_target.unwrap_or(self.unreachable_flow);
+ self.bind_logical_like_expression(
+ node,
+ left,
+ right,
+ is_and,
+ is_nullish,
+ assign_target,
+ t,
+ f,
+ );
+ }
+ }
+
+ /// `bindLogicalLikeExpression` (binder.go:2261) — narrow the left operand
+ /// against a fresh `preRight` label (vs the false target for `&&`/`&&=`, vs
+ /// the true target otherwise), then the right against the original targets;
+ /// a logical compound-assignment additionally mutates its target and tests
+ /// the whole node.
+ #[allow(clippy::too_many_arguments)] // faithful port of the tsgo signature
+ fn bind_logical_like_expression(
+ &mut self,
+ node: &Expression<'_>,
+ left: &Expression<'_>,
+ right: &Expression<'_>,
+ is_and: bool,
+ is_nullish: bool,
+ assign_target: Option<&Expression<'_>>,
+ true_target: FlowNodeId,
+ false_target: FlowNodeId,
+ ) {
+ let pre_right = self.create_branch_label();
+ if is_and {
+ self.bind_condition(Some(left), pre_right, false_target, is_nullish);
+ } else {
+ self.bind_condition(Some(left), true_target, pre_right, is_nullish);
+ }
+ self.current_flow = self.finish_flow_label(pre_right);
+ if let Some(target) = assign_target {
+ // Logical compound-assignment (binder.go:2271-2275): bind the RHS, mutate
+ // the target, then test `node` (never a boolean keyword → the parent-nullish
+ // guard is irrelevant). tsgo binds the RHS with `doWithConditionalBranches`
+ // (targets = the outer true/false), but the value-vs-condition split is then
+ // taken by `isTopLevelLogicalExpression(right)` on `right`'s PARENT — which
+ // is this `&&=`/`||=`/`??=` node, not a logical operator — so `right` is
+ // classified TOP-LEVEL and its internal conditions never thread into the
+ // outer targets (only the whole-node truthiness below does). tsv emulates
+ // `isTopLevelLogicalExpression` pointer-free as `current_true_target.is_none()`,
+ // so binding the RHS with the targets SET would misclassify a logical `right`
+ // (`a &&= x && y`) as nested. The faithful adaptation is to CLEAR the targets
+ // (not set them) around the RHS bind: a logical `right` then sees itself
+ // top-level (its own discarded post-label), and a non-logical `right` is
+ // identical either way (the targets are only read by the logical branch).
+ let saved_true = self.current_true_target.take();
+ let saved_false = self.current_false_target.take();
+ self.visit_expression(right);
+ self.current_true_target = saved_true;
+ self.current_false_target = saved_false;
+ self.bind_assignment_target_flow(target);
+ let node_id = self.expr_id(node);
+ let is_narrowing = is_narrowing_expression(node);
+ let tc = self.create_flow_condition(
+ FlowFlags::TRUE_CONDITION,
+ self.current_flow,
+ Some((node, node_id)),
+ is_narrowing,
+ false,
+ false,
+ );
+ self.add_antecedent(true_target, tc);
+ let fc = self.create_flow_condition(
+ FlowFlags::FALSE_CONDITION,
+ self.current_flow,
+ Some((node, node_id)),
+ is_narrowing,
+ false,
+ false,
+ );
+ self.add_antecedent(false_target, fc);
+ } else {
+ self.bind_condition(Some(right), true_target, false_target, is_nullish);
+ }
+ }
+
+ /// `bindConditionalExpressionFlow` (binder.go:2289) — a `?:` as a value: the
+ /// condition splits to true/false labels feeding the two arms, which merge at
+ /// a `hasFlowEffects`-gated post label.
+ fn bind_conditional_expression_flow(&mut self, c: &ConditionalExpression<'_>) {
+ let true_label = self.create_branch_label();
+ let false_label = self.create_branch_label();
+ let post = self.create_branch_label();
+ let saved_flow = self.current_flow;
+ let saved_effects = self.has_flow_effects;
+ self.has_flow_effects = false;
+ self.bind_condition(Some(c.test), true_label, false_label, false);
+ self.current_flow = self.finish_flow_label(true_label);
+ self.visit_expression(c.consequent);
+ self.add_antecedent(post, self.current_flow);
+ self.current_flow = self.finish_flow_label(false_label);
+ self.visit_expression(c.alternate);
+ self.add_antecedent(post, self.current_flow);
+ self.current_flow = if self.has_flow_effects {
+ self.finish_flow_label(post)
+ } else {
+ saved_flow
+ };
+ self.has_flow_effects = self.has_flow_effects || saved_effects;
+ }
+
+ /// `bindAssignmentTargetFlow` (binder.go:1821), **default branch only**: a
+ /// narrowable-reference target gets an `Assignment` mutation. The
+ /// array/object-literal destructuring recursion (the `inAssignmentPattern`
+ /// per-element machinery) is F2, alongside parameter-default forks — a
+ /// destructuring target is not a narrowable reference, so it mints no mutation
+ /// here, and its sub-references were already visited.
+ pub(super) fn bind_assignment_target_flow(&mut self, target: &Expression<'_>) {
+ if is_narrowable_reference(target) {
+ let id = self.expr_id(target);
+ self.current_flow =
+ self.create_flow_mutation(FlowFlags::ASSIGNMENT, self.current_flow, id);
+ }
+ }
+
+ /// The F0 [`NodeId`] of an expression node — its variant payload's
+ /// `(address, kind)` in the address map, via the shared
+ /// [`expression_addr_kind`] mapping (the same one `visit_expression`'s
+ /// lockstep guard pins in `binder/mod.rs`). Condition / mutation subjects are
+ /// always value expressions F0 lowered, so this never misses.
+ fn expr_id(&self, e: &Expression<'_>) -> NodeId {
+ let (addr, kind) = expression_addr_kind(e);
+ self.require(addr, kind)
+ }
+
+ // --- function-like / class expression containers ----------------------
+
+ /// A function expression. `is_iife` marks a call callee (an IIFE): the
+ /// container is entered **transparently** (no fresh `Start`, `current_flow`
+ /// not restored on exit) with its own return target, so the body joins the
+ /// containing control flow (binder.go:1525-1544). The return-flow anchor
+ /// stays off (`is_ctor_or_static = false`) — tsgo writes it only for
+ /// constructors / static blocks, never a plain IIFE.
+ fn visit_function_expression(
+ &mut self,
+ f: &FunctionExpression<'_>,
+ node_id: NodeId,
+ is_iife: bool,
+ ) {
+ // The function-expression flow write is captured at the OUTER flow,
+ // before the body's Start (binder.go:915). Unconditional: the container
+ // path does not nil it in dead code.
+ self.set_flow_leaf(node_id);
+ let saved = self.enter_container(Some(node_id), is_iife, is_iife);
+ self.bind_params(f.params);
+ self.visit_statement_list(f.body.body);
+ self.exit_container(saved, is_iife, true, true, node_id, false);
+ }
+
+ fn visit_arrow(
+ &mut self,
+ a: &tsv_ts::ast::internal::ArrowFunctionExpression<'_>,
+ node_id: NodeId,
+ is_iife: bool,
+ ) {
+ self.set_flow_leaf(node_id); // binder.go:915 (arrows dispatch here too)
+ let saved = self.enter_container(Some(node_id), is_iife, is_iife);
+ self.bind_params(a.params);
+ match &a.body {
+ ArrowFunctionBody::Expression(e) => self.visit_expression(e),
+ ArrowFunctionBody::BlockStatement(block) => self.visit_statement_list(block.body),
+ }
+ self.exit_container(saved, is_iife, true, true, node_id, false);
+ }
+
+ fn visit_class_expr(&mut self, c: &ClassExpression<'_>) {
+ self.visit_class_common(
+ c.id.as_ref(),
+ c.decorators,
+ c.super_class,
+ c.body.body,
+ true,
+ );
+ }
+
+ // --- expressions ------------------------------------------------------
+
+ pub(super) fn visit_expression(&mut self, expr: &Expression<'_>) {
+ use Expression as E;
+ // A **value** sub-position resets the condition targets, so a logical
+ // expression nested inside one (`if (f(x && y))`, `if (c ? x && y : z)`,
+ // `if (g([x && y]))`) is classified top-level — a value with its own
+ // post-label — not a sub-condition of the enclosing `bind_condition`. This
+ // is the pointer-free `isTopLevelLogicalExpression` (binder.go:2782): only
+ // the *threading* variants (`!`, `&&`/`||`/`??`, logical-assignment, parens)
+ // propagate the targets into their operands; every other expression is a
+ // value boundary. Without the reset, `current_true_target.is_none()` stays
+ // false through the whole condition subtree and mis-wires nested logicals.
+ let restore = if is_condition_threading(expr) {
+ None
+ } else {
+ Some((
+ self.current_true_target.take(),
+ self.current_false_target.take(),
+ ))
+ };
+ match expr {
+ E::Identifier(idn) => self.visit_identifier(idn),
+ E::ThisExpression(t) => {
+ let id = self.require(addr_of(t), NodeKind::ThisExpression);
+ self.set_flow_leaf(id);
+ }
+ E::Super(s) => {
+ let id = self.require(addr_of(s), NodeKind::Super);
+ self.set_flow_leaf(id);
+ }
+ E::MetaProperty(m) => {
+ // Non-leaf write (nil'd in dead code). tsv models `import`/`new`
+ // and `meta`/`target` as identifiers; they are keyword-ish, not
+ // references, so only the MetaProperty node is stamped.
+ let id = self.require(addr_of(m), NodeKind::MetaProperty);
+ self.set_flow_nonleaf(id);
+ }
+ E::MemberExpression(m) => self.bind_member_expression_flow(expr, m),
+ E::Literal(_) | E::PrivateIdentifier(_) | E::RegexLiteral(_) => {}
+ E::ObjectExpression(o) => {
+ for prop in o.properties {
+ self.visit_object_property(prop);
+ }
+ }
+ E::ArrayExpression(a) => {
+ for el in a.elements.iter().flatten() {
+ self.visit_expression(el);
+ }
+ }
+ E::UnaryExpression(u) if u.operator == UnaryOperator::Bang => {
+ self.bind_prefix_unary_expression_flow(u);
+ }
+ E::UnaryExpression(u) => self.visit_expression(u.argument),
+ E::UpdateExpression(u) => self.visit_expression(u.argument),
+ E::BinaryExpression(b) if b.operator.is_logical() => {
+ self.bind_logical_binary_expression_flow(expr, b);
+ }
+ E::BinaryExpression(b) => {
+ self.visit_expression(b.left);
+ self.visit_expression(b.right);
+ }
+ E::CallExpression(c) => self.bind_call_expression_flow(c),
+ E::NewExpression(n) => self.visit_new_expression(n),
+ E::ConditionalExpression(c) => self.bind_conditional_expression_flow(c),
+ E::ArrowFunctionExpression(a) => {
+ let id = self.require(addr_of(a), NodeKind::ArrowFunctionExpression);
+ self.visit_arrow(a, id, false);
+ }
+ E::FunctionExpression(f) => {
+ let id = self.require(addr_of(f), NodeKind::FunctionExpression);
+ self.visit_function_expression(f, id, false);
+ }
+ E::ClassExpression(c) => self.visit_class_expr(c),
+ E::SpreadElement(s) => self.visit_expression(s.argument),
+ E::TemplateLiteral(t) => {
+ for e in t.expressions {
+ self.visit_expression(e);
+ }
+ }
+ E::TaggedTemplateExpression(t) => self.visit_tagged_template_expression(t),
+ E::AwaitExpression(a) => self.visit_expression(a.argument),
+ E::YieldExpression(y) => {
+ if let Some(a) = y.argument {
+ self.visit_expression(a);
+ }
+ }
+ E::SequenceExpression(s) => self.bind_sequence_expression_flow(s),
+ E::AssignmentExpression(a) if is_logical_assign_op(a.operator) => {
+ self.bind_logical_assignment_expression_flow(expr, a);
+ }
+ E::AssignmentExpression(a) => self.bind_assignment_expression_flow(a),
+ E::ObjectPattern(op) => self.visit_object_pattern(op),
+ E::ArrayPattern(ap) => self.visit_array_pattern(ap),
+ E::AssignmentPattern(a) => self.visit_assignment_pattern(a),
+ E::RestElement(r) => self.visit_expression(r.argument),
+ E::TSTypeAssertion(t) => self.visit_expression(t.expression),
+ E::TSAsExpression(t) => self.visit_expression(t.expression),
+ E::TSSatisfiesExpression(t) => self.visit_expression(t.expression),
+ E::TSInstantiationExpression(t) => self.visit_expression(t.expression),
+ E::TSNonNullExpression(t) => self.visit_expression(t.expression),
+ E::TSParameterProperty(pp) => self.visit_expression(pp.parameter),
+ E::ImportExpression(i) => self.visit_import_expression(i),
+ E::JsdocCast(c) => self.visit_expression(c.inner),
+ E::ParenthesizedExpression(p) => self.visit_expression(p.expression),
+ }
+ if let Some((t, f)) = restore {
+ self.current_true_target = t;
+ self.current_false_target = f;
+ }
+ }
+
+ #[inline]
+ fn bind_member_expression_flow(
+ &mut self,
+ expr: &Expression<'_>,
+ m: &tsv_ts::ast::internal::MemberExpression<'_>,
+ ) {
+ // The access flow write (binder.go:618): non-leaf, reachable-
+ // only, gated on `isNarrowableReference`.
+ if is_narrowable_reference(expr) {
+ let id = self.require(addr_of(m), NodeKind::MemberExpression);
+ self.set_flow_nonleaf(id);
+ }
+ self.visit_expression(m.object);
+ self.visit_expression(m.property);
+ }
+
+ #[inline]
+ fn bind_prefix_unary_expression_flow(
+ &mut self,
+ u: &tsv_ts::ast::internal::UnaryExpression<'_>,
+ ) {
+ // `bindPrefixUnaryExpressionFlow` (binder.go:2174): swap the
+ // condition targets around the operand so `!x` narrows inversely.
+ // The pre/post swaps are symmetric — any sub-binder restores the
+ // targets to their entry value (via `do_with_conditional_branches`
+ // / the `!`-swap), so the second swap is a faithful restore.
+ std::mem::swap(
+ &mut self.current_true_target,
+ &mut self.current_false_target,
+ );
+ self.visit_expression(u.argument);
+ std::mem::swap(
+ &mut self.current_true_target,
+ &mut self.current_false_target,
+ );
+ }
+
+ #[inline]
+ fn bind_logical_binary_expression_flow(
+ &mut self,
+ expr: &Expression<'_>,
+ b: &BinaryExpression<'_>,
+ ) {
+ // `bindBinaryExpressionFlow` logical branch (binder.go:2219).
+ let is_and = b.operator == BinaryOperator::AmpersandAmpersand;
+ let is_nullish = b.operator == BinaryOperator::QuestionQuestion;
+ self.bind_binary_expression_flow(expr, b.left, b.right, is_and, is_nullish, None);
+ }
+
+ #[inline]
+ fn bind_call_expression_flow(&mut self, c: &tsv_ts::ast::internal::CallExpression<'_>) {
+ use Expression as E;
+ // IIFE detection (`GetImmediatelyInvokedFunctionExpression`,
+ // utilities.go:1834; `bindCallExpressionFlow`, binder.go:2419):
+ // a non-async (non-generator) function/arrow callee — through any
+ // grouping parens — is inlined into the containing flow. Its
+ // arguments bind FIRST so the callee's flow write captures the
+ // post-argument flow.
+ let mut unwrapped = c.callee;
+ while let E::ParenthesizedExpression(p) = unwrapped {
+ unwrapped = p.expression;
+ }
+ match unwrapped {
+ E::ArrowFunctionExpression(a) if !a.r#async => {
+ for arg in c.arguments {
+ self.visit_expression(arg);
+ }
+ let id = self.require(addr_of(a), NodeKind::ArrowFunctionExpression);
+ self.visit_arrow(a, id, true);
+ }
+ E::FunctionExpression(f) if !f.r#async && !f.generator => {
+ for arg in c.arguments {
+ self.visit_expression(arg);
+ }
+ let id = self.require(addr_of(f), NodeKind::FunctionExpression);
+ self.visit_function_expression(f, id, true);
+ }
+ _ => {
+ self.visit_expression(c.callee);
+ for arg in c.arguments {
+ self.visit_expression(arg);
+ }
+ }
+ }
+ }
+
+ #[inline]
+ fn visit_new_expression(&mut self, n: &tsv_ts::ast::internal::NewExpression<'_>) {
+ self.visit_expression(n.callee);
+ for a in n.arguments {
+ self.visit_expression(a);
+ }
+ }
+
+ #[inline]
+ fn visit_tagged_template_expression(
+ &mut self,
+ t: &tsv_ts::ast::internal::TaggedTemplateExpression<'_>,
+ ) {
+ self.visit_expression(t.tag);
+ for e in t.quasi.expressions {
+ self.visit_expression(e);
+ }
+ }
+
+ #[inline]
+ fn bind_sequence_expression_flow(&mut self, s: &tsv_ts::ast::internal::SequenceExpression<'_>) {
+ // `bindBinaryExpressionFlow` comma branch: each operand's value
+ // is discarded (statement-like), so a top-level dotted-name call
+ // is a potential assertion — apply maybe-call per operand
+ // (visit-then-maybe, like `ExpressionStatement`). tsgo nests
+ // comma as left-assoc `BinaryExpression`s applying maybe-call to
+ // both `Left`/`Right` at each level; the flattened form applies
+ // it once per leaf operand (intermediate comma nodes are no-op
+ // non-calls), so the effect matches.
+ // tsgo: binder.go bindBinaryExpressionFlow (comma branch)
+ for e in s.expressions {
+ self.visit_expression(e);
+ self.maybe_bind_expression_flow_if_call(e);
+ }
+ }
+
+ #[inline]
+ fn bind_logical_assignment_expression_flow(
+ &mut self,
+ expr: &Expression<'_>,
+ a: &tsv_ts::ast::internal::AssignmentExpression<'_>,
+ ) {
+ // `bindBinaryExpressionFlow` logical compound-assignment branch.
+ let is_and = a.operator == AssignmentOperator::LogicalAndAssign;
+ let is_nullish = a.operator == AssignmentOperator::NullishAssign;
+ self.bind_binary_expression_flow(expr, a.left, a.right, is_and, is_nullish, Some(a.left));
+ }
+
+ #[inline]
+ fn bind_assignment_expression_flow(
+ &mut self,
+ a: &tsv_ts::ast::internal::AssignmentExpression<'_>,
+ ) {
+ // `bindBinaryExpressionFlow` assignment branch (binder.go:2249) —
+ // bind operands, then the target's `Assignment` mutation.
+ self.visit_expression(a.left);
+ self.visit_expression(a.right);
+ self.bind_assignment_target_flow(a.left);
+ }
+
+ #[inline]
+ fn visit_object_pattern(&mut self, op: &tsv_ts::ast::internal::ObjectPattern<'_>) {
+ self.visit_decorators(op.decorators);
+ for prop in op.properties {
+ self.visit_object_pattern_property(prop);
+ }
+ }
+
+ #[inline]
+ fn visit_array_pattern(&mut self, ap: &tsv_ts::ast::internal::ArrayPattern<'_>) {
+ self.visit_decorators(ap.decorators);
+ for el in ap.elements.iter().flatten() {
+ self.visit_expression(el);
+ }
+ }
+
+ #[inline]
+ fn visit_assignment_pattern(&mut self, a: &tsv_ts::ast::internal::AssignmentPattern<'_>) {
+ self.visit_decorators(a.decorators);
+ self.visit_expression(a.left);
+ self.visit_expression(a.right);
+ }
+
+ #[inline]
+ fn visit_import_expression(&mut self, i: &tsv_ts::ast::internal::ImportExpression<'_>) {
+ self.visit_expression(i.source);
+ if let Some(o) = i.options {
+ self.visit_expression(o);
+ }
+ }
+
+ pub(super) fn visit_identifier(&mut self, ident: &Identifier<'_>) {
+ // Identifier flow write (binder.go:602): a leaf — unconditional, so a
+ // dead identifier keeps `Some(unreachable)`. Its decorators (parameter
+ // decorators) are value expressions; its type annotation is a type
+ // position (skipped).
+ let id = self.require(addr_of(ident), NodeKind::Identifier);
+ self.set_flow_leaf(id);
+ self.visit_decorators(ident.decorators());
+ }
+
+ pub(super) fn visit_decorators(&mut self, decorators: Option<&[Decorator<'_>]>) {
+ if let Some(decs) = decorators {
+ for d in decs {
+ self.visit_expression(&d.expression);
+ }
+ }
+ }
+
+ fn visit_object_property(&mut self, prop: &ObjectProperty<'_>) {
+ match prop {
+ ObjectProperty::Property(pr) => self.visit_object_expr_property(pr),
+ ObjectProperty::SpreadElement(s) => self.visit_expression(s.argument),
+ }
+ }
+
+ fn visit_object_expr_property(&mut self, pr: &Property<'_>) {
+ let is_method_or_accessor =
+ pr.method || pr.kind != tsv_ts::ast::internal::PropertyKind::Init;
+ if let (true, Expression::FunctionExpression(f)) = (is_method_or_accessor, &pr.value) {
+ // An object-literal method/accessor is a control-flow container
+ // anchored on its value FunctionExpression — the body-bearing node
+ // (unlike `MethodDefinition`, a `Property` does NOT share its value's
+ // address, so this is a consistency choice with `visit_method`, not a
+ // collision workaround). The PROPERTY node — tsv's analog of tsgo's
+ // object-literal MethodDeclaration — gets the outer-flow write
+ // (bindPropertyOrMethodOrAccessor, binder.go:981) and becomes the
+ // body Start's subject (binder.go:1534) — the P3 narrowing hint
+ // (`IsObjectLiteralOrClassExpressionMethodOrAccessor`,
+ // utilities.go:566; the class-expression half lives in `visit_method`).
+ self.visit_expression(&pr.key);
+ let anchor = self.require(addr_of(f), NodeKind::FunctionExpression);
+ let prop_id = self.require(addr_of(pr), NodeKind::Property);
+ self.set_flow_leaf(prop_id);
+ let saved = self.enter_container(Some(prop_id), false, false);
+ self.bind_params(f.params);
+ self.visit_statement_list(f.body.body);
+ self.exit_container(saved, false, true, true, anchor, false);
+ } else {
+ self.visit_expression(&pr.key);
+ self.visit_expression(&pr.value);
+ }
+ }
+
+ fn visit_object_pattern_property(&mut self, prop: &ObjectPatternProperty<'_>) {
+ match prop {
+ ObjectPatternProperty::Property(pr) => {
+ self.visit_expression(&pr.key);
+ self.visit_expression(&pr.value);
+ }
+ ObjectPatternProperty::RestElement(r) => self.visit_expression(r.argument),
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/build/mod.rs b/crates/tsv_check/src/binder/flow/build/mod.rs
new file mode 100644
index 000000000..1ddaaf824
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/build/mod.rs
@@ -0,0 +1,602 @@
+//! The flow-graph construction walk — `FlowBuilder` and the `build_flow` entry.
+//!
+//! Split by the AST shape each visitor descends: this parent module owns the
+//! `FlowBuilder` struct (with `SavedFlow` / `ActiveLabelEntry`), the
+//! flow-node-minting constructors (`newFlowNode*` / `createFlow*` /
+//! `finishFlowLabel` / `addAntecedent` family), and the container / statement-list
+//! traversal driver (`enter_container` / `exit_container` / `run` /
+//! `visit_statement_list`). The per-node visitors live in the submodules — each
+//! contributes its own `impl FlowBuilder` block: `statements` (statement dispatch,
+//! the per-statement flow shapers, and the declaration-container descents),
+//! `expressions` (`visit_expression`, the `bindCondition` machinery, the
+//! function/class-expression containers, and the pattern visitors) — and the pure
+//! AST predicates the walk dispatches on live in `predicates`. Purely a locality
+//! split — no behavior distinction between the files.
+
+mod expressions;
+mod predicates;
+mod statements;
+
+use super::*;
+use crate::binder::{BoundFile, NodeKind, addr_of};
+use predicates::{is_false_keyword, is_true_keyword};
+use smallvec::SmallVec;
+use tsv_ts::ast::Program;
+use tsv_ts::ast::internal::{Expression, Statement};
+
+#[cfg(test)]
+pub(super) use predicates::is_narrowable_reference;
+
+/// Build the flow product for one parsed file, from its `Program` and the F0
+/// [`BoundFile`] (the node-identity source). Invoked from `bind_program`'s
+/// per-unit loop for parsed non-lib units (lib files skip flow construction —
+/// no consumer reads lib flow and ambient files have no executable code).
+#[must_use]
+pub fn build_flow<'a>(program: &Program<'_>, source: &'a str, bound: &'a BoundFile) -> FlowProduct {
+ let mut b = FlowBuilder::new(bound, source);
+ b.run(program);
+ b.finish()
+}
+
+// --- FlowBuilder -----------------------------------------------------------
+
+/// Saved control-flow state restored at a flow-container boundary
+/// (binder.go:1517-1524, the F1b subset — `activeLabelList` / `seenThisKeyword`
+/// stay F2/unported). The true/false targets are **not** in tsgo's container
+/// save set; F1b adds them (see the module header — the pointer-free
+/// `isTopLevelLogicalExpression` heuristic needs a container to reset the
+/// condition context).
+struct SavedFlow {
+ current_flow: FlowNodeId,
+ current_return_target: Option,
+ current_exception_target: Option,
+ current_break_target: Option,
+ current_continue_target: Option,
+ current_true_target: Option,
+ current_false_target: Option,
+ has_explicit_return: bool,
+ /// `saveActiveLabelList` (binder.go:1522) — the active labeled statements,
+ /// cleared at every control-flow container (a label can't be jumped to from a
+ /// nested function, even a flow-transparent IIFE) and restored on exit.
+ active_label_list: Vec,
+}
+
+/// An entry in the active-label stack (`ActiveLabel`, binder.go:85-94), used LIFO
+/// (innermost last). The name is recovered on demand from the label identifier's
+/// span (`spans[label_node_id]`) rather than stored owned.
+struct ActiveLabelEntry {
+ /// The label's post-statement break target (`postStatementLabel`).
+ break_target: FlowNodeId,
+ /// The continue target, set by `set_continue_target` when the label directly
+ /// encloses a loop (`None` for a label on a non-loop statement).
+ continue_target: Option,
+ /// Whether a labeled `break`/`continue` resolved to this label — an
+ /// unreferenced label's identifier gets the `Unreachable` stamp (the TS7028
+ /// signal, binder.go:2167).
+ referenced: bool,
+ /// The label identifier's `NodeId` (the `Unreachable`-stamp target + the
+ /// name-lookup key).
+ label_node_id: NodeId,
+}
+
+/// The flow-graph construction walk.
+pub(super) struct FlowBuilder<'a> {
+ bound: &'a BoundFile,
+ /// The host document — the label-name lookup extracts `spans[id]` slices.
+ source: &'a str,
+
+ // graph columns
+ pub(super) flags: Vec,
+ subject: Vec,
+ antecedent: Vec,
+ pool: Vec,
+
+ /// Per-active-label scratch antecedent lists, keyed by the label's
+ /// `FlowNodeId`, flushed to `pool` at `finish_flow_label`
+ /// (the `newFlowList` cons-list analog).
+ label_scratch: tsv_lang::FxHashMap>,
+
+ // products
+ flow_of_node: Vec>,
+ node_flags: Vec,
+ end_flow: Vec<(NodeId, FlowNodeId)>,
+ return_flow: Vec<(NodeId, FlowNodeId)>,
+ /// Case-clause fallthrough anchors (`FallthroughFlowNode`, binder.go:2121),
+ /// sorted by `NodeId` in `finish()` like `end_flow`/`return_flow`.
+ fallthrough_flow: Vec<(NodeId, FlowNodeId)>,
+ /// Switch-clause payloads (`createFlowSwitchClause`); a `SwitchClause` node's
+ /// `subject` slot is a 1-based index into this.
+ switch_payloads: Vec,
+ /// Reduce-label payloads (`createReduceLabel`, try/finally); a `ReduceLabel`
+ /// node's `subject` slot is a 1-based index into this.
+ reduce_payloads: Vec,
+ /// The active labeled-statement stack (`activeLabelList`), used LIFO —
+ /// innermost is the last element. Saved/cleared/restored at every container.
+ active_label_list: Vec,
+
+ // construction state (the F1b subset of the container-boundary set)
+ current_flow: FlowNodeId,
+ pub(super) unreachable_flow: FlowNodeId,
+ current_return_target: Option,
+ /// Always `None` in F1b (`createFlowMutation` reads it; only try/finally sets
+ /// it, which is F2), but ported so the exception hook is faithful.
+ current_exception_target: Option,
+ /// Unlabeled-`break` / `continue` targets (binder.go:1546-1547) — set by the
+ /// loop/switch binders, `None` outside a loop/switch, reset at a container.
+ current_break_target: Option,
+ current_continue_target: Option,
+ /// `preSwitchCaseFlow` (binder.go:67) — the switch-head flow every clause
+ /// forks from. Set by `bind_switch_statement` after the discriminant is
+ /// bound, saved/restored there (not in the container set — it is only live
+ /// while binding a switch's case block), `None` otherwise.
+ pre_switch_case_flow: Option,
+ /// The condition-branch targets (binder.go:1790-1793). Set only inside
+ /// `do_with_conditional_branches` and swapped by the `!`-prefix; their
+ /// `Some`-ness is the pointer-free `isTopLevelLogicalExpression` signal (see
+ /// the module header). Reset at a container so a nested function body binds
+ /// its own logicals as top-level.
+ current_true_target: Option,
+ current_false_target: Option,
+ /// `hasExplicitReturn` (binder.go:1549) — set by `return`, saved+reset at a
+ /// container. Dark plumbing in F1b (the `HasExplicitReturn` node-flag write is
+ /// F3-consumed reachability), ported for the faithful container-boundary set.
+ has_explicit_return: bool,
+ /// `hasFlowEffects` (binder.go:501/516) — set by `createFlowMutation` /
+ /// `createFlowCall` / `return` / `throw` / `break` / `continue`; read by the
+ /// logical/conditional post-label save/restore family to decide whether a
+ /// post-expression label materializes. Not saved at a container (the family
+ /// wrappers always reset-then-`OR`, isolating each subtree).
+ has_flow_effects: bool,
+
+ // stats
+ branch_labels: u32,
+ dead_labels: u32,
+}
+
+impl<'a> FlowBuilder<'a> {
+ pub(super) fn new(bound: &'a BoundFile, source: &'a str) -> FlowBuilder<'a> {
+ let n = bound.node_count as usize;
+ let mut b = FlowBuilder {
+ bound,
+ source,
+ flags: Vec::new(),
+ subject: Vec::new(),
+ antecedent: Vec::new(),
+ pool: Vec::new(),
+ label_scratch: tsv_lang::FxHashMap::default(),
+ flow_of_node: vec![None; n],
+ node_flags: vec![0u8; n],
+ end_flow: Vec::new(),
+ return_flow: Vec::new(),
+ fallthrough_flow: Vec::new(),
+ switch_payloads: Vec::new(),
+ reduce_payloads: Vec::new(),
+ active_label_list: Vec::new(),
+ current_flow: FlowNodeId::UNREACHABLE,
+ unreachable_flow: FlowNodeId::UNREACHABLE,
+ current_return_target: None,
+ current_exception_target: None,
+ current_break_target: None,
+ current_continue_target: None,
+ pre_switch_case_flow: None,
+ current_true_target: None,
+ current_false_target: None,
+ has_explicit_return: false,
+ has_flow_effects: false,
+ branch_labels: 0,
+ dead_labels: 0,
+ };
+ // Mint the unreachableFlow singleton FIRST → id 1 by construction
+ // (binder.go:126); tsgo's pointer-identity test becomes id equality.
+ b.unreachable_flow = b.new_flow_node(FlowFlags::UNREACHABLE);
+ debug_assert_eq!(b.unreachable_flow, FlowNodeId::UNREACHABLE);
+ b.current_flow = b.unreachable_flow;
+ b
+ }
+
+ pub(super) fn finish(mut self) -> FlowProduct {
+ // Flush any label whose antecedents still live in scratch: the **loop
+ // labels** (`preWhile`/`preDo`/`preLoop` — referenced via their condition
+ // flow and a back/continue edge, but the loop binders never call
+ // `finish_flow_label` on them since a back edge can be added after the label
+ // is already used, so their entry + back edges never reach the pool via the
+ // collapse path), AND the **un-finished value-context post labels** — a
+ // top-level logical / conditional whose subtree had no flow effects keeps
+ // `current_flow` at the saved pre-expression flow and never finishes its
+ // `post` label, leaving a dead, unreferenced row (matching tsgo's
+ // un-finished label object). Deterministic order (sort by id) so the pool
+ // layout is reproducible; the per-label edge order is push-order.
+ let mut pending: Vec = self.label_scratch.keys().copied().collect();
+ pending.sort_unstable();
+ for label in pending {
+ let list = self.label_scratch.remove(&label).unwrap_or_default();
+ if list.is_empty() {
+ continue;
+ }
+ let off = self.pool.len() as u32;
+ self.pool.push(list.len() as u32);
+ self.pool.extend(list.iter().map(|e| e.get()));
+ self.antecedent[label.index()] = off + 1; // 1-based pool-run index
+ }
+ let mut end_flow = self.end_flow;
+ let mut return_flow = self.return_flow;
+ let mut fallthrough_flow = self.fallthrough_flow;
+ end_flow.sort_unstable_by_key(|&(n, _)| n);
+ return_flow.sort_unstable_by_key(|&(n, _)| n);
+ fallthrough_flow.sort_unstable_by_key(|&(n, _)| n);
+ FlowProduct {
+ graph: FlowGraph {
+ flags: self.flags,
+ subject: self.subject,
+ antecedent: self.antecedent,
+ pool: self.pool,
+ switch_payloads: self.switch_payloads,
+ reduce_payloads: self.reduce_payloads,
+ },
+ flow_of_node: self.flow_of_node,
+ node_flags: self.node_flags,
+ end_flow,
+ return_flow,
+ fallthrough_flow,
+ stats: FlowStats {
+ branch_labels: self.branch_labels,
+ dead_labels: self.dead_labels,
+ },
+ }
+ }
+
+ // --- flow node constructors (binder.go:454-575) -----------------------
+
+ /// `newFlowNode` (binder.go:454) — a bare node with only flags.
+ pub(super) fn new_flow_node(&mut self, flags: FlowFlags) -> FlowNodeId {
+ let id = FlowNodeId::from_index(self.flags.len());
+ self.flags.push(flags);
+ self.subject.push(0);
+ self.antecedent.push(0);
+ id
+ }
+
+ /// `newFlowNodeEx` (binder.go:460) — a node with a subject + single
+ /// antecedent.
+ fn new_flow_node_ex(
+ &mut self,
+ flags: FlowFlags,
+ subject: Option,
+ antecedent: FlowNodeId,
+ ) -> FlowNodeId {
+ let id = self.new_flow_node(flags);
+ self.subject[id.index()] = subject.map_or(0, NodeId::get);
+ self.antecedent[id.index()] = antecedent.get();
+ id
+ }
+
+ /// `createBranchLabel` (binder.go:471).
+ pub(super) fn create_branch_label(&mut self) -> FlowNodeId {
+ self.branch_labels += 1;
+ self.new_flow_node(FlowFlags::BRANCH_LABEL)
+ }
+
+ /// `createLoopLabel` (binder.go:467).
+ fn create_loop_label(&mut self) -> FlowNodeId {
+ self.new_flow_node(FlowFlags::LOOP_LABEL)
+ }
+
+ /// `createFlowMutation` (binder.go:499). The `currentExceptionTarget` hook
+ /// is a no-op in F1b (that field is always `None`; try/finally sets it, F2).
+ /// Sets `hasFlowEffects` (binder.go:501) — the condition/logical post-label
+ /// family reads it to decide whether a post-expression label materializes.
+ fn create_flow_mutation(
+ &mut self,
+ flags: FlowFlags,
+ antecedent: FlowNodeId,
+ node: NodeId,
+ ) -> FlowNodeId {
+ self.set_flow_node_referenced(antecedent);
+ self.has_flow_effects = true;
+ let result = self.new_flow_node_ex(flags, Some(node), antecedent);
+ if let Some(target) = self.current_exception_target {
+ self.add_antecedent(target, result);
+ }
+ result
+ }
+
+ /// `createFlowCall` (binder.go:514). Sets `hasFlowEffects = true`
+ /// (binder.go:516) — see `create_flow_mutation`.
+ fn create_flow_call(&mut self, antecedent: FlowNodeId, node: NodeId) -> FlowNodeId {
+ self.set_flow_node_referenced(antecedent);
+ self.has_flow_effects = true;
+ self.new_flow_node_ex(FlowFlags::CALL, Some(node), antecedent)
+ }
+
+ /// `createFlowSwitchClause` (binder.go:509) — a `SwitchClause` flow node
+ /// carrying the switch node + the matched half-open `[clause_start,
+ /// clause_end)` clause range as a `FlowSwitchClause` payload. The `subject`
+ /// slot holds a **1-based index** into `switch_payloads` (not a `NodeId`) —
+ /// read via [`FlowGraph::switch_clause_data`], never [`FlowGraph::subject`].
+ /// Unlike the mutation/call constructors this does **not** set
+ /// `hasFlowEffects` (a switch clause is a junction, not an effect).
+ fn create_flow_switch_clause(
+ &mut self,
+ antecedent: FlowNodeId,
+ switch: NodeId,
+ clause_start: u32,
+ clause_end: u32,
+ ) -> FlowNodeId {
+ self.set_flow_node_referenced(antecedent);
+ self.switch_payloads.push(FlowSwitchClause {
+ switch,
+ clause_start,
+ clause_end,
+ });
+ let payload_index = self.switch_payloads.len() as u32; // 1-based
+ let id = self.new_flow_node(FlowFlags::SWITCH_CLAUSE);
+ self.subject[id.index()] = payload_index;
+ self.antecedent[id.index()] = antecedent.get();
+ id
+ }
+
+ /// `createReduceLabel` (binder.go:475) — a `ReduceLabel` node carrying a
+ /// `target` label + a snapshot of a **reduced** antecedent list (flushed to
+ /// the pool as a length-prefixed run, like a label). Unlike every other flow
+ /// constructor this does **not** `setFlowNodeReferenced` its antecedent (tsgo
+ /// `newFlowNodeEx` without the reference bump). The `subject` slot holds a
+ /// **1-based index** into `reduce_payloads` (not a `NodeId`) — read via
+ /// [`FlowGraph::reduce_label_data`], never [`FlowGraph::subject`].
+ fn create_reduce_label(
+ &mut self,
+ target: FlowNodeId,
+ antecedents_snapshot: &[FlowNodeId],
+ antecedent: FlowNodeId,
+ ) -> FlowNodeId {
+ // Flush the reduced antecedent snapshot as a length-prefixed pool run.
+ let off = self.pool.len() as u32;
+ self.pool.push(antecedents_snapshot.len() as u32);
+ self.pool
+ .extend(antecedents_snapshot.iter().map(|e| e.get()));
+ self.reduce_payloads.push(FlowReduceLabel {
+ target,
+ antecedents: off + 1, // 1-based pool-run index
+ });
+ let payload_index = self.reduce_payloads.len() as u32; // 1-based
+ let id = self.new_flow_node(FlowFlags::REDUCE_LABEL);
+ self.subject[id.index()] = payload_index;
+ self.antecedent[id.index()] = antecedent.get();
+ id
+ }
+
+ /// `createFlowCondition` (binder.go:479) — the condition-binding constructor.
+ /// The `expression.Parent` guards (optional-chain root / nullish coalesce) are
+ /// supplied by the caller, which has the parent context tsv's AST does not
+ /// carry on an `Expression`; `is_narrowing` is the caller's
+ /// `is_narrowing_expression` verdict.
+ pub(super) fn create_flow_condition(
+ &mut self,
+ flags: FlowFlags,
+ antecedent: FlowNodeId,
+ expression: Option<(&Expression<'_>, NodeId)>,
+ is_narrowing: bool,
+ is_optional_chain_root: bool,
+ parent_is_nullish: bool,
+ ) -> FlowNodeId {
+ if self.flags[antecedent.index()].contains(FlowFlags::UNREACHABLE) {
+ return antecedent;
+ }
+ let Some((expr, expr_id)) = expression else {
+ return if flags.contains(FlowFlags::TRUE_CONDITION) {
+ antecedent
+ } else {
+ self.unreachable_flow
+ };
+ };
+ if (is_true_keyword(expr) && flags.contains(FlowFlags::FALSE_CONDITION)
+ || is_false_keyword(expr) && flags.contains(FlowFlags::TRUE_CONDITION))
+ && !is_optional_chain_root
+ && !parent_is_nullish
+ {
+ return self.unreachable_flow;
+ }
+ if !is_narrowing {
+ return antecedent;
+ }
+ self.set_flow_node_referenced(antecedent);
+ self.new_flow_node_ex(flags, Some(expr_id), antecedent)
+ }
+
+ /// `setFlowNodeReferenced` (binder.go:538) — first reference sets
+ /// `Referenced`, thereafter `Shared`.
+ fn set_flow_node_referenced(&mut self, flow: FlowNodeId) {
+ let f = &mut self.flags[flow.index()];
+ if f.contains(FlowFlags::REFERENCED) {
+ f.insert(FlowFlags::SHARED);
+ } else {
+ f.insert(FlowFlags::REFERENCED);
+ }
+ }
+
+ /// `addAntecedent` (binder.go:547) — order-preserving, first-write-wins
+ /// **id-equality** dedup append; unreachable edges are dropped;
+ /// `setFlowNodeReferenced` fires only on a genuine append.
+ pub(super) fn add_antecedent(&mut self, label: FlowNodeId, antecedent: FlowNodeId) {
+ if self.flags[antecedent.index()].contains(FlowFlags::UNREACHABLE) {
+ return;
+ }
+ let list = self.label_scratch.entry(label).or_default();
+ if list.contains(&antecedent) {
+ return;
+ }
+ list.push(antecedent);
+ self.set_flow_node_referenced(antecedent);
+ }
+
+ /// `finishFlowLabel` (binder.go:567) — 0 antecedents → `unreachableFlow`
+ /// (a dead label row), exactly 1 → the antecedent itself (the label never
+ /// enters the graph, dead row), 2+ → flush the run to the pool and keep the
+ /// label.
+ pub(super) fn finish_flow_label(&mut self, label: FlowNodeId) -> FlowNodeId {
+ let list = self.label_scratch.remove(&label).unwrap_or_default();
+ match list.as_slice() {
+ [] => {
+ self.dead_labels += 1;
+ self.unreachable_flow
+ }
+ [single] => {
+ self.dead_labels += 1;
+ *single
+ }
+ edges => {
+ let off = self.pool.len() as u32;
+ self.pool.push(edges.len() as u32);
+ self.pool.extend(edges.iter().map(|e| e.get()));
+ self.antecedent[label.index()] = off + 1; // 1-based pool-run index
+ label
+ }
+ }
+ }
+
+ // --- helpers ----------------------------------------------------------
+
+ #[inline]
+ fn require(&self, address: usize, kind: NodeKind) -> NodeId {
+ self.bound.require_node_id(address, kind)
+ }
+
+ #[inline]
+ fn current_unreachable(&self) -> bool {
+ self.current_flow == self.unreachable_flow
+ }
+
+ /// Stamp `flow_of_node[id] = current_flow` (a leaf write — unconditional,
+ /// so a dead leaf keeps `Some(unreachable)`, matching tsgo's token nodes
+ /// that bypass `bindChildren`).
+ #[inline]
+ fn set_flow_leaf(&mut self, id: NodeId) {
+ self.flow_of_node[id.index()] = Some(self.current_flow);
+ }
+
+ /// Stamp `flow_of_node[id]` for a **non-leaf** node whose bind()-switch
+ /// write is nil'd by `bindChildren` in dead code — so it lands only when
+ /// reachable (dead → left `None`).
+ #[inline]
+ fn set_flow_nonleaf(&mut self, id: NodeId) {
+ if !self.current_unreachable() {
+ self.flow_of_node[id.index()] = Some(self.current_flow);
+ }
+ }
+
+ // --- container save/restore (binder.go:1516-1591, F1 subset) ----------
+
+ /// Enter a control-flow container: fresh `Start` (unless flow-transparent),
+ /// optional return target, exception target reset.
+ fn enter_container(
+ &mut self,
+ start_subject: Option,
+ transparent: bool,
+ wants_return_target: bool,
+ ) -> SavedFlow {
+ let saved = SavedFlow {
+ current_flow: self.current_flow,
+ current_return_target: self.current_return_target,
+ current_exception_target: self.current_exception_target,
+ current_break_target: self.current_break_target,
+ current_continue_target: self.current_continue_target,
+ current_true_target: self.current_true_target,
+ current_false_target: self.current_false_target,
+ has_explicit_return: self.has_explicit_return,
+ // Cleared even for a flow-transparent IIFE: a label outside the
+ // callee can't be `break`/`continue`-targeted from inside it.
+ active_label_list: std::mem::take(&mut self.active_label_list),
+ };
+ if !transparent {
+ let start = self.new_flow_node(FlowFlags::START);
+ if let Some(s) = start_subject {
+ self.subject[start.index()] = s.get();
+ }
+ self.current_flow = start;
+ }
+ self.current_return_target = if wants_return_target {
+ Some(self.create_branch_label())
+ } else {
+ None
+ };
+ self.current_exception_target = None;
+ self.current_break_target = None;
+ self.current_continue_target = None;
+ // Reset the condition context so a nested body binds its own logicals as
+ // top-level (see the module header — the pointer-free
+ // `isTopLevelLogicalExpression` heuristic). tsgo leaves these untouched.
+ self.current_true_target = None;
+ self.current_false_target = None;
+ self.has_explicit_return = false;
+ saved
+ }
+
+ /// Exit a control-flow container: the postlude (end-of-flow anchor, return
+ /// target merge, restore). `is_ctor_or_static` gates the `return_flow`
+ /// anchor; `function_like && body_present` gates `end_flow`.
+ fn exit_container(
+ &mut self,
+ saved: SavedFlow,
+ transparent: bool,
+ function_like: bool,
+ body_present: bool,
+ anchor: NodeId,
+ is_ctor_or_static: bool,
+ ) {
+ if !self.current_unreachable() && function_like && body_present {
+ self.end_flow.push((anchor, self.current_flow));
+ }
+ if let Some(rt) = self.current_return_target {
+ self.add_antecedent(rt, self.current_flow);
+ self.current_flow = self.finish_flow_label(rt);
+ if is_ctor_or_static {
+ self.return_flow.push((anchor, self.current_flow));
+ }
+ }
+ if !transparent {
+ self.current_flow = saved.current_flow;
+ }
+ self.current_return_target = saved.current_return_target;
+ self.current_exception_target = saved.current_exception_target;
+ self.current_break_target = saved.current_break_target;
+ self.current_continue_target = saved.current_continue_target;
+ self.current_true_target = saved.current_true_target;
+ self.current_false_target = saved.current_false_target;
+ self.has_explicit_return = saved.has_explicit_return;
+ self.active_label_list = saved.active_label_list;
+ }
+
+ // --- entry (SourceFile container) -------------------------------------
+
+ fn run(&mut self, program: &Program<'_>) {
+ // The SourceFile is a control-flow container: fresh Start (id 2), no
+ // return target (not an IIFE/constructor), no Start subject.
+ let root = self.require(addr_of(program), NodeKind::Program);
+ let start = self.new_flow_node(FlowFlags::START);
+ self.current_flow = start;
+ self.current_return_target = None;
+ self.current_exception_target = None;
+ self.current_break_target = None;
+ self.current_continue_target = None;
+ self.current_true_target = None;
+ self.current_false_target = None;
+ self.has_explicit_return = false;
+ self.visit_statement_list(program.body);
+ // SourceFile end_flow is unconditional (binder.go:1567-1569).
+ self.end_flow.push((root, self.current_flow));
+ }
+
+ // --- statement lists (functions-first, binder.go:1766) ----------------
+
+ fn visit_statement_list(&mut self, stmts: &[Statement<'_>]) {
+ for stmt in stmts {
+ if matches!(stmt, Statement::FunctionDeclaration(_)) {
+ self.visit_statement(stmt);
+ }
+ }
+ for stmt in stmts {
+ if !matches!(stmt, Statement::FunctionDeclaration(_)) {
+ self.visit_statement(stmt);
+ }
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/build/predicates.rs b/crates/tsv_check/src/binder/flow/build/predicates.rs
new file mode 100644
index 000000000..68560154f
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/build/predicates.rs
@@ -0,0 +1,318 @@
+//! The pure AST predicates the flow walk dispatches on (`binder.go` /
+//! `utilities.go` ports) — free functions with no `FlowBuilder` state, factored
+//! out of the visitor modules. Purely a locality split — no behavior change.
+
+use tsv_ts::ast::internal::{
+ AssignmentOperator, BinaryExpression, BinaryOperator, Expression, LiteralValue, Statement,
+ UnaryOperator,
+};
+
+/// `is_potentially_executable` (utilities.go:4210) — the statement range (minus
+/// `Block`/`Empty`, which are below the range), with `VariableStatement` gated
+/// on block-scoping or an initializer, plus class/enum/module declarations.
+pub(super) fn is_potentially_executable(stmt: &Statement<'_>) -> bool {
+ use Statement as S;
+ match stmt {
+ S::ExpressionStatement(_)
+ | S::IfStatement(_)
+ | S::DoWhileStatement(_)
+ | S::WhileStatement(_)
+ | S::ForStatement(_)
+ | S::ForInStatement(_)
+ | S::ForOfStatement(_)
+ | S::ContinueStatement(_)
+ | S::BreakStatement(_)
+ | S::ReturnStatement(_)
+ | S::SwitchStatement(_)
+ | S::LabeledStatement(_)
+ | S::ThrowStatement(_)
+ | S::TryStatement(_)
+ | S::DebuggerStatement(_) => true,
+ S::VariableDeclaration(d) => {
+ use tsv_ts::ast::internal::VariableDeclarationKind as K;
+ d.kind != K::Var || d.declarations.iter().any(|decl| decl.init.is_some())
+ }
+ S::ClassDeclaration(_) | S::TSEnumDeclaration(_) | S::TSModuleDeclaration(_) => true,
+ _ => false,
+ }
+}
+
+/// Whether a statement kind is in tsc's `[FirstStatement, LastStatement]` range
+/// (binder.go:1663) — the entry-flow write set. Excludes `Block`/`Empty` (below
+/// the range) and every declaration kind (above it).
+pub(super) fn is_statement_range(stmt: &Statement<'_>) -> bool {
+ use Statement as S;
+ matches!(
+ stmt,
+ S::ExpressionStatement(_)
+ | S::VariableDeclaration(_)
+ | S::IfStatement(_)
+ | S::DoWhileStatement(_)
+ | S::WhileStatement(_)
+ | S::ForStatement(_)
+ | S::ForInStatement(_)
+ | S::ForOfStatement(_)
+ | S::ContinueStatement(_)
+ | S::BreakStatement(_)
+ | S::ReturnStatement(_)
+ | S::SwitchStatement(_)
+ | S::LabeledStatement(_)
+ | S::ThrowStatement(_)
+ | S::TryStatement(_)
+ | S::DebuggerStatement(_)
+ )
+}
+
+/// `IsDottedName` (utilities.go:1613).
+pub(super) fn is_dotted_name(expr: &Expression<'_>) -> bool {
+ use Expression as E;
+ match expr {
+ E::Identifier(_) | E::ThisExpression(_) | E::Super(_) | E::MetaProperty(_) => true,
+ E::MemberExpression(m) if !m.computed => is_dotted_name(m.object),
+ E::ParenthesizedExpression(p) => is_dotted_name(p.expression),
+ _ => false,
+ }
+}
+
+/// `isNarrowableReference` (binder.go:2633) — the access flow-write gate.
+/// Adapted to tsv's AST (tsc's comma/assignment `BinaryExpression` cases are
+/// tsv's `SequenceExpression` / `AssignmentExpression`).
+pub(in crate::binder::flow) fn is_narrowable_reference(node: &Expression<'_>) -> bool {
+ use Expression as E;
+ match node {
+ E::Identifier(_) | E::ThisExpression(_) | E::Super(_) | E::MetaProperty(_) => true,
+ E::MemberExpression(m) if !m.computed => is_narrowable_reference(m.object),
+ E::ParenthesizedExpression(p) => is_narrowable_reference(p.expression),
+ E::TSNonNullExpression(t) => is_narrowable_reference(t.expression),
+ E::MemberExpression(m) => {
+ // computed element access
+ is_string_or_numeric_literal_like(m.property)
+ || (is_entity_name_expression(m.property) && is_narrowable_reference(m.object))
+ }
+ E::AssignmentExpression(a) => is_left_hand_side_expression(a.left),
+ E::SequenceExpression(s) => s.expressions.last().is_some_and(is_narrowable_reference),
+ _ => false,
+ }
+}
+
+fn is_string_or_numeric_literal_like(node: &Expression<'_>) -> bool {
+ matches!(
+ node,
+ Expression::Literal(l) if matches!(l.value, LiteralValue::String(_) | LiteralValue::Number(_))
+ )
+}
+
+/// `IsEntityNameExpression` (utilities.go:1595) — an identifier or a dotted
+/// property-access chain bottoming in one.
+fn is_entity_name_expression(node: &Expression<'_>) -> bool {
+ use Expression as E;
+ match node {
+ E::Identifier(_) => true,
+ E::MemberExpression(m) if !m.computed => {
+ matches!(m.property, E::Identifier(_)) && is_entity_name_expression(m.object)
+ }
+ _ => false,
+ }
+}
+
+/// `isLeftHandSideExpressionKind` (utilities.go:396) — the postfix/primary
+/// expression forms. Reached only via the rare `(x = y).z` narrowable case.
+fn is_left_hand_side_expression(node: &Expression<'_>) -> bool {
+ use Expression as E;
+ matches!(
+ node,
+ E::MemberExpression(_)
+ | E::NewExpression(_)
+ | E::CallExpression(_)
+ | E::TaggedTemplateExpression(_)
+ | E::ArrayExpression(_)
+ | E::ParenthesizedExpression(_)
+ | E::ObjectExpression(_)
+ | E::ClassExpression(_)
+ | E::FunctionExpression(_)
+ | E::Identifier(_)
+ | E::PrivateIdentifier(_)
+ | E::RegexLiteral(_)
+ | E::Literal(_)
+ | E::TemplateLiteral(_)
+ | E::ThisExpression(_)
+ | E::Super(_)
+ | E::TSNonNullExpression(_)
+ | E::MetaProperty(_)
+ | E::ImportExpression(_)
+ )
+}
+
+pub(super) fn is_true_keyword(expr: &Expression<'_>) -> bool {
+ matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(true)))
+}
+
+pub(super) fn is_false_keyword(expr: &Expression<'_>) -> bool {
+ matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(false)))
+}
+
+/// Whether a condition node is a logical `&&`/`||`/`??` or a logical
+/// compound-assignment `&&=`/`||=`/`??=` — the `bindCondition` non-atomic test
+/// (binder.go:1801, combining `IsLogicalExpression` + `isLogicalAssignment`).
+/// Such a node's sub-binder already wired the true/false targets, so
+/// `bindCondition` must NOT re-add the atomic true/false conditions.
+pub(super) fn is_logical_condition(e: &Expression<'_>) -> bool {
+ match e {
+ Expression::BinaryExpression(b) => b.operator.is_logical(),
+ Expression::AssignmentExpression(a) => is_logical_assign_op(a.operator),
+ _ => false,
+ }
+}
+
+/// Whether an expression **threads** the enclosing condition targets into its
+/// operands (vs being a value boundary that resets them). Mirrors the four
+/// threading arms of `visit_expression`: `!`, `&&`/`||`/`??`, logical-assignment,
+/// and parentheses — the same set tsgo's `isTopLevelLogicalExpression`
+/// (binder.go:2782) ascends through. Every other expression is a value
+/// sub-position (see the reset in `visit_expression`).
+pub(super) fn is_condition_threading(e: &Expression<'_>) -> bool {
+ match e {
+ Expression::UnaryExpression(u) => u.operator == UnaryOperator::Bang,
+ Expression::ParenthesizedExpression(_) => true,
+ _ => is_logical_condition(e),
+ }
+}
+
+/// Whether an assignment operator is a logical compound-assignment
+/// (`||=`/`&&=`/`??=`) — `IsLogicalOrCoalescingAssignmentOperator`.
+pub(super) fn is_logical_assign_op(op: AssignmentOperator) -> bool {
+ matches!(
+ op,
+ AssignmentOperator::LogicalOrAssign
+ | AssignmentOperator::LogicalAndAssign
+ | AssignmentOperator::NullishAssign
+ )
+}
+
+/// `isNarrowingExpression` (binder.go:2602) — the `createFlowCondition` gate.
+/// Adapted to tsv's AST: comma / assignment are their own `SequenceExpression` /
+/// `AssignmentExpression` nodes (tsc folds them into `BinaryExpression`), so their
+/// `isNarrowingBinaryExpression` cases move here.
+pub(super) fn is_narrowing_expression(expr: &Expression<'_>) -> bool {
+ use Expression as E;
+ match expr {
+ E::Identifier(_) | E::ThisExpression(_) => true,
+ E::MemberExpression(_) => contains_narrowable_reference(expr),
+ E::CallExpression(c) => {
+ c.arguments.iter().any(contains_narrowable_reference)
+ || matches!(c.callee, E::MemberExpression(m)
+ if !m.computed && contains_narrowable_reference(m.object))
+ }
+ E::ParenthesizedExpression(p) => is_narrowing_expression(p.expression),
+ E::TSNonNullExpression(t) => is_narrowing_expression(t.expression),
+ E::UnaryExpression(u)
+ if u.operator == UnaryOperator::Typeof || u.operator == UnaryOperator::Bang =>
+ {
+ is_narrowing_expression(u.argument)
+ }
+ E::BinaryExpression(b) => is_narrowing_binary_expression(b),
+ // The `isNarrowingBinaryExpression` assignment cases (`=`/`||=`/`&&=`/`??=`
+ // → containsNarrowableReference(left)); other compound assignments are not
+ // narrowing.
+ E::AssignmentExpression(a) => {
+ matches!(
+ a.operator,
+ AssignmentOperator::Assign
+ | AssignmentOperator::LogicalOrAssign
+ | AssignmentOperator::LogicalAndAssign
+ | AssignmentOperator::NullishAssign
+ ) && contains_narrowable_reference(a.left)
+ }
+ // The `isNarrowingBinaryExpression` comma case (`isNarrowingExpression`
+ // of the last operand).
+ E::SequenceExpression(s) => s.expressions.last().is_some_and(is_narrowing_expression),
+ _ => false,
+ }
+}
+
+/// `containsNarrowableReference` (binder.go:2620) — a narrowable reference, or an
+/// optional-chain node whose object/callee contains one.
+fn contains_narrowable_reference(expr: &Expression<'_>) -> bool {
+ if is_narrowable_reference(expr) {
+ return true;
+ }
+ match expr {
+ Expression::MemberExpression(m) if expr.has_optional_in_chain() => {
+ contains_narrowable_reference(m.object)
+ }
+ Expression::CallExpression(c) if expr.has_optional_in_chain() => {
+ contains_narrowable_reference(c.callee)
+ }
+ Expression::TSNonNullExpression(n) if expr.has_optional_in_chain() => {
+ contains_narrowable_reference(n.expression)
+ }
+ _ => false,
+ }
+}
+
+/// `isNarrowingBinaryExpression` (binder.go:2666) for tsv's `BinaryExpression`
+/// (which never carries the comma / assignment operators — those are separate
+/// nodes, handled in `is_narrowing_expression`).
+fn is_narrowing_binary_expression(b: &BinaryExpression<'_>) -> bool {
+ use BinaryOperator as Op;
+ match b.operator {
+ Op::EqualsEquals | Op::BangEquals | Op::EqualsEqualsEquals | Op::BangEqualsEquals => {
+ let left = skip_parens(b.left);
+ let right = skip_parens(b.right);
+ is_narrowable_operand(left)
+ || is_narrowable_operand(right)
+ || is_narrowing_typeof_operands(right, left)
+ || is_narrowing_typeof_operands(left, right)
+ || (is_boolean_literal(right) && is_narrowing_expression(left))
+ || (is_boolean_literal(left) && is_narrowing_expression(right))
+ }
+ Op::Instanceof => is_narrowable_operand(b.left),
+ Op::In => is_narrowing_expression(b.right),
+ _ => false,
+ }
+}
+
+/// `isNarrowableOperand` (binder.go:2686).
+fn is_narrowable_operand(expr: &Expression<'_>) -> bool {
+ match expr {
+ Expression::ParenthesizedExpression(p) => is_narrowable_operand(p.expression),
+ Expression::AssignmentExpression(a) if a.operator == AssignmentOperator::Assign => {
+ is_narrowable_operand(a.left)
+ }
+ Expression::SequenceExpression(s) => {
+ s.expressions.last().is_some_and(is_narrowable_operand)
+ }
+ _ => contains_narrowable_reference(expr),
+ }
+}
+
+/// `isNarrowingTypeOfOperands` (binder.go:2702) — `typeof === `.
+fn is_narrowing_typeof_operands(expr1: &Expression<'_>, expr2: &Expression<'_>) -> bool {
+ matches!(expr1, Expression::UnaryExpression(u)
+ if u.operator == UnaryOperator::Typeof && is_narrowable_operand(u.argument))
+ && is_string_literal_like(expr2)
+}
+
+/// `IsStringLiteralLike` — a string literal or a no-substitution template.
+fn is_string_literal_like(e: &Expression<'_>) -> bool {
+ match e {
+ Expression::Literal(l) => matches!(l.value, LiteralValue::String(_)),
+ Expression::TemplateLiteral(t) => t.expressions.is_empty(),
+ _ => false,
+ }
+}
+
+/// `IsBooleanLiteral` — a `true` / `false` keyword literal.
+fn is_boolean_literal(e: &Expression<'_>) -> bool {
+ matches!(e, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(_)))
+}
+
+/// `SkipParentheses` — strip grouping `ParenthesizedExpression` wrappers (rare in
+/// tsv, which discards grouping parens except under `preserve_parens`).
+fn skip_parens<'a, 'arena>(e: &'a Expression<'arena>) -> &'a Expression<'arena> {
+ let mut e = e;
+ while let Expression::ParenthesizedExpression(p) = e {
+ e = p.expression;
+ }
+ e
+}
diff --git a/crates/tsv_check/src/binder/flow/build/statements.rs b/crates/tsv_check/src/binder/flow/build/statements.rs
new file mode 100644
index 000000000..5982f22e6
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/build/statements.rs
@@ -0,0 +1,933 @@
+//! The statement-shaped flow visitors — `visit_statement` and the per-statement
+//! flow shapers (conditions, loops, switch, try/finally, labeled statements),
+//! plus the declaration-container descents. Contributes its own
+//! `impl FlowBuilder` block; the struct and traversal driver live in the parent
+//! module. Purely a locality split — no behavior distinction.
+
+use super::super::*;
+use super::predicates::*;
+use super::{ActiveLabelEntry, FlowBuilder};
+use crate::binder::{NodeKind, addr_of, statement_kind};
+use smallvec::SmallVec;
+use tsv_ts::ast::internal::{
+ BreakStatement, ClassDeclaration, ClassMember, ContinueStatement, Decorator, DoWhileStatement,
+ Expression, ForInOfLeft, ForInit, ForStatement, FunctionDeclaration, Identifier, IfStatement,
+ LabeledStatement, MethodDefinition, MethodKind, ObjectPatternProperty, Statement, SwitchCase,
+ SwitchStatement, TSModuleDeclarationBody, TryStatement, VariableDeclarator, WhileStatement,
+};
+
+impl<'a> FlowBuilder<'a> {
+ // --- statements -------------------------------------------------------
+
+ pub(super) fn visit_statement(&mut self, stmt: &Statement<'_>) {
+ let id = self.require(addr_of(stmt), statement_kind(stmt));
+ if self.current_unreachable() {
+ // bindChildren dead path (binder.go:1651): the non-leaf statement's
+ // flow attachment is nil (already `None`); mark potentially-
+ // executable nodes; then descend generically (no flow shaping).
+ if is_potentially_executable(stmt) {
+ self.node_flags[id.index()] |= crate::binder::NODE_FLAGS_UNREACHABLE;
+ }
+ self.descend_children_generic(stmt);
+ return;
+ }
+ // Reachable: statement-range nodes capture the entry flow before the
+ // construct dispatches (binder.go:1663).
+ if is_statement_range(stmt) {
+ self.flow_of_node[id.index()] = Some(self.current_flow);
+ }
+ match stmt {
+ Statement::ExpressionStatement(s) => {
+ self.visit_expression(&s.expression);
+ self.maybe_bind_expression_flow_if_call(&s.expression);
+ }
+ Statement::VariableDeclaration(d) => {
+ for decl in d.declarations {
+ self.bind_variable_declaration_flow(decl);
+ }
+ }
+ Statement::ReturnStatement(s) => {
+ // `bindReturnStatement` (binder.go:1939).
+ if let Some(a) = &s.argument {
+ self.visit_expression(a);
+ }
+ if let Some(rt) = self.current_return_target {
+ self.add_antecedent(rt, self.current_flow);
+ }
+ self.current_flow = self.unreachable_flow;
+ self.has_explicit_return = true;
+ self.has_flow_effects = true;
+ }
+ Statement::ThrowStatement(s) => {
+ // `bindThrowStatement` (binder.go:1949).
+ self.visit_expression(&s.argument);
+ self.current_flow = self.unreachable_flow;
+ self.has_flow_effects = true;
+ }
+ // --- F1b: branching control-flow topology ---------------------
+ Statement::IfStatement(s) => self.bind_if_statement(s),
+ Statement::WhileStatement(s) => self.bind_while_statement(id, s),
+ Statement::DoWhileStatement(s) => self.bind_do_statement(id, s),
+ Statement::ForStatement(s) => self.bind_for_statement(id, s),
+ Statement::ForInStatement(s) => {
+ self.bind_for_in_or_of(id, &s.left, &s.right, s.body);
+ }
+ Statement::ForOfStatement(s) => {
+ self.bind_for_in_or_of(id, &s.left, &s.right, s.body);
+ }
+ Statement::BreakStatement(s) => self.bind_break_statement(s),
+ Statement::ContinueStatement(s) => self.bind_continue_statement(s),
+ Statement::SwitchStatement(s) => self.bind_switch_statement(id, s),
+ Statement::TryStatement(s) => self.bind_try_statement(s),
+ Statement::LabeledStatement(s) => self.bind_labeled_statement(s),
+ // Everything else (declarations, blocks, exports, modules) threads
+ // flow linearly through its children.
+ _ => self.descend_children_generic(stmt),
+ }
+ }
+
+ /// Descend a statement's value children threading `current_flow` linearly,
+ /// with **no** flow shaping — the `bindEachChild` analog. Used by the
+ /// **dead-code path** (where linear descent is correct — nothing is
+ /// reachable) for every statement kind, and by the reachable `_` arm for the
+ /// kinds without their own shaper (declarations, blocks, and the F2
+ /// sequential placeholders — labeled / try / exports / modules). The
+ /// branching arms below (`if` / the loops / `switch` / `break` / `continue`)
+ /// are therefore reached **only in dead code**; the reachable topology lives
+ /// in `visit_statement`. Containers nested here still open their own `Start`
+ /// regions, so a function body stays reachable even in dead code.
+ fn descend_children_generic(&mut self, stmt: &Statement<'_>) {
+ match stmt {
+ Statement::ExpressionStatement(s) => self.visit_expression(&s.expression),
+ Statement::VariableDeclaration(d) => {
+ for decl in d.declarations {
+ self.visit_expression(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expression(init);
+ }
+ }
+ }
+ Statement::FunctionDeclaration(f) => {
+ let id = self.require(addr_of(stmt), statement_kind(stmt));
+ self.visit_function_declaration(f, id);
+ }
+ Statement::ClassDeclaration(c) => self.visit_class_decl(c),
+ Statement::ReturnStatement(s) => {
+ if let Some(a) = &s.argument {
+ self.visit_expression(a);
+ }
+ }
+ Statement::ThrowStatement(s) => self.visit_expression(&s.argument),
+ Statement::BlockStatement(b) => self.visit_statement_list(b.body),
+ // --- dead-path linear descent for the branching kinds (their real
+ // topology lives in `visit_statement`; reached only when dead) ---
+ Statement::IfStatement(s) => {
+ self.visit_expression(&s.test);
+ self.visit_statement(s.consequent);
+ if let Some(alt) = s.alternate {
+ self.visit_statement(alt);
+ }
+ }
+ Statement::ForStatement(s) => {
+ match &s.init {
+ Some(ForInit::VariableDeclaration(d)) => {
+ for decl in d.declarations {
+ self.visit_expression(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expression(init);
+ }
+ }
+ }
+ Some(ForInit::Expression(e)) => self.visit_expression(e),
+ None => {}
+ }
+ if let Some(t) = &s.test {
+ self.visit_expression(t);
+ }
+ if let Some(u) = &s.update {
+ self.visit_expression(u);
+ }
+ self.visit_statement(s.body);
+ }
+ Statement::ForInStatement(s) => {
+ self.visit_for_left(&s.left);
+ self.visit_expression(&s.right);
+ self.visit_statement(s.body);
+ }
+ Statement::ForOfStatement(s) => {
+ self.visit_for_left(&s.left);
+ self.visit_expression(&s.right);
+ self.visit_statement(s.body);
+ }
+ Statement::WhileStatement(s) => {
+ self.visit_expression(&s.test);
+ self.visit_statement(s.body);
+ }
+ Statement::DoWhileStatement(s) => {
+ self.visit_statement(s.body);
+ self.visit_expression(&s.test);
+ }
+ Statement::SwitchStatement(s) => {
+ self.visit_expression(&s.discriminant);
+ for case in s.cases {
+ if let Some(t) = &case.test {
+ self.visit_expression(t);
+ }
+ self.visit_statement_list(case.consequent);
+ }
+ }
+ Statement::TryStatement(s) => {
+ self.visit_statement_list(s.block.body);
+ if let Some(handler) = &s.handler {
+ if let Some(param) = &handler.param {
+ self.visit_expression(param);
+ }
+ self.visit_statement_list(handler.body.body);
+ }
+ if let Some(finalizer) = &s.finalizer {
+ self.visit_statement_list(finalizer.body);
+ }
+ }
+ Statement::LabeledStatement(s) => {
+ // Dead-path fallback; the reachable topology lives in
+ // `bind_labeled_statement`.
+ self.visit_identifier(&s.label);
+ self.visit_statement(s.body);
+ }
+ Statement::BreakStatement(s) => {
+ if let Some(label) = &s.label {
+ self.visit_identifier(label);
+ }
+ }
+ Statement::ContinueStatement(s) => {
+ if let Some(label) = &s.label {
+ self.visit_identifier(label);
+ }
+ }
+ Statement::ExportNamedDeclaration(e) => {
+ if let Some(inner) = e.declaration {
+ self.visit_statement(inner);
+ }
+ // export specifiers / source are non-value (skipped).
+ }
+ Statement::ExportDefaultDeclaration(e) => self.visit_export_default(e),
+ Statement::TSExportAssignment(ea) => self.visit_expression(&ea.expression),
+ Statement::TSModuleDeclaration(m) => self.visit_module(m),
+ // No value content (types / imports / enum bodies / empty): skipped,
+ // per the "types are not descended" scope note. See module docs.
+ Statement::TSTypeAliasDeclaration(_)
+ | Statement::TSInterfaceDeclaration(_)
+ | Statement::TSDeclareFunction(_)
+ | Statement::TSEnumDeclaration(_)
+ | Statement::ImportDeclaration(_)
+ | Statement::TSImportEqualsDeclaration(_)
+ | Statement::ExportAllDeclaration(_)
+ | Statement::TSNamespaceExportDeclaration(_)
+ | Statement::EmptyStatement(_)
+ | Statement::DebuggerStatement(_) => {}
+ }
+ }
+
+ fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) {
+ use ForInOfLeft as L;
+ match left {
+ L::VariableDeclaration(d) => {
+ for decl in d.declarations {
+ self.visit_expression(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expression(init);
+ }
+ }
+ }
+ L::Pattern(e) => self.visit_expression(e),
+ }
+ }
+
+ // --- statement flow shapers -------------------------------------------
+
+ /// `bindVariableDeclarationFlow` + `bindInitializedVariableFlow`
+ /// (binder.go:2314) — a `var/let/const x = e` with an initializer emits one
+ /// unconditional `Assignment`. The name binds as a **binding target**
+ /// (`bind_binding_target`), so a destructuring default (`let {a = e} = …`)
+ /// forks per `bindInitializer`. A destructuring pattern emits one
+ /// `Assignment` per declarator (tsv has no binding-element node — see the
+ /// module scope note).
+ fn bind_variable_declaration_flow(&mut self, decl: &VariableDeclarator<'_>) {
+ self.bind_binding_target(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expression(init);
+ }
+ if decl.init.is_some() {
+ let decl_id = self.require(addr_of(decl), NodeKind::VariableDeclarator);
+ self.current_flow =
+ self.create_flow_mutation(FlowFlags::ASSIGNMENT, self.current_flow, decl_id);
+ }
+ }
+
+ // --- branching statement flow shapers ---------------------------------
+
+ /// `bindIfStatement` (binder.go:1924) — then/else branch labels merge at
+ /// `postIf`; each branch binds against the condition-split flow.
+ fn bind_if_statement(&mut self, s: &IfStatement<'_>) {
+ let then_label = self.create_branch_label();
+ let else_label = self.create_branch_label();
+ let post_if = self.create_branch_label();
+ self.bind_condition(Some(&s.test), then_label, else_label, false);
+ self.current_flow = self.finish_flow_label(then_label);
+ self.visit_statement(s.consequent);
+ self.add_antecedent(post_if, self.current_flow);
+ self.current_flow = self.finish_flow_label(else_label);
+ if let Some(alt) = s.alternate {
+ self.visit_statement(alt);
+ }
+ self.add_antecedent(post_if, self.current_flow);
+ self.current_flow = self.finish_flow_label(post_if);
+ }
+
+ /// `bindWhileStatement` (binder.go:1857) — the entry edge is added to the
+ /// loop label **before** it becomes `current_flow`; the back edge **after**
+ /// the body.
+ fn bind_while_statement(&mut self, stmt_id: NodeId, s: &WhileStatement<'_>) {
+ let loop_label = self.create_loop_label();
+ let pre_while = self.set_continue_target(stmt_id, loop_label);
+ let pre_body = self.create_branch_label();
+ let post_while = self.create_branch_label();
+ self.add_antecedent(pre_while, self.current_flow); // entry edge (first)
+ self.current_flow = pre_while;
+ self.bind_condition(Some(&s.test), pre_body, post_while, false);
+ self.current_flow = self.finish_flow_label(pre_body);
+ self.bind_iterative_statement(s.body, post_while, pre_while);
+ self.add_antecedent(pre_while, self.current_flow); // back edge (after)
+ self.current_flow = self.finish_flow_label(post_while);
+ }
+
+ /// `bindDoStatement` (binder.go:1871) — the body runs from the loop label
+ /// first; the continue target is a **pre-condition** branch label (not the
+ /// loop label), and the condition loops back to the loop label.
+ fn bind_do_statement(&mut self, stmt_id: NodeId, s: &DoWhileStatement<'_>) {
+ let pre_do = self.create_loop_label();
+ let condition_label = self.create_branch_label();
+ let pre_condition = self.set_continue_target(stmt_id, condition_label);
+ let post_do = self.create_branch_label();
+ self.add_antecedent(pre_do, self.current_flow);
+ self.current_flow = pre_do;
+ self.bind_iterative_statement(s.body, post_do, pre_condition);
+ self.add_antecedent(pre_condition, self.current_flow);
+ self.current_flow = self.finish_flow_label(pre_condition);
+ self.bind_condition(Some(&s.test), pre_do, post_do, false);
+ self.current_flow = self.finish_flow_label(post_do);
+ }
+
+ /// `bindForStatement` (binder.go:1885) — init → loop label → condition →
+ /// body (continue = the increment label) → incrementor → back edge.
+ fn bind_for_statement(&mut self, stmt_id: NodeId, s: &ForStatement<'_>) {
+ let loop_label = self.create_loop_label();
+ let pre_loop = self.set_continue_target(stmt_id, loop_label);
+ let pre_body = self.create_branch_label();
+ let pre_increment = self.create_branch_label();
+ let post_loop = self.create_branch_label();
+ match &s.init {
+ Some(ForInit::VariableDeclaration(d)) => {
+ for decl in d.declarations {
+ self.bind_variable_declaration_flow(decl);
+ }
+ }
+ Some(ForInit::Expression(e)) => self.visit_expression(e),
+ None => {}
+ }
+ self.add_antecedent(pre_loop, self.current_flow);
+ self.current_flow = pre_loop;
+ // A nil condition is a true passthrough / false-unreachable, handled by
+ // `create_flow_condition`'s nil-expression arm.
+ self.bind_condition(s.test.as_ref(), pre_body, post_loop, false);
+ self.current_flow = self.finish_flow_label(pre_body);
+ self.bind_iterative_statement(s.body, post_loop, pre_increment);
+ self.add_antecedent(pre_increment, self.current_flow);
+ self.current_flow = self.finish_flow_label(pre_increment);
+ if let Some(u) = &s.update {
+ self.visit_expression(u);
+ }
+ self.add_antecedent(pre_loop, self.current_flow); // back edge
+ self.current_flow = self.finish_flow_label(post_loop);
+ }
+
+ /// `bindForInOrOfStatement` (binder.go:1904). The exit edge is
+ /// **unconditional** (a for-in/of can exit after zero iterations); continue
+ /// targets the loop label. Shared by `for-in` and `for-of` (the for-of
+ /// `await` modifier is a `bool` in tsv — no node to bind, no fork).
+ fn bind_for_in_or_of(
+ &mut self,
+ stmt_id: NodeId,
+ left: &ForInOfLeft<'_>,
+ right: &Expression<'_>,
+ body: &Statement<'_>,
+ ) {
+ let loop_label = self.create_loop_label();
+ let pre_loop = self.set_continue_target(stmt_id, loop_label);
+ let post_loop = self.create_branch_label();
+ self.visit_expression(right);
+ self.add_antecedent(pre_loop, self.current_flow);
+ self.current_flow = pre_loop;
+ self.add_antecedent(post_loop, self.current_flow); // unconditional exit
+ // Bind the initializer (binder.go:1915-1918). A declaration-list variable
+ // is assigned each iteration (`bindVariableDeclarationFlow`'s for-in/of
+ // guard, binder.go:2316 — the `Assignment` mutation even with no
+ // initializer); a pattern initializer runs `bindAssignmentTargetFlow`.
+ match left {
+ ForInOfLeft::VariableDeclaration(d) => {
+ for decl in d.declarations {
+ self.bind_binding_target(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expression(init);
+ }
+ let decl_id = self.require(addr_of(decl), NodeKind::VariableDeclarator);
+ self.current_flow = self.create_flow_mutation(
+ FlowFlags::ASSIGNMENT,
+ self.current_flow,
+ decl_id,
+ );
+ }
+ }
+ ForInOfLeft::Pattern(p) => {
+ self.visit_expression(p);
+ self.bind_assignment_target_flow(p);
+ }
+ }
+ self.bind_iterative_statement(body, post_loop, pre_loop);
+ self.add_antecedent(pre_loop, self.current_flow); // back edge
+ self.current_flow = self.finish_flow_label(post_loop);
+ }
+
+ /// `setContinueTarget` (binder.go:1779) — walk the parent chain up from a
+ /// loop while each parent is a `LabeledStatement`, assigning that label's
+ /// continue target (so `continue L` on a labeled loop lands on the loop's
+ /// continue point), in lockstep with the active-label stack from its top. No
+ /// enclosing labeled statements → a no-op returning `target` unchanged.
+ fn set_continue_target(&mut self, loop_node: NodeId, target: FlowNodeId) -> FlowNodeId {
+ let mut node = loop_node;
+ let mut i = self.active_label_list.len();
+ loop {
+ let Some(parent) = self.bound.parents[node.index()] else {
+ break;
+ };
+ if self.bound.kinds[parent.index()] != NodeKind::LabeledStatement || i == 0 {
+ break;
+ }
+ i -= 1;
+ self.active_label_list[i].continue_target = Some(target);
+ node = parent;
+ }
+ target
+ }
+
+ /// `bindIterativeStatement` (binder.go:1807) — bind a loop body with its
+ /// break/continue targets installed, restored on exit.
+ fn bind_iterative_statement(
+ &mut self,
+ body: &Statement<'_>,
+ break_target: FlowNodeId,
+ continue_target: FlowNodeId,
+ ) {
+ let save_break = self.current_break_target;
+ let save_continue = self.current_continue_target;
+ self.current_break_target = Some(break_target);
+ self.current_continue_target = Some(continue_target);
+ self.visit_statement(body);
+ self.current_break_target = save_break;
+ self.current_continue_target = save_continue;
+ }
+
+ /// `bindBreakStatement` (binder.go:1955) — a labeled `break L` resolves to
+ /// `L`'s **break** target (`findActiveLabel`, marking it referenced); an
+ /// unlabeled `break` uses `current_break_target`. An unresolved label is a
+ /// no-op (deferred diagnostic).
+ fn bind_break_statement(&mut self, s: &BreakStatement<'_>) {
+ match &s.label {
+ None => {
+ let target = self.current_break_target;
+ self.bind_break_or_continue_flow(target);
+ }
+ Some(label) => {
+ self.visit_identifier(label);
+ let name = self.label_text(label);
+ if let Some(i) = self.find_active_label(name) {
+ self.active_label_list[i].referenced = true;
+ let target = Some(self.active_label_list[i].break_target);
+ self.bind_break_or_continue_flow(target);
+ }
+ }
+ }
+ }
+
+ /// `bindContinueStatement` (binder.go:1959) — a labeled `continue L` resolves
+ /// to `L`'s **continue** target; an unlabeled `continue` uses
+ /// `current_continue_target`. A missing/`None` target is a no-op.
+ fn bind_continue_statement(&mut self, s: &ContinueStatement<'_>) {
+ match &s.label {
+ None => {
+ let target = self.current_continue_target;
+ self.bind_break_or_continue_flow(target);
+ }
+ Some(label) => {
+ self.visit_identifier(label);
+ let name = self.label_text(label);
+ if let Some(i) = self.find_active_label(name) {
+ self.active_label_list[i].referenced = true;
+ let target = self.active_label_list[i].continue_target;
+ self.bind_break_or_continue_flow(target);
+ }
+ }
+ }
+ }
+
+ /// `bindBreakOrContinueFlow` (binder.go:1985) — route to the target and go
+ /// unreachable; a `None` target (break/continue outside any loop/switch) is a
+ /// no-op (the parser accepts it; the illegal-jump diagnostic is F3+).
+ fn bind_break_or_continue_flow(&mut self, target: Option) {
+ if let Some(t) = target {
+ self.add_antecedent(t, self.current_flow);
+ self.current_flow = self.unreachable_flow;
+ self.has_flow_effects = true;
+ }
+ }
+
+ /// `bindSwitchStatement` (binder.go:2074) — a `switch` with a **local**
+ /// post-switch break target (so a contained `break` resolves here, not at an
+ /// enclosing loop) and the real clause topology (`bind_case_block`). When no
+ /// clause is a `default`, an **unconditional** `(0, 0)` `SwitchClause`
+ /// exhaustiveness sentinel — "no clause matched" — feeds the post-switch
+ /// label alongside the case-block exit. `preSwitchCaseFlow` is captured
+ /// **after** the discriminant is bound (the flow every clause forks from) and
+ /// saved/restored here, as in tsgo (it is not in the container save set).
+ fn bind_switch_statement(&mut self, switch_id: NodeId, s: &SwitchStatement<'_>) {
+ let post_switch = self.create_branch_label();
+ self.visit_expression(&s.discriminant);
+ let save_break = self.current_break_target;
+ let save_pre_switch = self.pre_switch_case_flow;
+ self.current_break_target = Some(post_switch);
+ self.pre_switch_case_flow = Some(self.current_flow);
+ self.bind_case_block(switch_id, s);
+ self.add_antecedent(post_switch, self.current_flow);
+ let has_default = s.cases.iter().any(|c| c.test.is_none());
+ if !has_default {
+ // The "no clause matched" fall-off: reachable from the switch head
+ // regardless of narrowing (an empty `(0, 0)` range is the sentinel).
+ let pre_switch = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow);
+ let sentinel = self.create_flow_switch_clause(pre_switch, switch_id, 0, 0);
+ self.add_antecedent(post_switch, sentinel);
+ }
+ self.current_break_target = save_break;
+ self.pre_switch_case_flow = save_pre_switch;
+ self.current_flow = self.finish_flow_label(post_switch);
+ }
+
+ /// `bindCaseBlock` (binder.go:2095) — thread the clauses. Each clause's
+ /// `preCase` label is fed **from the switch head** (`preSwitchCaseFlow`,
+ /// unconditionally — a narrowing switch wraps it in a per-clause
+ /// `SwitchClause` node) plus the prior clause's fallthrough edge, so a clause
+ /// reached only after a prior `break`/`return` stays reachable (the F2a
+ /// reachability fix). An empty-clause run (`case a: case b:` with no
+ /// statements) re-points to the head only when nothing live falls into it.
+ fn bind_case_block(&mut self, switch_id: NodeId, s: &SwitchStatement<'_>) {
+ let clauses = s.cases;
+ let is_narrowing_switch =
+ is_true_keyword(&s.discriminant) || is_narrowing_expression(&s.discriminant);
+ let last = clauses.len().wrapping_sub(1);
+ let mut fallthrough_flow = self.unreachable_flow;
+ let mut i = 0;
+ while i < clauses.len() {
+ let clause_start = i as u32;
+ // Empty-clause run: advance past clauses with no statements (bar the
+ // last), re-pointing to the head only when nothing live falls in.
+ while clauses[i].consequent.is_empty() && i + 1 < clauses.len() {
+ if fallthrough_flow == self.unreachable_flow {
+ self.current_flow = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow);
+ }
+ self.bind_case_or_default_clause(&clauses[i]);
+ i += 1;
+ }
+ let pre_case = self.create_branch_label();
+ let pre_switch = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow);
+ let pre_case_flow = if is_narrowing_switch {
+ self.create_flow_switch_clause(pre_switch, switch_id, clause_start, i as u32 + 1)
+ } else {
+ pre_switch
+ };
+ self.add_antecedent(pre_case, pre_case_flow); // head edge (reachability fix)
+ self.add_antecedent(pre_case, fallthrough_flow); // fallthrough (unreachable = no-op)
+ self.current_flow = self.finish_flow_label(pre_case);
+ self.bind_case_or_default_clause(&clauses[i]);
+ fallthrough_flow = self.current_flow;
+ if !self.current_unreachable() && i != last {
+ let clause_id = self.require(addr_of(&clauses[i]), NodeKind::SwitchCase);
+ self.fallthrough_flow.push((clause_id, self.current_flow));
+ }
+ i += 1;
+ }
+ }
+
+ /// `bindCaseOrDefaultClause` (binder.go:2126) — the clause's test expression
+ /// binds under the switch head (`preSwitchCaseFlow`, saved/restored), its
+ /// statements under the current (post-`preCase`) flow.
+ fn bind_case_or_default_clause(&mut self, case: &SwitchCase<'_>) {
+ if let Some(test) = &case.test {
+ let saved = self.current_flow;
+ self.current_flow = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow);
+ self.visit_expression(test);
+ self.current_flow = saved;
+ }
+ self.visit_statement_list(case.consequent);
+ }
+
+ // --- try / catch / finally --------------------------------------------
+
+ /// A snapshot of a label's pending antecedent list (`label.Antecedents`) —
+ /// the try/finally combine reads three of these directly (the pointer-free
+ /// `combineFlowLists` analog).
+ fn scratch_snapshot(&self, label: FlowNodeId) -> SmallVec<[FlowNodeId; 4]> {
+ self.label_scratch.get(&label).cloned().unwrap_or_default()
+ }
+
+ /// `bindTryStatement` (binder.go:1993). Three fresh labels — `normalExit`,
+ /// `returnLabel`, `exceptionLabel` — thread the "any instruction can throw"
+ /// edge (`exceptionLabel` seeded from `current_flow` **before** the try
+ /// block, `current_exception_target` repointed so `create_flow_mutation`'s
+ /// fan-out comes alive). A catch is bound as a **second try** (a fresh
+ /// `exceptionLabel` seeded from the first one's finish). With a finally, the
+ /// finally label's antecedents = `normal ++ exception ++ return`
+ /// (`combineFlowLists`), it becomes `current_flow`, and up to three
+ /// `ReduceLabel`s route the finally's completion back through the return /
+ /// outer-exception / normal-exit subsets (binder.go:2052-2067).
+ fn bind_try_statement(&mut self, s: &TryStatement<'_>) {
+ let save_return_target = self.current_return_target;
+ let save_exception_target = self.current_exception_target;
+ let normal_exit = self.create_branch_label();
+ let return_label = self.create_branch_label();
+ let mut exception_label = self.create_branch_label();
+ if s.finalizer.is_some() {
+ self.current_return_target = Some(return_label);
+ }
+ // The exception edge for exceptions before any mutation.
+ self.add_antecedent(exception_label, self.current_flow);
+ self.current_exception_target = Some(exception_label);
+ self.visit_statement_list(s.block.body);
+ self.add_antecedent(normal_exit, self.current_flow);
+ if let Some(handler) = &s.handler {
+ // The catch is the target of exceptions from the try block; its own
+ // exceptions flow to a fresh label (catch = a second try).
+ self.current_flow = self.finish_flow_label(exception_label);
+ exception_label = self.create_branch_label();
+ self.add_antecedent(exception_label, self.current_flow);
+ self.current_exception_target = Some(exception_label);
+ if let Some(param) = &handler.param {
+ // The catch variable is a binding position (tsgo reaches it via
+ // bindBindingElementFlow → bindInitializer), so a flow-changing
+ // destructuring default forks — bind_binding_target, not the plain
+ // value walk. Equivalent for a non-defaulted param.
+ self.bind_binding_target(param);
+ }
+ self.visit_statement_list(handler.body.body);
+ self.add_antecedent(normal_exit, self.current_flow);
+ }
+ // Restore BEFORE the finally — the finally isn't inside its own try.
+ self.current_return_target = save_return_target;
+ self.current_exception_target = save_exception_target;
+ if let Some(finalizer) = &s.finalizer {
+ let normal_list = self.scratch_snapshot(normal_exit);
+ let exception_list = self.scratch_snapshot(exception_label);
+ let return_list = self.scratch_snapshot(return_label);
+ let finally_label = self.create_branch_label();
+ // finallyLabel.Antecedents = normal ++ exception ++ return
+ // (combineFlowLists, no dedup — faithful to binder.go:2043).
+ let mut combined: SmallVec<[FlowNodeId; 4]> = SmallVec::new();
+ combined.extend(normal_list.iter().copied());
+ combined.extend(exception_list.iter().copied());
+ combined.extend(return_list.iter().copied());
+ self.label_scratch.insert(finally_label, combined);
+ self.current_flow = finally_label;
+ self.visit_statement_list(finalizer.body);
+ if self.current_unreachable() {
+ // An unreachable end-of-finally makes the whole try unreachable.
+ self.current_flow = self.unreachable_flow;
+ } else {
+ // Route the finally's completion back through the return-only
+ // subset (IIFE/constructor return target), then the outer
+ // exception-only subset, then continue via the normal subset.
+ if let Some(rt) = self.current_return_target
+ && !return_list.is_empty()
+ {
+ let rl =
+ self.create_reduce_label(finally_label, &return_list, self.current_flow);
+ self.add_antecedent(rt, rl);
+ }
+ if let Some(et) = self.current_exception_target
+ && !exception_list.is_empty()
+ {
+ let el =
+ self.create_reduce_label(finally_label, &exception_list, self.current_flow);
+ self.add_antecedent(et, el);
+ }
+ if normal_list.is_empty() {
+ self.current_flow = self.unreachable_flow;
+ } else {
+ self.current_flow =
+ self.create_reduce_label(finally_label, &normal_list, self.current_flow);
+ }
+ }
+ } else {
+ self.current_flow = self.finish_flow_label(normal_exit);
+ }
+ }
+
+ // --- labeled statements -----------------------------------------------
+
+ /// `bindLabeledStatement` (binder.go:2153). Push an active-label entry
+ /// (break target = `postStatementLabel`, continue target set later by a
+ /// directly-enclosed loop's `set_continue_target`), bind the label + body,
+ /// then pop; an **unreferenced** label gets the `Unreachable` stamp on its
+ /// identifier (the TS7028 signal, binder.go:2167). The post label merges the
+ /// body's exit.
+ fn bind_labeled_statement(&mut self, s: &LabeledStatement<'_>) {
+ let post = self.create_branch_label();
+ let label_id = self.require(addr_of(&s.label), NodeKind::Identifier);
+ self.active_label_list.push(ActiveLabelEntry {
+ break_target: post,
+ continue_target: None,
+ referenced: false,
+ label_node_id: label_id,
+ });
+ self.visit_identifier(&s.label);
+ self.visit_statement(s.body);
+ // Balanced with the push above (pop is always `Some`). An unreferenced
+ // label's identifier gets the `Unreachable` stamp (the TS7028 signal).
+ if let Some(entry) = self.active_label_list.pop()
+ && !entry.referenced
+ {
+ self.node_flags[entry.label_node_id.index()] |= crate::binder::NODE_FLAGS_UNREACHABLE;
+ }
+ self.add_antecedent(post, self.current_flow);
+ self.current_flow = self.finish_flow_label(post);
+ }
+
+ /// `findActiveLabel` (binder.go:1976) — innermost-first (the stack top is the
+ /// last element, so scan from the end). Returns the stack index.
+ fn find_active_label(&self, name: &str) -> Option {
+ self.active_label_list
+ .iter()
+ .rposition(|e| self.bound.spans[e.label_node_id.index()].extract(self.source) == name)
+ }
+
+ /// The source text of a label identifier (the break/continue label name).
+ fn label_text(&self, ident: &Identifier<'_>) -> &'a str {
+ let id = self.require(addr_of(ident), NodeKind::Identifier);
+ self.bound.spans[id.index()].extract(self.source)
+ }
+
+ // --- containers -------------------------------------------------------
+
+ fn visit_function_declaration(&mut self, f: &FunctionDeclaration<'_>, anchor: NodeId) {
+ let saved = self.enter_container(None, false, false);
+ self.bind_params(f.params);
+ self.visit_statement_list(f.body.body);
+ self.exit_container(saved, false, true, true, anchor, false);
+ }
+
+ pub(super) fn bind_params(&mut self, params: &[Expression<'_>]) {
+ for param in params {
+ self.bind_binding_target(param);
+ }
+ }
+
+ /// `bindInitializer` (binder.go:2474) — bind a parameter / binding-element
+ /// **default** and fork `current_flow` around it, but **only** when binding
+ /// the default actually changed the flow (a `BindingElement`/`Parameter` has
+ /// no side effects when its initializer isn't evaluated — GH#49759). The
+ /// entry/exit pointer-equality guard is exact: a literal default (`= 1`)
+ /// leaves `current_flow` untouched and mints no label.
+ fn bind_initializer(&mut self, initializer: &Expression<'_>) {
+ let entry = self.current_flow;
+ self.visit_expression(initializer);
+ if entry == self.unreachable_flow || entry == self.current_flow {
+ return;
+ }
+ let exit = self.create_branch_label();
+ self.add_antecedent(exit, entry);
+ self.add_antecedent(exit, self.current_flow);
+ self.current_flow = self.finish_flow_label(exit);
+ }
+
+ /// Bind a **binding target** (declaration / parameter position):
+ /// `bindParameterFlow` / `bindBindingElementFlow` (binder.go:2463/2450). A
+ /// defaulted element's initializer is bound **before** the name (TC39 order,
+ /// via `bind_initializer`, which forks only when the default changed the
+ /// flow). Distinct from the value traversal (`visit_expression`) so the
+ /// assignment-target destructuring recursion — a separate deferred item —
+ /// stays untouched; for a non-defaulted target the two are equivalent.
+ fn bind_binding_target(&mut self, node: &Expression<'_>) {
+ use Expression as E;
+ match node {
+ E::AssignmentPattern(a) => {
+ self.visit_decorators(a.decorators);
+ self.bind_initializer(a.right);
+ self.bind_binding_target(a.left);
+ }
+ E::ObjectPattern(op) => {
+ self.visit_decorators(op.decorators);
+ for prop in op.properties {
+ match prop {
+ ObjectPatternProperty::Property(pr) => {
+ self.visit_expression(&pr.key);
+ self.bind_binding_target(&pr.value);
+ }
+ ObjectPatternProperty::RestElement(r) => {
+ self.bind_binding_target(r.argument);
+ }
+ }
+ }
+ }
+ E::ArrayPattern(ap) => {
+ self.visit_decorators(ap.decorators);
+ for el in ap.elements.iter().flatten() {
+ self.bind_binding_target(el);
+ }
+ }
+ E::RestElement(r) => self.bind_binding_target(r.argument),
+ E::TSParameterProperty(pp) => self.bind_binding_target(pp.parameter),
+ // A plain identifier / other leaf binding: the ordinary traversal.
+ _ => self.visit_expression(node),
+ }
+ }
+
+ fn visit_class_decl(&mut self, c: &ClassDeclaration<'_>) {
+ self.visit_class_common(
+ c.id.as_ref(),
+ c.decorators,
+ c.super_class,
+ c.body.body,
+ false,
+ );
+ }
+
+ /// The value-flow class descent shared by the declaration and expression forms
+ /// (distinct types with the same field shape): the name binding, decorators, and
+ /// the `extends` expression, then each member. Type positions (type params /
+ /// super type args / `implements`) are skipped. `is_class_expression` threads
+ /// the parent-kind half of tsgo's
+ /// `IsObjectLiteralOrClassExpressionMethodOrAccessor` gate (utilities.go:566)
+ /// down to `visit_method` — tsv expressions carry no parent pointer.
+ pub(super) fn visit_class_common(
+ &mut self,
+ name: Option<&Identifier<'_>>,
+ decorators: Option<&[Decorator<'_>]>,
+ super_class: Option<&Expression<'_>>,
+ members: &[ClassMember<'_>],
+ is_class_expression: bool,
+ ) {
+ if let Some(name) = name {
+ self.visit_identifier(name);
+ }
+ self.visit_decorators(decorators);
+ if let Some(sc) = super_class {
+ self.visit_expression(sc);
+ }
+ for member in members {
+ self.visit_class_member(member, is_class_expression);
+ }
+ }
+
+ fn visit_class_member(&mut self, member: &ClassMember<'_>, is_class_expression: bool) {
+ match member {
+ ClassMember::MethodDefinition(m) => self.visit_method(m, is_class_expression),
+ ClassMember::PropertyDefinition(p) => {
+ self.visit_decorators(p.decorators);
+ self.visit_expression(&p.key);
+ // property type annotation is a type position (skip).
+ if let Some(value) = &p.value {
+ // A property-with-initializer is a control-flow container
+ // (binder.go:2584): fresh Start around the initializer.
+ let p_id = self.require(addr_of(p), NodeKind::PropertyDefinition);
+ let saved = self.enter_container(None, false, false);
+ self.visit_expression(value);
+ self.exit_container(saved, false, false, false, p_id, false);
+ }
+ }
+ ClassMember::StaticBlock(s) => {
+ // A class static block is flow-transparent (binder.go:1525-1528)
+ // with its own return target; `return_flow` anchors on it.
+ let s_id = self.require(addr_of(s), NodeKind::StaticBlock);
+ let saved = self.enter_container(None, true, true);
+ self.visit_statement_list(s.body);
+ self.exit_container(saved, true, true, true, s_id, true);
+ }
+ // index signatures are type-only (skip).
+ ClassMember::IndexSignature(_) => {}
+ }
+ }
+
+ fn visit_method(&mut self, m: &MethodDefinition<'_>, is_class_expression: bool) {
+ self.visit_decorators(m.decorators);
+ let is_ctor = m.kind == MethodKind::Constructor;
+ self.visit_expression(&m.key);
+ // The method body lives in `value` (a FunctionExpression); the method is
+ // a control-flow container anchored on that FunctionExpression — the
+ // body-bearing node (tsv wraps a method body in a FunctionExpression,
+ // where tsc's method node holds the body directly). The `MethodDefinition`
+ // and its inline `value` share an address (a repr reorder puts `value` at
+ // offset 0), so the address map keys on `(address, NodeKind)`; anchoring
+ // here resolves the FunctionExpression id via its kind, and the method
+ // itself resolves separately by `NodeKind::MethodDefinition`.
+ let anchor = self.require(addr_of(&m.value), NodeKind::FunctionExpression);
+ // A **class-expression** method/accessor (never a constructor, never a
+ // class-declaration member) gets the outer-flow write on the METHOD node
+ // (bindPropertyOrMethodOrAccessor, binder.go:981) and becomes the body
+ // Start's subject (binder.go:1534) — the P3 narrowing hint
+ // (`IsObjectLiteralOrClassExpressionMethodOrAccessor`, utilities.go:566;
+ // the object-literal half lives in `visit_object_expr_property`).
+ let start_subject = if is_class_expression && !is_ctor {
+ let method_id = self.require(addr_of(m), NodeKind::MethodDefinition);
+ self.set_flow_leaf(method_id);
+ Some(method_id)
+ } else {
+ None
+ };
+ let saved = self.enter_container(start_subject, false, is_ctor);
+ self.bind_params(m.value.params);
+ self.visit_statement_list(m.value.body.body);
+ self.exit_container(saved, false, true, true, anchor, is_ctor);
+ }
+
+ fn visit_module(&mut self, m: &tsv_ts::ast::internal::TSModuleDeclaration<'_>) {
+ use tsv_ts::ast::internal::TSModuleName;
+ if let TSModuleName::Identifier(name) = &m.id {
+ self.visit_identifier(name);
+ }
+ match &m.body {
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => {
+ // A ModuleBlock is a control-flow container (binder.go:2582) —
+ // fresh Start, no return target, not function-like.
+ let block_id = self.require(addr_of(block), NodeKind::TSModuleBlock);
+ let saved = self.enter_container(None, false, false);
+ self.visit_statement_list(block.body);
+ self.exit_container(saved, false, false, false, block_id, false);
+ }
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => {
+ self.visit_module(nested);
+ }
+ None => {}
+ }
+ }
+
+ fn visit_export_default(&mut self, e: &tsv_ts::ast::internal::ExportDefaultDeclaration<'_>) {
+ use tsv_ts::ast::internal::ExportDefaultValue as V;
+ match &e.declaration {
+ V::Expression(expr) => self.visit_expression(expr),
+ V::FunctionDeclaration(f) => {
+ let id = self.require(addr_of(f), NodeKind::FunctionDeclaration);
+ self.visit_function_declaration(f, id);
+ }
+ V::ClassDeclaration(c) => self.visit_class_decl(c),
+ // A declare function / interface has no value body (skip).
+ V::TSDeclareFunction(_) | V::TSInterfaceDeclaration(_) => {}
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/flags.rs b/crates/tsv_check/src/binder/flow/flags.rs
new file mode 100644
index 000000000..41f446806
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/flags.rs
@@ -0,0 +1,73 @@
+// --- FlowFlags -------------------------------------------------------------
+
+/// The flow-node flag bits — a `u16` newtype over tsgo's 13 `FlowFlags`
+/// (flow.go:5-23; the max bit is `Shared`, `1 << 12`, so a `u16` fits). All 13
+/// bits are defined for shape; `SwitchClause` (F2a) and `ReduceLabel` (F2b) are
+/// set by the flow builder, while `ArrayMutation` is never *set* (its two ordinary
+/// mutation sites are deliberately skipped per the F2 census — a narrowing hint).
+///
+/// # tsgo
+/// `internal/ast/flow.go` `FlowFlags`.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
+pub struct FlowFlags(u16);
+
+impl FlowFlags {
+ /// Unreachable code.
+ pub const UNREACHABLE: FlowFlags = FlowFlags(1 << 0);
+ /// Start of the flow graph.
+ pub const START: FlowFlags = FlowFlags(1 << 1);
+ /// Non-looping junction.
+ pub const BRANCH_LABEL: FlowFlags = FlowFlags(1 << 2);
+ /// Looping junction.
+ pub const LOOP_LABEL: FlowFlags = FlowFlags(1 << 3);
+ /// Assignment.
+ pub const ASSIGNMENT: FlowFlags = FlowFlags(1 << 4);
+ /// Condition known to be true.
+ pub const TRUE_CONDITION: FlowFlags = FlowFlags(1 << 5);
+ /// Condition known to be false.
+ pub const FALSE_CONDITION: FlowFlags = FlowFlags(1 << 6);
+ /// Switch-statement clause (set by the switch flow builder, F2a).
+ pub const SWITCH_CLAUSE: FlowFlags = FlowFlags(1 << 7);
+ /// Potential array mutation — never set (its two ordinary mutation sites are
+ /// deliberately skipped per the F2 census; a narrowing-only hint).
+ pub const ARRAY_MUTATION: FlowFlags = FlowFlags(1 << 8);
+ /// Potential assertion call.
+ pub const CALL: FlowFlags = FlowFlags(1 << 9);
+ /// Temporarily reduce antecedents of a label (set by try/finally, F2b).
+ pub const REDUCE_LABEL: FlowFlags = FlowFlags(1 << 10);
+ /// Referenced as an antecedent once.
+ pub const REFERENCED: FlowFlags = FlowFlags(1 << 11);
+ /// Referenced as an antecedent more than once.
+ pub const SHARED: FlowFlags = FlowFlags(1 << 12);
+ /// `BranchLabel | LoopLabel`.
+ pub const LABEL: FlowFlags = FlowFlags((1 << 2) | (1 << 3));
+ /// `TrueCondition | FalseCondition`.
+ pub const CONDITION: FlowFlags = FlowFlags((1 << 5) | (1 << 6));
+
+ /// Whether every bit of `other` is set.
+ #[inline]
+ #[must_use]
+ pub const fn contains(self, other: FlowFlags) -> bool {
+ self.0 & other.0 == other.0
+ }
+
+ /// Whether any bit of `other` is set.
+ #[inline]
+ #[must_use]
+ pub const fn intersects(self, other: FlowFlags) -> bool {
+ self.0 & other.0 != 0
+ }
+
+ /// Set `other`'s bits.
+ #[inline]
+ pub(super) fn insert(&mut self, other: FlowFlags) {
+ self.0 |= other.0;
+ }
+
+ /// Whether this is a label node (`BranchLabel` or `LoopLabel`).
+ #[inline]
+ #[must_use]
+ pub const fn is_label(self) -> bool {
+ self.intersects(FlowFlags::LABEL)
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/graph.rs b/crates/tsv_check/src/binder/flow/graph.rs
new file mode 100644
index 000000000..3c8500c19
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/graph.rs
@@ -0,0 +1,187 @@
+use super::*;
+
+// --- F2 payload shapes (defined for the SoA shape; not populated in F1a) ----
+
+/// A switch-clause payload: the switch it belongs to and the half-open
+/// `[clause_start, clause_end)` clause range it matched. Written by the switch
+/// flow builder (binder.go:2087-2108) and read back through
+/// [`FlowGraph::switch_clause_data`].
+#[derive(Clone, Copy, Debug)]
+pub struct FlowSwitchClause {
+ /// The switch statement node.
+ pub switch: NodeId,
+ /// Inclusive clause-range start index.
+ pub clause_start: u32,
+ /// Exclusive clause-range end index.
+ pub clause_end: u32,
+}
+
+/// A reduce-label payload — the try/finally "temporarily reduce a label's
+/// antecedents" node (`createReduceLabel`, binder.go:475/2042-2045). Written by
+/// the try/finally flow builder and read back through
+/// [`FlowGraph::reduce_label_data`].
+#[derive(Clone, Copy, Debug)]
+pub struct FlowReduceLabel {
+ /// The label whose antecedent set is temporarily reduced.
+ pub target: FlowNodeId,
+ /// **1-based** pool-run index of the reduced antecedent list (the same
+ /// length-prefixed pool convention the label pool uses).
+ pub antecedents: u32,
+}
+
+// --- FlowGraph -------------------------------------------------------------
+
+/// A per-file control-flow graph in struct-of-arrays form (per
+/// `TODO_TYPECHECKER_INTERNALS.md` §Flow graph). Backward edges only (the
+/// checker walks use→def).
+///
+/// Columns are indexed by `FlowNodeId::index()`. Antecedents are
+/// kind-discriminated via `flags`: a non-label node's `antecedent` slot is the
+/// single antecedent's raw id (0 = none); a label's slot is a **1-based
+/// pool-run index** (0 = the label collapsed / was never finalized). The pool
+/// stores length-prefixed runs (`[len, edge0, edge1, …]`); the entry edge is
+/// appended first and order is preserved (load-bearing for P3), never sorted.
+pub struct FlowGraph {
+ pub(super) flags: Vec,
+ /// Kind-discriminated by `flags`: a `NodeId` (raw, 1-based) | payload index
+ /// | 0 = none. In F1a it is always a `NodeId` or 0.
+ pub(super) subject: Vec,
+ /// Non-label: the single antecedent's raw `FlowNodeId` (0 = none).
+ /// Label: a 1-based pool-run index (0 = collapsed / unfinalized).
+ pub(super) antecedent: Vec,
+ /// Length-prefixed antecedent runs for labels (`[len, e0, e1, …]`).
+ pub(super) pool: Vec,
+ /// Switch-clause payloads, addressed by a `SwitchClause` node's 1-based
+ /// `subject` slot (read via [`FlowGraph::switch_clause_data`]).
+ pub(super) switch_payloads: Vec,
+ /// Reduce-label payloads (try/finally), addressed by a `ReduceLabel` node's
+ /// 1-based `subject` slot (read via [`FlowGraph::reduce_label_data`]).
+ pub(super) reduce_payloads: Vec,
+}
+
+impl FlowGraph {
+ /// The number of flow nodes in the graph (id 1 is `unreachableFlow`).
+ #[inline]
+ #[must_use]
+ pub fn node_count(&self) -> u32 {
+ self.flags.len() as u32
+ }
+
+ /// The flags of a flow node.
+ #[inline]
+ #[must_use]
+ pub fn flags(&self, id: FlowNodeId) -> FlowFlags {
+ self.flags[id.index()]
+ }
+
+ /// The subject `NodeId` of a flow node, if any (labels have none).
+ ///
+ /// **Not** valid on a `SwitchClause` node: there the `subject` slot holds a
+ /// 1-based payload index, not a raw `NodeId` — use
+ /// [`FlowGraph::switch_clause_data`] instead.
+ #[inline]
+ #[must_use]
+ pub fn subject(&self, id: FlowNodeId) -> Option {
+ NodeId::from_raw_opt(self.subject[id.index()])
+ }
+
+ /// The switch-clause payload of a `SwitchClause` flow node.
+ ///
+ /// A `SwitchClause` node's `subject` slot stores a **1-based index** into
+ /// `switch_payloads` (the same convention the label pool uses), not a
+ /// [`NodeId`], so [`FlowGraph::subject`] must not be called on it — it would
+ /// mis-decode the index as a node id. This is the only correct reader;
+ /// callers gate on `flags(id).contains(SWITCH_CLAUSE)`.
+ #[must_use]
+ pub fn switch_clause_data(&self, id: FlowNodeId) -> &FlowSwitchClause {
+ debug_assert!(self.flags(id).contains(FlowFlags::SWITCH_CLAUSE));
+ let index = self.subject[id.index()] as usize; // 1-based
+ &self.switch_payloads[index - 1]
+ }
+
+ /// The reduce-label payload of a `ReduceLabel` flow node.
+ ///
+ /// Like a `SwitchClause` node, a `ReduceLabel` node's `subject` slot stores a
+ /// **1-based index** into `reduce_payloads`, not a [`NodeId`], so
+ /// [`FlowGraph::subject`] must not be called on it. Callers gate on
+ /// `flags(id).contains(REDUCE_LABEL)`. The payload's `antecedents` field is a
+ /// 1-based pool-run index of the reduced antecedent list.
+ #[must_use]
+ pub fn reduce_label_data(&self, id: FlowNodeId) -> &FlowReduceLabel {
+ debug_assert!(self.flags(id).contains(FlowFlags::REDUCE_LABEL));
+ let index = self.subject[id.index()] as usize; // 1-based
+ &self.reduce_payloads[index - 1]
+ }
+
+ /// A length-prefixed pool run as a raw slice (`slot` is 1-based; 0 = empty).
+ #[inline]
+ fn pool_run(&self, slot: u32) -> &[u32] {
+ if slot == 0 {
+ return &[];
+ }
+ let off = (slot - 1) as usize;
+ let len = self.pool[off] as usize;
+ &self.pool[off + 1..off + 1 + len]
+ }
+
+ /// The single antecedent of a **non-label** flow node (`None` for `Start` /
+ /// `Unreachable`) — the O(1) slot read the CFA's linear-chain walk follows
+ /// (tsgo's `flow.Antecedent` chase), no pool touch, no allocation.
+ ///
+ /// Not valid on a label node, whose slot holds a pool-run index — decode
+ /// those via [`FlowGraph::antecedents_iter`].
+ #[inline]
+ #[must_use]
+ pub fn single_antecedent(&self, id: FlowNodeId) -> Option {
+ debug_assert!(
+ !self.flags[id.index()].is_label(),
+ "a label's antecedent slot is a pool-run index — use antecedents_iter"
+ );
+ FlowNodeId::from_raw(self.antecedent[id.index()])
+ }
+
+ /// The antecedents of a flow node, in append order, as a **zero-alloc**
+ /// borrowing iterator — the hot-path form for the CFA walkers (label
+ /// recursion iterates this; linear chains take [`FlowGraph::single_antecedent`]).
+ /// Labels decode their length-prefixed pool run; non-label nodes yield their
+ /// 0-or-1 slot.
+ pub fn antecedents_iter(&self, id: FlowNodeId) -> impl Iterator- + '_ {
+ let flags = self.flags[id.index()];
+ let slot = self.antecedent[id.index()];
+ let (run, single) = if flags.is_label() {
+ (self.pool_run(slot), None)
+ } else {
+ (&[][..], FlowNodeId::from_raw(slot))
+ };
+ run.iter()
+ .filter_map(|&raw| FlowNodeId::from_raw(raw))
+ .chain(single)
+ }
+
+ /// The reduced antecedent list of a `ReduceLabel` node, in append order, as
+ /// a **zero-alloc** borrowing iterator (the temporary antecedent subset the
+ /// checker substitutes for `target` while it passes this node).
+ pub fn reduce_label_antecedents_iter(
+ &self,
+ id: FlowNodeId,
+ ) -> impl Iterator
- + '_ {
+ let data = self.reduce_label_data(id);
+ self.pool_run(data.antecedents)
+ .iter()
+ .filter_map(|&raw| FlowNodeId::from_raw(raw))
+ }
+
+ /// [`FlowGraph::reduce_label_antecedents_iter`], collected (the convenient
+ /// form for tests and the DOT renderer; hot paths take the iterator).
+ #[must_use]
+ pub fn reduce_label_antecedents(&self, id: FlowNodeId) -> Vec
{
+ self.reduce_label_antecedents_iter(id).collect()
+ }
+
+ /// [`FlowGraph::antecedents_iter`], collected (the convenient form for tests
+ /// and the DOT renderer; hot paths take the iterator).
+ #[must_use]
+ pub fn antecedents(&self, id: FlowNodeId) -> Vec {
+ self.antecedents_iter(id).collect()
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/mod.rs b/crates/tsv_check/src/binder/flow/mod.rs
new file mode 100644
index 000000000..fcefe34b7
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/mod.rs
@@ -0,0 +1,114 @@
+//! The flow-graph walk — a per-file control-flow graph in struct-of-arrays form.
+//!
+//! This is the **third walk** of the binder (after the SoA node-identity walk
+//! and the symbol bind). It ports tsgo's binder flow construction (`bind` /
+//! `bindContainer` / `bindChildren` + the per-statement flow shapers) onto the
+//! tsv AST, resolving each attachment's [`NodeId`] through the F0 address map's
+//! **strict** [`BoundFile::require_node_id`] (a miss aborts — a flow graph must
+//! never silently splice onto the wrong node).
+//!
+//! **F1b scope: the branching control-flow constructs.** On top of F1a's linear
+//! substrate this slice builds faithful topology for **conditions** (the
+//! `bindCondition` machinery — `&&`/`||`/`??`/`?:`/`!`/parenthesized + the
+//! `hasFlowEffects` save/restore family), **`if`/`else`**, the five loops
+//! (**`while`**, **`do…while`**, **`for`**, **`for-in`**, **`for-of`**), and
+//! **unlabeled `break`/`continue`**.
+//!
+//! **F2a scope: switch-statement flow topology.** On top of F1b's local
+//! post-switch break target this slice builds the real clause topology
+//! (`bindSwitchStatement` / `bindCaseBlock` / `bindCaseOrDefaultClause`): every
+//! clause's `preCase` label is fed **from the switch head unconditionally**
+//! (`preSwitchCaseFlow`) in addition to the prior clause's fallthrough edge, so a
+//! clause reached only after a prior `break`/`return` stays reachable — F1b's
+//! linear stub wrongly marked it `Unreachable`. A narrowing switch
+//! (`switch (true)` or a narrowing discriminant) additionally mints a
+//! `SwitchClause` flow node per clause carrying the matched half-open
+//! `[start, end)` clause range, and a switch with no `default` clause adds a
+//! `(0, 0)` "no clause matched" `SwitchClause` exhaustiveness sentinel to the
+//! post-switch label. Post-exhaustive-switch reachability (code after an
+//! exhaustive no-`default` switch) is type-dependent
+//! (`isExhaustiveSwitchStatement`) and stays deferred.
+//!
+//! **F2b scope: the four remaining flow landmines.** On top of F2a this slice
+//! builds **try/catch/finally** topology (`bindTryStatement` — the
+//! exception/return/normal-exit labels, the catch-as-second-try re-point, and
+//! the `ReduceLabel` finally-completion routing back through the return /
+//! outer-exception / normal antecedent subsets), **IIFE inlining**
+//! (`GetImmediatelyInvokedFunctionExpression` + the `bindContainer`
+//! `!isImmediatelyInvoked` gate — a non-async, non-generator function/arrow
+//! callee of a call is bound *transparently* into the containing flow, with its
+//! own return target merged at exit but no fresh `Start` and no `current_flow`
+//! restore), **initializer forks** (`bindInitializer` — a parameter /
+//! binding-element default that actually changes `current_flow` forks around
+//! it), and **labeled statements** (`bindLabeledStatement` + the
+//! `activeLabelList` — labeled `break`/`continue` resolution, per-label
+//! continue-target propagation, and the unreferenced-label `Unreachable` stamp).
+//! Flow stays **dark** — nothing consumes it until F3, so this slice emits no
+//! diagnostics.
+//!
+//! **`isTopLevelLogicalExpression` without parent pointers.** tsgo's
+//! `bindBinaryExpressionFlow` walks the parent chain to decide whether a logical
+//! expression is evaluated for its value (top-level → `hasFlowEffects` post-label
+//! wrap) or as a condition (nested → wired to the enclosing true/false targets).
+//! tsv's `Expression` has no parent pointer, so the walk is replaced by keeping
+//! the true/false targets `Some` **only** while binding an actual sub-condition —
+//! they are set by `do_with_conditional_branches` / the `!`-swap, and reset to
+//! `None` at three boundaries so they never leak into a non-condition: (1) at every
+//! **value sub-position** — `visit_expression` resets them for every non-threading
+//! expression, so a logical nested in a call argument / `?:` arm / array element
+//! (`if (f(x && y))`) is classified top-level (a value), not a sub-condition;
+//! (2) at every **flow container** — one can be entered mid-condition
+//! (`if (arr.some(x => x && y))`), which would otherwise leak the outer targets
+//! into the callback body; and (3) around the **logical-compound-assignment RHS** —
+//! the RHS of `&&=`/`||=`/`??=` is reached through a *threading* node (the
+//! compound-assign itself), so the `visit_expression` auto-reset never fires;
+//! `bind_logical_like_expression` clears the targets explicitly so a logical RHS
+//! (`a &&= x && y`) is classified top-level, matching tsgo's
+//! `isTopLevelLogicalExpression` verdict on the RHS's parent (the compound-assign,
+//! not a logical binary; see that site for detail). With these resets a logical
+//! expression is top-level iff `current_true_target` is `None`. All are deliberate
+//! departures from tsgo (which never saves the targets, relying on the parent walk)
+//! required by the pointer-free heuristic.
+//!
+//! **Deliberate scoping deviations (F1a; documented for F1b):**
+//! - **Types are not descended.** The walk visits value positions only; pure
+//! type nodes (annotations, type arguments, type-parameter constraints,
+//! heritage type args, interface/type-alias bodies, enum bodies) are skipped.
+//! tsgo stamps `currentFlow` on every identifier *including* type positions
+//! (binder.go:602). For **pure** type positions those stamps are inert (the
+//! checker runs no CFA there — the same soundness that lets lib files skip
+//! flow). The **exception is `typeof` queries**: `typeof x` / `typeof x.y` in a
+//! type position *is* flow-narrowed by the checker, which is exactly why tsgo
+//! gates the `QualifiedName` stamp on `IsPartOfTypeQuery` (binder.go:611). So
+//! the omitted type-position identifier stamp (for `typeof x`) and the
+//! `QualifiedName`-inside-`typeof` stamp are **not** dead weight — they are a
+//! **P3 prerequisite** for typeof-query narrowing (ledgered as such), not
+//! inert. Nothing before P3 reads them, so deferring is safe now.
+//! - **No `Start` region for the bodiless signature/type function-likes**
+//! (`TSFunctionType` / `TSConstructorType` / method-/call-/construct-signature)
+//! — a corollary of not descending types.
+//! - **Binding-element flow.** tsv has no distinct binding-element node (patterns
+//! are pattern-shaped `Expression`s), so a destructuring `let {a} = e` emits a
+//! single `Assignment` per *declarator* (subject = the declarator) rather than
+//! one per element (binder.go:2329). Exact for the identifier case; the
+//! contained identifiers still get their leaf stamps. Binding-element and
+//! parameter **defaults** fork per `bindInitializer` (F2b) when the default
+//! changes the flow.
+//
+// tsgo: internal/binder/binder.go bind / bindContainer / bindChildren
+// (+ the newFlowNode* / createFlow* / finishFlowLabel / addAntecedent
+// constructor family and the per-statement flow shapers)
+
+mod build;
+mod flags;
+mod graph;
+mod product;
+#[cfg(test)]
+mod tests;
+
+pub use build::build_flow;
+pub use flags::FlowFlags;
+pub use graph::{FlowGraph, FlowReduceLabel, FlowSwitchClause};
+pub use product::{FlowProduct, FlowStats, render_flow_dot};
+
+use crate::ids::{FlowNodeId, NodeId};
diff --git a/crates/tsv_check/src/binder/flow/product.rs b/crates/tsv_check/src/binder/flow/product.rs
new file mode 100644
index 000000000..5f3e21561
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/product.rs
@@ -0,0 +1,201 @@
+use super::*;
+use tsv_lang::Span;
+
+// --- FlowProduct -----------------------------------------------------------
+
+/// Small construction counters, surfaced for the density / dead-label-row
+/// perf report (they are not consumed by any checker phase).
+#[derive(Clone, Copy, Debug, Default)]
+pub struct FlowStats {
+ /// Branch labels created (`createBranchLabel`).
+ pub branch_labels: u32,
+ /// Branch labels that collapsed at `finishFlowLabel` (0 or 1 antecedent),
+ /// leaving a dead row — the fraction to watch (INTERNALS §Flow graph).
+ pub dead_labels: u32,
+}
+
+/// The owned, arena-free, file-local flow product carried **dark** in a
+/// `BoundUnit` (nothing consumes it until F3; F1a builds it and `--dump-flow`
+/// renders it). C15-relocatable by construction.
+pub struct FlowProduct {
+ /// The flow graph.
+ pub graph: FlowGraph,
+ /// Per-`NodeId` flow attachment (`None` where tsgo attaches nil — including
+ /// non-leaf nodes cleared in dead code; a dead *leaf* keeps
+ /// `Some(unreachable)`).
+ pub flow_of_node: Vec>,
+ /// Per-node flag bytes, one per [`NodeId`] (minted zeroed here — the flow
+ /// walk is the sole writer today), with the `Unreachable` bit set during the
+ /// dead-code walk (`NODE_FLAGS_UNREACHABLE`).
+ pub node_flags: Vec,
+ /// Function-body + `SourceFile` end-of-flow anchors (binder.go:1561,1569),
+ /// sorted by `NodeId`.
+ pub end_flow: Vec<(NodeId, FlowNodeId)>,
+ /// Constructor + class-static-block return-flow anchors ONLY
+ /// (binder.go:1575), sorted by `NodeId`. Every other tsgo `ReturnFlowNode`
+ /// write/read is dead plumbing and is not ported.
+ pub return_flow: Vec<(NodeId, FlowNodeId)>,
+ /// Case-clause fallthrough anchors: the reachable exit flow of each non-last
+ /// clause (tsgo's `clause.AsCaseOrDefaultClause().FallthroughFlowNode`,
+ /// binder.go:2121), sorted by `NodeId`.
+ pub fallthrough_flow: Vec<(NodeId, FlowNodeId)>,
+ /// Construction counters.
+ pub stats: FlowStats,
+}
+
+impl FlowProduct {
+ /// The `end_flow` anchor for a node, if any (small sorted anchor list).
+ #[must_use]
+ pub fn end_flow_of(&self, node: NodeId) -> Option {
+ self.end_flow
+ .binary_search_by_key(&node, |&(n, _)| n)
+ .ok()
+ .map(|i| self.end_flow[i].1)
+ }
+
+ /// The `return_flow` anchor for a node, if any (constructor / static block).
+ #[must_use]
+ pub fn return_flow_of(&self, node: NodeId) -> Option {
+ self.return_flow
+ .binary_search_by_key(&node, |&(n, _)| n)
+ .ok()
+ .map(|i| self.return_flow[i].1)
+ }
+
+ /// The `fallthrough_flow` anchor for a case clause, if any (the reachable
+ /// exit flow of a non-last clause).
+ #[must_use]
+ pub fn fallthrough_flow_of(&self, node: NodeId) -> Option {
+ self.fallthrough_flow
+ .binary_search_by_key(&node, |&(n, _)| n)
+ .ok()
+ .map(|i| self.fallthrough_flow[i].1)
+ }
+}
+
+// --- DOT renderer (formatControlFlowGraph reference) -----------------------
+
+/// Render one unit's flow graph to Graphviz DOT — the `--dump-flow` product.
+/// Backward DFS from the `SourceFile`/function end-of-flow anchors (and return
+/// anchors) with cycle detection, after Strada's `formatControlFlowGraph`
+/// (flag→header label, subject-node source text, backward edges). `node_spans`
+/// is the F0 `BoundFile::spans` column (subject text = `source[span]`).
+#[must_use]
+pub fn render_flow_dot(product: &FlowProduct, node_spans: &[Span], source: &str) -> String {
+ use std::fmt::Write as _;
+ let g = &product.graph;
+ let mut out = String::new();
+ out.push_str("digraph flow {\n");
+ out.push_str(" rankdir=BT;\n");
+ out.push_str(" node [shape=box, fontname=\"monospace\"];\n");
+
+ let mut seen = vec![false; g.node_count() as usize + 1];
+ let mut stack: Vec = Vec::new();
+ // Roots: every end_flow / return_flow anchor (the exits), plus id 1 so a
+ // fully-unreachable graph still renders the singleton.
+ for &(_, f) in product.end_flow.iter().chain(product.return_flow.iter()) {
+ stack.push(f);
+ }
+ stack.push(FlowNodeId::UNREACHABLE);
+
+ while let Some(id) = stack.pop() {
+ if seen[id.index() + 1] {
+ continue;
+ }
+ seen[id.index() + 1] = true;
+ let label = flow_node_label(g, id, node_spans, source);
+ let _ = writeln!(out, " N{} [label=\"{}\"];", id.get(), escape_dot(&label));
+ for ante in g.antecedents_iter(id) {
+ let _ = writeln!(out, " N{} -> N{};", id.get(), ante.get());
+ stack.push(ante); // cycle-guarded by `seen`
+ }
+ }
+
+ // Anchor edges (dashed) so the exits are visible.
+ for (node, f) in &product.end_flow {
+ let _ = writeln!(
+ out,
+ " END_{n} [shape=doublecircle, label=\"end#{n}\"];\n END_{n} -> N{f} [style=dashed];",
+ n = node.get(),
+ f = f.get()
+ );
+ }
+ out.push_str("}\n");
+ out
+}
+
+fn flow_node_label(g: &FlowGraph, id: FlowNodeId, node_spans: &[Span], source: &str) -> String {
+ let flags = g.flags(id);
+ let header = flow_flag_header(flags);
+ if flags.contains(FlowFlags::REDUCE_LABEL) {
+ // The `subject` slot is a payload index, not a NodeId — read the target
+ // through the payload, never subject().
+ let data = g.reduce_label_data(id);
+ return format!("#{} {}→N{}", id.get(), header, data.target.get());
+ }
+ if flags.contains(FlowFlags::SWITCH_CLAUSE) {
+ // A SwitchClause node's `subject` slot is a payload index, not a NodeId —
+ // read the switch text + clause range through the payload, never subject().
+ let data = g.switch_clause_data(id);
+ let span = node_spans[data.switch.index()];
+ let text = span.extract(source);
+ let text = text.split('\n').next().unwrap_or(text);
+ let text = match text.char_indices().nth(24) {
+ Some((idx, _)) => &text[..idx],
+ None => text,
+ };
+ return format!(
+ "#{} {}[{},{}): {}",
+ id.get(),
+ header,
+ data.clause_start,
+ data.clause_end,
+ text
+ );
+ }
+ if let Some(node) = g.subject(id) {
+ let span = node_spans[node.index()];
+ let text = span.extract(source);
+ let text = text.split('\n').next().unwrap_or(text);
+ // Truncate on a char boundary (byte-slicing `&text[..32]` panics when a
+ // multibyte char straddles byte 32).
+ let text = match text.char_indices().nth(32) {
+ Some((idx, _)) => &text[..idx],
+ None => text,
+ };
+ format!("#{} {}: {}", id.get(), header, text)
+ } else {
+ format!("#{} {}", id.get(), header)
+ }
+}
+
+/// The most salient flag as a short header label (label/condition/start/…).
+fn flow_flag_header(flags: FlowFlags) -> &'static str {
+ if flags.contains(FlowFlags::UNREACHABLE) {
+ "unreachable"
+ } else if flags.contains(FlowFlags::START) {
+ "start"
+ } else if flags.contains(FlowFlags::LOOP_LABEL) {
+ "loop"
+ } else if flags.contains(FlowFlags::BRANCH_LABEL) {
+ "branch"
+ } else if flags.contains(FlowFlags::ASSIGNMENT) {
+ "assign"
+ } else if flags.contains(FlowFlags::TRUE_CONDITION) {
+ "true"
+ } else if flags.contains(FlowFlags::FALSE_CONDITION) {
+ "false"
+ } else if flags.contains(FlowFlags::SWITCH_CLAUSE) {
+ "switch"
+ } else if flags.contains(FlowFlags::REDUCE_LABEL) {
+ "reduce"
+ } else if flags.contains(FlowFlags::CALL) {
+ "call"
+ } else {
+ "flow"
+ }
+}
+
+fn escape_dot(s: &str) -> String {
+ s.replace('\\', "\\\\").replace('"', "\\\"")
+}
diff --git a/crates/tsv_check/src/binder/flow/tests/expressions.rs b/crates/tsv_check/src/binder/flow/tests/expressions.rs
new file mode 100644
index 000000000..ca276b795
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/tests/expressions.rs
@@ -0,0 +1,425 @@
+//! Tests for the expression-shaped flow topology — logical / compound-assign
+//! conditions, class-expression / object-literal method start-subjects, and
+//! IIFE / function-expression isolation — the counterpart of
+//! `build/expressions.rs`.
+
+use super::super::*;
+use super::{build, build_with_bound, condition_of, find_flow, flow_of_node, ident, nodes_of_kind};
+use crate::binder::{BoundFile, NodeKind};
+
+/// Count `CALL` flow nodes in the whole graph.
+fn call_node_count(product: &FlowProduct) -> usize {
+ (1..=product.graph.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .filter(|&id| product.graph.flags(id).contains(FlowFlags::CALL))
+ .count()
+}
+
+#[test]
+fn comma_operands_each_mint_a_call_flow_node() {
+ // `bindBinaryExpressionFlow` comma branch applies `maybeBindExpressionFlowIfCall`
+ // to every operand — each discarded (statement-like) dotted-name call is a
+ // potential assertion, so a two-operand comma mints one CALL per operand.
+ let two = build("function f() { m1(), m2(); }");
+ assert_eq!(
+ call_node_count(&two),
+ 2,
+ "each comma operand's dotted-name call should mint a CALL flow node"
+ );
+ // Control: a bare expression statement mints exactly one (the established path).
+ let one = build("function f() { m1(); }");
+ assert_eq!(call_node_count(&one), 1);
+}
+
+/// Find the first node of `kind`, with its body-`Start` flow node (the START
+/// whose subject is that node), if any.
+fn start_subject_of(
+ product: &FlowProduct,
+ bound: &BoundFile,
+ kind: NodeKind,
+) -> (NodeId, Option) {
+ let node = NodeId::from_index(
+ bound
+ .kinds
+ .iter()
+ .position(|&k| k == kind)
+ .expect("node of kind"),
+ );
+ let g = &product.graph;
+ let start = (1..=g.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .find(|&f| g.flags(f).contains(FlowFlags::START) && g.subject(f) == Some(node));
+ (node, start)
+}
+
+#[test]
+fn class_expression_method_gets_flow_write_and_start_subject() {
+ // tsgo binder.go:981 (outer-flow write on the method node) + :1534
+ // (Start.Node = the method) — class-EXPRESSION methods only.
+ let (product, bound) =
+ build_with_bound("const C = class { m() { return 1; } get g() { return 2; } };");
+ let (method, start) = start_subject_of(&product, &bound, NodeKind::MethodDefinition);
+ assert!(
+ start.is_some(),
+ "class-expression method Start carries the method subject"
+ );
+ assert!(
+ product.flow_of_node[method.index()].is_some(),
+ "class-expression method node gets the outer-flow write"
+ );
+}
+
+#[test]
+fn class_declaration_method_stays_unstamped() {
+ // The Parent.Kind gate (utilities.go:566): a class-DECLARATION method gets
+ // neither the flow write nor a Start subject.
+ let (product, bound) = build_with_bound("class D { m() { return 1; } }");
+ let (method, start) = start_subject_of(&product, &bound, NodeKind::MethodDefinition);
+ assert!(
+ start.is_none(),
+ "class-declaration method Start has no subject"
+ );
+ assert!(product.flow_of_node[method.index()].is_none());
+}
+
+#[test]
+fn class_expression_constructor_excluded_from_method_gate() {
+ // A constructor is not a MethodDeclaration/accessor kind — excluded even
+ // inside a class expression.
+ let (product, bound) = build_with_bound("const C = class { constructor() { this.x = 1; } };");
+ let (ctor, start) = start_subject_of(&product, &bound, NodeKind::MethodDefinition);
+ assert!(start.is_none(), "constructor Start has no subject");
+ assert!(product.flow_of_node[ctor.index()].is_none());
+}
+
+#[test]
+fn object_literal_method_gets_flow_write_and_start_subject() {
+ // The object-literal half of the gate: the Property node (tsv's analog of
+ // tsgo's object-literal MethodDeclaration) is stamped and made the subject.
+ let (product, bound) = build_with_bound("const o = { m() { return 1; } };");
+ let (prop, start) = start_subject_of(&product, &bound, NodeKind::Property);
+ assert!(
+ start.is_some(),
+ "object-literal method Start carries the Property subject"
+ );
+ assert!(product.flow_of_node[prop.index()].is_some());
+}
+
+#[test]
+fn object_literal_plain_property_stays_unstamped() {
+ // A function-VALUED plain property is not a method: the FunctionExpression
+ // itself is the Start subject (the fn-expr rule), the Property is not.
+ let (product, bound) = build_with_bound("const o = { m: function () { return 1; } };");
+ let (prop, prop_start) = start_subject_of(&product, &bound, NodeKind::Property);
+ assert!(
+ prop_start.is_none(),
+ "plain property Start has no Property subject"
+ );
+ assert!(product.flow_of_node[prop.index()].is_none());
+ let (_f, f_start) = start_subject_of(&product, &bound, NodeKind::FunctionExpression);
+ assert!(
+ f_start.is_some(),
+ "the function expression keeps its own subject"
+ );
+}
+
+#[test]
+fn logical_in_condition_value_subposition_is_top_level() {
+ // `if (f(x && y)) a; else b;` — the `x && y` sits in a VALUE sub-position
+ // (a call argument) of the if condition, so it is top-level (a value with
+ // its own post-label), NOT a sub-condition of the if. tsgo classifies this
+ // via a parent walk (`isTopLevelLogicalExpression`); tsv resets the
+ // condition targets at the value boundary in `visit_expression`. The if's
+ // actual condition `f(x && y)` is non-narrowing with no flow effects, so
+ // BOTH arms enter from the function Start — the distinguishing property:
+ // the bug wired x/y's conditions into the if's then/else, making
+ // a.flow != b.flow. (`if (c ? x && y : z)` and `if (g([x && y]))` are the
+ // same class — value sub-positions.)
+ let src = "function w() { if (f(x && y)) a; else b; }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let b = ident(&bound, src, "b");
+ let a_flow = flow_of_node(&product, a);
+ let b_flow = flow_of_node(&product, b);
+ assert_eq!(
+ a_flow, b_flow,
+ "a non-narrowing if-condition merges both arms; x && y must not wire into them"
+ );
+ assert!(product.graph.flags(a_flow).contains(FlowFlags::START));
+ // `x && y` is still narrowed as a value — its own condition nodes exist,
+ // but they feed x && y's post-label, not the if arms.
+ let x = ident(&bound, src, "x");
+ let xc = condition_of(&product, x, true);
+ assert_ne!(a_flow, xc);
+}
+
+#[test]
+fn logical_compound_assign_rhs_is_top_level_value() {
+ // `a &&= x && y;` as a STATEMENT — the RHS `x && y` binds as a top-level
+ // VALUE. tsgo classifies it via `isTopLevelLogicalExpression` (binder.go:2782)
+ // on `right`'s PARENT, which is the `&&=` node (not a logical operator), so
+ // the RHS is top-level: its own true/false conditions are self-contained in a
+ // throwaway post-label and discarded (effect-free identifiers), NOT threaded
+ // into the outer `&&=` post-label. tsgo wires only FALSE(a) + the whole-node
+ // truthiness — 3 antecedents. The bug (threading the RHS) leaked x/y's four
+ // conditions, giving 6: [FALSE(a), FALSE(x), TRUE(y), FALSE(y), TRUE(whole),
+ // FALSE(whole)].
+ let src = "function f() { a &&= x && y; }";
+ let (product, bound) = build_with_bound(src);
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ // The `&&=` has flow effects (the Assignment mutation), so its post-label is
+ // materialized and becomes the function's end-of-flow.
+ let post = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(post).contains(FlowFlags::BRANCH_LABEL));
+
+ let a = ident(&bound, src, "a");
+ let whole = nodes_of_kind(&bound, NodeKind::AssignmentExpression)[0];
+ let false_a = condition_of(&product, a, false);
+ let true_whole = condition_of(&product, whole, true);
+ let false_whole = condition_of(&product, whole, false);
+ // Exact shape (and order): FALSE(a), then the whole-node TRUE/FALSE — no x/y.
+ assert_eq!(
+ product.graph.antecedents(post),
+ vec![false_a, true_whole, false_whole],
+ "the &&= post-label carries FALSE(a) + TRUE/FALSE(whole) only — x/y stay top-level"
+ );
+}
+
+#[test]
+fn logical_compound_assign_still_threads_whole_node_in_condition() {
+ // `if (a &&= x && y) d;` — the `&&=` node itself is a CONDITION (its parent
+ // is the if), so its whole-node truthiness threads into then/else, while its
+ // RHS `x && y` is still top-level (self-contained, discarded). Post-fix:
+ // - the then-branch enters from the whole-node TRUE condition ALONE
+ // (d.flow == TRUE(whole)) — x/y's TRUE(y) does not merge in;
+ // - the else branch carries exactly FALSE(a) + FALSE(whole) — x/y's
+ // FALSE(x)/FALSE(y) do not leak in.
+ // The bug merged TRUE(y) into the then-branch and FALSE(x)/FALSE(y) into the
+ // else-branch.
+ let src = "function f() { if (a &&= x && y) d; }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let d = ident(&bound, src, "d");
+ let whole = nodes_of_kind(&bound, NodeKind::AssignmentExpression)[0];
+ let false_a = condition_of(&product, a, false);
+ let true_whole = condition_of(&product, whole, true);
+ let false_whole = condition_of(&product, whole, false);
+
+ // then-branch = the whole-node TRUE condition alone (single antecedent
+ // collapses the then-label to the condition itself).
+ assert_eq!(
+ flow_of_node(&product, d),
+ true_whole,
+ "the then-branch enters from the &&= whole-node truthiness alone — TRUE(y) must not merge in"
+ );
+
+ // postIf merges the then-exit (TRUE(whole)) and the else-branch label.
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let post_if = product.end_flow_of(f).expect("f end_flow");
+ let ants = product.graph.antecedents(post_if);
+ assert_eq!(
+ ants.len(),
+ 2,
+ "postIf merges the then-exit and the else-branch"
+ );
+ assert_eq!(
+ ants[0], true_whole,
+ "then-exit is the whole-node TRUE condition"
+ );
+ let else_label = ants[1];
+ assert_eq!(
+ product.graph.antecedents(else_label),
+ vec![false_a, false_whole],
+ "the else branch carries only FALSE(a) + FALSE(whole) — x/y stay top-level"
+ );
+}
+
+#[test]
+fn coalescing_compound_assign_rhs_is_top_level_value() {
+ // `a ??= x || y;` as a STATEMENT — the shared logical-compound-assign branch
+ // walked with `is_and=false, is_nullish=true` (the `??=` path, distinct from
+ // `&&=`). Like `&&=`, the RHS `x || y` is a top-level VALUE: tsgo's
+ // `isTopLevelLogicalExpression(right)` (binder.go:2782) inspects `right`'s
+ // PARENT — the `??=` node, which is a compound-assignment operator, not a
+ // logical binary (`IsLogicalExpression` unwraps parens/`!` then requires a
+ // `&&`/`||`/`??` *binary*), so `right` is top-level. Its own true/false
+ // conditions are self-contained in a throwaway post-label and discarded
+ // (effect-free identifiers), NOT threaded into the outer `??=` post-label.
+ // The `??=`/`||` mirror of `bindLogicalLikeExpression` (binder.go:2266-2268,
+ // the non-`&&` branch) wires the LEFT's TRUE condition (not FALSE, as `&&=`
+ // does) into the post: the outer post carries TRUE(a) + the whole-node
+ // truthiness — 3 antecedents, no x/y. The bug (threading the RHS) would leak
+ // x/y's four conditions.
+ let src = "function f() { a ??= x || y; }";
+ let (product, bound) = build_with_bound(src);
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ // The `??=` mutates `a` (a flow effect), so its post-label is materialized and
+ // becomes the function's end-of-flow.
+ let post = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(post).contains(FlowFlags::BRANCH_LABEL));
+
+ let a = ident(&bound, src, "a");
+ let x = ident(&bound, src, "x");
+ let whole = nodes_of_kind(&bound, NodeKind::AssignmentExpression)[0];
+ let true_a = condition_of(&product, a, true);
+ let true_whole = condition_of(&product, whole, true);
+ let false_whole = condition_of(&product, whole, false);
+ // Exact shape (and order): TRUE(a) (the `??=`/`||` mirror of the `&&=` test's
+ // FALSE(a)), then the whole-node TRUE/FALSE — no x/y.
+ assert_eq!(
+ product.graph.antecedents(post),
+ vec![true_a, true_whole, false_whole],
+ "the ??= post-label carries TRUE(a) + TRUE/FALSE(whole) only — x || y stays top-level"
+ );
+ // `x || y` is still narrowed as a value — its TRUE(x) condition exists and
+ // feeds its OWN (discarded, effect-free) post-label, distinct from the ??= post.
+ let true_x = condition_of(&product, x, true);
+ let x_post = find_flow(&product, |g, id| {
+ g.flags(id).is_label() && g.antecedents(id).contains(&true_x)
+ });
+ assert_ne!(
+ x_post, post,
+ "x || y feeds its own post-label, not the ??= post"
+ );
+ assert!(!product.graph.antecedents(post).contains(&true_x));
+}
+
+#[test]
+fn nested_logical_compound_assign_rhs_gets_own_post_label() {
+ // `a &&= b ||= c;` — the RHS `b ||= c` is ITSELF a logical compound-assignment.
+ // Its parent is the outer `&&=` node (an assignment operator, not a logical
+ // binary), so tsgo `isTopLevelLogicalExpression(b ||= c)` is true: it is bound
+ // top-level with its OWN post-label, NOT threaded into the outer `&&=` targets.
+ // Because `b ||= c` has a flow effect (it mutates `b`), its post-label is
+ // materialized and the outer `a`-mutation flows THROUGH it — distinct from the
+ // effect-free logical-RHS case (`a ??= x || y`) where the RHS post is discarded.
+ let src = "function f() { a &&= b ||= c; }";
+ let (product, bound) = build_with_bound(src);
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let post = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(post).contains(FlowFlags::BRANCH_LABEL));
+
+ let a = ident(&bound, src, "a");
+ let b = ident(&bound, src, "b");
+ // Two AssignmentExpressions: the outer `a &&= b ||= c` (whole statement) and
+ // the inner RHS `b ||= c`. Disambiguate by span length (outer encloses inner).
+ let assigns = nodes_of_kind(&bound, NodeKind::AssignmentExpression);
+ assert_eq!(assigns.len(), 2);
+ let span_len = |id: NodeId| bound.spans[id.index()].end - bound.spans[id.index()].start;
+ let outer = assigns
+ .iter()
+ .copied()
+ .max_by_key(|&id| span_len(id))
+ .unwrap();
+ let inner = assigns
+ .iter()
+ .copied()
+ .min_by_key(|&id| span_len(id))
+ .unwrap();
+
+ let false_a = condition_of(&product, a, false);
+ let true_outer = condition_of(&product, outer, true);
+ let false_outer = condition_of(&product, outer, false);
+ // The outer `&&=` post carries FALSE(a) + the outer whole-node TRUE/FALSE only
+ // (the `&&=` mirror) — the inner `b ||= c`'s conditions do NOT leak in.
+ assert_eq!(
+ product.graph.antecedents(post),
+ vec![false_a, true_outer, false_outer],
+ "the &&= post carries FALSE(a) + TRUE/FALSE(outer) only — b ||= c stays top-level"
+ );
+
+ // The inner `b ||= c` has its OWN materialized post-label (it mutates `b`),
+ // carrying its own [TRUE(b), TRUE(inner), FALSE(inner)] — the `||=` mirror,
+ // self-contained exactly as the whole `??=` RHS was, one level down.
+ let true_b = condition_of(&product, b, true);
+ let true_inner = condition_of(&product, inner, true);
+ let false_inner = condition_of(&product, inner, false);
+ let inner_post = find_flow(&product, |g, id| {
+ g.flags(id).is_label() && g.antecedents(id).contains(&true_inner)
+ });
+ assert_ne!(
+ inner_post, post,
+ "b ||= c feeds its own post-label, not the &&= post"
+ );
+ assert_eq!(
+ product.graph.antecedents(inner_post),
+ vec![true_b, true_inner, false_inner],
+ "b ||= c's own post carries TRUE(b) + its whole-node TRUE/FALSE"
+ );
+ // The outer `a`-mutation's antecedent is that inner post (b ||= c had flow
+ // effects), so the nested compound-assign threads through as a top-level value.
+ let a_assign = find_flow(&product, |g, id| {
+ g.flags(id).contains(FlowFlags::ASSIGNMENT) && g.subject(id) == Some(a)
+ });
+ assert_eq!(
+ product.graph.antecedents(a_assign),
+ vec![inner_post],
+ "the outer a-mutation's antecedent is b ||= c's materialized post"
+ );
+}
+
+#[test]
+fn iife_body_is_inlined_into_containing_flow() {
+ // THE IIFE PROOF. `(function(){ g(); })(); h();` — the IIFE body is NOT
+ // flow-isolated: `h` continues from the IIFE body's exit (the g() call),
+ // and `g` binds under the ambient flow (no fresh Start).
+ let src = "function f() { (function(){ g(); })(); h(); }";
+ let (product, bound) = build_with_bound(src);
+ let g = ident(&bound, src, "g");
+ let h = ident(&bound, src, "h");
+ assert!(
+ product
+ .graph
+ .flags(flow_of_node(&product, g))
+ .contains(FlowFlags::START),
+ "g binds under the ambient (transparent) flow"
+ );
+ assert!(
+ product
+ .graph
+ .flags(flow_of_node(&product, h))
+ .contains(FlowFlags::CALL),
+ "h continues from the IIFE body's g() call, not a restored/fresh flow"
+ );
+}
+
+#[test]
+fn non_invoked_function_expression_is_flow_isolated() {
+ // Contrast: a non-invoked function expression IS isolated — `h` is
+ // unaffected (binds at the `const x = …` mutation), and `g` binds under
+ // the function's own fresh Start.
+ let src = "function f() { const x = function(){ g(); }; h(); }";
+ let (product, bound) = build_with_bound(src);
+ let g = ident(&bound, src, "g");
+ let h = ident(&bound, src, "h");
+ assert!(
+ product
+ .graph
+ .flags(flow_of_node(&product, g))
+ .contains(FlowFlags::START)
+ );
+ assert!(
+ product
+ .graph
+ .flags(flow_of_node(&product, h))
+ .contains(FlowFlags::ASSIGNMENT),
+ "h binds at the const-x assignment, not the isolated g() call"
+ );
+}
+
+#[test]
+fn async_iife_stays_isolated() {
+ // Guards the `!async` gate: an async IIFE is NOT inlined, so `h` binds
+ // under the outer function's own flow (Start), not continued from the
+ // async body's g() call. A regression dropping the async check would make
+ // `h`'s flow the inlined CALL (as in the sync-IIFE proof).
+ let src = "function f() { (async function(){ g(); })(); h(); }";
+ let (product, bound) = build_with_bound(src);
+ let h = ident(&bound, src, "h");
+ let h_flow = flow_of_node(&product, h);
+ assert!(
+ product.graph.flags(h_flow).contains(FlowFlags::START),
+ "h binds under the outer Start — the async IIFE body is flow-isolated"
+ );
+ assert!(!product.graph.flags(h_flow).contains(FlowFlags::CALL));
+}
diff --git a/crates/tsv_check/src/binder/flow/tests/mod.rs b/crates/tsv_check/src/binder/flow/tests/mod.rs
new file mode 100644
index 000000000..173764796
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/tests/mod.rs
@@ -0,0 +1,305 @@
+//! Shared test fixtures for the flow-graph walk, plus the flow-node minting,
+//! container, and label-pool tests — the graph-construction machinery that
+//! `build/mod.rs` itself owns, as opposed to any one statement or expression
+//! shape. The statement-shaped, expression-shaped, and predicate tests live
+//! in the `statements`, `expressions`, and `predicates` submodules,
+//! mirroring `build/statements.rs`, `build/expressions.rs`, and
+//! `build/predicates.rs`.
+
+mod expressions;
+mod predicates;
+mod statements;
+
+use super::build::FlowBuilder;
+use super::*;
+use crate::binder::{BoundFile, NodeKind, bind_file};
+use crate::ids::FileId;
+use bumpalo::Bump;
+
+/// Bind + build the flow product for a snippet (a fresh arena per call).
+fn flow_of(source: &str) -> (Bump, BoundFile) {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(source, &arena).expect("parse");
+ let bound = bind_file(&program, source, FileId::ROOT);
+ (arena, bound)
+}
+
+fn build(source: &str) -> FlowProduct {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(source, &arena).expect("parse");
+ let bound = bind_file(&program, source, FileId::ROOT);
+ build_flow(&program, source, &bound)
+}
+
+/// Build the flow product **and** keep the `BoundFile` (both owned) so a
+/// topology test can look up node ids by kind / text.
+fn build_with_bound(source: &str) -> (FlowProduct, BoundFile) {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(source, &arena).expect("parse");
+ let bound = bind_file(&program, source, FileId::ROOT);
+ let product = build_flow(&program, source, &bound);
+ (product, bound)
+}
+
+/// The flow node stamped on a node (panics if unattached).
+fn flow_of_node(product: &FlowProduct, id: NodeId) -> FlowNodeId {
+ product.flow_of_node[id.index()].expect("flow attachment")
+}
+
+/// The single flow node matching `pred` (panics if none / used where unique).
+fn find_flow(product: &FlowProduct, pred: impl Fn(&FlowGraph, FlowNodeId) -> bool) -> FlowNodeId {
+ (1..=product.graph.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .find(|&id| pred(&product.graph, id))
+ .expect("matching flow node")
+}
+
+/// The condition node (`TrueCondition`/`FalseCondition`) whose subject is
+/// `subject`.
+fn condition_of(product: &FlowProduct, subject: NodeId, want_true: bool) -> FlowNodeId {
+ let flag = if want_true {
+ FlowFlags::TRUE_CONDITION
+ } else {
+ FlowFlags::FALSE_CONDITION
+ };
+ find_flow(product, |g, id| {
+ g.flags(id).contains(flag) && g.subject(id) == Some(subject)
+ })
+}
+
+fn nodes_of_kind(bound: &BoundFile, kind: NodeKind) -> Vec {
+ bound
+ .kinds
+ .iter()
+ .enumerate()
+ .filter(|(_, k)| **k == kind)
+ .map(|(i, _)| NodeId::from_index(i))
+ .collect()
+}
+
+/// The `NodeId` of the identifier whose source text is exactly `text`.
+fn ident(bound: &BoundFile, source: &str, text: &str) -> NodeId {
+ for (i, k) in bound.kinds.iter().enumerate() {
+ if *k == NodeKind::Identifier && bound.spans[i].extract(source) == text {
+ return NodeId::from_index(i);
+ }
+ }
+ panic!("identifier {text:?} not found");
+}
+
+#[test]
+fn unreachable_flow_is_id_1() {
+ let product = build("const x = 1;");
+ let uid = FlowNodeId::UNREACHABLE;
+ assert_eq!(uid.get(), 1);
+ assert!(product.graph.flags(uid).contains(FlowFlags::UNREACHABLE));
+ // The SourceFile Start is id 2 (minted right after unreachable).
+ assert!(product.graph.node_count() >= 2);
+}
+
+#[test]
+fn antecedent_iter_forms_agree_with_collected_forms() {
+ // A branching + loop + try/finally program exercises single-slot nodes,
+ // multi-antecedent labels, and a ReduceLabel; the zero-alloc iterators
+ // must agree with the collected forms on every node, and the non-label
+ // single-slot read must agree with the general form.
+ let product = build(
+ "function f(a: boolean) { try { while (a) { if (a) break; a = !a; } } finally { g(); } }",
+ );
+ let g = &product.graph;
+ for raw in 1..=g.node_count() {
+ let id = FlowNodeId::from_raw(raw).unwrap();
+ let collected = g.antecedents(id);
+ assert_eq!(g.antecedents_iter(id).collect::>(), collected);
+ if !g.flags(id).is_label() {
+ assert_eq!(g.single_antecedent(id), collected.first().copied());
+ assert!(collected.len() <= 1);
+ }
+ if g.flags(id).contains(FlowFlags::REDUCE_LABEL) {
+ assert_eq!(
+ g.reduce_label_antecedents_iter(id).collect::>(),
+ g.reduce_label_antecedents(id)
+ );
+ }
+ }
+}
+
+#[test]
+fn node_flags_column_is_minted_here_zeroed_and_sized() {
+ // The per-node flag column lives on the flow product (its sole writer);
+ // reachable-only code leaves every byte zero.
+ let (product, bound) = build_with_bound("const x = 1; function f(a: T) { return a; }");
+ assert_eq!(product.node_flags.len(), bound.node_count as usize);
+ assert!(product.node_flags.iter().all(|&b| b == 0));
+}
+
+#[test]
+fn linear_two_statements_thread_one_start() {
+ let src = "function f() { a; b; }";
+ let (_arena, bound) = flow_of(src);
+ let product = {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ build_flow(&program, src, &bind_file(&program, src, FileId::ROOT))
+ };
+ // Both expression statements capture the same entry flow (f's Start), and
+ // that Start is f's end-of-flow (reachable at exit).
+ let stmts = nodes_of_kind(&bound, NodeKind::ExpressionStatement);
+ assert_eq!(stmts.len(), 2);
+ let flow_a = product.flow_of_node[stmts[0].index()].expect("a entry flow");
+ let flow_b = product.flow_of_node[stmts[1].index()].expect("b entry flow");
+ assert_eq!(flow_a, flow_b);
+ assert!(product.graph.flags(flow_a).contains(FlowFlags::START));
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ assert_eq!(product.end_flow_of(f), Some(flow_a));
+}
+
+#[test]
+fn linear_var_init_and_dotted_call() {
+ let product = build("function f() { let x = 1; g(); }");
+ // One Assignment mutation (`x = 1`) and one Call (`g()`).
+ let has_assignment = (1..=product.graph.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .any(|id| product.graph.flags(id).contains(FlowFlags::ASSIGNMENT));
+ let has_call = (1..=product.graph.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .any(|id| product.graph.flags(id).contains(FlowFlags::CALL));
+ assert!(
+ has_assignment,
+ "expected a createFlowMutation(Assignment) node"
+ );
+ assert!(has_call, "expected a createFlowCall node");
+}
+
+#[test]
+fn unreachable_after_return_propagates() {
+ let src = "function f() { return; a; }";
+ let (_arena, bound) = flow_of(src);
+ let product = {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ build_flow(&program, src, &bind_file(&program, src, FileId::ROOT))
+ };
+
+ // The ReturnStatement's entry flow is f's Start.
+ let ret = nodes_of_kind(&bound, NodeKind::ReturnStatement)[0];
+ let ret_flow = product.flow_of_node[ret.index()].expect("return entry flow");
+ assert!(product.graph.flags(ret_flow).contains(FlowFlags::START));
+
+ // The dead `a;` ExpressionStatement: flow nil (None) + Unreachable bit.
+ let a_stmt = nodes_of_kind(&bound, NodeKind::ExpressionStatement)[0];
+ assert_eq!(product.flow_of_node[a_stmt.index()], None);
+ assert_ne!(
+ product.node_flags[a_stmt.index()] & crate::binder::NODE_FLAGS_UNREACHABLE,
+ 0
+ );
+
+ // The dead leaf identifier `a` keeps Some(unreachable = id 1).
+ let a_id = ident(&bound, src, "a");
+ assert_eq!(
+ product.flow_of_node[a_id.index()],
+ Some(FlowNodeId::UNREACHABLE)
+ );
+
+ // f gets NO end_flow (its exit is unreachable). The only end_flow is the
+ // SourceFile root.
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ assert_eq!(product.end_flow_of(f), None);
+ assert_eq!(product.end_flow.len(), 1); // SourceFile only
+}
+
+#[test]
+fn constructor_gets_a_return_flow_anchor() {
+ let src = "class C { constructor() { return; } }";
+ let (_arena, bound) = flow_of(src);
+ let product = {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ build_flow(&program, src, &bind_file(&program, src, FileId::ROOT))
+ };
+ // The constructor container carries exactly one return_flow anchor (keyed
+ // on the value FunctionExpression — the reliably-addressable body-bearing
+ // node; see the F0-collision note in `visit_method`). Its single-
+ // antecedent return label collapsed to the `return`'s Start (a dead row).
+ assert_eq!(product.return_flow.len(), 1);
+ let rf = product.return_flow[0].1;
+ assert!(product.graph.flags(rf).contains(FlowFlags::START));
+ // The anchor is a FunctionExpression node (the method body).
+ let anchor_node = product.return_flow[0].0;
+ assert_eq!(
+ bound.kinds[anchor_node.index()],
+ NodeKind::FunctionExpression
+ );
+ // The symmetric accessor resolves the anchor to the same return flow.
+ assert_eq!(product.return_flow_of(anchor_node), Some(rf));
+ assert!(product.stats.branch_labels >= 1);
+ assert!(product.stats.dead_labels >= 1);
+}
+
+#[test]
+fn finish_flow_label_pool_run_preserves_order_and_dedups() {
+ let src = "const x = 1;";
+ let arena = Bump::new();
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ let bound = bind_file(&program, src, FileId::ROOT);
+ let mut b = FlowBuilder::new(&bound, src);
+ let a1 = b.new_flow_node(FlowFlags::START);
+ let a2 = b.new_flow_node(FlowFlags::ASSIGNMENT);
+ let label = b.create_branch_label();
+ b.add_antecedent(label, a1);
+ b.add_antecedent(label, a2);
+ b.add_antecedent(label, a1); // id-equality dedup: ignored
+ let finished = b.finish_flow_label(label);
+ assert_eq!(finished, label); // 2+ antecedents → the label survives
+ let product = b.finish();
+ // Entry edge first, order preserved, no duplicate.
+ assert_eq!(product.graph.antecedents(label), vec![a1, a2]);
+ // Both antecedents were referenced; a1 twice would be Shared, but the dup
+ // was a no-op, so a1 is Referenced-once here.
+ assert!(product.graph.flags(a1).contains(FlowFlags::REFERENCED));
+}
+
+#[test]
+fn referenced_shared_recompute_parity() {
+ // Recompute the live-graph in-degree and check it against the Referenced /
+ // Shared bits. `setFlowNodeReferenced` marks a node on EVERY antecedent
+ // add at construction (matching tsgo), including adds into a branch label
+ // that later COLLAPSES to a dead row — and tsv's SoA drops a collapsed
+ // label's edges (slot 0, no pool run). So the live in-degree is a **lower
+ // bound** on the referenced-count, and the sound, one-directional
+ // invariant is: every live antecedent edge is reflected in the bits (they
+ // never under-mark). The fn Start (shared by both condition nodes) gives a
+ // genuine live in-degree ≥ 2 → Shared.
+ let src = "function f() { if (x) a; else b; }";
+ let product = build(src);
+ let g = &product.graph;
+ let n = g.node_count();
+ let mut indeg = vec![0u32; (n + 1) as usize];
+ for id in (1..=n).filter_map(FlowNodeId::from_raw) {
+ for ante in g.antecedents(id) {
+ indeg[ante.get() as usize] += 1;
+ }
+ }
+ let mut saw_shared = false;
+ for id in (1..=n).filter_map(FlowNodeId::from_raw) {
+ let d = indeg[id.get() as usize];
+ let flags = g.flags(id);
+ if d >= 1 {
+ assert!(
+ flags.contains(FlowFlags::REFERENCED),
+ "in-degree ≥ 1 ⟹ Referenced at node {}",
+ id.get()
+ );
+ }
+ if d >= 2 {
+ assert!(
+ flags.contains(FlowFlags::SHARED),
+ "in-degree ≥ 2 ⟹ Shared at node {}",
+ id.get()
+ );
+ saw_shared = true;
+ }
+ }
+ assert!(saw_shared, "the fn Start is shared by both condition nodes");
+}
diff --git a/crates/tsv_check/src/binder/flow/tests/predicates.rs b/crates/tsv_check/src/binder/flow/tests/predicates.rs
new file mode 100644
index 000000000..d46da25a6
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/tests/predicates.rs
@@ -0,0 +1,109 @@
+//! Tests for the pure AST predicates the flow walk dispatches on — the
+//! counterpart of `build/predicates.rs`.
+
+use super::super::build::{FlowBuilder, is_narrowable_reference};
+use super::super::*;
+use crate::binder::{NodeKind, addr_of, bind_file};
+use crate::ids::FileId;
+use bumpalo::Bump;
+use tsv_ts::ast::internal::{Expression, Statement};
+
+#[test]
+fn create_flow_condition_ports_verbatim() {
+ let src = "true; false; y;";
+ let arena = Bump::new();
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ let bound = bind_file(&program, src, FileId::ROOT);
+
+ // Extract the top-level expressions + their node ids.
+ let expr_at = |i: usize| -> (&Expression<'_>, NodeId) {
+ let Statement::ExpressionStatement(s) = &program.body[i] else {
+ panic!("expression statement");
+ };
+ let id = match &s.expression {
+ Expression::Literal(l) => bound.require_node_id(addr_of(l), NodeKind::Literal),
+ Expression::Identifier(idn) => {
+ bound.require_node_id(addr_of(idn), NodeKind::Identifier)
+ }
+ _ => panic!("unexpected expression"),
+ };
+ (&s.expression, id)
+ };
+ let true_lit = expr_at(0);
+ let false_lit = expr_at(1);
+ let y = expr_at(2);
+
+ let mut b = FlowBuilder::new(&bound, src);
+ let ante = b.new_flow_node(FlowFlags::START);
+
+ // nil-expr True → passthrough; nil-expr False → unreachable.
+ assert_eq!(
+ b.create_flow_condition(FlowFlags::TRUE_CONDITION, ante, None, false, false, false),
+ ante
+ );
+ assert_eq!(
+ b.create_flow_condition(FlowFlags::FALSE_CONDITION, ante, None, false, false, false),
+ b.unreachable_flow
+ );
+
+ // literal `true` under a FalseCondition (not in an optional-chain /
+ // nullish context) short-circuits to unreachable; `false` under a
+ // TrueCondition likewise.
+ assert_eq!(
+ b.create_flow_condition(
+ FlowFlags::FALSE_CONDITION,
+ ante,
+ Some(true_lit),
+ false,
+ false,
+ false
+ ),
+ b.unreachable_flow
+ );
+ assert_eq!(
+ b.create_flow_condition(
+ FlowFlags::TRUE_CONDITION,
+ ante,
+ Some(false_lit),
+ false,
+ false,
+ false
+ ),
+ b.unreachable_flow
+ );
+
+ // A non-narrowing expression leaves the antecedent unchanged.
+ assert_eq!(
+ b.create_flow_condition(
+ FlowFlags::TRUE_CONDITION,
+ ante,
+ Some(y),
+ false,
+ false,
+ false
+ ),
+ ante
+ );
+
+ // A narrowing expression mints a new condition node carrying the flag.
+ let cond =
+ b.create_flow_condition(FlowFlags::TRUE_CONDITION, ante, Some(y), true, false, false);
+ assert_ne!(cond, ante);
+ assert!(b.flags[cond.index()].contains(FlowFlags::TRUE_CONDITION));
+}
+
+#[test]
+fn is_narrowable_reference_matches_tsgo_shape() {
+ // Sanity for the live access-gate helper.
+ let arena = Bump::new();
+ let src = "a.b; a[0]; a?.b;";
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ for stmt in program.body {
+ if let Statement::ExpressionStatement(s) = stmt {
+ assert!(
+ is_narrowable_reference(&s.expression),
+ "member/element access should be narrowable"
+ );
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/flow/tests/statements.rs b/crates/tsv_check/src/binder/flow/tests/statements.rs
new file mode 100644
index 000000000..b16770734
--- /dev/null
+++ b/crates/tsv_check/src/binder/flow/tests/statements.rs
@@ -0,0 +1,612 @@
+//! Tests for the statement-shaped flow topology — conditions, loops, switch,
+//! try/catch/finally, initializer forks, and labeled statements — the
+//! counterpart of `build/statements.rs`.
+
+use super::super::*;
+use super::{build, build_with_bound, condition_of, flow_of_node, ident, nodes_of_kind};
+use crate::binder::NodeKind;
+
+// --- F1b branching topology (hand-traced graphs) ----------------------
+
+#[test]
+fn if_else_two_arm_merge() {
+ // `if (x) a; else b;` — C1=TrueCond(x,F0), C2=FalseCond(x,F0); a.flow=C1,
+ // b.flow=C2; both merge at a materialized BranchLabel [C1,C2]; F0 Shared.
+ let src = "function f() { if (x) a; else b; }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let a = ident(&bound, src, "a");
+ let b = ident(&bound, src, "b");
+
+ let f0 = flow_of_node(&product, x);
+ assert!(product.graph.flags(f0).contains(FlowFlags::START));
+
+ let c1 = condition_of(&product, x, true);
+ let c2 = condition_of(&product, x, false);
+ assert_eq!(product.graph.antecedents(c1), vec![f0]);
+ assert_eq!(product.graph.antecedents(c2), vec![f0]);
+ assert_eq!(flow_of_node(&product, a), c1);
+ assert_eq!(flow_of_node(&product, b), c2);
+
+ // F0 is referenced by both conditions → Shared.
+ assert!(product.graph.flags(f0).contains(FlowFlags::SHARED));
+
+ // The if merges at postIf (a materialized 2-antecedent BranchLabel) — the
+ // function's end-of-flow.
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let exit = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(exit).contains(FlowFlags::BRANCH_LABEL));
+ assert_eq!(product.graph.antecedents(exit), vec![c1, c2]);
+}
+
+#[test]
+fn reachable_after_if_merge() {
+ // `if (x) a; b;` — with no else, `b` (the statement after the if) binds at
+ // the postIf merge label.
+ let src = "function f() { if (x) a; b; }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let b = ident(&bound, src, "b");
+ let c1 = condition_of(&product, x, true);
+ let c2 = condition_of(&product, x, false);
+ let b_flow = flow_of_node(&product, b);
+ // b's entry flow is the postIf label carrying the then-branch (C1) and the
+ // empty-else branch (C2).
+ assert!(
+ product
+ .graph
+ .flags(b_flow)
+ .contains(FlowFlags::BRANCH_LABEL)
+ );
+ assert_eq!(product.graph.antecedents(b_flow), vec![c1, c2]);
+}
+
+#[test]
+fn while_loop_topology() {
+ // `while (x) a;` — L1=LoopLabel; entry F0 added first, back edge (C1)
+ // after the body → L1.antecedents=[F0,C1]; x.flow=L1; a.flow=C1; exit=C2.
+ let src = "function f() { while (x) a; }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let a = ident(&bound, src, "a");
+ let while_stmt = nodes_of_kind(&bound, NodeKind::WhileStatement)[0];
+ let f0 = flow_of_node(&product, while_stmt); // the while's entry flow (f's Start)
+
+ let l1 = flow_of_node(&product, x);
+ assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL));
+ let c1 = condition_of(&product, x, true);
+ let c2 = condition_of(&product, x, false);
+ assert_eq!(product.graph.antecedents(l1), vec![f0, c1]);
+ assert_eq!(flow_of_node(&product, a), c1);
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ assert_eq!(product.end_flow_of(f), Some(c2));
+}
+
+#[test]
+fn do_while_loop_topology() {
+ // `do a; while (x);` — L1=LoopLabel[F0]; a.flow=L1; x.flow=L1; the
+ // true-condition loops back → L1.antecedents=[F0,C1]; exit=C2.
+ let src = "function f() { do a; while (x); }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let a = ident(&bound, src, "a");
+ let do_stmt = nodes_of_kind(&bound, NodeKind::DoWhileStatement)[0];
+ let f0 = flow_of_node(&product, do_stmt);
+
+ let l1 = flow_of_node(&product, a);
+ assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL));
+ assert_eq!(flow_of_node(&product, x), l1); // condition binds from the loop label
+ let c1 = condition_of(&product, x, true);
+ let c2 = condition_of(&product, x, false);
+ assert_eq!(product.graph.antecedents(l1), vec![f0, c1]);
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ assert_eq!(product.end_flow_of(f), Some(c2));
+}
+
+#[test]
+fn for_infinite_self_loop() {
+ // `for (;;) a;` — nil condition: True→L1 passthrough, False→unreachable
+ // (dropped). a.flow=L1; the back edge self-loops → L1.antecedents=[F0,L1];
+ // postLoop stays empty so the function exits unreachable (no end_flow).
+ let src = "function f() { for (;;) a; }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let for_stmt = nodes_of_kind(&bound, NodeKind::ForStatement)[0];
+ let f0 = flow_of_node(&product, for_stmt);
+
+ let l1 = flow_of_node(&product, a);
+ assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL));
+ // Self-loop: L1 is its own back-edge antecedent (guarded by vec equality).
+ assert_eq!(product.graph.antecedents(l1), vec![f0, l1]);
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ assert_eq!(product.end_flow_of(f), None); // unreachable exit
+}
+
+#[test]
+fn unlabeled_continue_targets_loop_label() {
+ // `while (x) continue;` — the continue routes back to the loop label,
+ // so L1.antecedents=[F0, C1]; the normal exit is the false condition.
+ let src = "function f() { while (x) continue; }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let l1 = flow_of_node(&product, x);
+ let c1 = condition_of(&product, x, true);
+ let antes = product.graph.antecedents(l1);
+ assert!(
+ antes.contains(&c1),
+ "continue back-edge lands on the loop label"
+ );
+ assert_eq!(antes.len(), 2); // [entry F0, continue C1]
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let c2 = condition_of(&product, x, false);
+ assert_eq!(product.end_flow_of(f), Some(c2));
+}
+
+#[test]
+fn unlabeled_break_targets_post_loop() {
+ // `while (x) break;` — the break routes to the post-loop label (the
+ // function exit), which also carries the false-condition edge; the break
+ // makes the back edge unreachable, so the loop label keeps only its entry.
+ let src = "function f() { while (x) break; }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let c1 = condition_of(&product, x, true);
+ let c2 = condition_of(&product, x, false);
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let exit = product.end_flow_of(f).expect("f end_flow");
+ let antes = product.graph.antecedents(exit);
+ assert!(antes.contains(&c1), "break edge to the post-loop label");
+ assert!(antes.contains(&c2), "false-condition exit edge");
+
+ // The loop label kept only the entry edge (the back edge was unreachable).
+ let l1 = flow_of_node(&product, x);
+ assert_eq!(product.graph.antecedents(l1).len(), 1);
+}
+
+// --- F2a switch topology (hand-traced graphs) -------------------------
+
+/// Every `SwitchClause` flow node, in id order.
+fn switch_clauses(product: &FlowProduct) -> Vec {
+ (1..=product.graph.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .filter(|&id| product.graph.flags(id).contains(FlowFlags::SWITCH_CLAUSE))
+ .collect()
+}
+
+#[test]
+fn switch_no_default_has_exhaustive_sentinel() {
+ // `switch (x) { case 1: a; }` — no default clause, so postSwitch gets the
+ // clause-1 exit AND a `(0, 0)` "no clause matched" SwitchClause sentinel.
+ let src = "function f() { switch (x) { case 1: a; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ // The clause body is reachable (fed from the switch head).
+ assert_ne!(flow_of_node(&product, a), FlowNodeId::UNREACHABLE);
+
+ // The `(0, 0)` sentinel exists and feeds postSwitch (the function exit).
+ let sentinel = switch_clauses(&product)
+ .into_iter()
+ .find(|&id| {
+ let d = product.graph.switch_clause_data(id);
+ d.clause_start == 0 && d.clause_end == 0
+ })
+ .expect("no-default (0,0) sentinel");
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let exit = product.end_flow_of(f).expect("f end_flow");
+ assert!(
+ product.graph.antecedents(exit).contains(&sentinel),
+ "the (0,0) sentinel feeds postSwitch"
+ );
+}
+
+#[test]
+fn switch_break_then_clause_stays_reachable() {
+ // THE F2a PROOF. `switch (x) { case 1: break; case 2: a; }` — case 1
+ // breaks, so nothing falls through into case 2; but case 2 is reachable
+ // FROM THE SWITCH HEAD, so `a` must be reachable. F1b's linear stub
+ // threaded current_flow (= unreachable after the break) into case 2 and
+ // wrongly marked it Unreachable — this test fails on that stub.
+ let src = "function f() { switch (x) { case 1: break; case 2: a; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let a_flow = flow_of_node(&product, a);
+ assert_ne!(
+ a_flow,
+ FlowNodeId::UNREACHABLE,
+ "case 2 is reachable from the switch head despite case 1's break"
+ );
+ // `a`'s entry is the clause's SwitchClause node covering range [1, 2).
+ assert!(
+ product
+ .graph
+ .flags(a_flow)
+ .contains(FlowFlags::SWITCH_CLAUSE)
+ );
+ assert_eq!(
+ {
+ let d = product.graph.switch_clause_data(a_flow);
+ (d.clause_start, d.clause_end)
+ },
+ (1, 2)
+ );
+ // The `a;` statement is reachable: Some entry flow, no Unreachable flag.
+ let a_stmt = nodes_of_kind(&bound, NodeKind::ExpressionStatement)[0];
+ assert!(product.flow_of_node[a_stmt.index()].is_some());
+ assert_eq!(
+ product.node_flags[a_stmt.index()] & crate::binder::NODE_FLAGS_UNREACHABLE,
+ 0
+ );
+}
+
+#[test]
+fn switch_fallthrough_feeds_next_clause() {
+ // `switch (x) { case 1: a; case 2: b; }` — case 1 falls through to case 2,
+ // so case 2's preCase merges its switch-head edge (a SwitchClause[1,2)) and
+ // case 1's fallthrough edge; case 1 records a fallthrough anchor.
+ let src = "function f() { switch (x) { case 1: a; case 2: b; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let b = ident(&bound, src, "b");
+ let a_flow = flow_of_node(&product, a);
+ let b_flow = flow_of_node(&product, b);
+
+ // case 2 binds at a materialized 2-antecedent branch label.
+ assert!(
+ product
+ .graph
+ .flags(b_flow)
+ .contains(FlowFlags::BRANCH_LABEL)
+ );
+ let antes = product.graph.antecedents(b_flow);
+ assert_eq!(antes.len(), 2);
+ // One antecedent is case 1's exit (the fallthrough).
+ assert!(antes.contains(&a_flow), "fallthrough edge from case 1");
+ // The other is case 2's switch-head SwitchClause with range [1, 2).
+ let head = antes
+ .iter()
+ .copied()
+ .find(|&x| x != a_flow)
+ .expect("head edge");
+ assert!(product.graph.flags(head).contains(FlowFlags::SWITCH_CLAUSE));
+ assert_eq!(
+ {
+ let d = product.graph.switch_clause_data(head);
+ (d.clause_start, d.clause_end)
+ },
+ (1, 2)
+ );
+ // case 1 (the first SwitchCase node) recorded its reachable exit anchor.
+ let case1 = nodes_of_kind(&bound, NodeKind::SwitchCase)[0];
+ assert_eq!(product.fallthrough_flow_of(case1), Some(a_flow));
+}
+
+#[test]
+fn switch_empty_clause_run_reachable() {
+ // `switch (x) { case 1: case 2: a; }` — the empty `case 1` shares the run
+ // with `case 2`; `a` is reachable, fed from the head via one SwitchClause
+ // whose range spans the merged run [0, 2).
+ let src = "function f() { switch (x) { case 1: case 2: a; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let a_flow = flow_of_node(&product, a);
+ assert_ne!(a_flow, FlowNodeId::UNREACHABLE);
+ assert!(
+ product
+ .graph
+ .flags(a_flow)
+ .contains(FlowFlags::SWITCH_CLAUSE)
+ );
+ assert_eq!(
+ {
+ let d = product.graph.switch_clause_data(a_flow);
+ (d.clause_start, d.clause_end)
+ },
+ (0, 2)
+ );
+}
+
+#[test]
+fn switch_true_narrows_with_real_range() {
+ // `switch (true) { case y: a; }` — a narrowing switch, so the clause gets
+ // a real SwitchClause node carrying its [0, 1) range, fed from the head.
+ let src = "function f() { switch (true) { case y: a; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let a_flow = flow_of_node(&product, a);
+ assert!(
+ product
+ .graph
+ .flags(a_flow)
+ .contains(FlowFlags::SWITCH_CLAUSE)
+ );
+ assert_eq!(
+ {
+ let d = product.graph.switch_clause_data(a_flow);
+ (d.clause_start, d.clause_end)
+ },
+ (0, 1)
+ );
+ // The SwitchClause node's single antecedent is the switch head (fn Start).
+ let head = product.graph.antecedents(a_flow);
+ assert_eq!(head.len(), 1);
+ assert!(product.graph.flags(head[0]).contains(FlowFlags::START));
+}
+
+#[test]
+fn switch_non_narrowing_clauses_have_no_payload() {
+ // `switch (f()) { case 1: a; case 2: b; }` — a call discriminant is NOT
+ // narrowing, so each clause is fed from the bare switch head (no per-clause
+ // `SwitchClause` payload node). Clauses stay reachable; the only SwitchClause
+ // in the graph is the no-default `(0,0)` sentinel. (Guards the `is_narrowing_switch`
+ // false branch — a regression that always minted SwitchClause nodes would
+ // pass every narrowing test.)
+ let src = "function f() { switch (f()) { case 1: a; case 2: b; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let b = ident(&bound, src, "b");
+ assert_ne!(flow_of_node(&product, a), FlowNodeId::UNREACHABLE);
+ assert_ne!(flow_of_node(&product, b), FlowNodeId::UNREACHABLE);
+ // Neither clause body's entry flow is a SwitchClause node.
+ assert!(
+ !product
+ .graph
+ .flags(flow_of_node(&product, a))
+ .contains(FlowFlags::SWITCH_CLAUSE)
+ );
+ assert!(
+ !product
+ .graph
+ .flags(flow_of_node(&product, b))
+ .contains(FlowFlags::SWITCH_CLAUSE)
+ );
+ // The only SwitchClause node is the `(0,0)` sentinel (no default clause).
+ let clauses = switch_clauses(&product);
+ assert_eq!(clauses.len(), 1);
+ let d = product.graph.switch_clause_data(clauses[0]);
+ assert_eq!((d.clause_start, d.clause_end), (0, 0));
+}
+
+#[test]
+fn switch_with_default_has_no_sentinel() {
+ // `switch (x) { case 1: a; default: b; }` — a `default` clause makes the
+ // switch exhaustive, so NO `(0,0)` sentinel is emitted. (Narrowing, so the
+ // clauses still get real SwitchClause payloads.) Guards the `has_default`
+ // path — a regression that always emitted the sentinel would pass every
+ // no-default test.
+ let src = "function f() { switch (x) { case 1: a; default: b; } }";
+ let (product, bound) = build_with_bound(src);
+ let a = ident(&bound, src, "a");
+ let b = ident(&bound, src, "b");
+ assert_ne!(flow_of_node(&product, a), FlowNodeId::UNREACHABLE);
+ assert_ne!(flow_of_node(&product, b), FlowNodeId::UNREACHABLE);
+ // No SwitchClause node carries the `(0,0)` sentinel range.
+ assert!(
+ switch_clauses(&product).into_iter().all(|id| {
+ let d = product.graph.switch_clause_data(id);
+ (d.clause_start, d.clause_end) != (0, 0)
+ }),
+ "a default-present switch emits no (0,0) exhaustiveness sentinel"
+ );
+}
+
+// --- F2b: the four remaining flow landmines (hand-traced graphs) -------
+
+/// Every `ReduceLabel` flow node, in id order.
+fn reduce_labels(product: &FlowProduct) -> Vec {
+ (1..=product.graph.node_count())
+ .filter_map(FlowNodeId::from_raw)
+ .filter(|&id| product.graph.flags(id).contains(FlowFlags::REDUCE_LABEL))
+ .collect()
+}
+
+#[test]
+fn try_finally_reduce_label_and_merge() {
+ // `try { a; } finally { b; }` — b binds at the finally label (a branch
+ // label merging the try-normal and exception antecedents); the try exits
+ // through a REDUCE_LABEL (the finally's normal-completion routing) whose
+ // target is that finally label.
+ let src = "function f() { try { a; } finally { b; } }";
+ let (product, bound) = build_with_bound(src);
+ let b = ident(&bound, src, "b");
+ let b_flow = flow_of_node(&product, b);
+ assert!(
+ product
+ .graph
+ .flags(b_flow)
+ .contains(FlowFlags::BRANCH_LABEL)
+ );
+
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let exit = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(exit).contains(FlowFlags::REDUCE_LABEL));
+ assert_eq!(product.graph.reduce_label_data(exit).target, b_flow);
+ // The reduced antecedent list is the try block's normal exit (f's Start).
+ let reduced = product.graph.reduce_label_antecedents(exit);
+ assert_eq!(reduced.len(), 1);
+ assert!(product.graph.flags(reduced[0]).contains(FlowFlags::START));
+}
+
+#[test]
+fn try_catch_finally_exception_edges() {
+ // Catch = a second try. `try { x = 1; } catch { b; } finally { c; }` —
+ // the catch binds at the try's exception label, fed by BOTH the
+ // "any instruction can throw" edge (the entry Start) AND the mutation's
+ // exception fan-out (createFlowMutation → currentExceptionTarget).
+ let src = "function f() { try { x = 1; } catch { b; } finally { c; } }";
+ let (product, bound) = build_with_bound(src);
+ let b = ident(&bound, src, "b");
+ let b_flow = flow_of_node(&product, b);
+ assert!(
+ product
+ .graph
+ .flags(b_flow)
+ .contains(FlowFlags::BRANCH_LABEL)
+ );
+ let antes = product.graph.antecedents(b_flow);
+ assert!(
+ antes
+ .iter()
+ .any(|&a| product.graph.flags(a).contains(FlowFlags::START)),
+ "the pre-mutation throw edge"
+ );
+ assert!(
+ antes
+ .iter()
+ .any(|&a| product.graph.flags(a).contains(FlowFlags::ASSIGNMENT)),
+ "the mutation's exception fan-out"
+ );
+ // The finally still routes normal completion through a REDUCE_LABEL.
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let exit = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(exit).contains(FlowFlags::REDUCE_LABEL));
+}
+
+#[test]
+fn try_finally_return_routes_through_reduce_label() {
+ // An IIFE gives the try a real (non-None) return target, so a `return`
+ // inside a try-with-finally materializes a return-only ReduceLabel that
+ // feeds that target (and collapses onto it as the function exit).
+ let src = "function f() { (function() { try { return 1; } finally { g(); } })(); }";
+ let (product, bound) = build_with_bound(src);
+ let reduces = reduce_labels(&product);
+ assert_eq!(
+ reduces.len(),
+ 1,
+ "one ReduceLabel: the return-only finally routing"
+ );
+ let rl = reduces[0];
+ let reduced = product.graph.reduce_label_antecedents(rl);
+ assert_eq!(reduced.len(), 1, "the single return path");
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ assert_eq!(product.end_flow_of(f), Some(rl));
+}
+
+#[test]
+fn try_return_finally_leaves_post_try_unreachable_in_plain_function() {
+ // Guards the normal-list-empty → unreachable branch: in a PLAIN function
+ // (no return target), `try { return; } finally {}` leaves the code after
+ // the try unreachable — the try's only exit was via `return` (to the
+ // return label), so the finally's normal-exit list is empty. The existing
+ // return-reduce test uses an IIFE (non-None return target), so this
+ // plain-function branch was uncovered.
+ let src = "function f() { try { return; } finally {} g(); }";
+ let (product, bound) = build_with_bound(src);
+ let g = ident(&bound, src, "g");
+ // `g` (a leaf in dead code) keeps `Some(unreachable)`; the `g();` statement
+ // is unreachable.
+ assert_eq!(flow_of_node(&product, g), FlowNodeId::UNREACHABLE);
+}
+
+#[test]
+fn parameter_default_that_changes_flow_forks() {
+ // A parameter default containing a flow-changing expression (an
+ // assignment mutation) forks current_flow around the initializer
+ // (bindInitializer). The only branch label is the fork's exit.
+ let src = "function f(a = (b = c)) {}";
+ let (product, bound) = build_with_bound(src);
+ assert_eq!(product.stats.branch_labels, 1);
+ let a = ident(&bound, src, "a");
+ let a_flow = flow_of_node(&product, a);
+ assert!(
+ product
+ .graph
+ .flags(a_flow)
+ .contains(FlowFlags::BRANCH_LABEL)
+ );
+ assert_eq!(
+ product.graph.antecedents(a_flow).len(),
+ 2,
+ "the no-default entry + the post-initializer flow merge"
+ );
+}
+
+#[test]
+fn parameter_default_without_flow_change_does_not_fork() {
+ // A literal default doesn't change current_flow → no fork, no label.
+ let src = "function f(a = 1) {}";
+ let product = build(src);
+ assert_eq!(product.stats.branch_labels, 0);
+}
+
+#[test]
+fn labeled_continue_resolves_to_loop_continue_target() {
+ // `outer: while (x) { continue outer; }` — continue outer routes to the
+ // while's continue target (the loop label), and `outer` is referenced so
+ // its label identifier carries NO Unreachable bit.
+ let src = "function f() { outer: while (x) { continue outer; } }";
+ let (product, bound) = build_with_bound(src);
+ let x = ident(&bound, src, "x");
+ let l1 = flow_of_node(&product, x);
+ assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL));
+ let c1 = condition_of(&product, x, true);
+ let antes = product.graph.antecedents(l1);
+ assert!(
+ antes.contains(&c1),
+ "continue outer lands on the loop label (like an unlabeled continue)"
+ );
+ assert_eq!(antes.len(), 2); // [entry, continue-outer back edge]
+
+ let outer = ident(&bound, src, "outer");
+ assert_eq!(
+ product.node_flags[outer.index()] & crate::binder::NODE_FLAGS_UNREACHABLE,
+ 0,
+ "outer is referenced → no Unreachable stamp"
+ );
+}
+
+#[test]
+fn unreferenced_label_gets_unreachable_stamp() {
+ // `unused: a;` — the label is never targeted, so its identifier gets the
+ // Unreachable bit (the TS7028 signal).
+ let src = "function f() { unused: a; }";
+ let (product, bound) = build_with_bound(src);
+ let unused = ident(&bound, src, "unused");
+ assert_ne!(
+ product.node_flags[unused.index()] & crate::binder::NODE_FLAGS_UNREACHABLE,
+ 0,
+ "an unreferenced label identifier carries the Unreachable bit"
+ );
+}
+
+#[test]
+fn labeled_break_targets_outer_post_label() {
+ // `outer: inner: while (x) { break outer; }` — break outer targets
+ // outer's post-statement label (the function exit, merging the break edge
+ // and the loop's normal false-condition exit). `outer` is referenced,
+ // `inner` is not.
+ let src = "function f() { outer: inner: while (x) { break outer; } }";
+ let (product, bound) = build_with_bound(src);
+ let outer = ident(&bound, src, "outer");
+ let inner = ident(&bound, src, "inner");
+ assert_eq!(
+ product.node_flags[outer.index()] & crate::binder::NODE_FLAGS_UNREACHABLE,
+ 0,
+ "outer is referenced by break outer"
+ );
+ assert_ne!(
+ product.node_flags[inner.index()] & crate::binder::NODE_FLAGS_UNREACHABLE,
+ 0,
+ "inner is unused"
+ );
+
+ let x = ident(&bound, src, "x");
+ let c1 = condition_of(&product, x, true);
+ let c2 = condition_of(&product, x, false);
+ let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0];
+ let exit = product.end_flow_of(f).expect("f end_flow");
+ assert!(product.graph.flags(exit).contains(FlowFlags::BRANCH_LABEL));
+ let antes = product.graph.antecedents(exit);
+ assert!(
+ antes.contains(&c1),
+ "the break-outer edge (from inside the loop body)"
+ );
+ assert!(
+ antes.contains(&c2),
+ "the loop's normal false-condition exit"
+ );
+}
diff --git a/crates/tsv_check/src/binder/lower/expression.rs b/crates/tsv_check/src/binder/lower/expression.rs
new file mode 100644
index 000000000..f3200d21b
--- /dev/null
+++ b/crates/tsv_check/src/binder/lower/expression.rs
@@ -0,0 +1,523 @@
+//! The expression-shaped visitor methods — `visit_expression` (the full
+//! pattern-aware descent, since expression slots also host `Object`/`Array`/
+//! `Assignment` patterns, `RestElement`, and `TSParameterProperty`) and its
+//! supporting visitors (object/array literal members, templates, decorators,
+//! identifiers).
+
+use super::super::*;
+use tsv_ts::ast::internal::{
+ ArrayExpression, ArrayPattern, ArrowFunctionBody, ArrowFunctionExpression,
+ AssignmentExpression, AssignmentPattern, AwaitExpression, BinaryExpression, CallExpression,
+ ClassExpression, ConditionalExpression, Decorator, Expression, FunctionExpression, Identifier,
+ ImportExpression, JsdocCast, MemberExpression, MetaProperty, NewExpression, ObjectExpression,
+ ObjectPattern, ObjectPatternProperty, ObjectProperty, ParenthesizedExpression, Property,
+ RestElement, SequenceExpression, SpreadElement, TSAsExpression, TSInstantiationExpression,
+ TSNonNullExpression, TSParameterProperty, TSSatisfiesExpression, TSTypeAssertion,
+ TaggedTemplateExpression, TemplateElement, TemplateLiteral, UnaryExpression, UpdateExpression,
+ YieldExpression,
+};
+
+impl SoaWalk {
+ pub(super) fn visit_params(&mut self, params: &[Expression<'_>], parent: NodeId) {
+ for param in params {
+ self.visit_expression(param, parent);
+ }
+ }
+
+ /// Visit any expression position, including the pattern-shaped ones
+ /// (`Object`/`Array`/`Assignment` pattern, `RestElement`, `TSParameterProperty`)
+ /// that occupy parameter, declarator, assignment-target, and for-left slots. A
+ /// binding identifier / pattern also carries an optional type annotation and
+ /// parameter decorators — `None` outside those positions, so descending them
+ /// unconditionally lets this one method serve every expression slot.
+ pub(super) fn visit_expression(&mut self, expr: &Expression<'_>, parent: NodeId) {
+ use Expression as E;
+ match expr {
+ E::Identifier(idn) => self.visit_identifier(idn, parent),
+ E::Literal(lit) => self.leaf(NodeKind::Literal, lit.span, addr_of(lit), parent),
+ E::PrivateIdentifier(pid) => {
+ self.leaf(NodeKind::PrivateIdentifier, pid.span, addr_of(pid), parent);
+ }
+ E::RegexLiteral(r) => self.leaf(NodeKind::RegexLiteral, r.span, addr_of(r), parent),
+ E::ThisExpression(t) => self.leaf(NodeKind::ThisExpression, t.span, addr_of(t), parent),
+ E::Super(s) => self.leaf(NodeKind::Super, s.span, addr_of(s), parent),
+ E::ObjectExpression(o) => self.visit_object_expression(o, parent),
+ E::ArrayExpression(a) => self.visit_array_expression(a, parent),
+ E::UnaryExpression(u) => self.visit_unary_expression(u, parent),
+ E::UpdateExpression(u) => self.visit_update_expression(u, parent),
+ E::BinaryExpression(b) => self.visit_binary_expression(b, parent),
+ E::CallExpression(c) => self.visit_call_expression(c, parent),
+ E::NewExpression(n) => self.visit_new_expression(n, parent),
+ E::MemberExpression(m) => self.visit_member_expression(m, parent),
+ E::ConditionalExpression(c) => self.visit_conditional_expression(c, parent),
+ E::ArrowFunctionExpression(a) => self.visit_arrow_function_expression(a, parent),
+ E::FunctionExpression(f) => self.visit_function_expression(f, parent),
+ E::ClassExpression(c) => self.visit_class_expression(c, parent),
+ E::SpreadElement(s) => self.visit_spread(s, parent),
+ E::TemplateLiteral(t) => self.visit_template_literal(t, parent),
+ E::TaggedTemplateExpression(t) => self.visit_tagged_template_expression(t, parent),
+ E::AwaitExpression(a) => self.visit_await_expression(a, parent),
+ E::YieldExpression(y) => self.visit_yield_expression(y, parent),
+ E::SequenceExpression(s) => self.visit_sequence_expression(s, parent),
+ E::AssignmentExpression(a) => self.visit_assignment_expression(a, parent),
+ E::ObjectPattern(op) => self.visit_object_pattern(op, parent),
+ E::ArrayPattern(ap) => self.visit_array_pattern(ap, parent),
+ E::AssignmentPattern(a) => self.visit_assignment_pattern(a, parent),
+ E::RestElement(r) => self.visit_rest_element(r, parent),
+ E::TSTypeAssertion(t) => self.visit_ts_type_assertion(t, parent),
+ E::TSAsExpression(t) => self.visit_ts_as_expression(t, parent),
+ E::TSSatisfiesExpression(t) => self.visit_ts_satisfies_expression(t, parent),
+ E::TSInstantiationExpression(t) => self.visit_ts_instantiation_expression(t, parent),
+ E::TSNonNullExpression(t) => self.visit_ts_non_null_expression(t, parent),
+ E::TSParameterProperty(pp) => self.visit_ts_parameter_property(pp, parent),
+ E::ImportExpression(i) => self.visit_import_expression(i, parent),
+ E::MetaProperty(m) => self.visit_meta_property(m, parent),
+ E::JsdocCast(c) => self.visit_jsdoc_cast(c, parent),
+ E::ParenthesizedExpression(p) => self.visit_parenthesized_expression(p, parent),
+ }
+ // Lockstep guard: the arm above must have registered this expression
+ // under the `(address, kind)` key the shared `expression_addr_kind`
+ // mapping (the flow walk's resolver) predicts — drift between the two
+ // is caught here per lowered expression in debug builds (which the
+ // conformance gate runs), before the strict resolver would hard-fail.
+ debug_assert!(
+ self.address_map.contains_key(&expression_addr_kind(expr)),
+ "visit_expression and expression_addr_kind disagree on an expression's (address, kind) key"
+ );
+ }
+
+ #[inline]
+ fn visit_object_expression(&mut self, o: &ObjectExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::ObjectExpression, o.span, Some(parent), addr_of(o));
+ for prop in o.properties {
+ self.visit_object_property(prop, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_array_expression(&mut self, a: &ArrayExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::ArrayExpression, a.span, Some(parent), addr_of(a));
+ for el in a.elements.iter().flatten() {
+ self.visit_expression(el, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_unary_expression(&mut self, u: &UnaryExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u));
+ self.visit_expression(u.argument, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_update_expression(&mut self, u: &UpdateExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::UpdateExpression, u.span, Some(parent), addr_of(u));
+ self.visit_expression(u.argument, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_binary_expression(&mut self, b: &BinaryExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::BinaryExpression, b.span, Some(parent), addr_of(b));
+ self.visit_expression(b.left, id);
+ self.visit_expression(b.right, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_call_expression(&mut self, c: &CallExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::CallExpression, c.span, Some(parent), addr_of(c));
+ self.visit_expression(c.callee, id);
+ if let Some(ta) = &c.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ for a in c.arguments {
+ self.visit_expression(a, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_new_expression(&mut self, n: &NewExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::NewExpression, n.span, Some(parent), addr_of(n));
+ self.visit_expression(n.callee, id);
+ if let Some(ta) = &n.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ for a in n.arguments {
+ self.visit_expression(a, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_member_expression(&mut self, m: &MemberExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::MemberExpression, m.span, Some(parent), addr_of(m));
+ self.visit_expression(m.object, id);
+ self.visit_expression(m.property, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_conditional_expression(&mut self, c: &ConditionalExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::ConditionalExpression,
+ c.span,
+ Some(parent),
+ addr_of(c),
+ );
+ self.visit_expression(c.test, id);
+ self.visit_expression(c.consequent, id);
+ self.visit_expression(c.alternate, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_arrow_function_expression(&mut self, a: &ArrowFunctionExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::ArrowFunctionExpression,
+ a.span,
+ Some(parent),
+ addr_of(a),
+ );
+ self.visit_type_params(a.type_parameters.as_ref(), id);
+ self.visit_params(a.params, id);
+ self.visit_type_annotation_opt(a.return_type.as_ref(), id);
+ match &a.body {
+ ArrowFunctionBody::Expression(e) => self.visit_expression(e, id),
+ ArrowFunctionBody::BlockStatement(b) => self.visit_statements(b.body, id),
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_class_expression(&mut self, c: &ClassExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::ClassExpression, c.span, Some(parent), addr_of(c));
+ if let Some(name) = &c.id {
+ self.visit_identifier(name, id);
+ }
+ // Kept in sync with `descend_class` (see the coverage test).
+ self.visit_type_params(c.type_parameters.as_ref(), id);
+ self.visit_class_heritage(
+ c.decorators,
+ c.super_class,
+ c.super_type_parameters.as_ref(),
+ c.implements,
+ id,
+ );
+ self.visit_class_body(&c.body, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_tagged_template_expression(
+ &mut self,
+ t: &TaggedTemplateExpression<'_>,
+ parent: NodeId,
+ ) {
+ let id = self.add(
+ NodeKind::TaggedTemplateExpression,
+ t.span,
+ Some(parent),
+ addr_of(t),
+ );
+ self.visit_expression(t.tag, id);
+ if let Some(ta) = &t.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ self.visit_template_literal(&t.quasi, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_await_expression(&mut self, a: &AwaitExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::AwaitExpression, a.span, Some(parent), addr_of(a));
+ self.visit_expression(a.argument, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_yield_expression(&mut self, y: &YieldExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::YieldExpression, y.span, Some(parent), addr_of(y));
+ if let Some(a) = y.argument {
+ self.visit_expression(a, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_sequence_expression(&mut self, s: &SequenceExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::SequenceExpression,
+ s.span,
+ Some(parent),
+ addr_of(s),
+ );
+ for e in s.expressions {
+ self.visit_expression(e, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_assignment_expression(&mut self, a: &AssignmentExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::AssignmentExpression,
+ a.span,
+ Some(parent),
+ addr_of(a),
+ );
+ // `a.left` may be an Object/Array pattern (destructuring assignment)
+ // — pattern-aware descent, never swallowed by a wildcard.
+ self.visit_expression(a.left, id);
+ self.visit_expression(a.right, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_object_pattern(&mut self, op: &ObjectPattern<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::ObjectPattern, op.span, Some(parent), addr_of(op));
+ if let Some(decs) = op.decorators {
+ self.visit_decorators(decs, id);
+ }
+ self.visit_type_annotation_opt(op.type_annotation.as_ref(), id);
+ for prop in op.properties {
+ self.visit_object_pattern_property(prop, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_array_pattern(&mut self, ap: &ArrayPattern<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::ArrayPattern, ap.span, Some(parent), addr_of(ap));
+ if let Some(decs) = ap.decorators {
+ self.visit_decorators(decs, id);
+ }
+ self.visit_type_annotation_opt(ap.type_annotation.as_ref(), id);
+ for el in ap.elements.iter().flatten() {
+ self.visit_expression(el, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_assignment_pattern(&mut self, a: &AssignmentPattern<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::AssignmentPattern,
+ a.span,
+ Some(parent),
+ addr_of(a),
+ );
+ if let Some(decs) = a.decorators {
+ self.visit_decorators(decs, id);
+ }
+ self.visit_expression(a.left, id);
+ self.visit_expression(a.right, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_ts_type_assertion(&mut self, t: &TSTypeAssertion<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::TSTypeAssertion, t.span, Some(parent), addr_of(t));
+ self.visit_type(t.type_annotation, id);
+ self.visit_expression(t.expression, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_ts_as_expression(&mut self, t: &TSAsExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::TSAsExpression, t.span, Some(parent), addr_of(t));
+ self.visit_expression(t.expression, id);
+ self.visit_type(t.type_annotation, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_ts_satisfies_expression(&mut self, t: &TSSatisfiesExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::TSSatisfiesExpression,
+ t.span,
+ Some(parent),
+ addr_of(t),
+ );
+ self.visit_expression(t.expression, id);
+ self.visit_type(t.type_annotation, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_ts_instantiation_expression(
+ &mut self,
+ t: &TSInstantiationExpression<'_>,
+ parent: NodeId,
+ ) {
+ let id = self.add(
+ NodeKind::TSInstantiationExpression,
+ t.span,
+ Some(parent),
+ addr_of(t),
+ );
+ self.visit_expression(t.expression, id);
+ self.visit_type_args(&t.type_arguments, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_ts_non_null_expression(&mut self, t: &TSNonNullExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::TSNonNullExpression,
+ t.span,
+ Some(parent),
+ addr_of(t),
+ );
+ self.visit_expression(t.expression, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_ts_parameter_property(&mut self, pp: &TSParameterProperty<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::TSParameterProperty,
+ pp.span,
+ Some(parent),
+ addr_of(pp),
+ );
+ self.visit_expression(pp.parameter, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_import_expression(&mut self, i: &ImportExpression<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::ImportExpression, i.span, Some(parent), addr_of(i));
+ self.visit_expression(i.source, id);
+ if let Some(o) = i.options {
+ self.visit_expression(o, id);
+ }
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_meta_property(&mut self, m: &MetaProperty<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::MetaProperty, m.span, Some(parent), addr_of(m));
+ self.visit_identifier(&m.meta, id);
+ self.visit_identifier(&m.property, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_jsdoc_cast(&mut self, c: &JsdocCast<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::JsdocCast, c.span, Some(parent), addr_of(c));
+ self.visit_expression(c.inner, id);
+ self.close(id);
+ }
+
+ #[inline]
+ fn visit_parenthesized_expression(&mut self, p: &ParenthesizedExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::ParenthesizedExpression,
+ p.span,
+ Some(parent),
+ addr_of(p),
+ );
+ self.visit_expression(p.expression, id);
+ self.close(id);
+ }
+
+ pub(super) fn visit_function_expression(&mut self, f: &FunctionExpression<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::FunctionExpression,
+ f.span,
+ Some(parent),
+ addr_of(f),
+ );
+ self.descend_function_common(
+ id,
+ f.id.as_ref(),
+ f.type_parameters.as_ref(),
+ f.params,
+ f.return_type.as_ref(),
+ f.body.body,
+ );
+ self.close(id);
+ }
+
+ fn visit_object_property(&mut self, prop: &ObjectProperty<'_>, parent: NodeId) {
+ match prop {
+ ObjectProperty::Property(pr) => self.visit_property(pr, parent),
+ ObjectProperty::SpreadElement(s) => self.visit_spread(s, parent),
+ }
+ }
+
+ fn visit_object_pattern_property(&mut self, prop: &ObjectPatternProperty<'_>, parent: NodeId) {
+ match prop {
+ ObjectPatternProperty::Property(pr) => self.visit_property(pr, parent),
+ ObjectPatternProperty::RestElement(r) => self.visit_rest_element(r, parent),
+ }
+ }
+
+ fn visit_property(&mut self, pr: &Property<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::Property, pr.span, Some(parent), addr_of(pr));
+ self.visit_expression(&pr.key, id);
+ self.visit_expression(&pr.value, id);
+ self.close(id);
+ }
+
+ fn visit_spread(&mut self, s: &SpreadElement<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::SpreadElement, s.span, Some(parent), addr_of(s));
+ self.visit_expression(s.argument, id);
+ self.close(id);
+ }
+
+ fn visit_rest_element(&mut self, r: &RestElement<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::RestElement, r.span, Some(parent), addr_of(r));
+ self.visit_type_annotation_opt(r.type_annotation.as_ref(), id);
+ self.visit_expression(r.argument, id);
+ self.close(id);
+ }
+
+ fn visit_template_literal(&mut self, t: &TemplateLiteral<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::TemplateLiteral, t.span, Some(parent), addr_of(t));
+ for q in t.quasis {
+ self.visit_template_element(q, id);
+ }
+ for e in t.expressions {
+ self.visit_expression(e, id);
+ }
+ self.close(id);
+ }
+
+ pub(super) fn visit_template_element(&mut self, q: &TemplateElement<'_>, parent: NodeId) {
+ self.leaf(NodeKind::TemplateElement, q.span, addr_of(q), parent);
+ }
+
+ pub(super) fn visit_decorators(&mut self, decorators: &[Decorator<'_>], parent: NodeId) {
+ for d in decorators {
+ let id = self.add(NodeKind::Decorator, d.span, Some(parent), addr_of(d));
+ self.visit_expression(&d.expression, id);
+ self.close(id);
+ }
+ }
+
+ // --- identifiers ---------------------------------------------------------
+
+ /// Id an identifier, then descend the binding-only extras (parameter
+ /// decorators + type annotation) it carries — both `None` for a reference, so
+ /// this serves reference and binding positions alike.
+ pub(super) fn visit_identifier(&mut self, ident: &Identifier<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::Identifier,
+ ident.span,
+ Some(parent),
+ addr_of(ident),
+ );
+ if let Some(decs) = ident.decorators() {
+ self.visit_decorators(decs, id);
+ }
+ if let Some(ann) = ident.type_annotation() {
+ self.visit_type_annotation(ann, id);
+ }
+ self.close(id);
+ }
+}
diff --git a/crates/tsv_check/src/binder/lower/mod.rs b/crates/tsv_check/src/binder/lower/mod.rs
new file mode 100644
index 000000000..2648de88f
--- /dev/null
+++ b/crates/tsv_check/src/binder/lower/mod.rs
@@ -0,0 +1,11 @@
+//! The lowering walk — `SoaWalk`'s per-node visitor methods, split by the AST
+//! shape they descend (statements, expressions, types). Each submodule
+//! contributes its own `impl SoaWalk { ... }` block; multiple `impl` blocks for
+//! the same type are ordinary Rust, so the `SoaWalk` struct itself (its fields
+//! and the `add`/`close`/`leaf` id-recording primitives) stays defined once in
+//! the parent `binder` module. Purely a locality split — no behavior
+//! distinction between the three files.
+
+mod expression;
+mod statement;
+mod types;
diff --git a/crates/tsv_check/src/binder/lower/statement.rs b/crates/tsv_check/src/binder/lower/statement.rs
new file mode 100644
index 000000000..b507cbacc
--- /dev/null
+++ b/crates/tsv_check/src/binder/lower/statement.rs
@@ -0,0 +1,592 @@
+//! The statement-shaped visitor methods — `visit_statement` and everything a
+//! statement position (declarations, class/module bodies, import/export
+//! specifiers) descends into.
+
+use super::super::*;
+use tsv_ts::ast::internal::{
+ CatchClause, ClassBody, ClassDeclaration, ClassMember, Decorator, ExportDefaultValue,
+ ExportSpecifier, Expression, ForInOfLeft, ForInit, FunctionDeclaration, Identifier,
+ ImportAttribute, ImportAttributeKey, ImportSpecifier, ModuleExportName, Statement, SwitchCase,
+ TSDeclareFunction, TSEnumMember, TSEnumMemberId, TSInterfaceDeclaration, TSInterfaceHeritage,
+ TSModuleDeclaration, TSModuleDeclarationBody, TSModuleName, TSModuleReference,
+ TSTypeAnnotation, TSTypeParameterDeclaration, TSTypeParameterInstantiation,
+ VariableDeclaration, VariableDeclarator,
+};
+
+impl SoaWalk {
+ pub(super) fn visit_statements(&mut self, stmts: &[Statement<'_>], parent: NodeId) {
+ for stmt in stmts {
+ self.visit_statement(stmt, parent);
+ }
+ }
+
+ /// Visit a statement: assign its id (keyed on the `&Statement` address, the key
+ /// the symbol bind and the address-map tests use), descend, then close.
+ pub(in crate::binder) fn visit_statement(&mut self, stmt: &Statement<'_>, parent: NodeId) {
+ let id = self.add(
+ statement_kind(stmt),
+ stmt.span(),
+ Some(parent),
+ addr_of(stmt),
+ );
+ match stmt {
+ Statement::ExpressionStatement(s) => self.visit_expression(&s.expression, id),
+ Statement::VariableDeclaration(decl) => self.visit_declarators(decl, id),
+ Statement::FunctionDeclaration(f) => self.descend_function(f, id),
+ Statement::ClassDeclaration(c) => self.descend_class(c, id),
+ Statement::TSDeclareFunction(f) => self.descend_declare_function(f, id),
+ Statement::TSTypeAliasDeclaration(t) => {
+ self.visit_identifier(&t.id, id);
+ self.visit_type_params(t.type_parameters.as_ref(), id);
+ self.visit_type(&t.type_annotation, id);
+ }
+ Statement::TSInterfaceDeclaration(i) => self.descend_interface(i, id),
+ Statement::TSEnumDeclaration(e) => {
+ self.visit_identifier(&e.id, id);
+ for member in e.members {
+ self.visit_enum_member(member, id);
+ }
+ }
+ Statement::TSModuleDeclaration(m) => self.descend_module(m, id),
+ Statement::ImportDeclaration(imp) => {
+ for spec in imp.specifiers {
+ self.visit_import_specifier(spec, id);
+ }
+ self.leaf(NodeKind::Literal, imp.source.span, addr_of(&imp.source), id);
+ if let Some(attrs) = imp.attributes {
+ for a in attrs {
+ self.visit_import_attribute(a, id);
+ }
+ }
+ }
+ Statement::TSImportEqualsDeclaration(ie) => {
+ self.visit_identifier(&ie.id, id);
+ self.visit_module_reference(&ie.module_reference, id);
+ }
+ Statement::ExportNamedDeclaration(e) => {
+ if let Some(inner) = e.declaration {
+ self.visit_statement(inner, id);
+ } else {
+ for spec in e.specifiers {
+ self.visit_export_specifier(spec, id);
+ }
+ }
+ if let Some(src) = &e.source {
+ self.leaf(NodeKind::Literal, src.span, addr_of(src), id);
+ }
+ if let Some(attrs) = e.attributes {
+ for a in attrs {
+ self.visit_import_attribute(a, id);
+ }
+ }
+ }
+ Statement::ExportDefaultDeclaration(e) => self.visit_export_default(&e.declaration, id),
+ Statement::ExportAllDeclaration(e) => {
+ if let Some(exp) = &e.exported {
+ self.visit_module_export_name(exp, id);
+ }
+ self.leaf(NodeKind::Literal, e.source.span, addr_of(&e.source), id);
+ if let Some(attrs) = e.attributes {
+ for a in attrs {
+ self.visit_import_attribute(a, id);
+ }
+ }
+ }
+ Statement::TSExportAssignment(ea) => self.visit_expression(&ea.expression, id),
+ Statement::TSNamespaceExportDeclaration(n) => self.visit_identifier(&n.id, id),
+ Statement::ReturnStatement(s) => {
+ if let Some(a) = &s.argument {
+ self.visit_expression(a, id);
+ }
+ }
+ // A function/try/catch/finally body `BlockStatement` is flattened by
+ // its owner (a list-wrapper, per today's shape); a *standalone* block
+ // statement is its own node whose body follows here.
+ Statement::BlockStatement(block) => self.visit_statements(block.body, id),
+ Statement::IfStatement(s) => {
+ self.visit_expression(&s.test, id);
+ self.visit_statement(s.consequent, id);
+ if let Some(alt) = s.alternate {
+ self.visit_statement(alt, id);
+ }
+ }
+ Statement::ForStatement(s) => {
+ match &s.init {
+ Some(ForInit::VariableDeclaration(decl)) => {
+ self.visit_variable_declaration(decl, id);
+ }
+ Some(ForInit::Expression(e)) => self.visit_expression(e, id),
+ None => {}
+ }
+ if let Some(t) = &s.test {
+ self.visit_expression(t, id);
+ }
+ if let Some(u) = &s.update {
+ self.visit_expression(u, id);
+ }
+ self.visit_statement(s.body, id);
+ }
+ Statement::ForInStatement(s) => {
+ self.visit_for_left(&s.left, id);
+ self.visit_expression(&s.right, id);
+ self.visit_statement(s.body, id);
+ }
+ Statement::ForOfStatement(s) => {
+ self.visit_for_left(&s.left, id);
+ self.visit_expression(&s.right, id);
+ self.visit_statement(s.body, id);
+ }
+ Statement::WhileStatement(s) => {
+ self.visit_expression(&s.test, id);
+ self.visit_statement(s.body, id);
+ }
+ Statement::DoWhileStatement(s) => {
+ self.visit_statement(s.body, id);
+ self.visit_expression(&s.test, id);
+ }
+ Statement::SwitchStatement(s) => {
+ self.visit_expression(&s.discriminant, id);
+ for case in s.cases {
+ self.visit_switch_case(case, id);
+ }
+ }
+ Statement::TryStatement(s) => {
+ self.visit_statements(s.block.body, id);
+ if let Some(handler) = &s.handler {
+ self.visit_catch_clause(handler, id);
+ }
+ if let Some(finalizer) = &s.finalizer {
+ self.visit_statements(finalizer.body, id);
+ }
+ }
+ Statement::ThrowStatement(s) => self.visit_expression(&s.argument, id),
+ Statement::BreakStatement(s) => {
+ if let Some(label) = &s.label {
+ self.visit_identifier(label, id);
+ }
+ }
+ Statement::ContinueStatement(s) => {
+ if let Some(label) = &s.label {
+ self.visit_identifier(label, id);
+ }
+ }
+ Statement::LabeledStatement(s) => {
+ self.visit_identifier(&s.label, id);
+ self.visit_statement(s.body, id);
+ }
+ Statement::EmptyStatement(_) | Statement::DebuggerStatement(_) => {}
+ }
+ self.close(id);
+ }
+
+ // --- declaration descents (shared between statement + export-default) -----
+
+ fn descend_function(&mut self, f: &FunctionDeclaration<'_>, id: NodeId) {
+ self.descend_function_common(
+ id,
+ f.id.as_ref(),
+ f.type_parameters.as_ref(),
+ f.params,
+ f.return_type.as_ref(),
+ f.body.body,
+ );
+ }
+
+ /// The body-bearing function descent shared by the declaration form
+ /// ([`Self::descend_function`]) and the method-value / function-expression form
+ /// (`SoaWalk::visit_function_expression`), keyed on the already-minted `id`. Kept
+ /// as one helper so `FunctionDeclaration` and `FunctionExpression` — distinct
+ /// types with the same field shape — never drift in what the walk descends.
+ pub(super) fn descend_function_common(
+ &mut self,
+ id: NodeId,
+ name: Option<&Identifier<'_>>,
+ type_parameters: Option<&TSTypeParameterDeclaration<'_>>,
+ params: &[Expression<'_>],
+ return_type: Option<&TSTypeAnnotation<'_>>,
+ body: &[Statement<'_>],
+ ) {
+ if let Some(name) = name {
+ self.visit_identifier(name, id);
+ }
+ self.visit_type_params(type_parameters, id);
+ self.visit_params(params, id);
+ self.visit_type_annotation_opt(return_type, id);
+ self.visit_statements(body, id);
+ }
+
+ fn descend_declare_function(&mut self, f: &TSDeclareFunction<'_>, id: NodeId) {
+ self.visit_identifier(&f.id, id);
+ self.visit_type_params(f.type_parameters.as_ref(), id);
+ self.visit_params(f.params, id);
+ self.visit_type_annotation_opt(f.return_type.as_ref(), id);
+ }
+
+ fn descend_class(&mut self, c: &ClassDeclaration<'_>, id: NodeId) {
+ if let Some(name) = &c.id {
+ self.visit_identifier(name, id);
+ }
+ // The class's own `` — kept in sync with the `ClassExpression` arm in
+ // `visit_expression` (guarded by the `require_node_id` coverage test).
+ self.visit_type_params(c.type_parameters.as_ref(), id);
+ self.visit_class_heritage(
+ c.decorators,
+ c.super_class,
+ c.super_type_parameters.as_ref(),
+ c.implements,
+ id,
+ );
+ self.visit_class_body(&c.body, id);
+ }
+
+ fn descend_interface(&mut self, i: &TSInterfaceDeclaration<'_>, id: NodeId) {
+ self.visit_identifier(&i.id, id);
+ self.visit_type_params(i.type_parameters.as_ref(), id);
+ self.visit_heritages(i.extends, id);
+ // `TSInterfaceBody` is a list-wrapper: its members stay flat under the
+ // interface (no separate node), matching today's shape.
+ self.visit_type_elements(i.body.body, id);
+ }
+
+ fn visit_export_default(&mut self, value: &ExportDefaultValue<'_>, parent: NodeId) {
+ match value {
+ ExportDefaultValue::Expression(e) => self.visit_expression(e, parent),
+ ExportDefaultValue::FunctionDeclaration(f) => {
+ let id = self.add(
+ NodeKind::FunctionDeclaration,
+ f.span,
+ Some(parent),
+ addr_of(f),
+ );
+ self.descend_function(f, id);
+ self.close(id);
+ }
+ ExportDefaultValue::TSDeclareFunction(f) => {
+ let id = self.add(
+ NodeKind::TSDeclareFunction,
+ f.span,
+ Some(parent),
+ addr_of(f),
+ );
+ self.descend_declare_function(f, id);
+ self.close(id);
+ }
+ ExportDefaultValue::ClassDeclaration(c) => {
+ let id = self.add(NodeKind::ClassDeclaration, c.span, Some(parent), addr_of(c));
+ self.descend_class(c, id);
+ self.close(id);
+ }
+ ExportDefaultValue::TSInterfaceDeclaration(i) => {
+ let id = self.add(
+ NodeKind::TSInterfaceDeclaration,
+ i.span,
+ Some(parent),
+ addr_of(i),
+ );
+ self.descend_interface(i, id);
+ self.close(id);
+ }
+ }
+ }
+
+ // --- variable declarations / for headers ---------------------------------
+
+ fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::VariableDeclaration,
+ decl.span,
+ Some(parent),
+ addr_of(decl),
+ );
+ self.visit_declarators(decl, id);
+ self.close(id);
+ }
+
+ fn visit_declarators(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) {
+ for declarator in decl.declarations {
+ self.visit_declarator(declarator, parent);
+ }
+ }
+
+ fn visit_declarator(&mut self, declarator: &VariableDeclarator<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::VariableDeclarator,
+ declarator.span,
+ Some(parent),
+ addr_of(declarator),
+ );
+ // The binding target — an identifier (with its type annotation) or a
+ // destructuring pattern — is an `Expression`, routed through the
+ // pattern-aware `visit_expression`.
+ self.visit_expression(&declarator.id, id);
+ if let Some(init) = &declarator.init {
+ self.visit_expression(init, id);
+ }
+ self.close(id);
+ }
+
+ fn visit_for_left(&mut self, left: &ForInOfLeft<'_>, parent: NodeId) {
+ match left {
+ ForInOfLeft::VariableDeclaration(decl) => self.visit_variable_declaration(decl, parent),
+ // A pattern here may be an Object/ArrayPattern — pattern-aware descent.
+ ForInOfLeft::Pattern(e) => self.visit_expression(e, parent),
+ }
+ }
+
+ // --- modules / enums / cases / catch -------------------------------------
+
+ /// Descend a module's name and body (the module's own node is `module_id`).
+ fn descend_module(&mut self, m: &TSModuleDeclaration<'_>, module_id: NodeId) {
+ match &m.id {
+ TSModuleName::Identifier(id) => self.visit_identifier(id, module_id),
+ TSModuleName::Literal(lit) => {
+ self.leaf(NodeKind::Literal, lit.span, addr_of(lit), module_id);
+ }
+ }
+ match &m.body {
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => {
+ let id = self.add(
+ NodeKind::TSModuleBlock,
+ block.span,
+ Some(module_id),
+ addr_of(block),
+ );
+ self.visit_statements(block.body, id);
+ self.close(id);
+ }
+ // The dotted-namespace continuation (`namespace A.B {}`) — a nested
+ // `TSModuleDeclaration` node (reused kind), recursed.
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => {
+ let id = self.add(
+ NodeKind::TSModuleDeclaration,
+ nested.span,
+ Some(module_id),
+ addr_of(nested),
+ );
+ self.descend_module(nested, id);
+ self.close(id);
+ }
+ None => {}
+ }
+ }
+
+ fn visit_enum_member(&mut self, member: &TSEnumMember<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::TSEnumMember,
+ member.span,
+ Some(parent),
+ addr_of(member),
+ );
+ match &member.id {
+ TSEnumMemberId::Identifier(idn) => self.visit_identifier(idn, id),
+ TSEnumMemberId::String(lit) => self.leaf(NodeKind::Literal, lit.span, addr_of(lit), id),
+ }
+ if let Some(init) = &member.initializer {
+ self.visit_expression(init, id);
+ }
+ self.close(id);
+ }
+
+ fn visit_switch_case(&mut self, case: &SwitchCase<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::SwitchCase, case.span, Some(parent), addr_of(case));
+ if let Some(t) = &case.test {
+ self.visit_expression(t, id);
+ }
+ self.visit_statements(case.consequent, id);
+ self.close(id);
+ }
+
+ fn visit_catch_clause(&mut self, h: &CatchClause<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::CatchClause, h.span, Some(parent), addr_of(h));
+ if let Some(param) = &h.param {
+ self.visit_expression(param, id);
+ }
+ // The catch body block is flattened (list-wrapper, today's shape).
+ self.visit_statements(h.body.body, id);
+ self.close(id);
+ }
+
+ // --- classes -------------------------------------------------------------
+
+ /// Descend class heritage: decorators, the `extends` expression + its type
+ /// arguments, and each `implements`/`extends` heritage clause.
+ pub(super) fn visit_class_heritage(
+ &mut self,
+ decorators: Option<&[Decorator<'_>]>,
+ super_class: Option<&Expression<'_>>,
+ super_type_parameters: Option<&TSTypeParameterInstantiation<'_>>,
+ heritages: &[TSInterfaceHeritage<'_>],
+ parent: NodeId,
+ ) {
+ if let Some(decs) = decorators {
+ self.visit_decorators(decs, parent);
+ }
+ if let Some(sc) = super_class {
+ self.visit_expression(sc, parent);
+ }
+ if let Some(tp) = super_type_parameters {
+ self.visit_type_args(tp, parent);
+ }
+ self.visit_heritages(heritages, parent);
+ }
+
+ fn visit_heritages(&mut self, heritages: &[TSInterfaceHeritage<'_>], parent: NodeId) {
+ for h in heritages {
+ let id = self.add(
+ NodeKind::TSInterfaceHeritage,
+ h.span,
+ Some(parent),
+ addr_of(h),
+ );
+ // The heritage target (`extends Base` / `implements Base`) — an entity
+ // name — plus its type arguments.
+ self.visit_entity_name(&h.expression, id);
+ if let Some(ta) = &h.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ self.close(id);
+ }
+ }
+
+ /// `ClassBody` is a list-wrapper: its members stay flat under the class (no
+ /// separate node), matching today's shape.
+ pub(super) fn visit_class_body(&mut self, body: &ClassBody<'_>, parent: NodeId) {
+ for member in body.body {
+ self.visit_class_member(member, parent);
+ }
+ }
+
+ fn visit_class_member(&mut self, member: &ClassMember<'_>, parent: NodeId) {
+ match member {
+ ClassMember::MethodDefinition(m) => {
+ let id = self.add(NodeKind::MethodDefinition, m.span, Some(parent), addr_of(m));
+ if let Some(decs) = m.decorators {
+ self.visit_decorators(decs, id);
+ }
+ self.visit_expression(&m.key, id);
+ self.visit_function_expression(&m.value, id);
+ self.close(id);
+ }
+ ClassMember::PropertyDefinition(p) => {
+ let id = self.add(
+ NodeKind::PropertyDefinition,
+ p.span,
+ Some(parent),
+ addr_of(p),
+ );
+ if let Some(decs) = p.decorators {
+ self.visit_decorators(decs, id);
+ }
+ self.visit_expression(&p.key, id);
+ self.visit_type_annotation_opt(p.type_annotation.as_ref(), id);
+ if let Some(v) = &p.value {
+ self.visit_expression(v, id);
+ }
+ self.close(id);
+ }
+ ClassMember::StaticBlock(s) => {
+ let id = self.add(NodeKind::StaticBlock, s.span, Some(parent), addr_of(s));
+ self.visit_statements(s.body, id);
+ self.close(id);
+ }
+ ClassMember::IndexSignature(i) => self.visit_index_signature(i, parent),
+ }
+ }
+
+ // --- imports / exports ----------------------------------------------------
+
+ fn visit_import_specifier(&mut self, spec: &ImportSpecifier<'_>, parent: NodeId) {
+ match spec {
+ ImportSpecifier::Default(d) => {
+ let id = self.add(
+ NodeKind::ImportDefaultSpecifier,
+ d.span,
+ Some(parent),
+ addr_of(d),
+ );
+ self.visit_identifier(&d.local, id);
+ self.close(id);
+ }
+ ImportSpecifier::Named(n) => {
+ let id = self.add(
+ NodeKind::ImportNamedSpecifier,
+ n.span,
+ Some(parent),
+ addr_of(n),
+ );
+ self.visit_module_export_name(&n.imported, id);
+ self.visit_identifier(&n.local, id);
+ self.close(id);
+ }
+ ImportSpecifier::Namespace(n) => {
+ let id = self.add(
+ NodeKind::ImportNamespaceSpecifier,
+ n.span,
+ Some(parent),
+ addr_of(n),
+ );
+ self.visit_identifier(&n.local, id);
+ self.close(id);
+ }
+ }
+ }
+
+ fn visit_export_specifier(&mut self, spec: &ExportSpecifier<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::ExportSpecifier,
+ spec.span,
+ Some(parent),
+ addr_of(spec),
+ );
+ self.visit_module_export_name(&spec.local, id);
+ self.visit_module_export_name(&spec.exported, id);
+ self.close(id);
+ }
+
+ fn visit_module_export_name(&mut self, name: &ModuleExportName<'_>, parent: NodeId) {
+ match name {
+ ModuleExportName::Identifier(id) => self.visit_identifier(id, parent),
+ ModuleExportName::Literal(lit) => {
+ self.leaf(NodeKind::Literal, lit.span, addr_of(lit), parent);
+ }
+ }
+ }
+
+ fn visit_import_attribute(&mut self, attr: &ImportAttribute<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::ImportAttribute,
+ attr.span,
+ Some(parent),
+ addr_of(attr),
+ );
+ match &attr.key {
+ ImportAttributeKey::Identifier(idn) => self.visit_identifier(idn, id),
+ ImportAttributeKey::Literal(lit) => {
+ self.leaf(NodeKind::Literal, lit.span, addr_of(lit), id);
+ }
+ }
+ self.leaf(NodeKind::Literal, attr.value.span, addr_of(&attr.value), id);
+ self.close(id);
+ }
+
+ fn visit_module_reference(&mut self, mr: &TSModuleReference<'_>, parent: NodeId) {
+ match mr {
+ TSModuleReference::ExternalModuleReference(ext) => {
+ let id = self.add(
+ NodeKind::TSExternalModuleReference,
+ ext.span,
+ Some(parent),
+ addr_of(ext),
+ );
+ self.leaf(
+ NodeKind::Literal,
+ ext.expression.span,
+ addr_of(&ext.expression),
+ id,
+ );
+ self.close(id);
+ }
+ TSModuleReference::EntityName(en) => self.visit_entity_name(en, parent),
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/lower/types.rs b/crates/tsv_check/src/binder/lower/types.rs
new file mode 100644
index 000000000..6dc89ce5e
--- /dev/null
+++ b/crates/tsv_check/src/binder/lower/types.rs
@@ -0,0 +1,393 @@
+//! The type-shaped visitor methods — `visit_type` and everything a type
+//! position (annotations, type arguments/parameters, entity names, interface /
+//! type-literal members) descends into. `visit_index_signature` lives here too:
+//! one `TSIndexSignature` node serves both a class member and a type-element
+//! position, and its shape (parameters + an optional type annotation) is purely
+//! type-flavored.
+
+use super::super::*;
+use tsv_ts::ast::internal::{
+ TSEntityName, TSImportType, TSIndexSignature, TSLiteralType, TSMappedTypeParameter,
+ TSQualifiedName, TSType, TSTypeAnnotation, TSTypeElement, TSTypeParameter,
+ TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypeQueryExprName,
+};
+
+impl SoaWalk {
+ pub(super) fn visit_index_signature(&mut self, i: &TSIndexSignature<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::TSIndexSignature, i.span, Some(parent), addr_of(i));
+ for p in i.parameters {
+ self.visit_identifier(p, id);
+ }
+ self.visit_type_annotation_opt(i.type_annotation.as_ref(), id);
+ self.close(id);
+ }
+
+ /// A `TSTypeAnnotation` (`: T`) is a transparent wrapper — not idd; the walk
+ /// descends straight into the inner `TSType`, which is the node.
+ pub(super) fn visit_type_annotation(&mut self, ann: &TSTypeAnnotation<'_>, parent: NodeId) {
+ self.visit_type(ann.type_annotation, parent);
+ }
+
+ pub(super) fn visit_type_annotation_opt(
+ &mut self,
+ ann: Option<&TSTypeAnnotation<'_>>,
+ parent: NodeId,
+ ) {
+ if let Some(a) = ann {
+ self.visit_type_annotation(a, parent);
+ }
+ }
+
+ pub(super) fn visit_type_args(
+ &mut self,
+ args: &TSTypeParameterInstantiation<'_>,
+ parent: NodeId,
+ ) {
+ let id = self.add(
+ NodeKind::TSTypeParameterInstantiation,
+ args.span,
+ Some(parent),
+ addr_of(args),
+ );
+ for t in args.params {
+ self.visit_type(t, id);
+ }
+ self.close(id);
+ }
+
+ pub(super) fn visit_type_params(
+ &mut self,
+ params: Option<&TSTypeParameterDeclaration<'_>>,
+ parent: NodeId,
+ ) {
+ if let Some(decl) = params {
+ let id = self.add(
+ NodeKind::TSTypeParameterDeclaration,
+ decl.span,
+ Some(parent),
+ addr_of(decl),
+ );
+ for p in decl.params {
+ self.visit_type_parameter(p, id);
+ }
+ self.close(id);
+ }
+ }
+
+ fn visit_type_parameter(&mut self, p: &TSTypeParameter<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::TSTypeParameter, p.span, Some(parent), addr_of(p));
+ self.visit_identifier(&p.name, id);
+ if let Some(c) = p.constraint {
+ self.visit_type(c, id);
+ }
+ if let Some(d) = p.default {
+ self.visit_type(d, id);
+ }
+ self.close(id);
+ }
+
+ fn visit_mapped_type_parameter(&mut self, mtp: &TSMappedTypeParameter<'_>, parent: NodeId) {
+ // The `name` is a bare `IdentName` (no child identifier node); the mapped
+ // type parameter's own span covers the name token.
+ let id = self.add(
+ NodeKind::TSMappedTypeParameter,
+ mtp.span,
+ Some(parent),
+ addr_of(mtp),
+ );
+ self.visit_type(mtp.constraint, id);
+ self.close(id);
+ }
+
+ pub(super) fn visit_entity_name(&mut self, name: &TSEntityName<'_>, parent: NodeId) {
+ match name {
+ TSEntityName::Identifier(id) => self.visit_identifier(id, parent),
+ TSEntityName::QualifiedName(qn) => self.visit_qualified_name(qn, parent),
+ }
+ }
+
+ fn visit_qualified_name(&mut self, qn: &TSQualifiedName<'_>, parent: NodeId) {
+ let id = self.add(
+ NodeKind::TSQualifiedName,
+ qn.span,
+ Some(parent),
+ addr_of(qn),
+ );
+ self.visit_entity_name(&qn.left, id);
+ self.visit_identifier(&qn.right, id);
+ self.close(id);
+ }
+
+ fn visit_import_type(&mut self, i: &TSImportType<'_>, parent: NodeId) {
+ let id = self.add(NodeKind::TSImportType, i.span, Some(parent), addr_of(i));
+ self.leaf(NodeKind::Literal, i.argument.span, addr_of(&i.argument), id);
+ if let Some(o) = i.options {
+ self.visit_expression(o, id);
+ }
+ if let Some(q) = &i.qualifier {
+ self.visit_entity_name(q, id);
+ }
+ if let Some(ta) = &i.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ self.close(id);
+ }
+
+ pub(super) fn visit_type_elements(&mut self, members: &[TSTypeElement<'_>], parent: NodeId) {
+ for member in members {
+ self.visit_type_element(member, parent);
+ }
+ }
+
+ fn visit_type_element(&mut self, member: &TSTypeElement<'_>, parent: NodeId) {
+ match member {
+ TSTypeElement::PropertySignature(p) => {
+ let id = self.add(
+ NodeKind::TSPropertySignature,
+ p.span,
+ Some(parent),
+ addr_of(p),
+ );
+ self.visit_expression(&p.key, id);
+ self.visit_type_annotation_opt(p.type_annotation.as_ref(), id);
+ self.close(id);
+ }
+ TSTypeElement::MethodSignature(m) => {
+ let id = self.add(
+ NodeKind::TSMethodSignature,
+ m.span,
+ Some(parent),
+ addr_of(m),
+ );
+ self.visit_expression(&m.key, id);
+ self.visit_type_params(m.type_parameters.as_ref(), id);
+ self.visit_params(m.params, id);
+ self.visit_type_annotation_opt(m.return_type.as_ref(), id);
+ self.close(id);
+ }
+ TSTypeElement::CallSignature(c) => {
+ let id = self.add(
+ NodeKind::TSCallSignatureDeclaration,
+ c.span,
+ Some(parent),
+ addr_of(c),
+ );
+ self.visit_type_params(c.type_parameters.as_ref(), id);
+ self.visit_params(c.params, id);
+ self.visit_type_annotation_opt(c.return_type.as_ref(), id);
+ self.close(id);
+ }
+ TSTypeElement::ConstructSignature(c) => {
+ let id = self.add(
+ NodeKind::TSConstructSignatureDeclaration,
+ c.span,
+ Some(parent),
+ addr_of(c),
+ );
+ self.visit_type_params(c.type_parameters.as_ref(), id);
+ self.visit_params(c.params, id);
+ self.visit_type_annotation_opt(c.return_type.as_ref(), id);
+ self.close(id);
+ }
+ TSTypeElement::IndexSignature(i) => self.visit_index_signature(i, parent),
+ }
+ }
+
+ pub(super) fn visit_type(&mut self, ty: &TSType<'_>, parent: NodeId) {
+ match ty {
+ TSType::Keyword(kw) => self.leaf(NodeKind::TSKeywordType, kw.span, addr_of(kw), parent),
+ TSType::ThisType(t) => self.leaf(NodeKind::TSThisType, t.span, addr_of(t), parent),
+ TSType::Literal(lit) => self.visit_literal_type(lit, parent),
+ TSType::Array(a) => {
+ let id = self.add(NodeKind::TSArrayType, a.span, Some(parent), addr_of(a));
+ self.visit_type(a.element_type, id);
+ self.close(id);
+ }
+ TSType::Union(u) => {
+ let id = self.add(NodeKind::TSUnionType, u.span, Some(parent), addr_of(u));
+ for t in u.types {
+ self.visit_type(t, id);
+ }
+ self.close(id);
+ }
+ TSType::Intersection(i) => {
+ let id = self.add(
+ NodeKind::TSIntersectionType,
+ i.span,
+ Some(parent),
+ addr_of(i),
+ );
+ for t in i.types {
+ self.visit_type(t, id);
+ }
+ self.close(id);
+ }
+ TSType::TypeReference(r) => {
+ let id = self.add(NodeKind::TSTypeReference, r.span, Some(parent), addr_of(r));
+ self.visit_entity_name(&r.type_name, id);
+ if let Some(ta) = &r.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ self.close(id);
+ }
+ TSType::TypeLiteral(tl) => {
+ let id = self.add(NodeKind::TSTypeLiteral, tl.span, Some(parent), addr_of(tl));
+ self.visit_type_elements(tl.members, id);
+ self.close(id);
+ }
+ TSType::Function(f) => {
+ let id = self.add(NodeKind::TSFunctionType, f.span, Some(parent), addr_of(f));
+ self.visit_type_params(f.type_parameters.as_ref(), id);
+ self.visit_params(f.params, id);
+ self.visit_type_annotation(&f.return_type, id);
+ self.close(id);
+ }
+ TSType::Constructor(c) => {
+ let id = self.add(
+ NodeKind::TSConstructorType,
+ c.span,
+ Some(parent),
+ addr_of(c),
+ );
+ self.visit_type_params(c.type_parameters.as_ref(), id);
+ self.visit_params(c.params, id);
+ self.visit_type_annotation(&c.return_type, id);
+ self.close(id);
+ }
+ TSType::Tuple(t) => {
+ let id = self.add(NodeKind::TSTupleType, t.span, Some(parent), addr_of(t));
+ for e in t.element_types {
+ self.visit_type(e, id);
+ }
+ self.close(id);
+ }
+ TSType::Parenthesized(p) => {
+ let id = self.add(
+ NodeKind::TSParenthesizedType,
+ p.span,
+ Some(parent),
+ addr_of(p),
+ );
+ self.visit_type(p.type_annotation, id);
+ self.close(id);
+ }
+ TSType::TypePredicate(p) => {
+ let id = self.add(NodeKind::TSTypePredicate, p.span, Some(parent), addr_of(p));
+ self.visit_identifier(&p.parameter_name, id);
+ if let Some(t) = p.type_annotation {
+ self.visit_type(t, id);
+ }
+ self.close(id);
+ }
+ TSType::Conditional(c) => {
+ let id = self.add(
+ NodeKind::TSConditionalType,
+ c.span,
+ Some(parent),
+ addr_of(c),
+ );
+ self.visit_type(c.check_type, id);
+ self.visit_type(c.extends_type, id);
+ self.visit_type(c.true_type, id);
+ self.visit_type(c.false_type, id);
+ self.close(id);
+ }
+ TSType::Mapped(m) => {
+ let id = self.add(NodeKind::TSMappedType, m.span, Some(parent), addr_of(m));
+ self.visit_mapped_type_parameter(&m.type_parameter, id);
+ if let Some(nt) = m.name_type {
+ self.visit_type(nt, id);
+ }
+ if let Some(ta) = m.type_annotation {
+ self.visit_type(ta, id);
+ }
+ self.close(id);
+ }
+ TSType::TypeOperator(o) => {
+ let id = self.add(NodeKind::TSTypeOperator, o.span, Some(parent), addr_of(o));
+ self.visit_type(o.type_annotation, id);
+ self.close(id);
+ }
+ TSType::Import(i) => self.visit_import_type(i, parent),
+ TSType::TypeQuery(q) => {
+ let id = self.add(NodeKind::TSTypeQuery, q.span, Some(parent), addr_of(q));
+ match &q.expr_name {
+ TSTypeQueryExprName::EntityName(en) => self.visit_entity_name(en, id),
+ TSTypeQueryExprName::Import(imp) => self.visit_import_type(imp, id),
+ }
+ if let Some(ta) = &q.type_arguments {
+ self.visit_type_args(ta, id);
+ }
+ self.close(id);
+ }
+ TSType::IndexedAccess(i) => {
+ let id = self.add(
+ NodeKind::TSIndexedAccessType,
+ i.span,
+ Some(parent),
+ addr_of(i),
+ );
+ self.visit_type(i.object_type, id);
+ self.visit_type(i.index_type, id);
+ self.close(id);
+ }
+ TSType::Rest(r) => {
+ let id = self.add(NodeKind::TSRestType, r.span, Some(parent), addr_of(r));
+ self.visit_type(r.type_annotation, id);
+ self.close(id);
+ }
+ TSType::Optional(o) => {
+ let id = self.add(NodeKind::TSOptionalType, o.span, Some(parent), addr_of(o));
+ self.visit_type(o.type_annotation, id);
+ self.close(id);
+ }
+ TSType::NamedTupleMember(n) => {
+ let id = self.add(
+ NodeKind::TSNamedTupleMember,
+ n.span,
+ Some(parent),
+ addr_of(n),
+ );
+ self.visit_identifier(&n.label, id);
+ self.visit_type(n.element_type, id);
+ self.close(id);
+ }
+ TSType::Infer(inf) => {
+ let id = self.add(NodeKind::TSInferType, inf.span, Some(parent), addr_of(inf));
+ self.visit_type_parameter(&inf.type_parameter, id);
+ self.close(id);
+ }
+ }
+ }
+
+ /// The nested `TSLiteralType` dispatcher: a template-literal type is its own
+ /// node (`TSTemplateLiteralType`); a string/number/bigint literal type reuses
+ /// `Literal`; a negative-number literal type reuses `UnaryExpression`.
+ fn visit_literal_type(&mut self, lit: &TSLiteralType<'_>, parent: NodeId) {
+ match lit {
+ TSLiteralType::TemplateLiteral(t) => {
+ let id = self.add(
+ NodeKind::TSTemplateLiteralType,
+ t.span,
+ Some(parent),
+ addr_of(t),
+ );
+ for q in t.quasis {
+ self.visit_template_element(q, id);
+ }
+ for ty in t.types {
+ self.visit_type(ty, id);
+ }
+ self.close(id);
+ }
+ TSLiteralType::String(l) | TSLiteralType::Number(l) | TSLiteralType::BigInt(l) => {
+ self.leaf(NodeKind::Literal, l.span, addr_of(l), parent);
+ }
+ TSLiteralType::UnaryExpression(u) => {
+ let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u));
+ self.visit_expression(u.argument, id);
+ self.close(id);
+ }
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs
new file mode 100644
index 000000000..f0a11c7c8
--- /dev/null
+++ b/crates/tsv_check/src/binder/mod.rs
@@ -0,0 +1,1135 @@
+//! The lower+bind pass: node identity (SoA columns) plus the symbol bind.
+//!
+//! Two cooperating walks run per file, kept in one module:
+//!
+//! - **the SoA walk** ([`SoaWalk`]) — one **full pre-order descent** assigning a
+//! dense pre-order [`NodeId`] to every AST node the checker addresses:
+//! statements, expressions (including the pattern-shaped ones at
+//! assignment-target / for-left positions), types, and their sub-nodes
+//! (heritage clauses, type parameters, member signatures, decorators, import/
+//! export specifiers, …). It fills the parent/kind/span/`subtree_end` side
+//! columns and the address→id map.
+//! Pre-order — each parent precedes its contiguous subtree, so the
+//! `subtree_end` interval test (`is X a descendant of Y`) stays valid. Sibling
+//! order follows the traversal, not always source order (an annotated binding
+//! descends its `: T` before the binding), which the interval test does not rely on. The one deliberate carve-out is the pure list-wrapper nodes
+//! (`ClassBody` / `TSInterfaceBody` / a function/try/catch/finally/static body
+//! `BlockStatement` / the `Program.body` slice / the transparent
+//! `TSTypeAnnotation` `: T` wrapper): their members/inner stay flat children of
+//! the owning node, matching today's shape (documented at the sites).
+//! - **the symbol bind** ([`sym::SymbolBinder`]) — a container-threaded walk that
+//! ports tsgo's binder: `declareSymbolEx` conflict cascade (TS2300/2451/2567/
+//! 2528), the module-member routing, class/enum/interface member tables, and
+//! the **functions-first** statement-list ordering (`bindEachStatementFunctionsFirst`).
+//! It reaches every binding-introducing position and emits the bind-time
+//! duplicate/conflict family.
+//!
+//! The two are separate passes rather than one fused walk because functions-first
+//! symbol binding reorders symbol *creation* within a statement list, which would
+//! break the SoA walk's strict pre-order id intervals. The symbol bind resolves a
+//! declaration's [`NodeId`] through the SoA walk's address map. Statement-level
+//! inner structs it keys on (`TSExportAssignment`, `ExportDefaultDeclaration`,
+//! `TSModuleDeclaration`) resolve against the enclosing `&Statement` address, so
+//! those `node_id_of` lookups fall back to the root id — the id is not consumed by
+//! the family cascade, which keys on name spans, so the fallback is inert. The
+//! **strict** resolver flow consumers use is [`BoundFile::require_node_id`], which
+//! hard-fails on a miss (a flow graph must never silently drop an attachment).
+//!
+//! **Borrow-only discipline (load-bearing).** Every visitor takes `&'arena`
+//! references and NEVER clones an AST node. The address map keys on
+//! `std::ptr::from_ref(node) as usize` (a safe reference-to-integer cast — the
+//! crate keeps `unsafe_code = "forbid"`), and arena addresses are stable for the
+//! program's lifetime. Every tsv AST type derives `Clone`, so one accidental
+//! `.clone()` in a visitor would mint a differently-addressed copy and silently
+//! break the map. Nothing type-level enforces this — the discipline is the contract.
+//!
+//! **No behavior change (F0 invariant).** The SoA columns feed only the symbol
+//! bind (which reads the address map) and this module's tests; the graded
+//! diagnostic stream carries no `NodeId` (`Diagnostic` has none; the symbol bind's
+//! `Decl.node` is dead), and every graded span derives from an AST `.span` /
+//! `.name_span()`, never a `spans[node_id]` lookup. So growing the walk — and thus
+//! renumbering the ids — cannot perturb graded output.
+//
+// tsgo: internal/binder/binder.go bindSourceFile / bindChildren / bindContainer
+// (single-walk parent stamping; tsgo stamps in the parser, we stamp here —
+// a recorded deviation with identical results)
+
+mod atoms;
+pub mod flow;
+mod lower;
+mod sym;
+pub mod symbols;
+
+use crate::diag::Diagnostic;
+use crate::ids::{FileId, NodeId};
+use crate::merge::FileMerge;
+use tsv_lang::{FxHashMap, Span};
+use tsv_ts::ast::Program;
+use tsv_ts::ast::internal::{Expression, Statement, TSModuleReference};
+
+/// The pre-order node kinds the SoA walk assigns — one variant per tsv_ts AST enum
+/// variant the walk ids (the program root, then statements, expressions, types, and
+/// their sub-nodes). Several kinds are **reused** across positions: `Identifier`
+/// tags every identifier — a binding *or* a reference (labels, member/property
+/// names, type-param names, entity-name segments, …); `Literal` tags a value
+/// literal and a string/number/bigint literal type; `UnaryExpression` a value unary
+/// and a negative-number literal type; `TSIndexSignature` both the class-member and
+/// type-element index-signature forms; `FunctionExpression` a value function and a
+/// method's `value`. The set is not graded or serialized, so its ordering is free.
+///
+/// `Hash` is derived so a `(usize, NodeKind)` pair can key the address map — the
+/// compound key that disambiguates the one offset-0 collision pair
+/// (`MethodDefinition` / its inline `value: FunctionExpression`).
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+#[repr(u16)]
+pub enum NodeKind {
+ /// The source file root.
+ Program,
+ /// An identifier — a binding (declaration name, parameter, catch/type-param
+ /// name) or a reference (variable use, label, member/property/entity-name
+ /// segment). The scope is every identifier the walk reaches, not only bindings.
+ Identifier,
+ // --- Statements ---
+ ExpressionStatement,
+ VariableDeclaration,
+ VariableDeclarator,
+ FunctionDeclaration,
+ ClassDeclaration,
+ TSTypeAliasDeclaration,
+ TSInterfaceDeclaration,
+ TSDeclareFunction,
+ TSEnumDeclaration,
+ TSModuleDeclaration,
+ ImportDeclaration,
+ TSImportEqualsDeclaration,
+ ExportNamedDeclaration,
+ ExportDefaultDeclaration,
+ ExportAllDeclaration,
+ TSExportAssignment,
+ TSNamespaceExportDeclaration,
+ ReturnStatement,
+ BlockStatement,
+ IfStatement,
+ ForStatement,
+ ForInStatement,
+ ForOfStatement,
+ WhileStatement,
+ DoWhileStatement,
+ SwitchStatement,
+ TryStatement,
+ ThrowStatement,
+ BreakStatement,
+ ContinueStatement,
+ LabeledStatement,
+ EmptyStatement,
+ DebuggerStatement,
+ // --- Expressions ---
+ Literal,
+ PrivateIdentifier,
+ ObjectExpression,
+ ArrayExpression,
+ UnaryExpression,
+ UpdateExpression,
+ BinaryExpression,
+ CallExpression,
+ NewExpression,
+ MemberExpression,
+ ConditionalExpression,
+ ArrowFunctionExpression,
+ FunctionExpression,
+ ClassExpression,
+ SpreadElement,
+ TemplateLiteral,
+ TaggedTemplateExpression,
+ AwaitExpression,
+ YieldExpression,
+ SequenceExpression,
+ RegexLiteral,
+ ThisExpression,
+ Super,
+ AssignmentExpression,
+ ObjectPattern,
+ ArrayPattern,
+ AssignmentPattern,
+ RestElement,
+ TSTypeAssertion,
+ TSAsExpression,
+ TSSatisfiesExpression,
+ TSInstantiationExpression,
+ TSNonNullExpression,
+ TSParameterProperty,
+ ImportExpression,
+ MetaProperty,
+ JsdocCast,
+ ParenthesizedExpression,
+ // --- Types ---
+ TSKeywordType,
+ TSArrayType,
+ TSUnionType,
+ TSIntersectionType,
+ TSTypeReference,
+ TSTypeLiteral,
+ TSFunctionType,
+ TSConstructorType,
+ TSTupleType,
+ TSParenthesizedType,
+ TSTypePredicate,
+ TSConditionalType,
+ TSMappedType,
+ TSTypeOperator,
+ TSImportType,
+ TSTypeQuery,
+ TSIndexedAccessType,
+ TSRestType,
+ TSOptionalType,
+ TSNamedTupleMember,
+ TSInferType,
+ TSThisType,
+ TSTemplateLiteralType,
+ // --- Type elements (interface / type-literal members) ---
+ TSPropertySignature,
+ TSMethodSignature,
+ TSCallSignatureDeclaration,
+ TSConstructSignatureDeclaration,
+ TSIndexSignature,
+ // --- Class members ---
+ MethodDefinition,
+ PropertyDefinition,
+ StaticBlock,
+ // --- Entity / property / specifier sub-nodes ---
+ TSQualifiedName,
+ Property,
+ ImportDefaultSpecifier,
+ ImportNamedSpecifier,
+ ImportNamespaceSpecifier,
+ ExportSpecifier,
+ TSExternalModuleReference,
+ TSModuleBlock,
+ // --- Plain structs at distinct addressable positions ---
+ Decorator,
+ TSInterfaceHeritage,
+ TSTypeParameterDeclaration,
+ TSTypeParameter,
+ TSTypeParameterInstantiation,
+ CatchClause,
+ SwitchCase,
+ TemplateElement,
+ ImportAttribute,
+ TSEnumMember,
+ TSMappedTypeParameter,
+}
+
+/// Per-node flag bits in the [`flow::FlowProduct::node_flags`] column (one `u8`
+/// per [`NodeId`], minted zeroed by the flow builder — the sole writer today,
+/// setting [`NODE_FLAGS_UNREACHABLE`] during unreachable tagging). A bind-side
+/// column returns to [`BoundFile`] when a bind-time writer lands (the planned
+/// ambient/context node-identity bits).
+#[allow(clippy::identity_op)] // bit 0 — kept in the `1 << N` idiom for the bits F1 adds
+pub const NODE_FLAGS_UNREACHABLE: u8 = 1 << 0;
+
+/// Whether a file is an external module — tsgo's `externalModuleIndicator`,
+/// derived post-parse (`getExternalModuleIndicator`). Recorded so the binder's
+/// module-vs-script member routing has the fact ready.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum ModuleNess {
+ /// Has a top-level module indicator (`import`/`export`/`export =`/an
+ /// `import =` with an external-module reference/`import.meta`).
+ Module,
+ /// No module indicator.
+ Script,
+}
+
+/// Per-file facts filled at lower+bind (reached O(1) from any node in the file).
+#[derive(Clone, Copy, Debug)]
+pub struct FileFacts {
+ /// Module-vs-script indicator (see [`ModuleNess`]).
+ pub module_ness: ModuleNess,
+}
+
+/// The product of binding one file: the pre-order node columns, the address->id
+/// map, per-file facts, and the bind diagnostics.
+pub struct BoundFile {
+ /// The file these nodes belong to.
+ pub file: FileId,
+ /// Total nodes assigned (the program root plus every visited node).
+ pub node_count: u32,
+ /// Parent id per node (`None` for the root), indexed by `NodeId::index`.
+ pub parents: Vec>,
+ /// Node kind per node.
+ pub kinds: Vec,
+ /// Node span per node.
+ pub spans: Vec,
+ /// The last id in each node's pre-order subtree (self for a leaf) — makes
+ /// descendant tests an O(1) id-range check.
+ pub subtree_end: Vec,
+ /// Node `(arena address, kind)` -> id (the random-access escape hatch). The
+ /// kind is part of the key so the one offset-0 collision pair
+ /// (`MethodDefinition` and its inline `value: FunctionExpression`) stays
+ /// distinctly resolvable (see [`BoundFile::require_node_id`]).
+ ///
+ /// These keys assume a node's address is stable, which is why the borrow-only
+ /// discipline exists. `tests/clone_discipline.rs` guards the clone half of it, but
+ /// it cannot see the other half: a `Copy` AST type (`TSKeywordType`) is
+ /// re-addressed by a plain `*deref` copy, with no clone syntax to flag. Keep those
+ /// borrowed too.
+ pub address_map: FxHashMap<(usize, NodeKind), NodeId>,
+ /// Bind diagnostics — the duplicate/conflict family, in emission order (the
+ /// caller sorts + dedups across the whole program).
+ pub diagnostics: Vec,
+ /// Per-file facts.
+ pub facts: FileFacts,
+ /// The program-independent merge product ([`crate::merge`] folds these across
+ /// files into the global scope).
+ pub merge: FileMerge,
+}
+
+impl BoundFile {
+ /// Whether node `descendant` lies in node `ancestor`'s pre-order subtree —
+ /// an O(1) id-interval test enabled by pre-order ids + `subtree_end`.
+ #[must_use]
+ pub fn is_descendant_of(&self, descendant: NodeId, ancestor: NodeId) -> bool {
+ ancestor < descendant && descendant <= self.subtree_end[ancestor.index()]
+ }
+
+ /// Strict `(address, kind)` -> [`NodeId`] resolution for flow consumers.
+ /// Unlike the symbol bind's lenient `node_id_of` (whose `Decl.node` result is
+ /// dead), a flow graph attaches to the node at `address`, so a **miss is a
+ /// bug** — the SoA walk failed to cover a node the flow pass reached, and a
+ /// silent `NodeId::FIRST` fallback would splice the graph onto the wrong node.
+ /// So this hard-fails rather than returning a sentinel.
+ ///
+ /// `address` is `std::ptr::from_ref(node) as usize` for the same arena node
+ /// reference the walk keyed on, and `kind` is the [`NodeKind`] the walk
+ /// assigned it.
+ ///
+ /// **The offset-0 collision, resolved by compound keying.** Pointer-identity
+ /// alone cannot distinguish a node from an offset-0 inline struct-typed child
+ /// at the same address. The AST has exactly one such pair: `MethodDefinition`
+ /// and its inline `value: FunctionExpression` (value at struct offset 0). The
+ /// kind component of the key disambiguates them — no same-kind collisions
+ /// exist (verified via `-Zprint-type-sizes`), so `(address, kind)` is a total
+ /// key. `require_node_id(addr_of(&method), NodeKind::MethodDefinition)` and
+ /// `require_node_id(addr_of(&method.value), NodeKind::FunctionExpression)` now
+ /// resolve to their respective distinct ids (pinned by
+ /// `method_and_value_resolve_distinctly` below).
+ #[must_use]
+ pub fn require_node_id(&self, address: usize, kind: NodeKind) -> NodeId {
+ match self.address_map.get(&(address, kind)) {
+ Some(&id) => id,
+ None => node_id_miss(address, kind),
+ }
+ }
+}
+
+/// The `require_node_id` miss path, isolated so its deliberate panic carries the
+/// one `#[allow(clippy::panic)]` the crate's restriction-lint posture requires
+/// (panic points need an explicit allow + justification). A miss means the SoA
+/// walk did not id a node a flow consumer reached — an internal invariant break
+/// that must abort, not a recoverable data error.
+#[cold]
+#[inline(never)]
+#[allow(clippy::panic)]
+fn node_id_miss(address: usize, kind: NodeKind) -> ! {
+ panic!(
+ "require_node_id: ({address:#x}, {kind:?}) not covered by the SoA walk — a flow \
+ consumer reached a node the lowering pass did not id (would corrupt the flow graph)"
+ );
+}
+
+/// Derive a file's [`ModuleNess`] — a faithful port of tsgo's
+/// `getExternalModuleIndicator` / `isAnExternalModuleIndicatorNode`: a top-level
+/// statement is an indicator when it carries an `export` modifier, is an
+/// `import`/`export`/`export =` declaration, or is an `import =` with an external
+/// (`require(...)`) module reference; failing that, an `import.meta` anywhere in
+/// the file counts. Notably `export as namespace` (a UMD export) does **not**
+/// count, and an `import =` with an entity-name reference (`import x = A.B`) does
+/// not.
+///
+/// The module indicators are all structural — no name resolution (and so no
+/// `source`) is needed here.
+#[must_use]
+pub fn module_ness(program: &Program<'_>) -> ModuleNess {
+ for stmt in program.body {
+ if is_external_module_indicator(stmt) {
+ return ModuleNess::Module;
+ }
+ }
+ if program.body.iter().any(stmt_contains_import_meta) {
+ return ModuleNess::Module;
+ }
+ ModuleNess::Script
+}
+
+/// tsgo's `isAnExternalModuleIndicatorNode` over one top-level statement.
+fn is_external_module_indicator(stmt: &Statement<'_>) -> bool {
+ match stmt {
+ // `import ...` / `export ... from` / `export {}` / `export *`.
+ Statement::ImportDeclaration(_)
+ | Statement::ExportNamedDeclaration(_)
+ | Statement::ExportAllDeclaration(_)
+ // `export = x` and `export default ...` are both `ExportAssignment` in
+ // tsgo, both indicators.
+ | Statement::TSExportAssignment(_)
+ | Statement::ExportDefaultDeclaration(_) => true,
+ // `import x = require('y')` counts only with an external-module reference;
+ // `import x = A.B` (an entity name) does not.
+ Statement::TSImportEqualsDeclaration(decl) => matches!(
+ decl.module_reference,
+ TSModuleReference::ExternalModuleReference(_)
+ ),
+ _ => false,
+ }
+}
+
+/// Whether a statement subtree contains an `import.meta` meta-property (tsgo's
+/// `getImportMetaIfNecessary`). A bounded structural walk over the statement and
+/// its nested expressions/blocks — `import.meta` is inert for the bind cascade,
+/// so this only refines the recorded [`ModuleNess`] fact.
+fn stmt_contains_import_meta(stmt: &Statement<'_>) -> bool {
+ use Statement as S;
+ match stmt {
+ S::ExpressionStatement(s) => expr_contains_import_meta(&s.expression),
+ S::VariableDeclaration(d) => d
+ .declarations
+ .iter()
+ .any(|decl| decl.init.as_ref().is_some_and(expr_contains_import_meta)),
+ S::ReturnStatement(s) => s.argument.as_ref().is_some_and(expr_contains_import_meta),
+ S::ThrowStatement(s) => expr_contains_import_meta(&s.argument),
+ S::BlockStatement(b) => b.body.iter().any(stmt_contains_import_meta),
+ S::IfStatement(s) => {
+ expr_contains_import_meta(&s.test)
+ || stmt_contains_import_meta(s.consequent)
+ || s.alternate.is_some_and(stmt_contains_import_meta)
+ }
+ S::ForStatement(s) => {
+ s.test.as_ref().is_some_and(expr_contains_import_meta)
+ || stmt_contains_import_meta(s.body)
+ }
+ S::ForInStatement(s) => {
+ expr_contains_import_meta(&s.right) || stmt_contains_import_meta(s.body)
+ }
+ S::ForOfStatement(s) => {
+ expr_contains_import_meta(&s.right) || stmt_contains_import_meta(s.body)
+ }
+ S::WhileStatement(s) => {
+ expr_contains_import_meta(&s.test) || stmt_contains_import_meta(s.body)
+ }
+ S::DoWhileStatement(s) => {
+ expr_contains_import_meta(&s.test) || stmt_contains_import_meta(s.body)
+ }
+ S::SwitchStatement(s) => {
+ expr_contains_import_meta(&s.discriminant)
+ || s.cases.iter().any(|c| {
+ c.test.as_ref().is_some_and(expr_contains_import_meta)
+ || c.consequent.iter().any(stmt_contains_import_meta)
+ })
+ }
+ S::TryStatement(s) => {
+ s.block.body.iter().any(stmt_contains_import_meta)
+ || s.handler
+ .as_ref()
+ .is_some_and(|h| h.body.body.iter().any(stmt_contains_import_meta))
+ || s.finalizer
+ .as_ref()
+ .is_some_and(|f| f.body.iter().any(stmt_contains_import_meta))
+ }
+ S::LabeledStatement(s) => stmt_contains_import_meta(s.body),
+ _ => false,
+ }
+}
+
+/// Whether an expression subtree contains an `import.meta` meta-property. Covers
+/// the common expression positions; deliberately not exhaustive over every type
+/// node (types never carry `import.meta`).
+fn expr_contains_import_meta(expr: &Expression<'_>) -> bool {
+ use Expression as E;
+ match expr {
+ // `import.meta` vs `new.target`: the only two meta-properties, told apart
+ // by the meta keyword's name length (`import` = 6, `new` = 3).
+ E::MetaProperty(m) => m.meta.name_len == 6,
+ E::ParenthesizedExpression(p) => expr_contains_import_meta(p.expression),
+ E::UnaryExpression(u) => expr_contains_import_meta(u.argument),
+ E::UpdateExpression(u) => expr_contains_import_meta(u.argument),
+ E::AwaitExpression(a) => expr_contains_import_meta(a.argument),
+ E::YieldExpression(y) => y.argument.is_some_and(expr_contains_import_meta),
+ E::BinaryExpression(b) => {
+ expr_contains_import_meta(b.left) || expr_contains_import_meta(b.right)
+ }
+ E::AssignmentExpression(a) => {
+ expr_contains_import_meta(a.left) || expr_contains_import_meta(a.right)
+ }
+ E::ConditionalExpression(c) => {
+ expr_contains_import_meta(c.test)
+ || expr_contains_import_meta(c.consequent)
+ || expr_contains_import_meta(c.alternate)
+ }
+ E::SequenceExpression(s) => s.expressions.iter().any(expr_contains_import_meta),
+ E::CallExpression(c) => {
+ expr_contains_import_meta(c.callee) || c.arguments.iter().any(expr_contains_import_meta)
+ }
+ E::NewExpression(n) => {
+ expr_contains_import_meta(n.callee) || n.arguments.iter().any(expr_contains_import_meta)
+ }
+ E::MemberExpression(m) => {
+ expr_contains_import_meta(m.object) || expr_contains_import_meta(m.property)
+ }
+ E::TSNonNullExpression(t) => expr_contains_import_meta(t.expression),
+ E::TSAsExpression(t) => expr_contains_import_meta(t.expression),
+ E::TSSatisfiesExpression(t) => expr_contains_import_meta(t.expression),
+ E::ArrayExpression(a) => a.elements.iter().flatten().any(expr_contains_import_meta),
+ _ => false,
+ }
+}
+
+/// Bind one file: run the SoA walk and the symbol bind, returning the [`BoundFile`].
+///
+/// `source` is the host document the AST spans index into (the binder resolves
+/// declared names by slicing it, matching the parser's span-identity names).
+#[must_use]
+pub fn bind_file<'arena>(
+ program: &'arena Program<'arena>,
+ source: &str,
+ file: FileId,
+) -> BoundFile {
+ // Pass 1: the SoA node-identity walk (source pre-order).
+ let mut walk = SoaWalk::default();
+ let root = walk.add(NodeKind::Program, program.span, None, addr_of(program));
+ // The `Program.body` slice is a pure list-wrapper: its statements stay flat
+ // children of the root (no separate node), matching today's shape.
+ for stmt in program.body {
+ walk.visit_statement(stmt, root);
+ }
+ walk.close(root);
+
+ let facts = FileFacts {
+ module_ness: module_ness(program),
+ };
+
+ // Pass 2: the symbol bind (functions-first, container-threaded).
+ let (diagnostics, merge) = {
+ let mut binder = sym::SymbolBinder::new(source, &walk.address_map, file, facts);
+ binder.bind_program(program);
+ binder.finish()
+ };
+
+ BoundFile {
+ file,
+ node_count: walk.kinds.len() as u32,
+ parents: walk.parents,
+ kinds: walk.kinds,
+ spans: walk.spans,
+ subtree_end: walk.subtree_end,
+ address_map: walk.address_map,
+ diagnostics,
+ facts,
+ merge,
+ }
+}
+
+/// The address key of an arena node: a reference-to-integer cast (safe — no
+/// dereference, so `unsafe_code = "forbid"` holds). Stable for the arena's life.
+#[inline]
+pub(crate) fn addr_of(node: &T) -> usize {
+ std::ptr::from_ref(node) as usize
+}
+
+/// The mutable SoA columns being filled by pass 1.
+#[derive(Default)]
+struct SoaWalk {
+ parents: Vec>,
+ kinds: Vec,
+ spans: Vec,
+ subtree_end: Vec,
+ address_map: FxHashMap<(usize, NodeKind), NodeId>,
+}
+
+impl SoaWalk {
+ /// Assign the next pre-order id to a node, recording its columns and address.
+ fn add(
+ &mut self,
+ kind: NodeKind,
+ span: Span,
+ parent: Option,
+ address: usize,
+ ) -> NodeId {
+ let id = NodeId::from_index(self.kinds.len());
+ self.parents.push(parent);
+ self.kinds.push(kind);
+ self.spans.push(span);
+ self.subtree_end.push(id); // provisional (a leaf owns only itself)
+ // The insert must run in ALL profiles — the flow walk's strict
+ // `require_node_id` reads this map at runtime. Each node is added exactly
+ // once by the pre-order walk, so a prior entry for this `(address, kind)`
+ // key is a genuine same-kind offset-0 collision (the "no same-kind
+ // collisions exist" claim in `require_node_id`, made a corpus-checked
+ // invariant — `tsc_conformance run` is a debug build). Only that
+ // collision *assertion* compiles out of release; the insert side effect
+ // is hoisted out of the assert condition so it always happens.
+ let prev = self.address_map.insert((address, kind), id);
+ debug_assert!(
+ prev.is_none(),
+ "same-kind address collision at {address:#x} / {kind:?}"
+ );
+ id
+ }
+
+ /// Close a node after its children are visited: its subtree spans every id
+ /// assigned since (the current maximum).
+ fn close(&mut self, id: NodeId) {
+ let last = NodeId::from_index(self.kinds.len() - 1);
+ self.subtree_end[id.index()] = last;
+ }
+
+ /// Add a leaf node (no children): one `add` immediately followed by `close`.
+ fn leaf(&mut self, kind: NodeKind, span: Span, address: usize, parent: NodeId) {
+ let id = self.add(kind, span, Some(parent), address);
+ self.close(id);
+ }
+}
+
+/// The [`NodeKind`] for a statement variant. Shared with the flow walk and the
+/// unreachable-code shim, which resolve statements through the compound-keyed
+/// address map (`require_node_id` / a lenient `address_map` lookup).
+pub(crate) fn statement_kind(stmt: &Statement<'_>) -> NodeKind {
+ match stmt {
+ Statement::ExpressionStatement(_) => NodeKind::ExpressionStatement,
+ Statement::VariableDeclaration(_) => NodeKind::VariableDeclaration,
+ Statement::TSTypeAliasDeclaration(_) => NodeKind::TSTypeAliasDeclaration,
+ Statement::TSInterfaceDeclaration(_) => NodeKind::TSInterfaceDeclaration,
+ Statement::TSDeclareFunction(_) => NodeKind::TSDeclareFunction,
+ Statement::TSEnumDeclaration(_) => NodeKind::TSEnumDeclaration,
+ Statement::TSModuleDeclaration(_) => NodeKind::TSModuleDeclaration,
+ Statement::ReturnStatement(_) => NodeKind::ReturnStatement,
+ Statement::BlockStatement(_) => NodeKind::BlockStatement,
+ Statement::FunctionDeclaration(_) => NodeKind::FunctionDeclaration,
+ Statement::ClassDeclaration(_) => NodeKind::ClassDeclaration,
+ Statement::ExportNamedDeclaration(_) => NodeKind::ExportNamedDeclaration,
+ Statement::ExportDefaultDeclaration(_) => NodeKind::ExportDefaultDeclaration,
+ Statement::ExportAllDeclaration(_) => NodeKind::ExportAllDeclaration,
+ Statement::TSExportAssignment(_) => NodeKind::TSExportAssignment,
+ Statement::TSNamespaceExportDeclaration(_) => NodeKind::TSNamespaceExportDeclaration,
+ Statement::ImportDeclaration(_) => NodeKind::ImportDeclaration,
+ Statement::TSImportEqualsDeclaration(_) => NodeKind::TSImportEqualsDeclaration,
+ Statement::IfStatement(_) => NodeKind::IfStatement,
+ Statement::ForStatement(_) => NodeKind::ForStatement,
+ Statement::ForInStatement(_) => NodeKind::ForInStatement,
+ Statement::ForOfStatement(_) => NodeKind::ForOfStatement,
+ Statement::WhileStatement(_) => NodeKind::WhileStatement,
+ Statement::DoWhileStatement(_) => NodeKind::DoWhileStatement,
+ Statement::SwitchStatement(_) => NodeKind::SwitchStatement,
+ Statement::TryStatement(_) => NodeKind::TryStatement,
+ Statement::ThrowStatement(_) => NodeKind::ThrowStatement,
+ Statement::BreakStatement(_) => NodeKind::BreakStatement,
+ Statement::ContinueStatement(_) => NodeKind::ContinueStatement,
+ Statement::LabeledStatement(_) => NodeKind::LabeledStatement,
+ Statement::EmptyStatement(_) => NodeKind::EmptyStatement,
+ Statement::DebuggerStatement(_) => NodeKind::DebuggerStatement,
+ }
+}
+
+/// The `(arena address, NodeKind)` compound key for an expression variant — the
+/// key `SoaWalk::visit_expression` registers it under in the address map.
+/// Shared with the flow walk's `expr_id`, so the two mappings cannot drift: a
+/// `debug_assert` at the end of `visit_expression` pins the agreement on every
+/// lowered expression (corpus-exercised — the conformance gate runs debug
+/// builds), and the flow walk's strict resolver hard-fails on any residual
+/// mismatch. A `JsdocCast` resolves to its **inner** expression's key: the flow
+/// walk treats the cast wrapper as transparent (matching tsgo, where the
+/// reparsed cast is not a flow subject), and the SoA walk lowers both the
+/// wrapper and the inner, so the inner's key is always present.
+pub(crate) fn expression_addr_kind(e: &Expression<'_>) -> (usize, NodeKind) {
+ use Expression as E;
+ match e {
+ E::JsdocCast(c) => expression_addr_kind(c.inner),
+ E::Literal(x) => (addr_of(x), NodeKind::Literal),
+ E::Identifier(x) => (addr_of(x), NodeKind::Identifier),
+ E::PrivateIdentifier(x) => (addr_of(x), NodeKind::PrivateIdentifier),
+ E::ObjectExpression(x) => (addr_of(x), NodeKind::ObjectExpression),
+ E::ArrayExpression(x) => (addr_of(x), NodeKind::ArrayExpression),
+ E::UnaryExpression(x) => (addr_of(x), NodeKind::UnaryExpression),
+ E::UpdateExpression(x) => (addr_of(x), NodeKind::UpdateExpression),
+ E::BinaryExpression(x) => (addr_of(x), NodeKind::BinaryExpression),
+ E::CallExpression(x) => (addr_of(x), NodeKind::CallExpression),
+ E::NewExpression(x) => (addr_of(x), NodeKind::NewExpression),
+ E::MemberExpression(x) => (addr_of(x), NodeKind::MemberExpression),
+ E::ConditionalExpression(x) => (addr_of(x), NodeKind::ConditionalExpression),
+ E::ArrowFunctionExpression(x) => (addr_of(x), NodeKind::ArrowFunctionExpression),
+ E::FunctionExpression(x) => (addr_of(x), NodeKind::FunctionExpression),
+ E::ClassExpression(x) => (addr_of(x), NodeKind::ClassExpression),
+ E::SpreadElement(x) => (addr_of(x), NodeKind::SpreadElement),
+ E::TemplateLiteral(x) => (addr_of(x), NodeKind::TemplateLiteral),
+ E::TaggedTemplateExpression(x) => (addr_of(x), NodeKind::TaggedTemplateExpression),
+ E::AwaitExpression(x) => (addr_of(x), NodeKind::AwaitExpression),
+ E::YieldExpression(x) => (addr_of(x), NodeKind::YieldExpression),
+ E::SequenceExpression(x) => (addr_of(x), NodeKind::SequenceExpression),
+ E::RegexLiteral(x) => (addr_of(x), NodeKind::RegexLiteral),
+ E::ThisExpression(x) => (addr_of(x), NodeKind::ThisExpression),
+ E::Super(x) => (addr_of(x), NodeKind::Super),
+ E::AssignmentExpression(x) => (addr_of(x), NodeKind::AssignmentExpression),
+ E::ObjectPattern(x) => (addr_of(x), NodeKind::ObjectPattern),
+ E::ArrayPattern(x) => (addr_of(x), NodeKind::ArrayPattern),
+ E::AssignmentPattern(x) => (addr_of(x), NodeKind::AssignmentPattern),
+ E::RestElement(x) => (addr_of(x), NodeKind::RestElement),
+ E::TSTypeAssertion(x) => (addr_of(x), NodeKind::TSTypeAssertion),
+ E::TSAsExpression(x) => (addr_of(x), NodeKind::TSAsExpression),
+ E::TSSatisfiesExpression(x) => (addr_of(x), NodeKind::TSSatisfiesExpression),
+ E::TSInstantiationExpression(x) => (addr_of(x), NodeKind::TSInstantiationExpression),
+ E::TSNonNullExpression(x) => (addr_of(x), NodeKind::TSNonNullExpression),
+ E::TSParameterProperty(x) => (addr_of(x), NodeKind::TSParameterProperty),
+ E::ImportExpression(x) => (addr_of(x), NodeKind::ImportExpression),
+ E::MetaProperty(x) => (addr_of(x), NodeKind::MetaProperty),
+ E::ParenthesizedExpression(x) => (addr_of(x), NodeKind::ParenthesizedExpression),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bumpalo::Bump;
+
+ fn bind(source: &str) -> BoundFile {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(source, &arena).expect("parse");
+ bind_file(&program, source, FileId::ROOT)
+ }
+
+ #[test]
+ fn preorder_ids_parents_and_kinds() {
+ // Program(1) -> VariableDeclaration(2) -> VariableDeclarator(3)
+ // -> Identifier(4) (the `x`, now with the init idd too)
+ // -> Literal(5) (the `1`)
+ let bound = bind("const x = 1;");
+ assert_eq!(bound.node_count, 5);
+ assert_eq!(bound.kinds[0], NodeKind::Program);
+ assert_eq!(bound.kinds[1], NodeKind::VariableDeclaration);
+ assert_eq!(bound.kinds[2], NodeKind::VariableDeclarator);
+ assert_eq!(bound.kinds[3], NodeKind::Identifier);
+ assert_eq!(bound.kinds[4], NodeKind::Literal);
+ assert_eq!(bound.parents[0], None);
+ assert_eq!(bound.parents[1], Some(NodeId::FIRST));
+ assert_eq!(bound.parents[3], Some(NodeId::from_index(2)));
+ assert_eq!(bound.parents[4], Some(NodeId::from_index(2)));
+ }
+
+ #[test]
+ fn subtree_end_enables_descendant_test() {
+ // Program(1) .. Literal(5); the root's subtree ends at the last id (5).
+ let bound = bind("const x = 1;");
+ let root = NodeId::FIRST;
+ let ident = NodeId::from_index(3); // the `x`
+ let decl = NodeId::from_index(1); // VariableDeclaration
+ assert_eq!(bound.subtree_end[root.index()], NodeId::from_index(4));
+ assert!(bound.is_descendant_of(ident, root));
+ assert!(bound.is_descendant_of(ident, decl));
+ assert!(!bound.is_descendant_of(root, ident));
+ assert!(!bound.is_descendant_of(decl, ident));
+ }
+
+ #[test]
+ fn address_map_resolves_a_statement() {
+ let arena = Bump::new();
+ let program = tsv_ts::parse("let a = 1; let b = 2;", &arena).expect("parse");
+ let bound = bind_file(&program, "let a = 1; let b = 2;", FileId::ROOT);
+ let second = &program.body[1];
+ let addr = std::ptr::from_ref(second) as usize;
+ let id = bound
+ .address_map
+ .get(&(addr, NodeKind::VariableDeclaration))
+ .copied()
+ .expect("mapped");
+ assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration);
+ }
+
+ #[test]
+ fn nested_statements_are_walked() {
+ let bound = bind("function f() { return; }");
+ assert!(bound.kinds.contains(&NodeKind::FunctionDeclaration));
+ assert!(bound.kinds.contains(&NodeKind::ReturnStatement));
+ let func = NodeId::from_index(1);
+ let ret = bound
+ .kinds
+ .iter()
+ .position(|k| *k == NodeKind::ReturnStatement)
+ .map(NodeId::from_index)
+ .expect("return present");
+ assert!(bound.is_descendant_of(ret, func));
+ }
+
+ #[test]
+ fn expressions_and_types_are_idd() {
+ // The full descent reaches into initializers and type annotations.
+ let bound = bind("const x: number = f(1);");
+ assert!(bound.kinds.contains(&NodeKind::TSKeywordType)); // `number`
+ assert!(bound.kinds.contains(&NodeKind::CallExpression)); // `f(1)`
+ assert!(bound.kinds.contains(&NodeKind::Literal)); // `1`
+ }
+
+ #[test]
+ fn require_node_id_resolves_a_known_node() {
+ let arena = Bump::new();
+ let program = tsv_ts::parse("let a = 1;", &arena).expect("parse");
+ let bound = bind_file(&program, "let a = 1;", FileId::ROOT);
+ let addr = std::ptr::from_ref(&program.body[0]) as usize;
+ let id = bound.require_node_id(addr, NodeKind::VariableDeclaration);
+ assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration);
+ }
+
+ #[test]
+ #[should_panic(expected = "not covered by the SoA walk")]
+ fn require_node_id_hard_fails_on_a_miss() {
+ // Address 0 is never a real arena node — the strict resolver must abort
+ // rather than return a corrupting `NodeId::FIRST` sentinel.
+ let bound = bind("const x = 1;");
+ let _ = bound.require_node_id(0, NodeKind::Program);
+ }
+
+ #[test]
+ fn method_and_value_resolve_distinctly() {
+ // The compound key disambiguates the one offset-0 pair: a
+ // `MethodDefinition` and its inline `value: FunctionExpression` share an
+ // address (value at struct offset 0), yet each resolves to its own id
+ // through the `(address, NodeKind)` key. Both look-ups hit the same
+ // address; only the kind tells them apart.
+ use tsv_ts::ast::internal::ClassMember;
+ let arena = Bump::new();
+ let src = "class C { m() {} }";
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ let bound = bind_file(&program, src, FileId::ROOT);
+ let Statement::ClassDeclaration(class) = &program.body[0] else {
+ panic!("expected a class declaration");
+ };
+ let ClassMember::MethodDefinition(method) = &class.body.body[0] else {
+ panic!("expected a method definition");
+ };
+ // The two share one address (the collision the compound key resolves).
+ let method_addr = std::ptr::from_ref(method) as usize;
+ let value_addr = std::ptr::from_ref(&method.value) as usize;
+ assert_eq!(method_addr, value_addr);
+ // The method resolves to its own id …
+ let method_id = bound.require_node_id(method_addr, NodeKind::MethodDefinition);
+ assert_eq!(bound.kinds[method_id.index()], NodeKind::MethodDefinition);
+ // … and the value resolves to a distinct id.
+ let value_id = bound.require_node_id(value_addr, NodeKind::FunctionExpression);
+ assert_eq!(bound.kinds[value_id.index()], NodeKind::FunctionExpression);
+ assert_ne!(method_id, value_id);
+ }
+
+ #[test]
+ fn class_type_parameters_are_descended() {
+ // Regression guard (F0 review): a class's own `` was dropped — no
+ // NodeId — so F1's strict `require_node_id` would panic on a class type
+ // parameter. `class C {}` mints Program, ClassDeclaration, Identifier(C),
+ // TSTypeParameterDeclaration, TSTypeParameter, Identifier(T) = 6 nodes, and
+ // `require_node_id` resolves the type-parameter node.
+ let arena = Bump::new();
+ let program = tsv_ts::parse("class C {}", &arena).expect("parse");
+ let bound = bind_file(&program, "class C {}", FileId::ROOT);
+ assert_eq!(bound.node_count, 6);
+ let tp = bound
+ .kinds
+ .iter()
+ .position(|k| *k == NodeKind::TSTypeParameter)
+ .map(NodeId::from_index)
+ .expect("class type parameter is idd");
+ // The `` decl is the type-param's parent; the class owns both.
+ assert!(bound.is_descendant_of(tp, NodeId::from_index(1)));
+ // The class-expression path mirrors the declaration path (kept in sync).
+ assert!(
+ bind("const C = class {};")
+ .kinds
+ .contains(&NodeKind::TSTypeParameter)
+ );
+ // A class type param's constraint + default are reached (both `TSTypeReference`s).
+ assert!(
+ bind("class C {}")
+ .kinds
+ .contains(&NodeKind::TSTypeReference)
+ );
+ }
+
+ /// The sorted family diagnostic codes a source produces — via the full
+ /// program pipeline, so the canonical sort + dedup applies (a conflict emits
+ /// one diagnostic per position after collapsing the repeated prior-decl ones).
+ fn diag_codes(source: &str) -> Vec {
+ let arena = Bump::new();
+ let result = crate::program::check_program(
+ &[crate::program::SourceUnit::new("t.ts", source)],
+ &arena,
+ &crate::options::CheckOptions::default(),
+ );
+ result.diagnostics.iter().map(|d| d.code).collect()
+ }
+
+ #[test]
+ fn cascade_block_scoped_redeclare_is_2451() {
+ assert_eq!(diag_codes("let x; let x;"), vec![2451, 2451]);
+ assert_eq!(diag_codes("const y = 1; const y = 2;"), vec![2451, 2451]);
+ }
+
+ #[test]
+ fn cascade_functions_first_picks_2300_over_2451() {
+ // The function hoists first, so the table symbol is the function (not
+ // block-scoped) -> TS2300 for the whole `x` run.
+ assert_eq!(
+ diag_codes("let x; var x; function x() {}"),
+ vec![2300, 2300, 2300]
+ );
+ // No same-scope function: `let` is first -> TS2451.
+ assert_eq!(
+ diag_codes("function f() { let y; { var y; } }"),
+ vec![2451, 2451]
+ );
+ }
+
+ #[test]
+ fn cascade_class_and_method_conflicts_are_2300() {
+ assert_eq!(diag_codes("class C {} class C {}"), vec![2300, 2300]);
+ // A method vs a same-named property conflicts (Method in PropertyExcludes).
+ assert_eq!(
+ diag_codes("class C { m() {} m: number; }"),
+ vec![2300, 2300]
+ );
+ // Duplicate parameters conflict via ParameterExcludes.
+ assert_eq!(diag_codes("function f(a, a) {}"), vec![2300, 2300]);
+ }
+
+ #[test]
+ fn cascade_enum_merge_is_2567() {
+ // A regular enum and a const enum cannot merge -> the enum-merge message.
+ assert_eq!(diag_codes("enum E {} const enum E {}"), vec![2567, 2567]);
+ // Two regular enums merge cleanly (no diagnostic).
+ assert!(diag_codes("enum F {} enum F {}").is_empty());
+ }
+
+ #[test]
+ fn cascade_multiple_default_exports_is_2528() {
+ assert_eq!(
+ diag_codes("export default 0; export default 1;"),
+ vec![2528, 2528]
+ );
+ }
+
+ #[test]
+ fn uninstantiated_namespace_does_not_conflict_with_var() {
+ // A types-only namespace binds as the inert NamespaceModule, so a same-named
+ // `var` merges without a diagnostic.
+ assert!(diag_codes("namespace N { interface I {} } declare var N: any;").is_empty());
+ // A value-content namespace is a ValueModule and conflicts with a `let`
+ // (TS2300 — the namespace, first in the table, is not block-scoped).
+ assert_eq!(
+ diag_codes("namespace M { const v = 1; } let M;"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn signature_duplicate_params_conflict() {
+ // A method / call / construct signature is its own function scope, so its
+ // duplicate params conflict (TS2300) — the anonymous signature declaration
+ // itself never conflicts.
+ assert_eq!(diag_codes("interface I { foo(x, x); }"), vec![2300, 2300]);
+ assert_eq!(diag_codes("interface I { (x, x); }"), vec![2300, 2300]);
+ assert_eq!(diag_codes("interface I { new (x, x); }"), vec![2300, 2300]);
+ // A generic method signature still conflicts on the params (the type param
+ // binds in the same scope without colliding).
+ assert_eq!(
+ diag_codes("interface I { foo(x: T, x: T); }"),
+ vec![2300, 2300]
+ );
+ // Distinct param names in one signature and a lone param never conflict.
+ assert!(diag_codes("interface I { foo(x, y); bar(z); }").is_empty());
+ }
+
+ #[test]
+ fn type_annotation_type_literal_members_bind() {
+ // A typed binding descends its annotation: a type-literal method signature's
+ // duplicate params conflict.
+ assert_eq!(diag_codes("var a: { foo(x, x); };"), vec![2300, 2300]);
+ // Duplicate *members* of a type literal silent-merge at bind, but the
+ // check pass emits them (a check-time TS2300 per declaration).
+ assert_eq!(
+ diag_codes("var a: { x: number; x: string; };"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn object_literal_duplicate_methods_conflict() {
+ // Two same-named object-literal methods conflict (the method exclude is the
+ // whole Value mask for an object-literal method).
+ assert_eq!(
+ diag_codes("var b = { foo() {}, foo() {} };"),
+ vec![2300, 2300]
+ );
+ // Duplicate plain properties silent-merge (Property is not in PropertyExcludes).
+ assert!(diag_codes("var b = { x: 1, x: 2 };").is_empty());
+ // A getter/setter pair of the same name merges without a diagnostic.
+ assert!(diag_codes("var b = { get x() { return 1; }, set x(v) {} };").is_empty());
+ }
+
+ #[test]
+ fn dotted_namespace_merges_with_explicit_nested() {
+ // The dotted form's intermediate segments route to the enclosing namespace's
+ // exports, so they merge with the explicit-nested form — and their conflicting
+ // members surface (two classes named `P` in the merged inner namespace).
+ assert_eq!(
+ diag_codes(
+ "namespace M.X { export class P {} } \
+ namespace M { export namespace X { export class P {} } }"
+ ),
+ vec![2300, 2300]
+ );
+ // A lone dotted namespace introduces no spurious conflict.
+ assert!(diag_codes("namespace A.B.C { export const x = 1; }").is_empty());
+ }
+
+ #[test]
+ fn private_name_mangling_collides_at_hash() {
+ // A private method vs a same-named private field is a bind-time conflict
+ // (Method in PropertyExcludes); the mangling (class symbol id + name) makes
+ // the two `#x` share a table key, and the squiggle covers the `#`. (Two
+ // private *fields* would be property-vs-property — a check-time TS2300.)
+ let src = "class C { #x() {} #x = 1; }";
+ let bound = bind(src);
+ let mut diags: Vec<(u32, u32)> = bound
+ .diagnostics
+ .iter()
+ .map(|d| (d.code, d.span.start))
+ .collect();
+ diags.sort_unstable();
+ assert_eq!(
+ diags.iter().map(|d| d.0).collect::>(),
+ vec![2300, 2300]
+ );
+ for (_, start) in &diags {
+ assert_eq!(&src[*start as usize..=*start as usize], "#");
+ }
+ }
+
+ #[test]
+ fn param_position_type_literal_method_params_conflict() {
+ // A method signature inside a parameter's type annotation is its own
+ // function scope, so its duplicate params conflict (the param-position
+ // type-annotation hook reaches the type literal).
+ assert_eq!(
+ diag_codes("function f(p: { foo(x, x); }) {}"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn object_literal_getter_getter_conflicts() {
+ // Two same-named object-literal getters conflict (GET_ACCESSOR_EXCLUDES);
+ // the accessor bump keeps the run reporting.
+ assert_eq!(
+ diag_codes("var b = { get x() {}, get x() {} };"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn object_literal_computed_key_method_conflicts() {
+ // A computed string-literal key names an object-literal method, so two
+ // `['foo']()` methods conflict (the object-literal method exclude is Value).
+ assert_eq!(
+ diag_codes("var b = { ['foo']() {}, ['foo']() {} };"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn check_pass_duplicate_type_parameters() {
+ // The check pass emits TS2300 for a duplicate type parameter (the binder
+ // silent-merges same-name type params, so this is check-only). One duplicate
+ // → one diagnostic after sort/dedup.
+ assert_eq!(diag_codes("function f() {}"), vec![2300]);
+ assert_eq!(diag_codes("class C {}"), vec![2300]);
+ assert_eq!(diag_codes("interface I {}"), vec![2300]);
+ // Distinct names never fire.
+ assert!(diag_codes("function g() {}").is_empty());
+ // Declaration-merged interfaces are scoped per-declaration — only the second
+ // (its own `C, C`) fires; the two decls never cross-compare (that would be
+ // TS2428, deliberately not ported).
+ assert_eq!(
+ diag_codes("interface J {} interface J {}"),
+ vec![2300]
+ );
+ }
+
+ #[test]
+ fn check_pass_type_parameters_three_way_dedup() {
+ // `` fires 1 at T₂ + 2 at T₃ raw; the program-wide sort/dedup
+ // collapses the T₃ pair → 2 final diagnostics.
+ assert_eq!(diag_codes("function f() {}"), vec![2300, 2300]);
+ }
+
+ #[test]
+ fn check_pass_non_decimal_numeric_keys_stay_distinct() {
+ // `0x0` / `0xff` (and octal / binary / numeric-separator forms) decode to
+ // NaN upstream; keyed on their verbatim source they stay distinct, so no
+ // false TS2300 (a `NaN`-keyed collision would flag them all).
+ assert!(diag_codes("type T = { 0x0: number; 0xff: string };").is_empty());
+ assert!(diag_codes("type T = { 0o7: number; 1_0: string };").is_empty());
+ // The identical form still collides (both key `"0x1"`).
+ assert_eq!(
+ diag_codes("type T = { 0x1: number; 0x1: string };"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn dotted_namespace_three_deep_merges_with_explicit_nested() {
+ // A 3-deep dotted namespace's intermediate segments route to their
+ // enclosing namespace's exports, so `M.X.Y` merges with the explicit 3-deep
+ // nested form and the inner `P` conflict surfaces.
+ assert_eq!(
+ diag_codes(
+ "namespace M.X.Y { export class P {} } \
+ namespace M { export namespace X { export namespace Y { export class P {} } } }"
+ ),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn export_default_identifier_is_alias_no_2528() {
+ // `export default ` binds as an inert alias, so a following
+ // default declaration does not conflict (matches tsgo; the redeclare is a
+ // check-time TS2323, not a bind-time TS2528).
+ assert!(
+ diag_codes("const foo = 1; export default foo; export default class Foo {}").is_empty()
+ );
+ }
+
+ #[test]
+ fn module_ness_detects_indicators() {
+ assert_eq!(
+ bind("export const x = 1;").facts.module_ness,
+ ModuleNess::Module
+ );
+ assert_eq!(
+ bind("import x from 'y';").facts.module_ness,
+ ModuleNess::Module
+ );
+ assert_eq!(bind("const x = 1;").facts.module_ness, ModuleNess::Script);
+ // `import x = require('y')` counts; `import x = A.B` and `export as
+ // namespace N` do not.
+ assert_eq!(
+ bind("import x = require('y');").facts.module_ness,
+ ModuleNess::Module
+ );
+ assert_eq!(
+ bind("import x = A.B;").facts.module_ness,
+ ModuleNess::Script
+ );
+ assert_eq!(
+ bind("export as namespace N;").facts.module_ness,
+ ModuleNess::Script
+ );
+ // `import.meta` anywhere counts.
+ assert_eq!(
+ bind("const u = import.meta.url;").facts.module_ness,
+ ModuleNess::Module
+ );
+ }
+}
diff --git a/crates/tsv_check/src/binder/sym/declare.rs b/crates/tsv_check/src/binder/sym/declare.rs
new file mode 100644
index 000000000..44289c571
--- /dev/null
+++ b/crates/tsv_check/src/binder/sym/declare.rs
@@ -0,0 +1,372 @@
+//! The declare/conflict cascade — tsgo's `declareSymbolEx` port: declare a
+//! symbol into the right table (locals/members/exports) via the container-kind
+//! routing (`declareSymbolAndAddToSymbolTable`/`declareModuleMember`/
+//! `declareClassMember`/`bindBlockScopedDeclaration`), running the conflict
+//! cascade (TS2300/2451/2567/2528) at each, plus alias declarations
+//! (`declare_alias`, for import/export specifiers and `import =`).
+//
+// tsgo: internal/binder/binder.go declareSymbolEx (the cascade),
+// declareSymbolAndAddToSymbolTable / declareModuleMember / declareClassMember
+// (the routing)
+
+// The routing methods `.expect()`/`.unwrap()` on `Scope::symbol`/`Scope::locals`
+// that the scope *kind* guarantees is `Some` — a class/enum/interface/module
+// scope always carries its container symbol, a locals scope always carries its
+// table. These are structural invariants of the container stack; a violation is a
+// binder bug (contained by the harness's per-test `catch_unwind`), not a
+// recoverable data error, so the panic points are the honest expression.
+#![allow(clippy::expect_used, clippy::unwrap_used)]
+
+use super::super::symbols::{Decl, SymbolFlags, SymbolId, TableId};
+use crate::diag::{Category, Diagnostic};
+use tsv_lang::Span;
+
+use super::{ContainerKind, DeclInput, SymbolBinder};
+
+impl<'a> SymbolBinder<'a> {
+ // --- the cascade ---------------------------------------------------------
+
+ /// tsgo `declareSymbolEx` — declare `decl` into `table`, running the conflict
+ /// cascade, and return the symbol the declaration attached to (a fresh orphan
+ /// on conflict, so the table's original symbol keeps accumulating priors).
+ pub(super) fn declare_symbol(
+ &mut self,
+ table: TableId,
+ parent: Option,
+ decl: DeclInput,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ ) -> SymbolId {
+ let existing = self.tables[table.index()].get(&decl.name).copied();
+ let symbol = match existing {
+ None => {
+ let sid = self.new_symbol(SymbolFlags::NONE, decl.name);
+ self.tables[table.index()].insert(decl.name, sid);
+ sid
+ }
+ Some(sid) => {
+ let flags = self.symbols[sid.index()].flags;
+ if flags.intersects(excludes) {
+ self.report_conflict(sid, &decl, includes);
+ // Accessor bump: mark the (kept) table symbol a full accessor
+ // so a get/non-accessor/set run all conflict.
+ let sflags = self.symbols[sid.index()].flags;
+ if sflags.intersects(SymbolFlags::ACCESSOR)
+ && (sflags.0 & SymbolFlags::ACCESSOR.0)
+ != (includes.0 & SymbolFlags::ACCESSOR.0)
+ {
+ self.symbols[sid.index()]
+ .flags
+ .insert(SymbolFlags::ACCESSOR);
+ }
+ // A fresh orphan (NOT inserted into the table): this
+ // declaration does not merge into the original, so the
+ // original's declaration list — the priors the cascade points
+ // at — stays fixed.
+ self.new_symbol(SymbolFlags::NONE, decl.name)
+ } else {
+ sid
+ }
+ }
+ };
+ self.add_declaration(symbol, &decl, includes);
+ if self.symbols[symbol.index()].parent.is_none() {
+ self.symbols[symbol.index()].parent = parent;
+ }
+ symbol
+ }
+
+ fn add_declaration(&mut self, symbol: SymbolId, decl: &DeclInput, includes: SymbolFlags) {
+ let is_type_decl = is_type_declaration(includes);
+ let s = &mut self.symbols[symbol.index()];
+ s.flags.insert(includes);
+ s.decls.push(Decl {
+ node: decl.node,
+ error_span: decl.error_span,
+ display: decl.display,
+ is_type_decl,
+ });
+ }
+
+ /// Emit the duplicate/conflict diagnostics for `decl` against `existing`.
+ fn report_conflict(&mut self, existing: SymbolId, decl: &DeclInput, includes: SymbolFlags) {
+ let sym_flags = self.symbols[existing.index()].flags;
+ let mut code: u32 = if sym_flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE) {
+ 2451
+ } else {
+ 2300
+ };
+ let mut needs_name = true;
+ if sym_flags.intersects(SymbolFlags::ENUM) || includes.intersects(SymbolFlags::ENUM) {
+ code = 2567;
+ needs_name = false;
+ }
+ let mut multiple_default = false;
+ if !self.symbols[existing.index()].decls.is_empty()
+ && (decl.is_default_export || decl.is_export_assignment_default)
+ {
+ code = 2528;
+ needs_name = false;
+ multiple_default = true;
+ }
+
+ let new_span = decl.error_span;
+ let new_name = if needs_name {
+ Some(self.atoms.resolve(decl.display).to_string())
+ } else {
+ None
+ };
+ let mut new_diag = self.make_diag(new_span, code, new_name.as_deref());
+
+ let priors: Vec = self.symbols[existing.index()].decls.to_vec();
+ for (index, pdecl) in priors.iter().enumerate() {
+ let pname = if needs_name {
+ Some(self.atoms.resolve(pdecl.display).to_string())
+ } else {
+ None
+ };
+ let mut d = self.make_diag(pdecl.error_span, code, pname.as_deref());
+ if multiple_default {
+ let rcode = if index == 0 { 2753 } else { 6204 };
+ let r_new = self.make_related(new_span, rcode);
+ d.related.push(r_new);
+ let r_first = self.make_related(pdecl.error_span, 2752);
+ new_diag.related.push(r_first);
+ }
+ self.diagnostics.push(d);
+ }
+ self.diagnostics.push(new_diag);
+ }
+
+ pub(super) fn make_diag(&self, span: Span, code: u32, name: Option<&str>) -> Diagnostic {
+ let message = message_for(code, name);
+ let args = name.map(|n| vec![n.to_string()]).unwrap_or_default();
+ Diagnostic {
+ file: Some(self.file),
+ span,
+ code,
+ category: Category::Error,
+ message,
+ args,
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+ }
+
+ fn make_related(&self, span: Span, code: u32) -> Diagnostic {
+ Diagnostic {
+ file: Some(self.file),
+ span,
+ code,
+ // The two `export default` related codes are `Error` category in tsgo's
+ // diagnosticMessages; `and here.` (6204) and the `Did you mean` hint
+ // (1369) are `Message`. (Category is unobservable in code+span grading;
+ // this stays faithful to the oracle.)
+ category: match code {
+ 2752 | 2753 => Category::Error,
+ _ => Category::Message,
+ },
+ message: message_for(code, None),
+ args: Vec::new(),
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+ }
+
+ // --- routing -------------------------------------------------------------
+
+ /// tsgo `declareSymbolAndAddToSymbolTable` — route by the current container.
+ pub(super) fn declare_in_container(
+ &mut self,
+ decl: DeclInput,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ ) -> SymbolId {
+ match self.container.kind {
+ ContainerKind::Module => self.declare_module_member(decl, includes, excludes),
+ ContainerKind::SourceFile => self.declare_source_file_member(decl, includes, excludes),
+ ContainerKind::Class => self.declare_class_member(decl, includes, excludes, false),
+ ContainerKind::Enum => {
+ let sym = self.container.symbol.expect("enum has a symbol");
+ let table = self.exports_of(sym);
+ self.declare_symbol(table, Some(sym), decl, includes, excludes)
+ }
+ ContainerKind::Interface => {
+ let sym = self
+ .container
+ .symbol
+ .expect("members container has a symbol");
+ let table = self.members_of(sym);
+ self.declare_symbol(table, Some(sym), decl, includes, excludes)
+ }
+ ContainerKind::Locals => {
+ let table = self.container.locals.expect("locals container has a table");
+ self.declare_symbol(table, None, decl, includes, excludes)
+ }
+ }
+ }
+
+ /// tsgo `bindBlockScopedDeclaration` — route by the current block scope.
+ pub(super) fn declare_block_scoped(
+ &mut self,
+ decl: DeclInput,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ ) -> SymbolId {
+ match self.block_scope.kind {
+ ContainerKind::Module => self.declare_module_member(decl, includes, excludes),
+ ContainerKind::SourceFile => {
+ if self.block_scope.is_external_module {
+ self.declare_module_member(decl, includes, excludes)
+ } else {
+ let table = self.block_scope.locals.expect("source file has locals");
+ self.declare_symbol(table, None, decl, includes, excludes)
+ }
+ }
+ _ => {
+ let table = self.block_scope.locals.expect("block scope has locals");
+ self.declare_symbol(table, None, decl, includes, excludes)
+ }
+ }
+ }
+
+ /// tsgo `declareSourceFileMember`.
+ fn declare_source_file_member(
+ &mut self,
+ decl: DeclInput,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ ) -> SymbolId {
+ if self.container.is_external_module {
+ self.declare_module_member(decl, includes, excludes)
+ } else {
+ let table = self.container.locals.expect("source file has locals");
+ self.declare_symbol(table, None, decl, includes, excludes)
+ }
+ }
+
+ /// tsgo `declareModuleMember` — the exported-member routing (dual split
+ /// collapsed to the export symbol; see the module doc). Aliases route through
+ /// [`Self::declare_alias`] instead, so this handles only value/type members.
+ fn declare_module_member(
+ &mut self,
+ mut decl: DeclInput,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ ) -> SymbolId {
+ let to_exports =
+ decl.exported || decl.is_default_export || self.container.is_export_context;
+ if to_exports {
+ let sym = self
+ .container
+ .symbol
+ .expect("module member exports needs a container symbol");
+ if decl.is_default_export {
+ // A default export forces the `"default"` table key.
+ decl.name = self.atoms.default_export();
+ }
+ let table = self.exports_of(sym);
+ self.declare_symbol(table, Some(sym), decl, includes, excludes)
+ } else {
+ let table = self.container.locals.expect("module member locals");
+ self.declare_symbol(table, None, decl, includes, excludes)
+ }
+ }
+
+ /// tsgo `declareClassMember` — static members to `exports`, else `members`.
+ pub(super) fn declare_class_member(
+ &mut self,
+ decl: DeclInput,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ is_static: bool,
+ ) -> SymbolId {
+ let sym = self.container.symbol.expect("class has a symbol");
+ let table = if is_static {
+ self.exports_of(sym)
+ } else {
+ self.members_of(sym)
+ };
+ self.declare_symbol(table, Some(sym), decl, includes, excludes)
+ }
+
+ // --- imports / exports (aliases) -----------------------------------------
+
+ /// Declare an alias symbol (import/export specifier, `import =`). Exported
+ /// aliases route to `exports`, others to `locals`.
+ pub(super) fn declare_alias(&mut self, decl: DeclInput, to_exports: bool) {
+ match self.container.kind {
+ ContainerKind::Module | ContainerKind::SourceFile
+ if self.container.symbol.is_some() =>
+ {
+ if to_exports {
+ let sym = self.container.symbol.unwrap();
+ let mut d = decl;
+ if d.is_default_export {
+ d.name = self.atoms.default_export();
+ }
+ let table = self.exports_of(sym);
+ self.declare_symbol(
+ table,
+ Some(sym),
+ d,
+ SymbolFlags::ALIAS,
+ SymbolFlags::ALIAS_EXCLUDES,
+ );
+ } else {
+ let table = self.container.locals.expect("locals for alias");
+ self.declare_symbol(
+ table,
+ None,
+ decl,
+ SymbolFlags::ALIAS,
+ SymbolFlags::ALIAS_EXCLUDES,
+ );
+ }
+ }
+ _ => {
+ if let Some(table) = self.container.locals {
+ self.declare_symbol(
+ table,
+ None,
+ decl,
+ SymbolFlags::ALIAS,
+ SymbolFlags::ALIAS_EXCLUDES,
+ );
+ }
+ }
+ }
+ }
+}
+
+/// Whether a declaration's `includes` flags mark it a *type* declaration (tsgo
+/// `IsTypeDeclaration`: class / interface / enum / type-alias / type-parameter) —
+/// each of those flag families corresponds one-to-one to a type-declaration node
+/// kind. The merge's `undefined`-redeclaration check (TS2397) skips these.
+fn is_type_declaration(includes: SymbolFlags) -> bool {
+ includes.intersects(SymbolFlags(
+ SymbolFlags::CLASS.0
+ | SymbolFlags::INTERFACE.0
+ | SymbolFlags::ENUM.0
+ | SymbolFlags::TYPE_ALIAS.0
+ | SymbolFlags::TYPE_PARAMETER.0,
+ ))
+}
+
+/// The `.errors.txt` message text for a family / related-info code.
+fn message_for(code: u32, name: Option<&str>) -> String {
+ match code {
+ 2300 => format!("Duplicate identifier '{}'.", name.unwrap_or("")),
+ 2451 => format!(
+ "Cannot redeclare block-scoped variable '{}'.",
+ name.unwrap_or("")
+ ),
+ 2567 => "Enum declarations can only merge with namespace or other enum declarations."
+ .to_string(),
+ 2528 => "A module cannot have multiple default exports.".to_string(),
+ 2752 => "The first export default is here.".to_string(),
+ 2753 => "Another export default is here.".to_string(),
+ 6204 => "and here.".to_string(),
+ _ => String::new(),
+ }
+}
diff --git a/crates/tsv_check/src/binder/sym/expression.rs b/crates/tsv_check/src/binder/sym/expression.rs
new file mode 100644
index 000000000..1f9eaae0a
--- /dev/null
+++ b/crates/tsv_check/src/binder/sym/expression.rs
@@ -0,0 +1,169 @@
+//! The expression-shaped bind-descent — `visit_expression` (function/arrow/
+//! class-expression scopes, the pattern-aware nested descent) and
+//! `bind_object_expression` (the object-literal member table). Contributes its
+//! own `impl SymbolBinder` block; the struct and the scope helpers live in the
+//! parent module. Purely a locality split — no behavior distinction.
+
+use super::super::symbols::SymbolFlags;
+use super::{DeclInput, SymbolBinder};
+use crate::ids::NodeId;
+use tsv_ts::ast::internal::{Expression, ObjectExpression, ObjectProperty, PropertyKind};
+
+impl<'a> SymbolBinder<'a> {
+ // --- expressions (nested scopes) -----------------------------------------
+
+ pub(super) fn visit_expression(&mut self, expr: &Expression<'a>) {
+ use Expression as E;
+ match expr {
+ E::FunctionExpression(f) => {
+ self.with_function_scope(f.type_parameters.as_ref(), |b| {
+ b.bind_params(f.params);
+ b.bind_statement_list(f.body.body, true);
+ });
+ }
+ E::ArrowFunctionExpression(a) => {
+ self.with_function_scope(a.type_parameters.as_ref(), |b| {
+ b.bind_params(a.params);
+ match &a.body {
+ tsv_ts::ast::internal::ArrowFunctionBody::Expression(e) => {
+ b.visit_expression(e);
+ }
+ tsv_ts::ast::internal::ArrowFunctionBody::BlockStatement(block) => {
+ b.bind_statement_list(block.body, true);
+ }
+ }
+ });
+ }
+ E::ClassExpression(c) => {
+ let sym = c.id.as_ref().map(|_| {
+ let name = self.atoms.intern("__class");
+ self.new_symbol(SymbolFlags::CLASS, name)
+ });
+ self.bind_class_body(&c.body, sym, c.type_parameters.as_ref());
+ }
+ E::ParenthesizedExpression(p) => self.visit_expression(p.expression),
+ E::UnaryExpression(u) => self.visit_expression(u.argument),
+ E::UpdateExpression(u) => self.visit_expression(u.argument),
+ E::AwaitExpression(a) => self.visit_expression(a.argument),
+ E::YieldExpression(y) => {
+ if let Some(a) = y.argument {
+ self.visit_expression(a);
+ }
+ }
+ E::BinaryExpression(b) => {
+ self.visit_expression(b.left);
+ self.visit_expression(b.right);
+ }
+ E::AssignmentExpression(a) => {
+ self.visit_expression(a.left);
+ self.visit_expression(a.right);
+ }
+ E::ConditionalExpression(c) => {
+ self.visit_expression(c.test);
+ self.visit_expression(c.consequent);
+ self.visit_expression(c.alternate);
+ }
+ E::SequenceExpression(s) => {
+ for e in s.expressions {
+ self.visit_expression(e);
+ }
+ }
+ E::CallExpression(c) => {
+ self.visit_expression(c.callee);
+ for a in c.arguments {
+ self.visit_expression(a);
+ }
+ }
+ E::NewExpression(n) => {
+ self.visit_expression(n.callee);
+ for a in n.arguments {
+ self.visit_expression(a);
+ }
+ }
+ E::MemberExpression(m) => {
+ self.visit_expression(m.object);
+ self.visit_expression(m.property);
+ }
+ E::TSNonNullExpression(t) => self.visit_expression(t.expression),
+ E::TSAsExpression(t) => self.visit_expression(t.expression),
+ E::TSSatisfiesExpression(t) => self.visit_expression(t.expression),
+ E::TSInstantiationExpression(t) => self.visit_expression(t.expression),
+ E::SpreadElement(s) => self.visit_expression(s.argument),
+ E::ArrayExpression(a) => {
+ for e in a.elements.iter().flatten() {
+ self.visit_expression(e);
+ }
+ }
+ E::ObjectExpression(o) => self.bind_object_expression(o),
+ E::TemplateLiteral(t) => {
+ for e in t.expressions {
+ self.visit_expression(e);
+ }
+ }
+ E::TaggedTemplateExpression(t) => {
+ self.visit_expression(t.tag);
+ for e in t.quasi.expressions {
+ self.visit_expression(e);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ // --- object literals -----------------------------------------------------
+
+ /// Bind an object literal's members into a fresh member table so duplicate
+ /// members conflict. tsgo binds the literal an anonymous `ObjectLiteral`
+ /// container; tsv builds the member table locally and swaps no scope — an
+ /// object literal is not a `HasLocals` container, and nothing consumes the
+ /// literal's symbol, so nested function/arrow *values* still open their own
+ /// scope through the per-value [`Self::visit_expression`] recursion.
+ ///
+ /// The load-bearing choice is the object-literal-method exclude: it is the
+ /// whole `Value` mask (tsgo `IsObjectLiteralMethod ? SymbolFlagsValue :
+ /// SymbolFlagsMethodExcludes`), and `Value ⊇ Method`, so two same-named
+ /// object-literal methods conflict — while class/interface methods
+ /// (`METHOD_EXCLUDES`) keep their silent-merge untouched.
+ ///
+ /// tsgo: internal/binder/binder.go bindPropertyOrMethodOrAccessor
+ /// (KindObjectLiteralExpression member cases)
+ fn bind_object_expression(&mut self, obj: &ObjectExpression<'a>) {
+ let table = self.new_table();
+ for prop in obj.properties {
+ match prop {
+ ObjectProperty::Property(pr) => {
+ if let Some(key) = self.resolve_member_key(&pr.key, pr.computed, None) {
+ let (inc, exc) = match pr.kind {
+ PropertyKind::Get => (
+ SymbolFlags::GET_ACCESSOR,
+ SymbolFlags::GET_ACCESSOR_EXCLUDES,
+ ),
+ PropertyKind::Set => (
+ SymbolFlags::SET_ACCESSOR,
+ SymbolFlags::SET_ACCESSOR_EXCLUDES,
+ ),
+ PropertyKind::Init if pr.method => {
+ (SymbolFlags::METHOD, SymbolFlags::VALUE)
+ }
+ PropertyKind::Init => {
+ (SymbolFlags::PROPERTY, SymbolFlags::PROPERTY_EXCLUDES)
+ }
+ };
+ let d = DeclInput {
+ name: key.key,
+ display: key.display,
+ error_span: key.span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_symbol(table, None, d, inc, exc);
+ }
+ self.visit_expression(&pr.value);
+ }
+ ObjectProperty::SpreadElement(s) => self.visit_expression(s.argument),
+ }
+ }
+ }
+}
diff --git a/crates/tsv_check/src/binder/sym/members.rs b/crates/tsv_check/src/binder/sym/members.rs
new file mode 100644
index 000000000..14c062b7c
--- /dev/null
+++ b/crates/tsv_check/src/binder/sym/members.rs
@@ -0,0 +1,468 @@
+//! The class/interface/type-literal/enum member and type descents —
+//! `bind_class_body`/`bind_class_member`/`bind_constructor_params`, the
+//! interface/type-literal member bind (`bind_interface_body`/
+//! `bind_type_element`/`declare_type_member`), enum members, and type
+//! parameters. Contributes its own `impl SymbolBinder` block; the struct and
+//! the scope helpers live in the parent module. Purely a locality split — no
+//! behavior distinction.
+
+use super::super::symbols::{SymbolFlags, SymbolId};
+use super::{ContainerKind, DeclInput, DeclMods, Scope, SymbolBinder};
+use crate::ids::NodeId;
+use tsv_ts::ast::internal::{
+ ClassBody, ClassMember, Expression, MethodKind, Statement, TSEnumMemberId, TSInterfaceBody,
+ TSType, TSTypeAnnotation, TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration,
+};
+
+impl<'a> SymbolBinder<'a> {
+ // --- classes -------------------------------------------------------------
+
+ // tsgo: internal/binder/binder.go bindClassLikeDeclaration (the static-`prototype` clash, :971)
+ pub(super) fn bind_class_body(
+ &mut self,
+ body: &ClassBody<'a>,
+ class_symbol: Option,
+ type_params: Option<&TSTypeParameterDeclaration<'a>>,
+ ) {
+ let Some(class_symbol) = class_symbol else {
+ // Anonymous / skipped class: still descend member values for nested
+ // bindings, but no member tables to conflict in.
+ self.descend_class_values(body);
+ return;
+ };
+ // The static-`prototype` clash (checker.go:971): a pre-seeded export.
+ let proto = self.atoms.intern("prototype");
+ let exports = self.exports_of(class_symbol);
+ if let Some(existing) = self.tables[exports.index()].get(&proto).copied()
+ && let Some(pdecl) = self.symbols[existing.index()].decls.first().copied()
+ {
+ let name = self.atoms.resolve(pdecl.display).to_string();
+ let diag = self.make_diag(pdecl.error_span, 2300, Some(&name));
+ self.diagnostics.push(diag);
+ }
+ let proto_sym = self.new_symbol(SymbolFlags::PROPERTY.union(SymbolFlags::PROTOTYPE), proto);
+ self.symbols[proto_sym.index()].parent = Some(class_symbol);
+ self.tables[exports.index()].insert(proto, proto_sym);
+
+ let saved = (self.container, self.block_scope);
+ let scope = Scope {
+ kind: ContainerKind::Class,
+ symbol: Some(class_symbol),
+ locals: None,
+ is_external_module: false,
+ is_export_context: false,
+ };
+ self.container = scope;
+ self.block_scope = scope;
+ self.bind_type_params(type_params);
+ for member in body.body {
+ self.bind_class_member(member, class_symbol);
+ }
+ self.container = saved.0;
+ self.block_scope = saved.1;
+ }
+
+ fn bind_class_member(&mut self, member: &ClassMember<'a>, class_symbol: SymbolId) {
+ match member {
+ ClassMember::MethodDefinition(m) => {
+ let is_static = m.is_static;
+ let (inc, exc) = match m.kind {
+ MethodKind::Constructor => (SymbolFlags::CONSTRUCTOR, SymbolFlags::NONE),
+ MethodKind::Get => (
+ SymbolFlags::GET_ACCESSOR,
+ SymbolFlags::GET_ACCESSOR_EXCLUDES,
+ ),
+ MethodKind::Set => (
+ SymbolFlags::SET_ACCESSOR,
+ SymbolFlags::SET_ACCESSOR_EXCLUDES,
+ ),
+ MethodKind::Method => {
+ let opt = if m.optional {
+ SymbolFlags::OPTIONAL
+ } else {
+ SymbolFlags::NONE
+ };
+ (SymbolFlags::METHOD.union(opt), SymbolFlags::METHOD_EXCLUDES)
+ }
+ };
+ if let MethodKind::Constructor = m.kind {
+ let d = DeclInput {
+ name: self.atoms.intern("__constructor"),
+ display: self.atoms.intern("__constructor"),
+ error_span: m.span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_class_member(d, inc, exc, is_static);
+ // Bind constructor params (incl. parameter properties -> class members).
+ self.with_function_scope(m.value.type_parameters.as_ref(), |b| {
+ b.bind_constructor_params(m.value.params, class_symbol);
+ b.bind_statement_list(method_body(&m.value), true);
+ });
+ } else if let Some(key) =
+ self.resolve_member_key(&m.key, m.computed, Some(class_symbol))
+ {
+ let d = DeclInput {
+ name: key.key,
+ display: key.display,
+ error_span: key.span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_class_member(d, inc, exc, is_static);
+ self.with_function_scope(m.value.type_parameters.as_ref(), |b| {
+ b.bind_params(m.value.params);
+ b.bind_statement_list(method_body(&m.value), true);
+ });
+ } else {
+ // Dynamic computed key: anonymous member, no conflict; still
+ // descend the value for nested bindings.
+ self.with_function_scope(m.value.type_parameters.as_ref(), |b| {
+ b.bind_params(m.value.params);
+ b.bind_statement_list(method_body(&m.value), true);
+ });
+ }
+ }
+ ClassMember::PropertyDefinition(p) => {
+ let (inc, exc) = if p.accessor {
+ (SymbolFlags::ACCESSOR, SymbolFlags::ACCESSOR_EXCLUDES)
+ } else {
+ let opt = if p.modifier == tsv_ts::ast::internal::PropertyModifier::Optional {
+ SymbolFlags::OPTIONAL
+ } else {
+ SymbolFlags::NONE
+ };
+ (
+ SymbolFlags::PROPERTY.union(opt),
+ SymbolFlags::PROPERTY_EXCLUDES,
+ )
+ };
+ if let Some(key) = self.resolve_member_key(&p.key, p.computed, Some(class_symbol)) {
+ let d = DeclInput {
+ name: key.key,
+ display: key.display,
+ error_span: key.span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_class_member(d, inc, exc, p.is_static);
+ }
+ if let Some(v) = &p.value {
+ self.visit_expression(v);
+ }
+ }
+ ClassMember::StaticBlock(s) => {
+ self.with_block_scope(|b| b.bind_statement_list(s.body, true));
+ }
+ ClassMember::IndexSignature(_) => {}
+ }
+ }
+
+ fn bind_constructor_params(&mut self, params: &[Expression<'a>], class_symbol: SymbolId) {
+ for param in params {
+ match param {
+ Expression::TSParameterProperty(pp) => {
+ // Bind as a parameter (in the constructor scope)...
+ self.bind_param(pp.parameter);
+ // ...and as a class instance member (tsgo bindParameter).
+ if let Expression::Identifier(id) = ident_of_param(pp.parameter) {
+ let opt = if id.optional {
+ SymbolFlags::OPTIONAL
+ } else {
+ SymbolFlags::NONE
+ };
+ let d = self.decl_from_ident(id, pp.span, DeclMods::default());
+ let table = self.members_of(class_symbol);
+ self.declare_symbol(
+ table,
+ Some(class_symbol),
+ d,
+ SymbolFlags::PROPERTY.union(opt),
+ SymbolFlags::PROPERTY_EXCLUDES,
+ );
+ }
+ }
+ _ => self.bind_param(param),
+ }
+ }
+ }
+
+ fn descend_class_values(&mut self, body: &ClassBody<'a>) {
+ for member in body.body {
+ match member {
+ ClassMember::MethodDefinition(m) => {
+ self.with_function_scope(m.value.type_parameters.as_ref(), |b| {
+ b.bind_params(m.value.params);
+ b.bind_statement_list(method_body(&m.value), true);
+ });
+ }
+ ClassMember::PropertyDefinition(p) => {
+ if let Some(v) = &p.value {
+ self.visit_expression(v);
+ }
+ }
+ ClassMember::StaticBlock(s) => {
+ self.with_block_scope(|b| b.bind_statement_list(s.body, true));
+ }
+ ClassMember::IndexSignature(_) => {}
+ }
+ }
+ }
+
+ // --- interfaces / enums / modules ---------------------------------------
+
+ pub(super) fn bind_interface_body(
+ &mut self,
+ body: &TSInterfaceBody<'a>,
+ interface_symbol: SymbolId,
+ type_params: Option<&TSTypeParameterDeclaration<'a>>,
+ ) {
+ let saved = (self.container, self.block_scope);
+ let scope = Scope {
+ kind: ContainerKind::Interface,
+ symbol: Some(interface_symbol),
+ locals: None,
+ is_external_module: false,
+ is_export_context: false,
+ };
+ self.container = scope;
+ self.block_scope = scope;
+ self.bind_type_params(type_params);
+ for member in body.body {
+ self.bind_type_element(member);
+ }
+ self.container = saved.0;
+ self.block_scope = saved.1;
+ }
+
+ pub(super) fn bind_interface_body_symbol_less(
+ &self,
+ _body: &TSInterfaceBody<'a>,
+ _type_params: Option<&TSTypeParameterDeclaration<'a>>,
+ ) {
+ // `export default interface` with no container symbol: nothing to bind.
+ }
+
+ // --- type annotations ----------------------------------------------------
+
+ /// Descend a binding's type annotation.
+ pub(super) fn bind_type_annotation(&mut self, ann: &TSTypeAnnotation<'a>) {
+ self.bind_type(ann.type_annotation);
+ }
+
+ /// Bind the only type shape whose members reach the family cascade — a type
+ /// literal. Every other variant is a deliberate no-op: a narrower-than-tsgo
+ /// traversal can only leave things missing, never fabricate an extra.
+ //
+ // TODO: this descent is both shallow (direct `TypeLiteral` only) and reached
+ // from only a few sites — it never runs on a type-alias RHS, heritage type
+ // arguments, a nested class expression, or a union/array-wrapped nested literal,
+ // so a method-vs-property conflict in a type literal there is missed at bind
+ // (miss-only; extra=0 holds; unexercised by the corpus). The coherent fix is one
+ // general bind-side type descent mirroring the check pass's `CheckWalk::visit_type`,
+ // wired into those sites together — not patched per-position.
+ fn bind_type(&mut self, ty: &TSType<'a>) {
+ if let TSType::TypeLiteral(tl) = ty {
+ self.bind_type_literal_body(tl);
+ }
+ }
+
+ /// Bind a type literal's members under an anonymous `TypeLiteral` symbol —
+ /// mirrors [`Self::bind_interface_body`]'s member scope, so a method
+ /// signature's duplicate params conflict and its duplicate members
+ /// silent-merge (the property/member family is check-time, out of this bind).
+ ///
+ /// tsgo: internal/binder/binder.go bindAnonymousDeclaration
+ /// (SymbolFlagsTypeLiteral, InternalSymbolNameType)
+ fn bind_type_literal_body(&mut self, tl: &TSTypeLiteral<'a>) {
+ let name = self.atoms.intern("__type");
+ let sym = self.new_symbol(SymbolFlags::TYPE_LITERAL, name);
+ let saved = (self.container, self.block_scope);
+ let scope = Scope {
+ kind: ContainerKind::Interface,
+ symbol: Some(sym),
+ locals: None,
+ is_external_module: false,
+ is_export_context: false,
+ };
+ self.container = scope;
+ self.block_scope = scope;
+ for member in tl.members {
+ self.bind_type_element(member);
+ }
+ self.container = saved.0;
+ self.block_scope = saved.1;
+ }
+
+ fn bind_type_element(&mut self, element: &TSTypeElement<'a>) {
+ match element {
+ TSTypeElement::PropertySignature(p) => {
+ self.declare_type_member(
+ &p.key,
+ p.computed,
+ SymbolFlags::PROPERTY,
+ SymbolFlags::PROPERTY_EXCLUDES,
+ );
+ // Descend the member's own type — a nested type literal's members bind
+ // (its method-vs-property conflict is bind-time, so it is missed unless
+ // this recurses). tsgo binds nested type-literal members; a
+ // property/property nested dup is caught separately by the check pass at
+ // any depth, so this closes only the bind-time family gap.
+ if let Some(ann) = &p.type_annotation {
+ self.bind_type_annotation(ann);
+ }
+ }
+ TSTypeElement::MethodSignature(m) => {
+ self.declare_type_member(
+ &m.key,
+ m.computed,
+ SymbolFlags::METHOD,
+ SymbolFlags::METHOD_EXCLUDES,
+ );
+ // A method signature is itself a `HasLocals` function-like container
+ // (tsgo `GetContainerFlags` KindMethodSignature), so its parameters
+ // bind into a fresh function scope — duplicate params within one
+ // signature conflict (TS2300) independently of the enclosing member
+ // table.
+ self.with_function_scope(m.type_parameters.as_ref(), |b| b.bind_params(m.params));
+ // The return type descends for the same nested-type-literal reason as a
+ // property signature (param type literals already descend via
+ // `bind_binding`).
+ if let Some(ann) = &m.return_type {
+ self.bind_type_annotation(ann);
+ }
+ }
+ // Call/construct signatures are anonymous in the member table: tsgo binds
+ // them `SymbolFlagsSignature` with no excludes, so they never conflict —
+ // tsv skips that inert declaration and binds only their parameters, into
+ // their own function scope. Index signatures have a single parameter that
+ // cannot self-conflict, so nothing binds.
+ // tsgo: internal/binder/binder.go GetContainerFlags (Kind{Call,Construct}Signature)
+ TSTypeElement::CallSignature(c) => {
+ self.with_function_scope(c.type_parameters.as_ref(), |b| b.bind_params(c.params));
+ if let Some(ann) = &c.return_type {
+ self.bind_type_annotation(ann);
+ }
+ }
+ TSTypeElement::ConstructSignature(c) => {
+ self.with_function_scope(c.type_parameters.as_ref(), |b| b.bind_params(c.params));
+ if let Some(ann) = &c.return_type {
+ self.bind_type_annotation(ann);
+ }
+ }
+ TSTypeElement::IndexSignature(_) => {}
+ }
+ }
+
+ /// Declare a type-literal / interface member (property or method signature)
+ /// keyed by its name into the current member container.
+ fn declare_type_member(
+ &mut self,
+ key_expr: &Expression<'a>,
+ computed: bool,
+ inc: SymbolFlags,
+ exc: SymbolFlags,
+ ) {
+ if let Some(key) = self.resolve_member_key(key_expr, computed, None) {
+ let d = DeclInput {
+ name: key.key,
+ display: key.display,
+ error_span: key.span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_in_container(d, inc, exc);
+ }
+ }
+
+ pub(super) fn bind_enum_members(
+ &mut self,
+ members: &[tsv_ts::ast::internal::TSEnumMember<'a>],
+ enum_symbol: SymbolId,
+ ) {
+ let saved = (self.container, self.block_scope);
+ let scope = Scope {
+ kind: ContainerKind::Enum,
+ symbol: Some(enum_symbol),
+ locals: None,
+ is_external_module: false,
+ is_export_context: false,
+ };
+ self.container = scope;
+ self.block_scope = scope;
+ for member in members {
+ let (key, span) = match &member.id {
+ TSEnumMemberId::Identifier(id) => (self.ident_atom(id), id.name_span()),
+ TSEnumMemberId::String(lit) => (self.string_atom(lit), lit.span),
+ };
+ let d = DeclInput {
+ name: key,
+ display: key,
+ error_span: span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_in_container(
+ d,
+ SymbolFlags::ENUM_MEMBER,
+ SymbolFlags::ENUM_MEMBER_EXCLUDES,
+ );
+ if let Some(init) = &member.initializer {
+ self.visit_expression(init);
+ }
+ }
+ self.container = saved.0;
+ self.block_scope = saved.1;
+ }
+
+ // --- type parameters -----------------------------------------------------
+
+ pub(super) fn bind_type_params(
+ &mut self,
+ type_params: Option<&TSTypeParameterDeclaration<'a>>,
+ ) {
+ if let Some(tp) = type_params {
+ for p in tp.params {
+ let d = self.decl_from_ident(&p.name, p.span, DeclMods::default());
+ self.declare_in_container(
+ d,
+ SymbolFlags::TYPE_PARAMETER,
+ SymbolFlags::TYPE_PARAMETER_EXCLUDES,
+ );
+ }
+ }
+ }
+
+ pub(super) fn bind_type_params_in_new_locals(
+ &mut self,
+ type_params: Option<&TSTypeParameterDeclaration<'a>>,
+ ) {
+ if type_params.is_none() {
+ return;
+ }
+ self.with_function_scope(type_params, |_| {});
+ }
+}
+
+/// The binding identifier of a parameter, unwrapping a default (`AssignmentPattern`).
+fn ident_of_param<'b, 'a>(param: &'b Expression<'a>) -> &'b Expression<'a> {
+ match param {
+ Expression::AssignmentPattern(a) => a.left,
+ other => other,
+ }
+}
+
+/// A method's body statements (a `FunctionExpression`'s block body).
+fn method_body<'a>(f: &tsv_ts::ast::internal::FunctionExpression<'a>) -> &'a [Statement<'a>] {
+ f.body.body
+}
diff --git a/crates/tsv_check/src/binder/sym/mod.rs b/crates/tsv_check/src/binder/sym/mod.rs
new file mode 100644
index 000000000..47f042db7
--- /dev/null
+++ b/crates/tsv_check/src/binder/sym/mod.rs
@@ -0,0 +1,543 @@
+//! The symbol bind — tsgo's binder ported for the duplicate/conflict family.
+//!
+//! A container-threaded walk that declares a symbol for every binding-introducing
+//! node into the right table (locals / members / exports), running the
+//! `declareSymbolEx` conflict cascade at each — the source of TS2300 (duplicate
+//! identifier), TS2451 (block-scoped redeclare), TS2567 (enum-merge), and TS2528
+//! (multiple default exports). Statement lists bind **functions-first**
+//! (`bindEachStatementFunctionsFirst`), so a hoisted function's symbol is the
+//! table's first entry — the reason `let x; var x; function x(){}` reports TS2300
+//! (function is first) where `let x; { var x; }` reports TS2451 (the `let` is
+//! first).
+//!
+//! Deliberate simplifications from the full binder, each sound for the family:
+//! - the exported-member **dual local+export split** is collapsed to a single
+//! export symbol. tsgo gives an exported module member two symbols
+//! (`declareModuleMember`, binder.go:387-414): an export symbol with the full
+//! flags, and a **local** symbol declared into the container's `locals` with
+//! only `ExportValue` as its *includes* but the **full `symbolExcludes`** mask
+//! (binder.go:409-411). That local half exists **precisely to conflict** — when
+//! an exported member follows a same-name **non-exported** local, the export's
+//! local half (full excludes) collides with the prior plain local in the
+//! `locals` table and issues a duplicate-identifier error. Collapsing to
+//! export-only drops that one collision, but it is sound for the **P1 family**
+//! because the local↔export mixing it would catch surfaces instead as the
+//! check-time **TS2395** ("individual declarations … must be all exported or all
+//! local"), which is out of the bind/merge family; and the functions-first pass
+//! defuses the common function-overload cases before they reach the locals
+//! table. It is sound for the **S4 merge** for a separate reason: the global
+//! merge folds a *script's* `file.Locals` (no dual split — scripts declare
+//! straight into locals with full flags) and `declare global` / augmentation
+//! *exports* (the export halves, full flags), and **never** an external module's
+//! locals (they don't reach global scope), so the merge never reads a
+//! dual-split local half at all.
+//! - module instantiation state (`getModuleInstanceState`) is approximated:
+//! specifier-only named exports are treated as non-instantiated rather than
+//! resolving each alias target, and const-enum-only propagation is folded into
+//! the instantiated verdict (a const enum makes a namespace `ValueModule`).
+//! - the JS-only `declareSymbolEx` branches (`isReplaceableByMethod`
+//! constructor-vs-prototype discard, and the assignment-merge escape that lets
+//! `SymbolFlagsAssignment` declarations coexist with variables) are deliberately
+//! unported: the tsc conformance corpus this grades is TS-only, so those
+//! JS-expando paths are unreachable. Revisit if a `.js`-flavored suite enters scope.
+//!
+//! The same-table cascade lands here; the **cross-declaration-space merge** (a
+//! script's `file.Locals` folded into global scope, `declare global` and
+//! `declare module "X"` augmentations) runs in [`crate::merge`] over the
+//! [`FileMerge`] product this bind returns.
+//!
+//! Split for locality across four files: this module (`mod.rs`) keeps the
+//! `SymbolBinder` struct, its lifecycle (`new`/`bind_program`/`finish`), the
+//! table/symbol/atom primitives every descendant shares, the member-key
+//! resolver, the functions-first statement-list driver (`bind_statement_list`),
+//! and the scope helpers (`with_function_scope`/`with_block_scope`) every
+//! descendant calls into; `statement.rs` holds the statement-shaped
+//! bind-descent (`visit_statement` and every `bind_*_statement`, the
+//! param/binding helpers, and the module/import/export-specifier binds);
+//! `expression.rs` holds `visit_expression` and `bind_object_expression`;
+//! `members.rs` holds the class/interface/type-literal/enum member and type
+//! descents; `declare.rs` holds the `declareSymbolEx` cascade and the container
+//! routing. No behavior distinction between the four.
+
+mod declare;
+mod expression;
+mod members;
+mod statement;
+
+use super::atoms::{Atom, Atoms};
+use super::symbols::{Symbol, SymbolFlags, SymbolId, TableId};
+use super::{FileFacts, NodeKind, addr_of};
+use crate::diag::Diagnostic;
+use crate::ids::{FileId, NodeId};
+use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug};
+use tsv_lang::{FxHashMap, Span};
+use tsv_ts::ast::Program;
+use tsv_ts::ast::internal::{
+ ExportDefaultValue, Expression, Identifier, Literal, LiteralValue, ModuleExportName, Statement,
+ TSTypeParameterDeclaration,
+};
+
+/// The container kinds that route member declarations (a subset of tsgo's node
+/// kinds, enough to dispatch `declareSymbolAndAddToSymbolTable`).
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ContainerKind {
+ SourceFile,
+ Module,
+ Class,
+ Enum,
+ Interface,
+ /// A function-like scope, a type-alias, or any other `HasLocals` container
+ /// whose members route to `locals`.
+ Locals,
+}
+
+/// A live scope in the container stack.
+#[derive(Clone, Copy)]
+struct Scope {
+ kind: ContainerKind,
+ /// The container's symbol (owns `members`/`exports`); `None` for a script
+ /// source file and for a plain block scope.
+ symbol: Option,
+ /// This scope's `locals` table; `None` for a class/enum/interface/members
+ /// container (they route through the symbol's tables).
+ locals: Option,
+ /// The source file is an external module (routes exported members).
+ is_external_module: bool,
+ /// Ambient implicit-export context (a `.d.ts` file / ambient module with no
+ /// export declarations); routes non-`export`ed members to `exports`.
+ is_export_context: bool,
+}
+
+/// A declaration's routing inputs for the cascade.
+struct DeclInput {
+ /// The final table key (default-forced / mangled by the caller).
+ name: Atom,
+ /// The display name for the `{0}` message argument.
+ display: Atom,
+ /// The span a diagnostic points at (the declaration name).
+ error_span: Span,
+ /// This declaration is a default export (`export default`, forcing the
+ /// `"default"` name).
+ is_default_export: bool,
+ /// This declaration is an `export default ` (tsgo's
+ /// `ExportAssignment` with `IsExportEquals == false`) — the other TS2528 case.
+ is_export_assignment_default: bool,
+ /// This declaration carries an `export` modifier (threaded from the wrapper) —
+ /// routes it to the container's `exports` table.
+ exported: bool,
+ /// The declaration node's best-effort dense id (via the SoA address map).
+ node: NodeId,
+}
+
+/// Modifiers threaded from an `export` wrapper into the wrapped declaration.
+#[derive(Clone, Copy, Default)]
+struct DeclMods {
+ exported: bool,
+ default: bool,
+}
+
+/// The symbol bind for one file.
+pub(super) struct SymbolBinder<'a> {
+ source: &'a str,
+ address_map: &'a FxHashMap<(usize, NodeKind), NodeId>,
+ file: FileId,
+ is_external: bool,
+
+ atoms: Atoms,
+ symbols: Vec,
+ tables: Vec>,
+ diagnostics: Vec,
+
+ container: Scope,
+ block_scope: Scope,
+
+ /// The source-file `locals` table — a **script**'s globals-eligible symbols.
+ source_file_locals: TableId,
+ /// `declare global {}` augmentation symbols (their exports merge into globals).
+ global_aug_symbols: Vec,
+ /// Non-global `declare module "X"` augmentations: `(unquoted-name atom, span)`.
+ module_augs: Vec<(Atom, Span)>,
+}
+
+impl<'a> SymbolBinder<'a> {
+ /// Build a binder for one file, seeding the source-file scope.
+ pub(super) fn new(
+ source: &'a str,
+ address_map: &'a FxHashMap<(usize, NodeKind), NodeId>,
+ file: FileId,
+ facts: FileFacts,
+ ) -> SymbolBinder<'a> {
+ let is_external_module = matches!(facts.module_ness, super::ModuleNess::Module);
+ let mut binder = SymbolBinder {
+ source,
+ address_map,
+ file,
+ is_external: is_external_module,
+ atoms: Atoms::new(),
+ symbols: Vec::new(),
+ tables: Vec::new(),
+ diagnostics: Vec::new(),
+ container: Scope {
+ kind: ContainerKind::SourceFile,
+ symbol: None,
+ locals: None,
+ is_external_module,
+ is_export_context: false,
+ },
+ block_scope: Scope {
+ kind: ContainerKind::SourceFile,
+ symbol: None,
+ locals: None,
+ is_external_module,
+ is_export_context: false,
+ },
+ // Provisional; overwritten with the real source-file locals below.
+ source_file_locals: TableId(0),
+ global_aug_symbols: Vec::new(),
+ module_augs: Vec::new(),
+ };
+ let locals = binder.new_table();
+ binder.source_file_locals = locals;
+ let symbol = if is_external_module {
+ // The file's own module symbol owns the `exports` table.
+ let name = binder.atoms.intern("\"module\"");
+ let sid = binder.new_symbol(SymbolFlags::VALUE_MODULE, name);
+ let exports = binder.new_table();
+ binder.symbols[sid.index()].exports = Some(exports);
+ Some(sid)
+ } else {
+ None
+ };
+ binder.container.symbol = symbol;
+ binder.container.locals = Some(locals);
+ binder.block_scope = binder.container;
+ binder
+ }
+
+ /// Bind the program body, then return.
+ pub(super) fn bind_program(&mut self, program: &Program<'a>) {
+ self.bind_statement_list(program.body, true);
+ }
+
+ // --- statement lists (functions-first) -----------------------------------
+
+ // tsgo: internal/binder/binder.go bindEachStatementFunctionsFirst (functions-first)
+ fn bind_statement_list(&mut self, stmts: &[Statement<'a>], functions_first: bool) {
+ if functions_first {
+ for stmt in stmts {
+ if is_function_statement(stmt) {
+ self.declare_hoisted_function(stmt);
+ }
+ }
+ }
+ for stmt in stmts {
+ let skip = functions_first && is_function_statement(stmt);
+ self.visit_statement(stmt, DeclMods::default(), skip);
+ }
+ }
+
+ // --- scopes ---------------------------------------------------------------
+
+ fn with_function_scope(
+ &mut self,
+ type_params: Option<&TSTypeParameterDeclaration<'a>>,
+ f: impl FnOnce(&mut Self),
+ ) {
+ let saved = (self.container, self.block_scope);
+ let locals = self.new_table();
+ let scope = Scope {
+ kind: ContainerKind::Locals,
+ symbol: None,
+ locals: Some(locals),
+ is_external_module: false,
+ is_export_context: false,
+ };
+ self.container = scope;
+ self.block_scope = scope;
+ self.bind_type_params(type_params);
+ f(self);
+ self.container = saved.0;
+ self.block_scope = saved.1;
+ }
+
+ fn with_block_scope(&mut self, f: impl FnOnce(&mut Self)) {
+ let saved = self.block_scope;
+ let locals = self.new_table();
+ self.block_scope = Scope {
+ kind: ContainerKind::Locals,
+ symbol: None,
+ locals: Some(locals),
+ is_external_module: false,
+ is_export_context: false,
+ };
+ f(self);
+ self.block_scope = saved;
+ }
+
+ /// Finish, returning the collected bind diagnostics and the merge product.
+ pub(super) fn finish(self) -> (Vec, FileMerge) {
+ // A script's source-file locals reach global scope; an external module's
+ // do not (its members live in the module's exports).
+ let source_locals = if self.is_external {
+ Vec::new()
+ } else {
+ self.resolve_table(self.source_file_locals)
+ };
+ let global_augmentations = self
+ .global_aug_symbols
+ .iter()
+ .map(|&sid| match self.symbols[sid.index()].exports {
+ Some(t) => self.resolve_table(t),
+ None => Vec::new(),
+ })
+ .collect();
+ let module_augmentations = self
+ .module_augs
+ .iter()
+ .map(|&(name, span)| ModuleAug {
+ file: self.file,
+ name: self.atoms.resolve(name).to_string(),
+ name_span: span,
+ })
+ .collect();
+ let merge = FileMerge {
+ file: self.file,
+ is_external: self.is_external,
+ source_locals,
+ global_augmentations,
+ module_augmentations,
+ };
+ (self.diagnostics, merge)
+ }
+
+ /// Resolve a symbol table into merge symbols, in **declaration order** (first
+ /// declaration's span) — deterministic iteration, never the hash-map's.
+ ///
+ /// The order is **total**, deliberately: a stable sort on a non-unique key
+ /// leaves ties in the input's order, which here is the map's iteration order
+ /// — i.e. the hasher. No tie is known to be reachable, but the name breaks
+ /// any that is, so the guarantee doesn't rest on that.
+ fn resolve_table(&self, table: TableId) -> Vec {
+ let mut symbols: Vec = self.tables[table.index()]
+ .values()
+ .map(|&sid| {
+ let sym = &self.symbols[sid.index()];
+ let decls = sym
+ .decls
+ .iter()
+ .map(|d| MergeDecl {
+ file: self.file,
+ error_span: d.error_span,
+ is_type_decl: d.is_type_decl,
+ })
+ .collect();
+ MergeSymbol {
+ name: self.atoms.resolve(sym.name).to_string(),
+ flags: sym.flags,
+ decls,
+ }
+ })
+ .collect();
+ // Borrow the name rather than cloning it into a sort key; it is unique
+ // per table by construction, so it terminates the order.
+ let span_key = |s: &MergeSymbol| {
+ s.decls.first().map_or((u32::MAX, u32::MAX), |d| {
+ (d.error_span.start, d.error_span.end)
+ })
+ };
+ symbols.sort_by(|a, b| {
+ span_key(a)
+ .cmp(&span_key(b))
+ .then_with(|| a.name.cmp(&b.name))
+ });
+ symbols
+ }
+
+ // --- table / symbol pool -------------------------------------------------
+
+ fn new_table(&mut self) -> TableId {
+ let id = TableId(self.tables.len() as u32);
+ self.tables.push(FxHashMap::default());
+ id
+ }
+
+ fn new_symbol(&mut self, flags: SymbolFlags, name: Atom) -> SymbolId {
+ let id = SymbolId(self.symbols.len() as u32);
+ self.symbols.push(Symbol::new(flags, name));
+ id
+ }
+
+ /// The `exports` table of `symbol`, created on first use.
+ fn exports_of(&mut self, symbol: SymbolId) -> TableId {
+ if let Some(t) = self.symbols[symbol.index()].exports {
+ return t;
+ }
+ let t = self.new_table();
+ self.symbols[symbol.index()].exports = Some(t);
+ t
+ }
+
+ /// The `members` table of `symbol`, created on first use.
+ fn members_of(&mut self, symbol: SymbolId) -> TableId {
+ if let Some(t) = self.symbols[symbol.index()].members {
+ return t;
+ }
+ let t = self.new_table();
+ self.symbols[symbol.index()].members = Some(t);
+ t
+ }
+
+ /// The [`NodeId`] of `node` (of kind `kind`), or [`NodeId::FIRST`] on a miss.
+ /// Lenient by design — the result feeds `Decl.node`, which is dead in the
+ /// single-file pipeline (statement-level inner structs the SoA walk keys on
+ /// the enclosing `&Statement` address fall back to the root id here).
+ fn node_id_of(&self, node: &T, kind: NodeKind) -> NodeId {
+ self.address_map
+ .get(&(addr_of(node), kind))
+ .copied()
+ .unwrap_or(NodeId::FIRST)
+ }
+
+ // --- name resolution -----------------------------------------------------
+
+ fn ident_atom(&mut self, id: &Identifier<'_>) -> Atom {
+ let name = id.name(self.source);
+ self.atoms.intern(name)
+ }
+
+ fn string_atom(&mut self, lit: &Literal<'_>) -> Atom {
+ match &lit.value {
+ LiteralValue::String(cooked) => {
+ let s = cooked.resolve(lit.span, self.source);
+ self.atoms.intern(s)
+ }
+ // Non-string literals (numbers etc.) key on their source text.
+ _ => {
+ let s = lit.span.extract(self.source);
+ self.atoms.intern(s)
+ }
+ }
+ }
+
+ fn module_export_name_atom(&mut self, name: &ModuleExportName<'_>) -> (Atom, Span) {
+ match name {
+ ModuleExportName::Identifier(id) => (self.ident_atom(id), id.name_span()),
+ ModuleExportName::Literal(lit) => (self.string_atom(lit), lit.span),
+ }
+ }
+
+ // --- member keys ---------------------------------------------------------
+
+ fn resolve_member_key(
+ &mut self,
+ key: &Expression<'a>,
+ computed: bool,
+ class_symbol: Option,
+ ) -> Option {
+ if computed {
+ // A computed key names a member only for a string/numeric literal.
+ return match key {
+ Expression::Literal(lit)
+ if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) =>
+ {
+ // The grouping key stays the decoded/canonical value (so `[0]` and
+ // `['0']` collide). The diagnostic points at the whole `[ … ]` name
+ // node — bracket-inclusive, matching tsgo (`getNameOfDeclaration` ->
+ // the ComputedPropertyName) and the check-pass span, so a key that
+ // conflicts at both phases collapses in the sort/dedup. The display
+ // is that raw bracket-inclusive source (tsgo's `symbolToString`).
+ let key_atom = self.string_atom(lit);
+ let source = self.source;
+ let start = crate::span_scan::bracket_start(source, lit.span.start);
+ let end = crate::span_scan::bracket_end(source, lit.span.end);
+ let display = self.atoms.intern(&source[start as usize..end as usize]);
+ Some(KeyInfo {
+ key: key_atom,
+ display,
+ span: Span::new(start, end),
+ })
+ }
+ _ => None,
+ };
+ }
+ match key {
+ Expression::Identifier(id) => {
+ let a = self.ident_atom(id);
+ Some(KeyInfo {
+ key: a,
+ display: a,
+ span: id.name_span(),
+ })
+ }
+ Expression::Literal(lit) => {
+ let a = self.string_atom(lit);
+ Some(KeyInfo {
+ key: a,
+ display: a,
+ span: lit.span,
+ })
+ }
+ Expression::PrivateIdentifier(pid) => {
+ let raw = pid.name(self.source);
+ // The display carries the leading `#`, matching the `#name` span and
+ // the check-side form (`duplicate_members.rs`'s `member_key`) — a
+ // duplicate reported by BOTH the bind cascade and the check pass shares
+ // a code+span but must share this message arg too, or sort/dedup can't
+ // collapse the pair (a latent span-multiset extra). tsgo prints
+ // `Duplicate identifier '#foo'.` — the `#` is included.
+ // TODO: member-key derivation is duplicated across the bind (`sym/`) and
+ // check (`duplicate_members.rs`) sides with no shared helper — span
+ // derivation is centralized in `span_scan.rs`, key and display are not.
+ // The two already disagree on numerics: `string_atom` keys a numeric
+ // literal on its raw source text while `duplicate_members::literal_key`
+ // canonicalizes through ECMA `Number::toString`, so `[0]` and `[0.0]`
+ // collide on the check side but not the bind side. Latent today (the
+ // check walk covers every member surface that reports, and a raw-text
+ // key can only under-report relative to a canonical one), but a
+ // bind-only reporting surface would expose it. tsgo canonicalizes in
+ // exactly one place.
+ let display = self.atoms.intern(&format!("#{raw}"));
+ // Mangle with the class symbol id so same-name privates in one
+ // class collide (tsgo GetSymbolNameForPrivateIdentifier). The mangled
+ // key keeps the bare `raw` — only `display` gains the `#`.
+ let mangled = format!("\u{FE}#{}@{}", class_symbol.map_or(0, |s| s.0), raw);
+ let key = self.atoms.intern(&mangled);
+ // The diagnostic points at the whole `#name` node (tsgo's
+ // `getNameOfDeclaration` -> the PrivateIdentifier), so the squiggle
+ // covers the `#` — and `display` now matches that span.
+ Some(KeyInfo {
+ key,
+ display,
+ span: pid.span,
+ })
+ }
+ _ => None,
+ }
+ }
+}
+
+/// A resolved member key.
+struct KeyInfo {
+ key: Atom,
+ display: Atom,
+ span: Span,
+}
+
+/// Whether a statement is a function declaration (possibly `export`-wrapped) —
+/// the set tsgo's `bindEachStatementFunctionsFirst` binds first.
+fn is_function_statement(stmt: &Statement<'_>) -> bool {
+ match stmt {
+ Statement::FunctionDeclaration(_) | Statement::TSDeclareFunction(_) => true,
+ Statement::ExportNamedDeclaration(e) => e.declaration.is_some_and(|inner| {
+ matches!(
+ inner,
+ Statement::FunctionDeclaration(_) | Statement::TSDeclareFunction(_)
+ )
+ }),
+ Statement::ExportDefaultDeclaration(e) => matches!(
+ e.declaration,
+ ExportDefaultValue::FunctionDeclaration(_) | ExportDefaultValue::TSDeclareFunction(_)
+ ),
+ _ => false,
+ }
+}
diff --git a/crates/tsv_check/src/binder/sym/statement.rs b/crates/tsv_check/src/binder/sym/statement.rs
new file mode 100644
index 000000000..5fda6e555
--- /dev/null
+++ b/crates/tsv_check/src/binder/sym/statement.rs
@@ -0,0 +1,851 @@
+//! The statement-shaped bind-descent — `visit_statement` (the dispatch) and
+//! every `bind_*_statement`, the export/default/function-name group, the
+//! param/binding helpers, and the module/import/export-specifier binds.
+//! Contributes its own `impl SymbolBinder` block; the struct, the
+//! functions-first statement-list driver, and the scope helpers live in the
+//! parent module. Purely a locality split — no behavior distinction.
+
+use super::super::symbols::SymbolFlags;
+use super::{ContainerKind, DeclInput, DeclMods, NodeKind, Scope, SymbolBinder};
+use crate::ids::NodeId;
+use tsv_lang::Span;
+use tsv_ts::ast::internal::{
+ ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, Identifier,
+ ImportSpecifier, ModuleExportName, ObjectPatternProperty, Statement, TSModuleDeclarationBody,
+ TSModuleName,
+};
+
+impl<'a> SymbolBinder<'a> {
+ /// Sub-step A: declare a hoisted function's symbol only (no body descent),
+ /// unwrapping any `export`/`export default` wrapper for its modifiers.
+ pub(super) fn declare_hoisted_function(&mut self, stmt: &Statement<'a>) {
+ match stmt {
+ Statement::FunctionDeclaration(f) => {
+ if let Some(id) = &f.id {
+ self.bind_function_name(id, f.span, DeclMods::default());
+ }
+ }
+ Statement::TSDeclareFunction(f) => {
+ self.bind_function_name(&f.id, f.span, DeclMods::default());
+ }
+ Statement::ExportNamedDeclaration(e) => {
+ if let Some(inner) = e.declaration {
+ self.declare_hoisted_function_inner(
+ inner,
+ DeclMods {
+ exported: true,
+ default: false,
+ },
+ );
+ }
+ }
+ Statement::ExportDefaultDeclaration(e) => {
+ let mods = DeclMods {
+ exported: true,
+ default: true,
+ };
+ match &e.declaration {
+ ExportDefaultValue::FunctionDeclaration(f) => {
+ self.bind_default_function(f.id.as_ref(), e.span, mods);
+ }
+ ExportDefaultValue::TSDeclareFunction(f) => {
+ self.bind_default_function(Some(&f.id), e.span, mods);
+ }
+ _ => {}
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn declare_hoisted_function_inner(&mut self, inner: &Statement<'a>, mods: DeclMods) {
+ match inner {
+ Statement::FunctionDeclaration(f) => {
+ if let Some(id) = &f.id {
+ self.bind_function_name(id, f.span, mods);
+ }
+ }
+ Statement::TSDeclareFunction(f) => self.bind_function_name(&f.id, f.span, mods),
+ _ => {}
+ }
+ }
+
+ // --- statements ----------------------------------------------------------
+
+ pub(super) fn visit_statement(
+ &mut self,
+ stmt: &Statement<'a>,
+ mods: DeclMods,
+ skip_symbol: bool,
+ ) {
+ match stmt {
+ Statement::VariableDeclaration(decl) => {
+ let (includes, excludes, block_scoped) = var_flags(decl.kind);
+ for d in decl.declarations {
+ self.bind_binding(&d.id, includes, excludes, block_scoped, mods, decl.span);
+ if let Some(init) = &d.init {
+ self.visit_expression(init);
+ }
+ }
+ }
+ Statement::FunctionDeclaration(f) => {
+ if !skip_symbol && let Some(id) = &f.id {
+ self.bind_function_name(id, f.span, mods);
+ }
+ self.with_function_scope(f.type_parameters.as_ref(), |b| {
+ b.bind_params(f.params);
+ b.bind_statement_list(f.body.body, true);
+ });
+ }
+ Statement::TSDeclareFunction(f) => {
+ if !skip_symbol {
+ self.bind_function_name(&f.id, f.span, mods);
+ }
+ self.with_function_scope(f.type_parameters.as_ref(), |b| {
+ b.bind_params(f.params);
+ });
+ }
+ Statement::ClassDeclaration(c) => self.bind_class_statement(c, mods, skip_symbol),
+ Statement::TSInterfaceDeclaration(i) => {
+ let d = self.decl_from_ident(&i.id, i.span, mods);
+ let sym = self.declare_block_scoped(
+ d,
+ SymbolFlags::INTERFACE,
+ SymbolFlags::INTERFACE_EXCLUDES,
+ );
+ self.bind_interface_body(&i.body, sym, i.type_parameters.as_ref());
+ }
+ Statement::TSEnumDeclaration(e) => self.bind_enum_statement(e, mods),
+ Statement::TSModuleDeclaration(m) => self.bind_module(m, mods),
+ Statement::TSTypeAliasDeclaration(t) => self.bind_type_alias_statement(t, mods),
+ Statement::ImportDeclaration(imp) => {
+ for spec in imp.specifiers {
+ self.bind_import_specifier(spec);
+ }
+ }
+ Statement::TSImportEqualsDeclaration(ie) => self.bind_import_equals_statement(ie),
+ Statement::ExportNamedDeclaration(e) => {
+ self.bind_export_named_statement(e, skip_symbol);
+ }
+ Statement::ExportDefaultDeclaration(e) => self.bind_export_default(e, skip_symbol),
+ // Control flow: descend for nested bindings + block scopes.
+ Statement::BlockStatement(b) => {
+ self.with_block_scope(|bd| bd.bind_statement_list(b.body, true));
+ }
+ Statement::IfStatement(s) => {
+ self.visit_expression(&s.test);
+ self.visit_statement(s.consequent, DeclMods::default(), false);
+ if let Some(alt) = s.alternate {
+ self.visit_statement(alt, DeclMods::default(), false);
+ }
+ }
+ Statement::ForStatement(s) => self.bind_for_statement(s),
+ Statement::ForInStatement(s) => self.with_block_scope(|bd| {
+ bd.bind_for_left(&s.left);
+ bd.visit_expression(&s.right);
+ bd.visit_statement(s.body, DeclMods::default(), false);
+ }),
+ Statement::ForOfStatement(s) => self.with_block_scope(|bd| {
+ bd.bind_for_left(&s.left);
+ bd.visit_expression(&s.right);
+ bd.visit_statement(s.body, DeclMods::default(), false);
+ }),
+ Statement::WhileStatement(s) => {
+ self.visit_expression(&s.test);
+ self.visit_statement(s.body, DeclMods::default(), false);
+ }
+ Statement::DoWhileStatement(s) => {
+ self.visit_statement(s.body, DeclMods::default(), false);
+ self.visit_expression(&s.test);
+ }
+ Statement::SwitchStatement(s) => self.bind_switch_statement(s),
+ Statement::TryStatement(s) => self.bind_try_statement(s),
+ Statement::LabeledStatement(s) => {
+ self.visit_statement(s.body, DeclMods::default(), false);
+ }
+ Statement::ReturnStatement(s) => {
+ if let Some(a) = &s.argument {
+ self.visit_expression(a);
+ }
+ }
+ Statement::ThrowStatement(s) => self.visit_expression(&s.argument),
+ Statement::ExpressionStatement(s) => self.visit_expression(&s.expression),
+ Statement::TSExportAssignment(ea) => self.bind_export_assignment_statement(ea),
+ Statement::ExportAllDeclaration(_)
+ | Statement::TSNamespaceExportDeclaration(_)
+ | Statement::BreakStatement(_)
+ | Statement::ContinueStatement(_)
+ | Statement::EmptyStatement(_)
+ | Statement::DebuggerStatement(_) => {}
+ }
+ }
+
+ #[inline]
+ fn bind_class_statement(
+ &mut self,
+ c: &tsv_ts::ast::internal::ClassDeclaration<'a>,
+ mods: DeclMods,
+ skip_symbol: bool,
+ ) {
+ let sym = if skip_symbol {
+ None
+ } else {
+ c.id.as_ref().map(|id| {
+ let d = self.decl_from_ident(id, c.span, mods);
+ self.declare_block_scoped(d, SymbolFlags::CLASS, SymbolFlags::CLASS_EXCLUDES)
+ })
+ };
+ self.bind_class_body(&c.body, sym, c.type_parameters.as_ref());
+ }
+
+ #[inline]
+ fn bind_enum_statement(
+ &mut self,
+ e: &tsv_ts::ast::internal::TSEnumDeclaration<'a>,
+ mods: DeclMods,
+ ) {
+ let (inc, exc) = if e.r#const {
+ (SymbolFlags::CONST_ENUM, SymbolFlags::CONST_ENUM_EXCLUDES)
+ } else {
+ (
+ SymbolFlags::REGULAR_ENUM,
+ SymbolFlags::REGULAR_ENUM_EXCLUDES,
+ )
+ };
+ let d = self.decl_from_ident(&e.id, e.span, mods);
+ let sym = self.declare_block_scoped(d, inc, exc);
+ self.bind_enum_members(e.members, sym);
+ }
+
+ #[inline]
+ fn bind_type_alias_statement(
+ &mut self,
+ t: &tsv_ts::ast::internal::TSTypeAliasDeclaration<'a>,
+ mods: DeclMods,
+ ) {
+ // tsgo's `declareSymbolEx` adds a TS1369 "Did you mean
+ // 'export type { T }'?" related info when a conflicting declaration
+ // is `export type T;` — a type alias with a *missing* `= type`
+ // (binder.go:260). That shape is deliberately unported: tsv's parser
+ // rejects `export type T;` ("Expected '='"), so the declaration never
+ // reaches this cascade. The sole corpus baseline exercising the hint
+ // (`exportDeclaration_missingBraces.ts`) is therefore a tsv
+ // parse-rejection, not a gradeable bind.
+ let d = self.decl_from_ident(&t.id, t.span, mods);
+ self.declare_block_scoped(d, SymbolFlags::TYPE_ALIAS, SymbolFlags::TYPE_ALIAS_EXCLUDES);
+ self.bind_type_params_in_new_locals(t.type_parameters.as_ref());
+ }
+
+ #[inline]
+ fn bind_import_equals_statement(
+ &mut self,
+ ie: &tsv_ts::ast::internal::TSImportEqualsDeclaration<'a>,
+ ) {
+ let d = self.decl_from_ident(
+ &ie.id,
+ ie.span,
+ DeclMods {
+ exported: ie.is_export,
+ default: false,
+ },
+ );
+ // An `import =` with an external reference or a plain entity name
+ // is an alias either way for the family (locals unless exported).
+ let _ = &ie.module_reference;
+ self.declare_alias(d, ie.is_export);
+ }
+
+ #[inline]
+ fn bind_export_named_statement(
+ &mut self,
+ e: &tsv_ts::ast::internal::ExportNamedDeclaration<'a>,
+ skip_symbol: bool,
+ ) {
+ if let Some(inner) = e.declaration {
+ self.visit_statement(
+ inner,
+ DeclMods {
+ exported: true,
+ default: false,
+ },
+ skip_symbol,
+ );
+ } else {
+ for spec in e.specifiers {
+ self.bind_export_specifier(spec);
+ }
+ }
+ }
+
+ #[inline]
+ fn bind_for_statement(&mut self, s: &tsv_ts::ast::internal::ForStatement<'a>) {
+ self.with_block_scope(|bd| {
+ if let Some(init) = &s.init {
+ match init {
+ ForInit::VariableDeclaration(decl) => bd.bind_var_declaration(decl),
+ ForInit::Expression(e) => bd.visit_expression(e),
+ }
+ }
+ if let Some(t) = &s.test {
+ bd.visit_expression(t);
+ }
+ if let Some(u) = &s.update {
+ bd.visit_expression(u);
+ }
+ bd.visit_statement(s.body, DeclMods::default(), false);
+ });
+ }
+
+ #[inline]
+ fn bind_switch_statement(&mut self, s: &tsv_ts::ast::internal::SwitchStatement<'a>) {
+ self.visit_expression(&s.discriminant);
+ self.with_block_scope(|bd| {
+ for case in s.cases {
+ if let Some(t) = &case.test {
+ bd.visit_expression(t);
+ }
+ bd.bind_statement_list(case.consequent, false);
+ }
+ });
+ }
+
+ #[inline]
+ fn bind_try_statement(&mut self, s: &tsv_ts::ast::internal::TryStatement<'a>) {
+ self.with_block_scope(|bd| bd.bind_statement_list(s.block.body, true));
+ if let Some(h) = &s.handler {
+ // The catch clause is a block scope holding the (block-scoped)
+ // parameter; its body is a *separate* nested block scope, so a
+ // `const e` shadowing `catch(e)` is a check-time TS2492, not a
+ // binder conflict (tsgo `bindVariableDeclarationOrBindingElement`
+ // -> `IsBlockOrCatchScoped`).
+ self.with_block_scope(|bd| {
+ if let Some(param) = &h.param {
+ bd.bind_binding(
+ param,
+ SymbolFlags::BLOCK_SCOPED_VARIABLE,
+ SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES,
+ true,
+ DeclMods::default(),
+ h.span,
+ );
+ }
+ bd.with_block_scope(|body| body.bind_statement_list(h.body.body, true));
+ });
+ }
+ if let Some(f) = &s.finalizer {
+ self.with_block_scope(|bd| bd.bind_statement_list(f.body, true));
+ }
+ }
+
+ #[inline]
+ fn bind_export_assignment_statement(
+ &mut self,
+ ea: &tsv_ts::ast::internal::TSExportAssignment<'a>,
+ ) {
+ // `export = x` — tsgo `bindExportAssignment` with `IsExportEquals`:
+ // declared into `exports` under the `"export="` name with ALL
+ // excludes (self-merge-only), so a second `export =` conflicts.
+ if let Some(sym) = self.container.symbol {
+ let name = self.atoms.export_equals();
+ // The name node is the expression when it is a bare identifier
+ // (tsgo `getNonAssignedNameOfDeclaration`), else the whole node.
+ let error_span = match &ea.expression {
+ Expression::Identifier(id) => id.name_span(),
+ _ => ea.span,
+ };
+ let d = DeclInput {
+ name,
+ display: name,
+ error_span,
+ is_default_export: false,
+ is_export_assignment_default: false,
+ exported: true,
+ node: self.node_id_of(ea, NodeKind::TSExportAssignment),
+ };
+ let table = self.exports_of(sym);
+ self.declare_symbol(table, Some(sym), d, SymbolFlags::PROPERTY, SymbolFlags::ALL);
+ }
+ self.visit_expression(&ea.expression);
+ }
+
+ fn bind_var_declaration(&mut self, decl: &tsv_ts::ast::internal::VariableDeclaration<'a>) {
+ let (includes, excludes, block_scoped) = var_flags(decl.kind);
+ for d in decl.declarations {
+ self.bind_binding(
+ &d.id,
+ includes,
+ excludes,
+ block_scoped,
+ DeclMods::default(),
+ decl.span,
+ );
+ if let Some(init) = &d.init {
+ self.visit_expression(init);
+ }
+ }
+ }
+
+ fn bind_for_left(&mut self, left: &ForInOfLeft<'a>) {
+ match left {
+ ForInOfLeft::VariableDeclaration(decl) => self.bind_var_declaration(decl),
+ ForInOfLeft::Pattern(_) => {}
+ }
+ }
+
+ // --- export default ------------------------------------------------------
+
+ fn bind_export_default(
+ &mut self,
+ e: &tsv_ts::ast::internal::ExportDefaultDeclaration<'a>,
+ skip_symbol: bool,
+ ) {
+ let mods = DeclMods {
+ exported: true,
+ default: true,
+ };
+ match &e.declaration {
+ ExportDefaultValue::Expression(expr) => {
+ // tsgo `bindExportAssignment` (non-`export =`): excludes = ALL. An
+ // entity-name expression (`export default foo`) is an **alias**
+ // (`ExpressionIsAlias`) whose diagnostic points at the name; any
+ // other expression (`export default 0`) is a `Property` pointing at
+ // the whole `export default` node.
+ if let Some(sym) = self.container.symbol {
+ let name = self.atoms.default_export();
+ let is_alias = matches!(
+ expr,
+ Expression::Identifier(_) | Expression::MemberExpression(_)
+ );
+ let flags = if is_alias {
+ SymbolFlags::ALIAS
+ } else {
+ SymbolFlags::PROPERTY
+ };
+ // The name node is the expression only when it is a bare
+ // identifier (tsgo `getNonAssignedNameOfDeclaration`); otherwise
+ // the whole `export default` node.
+ let error_span = match expr {
+ Expression::Identifier(id) => id.name_span(),
+ _ => e.span,
+ };
+ let d = DeclInput {
+ name,
+ display: name,
+ error_span,
+ is_default_export: false,
+ is_export_assignment_default: true,
+ exported: false,
+ node: self.node_id_of(e, NodeKind::ExportDefaultDeclaration),
+ };
+ let table = self.exports_of(sym);
+ self.declare_symbol(table, Some(sym), d, flags, SymbolFlags::ALL);
+ }
+ self.visit_expression(expr);
+ }
+ ExportDefaultValue::FunctionDeclaration(f) => {
+ if !skip_symbol {
+ self.bind_default_function(f.id.as_ref(), e.span, mods);
+ }
+ self.with_function_scope(f.type_parameters.as_ref(), |b| {
+ b.bind_params(f.params);
+ b.bind_statement_list(f.body.body, true);
+ });
+ }
+ ExportDefaultValue::TSDeclareFunction(f) => {
+ if !skip_symbol {
+ self.bind_default_function(Some(&f.id), e.span, mods);
+ }
+ self.with_function_scope(f.type_parameters.as_ref(), |b| b.bind_params(f.params));
+ }
+ ExportDefaultValue::ClassDeclaration(c) => {
+ let d = self.default_decl(c.id.as_ref(), e.span);
+ let sym = self.container.symbol.map(|cs| {
+ let table = self.exports_of(cs);
+ self.declare_symbol(
+ table,
+ Some(cs),
+ d,
+ SymbolFlags::CLASS,
+ SymbolFlags::CLASS_EXCLUDES,
+ )
+ });
+ self.bind_class_body(&c.body, sym, c.type_parameters.as_ref());
+ }
+ ExportDefaultValue::TSInterfaceDeclaration(i) => {
+ let d = self.default_decl(Some(&i.id), e.span);
+ if let Some(cs) = self.container.symbol {
+ let table = self.exports_of(cs);
+ self.declare_symbol(
+ table,
+ Some(cs),
+ d,
+ SymbolFlags::INTERFACE,
+ SymbolFlags::INTERFACE_EXCLUDES,
+ );
+ }
+ self.bind_interface_body_symbol_less(&i.body, i.type_parameters.as_ref());
+ }
+ }
+ }
+
+ fn default_decl(&mut self, id: Option<&Identifier<'a>>, node_span: Span) -> DeclInput {
+ let display = match id {
+ Some(i) => {
+ let name = i.name(self.source);
+ self.atoms.intern(name)
+ }
+ None => self.atoms.default_export(),
+ };
+ DeclInput {
+ name: self.atoms.default_export(),
+ display,
+ error_span: id.map_or(node_span, Identifier::name_span),
+ is_default_export: true,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ }
+ }
+
+ fn bind_default_function(
+ &mut self,
+ id: Option<&Identifier<'a>>,
+ node_span: Span,
+ _mods: DeclMods,
+ ) {
+ if let Some(cs) = self.container.symbol {
+ let d = self.default_decl(id, node_span);
+ let table = self.exports_of(cs);
+ self.declare_symbol(
+ table,
+ Some(cs),
+ d,
+ SymbolFlags::FUNCTION,
+ SymbolFlags::FUNCTION_EXCLUDES,
+ );
+ }
+ }
+
+ // --- function names --------------------------------------------------------
+
+ fn bind_function_name(&mut self, id: &Identifier<'a>, node_span: Span, mods: DeclMods) {
+ let d = self.decl_from_ident(id, node_span, mods);
+ self.declare_block_scoped(d, SymbolFlags::FUNCTION, SymbolFlags::FUNCTION_EXCLUDES);
+ }
+
+ // --- params + bindings ---------------------------------------------------
+
+ pub(super) fn bind_params(&mut self, params: &[Expression<'a>]) {
+ for param in params {
+ self.bind_param(param);
+ }
+ }
+
+ pub(super) fn bind_param(&mut self, param: &Expression<'a>) {
+ match param {
+ Expression::TSParameterProperty(pp) => {
+ // The inner parameter binds as a parameter; a property-parameter
+ // also declares a class member (handled where the constructor's
+ // owning class scope is live — the constructor scope's parent).
+ self.bind_param(pp.parameter);
+ }
+ _ => self.bind_binding(
+ param,
+ SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ SymbolFlags::PARAMETER_EXCLUDES,
+ false,
+ DeclMods::default(),
+ param_span(param),
+ ),
+ }
+ }
+
+ /// Bind a binding target: an identifier leaf routes through the given flags;
+ /// object/array patterns recurse; assignment patterns and rest unwrap.
+ fn bind_binding(
+ &mut self,
+ target: &Expression<'a>,
+ includes: SymbolFlags,
+ excludes: SymbolFlags,
+ block_scoped: bool,
+ mods: DeclMods,
+ node_span: Span,
+ ) {
+ match target {
+ Expression::Identifier(id) => {
+ let d = self.decl_from_ident(id, node_span, mods);
+ if block_scoped {
+ self.declare_block_scoped(d, includes, excludes);
+ } else {
+ self.declare_in_container(d, includes, excludes);
+ }
+ // The binder's one type-annotation entry point: a typed binding
+ // (`var a: { … }`) descends into its annotation so a type literal's
+ // members bind (its method-signature params conflict, its duplicate
+ // members silent-merge). Narrow by design — an incomplete traversal
+ // only leaves family instances missing, never fabricates a conflict.
+ if let Some(ann) = id.type_annotation() {
+ self.bind_type_annotation(ann);
+ }
+ }
+ Expression::ObjectPattern(p) => {
+ for prop in p.properties {
+ match prop {
+ ObjectPatternProperty::Property(pr) => {
+ self.bind_binding(
+ &pr.value,
+ includes,
+ excludes,
+ block_scoped,
+ mods,
+ pr.span,
+ );
+ }
+ ObjectPatternProperty::RestElement(r) => {
+ self.bind_binding(
+ r.argument,
+ includes,
+ excludes,
+ block_scoped,
+ mods,
+ r.span,
+ );
+ }
+ }
+ }
+ }
+ Expression::ArrayPattern(p) => {
+ for el in p.elements.iter().flatten() {
+ self.bind_binding(el, includes, excludes, block_scoped, mods, el_span(el));
+ }
+ }
+ Expression::AssignmentPattern(a) => {
+ self.bind_binding(a.left, includes, excludes, block_scoped, mods, node_span);
+ self.visit_expression(a.right);
+ }
+ Expression::RestElement(r) => {
+ self.bind_binding(r.argument, includes, excludes, block_scoped, mods, r.span);
+ }
+ _ => {}
+ }
+ }
+
+ pub(super) fn decl_from_ident(
+ &mut self,
+ id: &Identifier<'a>,
+ _node_span: Span,
+ mods: DeclMods,
+ ) -> DeclInput {
+ let name = self.ident_atom(id);
+ DeclInput {
+ name,
+ display: name,
+ error_span: id.name_span(),
+ is_default_export: mods.default,
+ is_export_assignment_default: false,
+ exported: mods.exported,
+ node: self.node_id_of(id, NodeKind::Identifier),
+ }
+ }
+
+ // --- modules ---------------------------------------------------------------
+
+ fn bind_module(&mut self, m: &tsv_ts::ast::internal::TSModuleDeclaration<'a>, mods: DeclMods) {
+ // The module's own symbol (name = identifier, or `"name"` for ambient).
+ let (name, display, span) = match &m.id {
+ TSModuleName::Identifier(id) => {
+ let a = self.ident_atom(id);
+ (a, a, id.name_span())
+ }
+ TSModuleName::Literal(lit) => {
+ let raw = lit.span.extract(self.source);
+ let key = self.atoms.intern(raw);
+ (key, key, lit.span)
+ }
+ };
+ let d = DeclInput {
+ name,
+ display,
+ error_span: span,
+ is_default_export: mods.default,
+ is_export_assignment_default: false,
+ exported: mods.exported,
+ node: self.node_id_of(m, NodeKind::TSModuleDeclaration),
+ };
+ // Instantiation state (tsgo `GetModuleInstanceState`): a namespace of only
+ // types binds as the inert `NamespaceModule`, so it never conflicts with a
+ // `var`/`let`/`type` of the same name; one with value content is `ValueModule`.
+ let (inc, exc) = if module_instantiated(m) {
+ (
+ SymbolFlags::VALUE_MODULE,
+ SymbolFlags::VALUE_MODULE_EXCLUDES,
+ )
+ } else {
+ (
+ SymbolFlags::NAMESPACE_MODULE,
+ SymbolFlags::NAMESPACE_MODULE_EXCLUDES,
+ )
+ };
+ let sym = self.declare_block_scoped(d, inc, exc);
+
+ // Record cross-declaration-space augmentations for the merge phase — only
+ // top-level ones (container still the source file). `declare global {}` is
+ // a global-scope augmentation (its exports merge into globals);
+ // `declare module "X"` in an external module is a module augmentation
+ // (tsgo `IsModuleAugmentationExternal`, the `KindSourceFile` arm).
+ if self.container.kind == ContainerKind::SourceFile {
+ if m.global {
+ self.global_aug_symbols.push(sym);
+ } else if self.is_external
+ && let TSModuleName::Literal(lit) = &m.id
+ {
+ let unquoted = self.string_atom(lit);
+ self.module_augs.push((unquoted, lit.span));
+ }
+ }
+
+ let saved = (self.container, self.block_scope);
+ let locals = self.new_table();
+ let scope = Scope {
+ kind: ContainerKind::Module,
+ symbol: Some(sym),
+ locals: Some(locals),
+ is_external_module: false,
+ is_export_context: m.declare,
+ };
+ self.container = scope;
+ self.block_scope = scope;
+ self.exports_of(sym);
+ match &m.body {
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => {
+ self.bind_statement_list(block.body, true);
+ }
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => {
+ // A dotted-namespace continuation: `namespace X.Y.Z {}` parses to a
+ // nested `TSModuleDeclaration` chain, and this body variant is
+ // constructed only by that dot path. tsgo's parser synthesizes an
+ // implicit `export` modifier (`NodeFlagsReparsed`) on every
+ // dot-continuation segment, so the intermediate segments land in the
+ // enclosing namespace's persistent *exports* table — the same table an
+ // explicit `export namespace Y {}` routes to — letting the dotted and
+ // explicit-nested forms merge (and their members conflict) instead of
+ // splitting into fresh per-instance locals that never meet.
+ //
+ // tsgo: internal/parser/parser.go parseModuleOrNamespaceDeclaration
+ self.bind_module(
+ nested,
+ DeclMods {
+ exported: true,
+ default: false,
+ },
+ );
+ }
+ None => {}
+ }
+ self.container = saved.0;
+ self.block_scope = saved.1;
+ }
+
+ // --- imports / exports (aliases) -----------------------------------------
+
+ fn bind_import_specifier(&mut self, spec: &ImportSpecifier<'a>) {
+ let id = match spec {
+ ImportSpecifier::Default(d) => &d.local,
+ ImportSpecifier::Named(n) => &n.local,
+ ImportSpecifier::Namespace(n) => &n.local,
+ };
+ let d = self.decl_from_ident(id, id.span, DeclMods::default());
+ self.declare_alias(d, false);
+ }
+
+ fn bind_export_specifier(&mut self, spec: &ExportSpecifier<'a>) {
+ // An export specifier's *exported* name is the table key in `exports`.
+ let (name, span) = self.module_export_name_atom(&spec.exported);
+ let is_default = matches!(&spec.exported, ModuleExportName::Identifier(id)
+ if id.name(self.source) == "default");
+ let d = DeclInput {
+ name,
+ display: name,
+ error_span: span,
+ is_default_export: is_default,
+ is_export_assignment_default: false,
+ exported: false,
+ node: NodeId::FIRST,
+ };
+ self.declare_alias(d, true);
+ }
+}
+
+/// A [`SymbolFlags`] triple for a variable declaration kind: `(includes,
+/// excludes, block_scoped)`. `block_scoped` selects `bindBlockScopedDeclaration`
+/// (block-scope routing) over `declareSymbolAndAddToSymbolTable` (container).
+fn var_flags(
+ kind: tsv_ts::ast::internal::VariableDeclarationKind,
+) -> (SymbolFlags, SymbolFlags, bool) {
+ use tsv_ts::ast::internal::VariableDeclarationKind as K;
+ match kind {
+ // `var` is function-scoped (routes through the container).
+ K::Var => (
+ SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ SymbolFlags::FUNCTION_SCOPED_VARIABLE_EXCLUDES,
+ false,
+ ),
+ // `let` / `const` / `using` / `await using` are block-scoped.
+ K::Let | K::Const | K::Using | K::AwaitUsing => (
+ SymbolFlags::BLOCK_SCOPED_VARIABLE,
+ SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES,
+ true,
+ ),
+ }
+}
+
+/// The span a bare parameter expression points a diagnostic at.
+fn param_span(param: &Expression<'_>) -> Span {
+ match param {
+ Expression::Identifier(id) => id.name_span(),
+ _ => param.span(),
+ }
+}
+
+/// The span an array-pattern element points a diagnostic at.
+fn el_span(el: &Expression<'_>) -> Span {
+ match el {
+ Expression::Identifier(id) => id.name_span(),
+ _ => el.span(),
+ }
+}
+
+/// Whether a namespace/module is instantiated (a `ValueModule`) — a faithful-
+/// enough port of tsgo's `getModuleInstanceState`. A module is *non*-instantiated
+/// (an inert `NamespaceModule`) only when its whole body is types: interfaces,
+/// type aliases, non-exported imports, uninstantiated nested namespaces, and
+/// specifier-only named exports (approximated as non-instantiated). Any value
+/// content — a var/function/class/enum, an `export =`/`export default`, an
+/// expression — makes it instantiated.
+fn module_instantiated(m: &tsv_ts::ast::internal::TSModuleDeclaration<'_>) -> bool {
+ match &m.body {
+ None => true,
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => module_instantiated(nested),
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => {
+ !block.body.iter().all(statement_is_non_instantiated)
+ }
+ }
+}
+
+/// Whether a module-body statement contributes no value (tsgo
+/// `getModuleInstanceStateWorker`).
+fn statement_is_non_instantiated(stmt: &Statement<'_>) -> bool {
+ match stmt {
+ Statement::TSInterfaceDeclaration(_) | Statement::TSTypeAliasDeclaration(_) => true,
+ Statement::ImportDeclaration(_) => true,
+ Statement::TSImportEqualsDeclaration(ie) => !ie.is_export,
+ Statement::TSModuleDeclaration(nested) => !module_instantiated(nested),
+ // `export interface`/`export type` wrap a non-instantiated declaration;
+ // specifier-only named exports are approximated non-instantiated.
+ Statement::ExportNamedDeclaration(e) => match e.declaration {
+ Some(inner) => statement_is_non_instantiated(inner),
+ None => true,
+ },
+ _ => false,
+ }
+}
diff --git a/crates/tsv_check/src/binder/symbols.rs b/crates/tsv_check/src/binder/symbols.rs
new file mode 100644
index 000000000..ab374306f
--- /dev/null
+++ b/crates/tsv_check/src/binder/symbols.rs
@@ -0,0 +1,243 @@
+//! Symbols, symbol flags, and symbol tables — the binder's substrate.
+//!
+//! Ported from tsgo's `internal/ast`: [`SymbolFlags`] is the bit table plus the
+//! `*Excludes` conflict masks (a construct's flags cannot coexist in one table
+//! with any flag in its excludes mask — the whole basis of the duplicate-identifier
+//! cascade), reproduced so the merge-vs-conflict verdict matches tsgo by
+//! construction. The port covers every flag the binder + merge classify with and
+//! every `*Excludes` mask; it deliberately omits the flags no ported path reads —
+//! `ConstEnumOnlyModule` (`1 << 28`, only a `getModuleInstanceState` refinement the
+//! `module_instantiated` approximation folds away), `GlobalLookup` (`1 << 30`, the
+//! name-resolver's global-scope marker), and the convenience composites
+//! (`Module`, `ExportHasLocal`, `BlockScoped`, `PropertyOrAccessor`, `ClassMember`,
+//! …) whose members are all present. A [`Symbol`] carries its accumulated flags, name [`Atom`], the
+//! declaration list the cascade points errors at, and the `members`/`exports`
+//! child tables containers own. Tables ([`TableId`] into the binder's pool) are
+//! `Atom → SymbolId` maps.
+//
+// tsgo: internal/ast/symbolflags.go (the bit table + *Excludes masks),
+// internal/ast/symbol.go (Symbol shape)
+
+use crate::binder::atoms::Atom;
+use crate::ids::NodeId;
+use smallvec::SmallVec;
+use tsv_lang::Span;
+
+/// A dense symbol identity into the binder's `symbols` vector.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+pub struct SymbolId(pub u32);
+
+impl SymbolId {
+ /// The 0-based index this id addresses.
+ #[inline]
+ #[must_use]
+ pub const fn index(self) -> usize {
+ self.0 as usize
+ }
+}
+
+/// A dense symbol-table identity into the binder's `tables` vector.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+pub struct TableId(pub u32);
+
+impl TableId {
+ /// The 0-based index this id addresses.
+ #[inline]
+ #[must_use]
+ pub const fn index(self) -> usize {
+ self.0 as usize
+ }
+}
+
+/// tsgo's `SymbolFlags` — a `u32` bitset whose bits classify a declaration and
+/// whose `*Excludes` masks (below) decide same-table conflicts.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub struct SymbolFlags(pub u32);
+
+// The full flag + `*Excludes` table is ported verbatim from tsgo; some masks are
+// not yet referenced by the family cascade but are kept so the port stays a
+// faithful, auditable mirror of `symbolflags.go`.
+#[allow(clippy::unreadable_literal, dead_code)]
+impl SymbolFlags {
+ pub const NONE: SymbolFlags = SymbolFlags(0);
+ pub const FUNCTION_SCOPED_VARIABLE: SymbolFlags = SymbolFlags(1 << 0);
+ pub const BLOCK_SCOPED_VARIABLE: SymbolFlags = SymbolFlags(1 << 1);
+ pub const PROPERTY: SymbolFlags = SymbolFlags(1 << 2);
+ pub const ENUM_MEMBER: SymbolFlags = SymbolFlags(1 << 3);
+ pub const FUNCTION: SymbolFlags = SymbolFlags(1 << 4);
+ pub const CLASS: SymbolFlags = SymbolFlags(1 << 5);
+ pub const INTERFACE: SymbolFlags = SymbolFlags(1 << 6);
+ pub const CONST_ENUM: SymbolFlags = SymbolFlags(1 << 7);
+ pub const REGULAR_ENUM: SymbolFlags = SymbolFlags(1 << 8);
+ pub const VALUE_MODULE: SymbolFlags = SymbolFlags(1 << 9);
+ pub const NAMESPACE_MODULE: SymbolFlags = SymbolFlags(1 << 10);
+ pub const TYPE_LITERAL: SymbolFlags = SymbolFlags(1 << 11);
+ pub const OBJECT_LITERAL: SymbolFlags = SymbolFlags(1 << 12);
+ pub const METHOD: SymbolFlags = SymbolFlags(1 << 13);
+ pub const CONSTRUCTOR: SymbolFlags = SymbolFlags(1 << 14);
+ pub const GET_ACCESSOR: SymbolFlags = SymbolFlags(1 << 15);
+ pub const SET_ACCESSOR: SymbolFlags = SymbolFlags(1 << 16);
+ pub const SIGNATURE: SymbolFlags = SymbolFlags(1 << 17);
+ pub const TYPE_PARAMETER: SymbolFlags = SymbolFlags(1 << 18);
+ pub const TYPE_ALIAS: SymbolFlags = SymbolFlags(1 << 19);
+ pub const EXPORT_VALUE: SymbolFlags = SymbolFlags(1 << 20);
+ pub const ALIAS: SymbolFlags = SymbolFlags(1 << 21);
+ pub const PROTOTYPE: SymbolFlags = SymbolFlags(1 << 22);
+ pub const EXPORT_STAR: SymbolFlags = SymbolFlags(1 << 23);
+ pub const OPTIONAL: SymbolFlags = SymbolFlags(1 << 24);
+ pub const TRANSIENT: SymbolFlags = SymbolFlags(1 << 25);
+ pub const ASSIGNMENT: SymbolFlags = SymbolFlags(1 << 26);
+ pub const MODULE_EXPORTS: SymbolFlags = SymbolFlags(1 << 27);
+ pub const REPLACEABLE_BY_METHOD: SymbolFlags = SymbolFlags(1 << 29);
+
+ pub const ENUM: SymbolFlags = SymbolFlags(Self::REGULAR_ENUM.0 | Self::CONST_ENUM.0);
+ pub const VARIABLE: SymbolFlags =
+ SymbolFlags(Self::FUNCTION_SCOPED_VARIABLE.0 | Self::BLOCK_SCOPED_VARIABLE.0);
+ pub const VALUE: SymbolFlags = SymbolFlags(
+ Self::VARIABLE.0
+ | Self::PROPERTY.0
+ | Self::ENUM_MEMBER.0
+ | Self::OBJECT_LITERAL.0
+ | Self::FUNCTION.0
+ | Self::CLASS.0
+ | Self::ENUM.0
+ | Self::VALUE_MODULE.0
+ | Self::METHOD.0
+ | Self::GET_ACCESSOR.0
+ | Self::SET_ACCESSOR.0,
+ );
+ pub const TYPE: SymbolFlags = SymbolFlags(
+ Self::CLASS.0
+ | Self::INTERFACE.0
+ | Self::ENUM.0
+ | Self::ENUM_MEMBER.0
+ | Self::TYPE_LITERAL.0
+ | Self::TYPE_PARAMETER.0
+ | Self::TYPE_ALIAS.0,
+ );
+ pub const ACCESSOR: SymbolFlags = SymbolFlags(Self::GET_ACCESSOR.0 | Self::SET_ACCESSOR.0);
+ /// All flags except the `GlobalLookup` sentinel — the `export =` excludes.
+ pub const ALL: SymbolFlags = SymbolFlags((1 << 30) - 1);
+
+ // --- *Excludes masks (verbatim from symbolflags.go) ---
+ pub const FUNCTION_SCOPED_VARIABLE_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::VALUE.0 & !Self::FUNCTION_SCOPED_VARIABLE.0);
+ pub const BLOCK_SCOPED_VARIABLE_EXCLUDES: SymbolFlags = Self::VALUE;
+ pub const PARAMETER_EXCLUDES: SymbolFlags = Self::VALUE;
+ pub const PROPERTY_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::VALUE.0 & !(Self::PROPERTY.0 | Self::ACCESSOR.0));
+ pub const ENUM_MEMBER_EXCLUDES: SymbolFlags = SymbolFlags(Self::VALUE.0 | Self::TYPE.0);
+ pub const FUNCTION_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::VALUE.0 & !(Self::FUNCTION.0 | Self::VALUE_MODULE.0 | Self::CLASS.0));
+ pub const CLASS_EXCLUDES: SymbolFlags = SymbolFlags(
+ (Self::VALUE.0 | Self::TYPE.0)
+ & !(Self::VALUE_MODULE.0 | Self::INTERFACE.0 | Self::FUNCTION.0),
+ );
+ pub const INTERFACE_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::TYPE.0 & !(Self::INTERFACE.0 | Self::CLASS.0));
+ pub const REGULAR_ENUM_EXCLUDES: SymbolFlags = SymbolFlags(
+ (Self::VALUE.0 | Self::TYPE.0) & !(Self::REGULAR_ENUM.0 | Self::VALUE_MODULE.0),
+ );
+ pub const CONST_ENUM_EXCLUDES: SymbolFlags =
+ SymbolFlags((Self::VALUE.0 | Self::TYPE.0) & !Self::CONST_ENUM.0);
+ pub const VALUE_MODULE_EXCLUDES: SymbolFlags = SymbolFlags(
+ Self::VALUE.0
+ & !(Self::FUNCTION.0 | Self::CLASS.0 | Self::REGULAR_ENUM.0 | Self::VALUE_MODULE.0),
+ );
+ pub const NAMESPACE_MODULE_EXCLUDES: SymbolFlags = Self::NONE;
+ pub const METHOD_EXCLUDES: SymbolFlags = SymbolFlags(Self::VALUE.0 & !Self::METHOD.0);
+ pub const GET_ACCESSOR_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::VALUE.0 & !(Self::SET_ACCESSOR.0 | Self::PROPERTY.0));
+ pub const SET_ACCESSOR_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::VALUE.0 & !(Self::GET_ACCESSOR.0 | Self::PROPERTY.0));
+ pub const ACCESSOR_EXCLUDES: SymbolFlags = SymbolFlags(Self::VALUE.0 & !Self::PROPERTY.0);
+ pub const TYPE_PARAMETER_EXCLUDES: SymbolFlags =
+ SymbolFlags(Self::TYPE.0 & !Self::TYPE_PARAMETER.0);
+ pub const TYPE_ALIAS_EXCLUDES: SymbolFlags = Self::TYPE;
+ pub const ALIAS_EXCLUDES: SymbolFlags = Self::ALIAS;
+
+ /// Whether any bit in `other` is set.
+ #[inline]
+ #[must_use]
+ pub const fn intersects(self, other: SymbolFlags) -> bool {
+ self.0 & other.0 != 0
+ }
+
+ /// Whether every bit in `other` is set.
+ #[inline]
+ #[must_use]
+ pub const fn contains(self, other: SymbolFlags) -> bool {
+ self.0 & other.0 == other.0
+ }
+
+ /// Set the bits in `other`.
+ #[inline]
+ pub fn insert(&mut self, other: SymbolFlags) {
+ self.0 |= other.0;
+ }
+
+ /// The union of two flag sets.
+ #[inline]
+ #[must_use]
+ pub const fn union(self, other: SymbolFlags) -> SymbolFlags {
+ SymbolFlags(self.0 | other.0)
+ }
+}
+
+/// One declaration attached to a symbol: the node's dense id and the source span
+/// the cascade points a diagnostic at (the declaration's *name* node, so the
+/// squiggle sits on the identifier, matching tsgo's `getNameOfDeclaration`).
+#[derive(Clone, Copy, Debug)]
+#[allow(dead_code)] // `node` is the future checker's declaration identity; the family cascade keys on `error_span`.
+pub struct Decl {
+ /// The declaration node's dense id (best-effort via the address map; not yet
+ /// consumed by the cascade, which keys on `error_span`).
+ pub node: NodeId,
+ /// The span the diagnostic points at (the declaration name, or the node when
+ /// it has no name).
+ pub error_span: Span,
+ /// The display name for the `{0}` message argument (the declaration's text).
+ pub display: Atom,
+ /// Whether this declaration is a *type* declaration (tsgo `IsTypeDeclaration`:
+ /// class / interface / enum / type-alias / type-parameter). The merge phase's
+ /// `undefined`-redeclaration check (TS2397) skips type declarations.
+ pub is_type_decl: bool,
+}
+
+/// A bound symbol: accumulated flags, its table key, its declarations, and the
+/// child tables a container owns.
+#[derive(Clone, Debug)]
+// `parent` mirrors tsgo's `Symbol` shape and is set by the bind but read by nothing
+// yet (hence the allow); the cascade + merge resolution read
+// `flags`/`name`/`decls`/`members`/`exports`.
+#[allow(dead_code)]
+pub struct Symbol {
+ /// The accumulated classification flags.
+ pub flags: SymbolFlags,
+ /// The table key (interned name).
+ pub name: Atom,
+ /// The declarations that formed this symbol (most have one).
+ pub decls: SmallVec<[Decl; 1]>,
+ /// The `members` table (instance members of a class/interface/type-literal).
+ pub members: Option,
+ /// The `exports` table (static members / module + enum exports).
+ pub exports: Option,
+ /// The parent symbol (the container whose table this symbol lives in);
+ /// recorded as tsgo does, unused by the cascade.
+ pub parent: Option,
+}
+
+impl Symbol {
+ /// A fresh symbol with the given flags and name and no declarations.
+ #[must_use]
+ pub fn new(flags: SymbolFlags, name: Atom) -> Symbol {
+ Symbol {
+ flags,
+ name,
+ decls: SmallVec::new(),
+ members: None,
+ exports: None,
+ parent: None,
+ }
+ }
+}
diff --git a/crates/tsv_check/src/check/duplicate_members.rs b/crates/tsv_check/src/check/duplicate_members.rs
new file mode 100644
index 000000000..eb8ea0c57
--- /dev/null
+++ b/crates/tsv_check/src/check/duplicate_members.rs
@@ -0,0 +1,507 @@
+//! The duplicate-member check — a syntactic port of tsgo's
+//! `checkObjectTypeForDuplicateDeclarations`.
+//!
+//! For one class body / interface-declaration body / type literal, this runs the
+//! two-map (instance / static) state machine tsgo runs, and on the transition into
+//! the `Reported` state re-scans the whole body (the `reportDuplicateMemberErrors`
+//! batch), emitting one **TS2300** per declaration whose (key, is_static) matches
+//! the offending bucket. It is deliberately **purely syntactic** — it never
+//! consults the binder's symbol tables (walking the shared interface member table
+//! would break declaration-merging), so it works off the AST members directly.
+//!
+//! Which members participate:
+//! - **classified** (feed the state machine): a class field / property signature
+//! (kind [`MemberKind::Property`]), a get/set accessor or an auto-`accessor`
+//! field (kind [`MemberKind::Accessor`]), and a constructor **parameter
+//! property** (kind `Property`, always instance).
+//! - **methods, call/construct/index signatures, static blocks** are *not*
+//! classified — they never drive a transition. A method still carries a name, so
+//! it participates in the batch (its symbol name can match a bucket), matching
+//! tsgo's `reportDuplicateMemberErrors` (which emits for any same-named member).
+//!
+//! Disjointness with the bind cascade is by construction: the binder reports on a
+//! same-table *flag* conflict (any pair touching a method, or a same-kind
+//! accessor/accessor), so those pairs never reach a classified→`Reported`
+//! transition here; this pass fires only on property/property and
+//! property/accessor pairs, which silent-merge in the binder. Where both do emit
+//! (e.g. `x; get x; m()`), the identical (span, code, args) diagnostics collapse in
+//! the program-wide sort/dedup — exactly as tsgo's binder + checker outputs do.
+//
+// tsgo: internal/checker/checker.go checkObjectTypeForDuplicateDeclarations (:3128)
+// + reportDuplicateMemberErrors
+
+use crate::diag::{Category, Diagnostic};
+use crate::ids::FileId;
+use crate::span_scan::{bracket_end, bracket_start};
+use tsv_lang::{FxHashMap, Span};
+use tsv_ts::ast::internal::{
+ ClassMember, Expression, Literal, LiteralValue, MethodKind, TSTypeElement,
+ TSTypeParameterDeclaration,
+};
+
+/// The per-body derivation context (source for names, file for spans).
+pub(super) struct MemberCtx<'a> {
+ pub source: &'a str,
+ pub file: FileId,
+}
+
+/// The two member classes tsgo's check distinguishes: `1` (property/property
+/// signature) and `2` (accessor). Methods and signatures are neither.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum MemberKind {
+ Property,
+ Accessor,
+}
+
+/// A body member reduced to what the check needs: its canonical grouping key, the
+/// display string its diagnostic message prints (`key` for a plain name, the raw
+/// bracket-inclusive `[ … ]` source for a computed key — matching tsgo's
+/// `symbolToString`), the span its diagnostic points at (the name node), its
+/// static-ness, whether it feeds the state machine (`classify`), and whether it is
+/// a constructor parameter property (batched irrespective of the bucket's
+/// static-ness, per tsgo).
+struct Entry {
+ key: String,
+ display: String,
+ span: Span,
+ is_static: bool,
+ classify: Option,
+ ctor_param: bool,
+}
+
+/// The state machine state for one (key, is_static) bucket — tsgo's `0/1/2/3`.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum State {
+ Unseen,
+ SeenProperty,
+ SeenAccessor,
+ Reported,
+}
+
+/// Check a class body for duplicate property/accessor declarations, appending
+/// TS2300 diagnostics to `out`.
+pub(super) fn check_class_members(
+ ctx: &MemberCtx<'_>,
+ members: &[ClassMember<'_>],
+ out: &mut Vec,
+) {
+ let entries = class_entries(ctx, members);
+ run(ctx, &entries, out);
+}
+
+/// Check an interface / type-literal body (a `TSTypeElement` list) for duplicate
+/// property/accessor signatures, appending TS2300 diagnostics to `out`.
+pub(super) fn check_type_elements(
+ ctx: &MemberCtx<'_>,
+ members: &[TSTypeElement<'_>],
+ out: &mut Vec,
+) {
+ let entries = type_element_entries(ctx, members);
+ run(ctx, &entries, out);
+}
+
+/// Build the entry list for a class body (constructor param-properties expanded in
+/// place; methods kept for the batch but unclassified).
+fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec {
+ let mut entries = Vec::new();
+ for member in members {
+ match member {
+ ClassMember::MethodDefinition(m) => match m.kind {
+ MethodKind::Constructor => {
+ for param in m.value.params {
+ if let Expression::TSParameterProperty(pp) = param
+ && let Some((key, span)) = param_property_key(ctx, pp.parameter)
+ {
+ entries.push(Entry {
+ display: key.clone(),
+ key,
+ span,
+ is_static: false,
+ classify: Some(MemberKind::Property),
+ ctor_param: true,
+ });
+ }
+ }
+ }
+ MethodKind::Method => {
+ if let Some((key, display, span)) = member_key(ctx, &m.key, m.computed) {
+ entries.push(Entry {
+ key,
+ display,
+ span,
+ is_static: m.is_static,
+ classify: None,
+ ctor_param: false,
+ });
+ }
+ }
+ MethodKind::Get | MethodKind::Set => {
+ if let Some((key, display, span)) = member_key(ctx, &m.key, m.computed) {
+ entries.push(Entry {
+ key,
+ display,
+ span,
+ is_static: m.is_static,
+ classify: Some(MemberKind::Accessor),
+ ctor_param: false,
+ });
+ }
+ }
+ },
+ ClassMember::PropertyDefinition(p) => {
+ if let Some((key, display, span)) = member_key(ctx, &p.key, p.computed) {
+ let classify = if p.accessor {
+ MemberKind::Accessor
+ } else {
+ MemberKind::Property
+ };
+ entries.push(Entry {
+ key,
+ display,
+ span,
+ is_static: p.is_static,
+ classify: Some(classify),
+ ctor_param: false,
+ });
+ }
+ }
+ ClassMember::StaticBlock(_) | ClassMember::IndexSignature(_) => {}
+ }
+ }
+ entries
+}
+
+/// Build the entry list for an interface / type-literal body. Every member is
+/// instance (no static); call/construct/index signatures carry no name.
+fn type_element_entries(ctx: &MemberCtx<'_>, members: &[TSTypeElement<'_>]) -> Vec {
+ let mut entries = Vec::new();
+ for member in members {
+ match member {
+ TSTypeElement::PropertySignature(p) => {
+ if let Some((key, display, span)) = member_key(ctx, &p.key, p.computed) {
+ entries.push(Entry {
+ key,
+ display,
+ span,
+ is_static: false,
+ classify: Some(MemberKind::Property),
+ ctor_param: false,
+ });
+ }
+ }
+ TSTypeElement::MethodSignature(m) => {
+ if let Some((key, display, span)) = member_key(ctx, &m.key, m.computed) {
+ let classify = match m.kind {
+ MethodKind::Get | MethodKind::Set => Some(MemberKind::Accessor),
+ // A plain method signature is unclassified but still batched.
+ _ => None,
+ };
+ entries.push(Entry {
+ key,
+ display,
+ span,
+ is_static: false,
+ classify,
+ ctor_param: false,
+ });
+ }
+ }
+ TSTypeElement::CallSignature(_)
+ | TSTypeElement::ConstructSignature(_)
+ | TSTypeElement::IndexSignature(_) => {}
+ }
+ }
+ entries
+}
+
+/// Run the state machine over `entries` (source order), firing the batch on each
+/// transition into `Reported`.
+fn run(ctx: &MemberCtx<'_>, entries: &[Entry], out: &mut Vec) {
+ let mut states: FxHashMap<(&str, bool), State> = FxHashMap::default();
+ for entry in entries {
+ let Some(kind) = entry.classify else { continue };
+ let bucket = (entry.key.as_str(), entry.is_static);
+ // Scope the mutable borrow so `fire_batch` (which reads `entries`, not
+ // `states`) can run after the transition is decided.
+ let transition = {
+ let state = states.entry(bucket).or_insert(State::Unseen);
+ match (*state, kind) {
+ (State::Unseen, MemberKind::Property) => {
+ *state = State::SeenProperty;
+ false
+ }
+ (State::Unseen, MemberKind::Accessor) => {
+ *state = State::SeenAccessor;
+ false
+ }
+ // A second property, or a property after an accessor — always an error.
+ (State::SeenProperty, _) | (State::SeenAccessor, MemberKind::Property) => {
+ *state = State::Reported;
+ true
+ }
+ // An accessor after an accessor is a legal get/set pair (the coarse
+ // kind can't tell get from set) — leave it to the binder's cascade.
+ (State::SeenAccessor, MemberKind::Accessor) => false,
+ (State::Reported, _) => false,
+ }
+ };
+ if transition {
+ fire_batch(ctx, entries, &entry.key, entry.is_static, out);
+ }
+ }
+}
+
+/// tsgo `reportDuplicateMemberErrors`: emit one TS2300 per declaration whose
+/// (key, is_static) matches the offending bucket. A constructor parameter property
+/// matches on key alone (tsgo's constructor branch ignores `checkStatic`).
+fn fire_batch(
+ ctx: &MemberCtx<'_>,
+ entries: &[Entry],
+ key: &str,
+ is_static: bool,
+ out: &mut Vec,
+) {
+ for entry in entries {
+ let matches = if entry.ctor_param {
+ entry.key == key
+ } else {
+ entry.key == key && entry.is_static == is_static
+ };
+ if matches {
+ out.push(make_2300(ctx.file, entry.span, &entry.display));
+ }
+ }
+}
+
+/// Build one `Duplicate identifier '{0}'.` diagnostic.
+fn make_2300(file: FileId, span: Span, display: &str) -> Diagnostic {
+ Diagnostic {
+ file: Some(file),
+ span,
+ code: 2300,
+ category: Category::Error,
+ message: format!("Duplicate identifier '{display}'."),
+ args: vec![display.to_string()],
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+}
+
+/// Check one type-parameter declaration's own parameters for duplicate names,
+/// appending a TS2300 at each later duplicate's name identifier.
+///
+/// A faithful port of tsgo's `checkTypeParameters` identity loop: for each param
+/// `i`, for each prior `j < i`, if they share a symbol — equivalently a name, since
+/// the binder silent-merges same-name type params (`TypeParameterExcludes` excludes
+/// `Type` but not `TypeParameter`, so no bind diagnostic fires) — emit `Duplicate
+/// identifier` at param `i`'s name. Scoped strictly to ONE declaration's own params:
+/// declaration-merged interfaces never cross-compare (that is the separate TS2428
+/// check, deliberately not ported here). The raw per-`(i, j)` push is intentional —
+/// the program-wide sort/dedup collapses the repeats a longer run produces
+/// (`` fires 1 at T₂ + 2 at T₃, deduping to 2).
+// tsgo: internal/checker/checker.go checkTypeParameters (:6979)
+// tsgo: internal/binder/binder.go bindTypeParameter (:1259) — TypeParameterExcludes
+// merges same-name type params silently (no bind diagnostic)
+pub(super) fn check_type_parameters(
+ ctx: &MemberCtx<'_>,
+ decl: &TSTypeParameterDeclaration<'_>,
+ out: &mut Vec,
+) {
+ for (i, param) in decl.params.iter().enumerate() {
+ let name = param.name.name(ctx.source);
+ for prior in &decl.params[..i] {
+ if prior.name.name(ctx.source) == name {
+ out.push(make_2300(ctx.file, param.name.name_span(), name));
+ }
+ }
+ }
+}
+
+/// Derive a member's `(grouping key, message display, name-node span)`. Returns
+/// `None` for a member with no stable key — a dynamic (non-literal) computed name,
+/// or a non-name key.
+///
+/// The **grouping key** is the canonical/decoded value (so `[0]` and `['0']`
+/// collide); the **display** is what tsgo's `symbolToString` prints — equal to the
+/// key for a plain name, but the raw bracket-inclusive `[ … ]` source for a
+/// computed key (`'["a"]'`, not `'a'`).
+fn member_key(
+ ctx: &MemberCtx<'_>,
+ key: &Expression<'_>,
+ computed: bool,
+) -> Option<(String, String, Span)> {
+ if computed {
+ // A computed name is a stable key only for a string/number literal; the
+ // diagnostic points at the whole `[ … ]` name node, so the span runs from
+ // the `[` to just past the `]` and the display is that raw source.
+ return match key {
+ Expression::Literal(lit)
+ if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) =>
+ {
+ let k = literal_key(ctx, lit)?;
+ let start = bracket_start(ctx.source, lit.span.start);
+ let end = bracket_end(ctx.source, lit.span.end);
+ let span = Span::new(start, end);
+ Some((k, span.extract(ctx.source).to_string(), span))
+ }
+ // A dynamic computed name is late-bound (bucket G, deferred) — skip.
+ _ => None,
+ };
+ }
+ match key {
+ Expression::Identifier(id) => {
+ let name = id.name(ctx.source).to_string();
+ Some((name.clone(), name, id.name_span()))
+ }
+ Expression::Literal(lit) => literal_key(ctx, lit).map(|k| (k.clone(), k, lit.span)),
+ Expression::PrivateIdentifier(pid) => {
+ // A `#name` — key it with the `#` so it never collides with the public
+ // `name`; the diagnostic covers the whole `#name` node.
+ let name = pid.name(ctx.source);
+ let keyed = format!("#{name}");
+ Some((keyed.clone(), keyed, pid.span))
+ }
+ _ => None,
+ }
+}
+
+/// The key of a constructor parameter property: the parameter identifier's name
+/// (unwrapping a default `= …`). `None` when the name is a binding pattern (tsgo's
+/// `!ast.IsBindingPattern(param.Name())` guard) — those contribute no member.
+fn param_property_key(ctx: &MemberCtx<'_>, parameter: &Expression<'_>) -> Option<(String, Span)> {
+ let inner = match parameter {
+ Expression::AssignmentPattern(a) => a.left,
+ other => other,
+ };
+ match inner {
+ Expression::Identifier(id) => Some((id.name(ctx.source).to_string(), id.name_span())),
+ _ => None,
+ }
+}
+
+/// The canonical key string of a literal property name: a string's decoded value,
+/// a decimal number's ECMA-262 `Number::toString` form (so `0`, `0.0`, `0e0` all key
+/// `0` and collide with the string `'0'`), a bigint's verbatim source (conservative —
+/// never over-collides). A non-decimal numeric literal (`0x0`, `0o7`, `1_0`) the
+/// parser currently decodes to `NaN` falls back to its verbatim source too, so those
+/// forms stay distinct rather than all keying `"NaN"`.
+fn literal_key(ctx: &MemberCtx<'_>, lit: &Literal<'_>) -> Option {
+ match &lit.value {
+ LiteralValue::String(cooked) => Some(cooked.resolve(lit.span, ctx.source).to_string()),
+ // A non-decimal numeric literal decodes to NaN upstream; key it on its
+ // verbatim source (like the bigint arm) so distinct forms stay distinct —
+ // `ecma_number_to_string(NaN)` would collapse them all to `"NaN"` and collide.
+ LiteralValue::Number(n) if n.is_nan() => Some(lit.span.extract(ctx.source).to_string()),
+ LiteralValue::Number(n) => Some(ecma_number_to_string(*n)),
+ LiteralValue::BigInt => Some(lit.span.extract(ctx.source).to_string()),
+ LiteralValue::Boolean(_) | LiteralValue::Null => None,
+ }
+}
+
+/// ECMA-262 `Number::toString` for a finite property-name value — the string tsgo
+/// keys a numeric member on (`jsnum.FromString(text).String()` via the scanner's
+/// `tokenValue`). Faithful to the spec's digit/exponent rules: `100` → `"100"`,
+/// `0.5` → `"0.5"`, `1e21` → `"1e+21"`, `1e-7` → `"1e-7"`. The shortest
+/// round-tripping significand comes from Rust's `{:e}` (Grisu), matching the spec's
+/// "s as small as possible".
+///
+/// Reusable free fn (the number→string helper the check family needs).
+// tsgo: internal/scanner/scanner.go scanNumber (tokenValue = jsnum.FromString(...).String())
+pub(crate) fn ecma_number_to_string(value: f64) -> String {
+ if value.is_nan() {
+ return "NaN".to_string();
+ }
+ if value == 0.0 {
+ // Covers +0 and -0 (both are `"0"`).
+ return "0".to_string();
+ }
+ if value.is_infinite() {
+ return if value < 0.0 { "-Infinity" } else { "Infinity" }.to_string();
+ }
+ let negative = value < 0.0;
+ let abs = value.abs();
+ // Rust's lower-exp form yields the shortest significand plus a base-10
+ // exponent, e.g. `2.55e2`. For any finite non-zero `f64` it is always
+ // `e`; the `else` fallbacks below never fire, but avoid a
+ // panic on the impossible.
+ let formatted = format!("{abs:e}");
+ let Some((mantissa, exp_str)) = formatted.split_once('e') else {
+ return formatted;
+ };
+ let Ok(exp) = exp_str.parse::() else {
+ return formatted;
+ };
+ let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
+ let k = i32::try_from(digits.len()).unwrap_or(i32::MAX); // significant-digit count
+ let n = exp + 1; // ECMA-262 `n`: the value is digits × 10^(n-k)
+ // `usize` views of the split points (each branch establishes `n > 0` first).
+ let n_split = usize::try_from(n).unwrap_or(0);
+
+ let mut out = String::new();
+ if negative {
+ out.push('-');
+ }
+ if k <= n && n <= 21 {
+ // Integer with trailing zeros.
+ out.push_str(&digits);
+ for _ in 0..(n - k) {
+ out.push('0');
+ }
+ } else if 0 < n && n <= 21 {
+ // Decimal point inside the digits.
+ out.push_str(&digits[..n_split]);
+ out.push('.');
+ out.push_str(&digits[n_split..]);
+ } else if -6 < n && n <= 0 {
+ // Leading `0.` and `-n` zeros.
+ out.push_str("0.");
+ for _ in 0..(-n) {
+ out.push('0');
+ }
+ out.push_str(&digits);
+ } else {
+ // Exponential form.
+ out.push_str(&digits[..1]);
+ if k > 1 {
+ out.push('.');
+ out.push_str(&digits[1..]);
+ }
+ out.push('e');
+ if n > 0 {
+ out.push('+');
+ } else {
+ out.push('-');
+ }
+ out.push_str(&(n - 1).abs().to_string());
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::ecma_number_to_string;
+
+ #[test]
+ fn ecma_number_to_string_integers_and_decimals() {
+ assert_eq!(ecma_number_to_string(0.0), "0");
+ assert_eq!(ecma_number_to_string(-0.0), "0");
+ // `0` and `0.0` and `0e0` all parse to the same f64 → same key (they collide).
+ // (A non-decimal `0x0` decodes to NaN upstream and is keyed on verbatim
+ // source instead — see `literal_key` — so it does NOT reach this helper.)
+ assert_eq!(ecma_number_to_string(1.0), "1");
+ assert_eq!(ecma_number_to_string(100.0), "100");
+ assert_eq!(ecma_number_to_string(255.0), "255");
+ assert_eq!(ecma_number_to_string(0.5), "0.5");
+ assert_eq!(ecma_number_to_string(2.5), "2.5");
+ assert_eq!(ecma_number_to_string(1.25), "1.25");
+ }
+
+ #[test]
+ fn ecma_number_to_string_exponent_thresholds() {
+ assert_eq!(ecma_number_to_string(1e21), "1e+21");
+ assert_eq!(ecma_number_to_string(1e-7), "1e-7");
+ assert_eq!(ecma_number_to_string(1e-6), "0.000001");
+ assert_eq!(ecma_number_to_string(1e20), "100000000000000000000");
+ assert_eq!(ecma_number_to_string(1.5e22), "1.5e+22");
+ }
+}
diff --git a/crates/tsv_check/src/check/mod.rs b/crates/tsv_check/src/check/mod.rs
new file mode 100644
index 000000000..f64705dd3
--- /dev/null
+++ b/crates/tsv_check/src/check/mod.rs
@@ -0,0 +1,869 @@
+//! The syntactic check pass — a standalone walk over `&Program` that emits the
+//! check-time diagnostics the binder's symbol cascade cannot (they are not
+//! same-table flag conflicts).
+//!
+//! It is deliberately **not** the binder: it never consults the symbol tables
+//! (walking the shared interface member table would break declaration-merging).
+//! It descends every syntactic position — class / interface / type-literal bodies,
+//! every type-annotation site (variable / parameter / return-type / predicate /
+//! function-type / union / intersection / assertion target / …), class and
+//! interface heritage type arguments, decorators (class / member / parameter),
+//! template-literal-type interpolations, and every type-parameter declaration — and
+//! runs a set of per-node checks. Those are the duplicate-member check and the
+//! type-parameter-identity check (both in [`duplicate_members`]), sharing the one
+//! descent.
+//!
+//! The output folds into each file's diagnostics in [`crate::program`], alongside
+//! the bind product, then the whole program is canonically sorted + deduped — so a
+//! diagnostic this pass and the binder both emit (identical span/code/args)
+//! collapses to one, exactly as tsgo's binder + checker outputs do.
+//
+// tsgo: internal/checker/checker.go checkSourceElement dispatch (the per-node
+// checks this walk ports piecemeal)
+
+mod duplicate_members;
+pub(crate) mod unreachable;
+
+use crate::diag::Diagnostic;
+use crate::ids::FileId;
+use duplicate_members::MemberCtx;
+use tsv_ts::ast::Program;
+use tsv_ts::ast::internal::{
+ ArrowFunctionBody, ClassBody, ClassMember, Decorator, ExportDefaultValue, Expression,
+ ForInOfLeft, ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSInterfaceDeclaration,
+ TSInterfaceHeritage, TSLiteralType, TSModuleDeclaration, TSModuleDeclarationBody, TSType,
+ TSTypeAnnotation, TSTypeElement, TSTypeParameterDeclaration, TSTypeParameterInstantiation,
+ VariableDeclaration,
+};
+
+/// Run the syntactic check pass over one parsed file, returning its check-time
+/// diagnostics (unsorted — the program-wide sort/dedup canonicalizes order).
+#[must_use]
+pub fn check_file_members(program: &Program<'_>, source: &str, file: FileId) -> Vec {
+ let mut walk = CheckWalk {
+ source,
+ file,
+ diagnostics: Vec::new(),
+ };
+ for stmt in program.body {
+ walk.visit_statement(stmt);
+ }
+ walk.diagnostics
+}
+
+/// The check walk's per-file state.
+struct CheckWalk<'a> {
+ source: &'a str,
+ file: FileId,
+ diagnostics: Vec,
+}
+
+impl<'a> CheckWalk<'a> {
+ /// The derivation context the per-node checks need (source + file).
+ /// Built from disjoint field copies (the references are `Copy`), so it does not
+ /// borrow `self` — leaving `self.diagnostics` free to borrow mutably alongside.
+ fn member_ctx(&self) -> MemberCtx<'a> {
+ MemberCtx {
+ source: self.source,
+ file: self.file,
+ }
+ }
+
+ // --- statements ----------------------------------------------------------
+
+ fn visit_statement(&mut self, stmt: &Statement<'_>) {
+ match stmt {
+ Statement::ExpressionStatement(s) => self.visit_expression(&s.expression),
+ Statement::VariableDeclaration(d) => self.visit_variable_declaration(d),
+ Statement::FunctionDeclaration(f) => self.check_function_common(
+ f.type_parameters.as_ref(),
+ f.params,
+ f.return_type.as_ref(),
+ f.body.body,
+ ),
+ Statement::TSDeclareFunction(f) => {
+ self.visit_type_params(f.type_parameters.as_ref());
+ self.visit_params(f.params);
+ self.visit_type_annotation_opt(f.return_type.as_ref());
+ }
+ Statement::ClassDeclaration(c) => self.check_class_common(
+ c.type_parameters.as_ref(),
+ c.decorators,
+ c.super_class,
+ c.super_type_parameters.as_ref(),
+ c.implements,
+ &c.body,
+ ),
+ Statement::TSInterfaceDeclaration(i) => self.check_interface_common(i),
+ Statement::TSTypeAliasDeclaration(t) => {
+ self.visit_type_params(t.type_parameters.as_ref());
+ self.visit_type(&t.type_annotation);
+ }
+ Statement::TSEnumDeclaration(e) => {
+ for member in e.members {
+ if let Some(init) = &member.initializer {
+ self.visit_expression(init);
+ }
+ }
+ }
+ Statement::TSModuleDeclaration(m) => self.visit_module_declaration(m),
+ Statement::ReturnStatement(s) => {
+ if let Some(a) = &s.argument {
+ self.visit_expression(a);
+ }
+ }
+ Statement::BlockStatement(b) => {
+ for s in b.body {
+ self.visit_statement(s);
+ }
+ }
+ Statement::IfStatement(s) => {
+ self.visit_expression(&s.test);
+ self.visit_statement(s.consequent);
+ if let Some(alt) = s.alternate {
+ self.visit_statement(alt);
+ }
+ }
+ Statement::ForStatement(s) => {
+ if let Some(init) = &s.init {
+ match init {
+ ForInit::VariableDeclaration(d) => self.visit_variable_declaration(d),
+ ForInit::Expression(e) => self.visit_expression(e),
+ }
+ }
+ if let Some(t) = &s.test {
+ self.visit_expression(t);
+ }
+ if let Some(u) = &s.update {
+ self.visit_expression(u);
+ }
+ self.visit_statement(s.body);
+ }
+ Statement::ForInStatement(s) => {
+ self.visit_for_left(&s.left);
+ self.visit_expression(&s.right);
+ self.visit_statement(s.body);
+ }
+ Statement::ForOfStatement(s) => {
+ self.visit_for_left(&s.left);
+ self.visit_expression(&s.right);
+ self.visit_statement(s.body);
+ }
+ Statement::WhileStatement(s) => {
+ self.visit_expression(&s.test);
+ self.visit_statement(s.body);
+ }
+ Statement::DoWhileStatement(s) => {
+ self.visit_statement(s.body);
+ self.visit_expression(&s.test);
+ }
+ Statement::SwitchStatement(s) => {
+ self.visit_expression(&s.discriminant);
+ for case in s.cases {
+ if let Some(t) = &case.test {
+ self.visit_expression(t);
+ }
+ for stmt in case.consequent {
+ self.visit_statement(stmt);
+ }
+ }
+ }
+ Statement::TryStatement(s) => {
+ for stmt in s.block.body {
+ self.visit_statement(stmt);
+ }
+ if let Some(h) = &s.handler {
+ if let Some(param) = &h.param {
+ self.visit_param(param);
+ }
+ for stmt in h.body.body {
+ self.visit_statement(stmt);
+ }
+ }
+ if let Some(f) = &s.finalizer {
+ for stmt in f.body {
+ self.visit_statement(stmt);
+ }
+ }
+ }
+ Statement::ThrowStatement(s) => self.visit_expression(&s.argument),
+ Statement::LabeledStatement(s) => self.visit_statement(s.body),
+ Statement::ExportNamedDeclaration(e) => {
+ if let Some(inner) = e.declaration {
+ self.visit_statement(inner);
+ }
+ }
+ Statement::ExportDefaultDeclaration(e) => self.visit_export_default(&e.declaration),
+ Statement::TSExportAssignment(ea) => self.visit_expression(&ea.expression),
+ Statement::ExportAllDeclaration(_)
+ | Statement::TSNamespaceExportDeclaration(_)
+ | Statement::ImportDeclaration(_)
+ | Statement::TSImportEqualsDeclaration(_)
+ | Statement::BreakStatement(_)
+ | Statement::ContinueStatement(_)
+ | Statement::EmptyStatement(_)
+ | Statement::DebuggerStatement(_) => {}
+ }
+ }
+
+ fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'_>) {
+ for d in decl.declarations {
+ self.visit_param(&d.id);
+ if let Some(init) = &d.init {
+ self.visit_expression(init);
+ }
+ }
+ }
+
+ fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) {
+ match left {
+ ForInOfLeft::VariableDeclaration(d) => self.visit_variable_declaration(d),
+ ForInOfLeft::Pattern(p) => self.visit_expression(p),
+ }
+ }
+
+ /// Descend a `namespace`/`module` body — a block, or the nested declaration a
+ /// dotted `namespace X.Y {}` parses to (recursed without cloning the node).
+ fn visit_module_declaration(&mut self, m: &TSModuleDeclaration<'_>) {
+ match &m.body {
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => {
+ for s in block.body {
+ self.visit_statement(s);
+ }
+ }
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => {
+ self.visit_module_declaration(nested);
+ }
+ None => {}
+ }
+ }
+
+ fn visit_export_default(&mut self, value: &ExportDefaultValue<'_>) {
+ match value {
+ ExportDefaultValue::Expression(e) => self.visit_expression(e),
+ ExportDefaultValue::FunctionDeclaration(f) => self.check_function_common(
+ f.type_parameters.as_ref(),
+ f.params,
+ f.return_type.as_ref(),
+ f.body.body,
+ ),
+ ExportDefaultValue::TSDeclareFunction(f) => {
+ self.visit_type_params(f.type_parameters.as_ref());
+ self.visit_params(f.params);
+ self.visit_type_annotation_opt(f.return_type.as_ref());
+ }
+ ExportDefaultValue::ClassDeclaration(c) => self.check_class_common(
+ c.type_parameters.as_ref(),
+ c.decorators,
+ c.super_class,
+ c.super_type_parameters.as_ref(),
+ c.implements,
+ &c.body,
+ ),
+ ExportDefaultValue::TSInterfaceDeclaration(i) => self.check_interface_common(i),
+ }
+ }
+
+ // --- shared declaration descents -----------------------------------------
+
+ /// The class descent shared by the declaration, export-default, and expression
+ /// forms: type parameters, heritage (decorators / `extends` / `implements`), and
+ /// the member body. The three sites are byte-identical across `ClassDeclaration`
+ /// and `ClassExpression` (distinct types with the same field shape).
+ fn check_class_common(
+ &mut self,
+ type_parameters: Option<&TSTypeParameterDeclaration<'_>>,
+ decorators: Option<&[Decorator<'_>]>,
+ super_class: Option<&Expression<'_>>,
+ super_type_parameters: Option<&TSTypeParameterInstantiation<'_>>,
+ implements: &[TSInterfaceHeritage<'_>],
+ body: &ClassBody<'_>,
+ ) {
+ self.visit_type_params(type_parameters);
+ self.visit_class_heritage(decorators, super_class, super_type_parameters, implements);
+ self.visit_class_body(body);
+ }
+
+ /// The interface descent shared by the declaration and export-default forms.
+ fn check_interface_common(&mut self, interface: &TSInterfaceDeclaration<'_>) {
+ self.visit_type_params(interface.type_parameters.as_ref());
+ self.visit_heritage_type_args(interface.extends);
+ self.visit_type_elements(interface.body.body);
+ }
+
+ /// The body-bearing function descent shared by the declaration, export-default,
+ /// and expression forms: type parameters, parameters, return type, then the body
+ /// statements. (The bodyless `TSDeclareFunction` arms share only the header, so
+ /// they stay inline.)
+ fn check_function_common(
+ &mut self,
+ type_parameters: Option<&TSTypeParameterDeclaration<'_>>,
+ params: &[Expression<'_>],
+ return_type: Option<&TSTypeAnnotation<'_>>,
+ body: &[Statement<'_>],
+ ) {
+ self.visit_type_params(type_parameters);
+ self.visit_params(params);
+ self.visit_type_annotation_opt(return_type);
+ for s in body {
+ self.visit_statement(s);
+ }
+ }
+
+ // --- expressions ---------------------------------------------------------
+
+ fn visit_expression(&mut self, expr: &Expression<'_>) {
+ use Expression as E;
+ match expr {
+ E::FunctionExpression(f) => self.check_function_common(
+ f.type_parameters.as_ref(),
+ f.params,
+ f.return_type.as_ref(),
+ f.body.body,
+ ),
+ E::ArrowFunctionExpression(a) => {
+ self.visit_type_params(a.type_parameters.as_ref());
+ self.visit_params(a.params);
+ self.visit_type_annotation_opt(a.return_type.as_ref());
+ match &a.body {
+ ArrowFunctionBody::Expression(e) => self.visit_expression(e),
+ ArrowFunctionBody::BlockStatement(b) => {
+ for s in b.body {
+ self.visit_statement(s);
+ }
+ }
+ }
+ }
+ E::ClassExpression(c) => self.check_class_common(
+ c.type_parameters.as_ref(),
+ c.decorators,
+ c.super_class,
+ c.super_type_parameters.as_ref(),
+ c.implements,
+ &c.body,
+ ),
+ E::TSAsExpression(t) => {
+ self.visit_expression(t.expression);
+ self.visit_type(t.type_annotation);
+ }
+ E::TSSatisfiesExpression(t) => {
+ self.visit_expression(t.expression);
+ self.visit_type(t.type_annotation);
+ }
+ E::TSTypeAssertion(t) => {
+ self.visit_type(t.type_annotation);
+ self.visit_expression(t.expression);
+ }
+ E::TSInstantiationExpression(t) => {
+ self.visit_expression(t.expression);
+ self.visit_type_args(&t.type_arguments);
+ }
+ E::TSNonNullExpression(t) => self.visit_expression(t.expression),
+ E::ParenthesizedExpression(p) => self.visit_expression(p.expression),
+ E::JsdocCast(c) => self.visit_expression(c.inner),
+ E::UnaryExpression(u) => self.visit_expression(u.argument),
+ E::UpdateExpression(u) => self.visit_expression(u.argument),
+ E::AwaitExpression(a) => self.visit_expression(a.argument),
+ E::YieldExpression(y) => {
+ if let Some(a) = y.argument {
+ self.visit_expression(a);
+ }
+ }
+ E::BinaryExpression(b) => {
+ self.visit_expression(b.left);
+ self.visit_expression(b.right);
+ }
+ E::AssignmentExpression(a) => {
+ self.visit_expression(a.left);
+ self.visit_expression(a.right);
+ }
+ E::ConditionalExpression(c) => {
+ self.visit_expression(c.test);
+ self.visit_expression(c.consequent);
+ self.visit_expression(c.alternate);
+ }
+ E::SequenceExpression(s) => {
+ for e in s.expressions {
+ self.visit_expression(e);
+ }
+ }
+ E::CallExpression(c) => {
+ self.visit_expression(c.callee);
+ if let Some(ta) = &c.type_arguments {
+ self.visit_type_args(ta);
+ }
+ for a in c.arguments {
+ self.visit_expression(a);
+ }
+ }
+ E::NewExpression(n) => {
+ self.visit_expression(n.callee);
+ if let Some(ta) = &n.type_arguments {
+ self.visit_type_args(ta);
+ }
+ for a in n.arguments {
+ self.visit_expression(a);
+ }
+ }
+ E::MemberExpression(m) => {
+ self.visit_expression(m.object);
+ self.visit_expression(m.property);
+ }
+ E::SpreadElement(s) => self.visit_expression(s.argument),
+ E::ArrayExpression(a) => {
+ for e in a.elements.iter().flatten() {
+ self.visit_expression(e);
+ }
+ }
+ E::ObjectExpression(o) => {
+ for prop in o.properties {
+ match prop {
+ ObjectProperty::Property(pr) => self.visit_expression(&pr.value),
+ ObjectProperty::SpreadElement(s) => self.visit_expression(s.argument),
+ }
+ }
+ }
+ E::TemplateLiteral(t) => {
+ for e in t.expressions {
+ self.visit_expression(e);
+ }
+ }
+ E::TaggedTemplateExpression(t) => {
+ self.visit_expression(t.tag);
+ if let Some(ta) = &t.type_arguments {
+ self.visit_type_args(ta);
+ }
+ for e in t.quasi.expressions {
+ self.visit_expression(e);
+ }
+ }
+ E::ImportExpression(i) => {
+ self.visit_expression(i.source);
+ if let Some(o) = i.options {
+ self.visit_expression(o);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ // --- classes / interfaces / type literals --------------------------------
+
+ /// Descend a decorator list — each decorator's expression can host a type
+ /// literal (`@dec({} as {x; x})`). tsgo's `checkSourceElement` checks decorators.
+ fn visit_decorators(&mut self, decorators: Option<&[Decorator<'_>]>) {
+ if let Some(decs) = decorators {
+ for d in decs {
+ self.visit_expression(&d.expression);
+ }
+ }
+ }
+
+ /// Descend a class's decorators + heritage: the `extends` expression and its
+ /// type arguments, and each `implements` clause's type arguments — every site a
+ /// type literal can hide (`extends Base<{x; x}>`). tsgo's `checkSourceElement`
+ /// descends the heritage type arguments and decorators.
+ fn visit_class_heritage(
+ &mut self,
+ decorators: Option<&[Decorator<'_>]>,
+ super_class: Option<&Expression<'_>>,
+ super_type_parameters: Option<&TSTypeParameterInstantiation<'_>>,
+ implements: &[TSInterfaceHeritage<'_>],
+ ) {
+ self.visit_decorators(decorators);
+ if let Some(sc) = super_class {
+ self.visit_expression(sc);
+ }
+ if let Some(tp) = super_type_parameters {
+ self.visit_type_args(tp);
+ }
+ self.visit_heritage_type_args(implements);
+ }
+
+ /// Descend each heritage clause's type arguments (shared by a class's
+ /// `implements` and an interface's `extends` — both are `TSInterfaceHeritage`).
+ fn visit_heritage_type_args(&mut self, heritages: &[TSInterfaceHeritage<'_>]) {
+ for h in heritages {
+ if let Some(ta) = &h.type_arguments {
+ self.visit_type_args(ta);
+ }
+ }
+ }
+
+ fn visit_class_body(&mut self, body: &ClassBody<'_>) {
+ let ctx = self.member_ctx();
+ duplicate_members::check_class_members(&ctx, body.body, &mut self.diagnostics);
+ for member in body.body {
+ match member {
+ ClassMember::MethodDefinition(m) => {
+ self.visit_decorators(m.decorators);
+ self.visit_type_params(m.value.type_parameters.as_ref());
+ self.visit_params(m.value.params);
+ self.visit_type_annotation_opt(m.value.return_type.as_ref());
+ for s in m.value.body.body {
+ self.visit_statement(s);
+ }
+ }
+ ClassMember::PropertyDefinition(p) => {
+ self.visit_decorators(p.decorators);
+ self.visit_type_annotation_opt(p.type_annotation.as_ref());
+ if let Some(v) = &p.value {
+ self.visit_expression(v);
+ }
+ }
+ ClassMember::StaticBlock(s) => {
+ for stmt in s.body {
+ self.visit_statement(stmt);
+ }
+ }
+ ClassMember::IndexSignature(i) => {
+ self.visit_type_annotation_opt(i.type_annotation.as_ref());
+ }
+ }
+ }
+ }
+
+ fn visit_type_elements(&mut self, members: &[TSTypeElement<'_>]) {
+ let ctx = self.member_ctx();
+ duplicate_members::check_type_elements(&ctx, members, &mut self.diagnostics);
+ for member in members {
+ match member {
+ TSTypeElement::PropertySignature(p) => {
+ self.visit_type_annotation_opt(p.type_annotation.as_ref());
+ }
+ TSTypeElement::MethodSignature(m) => {
+ self.visit_type_params(m.type_parameters.as_ref());
+ self.visit_params(m.params);
+ self.visit_type_annotation_opt(m.return_type.as_ref());
+ }
+ TSTypeElement::CallSignature(c) => {
+ self.visit_type_params(c.type_parameters.as_ref());
+ self.visit_params(c.params);
+ self.visit_type_annotation_opt(c.return_type.as_ref());
+ }
+ TSTypeElement::ConstructSignature(c) => {
+ self.visit_type_params(c.type_parameters.as_ref());
+ self.visit_params(c.params);
+ self.visit_type_annotation_opt(c.return_type.as_ref());
+ }
+ TSTypeElement::IndexSignature(i) => {
+ self.visit_type_annotation_opt(i.type_annotation.as_ref());
+ }
+ }
+ }
+ }
+
+ // --- parameters ----------------------------------------------------------
+
+ fn visit_params(&mut self, params: &[Expression<'_>]) {
+ for param in params {
+ self.visit_param(param);
+ }
+ }
+
+ fn visit_param(&mut self, param: &Expression<'_>) {
+ match param {
+ Expression::Identifier(id) => {
+ self.visit_decorators(id.decorators());
+ if let Some(ann) = id.type_annotation() {
+ self.visit_type_annotation(ann);
+ }
+ }
+ Expression::ObjectPattern(op) => {
+ self.visit_decorators(op.decorators);
+ if let Some(ann) = &op.type_annotation {
+ self.visit_type_annotation(ann);
+ }
+ for prop in op.properties {
+ match prop {
+ ObjectPatternProperty::Property(pr) => self.visit_param(&pr.value),
+ ObjectPatternProperty::RestElement(r) => self.visit_param(r.argument),
+ }
+ }
+ }
+ Expression::ArrayPattern(ap) => {
+ self.visit_decorators(ap.decorators);
+ if let Some(ann) = &ap.type_annotation {
+ self.visit_type_annotation(ann);
+ }
+ for el in ap.elements.iter().flatten() {
+ self.visit_param(el);
+ }
+ }
+ Expression::AssignmentPattern(a) => {
+ self.visit_decorators(a.decorators);
+ self.visit_param(a.left);
+ self.visit_expression(a.right);
+ }
+ Expression::RestElement(r) => {
+ if let Some(ann) = &r.type_annotation {
+ self.visit_type_annotation(ann);
+ }
+ self.visit_param(r.argument);
+ }
+ Expression::TSParameterProperty(pp) => self.visit_param(pp.parameter),
+ _ => {}
+ }
+ }
+
+ // --- types (general recursion) -------------------------------------------
+
+ fn visit_type_annotation_opt(&mut self, ann: Option<&TSTypeAnnotation<'_>>) {
+ if let Some(a) = ann {
+ self.visit_type_annotation(a);
+ }
+ }
+
+ fn visit_type_annotation(&mut self, ann: &TSTypeAnnotation<'_>) {
+ self.visit_type(ann.type_annotation);
+ }
+
+ fn visit_type_args(&mut self, args: &TSTypeParameterInstantiation<'_>) {
+ for t in args.params {
+ self.visit_type(t);
+ }
+ }
+
+ /// The per-type-parameter-declaration hook: the duplicate-name identity check
+ /// (`check_type_parameters`) plus the descent into each parameter's constraint
+ /// and default (both are types).
+ fn visit_type_params(&mut self, params: Option<&TSTypeParameterDeclaration<'_>>) {
+ if let Some(decl) = params {
+ let ctx = self.member_ctx();
+ duplicate_members::check_type_parameters(&ctx, decl, &mut self.diagnostics);
+ for p in decl.params {
+ if let Some(c) = p.constraint {
+ self.visit_type(c);
+ }
+ if let Some(d) = p.default {
+ self.visit_type(d);
+ }
+ }
+ }
+ }
+
+ fn visit_type(&mut self, ty: &TSType<'_>) {
+ match ty {
+ TSType::TypeLiteral(tl) => self.visit_type_elements(tl.members),
+ TSType::Array(a) => self.visit_type(a.element_type),
+ TSType::Union(u) => {
+ for t in u.types {
+ self.visit_type(t);
+ }
+ }
+ TSType::Intersection(i) => {
+ for t in i.types {
+ self.visit_type(t);
+ }
+ }
+ TSType::Parenthesized(p) => self.visit_type(p.type_annotation),
+ TSType::Function(f) => {
+ self.visit_type_params(f.type_parameters.as_ref());
+ self.visit_params(f.params);
+ self.visit_type_annotation(&f.return_type);
+ }
+ TSType::Constructor(c) => {
+ self.visit_type_params(c.type_parameters.as_ref());
+ self.visit_params(c.params);
+ self.visit_type_annotation(&c.return_type);
+ }
+ TSType::Tuple(t) => {
+ for e in t.element_types {
+ self.visit_type(e);
+ }
+ }
+ TSType::TypePredicate(p) => {
+ if let Some(t) = p.type_annotation {
+ self.visit_type(t);
+ }
+ }
+ TSType::Conditional(c) => {
+ self.visit_type(c.check_type);
+ self.visit_type(c.extends_type);
+ self.visit_type(c.true_type);
+ self.visit_type(c.false_type);
+ }
+ TSType::Mapped(m) => {
+ self.visit_type(m.type_parameter.constraint);
+ if let Some(nt) = m.name_type {
+ self.visit_type(nt);
+ }
+ if let Some(ta) = m.type_annotation {
+ self.visit_type(ta);
+ }
+ }
+ TSType::TypeOperator(o) => self.visit_type(o.type_annotation),
+ TSType::IndexedAccess(i) => {
+ self.visit_type(i.object_type);
+ self.visit_type(i.index_type);
+ }
+ TSType::Rest(r) => self.visit_type(r.type_annotation),
+ TSType::Optional(o) => self.visit_type(o.type_annotation),
+ TSType::NamedTupleMember(n) => self.visit_type(n.element_type),
+ TSType::Infer(inf) => {
+ if let Some(c) = inf.type_parameter.constraint {
+ self.visit_type(c);
+ }
+ if let Some(d) = inf.type_parameter.default {
+ self.visit_type(d);
+ }
+ }
+ TSType::TypeReference(r) => {
+ if let Some(args) = &r.type_arguments {
+ self.visit_type_args(args);
+ }
+ }
+ TSType::TypeQuery(q) => {
+ if let Some(args) = &q.type_arguments {
+ self.visit_type_args(args);
+ }
+ }
+ TSType::Import(i) => {
+ if let Some(args) = &i.type_arguments {
+ self.visit_type_args(args);
+ }
+ }
+ TSType::Literal(lit) => {
+ // A template-literal type's interpolations are types, which can be
+ // type literals (`` `p-${ {x; x} }` ``); every other literal type is a
+ // leaf.
+ if let TSLiteralType::TemplateLiteral(t) = lit {
+ for ty in t.types {
+ self.visit_type(ty);
+ }
+ }
+ }
+ TSType::Keyword(_) | TSType::ThisType(_) => {}
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bumpalo::Bump;
+
+ /// Run the check pass in isolation over `source`, returning the raw (unsorted,
+ /// un-deduped) diagnostic codes — the check pass alone, no binder, no
+ /// program-wide sort/dedup.
+ fn check_codes(source: &str) -> Vec {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(source, &arena).expect("parse");
+ check_file_members(&program, source, FileId::ROOT)
+ .iter()
+ .map(|d| d.code)
+ .collect()
+ }
+
+ #[test]
+ fn type_parameters_duplicate_fires_at_later_param() {
+ // The identity check emits one TS2300 (at the second occurrence); no binder.
+ assert_eq!(check_codes("function f() {}"), vec![2300]);
+ assert_eq!(check_codes("class C {}"), vec![2300]);
+ assert_eq!(check_codes("interface I {}"), vec![2300]);
+ // Distinct names never fire.
+ assert!(check_codes("function g() {}").is_empty());
+ }
+
+ #[test]
+ fn type_parameters_three_way_pushes_raw_before_dedup() {
+ // The raw per-(i, j) push: T₂ fires once (j=0), T₃ fires twice (j=0, j=1) —
+ // three diagnostics at two distinct spans. The program-wide sort/dedup (see
+ // the binder-side `diag_codes` test) later collapses the T₃ pair to one.
+ let arena = Bump::new();
+ let src = "function f() {}";
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ let diags = check_file_members(&program, src, FileId::ROOT);
+ assert_eq!(
+ diags.iter().map(|d| d.code).collect::>(),
+ vec![2300, 2300, 2300]
+ );
+ let distinct: std::collections::BTreeSet =
+ diags.iter().map(|d| d.span.start).collect();
+ assert_eq!(distinct.len(), 2, "two distinct spans (T2 once, T3 twice)");
+ }
+
+ #[test]
+ fn accessor_accessor_is_check_pass_noop() {
+ // The state machine leaves a same-named accessor/accessor pair to the binder
+ // (the coarse kind can't tell get from set), so the check pass emits nothing.
+ assert!(check_codes("class C { get x() {} get x() {} }").is_empty());
+ assert!(check_codes("class C { get x() {} set x(v) {} }").is_empty());
+ }
+
+ #[test]
+ fn static_and_instance_members_are_separate_buckets() {
+ // `static x` and instance `x` live in different (key, is_static) buckets, so
+ // the check pass never merges them into a duplicate.
+ assert!(check_codes("class C { static x = 1; x = 2; }").is_empty());
+ // A same-static-ness duplicate DOES fire — the separation is by
+ // (key, is_static), not by ignoring static.
+ assert_eq!(
+ check_codes("class C { static x = 1; static x = 2; }"),
+ vec![2300, 2300]
+ );
+ }
+
+ #[test]
+ fn heritage_and_decorator_and_template_type_literals_are_checked() {
+ // The walk descends class/interface heritage type arguments, decorators, and
+ // template-literal-type interpolations — every syntactic position a type
+ // literal can hide — so a duplicate member there is caught (two raw TS2300).
+ assert_eq!(
+ check_codes("class C extends Base<{x:number;x:string}> {}"),
+ vec![2300, 2300]
+ );
+ assert_eq!(
+ check_codes("interface I extends Base<{x;x}> {}"),
+ vec![2300, 2300]
+ );
+ assert_eq!(
+ check_codes("class C implements Base<{x;x}> {}"),
+ vec![2300, 2300]
+ );
+ assert_eq!(
+ check_codes("@dec({} as {x;x}) class C {}"),
+ vec![2300, 2300]
+ );
+ assert_eq!(
+ check_codes("class C { @dec({} as {x;x}) m() {} }"),
+ vec![2300, 2300]
+ );
+ assert_eq!(
+ check_codes("class C { @dec({} as {x;x}) p = 1; }"),
+ vec![2300, 2300]
+ );
+ // A constructor parameter's own decorator.
+ assert_eq!(
+ check_codes("class C { m(@dec({} as {x;x}) p) {} }"),
+ vec![2300, 2300]
+ );
+ assert_eq!(check_codes("type T = `p-${ {x;x} }`;"), vec![2300, 2300]);
+ }
+
+ #[test]
+ fn computed_literal_key_display_is_raw_bracket_source() {
+ // A computed key's message arg is the raw `[ … ]` source (tsgo's
+ // `symbolToString`), not the decoded value — while its grouping key stays the
+ // decoded value. Interface computed keys fire property/property in the check
+ // pass, so this exercises the check-side display in isolation.
+ let arena = Bump::new();
+ let src = "interface I { ['a']: number; ['a']: string; }";
+ let program = tsv_ts::parse(src, &arena).expect("parse");
+ let diags = check_file_members(&program, src, FileId::ROOT);
+ assert_eq!(diags.len(), 2);
+ assert!(diags.iter().all(|d| d.code == 2300));
+ assert!(diags.iter().all(|d| d.args == vec!["['a']".to_string()]));
+ }
+
+ #[test]
+ fn constructor_parameter_property_participates_in_batch() {
+ // A constructor parameter property (`public x`) is an instance property that
+ // matches the batch on key alone; paired with a field `x` the check pass
+ // fires at both (2 raw diagnostics — property/property silent-merges at bind).
+ assert_eq!(
+ check_codes("class C { constructor(public x: number) {} x = 1; }"),
+ vec![2300, 2300]
+ );
+ }
+}
diff --git a/crates/tsv_check/src/check/unreachable.rs b/crates/tsv_check/src/check/unreachable.rs
new file mode 100644
index 000000000..70d343808
--- /dev/null
+++ b/crates/tsv_check/src/check/unreachable.rs
@@ -0,0 +1,1077 @@
+//! The unreachable-code (TS7027) + unused-label (TS7028) shim — a **fast-path**
+//! reader of the flow product's `NODE_FLAGS_UNREACHABLE` bit.
+//!
+//! This is the first slice that consumes the flow product ([`crate::binder::flow`])
+//! and emits diagnostics. It ports the binder-set-bit branch of tsgo's
+//! `checkSourceElementUnreachable` / `isSourceElementUnreachable` and its
+//! `checkLabeledStatement` unused-label check — **only** the branch that reads the
+//! binder's `Unreachable` flag. The type-dependent fallback
+//! (`isReachableFlowNode` — never-returning signatures, assertion predicates,
+//! exhaustive switches) is `deferred_cfa`, out of scope.
+//!
+//! ## Two phases, split across bind and check
+//!
+//! The flag bit, the run grouping, and the const-enum / module-instance
+//! classification are **all syntactic** (bind-time, variant-independent). So the
+//! candidate table is built **once per unit** in `bind_program` while the AST +
+//! flow product are alive ([`build_candidates`]) and stored owned in the
+//! `BoundUnit` — keeping the `BoundProgram` C15-relocatable. Then per-variant
+//! [`UnreachableCandidates::emit`] applies the option filter (which members count
+//! as executable) and routes each surviving run — **error** into `diagnostics`
+//! (only when the option is explicit-`False`), **suggestion** into a separate
+//! `suggestions` sink (the default `Unknown`), which the conformance gate's
+//! expect-clean channel never inspects.
+//!
+//! ## Grouping (tsgo checker.go:2394-2439, forward scan only)
+//!
+//! Within a statement list, a maximal run of consecutive candidates (each a
+//! potentially-executable statement carrying the `Unreachable` bit) is recorded
+//! once. At emit time the run is split into sub-runs at members that fail the
+//! option filter (a `const enum` at `preserveConstEnums:false`, a non-instantiated
+//! module), and one TS7027 is emitted per surviving sub-run spanning
+//! `first.start → last.end`. A reported run's children are **not** descended for
+//! more runs — tsgo's `withinUnreachableCode` / `reportedUnreachableNodes`
+//! suppression, which here falls out of not descending into a candidate statement.
+//
+// tsgo: internal/checker/checker.go checkSourceElementUnreachable (2380-2439),
+// isSourceElementUnreachable (2441-2459), checkLabeledStatement (4190-4206),
+// errorOrSuggestion/addErrorOrSuggestion (13937-13957);
+// internal/ast/utilities.go GetModuleInstanceState / IsInstantiatedModule
+// (2294-2428), IsPotentiallyExecutableNode (4210).
+
+use crate::binder::flow::FlowProduct;
+use crate::binder::{BoundFile, NODE_FLAGS_UNREACHABLE, NodeKind, addr_of, statement_kind};
+use crate::diag::Diagnostic;
+use crate::ids::{FileId, NodeId};
+use crate::options::{CheckOptions, Tristate};
+use smallvec::SmallVec;
+use tsv_lang::{Comment, Span};
+use tsv_ts::ast::Program;
+use tsv_ts::ast::internal::{
+ ArrowFunctionBody, ClassMember, Decorator, ExportDefaultValue, Expression, ForInOfLeft,
+ ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSModuleDeclaration,
+ TSModuleDeclarationBody,
+};
+
+/// A namespace body's instantiation classification — tsgo's
+/// `ModuleInstanceState`, a **pure syntactic** fold of the body's declarations.
+///
+/// # tsgo
+/// `internal/ast/utilities.go` `ModuleInstanceState`.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum ModuleInstanceState {
+ /// Only interfaces / type aliases / non-exported imports — no value emitted.
+ NonInstantiated,
+ /// Produces a value (functions, classes, non-const enums, value exports).
+ Instantiated,
+ /// Contains only `const enum`s — instantiated iff `preserveConstEnums`.
+ ConstEnumOnly,
+}
+
+/// The per-candidate classification that decides whether an unreachable member is
+/// reportable under the option filter (tsgo `isSourceElementUnreachable`'s switch).
+#[derive(Clone, Copy, Debug)]
+enum CandidateKind {
+ /// Always reportable (class, plain statement, instantiated module, non-const
+ /// enum).
+ Plain,
+ /// A (possibly const) enum: reportable iff `!is_const || preserveConstEnums`.
+ Enum {
+ /// Whether this is a `const enum`.
+ is_const: bool,
+ },
+ /// A namespace/module: reportable iff `IsInstantiatedModule(state, preserve)`.
+ Module {
+ /// The syntactic instantiation state.
+ state: ModuleInstanceState,
+ },
+}
+
+/// One member of a contiguous unreachable run: its reportable span + its filter
+/// classification.
+#[derive(Clone, Copy, Debug)]
+struct RunMember {
+ span: Span,
+ kind: CandidateKind,
+}
+
+/// A maximal contiguous run of unreachable candidates in one statement list
+/// (variant-independent; the option filter splits it at emit time).
+#[derive(Clone, Debug)]
+struct CandidateRun {
+ members: SmallVec<[RunMember; 4]>,
+}
+
+/// The owned, arena-free per-unit unreachable/unused-label candidate table, built
+/// at bind time and consumed by [`UnreachableCandidates::emit`] per variant.
+/// C15-relocatable by construction (spans are `Copy`; nothing borrows the AST).
+#[derive(Clone, Debug, Default)]
+pub struct UnreachableCandidates {
+ /// Maximal unreachable-statement runs (TS7027 candidates).
+ runs: Vec,
+ /// Unreferenced-label identifier spans (TS7028 candidates), pre-order.
+ unused_labels: Vec,
+ /// Byte ranges of source lines suppressed by a preceding
+ /// `@ts-ignore` / `@ts-expect-error` directive (source-fixed, so
+ /// option-independent). A reachability diagnostic whose start falls in one is
+ /// dropped, matching tsc's `getDiagnosticsWithPrecedingDirectives`.
+ suppressed_ranges: Vec<(u32, u32)>,
+}
+
+impl UnreachableCandidates {
+ /// Emit the reachability diagnostics for one unit under `options`, routing each
+ /// to `diagnostics` (error category) or `suggestions` (suggestion category):
+ /// TS7027 per surviving unreachable sub-run, TS7028 per unreferenced label.
+ /// A `True` option skips its probe entirely; `False` → error; `Unknown` →
+ /// suggestion.
+ // `&CheckOptions` mirrors the `check_bound` threading (uniform + future-proof).
+ #[allow(clippy::trivially_copy_pass_by_ref)]
+ pub fn emit(
+ &self,
+ file: FileId,
+ options: &CheckOptions,
+ diagnostics: &mut Vec,
+ suggestions: &mut Vec,
+ ) {
+ // TODO: tsgo's `getDiagnosticsWithPrecedingDirectives` filters ALL bind+check
+ // diagnostics; tsv suppresses only this flow family (TS7027/7028), so a
+ // `@ts-ignore`'d dup-family diagnostic still emits as a family extra. Widening
+ // is deferred — the clean form is a final per-file pass over the concatenated
+ // bind+check(+merge) diagnostics, which entangles with multi-file merge
+ // cross-file attribution (a P3/multi-file concern).
+ // TS7027 — unreachable code. Skip the probe entirely at `True`.
+ if options.allow_unreachable_code != Tristate::True {
+ let is_error = options.allow_unreachable_code == Tristate::False;
+ let preserve = options.preserve_const_enums;
+ for run in &self.runs {
+ let mut i = 0;
+ while i < run.members.len() {
+ // Skip a filtered-out member (splits the run — the const-enum /
+ // non-instantiated-module gap).
+ if !passes(run.members[i].kind, preserve) {
+ i += 1;
+ continue;
+ }
+ // Absorb the contiguous filter-passing sub-run.
+ let start = run.members[i].span.start;
+ let mut end = run.members[i].span.end;
+ let mut j = i + 1;
+ while j < run.members.len() && passes(run.members[j].kind, preserve) {
+ end = run.members[j].span.end;
+ j += 1;
+ }
+ // A directive (`@ts-ignore` / `@ts-expect-error`) preceding the
+ // sub-run's start line drops an ERROR-category diagnostic; tsgo's
+ // suggestion collection bypasses directive filtering
+ // (`getSuggestionDiagnosticsWithChecker`), so a suggestion is kept.
+ if !(is_error && self.is_suppressed(start)) {
+ push(
+ diagnostics,
+ suggestions,
+ is_error,
+ file,
+ Span::new(start, end),
+ 7027,
+ "Unreachable code detected.",
+ );
+ }
+ i = j;
+ }
+ }
+ }
+ // TS7028 — unused label. One per unreferenced label identifier (no run
+ // grouping); span = the label identifier alone.
+ if options.allow_unused_labels != Tristate::True {
+ let is_error = options.allow_unused_labels == Tristate::False;
+ for &span in &self.unused_labels {
+ // Error-category only; a suggestion bypasses directive suppression.
+ if is_error && self.is_suppressed(span.start) {
+ continue;
+ }
+ push(
+ diagnostics,
+ suggestions,
+ is_error,
+ file,
+ span,
+ 7028,
+ "Unused label.",
+ );
+ }
+ }
+ }
+
+ /// Whether `start` (a diagnostic's start offset) falls on a directive-suppressed
+ /// line. The range list is tiny (usually empty), so a linear scan is fine.
+ fn is_suppressed(&self, start: u32) -> bool {
+ self.suppressed_ranges
+ .iter()
+ .any(|&(s, e)| start >= s && start < e)
+ }
+}
+
+/// Route a reachability diagnostic to the error sink or the suggestion sink.
+fn push(
+ diagnostics: &mut Vec,
+ suggestions: &mut Vec,
+ is_error: bool,
+ file: FileId,
+ span: Span,
+ code: u32,
+ message: &str,
+) {
+ if is_error {
+ diagnostics.push(Diagnostic::error(file, span, code, message));
+ } else {
+ suggestions.push(Diagnostic::suggestion(file, span, code, message));
+ }
+}
+
+/// Whether an unreachable member is reportable under `preserve_const_enums`
+/// (tsgo `isSourceElementUnreachable`'s enum/module switch).
+fn passes(kind: CandidateKind, preserve: bool) -> bool {
+ match kind {
+ CandidateKind::Plain => true,
+ CandidateKind::Enum { is_const } => !is_const || preserve,
+ CandidateKind::Module { state } => is_instantiated_module(state, preserve),
+ }
+}
+
+/// `IsInstantiatedModule` (tsgo utilities.go:2422).
+fn is_instantiated_module(state: ModuleInstanceState, preserve: bool) -> bool {
+ matches!(state, ModuleInstanceState::Instantiated)
+ || (preserve && matches!(state, ModuleInstanceState::ConstEnumOnly))
+}
+
+/// Build the per-unit candidate table from a parsed file's AST + the flow product
+/// (the `Unreachable`-bit source). Called once per parsed unit in `bind_program`.
+#[must_use]
+pub fn build_candidates(
+ program: &Program<'_>,
+ source: &str,
+ bound: &BoundFile,
+ flow: &FlowProduct,
+) -> UnreachableCandidates {
+ let mut walk = CandidateWalk {
+ bound,
+ flow,
+ runs: Vec::new(),
+ };
+ walk.visit_list(program.body);
+ let unused_labels = collect_unused_labels(bound, flow);
+ let suppressed_ranges = compute_suppressed_ranges(source, program.comments);
+ UnreachableCandidates {
+ runs: walk.runs,
+ unused_labels,
+ suppressed_ranges,
+ }
+}
+
+/// Compute the source lines suppressed by an `@ts-ignore` / `@ts-expect-error`
+/// directive — tsc's `getDiagnosticsWithPrecedingDirectives`. A directive on line
+/// `D` suppresses diagnostics on the following lines, scanning down through
+/// blank/comment lines up to and including the first code line it protects. Empty
+/// (the fast path) when the file has no directive comments.
+fn compute_suppressed_ranges(source: &str, comments: &[Comment]) -> Vec<(u32, u32)> {
+ // Key each directive off its comment's END offset: tsc attributes a directive
+ // to its comment's LAST line (`lastLineStart`), so a multi-line
+ // `/* … @ts-ignore */` protects the line after the `*/`, not after the `/*`. A
+ // `//` directive can't span lines, so `end` == `start`'s line for those.
+ let mut directive_ends: Vec = comments
+ .iter()
+ .filter(|c| is_directive_comment(c.content(source)))
+ .map(|c| c.span.end)
+ .collect();
+ if directive_ends.is_empty() {
+ return Vec::new();
+ }
+ directive_ends.sort_unstable();
+ let bytes = source.as_bytes();
+ let line_starts = compute_line_starts(source);
+ let mut ranges: Vec<(u32, u32)> = Vec::new();
+ for &directive_end in &directive_ends {
+ let mut l = line_of(&line_starts, directive_end) + 1;
+ while l < line_starts.len() {
+ let line_start = line_starts[l];
+ let line_end = line_starts
+ .get(l + 1)
+ .copied()
+ .unwrap_or(source.len() as u32);
+ ranges.push((line_start, line_end));
+ if is_comment_or_blank_line(bytes, line_start as usize) {
+ l += 1;
+ } else {
+ break; // the protected code line — stop
+ }
+ }
+ }
+ ranges.sort_unstable();
+ ranges
+}
+
+/// Byte offsets of each line start (`[0, …after each '\n']`).
+fn compute_line_starts(source: &str) -> Vec {
+ let mut starts = vec![0u32];
+ for (i, b) in source.bytes().enumerate() {
+ if b == b'\n' {
+ starts.push((i + 1) as u32);
+ }
+ }
+ starts
+}
+
+/// The line index (0-based) containing `offset`.
+fn line_of(line_starts: &[u32], offset: u32) -> usize {
+ match line_starts.binary_search(&offset) {
+ Ok(i) => i,
+ // `line_starts[0] == 0 <= offset`, so the insertion point is `>= 1`.
+ Err(i) => i - 1,
+ }
+}
+
+/// `isCommentOrBlankLine` (tsgo program.go:1427) — a line that is whitespace-only
+/// or begins (after indent) with `//`.
+fn is_comment_or_blank_line(bytes: &[u8], line_start: usize) -> bool {
+ let mut p = line_start;
+ while p < bytes.len() && (bytes[p] == b' ' || bytes[p] == b'\t') {
+ p += 1;
+ }
+ p == bytes.len()
+ || bytes[p] == b'\r'
+ || bytes[p] == b'\n'
+ || (p + 1 < bytes.len() && bytes[p] == b'/' && bytes[p + 1] == b'/')
+}
+
+/// Whether a comment's (delimiter-stripped) content is an `@ts-ignore` /
+/// `@ts-expect-error` directive (tsgo scanner.go `processCommentDirective`): skip
+/// leading whitespace + extra `/`/`*`, then require `@ts-ignore` / `@ts-expect-error`.
+fn is_directive_comment(content: &str) -> bool {
+ let t = content
+ .trim_start()
+ .trim_start_matches(['/', '*'])
+ .trim_start();
+ t.strip_prefix('@')
+ .is_some_and(|r| r.starts_with("ts-ignore") || r.starts_with("ts-expect-error"))
+}
+
+/// Scan the SoA columns for unreferenced-label identifiers: a node carrying the
+/// `Unreachable` bit whose kind is `Identifier` and whose parent is a
+/// `LabeledStatement` (the only `Identifier` child of a labeled statement is its
+/// label). The bit is set on such a label only by `bindLabeledStatement` for an
+/// unreferenced label, so this is exact and needs no traversal.
+fn collect_unused_labels(bound: &BoundFile, flow: &FlowProduct) -> Vec {
+ let mut out = Vec::new();
+ for i in 0..bound.node_count as usize {
+ if flow.node_flags[i] & NODE_FLAGS_UNREACHABLE == 0
+ || bound.kinds[i] != NodeKind::Identifier
+ {
+ continue;
+ }
+ if let Some(parent) = bound.parents[i]
+ && bound.kinds[parent.index()] == NodeKind::LabeledStatement
+ {
+ out.push(bound.spans[i]);
+ }
+ }
+ out
+}
+
+/// The candidate-collection walk over the value structure of one file.
+struct CandidateWalk<'a> {
+ bound: &'a BoundFile,
+ flow: &'a FlowProduct,
+ runs: Vec,
+}
+
+impl CandidateWalk<'_> {
+ /// The `NodeId` of a statement if it carries the `Unreachable` bit (a
+ /// candidate). Only potentially-executable statements ever get the bit
+ /// (`bindChildren`'s dead path gates on `IsPotentiallyExecutableNode`), so a
+ /// bit-bearing statement *is* a candidate. A safe (non-panicking) address
+ /// lookup — a miss (never expected) simply treats the statement as
+ /// non-candidate.
+ fn candidate_id(&self, stmt: &Statement<'_>) -> Option {
+ let id = self
+ .bound
+ .address_map
+ .get(&(addr_of(stmt), statement_kind(stmt)))
+ .copied()?;
+ (self.flow.node_flags[id.index()] & NODE_FLAGS_UNREACHABLE != 0).then_some(id)
+ }
+
+ /// Process a statement list: group maximal runs of consecutive candidates
+ /// (recorded, not descended — the suppression), and descend every
+ /// non-candidate statement to find dead code in its nested lists / bodies.
+ fn visit_list(&mut self, stmts: &[Statement<'_>]) {
+ let mut i = 0;
+ while i < stmts.len() {
+ let Some(id) = self.candidate_id(&stmts[i]) else {
+ self.descend(&stmts[i]);
+ i += 1;
+ continue;
+ };
+ // Start a maximal run; a candidate's subtree is suppressed (not
+ // descended), matching tsgo's `withinUnreachableCode`.
+ let mut members: SmallVec<[RunMember; 4]> = SmallVec::new();
+ members.push(RunMember {
+ span: self.bound.spans[id.index()],
+ kind: classify(&stmts[i]),
+ });
+ i += 1;
+ while i < stmts.len() {
+ let Some(id) = self.candidate_id(&stmts[i]) else {
+ break;
+ };
+ members.push(RunMember {
+ span: self.bound.spans[id.index()],
+ kind: classify(&stmts[i]),
+ });
+ i += 1;
+ }
+ self.runs.push(CandidateRun { members });
+ }
+ }
+
+ /// Descend a **non-candidate** statement's nested statement positions and value
+ /// expressions (an embedded arrow/function/class body can hide dead code).
+ fn descend(&mut self, stmt: &Statement<'_>) {
+ use Statement as S;
+ match stmt {
+ S::ExpressionStatement(s) => self.visit_expr(&s.expression),
+ S::VariableDeclaration(d) => {
+ for decl in d.declarations {
+ self.visit_expr(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expr(init);
+ }
+ }
+ }
+ S::FunctionDeclaration(f) => {
+ self.visit_params(f.params);
+ self.visit_list(f.body.body);
+ }
+ S::ClassDeclaration(c) => {
+ self.visit_class(c.body.body, c.super_class, c.decorators);
+ }
+ S::TSEnumDeclaration(e) => {
+ for m in e.members {
+ if let Some(init) = &m.initializer {
+ self.visit_expr(init);
+ }
+ }
+ }
+ S::TSModuleDeclaration(m) => self.visit_module(m),
+ S::ReturnStatement(s) => {
+ if let Some(a) = &s.argument {
+ self.visit_expr(a);
+ }
+ }
+ S::ThrowStatement(s) => self.visit_expr(&s.argument),
+ S::BlockStatement(b) => self.visit_list(b.body),
+ S::IfStatement(s) => {
+ self.visit_expr(&s.test);
+ self.visit_list(std::slice::from_ref(s.consequent));
+ if let Some(alt) = s.alternate {
+ self.visit_list(std::slice::from_ref(alt));
+ }
+ }
+ S::ForStatement(s) => {
+ match &s.init {
+ Some(ForInit::VariableDeclaration(d)) => {
+ for decl in d.declarations {
+ self.visit_expr(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expr(init);
+ }
+ }
+ }
+ Some(ForInit::Expression(e)) => self.visit_expr(e),
+ None => {}
+ }
+ if let Some(t) = &s.test {
+ self.visit_expr(t);
+ }
+ if let Some(u) = &s.update {
+ self.visit_expr(u);
+ }
+ self.visit_list(std::slice::from_ref(s.body));
+ }
+ S::ForInStatement(s) => {
+ self.visit_for_left(&s.left);
+ self.visit_expr(&s.right);
+ self.visit_list(std::slice::from_ref(s.body));
+ }
+ S::ForOfStatement(s) => {
+ self.visit_for_left(&s.left);
+ self.visit_expr(&s.right);
+ self.visit_list(std::slice::from_ref(s.body));
+ }
+ S::WhileStatement(s) => {
+ self.visit_expr(&s.test);
+ self.visit_list(std::slice::from_ref(s.body));
+ }
+ S::DoWhileStatement(s) => {
+ self.visit_list(std::slice::from_ref(s.body));
+ self.visit_expr(&s.test);
+ }
+ S::SwitchStatement(s) => {
+ self.visit_expr(&s.discriminant);
+ for case in s.cases {
+ if let Some(t) = &case.test {
+ self.visit_expr(t);
+ }
+ self.visit_list(case.consequent);
+ }
+ }
+ S::TryStatement(s) => {
+ self.visit_list(s.block.body);
+ if let Some(h) = &s.handler {
+ if let Some(p) = &h.param {
+ self.visit_expr(p);
+ }
+ self.visit_list(h.body.body);
+ }
+ if let Some(f) = &s.finalizer {
+ self.visit_list(f.body);
+ }
+ }
+ S::LabeledStatement(s) => self.visit_list(std::slice::from_ref(s.body)),
+ S::ExportNamedDeclaration(e) => {
+ if let Some(inner) = e.declaration {
+ self.visit_list(std::slice::from_ref(inner));
+ }
+ }
+ S::ExportDefaultDeclaration(e) => self.visit_export_default(&e.declaration),
+ S::TSExportAssignment(ea) => self.visit_expr(&ea.expression),
+ // No value body to descend for dead code.
+ S::TSDeclareFunction(_)
+ | S::TSInterfaceDeclaration(_)
+ | S::TSTypeAliasDeclaration(_)
+ | S::ExportAllDeclaration(_)
+ | S::TSNamespaceExportDeclaration(_)
+ | S::ImportDeclaration(_)
+ | S::TSImportEqualsDeclaration(_)
+ | S::BreakStatement(_)
+ | S::ContinueStatement(_)
+ | S::EmptyStatement(_)
+ | S::DebuggerStatement(_) => {}
+ }
+ }
+
+ fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) {
+ match left {
+ ForInOfLeft::VariableDeclaration(d) => {
+ for decl in d.declarations {
+ self.visit_expr(&decl.id);
+ if let Some(init) = &decl.init {
+ self.visit_expr(init);
+ }
+ }
+ }
+ ForInOfLeft::Pattern(e) => self.visit_expr(e),
+ }
+ }
+
+ fn visit_export_default(&mut self, v: &ExportDefaultValue<'_>) {
+ use ExportDefaultValue as V;
+ match v {
+ V::Expression(e) => self.visit_expr(e),
+ V::FunctionDeclaration(f) => {
+ self.visit_params(f.params);
+ self.visit_list(f.body.body);
+ }
+ V::ClassDeclaration(c) => self.visit_class(c.body.body, c.super_class, c.decorators),
+ V::TSDeclareFunction(_) | V::TSInterfaceDeclaration(_) => {}
+ }
+ }
+
+ fn visit_class(
+ &mut self,
+ members: &[ClassMember<'_>],
+ super_class: Option<&Expression<'_>>,
+ decorators: Option<&[Decorator<'_>]>,
+ ) {
+ self.visit_decorators(decorators);
+ if let Some(sc) = super_class {
+ self.visit_expr(sc);
+ }
+ for member in members {
+ match member {
+ ClassMember::MethodDefinition(m) => {
+ self.visit_decorators(m.decorators);
+ self.visit_params(m.value.params);
+ self.visit_list(m.value.body.body);
+ }
+ ClassMember::PropertyDefinition(p) => {
+ self.visit_decorators(p.decorators);
+ if let Some(v) = &p.value {
+ self.visit_expr(v);
+ }
+ }
+ ClassMember::StaticBlock(s) => self.visit_list(s.body),
+ ClassMember::IndexSignature(_) => {}
+ }
+ }
+ }
+
+ fn visit_module(&mut self, m: &TSModuleDeclaration<'_>) {
+ match &m.body {
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => self.visit_list(block.body),
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => self.visit_module(nested),
+ None => {}
+ }
+ }
+
+ fn visit_decorators(&mut self, decorators: Option<&[Decorator<'_>]>) {
+ if let Some(decs) = decorators {
+ for d in decs {
+ self.visit_expr(&d.expression);
+ }
+ }
+ }
+
+ fn visit_params(&mut self, params: &[Expression<'_>]) {
+ for p in params {
+ self.visit_param(p);
+ }
+ }
+
+ fn visit_param(&mut self, param: &Expression<'_>) {
+ use Expression as E;
+ match param {
+ E::AssignmentPattern(a) => {
+ self.visit_param(a.left);
+ self.visit_expr(a.right);
+ }
+ E::ObjectPattern(op) => {
+ for prop in op.properties {
+ match prop {
+ ObjectPatternProperty::Property(pr) => self.visit_param(&pr.value),
+ ObjectPatternProperty::RestElement(r) => self.visit_param(r.argument),
+ }
+ }
+ }
+ E::ArrayPattern(ap) => {
+ for el in ap.elements.iter().flatten() {
+ self.visit_param(el);
+ }
+ }
+ E::RestElement(r) => self.visit_param(r.argument),
+ E::TSParameterProperty(pp) => self.visit_param(pp.parameter),
+ _ => {}
+ }
+ }
+
+ /// Descend a value expression, looking for embedded function/arrow/class
+ /// bodies (which hold their own statement lists / dead code).
+ fn visit_expr(&mut self, expr: &Expression<'_>) {
+ use Expression as E;
+ match expr {
+ E::FunctionExpression(f) => {
+ self.visit_params(f.params);
+ self.visit_list(f.body.body);
+ }
+ E::ArrowFunctionExpression(a) => {
+ self.visit_params(a.params);
+ match &a.body {
+ ArrowFunctionBody::Expression(e) => self.visit_expr(e),
+ ArrowFunctionBody::BlockStatement(b) => self.visit_list(b.body),
+ }
+ }
+ E::ClassExpression(c) => self.visit_class(c.body.body, c.super_class, c.decorators),
+ E::TSAsExpression(t) => self.visit_expr(t.expression),
+ E::TSSatisfiesExpression(t) => self.visit_expr(t.expression),
+ E::TSTypeAssertion(t) => self.visit_expr(t.expression),
+ E::TSInstantiationExpression(t) => self.visit_expr(t.expression),
+ E::TSNonNullExpression(t) => self.visit_expr(t.expression),
+ E::ParenthesizedExpression(p) => self.visit_expr(p.expression),
+ E::JsdocCast(c) => self.visit_expr(c.inner),
+ E::UnaryExpression(u) => self.visit_expr(u.argument),
+ E::UpdateExpression(u) => self.visit_expr(u.argument),
+ E::AwaitExpression(a) => self.visit_expr(a.argument),
+ E::YieldExpression(y) => {
+ if let Some(a) = y.argument {
+ self.visit_expr(a);
+ }
+ }
+ E::BinaryExpression(b) => {
+ self.visit_expr(b.left);
+ self.visit_expr(b.right);
+ }
+ E::AssignmentExpression(a) => {
+ self.visit_expr(a.left);
+ self.visit_expr(a.right);
+ }
+ E::ConditionalExpression(c) => {
+ self.visit_expr(c.test);
+ self.visit_expr(c.consequent);
+ self.visit_expr(c.alternate);
+ }
+ E::SequenceExpression(s) => {
+ for e in s.expressions {
+ self.visit_expr(e);
+ }
+ }
+ E::CallExpression(c) => {
+ self.visit_expr(c.callee);
+ for a in c.arguments {
+ self.visit_expr(a);
+ }
+ }
+ E::NewExpression(n) => {
+ self.visit_expr(n.callee);
+ for a in n.arguments {
+ self.visit_expr(a);
+ }
+ }
+ E::MemberExpression(m) => {
+ self.visit_expr(m.object);
+ self.visit_expr(m.property);
+ }
+ E::SpreadElement(s) => self.visit_expr(s.argument),
+ E::ArrayExpression(a) => {
+ for e in a.elements.iter().flatten() {
+ self.visit_expr(e);
+ }
+ }
+ E::ObjectExpression(o) => {
+ for prop in o.properties {
+ match prop {
+ ObjectProperty::Property(pr) => {
+ self.visit_expr(&pr.key);
+ self.visit_expr(&pr.value);
+ }
+ ObjectProperty::SpreadElement(s) => self.visit_expr(s.argument),
+ }
+ }
+ }
+ E::TemplateLiteral(t) => {
+ for e in t.expressions {
+ self.visit_expr(e);
+ }
+ }
+ E::TaggedTemplateExpression(t) => {
+ self.visit_expr(t.tag);
+ for e in t.quasi.expressions {
+ self.visit_expr(e);
+ }
+ }
+ E::ImportExpression(i) => {
+ self.visit_expr(i.source);
+ if let Some(o) = i.options {
+ self.visit_expr(o);
+ }
+ }
+ E::AssignmentPattern(a) => {
+ self.visit_expr(a.left);
+ self.visit_expr(a.right);
+ }
+ E::ObjectPattern(op) => {
+ for prop in op.properties {
+ match prop {
+ ObjectPatternProperty::Property(pr) => {
+ self.visit_expr(&pr.key);
+ self.visit_expr(&pr.value);
+ }
+ ObjectPatternProperty::RestElement(r) => self.visit_expr(r.argument),
+ }
+ }
+ }
+ E::ArrayPattern(ap) => {
+ for el in ap.elements.iter().flatten() {
+ self.visit_expr(el);
+ }
+ }
+ E::RestElement(r) => self.visit_expr(r.argument),
+ E::TSParameterProperty(pp) => self.visit_expr(pp.parameter),
+ // Leaves (identifier / literal / this / super / meta / private / regex).
+ _ => {}
+ }
+ }
+}
+
+/// Classify a candidate statement for the option filter.
+fn classify(stmt: &Statement<'_>) -> CandidateKind {
+ match stmt {
+ Statement::TSEnumDeclaration(e) => CandidateKind::Enum {
+ is_const: e.r#const,
+ },
+ Statement::TSModuleDeclaration(m) => CandidateKind::Module {
+ state: module_instance_state(m),
+ },
+ _ => CandidateKind::Plain,
+ }
+}
+
+/// `GetModuleInstanceState` (tsgo utilities.go:2302) — a namespace with no body
+/// (`declare module 'x';`) is instantiated; otherwise fold its block.
+fn module_instance_state(m: &TSModuleDeclaration<'_>) -> ModuleInstanceState {
+ match &m.body {
+ None => ModuleInstanceState::Instantiated,
+ Some(TSModuleDeclarationBody::TSModuleBlock(block)) => block_instance_state(block.body),
+ Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => module_instance_state(nested),
+ }
+}
+
+/// The `ModuleBlock` fold of `getModuleInstanceStateWorker` (tsgo utilities.go:2363):
+/// non-instantiated unless a member is const-enum-only (→ const-enum-only) or
+/// instantiated (→ instantiated, short-circuit).
+fn block_instance_state(stmts: &[Statement<'_>]) -> ModuleInstanceState {
+ let mut state = ModuleInstanceState::NonInstantiated;
+ for stmt in stmts {
+ match statement_instance_state(stmt) {
+ ModuleInstanceState::NonInstantiated => {}
+ ModuleInstanceState::ConstEnumOnly => state = ModuleInstanceState::ConstEnumOnly,
+ ModuleInstanceState::Instantiated => return ModuleInstanceState::Instantiated,
+ }
+ }
+ state
+}
+
+/// The per-statement classification of `getModuleInstanceStateWorker`'s switch.
+fn statement_instance_state(stmt: &Statement<'_>) -> ModuleInstanceState {
+ use Statement as S;
+ match stmt {
+ S::TSInterfaceDeclaration(_) | S::TSTypeAliasDeclaration(_) => {
+ ModuleInstanceState::NonInstantiated
+ }
+ S::TSEnumDeclaration(e) => {
+ if e.r#const {
+ ModuleInstanceState::ConstEnumOnly
+ } else {
+ ModuleInstanceState::Instantiated
+ }
+ }
+ // A non-exported import/import-equals produces no value. tsv treats a bare
+ // import as non-instantiated; the exported `export import x = …` form (tsgo:
+ // instantiated) is a rare-in-dead-code residual, and under-reporting it is
+ // safe (a missing, never an extra).
+ S::ImportDeclaration(_) | S::TSImportEqualsDeclaration(_) => {
+ ModuleInstanceState::NonInstantiated
+ }
+ S::TSModuleDeclaration(nested) => module_instance_state(nested),
+ S::ExportNamedDeclaration(e) => match e.declaration {
+ // `export interface` / `export const enum` / `export const` — classify
+ // the wrapped declaration.
+ Some(inner) => statement_instance_state(inner),
+ // Bare `export { … }` alias targets: a faithful resolution
+ // (`getModuleInstanceStateForAliasTarget`) walks the enclosing scope per
+ // name — a type-only re-export folds to NonInstantiated. tsv does not
+ // resolve, so it takes the **NonInstantiated** default: under-reporting a
+ // value re-export is safe (a missing), whereas assuming Instantiated
+ // would over-report a type-only re-export as a false TS7027 extra (the
+ // dangerous direction). The residual is a dead namespace whose only
+ // member is a value re-export.
+ None => ModuleInstanceState::NonInstantiated,
+ },
+ _ => ModuleInstanceState::Instantiated,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::binder::{bind_file, flow::build_flow};
+ use crate::ids::FileId;
+ use bumpalo::Bump;
+
+ /// Emit both sinks under `options` and return them owned (the arena drops here
+ /// — the candidate table and diagnostics borrow nothing from it).
+ #[allow(clippy::trivially_copy_pass_by_ref)]
+ fn emit_both(source: &str, opts: &CheckOptions) -> (Vec, Vec) {
+ let arena = Bump::new();
+ let program = tsv_ts::parse(source, &arena).expect("parse");
+ let bound = bind_file(&program, source, FileId::ROOT);
+ let flow = build_flow(&program, source, &bound);
+ let cands = build_candidates(&program, source, &bound, &flow);
+ let mut diags = Vec::new();
+ let mut sugg = Vec::new();
+ cands.emit(FileId::ROOT, opts, &mut diags, &mut sugg);
+ (diags, sugg)
+ }
+
+ /// Emit TS7027/7028 as `(code, start, end)` (both sinks merged).
+ fn emit_codes(
+ source: &str,
+ allow_unreachable: Tristate,
+ allow_unused: Tristate,
+ preserve: bool,
+ ) -> Vec<(u32, u32, u32)> {
+ let opts = CheckOptions {
+ allow_unreachable_code: allow_unreachable,
+ allow_unused_labels: allow_unused,
+ preserve_const_enums: preserve,
+ };
+ let (diags, sugg) = emit_both(source, &opts);
+ diags
+ .iter()
+ .chain(sugg.iter())
+ .map(|d| (d.code, d.span.start, d.span.end))
+ .collect()
+ }
+
+ #[test]
+ fn contiguous_enum_run_merges_or_splits_by_preserve() {
+ // `enum A` then `const enum B` after a return: one merged run when
+ // preserving const enums, split (only A) otherwise.
+ let src = "function f() { return; enum A { X } const enum B { Y } }";
+ let merged = emit_codes(src, Tristate::False, Tristate::Unknown, true);
+ assert_eq!(merged.len(), 1, "preserve merges the two into one run");
+ let split = emit_codes(src, Tristate::False, Tristate::Unknown, false);
+ assert_eq!(split.len(), 1, "no-preserve keeps only the non-const enum");
+ // The merged span is strictly wider (absorbs `const enum B`).
+ assert!(merged[0].2 > split[0].2);
+ }
+
+ #[test]
+ fn lone_const_enum_reports_only_when_preserving() {
+ let src = "function f() { return; const enum B { Y } }";
+ assert_eq!(
+ emit_codes(src, Tristate::False, Tristate::Unknown, false).len(),
+ 0
+ );
+ assert_eq!(
+ emit_codes(src, Tristate::False, Tristate::Unknown, true).len(),
+ 1
+ );
+ }
+
+ #[test]
+ fn non_instantiated_module_never_reports() {
+ // A dead namespace containing only a type is non-instantiated → no TS7027,
+ // even preserving const enums.
+ let src = "function f() { return; namespace N { interface I {} } }";
+ assert_eq!(
+ emit_codes(src, Tristate::False, Tristate::Unknown, false).len(),
+ 0
+ );
+ assert_eq!(
+ emit_codes(src, Tristate::False, Tristate::Unknown, true).len(),
+ 0
+ );
+ // A namespace with a value export is instantiated → reports.
+ let src2 = "function f() { return; namespace N { export const v = 1; } }";
+ assert_eq!(
+ emit_codes(src2, Tristate::False, Tristate::Unknown, false).len(),
+ 1
+ );
+ }
+
+ #[test]
+ fn midrun_const_enum_splits_run_into_two() {
+ // `[plain, const enum, plain]` after a return: at preserve=false the const
+ // enum fails the filter and SPLITS the run into two separate TS7027 (the
+ // mid-run re-split path — a filtered gap in the middle of a run);
+ // preserve=true merges all three into one.
+ let src = "function f() { return; a; const enum B { Y } c; }";
+ let split = emit_codes(src, Tristate::False, Tristate::Unknown, false);
+ assert_eq!(
+ split.len(),
+ 2,
+ "the const enum splits the run into two TS7027"
+ );
+ assert!(split.iter().all(|d| d.0 == 7027));
+ let merged = emit_codes(src, Tristate::False, Tristate::Unknown, true);
+ assert_eq!(merged.len(), 1, "preserve merges all three into one run");
+ }
+
+ #[test]
+ fn bare_export_alias_target_is_non_instantiated() {
+ // Regression (F3 review): a dead namespace whose only members are a type
+ // and a bare `export { … }` re-export must NOT over-report. tsv can't
+ // resolve the alias, so it defaults to NonInstantiated — assuming
+ // Instantiated would over-report a type-only re-export as a false TS7027
+ // extra (the dangerous direction).
+ let src = "function f() { return; namespace N { interface I {} export { I }; } }";
+ assert_eq!(
+ emit_codes(src, Tristate::False, Tristate::Unknown, false).len(),
+ 0
+ );
+ assert_eq!(
+ emit_codes(src, Tristate::False, Tristate::Unknown, true).len(),
+ 0
+ );
+ }
+
+ #[test]
+ fn unused_label_span_is_the_identifier() {
+ let src = "loop: while (true) { break; }";
+ let out = emit_codes(src, Tristate::Unknown, Tristate::False, false);
+ assert_eq!(out.len(), 1);
+ assert_eq!(out[0].0, 7028);
+ // Span covers `loop` (4 bytes at offset 0).
+ assert_eq!((out[0].1, out[0].2), (0, 4));
+ }
+
+ #[test]
+ fn suggestion_vs_error_routing() {
+ let src = "function f() { return; foo(); }";
+ // Unknown → suggestion sink; False → error sink; True → neither.
+ for (opt, in_err, in_sugg) in [
+ (Tristate::Unknown, 0, 1),
+ (Tristate::False, 1, 0),
+ (Tristate::True, 0, 0),
+ ] {
+ let opts = CheckOptions {
+ allow_unreachable_code: opt,
+ allow_unused_labels: Tristate::Unknown,
+ preserve_const_enums: false,
+ };
+ let (diags, sugg) = emit_both(src, &opts);
+ assert_eq!(diags.len(), in_err, "error sink for {opt:?}");
+ assert_eq!(sugg.len(), in_sugg, "suggestion sink for {opt:?}");
+ }
+ }
+
+ #[test]
+ fn ts_ignore_directive_suppresses_unreachable() {
+ // `@ts-ignore` / `@ts-expect-error` on the preceding line drops the TS7027
+ // entirely (neither sink) — tsc's comment-directive suppression.
+ let ignored =
+ "function a() {\n\tthrow new Error('');\n\t// @ts-ignore\n\tconsole.log('x');\n}";
+ assert_eq!(
+ emit_codes(ignored, Tristate::False, Tristate::Unknown, false).len(),
+ 0
+ );
+ let expect =
+ "function a() {\n\tthrow new Error('');\n\t// @ts-expect-error\n\tconsole.log('x');\n}";
+ assert_eq!(
+ emit_codes(expect, Tristate::False, Tristate::Unknown, false).len(),
+ 0
+ );
+ // A non-directive comment on the preceding line does NOT suppress.
+ let plain =
+ "function a() {\n\tthrow new Error('');\n\t// just a note\n\tconsole.log('x');\n}";
+ assert_eq!(
+ emit_codes(plain, Tristate::False, Tristate::Unknown, false).len(),
+ 1
+ );
+ }
+
+ #[test]
+ fn module_instance_state_classification() {
+ // Interface/type only → non-instantiated; const enum only → const-enum-only;
+ // a value → instantiated.
+ let arena = Bump::new();
+ let parse = |s: &str| tsv_ts::parse(s, &arena).expect("parse");
+ let state_of = |src: &str| -> ModuleInstanceState {
+ let program = parse(src);
+ match &program.body[0] {
+ Statement::TSModuleDeclaration(m) => module_instance_state(m),
+ _ => panic!("expected a module"),
+ }
+ };
+ assert_eq!(
+ state_of("namespace N { interface I {} type T = number; }"),
+ ModuleInstanceState::NonInstantiated
+ );
+ assert_eq!(
+ state_of("namespace N { const enum E { A } }"),
+ ModuleInstanceState::ConstEnumOnly
+ );
+ assert_eq!(
+ state_of("namespace N { export function f() {} }"),
+ ModuleInstanceState::Instantiated
+ );
+ }
+}
diff --git a/crates/tsv_check/src/diag.rs b/crates/tsv_check/src/diag.rs
new file mode 100644
index 000000000..de8e0987d
--- /dev/null
+++ b/crates/tsv_check/src/diag.rs
@@ -0,0 +1,504 @@
+//! The checker's `Diagnostic` representation and the sort/dedup kernel.
+//!
+//! `Diagnostic` is the shape a future checker emits; at this slice the checker
+//! emits none, so the value here is the **pure kernel** ports — the canonical
+//! sort ([`compare_diagnostics`], tsgo's `CompareDiagnostics`) and the
+//! dedup-with-related-info-merge ([`sort_and_deduplicate`], tsgo's
+//! `compactAndMergeRelatedInfos`) — unit-tested against every comparator leg.
+//! The canonical *sorted* order is a must-match property (baseline order is
+//! canonical regardless of production order), so these are semantic-core.
+//!
+//! A message chain and each related-info entry are modelled as nested
+//! `Diagnostic`s exactly as tsgo models them (`messageChain []*Diagnostic`,
+//! related information `[]*Diagnostic`) — the comparator reads only the fields
+//! each leg touches (chain nodes: code/args/chain; related nodes: the full
+//! diagnostic recursively). Message text is kept as an owned string here (the
+//! template-catalog codegen is a later phase); the comparator keys on `args`,
+//! never the rendered text, so this simplification is faithful.
+//
+// tsgo: internal/ast/diagnostic.go CompareDiagnostics (:329),
+// EqualDiagnosticsNoRelatedInfo (:265), equalMessageChain,
+// compareMessageChainSize/Content, compareRelatedInfo
+// tsgo: internal/compiler/program.go SortAndDeduplicateDiagnostics (:1436),
+// compactAndMergeRelatedInfos (:1444)
+
+use crate::ids::FileId;
+use std::cmp::Ordering;
+use tsv_lang::Span;
+
+/// The diagnostic category (tsc's `DiagnosticCategory`).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Category {
+ /// A warning (tsc emits these rarely).
+ Warning,
+ /// An error — the conformance-visible category.
+ Error,
+ /// A suggestion (opt-in, `@captureSuggestions`).
+ Suggestion,
+ /// An informational message.
+ Message,
+}
+
+/// One diagnostic — plus, when it is a message-chain or related-info node, its
+/// nested diagnostics.
+///
+/// The comparator/dedup read a node differently by role:
+/// - as a top-level diagnostic: path, span, code, args, chain, related info;
+/// - as a **chain** node: only code, args, and its own chain;
+/// - as a **related-info** node: the full diagnostic, recursively.
+#[derive(Clone, Debug)]
+pub struct Diagnostic {
+ /// The file this diagnostic points at; `None` for a global (fileless) one,
+ /// which sorts first (its path resolves to the empty string).
+ pub file: Option,
+ /// The source range — `Pos` (start) and `End` in the tsgo comparer.
+ pub span: Span,
+ /// The tsc numeric code (the checker emits positive TS codes).
+ pub code: u32,
+ /// The diagnostic category.
+ pub category: Category,
+ /// The rendered head message (display only — the comparer keys on `args`).
+ pub message: String,
+ /// Message-substitution arguments — tsgo's `MessageArgs` compared leg.
+ pub args: Vec,
+ /// The elaboration chain, each node a nested `Diagnostic`.
+ pub chain: Vec,
+ /// Related information, each entry a full nested `Diagnostic`.
+ pub related: Vec,
+}
+
+impl Diagnostic {
+ /// Build a bare error diagnostic (no chain, no related info) — the shape
+ /// the first family checks will emit.
+ #[must_use]
+ pub fn error(file: FileId, span: Span, code: u32, message: impl Into) -> Diagnostic {
+ Diagnostic {
+ file: Some(file),
+ span,
+ code,
+ category: Category::Error,
+ message: message.into(),
+ args: Vec::new(),
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+ }
+
+ /// Build a bare suggestion diagnostic — the same shape as [`Diagnostic::error`]
+ /// but [`Category::Suggestion`]. Suggestions are routed to a separate sink the
+ /// conformance gate's error channel never inspects (the default reachability
+ /// diagnostic when its option is unset).
+ #[must_use]
+ pub fn suggestion(
+ file: FileId,
+ span: Span,
+ code: u32,
+ message: impl Into,
+ ) -> Diagnostic {
+ Diagnostic {
+ file: Some(file),
+ span,
+ code,
+ category: Category::Suggestion,
+ message: message.into(),
+ args: Vec::new(),
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+ }
+}
+
+/// The diagnostic's sort path: the file's name, or `""` for a global one.
+fn diag_path<'a>(d: &Diagnostic, paths: &[&'a str]) -> &'a str {
+ match d.file {
+ Some(f) => paths.get(f.index()).copied().unwrap_or(""),
+ None => "",
+ }
+}
+
+/// Compare two diagnostics by tsgo's `CompareDiagnostics` total order:
+/// path -> start -> end -> code -> args -> chain size -> chain content ->
+/// related info. `paths` maps `FileId` -> file name.
+///
+/// `args` compares as `slices.Compare` (element-wise then length) — Rust's
+/// `Vec` ordering is exactly that.
+// tsgo: internal/ast/diagnostic.go CompareDiagnostics (:329)
+#[must_use]
+pub fn compare_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[&str]) -> Ordering {
+ diag_path(a, paths)
+ .cmp(diag_path(b, paths))
+ .then_with(|| a.span.start.cmp(&b.span.start))
+ .then_with(|| a.span.end.cmp(&b.span.end))
+ .then_with(|| a.code.cmp(&b.code))
+ .then_with(|| a.args.cmp(&b.args))
+ .then_with(|| compare_chain_size(&a.chain, &b.chain))
+ .then_with(|| compare_chain_content(&a.chain, &b.chain))
+ .then_with(|| compare_related_info(&a.related, &b.related, paths))
+}
+
+/// Compare message-chain size: **more elaboration sorts first**, then recurse
+/// pairwise (tsgo `compareMessageChainSize`, `len(c2) - len(c1)`).
+fn compare_chain_size(a: &[Diagnostic], b: &[Diagnostic]) -> Ordering {
+ b.len().cmp(&a.len()).then_with(|| {
+ for (ca, cb) in a.iter().zip(b) {
+ let c = compare_chain_size(&ca.chain, &cb.chain);
+ if c != Ordering::Equal {
+ return c;
+ }
+ }
+ Ordering::Equal
+ })
+}
+
+/// Compare message-chain content: per element, compare `args`, then recurse
+/// (tsgo `compareMessageChainContent`). Sizes are already equal when this runs.
+fn compare_chain_content(a: &[Diagnostic], b: &[Diagnostic]) -> Ordering {
+ for (ca, cb) in a.iter().zip(b) {
+ let c = ca.args.cmp(&cb.args);
+ if c != Ordering::Equal {
+ return c;
+ }
+ let c = compare_chain_content(&ca.chain, &cb.chain);
+ if c != Ordering::Equal {
+ return c;
+ }
+ }
+ Ordering::Equal
+}
+
+/// Compare related-info lists: **more related info sorts first**, then compare
+/// each entry as a full diagnostic (tsgo `compareRelatedInfo`).
+fn compare_related_info(a: &[Diagnostic], b: &[Diagnostic], paths: &[&str]) -> Ordering {
+ b.len().cmp(&a.len()).then_with(|| {
+ for (ra, rb) in a.iter().zip(b) {
+ let c = compare_diagnostics(ra, rb, paths);
+ if c != Ordering::Equal {
+ return c;
+ }
+ }
+ Ordering::Equal
+ })
+}
+
+/// Equality excluding related information (tsgo `EqualDiagnosticsNoRelatedInfo`):
+/// path, loc (start+end), code, args, and the full message chain.
+#[must_use]
+pub fn equal_no_related_info(a: &Diagnostic, b: &Diagnostic, paths: &[&str]) -> bool {
+ diag_path(a, paths) == diag_path(b, paths)
+ && a.span == b.span
+ && a.code == b.code
+ && a.args == b.args
+ && equal_chain(&a.chain, &b.chain)
+}
+
+/// Message-chain equality (tsgo `equalMessageChain`): code, args, chain —
+/// **not** path or loc.
+fn equal_chain(a: &[Diagnostic], b: &[Diagnostic]) -> bool {
+ a.len() == b.len()
+ && a.iter()
+ .zip(b)
+ .all(|(x, y)| x.code == y.code && x.args == y.args && equal_chain(&x.chain, &y.chain))
+}
+
+/// Full diagnostic equality (tsgo `EqualDiagnostics`): equal-no-related-info and
+/// related info equal recursively.
+#[must_use]
+pub fn equal_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[&str]) -> bool {
+ equal_no_related_info(a, b, paths)
+ && a.related.len() == b.related.len()
+ && a.related
+ .iter()
+ .zip(&b.related)
+ .all(|(x, y)| equal_diagnostics(x, y, paths))
+}
+
+/// Sort `diags` into canonical order and merge duplicates, faithful to tsgo's
+/// `SortAndDeduplicateDiagnostics` -> `compactAndMergeRelatedInfos`: a run of
+/// diagnostics equal except for related information collapses to the first, with
+/// their related infos concatenated, sorted, and deduped.
+// tsgo: internal/compiler/program.go SortAndDeduplicateDiagnostics (:1436)
+pub fn sort_and_deduplicate(diags: &mut Vec, paths: &[&str]) {
+ diags.sort_by(|a, b| compare_diagnostics(a, b, paths));
+ compact_and_merge_related_infos(diags, paths);
+}
+
+/// Collapse runs of `equal_no_related_info` diagnostics, merging related info
+/// (tsgo `compactAndMergeRelatedInfos`). Keeps the first of each run.
+// The inner `while let` can't become a `for`: the iterator is shared with the
+// outer run loop (a non-equal candidate becomes the next run's head), so it must
+// outlive the inner loop.
+#[allow(clippy::while_let_on_iterator)]
+fn compact_and_merge_related_infos(diags: &mut Vec, paths: &[&str]) {
+ if diags.len() < 2 {
+ return;
+ }
+ let mut out: Vec = Vec::with_capacity(diags.len());
+ let mut iter = std::mem::take(diags).into_iter();
+ let mut current = iter.next();
+ while let Some(mut head) = current.take() {
+ // Related infos across the whole run (the head's included, per tsgo).
+ let mut run_related: Vec = std::mem::take(&mut head.related);
+ let mut had_dupes = false;
+ // `current` stays `None` if the iterator empties — the run ends the list.
+ while let Some(candidate) = iter.next() {
+ if equal_no_related_info(&head, &candidate, paths) {
+ had_dupes = true;
+ run_related.extend(candidate.related);
+ } else {
+ current = Some(candidate);
+ break;
+ }
+ }
+ if had_dupes {
+ // tsgo sets merged related only when the run produced any; an
+ // all-empty run leaves the head's (empty) related untouched.
+ if !run_related.is_empty() {
+ run_related.sort_by(|a, b| compare_diagnostics(a, b, paths));
+ dedup_by_equal(&mut run_related, paths);
+ head.related = run_related;
+ }
+ } else {
+ // A singleton run keeps its related info verbatim.
+ head.related = run_related;
+ }
+ out.push(head);
+ }
+ *diags = out;
+}
+
+/// Remove adjacent `equal_diagnostics` duplicates, keeping the first (tsgo
+/// `slices.CompactFunc(_, EqualDiagnostics)` over the sorted related list).
+fn dedup_by_equal(diags: &mut Vec, paths: &[&str]) {
+ diags.dedup_by(|a, b| equal_diagnostics(a, b, paths));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn paths() -> Vec<&'static str> {
+ vec!["a.ts", "b.ts"]
+ }
+
+ fn diag(file: Option, start: u32, end: u32, code: u32) -> Diagnostic {
+ Diagnostic {
+ file: file.map(FileId),
+ span: Span::new(start, end),
+ code,
+ category: Category::Error,
+ message: String::new(),
+ args: Vec::new(),
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+ }
+
+ fn with_args(mut d: Diagnostic, args: &[&str]) -> Diagnostic {
+ d.args = args.iter().map(|s| (*s).to_string()).collect();
+ d
+ }
+
+ fn with_chain(mut d: Diagnostic, chain: Vec) -> Diagnostic {
+ d.chain = chain;
+ d
+ }
+
+ fn with_related(mut d: Diagnostic, related: Vec) -> Diagnostic {
+ d.related = related;
+ d
+ }
+
+ /// Assert `a` sorts strictly before `b` under the two-file `paths()`.
+ fn assert_lt(a: &Diagnostic, b: &Diagnostic) {
+ assert_eq!(compare_diagnostics(a, b, &paths()), Ordering::Less);
+ assert_eq!(compare_diagnostics(b, a, &paths()), Ordering::Greater);
+ }
+
+ #[test]
+ fn path_leg() {
+ // b.ts (FileId 1) sorts after a.ts (FileId 0); a global (None) first.
+ assert_lt(&diag(Some(0), 0, 0, 1), &diag(Some(1), 0, 0, 1));
+ assert_lt(&diag(None, 5, 5, 1), &diag(Some(0), 0, 0, 1));
+ }
+
+ #[test]
+ fn start_then_end_then_code_legs() {
+ assert_lt(&diag(Some(0), 1, 9, 1), &diag(Some(0), 2, 3, 1)); // start
+ assert_lt(&diag(Some(0), 2, 3, 1), &diag(Some(0), 2, 5, 1)); // same start, end tiebreak
+ assert_lt(&diag(Some(0), 2, 3, 1), &diag(Some(0), 2, 3, 9)); // same span, code tiebreak
+ }
+
+ #[test]
+ fn args_leg_is_slices_compare() {
+ let p = paths();
+ let a = with_args(diag(Some(0), 0, 0, 1), &["x"]);
+ let b = with_args(diag(Some(0), 0, 0, 1), &["y"]);
+ assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Less);
+ // shorter prefix sorts first
+ let short = with_args(diag(Some(0), 0, 0, 1), &["x"]);
+ let long = with_args(diag(Some(0), 0, 0, 1), &["x", "y"]);
+ assert_eq!(compare_diagnostics(&short, &long, &p), Ordering::Less);
+ }
+
+ #[test]
+ fn chain_size_leg_more_elaboration_first() {
+ let p = paths();
+ let child = diag(Some(0), 0, 0, 2);
+ let more = with_chain(diag(Some(0), 0, 0, 1), vec![child]);
+ let less = diag(Some(0), 0, 0, 1);
+ // more elaboration (non-empty chain) sorts first
+ assert_eq!(compare_diagnostics(&more, &less, &p), Ordering::Less);
+ assert_eq!(compare_diagnostics(&less, &more, &p), Ordering::Greater);
+ }
+
+ #[test]
+ fn chain_content_leg() {
+ let p = paths();
+ let a = with_chain(
+ diag(Some(0), 0, 0, 1),
+ vec![with_args(diag(Some(0), 0, 0, 2), &["a"])],
+ );
+ let b = with_chain(
+ diag(Some(0), 0, 0, 1),
+ vec![with_args(diag(Some(0), 0, 0, 2), &["b"])],
+ );
+ // same size, chain content (args) breaks the tie
+ assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Less);
+ }
+
+ #[test]
+ fn related_info_leg_count_then_recursive() {
+ let p = paths();
+ let r = diag(Some(0), 3, 4, 9);
+ let more = with_related(diag(Some(0), 0, 0, 1), vec![r]);
+ let less = diag(Some(0), 0, 0, 1);
+ // more related info sorts first
+ assert_eq!(compare_diagnostics(&more, &less, &p), Ordering::Less);
+ // same count, recursive compare of the related entries
+ let a = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 1, 2, 9)]);
+ let b = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 5, 6, 9)]);
+ assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Less);
+ }
+
+ #[test]
+ fn equal_ignores_related_but_reads_chain() {
+ let p = paths();
+ let base = diag(Some(0), 0, 0, 1);
+ let with_r = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 9, 9, 2)]);
+ assert!(equal_no_related_info(&base, &with_r, &p));
+ assert!(!equal_diagnostics(&base, &with_r, &p));
+ // a differing chain breaks even no-related equality
+ let with_c = with_chain(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 0, 0, 2)]);
+ assert!(!equal_no_related_info(&base, &with_c, &p));
+ }
+
+ #[test]
+ fn dedup_collapses_identical() {
+ let p = paths();
+ let mut v = vec![
+ diag(Some(0), 0, 0, 1),
+ diag(Some(0), 0, 0, 1),
+ diag(Some(0), 1, 1, 2),
+ ];
+ sort_and_deduplicate(&mut v, &p);
+ assert_eq!(v.len(), 2);
+ assert_eq!(v[0].code, 1);
+ assert_eq!(v[1].code, 2);
+ }
+
+ #[test]
+ fn dedup_merges_related_info_sorted_and_deduped() {
+ let p = paths();
+ // Two diagnostics equal-except-related-info; their related infos merge,
+ // sort, and dedup into the survivor.
+ let d1 = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 5, 6, 9)]);
+ let d2 = with_related(
+ diag(Some(0), 0, 0, 1),
+ vec![diag(Some(0), 1, 2, 9), diag(Some(0), 5, 6, 9)],
+ );
+ let mut v = vec![d1, d2];
+ sort_and_deduplicate(&mut v, &p);
+ assert_eq!(v.len(), 1);
+ // (1,2) and (5,6) survive once each, sorted by position.
+ assert_eq!(v[0].related.len(), 2);
+ assert_eq!(v[0].related[0].span, Span::new(1, 2));
+ assert_eq!(v[0].related[1].span, Span::new(5, 6));
+ }
+
+ #[test]
+ fn equality_and_order_ignore_message_text() {
+ // Message text is display-only — the comparer keys on `args`, never the
+ // rendered string (the template-catalog codegen is a later phase).
+ let p = paths();
+ let mut a = with_args(diag(Some(0), 0, 0, 2300), &["x"]);
+ a.message = "Duplicate identifier 'x'.".to_string();
+ let mut b = with_args(diag(Some(0), 0, 0, 2300), &["x"]);
+ b.message = "a completely different rendering".to_string();
+ assert!(equal_no_related_info(&a, &b, &p));
+ assert!(equal_diagnostics(&a, &b, &p));
+ assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Equal);
+ }
+
+ #[test]
+ fn depth_2_chain_recursion() {
+ // Two-level chains: outer -> mid -> leaf. Equal chains compare Equal;
+ // a differing leaf arg two levels down orders by that arg.
+ let p = paths();
+ let mid = with_chain(
+ diag(Some(0), 0, 0, 2),
+ vec![with_args(diag(Some(0), 0, 0, 3), &["x"])],
+ );
+ let a = with_chain(diag(Some(0), 0, 0, 1), vec![mid.clone()]);
+ let b = with_chain(diag(Some(0), 0, 0, 1), vec![mid]);
+ assert!(equal_no_related_info(&a, &b, &p));
+ assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Equal);
+
+ let mid_y = with_chain(
+ diag(Some(0), 0, 0, 2),
+ vec![with_args(diag(Some(0), 0, 0, 3), &["y"])],
+ );
+ let c = with_chain(diag(Some(0), 0, 0, 1), vec![mid_y]);
+ assert!(!equal_no_related_info(&a, &c, &p));
+ // Same chain sizes; the depth-2 content ("x" < "y") breaks the tie.
+ assert_eq!(compare_diagnostics(&a, &c, &p), Ordering::Less);
+ }
+
+ #[test]
+ fn depth_2_related_recursion() {
+ // Related info compares recursively as full diagnostics — including a
+ // related entry's own nested related.
+ let p = paths();
+ let outer_r = with_related(diag(Some(0), 3, 4, 5), vec![diag(Some(0), 7, 8, 9)]);
+ let a = with_related(diag(Some(0), 0, 0, 1), vec![outer_r.clone()]);
+ let b = with_related(diag(Some(0), 0, 0, 1), vec![outer_r]);
+ assert!(equal_diagnostics(&a, &b, &p));
+
+ // A differing nested related span breaks full equality — but related info
+ // is excluded from `equal_no_related_info`, so that stays true.
+ let outer_r2 = with_related(diag(Some(0), 3, 4, 5), vec![diag(Some(0), 100, 101, 9)]);
+ let c = with_related(diag(Some(0), 0, 0, 1), vec![outer_r2]);
+ assert!(!equal_diagnostics(&a, &c, &p));
+ assert!(equal_no_related_info(&a, &c, &p));
+ }
+
+ #[test]
+ fn sort_is_stable_total_order() {
+ let p = paths();
+ let mut v = vec![
+ diag(Some(1), 0, 0, 1),
+ diag(Some(0), 4, 5, 1),
+ diag(None, 0, 0, 7),
+ diag(Some(0), 4, 5, 1),
+ diag(Some(0), 1, 2, 1),
+ ];
+ sort_and_deduplicate(&mut v, &p);
+ // global first, then a.ts by position, then b.ts; the duplicate collapsed.
+ assert_eq!(v.len(), 4);
+ assert_eq!(v[0].file, None);
+ assert_eq!(v[1].file, Some(FileId(0)));
+ assert_eq!(v[1].span, Span::new(1, 2));
+ assert_eq!(v[2].file, Some(FileId(0)));
+ assert_eq!(v[2].span, Span::new(4, 5));
+ assert_eq!(v[3].file, Some(FileId(1)));
+ }
+}
diff --git a/crates/tsv_check/src/ids.rs b/crates/tsv_check/src/ids.rs
new file mode 100644
index 000000000..4d0c23146
--- /dev/null
+++ b/crates/tsv_check/src/ids.rs
@@ -0,0 +1,174 @@
+//! Dense integer identities for the checker's side tables.
+//!
+//! `NodeId` is a program-dense pre-order index over the AST nodes the binder
+//! addresses; `FileId` indexes the per-program file set. Both are `u32`-width
+//! newtypes — the checker's struct-of-arrays columns are `Vec`s indexed by
+//! these, so an id is a plain array offset. tsgo keys the same facts through
+//! global integer ids into flat `nodeLinks`/`symbolLinks` arrays; the deviation
+//! is that we assign the ids **eagerly** in the bind walk rather than lazily on
+//! first touch (unobservable, and it makes every column dense from the start).
+//!
+//! Distinct newtypes make cross-index bugs uncompilable (tsgo uses raw
+//! `uint32`s/pointers and relies on review) — a `NodeId` can never be used where
+//! a `FileId` is expected.
+//
+// tsgo: internal/ast/ids.go (NodeId/SymbolId are global atomic counters)
+
+use std::num::NonZeroU32;
+
+/// A dense, pre-order node identity assigned by the binder walk.
+///
+/// Ids start at 1 so `Option` niche-packs into 4 bytes — a `None` parent
+/// for the root costs no discriminant, the sentinel idiom without a magic
+/// `u32::MAX`. Convert to a 0-based column index with [`NodeId::index`].
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct NodeId(NonZeroU32);
+
+impl NodeId {
+ /// The first node id assigned in a pre-order walk (the program root).
+ pub const FIRST: NodeId = NodeId(NonZeroU32::MIN);
+
+ /// Build a `NodeId` from a 0-based dense index (`index + 1`).
+ ///
+ /// Total by construction: real ASTs never approach `u32::MAX` nodes, but a
+ /// wrap is clamped to [`NodeId::FIRST`] rather than panicking — the crate's
+ /// `unwrap_used` / `panic` clippy lints are warn-level, so this stays
+ /// panic-free by construction rather than by an `#[allow]` at a fallible call.
+ #[inline]
+ #[must_use]
+ pub fn from_index(index: usize) -> NodeId {
+ let raw = (index as u32).wrapping_add(1);
+ match NonZeroU32::new(raw) {
+ Some(n) => NodeId(n),
+ None => NodeId::FIRST,
+ }
+ }
+
+ /// The 0-based column index this id addresses (`id - 1`).
+ #[inline]
+ #[must_use]
+ pub const fn index(self) -> usize {
+ (self.0.get() - 1) as usize
+ }
+
+ /// The raw 1-based id value.
+ #[inline]
+ #[must_use]
+ pub const fn get(self) -> u32 {
+ self.0.get()
+ }
+
+ /// A `NodeId` from a raw 1-based value, or `None` for 0 (the "no node"
+ /// sentinel the flow graph's `subject` column uses).
+ #[inline]
+ #[must_use]
+ pub fn from_raw_opt(raw: u32) -> Option {
+ NonZeroU32::new(raw).map(NodeId)
+ }
+}
+
+/// A dense, per-file flow-node identity assigned by the flow-graph walk
+/// ([`crate::binder`]'s third walk).
+///
+/// Ids start at 1 (`Option` niche-packs into 4 bytes — the same
+/// idiom as [`NodeId`], the sentinel without a magic `u32::MAX`). The per-file
+/// `unreachableFlow` singleton is minted first, so it is **id 1 by
+/// construction** — tsgo's pointer-identity unreachable test becomes id
+/// equality against [`FlowNodeId::UNREACHABLE`].
+//
+// tsgo: internal/ast/ids.go / internal/binder/binder.go (flowNodeArena — a
+// per-file bump arena; ours is a per-file dense id space)
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct FlowNodeId(NonZeroU32);
+
+impl FlowNodeId {
+ /// The per-file `unreachableFlow` singleton — minted first, so id 1
+ /// (binder.go:126). Pointer identity in tsgo becomes id equality here.
+ pub const UNREACHABLE: FlowNodeId = FlowNodeId(NonZeroU32::MIN);
+
+ /// Build a `FlowNodeId` from a 0-based dense index (`index + 1`).
+ ///
+ /// Total by construction (a wrap clamps to [`FlowNodeId::UNREACHABLE`]
+ /// rather than panicking — the crate's `panic`/`unwrap_used` lints are
+ /// warn-level, so this stays panic-free without an `#[allow]`).
+ #[inline]
+ #[must_use]
+ pub fn from_index(index: usize) -> FlowNodeId {
+ let raw = (index as u32).wrapping_add(1);
+ match NonZeroU32::new(raw) {
+ Some(n) => FlowNodeId(n),
+ None => FlowNodeId::UNREACHABLE,
+ }
+ }
+
+ /// Build a `FlowNodeId` from a raw 1-based value, or `None` for 0 (the
+ /// "no antecedent" / "no subject" sentinel in the SoA columns).
+ #[inline]
+ #[must_use]
+ pub fn from_raw(raw: u32) -> Option {
+ NonZeroU32::new(raw).map(FlowNodeId)
+ }
+
+ /// The 0-based column index this id addresses (`id - 1`).
+ #[inline]
+ #[must_use]
+ pub const fn index(self) -> usize {
+ (self.0.get() - 1) as usize
+ }
+
+ /// The raw 1-based id value.
+ #[inline]
+ #[must_use]
+ pub const fn get(self) -> u32 {
+ self.0.get()
+ }
+}
+
+/// A dense per-program file identity (0-based). Single-file callers use
+/// [`FileId::ROOT`].
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
+pub struct FileId(pub u32);
+
+impl FileId {
+ /// The first file in a program (the single unit of a single-file test).
+ pub const ROOT: FileId = FileId(0);
+
+ /// The 0-based column index this id addresses.
+ #[inline]
+ #[must_use]
+ pub const fn index(self) -> usize {
+ self.0 as usize
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn node_id_index_round_trips() {
+ for i in [0usize, 1, 2, 41, 1000, 100_000] {
+ let id = NodeId::from_index(i);
+ assert_eq!(id.index(), i);
+ assert_eq!(id.get(), i as u32 + 1);
+ }
+ }
+
+ #[test]
+ fn first_id_is_one() {
+ assert_eq!(NodeId::FIRST.get(), 1);
+ assert_eq!(NodeId::from_index(0), NodeId::FIRST);
+ }
+
+ #[test]
+ fn option_node_id_is_four_bytes() {
+ // The niche is the whole point of starting ids at 1.
+ assert_eq!(size_of::>(), 4);
+ }
+
+ #[test]
+ fn file_id_root_and_index() {
+ assert_eq!(FileId::ROOT, FileId(0));
+ assert_eq!(FileId(3).index(), 3);
+ }
+}
diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs
new file mode 100644
index 000000000..ef06530e0
--- /dev/null
+++ b/crates/tsv_check/src/lib.rs
@@ -0,0 +1,83 @@
+//! `tsv_check` — experimental TypeScript type-checking for tsv.
+//!
+//! The checker consumes the `tsv_ts` internal AST and produces TypeScript
+//! diagnostics. It is a **consumer crate** of `tsv_ts`'s concrete types (the
+//! `tsv_svelte` precedent) — no `Language` trait, no registry, no dyn dispatch.
+//!
+//! ## Zero-cost invariant
+//!
+//! `tsv_check` is referenced only by the dev harness (`tsv_debug`); no
+//! `tsv_ffi`/`tsv_wasm`/`tsv_napi`/`tsv_cli` format-or-parse artifact links it.
+//! That exclusion is a crate boundary, not a cfg — stronger than a feature gate.
+//!
+//! ## Pipeline
+//!
+//! ```text
+//! source units (+ arena)
+//! -> parse (goal rule: Module first, Script retry) [program]
+//! -> lower+bind: SoA node-identity walk + symbol bind [binder/mod,sym]
+//! -> flow: per-file control-flow graph (third walk) [binder/flow]
+//! -> merge (single-threaded global-scope fold) [merge]
+//! -> check: syntactic per-node checks + the TS7027/7028
+//! unreachable/unused-label flow shim [check]
+//! -> sort + dedup (tsgo's comparer) [diag]
+//! -> owned diagnostics (+ a separate suggestion sink)
+//! ```
+//!
+//! [`check_program`] is the single entry point. The caller owns the parse arena
+//! (`&bumpalo::Bump`, the tsv_ts caller-owns-arena contract) and the returned
+//! [`CheckResult`] borrows nothing from it.
+//!
+//! ## Modules
+//!
+//! - [`ids`] — `NodeId` / `FileId` dense-integer identities.
+//! - [`diag`] — the `Diagnostic` shape and the canonical sort/dedup kernel.
+//! - `span_scan` (private) — the bracket-inclusive computed-key scan the binder
+//! and check pass share, so their spans agree by construction.
+//! - `binder` (private) — the two cooperating walks (pre-order SoA lowering +
+//! functions-first symbol bind) plus the third flow-construction walk.
+//! - `check` (private) — the syntactic per-node check pass ([`check_file_members`]:
+//! duplicate member declarations today; a general walk for future per-node
+//! checks) plus `unreachable` — the TS7027/TS7028 flow shim over the binder's
+//! flow product.
+//! - [`merge`] — the single-threaded global-scope fold (cross-declaration-space
+//! conflicts, `globalThis`/`undefined`, module augmentations).
+//! - `program` (private) — pipeline assembly: the unconditional per-unit
+//! bind+check concat (the baseline-oracle parity path — a parse-rejected unit
+//! contributes nothing, but never suppresses its siblings; the product-mode
+//! short-circuit is deliberately not modelled here).
+//!
+//! ## Reference-anchor convention
+//!
+//! Semantic-core functions carry a `// tsgo: ` pointer to their
+//! typescript-go counterpart (the lexer's ECMA-262-citation convention applied
+//! to the checker), so the port stays diffable against the oracle.
+
+mod binder;
+mod check;
+mod options;
+mod program;
+mod span_scan;
+
+pub mod diag;
+pub mod ids;
+pub mod merge;
+
+pub use binder::flow::{FlowFlags, FlowGraph, FlowProduct, FlowStats, build_flow, render_flow_dot};
+pub use binder::{
+ BoundFile, FileFacts, ModuleNess, NODE_FLAGS_UNREACHABLE, NodeKind, bind_file, module_ness,
+};
+pub use check::check_file_members;
+pub use check::unreachable::ModuleInstanceState;
+pub use diag::{Category, Diagnostic};
+pub use ids::{FileId, FlowNodeId, NodeId};
+pub use merge::{LibBase, LibFile};
+pub use options::{CheckOptions, Tristate};
+pub use program::{
+ BoundProgram, CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, bind_lib,
+ bind_program, check_bound, check_program, check_program_with_lib,
+};
+
+// Re-exported so consumers can name the parse goal a `ParsedFacts` reports
+// without a separate `tsv_ts` import.
+pub use tsv_ts::Goal;
diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs
new file mode 100644
index 000000000..60a8d2432
--- /dev/null
+++ b/crates/tsv_check/src/merge.rs
@@ -0,0 +1,1288 @@
+//! The single-threaded global merge — tsgo's `initializeChecker` merge sequence,
+//! ported for the merge-path family (TS2397 / TS2664 / TS2649 / TS2671 and the
+//! cross-declaration-space TS2300 / TS2451 / TS2567).
+//!
+//! Each file's bind produces a program-independent [`FileMerge`] product; this
+//! phase folds them into one global scope by tsgo's rules. The phase order is
+//! lifted verbatim from `initializeChecker` (checker.go:1296), **not** rediscovered:
+//!
+//! 1. regular locals of each **script** (non-external) file merge into the globals
+//! table (`mergeGlobalSymbol`), preceded by the per-file `globalThis` check;
+//! 2. **global** (`declare global`) augmentations merge their exports into globals;
+//! 3. the `undefined` redeclaration check (`addUndefinedToGlobalsOrErrorOnRedeclaration`);
+//! 4. global **ambient-module** declarations (deferred — they may need global
+//! symbols resolved; tsgo regression #2953) merge last among the globals;
+//! 5. non-global **module augmentations** (`declare module "X"`) resolve + merge.
+//!
+//! Iteration is **deterministic** (file order, then declaration order) — never a
+//! hash-map's iteration order (the grimoire-recorded tsgo determinism landmine).
+//!
+//! **Two-level globals (overlay + lib base).** A resolved lib set folds into an
+//! immutable [`LibBase`] (built once per distinct set, shared across variants);
+//! the merge maintains a per-program **overlay** it consults *before* the base
+//! ([`merge_global_symbol`]). A script's `var eval` / `class Symbol` /
+//! `class Promise`, or a `declare global` `interface ElementTagNameMap`, conflicts
+//! with the lib global of that name via the same `*Excludes` masks — issuing the
+//! observable test-file primary whose related info is the priority-ordered lib
+//! declarations (leading TS6203). Test symbols never mutate the base; on a conflict
+//! a base declaration's lib-local file id translates into program FileId space
+//! (`lib_file_offset + index`), so the lib file appends after the test units in the
+//! diagnostic path space and its masked primaries carry the lib file name. With no
+//! base (`lib = None`) the globals table starts empty, so the cross-space path
+//! ([`report_merge_symbol_error`]) is exercised only by a **multi-file** program.
+//!
+//! Module resolution is trivial single-file: an augmentation resolves iff an
+//! ambient module of that name exists in the same file, which for an
+//! external-module file is never — so a single-file augmentation is **always** "not
+//! found" (TS2664). The resolves-to-a-non-module errors (TS2649 / TS2671) need a
+//! multi-file resolution target and are structurally unreachable at single-file
+//! scope; their machinery is noted at the site, not emitted.
+//!
+//! `mergeSymbol`'s member/export **recursion** (merging a `declare global`
+//! interface *into* a lib interface's member table) is not modelled: the family
+//! keys on the top-level symbol conflict, and a legal interface↔interface merge is
+//! silent (no member table to reconcile for the duplicate-identifier verdict).
+//
+// tsgo: internal/checker/checker.go initializeChecker (:1296, the phase order),
+// mergeGlobalSymbol (:1386), mergeModuleAugmentation (:1397),
+// addUndefinedToGlobalsOrErrorOnRedeclaration (:1452), mergeSymbol (:14072),
+// reportMergeSymbolError (:14127), addDuplicateDeclarationError (:14158),
+// lookupOrIssueError (:14196), getExcludedSymbolFlags (:14213)
+
+use crate::binder::symbols::SymbolFlags;
+use crate::diag::{Category, Diagnostic};
+use crate::ids::FileId;
+use tsv_lang::{FxHashMap, Span};
+
+/// tsgo's `InternalSymbolNameDefault`-style reserved global identifiers the merge
+/// checks by name.
+const NAME_GLOBAL_THIS: &str = "globalThis";
+const NAME_UNDEFINED: &str = "undefined";
+
+/// The `Module` composite (tsgo `SymbolFlagsModule`): a namespace/ambient module.
+const MODULE_FLAGS: SymbolFlags =
+ SymbolFlags(SymbolFlags::VALUE_MODULE.0 | SymbolFlags::NAMESPACE_MODULE.0);
+
+/// tsgo `ast.IsAmbientModuleSymbolName` — a quoted module name (`"X"`), the key of
+/// a `declare module "X"` symbol.
+fn is_ambient_module_symbol_name(name: &str) -> bool {
+ name.starts_with('"') && name.ends_with('"')
+}
+
+/// One bound lib (`.d.ts`) file's global-eligible product — its owned
+/// [`FileMerge`] plus the display name a cross-file conflict points a masked
+/// diagnostic at. Arena-independent (owned strings), so the caller may drop the
+/// parse arena the moment the bind returns and reuse this across every variant.
+pub struct LibFile {
+ /// The lib file's display name (e.g. `lib.es5.d.ts`).
+ pub name: String,
+ /// The bound merge product (a lib is an ambient script — its globals live in
+ /// `source_locals`).
+ pub merge: FileMerge,
+}
+
+/// The immutable folded global scope of one resolved lib set — built once per
+/// distinct sorted lib list and shared across every variant that resolves to it
+/// (a test's globals consult it, never mutate it).
+///
+/// tsgo binds the lib files in **priority order** (`fileloader.go sortLibs`) before
+/// the test file, so each global symbol's declaration list is priority-ordered —
+/// which fixes the TS6203/6204 related-info attribution (highest-priority lib leads
+/// with TS6203). This mirror folds the same way and seeds `globalThis`
+/// (`VALUE_MODULE|NAMESPACE_MODULE`), matching `initializeChecker`'s pre-merge
+/// seed.
+pub struct LibBase {
+ /// The lib file names in priority order — the index is a **lib-local file id**;
+ /// a cross-file conflict translates it into program FileId space
+ /// (`lib_file_offset + index`).
+ pub lib_files: Vec,
+ /// The folded globals: symbol name -> its accumulated flags + priority-ordered
+ /// declarations.
+ globals: FxHashMap,
+}
+
+/// One folded lib global symbol.
+struct LibEntry {
+ flags: SymbolFlags,
+ /// Declarations in priority-then-source order (drives the TS6203/6204 order).
+ decls: Vec,
+}
+
+/// One declaration of a [`LibEntry`], keyed by its lib-local file id.
+struct LibDecl {
+ /// Index into [`LibBase::lib_files`] — the lib file this declaration lives in.
+ lib_file: u32,
+ error_span: Span,
+ is_type_decl: bool,
+}
+
+impl LibBase {
+ /// Build a lib base by folding the lib files' globals in the given order
+ /// (which **must** be priority order, so the related-info attribution matches
+ /// tsgo). Lib globals of the same name accumulate (flags union, declarations
+ /// appended) — well-formed libs merge cleanly, so no conflict is detected here.
+ ///
+ /// # Panics
+ ///
+ /// Never — the fold only inserts into an owned table.
+ #[must_use]
+ pub fn build(libs: &[&LibFile]) -> LibBase {
+ let mut globals: FxHashMap = FxHashMap::default();
+ // Seed `globalThis` (tsgo's `initializeChecker` seed) so a test's
+ // `var globalThis` hits the NamespaceModule guard rather than a stray merge.
+ globals.insert(
+ NAME_GLOBAL_THIS.to_string(),
+ LibEntry {
+ flags: MODULE_FLAGS,
+ decls: Vec::new(),
+ },
+ );
+ for (index, lib) in libs.iter().enumerate() {
+ let lib_file = index as u32;
+ // A lib is an ambient script: its globals are its source-file locals.
+ for sym in &lib.merge.source_locals {
+ fold_lib_symbol(&mut globals, sym, lib_file);
+ }
+ // A lib could theoretically carry a `declare global {}` block; fold its
+ // exports too (empty for the bundled libs, cheap to be safe).
+ for aug in &lib.merge.global_augmentations {
+ for sym in aug {
+ fold_lib_symbol(&mut globals, sym, lib_file);
+ }
+ }
+ }
+ LibBase {
+ lib_files: libs.iter().map(|l| l.name.clone()).collect(),
+ globals,
+ }
+ }
+
+ /// Look up a global by name.
+ fn get(&self, name: &str) -> Option<&LibEntry> {
+ self.globals.get(name)
+ }
+}
+
+/// Fold one lib symbol into the base globals (accumulate flags + priority-ordered
+/// declarations).
+fn fold_lib_symbol(globals: &mut FxHashMap, sym: &MergeSymbol, lib_file: u32) {
+ let entry = globals.entry(sym.name.clone()).or_insert_with(|| LibEntry {
+ flags: SymbolFlags::NONE,
+ decls: Vec::new(),
+ });
+ entry.flags.insert(sym.flags);
+ for decl in &sym.decls {
+ entry.decls.push(LibDecl {
+ lib_file,
+ error_span: decl.error_span,
+ is_type_decl: decl.is_type_decl,
+ });
+ }
+}
+
+/// The merge-relevant product of binding one file — program-independent (a C15
+/// requirement), fully resolved to owned strings so cross-file names reconcile by
+/// value with no shared interner.
+#[derive(Clone)]
+pub struct FileMerge {
+ /// The file these declarations belong to.
+ pub file: FileId,
+ /// Whether the file is an external module (its top-level members reach the
+ /// module's exports, **not** global scope — so `source_locals` is empty).
+ pub is_external: bool,
+ /// The source-file locals, in declaration order — the symbols a **script**
+ /// contributes to global scope (empty for an external module).
+ pub source_locals: Vec,
+ /// Each `declare global {}` augmentation's exports (its members merge into
+ /// globals), in source order.
+ pub global_augmentations: Vec>,
+ /// Non-global `declare module "X"` augmentations, in source order (deduped by
+ /// name in [`merge_program`], matching tsgo's first-declaration-only merge).
+ pub module_augmentations: Vec,
+}
+
+/// One symbol exposed to the merge: its accumulated flags, resolved name, and its
+/// declarations (each pointing a diagnostic at a name span).
+#[derive(Clone)]
+pub struct MergeSymbol {
+ /// The resolved symbol-table key (identifier text).
+ pub name: String,
+ /// The accumulated classification flags.
+ pub flags: SymbolFlags,
+ /// The declarations that formed this symbol, in declaration order.
+ pub decls: Vec,
+}
+
+/// One declaration of a [`MergeSymbol`], carrying its owning file so a cross-file
+/// conflict can point at declarations in either file.
+#[derive(Clone)]
+pub struct MergeDecl {
+ /// The file the declaration lives in.
+ pub file: FileId,
+ /// The span a diagnostic points at (the declaration name).
+ pub error_span: Span,
+ /// tsgo `IsTypeDeclaration` (class / interface / enum / type-alias /
+ /// type-parameter) — the `undefined` check skips these.
+ pub is_type_decl: bool,
+}
+
+/// A non-global `declare module "X"` augmentation: the unquoted module name (the
+/// `{0}` argument) and the string-literal span a TS2664 points at.
+#[derive(Clone)]
+pub struct ModuleAug {
+ /// The file the augmentation lives in.
+ pub file: FileId,
+ /// The unquoted module name (`"M"` → `M`).
+ pub name: String,
+ /// The string-literal span (points at the opening quote).
+ pub name_span: Span,
+}
+
+/// A live global-scope symbol accumulated across files.
+struct GlobalEntry {
+ name: String,
+ flags: SymbolFlags,
+ decls: Vec,
+}
+
+/// The merge phase's diagnostic sink.
+///
+/// tsgo's `lookupOrIssueError` keys its dedup on the **full** `CompareDiagnostics`
+/// (related-info length is a sort key), so a primary that has already accreted
+/// related info is never found again — every conflicting merge issues a *fresh*
+/// primary carrying its own leading TS6203, and the caller's final
+/// `compact_and_merge_related_infos` unions the related infos across the duplicate
+/// primaries at each node (the one-primary-per-node, all-6203, uncapped result).
+/// So the merge just pushes fresh primaries; there is no issued-index map here.
+struct MergeOut {
+ diags: Vec,
+}
+
+impl MergeOut {
+ fn new() -> MergeOut {
+ MergeOut { diags: Vec::new() }
+ }
+
+ /// Push a diagnostic.
+ fn push(&mut self, diag: Diagnostic) {
+ self.diags.push(diag);
+ }
+}
+
+/// Run the global merge across a program's per-file bind products, consulting an
+/// optional [`LibBase`], returning the merge diagnostics (unsorted — the caller
+/// concatenates and canonically sorts).
+///
+/// The **overlay** (a program's own globals) is consulted before the immutable
+/// **base** (the lib globals): a test symbol merges into a prior test symbol of the
+/// same name, else conflicts-or-merges against the base, else is inserted fresh.
+/// Test symbols never mutate the base, so the base is shared across variants. On a
+/// base conflict, a base declaration's lib-local file id is translated into program
+/// FileId space via `lib_file_offset` (= the number of program units), so the lib
+/// file appends after the test units in the diagnostic path space.
+#[must_use]
+pub fn merge_program(
+ files: &[&FileMerge],
+ lib: Option<&LibBase>,
+ lib_file_offset: u32,
+) -> Vec {
+ let mut out = MergeOut::new();
+ let mut globals: FxHashMap = FxHashMap::default();
+
+ // Ambient-module-name Module symbols (`declare module "X"` in a script) are
+ // deferred from phase 1 to the post-global-type phase — they may need other
+ // global symbols/types resolved first (tsgo regression #2953).
+ let mut deferred_ambient: Vec<&MergeSymbol> = Vec::new();
+
+ // --- Phase 1: script locals + the globalThis check (file order) ---
+ for &file in files {
+ if file.is_external {
+ continue;
+ }
+ // The globalThis check runs over the file's own locals, before merging.
+ if let Some(sym) = file
+ .source_locals
+ .iter()
+ .find(|s| s.name == NAME_GLOBAL_THIS)
+ {
+ for decl in &sym.decls {
+ out.push(conflict_2397(decl, NAME_GLOBAL_THIS));
+ }
+ }
+ for sym in &file.source_locals {
+ if sym.flags.intersects(MODULE_FLAGS) && is_ambient_module_symbol_name(&sym.name) {
+ deferred_ambient.push(sym);
+ } else {
+ merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out);
+ }
+ }
+ }
+
+ // --- Phase 2: global (`declare global`) augmentations ---
+ for &file in files {
+ for aug in &file.global_augmentations {
+ for sym in aug {
+ merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out);
+ }
+ }
+ }
+
+ // --- Phase 3: the `undefined` redeclaration check ---
+ // tsgo seeds `c.globals["undefined"]` with the builtin `undefinedSymbol`; with
+ // no lib base, `globals["undefined"]` is present iff a file declared it, so a
+ // present entry is exactly the redeclaration case.
+ if let Some(entry) = globals.get(NAME_UNDEFINED) {
+ for decl in &entry.decls {
+ if !decl.is_type_decl {
+ out.push(conflict_2397(decl, NAME_UNDEFINED));
+ }
+ }
+ }
+
+ // --- Phase 4: global ambient-module declarations (deferred) ---
+ // tsgo merges these past global-type creation (regression #2953). A script's
+ // `declare module "X"` merges into globals here; a conflict needs another
+ // globals symbol of the same quoted name (multi-file or lib), so at single-file
+ // scope it merges into empty globals with no diagnostic.
+ for sym in deferred_ambient {
+ merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out);
+ }
+
+ // --- Phase 5: non-global module augmentations (`declare module "X"`) ---
+ for &file in files {
+ // Dedup by name within the file (tsgo merges only a symbol's first
+ // declaration; same-name augmentations share one symbol).
+ let mut seen: Vec<&str> = Vec::new();
+ for aug in &file.module_augmentations {
+ if seen.contains(&aug.name.as_str()) {
+ continue;
+ }
+ seen.push(&aug.name);
+ merge_module_augmentation(aug, &mut out);
+ }
+ }
+
+ out.diags
+}
+
+/// tsgo `mergeGlobalSymbol` — merge one symbol into the globals table, reporting a
+/// cross-declaration-space conflict when the flags exclude each other. The overlay
+/// (a program's own accumulated globals) is consulted first, then the immutable
+/// lib base.
+fn merge_global_symbol(
+ globals: &mut FxHashMap,
+ lib: Option<&LibBase>,
+ lib_file_offset: u32,
+ source: &MergeSymbol,
+ out: &mut MergeOut,
+) {
+ if let Some(target) = globals.get_mut(&source.name) {
+ merge_symbol(target, source, out);
+ return;
+ }
+ if let Some(base) = lib.and_then(|l| l.get(&source.name)) {
+ // Conflict-or-merge against the immutable base without mutating it. This does
+ // NOT copy the base entry into the overlay, so a *later* phase that re-presents
+ // this name — a phase-2 `declare global` export or a phase-4 deferred ambient
+ // module — again finds it absent from the overlay and re-merges against the
+ // bare base, dropping this phase-1 symbol's contributed flags. Because the
+ // merge is a monotonic flag-union, losing an accumulated flag can only FAIL to
+ // detect a conflict the union would have caught: it can only under-report (a
+ // `missing`), never over-report (a `family_extra`, the hard gate). So the
+ // clean invariant holds at the single-file scope this slice grades. The
+ // multi-file slice — where the same global name legitimately recurs across
+ // files — needs the real fix.
+ // TODO: copy-on-write the base entry into the overlay on the first base-merge,
+ // so a subsequent same-name symbol accumulates against the union, not the bare base.
+ merge_symbol_against_base(base, &source.name, lib_file_offset, source, out);
+ return;
+ }
+ globals.insert(
+ source.name.clone(),
+ GlobalEntry {
+ name: source.name.clone(),
+ flags: source.flags,
+ decls: source.decls.clone(),
+ },
+ );
+}
+
+/// tsgo `mergeSymbol` with the target an immutable [`LibBase`] entry — the
+/// conflict decision when a test symbol merges into a lib global. On a clean merge
+/// nothing is emitted (and the base is not mutated); on a conflict the base
+/// declarations are translated into program FileId space.
+fn merge_symbol_against_base(
+ base: &LibEntry,
+ name: &str,
+ lib_file_offset: u32,
+ source: &MergeSymbol,
+ out: &mut MergeOut,
+) {
+ if !base.flags.intersects(excluded_symbol_flags(source.flags)) {
+ // No conflict: the test symbol legally augments/merges the lib global.
+ return;
+ }
+ if base.flags.intersects(SymbolFlags::NAMESPACE_MODULE) {
+ // A value merging into a non-instantiated namespace (TS2649) — but never when
+ // the base is the seeded `globalThis` (the phase-1 TS2397 already reports that,
+ // and a second TS2649 would not make sense; tsgo's `target != globalThisSymbol`
+ // guard). `name` is the merge target's name (the base entry is looked up by it),
+ // so this name check is the observable equivalent of tsgo's identity guard.
+ if name != NAME_GLOBAL_THIS
+ && let Some(decl) = source.decls.first()
+ {
+ out.push(augment_error(decl.file, decl.error_span, 2649, name));
+ }
+ return;
+ }
+ report_merge_symbol_error_with_base(base, name, lib_file_offset, source, out);
+}
+
+/// tsgo `reportMergeSymbolError` with the target a [`LibBase`] entry: the same
+/// three-way message selection and both-direction emission as
+/// [`report_merge_symbol_error`], the base declarations translated into program
+/// FileId space. The test-side primaries (their related info the priority-ordered
+/// lib declarations) are the observable ones; the lib-side primaries land on the
+/// masked lib file the baseline hides.
+fn report_merge_symbol_error_with_base(
+ base: &LibEntry,
+ name: &str,
+ lib_file_offset: u32,
+ source: &MergeSymbol,
+ out: &mut MergeOut,
+) {
+ let base_decls: Vec = base
+ .decls
+ .iter()
+ .map(|d| MergeDecl {
+ file: FileId(lib_file_offset + d.lib_file),
+ error_span: d.error_span,
+ is_type_decl: d.is_type_decl,
+ })
+ .collect();
+ let is_either_enum =
+ base.flags.intersects(SymbolFlags::ENUM) || source.flags.intersects(SymbolFlags::ENUM);
+ let is_either_block = base.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE)
+ || source.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE);
+ let code = if is_either_enum {
+ 2567
+ } else if is_either_block {
+ 2451
+ } else {
+ 2300
+ };
+ let symbol_name = name.to_string();
+ add_dup_errors(&source.decls, code, &symbol_name, &base_decls, out);
+ add_dup_errors(&base_decls, code, &symbol_name, &source.decls, out);
+}
+
+/// tsgo `mergeSymbol` (the merge/conflict decision). No member/export recursion at
+/// P1 (globals hold no members — see the module header).
+fn merge_symbol(target: &mut GlobalEntry, source: &MergeSymbol, out: &mut MergeOut) {
+ if !target.flags.intersects(excluded_symbol_flags(source.flags)) {
+ // No conflict: accumulate flags + declarations.
+ target.flags.insert(source.flags);
+ target.decls.extend(source.decls.iter().cloned());
+ } else if target.flags.intersects(SymbolFlags::NAMESPACE_MODULE) {
+ // A value merging into a non-instantiated namespace: "cannot augment module
+ // with value exports" (TS2649) — but NOT when the target is `globalThis`
+ // (tsgo `mergeSymbol`'s `target != globalThisSymbol` guard): the phase-1
+ // TS2397 already reports that conflict, and a second TS2649 would not make
+ // sense. tsgo keys on symbol identity; a **name** check is the observable
+ // equivalent here — the globals table holds exactly one entry per name and a
+ // merge only ever pairs same-named symbols, so `target.name == "globalThis"`
+ // identifies it whether the entry arrived as the lib-base seed or (with no lib)
+ // as a user-declared overlay entry from an earlier file. The base path
+ // (`merge_symbol_against_base`) makes the same name-based choice.
+ if target.name != NAME_GLOBAL_THIS
+ && let Some(decl) = source.decls.first()
+ {
+ out.push(augment_error(
+ decl.file,
+ decl.error_span,
+ 2649,
+ &target.name,
+ ));
+ }
+ } else {
+ report_merge_symbol_error(target, source, out);
+ }
+}
+
+/// tsgo `reportMergeSymbolError` — the same three-way message selection as the
+/// bind-time cascade, emitting a fresh primary on **every** declaration of both
+/// symbols with related info; the caller-side `compact_and_merge_related_infos`
+/// collapses the duplicate primaries (see [`MergeOut`]'s header for why there is
+/// no issued-index dedup here).
+fn report_merge_symbol_error(target: &GlobalEntry, source: &MergeSymbol, out: &mut MergeOut) {
+ let is_either_enum =
+ target.flags.intersects(SymbolFlags::ENUM) || source.flags.intersects(SymbolFlags::ENUM);
+ let is_either_block = target.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE)
+ || source.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE);
+ let code = if is_either_enum {
+ 2567
+ } else if is_either_block {
+ 2451
+ } else {
+ 2300
+ };
+ let symbol_name = source.name.clone();
+ add_dup_errors(&source.decls, code, &symbol_name, &target.decls, out);
+ add_dup_errors(&target.decls, code, &symbol_name, &source.decls, out);
+}
+
+/// tsgo `addDuplicateDeclarationErrorsForSymbols` — one call to
+/// [`add_duplicate_declaration_error`] per declaration node of `decls`, each
+/// carrying related info pointing at the *other* symbol's declarations.
+fn add_dup_errors(
+ decls: &[MergeDecl],
+ code: u32,
+ symbol_name: &str,
+ related_nodes: &[MergeDecl],
+ out: &mut MergeOut,
+) {
+ for decl in decls {
+ add_duplicate_declaration_error(decl, code, symbol_name, related_nodes, out);
+ }
+}
+
+/// tsgo `addDuplicateDeclarationError`: issue a **fresh** primary at `decl` and
+/// attach its related info — leading (TS6203) for the first related node, follow-on
+/// (TS6204) after, capped at 5 *within this primary* and deduped by target node.
+///
+/// Every conflicting merge issues a fresh primary (tsgo's `lookupOrIssueError`
+/// never re-finds a primary that has accreted related info — related-length is a
+/// `CompareDiagnostics` sort key), so the cross-merge union of related info across
+/// duplicate primaries at one node is left to the caller's final
+/// `compact_and_merge_related_infos`. That union is uncapped and all-TS6203 (each
+/// primary's related loop starts empty, so each leads with a TS6203).
+fn add_duplicate_declaration_error(
+ decl: &MergeDecl,
+ code: u32,
+ symbol_name: &str,
+ related_nodes: &[MergeDecl],
+ out: &mut MergeOut,
+) {
+ let needs_name = code != 2567;
+ let args = if needs_name {
+ vec![symbol_name.to_string()]
+ } else {
+ Vec::new()
+ };
+ let mut primary = Diagnostic {
+ file: Some(decl.file),
+ span: decl.error_span,
+ code,
+ category: Category::Error,
+ message: message_for(code, Some(symbol_name)),
+ args,
+ chain: Vec::new(),
+ related: Vec::new(),
+ };
+ for related in related_nodes {
+ if related.file == decl.file && related.error_span == decl.error_span {
+ continue;
+ }
+ if primary.related.len() >= 5
+ || primary
+ .related
+ .iter()
+ .any(|r| r.file == Some(related.file) && r.span == related.error_span)
+ {
+ continue;
+ }
+ let related_diag = if primary.related.is_empty() {
+ related_info(related, 6203, Some(symbol_name))
+ } else {
+ related_info(related, 6204, None)
+ };
+ primary.related.push(related_diag);
+ }
+ out.push(primary);
+}
+
+/// tsgo `mergeModuleAugmentation` (the non-global arm) at single-file scope: the
+/// augmentation's module name never resolves (no sibling module), so it is always
+/// "not found" (TS2664). The resolves-to-a-non-module errors (TS2649 / TS2671)
+/// need a multi-file resolution target and are unreachable here.
+fn merge_module_augmentation(aug: &ModuleAug, out: &mut MergeOut) {
+ out.push(augment_error(aug.file, aug.name_span, 2664, &aug.name));
+}
+
+/// Build a TS2397 ("declaration name conflicts with built-in global identifier").
+fn conflict_2397(decl: &MergeDecl, name: &str) -> Diagnostic {
+ Diagnostic {
+ file: Some(decl.file),
+ span: decl.error_span,
+ code: 2397,
+ category: Category::Error,
+ message: message_for(2397, Some(name)),
+ args: vec![name.to_string()],
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+}
+
+/// Build a module-augmentation error (TS2664 / TS2649 / TS2671), all `{0}` = the
+/// module name.
+fn augment_error(file: FileId, span: Span, code: u32, name: &str) -> Diagnostic {
+ Diagnostic {
+ file: Some(file),
+ span,
+ code,
+ category: Category::Error,
+ message: message_for(code, Some(name)),
+ args: vec![name.to_string()],
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+}
+
+/// Build a related-info node (TS6203 / TS6204) pointing at `decl`'s name.
+fn related_info(decl: &MergeDecl, code: u32, name: Option<&str>) -> Diagnostic {
+ Diagnostic {
+ file: Some(decl.file),
+ span: decl.error_span,
+ code,
+ // 6203/6204 are `Message` category (unobservable in code+span grading;
+ // faithful to tsgo's diagnosticMessages).
+ category: Category::Message,
+ message: message_for(code, name),
+ args: name.map(|n| vec![n.to_string()]).unwrap_or_default(),
+ chain: Vec::new(),
+ related: Vec::new(),
+ }
+}
+
+/// tsgo `getExcludedSymbolFlags` — the union of the `*Excludes` masks for every
+/// flag set on `flags` (with the `ReplaceableByMethod` special case).
+fn excluded_symbol_flags(flags: SymbolFlags) -> SymbolFlags {
+ let mut result = SymbolFlags::NONE;
+ let add = |result: &mut SymbolFlags, present: SymbolFlags, mask: SymbolFlags| {
+ if flags.intersects(present) {
+ *result = result.union(mask);
+ }
+ };
+ add(
+ &mut result,
+ SymbolFlags::BLOCK_SCOPED_VARIABLE,
+ SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ SymbolFlags::FUNCTION_SCOPED_VARIABLE_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::PROPERTY,
+ SymbolFlags::PROPERTY_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::ENUM_MEMBER,
+ SymbolFlags::ENUM_MEMBER_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::FUNCTION,
+ SymbolFlags::FUNCTION_EXCLUDES,
+ );
+ add(&mut result, SymbolFlags::CLASS, SymbolFlags::CLASS_EXCLUDES);
+ add(
+ &mut result,
+ SymbolFlags::INTERFACE,
+ SymbolFlags::INTERFACE_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::REGULAR_ENUM,
+ SymbolFlags::REGULAR_ENUM_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::CONST_ENUM,
+ SymbolFlags::CONST_ENUM_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::VALUE_MODULE,
+ SymbolFlags::VALUE_MODULE_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::METHOD,
+ SymbolFlags::METHOD_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::GET_ACCESSOR,
+ SymbolFlags::GET_ACCESSOR_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::SET_ACCESSOR,
+ SymbolFlags::SET_ACCESSOR_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::TYPE_PARAMETER,
+ SymbolFlags::TYPE_PARAMETER_EXCLUDES,
+ );
+ add(
+ &mut result,
+ SymbolFlags::TYPE_ALIAS,
+ SymbolFlags::TYPE_ALIAS_EXCLUDES,
+ );
+ add(&mut result, SymbolFlags::ALIAS, SymbolFlags::ALIAS_EXCLUDES);
+ // NamespaceModule contributes no excludes (it merges with anything).
+ if flags.intersects(SymbolFlags::REPLACEABLE_BY_METHOD) {
+ result = SymbolFlags(result.0 & !SymbolFlags::METHOD.0);
+ }
+ result
+}
+
+/// The `.errors.txt` message text for a merge-path / related-info code.
+fn message_for(code: u32, name: Option<&str>) -> String {
+ match code {
+ 2300 => format!("Duplicate identifier '{}'.", name.unwrap_or("")),
+ 2397 => format!(
+ "Declaration name conflicts with built-in global identifier '{}'.",
+ name.unwrap_or("")
+ ),
+ 2451 => format!(
+ "Cannot redeclare block-scoped variable '{}'.",
+ name.unwrap_or("")
+ ),
+ 2567 => "Enum declarations can only merge with namespace or other enum declarations."
+ .to_string(),
+ 2649 => format!(
+ "Cannot augment module '{}' with value exports because it resolves to a non-module entity.",
+ name.unwrap_or("")
+ ),
+ 2664 => {
+ format!(
+ "Invalid module name in augmentation, module '{}' cannot be found.",
+ name.unwrap_or("")
+ )
+ }
+ 2671 => format!(
+ "Cannot augment module '{}' because it resolves to a non-module entity.",
+ name.unwrap_or("")
+ ),
+ 6203 => format!("'{}' was also declared here.", name.unwrap_or("")),
+ 6204 => "and here.".to_string(),
+ _ => String::new(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use crate::diag::sort_and_deduplicate;
+
+ fn decl(file: u32, start: u32, name: &str, is_type_decl: bool) -> MergeDecl {
+ MergeDecl {
+ file: FileId(file),
+ error_span: Span::new(start, start + name.len() as u32),
+ is_type_decl,
+ }
+ }
+
+ fn script(file: u32, locals: Vec) -> FileMerge {
+ FileMerge {
+ file: FileId(file),
+ is_external: false,
+ source_locals: locals,
+ global_augmentations: Vec::new(),
+ module_augmentations: Vec::new(),
+ }
+ }
+
+ /// Two scripts sharing global scope, each declaring `let x`, conflict across
+ /// files (TS2451) — the merge-path analog of the bind-time cascade.
+ #[test]
+ fn cross_file_block_scoped_conflict_is_2451() {
+ let a = script(
+ 0,
+ vec![MergeSymbol {
+ name: "x".to_string(),
+ flags: SymbolFlags::BLOCK_SCOPED_VARIABLE,
+ decls: vec![decl(0, 4, "x", false)],
+ }],
+ );
+ let b = script(
+ 1,
+ vec![MergeSymbol {
+ name: "x".to_string(),
+ flags: SymbolFlags::BLOCK_SCOPED_VARIABLE,
+ decls: vec![decl(1, 4, "x", false)],
+ }],
+ );
+ let diags = merge_program(&[&a, &b], None, 0);
+ let codes: Vec = diags.iter().map(|d| d.code).collect();
+ // One TS2451 on each declaration; each carries a TS6203 related info.
+ assert_eq!(codes, vec![2451, 2451]);
+ assert!(
+ diags
+ .iter()
+ .all(|d| d.related.len() == 1 && d.related[0].code == 6203)
+ );
+ // Emitted on both files (raw order is source-then-target — the canonical
+ // sort in `check_program` reorders to path order).
+ let mut files: Vec = diags.iter().filter_map(|d| d.file.map(|f| f.0)).collect();
+ files.sort_unstable();
+ assert_eq!(files, vec![0, 1]);
+ }
+
+ /// A globals conflict where neither side is block-scoped nor enum is TS2300.
+ #[test]
+ fn cross_file_duplicate_identifier_is_2300() {
+ let mk = |file: u32| {
+ script(
+ file,
+ vec![MergeSymbol {
+ name: "C".to_string(),
+ flags: SymbolFlags::CLASS,
+ decls: vec![decl(file, 6, "C", true)],
+ }],
+ )
+ };
+ let diags = merge_program(&[&mk(0), &mk(1)], None, 0);
+ assert_eq!(
+ diags.iter().map(|d| d.code).collect::>(),
+ vec![2300, 2300]
+ );
+ }
+
+ /// A regular enum and a const enum in separate files can't merge (TS2567).
+ #[test]
+ fn cross_file_enum_merge_is_2567() {
+ let a = script(
+ 0,
+ vec![MergeSymbol {
+ name: "E".to_string(),
+ flags: SymbolFlags::REGULAR_ENUM,
+ decls: vec![decl(0, 5, "E", true)],
+ }],
+ );
+ let b = script(
+ 1,
+ vec![MergeSymbol {
+ name: "E".to_string(),
+ flags: SymbolFlags::CONST_ENUM,
+ decls: vec![decl(1, 11, "E", true)],
+ }],
+ );
+ let diags = merge_program(&[&a, &b], None, 0);
+ assert_eq!(
+ diags.iter().map(|d| d.code).collect::>(),
+ vec![2567, 2567]
+ );
+ // 2567 carries no `{0}` argument.
+ assert!(diags.iter().all(|d| d.args.is_empty()));
+ }
+
+ /// A name conflicting across many files: the merge pushes a fresh primary per
+ /// conflicting merge (so the raw pool has duplicates at the first file's node),
+ /// and the caller's `sort_and_deduplicate` unions them into one primary per
+ /// node. The first file's node accretes a related entry per *other* file — all
+ /// **TS6203** (each fresh primary leads with a TS6203), uncapped by the
+ /// per-primary cap of 5.
+ #[test]
+ fn cross_merge_related_union_is_all_6203_uncapped() {
+ // Seven files each declaring `let x`. File 0 (globals[x]) is the recurring
+ // merge target, so its node accretes six related entries after the union.
+ let paths: Vec = (0..7).map(|f| format!("f{f}.ts")).collect();
+ let files: Vec = (0..7)
+ .map(|f| {
+ script(
+ f,
+ vec![MergeSymbol {
+ name: "x".to_string(),
+ flags: SymbolFlags::BLOCK_SCOPED_VARIABLE,
+ decls: vec![decl(f, 4, "x", false)],
+ }],
+ )
+ })
+ .collect();
+ let file_refs: Vec<&FileMerge> = files.iter().collect();
+ let mut diags = merge_program(&file_refs, None, 0);
+ // Raw pool: every conflicting merge pushes a fresh primary (six merges,
+ // each emitting a source-side and a target-side primary = twelve).
+ assert_eq!(diags.len(), 12);
+ // After the caller's canonical sort + related-info union.
+ let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
+ sort_and_deduplicate(&mut diags, &path_refs);
+ assert_eq!(diags.len(), 7); // one primary per file's node
+ let head = &diags[0]; // f0.ts, the recurring target
+ assert_eq!(head.file, Some(FileId(0)));
+ assert_eq!(head.related.len(), 6); // one per *other* file — uncapped
+ assert!(
+ head.related.iter().all(|r| r.code == 6203),
+ "every unioned related entry leads with TS6203"
+ );
+ }
+
+ /// The review's prescribed case: a name conflicting across three files whose
+ /// declarations sit in distinct files, asserting the related **codes** are all
+ /// TS6203 after the union (never a TS6204 on the accreting node).
+ #[test]
+ fn three_way_cross_file_conflict_related_codes_all_6203() {
+ let paths = vec!["a.ts", "b.ts", "c.ts"];
+ let mk = |f: u32| {
+ script(
+ f,
+ vec![MergeSymbol {
+ name: "C".to_string(),
+ flags: SymbolFlags::CLASS,
+ decls: vec![decl(f, 6, "C", true)],
+ }],
+ )
+ };
+ let mut diags = merge_program(&[&mk(0), &mk(1), &mk(2)], None, 0);
+ sort_and_deduplicate(&mut diags, &paths);
+ assert_eq!(diags.len(), 3);
+ // All primaries are TS2300; a.ts (the recurring target) carries two related
+ // entries, both TS6203 (the union of two fresh single-related primaries).
+ assert!(diags.iter().all(|d| d.code == 2300));
+ let a = diags
+ .iter()
+ .find(|d| d.file == Some(FileId(0)))
+ .expect("a.ts primary");
+ assert_eq!(a.related.len(), 2);
+ assert!(a.related.iter().all(|r| r.code == 6203));
+ }
+
+ /// The WITHIN-primary related cap (five), distinct from the uncapped cross-merge
+ /// union. A single conflicting merge whose target already accreted six clean
+ /// declarations issues ONE primary carrying a leading TS6203 + four TS6204 — the
+ /// sixth related node is DROPPED at emission (tsgo `addDuplicateDeclarationError`
+ /// caps a single primary's related chain at five). Contrast
+ /// `cross_merge_related_union_is_all_6203_uncapped`, where the cap never bites
+ /// because each fresh primary starts its own chain.
+ #[test]
+ fn within_primary_related_cap_drops_the_sixth() {
+ // Six files declare `enum E` (regular enums merge cleanly), so globals[E]
+ // accretes six declarations; a seventh file's `const enum E` conflicts
+ // (TS2567) and its single primary points related info at all six — capped.
+ let mut files: Vec = (0..6)
+ .map(|f| {
+ script(
+ f,
+ vec![MergeSymbol {
+ name: "E".to_string(),
+ flags: SymbolFlags::REGULAR_ENUM,
+ decls: vec![decl(f, 5, "E", true)],
+ }],
+ )
+ })
+ .collect();
+ files.push(script(
+ 6,
+ vec![MergeSymbol {
+ name: "E".to_string(),
+ flags: SymbolFlags::CONST_ENUM,
+ decls: vec![decl(6, 11, "E", true)],
+ }],
+ ));
+ let file_refs: Vec<&FileMerge> = files.iter().collect();
+ let diags = merge_program(&file_refs, None, 0);
+ // The const-enum primary (file 6) carries the capped chain (asserted on the
+ // raw pool, before any cross-merge union, to isolate the within-primary cap).
+ let source_primary = diags
+ .iter()
+ .find(|d| d.file == Some(FileId(6)))
+ .expect("a const-enum primary at file 6");
+ assert_eq!(source_primary.code, 2567);
+ let codes: Vec = source_primary.related.iter().map(|r| r.code).collect();
+ // Leading TS6203 + four TS6204 = five related; the sixth conflicting decl is dropped.
+ assert_eq!(codes, vec![6203, 6204, 6204, 6204, 6204]);
+ }
+
+ /// A single script declaring `var globalThis` triggers TS2397 per declaration.
+ #[test]
+ fn global_this_collision_is_2397() {
+ let f = script(
+ 0,
+ vec![MergeSymbol {
+ name: "globalThis".to_string(),
+ flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ decls: vec![decl(0, 4, "globalThis", false)],
+ }],
+ );
+ let diags = merge_program(&[&f], None, 0);
+ assert_eq!(diags.len(), 1);
+ assert_eq!(diags[0].code, 2397);
+ assert_eq!(diags[0].args, vec!["globalThis".to_string()]);
+ }
+
+ /// The `undefined` check skips type declarations (class/interface) and fires
+ /// only on the value (namespace/var) declaration.
+ #[test]
+ fn undefined_redeclaration_skips_type_declarations() {
+ let f = script(
+ 0,
+ vec![MergeSymbol {
+ name: "undefined".to_string(),
+ flags: SymbolFlags::CLASS.union(SymbolFlags::VALUE_MODULE),
+ decls: vec![
+ decl(0, 6, "undefined", true), // class
+ decl(0, 40, "undefined", false), // namespace
+ ],
+ }],
+ );
+ let diags = merge_program(&[&f], None, 0);
+ assert_eq!(diags.len(), 1);
+ assert_eq!(diags[0].code, 2397);
+ assert_eq!(diags[0].span.start, 40);
+ }
+
+ /// A module augmentation single-file is always "not found" (TS2664), deduped
+ /// by name.
+ #[test]
+ fn module_augmentation_not_found_is_2664_deduped() {
+ let f = FileMerge {
+ file: FileId(0),
+ is_external: true,
+ source_locals: Vec::new(),
+ global_augmentations: Vec::new(),
+ module_augmentations: vec![
+ ModuleAug {
+ file: FileId(0),
+ name: "M".to_string(),
+ name_span: Span::new(22, 25),
+ },
+ ModuleAug {
+ file: FileId(0),
+ name: "M".to_string(),
+ name_span: Span::new(50, 53),
+ },
+ ],
+ };
+ let diags = merge_program(&[&f], None, 0);
+ assert_eq!(diags.len(), 1);
+ assert_eq!(diags[0].code, 2664);
+ assert_eq!(diags[0].span.start, 22);
+ assert_eq!(diags[0].args, vec!["M".to_string()]);
+ }
+
+ /// An external module's locals never reach global scope (no globalThis/undefined
+ /// check, no global merge).
+ #[test]
+ fn external_module_locals_do_not_reach_globals() {
+ let f = FileMerge {
+ file: FileId(0),
+ is_external: true,
+ source_locals: vec![MergeSymbol {
+ name: "globalThis".to_string(),
+ flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ decls: vec![decl(0, 4, "globalThis", false)],
+ }],
+ global_augmentations: Vec::new(),
+ module_augmentations: Vec::new(),
+ };
+ assert!(merge_program(&[&f], None, 0).is_empty());
+ }
+
+ // --- lib base ---------------------------------------------------------
+
+ /// A tiny lib file with two globals, in priority order.
+ fn lib(name: &str, symbols: Vec) -> LibFile {
+ LibFile {
+ name: name.to_string(),
+ merge: FileMerge {
+ file: FileId(0),
+ is_external: false,
+ source_locals: symbols,
+ global_augmentations: Vec::new(),
+ module_augmentations: Vec::new(),
+ },
+ }
+ }
+
+ fn lib_symbol(name: &str, flags: SymbolFlags, span: u32, is_type: bool) -> MergeSymbol {
+ MergeSymbol {
+ name: name.to_string(),
+ flags,
+ decls: vec![decl(0, span, name, is_type)],
+ }
+ }
+
+ /// A test `class` conflicting with a lib global spanning three files reproduces
+ /// the priority-ordered TS6203/6204 related chain (leading TS6203 on the
+ /// highest-priority lib file), and the lib declarations translate into program
+ /// FileId space (offset = the number of program units).
+ #[test]
+ fn lib_conflict_emits_priority_ordered_related_chain() {
+ // Symbol declared across three lib files (priority order es5, es2015.symbol,
+ // es2015.symbol.wellknown): interface, var, interface -> flags Interface|Fsv.
+ let es5 = lib(
+ "lib.es5.d.ts",
+ vec![lib_symbol("Symbol", SymbolFlags::INTERFACE, 10, true)],
+ );
+ let sym = lib(
+ "lib.es2015.symbol.d.ts",
+ vec![lib_symbol(
+ "Symbol",
+ SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ 20,
+ false,
+ )],
+ );
+ let wk = lib(
+ "lib.es2015.symbol.wellknown.d.ts",
+ vec![lib_symbol("Symbol", SymbolFlags::INTERFACE, 30, true)],
+ );
+ let base = LibBase::build(&[&es5, &sym, &wk]);
+ // The single test unit is FileId 0, so lib files map to FileIds 1..=3.
+ let test = script(
+ 0,
+ vec![MergeSymbol {
+ name: "Symbol".to_string(),
+ flags: SymbolFlags::CLASS,
+ decls: vec![decl(0, 6, "Symbol", true)],
+ }],
+ );
+ let mut diags = merge_program(&[&test], Some(&base), 1);
+ let paths = vec![
+ "test.ts",
+ "lib.es5.d.ts",
+ "lib.es2015.symbol.d.ts",
+ "lib.es2015.symbol.wellknown.d.ts",
+ ];
+ sort_and_deduplicate(&mut diags, &paths);
+ // The observable primary is on the test file's `class Symbol`.
+ let test_primary = diags
+ .iter()
+ .find(|d| d.file == Some(FileId(0)))
+ .expect("a test-file primary");
+ assert_eq!(test_primary.code, 2300);
+ assert_eq!(test_primary.span.start, 6);
+ // Its related chain is priority-ordered: TS6203 on es5, TS6204 on the rest,
+ // each pointing at the (translated) lib FileId.
+ let codes: Vec = test_primary.related.iter().map(|r| r.code).collect();
+ assert_eq!(codes, vec![6203, 6204, 6204]);
+ let files: Vec> = test_primary.related.iter().map(|r| r.file).collect();
+ assert_eq!(
+ files,
+ vec![Some(FileId(1)), Some(FileId(2)), Some(FileId(3))]
+ );
+ // The lib-side primaries land on the (masked) lib files.
+ assert!(
+ diags
+ .iter()
+ .any(|d| d.file == Some(FileId(1)) && d.code == 2300)
+ );
+ }
+
+ /// A clean augmentation of a lib global (a test `interface` merging into a lib
+ /// interface) emits nothing.
+ #[test]
+ fn lib_clean_interface_merge_is_silent() {
+ let array = lib(
+ "lib.es5.d.ts",
+ vec![lib_symbol("Array", SymbolFlags::INTERFACE, 10, true)],
+ );
+ let base = LibBase::build(&[&array]);
+ let test = script(
+ 0,
+ vec![MergeSymbol {
+ name: "Array".to_string(),
+ flags: SymbolFlags::INTERFACE,
+ decls: vec![decl(0, 10, "Array", true)],
+ }],
+ );
+ assert!(merge_program(&[&test], Some(&base), 1).is_empty());
+ }
+
+ /// A test `var globalThis` hits the seeded-globalThis NamespaceModule guard (no
+ /// TS2649), leaving only the phase-1 TS2397.
+ #[test]
+ fn lib_var_globalthis_only_2397() {
+ let base = LibBase::build(&[]); // still seeds globalThis
+ let test = script(
+ 0,
+ vec![MergeSymbol {
+ name: "globalThis".to_string(),
+ flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE,
+ decls: vec![decl(0, 4, "globalThis", false)],
+ }],
+ );
+ let diags = merge_program(&[&test], Some(&base), 1);
+ assert_eq!(diags.len(), 1);
+ assert_eq!(diags[0].code, 2397);
+ }
+
+ /// The TS2649 `globalThis` guard is name-based, so it holds even when the
+ /// `globalThis` entry arrived via the **overlay** (a user declaration) rather than
+ /// the lib-base seed. With no lib, an earlier file's instantiated
+ /// `namespace globalThis` seeds the overlay entry (NamespaceModule | ValueModule); a
+ /// later `var globalThis` conflicts and hits the NamespaceModule arm — the guard
+ /// suppresses TS2649, leaving only the two phase-1 TS2397s.
+ #[test]
+ fn overlay_globalthis_guard_suppresses_2649() {
+ let ns = script(
+ 0,
+ vec![MergeSymbol {
+ name: "globalThis".to_string(),
+ flags: MODULE_FLAGS, // an instantiated `namespace globalThis {…}`
+ decls: vec![decl(0, 10, "globalThis", false)],
+ }],
+ );
+ let var = script(
+ 1,
+ vec![MergeSymbol {
+ name: "globalThis".to_string(),
+ flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE, // `var globalThis`
+ decls: vec![decl(1, 4, "globalThis", false)],
+ }],
+ );
+ // No lib base — the overlay starts empty, so `globalThis` reaches it only via
+ // the user declarations (not the seed).
+ let diags = merge_program(&[&ns, &var], None, 0);
+ // Only the phase-1 globalThis checks fire; the NamespaceModule guard holds for
+ // the overlay entry, so no TS2649.
+ assert_eq!(diags.iter().filter(|d| d.code == 2397).count(), 2);
+ assert!(diags.iter().all(|d| d.code == 2397));
+ }
+
+ /// A `declare global` augmentation (an interface) conflicting with a lib type
+ /// alias of the same name is TS2300 (the ElementTagNameMap shape).
+ #[test]
+ fn lib_declare_global_interface_vs_type_alias_is_2300() {
+ let dom = lib(
+ "lib.dom.d.ts",
+ vec![lib_symbol(
+ "ElementTagNameMap",
+ SymbolFlags::TYPE_ALIAS,
+ 10,
+ true,
+ )],
+ );
+ let base = LibBase::build(&[&dom]);
+ // An external module carrying a `declare global { interface ElementTagNameMap }`.
+ let test = FileMerge {
+ file: FileId(0),
+ is_external: true,
+ source_locals: Vec::new(),
+ global_augmentations: vec![vec![MergeSymbol {
+ name: "ElementTagNameMap".to_string(),
+ flags: SymbolFlags::INTERFACE,
+ decls: vec![decl(0, 40, "ElementTagNameMap", true)],
+ }]],
+ module_augmentations: Vec::new(),
+ };
+ let diags = merge_program(&[&test], Some(&base), 1);
+ let test_primary = diags
+ .iter()
+ .find(|d| d.file == Some(FileId(0)))
+ .expect("primary");
+ assert_eq!(test_primary.code, 2300);
+ assert_eq!(test_primary.span.start, 40);
+ assert_eq!(test_primary.related.len(), 1);
+ assert_eq!(test_primary.related[0].code, 6203);
+ assert_eq!(test_primary.related[0].file, Some(FileId(1)));
+ }
+}
diff --git a/crates/tsv_check/src/options.rs b/crates/tsv_check/src/options.rs
new file mode 100644
index 000000000..e345b0be7
--- /dev/null
+++ b/crates/tsv_check/src/options.rs
@@ -0,0 +1,42 @@
+//! Checker options — tsv_check's first options surface.
+//!
+//! The checker's observable behavior is mostly option-independent (the
+//! bind/merge duplicate-conflict family, the syntactic check pass), but the
+//! reachability shims read a small set of compiler options: `allowUnreachableCode`
+//! (TS7027), `allowUnusedLabels` (TS7028), and `preserveConstEnums` (which of an
+//! unreachable module/enum member's declarations count as executable). This is the
+//! whole of tsv_check's options model — deliberately minimal, ported only where a
+//! diagnostic's category or existence actually depends on it.
+//
+// tsgo: internal/core/tristate.go Tristate; internal/core/compileroptions.go
+// (AllowUnreachableCode / AllowUnusedLabels / PreserveConstEnums +
+// ShouldPreserveConstEnums).
+
+/// A three-state boolean mirroring tsgo's `core.Tristate`: an **unset** option
+/// (`Unknown`, the default) inherits rather than reading as `false`, so the
+/// suggestion-vs-error routing needs the explicit-`False` distinction.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
+pub enum Tristate {
+ /// No explicit value — inherits (routes reachability diagnostics as
+ /// *suggestions*, tsgo's default when the flag is unset).
+ #[default]
+ Unknown,
+ /// Explicit `false` — the reachability diagnostic is an **error**.
+ False,
+ /// Explicit `true` — the reachability probe is **skipped** entirely.
+ True,
+}
+
+/// The checker options tsv_check reads. `Default` is the all-unset state
+/// (`Unknown` / `Unknown` / `false`), which every non-harness caller passes.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct CheckOptions {
+ /// `allowUnreachableCode` — gates TS7027 (`Unreachable code detected.`):
+ /// `False` → error, `Unknown` → suggestion, `True` → no report.
+ pub allow_unreachable_code: Tristate,
+ /// `allowUnusedLabels` — gates TS7028 (`Unused label.`): same routing.
+ pub allow_unused_labels: Tristate,
+ /// `ShouldPreserveConstEnums()` — whether an unreachable `const enum` (and a
+ /// const-enum-only namespace) counts as executable and so is reportable.
+ pub preserve_const_enums: bool,
+}
diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs
new file mode 100644
index 000000000..ee7b3e176
--- /dev/null
+++ b/crates/tsv_check/src/program.rs
@@ -0,0 +1,540 @@
+//! Program pipeline assembly: parse (goal rule) -> bind -> check -> sort/dedup.
+//!
+//! **Two assembly modes, and this is the parity one.** The conformance harness
+//! grades against tsgo's committed `.errors.txt` **baselines**, whose oracle path
+//! is `harnessutil.go CompileFilesEx` (:634-645): it concatenates each unit's
+//! syntactic + semantic diagnostics **unconditionally — no short-circuit**. So a
+//! unit tsv parse-rejects contributes only its parse verdict (no AST to bind),
+//! while every unit that parses contributes its bind/check diagnostics regardless
+//! of a sibling's rejection. For the single-file tests this slice grades that
+//! means a rejected file is simply ungradeable — its `CheckResult` has no
+//! diagnostics because there is no AST, not because a program-wide guard fired.
+//!
+//! The **product path** (`tsv check`, mirroring the tsc CLI) is instead tsgo's
+//! `GetDiagnosticsOfAnyProgram` (`program.go:1755`), which **short-circuits** at
+//! :1770 — if syntactic diagnostics exist, semantic diagnostics are skipped
+//! program-wide. Porting that short-circuit into this parity pipeline would
+//! manufacture false `missing`s the moment multi-file grading starts, so it is a
+//! deliberate mode distinction deferred to the product path, not modelled here.
+//!
+//! Each unit parses via the **goal rule**: `Goal::Module` first (correct for
+//! ~all real TS), and on failure a `Goal::Script` retry (top-level `await` as an
+//! identifier, no `import`/`export`). Both goals failing is a parse rejection.
+//!
+//! The caller owns the parse arena (`&'a Bump`) — the tsv_ts caller-owns-arena
+//! contract scaled to a unit set. The returned [`CheckResult`] is fully owned
+//! (diagnostics carry owned strings and `Copy` spans; nothing borrows the
+//! arena), so the caller may reset and reuse the arena the moment this returns.
+//
+// tsgo: internal/testutil/harnessutil.go CompileFilesEx (:634-645) — the
+// baseline-oracle parity path (unconditional syntactic+semantic concat);
+// the bind-then-check concat is getBindAndCheckDiagnosticsWithChecker
+// (program.go:1337); final sort+dedup is caller-side (execute/tsc/emit.go:120).
+// The product-mode short-circuit lives at GetDiagnosticsOfAnyProgram
+// (program.go:1755, :1770) and is deliberately NOT ported here.
+
+use crate::binder::flow::{FlowProduct, build_flow};
+use crate::binder::{ModuleNess, bind_file, module_ness};
+use crate::check::unreachable::{UnreachableCandidates, build_candidates};
+use crate::diag::{Diagnostic, sort_and_deduplicate};
+use crate::ids::FileId;
+use crate::merge::{FileMerge, LibBase, LibFile, merge_program};
+use crate::options::CheckOptions;
+use bumpalo::Bump;
+use tsv_ts::ast::Program;
+use tsv_ts::{Goal, parse_with_goal};
+
+/// One source unit to check — a file name (its diagnostic path) and its source.
+pub struct SourceUnit<'a> {
+ /// The unit's display name (the diagnostic path).
+ pub name: &'a str,
+ /// The unit's source text.
+ pub source: &'a str,
+}
+
+impl<'a> SourceUnit<'a> {
+ /// Build a source unit.
+ #[must_use]
+ pub fn new(name: &'a str, source: &'a str) -> SourceUnit<'a> {
+ SourceUnit { name, source }
+ }
+}
+
+/// The result of checking a program: its (sorted, deduped) diagnostics, its
+/// suggestion-category diagnostics (a separate sink), a per-unit report, and
+/// whether any unit parse-rejected.
+pub struct CheckResult {
+ /// Diagnostics in canonical sorted order — the unconditional concat of every
+ /// unit that parsed (a rejected unit contributes none, having no AST).
+ pub diagnostics: Vec,
+ /// Suggestion-category diagnostics (the default-`Unknown` reachability
+ /// shims), kept **out of** [`CheckResult::diagnostics`] so the conformance
+ /// gate's error/expect-clean channel never sees them.
+ pub suggestions: Vec,
+ /// Per-unit parse/bind report, in input order.
+ pub files: Vec,
+ /// Whether any unit parse-rejected (a reported fact; it does **not** suppress
+ /// the other units' diagnostics — see the module header's parity note).
+ pub parse_rejected: bool,
+}
+
+/// The per-unit parse/bind report.
+pub struct FileReport {
+ /// The unit's file id.
+ pub file: FileId,
+ /// The unit's display name.
+ pub name: String,
+ /// The parse outcome and, when parsed, the bind facts.
+ pub parse: ParseReport,
+}
+
+/// A unit's parse outcome.
+#[derive(Clone)]
+pub enum ParseReport {
+ /// The unit parsed (possibly via the `Goal::Script` retry).
+ Parsed(ParsedFacts),
+ /// Both goals failed; `message` is the primary-goal (`Module`) error.
+ Rejected {
+ /// The `Goal::Module` parse error message.
+ message: String,
+ },
+}
+
+/// Facts recorded for a parsed unit.
+#[derive(Clone)]
+pub struct ParsedFacts {
+ /// The goal the unit parsed under.
+ pub goal: Goal,
+ /// Whether the `Goal::Module` parse failed and the `Goal::Script` retry won.
+ pub used_script_retry: bool,
+ /// The unit's module-vs-script indicator (import/export presence).
+ pub module_ness: ModuleNess,
+ /// The bound node count (0 when the program short-circuited before binding).
+ pub node_count: u32,
+}
+
+/// A parsed + bound program — variant-independent and fully owned
+/// (arena-independent), so the caller may drop the parse arena the moment this
+/// returns and merge it against any number of lib bases ([`check_bound`]). This is
+/// the split that keeps parse+bind out of the per-variant loop: parse+bind once,
+/// merge per resolved lib set.
+pub struct BoundProgram {
+ /// Whether any unit parse-rejected (a reported fact; it does **not** suppress
+ /// the other units' diagnostics — the CompileFilesEx parity).
+ pub parse_rejected: bool,
+ units: Vec,
+ total_nodes: u64,
+}
+
+/// One unit's owned bind product inside a [`BoundProgram`].
+struct BoundUnit {
+ file: FileId,
+ name: String,
+ parse: ParseReport,
+ /// The bind (+ check) diagnostics — variant-independent, cloned into each
+ /// [`check_bound`] result.
+ bind_diagnostics: Vec,
+ /// The merge product, `None` when the unit parse-rejected.
+ merge: Option,
+ /// The per-file flow product, carried **dark** — `--dump-flow` renders it and
+ /// F3's candidate table is built from it. `None` when the unit parse-rejected
+ /// (no AST to walk).
+ flow: Option,
+ /// The variant-independent unreachable-code / unused-label candidate table
+ /// (F3), built once at bind time and filtered per variant in [`check_bound`].
+ /// `None` when the unit parse-rejected.
+ candidates: Option,
+}
+
+impl BoundProgram {
+ /// Total bound nodes across parsed units (informational).
+ #[must_use]
+ pub fn total_node_count(&self) -> u64 {
+ self.total_nodes
+ }
+
+ /// A unit's dark-carried flow product (`None` for a rejected unit or an
+ /// out-of-range index) — the natural accessor for a bound program's flow
+ /// product, which the CFA type engine (P3) will consume.
+ // TODO: no consumer wires this yet — P3's CFA reads a bound program's flow
+ // here (`--dump-flow` renders via `bind_file` + `build_flow` directly, not this).
+ #[must_use]
+ pub fn unit_flow(&self, index: usize) -> Option<&FlowProduct> {
+ self.units.get(index).and_then(|u| u.flow.as_ref())
+ }
+
+ /// The per-unit parse reports, in input order (a read-only view for the caller
+ /// that need not run [`check_bound`] to learn parse facts).
+ #[must_use]
+ pub fn parse_reports(&self) -> Vec<(&str, &ParseReport)> {
+ self.units
+ .iter()
+ .map(|u| (u.name.as_str(), &u.parse))
+ .collect()
+ }
+}
+
+/// Parse every unit via the goal rule and bind each, returning the owned
+/// [`BoundProgram`]. The merge is deferred to [`check_bound`] (it depends on the
+/// resolved lib set), so this is variant-independent.
+#[must_use]
+pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgram {
+ let mut bound_units: Vec = Vec::with_capacity(units.len());
+ let mut parse_rejected = false;
+ let mut total_nodes = 0u64;
+
+ for (i, unit) in units.iter().enumerate() {
+ let file = FileId(i as u32);
+ match parse_unit(unit.source, arena) {
+ Ok((program, goal, used_script_retry)) => {
+ let module_ness = module_ness(&program);
+ let bound = bind_file(&program, unit.source, file);
+ total_nodes += u64::from(bound.node_count);
+ // The third walk: the flow graph, built from the parsed program
+ // and F0's node identity. Borrows `&bound`, so it runs before the
+ // bind product's fields move out below. Carried dark in the unit.
+ let flow = build_flow(&program, unit.source, &bound);
+ // F3: the unreachable-code / unused-label candidate table, built
+ // once here (the flag bit, run grouping, and const-enum/module
+ // classification are all syntactic). Filtered per variant in
+ // `check_bound`, keeping `BoundProgram` variant-independent.
+ let candidates = build_candidates(&program, unit.source, &bound, &flow);
+ // Per file: bind diagnostics then check diagnostics — the
+ // getBindAndCheckDiagnostics concat. The check pass is a standalone
+ // syntactic walk over the program (it needs no `BoundFile`); its
+ // output folds in here, and the program-wide sort/dedup collapses any
+ // diagnostic the bind and check both emit.
+ let check_diags = crate::check::check_file_members(&program, unit.source, file);
+ let mut bind_diagnostics = bound.diagnostics;
+ bind_diagnostics.extend(check_diags);
+ bound_units.push(BoundUnit {
+ file,
+ name: unit.name.to_string(),
+ parse: ParseReport::Parsed(ParsedFacts {
+ goal,
+ used_script_retry,
+ module_ness,
+ node_count: bound.node_count,
+ }),
+ bind_diagnostics,
+ merge: Some(bound.merge),
+ flow: Some(flow),
+ candidates: Some(candidates),
+ });
+ }
+ Err(message) => {
+ parse_rejected = true;
+ bound_units.push(BoundUnit {
+ file,
+ name: unit.name.to_string(),
+ parse: ParseReport::Rejected { message },
+ bind_diagnostics: Vec::new(),
+ merge: None,
+ flow: None,
+ candidates: None,
+ });
+ }
+ }
+ }
+
+ BoundProgram {
+ parse_rejected,
+ units: bound_units,
+ total_nodes,
+ }
+}
+
+/// Merge a [`BoundProgram`] against an optional [`LibBase`] under `options` and
+/// return the final [`CheckResult`] (canonically sorted + deduped). The bind
+/// diagnostics are the variant-independent concat (the CompileFilesEx parity
+/// path); the merge phase consults the lib base, so the lib file names append
+/// after the program units in the diagnostic path space. `options` drives the
+/// per-variant reachability shims (TS7027/7028) — the only option-dependent
+/// output, routed to `diagnostics` (error) or the separate `suggestions` sink.
+// `options` is threaded by reference (uniform with `lib: Option<&LibBase>` and
+// future-proof if `CheckOptions` grows) though it is currently `Copy`-small.
+#[allow(clippy::trivially_copy_pass_by_ref)]
+#[must_use]
+pub fn check_bound(
+ bound: &BoundProgram,
+ lib: Option<&LibBase>,
+ options: &CheckOptions,
+) -> CheckResult {
+ let mut diagnostics: Vec = Vec::new();
+ let mut suggestions: Vec = Vec::new();
+ for unit in &bound.units {
+ diagnostics.extend(unit.bind_diagnostics.iter().cloned());
+ // F3: filter the unit's variant-independent candidate table under
+ // `options` — errors into `diagnostics`, suggestions into their own sink.
+ if let Some(candidates) = &unit.candidates {
+ candidates.emit(unit.file, options, &mut diagnostics, &mut suggestions);
+ }
+ }
+ // Borrow each unit's merge product (lib globals live in the base, not in
+ // `files`); the merge only reads it, so no clone is needed even per-variant.
+ let merges: Vec<&FileMerge> = bound
+ .units
+ .iter()
+ .filter_map(|u| u.merge.as_ref())
+ .collect();
+ let lib_file_offset = bound.units.len() as u32;
+ diagnostics.extend(merge_program(&merges, lib, lib_file_offset));
+
+ // Path space: program units first, then the lib files (their FileIds are
+ // `lib_file_offset + lib-local index`). Borrowed — the comparator only reads.
+ let mut paths: Vec<&str> = bound.units.iter().map(|u| u.name.as_str()).collect();
+ if let Some(base) = lib {
+ paths.extend(base.lib_files.iter().map(String::as_str));
+ }
+ sort_and_deduplicate(&mut diagnostics, &paths);
+ sort_and_deduplicate(&mut suggestions, &paths);
+
+ let files = bound
+ .units
+ .iter()
+ .map(|u| FileReport {
+ file: u.file,
+ name: u.name.clone(),
+ parse: u.parse.clone(),
+ })
+ .collect();
+ CheckResult {
+ diagnostics,
+ suggestions,
+ files,
+ parse_rejected: bound.parse_rejected,
+ }
+}
+
+/// Check a program with no lib base — parse every unit via the goal rule, bind,
+/// merge, and return canonically sorted diagnostics.
+#[allow(clippy::trivially_copy_pass_by_ref)] // `&CheckOptions` — see `check_bound`
+#[must_use]
+pub fn check_program<'a>(
+ units: &[SourceUnit<'a>],
+ arena: &'a Bump,
+ options: &CheckOptions,
+) -> CheckResult {
+ check_bound(&bind_program(units, arena), None, options)
+}
+
+/// Check a program against an optional lib base (the lib-aware entry point).
+#[allow(clippy::trivially_copy_pass_by_ref)] // `&CheckOptions` — see `check_bound`
+#[must_use]
+pub fn check_program_with_lib<'a>(
+ units: &[SourceUnit<'a>],
+ lib: Option<&LibBase>,
+ arena: &'a Bump,
+ options: &CheckOptions,
+) -> CheckResult {
+ check_bound(&bind_program(units, arena), lib, options)
+}
+
+/// Parse + bind one lib `.d.ts` file, returning its owned global-eligible product
+/// for folding into a [`LibBase`]. A lib is an ambient script; its globals are its
+/// source-file locals (bound under FileId 0 — the fold re-keys by priority index).
+///
+/// # Errors
+///
+/// Returns the parse error message when the lib file does not parse under either
+/// goal (expected never for the bundled libs; the caller counts it as a carve-out).
+pub fn bind_lib(name: &str, source: &str) -> Result {
+ let arena = Bump::new();
+ let (program, _goal, _retry) = parse_unit(source, &arena)?;
+ let bound = bind_file(&program, source, FileId::ROOT);
+ // A lib contributes its globals through the merge either as an ambient script
+ // (globals in `source_locals`) or, when the lib file is itself a module — e.g.
+ // `lib.es2025.iterator.d.ts`, which carries a top-level `export {}` and so binds
+ // external — through a `declare global {}` block (`global_augmentations`). A lib
+ // that bound external with NEITHER would silently fold to nothing. This
+ // `debug_assert!` is the fast local guard (dev builds only); the conformance
+ // harness's lib channel counts any such lib and fails its run on a nonzero count,
+ // so release/corpus builds catch the same no-op this compiles out of.
+ debug_assert!(
+ !bound.merge.is_external || !bound.merge.global_augmentations.is_empty(),
+ "lib {name} bound as an external module with no `declare global` block — its globals would be silently dropped",
+ );
+ Ok(LibFile {
+ name: name.to_string(),
+ merge: bound.merge,
+ })
+}
+
+/// Parse a unit via the goal rule: `Module` first, `Script` on failure. Returns
+/// the program, the goal it parsed under, and whether the `Script` retry won; on
+/// double failure returns the `Module`-goal error message.
+fn parse_unit<'a>(source: &'a str, arena: &'a Bump) -> Result<(Program<'a>, Goal, bool), String> {
+ match parse_with_goal(source, Goal::Module, arena) {
+ Ok(program) => Ok((program, Goal::Module, false)),
+ Err(module_err) => match parse_with_goal(source, Goal::Script, arena) {
+ Ok(program) => Ok((program, Goal::Script, true)),
+ // Both goals failed: report the primary (Module) goal's error.
+ Err(_script_err) => Err(module_err.to_string()),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn check(source: &str) -> CheckResult {
+ let arena = Bump::new();
+ check_program(
+ &[SourceUnit::new("test.ts", source)],
+ &arena,
+ &CheckOptions::default(),
+ )
+ }
+
+ #[test]
+ fn clean_program_binds_and_grades_empty() {
+ let result = check("const x: number = 1;");
+ assert!(!result.parse_rejected);
+ assert!(result.diagnostics.is_empty());
+ assert_eq!(result.files.len(), 1);
+ match &result.files[0].parse {
+ ParseReport::Parsed(facts) => {
+ assert_eq!(facts.goal, Goal::Module);
+ assert!(!facts.used_script_retry);
+ assert!(facts.node_count >= 3); // Program + decl + declarator (+ id)
+ }
+ ParseReport::Rejected { .. } => panic!("expected a clean parse"),
+ }
+ }
+
+ #[test]
+ fn parse_rejection_yields_no_diagnostics() {
+ // A hard syntax error both goals reject: no AST to bind, so no diagnostics
+ // (the single-file "ungradeable" case).
+ let result = check("const = = = ;");
+ assert!(result.parse_rejected);
+ assert!(result.diagnostics.is_empty());
+ assert!(matches!(
+ result.files[0].parse,
+ ParseReport::Rejected { .. }
+ ));
+ }
+
+ #[test]
+ fn script_retry_on_top_level_import_free_await_ident() {
+ // `await` as a plain binding is a Module-goal error (reserved) but valid
+ // at Script goal — the retry should win.
+ let result = check("var await = 1;");
+ match &result.files[0].parse {
+ ParseReport::Parsed(facts) => {
+ assert_eq!(facts.goal, Goal::Script);
+ assert!(facts.used_script_retry);
+ }
+ ParseReport::Rejected { .. } => panic!("expected the Script retry to win"),
+ }
+ }
+
+ #[test]
+ fn sibling_rejection_does_not_suppress_a_parsed_unit() {
+ // The CompileFilesEx parity: a rejected sibling does NOT short-circuit the
+ // program — the unit that parsed still contributes its bind diagnostics.
+ let arena = Bump::new();
+ let result = check_program(
+ &[
+ SourceUnit::new("a.ts", "let x; let x;"),
+ SourceUnit::new("b.ts", "const = ;"),
+ ],
+ &arena,
+ &CheckOptions::default(),
+ );
+ assert!(result.parse_rejected);
+ // a.ts's two TS2451 survive despite b.ts rejecting.
+ assert_eq!(result.diagnostics.len(), 2);
+ assert!(result.diagnostics.iter().all(|d| d.code == 2451));
+ assert!(matches!(result.files[0].parse, ParseReport::Parsed(_)));
+ assert!(matches!(
+ result.files[1].parse,
+ ParseReport::Rejected { .. }
+ ));
+ }
+
+ #[test]
+ fn computed_literal_key_bind_and_check_spans_collapse() {
+ // A computed-literal key that conflicts at BOTH bind (method vs property) and
+ // check (property vs property) once produced two differently-spanned
+ // diagnostics per declaration — the bind side spanned the bare literal, the
+ // check side the whole `[ … ]` node — so the sort/dedup couldn't collapse
+ // them (six TS2300 for three declarations). Both phases now span the
+ // bracket-inclusive name node with the raw `[0]` message arg, so identical
+ // diagnostics collapse: three declarations -> three TS2300.
+ let result = check("class C { [0]() {} [0] = 1; [0] = 2; }");
+ let ts2300: Vec<_> = result
+ .diagnostics
+ .iter()
+ .filter(|d| d.code == 2300)
+ .collect();
+ assert_eq!(ts2300.len(), 3);
+ assert!(ts2300.iter().all(|d| d.args == vec!["[0]".to_string()]));
+ assert_eq!(
+ ts2300
+ .iter()
+ .map(|d| (d.span.start, d.span.end))
+ .collect::>(),
+ vec![(10, 13), (19, 22), (28, 31)]
+ );
+ }
+
+ #[test]
+ fn private_name_bind_and_check_display_collapse() {
+ // A duplicate private member (`#x`) is reported by BOTH the bind cascade and
+ // the check pass. Both point at the same `#name` span with code 2300, but the
+ // bind side once built its message arg WITHOUT the leading `#` (bare `x`) while
+ // the check side built it WITH (`#x`), so the differing args blocked sort/dedup
+ // — a latent extra (six TS2300 for three declarations). Both phases now carry
+ // the `#x` form (matching tsgo's `Duplicate identifier '#foo'.`), so identical
+ // diagnostics collapse: three declarations -> three TS2300.
+ let result = check("class C { #x = 1; get #x() { return 1; } #x() {} }");
+ let ts2300: Vec<_> = result
+ .diagnostics
+ .iter()
+ .filter(|d| d.code == 2300)
+ .collect();
+ let summary: Vec<_> = ts2300
+ .iter()
+ .map(|d| (d.args.clone(), d.span.start, d.span.end))
+ .collect();
+ // Every private-name TS2300 carries the `#x` arg (with the `#`), not bare `x`.
+ assert!(
+ ts2300.iter().all(|d| d.args == vec!["#x".to_string()]),
+ "expected all private-name TS2300 args to be `#x`, got {summary:?}",
+ );
+ // No duplicated (code, span) pair survives dedup — the latent extra is closed.
+ let mut spans: Vec<_> = ts2300.iter().map(|d| (d.span.start, d.span.end)).collect();
+ let n = spans.len();
+ spans.sort_unstable();
+ spans.dedup();
+ assert_eq!(
+ spans.len(),
+ n,
+ "a duplicated (code, span) private-name TS2300 remains: {summary:?}",
+ );
+ assert_eq!(
+ n, 3,
+ "expected three deduped private-name TS2300: {summary:?}"
+ );
+ }
+
+ #[test]
+ fn nested_type_literal_method_property_conflict_binds() {
+ // A nested type literal's method-vs-property conflict is bind-time; it was
+ // missed at depth >= 1 because a property signature's own type annotation
+ // never descended. The property/property nested dup was always caught (the
+ // check pass recurses at any depth) — this closes only the bind-time family
+ // gap. Depth-0 control and the nested case both fire two TS2300.
+ let ts2300 = |source: &str| {
+ check(source)
+ .diagnostics
+ .iter()
+ .filter(|d| d.code == 2300)
+ .count()
+ };
+ assert_eq!(ts2300("var a: { m(): void; m: number };"), 2);
+ assert_eq!(ts2300("var a: { outer: { m(): void; m: number } };"), 2);
+ }
+}
diff --git a/crates/tsv_check/src/span_scan.rs b/crates/tsv_check/src/span_scan.rs
new file mode 100644
index 000000000..cde17802d
--- /dev/null
+++ b/crates/tsv_check/src/span_scan.rs
@@ -0,0 +1,56 @@
+//! Small source-scan helpers shared by the binder and the check pass.
+//!
+//! A computed member name (`[ … ]`) points its diagnostic at the whole
+//! `ComputedPropertyName` node — bracket-inclusive — in tsgo. Both the bind
+//! cascade (`binder::sym::resolve_member_key`) and the syntactic check
+//! (`check::duplicate_members::member_key`) need the same `[`…`]` bounds so a
+//! computed-literal key that conflicts at *both* phases produces byte-identical
+//! spans that collapse in the program-wide sort/dedup (rather than two
+//! differently-spanned diagnostics that survive as an extra). Lifting the scan
+//! here keeps that agreement structural, not hand-mirrored.
+//
+// tsgo: internal/checker/checker.go reportDuplicateMemberErrors — the squiggle is
+// getNameOfDeclaration -> the ComputedPropertyName node (bracket-inclusive).
+//
+// TODO: both scans are comment-blind — a raw byte loop takes the first `[`/`]` it
+// meets, so a comment carrying one between the bracket and the key expression wins
+// (`[/* a[0] */ 'k']` spans `[0] */ 'k']`). The wrong span also becomes the wrong
+// `args` entry, which is the leg tsgo's comparer keys on. The fix is the
+// comment-aware `tsv_lang::source_scan::{rfind,find}_char_skipping_comments`;
+// `bracket_start` then needs a lower bound to scan forward from (the enclosing
+// member's start), since the file-start bound it would otherwise take is O(offset)
+// per computed key.
+
+/// The byte offset of the `[` opening a computed key, scanning back from the key
+/// expression's start (a plain byte loop — `[` is ASCII). Falls back to the
+/// expression start if no `[` precedes it (never for a well-formed computed name).
+#[must_use]
+pub(crate) fn bracket_start(source: &str, expr_start: u32) -> u32 {
+ let bytes = source.as_bytes();
+ let mut i = expr_start as usize;
+ while i > 0 {
+ i -= 1;
+ if bytes[i] == b'[' {
+ return i as u32;
+ }
+ }
+ expr_start
+}
+
+/// The byte offset just past the `]` closing a computed key, scanning forward from
+/// the key expression's end (a plain byte loop — `]` is ASCII). Mirrors
+/// [`bracket_start`] so the diagnostic spans the whole `[ … ]` name node (as tsgo
+/// does). Falls back to the expression end if no `]` follows (never for a
+/// well-formed computed name).
+#[must_use]
+pub(crate) fn bracket_end(source: &str, expr_end: u32) -> u32 {
+ let bytes = source.as_bytes();
+ let mut i = expr_end as usize;
+ while i < bytes.len() {
+ if bytes[i] == b']' {
+ return i as u32 + 1;
+ }
+ i += 1;
+ }
+ expr_end
+}
diff --git a/crates/tsv_check/tests/clone_discipline.rs b/crates/tsv_check/tests/clone_discipline.rs
new file mode 100644
index 000000000..a1231d3f8
--- /dev/null
+++ b/crates/tsv_check/tests/clone_discipline.rs
@@ -0,0 +1,466 @@
+//! The borrow-only discipline, enforced: `tsv_check` visitors borrow AST nodes and
+//! never clone them.
+//!
+//! The binder keys its address map on `(std::ptr::from_ref(node) as usize, NodeKind)`,
+//! which resolves only while every node the checker sees is the *same* arena node the
+//! lowering walk numbered. Every tsv AST type derives `Clone`, so one accidental
+//! `.clone()` mints a differently-addressed copy the map has never seen — and the two
+//! resolution paths fail differently: the strict one (`BoundFile::require_node_id`, the
+//! flow builder) aborts loudly, while the lenient unreachable-candidate lookup simply
+//! misses, leaving a silently wrong candidate table rather than a crash. The quiet half
+//! is why the convention needs a guard instead of a code review.
+//!
+//! This test scans every `src/**/*.rs` in the crate for clone-shaped calls (see
+//! [`PATTERNS`]) and fails on any that is not in the reviewed [`ALLOW`] ledger — and on
+//! any ledger entry whose line is no longer in the source, so a sanction can't outlive
+//! the code it sanctions. Occurrences after a comment-starting `//` don't count, so
+//! prose about cloning is inert; a `//` *inside a string literal* does not start a
+//! comment (this crate embeds TS fixtures like `"\t// @ts-ignore\n"`, and truncating
+//! there would hide a clone later on the line).
+//!
+//! An entry is keyed on `(path, exact trimmed line)`, so **one entry covers every
+//! identical line in that file** — identical text carries identical review, and
+//! reformatting a sanctioned line forces a fresh one. That equivalence holds only while
+//! the reason is derivable from the text: a key generic enough to recur incidentally
+//! (a bare `.clone()` left by a chain wrap) would blanket-sanction a file, so
+//! `allow_ledger_keys_carry_context` requires every key to carry surrounding code.
+//!
+//! Test modules are scanned like the rest — a `#[cfg(test)]` clone gets the same
+//! one-line review as any other. Oracle-free, std-only, and it rides `cargo test`, so
+//! the guard lives and dies with the crate it guards.
+//!
+//! **What it cannot see.** A clone scanner is a syntax filter, so four re-addressing
+//! routes stay outside it: (i) a `Copy` AST type needs no clone syntax at all —
+//! `TSKeywordType` is `Copy` *and* address-map-keyed (`leaf(NodeKind::TSKeywordType,
+//! …, addr_of(kw), …)`), so a plain `*kw` deref mints a fresh address invisibly; (ii)
+//! `to_owned()` / `to_vec()` over an AST slice would copy nodes without a clone call
+//! (the one live `decls.to_vec()` in `sym/declare.rs` copies `Decl` PODs, not AST);
+//! (iii) clones performed in another crate are out of scope entirely; and (iv) the
+//! converse — cloning a struct that merely *holds* `&'arena` references is safe, since
+//! the referenced addresses are preserved, so the ledger should not be read as a ban on
+//! all cloning. Keep `Copy` AST nodes borrowed for the same reason the ledger exists.
+
+use std::path::{Path, PathBuf};
+
+/// One reviewed, sanctioned clone site: `(crate-relative path, exact trimmed source
+/// line, why it is safe)`. Every entry must be a **non-AST** clone — an owned name, a
+/// diagnostic, a POD of ids and spans — never an AST node.
+type Allow = (&'static str, &'static str, &'static str);
+
+/// The reviewed ledger. Regenerate the candidate list with
+/// `grep -rn '\.clone(\|\.cloned(\|Clone::clone(' crates/tsv_check/src`, then classify
+/// each site by hand: what is cloned, and why cloning it can't perturb the address map.
+const ALLOW: &[Allow] = &[
+ // ── binder ───────────────────────────────────────────────────────────────
+ (
+ "src/binder/atoms.rs",
+ "self.names.push(owned.clone());",
+ "the interner's owned `Box` name — one copy for the id vector, one as the \
+ lookup key; no AST node is involved",
+ ),
+ (
+ "src/binder/flow/build/statements.rs",
+ "self.label_scratch.get(&label).cloned().unwrap_or_default()",
+ "a label's pending-antecedent `SmallVec<[FlowNodeId; 4]>` — dense flow-node ids, \
+ not AST nodes",
+ ),
+ // ── check ────────────────────────────────────────────────────────────────
+ (
+ "src/check/duplicate_members.rs",
+ "display: key.clone(),",
+ "the member key `String` — an `Entry` carries it twice, as the bucket key and as \
+ the diagnostic display text",
+ ),
+ (
+ "src/check/duplicate_members.rs",
+ "Some((name.clone(), name, id.name_span()))",
+ "the identifier's owned name `String`, returned as both key and display; the AST \
+ identifier itself is only read (`name_span`)",
+ ),
+ (
+ "src/check/duplicate_members.rs",
+ "Expression::Literal(lit) => literal_key(ctx, lit).map(|k| (k.clone(), k, lit.span)),",
+ "the literal's owned key `String`, returned as both key and display; the AST \
+ literal itself is only read (`.span`)",
+ ),
+ (
+ "src/check/duplicate_members.rs",
+ "Some((keyed.clone(), keyed, pid.span))",
+ "the `#name` key `String` built by `format!`, returned as both key and display; \
+ the AST private identifier is only read (`.span`)",
+ ),
+ // ── diag ─────────────────────────────────────────────────────────────────
+ (
+ "src/diag.rs",
+ "let a = with_chain(diag(Some(0), 0, 0, 1), vec![mid.clone()]);",
+ "unit-test fixture: an owned `Diagnostic` reused as the chain of two comparands",
+ ),
+ (
+ "src/diag.rs",
+ "let a = with_related(diag(Some(0), 0, 0, 1), vec![outer_r.clone()]);",
+ "unit-test fixture: an owned `Diagnostic` reused as the related info of two \
+ comparands",
+ ),
+ // ── merge ────────────────────────────────────────────────────────────────
+ (
+ "src/merge.rs",
+ "lib_files: libs.iter().map(|l| l.name.clone()).collect(),",
+ "lib file-name `String`s copied into the base's path table",
+ ),
+ (
+ "src/merge.rs",
+ "let entry = globals.entry(sym.name.clone()).or_insert_with(|| LibEntry {",
+ "the merge symbol's owned name `String` as a globals map key — `FileMerge` is \
+ deliberately AST-free and program-independent",
+ ),
+ (
+ "src/merge.rs",
+ "source.name.clone(),",
+ "the merge symbol's owned name `String` as the globals map key",
+ ),
+ (
+ "src/merge.rs",
+ "name: source.name.clone(),",
+ "the same owned name `String`, stored on the `GlobalEntry` for diagnostics",
+ ),
+ (
+ "src/merge.rs",
+ "decls: source.decls.clone(),",
+ "`Vec` — owned `{FileId, Span, bool}` PODs, no AST reference",
+ ),
+ (
+ "src/merge.rs",
+ "target.decls.extend(source.decls.iter().cloned());",
+ "the same owned `MergeDecl` PODs, accumulated onto the merge target",
+ ),
+ (
+ "src/merge.rs",
+ "let symbol_name = source.name.clone();",
+ "the merge symbol's owned name `String`, borrowed by both `add_dup_errors` calls",
+ ),
+ // ── program ──────────────────────────────────────────────────────────────
+ (
+ "src/program.rs",
+ "diagnostics.extend(unit.bind_diagnostics.iter().cloned());",
+ "owned `Diagnostic`s copied out of the variant-independent bound product so each \
+ variant's run gets its own vector",
+ ),
+ (
+ "src/program.rs",
+ "name: u.name.clone(),",
+ "the unit's file-name `String` for its `FileReport`",
+ ),
+ (
+ "src/program.rs",
+ "parse: u.parse.clone(),",
+ "`ParseReport` — an owned goal / module-ness / node-count record (or a rejection \
+ message), never the AST",
+ ),
+ (
+ "src/program.rs",
+ ".map(|d| (d.args.clone(), d.span.start, d.span.end))",
+ "unit-test fixture: a diagnostic's owned `Vec` args, for the assertion's \
+ failure summary",
+ ),
+];
+
+/// The clone-shaped call forms the scan recognizes: the method calls `.clone(` /
+/// `.cloned(` / `.clone_from(`, and the qualified forms `Clone::clone(` and `>::clone(`
+/// (which catches UFCS `::clone(x)`). Classification is an `any`, so the
+/// overlap between the two qualified forms costs nothing.
+const PATTERNS: [&str; 5] = [
+ ".clone(",
+ ".cloned(",
+ ".clone_from(",
+ "Clone::clone(",
+ ">::clone(",
+];
+
+/// The crate directory, prefixed onto reported paths so a violation line resolves from
+/// the workspace root — where `cargo test` runs. Ledger keys stay crate-relative.
+const CRATE_DIR: &str = "crates/tsv_check";
+
+/// The floor on an [`ALLOW`] key's length. A key is a blanket sanction for every
+/// identical line in its file, so it must carry enough surrounding code to be specific;
+/// a bare `.clone();` left behind by a chain wrap must not qualify.
+const MIN_ALLOW_KEY_LEN: usize = 16;
+
+/// What a new, unreviewed clone site costs — printed beside every violation, because
+/// the failure mode this guards is silent and the fix depends on what was cloned.
+const HAZARD_HELP: &str = "\
+Cloning an AST node mints a differently-addressed copy, and the binder's address map
+keys on `(address, NodeKind)` — so the copy resolves to nothing. The strict path
+(`BoundFile::require_node_id`, the flow builder) aborts on that miss, but the lenient
+unreachable-candidate lookup just skips the node, leaving a silently wrong candidate
+table instead of a crash.
+
+Two ways out: borrow the node (`&'arena`) instead of cloning it — or, if this is
+genuinely a non-AST clone (an owned name, a diagnostic, a POD of ids and spans), add a
+reviewed entry to ALLOW in crates/tsv_check/tests/clone_discipline.rs naming what is
+cloned and why it cannot perturb the address map.";
+
+/// What a stale ledger entry means, and the one thing to do about it.
+const STALE_HELP: &str = "\
+The sanctioned line is no longer in the source — the clone was removed, or the line was
+reformatted (which invalidates its review). Remove the entry from ALLOW in
+crates/tsv_check/tests/clone_discipline.rs.";
+
+/// A detected clone-shaped call site.
+struct Site {
+ path: String,
+ line_no: usize,
+ code: String,
+}
+
+#[test]
+fn every_clone_site_is_reviewed() {
+ let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
+ let src = crate_root.join("src");
+ let mut files = Vec::new();
+ // An unreadable path — file OR directory — means the scan covered less than it
+ // claims, which would let a clone through silently. Both land in one loud list.
+ let mut unreadable: Vec = Vec::new();
+ collect_rs_files(&src, &mut files, &mut unreadable);
+ assert!(
+ !files.is_empty(),
+ "no .rs files found under {} — the scan would pass vacuously",
+ src.display()
+ );
+
+ let mut sites: Vec = Vec::new();
+ for file in &files {
+ let Ok(text) = std::fs::read_to_string(file) else {
+ unreadable.push(file.clone());
+ continue;
+ };
+ let rel = crate_relative(file, crate_root);
+ for (i, line) in text.lines().enumerate() {
+ if let Some(code) = clone_site_line(line) {
+ sites.push(Site {
+ path: rel.clone(),
+ line_no: i + 1,
+ code,
+ });
+ }
+ }
+ }
+ assert!(
+ unreadable.is_empty(),
+ "unreadable source path(s), so the scan is incomplete: {unreadable:?}"
+ );
+
+ let violations: Vec<&Site> = sites.iter().filter(|s| !is_allowed(s)).collect();
+ // A sanctioned line that no longer exists: the clone was removed, or the line was
+ // reformatted (which invalidates the review). The ledger must mirror the live sites
+ // exactly, so a dead entry fails too.
+ let stale: Vec<&Allow> = ALLOW
+ .iter()
+ .filter(|entry| !sites.iter().any(|s| s.path == entry.0 && s.code == entry.1))
+ .collect();
+
+ let mut report: Vec = Vec::new();
+ if !violations.is_empty() {
+ report.push(format!(
+ "{} unreviewed clone site(s) in tsv_check:\n",
+ violations.len()
+ ));
+ for v in &violations {
+ report.push(format!(
+ " {CRATE_DIR}/{}:{}: {}",
+ v.path, v.line_no, v.code
+ ));
+ }
+ report.push(format!("\n{HAZARD_HELP}"));
+ }
+ if !stale.is_empty() {
+ if !report.is_empty() {
+ report.push(String::new());
+ }
+ report.push(format!("{} stale ALLOW entr(y/ies):\n", stale.len()));
+ for (path, line, reason) in &stale {
+ report.push(format!(" {path}: {line}\n ({reason})"));
+ }
+ report.push(format!("\n{STALE_HELP}"));
+ }
+ assert!(report.is_empty(), "{}", report.join("\n"));
+}
+
+#[test]
+fn allow_ledger_has_no_duplicate_keys() {
+ // (path, line) must be unique: a second entry for the same key is dead weight that
+ // can never go stale on its own, so it would silently outlive its review.
+ let mut seen = std::collections::BTreeSet::new();
+ for (path, line, _) in ALLOW {
+ assert!(
+ seen.insert((*path, *line)),
+ "duplicate ALLOW key: {path}: {line}"
+ );
+ }
+}
+
+#[test]
+fn detector_recognizes_the_clone_forms() {
+ assert_eq!(
+ clone_site_line("let b = a.clone();").as_deref(),
+ Some("let b = a.clone();")
+ );
+ assert_eq!(
+ clone_site_line("\t\txs.iter().cloned().collect()").as_deref(),
+ Some("xs.iter().cloned().collect()")
+ );
+ assert_eq!(
+ clone_site_line("dst.clone_from(&src);").as_deref(),
+ Some("dst.clone_from(&src);")
+ );
+ assert_eq!(
+ clone_site_line("let b = Clone::clone(&a);").as_deref(),
+ Some("let b = Clone::clone(&a);")
+ );
+ // The UFCS form names no `Clone::` path of its own.
+ assert_eq!(
+ clone_site_line("let b = ::clone(a);").as_deref(),
+ Some("let b = ::clone(a);")
+ );
+ // A derive is not a call site.
+ assert_eq!(clone_site_line("#[derive(Clone)]"), None);
+ assert_eq!(clone_site_line("let x = 1 + 2;"), None);
+}
+
+#[test]
+fn detector_ignores_comments() {
+ // Doc and line comments discussing clones are prose, not call sites.
+ assert_eq!(clone_site_line("//! one `.clone()` breaks the map"), None);
+ assert_eq!(clone_site_line("/// never call `.clone()` here"), None);
+ assert_eq!(
+ clone_site_line(" // node.clone() would mint a copy"),
+ None
+ );
+ // …but a real clone with trailing commentary still counts, keyed on the whole line.
+ assert_eq!(
+ clone_site_line("let n = name.clone(); // owned String").as_deref(),
+ Some("let n = name.clone(); // owned String")
+ );
+}
+
+#[test]
+fn detector_sees_past_a_slash_slash_inside_a_string() {
+ // The crate embeds TS fixtures carrying `//`; cutting the line there would hide
+ // every clone after it. Both a URL and an embedded line comment must stay open.
+ assert_eq!(
+ clone_site_line("let s = \"https://x\"; s.clone();").as_deref(),
+ Some("let s = \"https://x\"; s.clone();")
+ );
+ assert_eq!(
+ clone_site_line("let src = \"a();\\n\\t// @ts-ignore\\n\"; src.clone();").as_deref(),
+ Some("let src = \"a();\\n\\t// @ts-ignore\\n\"; src.clone();")
+ );
+ // A real comment after a balanced string still ends the code.
+ assert_eq!(clone_site_line("let s = \"//\"; // s.clone() here"), None);
+}
+
+#[test]
+fn allow_ledger_keys_carry_context() {
+ // A key blanket-sanctions every identical line in its file, so it has to be
+ // specific: it must not be (or start as) a bare clone call, and must carry
+ // surrounding code. A `.clone();` left by a rustfmt chain wrap fails both.
+ for (path, line, _) in ALLOW {
+ assert!(
+ !PATTERNS.iter().any(|pattern| line.starts_with(pattern)),
+ "ALLOW key is a bare clone call, so it would sanction any such line in \
+ {path}: {line}"
+ );
+ assert!(
+ line.len() >= MIN_ALLOW_KEY_LEN,
+ "ALLOW key is too generic ({} < {MIN_ALLOW_KEY_LEN} chars) in {path}: {line}",
+ line.len()
+ );
+ }
+}
+
+/// Whether `site` matches an [`ALLOW`] entry on path **and** exact trimmed line.
+fn is_allowed(site: &Site) -> bool {
+ ALLOW
+ .iter()
+ .any(|(path, line, _)| *path == site.path && *line == site.code)
+}
+
+/// The trimmed text of `line` if its code carries a clone-shaped call, else `None`.
+///
+/// The returned key is the *whole* trimmed line, trailing comment included — the ledger
+/// sanctions a line as written.
+fn clone_site_line(line: &str) -> Option {
+ let code = code_before_comment(line);
+ PATTERNS
+ .iter()
+ .any(|pattern| code.contains(pattern))
+ .then(|| line.trim().to_string())
+}
+
+/// The code portion of `line`: everything ahead of the first `//` that actually starts
+/// a comment. Cutting there subsumes the whole-line-comment case (`//`, `///` and `//!`
+/// all leave nothing but indentation ahead of it) and drops trailing commentary, so
+/// prose about cloning never trips the scan — but a `//` inside a string literal is
+/// data, not a comment, and is skipped so a clone later on the line stays visible.
+fn code_before_comment(line: &str) -> &str {
+ let mut from = 0;
+ while let Some(rel) = line[from..].find("//") {
+ let at = from + rel;
+ if !ends_inside_string(&line[..at]) {
+ return &line[..at];
+ }
+ from = at + 2;
+ }
+ line
+}
+
+/// Whether `prefix` ends inside a double-quoted literal — an odd number of `"` that
+/// aren't backslash-escaped.
+///
+/// A heuristic, and deliberately biased: raw strings (`r#"…"#`), byte strings and a
+/// `'"'` char literal all read as an unbalanced quote, which makes this answer *yes*
+/// and keeps **more** of the line in scope. So a misjudgment can only surface a site
+/// for review (a false positive the ledger resolves), never hide one.
+fn ends_inside_string(prefix: &str) -> bool {
+ let bytes = prefix.as_bytes();
+ let mut quotes = 0usize;
+ let mut i = 0;
+ while i < bytes.len() {
+ match bytes[i] {
+ // Skip the escaped byte, so `\"` never counts as a delimiter.
+ b'\\' => i += 1,
+ b'"' => quotes += 1,
+ _ => {}
+ }
+ i += 1;
+ }
+ quotes % 2 == 1
+}
+
+/// Every `.rs` file under `dir`, recursively, in deterministic (sorted) order. A
+/// directory that cannot be read is recorded in `unreadable` rather than skipped — a
+/// silently pruned subtree is a hole in the scan.
+fn collect_rs_files(dir: &Path, out: &mut Vec, unreadable: &mut Vec) {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ unreadable.push(dir.to_path_buf());
+ return;
+ };
+ let mut paths: Vec = entries
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .collect();
+ paths.sort();
+ for path in paths {
+ if path.is_dir() {
+ collect_rs_files(&path, out, unreadable);
+ } else if path.extension().is_some_and(|ext| ext == "rs") {
+ out.push(path);
+ }
+ }
+}
+
+/// Crate-relative path: `/src/merge.rs` → `src/merge.rs`.
+fn crate_relative(path: &Path, crate_root: &Path) -> String {
+ path.strip_prefix(crate_root)
+ .unwrap_or(path)
+ .to_string_lossy()
+ .replace('\\', "/")
+}
diff --git a/crates/tsv_check/tests/lib_base.rs b/crates/tsv_check/tests/lib_base.rs
new file mode 100644
index 000000000..cd3b938f8
--- /dev/null
+++ b/crates/tsv_check/tests/lib_base.rs
@@ -0,0 +1,112 @@
+//! End-to-end lib-base integration: parse + bind real (small) `.d.ts` lib sources,
+//! fold them into a [`LibBase`], and check a program against it — the full S5 path
+//! (`bind_lib` -> `LibBase::build` -> `check_program_with_lib`) a single move.
+//!
+//! Distinct from the `merge` unit tests (which drive synthetic `FileMerge`s): these
+//! drive the *binder* over real lib TypeScript, so the lib global's flags come from
+//! the same bind path the harness uses.
+
+use bumpalo::Bump;
+use tsv_check::{CheckOptions, FileId, LibBase, SourceUnit, bind_lib, check_program_with_lib};
+
+/// `var eval;` conflicts with the lib's `declare function eval` — the
+/// `variableDeclarationInStrictMode1` shape, end to end.
+#[test]
+fn var_eval_conflicts_with_lib_function_eval() {
+ let es5 =
+ bind_lib("lib.es5.d.ts", "declare function eval(x: string): any;").expect("lib parses");
+ let base = LibBase::build(&[&es5]);
+
+ let arena = Bump::new();
+ let units = [SourceUnit::new("t.ts", "\"use strict\";\nvar eval;")];
+ let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default());
+
+ // The observable primary is on the test file (FileId 0); the lib-file primary
+ // (FileId 1 = lib.es5.d.ts) is present too but is what the baseline masks.
+ let test_primary = result
+ .diagnostics
+ .iter()
+ .find(|d| d.file == Some(FileId(0)) && d.code == 2300)
+ .expect("a TS2300 on the test file");
+ // `var eval` starts on line 2, column 5 -> byte offset of the `eval` name.
+ assert_eq!(test_primary.related.len(), 1);
+ assert_eq!(test_primary.related[0].code, 6203);
+ assert_eq!(test_primary.related[0].file, Some(FileId(1))); // lib.es5.d.ts
+ // A masked lib-file primary exists (the runner drops it; the baseline hides it).
+ assert!(
+ result
+ .diagnostics
+ .iter()
+ .any(|d| d.file == Some(FileId(1)) && d.code == 2300)
+ );
+}
+
+/// `class Promise {}` conflicts with a lib global declared across several files,
+/// producing the priority-ordered TS6203/6204 related chain.
+#[test]
+fn class_promise_conflicts_across_lib_files() {
+ // Priority order: es5 (interface), es2015.iterable (interface), es2015.promise
+ // (var) — the fold order fixes the related-info attribution.
+ let es5 = bind_lib("lib.es5.d.ts", "interface Promise {}").expect("parses");
+ let iterable = bind_lib("lib.es2015.iterable.d.ts", "interface Promise {}").expect("parses");
+ let promise = bind_lib(
+ "lib.es2015.promise.d.ts",
+ "declare var Promise: PromiseConstructor;",
+ )
+ .expect("parses");
+ let base = LibBase::build(&[&es5, &iterable, &promise]);
+
+ let arena = Bump::new();
+ let units = [SourceUnit::new(
+ "promiseDefinitionTest.ts",
+ "class Promise {}",
+ )];
+ let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default());
+
+ let primary = result
+ .diagnostics
+ .iter()
+ .find(|d| d.file == Some(FileId(0)) && d.code == 2300)
+ .expect("a TS2300 on the test file");
+ let codes: Vec = primary.related.iter().map(|r| r.code).collect();
+ assert_eq!(codes, vec![6203, 6204, 6204]);
+ // Priority order: es5 (FileId 1), es2015.iterable (2), es2015.promise (3).
+ let files: Vec> = primary.related.iter().map(|r| r.file).collect();
+ assert_eq!(
+ files,
+ vec![Some(FileId(1)), Some(FileId(2)), Some(FileId(3))]
+ );
+}
+
+/// A clean augmentation (`interface Array {}` merging into the lib's `Array`)
+/// emits nothing — the lib base must not manufacture spurious conflicts.
+#[test]
+fn interface_augmentation_of_lib_is_silent() {
+ let es5 = bind_lib(
+ "lib.es5.d.ts",
+ "interface Array { length: number; }\ndeclare var Array: ArrayConstructor;",
+ )
+ .expect("parses");
+ let base = LibBase::build(&[&es5]);
+
+ let arena = Bump::new();
+ let units = [SourceUnit::new(
+ "t.ts",
+ "interface Array { extra(): void; }",
+ )];
+ let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default());
+ assert!(
+ result.diagnostics.is_empty(),
+ "a legal interface merge must be silent"
+ );
+}
+
+/// With no lib base, the same program is clean (the conflict is lib-sourced) —
+/// proving the base is what introduces the cross-declaration-space conflict.
+#[test]
+fn no_lib_base_no_conflict() {
+ let arena = Bump::new();
+ let units = [SourceUnit::new("t.ts", "var eval;")];
+ let result = check_program_with_lib(&units, None, &arena, &CheckOptions::default());
+ assert!(result.diagnostics.is_empty());
+}
diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md
index 82b77468f..bc584d732 100644
--- a/crates/tsv_debug/CLAUDE.md
+++ b/crates/tsv_debug/CLAUDE.md
@@ -15,6 +15,7 @@ The Deno sidecar is a **long-running subprocess pool** spawned lazily on first u
- `deno/` — Sidecar plumbing: `actor.rs` owns one spawned process (`mod.rs` holds the pool + round-robin dispatch), `protocol.rs` is the JSON-lines wire format, `sidecar.ts` is the embedded TypeScript that runs inside Deno and dispatches to prettier / svelte (parse **and** `compile`) / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. The `svelte-render-key` tool compiles for the server target and reduces the baked template to its browser-visible render (block-aware whitespace collapse, `