From cb6a8fdbfdcf98519aff06e7d255025f9124001d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 9 Jul 2026 21:46:55 -0400 Subject: [PATCH 01/79] =?UTF-8?q?docs:=20typechecker-branch=20commit=20reg?= =?UTF-8?q?ime=20=E2=80=94=20agent=20commits=20permitted=20on=20this=20wor?= =?UTF-8?q?ktree=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f58edf82..54be0d3e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,16 @@ High-performance Rust parser as a drop-in replacement for Svelte's modern parser ## Committing -`git add` and `git commit` are denied by `.claude/settings.local.json` in -this repo — make the edits and stop, the user commits. +**Branch exception (`typechecker` branch / the `~/dev/tsv-typechecker` +worktree only — remove this block before merging to main):** agent commits +ARE permitted here. Commit at sensible stopping points with short 1-liner +messages (`fix:` / `feat:` / `docs:` / `test:` / `refactor:` / `chore:` +prefixes; no body, no trailers, no `Co-Authored-By`). Merging to main, +version bumps, and publishing remain user-owned. + +On main and every other branch: `git add` and `git commit` are denied by +`.claude/settings.local.json` in this repo — make the edits and stop, the +user commits. **Do not edit `CHANGELOG.md`.** Like release version bumps, the changelog is the user's responsibility — agents make the source/doc/fixture edits and leave From 4f2929199ea6d7bb4f20b33751cae1b6a1a676d2 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 9 Jul 2026 22:30:23 -0400 Subject: [PATCH 02/79] feat: tsc_conformance query tool over tsgo baselines --- crates/tsv_debug/src/cli/commands/mod.rs | 1 + .../src/cli/commands/tsc_conformance.rs | 184 +++++++++ crates/tsv_debug/src/cli/mod.rs | 3 + crates/tsv_debug/src/lib.rs | 1 + crates/tsv_debug/src/main.rs | 1 + .../tsv_debug/src/tsc_conformance/baseline.rs | 191 +++++++++ .../src/tsc_conformance/discovery.rs | 92 +++++ crates/tsv_debug/src/tsc_conformance/mod.rs | 17 + crates/tsv_debug/src/tsc_conformance/query.rs | 380 ++++++++++++++++++ 9 files changed, 870 insertions(+) create mode 100644 crates/tsv_debug/src/cli/commands/tsc_conformance.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/baseline.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/discovery.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/mod.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/query.rs diff --git a/crates/tsv_debug/src/cli/commands/mod.rs b/crates/tsv_debug/src/cli/commands/mod.rs index 55f3e5b65..d57e84cf0 100644 --- a/crates/tsv_debug/src/cli/commands/mod.rs +++ b/crates/tsv_debug/src/cli/commands/mod.rs @@ -24,6 +24,7 @@ pub mod scan_audit; pub mod swallow_audit; pub mod test262; pub mod ts_fixture_audit; +pub mod tsc_conformance; use crate::cli::CliError; use crate::fixtures::Fixture; diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs new file mode 100644 index 000000000..472a68ade --- /dev/null +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -0,0 +1,184 @@ +//! tsc_conformance command — ad-hoc queries over the TypeScript-Go conformance +//! baselines (`*.errors.txt`). Pure Rust, no typechecker: tool #1 of the +//! typechecker conformance harness (the "ask important questions" tool). Reads +//! only the committed tsgo baselines — the corpus *inputs* live in a git +//! submodule that is often unmaterialized. + +use crate::cli::CliError; +use crate::tsc_conformance::{ + baselines_dir, corpus_materialized, denominators, discover_baselines, histogram, tests_by_code, +}; +use argh::FromArgs; +use std::path::PathBuf; + +/// REGRESSION PIN (exact): total tsgo .errors.txt baselines. Measured +/// 2026-07-09, ../typescript-go at 168e7015 (_submodules/TypeScript corpus pin +/// 4d4f005c, may be unmaterialized). The checkout is updated deliberately, so any +/// move (a discovery bug, or a typescript-go pull) must be re-pinned here. +const BASELINE_COUNT_PIN: usize = 7033; + +/// Query the tsgo TypeScript conformance baselines. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "tsc_conformance")] +pub struct TscConformanceCommand { + #[argh(subcommand)] + nested: TscConformanceSub, +} + +#[derive(FromArgs, Debug)] +#[argh(subcommand)] +enum TscConformanceSub { + Query(QueryCommand), +} + +/// Answer an ad-hoc question over the baselines. +/// +/// Queries: `histogram` (per-code instance counts + totals), `tests-by-code +/// ` (baselines mentioning a code), `denominators` (test-identity sizing). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "query")] +pub struct QueryCommand { + /// path to the typescript-go checkout (default: ../typescript-go) + #[argh(option, default = "PathBuf::from(\"../typescript-go\")")] + path: PathBuf, + + /// emit JSON instead of a human table + #[argh(switch)] + json: bool, + + /// which query: `histogram`, `tests-by-code`, or `denominators` + #[argh(positional)] + kind: String, + + /// query arguments (e.g. the error code for `tests-by-code`) + #[argh(positional)] + args: Vec, +} + +impl TscConformanceCommand { + pub(crate) fn run(self) -> Result<(), CliError> { + match self.nested { + TscConformanceSub::Query(query) => query.run(), + } + } +} + +impl QueryCommand { + fn run(self) -> Result<(), CliError> { + let dir = baselines_dir(&self.path); + if !dir.exists() { + eprintln!( + "Error: tsgo baselines directory not found: {}", + dir.display() + ); + eprintln!(); + eprintln!("Expected a typescript-go checkout with committed baselines. To set it up:"); + eprintln!(" cd .. && git clone https://github.com/microsoft/typescript-go"); + eprintln!(" cd typescript-go && git submodule update --init"); + eprintln!(); + eprintln!("Or specify a custom path:"); + eprintln!( + " cargo run -p tsv_debug tsc_conformance query {} --path /path/to/typescript-go", + self.kind + ); + return Err(CliError::Failed); + } + + let baselines = match discover_baselines(&dir) { + Ok(baselines) => baselines, + Err(e) => { + eprintln!("Error discovering baselines: {e}"); + return Err(CliError::Failed); + } + }; + + match self.kind.as_str() { + "histogram" => { + enforce_pin(baselines.len())?; + let report = histogram(&baselines); + if self.json { + print_json(&report) + } else { + report.print_table(); + Ok(()) + } + } + "denominators" => { + enforce_pin(baselines.len())?; + let report = denominators(&baselines); + if self.json { + print_json(&report) + } else { + report.print_summary(corpus_materialized(&self.path)); + Ok(()) + } + } + "tests-by-code" => { + let Some(code_arg) = self.args.first() else { + eprintln!( + "Error: `tests-by-code` requires an error code, e.g. `tests-by-code 2454`" + ); + return Err(CliError::Failed); + }; + let code = parse_code(code_arg)?; + let report = tests_by_code(&baselines, code); + if self.json { + print_json(&report) + } else { + report.print(); + Ok(()) + } + } + // TODO(tsc_conformance): pin-diff subquery — "what moved between two + // tsgo refs" (which codes/tests appeared or vanished). Answered + // manually for this pin; needs two baseline snapshots to diff, so it's + // deferred to a later slice rather than stubbed with fake data. + other => { + eprintln!( + "Error: unknown query `{other}`. Valid queries: histogram, tests-by-code , denominators." + ); + Err(CliError::Failed) + } + } + } +} + +/// Enforce the baseline-count regression pin (unfiltered `histogram` / +/// `denominators` runs), mirroring `test262`'s hard-fail on a pin mismatch. +fn enforce_pin(count: usize) -> Result<(), CliError> { + if count != BASELINE_COUNT_PIN { + eprintln!( + "Error: pinned count mismatch — discovered {count} .errors.txt baselines ≠ pinned {BASELINE_COUNT_PIN}. \ + If deliberate (a typescript-go pull, a discovery change), re-pin BASELINE_COUNT_PIN." + ); + return Err(CliError::Failed); + } + Ok(()) +} + +/// Parse an error code, accepting a bare number (`2454`) or a `TS`-prefixed form +/// (`TS2454`, case-insensitive). +fn parse_code(arg: &str) -> Result { + let digits = arg + .strip_prefix("TS") + .or_else(|| arg.strip_prefix("ts")) + .unwrap_or(arg); + digits.parse().map_err(|_| { + eprintln!("Error: invalid error code `{arg}` — expected a number like 2454 or TS2454."); + CliError::Failed + }) +} + +/// Serialize a report to pretty JSON on stdout. +fn print_json(report: &T) -> Result<(), CliError> { + match serde_json::to_string_pretty(report) { + Ok(json) => { + println!("{json}"); + Ok(()) + } + Err(e) => { + eprintln!("Error serializing JSON: {e}"); + Err(CliError::Failed) + } + } +} diff --git a/crates/tsv_debug/src/cli/mod.rs b/crates/tsv_debug/src/cli/mod.rs index 0255de76e..572c41b85 100644 --- a/crates/tsv_debug/src/cli/mod.rs +++ b/crates/tsv_debug/src/cli/mod.rs @@ -16,6 +16,7 @@ use commands::{ json_profile::JsonProfileCommand, lex_diff::LexDiffCommand, line_width::LineWidthCommand, metrics::MetricsCommand, profile::ProfileCommand, scan_audit::ScanAuditCommand, test262::Test262Command, ts_fixture_audit::TsFixtureAuditCommand, + tsc_conformance::TscConformanceCommand, }; /// A command failure, carrying the process exit code up to the single exit @@ -84,6 +85,7 @@ pub enum Subcommand { #[cfg(feature = "swallow_check")] SwallowAudit(SwallowAuditCommand), Test262(Test262Command), + TscConformance(TscConformanceCommand), TsFixtureAudit(TsFixtureAuditCommand), } @@ -121,6 +123,7 @@ impl TopLevel { #[cfg(feature = "swallow_check")] Subcommand::SwallowAudit(c) => c.run(), Subcommand::Test262(c) => c.run(), + Subcommand::TscConformance(c) => c.run(), Subcommand::TsFixtureAudit(c) => c.run(), } } diff --git a/crates/tsv_debug/src/lib.rs b/crates/tsv_debug/src/lib.rs index e8b864815..8db01d287 100644 --- a/crates/tsv_debug/src/lib.rs +++ b/crates/tsv_debug/src/lib.rs @@ -5,3 +5,4 @@ pub mod error; pub mod fixtures; pub mod render_normalize; pub mod test262; +pub mod tsc_conformance; diff --git a/crates/tsv_debug/src/main.rs b/crates/tsv_debug/src/main.rs index 4a7e3c65a..653db81ac 100644 --- a/crates/tsv_debug/src/main.rs +++ b/crates/tsv_debug/src/main.rs @@ -5,6 +5,7 @@ mod error; mod fixtures; mod render_normalize; mod test262; +mod tsc_conformance; use std::process::ExitCode; diff --git a/crates/tsv_debug/src/tsc_conformance/baseline.rs b/crates/tsv_debug/src/tsc_conformance/baseline.rs new file mode 100644 index 000000000..bb40aa4fd --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/baseline.rs @@ -0,0 +1,191 @@ +//! Parse the summary block of a tsgo `.errors.txt` baseline. +//! +//! A baseline file has a leading **summary block** — one line per diagnostic, +//! canonically sorted — followed by per-file sections that reprint the source +//! with `!!!`-prefixed diagnostic bodies: +//! +//! ```text +//! (,): error TS: ← positional summary line +//! error TS: ← global (fileless) summary line +//! +//! +//! !!! error TS: ← global re-render (skip) +//! ==== ( errors) ==== ← first per-file section +//! ← 4-space-indented, verbatim +//! ``` +//! +//! The counting rule (one diagnostic **instance** per summary line): read only +//! the lines **before** the first `==== ` header, skipping blank lines and any +//! `!!!` line (the global re-render, which would otherwise double-count). Source +//! lines can contain the literal text `error TS`, so the scan must never enter a +//! `==== ` section — hence the hard stop at the first header. +//! +//! A later slice extends this module with full section parsing + rendering for a +//! round-trip check against tsgo; the summary parser here is the shared seed. + +/// One diagnostic parsed from a baseline's summary block. +/// +/// A line **with** the `(,)` prefix is *positional* (`file`/`line`/ +/// `col` are `Some`); a line **without** it is a *global* (fileless) diagnostic +/// (all three are `None`). `code` is the numeric `TS` in both cases. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryDiagnostic { + /// The source file the diagnostic points at, or `None` for a global + /// (fileless) diagnostic. Extracted verbatim from the summary line. + pub file: Option, + /// 1-based line number, or `None` for a global diagnostic. + pub line: Option, + /// 1-based column number, or `None` for a global diagnostic. + pub col: Option, + /// The `TS` number (e.g. `2454`). + pub code: u32, +} + +/// Parse a baseline file's summary block into its diagnostic instances. +/// +/// Handles CRLF baselines: [`str::lines`] strips the trailing `\r`. The scan +/// stops at the first `==== ` section header so source text is never parsed. +pub fn parse_summary_block(content: &str) -> Vec { + let mut diagnostics = Vec::new(); + + for line in content.lines() { + // The summary block ends at the first per-file section header. + if line.starts_with("==== ") { + break; + } + // Skip blank lines and the `!!!` global re-render (the bare summary line + // above it is the one instance we count). + if line.trim().is_empty() || line.starts_with("!!!") { + continue; + } + if let Some(diag) = parse_summary_line(line) { + diagnostics.push(diag); + } + } + + diagnostics +} + +/// Parse one summary-block line, or `None` if it is not a diagnostic line. +/// +/// Two shapes: +/// - global: `error TS: ` +/// - positional: `(,): error TS: ` +fn parse_summary_line(line: &str) -> Option { + // Global (fileless) diagnostic: `error TS: `. + if let Some(rest) = line.strip_prefix("error TS") { + let code = leading_code(rest)?; + return Some(SummaryDiagnostic { + file: None, + line: None, + col: None, + code, + }); + } + + // Positional diagnostic: the `): error TS` marker separates the + // `(,)` prefix from the code. Filenames don't contain this + // marker, so the first occurrence is the boundary. + let marker = "): error TS"; + let idx = line.find(marker)?; + let code = leading_code(&line[idx + marker.len()..])?; + + let head = &line[..idx]; // `(,` + let open = head.rfind('(')?; + let file = &head[..open]; + let (l, c) = head[open + 1..].split_once(',')?; // `,` + let line_no: u32 = l.parse().ok()?; + let col_no: u32 = c.parse().ok()?; + + Some(SummaryDiagnostic { + file: Some(file.to_string()), + line: Some(line_no), + col: Some(col_no), + code, + }) +} + +/// Read the leading run of ASCII digits from `s`, requiring a `:` terminator +/// (the `error TS:` shape). Returns `None` if `s` doesn't start with a +/// digit or the digits aren't followed by `:`. +fn leading_code(s: &str) -> Option { + let end = s.find(|ch: char| !ch.is_ascii_digit())?; + if end == 0 { + return None; // no leading digits + } + if s.as_bytes().get(end) != Some(&b':') { + return None; // digits not terminated by ':' + } + s[..end].parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_positional_and_stops_at_section() { + // CRLF, exactly as the baselines are stored. The `!!!` body inside the + // section must not be reached (we stop at `==== `). + let content = "foo.ts(3,1): error TS1206: Decorators are not valid here.\r\n\ + \r\n\r\n\ + ==== foo.ts (1 errors) ====\r\n\ + \t@dec\r\n\ + \t~\r\n\ + !!! error TS1206: Decorators are not valid here.\r\n"; + let diags = parse_summary_block(content); + assert_eq!(diags.len(), 1); + assert_eq!( + diags[0], + SummaryDiagnostic { + file: Some("foo.ts".to_string()), + line: Some(3), + col: Some(1), + code: 1206, + } + ); + } + + #[test] + fn global_diagnostic_counted_once_not_from_bang_render() { + // A bare global line plus its `!!!` re-render, both before the first + // `====`. The re-render must not double-count. + let content = "error TS5102: Option 'downlevelIteration' has been removed.\r\n\ + \r\n\r\n\ + !!! error TS5102: Option 'downlevelIteration' has been removed.\r\n\ + ==== a.ts (0 errors) ====\r\n\ + \tvar a: any;\r\n"; + let diags = parse_summary_block(content); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, 5102); + assert!(diags[0].file.is_none()); + assert!(diags[0].line.is_none()); + assert!(diags[0].col.is_none()); + } + + #[test] + fn source_lines_after_section_are_not_parsed() { + // A source line inside a `====` section literally contains `error TS`; + // it must be ignored because the scan stops at the section header. + let content = "a.ts(1,1): error TS2304: Cannot find name 'x'.\r\n\ + \r\n\r\n\ + ==== a.ts (1 errors) ====\r\n\ + \t// error TS9999: not a real diagnostic\r\n"; + let diags = parse_summary_block(content); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, 2304); + } + + #[test] + fn multiple_summary_lines_split_positional_and_global() { + let content = "a.ts(1,1): error TS2304: Cannot find name 'x'.\r\n\ + error TS5102: Option 'downlevelIteration' has been removed.\r\n\ + a.ts(2,5): error TS2454: Variable 'y' is used before being assigned.\r\n\ + ==== a.ts (2 errors) ====\r\n"; + let diags = parse_summary_block(content); + assert_eq!(diags.len(), 3); + assert_eq!(diags.iter().filter(|d| d.file.is_some()).count(), 2); + assert_eq!(diags.iter().filter(|d| d.file.is_none()).count(), 1); + assert_eq!(diags[1].code, 5102); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/discovery.rs b/crates/tsv_debug/src/tsc_conformance/discovery.rs new file mode 100644 index 000000000..45a4782d4 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/discovery.rs @@ -0,0 +1,92 @@ +//! Discover tsgo conformance baseline files (`*.errors.txt`). +//! +//! Mirrors `test262/discovery.rs`: a plain `std::fs` recursion, no extra +//! dependency. The baselines are the committed oracle; the corpus *inputs* live +//! in a separate (often unmaterialized) submodule that these queries don't need. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// The committed baselines live here, relative to a typescript-go checkout. +const BASELINES_SUBDIR: &str = "testdata/baselines/reference/submodule"; + +/// The corpus *input* files live here — a git submodule, often unmaterialized. +const CORPUS_SUBDIR: &str = "_submodules/TypeScript"; + +/// A discovered baseline file (`*.errors.txt`). +#[derive(Debug, Clone)] +pub struct Baseline { + /// Path to the `.errors.txt` file on disk. + pub path: PathBuf, + /// Path relative to the baselines root, `/`-separated (e.g. + /// `compiler/foo.errors.txt`). The stable identity used by every query. + pub relative_path: String, +} + +/// The baselines directory inside a typescript-go checkout. +pub fn baselines_dir(checkout: &Path) -> PathBuf { + checkout.join(BASELINES_SUBDIR) +} + +/// Whether the corpus *input* submodule is materialized (has any entries). +/// +/// The core queries never touch it, but precise JSX detection and the (deferred) +/// pin-diff query would — so callers note when it's empty and skip rather than +/// pretend. A missing or empty directory both read as not materialized. +pub fn corpus_materialized(checkout: &Path) -> bool { + let dir = checkout.join(CORPUS_SUBDIR); + fs::read_dir(&dir).is_ok_and(|mut it| it.next().is_some()) +} + +/// Walk the baselines directory and discover every `.errors.txt` file, sorted by +/// relative path. The sibling `.types` / `.symbols` baselines are ignored — only +/// files ending in `.errors.txt` are collected. +pub fn discover_baselines(baselines_dir: &Path) -> Result, String> { + if !baselines_dir.exists() { + return Err(format!( + "baselines directory not found: {}", + baselines_dir.display() + )); + } + + let mut out = Vec::new(); + discover_recursive(baselines_dir, baselines_dir, &mut out)?; + out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + Ok(out) +} + +fn discover_recursive(dir: &Path, root: &Path, out: &mut Vec) -> Result<(), String> { + let entries = fs::read_dir(dir) + .map_err(|e| format!("Failed to read directory {}: {e}", dir.display()))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let path = entry.path(); + + if path.is_dir() { + discover_recursive(&path, root, out)?; + } else if path.is_file() { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.ends_with(".errors.txt") { + continue; + } + + let relative_path = path + .strip_prefix(root) + .map_or_else( + |_| path.to_string_lossy().into_owned(), + |p| p.to_string_lossy().into_owned(), + ) + .replace('\\', "/"); + + out.push(Baseline { + path, + relative_path, + }); + } + } + + Ok(()) +} diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs new file mode 100644 index 000000000..aa1b2c582 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -0,0 +1,17 @@ +//! tsc_conformance — ad-hoc queries over the TypeScript-Go conformance baselines. +//! +//! Tool #1 of the typechecker conformance harness (the "ask important questions" +//! tool). ZERO typechecker code: every query here is derived from the committed +//! tsgo `*.errors.txt` baselines alone. The corpus *input* files live in a git +//! submodule that is often unmaterialized, so any question needing test inputs or +//! directives degrades gracefully rather than crashing. +//! +//! The shared `.errors.txt` summary-block parser in [`baseline`] is the seed a +//! later slice extends into a full round-trip renderer. + +pub mod baseline; +pub mod discovery; +pub mod query; + +pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; +pub use query::{denominators, histogram, tests_by_code}; diff --git a/crates/tsv_debug/src/tsc_conformance/query.rs b/crates/tsv_debug/src/tsc_conformance/query.rs new file mode 100644 index 000000000..2309e3dfc --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/query.rs @@ -0,0 +1,380 @@ +//! Aggregations over the discovered baselines — the three `query` subqueries. +//! +//! Each function reads every baseline once, parses its summary block +//! ([`super::baseline::parse_summary_block`]), and folds the diagnostics into a +//! serializable report. The reports own their human-readable `print_*` rendering +//! so the command file stays thin. + +use super::baseline::parse_summary_block; +use super::discovery::Baseline; +use std::collections::HashMap; + +/// TS4xxx codes are the declaration-emit family. +const DECLARATION_EMIT_RANGE: std::ops::RangeInclusive = 4000..=4999; + +/// Read a baseline's content, warning and skipping on a read error (the files +/// were just discovered, so this is essentially never hit). +fn read_baseline(baseline: &Baseline) -> Option { + match std::fs::read_to_string(&baseline.path) { + Ok(content) => Some(content), + Err(e) => { + eprintln!("warning: could not read {}: {e}", baseline.path.display()); + None + } + } +} + +/// `count / total * 100`, guarding division by zero. +#[allow(clippy::cast_precision_loss)] // diagnostic counts stay well within f64 precision +fn pct(count: usize, total: usize) -> f64 { + if total == 0 { + 0.0 + } else { + (count as f64 / total as f64) * 100.0 + } +} + +// --------------------------------------------------------------------------- +// histogram +// --------------------------------------------------------------------------- + +/// One code's row in the histogram. +#[derive(Debug, serde::Serialize)] +pub struct CodeCount { + /// The `TS` number. + pub code: u32, + /// Instance count across all baselines' summary blocks. + pub count: usize, + /// `count` as a percentage of all instances. + pub pct: f64, + /// Running percentage through this row (codes sorted by descending count). + pub cumulative_pct: f64, +} + +/// The `histogram` subquery report: per-code instance counts plus totals. +#[derive(Debug, serde::Serialize)] +pub struct HistogramReport { + /// Number of `.errors.txt` files scanned. + pub files_scanned: usize, + /// Total diagnostic instances (summary lines) across all files. + pub total_instances: usize, + /// Instances carrying a `(line,col)` prefix. + pub positional_instances: usize, + /// Instances with no location (global / fileless). + pub global_instances: usize, + /// Number of distinct `TS` values. + pub distinct_codes: usize, + /// Per-code rows, sorted by descending count (ties broken by ascending code). + pub codes: Vec, +} + +/// Build the error-code instance histogram over every baseline. +pub fn histogram(baselines: &[Baseline]) -> HistogramReport { + let mut counts: HashMap = HashMap::new(); + let mut total = 0usize; + let mut positional = 0usize; + let mut global = 0usize; + + for baseline in baselines { + let Some(content) = read_baseline(baseline) else { + continue; + }; + for diag in parse_summary_block(&content) { + *counts.entry(diag.code).or_insert(0) += 1; + total += 1; + if diag.file.is_some() { + positional += 1; + } else { + global += 1; + } + } + } + + // Descending by count, then ascending by code for a stable order. + let mut ranked: Vec<(u32, usize)> = counts.into_iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + let distinct_codes = ranked.len(); + + let mut cumulative = 0usize; + let codes = ranked + .into_iter() + .map(|(code, count)| { + cumulative += count; + CodeCount { + code, + count, + pct: pct(count, total), + cumulative_pct: pct(cumulative, total), + } + }) + .collect(); + + HistogramReport { + files_scanned: baselines.len(), + total_instances: total, + positional_instances: positional, + global_instances: global, + distinct_codes, + codes, + } +} + +impl HistogramReport { + /// Print the human table: totals header, then every code row. + pub fn print_table(&self) { + println!("tsgo conformance — error-code histogram"); + println!("======================================="); + println!("Files scanned: {}", self.files_scanned); + println!("Total instances: {}", self.total_instances); + println!(" positional: {}", self.positional_instances); + println!(" global (fileless): {}", self.global_instances); + println!("Distinct codes: {}", self.distinct_codes); + println!(); + println!("{:>9} {:>8} {:>7} {:>7}", "code", "count", "%", "cum%"); + println!( + "{:>9} {:>8} {:>7} {:>7}", + "---------", "--------", "-------", "-------" + ); + for row in &self.codes { + println!( + "{:>9} {:>8} {:>6.2}% {:>6.2}%", + format!("TS{}", row.code), + row.count, + row.pct, + row.cumulative_pct, + ); + } + } +} + +// --------------------------------------------------------------------------- +// tests-by-code +// --------------------------------------------------------------------------- + +/// The `tests-by-code` subquery report: the baseline files that mention a code. +#[derive(Debug, serde::Serialize)] +pub struct TestsByCodeReport { + /// The queried `TS`. + pub code: u32, + /// Number of baseline files whose summary block contains `code`. + pub count: usize, + /// Those files' relative paths, sorted. + pub files: Vec, +} + +/// Find every baseline whose summary block contains `code` (once per file, not +/// per instance). +pub fn tests_by_code(baselines: &[Baseline], code: u32) -> TestsByCodeReport { + let mut files = Vec::new(); + for baseline in baselines { + let Some(content) = read_baseline(baseline) else { + continue; + }; + if parse_summary_block(&content).iter().any(|d| d.code == code) { + files.push(baseline.relative_path.clone()); + } + } + files.sort(); + TestsByCodeReport { + code, + count: files.len(), + files, + } +} + +impl TestsByCodeReport { + /// Print the count line, then one file path per line (head-friendly). + pub fn print(&self) { + println!( + "Baselines whose summary block contains TS{}: {}", + self.code, self.count + ); + println!(); + for file in &self.files { + println!("{file}"); + } + } +} + +// --------------------------------------------------------------------------- +// denominators +// --------------------------------------------------------------------------- + +/// The `denominators` subquery report: the sizing numbers a conformance-rate +/// denominator is drawn from. +#[derive(Debug, serde::Serialize)] +pub struct DenominatorsReport { + /// Total `.errors.txt` baseline files. + pub total_baselines: usize, + /// Distinct test identities (relative path with any trailing `(…)` variant + /// suffix stripped, deduped). + pub distinct_identities: usize, + /// Baselines carrying a `(key=value)` variant suffix. + pub variant_suffixed: usize, + /// Distinct identities backed by more than one baseline file (i.e. tests with + /// more than one variant). + pub identities_with_multiple_variants: usize, + /// Baselines whose relative path contains `jsx` (case-insensitive) — a + /// **lower bound** on JSX-scoped tests (precise detection needs the corpus). + pub jsx_scoped_lower_bound: usize, + /// Baselines whose summary block contains any TS4xxx (declaration-emit) code. + pub baselines_with_declaration_emit_code: usize, +} + +/// Compute the denominator sizing numbers over every baseline. +pub fn denominators(baselines: &[Baseline]) -> DenominatorsReport { + let mut identity_counts: HashMap = HashMap::new(); + let mut variant_suffixed = 0usize; + let mut jsx = 0usize; + let mut declaration_emit = 0usize; + + for baseline in baselines { + let (identity, had_variant) = test_identity(&baseline.relative_path); + *identity_counts.entry(identity).or_insert(0) += 1; + if had_variant { + variant_suffixed += 1; + } + if baseline.relative_path.to_ascii_lowercase().contains("jsx") { + jsx += 1; + } + if let Some(content) = read_baseline(baseline) + && parse_summary_block(&content) + .iter() + .any(|d| DECLARATION_EMIT_RANGE.contains(&d.code)) + { + declaration_emit += 1; + } + } + + let identities_with_multiple_variants = identity_counts.values().filter(|&&n| n > 1).count(); + + DenominatorsReport { + total_baselines: baselines.len(), + distinct_identities: identity_counts.len(), + variant_suffixed, + identities_with_multiple_variants, + jsx_scoped_lower_bound: jsx, + baselines_with_declaration_emit_code: declaration_emit, + } +} + +impl DenominatorsReport { + /// Print the human summary; `corpus_materialized` tunes the JSX caveat note. + pub fn print_summary(&self, corpus_materialized: bool) { + println!("tsgo conformance — denominators"); + println!("==============================="); + println!( + "Total .errors.txt baselines: {}", + self.total_baselines + ); + println!( + "Distinct test identities: {}", + self.distinct_identities + ); + println!(" (relative path, trailing (…) variant suffix stripped, deduped)"); + println!( + "Variant-suffixed baselines: {}", + self.variant_suffixed + ); + println!( + "Tests with >1 variant: {}", + self.identities_with_multiple_variants + ); + println!( + "Baselines with a TS4xxx (declaration-emit) code: {}", + self.baselines_with_declaration_emit_code + ); + println!(); + println!( + "JSX-scoped baselines (lower bound): {}", + self.jsx_scoped_lower_bound + ); + println!(" Heuristic: relative path contains \"jsx\" (case-insensitive)."); + println!(" Precise JSX detection needs @jsx directives / .tsx inputs from the"); + println!( + " corpus submodule, which is {}.", + if corpus_materialized { + "materialized" + } else { + "NOT materialized" + } + ); + if !corpus_materialized { + println!(" Run `git submodule update --init` in ../typescript-go to materialize it."); + } + } +} + +/// A test's identity and whether it carried a variant suffix: the relative path +/// with `.errors.txt` and any single trailing `(…)` group stripped from the file +/// name. A dotted case number (e.g. `foo.1`) is part of the identity, not a +/// variant — only a trailing parenthesized group is a variant. +fn test_identity(relative_path: &str) -> (String, bool) { + let (dir, filename) = match relative_path.rsplit_once('/') { + Some((d, f)) => (Some(d), f), + None => (None, relative_path), + }; + let base = filename.strip_suffix(".errors.txt").unwrap_or(filename); + let stripped = strip_variant_suffix(base); + let had_variant = stripped.len() != base.len(); + let identity = match dir { + Some(d) => format!("{d}/{stripped}"), + None => stripped.to_string(), + }; + (identity, had_variant) +} + +/// Strip a single trailing `(…)` variant group (with no nested parens), matching +/// the baselines' `(key=value)` suffix. Returns `base` unchanged when absent. +fn strip_variant_suffix(base: &str) -> &str { + if let Some(inner) = base.strip_suffix(')') + && let Some(open) = inner.rfind('(') + { + let group = &inner[open + 1..]; + if !group.contains('(') && !group.contains(')') { + return &base[..open]; + } + } + base +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_strips_single_variant_suffix() { + let (id, had) = test_identity("conformance/foo(target=es2015).errors.txt"); + assert_eq!(id, "conformance/foo"); + assert!(had); + } + + #[test] + fn identity_keeps_dotted_case_number() { + // `.1` is a distinct numbered test, not a variant suffix. + let (id, had) = test_identity("conformance/topLevelAwaitErrors.1.errors.txt"); + assert_eq!(id, "conformance/topLevelAwaitErrors.1"); + assert!(!had); + } + + #[test] + fn identity_variant_on_dotted_case_number() { + let (id, had) = + test_identity("conformance/topLevelAwaitErrors.1(module=esnext).errors.txt"); + assert_eq!(id, "conformance/topLevelAwaitErrors.1"); + assert!(had); + } + + #[test] + fn identity_no_variant() { + let (id, had) = test_identity("compiler/bar.errors.txt"); + assert_eq!(id, "compiler/bar"); + assert!(!had); + } + + #[test] + fn pct_guards_zero_total() { + assert!((pct(0, 0) - 0.0).abs() < f64::EPSILON); + assert!((pct(1, 4) - 25.0).abs() < f64::EPSILON); + } +} From e16d9a7bec28b196b9c66ce5ea4d899dc7fee409 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 9 Jul 2026 23:19:48 -0400 Subject: [PATCH 03/79] feat: tsc_conformance errors.txt parser + renderer + roundtrip self-check (99.77%) --- .../src/cli/commands/tsc_conformance.rs | 134 ++++- .../tsv_debug/src/tsc_conformance/baseline.rs | 509 +++++++++++++++++- crates/tsv_debug/src/tsc_conformance/mod.rs | 10 +- .../tsv_debug/src/tsc_conformance/render.rs | 473 ++++++++++++++++ .../src/tsc_conformance/roundtrip.rs | 332 ++++++++++++ 5 files changed, 1431 insertions(+), 27 deletions(-) create mode 100644 crates/tsv_debug/src/tsc_conformance/render.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/roundtrip.rs diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 472a68ade..68cac13e1 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -6,7 +6,8 @@ use crate::cli::CliError; use crate::tsc_conformance::{ - baselines_dir, corpus_materialized, denominators, discover_baselines, histogram, tests_by_code, + baselines_dir, corpus_materialized, denominators, discover_baselines, histogram, run_roundtrip, + tests_by_code, }; use argh::FromArgs; use std::path::PathBuf; @@ -17,6 +18,15 @@ use std::path::PathBuf; /// move (a discovery bug, or a typescript-go pull) must be re-pinned here. const BASELINE_COUNT_PIN: usize = 7033; +/// REGRESSION PIN (exact): baselines that round-trip byte-identically +/// (`parse → render == input`). Measured 2026-07-09 vs pin 168e7015: 7017 of +/// 7033. The 16-baseline residual is fully accounted for — 14 ANSI-colored +/// `pretty=true` baselines (out of the ported rune-path scope) and 2 whose +/// related-info message chains have byte-ambiguous deep continuation lines. A +/// move in either direction is a deliberate re-pin (a parser/renderer change, or +/// a typescript-go pull); pin two-sided so drift can't hide. +const ROUNDTRIP_PASS_PIN: usize = 7017; + /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "tsc_conformance")] @@ -29,6 +39,7 @@ pub struct TscConformanceCommand { #[argh(subcommand)] enum TscConformanceSub { Query(QueryCommand), + Roundtrip(RoundtripCommand), } /// Answer an ad-hoc question over the baselines. @@ -55,42 +66,119 @@ pub struct QueryCommand { args: Vec, } +/// Round-trip self-check (the P0 gate): parse → re-render → byte-compare every +/// tsgo baseline. Prints files checked, byte-identical count, pass rate, and a +/// failure-bucket taxonomy. Exit 0 only on the pinned pass count (two-sided). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "roundtrip")] +pub struct RoundtripCommand { + /// path to the typescript-go checkout (default: ../typescript-go) + #[argh(option, default = "PathBuf::from(\"../typescript-go\")")] + path: PathBuf, + + /// emit a JSON report instead of the human summary + #[argh(switch)] + json: bool, + + /// list every failing baseline path + #[argh(switch)] + verbose: bool, + + /// baseline path substrings to include (OR); default: all baselines + #[argh(positional)] + filters: Vec, +} + impl TscConformanceCommand { pub(crate) fn run(self) -> Result<(), CliError> { match self.nested { TscConformanceSub::Query(query) => query.run(), + TscConformanceSub::Roundtrip(rt) => rt.run(), } } } -impl QueryCommand { +impl RoundtripCommand { fn run(self) -> Result<(), CliError> { - let dir = baselines_dir(&self.path); - if !dir.exists() { - eprintln!( - "Error: tsgo baselines directory not found: {}", - dir.display() - ); - eprintln!(); - eprintln!("Expected a typescript-go checkout with committed baselines. To set it up:"); - eprintln!(" cd .. && git clone https://github.com/microsoft/typescript-go"); - eprintln!(" cd typescript-go && git submodule update --init"); - eprintln!(); - eprintln!("Or specify a custom path:"); + let baselines = load_baselines(&self.path, "roundtrip")?; + let filtered = filter_baselines(baselines, &self.filters); + let unfiltered = self.filters.is_empty(); + + // The pins only apply to a full (unfiltered) run. + if unfiltered { + enforce_pin(filtered.len())?; + } + + let report = run_roundtrip(&filtered); + if self.json { + print_json(&report)?; + } else { + report.print(self.verbose); + } + + // On a full run, gate on the exact pinned pass count (two-sided). + if unfiltered && report.byte_identical != ROUNDTRIP_PASS_PIN { eprintln!( - " cargo run -p tsv_debug tsc_conformance query {} --path /path/to/typescript-go", - self.kind + "\nError: round-trip pass count {} != pinned {ROUNDTRIP_PASS_PIN}. \ + If deliberate (a parser/renderer change, or a typescript-go pull), re-pin \ + ROUNDTRIP_PASS_PIN.", + report.byte_identical ); return Err(CliError::Failed); } + Ok(()) + } +} - let baselines = match discover_baselines(&dir) { - Ok(baselines) => baselines, - Err(e) => { - eprintln!("Error discovering baselines: {e}"); - return Err(CliError::Failed); - } - }; +/// Keep only baselines whose relative path contains any filter substring (OR); +/// an empty filter list keeps everything. +fn filter_baselines( + baselines: Vec, + filters: &[String], +) -> Vec { + if filters.is_empty() { + return baselines; + } + baselines + .into_iter() + .filter(|b| filters.iter().any(|f| b.relative_path.contains(f.as_str()))) + .collect() +} + +/// Discover the tsgo baselines under `checkout`, printing the setup help and +/// failing if the checkout (or its baselines directory) is missing. +/// +/// `example` names the subcommand for the "Or specify a custom path" hint. +fn load_baselines( + checkout: &std::path::Path, + example: &str, +) -> Result, CliError> { + let dir = baselines_dir(checkout); + if !dir.exists() { + eprintln!( + "Error: tsgo baselines directory not found: {}", + dir.display() + ); + eprintln!(); + eprintln!("Expected a typescript-go checkout with committed baselines. To set it up:"); + eprintln!(" cd .. && git clone https://github.com/microsoft/typescript-go"); + eprintln!(" cd typescript-go && git submodule update --init"); + eprintln!(); + eprintln!("Or specify a custom path:"); + eprintln!( + " cargo run -p tsv_debug tsc_conformance {example} --path /path/to/typescript-go" + ); + return Err(CliError::Failed); + } + discover_baselines(&dir).map_err(|e| { + eprintln!("Error discovering baselines: {e}"); + CliError::Failed + }) +} + +impl QueryCommand { + fn run(self) -> Result<(), CliError> { + let baselines = load_baselines(&self.path, &format!("query {}", self.kind))?; match self.kind.as_str() { "histogram" => { diff --git a/crates/tsv_debug/src/tsc_conformance/baseline.rs b/crates/tsv_debug/src/tsc_conformance/baseline.rs index bb40aa4fd..a4ac1d9f1 100644 --- a/crates/tsv_debug/src/tsc_conformance/baseline.rs +++ b/crates/tsv_debug/src/tsc_conformance/baseline.rs @@ -20,8 +20,13 @@ //! lines can contain the literal text `error TS`, so the scan must never enter a //! `==== ` section — hence the hard stop at the first header. //! -//! A later slice extends this module with full section parsing + rendering for a -//! round-trip check against tsgo; the summary parser here is the shared seed. +//! The full-baseline model ([`ParsedBaseline`]) and its parser +//! ([`parse_baseline`]) below extend that seed into the round-trip surface: a +//! structured recovery of the summary diagnostics, the global `!!!` re-render, +//! and every `==== ` file section (source + recovered spans), rendered back by +//! [`super::render`] and byte-compared by [`super::roundtrip`]. + +use super::render::{advance_runes, col_to_byte, lf_line_starts}; /// One diagnostic parsed from a baseline's summary block. /// @@ -119,6 +124,439 @@ fn leading_code(s: &str) -> Option { s[..end].parse().ok() } +// =========================================================================== +// Full-baseline model + parser (the round-trip surface) +// =========================================================================== + +/// A summary-line location token: `(,)`, or the masked default- +/// library form `(--,--)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Loc { + /// `(,)` — 1-based line and UTF-16 column. + Numbered { + /// 1-based line number. + line: u32, + /// 1-based UTF-16 column. + col: u32, + }, + /// `(--,--)` — a default-library position the harness masks. + Masked, +} + +/// One recovered diagnostic (its summary entry plus, where present, the related +/// info from its `!!!` re-render). +#[derive(Debug, Clone)] +pub struct Diag { + /// Display path the diagnostic points at, or `None` for a global (fileless) + /// diagnostic. + pub file: Option, + /// The summary location token; `None` for a global diagnostic. + pub loc: Option, + /// The diagnostic category (`error`, `warning`, `message`, `suggestion`). + pub category: String, + /// The `TS` number (can be negative, e.g. the harness's `TS-1`). + pub code: i32, + /// The flattened message as physical lines (main text, then each message- + /// chain line with its 2-space-per-level indent preserved). + pub msg_lines: Vec, + /// The diagnostic's `!!! related …` lines, stored verbatim (including any + /// rare chain-continuation lines), re-emitted as-is. + pub related: Vec, +} + +/// A diagnostic squiggled inside a file section, with its span recovered in the +/// section's LF-content byte coordinate. +#[derive(Debug, Clone)] +pub struct SectionDiag { + /// Index into [`ParsedBaseline::diags`]. + pub diag_index: usize, + /// Byte offset of the span start in the LF-joined section content. + pub pos_abs: usize, + /// Byte length of the span. + pub len: usize, +} + +/// A recovered `==== ` file section: the reprinted source and the diagnostics +/// squiggled in it (in canonical / `fileErrors` order). +#[derive(Debug, Clone)] +pub struct Section { + /// The section's display file name (from the `==== …` header). + pub name: String, + /// The de-indented source lines (the LF-content view the spans index into). + pub src_lines: Vec, + /// This file's diagnostics with recovered spans. + pub diags: Vec, +} + +/// A fully parsed baseline: every diagnostic (summary order) plus every file +/// section (input order). [`super::render::render_baseline`] turns it back into +/// the original byte stream. +#[derive(Debug, Clone)] +pub struct ParsedBaseline { + /// All diagnostics, in the baseline's (already-sorted) summary order. + pub diags: Vec, + /// The `==== ` file sections, in input order. + pub sections: Vec
, +} + +/// The diagnostic categories that can head a summary line. +const CATEGORIES: [&str; 4] = ["error", "warning", "message", "suggestion"]; + +/// Parse a whole `.errors.txt` baseline into its [`ParsedBaseline`] model. +/// +/// The baseline is split on `CRLF` (`str::split("\r\n")`), which is an exact +/// inverse of `join("\r\n")` — so round-tripping the model is a byte question. +/// The layout parsed: +/// +/// 1. the **summary block** — physical lines until the first blank line; a line +/// with a leading space is a message-chain continuation of the previous +/// diagnostic, else a new diagnostic head; +/// 2. two blank lines; +/// 3. the **global `!!!` re-render** — for each global (fileless) diagnostic in +/// order, its message lines then its related lines; +/// 4. the **`==== ` sections** — each parsed by [`parse_section_body`], which +/// recovers the source and each diagnostic's span. +/// +/// # Errors +/// +/// Returns a short reason string on any structural surprise (an unparsable +/// summary line, a missing blank separator, a section-body desync, an +/// unrecoverable span). The round-trip driver buckets these; a returned `Err` +/// is a parse failure, a rendered-bytes mismatch is a render failure. +pub fn parse_baseline(content: &str) -> Result { + let lines: Vec<&str> = content.split("\r\n").collect(); + + // --- 1. summary block --- + let mut diags: Vec = Vec::new(); + let mut i = 0usize; + while i < lines.len() && !lines[i].is_empty() { + let line = lines[i]; + if line.starts_with(' ') { + let d = diags + .last_mut() + .ok_or("chain continuation before any diagnostic")?; + d.msg_lines.push(line.to_string()); + } else { + let head = parse_summary_head(line) + .ok_or_else(|| format!("unparsable summary line: {line:?}"))?; + diags.push(Diag { + file: head.file, + loc: head.loc, + category: head.category, + code: head.code, + msg_lines: vec![head.first_msg], + related: Vec::new(), + }); + } + i += 1; + } + if diags.is_empty() { + return Err("empty summary block".to_string()); + } + let summary_end = i; + + // --- 2. the two blank lines --- + if lines.get(summary_end) != Some(&"") || lines.get(summary_end + 1) != Some(&"") { + return Err("expected two blank lines after summary block".to_string()); + } + i = summary_end + 2; + + // --- 3. global (fileless) `!!!` re-render --- + for d in diags.iter_mut() { + if d.file.is_some() { + continue; + } + let msg_count = d.msg_lines.len(); + for _ in 0..msg_count { + match lines.get(i) { + Some(l) if l.starts_with("!!! ") => i += 1, + _ => return Err("global !!! message underflow".to_string()), + } + } + let mut related = Vec::new(); + while let Some(l) = lines.get(i) { + if l.starts_with("!!! related ") { + related.push((*l).to_string()); + i += 1; + } else { + break; + } + } + d.related = related; + } + + // --- 4. `==== ` sections --- + let mut sections = Vec::new(); + while i < lines.len() { + let header = lines[i]; + let (name, _n_errors) = parse_section_header(header) + .ok_or_else(|| format!("bad section header: {header:?}"))?; + i += 1; + let body_start = i; + while i < lines.len() && !lines[i].starts_with("==== ") { + i += 1; + } + let body = &lines[body_start..i]; + let section = parse_section_body(name, body, &mut diags)?; + sections.push(section); + } + + Ok(ParsedBaseline { diags, sections }) +} + +/// The head fields of a summary line, before message text. +struct SummaryHead { + file: Option, + loc: Option, + category: String, + code: i32, + first_msg: String, +} + +/// Parse one summary-line head, or `None` for a chain continuation / non-head. +/// +/// Global: `{category} TS{code}: {msg}`. Positional: +/// `{file}({loc}): {category} TS{code}: {msg}` — located by the earliest +/// `): {category} TS` marker across categories (a filename never contains it, so +/// the first is the loc boundary). +fn parse_summary_head(line: &str) -> Option { + // Global head: `{category} TS…` at the very start. + for cat in CATEGORIES { + let prefix = format!("{cat} TS"); + if let Some(rest) = line.strip_prefix(&prefix) { + let (code, consumed) = read_code(rest)?; + let msg = rest.get(consumed..)?.strip_prefix(": ")?; + return Some(SummaryHead { + file: None, + loc: None, + category: cat.to_string(), + code, + first_msg: msg.to_string(), + }); + } + } + + // Positional head: earliest `): {category} TS` marker. + let mut best: Option<(usize, &str)> = None; + for cat in CATEGORIES { + let marker = format!("): {cat} TS"); + if let Some(idx) = line.find(&marker) + && best.is_none_or(|(b, _)| idx < b) + { + best = Some((idx, cat)); + } + } + let (idx, cat) = best?; + let head = line.get(..idx)?; // `{file}({loc}` + let open = head.rfind('(')?; + let file = head.get(..open)?.to_string(); + let loc = parse_loc(head.get(open + 1..)?)?; + + let marker_len = "): ".len() + cat.len() + " TS".len(); + let rest = line.get(idx + marker_len..)?; + let (code, consumed) = read_code(rest)?; + let msg = rest.get(consumed..)?.strip_prefix(": ")?; + Some(SummaryHead { + file: Some(file), + loc: Some(loc), + category: cat.to_string(), + code, + first_msg: msg.to_string(), + }) +} + +/// Read a `-?\d+` code from the start of `s`, returning `(code, bytes_consumed)`. +fn read_code(s: &str) -> Option<(i32, usize)> { + let bytes = s.as_bytes(); + let mut i = usize::from(bytes.first() == Some(&b'-')); + let digits_start = i; + while bytes.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + if i == digits_start { + return None; + } + let code: i32 = s.get(..i)?.parse().ok()?; + Some((code, i)) +} + +/// Parse a location token's inner text (`,` or `--,--`). +fn parse_loc(inner: &str) -> Option { + if inner == "--,--" { + return Some(Loc::Masked); + } + let (l, c) = inner.split_once(',')?; + Some(Loc::Numbered { + line: l.parse().ok()?, + col: c.parse().ok()?, + }) +} + +/// Parse `==== {name} ({N} errors) ====` into `(name, N)`. +fn parse_section_header(line: &str) -> Option<(String, usize)> { + let inner = line.strip_prefix("==== ")?.strip_suffix(" ====")?; + // `{name} ({N} errors)` — split at the last ` (` (a filename may contain + // one, so the last is the error-count group). + let open = inner.rfind(" (")?; + let name = inner.get(..open)?.to_string(); + let count = inner.get(open + 2..)?.strip_suffix(" errors)")?; + Some((name, count.parse().ok()?)) +} + +/// In-flight span-recovery state for one section diagnostic. +struct Work { + diag_index: usize, + start_line0: usize, + col: u32, + ended: bool, + end_line0: usize, + end_tildes: usize, +} + +/// Parse a `==== ` section body, recovering its source lines and each +/// diagnostic's span. +/// +/// Drives the same structure `iterateErrorBaseline`'s inner loop generates, in +/// reverse: after each source line, exactly the diagnostics **touching** it +/// (spans already open, plus spans starting on this line) emit a squiggle, in +/// `Pos` order; a diagnostic's `!!!` block follows its squiggle iff it ends on +/// this line (detected by the next line being `!!! …`). Because the touching set +/// and its order are known from the summary, the source/squiggle boundary is +/// recovered without content heuristics. The end line's tilde count then gives +/// the span's end offset, closing the clip math in reverse. +fn parse_section_body(name: String, body: &[&str], diags: &mut [Diag]) -> Result { + // This file's diagnostics, in summary (== fileErrors) order. + let mut works: Vec = Vec::new(); + for (idx, d) in diags.iter().enumerate() { + if d.file.as_deref() == Some(name.as_str()) { + match d.loc { + Some(Loc::Numbered { line, col }) => works.push(Work { + diag_index: idx, + start_line0: (line as usize).saturating_sub(1), + col, + ended: false, + end_line0: 0, + end_tildes: 0, + }), + _ => { + return Err(format!( + "section {name}: diagnostic has no numbered location" + )); + } + } + } + } + + let mut src_lines: Vec = Vec::new(); + let mut active: Vec = Vec::new(); // indices into `works`, spans in progress + let mut bi = 0usize; + let mut src_idx = 0usize; + + while bi < body.len() { + // Consume the source line (always 4-space-indented). + let src = body[bi].strip_prefix(" ").ok_or_else(|| { + format!( + "section {name}: source line missing 4-space indent: {:?}", + body[bi] + ) + })?; + src_lines.push(src.to_string()); + bi += 1; + + // touching = open spans (Pos order) then spans starting on this line. + let starting: Vec = (0..works.len()) + .filter(|&w| !works[w].ended && !active.contains(&w) && works[w].start_line0 == src_idx) + .collect(); + let touching: Vec = active.iter().copied().chain(starting).collect(); + + let mut newly_active: Vec = Vec::new(); + for w in touching { + let sq = body + .get(bi) + .ok_or_else(|| format!("section {name}: squiggle underflow"))?; + let sq_body = sq.strip_prefix(" ").ok_or_else(|| { + format!("section {name}: squiggle missing 4-space indent: {sq:?}") + })?; + bi += 1; + let tildes = sq_body.bytes().filter(|&b| b == b'~').count(); + + // A diagnostic ends on this line iff its `!!!` block follows its + // squiggle (the renderer emits them adjacently). + if body.get(bi).is_some_and(|l| l.starts_with("!!! ")) { + works[w].ended = true; + works[w].end_line0 = src_idx; + works[w].end_tildes = tildes; + + // Consume the `!!!` block: message lines then related lines. + let msg_count = diags[works[w].diag_index].msg_lines.len(); + for _ in 0..msg_count { + match body.get(bi) { + Some(l) if l.starts_with("!!! ") => bi += 1, + _ => return Err(format!("section {name}: !!! message underflow")), + } + } + let mut related = Vec::new(); + while let Some(l) = body.get(bi) { + if l.starts_with("!!! related ") { + related.push((*l).to_string()); + bi += 1; + } else { + break; + } + } + diags[works[w].diag_index].related = related; + } else if !active.contains(&w) { + newly_active.push(w); + } + } + + active.retain(|&w| !works[w].ended); + active.extend(newly_active); + src_idx += 1; + } + + if works.iter().any(|w| !w.ended) { + return Err(format!("section {name}: unclosed diagnostic(s)")); + } + + // Recover byte spans in the LF-content coordinate. + let starts = lf_line_starts(&src_lines); + let mut section_diags = Vec::with_capacity(works.len()); + for w in &works { + let start_line = src_lines + .get(w.start_line0) + .ok_or_else(|| format!("section {name}: start line out of range"))?; + let start_byte = col_to_byte(start_line, w.col); + let pos_abs = starts[w.start_line0] + start_byte; + + let end_line = src_lines + .get(w.end_line0) + .ok_or_else(|| format!("section {name}: end line out of range"))?; + // On the span's start line the squiggle begins at the column; on a + // continuation (multi-line) end line it begins at 0. + let sq_start = if w.end_line0 == w.start_line0 { + start_byte + } else { + 0 + }; + let end_abs = + starts[w.end_line0] + sq_start + advance_runes(end_line, sq_start, w.end_tildes); + let len = end_abs.saturating_sub(pos_abs); + + section_diags.push(SectionDiag { + diag_index: w.diag_index, + pos_abs, + len, + }); + } + + Ok(Section { + name, + src_lines, + diags: section_diags, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -188,4 +626,71 @@ mod tests { assert_eq!(diags.iter().filter(|d| d.file.is_none()).count(), 1); assert_eq!(diags[1].code, 5102); } + + #[test] + fn summary_head_positional_global_masked_negative() { + let pos = parse_summary_head("foo.ts(3,1): error TS1206: Decorators are not valid here.") + .expect("positional"); + assert_eq!(pos.file.as_deref(), Some("foo.ts")); + assert_eq!(pos.loc, Some(Loc::Numbered { line: 3, col: 1 })); + assert_eq!(pos.category, "error"); + assert_eq!(pos.code, 1206); + assert_eq!(pos.first_msg, "Decorators are not valid here."); + + let global = parse_summary_head("error TS5102: Option removed.").expect("global"); + assert!(global.file.is_none() && global.loc.is_none() && global.code == 5102); + + let masked = parse_summary_head("lib.es5.d.ts(--,--): error TS2411: bad.").expect("masked"); + assert_eq!(masked.file.as_deref(), Some("lib.es5.d.ts")); + assert_eq!(masked.loc, Some(Loc::Masked)); + + // Negative harness code TS-1. + let neg = parse_summary_head("error TS-1: Pre-emit mismatch!").expect("negative"); + assert_eq!(neg.code, -1); + + // Non-error categories. + let sugg = parse_summary_head("a.ts(1,1): suggestion TS6133: unused.").expect("suggestion"); + assert_eq!(sugg.category, "suggestion"); + } + + #[test] + fn summary_head_rejects_continuation() { + // A leading-space chain-continuation line is not a head. + assert!(parse_summary_head(" Type 'U' is not assignable.").is_none()); + } + + #[test] + fn section_header_parses_name_and_count() { + assert_eq!( + parse_section_header("==== foo.ts (2 errors) ===="), + Some(("foo.ts".to_string(), 2)) + ); + // A filename containing " (" keeps its parens; the last group is the count. + assert_eq!( + parse_section_header("==== a (b).ts (0 errors) ===="), + Some(("a (b).ts".to_string(), 0)) + ); + assert_eq!(parse_section_header("not a header"), None); + } + + #[test] + fn parse_baseline_recovers_summary_and_section_span() { + // A single positional diagnostic; the squiggle recovers a length-4 span. + let content = "a.ts(1,1): error TS2304: Cannot find name 'test'.\r\n\ + \r\n\r\n\ + ==== a.ts (1 errors) ====\r\n\ + \x20\x20\x20\x20test;\r\n\ + \x20\x20\x20\x20~~~~\r\n\ + !!! error TS2304: Cannot find name 'test'."; + let parsed = parse_baseline(content).expect("parse"); + assert_eq!(parsed.diags.len(), 1); + assert_eq!(parsed.sections.len(), 1); + let sec = &parsed.sections[0]; + assert_eq!(sec.name, "a.ts"); + assert_eq!(sec.src_lines, vec!["test;".to_string()]); + assert_eq!(sec.diags.len(), 1); + // Start at byte 0 (col 1), 4 tildes → length 4. + assert_eq!(sec.diags[0].pos_abs, 0); + assert_eq!(sec.diags[0].len, 4); + } } diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index aa1b2c582..58eff6009 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -6,12 +6,18 @@ //! submodule that is often unmaterialized, so any question needing test inputs or //! directives degrades gracefully rather than crashing. //! -//! The shared `.errors.txt` summary-block parser in [`baseline`] is the seed a -//! later slice extends into a full round-trip renderer. +//! [`baseline`] holds both the summary-block parser (the `query` tool's seed) +//! and the full-baseline parser ([`baseline::parse_baseline`]); [`render`] is +//! the faithful `.errors.txt` renderer ported from typescript-go; [`roundtrip`] +//! parses → renders → byte-compares every baseline (the P0 self-check, `zero` +//! checker code). pub mod baseline; pub mod discovery; pub mod query; +pub mod render; +pub mod roundtrip; pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use query::{denominators, histogram, tests_by_code}; +pub use roundtrip::run_roundtrip; diff --git a/crates/tsv_debug/src/tsc_conformance/render.rs b/crates/tsv_debug/src/tsc_conformance/render.rs new file mode 100644 index 000000000..92c998044 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/render.rs @@ -0,0 +1,473 @@ +//! Render a [`ParsedBaseline`] back to a tsgo `.errors.txt` byte stream. +//! +//! A faithful port of typescript-go's error-baseline renderer — the ported, +//! baseline-verified spec for the format tsv must eventually emit its own +//! diagnostics through. The round-trip check (`super::roundtrip`) feeds this a +//! model recovered from a real baseline and byte-compares the result; the port +//! therefore has to reproduce every load-bearing subtlety of the original. +//! +//! Reference (censused 2026-07-09 vs pin `168e7015`): +//! - tsgo: `internal/testutil/tsbaseline/error_baseline.go` +//! (`iterateErrorBaseline`, ~260 lines) +//! - tsgo: `internal/diagnosticwriter/diagnosticwriter.go` +//! (`WriteFormatDiagnostic`, `flattenDiagnosticMessageChain`, ~506 lines) +//! +//! Only the **rune** path is ported (the `pretty=false` default): tildes count +//! in runes (`utf8.RuneCountInString`), tabs survive the prefix-blank, and the +//! hard-coded newline is CRLF. The colored `pretty=true` `writeCodeSnippet` / +//! tabular-summary path is out of scope (a handful of corpus baselines exercise +//! it; the round-trip driver buckets them). +//! +//! Coordinate note: the model stores each file's source as LF-joined lines and +//! each span as a byte `(pos, len)` in that LF content. The parser recovered the +//! span from the same LF view, so the two cancel: the rendered squiggles depend +//! only on within-line geometry (line-ending-independent) plus the recovered +//! end offset, so LF vs the original CRLF source never changes the output. +//! +//! Lib masking is reproduced by recovering the already-masked token verbatim +//! (`Loc::Masked` → `(--,--)`, related `lib…:--:--` echoed literally) rather +//! than re-deriving it with the two harness regexes: the baseline is read +//! *after* masking, so the pre-mask lib positions are unrecoverable, and +//! emitting the recovered token is byte-equivalent to running the regex. + +use super::baseline::{Diag, Loc, ParsedBaseline, Section}; + +/// Convert a 1-based UTF-16 column to a byte offset within `line`. +/// +/// tsgo positions are UTF-16 code units (`col` counts them, 1-based); tsv works +/// in bytes. Walks the line accumulating each char's UTF-16 width until it +/// reaches `col - 1`, returning that char boundary. A column past the line end +/// clamps to `line.len()` (the EOF/last-column case). +pub(crate) fn col_to_byte(line: &str, col: u32) -> usize { + let target = col.saturating_sub(1) as usize; + let mut u16_count = 0usize; + for (byte_idx, ch) in line.char_indices() { + if u16_count >= target { + return byte_idx; + } + u16_count += ch.len_utf16(); + } + line.len() +} + +/// Advance `n` runes (chars) from `start_byte` in `line`, returning the number +/// of bytes covered. Used to recover a span's end offset from a squiggle's tilde +/// count. `start_byte` must be a char boundary; a non-boundary or `n` beyond the +/// line end degrades gracefully (returns the remaining byte length). +pub(crate) fn advance_runes(line: &str, start_byte: usize, n: usize) -> usize { + let Some(slice) = line.get(start_byte..) else { + return 0; + }; + for (count, (byte_idx, _)) in slice.char_indices().enumerate() { + if count == n { + return byte_idx; + } + } + slice.len() +} + +/// Byte offset of each LF line start over `join(lines, "\n")`. Both the parser +/// (recovering spans) and the renderer (the squiggle loop) derive their line +/// map from this, so the two stay in the same coordinate system. +pub(crate) fn lf_line_starts(lines: &[String]) -> Vec { + let mut starts = Vec::with_capacity(lines.len()); + let mut acc = 0usize; + for l in lines { + starts.push(acc); + acc += l.len() + 1; // +1 for the LF separator + } + starts +} + +/// Byte length of `join(lines, "\n")` (the last line carries no trailing LF). +pub(crate) fn lf_content_len(lines: &[String], starts: &[usize]) -> usize { + match (lines.last(), starts.last()) { + (Some(last), Some(&start)) => start + last.len(), + _ => 0, + } +} + +/// Blank a squiggle line's prefix: every non-whitespace char becomes a single +/// space, whitespace survives (`error_baseline.go:211`, `nonWhitespace` = +/// Go `\S` = not `[\t\n\f\r ]`). Tabs are kept 1:1 — the tab-survival rule the +/// pretty path deliberately does *not* share. A multi-byte non-whitespace char +/// collapses to one space, so the result is not generally byte-length-preserving +/// — hence the renderer recomputes the prefix from the source rather than +/// reusing the recovered squiggle's prefix width. +pub(crate) fn blank_prefix(prefix: &str) -> String { + let mut out = String::with_capacity(prefix.len()); + for ch in prefix.chars() { + if matches!(ch, ' ' | '\t' | '\n' | '\u{000C}' | '\r') { + out.push(ch); + } else { + out.push(' '); + } + } + out +} + +/// Count the runes (chars) fully inside the byte range `[start, end)` of `line`. +/// Mirrors `utf8.RuneCountInString(line[squiggleStart:squiggleEnd])`. A range +/// end that falls mid-char stops before the partial char (the recovered spans +/// keep both ends on char boundaries, so this only guards against surprises). +pub(crate) fn runes_in_byte_range(line: &str, start: usize, end: usize) -> usize { + let start = start.min(line.len()); + let end = end.min(line.len()).max(start); + let Some(slice) = line.get(start..) else { + return 0; + }; + let mut count = 0usize; + let mut idx = start; + for ch in slice.chars() { + let next = idx + ch.len_utf8(); + if next > end { + break; + } + count += 1; + idx = next; + } + count +} + +/// tsgo's `isDefaultLibraryFile` (`util.go:51`): base name `lib.*.d.ts`. +pub(crate) fn is_default_library_file(path: &str) -> bool { + let base = path.rsplit(['/', '\\']).next().unwrap_or(path); + base.starts_with("lib.") && base.ends_with(".d.ts") +} + +/// tsgo's `isTsConfigFile` (`util.go:60`): contains `tsconfig` and `json`. +pub(crate) fn is_ts_config_file(path: &str) -> bool { + path.contains("tsconfig") && path.contains("json") +} + +/// The hard-coded harness newline (`error_baseline.go:24`), the one const the +/// three literal CRLF sites collapse to. +const CRLF: &str = "\r\n"; + +/// Emit `CRLF` before every element except the first, mirroring tsgo's stateful +/// `newLine()` closure (shared across the global block and every file section, +/// so the very first line — global error or first `====` header — gets no +/// leading newline). +fn push_nl(out: &mut String, first: &mut bool) { + if *first { + *first = false; + } else { + out.push_str(CRLF); + } +} + +/// Render one diagnostic's `!!!` block (`outputErrorText`): each non-empty +/// message line prefixed `!!! {category} TS{code}: `, then its related-info +/// lines verbatim. Shared by the global re-render loop and each file section — +/// this is the "global diagnostics render twice" reproduction (once bare in the +/// summary, once here). +fn emit_bang_block(out: &mut String, first: &mut bool, d: &Diag) { + for m in &d.msg_lines { + if m.is_empty() { + continue; + } + push_nl(out, first); + out.push_str("!!! "); + out.push_str(&d.category); + out.push_str(" TS"); + push_code(out, d.code); + out.push_str(": "); + out.push_str(m); + } + for r in &d.related { + push_nl(out, first); + out.push_str(r); + } +} + +/// Append `TS`-code digits (handles the negative harness code `TS-1`). +fn push_code(out: &mut String, code: i32) { + use std::fmt::Write as _; + let _ = write!(out, "{code}"); +} + +/// Render the whole baseline model back to its byte stream. +/// +/// Structure (mirrors `iterateErrorBaseline` + `GetErrorBaseline`): +/// 1. the summary block (`topDiagnostics`) — one entry per diagnostic, each +/// ending `CRLF`; then a `CRLF CRLF` separator; +/// 2. the global (fileless) diagnostics' `!!!` re-render; +/// 3. each `==== {file} ({N} errors) ====` section in input order. +#[must_use] +pub fn render_baseline(b: &ParsedBaseline) -> String { + let mut out = String::new(); + + // --- 1. summary block (topDiagnostics) --- + for d in &b.diags { + if let Some(file) = &d.file { + out.push_str(file); + match d.loc { + Some(Loc::Numbered { line, col }) => { + use std::fmt::Write as _; + let _ = write!(out, "({line},{col})"); + } + // A default-library position the harness masks to `(--,--)`. + Some(Loc::Masked) => out.push_str("(--,--)"), + None => {} + } + out.push_str(": "); + } + out.push_str(&d.category); + out.push_str(" TS"); + push_code(&mut out, d.code); + out.push_str(": "); + for (i, m) in d.msg_lines.iter().enumerate() { + if i > 0 { + out.push_str(CRLF); + } + out.push_str(m); + } + out.push_str(CRLF); + } + // The `+ harnessNewLine + harnessNewLine` after topDiagnostics. + out.push_str(CRLF); + out.push_str(CRLF); + + // --- 2 & 3: the stateful-newLine region --- + let mut first = true; + + // Global (fileless) diagnostics re-render, in summary order. + for d in &b.diags { + if d.file.is_none() { + emit_bang_block(&mut out, &mut first, d); + } + } + + // File sections, in input order. + for sec in &b.sections { + push_nl(&mut out, &mut first); + out.push_str("==== "); + out.push_str(&sec.name); + out.push_str(" ("); + { + use std::fmt::Write as _; + let _ = write!(out, "{}", sec.diags.len()); + } + out.push_str(" errors) ===="); + render_section(&mut out, &mut first, sec, &b.diags); + } + + out +} + +/// Render one file section: each source line (4-space-indented) followed by any +/// error squiggles that touch it, with the `!!!` message emitted on the span's +/// end line. Ported from the inner file loop of `iterateErrorBaseline` +/// (`:180-222`), recomputing every squiggle from the span so the clip math is +/// exercised in the forward direction (the parser exercised it in reverse). +fn render_section(out: &mut String, first: &mut bool, sec: &Section, diags: &[Diag]) { + let n = sec.src_lines.len(); + let starts = lf_line_starts(&sec.src_lines); + let content_len = lf_content_len(&sec.src_lines, &starts); + + for (idx, line) in sec.src_lines.iter().enumerate() { + push_nl(out, first); + out.push_str(" "); + out.push_str(line); + + let this_start = starts[idx]; + let is_last = idx + 1 == n; + let next_start = if is_last { + content_len + } else { + starts[idx + 1] + }; + let line_len = line.len(); + + for sd in &sec.diags { + let err_start = sd.pos_abs; + let end = sd.pos_abs + sd.len; + // "Does any error start or continue on to this line?" (`:201`). + if end >= this_start && (err_start < next_start || is_last) { + // squiggleStart = max(0, errStart - thisLineStart). + let squiggle_start = err_start.saturating_sub(this_start); + // length = (end - errStart) - max(0, thisLineStart - errStart). + let length = (end - err_start) - this_start.saturating_sub(err_start); + + push_nl(out, first); + out.push_str(" "); + let ss = squiggle_start.min(line_len); + out.push_str(&blank_prefix(&line[..ss])); + // squiggleEnd = max(squiggleStart, min(squiggleStart+length, len(line))). + let se = squiggle_start.max((squiggle_start + length).min(line_len)); + let tildes = runes_in_byte_range(line, ss, se); + for _ in 0..tildes { + out.push('~'); + } + + // "If the error ended here, or we're at the end of the file, + // emit its message" (`:216`). + if (is_last || next_start > end) + && let Some(d) = diags.get(sd.diag_index) + { + emit_bang_block(out, first, d); + } + } + } + } +} + +/// The renderer's two self-assertions (`error_baseline.go:225` and `:238-251`), +/// ported as a checkable predicate rather than a test-time `assert`. Returns the +/// list of violation messages (empty = both hold); the round-trip driver counts +/// baselines that trip either. +/// +/// 1. Per file section, the number of squiggled diagnostics equals the section's +/// error count (by construction each section diagnostic is placed once, so +/// this cross-checks the parser's section/summary agreement). +/// 2. Total accounting: non-lib-non-tsconfig (fileless diagnostics included) + +/// lib + tsconfig equals the diagnostic count — a partition check that the +/// parser accounted for every diagnostic. +/// +/// The dead `dupeCase` branch (`error_baseline.go:156,226-233`) is intentionally +/// not ported: `isDupe` is never true (the map is never written), so it is inert. +#[must_use] +pub fn self_assertion_violations(b: &ParsedBaseline) -> Vec { + let mut violations = Vec::new(); + + // (1) per-file squiggle count == error count. + for sec in &b.sections { + let squiggled = sec.diags.len(); + // Every section diagnostic corresponds to one file diagnostic; a + // mismatch would surface here (it never does for a well-formed model). + let file_diags = b + .diags + .iter() + .filter(|d| d.file.as_deref() == Some(sec.name.as_str())) + .count(); + if squiggled != file_diags { + violations.push(format!( + "section {}: squiggled {squiggled} != file diagnostics {file_diags}", + sec.name + )); + } + } + + // (2) total accounting. + let mut num_lib = 0usize; + let mut num_tsconfig = 0usize; + let mut num_other = 0usize; // non-lib, non-tsconfig, plus fileless + for d in &b.diags { + match &d.file { + None => num_other += 1, + Some(f) if is_default_library_file(f) => num_lib += 1, + Some(f) if is_ts_config_file(f) => num_tsconfig += 1, + Some(_) => num_other += 1, + } + } + if num_other + num_lib + num_tsconfig != b.diags.len() { + violations.push(format!( + "total accounting: {num_other}+{num_lib}+{num_tsconfig} != {}", + b.diags.len() + )); + } + + violations +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn col_to_byte_ascii_and_astral() { + // ASCII: col is byte offset + 1. + assert_eq!(col_to_byte("abcdef", 1), 0); + assert_eq!(col_to_byte("abcdef", 4), 3); + // Past the end clamps to the byte length. + assert_eq!(col_to_byte("ab", 9), 2); + // Astral char is 2 UTF-16 units: 'a' 𐐷 'b' → cols 1,2,4. + let s = "a\u{10437}b"; + assert_eq!(col_to_byte(s, 1), 0); // before 'a' + assert_eq!(col_to_byte(s, 2), 1); // before 𐐷 (after 1 unit) + assert_eq!(col_to_byte(s, 4), 5); // before 'b' (𐐷 is 4 bytes, 2 units) + } + + #[test] + fn advance_runes_counts_bytes() { + assert_eq!(advance_runes("abcdef", 0, 3), 3); + assert_eq!(advance_runes("abcdef", 2, 2), 2); + // Multi-byte: advancing 1 rune over 'é' (2 bytes). + assert_eq!(advance_runes("é!", 0, 1), 2); + // Beyond the end returns the remaining length. + assert_eq!(advance_runes("ab", 0, 9), 2); + } + + #[test] + fn lf_line_starts_and_len() { + let lines = vec!["ab".to_string(), String::new(), "cde".to_string()]; + let starts = lf_line_starts(&lines); + assert_eq!(starts, vec![0, 3, 4]); // "ab\n\ncde" + assert_eq!(lf_content_len(&lines, &starts), 7); + } + + #[test] + fn blank_prefix_keeps_tabs_blanks_text() { + // Tabs survive; spaces survive; other chars → single space. + // "\tab c" = tab + a,b (→2 spaces) + space (survives) + c (→space). + assert_eq!(blank_prefix("\tab c"), "\t "); + assert_eq!(blank_prefix("abc"), " "); + // A multi-byte char collapses to one space (not byte-length preserving). + assert_eq!(blank_prefix("é"), " "); + } + + #[test] + fn runes_in_byte_range_counts_full_chars() { + assert_eq!(runes_in_byte_range("abcdef", 0, 3), 3); + // Two-byte char fully inside counts once. + assert_eq!(runes_in_byte_range("aéb", 1, 3), 1); + // Mid-char end stops before the partial char. + assert_eq!(runes_in_byte_range("aéb", 1, 2), 0); + } + + #[test] + fn library_and_tsconfig_classifiers() { + assert!(is_default_library_file("lib.es5.d.ts")); + assert!(is_default_library_file("/x/lib.dom.d.ts")); + assert!(!is_default_library_file("libfoo.d.ts")); + assert!(!is_default_library_file("a.ts")); + assert!(is_ts_config_file("/p/tsconfig.json")); + assert!(!is_ts_config_file("a.ts")); + } + + /// Parse a hand-built baseline and confirm it re-renders byte-identically — + /// the round-trip contract, exercised over the format's fiddly bits: a + /// message chain (summary continuation + double-rendered `!!!` lines), a + /// global diagnostic (rendered twice), a lib-masked summary line, related + /// info, a tab-bearing squiggle prefix, and a trailing (no-final-newline) + /// section. + #[test] + fn roundtrip_identity_over_mixed_baseline() { + use super::super::baseline::parse_baseline; + let content = concat!( + // summary block: global, positional-with-chain, lib-masked + "error TS5110: Option must be set.\r\n", + "a.ts(1,2): error TS2322: Type 'U' is not assignable.\r\n", + " Type 'U' is not assignable to 'V'.\r\n", + "lib.es5.d.ts(--,--): error TS2411: Property bad.\r\n", + "\r\n\r\n", + // global !!! re-render (rendered twice: bare above, prefixed here) + "!!! error TS5110: Option must be set.\r\n", + // section: source lines are 4-space-indented; the squiggle keeps the tab + "==== a.ts (1 errors) ====\r\n", + " \tconst x = 1;\r\n", + " \t~~~~~\r\n", + "!!! error TS2322: Type 'U' is not assignable.\r\n", + "!!! error TS2322: Type 'U' is not assignable to 'V'.\r\n", + "!!! related TS2208 a.ts:1:1: A hint.\r\n", + " \tconst y = 2;" + ); + let parsed = parse_baseline(content).expect("parse"); + assert_eq!( + render_baseline(&parsed), + content, + "round-trip must be byte-identical" + ); + assert!(self_assertion_violations(&parsed).is_empty()); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs new file mode 100644 index 000000000..b3c10e662 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs @@ -0,0 +1,332 @@ +//! The P0 round-trip self-check: parse every tsgo `.errors.txt` baseline, +//! re-render it through [`super::render`], and byte-compare against the original. +//! +//! This proves the parser and the renderer in one move, with zero checker code: +//! a 100% byte-identical pass means the recovered model is a faithful, +//! reversible view of the baseline format. Failures are bucketed by their most +//! salient format feature (the P0 work-list); the pass count is a two-sided +//! regression pin. +//! +//! Known residual (measured 2026-07-09 vs pin `168e7015`): a small set of +//! ANSI-colored `pretty=true` baselines (out of the ported rune-path scope) and +//! a couple of baselines whose related-info carries a message chain whose deeper +//! (4-space) continuation lines are byte-ambiguous with source lines. Both are +//! reported honestly rather than hidden by loosening the comparison. + +use super::baseline::parse_baseline; +use super::discovery::Baseline; +use super::render::{render_baseline, self_assertion_violations}; +use std::collections::BTreeMap; + +/// Cap on the message excerpt shown for a failing baseline (in chars). +const EXCERPT_CHARS: usize = 120; +/// Examples retained per bucket. +const EXAMPLES_PER_BUCKET: usize = 3; + +/// One example of a failing baseline. +#[derive(Debug, serde::Serialize)] +pub struct FailExample { + /// The baseline's relative path. + pub path: String, + /// Why it failed (a parse-error reason, or `render mismatch`). + pub reason: String, + /// 0-based index of the first differing line (render mismatches only). + #[serde(skip_serializing_if = "Option::is_none")] + pub first_diff_line: Option, + /// The expected line at the first difference (truncated). + #[serde(skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// The rendered line at the first difference (truncated). + #[serde(skip_serializing_if = "Option::is_none")] + pub got: Option, +} + +/// One failure bucket (a format feature) with its count and a few examples. +#[derive(Debug, serde::Serialize)] +pub struct BucketCount { + /// The bucket name (e.g. `pretty`, `related_info`). + pub bucket: String, + /// Number of failing baselines in this bucket. + pub count: usize, + /// Up to [`EXAMPLES_PER_BUCKET`] example failures. + pub examples: Vec, +} + +/// The round-trip report. +#[derive(Debug, serde::Serialize)] +pub struct RoundtripReport { + /// Baselines checked. + pub files_checked: usize, + /// Baselines that round-tripped byte-identically. + pub byte_identical: usize, + /// `byte_identical / files_checked * 100`. + pub pass_rate: f64, + /// Baselines whose parsed model tripped a ported self-assertion (should be 0). + pub self_assertion_violations: usize, + /// Failure buckets, sorted by descending count. + pub buckets: Vec, + /// Every failing baseline's path (sorted); only shown in `--verbose`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub failing_paths: Vec, +} + +/// Parse → render → byte-compare every baseline, folding results into a report. +pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { + let mut byte_identical = 0usize; + let mut assertion_violations = 0usize; + let mut bucket_map: BTreeMap<&'static str, Vec> = BTreeMap::new(); + let mut failing_paths: Vec = Vec::new(); + + for baseline in baselines { + let content = match std::fs::read_to_string(&baseline.path) { + Ok(c) => c, + Err(e) => { + record( + &mut bucket_map, + &mut failing_paths, + "read_error", + FailExample { + path: baseline.relative_path.clone(), + reason: format!("read error: {e}"), + first_diff_line: None, + expected: None, + got: None, + }, + ); + continue; + } + }; + + match parse_baseline(&content) { + Ok(parsed) => { + if !self_assertion_violations(&parsed).is_empty() { + assertion_violations += 1; + } + let rendered = render_baseline(&parsed); + if rendered == content { + byte_identical += 1; + } else { + let (line, expected, got) = first_diff(&content, &rendered); + record( + &mut bucket_map, + &mut failing_paths, + categorize(&content), + FailExample { + path: baseline.relative_path.clone(), + reason: "render mismatch".to_string(), + first_diff_line: Some(line), + expected: Some(expected), + got: Some(got), + }, + ); + } + } + Err(reason) => { + record( + &mut bucket_map, + &mut failing_paths, + categorize(&content), + FailExample { + path: baseline.relative_path.clone(), + reason, + first_diff_line: None, + expected: None, + got: None, + }, + ); + } + } + } + + let files_checked = baselines.len(); + let pass_rate = pct(byte_identical, files_checked); + + let mut buckets: Vec = bucket_map + .into_iter() + .map(|(bucket, mut examples)| { + let count = examples.len(); + examples.truncate(EXAMPLES_PER_BUCKET); + BucketCount { + bucket: bucket.to_string(), + count, + examples, + } + }) + .collect(); + buckets.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.bucket.cmp(&b.bucket))); + failing_paths.sort(); + + RoundtripReport { + files_checked, + byte_identical, + pass_rate, + self_assertion_violations: assertion_violations, + buckets, + failing_paths, + } +} + +/// Push a failure into its bucket and onto the failing-paths list. +fn record( + bucket_map: &mut BTreeMap<&'static str, Vec>, + failing_paths: &mut Vec, + bucket: &'static str, + example: FailExample, +) { + failing_paths.push(example.path.clone()); + bucket_map.entry(bucket).or_default().push(example); +} + +/// `count / total * 100`, guarding division by zero. +#[allow(clippy::cast_precision_loss)] // counts stay well within f64 precision +fn pct(count: usize, total: usize) -> f64 { + if total == 0 { + 0.0 + } else { + (count as f64 / total as f64) * 100.0 + } +} + +/// The first differing `CRLF`-line between the original and the render, with a +/// truncated excerpt of each (`` when one side ran out first). +fn first_diff(original: &str, rendered: &str) -> (usize, String, String) { + let a: Vec<&str> = original.split("\r\n").collect(); + let b: Vec<&str> = rendered.split("\r\n").collect(); + let mut k = 0usize; + while k < a.len() && k < b.len() && a[k] == b[k] { + k += 1; + } + let excerpt = |s: Option<&&str>| s.map_or_else(|| "".to_string(), |v| truncate(v)); + (k, excerpt(a.get(k)), excerpt(b.get(k))) +} + +/// Truncate to [`EXCERPT_CHARS`] chars (char-safe), appending an ellipsis. +fn truncate(s: &str) -> String { + if s.chars().count() <= EXCERPT_CHARS { + s.to_string() + } else { + let head: String = s.chars().take(EXCERPT_CHARS).collect(); + format!("{head}…") + } +} + +/// Bucket a failing baseline by its most salient format feature (priority +/// order). This is a heuristic taxonomy — the correlate of the failure, not a +/// proof of its cause — and doubles as the P0 work-list. +fn categorize(content: &str) -> &'static str { + if content.contains('\u{1b}') { + // ANSI escape → the colored pretty=true path (out of the rune-path scope). + "pretty" + } else if content.contains("!!! related") { + "related_info" + } else if has_summary_chain(content) { + "chain" + } else if content.contains("(--,--)") || content.contains(":--:--") { + "lib_mask" + } else if has_multiline_span(content) { + "multiline_span" + } else if !content.is_ascii() { + "astral_rune" + } else { + "other" + } +} + +/// A message-chain continuation in the summary block (a leading-space line +/// before the first `====` section). +fn has_summary_chain(content: &str) -> bool { + for line in content.split("\r\n") { + if line.starts_with("==== ") { + break; + } + if line.starts_with(' ') && !line.trim().is_empty() { + return true; + } + } + false +} + +/// Two consecutive squiggle-only lines — the signature of a multi-line span. +fn has_multiline_span(content: &str) -> bool { + let is_squiggle = |line: &str| { + line.strip_prefix(" ").is_some_and(|body| { + body.contains('~') && body.bytes().all(|b| matches!(b, b' ' | b'\t' | b'~')) + }) + }; + let lines: Vec<&str> = content.split("\r\n").collect(); + lines + .windows(2) + .any(|w| is_squiggle(w[0]) && is_squiggle(w[1])) +} + +impl RoundtripReport { + /// Print the human report; `verbose` also lists every failing path. + pub fn print(&self, verbose: bool) { + println!("tsgo conformance — round-trip self-check"); + println!("========================================"); + println!("Files checked: {}", self.files_checked); + println!("Byte-identical: {}", self.byte_identical); + println!("Pass rate: {:.3}%", self.pass_rate); + println!("Self-assert fails: {}", self.self_assertion_violations); + if self.buckets.is_empty() { + println!("\nAll baselines round-trip byte-identically."); + return; + } + println!("\nFailure buckets (feature correlate, priority-ordered):"); + for b in &self.buckets { + println!(" {:>5} {}", b.count, b.bucket); + for ex in &b.examples { + println!(" {} — {}", ex.path, ex.reason); + if let (Some(l), Some(e), Some(g)) = + (ex.first_diff_line, ex.expected.as_ref(), ex.got.as_ref()) + { + println!(" @line {l}"); + println!(" expected: {e}"); + println!(" got: {g}"); + } + } + } + if verbose { + println!("\nAll failing paths:"); + for p in &self.failing_paths { + println!(" {p}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_diff_locates_line() { + let a = "x\r\ny\r\nz"; + let b = "x\r\nY\r\nz"; + let (line, exp, got) = first_diff(a, b); + assert_eq!(line, 1); + assert_eq!(exp, "y"); + assert_eq!(got, "Y"); + } + + #[test] + fn categorize_priority() { + assert_eq!(categorize("\u{1b}[96mx\u{1b}[0m"), "pretty"); + assert_eq!( + categorize("a.ts(1,1): error TS1: x\r\n!!! related TS2 a:1:1: y"), + "related_info" + ); + assert_eq!( + categorize("a.ts(1,1): error TS1: x\r\n chained\r\n\r\n\r\n==== a"), + "chain" + ); + assert_eq!(categorize("lib.d.ts(--,--): error TS1: x"), "lib_mask"); + assert_eq!(categorize("a.ts(1,1): error TS1: x"), "other"); + } + + #[test] + fn multiline_span_detected() { + assert!(has_multiline_span(" ~~~~\r\n ~~~~")); + assert!(!has_multiline_span(" code;\r\n ~~~~")); + } +} From 32e8554e7c76662acd7f0493aa85b7ab803a51dd Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 07:43:32 -0400 Subject: [PATCH 04/79] docs: tsc_conformance CLAUDE.md catalog entry --- CLAUDE.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 54be0d3e7..a1026617b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -785,6 +785,37 @@ deno run --allow-read --allow-env --allow-ffi --allow-net --allow-sys \ See ./docs/conformance_test262.md. +**tsgo Typechecker-Conformance Harness (tsc_conformance):** + +Pure-Rust harness over tsgo's committed `.errors.txt` error baselines (in +`../typescript-go`, oracle pin `168e7015`, read from the checked-in +`testdata/baselines/reference/submodule` — **not** the large, often-unmaterialized +`_submodules/TypeScript` corpus, so it runs on a bare checkout). No Deno, **zero +checker code**. Distinct from the parser-conformance surfaces: `conformance:ts-repo` +grades tsv's *parser* against the tsc corpus, whereas this reads tsgo's *checker* +error output — the seam a future tsv checker will emit through. + +```bash +# query - aggregations over every baseline's summary block +cargo run -p tsv_debug tsc_conformance query histogram # per-TS-code instance counts + totals +cargo run -p tsv_debug tsc_conformance query tests-by-code 2454 # baselines mentioning a code +cargo run -p tsv_debug tsc_conformance query denominators # test-identity / variant / JSX sizing +# Options: --path (default ../typescript-go), --json. +# histogram/denominators enforce the exact baseline-count pin; tests-by-code does not. + +# roundtrip - parse every baseline → re-render → byte-compare (the P0 self-check): +# proves the .errors.txt parser + renderer port in one move, no checker involved. +cargo run -p tsv_debug tsc_conformance roundtrip # full self-check (all baselines) +cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by path substring (skips the pins) +# Options: --path, --json, --verbose (list every failing path). A full (unfiltered) +# run enforces two exact regression pins — total baselines and byte-identical count — +# and fails the process on drift; a re-pin is deliberate (a code change or a tsgo pull). +``` + +A manual harness (self-checking pins on a full run), not part of `deno task check` +or the automated release conformance step. Failing baselines are bucketed by their +most salient format feature (a triage taxonomy, not a proof of cause). + **Performance Profiling Commands:** ```bash From fb319a4f4daf096ee69fc5659364a1db0e5a79ac Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 07:43:32 -0400 Subject: [PATCH 05/79] test: tsc_conformance renderer standalone-model tests --- .../tsv_debug/src/tsc_conformance/render.rs | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/crates/tsv_debug/src/tsc_conformance/render.rs b/crates/tsv_debug/src/tsc_conformance/render.rs index 92c998044..119c25cb5 100644 --- a/crates/tsv_debug/src/tsc_conformance/render.rs +++ b/crates/tsv_debug/src/tsc_conformance/render.rs @@ -470,4 +470,145 @@ mod tests { ); assert!(self_assertion_violations(&parsed).is_empty()); } + + // --- standalone renderer tests (model built by hand, not via the parser) --- + // + // The roundtrip tests all feed `render_baseline` a *parser-recovered* model, so + // parser and renderer are only ever exercised as a pair. These build a + // `ParsedBaseline` directly — the path a future tsv checker takes, emitting a + // model it assembled rather than one recovered from a baseline — so the + // renderer's byte contract is pinned independently of the parser. + + use super::super::baseline::SectionDiag; + + /// Build an `error`-category diagnostic (the only category these tests need). + fn diag(file: Option<&str>, loc: Option, code: i32, msgs: &[&str]) -> Diag { + Diag { + file: file.map(str::to_string), + loc, + category: "error".to_string(), + code, + msg_lines: msgs.iter().map(|s| (*s).to_string()).collect(), + related: Vec::new(), + } + } + + /// Build a section from source lines and `(diag_index, pos_abs, len)` spans. + fn section(name: &str, src: &[&str], spans: &[(usize, usize, usize)]) -> Section { + Section { + name: name.to_string(), + src_lines: src.iter().map(|s| (*s).to_string()).collect(), + diags: spans + .iter() + .map(|&(diag_index, pos_abs, len)| SectionDiag { + diag_index, + pos_abs, + len, + }) + .collect(), + } + } + + #[test] + fn standalone_render_positional_single_diag() { + // `const x = 1;` — the `x` is a length-1 span at byte 6 (col 7); the + // squiggle prefix blanks `const ` to six spaces (plus the 4-space indent). + let b = ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 7 }), + 2304, + &["Cannot find name 'x'."], + )], + sections: vec![section("a.ts", &["const x = 1;"], &[(0, 6, 1)])], + }; + let expected = concat!( + "a.ts(1,7): error TS2304: Cannot find name 'x'.\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " const x = 1;\r\n", + " ~\r\n", + "!!! error TS2304: Cannot find name 'x'.", + ); + assert_eq!(render_baseline(&b), expected); + assert!(self_assertion_violations(&b).is_empty()); + } + + #[test] + fn standalone_render_global_diag_renders_twice() { + // A fileless diagnostic prints bare in the summary AND `!!!`-prefixed in + // the re-render; the shared first-line flag means that first `!!!` carries + // no leading CRLF (it opens the stateful-newLine region). + let b = ParsedBaseline { + diags: vec![diag(None, None, 5102, &["Option 'x' has been removed."])], + sections: vec![], + }; + let expected = concat!( + "error TS5102: Option 'x' has been removed.\r\n", + "\r\n\r\n", + "!!! error TS5102: Option 'x' has been removed.", + ); + assert_eq!(render_baseline(&b), expected); + assert!(self_assertion_violations(&b).is_empty()); + } + + #[test] + fn standalone_render_message_chain() { + // A two-line message chain: the continuation joins the summary head with + // CRLF, and each non-empty line gets its own `!!!` prefix in the block. + let b = ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2322, + &[ + "Type 'A' is not assignable to 'B'.", + " Type 'A' is missing prop.", + ], + )], + sections: vec![section("a.ts", &["let a: B = x;"], &[(0, 0, 3)])], + }; + let expected = concat!( + "a.ts(1,1): error TS2322: Type 'A' is not assignable to 'B'.\r\n", + " Type 'A' is missing prop.\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " let a: B = x;\r\n", + " ~~~\r\n", + "!!! error TS2322: Type 'A' is not assignable to 'B'.\r\n", + "!!! error TS2322: Type 'A' is missing prop.", + ); + assert_eq!(render_baseline(&b), expected); + assert!(self_assertion_violations(&b).is_empty()); + } + + #[test] + fn standalone_render_multiline_span_message_on_end_line() { + // A span covering the whole `foo(\n bar\n)` squiggles every touched line + // (continuations squiggle from column 0) but emits its `!!!` message only + // on the end line — the forward clip math, exercised without the parser. + let b = ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2554, + &["Expected 0 arguments."], + )], + sections: vec![section("a.ts", &["foo(", " bar", ")"], &[(0, 0, 12)])], + }; + let expected = concat!( + "a.ts(1,1): error TS2554: Expected 0 arguments.\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " foo(\r\n", + " ~~~~\r\n", + " bar\r\n", + " ~~~~~\r\n", + " )\r\n", + " ~\r\n", + "!!! error TS2554: Expected 0 arguments.", + ); + assert_eq!(render_baseline(&b), expected); + assert!(self_assertion_violations(&b).is_empty()); + } } From 39ef63a95e15eb77438cee5263ff4f749ce0f940 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 08:00:24 -0400 Subject: [PATCH 06/79] =?UTF-8?q?fix:=20recover=20nested=20related-info=20?= =?UTF-8?q?message=20chains=20in=20tsc=5Fconformance=20(7017=E2=86=927019,?= =?UTF-8?q?=20in-scope=20100%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/cli/commands/tsc_conformance.rs | 14 +-- .../tsv_debug/src/tsc_conformance/baseline.rs | 98 +++++++++++++++---- .../src/tsc_conformance/roundtrip.rs | 10 +- 3 files changed, 90 insertions(+), 32 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 68cac13e1..7d54901cb 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -19,13 +19,13 @@ use std::path::PathBuf; const BASELINE_COUNT_PIN: usize = 7033; /// REGRESSION PIN (exact): baselines that round-trip byte-identically -/// (`parse → render == input`). Measured 2026-07-09 vs pin 168e7015: 7017 of -/// 7033. The 16-baseline residual is fully accounted for — 14 ANSI-colored -/// `pretty=true` baselines (out of the ported rune-path scope) and 2 whose -/// related-info message chains have byte-ambiguous deep continuation lines. A -/// move in either direction is a deliberate re-pin (a parser/renderer change, or -/// a typescript-go pull); pin two-sided so drift can't hide. -const ROUNDTRIP_PASS_PIN: usize = 7017; +/// (`parse → render == input`). Measured vs pin 168e7015: 7019 of 7033 — the +/// full in-scope set. The 14-baseline residual is entirely the ANSI-colored +/// `pretty=true` baselines (out of the ported rune-path scope); in-scope +/// round-trip is 100%. A move in either direction is a deliberate re-pin (a +/// parser/renderer change, or a typescript-go pull); pin two-sided so drift +/// can't hide. +const ROUNDTRIP_PASS_PIN: usize = 7019; /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] diff --git a/crates/tsv_debug/src/tsc_conformance/baseline.rs b/crates/tsv_debug/src/tsc_conformance/baseline.rs index a4ac1d9f1..dd803ed73 100644 --- a/crates/tsv_debug/src/tsc_conformance/baseline.rs +++ b/crates/tsv_debug/src/tsc_conformance/baseline.rs @@ -273,16 +273,7 @@ pub fn parse_baseline(content: &str) -> Result { _ => return Err("global !!! message underflow".to_string()), } } - let mut related = Vec::new(); - while let Some(l) = lines.get(i) { - if l.starts_with("!!! related ") { - related.push((*l).to_string()); - i += 1; - } else { - break; - } - } - d.related = related; + d.related = collect_related(&lines, &mut i); } // --- 4. `==== ` sections --- @@ -304,6 +295,47 @@ pub fn parse_baseline(content: &str) -> Result { Ok(ParsedBaseline { diags, sections }) } +/// Collect a diagnostic's related-info block from `lines` starting at `*i`: each +/// `!!! related …` line, plus the raw continuation lines of that related info's +/// **own** message chain. +/// +/// A related info can itself be a message chain; `FlattenDiagnosticMessage` +/// renders its first line onto the `!!! related …:` line and its deeper levels as +/// bare continuation lines — 2-space-per-level indent, **no** `!!!` prefix and no +/// 4-space section indent (`diagnosticwriter.go`'s chain flattening). Those lines +/// are re-emitted verbatim by the renderer, so recovering them is a capture +/// question. The chain opens at indent 2 (level 1) and increases by exactly 2 per +/// level, which disambiguates it from a section source line — every source line +/// carries the ≥4-space section indent, so a line at the next expected chain +/// indent that also holds content is a continuation. The strict `+2` run stops the +/// moment a line breaks the pattern (e.g. the next source line, `!!!` block, or a +/// blank line), so a source line can't be swallowed unless it sits at exactly the +/// pending chain indent *and* the chain didn't already terminate — a case the +/// round-trip over every baseline confirms does not arise. +fn collect_related(lines: &[&str], i: &mut usize) -> Vec { + let mut related = Vec::new(); + while lines.get(*i).is_some_and(|l| l.starts_with("!!! related ")) { + related.push(lines[*i].to_string()); + *i += 1; + // The related info's own message-chain continuation lines. + let mut expected = 2usize; + while let Some(cont) = lines.get(*i) { + if cont.starts_with("!!! ") { + break; + } + let indent = cont.bytes().take_while(|&b| b == b' ').count(); + if indent == expected && cont.len() > indent { + related.push((*cont).to_string()); + *i += 1; + expected += 2; + } else { + break; + } + } + } + related +} + /// The head fields of a summary line, before message text. struct SummaryHead { file: Option, @@ -495,16 +527,7 @@ fn parse_section_body(name: String, body: &[&str], diags: &mut [Diag]) -> Result _ => return Err(format!("section {name}: !!! message underflow")), } } - let mut related = Vec::new(); - while let Some(l) = body.get(bi) { - if l.starts_with("!!! related ") { - related.push((*l).to_string()); - bi += 1; - } else { - break; - } - } - diags[works[w].diag_index].related = related; + diags[works[w].diag_index].related = collect_related(body, &mut bi); } else if !active.contains(&w) { newly_active.push(w); } @@ -693,4 +716,39 @@ mod tests { assert_eq!(sec.diags[0].pos_abs, 0); assert_eq!(sec.diags[0].len, 4); } + + #[test] + fn parse_baseline_recovers_nested_related_message_chain() { + // A `!!! related` diagnostic that is itself a message chain: its deeper + // levels render as bare 2/4/6-space continuation lines (no `!!!` prefix, + // no section indent). The parser must capture them into `related` so the + // baseline round-trips — this is the async/iterator case that was once the + // last in-scope residual. The trailing ` next;` (4-space source indent) + // must NOT be swallowed: the chain has terminated at level 3 (expected + // indent 8), so a 4-space line breaks the run. + let content = concat!( + "a.ts(1,1): error TS2504: bad iter.\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " iter;\r\n", + " ~~~~\r\n", + "!!! error TS2504: bad iter.\r\n", + "!!! related TS2322 a.ts:1:1: Type X not assignable to Y.\r\n", + " Types of property 'p' are incompatible.\r\n", + " Type A is not assignable to type B.\r\n", + " Deep level three.\r\n", + " next;", + ); + let parsed = parse_baseline(content).expect("parse"); + assert_eq!(parsed.diags.len(), 1); + // 1 `!!! related` line + 3 chain-continuation lines. + assert_eq!(parsed.diags[0].related.len(), 4); + // The trailing source line survived as its own section source line. + assert_eq!(parsed.sections[0].src_lines, vec!["iter;", "next;"]); + assert_eq!( + super::super::render::render_baseline(&parsed), + content, + "nested related chain must round-trip byte-identically" + ); + } } diff --git a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs index b3c10e662..7b8ef4c63 100644 --- a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs +++ b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs @@ -7,11 +7,11 @@ //! salient format feature (the P0 work-list); the pass count is a two-sided //! regression pin. //! -//! Known residual (measured 2026-07-09 vs pin `168e7015`): a small set of -//! ANSI-colored `pretty=true` baselines (out of the ported rune-path scope) and -//! a couple of baselines whose related-info carries a message chain whose deeper -//! (4-space) continuation lines are byte-ambiguous with source lines. Both are -//! reported honestly rather than hidden by loosening the comparison. +//! Known residual (measured vs pin `168e7015`): the ANSI-colored `pretty=true` +//! baselines, which are out of the ported rune-path scope. In-scope round-trip is +//! 100%; the pretty set is reported honestly rather than hidden by loosening the +//! comparison. (Related-info diagnostics that carry their own message chain — once +//! a residual — are now recovered by [`super::baseline`]'s continuation capture.) use super::baseline::parse_baseline; use super::discovery::Baseline; From db6a4d0f5d893235682c201fef572cd8bd153d75 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 08:17:41 -0400 Subject: [PATCH 07/79] feat: gate tsc_conformance roundtrip in deno task conformance + publish Step 3b --- CLAUDE.md | 15 +++++++++++---- benches/js/CLAUDE.md | 27 +++++++++++++++------------ benches/js/conformance.ts | 17 +++++++++++++++++ deno.json | 3 ++- scripts/doctor.ts | 9 +++++++++ scripts/publish.ts | 8 ++++++-- 6 files changed, 60 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1026617b..1d6e2edf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,7 +246,7 @@ Package shape: built from the wasm-pack `web` target, then `scripts/patch_npm_pa `scripts/publish.ts` orchestrates the release end to end (preflight → bump → check → conformance → build (npm packages + deno bundles, so artifact validation never sees stale bundles) → verify → artifact validation: size bounds + Deno smoke + Node tests → idempotent npm publish → git commit + tag + push), printing a wasm size summary (raw + gzipped) at the end. It stamps CHANGELOG.md's `## Unreleased` section into the released version's section — that section must be non-empty and carry a `` marker that matches `--bump` (the bump is required in **both** places and they must agree; on stamp the marker is dropped and a fresh empty `## Unreleased` reset to `bump: patch` is seeded for the next cycle). The user keeps it updated as work lands — agents don't touch `CHANGELOG.md` (see [Committing](#committing)). A failed wetrun is resumable: re-run `--wetrun` without `--bump`. -**Conformance gates (Step 3b).** The release-cadence correctness gates that run against **external oracles** — so they can't live in `deno task check` — run here via `deno task conformance`: the Svelte parser vs `svelte/compiler` (`conformance:svelte-fixtures`), the TS parser vs acorn-typescript's own suite (`conformance:ts-fixtures`) and the tsc corpus (`conformance:ts-repo`), plus all three languages' AST + formatter vs the canonical parsers / prettier (`corpus:compare:parse` + `:format`, `--all`). Skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript` checkouts + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. `test262` (needs `../test262`) and the CSS-WPT harvest stay manual, out of the automated step. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; the historical FFI heisenbug now surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). +**Conformance gates (Step 3b).** The release-cadence correctness gates that run against **external oracles** — so they can't live in `deno task check` — run here via `deno task conformance`: the Svelte parser vs `svelte/compiler` (`conformance:svelte-fixtures`), the TS parser vs acorn-typescript's own suite (`conformance:ts-fixtures`) and the tsc corpus (`conformance:ts-repo`), the `tsc_conformance` roundtrip self-check vs tsgo's `.errors.txt` baselines (`conformance:tsc-roundtrip`), plus all three languages' AST + formatter vs the canonical parsers / prettier (`corpus:compare:parse` + `:format`, `--all`). Skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go` checkouts + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. `test262` (needs `../test262`) and the CSS-WPT harvest stay manual, out of the automated step. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; the historical FFI heisenbug now surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). ```bash deno task publish # dry-run: validate everything, no mutation @@ -806,15 +806,22 @@ cargo run -p tsv_debug tsc_conformance query denominators # test-identity # roundtrip - parse every baseline → re-render → byte-compare (the P0 self-check): # proves the .errors.txt parser + renderer port in one move, no checker involved. cargo run -p tsv_debug tsc_conformance roundtrip # full self-check (all baselines) +deno task conformance:tsc-roundtrip # the same, as a deno task cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by path substring (skips the pins) # Options: --path, --json, --verbose (list every failing path). A full (unfiltered) # run enforces two exact regression pins — total baselines and byte-identical count — # and fails the process on drift; a re-pin is deliberate (a code change or a tsgo pull). ``` -A manual harness (self-checking pins on a full run), not part of `deno task check` -or the automated release conformance step. Failing baselines are bucketed by their -most salient format feature (a triage taxonomy, not a proof of cause). +`roundtrip` is a **leg of `deno task conformance`** (the pre-release aggregate) and +so runs in publish **Step 3b** — its two exact pins fail a release on drift. +`../typescript-go` is therefore a release-required oracle there (a missing checkout +FAILS a `--wetrun`, warn-skips a dry-run, is re-warned in the final summary), and +`deno task doctor` reports its readiness. Like `../typescript` it is a git-SHA +checkout, so — matching that precedent — it is **not** in `pins:audit` (npm-version +only); its tsgo commit is pinned by the Rust count-pins. It stays out of +`deno task check` (which is external-oracle-free). Failing baselines are bucketed by +their most salient format feature (a triage taxonomy, not a proof of cause). **Performance Profiling Commands:** diff --git a/benches/js/CLAUDE.md b/benches/js/CLAUDE.md index 256ebddfd..953ca479c 100644 --- a/benches/js/CLAUDE.md +++ b/benches/js/CLAUDE.md @@ -399,18 +399,21 @@ 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`.** 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 oracles (and so can't -live in `deno task check`). `deno task conformance` builds the corpus FFI once and -runs all five 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. It is wired into `scripts/publish.ts` **Step 3b** -(skipped by `--no-check`). Step 3b preflights the oracles (`../svelte`, -`../acorn-typescript`, `../typescript`, this dir's `node_modules`): a missing one +gates (svelte-fixtures, ts-fixtures, ts-repo), the `tsc_conformance` roundtrip +self-check (`tsc-roundtrip` — checker-conformance vs tsgo's `.errors.txt` +baselines, not parse; the one pure-Rust leg, shelled out via `cargo`, so the task +carries `--allow-run=cargo`), 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`). `deno task +conformance` builds the corpus FFI once and 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. It is wired into +`scripts/publish.ts` **Step 3b** (skipped by `--no-check`). Step 3b preflights the +oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go`, +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 diff --git a/benches/js/conformance.ts b/benches/js/conformance.ts index fc7bb3250..50597bb76 100644 --- a/benches/js/conformance.ts +++ b/benches/js/conformance.ts @@ -37,10 +37,27 @@ if (Deno.args.length > 0) { Deno.exit(1); } +/** + * The tsc_conformance roundtrip self-check — pure-Rust, reads tsgo's committed + * `.errors.txt` baselines from `../typescript-go`. Shelled out (the only non-JS + * leg) so it rides the same fail-fast + one summary as the parse gates. The + * command prints its own report and exits non-zero on a pin mismatch or a + * missing checkout, so mirror the other legs' "exit the process on a finding". + */ +async function run_tsc_roundtrip(): Promise { + const {code} = await new Deno.Command('cargo', { + args: ['run', '-p', 'tsv_debug', '--quiet', 'tsc_conformance', 'roundtrip'], + stdout: 'inherit', + stderr: 'inherit', + }).output(); + if (code !== 0) Deno.exit(1); +} + const legs: [string, () => Promise][] = [ ['conformance:svelte-fixtures', () => run_fixtures_gate(SVELTE_FIXTURES_GATE)], ['conformance:ts-fixtures', () => run_fixtures_gate(TS_FIXTURES_GATE)], ['conformance:ts-repo', () => run_ts_repo_compare([])], + ['conformance:tsc-roundtrip', run_tsc_roundtrip], ['corpus:compare:parse --all', () => run_corpus_compare_parse(['--all'])], ['corpus:compare:format --all', () => run_corpus_compare_format(['--all'])], ]; diff --git a/deno.json b/deno.json index aac23a770..a1239c160 100644 --- a/deno.json +++ b/deno.json @@ -77,7 +77,8 @@ "conformance:ts-fixtures:run": "deno run --allow-ffi --allow-read --allow-env --allow-net --allow-sys benches/js/diagnostics/ts_fixtures_compare.ts", "conformance:ts-repo": "deno task build:ffi:corpus && TSV_FFI_PROFILE=corpus deno task conformance:ts-repo:run", "conformance:ts-repo:run": "deno run --allow-ffi --allow-read --allow-env --allow-net --allow-sys benches/js/diagnostics/ts_repo_compare.ts", - "conformance": "deno task build:ffi:corpus && TSV_FFI_PROFILE=corpus PRETTIER_DEBUG=1 deno run --allow-ffi --allow-read --allow-env --allow-net --allow-sys --allow-write=benches/js/.cache benches/js/conformance.ts", + "conformance:tsc-roundtrip": "cargo run -p tsv_debug --quiet tsc_conformance roundtrip", + "conformance": "deno task build:ffi:corpus && TSV_FFI_PROFILE=corpus PRETTIER_DEBUG=1 deno run --allow-ffi --allow-read --allow-env --allow-net --allow-sys --allow-run=cargo --allow-write=benches/js/.cache benches/js/conformance.ts", "divergence:audit": "deno run --allow-read benches/js/divergence_audit.ts", "test:deno": "deno test --allow-read benches/js/lib/divergence/", "test:deno:canonical": "deno test --config benches/js/deno.json --allow-read --allow-env --allow-sys benches/js/lib/canonical_test.ts", diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 5e1ce9256..1915d512a 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -163,6 +163,15 @@ if (exists('../typescript/tests/baselines/reference')) { warn('../typescript present but tests/baselines/reference missing — conformance:ts-repo will FAIL (partial checkout)'); } else warn('../typescript checkout missing — conformance:ts-repo needs it'); +// Git-SHA-pinned like ../typescript (not npm-versioned), so — same as ../typescript +// — it isn't in pins:audit; presence is checked here, and its pinned tsgo commit is +// enforced by the Rust roundtrip count-pins (ROUNDTRIP_PASS_PIN / BASELINE_COUNT_PIN). +if (exists('../typescript-go/testdata/baselines/reference/submodule')) { + ok('../typescript-go checkout (tsgo baselines present)'); +} else if (exists('../typescript-go')) { + warn('../typescript-go present but testdata/baselines/reference/submodule missing — conformance:tsc-roundtrip will FAIL (partial checkout)'); +} else warn('../typescript-go checkout missing — conformance:tsc-roundtrip needs it'); + // Informational (NOT gated by pins:audit — see its docstring): the prettier // checkout is a reading reference + corpus-suite source whose oracle output is // computed live per file, and it legitimately rides `-dev` versions. diff --git a/scripts/publish.ts b/scripts/publish.ts index f8d407275..9f69c4c54 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -318,8 +318,9 @@ if (no_check) { // them here means a release can't ship a parse/format regression the in-repo gate // is structurally blind to. Skipped by --no-check alongside Step 3. // -// The gates need the ../svelte + ../acorn-typescript + ../typescript checkouts -// and the benches/js `node_modules` sidecar (`deno task bench:install`). The +// The gates need the ../svelte + ../acorn-typescript + ../typescript + +// ../typescript-go checkouts and the benches/js `node_modules` sidecar +// (`deno task bench:install`). The // probe must cover every oracle the aggregate's legs need — the gates // themselves fail closed on a missing checkout, so the probe is the ONE // tolerance point: a dry-run warn-and-skips (clean machines can still @@ -337,6 +338,9 @@ if (no_check) { exists('../svelte/packages/svelte/tests') ? null : '../svelte checkout', exists('../acorn-typescript/test') ? null : '../acorn-typescript checkout', exists('../typescript/tests') ? null : '../typescript checkout', + exists('../typescript-go/testdata/baselines/reference/submodule') + ? null + : '../typescript-go checkout', exists('benches/js/node_modules') ? null : 'benches/js/node_modules (deno task bench:install)', ].filter((m): m is string => m !== null); if (missing.length > 0 && wetrun) { From 9fce7158608a405cfeb1d13e1c44fdf64a0138bb Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 08:27:00 -0400 Subject: [PATCH 08/79] feat: carve the 14 pretty baselines out of tsc_conformance roundtrip scope (in-scope 100%) --- .../src/cli/commands/tsc_conformance.rs | 59 +++++++++++----- .../src/tsc_conformance/roundtrip.rs | 67 ++++++++++++++----- 2 files changed, 94 insertions(+), 32 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 7d54901cb..14be3aeaf 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -18,15 +18,19 @@ use std::path::PathBuf; /// move (a discovery bug, or a typescript-go pull) must be re-pinned here. const BASELINE_COUNT_PIN: usize = 7033; -/// REGRESSION PIN (exact): baselines that round-trip byte-identically -/// (`parse → render == input`). Measured vs pin 168e7015: 7019 of 7033 — the -/// full in-scope set. The 14-baseline residual is entirely the ANSI-colored -/// `pretty=true` baselines (out of the ported rune-path scope); in-scope -/// round-trip is 100%. A move in either direction is a deliberate re-pin (a -/// parser/renderer change, or a typescript-go pull); pin two-sided so drift -/// can't hide. +/// REGRESSION PIN (exact): in-scope baselines that round-trip byte-identically +/// (`parse → render == input`). Measured vs pin 168e7015: 7019 — the **full** +/// in-scope set (100%), i.e. `BASELINE_COUNT_PIN - PRETTY_CARVEOUT_PIN`. A move +/// in either direction is a deliberate re-pin (a parser/renderer change, or a +/// typescript-go pull); pin two-sided so drift can't hide. const ROUNDTRIP_PASS_PIN: usize = 7019; +/// REGRESSION PIN (exact): ANSI-colored `pretty=true` baselines carved out of the +/// round-trip scope — a separate renderer path (not the ported rune path), +/// excluded from the denominator rather than counted as failures. Pinned so the +/// pretty set can't grow or shrink silently on a typescript-go pull. +const PRETTY_CARVEOUT_PIN: usize = 14; + /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "tsc_conformance")] @@ -116,15 +120,38 @@ impl RoundtripCommand { report.print(self.verbose); } - // On a full run, gate on the exact pinned pass count (two-sided). - if unfiltered && report.byte_identical != ROUNDTRIP_PASS_PIN { - eprintln!( - "\nError: round-trip pass count {} != pinned {ROUNDTRIP_PASS_PIN}. \ - If deliberate (a parser/renderer change, or a typescript-go pull), re-pin \ - ROUNDTRIP_PASS_PIN.", - report.byte_identical - ); - return Err(CliError::Failed); + // On a full run, gate three exact invariants (all two-sided): + // 1. in-scope round-trip is 100% (no in-scope baseline regressed), + // 2. the in-scope pass count matches its pin, + // 3. the pretty carve-out count matches its pin (the excluded set is stable). + if unfiltered { + let mut errs: Vec = Vec::new(); + if report.byte_identical != report.files_checked { + errs.push(format!( + "in-scope round-trip not 100% — {} of {} passed", + report.byte_identical, report.files_checked + )); + } + if report.byte_identical != ROUNDTRIP_PASS_PIN { + errs.push(format!( + "in-scope pass count {} != pinned {ROUNDTRIP_PASS_PIN}", + report.byte_identical + )); + } + if report.pretty_carved_out != PRETTY_CARVEOUT_PIN { + errs.push(format!( + "pretty carve-out {} != pinned {PRETTY_CARVEOUT_PIN}", + report.pretty_carved_out + )); + } + if !errs.is_empty() { + eprintln!( + "\nError: {}. If deliberate (a parser/renderer change, or a typescript-go \ + pull), re-pin ROUNDTRIP_PASS_PIN / PRETTY_CARVEOUT_PIN.", + errs.join("; ") + ); + return Err(CliError::Failed); + } } Ok(()) } diff --git a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs index 7b8ef4c63..bb71af7a9 100644 --- a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs +++ b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs @@ -7,11 +7,13 @@ //! salient format feature (the P0 work-list); the pass count is a two-sided //! regression pin. //! -//! Known residual (measured vs pin `168e7015`): the ANSI-colored `pretty=true` -//! baselines, which are out of the ported rune-path scope. In-scope round-trip is -//! 100%; the pretty set is reported honestly rather than hidden by loosening the -//! comparison. (Related-info diagnostics that carry their own message chain — once -//! a residual — are now recovered by [`super::baseline`]'s continuation capture.) +//! Scope (measured vs pin `168e7015`): the ANSI-colored `pretty=true` baselines are +//! a separate renderer path (not the ported `pretty=false` rune path), so they are +//! **carved out of the denominator** — excluded from `files_checked`, counted under +//! `pretty_carved_out`, and pinned so the set can't drift silently. In-scope +//! round-trip is 100%; the carve-out is surfaced explicitly, not hidden by loosening +//! the comparison. (Related-info diagnostics that carry their own message chain — +//! once a residual — are now recovered by [`super::baseline`]'s continuation capture.) use super::baseline::parse_baseline; use super::discovery::Baseline; @@ -55,15 +57,22 @@ pub struct BucketCount { /// The round-trip report. #[derive(Debug, serde::Serialize)] pub struct RoundtripReport { - /// Baselines checked. + /// All `.errors.txt` baselines discovered (the in-scope set plus the carve-out). + pub files_discovered: usize, + /// ANSI-colored `pretty=true` baselines carved out of scope: a separate + /// renderer path (not the ported rune path), excluded from the denominator + /// rather than counted as failures. Pinned, so the set can't drift silently. + pub pretty_carved_out: usize, + /// In-scope baselines checked (`files_discovered - pretty_carved_out`). pub files_checked: usize, - /// Baselines that round-tripped byte-identically. + /// In-scope baselines that round-tripped byte-identically. pub byte_identical: usize, - /// `byte_identical / files_checked * 100`. + /// `byte_identical / files_checked * 100` (in-scope pass rate). pub pass_rate: f64, /// Baselines whose parsed model tripped a ported self-assertion (should be 0). pub self_assertion_violations: usize, - /// Failure buckets, sorted by descending count. + /// In-scope failure buckets, sorted by descending count (pretty is carved out + /// upstream, so it never appears here). pub buckets: Vec, /// Every failing baseline's path (sorted); only shown in `--verbose`. #[serde(skip_serializing_if = "Vec::is_empty")] @@ -73,6 +82,7 @@ pub struct RoundtripReport { /// Parse → render → byte-compare every baseline, folding results into a report. pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { let mut byte_identical = 0usize; + let mut pretty_carved_out = 0usize; let mut assertion_violations = 0usize; let mut bucket_map: BTreeMap<&'static str, Vec> = BTreeMap::new(); let mut failing_paths: Vec = Vec::new(); @@ -97,6 +107,15 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { } }; + // Carve out the ANSI-colored pretty=true baselines: a separate renderer + // path (out of the ported rune-path scope), excluded from the denominator + // as a deliberate scope bound — not a failure. The count is pinned, so the + // pretty set can't grow or shrink without a conscious re-pin. + if is_pretty(&content) { + pretty_carved_out += 1; + continue; + } + match parse_baseline(&content) { Ok(parsed) => { if !self_assertion_violations(&parsed).is_empty() { @@ -138,7 +157,8 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { } } - let files_checked = baselines.len(); + let files_discovered = baselines.len(); + let files_checked = files_discovered - pretty_carved_out; let pass_rate = pct(byte_identical, files_checked); let mut buckets: Vec = bucket_map @@ -157,6 +177,8 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { failing_paths.sort(); RoundtripReport { + files_discovered, + pretty_carved_out, files_checked, byte_identical, pass_rate, @@ -166,6 +188,13 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { } } +/// An ANSI-colored `pretty=true` baseline — a separate renderer path carved out +/// of the round-trip scope. The ESC byte is present iff tsgo's colored renderer +/// (not the ported `pretty=false` rune path) produced the baseline. +fn is_pretty(content: &str) -> bool { + content.contains('\u{1b}') +} + /// Push a failure into its bucket and onto the failing-paths list. fn record( bucket_map: &mut BTreeMap<&'static str, Vec>, @@ -214,8 +243,9 @@ fn truncate(s: &str) -> String { /// order). This is a heuristic taxonomy — the correlate of the failure, not a /// proof of its cause — and doubles as the P0 work-list. fn categorize(content: &str) -> &'static str { - if content.contains('\u{1b}') { - // ANSI escape → the colored pretty=true path (out of the rune-path scope). + if is_pretty(content) { + // Defensive: pretty baselines are carved out before they reach here, so + // this only fires if a pretty baseline ever slipped through as a failure. "pretty" } else if content.contains("!!! related") { "related_info" @@ -264,12 +294,17 @@ impl RoundtripReport { pub fn print(&self, verbose: bool) { println!("tsgo conformance — round-trip self-check"); println!("========================================"); - println!("Files checked: {}", self.files_checked); - println!("Byte-identical: {}", self.byte_identical); - println!("Pass rate: {:.3}%", self.pass_rate); + println!("Files discovered: {}", self.files_discovered); + println!( + "Pretty carved out: {} (ANSI pretty=true — separate renderer path, out of scope)", + self.pretty_carved_out + ); + println!("In-scope checked: {}", self.files_checked); + println!("Byte-identical: {}", self.byte_identical); + println!("Pass rate: {:.3}%", self.pass_rate); println!("Self-assert fails: {}", self.self_assertion_violations); if self.buckets.is_empty() { - println!("\nAll baselines round-trip byte-identically."); + println!("\nAll in-scope baselines round-trip byte-identically."); return; } println!("\nFailure buckets (feature correlate, priority-ordered):"); From 7c2aa110b7310a04b84197752e64d43b189281b5 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 08:27:35 -0400 Subject: [PATCH 09/79] docs: tsc_conformance roundtrip pretty carve-out (in-scope 100%) --- CLAUDE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1d6e2edf0..379dd9a5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -808,9 +808,12 @@ cargo run -p tsv_debug tsc_conformance query denominators # test-identity cargo run -p tsv_debug tsc_conformance roundtrip # full self-check (all baselines) deno task conformance:tsc-roundtrip # the same, as a deno task cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by path substring (skips the pins) -# Options: --path, --json, --verbose (list every failing path). A full (unfiltered) -# run enforces two exact regression pins — total baselines and byte-identical count — -# and fails the process on drift; a re-pin is deliberate (a code change or a tsgo pull). +# Options: --path, --json, --verbose (list every failing path). The 14 ANSI +# `pretty=true` baselines (a separate renderer path) are carved out of the +# denominator, so in-scope round-trip is 100%. A full (unfiltered) run enforces +# three exact pins — total baselines, in-scope byte-identical count, and the pretty +# carve-out count — plus an in-scope-100% invariant, failing on any drift; a re-pin +# is deliberate (a code change or a tsgo pull). ``` `roundtrip` is a **leg of `deno task conformance`** (the pre-release aggregate) and From f521ae8cf2fae063a2f61f4b764689c3c07dfa8c Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 10:25:31 -0400 Subject: [PATCH 10/79] =?UTF-8?q?feat:=20pretty/ANSI=20tsc=5Fconformance?= =?UTF-8?q?=20baseline=20path=20=E2=80=94=20roundtrip=207033/7033,=20carve?= =?UTF-8?q?-out=20dissolved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 13 +- .../src/cli/commands/tsc_conformance.rs | 40 +- .../tsv_debug/src/tsc_conformance/baseline.rs | 47 +- crates/tsv_debug/src/tsc_conformance/mod.rs | 8 +- .../tsv_debug/src/tsc_conformance/pretty.rs | 1121 +++++++++++++++++ .../tsv_debug/src/tsc_conformance/render.rs | 32 +- .../src/tsc_conformance/roundtrip.rs | 135 +- 7 files changed, 1292 insertions(+), 104 deletions(-) create mode 100644 crates/tsv_debug/src/tsc_conformance/pretty.rs diff --git a/CLAUDE.md b/CLAUDE.md index 379dd9a5f..9c3e62f4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -809,15 +809,16 @@ cargo run -p tsv_debug tsc_conformance roundtrip # full self-che deno task conformance:tsc-roundtrip # the same, as a deno task cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by path substring (skips the pins) # Options: --path, --json, --verbose (list every failing path). The 14 ANSI -# `pretty=true` baselines (a separate renderer path) are carved out of the -# denominator, so in-scope round-trip is 100%. A full (unfiltered) run enforces -# three exact pins — total baselines, in-scope byte-identical count, and the pretty -# carve-out count — plus an in-scope-100% invariant, failing on any drift; a re-pin -# is deliberate (a code change or a tsgo pull). +# `pretty=true` baselines take their own colored model/parser/renderer (UTF-16 +# columns, tab→space snippets, the error-summary trailer) but stay in the +# denominator, so round-trip is 100% across all baselines. A full (unfiltered) run +# enforces three exact pins — total baselines, byte-identical count, and the +# pretty-path count — plus a 100% invariant, failing on any drift; a re-pin is +# deliberate (a code change or a tsgo pull). ``` `roundtrip` is a **leg of `deno task conformance`** (the pre-release aggregate) and -so runs in publish **Step 3b** — its two exact pins fail a release on drift. +so runs in publish **Step 3b** — its three exact pins fail a release on drift. `../typescript-go` is therefore a release-required oracle there (a missing checkout FAILS a `--wetrun`, warn-skips a dry-run, is re-warned in the final summary), and `deno task doctor` reports its readiness. Like `../typescript` it is a git-SHA diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 14be3aeaf..06ddf0e56 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -18,18 +18,18 @@ use std::path::PathBuf; /// move (a discovery bug, or a typescript-go pull) must be re-pinned here. const BASELINE_COUNT_PIN: usize = 7033; -/// REGRESSION PIN (exact): in-scope baselines that round-trip byte-identically -/// (`parse → render == input`). Measured vs pin 168e7015: 7019 — the **full** -/// in-scope set (100%), i.e. `BASELINE_COUNT_PIN - PRETTY_CARVEOUT_PIN`. A move -/// in either direction is a deliberate re-pin (a parser/renderer change, or a -/// typescript-go pull); pin two-sided so drift can't hide. -const ROUNDTRIP_PASS_PIN: usize = 7019; +/// REGRESSION PIN (exact): baselines that round-trip byte-identically +/// (`parse → render == input`). Measured vs pin 168e7015: 7033 — the **full** +/// baseline set (100%, plain + pretty paths together, i.e. `BASELINE_COUNT_PIN`). +/// A move in either direction is a deliberate re-pin (a parser/renderer change, +/// or a typescript-go pull); pin two-sided so drift can't hide. +const ROUNDTRIP_PASS_PIN: usize = 7033; -/// REGRESSION PIN (exact): ANSI-colored `pretty=true` baselines carved out of the -/// round-trip scope — a separate renderer path (not the ported rune path), -/// excluded from the denominator rather than counted as failures. Pinned so the -/// pretty set can't grow or shrink silently on a typescript-go pull. -const PRETTY_CARVEOUT_PIN: usize = 14; +/// REGRESSION PIN (exact): baselines that take the ANSI-colored `pretty=true` +/// path (its own model, parser, and colored renderer). In scope and folded into +/// the pass count; pinned so the pretty set can't grow or shrink silently on a +/// typescript-go pull. +const PRETTY_PATH_PIN: usize = 14; /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] @@ -121,33 +121,33 @@ impl RoundtripCommand { } // On a full run, gate three exact invariants (all two-sided): - // 1. in-scope round-trip is 100% (no in-scope baseline regressed), - // 2. the in-scope pass count matches its pin, - // 3. the pretty carve-out count matches its pin (the excluded set is stable). + // 1. round-trip is 100% (no baseline regressed), + // 2. the pass count matches its pin, + // 3. the pretty-path count matches its pin (the colored set is stable). if unfiltered { let mut errs: Vec = Vec::new(); if report.byte_identical != report.files_checked { errs.push(format!( - "in-scope round-trip not 100% — {} of {} passed", + "round-trip not 100% — {} of {} passed", report.byte_identical, report.files_checked )); } if report.byte_identical != ROUNDTRIP_PASS_PIN { errs.push(format!( - "in-scope pass count {} != pinned {ROUNDTRIP_PASS_PIN}", + "pass count {} != pinned {ROUNDTRIP_PASS_PIN}", report.byte_identical )); } - if report.pretty_carved_out != PRETTY_CARVEOUT_PIN { + if report.pretty_path != PRETTY_PATH_PIN { errs.push(format!( - "pretty carve-out {} != pinned {PRETTY_CARVEOUT_PIN}", - report.pretty_carved_out + "pretty-path count {} != pinned {PRETTY_PATH_PIN}", + report.pretty_path )); } if !errs.is_empty() { eprintln!( "\nError: {}. If deliberate (a parser/renderer change, or a typescript-go \ - pull), re-pin ROUNDTRIP_PASS_PIN / PRETTY_CARVEOUT_PIN.", + pull), re-pin ROUNDTRIP_PASS_PIN / PRETTY_PATH_PIN.", errs.join("; ") ); return Err(CliError::Failed); diff --git a/crates/tsv_debug/src/tsc_conformance/baseline.rs b/crates/tsv_debug/src/tsc_conformance/baseline.rs index dd803ed73..67d76cd8c 100644 --- a/crates/tsv_debug/src/tsc_conformance/baseline.rs +++ b/crates/tsv_debug/src/tsc_conformance/baseline.rs @@ -199,8 +199,9 @@ pub struct ParsedBaseline { pub sections: Vec
, } -/// The diagnostic categories that can head a summary line. -const CATEGORIES: [&str; 4] = ["error", "warning", "message", "suggestion"]; +/// The diagnostic categories that can head a summary line. Shared with the +/// pretty head parser. +pub(super) const CATEGORIES: [&str; 4] = ["error", "warning", "message", "suggestion"]; /// Parse a whole `.errors.txt` baseline into its [`ParsedBaseline`] model. /// @@ -259,7 +260,34 @@ pub fn parse_baseline(content: &str) -> Result { if lines.get(summary_end) != Some(&"") || lines.get(summary_end + 1) != Some(&"") { return Err("expected two blank lines after summary block".to_string()); } - i = summary_end + 2; + + // --- 3 & 4: the global `!!!` re-render and `==== ` sections --- + let sections = parse_middle(&lines, summary_end + 2, lines.len(), &mut diags)?; + + Ok(ParsedBaseline { diags, sections }) +} + +/// Parse the middle region — the global (fileless) `!!!` re-render followed by +/// the `==== ` file sections — from `lines[start..end]`, filling each +/// diagnostic's `related` and recovering every section's source and spans. +/// +/// Shared by the plain [`parse_baseline`] (which reaches it after the summary +/// block, with `end == lines.len()`) and the pretty parser (which reaches it +/// after the colored top block, with `end` bounded at the summary trailer). The +/// diagnostics' heads (file/loc/category/code/message) must already be present +/// in `diags`, in canonical summary order; this recovers only the middle. +/// +/// # Errors +/// +/// Returns a short reason on a `!!!` underflow, a bad section header, or a +/// section-body desync (the same buckets the round-trip driver reports). +pub(super) fn parse_middle( + lines: &[&str], + start: usize, + end: usize, + diags: &mut [Diag], +) -> Result, String> { + let mut i = start; // --- 3. global (fileless) `!!!` re-render --- for d in diags.iter_mut() { @@ -273,26 +301,26 @@ pub fn parse_baseline(content: &str) -> Result { _ => return Err("global !!! message underflow".to_string()), } } - d.related = collect_related(&lines, &mut i); + d.related = collect_related(lines, &mut i); } // --- 4. `==== ` sections --- let mut sections = Vec::new(); - while i < lines.len() { + while i < end { let header = lines[i]; let (name, _n_errors) = parse_section_header(header) .ok_or_else(|| format!("bad section header: {header:?}"))?; i += 1; let body_start = i; - while i < lines.len() && !lines[i].starts_with("==== ") { + while i < end && !lines[i].starts_with("==== ") { i += 1; } let body = &lines[body_start..i]; - let section = parse_section_body(name, body, &mut diags)?; + let section = parse_section_body(name, body, diags)?; sections.push(section); } - Ok(ParsedBaseline { diags, sections }) + Ok(sections) } /// Collect a diagnostic's related-info block from `lines` starting at `*i`: each @@ -398,7 +426,8 @@ fn parse_summary_head(line: &str) -> Option { } /// Read a `-?\d+` code from the start of `s`, returning `(code, bytes_consumed)`. -fn read_code(s: &str) -> Option<(i32, usize)> { +/// Shared with the pretty head parser. +pub(super) fn read_code(s: &str) -> Option<(i32, usize)> { let bytes = s.as_bytes(); let mut i = usize::from(bytes.first() == Some(&b'-')); let digits_start = i; diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 58eff6009..f9f5a64f7 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -8,12 +8,14 @@ //! //! [`baseline`] holds both the summary-block parser (the `query` tool's seed) //! and the full-baseline parser ([`baseline::parse_baseline`]); [`render`] is -//! the faithful `.errors.txt` renderer ported from typescript-go; [`roundtrip`] -//! parses → renders → byte-compares every baseline (the P0 self-check, `zero` -//! checker code). +//! the faithful plain `.errors.txt` renderer ported from typescript-go; +//! [`pretty`] is its ANSI-colored `pretty=true` counterpart (model + parser + +//! renderer); [`roundtrip`] parses → renders → byte-compares every baseline +//! (the P0 self-check, `zero` checker code). pub mod baseline; pub mod discovery; +pub mod pretty; pub mod query; pub mod render; pub mod roundtrip; diff --git a/crates/tsv_debug/src/tsc_conformance/pretty.rs b/crates/tsv_debug/src/tsc_conformance/pretty.rs new file mode 100644 index 000000000..1c4f2eeff --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/pretty.rs @@ -0,0 +1,1121 @@ +//! Parse + render the ANSI-colored `pretty=true` `.errors.txt` baselines. +//! +//! A faithful port of typescript-go's colored diagnostic renderer — the pretty +//! sibling of [`super::render`]'s plain path, and the second seam a future tsv +//! checker will emit its diagnostics through. The round-trip check +//! (`super::roundtrip`) parses a real pretty baseline into [`PrettyBaseline`] +//! and byte-compares the re-render, so the port has to reproduce every colored +//! subtlety of the original. +//! +//! Reference (censused vs pin `168e7015`): +//! - tsgo: `internal/testutil/tsbaseline/error_baseline.go` +//! (`GetErrorBaseline` pretty branch, `iterateErrorBaseline`) +//! - tsgo: `internal/diagnosticwriter/diagnosticwriter.go` +//! (`FormatDiagnosticWithColorAndContext`, `writeCodeSnippet`, `WriteLocation`, +//! `WriteErrorSummaryText`, `writeTabularErrorsDisplay`, `prettyPathForFileError`) +//! +//! Structure of a pretty baseline (`GetErrorBaseline` with `pretty=true`): +//! +//! ```text +//! ← FormatDiagnosticsWithColorAndContext (per-diag) +//! ← + harnessNewLine + harnessNewLine +//! ← identical to the plain path (fileless diags) +//! ==== (N errors) ==== ← identical to the plain path (source + squiggles + !!!) +//! Found N errors … ← WriteErrorSummaryText (message + optional table) +//! ``` +//! +//! The **middle** (global re-render + `==== ` sections) is byte-for-byte the +//! same stream the plain renderer produces, so it is recovered by +//! [`super::baseline::parse_middle`] and re-emitted by +//! [`super::render::render_middle`]. Only the colored **top block** and the +//! **summary trailer** are pretty-specific, and only one datum is absent from +//! the plain middle: each file-bearing related-info's span **length**, which +//! appears solely in the top block's related code-frame (its tilde run). That is +//! recovered here and carried in [`PrettyBaseline::related_lens`]. +//! +//! Two unit systems, kept deliberately distinct from the plain rune path: +//! - the top block measures columns and squiggle widths in **UTF-16 code units** +//! (`scanner.GetECMALineAndUTF16CharacterOfPosition` / `core.UTF16Len`), where +//! the plain path counts runes; +//! - `writeCodeSnippet` **destructively converts tabs to single spaces** in the +//! displayed line (`diagnosticwriter.go:208`), where the plain path preserves +//! tabs in the reprinted source. + +use super::baseline::{read_code, Diag, Loc, ParsedBaseline, Section, CATEGORIES}; +use super::render::{col_to_byte, lf_line_starts, push_code, render_middle, CRLF}; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +// tsgo: diagnosticwriter.go:107-120 — the ANSI color / style sequences. +const GREY: &str = "\u{1b}[90m"; +const RED: &str = "\u{1b}[91m"; +const YELLOW: &str = "\u{1b}[93m"; +const BLUE: &str = "\u{1b}[94m"; +const CYAN: &str = "\u{1b}[96m"; +const GUTTER_STYLE: &str = "\u{1b}[7m"; +const RESET: &str = "\u{1b}[0m"; +const GUTTER_SEPARATOR: &str = " "; +const ELLIPSIS: &str = "..."; + +/// tsgo: diagnostics_generated.go — `File_appears_to_be_binary` (code 1490); the +/// one diagnostic whose colored form omits the code snippet +/// (`diagnosticwriter.go:146`). +const FILE_BINARY_CODE: i32 = 1490; + +/// A fully parsed ANSI `pretty=true` baseline: the plain middle model plus the +/// one datum the middle can't carry — related-info span lengths. +/// +/// [`render_pretty`] turns it back into the original byte stream. +#[derive(Debug, Clone)] +pub struct PrettyBaseline { + /// The plain model recovered from the middle (global re-render + `==== ` + /// sections) — drives both the middle re-render and the derived top block + + /// summary. + pub base: ParsedBaseline, + /// For each diagnostic (parallel to `base.diags`), the UTF-16 span lengths of + /// its **file-bearing** related infos, in `RelatedInformation` order. + /// Recovered from the top block's related code-frames (their tilde runs) — + /// the lengths the plain `!!! related` lines omit. + pub related_lens: Vec>, +} + +// =========================================================================== +// Parser +// =========================================================================== + +/// Parse a whole ANSI `pretty=true` baseline into its [`PrettyBaseline`] model. +/// +/// The baseline is split on `CRLF` and cut into three regions by structural +/// markers (no ANSI-content heuristics): +/// 1. **top block** — up to the first `!!! ` / `==== ` line; parsed for the +/// diagnostic heads and the related span lengths; +/// 2. **middle** — the global re-render + `==== ` sections, bounded below by the +/// summary trailer; recovered by [`super::baseline::parse_middle`]; +/// 3. **summary trailer** — the first line that is neither 4-space-indented nor +/// `!!! ` / `==== ` (the `Found …` line) onward; re-derived at render time. +/// +/// # Errors +/// +/// Returns a short reason on any structural surprise (an unparsable head, a +/// missing middle region, a section desync) — the buckets the round-trip driver +/// reports. +pub fn parse_pretty(content: &str) -> Result { + let lines: Vec<&str> = content.split(CRLF).collect(); + + // Region 1/2 boundary: the first middle line (global re-render or section). + let mid_start = lines + .iter() + .position(|l| l.starts_with("!!! ") || l.starts_with("==== ")) + .ok_or("pretty baseline has no middle region")?; + + // Region 2/3 boundary: the first line that is not part of the middle (the + // summary's `Found …` line), or the end of the file. + let summ_start = (mid_start..lines.len()) + .find(|&k| !is_middle_line(lines[k])) + .unwrap_or(lines.len()); + + let (mut diags, related_lens) = parse_pretty_top(&lines[..mid_start])?; + if diags.is_empty() { + return Err("pretty top block has no diagnostics".to_string()); + } + + let sections = super::baseline::parse_middle(&lines, mid_start, summ_start, &mut diags)?; + + Ok(PrettyBaseline { + base: ParsedBaseline { diags, sections }, + related_lens, + }) +} + +/// A middle-region line: a 4-space-indented source/squiggle line, a `!!! ` body +/// line, or a `==== ` section header. Everything else after the sections start is +/// the summary trailer. +fn is_middle_line(l: &str) -> bool { + l.starts_with(" ") || l.starts_with("!!! ") || l.starts_with("==== ") +} + +/// Parse the colored top block into diagnostic heads plus, per diagnostic, the +/// UTF-16 lengths of its file-bearing related infos (from the related +/// code-frames' tilde runs). +/// +/// The heads mirror `FlattenDiagnosticMessage`'s output: a positional head line +/// (cyan file / yellow line:col), an optional global head (category-colored, +/// no location), 0+ 2-space message-chain continuation lines, then the code +/// frames. Only the related squiggle lines carry data we can't recover from the +/// middle; every other top line is skipped. +fn parse_pretty_top(top: &[&str]) -> Result<(Vec, Vec>), String> { + let mut diags: Vec = Vec::new(); + let mut related_lens: Vec> = Vec::new(); + + for &line in top { + if line.is_empty() { + continue; + } + + // Related code-frame line (4-space indent + gutter). Capture the tilde + // run on the squiggle line (all-blank gutter); skip the content line. + if let Some(after_indent) = line.strip_prefix(" ") + && after_indent.starts_with(GUTTER_STYLE) + { + if gutter_is_blank(after_indent) { + let lens = related_lens + .last_mut() + .ok_or("related squiggle before any diagnostic")?; + lens.push(count_tildes(line)); + } + continue; + } + + // Main code-frame line (gutter at column 0): span length comes from the + // section squiggle, so nothing to capture. + if line.starts_with(GUTTER_STYLE) { + continue; + } + + // Related head (2-space indent + cyan location): the length comes from + // its squiggle line, so skip the head. + if line.strip_prefix(" ").is_some_and(|r| r.starts_with(CYAN)) { + continue; + } + + // Diagnostic head — positional (cyan file at column 0) or global + // (category color at column 0). + if line.starts_with(CYAN) || starts_with_category_color(line) { + let head = parse_pretty_head(line)?; + diags.push(Diag { + file: head.file, + loc: head.loc, + category: head.category, + code: head.code, + msg_lines: vec![head.first_msg], + related: Vec::new(), + }); + related_lens.push(Vec::new()); + continue; + } + + // Message-chain continuation (2-space indent + text): append to the + // current diagnostic's message. + if line.starts_with(" ") { + let d = diags + .last_mut() + .ok_or("message continuation before any diagnostic")?; + d.msg_lines.push(line.to_string()); + continue; + } + + return Err(format!("unrecognized pretty top-block line: {line:?}")); + } + + Ok((diags, related_lens)) +} + +/// True if the gutter of `after_indent` (which begins with [`GUTTER_STYLE`]) is +/// all spaces — the squiggle line's `%*s` gutter, versus the content line's +/// `%*d` line-number gutter. +fn gutter_is_blank(after_indent: &str) -> bool { + let Some(rest) = after_indent.strip_prefix(GUTTER_STYLE) else { + return false; + }; + // The gutter never contains ESC, so the first RESET terminates it. + rest.find(RESET) + .and_then(|end| rest.get(..end)) + .is_some_and(|g| g.bytes().all(|b| b == b' ')) +} + +/// Count the tilde run on a squiggle line (its UTF-16 span length). Tildes appear +/// only as the squiggle, so a raw byte count is exact. +fn count_tildes(line: &str) -> u32 { + line.bytes().filter(|&b| b == b'~').count() as u32 +} + +/// True if `line` begins with a category color (a global head with no location). +/// The positional file color (cyan) is deliberately excluded. +fn starts_with_category_color(line: &str) -> bool { + line.starts_with(RED) + || line.starts_with(YELLOW) + || line.starts_with(GREY) + || line.starts_with(BLUE) +} + +/// The head fields of a top-block diagnostic, before its code frame. +struct PrettyHead { + file: Option, + loc: Option, + category: String, + code: i32, + first_msg: String, +} + +/// Parse one top-block head line (ANSI-stripped): positional +/// `{file}:{line}:{col} - {category} TS{code}: {message}` or global +/// `{category} TS{code}: {message}`. +fn parse_pretty_head(line: &str) -> Result { + let stripped = ansi_strip(line); + if line.starts_with(CYAN) { + // Positional: split off the location at the first ` - ` (the ` - ` the + // renderer inserts after WriteLocation; a message may contain more). + let (locpart, rest) = stripped + .split_once(" - ") + .ok_or_else(|| format!("pretty head missing ' - ': {stripped:?}"))?; + let (file, loc) = parse_pretty_location(locpart) + .ok_or_else(|| format!("bad pretty location: {locpart:?}"))?; + let (category, code, msg) = parse_category_code_msg(rest) + .ok_or_else(|| format!("bad pretty head body: {rest:?}"))?; + Ok(PrettyHead { + file: Some(file), + loc: Some(loc), + category, + code, + first_msg: msg, + }) + } else { + let (category, code, msg) = parse_category_code_msg(&stripped) + .ok_or_else(|| format!("bad global pretty head: {stripped:?}"))?; + Ok(PrettyHead { + file: None, + loc: None, + category, + code, + first_msg: msg, + }) + } +} + +/// Parse a top-block location `{file}:{line}:{col}` (colon form, always numbered +/// — the harness's `(--,--)` masking never reaches the colored path). +fn parse_pretty_location(s: &str) -> Option<(String, Loc)> { + let (rest, col) = s.rsplit_once(':')?; + let (file, line) = rest.rsplit_once(':')?; + Some(( + file.to_string(), + Loc::Numbered { + line: line.parse().ok()?, + col: col.parse().ok()?, + }, + )) +} + +/// Parse `{category} TS{code}: {message}` at the start of `s`. +fn parse_category_code_msg(s: &str) -> Option<(String, i32, String)> { + for cat in CATEGORIES { + if let Some(rest) = s.strip_prefix(cat).and_then(|r| r.strip_prefix(" TS")) { + let (code, consumed) = read_code(rest)?; + let msg = rest.get(consumed..)?.strip_prefix(": ")?; + return Some((cat.to_string(), code, msg.to_string())); + } + } + None +} + +/// Strip SGR ANSI escapes (`ESC [ … m`) from `line`. Every sequence the colored +/// renderer emits is an SGR terminating in `m`. +fn ansi_strip(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while let Some(esc) = rest.find('\u{1b}') { + out.push_str(rest.get(..esc).unwrap_or("")); + let after = rest.get(esc..).unwrap_or(""); + match after.find('m') { + Some(m) => rest = after.get(m + 1..).unwrap_or(""), + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out +} + +// =========================================================================== +// Renderer +// =========================================================================== + +/// Render a [`PrettyBaseline`] back to its `.errors.txt` byte stream: the colored +/// top block, the plain middle, then the summary trailer. +/// +/// Mirrors `GetErrorBaseline` (pretty branch) + `iterateErrorBaseline`. +/// +/// # Errors +/// +/// Returns a short reason if a diagnostic references a section/source that the +/// model doesn't carry (an internal inconsistency); the round-trip driver +/// buckets it. +pub fn render_pretty(b: &PrettyBaseline) -> Result { + let mut out = String::new(); + + // 1. colored top block (FormatDiagnosticsWithColorAndContext) … + render_pretty_top(&mut out, b)?; + // … + harnessNewLine + harnessNewLine (error_baseline.go:142). + out.push_str(CRLF); + out.push_str(CRLF); + + // 2 & 3. the plain middle (global re-render + sections). + render_middle(&mut out, &b.base.diags, &b.base.sections); + + // 4. the colored summary trailer (WriteErrorSummaryText). + render_pretty_summary(&mut out, &b.base); + + Ok(out) +} + +/// Render the top block: each diagnostic in order, joined by a single `newLine` +/// (`FormatDiagnosticsWithColorAndContext`, so between two diagnostics the prior +/// one's own trailing newline plus this separator make a blank line). +fn render_pretty_top(out: &mut String, b: &PrettyBaseline) -> Result<(), String> { + for (i, d) in b.base.diags.iter().enumerate() { + if i > 0 { + out.push_str(CRLF); + } + render_pretty_diagnostic(out, b, i, d)?; + } + Ok(()) +} + +/// Render one colored diagnostic (`FormatDiagnosticWithColorAndContext`): +/// optional location, category + code, flattened message, code frame, and +/// related-info blocks. +fn render_pretty_diagnostic( + out: &mut String, + b: &PrettyBaseline, + i: usize, + d: &Diag, +) -> Result<(), String> { + if let Some(file) = &d.file { + let (line, col) = numbered_loc(d.loc) + .ok_or_else(|| format!("pretty diagnostic for {file} has no numbered location"))?; + write_location(out, file, line, col); + out.push_str(" - "); + } + + // {categoryColor}{category}{reset}{grey} TS{code}: {reset}{message} + out.push_str(category_color(&d.category)); + out.push_str(&d.category); + out.push_str(RESET); + out.push_str(GREY); + out.push_str(" TS"); + push_code(out, d.code); + out.push_str(": "); + out.push_str(RESET); + write_flattened_message(out, &d.msg_lines); + + // Code frame for a file-bearing, non-binary diagnostic. + if d.file.is_some() && d.code != FILE_BINARY_CODE { + let (sec, pos, len) = + diag_span(b, i).ok_or_else(|| format!("no section span for diagnostic {i}"))?; + out.push_str(CRLF); + write_code_snippet(out, &sec.src_lines, pos, len, category_color(&d.category), ""); + out.push_str(CRLF); + } + + render_pretty_related(out, b, i, d) +} + +/// Render a diagnostic's related-info blocks (`diagnosticwriter.go:152-166`). +/// A file-bearing related emits ` {location} - {message}` + a cyan code frame; +/// a fileless related emits only the trailing `newLine`. Each contributes one +/// trailing `newLine` regardless. +fn render_pretty_related( + out: &mut String, + b: &PrettyBaseline, + i: usize, + d: &Diag, +) -> Result<(), String> { + let entries = parse_related_entries(&d.related); + if entries.is_empty() { + return Ok(()); + } + let lens = b.related_lens.get(i).map_or(&[][..], Vec::as_slice); + let mut len_idx = 0usize; + for e in &entries { + if let Some(loc) = &e.loc { + out.push_str(CRLF); + out.push_str(" "); + write_location(out, &loc.file, loc.line, loc.col); + out.push_str(" - "); + write_flattened_message(out, &e.msg_lines); + let len_utf16 = *lens + .get(len_idx) + .ok_or_else(|| format!("related span-length underflow for diagnostic {i}"))?; + len_idx += 1; + let (sec, pos, byte_len) = related_span(b, &loc.file, loc.line, loc.col, len_utf16) + .ok_or_else(|| { + format!("no related source for {}:{}:{}", loc.file, loc.line, loc.col) + })?; + write_code_snippet(out, &sec.src_lines, pos, byte_len, CYAN, " "); + } + out.push_str(CRLF); + } + Ok(()) +} + +/// tsgo: diagnosticwriter.go:305-319 — `WriteLocation`, the colored +/// `{cyan}file{reset}:{yellow}line{reset}:{yellow}col{reset}` (line/col 1-based). +fn write_location(out: &mut String, file: &str, line: u32, col: u32) { + out.push_str(CYAN); + out.push_str(file); + out.push_str(RESET); + out.push(':'); + out.push_str(YELLOW); + let _ = write!(out, "{line}"); + out.push_str(RESET); + out.push(':'); + out.push_str(YELLOW); + let _ = write!(out, "{col}"); + out.push_str(RESET); +} + +/// tsgo: diagnosticwriter.go:263-281 — `WriteFlattenedDiagnosticMessage`. The +/// first message line then each chain line, joined by `newLine`; the stored +/// continuation lines already carry their 2-space-per-level indent. +fn write_flattened_message(out: &mut String, msg_lines: &[String]) { + for (k, m) in msg_lines.iter().enumerate() { + if k > 0 { + out.push_str(CRLF); + } + out.push_str(m); + } +} + +/// tsgo: diagnosticwriter.go:283-295 — `getCategoryFormat`. Unknown categories +/// never reach here (the set is fixed); default to the error color rather than +/// panic as the Go source does. +fn category_color(category: &str) -> &'static str { + match category { + "warning" => YELLOW, + "suggestion" => GREY, + "message" => BLUE, + _ => RED, + } +} + +/// tsgo: diagnosticwriter.go:169-251 — `writeCodeSnippet`. Emits the gutter-style +/// line-number + content lines and their squiggle lines for `[start, start+length)`, +/// eliding the interior of a >5-line span with an ellipsis gutter. Columns and +/// squiggle widths are **UTF-16** code units; the displayed content has tabs +/// converted to single spaces and trailing whitespace trimmed. +fn write_code_snippet( + out: &mut String, + src_lines: &[String], + start: usize, + length: usize, + squiggle_color: &str, + indent: &str, +) { + let starts = lf_line_starts(src_lines); + let (first_line, first_char) = line_and_utf16col(src_lines, &starts, start); + let (last_line, mut last_char) = line_and_utf16col(src_lines, &starts, start + length); + if length == 0 { + // When length is zero, squiggle the character right after the start. + last_char += 1; + } + + let has_more_than_five = last_line.saturating_sub(first_line) >= 4; + let mut gutter_width = digits(last_line + 1); + if has_more_than_five { + gutter_width = gutter_width.max(ELLIPSIS.len()); + } + + let mut i = first_line; + while i <= last_line { + out.push_str(CRLF); + + // Over 5 lines: show the first 2 and last 2, eliding the middle. + if has_more_than_five && first_line + 1 < i && i + 1 < last_line { + out.push_str(indent); + out.push_str(GUTTER_STYLE); + let _ = write!(out, "{ELLIPSIS:>gutter_width$}"); + out.push_str(RESET); + out.push_str(GUTTER_SEPARATOR); + out.push_str(CRLF); + i = last_line.saturating_sub(1); + } + + let content = trim_end_ws(src_lines.get(i).map_or("", String::as_str)).replace('\t', " "); + + // Gutter + content line. + out.push_str(indent); + out.push_str(GUTTER_STYLE); + let line_no = i + 1; + let _ = write!(out, "{line_no:>gutter_width$}"); + out.push_str(RESET); + out.push_str(GUTTER_SEPARATOR); + out.push_str(&content); + out.push_str(CRLF); + + // Gutter + squiggle line. + out.push_str(indent); + out.push_str(GUTTER_STYLE); + let _ = write!(out, "{blank:>gutter_width$}", blank = ""); + out.push_str(RESET); + out.push_str(GUTTER_SEPARATOR); + out.push_str(squiggle_color); + let content_units = utf16_len(&content); + if i == first_line { + let last_char_for_line = if i == last_line { last_char } else { content_units }; + push_repeat(out, ' ', first_char); + push_repeat(out, '~', last_char_for_line.saturating_sub(first_char)); + } else if i == last_line { + push_repeat(out, '~', last_char); + } else { + push_repeat(out, '~', content_units); + } + out.push_str(RESET); + + i += 1; + } +} + +/// tsgo: diagnosticwriter.go:330-375 — `WriteErrorSummaryText`. Counts +/// error-category diagnostics, then emits the `Found …` message and, for >1 +/// erroring file, the tabular file/count display. +fn render_pretty_summary(out: &mut String, base: &ParsedBaseline) { + // getErrorSummary: error-category diagnostics only, grouped by file (sorted). + let mut total = 0usize; + let mut global_errors = 0usize; + let mut by_file: BTreeMap<&str, Vec> = BTreeMap::new(); + for (i, d) in base.diags.iter().enumerate() { + if d.category != "error" { + continue; + } + total += 1; + match &d.file { + None => global_errors += 1, + Some(f) => by_file.entry(f.as_str()).or_default().push(i), + } + } + if total == 0 { + return; + } + + let num_erroring_files = by_file.len(); + // sortedFiles[0] — BTreeMap iterates keys in sorted (byte) order. + let first_file_name = by_file + .iter() + .next() + .map(|(file, idxs)| pretty_path_for_file_error(base, file, idxs)) + .unwrap_or_default(); + + let message = if total == 1 { + if global_errors > 0 || first_file_name.is_empty() { + "Found 1 error.".to_string() + } else { + format!("Found 1 error in {first_file_name}") + } + } else { + match num_erroring_files { + 0 => format!("Found {total} errors."), + 1 => format!("Found {total} errors in the same file, starting at: {first_file_name}"), + _ => format!("Found {total} errors in {num_erroring_files} files."), + } + }; + + out.push_str(CRLF); + out.push_str(&message); + out.push_str(CRLF); + out.push_str(CRLF); + if num_erroring_files > 1 { + write_tabular(out, base, &by_file); + out.push_str(CRLF); + } +} + +/// tsgo: diagnosticwriter.go:412-441 — `writeTabularErrorsDisplay`. The +/// `Errors Files` header then one right-justified count + pretty path per file. +fn write_tabular(out: &mut String, base: &ParsedBaseline, by_file: &BTreeMap<&str, Vec>) { + let max_errors = by_file.values().map(Vec::len).max().unwrap_or(0); + let header_row = "Errors Files"; + let left_heading_len = header_row.split(' ').next().map_or(0, str::len); + let biggest_count_len = digits(max_errors); + let left_padding_goal = left_heading_len.max(biggest_count_len); + let header_padding = biggest_count_len.saturating_sub(left_heading_len); + + push_repeat(out, ' ', header_padding as u32); + out.push_str(header_row); + out.push_str(CRLF); + + for (file, idxs) in by_file { + let count = idxs.len(); + let _ = write!(out, "{count:>left_padding_goal$} "); + out.push_str(&pretty_path_for_file_error(base, file, idxs)); + out.push_str(CRLF); + } +} + +/// tsgo: diagnosticwriter.go:443-459 — `prettyPathForFileError`. The file name +/// plus a grey `:{line}` of its first error (1-based). Relative-path collapsing +/// is a no-op for the baselines' already-relative names. +fn pretty_path_for_file_error(base: &ParsedBaseline, file: &str, idxs: &[usize]) -> String { + let line = idxs + .first() + .and_then(|&i| base.diags.get(i)) + .and_then(|d| numbered_loc(d.loc)) + .map_or(0, |(l, _)| l); + format!("{file}{GREY}:{line}{RESET}") +} + +// =========================================================================== +// Related-line + span helpers +// =========================================================================== + +/// One recovered related-info entry from a diagnostic's verbatim `related` lines. +struct RelatedEntry { + /// `Some` for a file-bearing related, `None` for a fileless one. + loc: Option, + /// The related's flattened message (first line, then any chain lines). + msg_lines: Vec, +} + +/// A file-bearing related location. +struct RelatedLoc { + file: String, + line: u32, + col: u32, +} + +/// Parse a diagnostic's verbatim `related` lines (`!!! related …` heads plus any +/// bare message-chain continuation lines) into structured entries. +fn parse_related_entries(related: &[String]) -> Vec { + let mut entries: Vec = Vec::new(); + for line in related { + if let Some(rest) = line.strip_prefix("!!! related ") { + let (loc, msg) = parse_related_verbatim(rest); + entries.push(RelatedEntry { + loc, + msg_lines: vec![msg], + }); + } else if let Some(last) = entries.last_mut() { + // A chain-continuation line of the previous related's own message. + last.msg_lines.push(line.clone()); + } + } + entries +} + +/// Parse the body of a `!!! related ` line (after that prefix): +/// `TS{code} {file}:{line}:{col}: {message}` or `TS{code}: {message}` (fileless). +fn parse_related_verbatim(rest: &str) -> (Option, String) { + let after_ts = rest.strip_prefix("TS").unwrap_or(rest); + let Some((_, consumed)) = read_code(after_ts) else { + return (None, rest.to_string()); + }; + let tail = after_ts.get(consumed..).unwrap_or(""); + if let Some(msg) = tail.strip_prefix(": ") { + return (None, msg.to_string()); // fileless related + } + if let Some(locmsg) = tail.strip_prefix(' ') + && let Some((loc, msg)) = split_related_location(locmsg) + { + return (Some(loc), msg); + } + (None, tail.to_string()) +} + +/// Split `{file}:{line}:{col}: {message}` at the `: ` that terminates the +/// location (the first one whose preceding token is a valid `file:line:col`), so +/// a colon-bearing message can't confuse it. +fn split_related_location(s: &str) -> Option<(RelatedLoc, String)> { + let mut from = 0usize; + loop { + let rel = s.get(from..)?.find(": ")?; + let pos = from + rel; + if let Some((file, line, col)) = s + .get(..pos) + .and_then(|locpart| locpart.rsplit_once(':')) + .and_then(|(fl, col)| { + let (file, line) = fl.rsplit_once(':')?; + Some((file, line.parse::().ok()?, col.parse::().ok()?)) + }) + && !file.is_empty() + { + return Some(( + RelatedLoc { + file: file.to_string(), + line, + col, + }, + s.get(pos + 2..)?.to_string(), + )); + } + from = pos + 2; + } +} + +/// Find a diagnostic's `(section, pos, len)` span in its file section. +fn diag_span(b: &PrettyBaseline, diag_index: usize) -> Option<(&Section, usize, usize)> { + let file = b.base.diags.get(diag_index)?.file.as_deref()?; + let sec = b.base.sections.iter().find(|s| s.name == file)?; + let sd = sec.diags.iter().find(|sd| sd.diag_index == diag_index)?; + Some((sec, sd.pos_abs, sd.len)) +} + +/// Resolve a related location + UTF-16 length to `(section, byte_pos, byte_len)` +/// in the related file's source. +fn related_span<'a>( + b: &'a PrettyBaseline, + file: &str, + line: u32, + col: u32, + len_utf16: u32, +) -> Option<(&'a Section, usize, usize)> { + let sec = b.base.sections.iter().find(|s| s.name == file)?; + let starts = lf_line_starts(&sec.src_lines); + let line_idx = (line as usize).checked_sub(1)?; + let src_line = sec.src_lines.get(line_idx)?; + let start_in_line = col_to_byte(src_line, col); + let pos_abs = starts.get(line_idx)? + start_in_line; + let byte_len = advance_utf16(src_line, start_in_line, len_utf16); + Some((sec, pos_abs, byte_len)) +} + +// =========================================================================== +// UTF-16 + small utilities (the pretty path's rune-distinct measurements) +// =========================================================================== + +/// Extract `(line, col)` from a numbered [`Loc`]; `None` for masked/absent. +fn numbered_loc(loc: Option) -> Option<(u32, u32)> { + match loc { + Some(Loc::Numbered { line, col }) => Some((line, col)), + _ => None, + } +} + +/// UTF-16 code-unit length of `s`. +fn utf16_len(s: &str) -> u32 { + s.chars().map(|c| c.len_utf16() as u32).sum() +} + +/// Line index and UTF-16 column of the LF-content byte offset `pos` over +/// `src_lines` (mirrors `scanner.GetECMALineAndUTF16CharacterOfPosition`). +fn line_and_utf16col(src_lines: &[String], starts: &[usize], pos: usize) -> (usize, u32) { + let line = match starts.binary_search(&pos) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + }; + let line_start = *starts.get(line).unwrap_or(&0); + let in_line = pos.saturating_sub(line_start); + let src = src_lines.get(line).map_or("", String::as_str); + (line, utf16_len(src.get(..in_line.min(src.len())).unwrap_or(""))) +} + +/// Advance `units` UTF-16 code units from `start_byte` in `line`, returning the +/// number of bytes covered. +fn advance_utf16(line: &str, start_byte: usize, units: u32) -> usize { + let Some(slice) = line.get(start_byte..) else { + return 0; + }; + let mut seen = 0u32; + for (byte_idx, ch) in slice.char_indices() { + if seen >= units { + return byte_idx; + } + seen += ch.len_utf16() as u32; + } + slice.len() +} + +/// Decimal digit count of `n` (`len(strconv.Itoa(n))` for `n >= 0`). +fn digits(n: usize) -> usize { + n.checked_ilog10().map_or(1, |l| l as usize + 1) +} + +/// Trailing-whitespace trim matching Go's `strings.TrimRightFunc(unicode.IsSpace)`. +fn trim_end_ws(s: &str) -> &str { + s.trim_end_matches(char::is_whitespace) +} + +/// Push `n` copies of `ch`. +fn push_repeat(out: &mut String, ch: char, n: u32) { + for _ in 0..n { + out.push(ch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tsc_conformance::baseline::SectionDiag; + use crate::tsc_conformance::render::self_assertion_violations; + + // --- parser-unit coverage --- + + #[test] + fn ansi_strip_removes_sgr_sequences() { + assert_eq!( + ansi_strip("\u{1b}[96mfile.ts\u{1b}[0m:\u{1b}[93m3\u{1b}[0m"), + "file.ts:3" + ); + assert_eq!(ansi_strip("plain"), "plain"); + } + + #[test] + fn pretty_head_positional_and_global() { + let pos = parse_pretty_head("\u{1b}[96ma.ts\u{1b}[0m:\u{1b}[93m3\u{1b}[0m:\u{1b}[93m8\u{1b}[0m - \u{1b}[91merror\u{1b}[0m\u{1b}[90m TS2345: \u{1b}[0mArgument bad.").expect("positional"); + assert_eq!(pos.file.as_deref(), Some("a.ts")); + assert_eq!(pos.loc, Some(Loc::Numbered { line: 3, col: 8 })); + assert_eq!(pos.category, "error"); + assert_eq!(pos.code, 2345); + assert_eq!(pos.first_msg, "Argument bad."); + + let global = + parse_pretty_head("\u{1b}[91merror\u{1b}[0m\u{1b}[90m TS-1: \u{1b}[0mPre-emit mismatch!") + .expect("global"); + assert!(global.file.is_none() && global.loc.is_none()); + assert_eq!(global.code, -1); + assert_eq!(global.first_msg, "Pre-emit mismatch!"); + } + + #[test] + fn related_verbatim_file_and_fileless() { + let (loc, msg) = parse_related_verbatim("TS6203 file2.ts:1:6: 'Foo' was also declared here."); + let loc = loc.expect("file-bearing"); + assert_eq!((loc.file.as_str(), loc.line, loc.col), ("file2.ts", 1, 6)); + assert_eq!(msg, "'Foo' was also declared here."); + + let (loc2, msg2) = parse_related_verbatim("TS-1: The excess diagnostics are:"); + assert!(loc2.is_none()); + assert_eq!(msg2, "The excess diagnostics are:"); + } + + #[test] + fn gutter_and_tilde_classification() { + // Content gutter (digit) vs squiggle gutter (blank). + assert!(!gutter_is_blank("\u{1b}[7m3\u{1b}[0m const x;")); + assert!(gutter_is_blank("\u{1b}[7m \u{1b}[0m \u{1b}[91m ~~~\u{1b}[0m")); + assert_eq!(count_tildes(" \u{1b}[7m \u{1b}[0m \u{1b}[96m ~~~\u{1b}[0m"), 3); + } + + // --- standalone renderer coverage (model built by hand, not via the parser) --- + // + // Mirrors render.rs's plain standalone tests: assemble a `PrettyBaseline` + // directly — the path a future tsv checker takes — so the colored renderer's + // byte contract is pinned independently of the parser. + + /// Build an `error`-category diagnostic. + fn diag(file: Option<&str>, loc: Option, code: i32, msgs: &[&str], related: &[&str]) -> Diag { + Diag { + file: file.map(str::to_string), + loc, + category: "error".to_string(), + code, + msg_lines: msgs.iter().map(|s| (*s).to_string()).collect(), + related: related.iter().map(|s| (*s).to_string()).collect(), + } + } + + fn section(name: &str, src: &[&str], spans: &[(usize, usize, usize)]) -> Section { + Section { + name: name.to_string(), + src_lines: src.iter().map(|s| (*s).to_string()).collect(), + diags: spans + .iter() + .map(|&(diag_index, pos_abs, len)| SectionDiag { + diag_index, + pos_abs, + len, + }) + .collect(), + } + } + + #[test] + fn standalone_render_positional_single_diag() { + // `const x = 1;` — the `x` is a length-1 span at byte 6 (UTF-16 col 7). + let b = PrettyBaseline { + base: ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 7 }), + 2304, + &["Cannot find name 'x'."], + &[], + )], + sections: vec![section("a.ts", &["const x = 1;"], &[(0, 6, 1)])], + }, + related_lens: vec![vec![]], + }; + let expected = format!( + concat!( + // colored top block + "{c}a.ts{r}:{y}1{r}:{y}7{r} - {red}error{r}{g} TS2304: {r}Cannot find name 'x'.\r\n", + "\r\n", + "{gs}1{r} const x = 1;\r\n", + "{gs} {r} {red} ~{r}\r\n", + // + harnessNewLine + harnessNewLine + "\r\n\r\n", + // plain middle + "==== a.ts (1 errors) ====\r\n", + " const x = 1;\r\n", + " ~\r\n", + "!!! error TS2304: Cannot find name 'x'.", + // summary trailer + "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", + ), + c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + ); + assert_eq!(render_pretty(&b).expect("render"), expected); + assert!(self_assertion_violations(&b.base).is_empty()); + } + + #[test] + fn standalone_render_related_and_message_chain() { + // A two-line message plus one file-bearing related (its own file section). + let b = PrettyBaseline { + base: ParsedBaseline { + diags: vec![diag( + Some("index.ts"), + Some(Loc::Numbered { line: 1, col: 8 }), + 2345, + &["Argument bad.", " Provides no match."], + &["!!! related TS7038 index.ts:1:1: Type originates here."], + )], + sections: vec![section("index.ts", &["invoke(foo);"], &[(0, 7, 3)])], + }, + // The related span `invoke` is 6 UTF-16 units at index.ts:1:1. + related_lens: vec![vec![6]], + }; + let expected = format!( + concat!( + "{c}index.ts{r}:{y}1{r}:{y}8{r} - {red}error{r}{g} TS2345: {r}Argument bad.\r\n", + " Provides no match.\r\n", + "\r\n", + "{gs}1{r} invoke(foo);\r\n", + "{gs} {r} {red} ~~~{r}\r\n", + "\r\n", + " {c}index.ts{r}:{y}1{r}:{y}1{r} - Type originates here.\r\n", + " {gs}1{r} invoke(foo);\r\n", + " {gs} {r} {c}~~~~~~{r}\r\n", + "\r\n\r\n", + "==== index.ts (1 errors) ====\r\n", + " invoke(foo);\r\n", + " ~~~\r\n", + "!!! error TS2345: Argument bad.\r\n", + "!!! error TS2345: Provides no match.\r\n", + "!!! related TS7038 index.ts:1:1: Type originates here.", + "\r\nFound 1 error in index.ts{g}:1{r}\r\n\r\n", + ), + c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + ); + assert_eq!(render_pretty(&b).expect("render"), expected); + assert!(self_assertion_violations(&b.base).is_empty()); + } + + #[test] + fn standalone_render_global_diag_and_tabular_summary() { + // A global (fileless) error, then a file error in each of two files — + // exercises the global head (no location/snippet) and the tabular trailer. + let b = PrettyBaseline { + base: ParsedBaseline { + diags: vec![ + diag(None, None, -1, &["Count mismatch!"], &[]), + diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2304, + &["Cannot find name 'x'."], + &[], + ), + diag( + Some("b.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2304, + &["Cannot find name 'y'."], + &[], + ), + ], + sections: vec![ + section("a.ts", &["x;"], &[(1, 0, 1)]), + section("b.ts", &["y;"], &[(2, 0, 1)]), + ], + }, + related_lens: vec![vec![], vec![], vec![]], + }; + let expected = format!( + concat!( + // global head — no location, no code frame, no related, so it + // ends at the message; only the inter-diagnostic separator + // newline follows (no blank line, unlike a diag with a snippet). + "{red}error{r}{g} TS-1: {r}Count mismatch!\r\n", + // a.ts positional diag + "{c}a.ts{r}:{y}1{r}:{y}1{r} - {red}error{r}{g} TS2304: {r}Cannot find name 'x'.\r\n", + "\r\n", + "{gs}1{r} x;\r\n", + "{gs} {r} {red}~{r}\r\n", + "\r\n", + // b.ts positional diag + "{c}b.ts{r}:{y}1{r}:{y}1{r} - {red}error{r}{g} TS2304: {r}Cannot find name 'y'.\r\n", + "\r\n", + "{gs}1{r} y;\r\n", + "{gs} {r} {red}~{r}\r\n", + "\r\n\r\n", + // plain middle — global re-render then sections + "!!! error TS-1: Count mismatch!\r\n", + "==== a.ts (1 errors) ====\r\n", + " x;\r\n", + " ~\r\n", + "!!! error TS2304: Cannot find name 'x'.\r\n", + "==== b.ts (1 errors) ====\r\n", + " y;\r\n", + " ~\r\n", + "!!! error TS2304: Cannot find name 'y'.", + // summary — "in N files" + tabular + "\r\nFound 3 errors in 2 files.\r\n\r\n", + "Errors Files\r\n", + " 1 a.ts{g}:1{r}\r\n", + " 1 b.ts{g}:1{r}\r\n", + "\r\n", + ), + c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + ); + assert_eq!(render_pretty(&b).expect("render"), expected); + assert!(self_assertion_violations(&b.base).is_empty()); + } + + #[test] + fn standalone_render_multiline_snippet() { + // A 3-line span exercises the multi-line code frame (first line squiggles + // from the column to end-of-content, interior fully, last to its column) + // — a path no in-corpus pretty baseline reaches, so pinned here. + let src = ["foo(", " bar,", ")"]; + // span covers `foo(\n bar,\n)` = bytes 0..12 (LF-joined: 4 + 1 + 6 + 1 + ...). + // "foo(" = 4, +LF = 5; " bar," = 6 → 11, +LF = 12; ")" at 12. len to end of ")" = 13-0 = 13. + let b = PrettyBaseline { + base: ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2554, + &["Expected 0 arguments."], + &[], + )], + sections: vec![section("a.ts", &src, &[(0, 0, 13)])], + }, + related_lens: vec![vec![]], + }; + let expected = format!( + concat!( + "{c}a.ts{r}:{y}1{r}:{y}1{r} - {red}error{r}{g} TS2554: {r}Expected 0 arguments.\r\n", + "\r\n", + "{gs}1{r} foo(\r\n", + "{gs} {r} {red}~~~~{r}\r\n", + "{gs}2{r} bar,\r\n", + "{gs} {r} {red}~~~~~~{r}\r\n", + "{gs}3{r} )\r\n", + "{gs} {r} {red}~{r}\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " foo(\r\n", + " ~~~~\r\n", + " bar,\r\n", + " ~~~~~~\r\n", + " )\r\n", + " ~\r\n", + "!!! error TS2554: Expected 0 arguments.", + "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", + ), + c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + ); + assert_eq!(render_pretty(&b).expect("render"), expected); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/render.rs b/crates/tsv_debug/src/tsc_conformance/render.rs index 119c25cb5..51279f9cf 100644 --- a/crates/tsv_debug/src/tsc_conformance/render.rs +++ b/crates/tsv_debug/src/tsc_conformance/render.rs @@ -141,8 +141,8 @@ pub(crate) fn is_ts_config_file(path: &str) -> bool { } /// The hard-coded harness newline (`error_baseline.go:24`), the one const the -/// three literal CRLF sites collapse to. -const CRLF: &str = "\r\n"; +/// three literal CRLF sites collapse to. Shared with the pretty renderer. +pub(crate) const CRLF: &str = "\r\n"; /// Emit `CRLF` before every element except the first, mirroring tsgo's stateful /// `newLine()` closure (shared across the global block and every file section, @@ -180,8 +180,9 @@ fn emit_bang_block(out: &mut String, first: &mut bool, d: &Diag) { } } -/// Append `TS`-code digits (handles the negative harness code `TS-1`). -fn push_code(out: &mut String, code: i32) { +/// Append `TS`-code digits (handles the negative harness code `TS-1`). Shared +/// with the pretty renderer. +pub(crate) fn push_code(out: &mut String, code: i32) { use std::fmt::Write as _; let _ = write!(out, "{code}"); } @@ -228,19 +229,28 @@ pub fn render_baseline(b: &ParsedBaseline) -> String { out.push_str(CRLF); out.push_str(CRLF); - // --- 2 & 3: the stateful-newLine region --- + render_middle(&mut out, &b.diags, &b.sections); + + out +} + +/// Render the stateful-`newLine` region: the global (fileless) diagnostics' +/// `!!!` re-render, then each `==== ` file section in input order. Shared by the +/// plain [`render_baseline`] (after its summary block) and the pretty renderer +/// (after its colored top block) — both produce a byte-identical middle. +pub(crate) fn render_middle(out: &mut String, diags: &[Diag], sections: &[Section]) { let mut first = true; // Global (fileless) diagnostics re-render, in summary order. - for d in &b.diags { + for d in diags { if d.file.is_none() { - emit_bang_block(&mut out, &mut first, d); + emit_bang_block(out, &mut first, d); } } // File sections, in input order. - for sec in &b.sections { - push_nl(&mut out, &mut first); + for sec in sections { + push_nl(out, &mut first); out.push_str("==== "); out.push_str(&sec.name); out.push_str(" ("); @@ -249,10 +259,8 @@ pub fn render_baseline(b: &ParsedBaseline) -> String { let _ = write!(out, "{}", sec.diags.len()); } out.push_str(" errors) ===="); - render_section(&mut out, &mut first, sec, &b.diags); + render_section(out, &mut first, sec, diags); } - - out } /// Render one file section: each source line (4-space-indented) followed by any diff --git a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs index bb71af7a9..06bb3e0da 100644 --- a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs +++ b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs @@ -7,16 +7,19 @@ //! salient format feature (the P0 work-list); the pass count is a two-sided //! regression pin. //! -//! Scope (measured vs pin `168e7015`): the ANSI-colored `pretty=true` baselines are -//! a separate renderer path (not the ported `pretty=false` rune path), so they are -//! **carved out of the denominator** — excluded from `files_checked`, counted under -//! `pretty_carved_out`, and pinned so the set can't drift silently. In-scope -//! round-trip is 100%; the carve-out is surfaced explicitly, not hidden by loosening -//! the comparison. (Related-info diagnostics that carry their own message chain — -//! once a residual — are now recovered by [`super::baseline`]'s continuation capture.) +//! Scope (measured vs pin `168e7015`): every baseline is in scope. The plain +//! `pretty=false` baselines go through [`super::baseline`] + [`super::render`]; +//! the ANSI-colored `pretty=true` baselines go through [`super::pretty`] (its own +//! model, parser, and colored renderer). The count that took the pretty path is +//! tracked (`pretty_path`) and pinned so the set can't drift silently, but both +//! paths fold into the single `byte_identical` denominator — the in-scope +//! round-trip is 100% across all baselines. (Related-info diagnostics that carry +//! their own message chain — once a residual — are recovered by +//! [`super::baseline`]'s continuation capture.) use super::baseline::parse_baseline; use super::discovery::Baseline; +use super::pretty::{parse_pretty, render_pretty}; use super::render::{render_baseline, self_assertion_violations}; use std::collections::BTreeMap; @@ -57,22 +60,22 @@ pub struct BucketCount { /// The round-trip report. #[derive(Debug, serde::Serialize)] pub struct RoundtripReport { - /// All `.errors.txt` baselines discovered (the in-scope set plus the carve-out). + /// All `.errors.txt` baselines discovered — the full in-scope set. pub files_discovered: usize, - /// ANSI-colored `pretty=true` baselines carved out of scope: a separate - /// renderer path (not the ported rune path), excluded from the denominator - /// rather than counted as failures. Pinned, so the set can't drift silently. - pub pretty_carved_out: usize, - /// In-scope baselines checked (`files_discovered - pretty_carved_out`). + /// Baselines that took the ANSI-colored `pretty=true` path + /// ([`super::pretty`]) rather than the plain path. In scope and folded into + /// `byte_identical`; pinned, so the set can't drift silently. + pub pretty_path: usize, + /// In-scope baselines checked (all of them: `files_discovered`). pub files_checked: usize, - /// In-scope baselines that round-tripped byte-identically. + /// Baselines that round-tripped byte-identically (plain + pretty paths). pub byte_identical: usize, /// `byte_identical / files_checked * 100` (in-scope pass rate). pub pass_rate: f64, /// Baselines whose parsed model tripped a ported self-assertion (should be 0). pub self_assertion_violations: usize, - /// In-scope failure buckets, sorted by descending count (pretty is carved out - /// upstream, so it never appears here). + /// Failure buckets, sorted by descending count (a pretty failure buckets + /// under `pretty`). pub buckets: Vec, /// Every failing baseline's path (sorted); only shown in `--verbose`. #[serde(skip_serializing_if = "Vec::is_empty")] @@ -82,7 +85,7 @@ pub struct RoundtripReport { /// Parse → render → byte-compare every baseline, folding results into a report. pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { let mut byte_identical = 0usize; - let mut pretty_carved_out = 0usize; + let mut pretty_path = 0usize; let mut assertion_violations = 0usize; let mut bucket_map: BTreeMap<&'static str, Vec> = BTreeMap::new(); let mut failing_paths: Vec = Vec::new(); @@ -107,25 +110,37 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { } }; - // Carve out the ANSI-colored pretty=true baselines: a separate renderer - // path (out of the ported rune-path scope), excluded from the denominator - // as a deliberate scope bound — not a failure. The count is pinned, so the - // pretty set can't grow or shrink without a conscious re-pin. - if is_pretty(&content) { - pretty_carved_out += 1; - continue; + // The ANSI-colored pretty=true baselines take their own model + colored + // renderer; they stay in the denominator and are counted here so the set + // can't drift without a conscious re-pin. + let (parse_ok, rendered, assertion_hit) = if is_pretty(&content) { + pretty_path += 1; + match parse_pretty(&content) { + Ok(pb) => { + let hit = !self_assertion_violations(&pb.base).is_empty(); + (true, render_pretty(&pb), hit) + } + Err(reason) => (false, Err(reason), false), + } + } else { + match parse_baseline(&content) { + Ok(parsed) => { + let hit = !self_assertion_violations(&parsed).is_empty(); + (true, Ok(render_baseline(&parsed)), hit) + } + Err(reason) => (false, Err(reason), false), + } + }; + + if assertion_hit { + assertion_violations += 1; } - match parse_baseline(&content) { - Ok(parsed) => { - if !self_assertion_violations(&parsed).is_empty() { - assertion_violations += 1; - } - let rendered = render_baseline(&parsed); - if rendered == content { - byte_identical += 1; - } else { - let (line, expected, got) = first_diff(&content, &rendered); + if parse_ok { + match rendered { + Ok(r) if r == content => byte_identical += 1, + Ok(r) => { + let (line, expected, got) = first_diff(&content, &r); record( &mut bucket_map, &mut failing_paths, @@ -139,26 +154,39 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { }, ); } + Err(reason) => { + record( + &mut bucket_map, + &mut failing_paths, + categorize(&content), + FailExample { + path: baseline.relative_path.clone(), + reason: format!("render error: {reason}"), + first_diff_line: None, + expected: None, + got: None, + }, + ); + } } - Err(reason) => { - record( - &mut bucket_map, - &mut failing_paths, - categorize(&content), - FailExample { - path: baseline.relative_path.clone(), - reason, - first_diff_line: None, - expected: None, - got: None, - }, - ); - } + } else if let Err(reason) = rendered { + record( + &mut bucket_map, + &mut failing_paths, + categorize(&content), + FailExample { + path: baseline.relative_path.clone(), + reason, + first_diff_line: None, + expected: None, + got: None, + }, + ); } } let files_discovered = baselines.len(); - let files_checked = files_discovered - pretty_carved_out; + let files_checked = files_discovered; let pass_rate = pct(byte_identical, files_checked); let mut buckets: Vec = bucket_map @@ -178,7 +206,7 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { RoundtripReport { files_discovered, - pretty_carved_out, + pretty_path, files_checked, byte_identical, pass_rate, @@ -244,8 +272,7 @@ fn truncate(s: &str) -> String { /// proof of its cause — and doubles as the P0 work-list. fn categorize(content: &str) -> &'static str { if is_pretty(content) { - // Defensive: pretty baselines are carved out before they reach here, so - // this only fires if a pretty baseline ever slipped through as a failure. + // A pretty-path baseline that failed its own model/parser/renderer. "pretty" } else if content.contains("!!! related") { "related_info" @@ -296,8 +323,8 @@ impl RoundtripReport { println!("========================================"); println!("Files discovered: {}", self.files_discovered); println!( - "Pretty carved out: {} (ANSI pretty=true — separate renderer path, out of scope)", - self.pretty_carved_out + "Pretty path: {} (ANSI pretty=true — colored model/parser/renderer)", + self.pretty_path ); println!("In-scope checked: {}", self.files_checked); println!("Byte-identical: {}", self.byte_identical); From 70c6f50f88e123e31047293c2b5a306404ecddb5 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 11:10:14 -0400 Subject: [PATCH 11/79] =?UTF-8?q?feat:=20tsc=5Fconformance=20index=20?= =?UTF-8?q?=E2=80=94=20corpus/directives/variants=20substrate,=203=20gates?= =?UTF-8?q?=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 24 +- crates/tsv_debug/CLAUDE.md | 1 + .../src/cli/commands/tsc_conformance.rs | 200 +++++- .../tsv_debug/src/tsc_conformance/corpus.rs | 204 ++++++ .../src/tsc_conformance/directives.rs | 350 ++++++++++ crates/tsv_debug/src/tsc_conformance/index.rs | 478 +++++++++++++ crates/tsv_debug/src/tsc_conformance/mod.rs | 12 + .../src/tsc_conformance/options_meta.rs | 628 ++++++++++++++++++ .../src/tsc_conformance/roundtrip.rs | 2 +- .../tsv_debug/src/tsc_conformance/variants.rs | 281 ++++++++ 10 files changed, 2172 insertions(+), 8 deletions(-) create mode 100644 crates/tsv_debug/src/tsc_conformance/corpus.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/directives.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/index.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/options_meta.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/variants.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9c3e62f4e..c2d61bbec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -789,11 +789,13 @@ See ./docs/conformance_test262.md. Pure-Rust harness over tsgo's committed `.errors.txt` error baselines (in `../typescript-go`, oracle pin `168e7015`, read from the checked-in -`testdata/baselines/reference/submodule` — **not** the large, often-unmaterialized -`_submodules/TypeScript` corpus, so it runs on a bare checkout). No Deno, **zero -checker code**. Distinct from the parser-conformance surfaces: `conformance:ts-repo` -grades tsv's *parser* against the tsc corpus, whereas this reads tsgo's *checker* -error output — the seam a future tsv checker will emit through. +`testdata/baselines/reference/submodule`). No Deno, **zero checker code**. +`query`/`roundtrip` need only the baselines, so they run on a bare checkout; +`index` additionally requires the materialized `_submodules/TypeScript` corpus +(`git submodule update --init`). Distinct from the parser-conformance surfaces: +`conformance:ts-repo` grades tsv's *parser* against the tsc corpus, whereas this +reads tsgo's *checker* error output — the seam a future tsv checker will emit +through. ```bash # query - aggregations over every baseline's summary block @@ -815,6 +817,18 @@ cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by pat # enforces three exact pins — total baselines, byte-identical count, and the # pretty-path count — plus a 100% invariant, failing on any drift; a re-pin is # deliberate (a code change or a tsgo pull). + +# index - the corpus-INPUT side: indexes tests/cases/{compiler,conformance}, +# parses directives, splits @filename units, expands each test's varyBy variants +# (ported option-metadata table; `*`/exclusion syntax; cap 25), applies the +# Removed-in-TS7 + static skip lists, and proves three gates against the on-disk +# baselines: the baseline join (every baseline ↔ exactly one derived non-skipped +# variant), the unit-text round-trip (units reproduce the ==== section bodies; +# pretty carved out), and the exact denominator pins. Zero checker code; requires +# the materialized corpus submodule (unlike query/roundtrip). The INDEX_* pins +# move only on a deliberate re-pin (a tsgo pull or a harness-logic change). +cargo run -p tsv_debug tsc_conformance index # full run (enforces all pins) +# Options: --path, --json. ``` `roundtrip` is a **leg of `deno task conformance`** (the pre-release aggregate) and diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index a5aa418c2..c404f81f7 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 / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. - `fixtures/` — The fixture workflow surface — `model.rs` holds the data model (`InputType`, `Fixture`, divergence-suffix rules), `discovery.rs` walks the fixtures tree, `variants.rs` discovers variant files, `mod.rs` keeps IO/parse helpers, `validation/` is the validator (`fixtures_validate`: structure rules, per-phase checks, errors, summary), `audit_signature.rs` pins prettier's multi-pass chain. - `test262/` — ECMAScript conformance runner: `discovery.rs` walks the test262 tree, `frontmatter.rs` parses the YAML harness metadata, `runner.rs` drives our parser (`run_test`) and grades the strict subset into the differential `Manifest` (`grade_for_manifest`, sharing the `classify` skip logic with `run_test`). Pure Rust — no Deno needed. The `test262 --emit-manifest` JSON feeds `benches/js/diagnostics/test262_compare.ts` for the tsv-vs-oxc-parser comparison. +- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates. Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. - `diff.rs` — Colored text / JSON diffing with line-width annotations (used by `compare`, `ast_diff`, validation failures). - `error.rs` — `DebugError` — wraps `DenoError`, `io::Error`, `serde_json::Error`; exposes `hint()` for actionable messages. - `cli/` — argh `TopLevel`/`Subcommand` dispatch in `mod.rs` + per-command `FromArgs` modules under `cli/commands/`. Shared three-mode input plumbing (file path / `--content` / `--stdin`) comes from `tsv_cli::cli::input::InputArgs`. diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 06ddf0e56..87251c81f 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -5,9 +5,10 @@ //! submodule that is often unmaterialized. use crate::cli::CliError; +use crate::tsc_conformance::index::IndexReport; use crate::tsc_conformance::{ - baselines_dir, corpus_materialized, denominators, discover_baselines, histogram, run_roundtrip, - tests_by_code, + baselines_dir, corpus_materialized, denominators, discover_baselines, histogram, run_index, + run_roundtrip, tests_by_code, }; use argh::FromArgs; use std::path::PathBuf; @@ -31,6 +32,37 @@ const ROUNDTRIP_PASS_PIN: usize = 7033; /// typescript-go pull. const PRETTY_PATH_PIN: usize = 14; +/// REGRESSION PINS (exact, two-sided) for the `index` corpus-input self-checks. +/// Measured 2026-07-10, ../typescript-go at 168e7015 (`_submodules/TypeScript` +/// corpus materialized). Every move is a deliberate re-pin (a harness-port change, +/// or a typescript-go pull). The corpus files: +const INDEX_TOTAL_SCANNED_PIN: usize = 12445; +const INDEX_TS_PIN: usize = 12114; +const INDEX_TSX_PIN: usize = 330; +const INDEX_JS_PIN: usize = 1; +/// Static test-level skips (`skippedTests`) and per-directory sizing. +const INDEX_SKIPPED_TESTS_PIN: usize = 45; +const INDEX_SINGLE_FILE_PIN: usize = 10388; +const INDEX_MULTI_FILE_PIN: usize = 2012; +/// Selection-predicate denominators. +const INDEX_JSX_SCOPED_PIN: usize = 379; +const INDEX_JS_FLAVORED_PIN: usize = 934; +const INDEX_PRETTY_TESTS_PIN: usize = 14; +const INDEX_BASENAME_COLLISIONS_PIN: usize = 0; +const INDEX_CAP_EXCEEDED_PIN: usize = 0; +/// Variant sizing: total variants, the variant-level (unsupported-option) skips, +/// the non-skipped variants, and the expect-clean count. +const INDEX_VARIANT_TOTAL_PIN: usize = 14916; +const INDEX_SKIPPED_VARIANTS_PIN: usize = 2068; +const INDEX_NONSKIP_VARIANTS_PIN: usize = 12848; +const INDEX_EXPECT_CLEAN_PIN: usize = 5815; +/// Gate 1 (baseline join): every on-disk baseline matches one non-skipped variant. +const INDEX_JOIN_MATCHED_PIN: usize = 7033; +/// Gate 2 (unit-text round-trip): non-pretty baselined tests whose units reproduce +/// their section bodies, and the pretty baselines carved out. +const INDEX_UNIT_ROUNDTRIP_PIN: usize = 7019; +const INDEX_UNIT_ROUNDTRIP_PRETTY_PIN: usize = 14; + /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "tsc_conformance")] @@ -44,6 +76,7 @@ pub struct TscConformanceCommand { enum TscConformanceSub { Query(QueryCommand), Roundtrip(RoundtripCommand), + Index(IndexCommand), } /// Answer an ad-hoc question over the baselines. @@ -93,15 +126,178 @@ pub struct RoundtripCommand { filters: Vec, } +/// Corpus-input self-check (the S1 gates): index the tsc corpus, expand every +/// test's varyBy variants, and prove three invariants against the on-disk +/// baselines — the join (every baseline maps to one non-skipped variant), the +/// unit-text round-trip (units reproduce the `====` section bodies), and the +/// denominator pins. Zero checker code. Exit 0 only when all three pass and the +/// pins hold (two-sided); filters are not offered — the pins need the full run. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "index")] +pub struct IndexCommand { + /// path to the typescript-go checkout (default: ../typescript-go) + #[argh(option, default = "PathBuf::from(\"../typescript-go\")")] + path: PathBuf, + + /// emit a JSON report instead of the human summary + #[argh(switch)] + json: bool, + + /// list every unmatched baseline, mismatch, and unknown directive + #[argh(switch)] + verbose: bool, +} + impl TscConformanceCommand { pub(crate) fn run(self) -> Result<(), CliError> { match self.nested { TscConformanceSub::Query(query) => query.run(), TscConformanceSub::Roundtrip(rt) => rt.run(), + TscConformanceSub::Index(index) => index.run(), } } } +impl IndexCommand { + fn run(self) -> Result<(), CliError> { + // The corpus inputs must be materialized (unlike the baseline-only query + // and roundtrip tools). + if !corpus_materialized(&self.path) { + eprintln!( + "Error: the tsc corpus inputs are not materialized under {}.", + self.path.display() + ); + eprintln!("Run `git submodule update --init` in ../typescript-go to materialize them."); + return Err(CliError::Failed); + } + let report = run_index(&self.path).map_err(|e| { + eprintln!("Error indexing corpus: {e}"); + CliError::Failed + })?; + + if self.json { + print_json(&report)?; + } else { + report.print(self.verbose); + } + + enforce_index_pins(&report) + } +} + +/// Enforce the `index` gates and denominator pins (all two-sided). Any failure +/// prints the offending checks and exits non-zero. +fn enforce_index_pins(report: &IndexReport) -> Result<(), CliError> { + let mut errs: Vec = Vec::new(); + let pin = |errs: &mut Vec, label: &str, got: usize, want: usize| { + if got != want { + errs.push(format!("{label} {got} != pinned {want}")); + } + }; + + // Denominators (gate 3). + pin(&mut errs, "total scanned", report.total_scanned, INDEX_TOTAL_SCANNED_PIN); + pin(&mut errs, ".ts count", report.ts_count, INDEX_TS_PIN); + pin(&mut errs, ".tsx count", report.tsx_count, INDEX_TSX_PIN); + pin(&mut errs, ".js count", report.js_count, INDEX_JS_PIN); + pin(&mut errs, "skipped tests", report.skipped_tests, INDEX_SKIPPED_TESTS_PIN); + pin(&mut errs, "single-file", report.single_file, INDEX_SINGLE_FILE_PIN); + pin(&mut errs, "multi-file", report.multi_file, INDEX_MULTI_FILE_PIN); + pin(&mut errs, "jsx-scoped", report.jsx_scoped, INDEX_JSX_SCOPED_PIN); + pin(&mut errs, "js-flavored", report.js_flavored, INDEX_JS_FLAVORED_PIN); + pin(&mut errs, "pretty tests", report.pretty_tests, INDEX_PRETTY_TESTS_PIN); + pin( + &mut errs, + "basename collisions", + report.basename_collisions, + INDEX_BASENAME_COLLISIONS_PIN, + ); + pin(&mut errs, "cap-exceeded", report.cap_exceeded, INDEX_CAP_EXCEEDED_PIN); + pin(&mut errs, "variant total", report.variant_total, INDEX_VARIANT_TOTAL_PIN); + pin( + &mut errs, + "skipped variants", + report.skipped_variants, + INDEX_SKIPPED_VARIANTS_PIN, + ); + pin( + &mut errs, + "non-skipped variants", + report.nonskip_variants, + INDEX_NONSKIP_VARIANTS_PIN, + ); + pin(&mut errs, "expect-clean", report.expect_clean, INDEX_EXPECT_CLEAN_PIN); + + // Gate 1: baseline join. + pin(&mut errs, "baselines total", report.baselines_total, INDEX_JOIN_MATCHED_PIN); + pin(&mut errs, "join matched", report.join_matched, INDEX_JOIN_MATCHED_PIN); + if !report.join_unmatched.is_empty() { + errs.push(format!( + "{} unmatched baseline(s), e.g. {}", + report.join_unmatched.len(), + report.join_unmatched.first().map_or("", String::as_str) + )); + } + if !report.join_skipped_with_baseline.is_empty() { + errs.push(format!( + "{} baseline(s) map only to skipped variants, e.g. {}", + report.join_skipped_with_baseline.len(), + report.join_skipped_with_baseline.first().map_or("", String::as_str) + )); + } + if !report.join_ambiguous.is_empty() { + errs.push(format!( + "{} ambiguous baseline(s), e.g. {}", + report.join_ambiguous.len(), + report.join_ambiguous.first().map_or("", String::as_str) + )); + } + + // Gate 2: unit-text round-trip. + pin( + &mut errs, + "unit round-trip checked", + report.unit_roundtrip_checked, + INDEX_UNIT_ROUNDTRIP_PIN, + ); + pin( + &mut errs, + "unit round-trip pretty", + report.unit_roundtrip_pretty_skipped, + INDEX_UNIT_ROUNDTRIP_PRETTY_PIN, + ); + if !report.unit_roundtrip_mismatches.is_empty() { + errs.push(format!( + "{} unit round-trip mismatch(es), e.g. {}", + report.unit_roundtrip_mismatches.len(), + report + .unit_roundtrip_mismatches + .first() + .map_or(String::new(), |m| m.baseline.clone()) + )); + } + + // Directive universe. + if !report.unknown_directives.is_empty() { + errs.push(format!( + "{} unknown directive(s): {}", + report.unknown_directives.len(), + report.unknown_directives.join(", ") + )); + } + + if errs.is_empty() { + Ok(()) + } else { + eprintln!( + "\nError: {}. If deliberate (a harness-port change, or a typescript-go pull), \ + re-pin the INDEX_* constants.", + errs.join("; ") + ); + Err(CliError::Failed) + } +} + impl RoundtripCommand { fn run(self) -> Result<(), CliError> { let baselines = load_baselines(&self.path, "roundtrip")?; diff --git a/crates/tsv_debug/src/tsc_conformance/corpus.rs b/crates/tsv_debug/src/tsc_conformance/corpus.rs new file mode 100644 index 000000000..4ae1af848 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/corpus.rs @@ -0,0 +1,204 @@ +//! Index the tsc corpus inputs under `tests/cases/{compiler,conformance}`. +//! +//! The corpus lives in the (now-materialized) TypeScript submodule; a test's +//! identity is its path. Baselines are flat per suite, so the baseline↔test join +//! is by basename within a suite — basename collisions across nesting are counted +//! and reported, never silently merged. +// +// tsgo: internal/testrunner/compiler_runner.go NewCompilerBaselineRunner (basePath) +// tsgo: internal/vfs/internal/internal.go decodeBytes (BOM/UTF-16 handling) + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The corpus input tree, relative to a typescript-go checkout. +const CORPUS_SUBDIR: &str = "_submodules/TypeScript/tests/cases"; + +/// The two in-scope suites (`compiler` = regression, `conformance`), each a flat +/// baseline namespace. +pub const SUITES: [&str; 2] = ["compiler", "conformance"]; + +/// One discovered corpus test file. +#[derive(Debug, Clone)] +pub struct CorpusTest { + /// Suite (`compiler` or `conformance`) — the baseline namespace. + pub suite: &'static str, + /// Path relative to the suite root, `/`-separated. + pub relative_path: String, + /// The file's basename (e.g. `foo.ts`) — the baseline-join key. + pub basename: String, + /// The lowercased extension without the dot (`ts` / `tsx` / `js`). + pub extension: String, + /// Absolute path on disk. + pub path: PathBuf, +} + +/// The corpus directory inside a typescript-go checkout. +pub fn corpus_dir(checkout: &Path) -> PathBuf { + checkout.join(CORPUS_SUBDIR) +} + +/// Read a corpus file, decoding a BOM exactly as tsgo's `decodeBytes` does: +/// UTF-16 LE/BE (`FF FE` / `FE FF`) are decoded and the BOM dropped; a UTF-8 BOM +/// (`EF BB BF`) is stripped; everything else is treated as UTF-8. Invalid +/// sequences become the replacement character (the harness never rejects on +/// encoding). +pub fn read_corpus_file(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; + Ok(decode_bytes(&bytes)) +} + +/// Decode file bytes per tsgo's `decodeBytes`. +fn decode_bytes(bytes: &[u8]) -> String { + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + return decode_utf16(&bytes[2..], false); + } + if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { + return decode_utf16(&bytes[2..], true); + } + let body = if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF { + &bytes[3..] + } else { + bytes + }; + String::from_utf8_lossy(body).into_owned() +} + +/// Decode a UTF-16 byte stream (`big_endian` selects the order), an odd trailing +/// byte dropped as tsgo's `binary.Read` would leave it out of the `uint16` slice. +fn decode_utf16(bytes: &[u8], big_endian: bool) -> String { + let units: Vec = bytes + .chunks_exact(2) + .map(|c| { + if big_endian { + u16::from_be_bytes([c[0], c[1]]) + } else { + u16::from_le_bytes([c[0], c[1]]) + } + }) + .collect(); + String::from_utf16_lossy(&units) +} + +/// Walk both suites and index every `.ts` / `.tsx` / `.js` corpus file, sorted by +/// `(suite, relative_path)`. Safety-net directories are not present in the corpus, +/// so no pruning is needed. +pub fn discover_corpus(checkout: &Path) -> Result, String> { + let base = corpus_dir(checkout); + if !base.exists() { + return Err(format!("corpus directory not found: {}", base.display())); + } + let mut out = Vec::new(); + for suite in SUITES { + let root = base.join(suite); + if root.exists() { + walk(&root, &root, suite, &mut out)?; + } + } + out.sort_by(|a, b| (a.suite, &a.relative_path).cmp(&(b.suite, &b.relative_path))); + Ok(out) +} + +fn walk( + dir: &Path, + root: &Path, + suite: &'static str, + out: &mut Vec, +) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|e| format!("read dir {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("read entry: {e}"))?; + let path = entry.path(); + if path.is_dir() { + walk(&path, root, suite, out)?; + } else if path.is_file() { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let ext = match name.rsplit_once('.') { + Some((_, e)) => e.to_ascii_lowercase(), + None => continue, + }; + if ext != "ts" && ext != "tsx" && ext != "js" { + continue; + } + let relative_path = path + .strip_prefix(root) + .map_or_else( + |_| path.to_string_lossy().into_owned(), + |p| p.to_string_lossy().into_owned(), + ) + .replace('\\', "/"); + out.push(CorpusTest { + suite, + relative_path, + basename: name.to_string(), + extension: ext, + path, + }); + } + } + Ok(()) +} + +/// A basename shared by more than one corpus test in the same suite — a join +/// ambiguity (the baseline join is by `(suite, basename)`). +#[derive(Debug, Clone, serde::Serialize)] +pub struct BasenameCollision { + /// The suite the collision is in. + pub suite: String, + /// The shared basename. + pub basename: String, + /// The colliding tests' relative paths. + pub paths: Vec, +} + +/// Find every `(suite, basename)` shared by more than one corpus test. +pub fn basename_collisions(tests: &[CorpusTest]) -> Vec { + let mut by_key: BTreeMap<(&str, &str), Vec> = BTreeMap::new(); + for t in tests { + by_key + .entry((t.suite, t.basename.as_str())) + .or_default() + .push(t.relative_path.clone()); + } + by_key + .into_iter() + .filter(|(_, v)| v.len() > 1) + .map(|((suite, basename), paths)| BasenameCollision { + suite: suite.to_string(), + basename: basename.to_string(), + paths, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_utf8_bom() { + let bytes = [0xEF, 0xBB, 0xBF, b'/', b'/', b'@']; + assert_eq!(decode_bytes(&bytes), "//@"); + } + + #[test] + fn decodes_utf16_le() { + // "//" in UTF-16 LE with BOM. + let bytes = [0xFF, 0xFE, b'/', 0x00, b'/', 0x00]; + assert_eq!(decode_bytes(&bytes), "//"); + } + + #[test] + fn decodes_utf16_be() { + let bytes = [0xFE, 0xFF, 0x00, b'/', 0x00, b'/']; + assert_eq!(decode_bytes(&bytes), "//"); + } + + #[test] + fn plain_utf8_passthrough() { + assert_eq!(decode_bytes(b"// @target: es5"), "// @target: es5"); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/directives.rs b/crates/tsv_debug/src/tsc_conformance/directives.rs new file mode 100644 index 000000000..5305441fb --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/directives.rs @@ -0,0 +1,350 @@ +//! Parse a corpus test's `// @key: value` directives and split it into file +//! units, faithful to tsgo's `test_case_parser.go`. +//! +//! Two products: the flat **settings** map (`extractCompilerSettings` — every +//! directive anywhere in the file, keys lowercased, last write wins) that drives +//! variant expansion; and the ordered **units** (`makeUnitsFromTest` / +//! `ParseTestFilesAndSymlinks` in non-implicit mode) that `@filename` splits. +//! +//! The directive grammar is tsgo's `optionRegex` +//! (`(?m)^//\s*@(\w+)\s*:\s*([^\r\n]*)`), reproduced without a regex engine: a +//! match anchors at a physical line start (after `\n`, never a lone `\r`), the +//! value runs to the next `\r`/`\n`. This hand port is byte-for-byte equivalent to +//! the regex over the pinned corpus. +// +// tsgo: internal/testrunner/test_case_parser.go optionRegex / extractCompilerSettings +// tsgo: internal/testrunner/test_case_parser.go ParseTestFilesAndSymlinksWithOptions +// tsgo: internal/testrunner/compiler_runner.go newCompilerTest (root-vs-otherFiles rule) + +use crate::tsc_conformance::options_meta::is_config_file_name; +use std::collections::BTreeMap; + +/// One file unit split out of a test (`@filename` boundaries), or the whole file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Unit { + /// The unit's declared name (from `@filename`, or the test basename). + pub name: String, + /// The unit's source text — physical lines joined with `\n`, leading blank + /// lines dropped (tsgo's `Len() != 0` accumulation). + pub content: String, +} + +/// Split `content` into physical lines on `\r?\n` (tsgo's `lineDelimiter`). The +/// `\r` before a `\n` is part of the separator; a lone `\r` is not — so a +/// CR-only file is a single line. +fn split_lines(content: &str) -> Vec<&str> { + let bytes = content.as_bytes(); + let mut lines = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\n' { + let mut end = i; + if end > start && bytes[end - 1] == b'\r' { + end -= 1; + } + lines.push(&content[start..end]); + start = i + 1; + } + i += 1; + } + lines.push(&content[start..]); + lines +} + +/// Skip a run of horizontal whitespace (space, tab, form-feed) — the harness's +/// intra-line `\s*`. `\r`/`\n` never appear here in the corpus, so restricting to +/// horizontal whitespace is exact. +fn skip_hspace(s: &str) -> &str { + s.trim_start_matches([' ', '\t', '\u{0c}']) +} + +/// Parse a physical line as a directive, returning `(lowercased key, raw value)` +/// where the value runs from after `:` to the next `\r`. `None` if the line is +/// not a directive. +fn parse_directive(seg: &str) -> Option<(String, &str)> { + let rest = skip_hspace(seg.strip_prefix("//")?); + let rest = rest.strip_prefix('@')?; + let key_end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + if key_end == 0 { + return None; + } + let key = &rest[..key_end]; + let rest = skip_hspace(&rest[key_end..]); + let rest = rest.strip_prefix(':')?; + let rest = skip_hspace(rest); + let value = rest.split_once('\r').map_or(rest, |(v, _)| v); + Some((key.to_ascii_lowercase(), value)) +} + +/// Extract the flat compiler-settings map (`extractCompilerSettings`): every +/// directive in the file, keys lowercased, value `TrimSpace` then one trailing +/// `;` stripped, last write winning. +#[must_use] +pub fn extract_settings(content: &str) -> BTreeMap { + let mut settings = BTreeMap::new(); + for seg in split_lines(content) { + if let Some((key, value)) = parse_directive(seg) { + let trimmed = value.trim(); + let cleaned = trimmed.strip_suffix(';').unwrap_or(trimmed); + settings.insert(key, cleaned.to_string()); + } + } + settings +} + +/// The base file name (final `/`-separated component). +fn base_file_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_string() +} + +/// Split a compiler test into file units (`makeUnitsFromTest` in non-implicit +/// mode). `@filename` starts a new unit; every other directive line is consumed +/// (not content); content accumulates with `\n`, leading blanks dropped. A test +/// with no `@filename` yields one unit named after the test file. +/// +/// `test_filename` is the corpus file's path (or basename). +#[must_use] +pub fn split_units(content: &str, test_filename: &str) -> Vec { + let mut units: Vec = Vec::new(); + let mut cur_name = String::new(); + let mut cur = String::new(); + + for seg in split_lines(content) { + if let Some((key, value)) = parse_directive(seg) { + if key != "filename" { + // currentDirectory / symlink / link / global options are all + // consumed here — never part of a unit's content. + continue; + } + let name = value.trim().to_string(); + if cur_name.is_empty() { + // First `@filename`: any accumulated (comment-only) content is + // discarded, matching the harness's Reset. + cur.clear(); + } else { + units.push(Unit { + name: cur_name.clone(), + content: std::mem::take(&mut cur), + }); + } + cur_name = name; + } else { + if !cur.is_empty() { + cur.push('\n'); + } + cur.push_str(seg); + } + } + + if units.is_empty() && cur_name.is_empty() { + cur_name = base_file_name(test_filename); + } + units.push(Unit { + name: cur_name, + content: cur, + }); + units +} + +/// A test's units classified into baseline-section order +/// (`Concatenate(tsConfigFiles, toBeCompiled, otherFiles)`). +#[derive(Debug, Clone)] +pub struct Classified { + /// The recognized tsconfig/jsconfig unit, if any (emitted first). + pub tsconfig: Option, + /// Units compiled directly. + pub to_be_compiled: Vec, + /// Other files brought in by reference. + pub other_files: Vec, + /// Whether a tsconfig unit was present (its `FileNames` glob resolution is out + /// of scope, so `to_be_compiled`/`other_files` split is not authoritative in + /// that case — all non-config units land in `to_be_compiled`). + pub tsconfig_unresolved: bool, +} + +impl Classified { + /// The units in baseline-section order. + #[must_use] + pub fn section_order(&self) -> Vec<&Unit> { + self.tsconfig + .iter() + .chain(self.to_be_compiled.iter()) + .chain(self.other_files.iter()) + .collect() + } +} + +/// Whether `content` contains a triple-slash reference (`reference` + one +/// whitespace + `path`), tsgo's `referencesRegex`. +fn contains_reference_path(content: &str) -> bool { + let bytes = content.as_bytes(); + let mut from = 0; + while let Some(pos) = content[from..].find("reference") { + let after = from + pos + "reference".len(); + if let Some(&c) = bytes.get(after) + && (c as char).is_ascii_whitespace() + && content[after + 1..].starts_with("path") + { + return true; + } + from += pos + 1; + } + false +} + +/// Classify a test's units into baseline-section order, applying the last-unit +/// `require(` / triple-slash-reference heuristic (`newCompilerTest`). A tsconfig +/// unit is pulled out first; its `FileNames` resolution is out of scope, so with a +/// tsconfig every remaining unit is reported as `to_be_compiled`. +/// +/// `settings` supplies `noImplicitReferences` (its presence forces the last-unit +/// split). +#[must_use] +pub fn classify_units(units: Vec, settings: &BTreeMap) -> Classified { + // Pull out the first tsconfig/jsconfig unit. + let mut tsconfig = None; + let mut rest: Vec = Vec::with_capacity(units.len()); + for unit in units { + if tsconfig.is_none() && is_config_file_name(&unit.name) { + tsconfig = Some(unit); + } else { + rest.push(unit); + } + } + + if tsconfig.is_some() { + return Classified { + tsconfig, + to_be_compiled: rest, + other_files: Vec::new(), + tsconfig_unresolved: true, + }; + } + + let force_last = settings + .get("noimplicitreferences") + .is_some_and(|v| !v.is_empty()) + || rest + .last() + .is_some_and(|u| u.content.contains("require(") || contains_reference_path(&u.content)); + + if force_last && rest.len() > 1 { + let last = rest.pop().unwrap_or_else(|| Unit { + name: String::new(), + content: String::new(), + }); + Classified { + tsconfig: None, + to_be_compiled: vec![last], + other_files: rest, + tsconfig_unresolved: false, + } + } else { + Classified { + tsconfig: None, + to_be_compiled: rest, + other_files: Vec::new(), + tsconfig_unresolved: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_lines_crlf_lf_and_cr() { + assert_eq!(split_lines("a\r\nb\nc"), vec!["a", "b", "c"]); + assert_eq!(split_lines("a\nb\n"), vec!["a", "b", ""]); + // A lone CR is not a separator. + assert_eq!(split_lines("a\rb"), vec!["a\rb"]); + } + + #[test] + fn directive_parsing() { + assert_eq!( + parse_directive("// @target: es5"), + Some(("target".to_string(), "es5")) + ); + assert_eq!( + parse_directive("//@Module: CommonJS "), + Some(("module".to_string(), "CommonJS ")) + ); + assert_eq!(parse_directive("const x = 1;"), None); + assert_eq!(parse_directive("// not a directive"), None); + // Value stops at a CR inside a CR-only line. + assert_eq!( + parse_directive("//@target: es6\r// rest"), + Some(("target".to_string(), "es6")) + ); + } + + #[test] + fn settings_last_wins_and_trims() { + // Last write wins; TrimSpace then one trailing `;` stripped. + let s = extract_settings("// @target: es5\n// @target: es2015;\ncode;"); + assert_eq!(s.get("target").map(String::as_str), Some("es2015")); + } + + #[test] + fn settings_strip_is_trim_then_semicolon_only() { + // Faithful to tsgo: TrimSuffix(TrimSpace(value), ";") does NOT re-trim, so + // a space before the `;` survives (harmless — variant splitting re-trims). + let s = extract_settings("// @target: es2015 ;\n"); + assert_eq!(s.get("target").map(String::as_str), Some("es2015 ")); + } + + #[test] + fn single_file_names_after_test() { + let units = split_units("const x = 1;", "foo.ts"); + assert_eq!(units.len(), 1); + assert_eq!(units[0].name, "foo.ts"); + assert_eq!(units[0].content, "const x = 1;"); + } + + #[test] + fn multi_file_split_and_leading_blank_drop() { + // A leading blank line before the first content line is dropped. + let src = "// @filename: a.ts\n\nlet a = 1;\n// @filename: b.ts\nlet b = 2;"; + let units = split_units(src, "test.ts"); + assert_eq!(units.len(), 2); + assert_eq!(units[0].name, "a.ts"); + assert_eq!(units[0].content, "let a = 1;"); + assert_eq!(units[1].name, "b.ts"); + assert_eq!(units[1].content, "let b = 2;"); + } + + #[test] + fn classify_last_unit_on_reference() { + let src = "// @filename: a.ts\nlet a = 1;\n// @filename: b.ts\n/// \nlet b = 2;"; + let units = split_units(src, "test.ts"); + let c = classify_units(units, &BTreeMap::new()); + assert!(c.tsconfig.is_none()); + assert_eq!(c.to_be_compiled.len(), 1); + assert_eq!(c.to_be_compiled[0].name, "b.ts"); + assert_eq!(c.other_files.len(), 1); + } + + #[test] + fn classify_all_when_no_reference() { + let src = "// @filename: a.ts\nlet a = 1;\n// @filename: b.ts\nlet b = 2;"; + let units = split_units(src, "test.ts"); + let c = classify_units(units, &BTreeMap::new()); + assert_eq!(c.to_be_compiled.len(), 2); + assert!(c.other_files.is_empty()); + } + + #[test] + fn classify_pulls_tsconfig_first() { + let src = "// @filename: tsconfig.json\n{}\n// @filename: a.ts\nlet a = 1;"; + let units = split_units(src, "test.ts"); + let c = classify_units(units, &BTreeMap::new()); + assert!(c.tsconfig.is_some()); + assert!(c.tsconfig_unresolved); + assert_eq!(c.section_order().len(), 2); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/index.rs b/crates/tsv_debug/src/tsc_conformance/index.rs new file mode 100644 index 000000000..13333e3d0 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/index.rs @@ -0,0 +1,478 @@ +//! The corpus-input self-check runner: index the tsc corpus, expand every test's +//! variants, and prove three invariants against the on-disk baselines. +//! +//! Zero checker code — this only relates the corpus inputs to the committed +//! baselines: +//! +//! 1. **Baseline join** — every on-disk baseline maps to exactly one non-skipped +//! derived (test, variant); a baseline mapping to only skipped variants, to +//! none, or ambiguously to several is a failure. +//! 2. **Unit-text round-trip** — for each baselined non-pretty test, the units the +//! directive parser splits out reproduce (as a multiset) the baseline's `====` +//! section bodies. +//! 3. **Denominators** — the sizing counts (scanned, per-extension, multi-file, +//! JSX, JS-flavored, pretty, skip classes, variants, expect-clean) are the +//! CLI's exact pins. +//! +//! The whole run also enforces that every `// @` directive in a non-skipped test +//! is recognized — an unknown directive is a hard harness failure and here a gate +//! failure (it means the ported option universe is incomplete). + +use crate::tsc_conformance::corpus::{ + basename_collisions, discover_corpus, read_corpus_file, BasenameCollision, CorpusTest, +}; +use crate::tsc_conformance::directives::{classify_units, extract_settings, split_units}; +use crate::tsc_conformance::discovery::{discover_baselines, Baseline}; +use crate::tsc_conformance::options_meta::{ + is_known_directive, strict_members, variant_is_unsupported, DEFAULT_USE_CASE_SENSITIVE_FILE_NAMES, + HARNESS_FORCED_DEFAULTS, SKIPPED_TESTS, +}; +use crate::tsc_conformance::roundtrip::is_pretty; +use crate::tsc_conformance::variants::{config_name, expand}; +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +/// One derived (test, variant) that produces a given baseline name. +struct Derived { + /// Index into the per-test records. + test_idx: usize, + /// Whether the variant is skipped by the unsupported-option classes. + skipped: bool, +} + +/// Per-test record retained for the unit round-trip gate: the section-ordered +/// unit bodies (each split into physical lines) that a baseline's `====` sections +/// must reproduce. +struct TestRecord { + relative_path: String, + unit_count: usize, + unit_line_sets: Vec>, +} + +/// A unit round-trip mismatch (a test whose split units don't reproduce its +/// baseline's section bodies). +#[derive(Debug, Clone, serde::Serialize)] +pub struct UnitMismatch { + /// The baseline's `suite/name.errors.txt` path. + pub baseline: String, + /// The corpus test's relative path. + pub test: String, + /// A short reason. + pub reason: String, +} + +/// The `index` report: denominators plus the three gates' results. +#[derive(Debug, Clone, serde::Serialize)] +pub struct IndexReport { + // --- denominators --- + /// Total corpus files scanned. + pub total_scanned: usize, + /// `.ts` files. + pub ts_count: usize, + /// `.tsx` files. + pub tsx_count: usize, + /// `.js` files. + pub js_count: usize, + /// Tests skipped by the static 45-entry list (test-level). + pub skipped_tests: usize, + /// Non-skipped tests with more than one unit. + pub multi_file: usize, + /// Non-skipped tests with a single unit. + pub single_file: usize, + /// Non-skipped tests that are JSX-scoped (`.tsx` / `@jsx` / under a `jsx/` + /// path). + pub jsx_scoped: usize, + /// Non-skipped tests that are JS-flavored (`@checkJs` / `@allowJs` / `.js`). + pub js_flavored: usize, + /// Non-skipped tests carrying `@pretty`. + pub pretty_tests: usize, + /// Total variants across non-skipped tests (including variant-level skips). + pub variant_total: usize, + /// Variants skipped by the unsupported-option classes (variant-level). + pub skipped_variants: usize, + /// Non-skipped variants. + pub nonskip_variants: usize, + /// Non-skipped variants with no on-disk baseline (expect-clean). + pub expect_clean: usize, + /// `(suite, basename)` collisions across corpus nesting. + pub basename_collisions: usize, + /// The collisions themselves (for the report). + pub collisions: Vec, + /// Tests whose variant product exceeded the cap (a harness failure). + pub cap_exceeded: usize, + /// Tests carrying a tsconfig/jsconfig unit (whose `FileNames` glob resolution + /// is out of scope; their section split is by multiset, not order). + pub tests_with_tsconfig: usize, + + // --- gate 1: baseline join --- + /// Total on-disk baselines. + pub baselines_total: usize, + /// Baselines matched to exactly one non-skipped derived variant. + pub join_matched: usize, + /// On-disk baselines with no derived variant. + pub join_unmatched: Vec, + /// On-disk baselines mapping only to skipped variants. + pub join_skipped_with_baseline: Vec, + /// On-disk baselines mapping ambiguously to several non-skipped variants. + pub join_ambiguous: Vec, + + // --- gate 2: unit round-trip --- + /// Non-pretty baselined tests whose units were checked. + pub unit_roundtrip_checked: usize, + /// Pretty baselines skipped by gate 2. + pub unit_roundtrip_pretty_skipped: usize, + /// Unit round-trip mismatches. + pub unit_roundtrip_mismatches: Vec, + + // --- directive universe --- + /// Unknown `// @` directives in non-skipped tests (must be empty). + pub unknown_directives: Vec, + + // --- options-model substrate (reported, not gated) --- + /// Number of harness-forced compiler defaults (distinct from compiler + /// defaults). + pub harness_forced_defaults: usize, + /// Number of `strict`-family members (options that inherit `strict`). + pub strict_family: usize, + /// The harness default for case-sensitive file names. + pub case_sensitive_default: bool, +} + +/// Split an on-disk baseline's `suite/name.errors.txt` relative path into +/// `(suite, name)`. +fn split_baseline_key(relative_path: &str) -> Option<(&str, &str)> { + relative_path.split_once('/') +} + +/// Run the corpus-input index over a typescript-go checkout. +pub fn run_index(checkout: &Path) -> Result { + let corpus = discover_corpus(checkout)?; + let baselines = discover_baselines(&crate::tsc_conformance::discovery::baselines_dir(checkout))?; + let collisions = basename_collisions(&corpus); + + let mut report = IndexReport { + total_scanned: corpus.len(), + ts_count: 0, + tsx_count: 0, + js_count: 0, + skipped_tests: 0, + multi_file: 0, + single_file: 0, + jsx_scoped: 0, + js_flavored: 0, + pretty_tests: 0, + variant_total: 0, + skipped_variants: 0, + nonskip_variants: 0, + expect_clean: 0, + basename_collisions: collisions.len(), + collisions, + cap_exceeded: 0, + tests_with_tsconfig: 0, + baselines_total: baselines.len(), + join_matched: 0, + join_unmatched: Vec::new(), + join_skipped_with_baseline: Vec::new(), + join_ambiguous: Vec::new(), + unit_roundtrip_checked: 0, + unit_roundtrip_pretty_skipped: 0, + unit_roundtrip_mismatches: Vec::new(), + unknown_directives: Vec::new(), + harness_forced_defaults: HARNESS_FORCED_DEFAULTS.len(), + strict_family: strict_members().len(), + case_sensitive_default: DEFAULT_USE_CASE_SENSITIVE_FILE_NAMES, + }; + + let mut derived: HashMap<(&'static str, String), Vec> = HashMap::new(); + let mut tests: Vec = Vec::new(); + let mut unknown: BTreeMap = BTreeMap::new(); + + for test in &corpus { + match test.extension.as_str() { + "ts" => report.ts_count += 1, + "tsx" => report.tsx_count += 1, + "js" => report.js_count += 1, + _ => {} + } + if SKIPPED_TESTS.contains(&test.basename.as_str()) { + report.skipped_tests += 1; + continue; + } + + let content = read_corpus_file(&test.path)?; + let settings = extract_settings(&content); + + for key in settings.keys() { + if !is_known_directive(key) { + unknown.entry(key.clone()).or_insert_with(|| test.relative_path.clone()); + } + } + + let units = split_units(&content, &test.basename); + let unit_count = units.len(); + if unit_count > 1 { + report.multi_file += 1; + } else { + report.single_file += 1; + } + if is_jsx_scoped(test, &settings) { + report.jsx_scoped += 1; + } + if is_js_flavored(test, &settings) { + report.js_flavored += 1; + } + if settings.contains_key("pretty") { + report.pretty_tests += 1; + } + + let expansion = expand(&settings); + if expansion.cap_exceeded { + report.cap_exceeded += 1; + continue; + } + + // Order the units per the baseline `====` section order + // (`Concatenate(tsConfigFiles, toBeCompiled, otherFiles)`), then reduce to + // their line bodies for the round-trip multiset. + let classified = classify_units(units, &settings); + if classified.tsconfig_unresolved { + report.tests_with_tsconfig += 1; + } + let unit_line_sets: Vec> = classified + .section_order() + .iter() + .map(|u| split_content_lines(&u.content)) + .collect(); + + let test_idx = tests.len(); + for variant in &expansion.variants { + report.variant_total += 1; + let skipped = variant_is_unsupported(&variant.config); + if skipped { + report.skipped_variants += 1; + } else { + report.nonskip_variants += 1; + } + let name = config_name(&test.basename, &variant.description); + derived + .entry((test.suite, name)) + .or_default() + .push(Derived { test_idx, skipped }); + } + tests.push(TestRecord { + relative_path: test.relative_path.clone(), + unit_count, + unit_line_sets, + }); + } + + report.unknown_directives = unknown.into_keys().collect(); + + // --- Gate 1: baseline join --- + let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); + for baseline in &baselines { + if let Some((suite, name)) = split_baseline_key(&baseline.relative_path) { + ondisk.insert((suite, name.to_string()), baseline); + } + } + + for ((suite, name), baseline) in &ondisk { + let key = (*suite, name.clone()); + match derived.get(&key) { + None => report.join_unmatched.push(baseline.relative_path.clone()), + Some(entries) => { + let nonskip: Vec<&Derived> = entries.iter().filter(|d| !d.skipped).collect(); + if nonskip.is_empty() { + report.join_skipped_with_baseline.push(baseline.relative_path.clone()); + } else if nonskip.len() > 1 { + report.join_ambiguous.push(baseline.relative_path.clone()); + } else { + report.join_matched += 1; + // --- Gate 2: unit round-trip (non-pretty only) --- + check_unit_roundtrip(baseline, &tests[nonskip[0].test_idx], &mut report); + } + } + } + } + + // Expect-clean: every non-skipped derived name with no on-disk baseline. + let mut seen_names: std::collections::HashSet<(&str, &str)> = std::collections::HashSet::new(); + for ((suite, name), entries) in &derived { + if entries.iter().all(|d| d.skipped) { + continue; + } + if !seen_names.insert((suite, name.as_str())) { + continue; + } + let has = ondisk.contains_key(&(*suite, name.clone())); + if !has { + report.expect_clean += 1; + } + } + + report.join_unmatched.sort(); + report.join_skipped_with_baseline.sort(); + report.join_ambiguous.sort(); + Ok(report) +} + +/// Compare a test's split units to its baseline's `====` section bodies as a +/// multiset. Pretty baselines are counted and skipped (their body layout is a +/// separate renderer path). +fn check_unit_roundtrip(baseline: &Baseline, record: &TestRecord, report: &mut IndexReport) { + let content = match std::fs::read_to_string(&baseline.path) { + Ok(c) => c, + Err(e) => { + report.unit_roundtrip_mismatches.push(UnitMismatch { + baseline: baseline.relative_path.clone(), + test: record.relative_path.clone(), + reason: format!("read error: {e}"), + }); + return; + } + }; + if is_pretty(&content) { + report.unit_roundtrip_pretty_skipped += 1; + return; + } + let parsed = match crate::tsc_conformance::baseline::parse_baseline(&content) { + Ok(p) => p, + Err(reason) => { + report.unit_roundtrip_mismatches.push(UnitMismatch { + baseline: baseline.relative_path.clone(), + test: record.relative_path.clone(), + reason: format!("baseline parse: {reason}"), + }); + return; + } + }; + + report.unit_roundtrip_checked += 1; + + let mut unit_lines: Vec> = record.unit_line_sets.clone(); + let mut section_lines: Vec> = + parsed.sections.iter().map(|s| s.src_lines.clone()).collect(); + unit_lines.sort(); + section_lines.sort(); + + if unit_lines != section_lines { + report.unit_roundtrip_mismatches.push(UnitMismatch { + baseline: baseline.relative_path.clone(), + test: record.relative_path.clone(), + reason: format!( + "unit/section body mismatch ({} units vs {} sections)", + record.unit_count, + parsed.sections.len() + ), + }); + } +} + +/// Split a unit's content into physical lines on `\r?\n` (the section-body +/// coordinate the baseline reprints). +fn split_content_lines(content: &str) -> Vec { + let bytes = content.as_bytes(); + let mut lines = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\n' { + let mut end = i; + if end > start && bytes[end - 1] == b'\r' { + end -= 1; + } + lines.push(content[start..end].to_string()); + start = i + 1; + } + i += 1; + } + lines.push(content[start..].to_string()); + lines +} + +/// Whether a test is JSX-scoped: a `.tsx` file, an `@jsx` directive, or a path +/// under a `jsx/` directory. +fn is_jsx_scoped(test: &CorpusTest, settings: &BTreeMap) -> bool { + test.extension == "tsx" + || settings.contains_key("jsx") + || test.relative_path.contains("/jsx/") + || test.relative_path.starts_with("jsx/") +} + +/// Whether a test is JS-flavored: `@checkJs` / `@allowJs`, or a `.js` file. +fn is_js_flavored(test: &CorpusTest, settings: &BTreeMap) -> bool { + settings.contains_key("checkjs") || settings.contains_key("allowjs") || test.extension == "js" +} + +impl IndexReport { + /// Print the human summary. + pub fn print(&self, verbose: bool) { + println!("tsc_conformance — corpus index"); + println!("=============================="); + println!("Total scanned: {}", self.total_scanned); + println!( + " .ts / .tsx / .js: {} / {} / {}", + self.ts_count, self.tsx_count, self.js_count + ); + println!("Skipped tests (45-list): {}", self.skipped_tests); + println!( + "Single-file / multi-file: {} / {}", + self.single_file, self.multi_file + ); + println!("JSX-scoped: {}", self.jsx_scoped); + println!("JS-flavored: {}", self.js_flavored); + println!("Pretty tests: {}", self.pretty_tests); + println!("Basename collisions: {}", self.basename_collisions); + println!("Cap-exceeded tests: {}", self.cap_exceeded); + println!("Tests with tsconfig: {}", self.tests_with_tsconfig); + println!(); + println!("Variants (non-skipped tests): {}", self.variant_total); + println!(" skipped (unsupported): {}", self.skipped_variants); + println!(" non-skipped: {}", self.nonskip_variants); + println!(" with baseline: {}", self.join_matched); + println!(" expect-clean: {}", self.expect_clean); + println!(); + println!("Gate 1 — baseline join"); + println!(" on-disk baselines: {}", self.baselines_total); + println!(" matched: {}", self.join_matched); + println!(" unmatched: {}", self.join_unmatched.len()); + println!( + " only-skipped w/ baseline: {}", + self.join_skipped_with_baseline.len() + ); + println!(" ambiguous: {}", self.join_ambiguous.len()); + println!(); + println!("Gate 2 — unit-text round-trip"); + println!(" checked (non-pretty): {}", self.unit_roundtrip_checked); + println!(" pretty skipped: {}", self.unit_roundtrip_pretty_skipped); + println!(" mismatches: {}", self.unit_roundtrip_mismatches.len()); + println!(); + println!("Unknown directives: {}", self.unknown_directives.len()); + println!(); + println!( + "Substrate: {} harness-forced defaults, {} strict-family members, case-sensitive={}", + self.harness_forced_defaults, self.strict_family, self.case_sensitive_default + ); + + if verbose { + for u in &self.join_unmatched { + println!(" UNMATCHED {u}"); + } + for u in &self.join_skipped_with_baseline { + println!(" SKIPPED-WITH-BASELINE {u}"); + } + for u in &self.join_ambiguous { + println!(" AMBIGUOUS {u}"); + } + for m in &self.unit_roundtrip_mismatches { + println!(" UNIT-MISMATCH {} ({}) — {}", m.baseline, m.test, m.reason); + } + for d in &self.unknown_directives { + println!(" UNKNOWN-DIRECTIVE {d}"); + } + for c in &self.collisions { + println!(" COLLISION {}/{} {:?}", c.suite, c.basename, c.paths); + } + } + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index f9f5a64f7..53d8535ad 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -12,14 +12,26 @@ //! [`pretty`] is its ANSI-colored `pretty=true` counterpart (model + parser + //! renderer); [`roundtrip`] parses → renders → byte-compares every baseline //! (the P0 self-check, `zero` checker code). +//! +//! The corpus-*input* side ([`corpus`], [`directives`], [`variants`], +//! [`options_meta`], [`index`]) ports the tsgo test harness: it indexes the +//! `tests/cases` inputs, parses their `// @` directives, expands their varyBy +//! variants, and joins the derived variants back to the on-disk baselines — the +//! substrate a future checker will drive, still zero checker code. pub mod baseline; +pub mod corpus; +pub mod directives; pub mod discovery; +pub mod index; +pub mod options_meta; pub mod pretty; pub mod query; pub mod render; pub mod roundtrip; +pub mod variants; pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; +pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; diff --git a/crates/tsv_debug/src/tsc_conformance/options_meta.rs b/crates/tsv_debug/src/tsc_conformance/options_meta.rs new file mode 100644 index 000000000..303c766de --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/options_meta.rs @@ -0,0 +1,628 @@ +//! Compiler-option metadata ported from tsgo, the substrate the corpus-input side +//! is built on. Two consumers: the **varyBy** derivation (which options a test may +//! fan out over) and the **known-directive universe** (which `// @key` directives +//! the harness recognizes). Both are read directly off the ported table so they +//! can't drift apart. +//! +//! The table is a faithful port of `tsoptions.OptionsDeclarations` +//! (`internal/tsoptions/declscompiler.go`: `commonOptionsWithBuild` + +//! `optionsForCompiler`) at the pinned tsgo commit — name, value kind, the +//! command-line-only flag, whether any of the eight `Affects*` flags is set +//! (collapsed to one bool, since varyBy only asks "any"), and the `strictFlag`. +//! The enum maps are ported from `internal/tsoptions/enummaps.go`; their canonical +//! value is the tsgo `core.*Kind` constant name, so aliases (`es6`/`es2015`) share +//! one identity for dedup. +// +// tsgo: internal/tsoptions/declscompiler.go OptionsDeclarations +// tsgo: internal/tsoptions/enummaps.go +// tsgo: internal/testutil/harnessutil/harnessutil.go compilerOptions/harnessCommandLineOptions + +use std::collections::BTreeMap; + +/// The value kind of a compiler option — the subset of tsgo's +/// `CommandLineOptionKind` the harness distinguishes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum OptionKind { + /// `boolean`. + Boolean, + /// `number`. + Number, + /// `string`. + Str, + /// `object` (e.g. `paths`). + Object, + /// `list`. + List, + /// `enum` (map-backed). + Enum, +} + +/// One ported compiler-option declaration (the fields the harness reads). +#[derive(Clone, Copy, Debug)] +pub struct OptionMeta { + /// The option's canonical (camelCase) name; directive lookup is + /// case-insensitive. + pub name: &'static str, + /// The value kind. + pub kind: OptionKind, + /// `IsCommandLineOnly` — command-line-only options never vary. + pub command_line_only: bool, + /// OR of the eight `Affects*` flags (varyBy only asks whether any is set). + pub affects: bool, + /// `strictFlag` — a member of the `strict` family (inherits `strict` when + /// unset). + pub strict_flag: bool, +} + +const fn om( + name: &'static str, + kind: OptionKind, + command_line_only: bool, + affects: bool, + strict_flag: bool, +) -> OptionMeta { + OptionMeta { + name, + kind, + command_line_only, + affects, + strict_flag, + } +} + +/// The ported `OptionsDeclarations` table (duplicate names collapsed to the +/// first, matching `getCommandLineOption`'s first-match lookup). +pub const OPTIONS: &[OptionMeta] = &[ + om("help", OptionKind::Boolean, true, false, false), + om("watch", OptionKind::Boolean, true, false, false), + om("preserveWatchOutput", OptionKind::Boolean, false, false, false), + om("listFiles", OptionKind::Boolean, false, false, false), + om("explainFiles", OptionKind::Boolean, false, false, false), + om("listEmittedFiles", OptionKind::Boolean, false, false, false), + om("pretty", OptionKind::Boolean, false, false, false), + om("traceResolution", OptionKind::Boolean, false, false, false), + om("diagnostics", OptionKind::Boolean, false, false, false), + om("extendedDiagnostics", OptionKind::Boolean, false, false, false), + om("generateCpuProfile", OptionKind::Str, false, false, false), + om("generateTrace", OptionKind::Str, false, false, false), + om("incremental", OptionKind::Boolean, false, false, false), + om("declaration", OptionKind::Boolean, false, true, false), + om("declarationMap", OptionKind::Boolean, false, true, false), + om("emitDeclarationOnly", OptionKind::Boolean, false, true, false), + om("sourceMap", OptionKind::Boolean, false, true, false), + om("inlineSourceMap", OptionKind::Boolean, false, true, false), + om("noCheck", OptionKind::Boolean, false, false, false), + om("deduplicatePackages", OptionKind::Boolean, false, true, false), + om("noEmit", OptionKind::Boolean, false, false, false), + om("assumeChangesOnlyAffectDirectDependencies", OptionKind::Boolean, false, true, false), + om("locale", OptionKind::Str, true, false, false), + om("quiet", OptionKind::Boolean, false, false, false), + om("singleThreaded", OptionKind::Boolean, false, false, false), + om("pprofDir", OptionKind::Str, false, false, false), + om("checkers", OptionKind::Number, false, false, false), + om("all", OptionKind::Boolean, false, false, false), + om("version", OptionKind::Boolean, false, false, false), + om("init", OptionKind::Boolean, false, false, false), + om("project", OptionKind::Str, false, false, false), + om("showConfig", OptionKind::Boolean, true, false, false), + om("listFilesOnly", OptionKind::Boolean, true, false, false), + om("ignoreConfig", OptionKind::Boolean, true, false, false), + om("target", OptionKind::Enum, false, true, false), + om("module", OptionKind::Enum, false, true, false), + om("lib", OptionKind::List, false, true, false), + om("allowJs", OptionKind::Boolean, false, true, false), + om("checkJs", OptionKind::Boolean, false, true, false), + om("jsx", OptionKind::Enum, false, true, false), + om("outFile", OptionKind::Str, false, true, false), + om("outDir", OptionKind::Str, false, true, false), + om("rootDir", OptionKind::Str, false, true, false), + om("composite", OptionKind::Boolean, false, true, false), + om("tsBuildInfoFile", OptionKind::Str, false, true, false), + om("removeComments", OptionKind::Boolean, false, true, false), + om("importHelpers", OptionKind::Boolean, false, true, false), + om("downlevelIteration", OptionKind::Boolean, false, true, false), + om("isolatedModules", OptionKind::Boolean, false, false, false), + om("verbatimModuleSyntax", OptionKind::Boolean, false, true, false), + om("isolatedDeclarations", OptionKind::Boolean, false, true, false), + om("erasableSyntaxOnly", OptionKind::Boolean, false, true, false), + om("libReplacement", OptionKind::Boolean, false, true, false), + om("strict", OptionKind::Boolean, false, true, false), + om("noImplicitAny", OptionKind::Boolean, false, true, true), + om("strictNullChecks", OptionKind::Boolean, false, true, true), + om("strictFunctionTypes", OptionKind::Boolean, false, true, true), + om("strictBindCallApply", OptionKind::Boolean, false, true, true), + om("strictPropertyInitialization", OptionKind::Boolean, false, true, true), + om("strictBuiltinIteratorReturn", OptionKind::Boolean, false, true, true), + om("noImplicitThis", OptionKind::Boolean, false, true, true), + om("useUnknownInCatchVariables", OptionKind::Boolean, false, true, true), + om("alwaysStrict", OptionKind::Boolean, false, true, false), + om("stableTypeOrdering", OptionKind::Boolean, false, true, false), + om("noUnusedLocals", OptionKind::Boolean, false, true, false), + om("noUnusedParameters", OptionKind::Boolean, false, true, false), + om("exactOptionalPropertyTypes", OptionKind::Boolean, false, true, false), + om("noImplicitReturns", OptionKind::Boolean, false, true, false), + om("noFallthroughCasesInSwitch", OptionKind::Boolean, false, true, false), + om("noUncheckedIndexedAccess", OptionKind::Boolean, false, true, false), + om("noImplicitOverride", OptionKind::Boolean, false, true, false), + om("noPropertyAccessFromIndexSignature", OptionKind::Boolean, false, true, false), + om("moduleResolution", OptionKind::Enum, false, true, false), + om("baseUrl", OptionKind::Str, false, true, false), + om("paths", OptionKind::Object, false, true, false), + om("rootDirs", OptionKind::List, false, true, false), + om("typeRoots", OptionKind::List, false, true, false), + om("types", OptionKind::List, false, true, false), + om("allowSyntheticDefaultImports", OptionKind::Boolean, false, true, false), + om("esModuleInterop", OptionKind::Boolean, false, true, false), + om("preserveSymlinks", OptionKind::Boolean, false, false, false), + om("allowUmdGlobalAccess", OptionKind::Boolean, false, true, false), + om("moduleSuffixes", OptionKind::List, false, true, false), + om("allowImportingTsExtensions", OptionKind::Boolean, false, true, false), + om("rewriteRelativeImportExtensions", OptionKind::Boolean, false, true, false), + om("resolvePackageJsonExports", OptionKind::Boolean, false, true, false), + om("resolvePackageJsonImports", OptionKind::Boolean, false, true, false), + om("customConditions", OptionKind::List, false, true, false), + om("noUncheckedSideEffectImports", OptionKind::Boolean, false, true, false), + om("sourceRoot", OptionKind::Str, false, true, false), + om("mapRoot", OptionKind::Str, false, true, false), + om("inlineSources", OptionKind::Boolean, false, true, false), + om("experimentalDecorators", OptionKind::Boolean, false, true, false), + om("emitDecoratorMetadata", OptionKind::Boolean, false, true, false), + om("jsxFactory", OptionKind::Str, false, false, false), + om("jsxFragmentFactory", OptionKind::Str, false, false, false), + om("jsxImportSource", OptionKind::Str, false, true, false), + om("resolveJsonModule", OptionKind::Boolean, false, true, false), + om("allowArbitraryExtensions", OptionKind::Boolean, false, true, false), + om("reactNamespace", OptionKind::Str, false, true, false), + om("skipDefaultLibCheck", OptionKind::Boolean, false, true, false), + om("emitBOM", OptionKind::Boolean, false, true, false), + om("newLine", OptionKind::Enum, false, true, false), + om("noErrorTruncation", OptionKind::Boolean, false, true, false), + om("noLib", OptionKind::Boolean, false, true, false), + om("noResolve", OptionKind::Boolean, false, true, false), + om("stripInternal", OptionKind::Boolean, false, true, false), + om("disableSizeLimit", OptionKind::Boolean, false, true, false), + om("disableSourceOfProjectReferenceRedirect", OptionKind::Boolean, false, false, false), + om("disableSolutionSearching", OptionKind::Boolean, false, false, false), + om("disableReferencedProjectLoad", OptionKind::Boolean, false, false, false), + om("noEmitHelpers", OptionKind::Boolean, false, true, false), + om("noEmitOnError", OptionKind::Boolean, false, true, false), + om("preserveConstEnums", OptionKind::Boolean, false, true, false), + om("declarationDir", OptionKind::Str, false, true, false), + om("skipLibCheck", OptionKind::Boolean, false, true, false), + om("allowUnusedLabels", OptionKind::Boolean, false, true, false), + om("allowUnreachableCode", OptionKind::Boolean, false, true, false), + om("forceConsistentCasingInFileNames", OptionKind::Boolean, false, true, false), + om("maxNodeModuleJsDepth", OptionKind::Number, false, true, false), + om("useDefineForClassFields", OptionKind::Boolean, false, true, false), + om("plugins", OptionKind::List, false, false, false), + om("moduleDetection", OptionKind::Enum, false, true, false), + om("ignoreDeprecations", OptionKind::Str, false, false, false), +]; + +/// One enum-option value mapping: the directive key and its canonical identity +/// (the tsgo `core.*Kind` constant name), so aliases collapse for dedup. +#[derive(Clone, Copy, Debug)] +pub struct EnumEntry { + /// The owning option's name. + pub option: &'static str, + /// The directive-writable value key (already lowercase). + pub key: &'static str, + /// The canonical identity shared by aliases. + pub canonical: &'static str, +} + +const fn ee(option: &'static str, key: &'static str, canonical: &'static str) -> EnumEntry { + EnumEntry { + option, + key, + canonical, + } +} + +/// The ported enum maps for every varyBy-eligible enum option. `lib` is omitted: +/// it is a list, never a variant. +pub const ENUM_ENTRIES: &[EnumEntry] = &[ + ee("target", "es5", "core.ScriptTargetES5"), + ee("target", "es6", "core.ScriptTargetES2015"), + ee("target", "es2015", "core.ScriptTargetES2015"), + ee("target", "es2016", "core.ScriptTargetES2016"), + ee("target", "es2017", "core.ScriptTargetES2017"), + ee("target", "es2018", "core.ScriptTargetES2018"), + ee("target", "es2019", "core.ScriptTargetES2019"), + ee("target", "es2020", "core.ScriptTargetES2020"), + ee("target", "es2021", "core.ScriptTargetES2021"), + ee("target", "es2022", "core.ScriptTargetES2022"), + ee("target", "es2023", "core.ScriptTargetES2023"), + ee("target", "es2024", "core.ScriptTargetES2024"), + ee("target", "es2025", "core.ScriptTargetES2025"), + ee("target", "esnext", "core.ScriptTargetESNext"), + ee("module", "commonjs", "core.ModuleKindCommonJS"), + ee("module", "amd", "core.ModuleKindAMD"), + ee("module", "system", "core.ModuleKindSystem"), + ee("module", "umd", "core.ModuleKindUMD"), + ee("module", "es6", "core.ModuleKindES2015"), + ee("module", "es2015", "core.ModuleKindES2015"), + ee("module", "es2020", "core.ModuleKindES2020"), + ee("module", "es2022", "core.ModuleKindES2022"), + ee("module", "esnext", "core.ModuleKindESNext"), + ee("module", "node16", "core.ModuleKindNode16"), + ee("module", "node18", "core.ModuleKindNode18"), + ee("module", "node20", "core.ModuleKindNode20"), + ee("module", "nodenext", "core.ModuleKindNodeNext"), + ee("module", "preserve", "core.ModuleKindPreserve"), + ee("moduleResolution", "node16", "core.ModuleResolutionKindNode16"), + ee("moduleResolution", "nodenext", "core.ModuleResolutionKindNodeNext"), + ee("moduleResolution", "bundler", "core.ModuleResolutionKindBundler"), + ee("moduleResolution", "classic", "core.ModuleResolutionKindClassic"), + ee("moduleResolution", "node", "core.ModuleResolutionKindNode10"), + ee("moduleResolution", "node10", "core.ModuleResolutionKindNode10"), + ee("jsx", "preserve", "core.JsxEmitPreserve"), + ee("jsx", "react-native", "core.JsxEmitReactNative"), + ee("jsx", "react-jsx", "core.JsxEmitReactJSX"), + ee("jsx", "react-jsxdev", "core.JsxEmitReactJSXDev"), + ee("jsx", "react", "core.JsxEmitReact"), + ee("moduleDetection", "auto", "core.ModuleDetectionKindAuto"), + ee("moduleDetection", "legacy", "core.ModuleDetectionKindLegacy"), + ee("moduleDetection", "force", "core.ModuleDetectionKindForce"), + ee("newLine", "crlf", "core.NewLineKindCRLF"), + ee("newLine", "lf", "core.NewLineKindLF"), +]; + +/// The four synthetic compiler options the harness appends to `OptionsDeclarations` +/// (`harnessutil.go`'s `compilerOptions`) — known directives, but not real +/// compiler flags. `noErrorTruncation` / `noCheck` also exist in the real table; +/// the duplicates are harmless for a membership set. +pub const SYNTHETIC_OPTIONS: &[&str] = &[ + "allowNonTsExtensions", + "noErrorTruncation", + "suppressOutputPathCheck", + "noCheck", +]; + +/// The thirteen harness-only command-line options (`harnessCommandLineOptions`). +/// These are recognized directives that configure the harness rather than the +/// compiler. +pub const HARNESS_OPTIONS: &[&str] = &[ + "useCaseSensitiveFileNames", + "baselineFile", + "includeBuiltFile", + "fileName", + "libFiles", + "noImplicitReferences", + "currentDirectory", + "symlink", + "link", + "noTypesAndSymbols", + "fullEmitPaths", + "reportDiagnostics", + "captureSuggestions", +]; + +/// The static test-level skip list (`compiler_runner.go` `skippedTests`): 45 tests +/// skipped by basename before directives are parsed (built-API dependence or +/// completely-removed options that fail to parse). They produce no baseline. +pub const SKIPPED_TESTS: &[&str] = &[ + "APILibCheck.ts", + "APISample_Watch.ts", + "APISample_WatchWithDefaults.ts", + "APISample_WatchWithOwnWatchHost.ts", + "APISample_compile.ts", + "APISample_jsdoc.ts", + "APISample_linter.ts", + "APISample_parseConfig.ts", + "APISample_transform.ts", + "APISample_watcher.ts", + "preserveUnusedImports.ts", + "noCrashWithVerbatimModuleSyntaxAndImportsNotUsedAsValues.ts", + "verbatimModuleSyntaxCompat.ts", + "verbatimModuleSyntaxCompat2.ts", + "verbatimModuleSyntaxCompat3.ts", + "verbatimModuleSyntaxCompat4.ts", + "preserveValueImports.ts", + "preserveValueImports_importsNotUsedAsValues.ts", + "preserveValueImports_errors.ts", + "preserveValueImports_mixedImports.ts", + "preserveValueImports_module.ts", + "importsNotUsedAsValues_error.ts", + "alwaysStrictNoImplicitUseStrict.ts", + "nonPrimitiveIndexingWithForInSupressError.ts", + "parameterInitializerBeforeDestructuringEmit.ts", + "mappedTypeUnionConstraintInferences.ts", + "lateBoundConstraintTypeChecksCorrectly.ts", + "keyofDoesntContainSymbols.ts", + "isolatedModulesOut.ts", + "noStrictGenericChecks.ts", + "noImplicitUseStrict_umd.ts", + "noImplicitUseStrict_system.ts", + "noImplicitUseStrict_es6.ts", + "noImplicitUseStrict_commonjs.ts", + "noImplicitUseStrict_amd.ts", + "noImplicitAnyIndexingSuppressed.ts", + "excessPropertyErrorsSuppressed.ts", + "moduleNoneDynamicImport.ts", + "moduleNoneErrors.ts", + "moduleNoneOutFile.ts", + "noErrorUsingImportExportModuleAugmentationInDeclarationFile1.ts", + "noErrorUsingImportExportModuleAugmentationInDeclarationFile2.ts", + "noErrorUsingImportExportModuleAugmentationInDeclarationFile3.ts", + "requireOfJsonFileWithModuleEmitNone.ts", + "requireOfJsonFileWithModuleNodeResolutionEmitNone.ts", +]; + +/// The extra directive the harness swallows without validation +/// (`SetOptionsFromTestConfig` special-cases `typescriptversion`). +pub const SWALLOWED_DIRECTIVE: &str = "typescriptversion"; + +/// tsgo's three-state boolean (`core.Tristate`): unset inherits, only an explicit +/// value is `False`/`True`. Modeled because the harness's skip check reads +/// `IsFalse()` (explicit false), not the inherited value. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Tristate { + /// No value provided — inherits (e.g. from `strict`). + Unset, + /// Explicit `false`. + False, + /// Explicit `true`. + True, +} + +/// The harness-forced compiler defaults (`CompileFiles`), kept distinct from the +/// real compiler defaults: `newLine`→CRLF and `skipDefaultLibCheck`→true when +/// unset, `noErrorTruncation`→true unconditionally. None of these are skip +/// triggers, so they don't affect variant selection — recorded as substrate. +pub const HARNESS_FORCED_DEFAULTS: &[(&str, &str)] = &[ + ("newLine", "crlf (when unset)"), + ("skipDefaultLibCheck", "true (when unset)"), + ("noErrorTruncation", "true (always)"), +]; + +/// The harness default for case-sensitive file names (`HarnessOptions{ +/// UseCaseSensitiveFileNames: true }`). +pub const DEFAULT_USE_CASE_SENSITIVE_FILE_NAMES: bool = true; + +/// Whether a unit's file name is a tsconfig/jsconfig (`GetConfigNameFromFileName`): +/// its basename, lowercased, is `tsconfig.json` or `jsconfig.json`. +#[must_use] +pub fn is_config_file_name(file_name: &str) -> bool { + let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name); + let lower = base.to_ascii_lowercase(); + lower == "tsconfig.json" || lower == "jsconfig.json" +} + +/// Find an option by name, case-insensitively (mirrors `getCommandLineOption`'s +/// `EqualFold` first-match). +#[must_use] +pub fn lookup(name: &str) -> Option<&'static OptionMeta> { + OPTIONS.iter().find(|o| o.name.eq_ignore_ascii_case(name)) +} + +/// Whether an option (by lowercased directive key) is a varyBy option: +/// `!IsCommandLineOnly && (boolean|enum) && any Affects*`, plus the two hardcoded +/// additions `noEmit` and `isolatedModules` (`getCompilerVaryByMap`). +#[must_use] +pub fn is_vary_by(name_lower: &str) -> bool { + if name_lower == "noemit" || name_lower == "isolatedmodules" { + return true; + } + lookup(name_lower).is_some_and(|o| { + !o.command_line_only + && matches!(o.kind, OptionKind::Boolean | OptionKind::Enum) + && o.affects + }) +} + +/// Whether a `// @key` directive (lowercased key) is recognized by the harness: +/// a compiler option, a synthetic option, a harness option, or the swallowed +/// `typescriptversion`. An unrecognized directive is a hard harness failure. +#[must_use] +pub fn is_known_directive(name_lower: &str) -> bool { + if name_lower == SWALLOWED_DIRECTIVE { + return true; + } + lookup(name_lower).is_some() + || SYNTHETIC_OPTIONS.iter().any(|s| s.eq_ignore_ascii_case(name_lower)) + || HARNESS_OPTIONS.iter().any(|s| s.eq_ignore_ascii_case(name_lower)) +} + +/// A normalized option value — the identity used for variant dedup within one +/// option (never compared across options). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum NormValue { + /// A canonical enum identity (shared by aliases). + Enum(&'static str), + /// A boolean. + Bool(bool), + /// Any other kind's raw string. + Other(String), +} + +/// Normalize a value for a varyBy option, or `None` if the value is unrecognized +/// (an unknown enum key / non-boolean). Mirrors `tryGetValueOfOptionString`. +#[must_use] +pub fn normalize_value(option_lower: &str, value: &str) -> Option { + let vlower = value.to_ascii_lowercase(); + if ENUM_ENTRIES.iter().any(|e| e.option.eq_ignore_ascii_case(option_lower)) { + return ENUM_ENTRIES + .iter() + .find(|e| e.option.eq_ignore_ascii_case(option_lower) && e.key == vlower) + .map(|e| NormValue::Enum(e.canonical)); + } + match lookup(option_lower).map(|o| o.kind) { + Some(OptionKind::Boolean) => match vlower.as_str() { + "true" => Some(NormValue::Bool(true)), + "false" => Some(NormValue::Bool(false)), + _ => None, + }, + _ => Some(NormValue::Other(value.to_string())), + } +} + +/// Every writable value for a varyBy option, in declaration order (enum keys, or +/// `true`/`false`). Empty for non-enum/non-boolean options. Mirrors +/// `getAllValuesForOption` (the `*` expansion source). +#[must_use] +pub fn all_values(option_lower: &str) -> Vec<&'static str> { + if ENUM_ENTRIES.iter().any(|e| e.option.eq_ignore_ascii_case(option_lower)) { + return ENUM_ENTRIES + .iter() + .filter(|e| e.option.eq_ignore_ascii_case(option_lower)) + .map(|e| e.key) + .collect(); + } + match lookup(option_lower).map(|o| o.kind) { + Some(OptionKind::Boolean) => vec!["true", "false"], + _ => Vec::new(), + } +} + +/// The `strict`-family member names (their `strictFlag`): each inherits `strict` +/// when unset (and unset `strict` counts as `true`). Substrate for the options +/// model; variant selection does not use inheritance (the skip check reads the +/// explicit tri-state). +#[must_use] +pub fn strict_members() -> Vec<&'static str> { + OPTIONS.iter().filter(|o| o.strict_flag).map(|o| o.name).collect() +} + +/// Resolve a boolean option's explicit tri-state from a variant config: `True` / +/// `False` for an explicit value, `Unset` when absent or unparseable. This is the +/// `IsFalse()` distinction the skip check needs — an unset boolean inherits and is +/// not treated as `false`. +#[must_use] +pub fn resolve_bool(config: &BTreeMap, key_lower: &str) -> Tristate { + match config.get(key_lower).map(|v| normalize_value(key_lower, v)) { + Some(Some(NormValue::Bool(true))) => Tristate::True, + Some(Some(NormValue::Bool(false))) => Tristate::False, + _ => Tristate::Unset, + } +} + +/// Whether a resolved variant config is skipped by `SkipUnsupportedCompilerOptions` +/// (the Removed-in-TS7 option classes): unsupported `module` / `moduleResolution` +/// / `target`, an explicitly-false `esModuleInterop` / `allowSyntheticDefaultImports` +/// / `alwaysStrict`, or a set `baseUrl` / `outFile`. +/// +/// Resolved from directive-provided values only (tsconfig-provided options are out +/// of scope). This is exact for directive-driven tests and can only *under*-skip a +/// tsconfig-driven one — safe for the join, since a skipped variant has no baseline +/// either way. +/// +/// `config` maps lowercased directive keys to their raw string values. +#[must_use] +pub fn variant_is_unsupported(config: &BTreeMap) -> bool { + // Unsupported module kinds. + if let Some(v) = config.get("module") + && let Some(NormValue::Enum(c)) = normalize_value("module", v) + && matches!( + c, + "core.ModuleKindAMD" | "core.ModuleKindUMD" | "core.ModuleKindSystem" + ) + { + return true; + } + // Unsupported module-resolution kinds. + if let Some(v) = config.get("moduleresolution") + && let Some(NormValue::Enum(c)) = normalize_value("moduleResolution", v) + && matches!( + c, + "core.ModuleResolutionKindNode10" | "core.ModuleResolutionKindClassic" + ) + { + return true; + } + // Unsupported target ES5. + if let Some(v) = config.get("target") + && normalize_value("target", v) == Some(NormValue::Enum("core.ScriptTargetES5")) + { + return true; + } + // Explicitly-false booleans (`IsFalse()`: an unset value inherits, so only an + // explicit `False` triggers the skip). + for key in ["esmoduleinterop", "allowsyntheticdefaultimports", "alwaysstrict"] { + if resolve_bool(config, key) == Tristate::False { + return true; + } + } + // Set string paths. + if config.get("baseurl").is_some_and(|v| !v.trim().is_empty()) { + return true; + } + if config.get("outfile").is_some_and(|v| !v.trim().is_empty()) { + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_shape_pins() { + // Ported from tsgo at the pinned commit; a move is a deliberate re-port. + assert_eq!(OPTIONS.len(), 124); + assert_eq!(ENUM_ENTRIES.len(), 44); + assert_eq!(SKIPPED_TESTS.len(), 45); + assert_eq!(HARNESS_OPTIONS.len(), 13); + assert_eq!(SYNTHETIC_OPTIONS.len(), 4); + // getCompilerVaryByMap derives 70 affects-based options plus the two + // hardcoded additions (noEmit, isolatedModules) = 72 at the pin. + let vary = OPTIONS + .iter() + .filter(|o| is_vary_by(&o.name.to_ascii_lowercase())) + .count(); + assert_eq!(vary, 72); + } + + #[test] + fn vary_by_rules() { + assert!(is_vary_by("strict")); // boolean + affects + assert!(is_vary_by("target")); // enum + affects + assert!(is_vary_by("noemit")); // hardcoded + assert!(is_vary_by("isolatedmodules")); // hardcoded + assert!(!is_vary_by("lib")); // list (comma is not a variant) + assert!(!is_vary_by("help")); // command-line-only + assert!(!is_vary_by("jsxfactory")); // string, no affects + assert!(!is_vary_by("filename")); // harness option, not a compiler option + } + + #[test] + fn known_directive_universe() { + assert!(is_known_directive("strict")); + assert!(is_known_directive("filename")); // harness + assert!(is_known_directive("allownontsextensions")); // synthetic + assert!(is_known_directive("typescriptversion")); // swallowed + assert!(!is_known_directive("definitelynotanoption")); + } + + #[test] + fn value_normalization_and_dedup() { + // es6 and es2015 alias to one identity. + assert_eq!( + normalize_value("target", "es6"), + normalize_value("target", "ES2015") + ); + assert_eq!(normalize_value("strict", "TRUE"), Some(NormValue::Bool(true))); + assert_eq!(normalize_value("target", "nope"), None); + assert_eq!(all_values("moduledetection"), vec!["auto", "legacy", "force"]); + assert_eq!(all_values("strict"), vec!["true", "false"]); + } + + #[test] + fn skip_resolution() { + let mk = |k: &str, v: &str| { + let mut m = BTreeMap::new(); + m.insert(k.to_string(), v.to_string()); + m + }; + assert!(variant_is_unsupported(&mk("target", "es5"))); + assert!(variant_is_unsupported(&mk("module", "amd"))); + assert!(variant_is_unsupported(&mk("moduleresolution", "classic"))); + assert!(variant_is_unsupported(&mk("esmoduleinterop", "false"))); + assert!(variant_is_unsupported(&mk("outfile", "out.js"))); + assert!(!variant_is_unsupported(&mk("target", "es2015"))); + assert!(!variant_is_unsupported(&mk("module", "esnext"))); + assert!(!variant_is_unsupported(&mk("esmoduleinterop", "true"))); + assert!(!variant_is_unsupported(&BTreeMap::new())); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs index 06bb3e0da..d113194df 100644 --- a/crates/tsv_debug/src/tsc_conformance/roundtrip.rs +++ b/crates/tsv_debug/src/tsc_conformance/roundtrip.rs @@ -219,7 +219,7 @@ pub fn run_roundtrip(baselines: &[Baseline]) -> RoundtripReport { /// An ANSI-colored `pretty=true` baseline — a separate renderer path carved out /// of the round-trip scope. The ESC byte is present iff tsgo's colored renderer /// (not the ported `pretty=false` rune path) produced the baseline. -fn is_pretty(content: &str) -> bool { +pub(super) fn is_pretty(content: &str) -> bool { content.contains('\u{1b}') } diff --git a/crates/tsv_debug/src/tsc_conformance/variants.rs b/crates/tsv_debug/src/tsc_conformance/variants.rs new file mode 100644 index 000000000..737cbfe32 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/variants.rs @@ -0,0 +1,281 @@ +//! Fan a test's settings out into its variant configurations and synthesize each +//! variant's baseline filename, faithful to tsgo's harness. +//! +//! Only **varyBy** options fan out; a comma list in a list-typed option (`@lib: +//! es5, dom`) is one value, not a set of variants. `splitOptionValues` handles the +//! `*` (all values), `-x`/`!x` (exclusion), and typed-dedup semantics; the variant +//! product is capped at 25 (a hard harness failure above). The baseline name is +//! `name(k1=v1,k2=v2).errors.txt` with sorted keys and lowercased values — the +//! exact on-disk form. +// +// tsgo: internal/testutil/harnessutil/harnessutil.go GetFileBasedTestConfigurations +// tsgo: internal/testutil/harnessutil/harnessutil.go splitOptionValues (comma/`*`/exclusion/dedup) +// tsgo: internal/testutil/harnessutil/harnessutil.go getFileBasedTestConfigurationDescription +// tsgo: internal/testrunner/compiler_runner.go newCompilerTest (configuredName) + +use crate::tsc_conformance::options_meta::{all_values, is_vary_by, normalize_value, NormValue}; +use std::collections::BTreeMap; + +/// The variant cap: a product above this is a hard harness failure. +const VARIATION_CAP: usize = 25; + +/// One expanded variant of a test. +#[derive(Debug, Clone)] +pub struct Variant { + /// The variant description (`k1=v1,k2=v2`, sorted keys, lowercased values), or + /// empty for the single unvaried configuration. + pub description: String, + /// The merged resolved config (varying values plus non-varying options), + /// lowercased keys. + pub config: BTreeMap, +} + +/// The result of expanding a test's settings. +#[derive(Debug, Clone)] +pub struct Expansion { + /// The variants (at least one; a settingless test yields one empty variant). + pub variants: Vec, + /// Whether the variant product exceeded the cap (tsgo `t.Fatal`). Never true + /// on the valid corpus. + pub cap_exceeded: bool, +} + +/// Split a varyBy option's directive value into its distinct writable values, +/// handling `*` (all values), `-x`/`!x` (exclusion), and typed dedup — the +/// original include strings, deduped by normalized identity (first wins). +#[must_use] +pub fn split_option_values(value: &str, option_lower: &str) -> Vec { + if value.is_empty() { + return Vec::new(); + } + let mut star = false; + let mut includes: Vec<&str> = Vec::new(); + let mut excludes: Vec<&str> = Vec::new(); + for part in value.split(',') { + let s = part.trim(); + if s.is_empty() { + continue; + } + if s == "*" { + star = true; + } else if let Some(rest) = s.strip_prefix('-').or_else(|| s.strip_prefix('!')) { + excludes.push(rest); + } else { + includes.push(s); + } + } + if includes.is_empty() && !star && excludes.is_empty() { + return Vec::new(); + } + + // Insertion-ordered dedup by normalized identity; unrecognized includes keep + // their own identity so the variant count is never silently reduced. + let mut order: Vec<(NormValue, String)> = Vec::new(); + let mut insert = |canon: NormValue, original: &str| { + if !order.iter().any(|(c, _)| *c == canon) { + order.push((canon, original.to_string())); + } + }; + for inc in &includes { + let canon = + normalize_value(option_lower, inc).unwrap_or_else(|| NormValue::Other(inc.to_lowercase())); + insert(canon, inc); + } + if star { + for key in all_values(option_lower) { + if let Some(canon) = normalize_value(option_lower, key) { + insert(canon, key); + } + } + } + for exc in &excludes { + if let Some(canon) = normalize_value(option_lower, exc) { + order.retain(|(c, _)| *c != canon); + } + } + order.into_iter().map(|(_, original)| original).collect() +} + +/// Expand a settings map into its variant configurations +/// (`GetFileBasedTestConfigurations`). +#[must_use] +pub fn expand(settings: &BTreeMap) -> Expansion { + let mut option_entries: Vec<(String, Vec)> = Vec::new(); + let mut variation_count = 1usize; + let mut non_varying: BTreeMap = BTreeMap::new(); + + for (opt, value) in settings { + if is_vary_by(opt) { + let entries = split_option_values(value, opt); + if entries.len() > 1 { + variation_count = variation_count.saturating_mul(entries.len()); + if variation_count > VARIATION_CAP { + return Expansion { + variants: Vec::new(), + cap_exceeded: true, + }; + } + option_entries.push((opt.clone(), entries)); + } else if entries.len() == 1 { + non_varying.insert(opt.clone(), entries[0].clone()); + } + // len 0: the option is dropped entirely. + } else { + non_varying.insert(opt.clone(), value.clone()); + } + } + + let variants = if option_entries.is_empty() { + if non_varying.is_empty() { + vec![Variant { + description: String::new(), + config: BTreeMap::new(), + }] + } else { + vec![Variant { + description: String::new(), + config: non_varying, + }] + } + } else { + // Cartesian product over the varying options. + let mut combos: Vec> = vec![Vec::new()]; + for (key, values) in &option_entries { + let mut next = Vec::with_capacity(combos.len() * values.len()); + for combo in &combos { + for v in values { + let mut c = combo.clone(); + c.push((key.clone(), v.clone())); + next.push(c); + } + } + combos = next; + } + combos + .into_iter() + .map(|combo| { + let varying: BTreeMap = combo.into_iter().collect(); + let description = varying + .iter() + .map(|(k, v)| format!("{k}={}", v.to_lowercase())) + .collect::>() + .join(","); + let mut config = varying; + for (k, v) in &non_varying { + config.entry(k.clone()).or_insert_with(|| v.clone()); + } + Variant { description, config } + }) + .collect() + }; + + Expansion { + variants, + cap_exceeded: false, + } +} + +/// Synthesize a variant's baseline filename (`configuredName` then the +/// `.tsx?`→`.errors.txt` replacement). An empty description yields the plain +/// `basename.errors.txt`. A non-`.ts`/`.tsx` basename keeps its extension (so its +/// synthesized name never joins an `.errors.txt` baseline). +#[must_use] +pub fn config_name(basename: &str, description: &str) -> String { + if description.is_empty() { + return errors_name(basename); + } + let configured = if let Some(stem) = basename.strip_suffix(".tsx") { + format!("{stem}({description}).tsx") + } else if let Some(stem) = basename.strip_suffix(".ts") { + format!("{stem}({description}).ts") + } else { + format!("{basename}({description})") + }; + errors_name(&configured) +} + +/// Replace a trailing `.ts`/`.tsx` with `.errors.txt` (tsgo's `tsExtension` +/// regex); other extensions are left unchanged. +fn errors_name(name: &str) -> String { + if let Some(stem) = name.strip_suffix(".tsx") { + format!("{stem}.errors.txt") + } else if let Some(stem) = name.strip_suffix(".ts") { + format!("{stem}.errors.txt") + } else { + name.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn split_star_and_exclusion() { + // `*` expands to all bool values, exclusion removes one. + let mut v = split_option_values("*, -true", "strict"); + v.sort(); + assert_eq!(v, vec!["false"]); + } + + #[test] + fn split_dedup_aliases() { + // es6 and es2015 alias; first (es6) wins, so one value. + assert_eq!(split_option_values("es6, es2015", "target"), vec!["es6"]); + } + + #[test] + fn split_list_typed_is_single() { + // A list option is never a varyBy option, so this helper is not called for + // it; but a plain multi-value on a scalar enum splits. + assert_eq!(split_option_values("es5, es2015", "target").len(), 2); + } + + #[test] + fn expand_single_target_pair() { + let e = expand(&settings(&[("target", "es5, es2015")])); + assert!(!e.cap_exceeded); + assert_eq!(e.variants.len(), 2); + let descs: Vec<_> = e.variants.iter().map(|v| v.description.clone()).collect(); + assert!(descs.contains(&"target=es5".to_string())); + assert!(descs.contains(&"target=es2015".to_string())); + } + + #[test] + fn expand_product_sorted_description() { + let e = expand(&settings(&[("strict", "true, false"), ("module", "commonjs, esnext")])); + assert_eq!(e.variants.len(), 4); + // Keys are sorted in the description. + assert!(e + .variants + .iter() + .all(|v| v.description.starts_with("module=") && v.description.contains(",strict="))); + } + + #[test] + fn expand_non_varying_kept() { + // A single-value varyBy option is non-varying; a non-varyBy option too. + let e = expand(&settings(&[("target", "es2015"), ("jsxfactory", "h")])); + assert_eq!(e.variants.len(), 1); + assert_eq!(e.variants[0].description, ""); + assert_eq!(e.variants[0].config.get("target").map(String::as_str), Some("es2015")); + assert_eq!(e.variants[0].config.get("jsxfactory").map(String::as_str), Some("h")); + } + + #[test] + fn config_name_synthesis() { + assert_eq!(config_name("foo.ts", ""), "foo.errors.txt"); + assert_eq!(config_name("foo.ts", "target=es2015"), "foo(target=es2015).errors.txt"); + assert_eq!(config_name("foo.tsx", "jsx=react"), "foo(jsx=react).errors.txt"); + assert_eq!(config_name("foo.d.ts", "target=es5"), "foo.d(target=es5).errors.txt"); + // A .js basename keeps its extension (never joins an .errors.txt). + assert_eq!(config_name("foo.js", ""), "foo.js"); + } +} From f373ed085e5d8e9e4fefb0cfa9a2e781193a5028 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 11:33:55 -0400 Subject: [PATCH 12/79] test: pretty-path standalone render tests for unexercised branches + unit-system guard comments --- .../tsv_debug/src/tsc_conformance/pretty.rs | 197 +++++++++++++++++- .../tsv_debug/src/tsc_conformance/render.rs | 6 + 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/crates/tsv_debug/src/tsc_conformance/pretty.rs b/crates/tsv_debug/src/tsc_conformance/pretty.rs index 1c4f2eeff..7fb533848 100644 --- a/crates/tsv_debug/src/tsc_conformance/pretty.rs +++ b/crates/tsv_debug/src/tsc_conformance/pretty.rs @@ -781,13 +781,16 @@ fn numbered_loc(loc: Option) -> Option<(u32, u32)> { } } -/// UTF-16 code-unit length of `s`. +/// UTF-16 code-unit length of `s` — the pretty path's width unit, kept distinct +/// from the plain path's rune count and never merged with it (see `advance_utf16`). fn utf16_len(s: &str) -> u32 { s.chars().map(|c| c.len_utf16() as u32).sum() } /// Line index and UTF-16 column of the LF-content byte offset `pos` over -/// `src_lines` (mirrors `scanner.GetECMALineAndUTF16CharacterOfPosition`). +/// `src_lines` (mirrors `scanner.GetECMALineAndUTF16CharacterOfPosition`). Measures +/// in UTF-16 code units — the pretty path's unit, not interchangeable with the +/// plain rune path (see `advance_utf16`). fn line_and_utf16col(src_lines: &[String], starts: &[usize], pos: usize) -> (usize, u32) { let line = match starts.binary_search(&pos) { Ok(i) => i, @@ -801,6 +804,12 @@ fn line_and_utf16col(src_lines: &[String], starts: &[usize], pos: usize) -> (usi /// Advance `units` UTF-16 code units from `start_byte` in `line`, returning the /// number of bytes covered. +/// +/// Unit-system guard: this and `render.rs`'s `advance_runes` measure in different +/// units and must never be merged or deduplicated. The pretty path counts UTF-16 +/// code units (tsgo's `writeCodeSnippet` / `core.UTF16Len`); the plain path counts +/// runes (`error_baseline.go`'s `utf8.RuneCountInString`). An astral char is two +/// units here but one rune there, so the same span squiggles a different width. fn advance_utf16(line: &str, start_byte: usize, units: u32) -> usize { let Some(slice) = line.get(start_byte..) else { return 0; @@ -1118,4 +1127,188 @@ mod tests { ); assert_eq!(render_pretty(&b).expect("render"), expected); } + + // --- write_code_snippet branches no in-corpus pretty baseline reaches --- + + #[test] + fn write_code_snippet_over_five_lines_elides_middle() { + // A >5-line span (last_line - first_line >= 4) shows the first 2 and last 2 + // lines with an ellipsis gutter between them (diagnosticwriter.go:187-197). + // gutter_width widens to max(len("..."), digits(last_line + 1)) = max(3, 1) + // = 3 (:180-182), so the line numbers right-justify to width 3. Exercised on + // write_code_snippet directly to isolate the elision branch. + let src: Vec = ["aaa", "bbb", "ccc", "ddd", "eee", "fff"] + .iter() + .map(|s| (*s).to_string()) + .collect(); + // The span (0, 23) covers all six 3-char lines of "aaa\n…\nfff". + let mut out = String::new(); + write_code_snippet(&mut out, &src, 0, 23, RED, ""); + let expected = format!( + concat!( + "\r\n", + "{gs} 1{r} aaa\r\n", + "{gs} {r} {red}~~~{r}", + "\r\n", + "{gs} 2{r} bbb\r\n", + "{gs} {r} {red}~~~{r}", + "\r\n", + "{gs}...{r} \r\n", + "{gs} 5{r} eee\r\n", + "{gs} {r} {red}~~~{r}", + "\r\n", + "{gs} 6{r} fff\r\n", + "{gs} {r} {red}~~~{r}", + ), + r = RESET, red = RED, gs = GUTTER_STYLE + ); + assert_eq!(out, expected); + } + + #[test] + fn write_code_snippet_zero_length_squiggles_one_char() { + // A zero-length span squiggles a single character: last_char is bumped by + // one (diagnosticwriter.go:172-174) so exactly one tilde is emitted, at the + // start column (byte 1 of "abc" → one leading space, then one tilde). + let src = vec!["abc".to_string()]; + let mut out = String::new(); + write_code_snippet(&mut out, &src, 1, 0, RED, ""); + let expected = format!( + concat!("\r\n", "{gs}1{r} abc\r\n", "{gs} {r} {red} ~{r}",), + r = RESET, red = RED, gs = GUTTER_STYLE + ); + assert_eq!(out, expected); + } + + #[test] + fn standalone_render_astral_utf16_vs_rune_squiggle() { + // Crux unit-system distinction, end to end: U+10437 (𐐷) is one rune but two + // UTF-16 code units. The colored top block counts the squiggle in UTF-16 + // units (writeCodeSnippet / core.UTF16Len → two tildes); the plain middle + // counts runes (error_baseline.go's utf8.RuneCountInString → one tilde). + let b = PrettyBaseline { + base: ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2304, + &["Cannot find name."], + &[], + )], + // The astral char is 4 bytes; the span (0, 4) covers exactly it. + sections: vec![section("a.ts", &["\u{10437};"], &[(0, 0, 4)])], + }, + related_lens: vec![vec![]], + }; + let expected = format!( + concat!( + "{c}a.ts{r}:{y}1{r}:{y}1{r} - {red}error{r}{g} TS2304: {r}Cannot find name.\r\n", + "\r\n", + "{gs}1{r} \u{10437};\r\n", + "{gs} {r} {red}~~{r}\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " \u{10437};\r\n", + " ~\r\n", + "!!! error TS2304: Cannot find name.", + "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", + ), + c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + ); + assert_eq!(render_pretty(&b).expect("render"), expected); + assert!(self_assertion_violations(&b.base).is_empty()); + } + + #[test] + fn standalone_render_level_two_message_chain() { + // A message chain nested two levels deep: the top block joins the head with + // a 2-space (level-1) then 4-space (level-2) continuation via + // write_flattened_message (diagnosticwriter.go:271-281, " " per level), and + // each non-empty line re-renders `!!!`-prefixed in the plain middle. + let b = PrettyBaseline { + base: ParsedBaseline { + diags: vec![diag( + Some("a.ts"), + Some(Loc::Numbered { line: 1, col: 1 }), + 2322, + &[ + "Type 'A' is not assignable to type 'B'.", + " The types are incompatible.", + " Property 'x' is missing.", + ], + &[], + )], + sections: vec![section("a.ts", &["x;"], &[(0, 0, 1)])], + }, + related_lens: vec![vec![]], + }; + let expected = format!( + concat!( + "{c}a.ts{r}:{y}1{r}:{y}1{r} - {red}error{r}{g} TS2322: {r}Type 'A' is not assignable to type 'B'.\r\n", + " The types are incompatible.\r\n", + " Property 'x' is missing.\r\n", + "\r\n", + "{gs}1{r} x;\r\n", + "{gs} {r} {red}~{r}\r\n", + "\r\n\r\n", + "==== a.ts (1 errors) ====\r\n", + " x;\r\n", + " ~\r\n", + "!!! error TS2322: Type 'A' is not assignable to type 'B'.\r\n", + "!!! error TS2322: The types are incompatible.\r\n", + "!!! error TS2322: Property 'x' is missing.", + "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", + ), + c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + ); + assert_eq!(render_pretty(&b).expect("render"), expected); + assert!(self_assertion_violations(&b.base).is_empty()); + } + + #[test] + fn standalone_summary_tabular_multi_digit_count() { + // The tabular errors display right-justifies each file's error count in a + // column max(len("Errors"), digits(maxCount)) = max(6, 2) = 6 wide + // (diagnosticwriter.go:423-437). A 10-error file exercises the multi-digit + // count " 10" (four spaces + two digits) beside the single-digit " 1". + // Driven through render_pretty_summary (the summary never reads sections). + let mut diags: Vec = Vec::new(); + for _ in 0..10 { + diags.push(diag( + Some("a.ts"), + Some(Loc::Numbered { line: 3, col: 1 }), + 2304, + &["e"], + &[], + )); + } + // Only the first error's line drives each file's pretty path. + diags.push(diag( + Some("b.ts"), + Some(Loc::Numbered { line: 7, col: 1 }), + 2304, + &["e"], + &[], + )); + let base = ParsedBaseline { + diags, + sections: vec![], + }; + + let mut out = String::new(); + render_pretty_summary(&mut out, &base); + let expected = format!( + concat!( + "\r\n", + "Found 11 errors in 2 files.\r\n", + "\r\n", + "Errors Files\r\n", + " 10 a.ts{g}:3{r}\r\n", + " 1 b.ts{g}:7{r}\r\n", + "\r\n", + ), + g = GREY, r = RESET + ); + assert_eq!(out, expected); + } } diff --git a/crates/tsv_debug/src/tsc_conformance/render.rs b/crates/tsv_debug/src/tsc_conformance/render.rs index 51279f9cf..92a8977cb 100644 --- a/crates/tsv_debug/src/tsc_conformance/render.rs +++ b/crates/tsv_debug/src/tsc_conformance/render.rs @@ -54,6 +54,12 @@ pub(crate) fn col_to_byte(line: &str, col: u32) -> usize { /// of bytes covered. Used to recover a span's end offset from a squiggle's tilde /// count. `start_byte` must be a char boundary; a non-boundary or `n` beyond the /// line end degrades gracefully (returns the remaining byte length). +/// +/// Unit-system guard: this and `pretty.rs`'s `advance_utf16` measure in different +/// units and must never be merged or deduplicated. The plain path counts runes +/// (`error_baseline.go`'s `utf8.RuneCountInString`); the pretty path counts UTF-16 +/// code units (tsgo's `writeCodeSnippet`). The same astral char is one rune here +/// but two units there. pub(crate) fn advance_runes(line: &str, start_byte: usize, n: usize) -> usize { let Some(slice) = line.get(start_byte..) else { return 0; From 04556ca9231e8e2817d0d9f7761759ff2b72cdf1 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 11:51:46 -0400 Subject: [PATCH 13/79] fix: index gate 2 positional (name,body) via ported section-name resolution; count unknown includes; edge-semantics tests --- .../src/cli/commands/tsc_conformance.rs | 5 + .../src/tsc_conformance/directives.rs | 203 +++++++++++++++++- crates/tsv_debug/src/tsc_conformance/index.rs | 117 +++++++--- .../tsv_debug/src/tsc_conformance/variants.rs | 118 +++++++++- 4 files changed, 402 insertions(+), 41 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 87251c81f..b1cda0e83 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -50,6 +50,10 @@ const INDEX_JS_FLAVORED_PIN: usize = 934; const INDEX_PRETTY_TESTS_PIN: usize = 14; const INDEX_BASENAME_COLLISIONS_PIN: usize = 0; const INDEX_CAP_EXCEEDED_PIN: usize = 0; +/// varyBy include values with no normalized identity (tsgo hard-fails on each; the +/// harness keeps them as graceful `Other` variants). Zero on the pinned corpus — a +/// nonzero count is a phantom-variant signal from a corpus pull, not a clean move. +const INDEX_UNKNOWN_INCLUDES_PIN: usize = 0; /// Variant sizing: total variants, the variant-level (unsupported-option) skips, /// the non-skipped variants, and the expect-clean count. const INDEX_VARIANT_TOTAL_PIN: usize = 14916; @@ -213,6 +217,7 @@ fn enforce_index_pins(report: &IndexReport) -> Result<(), CliError> { INDEX_BASENAME_COLLISIONS_PIN, ); pin(&mut errs, "cap-exceeded", report.cap_exceeded, INDEX_CAP_EXCEEDED_PIN); + pin(&mut errs, "unknown includes", report.unknown_includes, INDEX_UNKNOWN_INCLUDES_PIN); pin(&mut errs, "variant total", report.variant_total, INDEX_VARIANT_TOTAL_PIN); pin( &mut errs, diff --git a/crates/tsv_debug/src/tsc_conformance/directives.rs b/crates/tsv_debug/src/tsc_conformance/directives.rs index 5305441fb..43e8634c4 100644 --- a/crates/tsv_debug/src/tsc_conformance/directives.rs +++ b/crates/tsv_debug/src/tsc_conformance/directives.rs @@ -9,8 +9,12 @@ //! The directive grammar is tsgo's `optionRegex` //! (`(?m)^//\s*@(\w+)\s*:\s*([^\r\n]*)`), reproduced without a regex engine: a //! match anchors at a physical line start (after `\n`, never a lone `\r`), the -//! value runs to the next `\r`/`\n`. This hand port is byte-for-byte equivalent to -//! the regex over the pinned corpus. +//! value runs to the next `\r`/`\n`. This hand port is equivalent to the regex over +//! the pinned corpus, not universally: the regex's `\s` also matches `\r`/`\n`, so +//! the intra-line skips here (`skip_hspace`) diverge on pathological forms a lone +//! `\r`/`\n` could reach (e.g. `//\r@x:` / `//\n@x:` splitting a directive across a +//! physical-line boundary). Those don't occur in the corpus — the round-trip and +//! index gates pin that. // // tsgo: internal/testrunner/test_case_parser.go optionRegex / extractCompilerSettings // tsgo: internal/testrunner/test_case_parser.go ParseTestFilesAndSymlinksWithOptions @@ -252,6 +256,147 @@ pub fn classify_units(units: Vec, settings: &BTreeMap) -> } } +// =========================================================================== +// Section display-name derivation (the `==== ====` header the baseline +// prints for each input file). tsgo builds it as +// `removeTestPathPrefixes(GetNormalizedAbsolutePath(unit.name, currentDirectory))`, +// so a relative `@filename` like `./a.ts` (or one with redundant `//`) resolves to +// the normalized form the header carries. Ported scoped to the roots the corpus +// uses (POSIX `/`, DOS `X:/`); the index gate's positional pin guards any form +// beyond that (a URL/UNC name would surface as a mismatch and force a port). +// =========================================================================== +// +// tsgo: internal/testrunner/compiler_runner.go (srcFolder, createHarnessTestFile UnitName) +// tsgo: internal/testutil/tsbaseline/error_baseline.go (==== header via removeTestPathPrefixes) +// tsgo: internal/testutil/tsbaseline/util.go removeTestPathPrefixes +// tsgo: internal/tspath/path.go GetNormalizedAbsolutePath / GetEncodedRootLength + +/// The harness src root (`compiler_runner.go` `srcFolder`) that a `@currentDirectory` +/// (or a bare relative `@filename`) resolves against. +const SRC_FOLDER: &str = "/.src"; + +/// The test-path prefix strips the baseline applies (`tsbaseline` `testPathPrefixReplacer`), +/// in argument order — a single left-to-right pass, first pattern matching at a +/// position wins (Go `strings.NewReplacer` semantics). +const TEST_PATH_PREFIX_REPLACEMENTS: &[(&str, &str)] = &[ + ("/.ts/", ""), + ("/.lib/", ""), + ("/.src/", ""), + ("bundled:///libs/", ""), + ("file:///./ts/", "file:///"), + ("file:///./lib/", "file:///"), + ("file:///./src/", "file:///"), +]; + +/// The root-prefix length of a path (`GetEncodedRootLength`, scoped to the corpus's +/// POSIX and DOS roots): `1` for a leading `/`, `2`/`3` for a DOS `X:` / `X:/` +/// volume, else `0` (relative). URL/UNC/untitled roots don't occur in the corpus. +fn root_length(path: &str) -> usize { + let b = path.as_bytes(); + let Some(&c0) = b.first() else { return 0 }; + if c0 == b'/' { + return 1; + } + if c0.is_ascii_alphabetic() && b.len() > 1 && b[1] == b':' { + if b.len() == 2 { + return 2; + } + if b[2] == b'/' || b[2] == b'\\' { + return 3; + } + } + 0 +} + +/// Join a relative path onto a base directory (`CombinePaths`, scoped): a rooted +/// `rel` replaces the base, an empty `rel` keeps the base, else the two join with a +/// single `/`. +fn combine_paths(dir: &str, rel: &str) -> String { + if rel.is_empty() { + return dir.to_string(); + } + if root_length(rel) != 0 || dir.is_empty() { + return rel.to_string(); + } + format!("{}/{}", dir.trim_end_matches('/'), rel) +} + +/// Resolve `.`/`..` and collapse redundant `/` in the path's non-root portion, +/// preserving the root (`normalizePath`'s segment reduction). A `..` that would +/// escape a rooted path is dropped; on a relative path a leading `..` is kept. +fn normalize_path_segments(path: &str) -> String { + let root_len = root_length(path); + let root = &path[..root_len]; + let mut segments: Vec<&str> = Vec::new(); + for seg in path[root_len..].split('/') { + match seg { + "" | "." => {} + ".." => { + if segments.last().is_some_and(|&last| last != "..") { + segments.pop(); + } else if root_len == 0 { + segments.push(".."); + } + } + s => segments.push(s), + } + } + let joined = segments.join("/"); + if root.is_empty() { + joined + } else if joined.is_empty() { + root.to_string() + } else { + format!("{root}{joined}") + } +} + +/// Root `file_name` against `current_directory` (when relative) and normalize its +/// segments (`GetNormalizedAbsolutePath`). +fn normalized_absolute_path(file_name: &str, current_directory: &str) -> String { + let joined = if root_length(file_name) == 0 && !current_directory.is_empty() { + combine_paths(current_directory, file_name) + } else { + file_name.to_string() + }; + normalize_path_segments(&joined) +} + +/// Strip the harness's test-path prefixes (`removeTestPathPrefixes`), a single +/// left-to-right pass matching [`TEST_PATH_PREFIX_REPLACEMENTS`] in order. +fn remove_test_path_prefixes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + 'scan: while !rest.is_empty() { + for (pat, rep) in TEST_PATH_PREFIX_REPLACEMENTS { + if let Some(after) = rest.strip_prefix(pat) { + out.push_str(rep); + rest = after; + continue 'scan; + } + } + let ch = rest.chars().next().unwrap_or_default(); + out.push(ch); + rest = &rest[ch.len_utf8()..]; + } + out +} + +/// The harness current directory for a test (`@currentDirectory` resolved against +/// the src root, or the src root itself when unset). +#[must_use] +pub fn harness_current_directory(settings: &BTreeMap) -> String { + let cd = settings.get("currentdirectory").map_or("", String::as_str); + normalized_absolute_path(cd, SRC_FOLDER) +} + +/// The baseline `==== ====` display name for a unit, given the test's +/// resolved current directory (from [`harness_current_directory`]). +#[must_use] +pub fn section_display_name(unit_name: &str, current_directory: &str) -> String { + remove_test_path_prefixes(&normalized_absolute_path(unit_name, current_directory)) +} + #[cfg(test)] mod tests { use super::*; @@ -329,6 +474,35 @@ mod tests { assert_eq!(c.other_files.len(), 1); } + #[test] + fn classify_last_unit_on_require() { + // A `require(` in the last unit forces the last-unit split, exactly like a + // triple-slash reference: to_be_compiled = [last], the rest → other_files. + let src = "// @filename: a.ts\nlet a = 1;\n// @filename: b.ts\nconst dep = require('./a');"; + let units = split_units(src, "test.ts"); + let c = classify_units(units, &BTreeMap::new()); + assert!(c.tsconfig.is_none()); + assert_eq!(c.to_be_compiled.len(), 1); + assert_eq!(c.to_be_compiled[0].name, "b.ts"); + assert_eq!(c.other_files.len(), 1); + assert_eq!(c.other_files[0].name, "a.ts"); + } + + #[test] + fn classify_last_unit_on_noimplicitreferences() { + // `@noImplicitReferences` forces the last-unit split even with no require / + // reference in the content. + let src = "// @filename: a.ts\nlet a = 1;\n// @filename: b.ts\nlet b = 2;"; + let units = split_units(src, "test.ts"); + let mut settings = BTreeMap::new(); + settings.insert("noimplicitreferences".to_string(), "true".to_string()); + let c = classify_units(units, &settings); + assert_eq!(c.to_be_compiled.len(), 1); + assert_eq!(c.to_be_compiled[0].name, "b.ts"); + assert_eq!(c.other_files.len(), 1); + assert_eq!(c.other_files[0].name, "a.ts"); + } + #[test] fn classify_all_when_no_reference() { let src = "// @filename: a.ts\nlet a = 1;\n// @filename: b.ts\nlet b = 2;"; @@ -338,6 +512,31 @@ mod tests { assert!(c.other_files.is_empty()); } + #[test] + fn display_name_relative_and_redundant_slash() { + // No @currentDirectory: relative names resolve against /.src, then the + // /.src/ prefix is stripped — so `./a.d.ts` → `a.d.ts` and a redundant `//` + // collapses. These are the exact forms the section header carries. + let cwd = harness_current_directory(&BTreeMap::new()); + assert_eq!(section_display_name("./a.d.ts", &cwd), "a.d.ts"); + assert_eq!(section_display_name("plain.ts", &cwd), "plain.ts"); + assert_eq!( + section_display_name("node_modules/x/development//x.d.ts", &cwd), + "node_modules/x/development/x.d.ts" + ); + } + + #[test] + fn display_name_rooted_names_unchanged() { + // A rooted `@filename` ignores the current directory and carries no /.src + // prefix, so it round-trips verbatim. + let mut settings = BTreeMap::new(); + settings.insert("currentdirectory".to_string(), "/".to_string()); + let cwd = harness_current_directory(&settings); + assert_eq!(section_display_name("/deps/dep/dep.d.ts", &cwd), "/deps/dep/dep.d.ts"); + assert_eq!(section_display_name("/app.ts", &cwd), "/app.ts"); + } + #[test] fn classify_pulls_tsconfig_first() { let src = "// @filename: tsconfig.json\n{}\n// @filename: a.ts\nlet a = 1;"; diff --git a/crates/tsv_debug/src/tsc_conformance/index.rs b/crates/tsv_debug/src/tsc_conformance/index.rs index 13333e3d0..f59ad8d4b 100644 --- a/crates/tsv_debug/src/tsc_conformance/index.rs +++ b/crates/tsv_debug/src/tsc_conformance/index.rs @@ -21,7 +21,9 @@ use crate::tsc_conformance::corpus::{ basename_collisions, discover_corpus, read_corpus_file, BasenameCollision, CorpusTest, }; -use crate::tsc_conformance::directives::{classify_units, extract_settings, split_units}; +use crate::tsc_conformance::directives::{ + classify_units, extract_settings, harness_current_directory, section_display_name, split_units, +}; use crate::tsc_conformance::discovery::{discover_baselines, Baseline}; use crate::tsc_conformance::options_meta::{ is_known_directive, strict_members, variant_is_unsupported, DEFAULT_USE_CASE_SENSITIVE_FILE_NAMES, @@ -41,12 +43,18 @@ struct Derived { } /// Per-test record retained for the unit round-trip gate: the section-ordered -/// unit bodies (each split into physical lines) that a baseline's `====` sections +/// units — each a `(name, body-lines)` pair — that a baseline's `====` sections /// must reproduce. struct TestRecord { relative_path: String, unit_count: usize, - unit_line_sets: Vec>, + /// Whether the test carried a tsconfig unit. Its `FileNames` glob resolution is + /// out of scope, so the section order isn't authoritative; the gate falls back + /// to a body multiset for these (name/order comparison deferred). + tsconfig_unresolved: bool, + /// `(unit name, body physical lines)` in baseline-section order + /// (`Concatenate(tsConfigFiles, toBeCompiled, otherFiles)`). + units: Vec<(String, Vec)>, } /// A unit round-trip mismatch (a test whose split units don't reproduce its @@ -100,6 +108,10 @@ pub struct IndexReport { pub collisions: Vec, /// Tests whose variant product exceeded the cap (a harness failure). pub cap_exceeded: usize, + /// varyBy include values with no normalized identity across all expanded tests + /// (tsgo hard-fails on each; this harness keeps them as graceful `Other` + /// variants). Pinned so a corpus pull can't slip in phantom variants unseen. + pub unknown_includes: usize, /// Tests carrying a tsconfig/jsconfig unit (whose `FileNames` glob resolution /// is out of scope; their section split is by multiset, not order). pub tests_with_tsconfig: usize, @@ -168,6 +180,7 @@ pub fn run_index(checkout: &Path) -> Result { basename_collisions: collisions.len(), collisions, cap_exceeded: 0, + unknown_includes: 0, tests_with_tsconfig: 0, baselines_total: baselines.len(), join_matched: 0, @@ -230,18 +243,26 @@ pub fn run_index(checkout: &Path) -> Result { report.cap_exceeded += 1; continue; } + report.unknown_includes += expansion.unknown_includes; // Order the units per the baseline `====` section order - // (`Concatenate(tsConfigFiles, toBeCompiled, otherFiles)`), then reduce to - // their line bodies for the round-trip multiset. + // (`Concatenate(tsConfigFiles, toBeCompiled, otherFiles)`), resolving each + // unit's baseline display name so the round-trip can compare `(name, body)` + // positionally. + let current_directory = harness_current_directory(&settings); let classified = classify_units(units, &settings); if classified.tsconfig_unresolved { report.tests_with_tsconfig += 1; } - let unit_line_sets: Vec> = classified + let unit_pairs: Vec<(String, Vec)> = classified .section_order() .iter() - .map(|u| split_content_lines(&u.content)) + .map(|u| { + ( + section_display_name(&u.name, ¤t_directory), + split_content_lines(&u.content), + ) + }) .collect(); let test_idx = tests.len(); @@ -262,7 +283,8 @@ pub fn run_index(checkout: &Path) -> Result { tests.push(TestRecord { relative_path: test.relative_path.clone(), unit_count, - unit_line_sets, + tsconfig_unresolved: classified.tsconfig_unresolved, + units: unit_pairs, }); } @@ -296,16 +318,12 @@ pub fn run_index(checkout: &Path) -> Result { } // Expect-clean: every non-skipped derived name with no on-disk baseline. - let mut seen_names: std::collections::HashSet<(&str, &str)> = std::collections::HashSet::new(); + // `derived` is keyed by `(suite, name)`, so each iteration is a distinct name. for ((suite, name), entries) in &derived { if entries.iter().all(|d| d.skipped) { continue; } - if !seen_names.insert((suite, name.as_str())) { - continue; - } - let has = ondisk.contains_key(&(*suite, name.clone())); - if !has { + if !ondisk.contains_key(&(*suite, name.clone())) { report.expect_clean += 1; } } @@ -316,9 +334,15 @@ pub fn run_index(checkout: &Path) -> Result { Ok(report) } -/// Compare a test's split units to its baseline's `====` section bodies as a -/// multiset. Pretty baselines are counted and skipped (their body layout is a -/// separate renderer path). +/// Compare a test's split units to its baseline's `====` sections. For a test +/// without a tsconfig unit the comparison is **positional** — each section's +/// `(name, body)` must equal the derived unit at the same index, in +/// `Concatenate(toBeCompiled, otherFiles)` order — so section naming and ordering +/// are proven, not just body content. For a tsconfig test the `FileNames` glob +/// resolution is out of scope (the `toBeCompiled`/`otherFiles` split isn't +/// authoritative), so the comparison falls back to a body multiset. Pretty +/// baselines are counted and skipped (their body layout is a separate renderer +/// path). fn check_unit_roundtrip(baseline: &Baseline, record: &TestRecord, report: &mut IndexReport) { let content = match std::fs::read_to_string(&baseline.path) { Ok(c) => c, @@ -349,25 +373,61 @@ fn check_unit_roundtrip(baseline: &Baseline, record: &TestRecord, report: &mut I report.unit_roundtrip_checked += 1; - let mut unit_lines: Vec> = record.unit_line_sets.clone(); - let mut section_lines: Vec> = - parsed.sections.iter().map(|s| s.src_lines.clone()).collect(); - unit_lines.sort(); - section_lines.sort(); + let reason = if record.tsconfig_unresolved { + // Body multiset only (FileNames ordering deferred). + let mut unit_lines: Vec<&Vec> = record.units.iter().map(|(_, body)| body).collect(); + let mut section_lines: Vec<&Vec> = parsed.sections.iter().map(|s| &s.src_lines).collect(); + unit_lines.sort(); + section_lines.sort(); + (unit_lines != section_lines).then(|| { + format!( + "unit/section body mismatch ({} units vs {} sections)", + record.unit_count, + parsed.sections.len() + ) + }) + } else { + // Positional `(name, body)` comparison. + positional_mismatch(record, &parsed.sections) + }; - if unit_lines != section_lines { + if let Some(reason) = reason { report.unit_roundtrip_mismatches.push(UnitMismatch { baseline: baseline.relative_path.clone(), test: record.relative_path.clone(), - reason: format!( - "unit/section body mismatch ({} units vs {} sections)", - record.unit_count, - parsed.sections.len() - ), + reason, }); } } +/// Positionally compare a non-tsconfig test's section-ordered units against a +/// baseline's `====` sections; `None` on an exact match, else a short reason +/// naming the first divergence. +fn positional_mismatch( + record: &TestRecord, + sections: &[crate::tsc_conformance::baseline::Section], +) -> Option { + if record.units.len() != sections.len() { + return Some(format!( + "unit/section count mismatch ({} units vs {} sections)", + record.units.len(), + sections.len() + )); + } + for (i, ((name, body), section)) in record.units.iter().zip(sections).enumerate() { + if name != §ion.name { + return Some(format!( + "section {i} name mismatch (unit {name:?} vs section {:?})", + section.name + )); + } + if body != §ion.src_lines { + return Some(format!("section {i} ({name:?}) body mismatch")); + } + } + None +} + /// Split a unit's content into physical lines on `\r?\n` (the section-body /// coordinate the baseline reprints). fn split_content_lines(content: &str) -> Vec { @@ -424,6 +484,7 @@ impl IndexReport { println!("Pretty tests: {}", self.pretty_tests); println!("Basename collisions: {}", self.basename_collisions); println!("Cap-exceeded tests: {}", self.cap_exceeded); + println!("Unknown includes: {}", self.unknown_includes); println!("Tests with tsconfig: {}", self.tests_with_tsconfig); println!(); println!("Variants (non-skipped tests): {}", self.variant_total); diff --git a/crates/tsv_debug/src/tsc_conformance/variants.rs b/crates/tsv_debug/src/tsc_conformance/variants.rs index 737cbfe32..76c424f34 100644 --- a/crates/tsv_debug/src/tsc_conformance/variants.rs +++ b/crates/tsv_debug/src/tsc_conformance/variants.rs @@ -38,15 +38,39 @@ pub struct Expansion { /// Whether the variant product exceeded the cap (tsgo `t.Fatal`). Never true /// on the valid corpus. pub cap_exceeded: bool, + /// Include values with no normalized identity, summed across this test's varyBy + /// options. tsgo hard-fails on such an include (`getValueOfOptionString`'s + /// `t.Fatalf`); this harness keeps a graceful `Other` identity so the value + /// still expands, and counts it here so the corpus index can pin the total (a + /// nonzero count means a corpus pull introduced values the ported option + /// universe doesn't know, i.e. phantom variants). + pub unknown_includes: usize, +} + +/// The outcome of splitting a varyBy option's directive value: its distinct +/// writable values plus the count of include values with no normalized identity. +#[derive(Debug, Clone, Default)] +pub struct SplitOutcome { + /// The original include strings, deduped by normalized identity (first wins). + pub values: Vec, + /// Include values `normalize_value` did not recognize (the graceful-`Other` + /// fallbacks); tsgo hard-fails on each of these instead. + pub unknown_includes: usize, } /// Split a varyBy option's directive value into its distinct writable values, /// handling `*` (all values), `-x`/`!x` (exclusion), and typed dedup — the /// original include strings, deduped by normalized identity (first wins). +/// +/// An include the option universe doesn't recognize keeps its own lowercased +/// identity (so the variant count is never silently reduced) and is tallied in +/// [`SplitOutcome::unknown_includes`]; unrecognized *excludes* are skipped +/// silently, matching tsgo (`tryGetValueOfOptionString` returns "not ok" and the +/// exclude is a no-op). #[must_use] -pub fn split_option_values(value: &str, option_lower: &str) -> Vec { +pub fn split_option_values(value: &str, option_lower: &str) -> SplitOutcome { if value.is_empty() { - return Vec::new(); + return SplitOutcome::default(); } let mut star = false; let mut includes: Vec<&str> = Vec::new(); @@ -65,7 +89,7 @@ pub fn split_option_values(value: &str, option_lower: &str) -> Vec { } } if includes.is_empty() && !star && excludes.is_empty() { - return Vec::new(); + return SplitOutcome::default(); } // Insertion-ordered dedup by normalized identity; unrecognized includes keep @@ -76,9 +100,15 @@ pub fn split_option_values(value: &str, option_lower: &str) -> Vec { order.push((canon, original.to_string())); } }; + let mut unknown_includes = 0usize; for inc in &includes { - let canon = - normalize_value(option_lower, inc).unwrap_or_else(|| NormValue::Other(inc.to_lowercase())); + let canon = match normalize_value(option_lower, inc) { + Some(canon) => canon, + None => { + unknown_includes += 1; + NormValue::Other(inc.to_lowercase()) + } + }; insert(canon, inc); } if star { @@ -93,7 +123,10 @@ pub fn split_option_values(value: &str, option_lower: &str) -> Vec { order.retain(|(c, _)| *c != canon); } } - order.into_iter().map(|(_, original)| original).collect() + SplitOutcome { + values: order.into_iter().map(|(_, original)| original).collect(), + unknown_includes, + } } /// Expand a settings map into its variant configurations @@ -103,16 +136,20 @@ pub fn expand(settings: &BTreeMap) -> Expansion { let mut option_entries: Vec<(String, Vec)> = Vec::new(); let mut variation_count = 1usize; let mut non_varying: BTreeMap = BTreeMap::new(); + let mut unknown_includes = 0usize; for (opt, value) in settings { if is_vary_by(opt) { - let entries = split_option_values(value, opt); + let outcome = split_option_values(value, opt); + unknown_includes += outcome.unknown_includes; + let entries = outcome.values; if entries.len() > 1 { variation_count = variation_count.saturating_mul(entries.len()); if variation_count > VARIATION_CAP { return Expansion { variants: Vec::new(), cap_exceeded: true, + unknown_includes, }; } option_entries.push((opt.clone(), entries)); @@ -172,6 +209,7 @@ pub fn expand(settings: &BTreeMap) -> Expansion { Expansion { variants, cap_exceeded: false, + unknown_includes, } } @@ -219,23 +257,50 @@ mod tests { #[test] fn split_star_and_exclusion() { - // `*` expands to all bool values, exclusion removes one. - let mut v = split_option_values("*, -true", "strict"); + // `*` expands to all bool values, `-` exclusion removes one. + let mut v = split_option_values("*, -true", "strict").values; v.sort(); assert_eq!(v, vec!["false"]); } + #[test] + fn split_bang_exclusion() { + // `!x` is the exclusion form alongside `-x` (both strip one value). + let out = split_option_values("*, !true", "strict"); + assert_eq!(out.values, vec!["false"]); + assert_eq!(out.unknown_includes, 0); + } + + #[test] + fn split_empty_set_after_exclusion() { + // `*` minus every value: tsgo panics ("resulted in an empty set"); this + // harness returns an empty value list gracefully (the option is then + // dropped by `expand`, not a variant). + let out = split_option_values("*, -true, -false", "strict"); + assert!(out.values.is_empty()); + assert_eq!(out.unknown_includes, 0); + } + + #[test] + fn split_unknown_include_fallback_is_counted() { + // An include the option universe doesn't recognize still expands (graceful + // `Other` identity) but is tallied — tsgo hard-fails on it instead. + let out = split_option_values("es5, notarealtarget", "target"); + assert_eq!(out.values, vec!["es5", "notarealtarget"]); + assert_eq!(out.unknown_includes, 1); + } + #[test] fn split_dedup_aliases() { // es6 and es2015 alias; first (es6) wins, so one value. - assert_eq!(split_option_values("es6, es2015", "target"), vec!["es6"]); + assert_eq!(split_option_values("es6, es2015", "target").values, vec!["es6"]); } #[test] fn split_list_typed_is_single() { // A list option is never a varyBy option, so this helper is not called for // it; but a plain multi-value on a scalar enum splits. - assert_eq!(split_option_values("es5, es2015", "target").len(), 2); + assert_eq!(split_option_values("es5, es2015", "target").values.len(), 2); } #[test] @@ -269,6 +334,37 @@ mod tests { assert_eq!(e.variants[0].config.get("jsxfactory").map(String::as_str), Some("h")); } + #[test] + fn expand_cap_exceeded() { + // target `*` (13 distinct) × strict `true,false` (2) = 26 > the cap of 25: + // a hard harness failure (tsgo `t.Fatal`), here surfaced as `cap_exceeded` + // with no variants. + let e = expand(&settings(&[("target", "*"), ("strict", "true, false")])); + assert!(e.cap_exceeded); + assert!(e.variants.is_empty()); + } + + #[test] + fn expand_surfaces_unknown_includes() { + // The unknown-include tally propagates from `split_option_values` up to the + // expansion (the F1 phantom-variant guard). + let e = expand(&settings(&[("target", "es2015, notarealtarget")])); + assert!(!e.cap_exceeded); + assert_eq!(e.unknown_includes, 1); + assert_eq!(e.variants.len(), 2); + } + + #[test] + fn expand_empty_after_exclusion_drops_option() { + // A varyBy option that reduces to an empty set is dropped entirely, leaving + // the single unvaried configuration. + let e = expand(&settings(&[("strict", "*, -true, -false")])); + assert!(!e.cap_exceeded); + assert_eq!(e.variants.len(), 1); + assert_eq!(e.variants[0].description, ""); + assert!(e.variants[0].config.is_empty()); + } + #[test] fn config_name_synthesis() { assert_eq!(config_name("foo.ts", ""), "foo.errors.txt"); From a3aebdfc9e0c629bc07546b6c8ddbe479da9e272 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 12:35:58 -0400 Subject: [PATCH 14/79] =?UTF-8?q?feat:=20mint=20tsv=5Fcheck=20=E2=80=94=20?= =?UTF-8?q?pipeline=20skeleton,=20diag=20sort/dedup=20ports,=20fused-walk?= =?UTF-8?q?=20lowering=20+=20tsc=5Fconformance=20run/check-test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 17 + Cargo.lock | 10 + Cargo.toml | 6 + crates/tsv_check/CLAUDE.md | 83 +++ crates/tsv_check/Cargo.toml | 18 + crates/tsv_check/src/binder/mod.rs | 462 ++++++++++++++++ crates/tsv_check/src/diag.rs | 412 ++++++++++++++ crates/tsv_check/src/hash.rs | 162 ++++++ crates/tsv_check/src/ids.rs | 108 ++++ crates/tsv_check/src/lib.rs | 60 ++ crates/tsv_check/src/program.rs | 268 +++++++++ crates/tsv_debug/CLAUDE.md | 2 +- crates/tsv_debug/Cargo.toml | 1 + .../src/cli/commands/tsc_conformance.rs | 200 ++++++- crates/tsv_debug/src/tsc_conformance/index.rs | 9 +- crates/tsv_debug/src/tsc_conformance/mod.rs | 2 + .../tsv_debug/src/tsc_conformance/runner.rs | 523 ++++++++++++++++++ 17 files changed, 2337 insertions(+), 6 deletions(-) create mode 100644 crates/tsv_check/CLAUDE.md create mode 100644 crates/tsv_check/Cargo.toml create mode 100644 crates/tsv_check/src/binder/mod.rs create mode 100644 crates/tsv_check/src/diag.rs create mode 100644 crates/tsv_check/src/hash.rs create mode 100644 crates/tsv_check/src/ids.rs create mode 100644 crates/tsv_check/src/lib.rs create mode 100644 crates/tsv_check/src/program.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner.rs diff --git a/CLAUDE.md b/CLAUDE.md index c2d61bbec..3459b37e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -487,6 +487,7 @@ tsv/ │ ├── tsv_ts/ # TypeScript: parse(), format(), convert_ast_json_bytes() │ ├── tsv_css/ # CSS: parse(), format(), convert_ast_json_bytes() │ ├── tsv_svelte/ # Svelte: parse(), format(), convert_ast_json_bytes() +│ ├── tsv_check/ # TypeScript binder + checker (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) @@ -818,6 +819,22 @@ cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by pat # pretty-path count — plus a 100% invariant, failing on any drift; a re-pin is # deliberate (a code change or a tsgo pull). +# run - the walking-skeleton gate over tsv_check: sweeps every in-scope variant +# (single-file, non-JSX, non-JS-flavored, non-skipped), drives parse → lower+bind +# → no-op check → sort/dedup via the tsv_check crate, grades expect-clean variants +# (must be zero-diagnostic), and publishes the parse-divergence census (tsv +# parse-rejections bucketed by baseline shape + Script-goal retries). Each test +# runs catch_unwind-wrapped on a generous-stack worker; tracked parser crashes +# live in a pinned CRASH_EXCLUSIONS ledger. Exact two-sided RUN_* pins on full +# runs. Not yet a deno-task/conformance leg (joins when the first code family +# gates). +cargo run -p tsv_debug --quiet tsc_conformance run + +# check-test - the inner dev loop: one corpus test (optionally one variant), +# our diagnostics vs the baseline's summary block as a readable diff. +cargo run -p tsv_debug --quiet tsc_conformance check-test duplicateVar --variant target=es2015 +# Options: --json; works for expect-clean tests (prints the expectation). + # index - the corpus-INPUT side: indexes tests/cases/{compiler,conformance}, # parses directives, splits @filename units, expands each test's varyBy variants # (ported option-metadata table; `*`/exclusion syntax; cap 25), applies the diff --git a/Cargo.lock b/Cargo.lock index e3db5c755..629d66b8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,6 +666,15 @@ dependencies = [ "tsv_lang", ] +[[package]] +name = "tsv_check" +version = "0.1.0" +dependencies = [ + "bumpalo", + "tsv_lang", + "tsv_ts", +] + [[package]] name = "tsv_cli" version = "0.1.0" @@ -705,6 +714,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tsv_check", "tsv_cli", "tsv_css", "tsv_html", diff --git a/Cargo.toml b/Cargo.toml index e1bfbfc9a..655f0b300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/tsv_ts", "crates/tsv_css", "crates/tsv_svelte", + "crates/tsv_check", "crates/tsv_cli", "crates/tsv_debug", "crates/tsv_wasm", @@ -45,6 +46,11 @@ tsv_ts = { path = "crates/tsv_ts", default-features = false } tsv_css = { path = "crates/tsv_css", default-features = false } tsv_svelte = { path = "crates/tsv_svelte", default-features = false } +# 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/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md new file mode 100644 index 000000000..120e4e161 --- /dev/null +++ b/crates/tsv_check/CLAUDE.md @@ -0,0 +1,83 @@ +# tsv_check + +> 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 + +- **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 the root + CLAUDE.md §tsgo Typechecker-Conformance Harness). +- `unsafe_code = "forbid"` (workspace lints inherited). + +## Module map + +- `lib.rs` — public API surface. +- `program.rs` — pipeline assembly, ported from tsgo + `program.go GetDiagnosticsOfAnyProgram`: per-unit parse with the goal + rule (`Goal::Module` first, `Goal::Script` retry on failure, both-fail = + program parse-rejection), the **parse-error short-circuit** (any unit + rejects ⇒ zero bind/check diagnostics program-wide), per-file + bind-then-check concatenation, final sort+dedup. +- `binder/` — the fused lower+bind pre-order walk: assigns dense 1-based + `NodeId`s, fills SoA side columns (`parents`/`kinds`/`spans`/ + `subtree_end` — the latter makes descendant tests O(1) interval checks), + builds the address→NodeId map, derives per-file facts (module-ness from + import/export presence). **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. +- `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` (`NonZeroU32`, 1-based pre-order; `Option` + niche-packs to 4 bytes) and `FileId` newtypes. +- `hash.rs` — crate-private Fx-style multiply-xor hasher + + `FxHashMap`/`FxHashSet` aliases (no external hashing dependency). + +## Public API + +```rust +let arena = bumpalo::Bump::new(); +let units = [SourceUnit::new("a.ts", source)]; +let result: CheckResult = check_program(&units, &arena); +// result.diagnostics — canonically sorted + deduplicated +// 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. + +## Which tool answers which question + +- `tsv_debug tsc_conformance run` — the standing gate: sweeps the in-scope + corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades + expect-clean variants, publishes the parse-divergence census; exact + `RUN_*` pins. +- `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..0edebf844 --- /dev/null +++ b/crates/tsv_check/Cargo.toml @@ -0,0 +1,18 @@ +[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 } + +[lints] +workspace = true diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs new file mode 100644 index 000000000..70c26d82a --- /dev/null +++ b/crates/tsv_check/src/binder/mod.rs @@ -0,0 +1,462 @@ +//! The fused lower+bind walk (skeleton). +//! +//! One pre-order walk per file assigns dense pre-order [`NodeId`]s to the node +//! kinds the checker addresses — for now statements, the declarations nested in +//! them, and declared-name identifiers — records each node's parent, kind, and +//! span in struct-of-arrays side columns, computes the pre-order `subtree_end` +//! interval (so "is X a descendant of Y" is an O(1) id-range test), and maps +//! each node's arena address to its id. This is tsc's own architecture made +//! eager: tsc's binder is a single walk that stamps parents and lazily mints +//! per-node ids into flat link arrays; we assign the ids eagerly in the same +//! walk (unobservable, and it makes every column dense from the start). Symbol +//! tables and bind diagnostics are later slices; this walk returns none. +//! +//! **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, which is exactly the checking scope. 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. +// +// tsgo: internal/binder/binder.go bindSourceFile / bindChildren / bindEachChild +// (single-walk parent stamping; tsgo stamps in the parser, we stamp here — +// a recorded deviation with identical results) + +use crate::diag::Diagnostic; +use crate::hash::FxHashMap; +use crate::ids::{FileId, NodeId}; +use tsv_lang::Span; +use tsv_ts::ast::internal::{ForInOfLeft, ForInit}; +use tsv_ts::ast::{Expression, Program, Statement, VariableDeclaration, VariableDeclarator}; + +/// The pre-order node kinds the skeleton walk assigns. Mirrors the tsv_ts +/// `Statement` variants plus the program root and declared-name identifiers; +/// the set grows as checks address more node kinds. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u16)] +pub enum NodeKind { + /// The source file root. + Program, + /// A declared-name identifier (a binding), or a `break`/`continue`/label id. + Identifier, + 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, +} + +/// Whether a file is an external module (has a top-level `import`/`export`) — the +/// tsgo `externalModuleIndicator`, derived post-parse. Nothing consumes it yet; +/// recorded so the future binder's module-vs-script gating has the fact ready. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ModuleNess { + /// Has a top-level `import`/`export` (or `export =` / `import =`). + Module, + /// No module indicator at the top level. + 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 (empty for now) 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 -> id (the random-access escape hatch). + pub address_map: FxHashMap, + /// Bind diagnostics — empty at this slice. + pub diagnostics: Vec, + /// Per-file facts. + pub facts: FileFacts, +} + +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()] + } +} + +/// Derive a file's [`ModuleNess`] from its top-level statements (import/export +/// presence). A cheap body scan — no bind state needed. +#[must_use] +pub fn module_ness(program: &Program<'_>) -> ModuleNess { + for stmt in program.body { + if matches!( + stmt, + Statement::ImportDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) + | Statement::ExportNamedDeclaration(_) + | Statement::ExportDefaultDeclaration(_) + | Statement::ExportAllDeclaration(_) + | Statement::TSExportAssignment(_) + | Statement::TSNamespaceExportDeclaration(_) + ) { + return ModuleNess::Module; + } + } + ModuleNess::Script +} + +/// Bind one file: run the fused lower+bind walk and return its [`BoundFile`]. +#[must_use] +pub fn bind_file<'arena>(program: &'arena Program<'arena>, file: FileId) -> BoundFile { + let mut binder = Binder::default(); + let root = binder.add(NodeKind::Program, program.span, None, addr_of(program)); + for stmt in program.body { + binder.visit_statement(stmt, root); + } + binder.close(root); + BoundFile { + file, + node_count: binder.kinds.len() as u32, + parents: binder.parents, + kinds: binder.kinds, + spans: binder.spans, + subtree_end: binder.subtree_end, + address_map: binder.address_map, + diagnostics: Vec::new(), + facts: FileFacts { module_ness: module_ness(program) }, + } +} + +/// 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] +fn addr_of(node: &T) -> usize { + std::ptr::from_ref(node) as usize +} + +/// The mutable SoA columns being filled by the walk. +#[derive(Default)] +struct Binder { + parents: Vec>, + kinds: Vec, + spans: Vec, + subtree_end: Vec, + address_map: FxHashMap, +} + +impl Binder { + /// 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) + self.address_map.insert(address, id); + 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; + } + + /// Visit a statement: assign its id, then descend into the declarations and + /// nested statements the skeleton tracks. + 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::VariableDeclaration(decl) => self.visit_declarators(decl, id), + Statement::FunctionDeclaration(func) => { + if let Some(name) = &func.id { + self.visit_identifier(name, id); + } + self.visit_statements(func.body.body, id); + } + Statement::ClassDeclaration(class) => { + if let Some(name) = &class.id { + self.visit_identifier(name, id); + } + } + Statement::BlockStatement(block) => self.visit_statements(block.body, id), + Statement::IfStatement(if_stmt) => { + self.visit_statement(if_stmt.consequent, id); + if let Some(alt) = if_stmt.alternate { + self.visit_statement(alt, id); + } + } + Statement::ForStatement(for_stmt) => { + if let Some(ForInit::VariableDeclaration(decl)) = &for_stmt.init { + self.visit_variable_declaration(decl, id); + } + self.visit_statement(for_stmt.body, id); + } + Statement::ForInStatement(for_in) => { + self.visit_for_left(&for_in.left, id); + self.visit_statement(for_in.body, id); + } + Statement::ForOfStatement(for_of) => { + self.visit_for_left(&for_of.left, id); + self.visit_statement(for_of.body, id); + } + Statement::WhileStatement(while_stmt) => self.visit_statement(while_stmt.body, id), + Statement::DoWhileStatement(do_while) => self.visit_statement(do_while.body, id), + Statement::SwitchStatement(switch) => { + for case in switch.cases { + self.visit_statements(case.consequent, id); + } + } + Statement::TryStatement(try_stmt) => { + self.visit_statements(try_stmt.block.body, id); + if let Some(handler) = &try_stmt.handler { + self.visit_statements(handler.body.body, id); + } + if let Some(finalizer) = &try_stmt.finalizer { + self.visit_statements(finalizer.body, id); + } + } + Statement::LabeledStatement(labeled) => { + self.visit_identifier(&labeled.label, id); + self.visit_statement(labeled.body, id); + } + // The remaining statement kinds carry no nested statement or + // declared-name node the skeleton tracks yet (expressions, types, + // module items, throw/return arguments): assigned an id, no descent. + Statement::ExpressionStatement(_) + | Statement::TSTypeAliasDeclaration(_) + | Statement::TSInterfaceDeclaration(_) + | Statement::TSDeclareFunction(_) + | Statement::TSEnumDeclaration(_) + | Statement::TSModuleDeclaration(_) + | Statement::ReturnStatement(_) + | Statement::ExportNamedDeclaration(_) + | Statement::ExportDefaultDeclaration(_) + | Statement::ExportAllDeclaration(_) + | Statement::TSExportAssignment(_) + | Statement::TSNamespaceExportDeclaration(_) + | Statement::ImportDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) + | Statement::ThrowStatement(_) + | Statement::BreakStatement(_) + | Statement::ContinueStatement(_) + | Statement::EmptyStatement(_) + | Statement::DebuggerStatement(_) => {} + } + self.close(id); + } + + /// Visit a slice of statements under a parent. + fn visit_statements(&mut self, stmts: &[Statement<'_>], parent: NodeId) { + for stmt in stmts { + self.visit_statement(stmt, parent); + } + } + + /// Visit a `VariableDeclaration` node (used by a `for` init clause, which is + /// not itself a `Statement`). + 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); + } + + /// Visit the declarators of a `VariableDeclaration` (already-assigned parent). + fn visit_declarators(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) { + for declarator in decl.declarations { + self.visit_declarator(declarator, parent); + } + } + + /// Visit one declarator, recording its declared-name identifier when the + /// binding is a plain identifier (destructuring patterns are a later slice). + fn visit_declarator(&mut self, declarator: &VariableDeclarator<'_>, parent: NodeId) { + let id = self.add( + NodeKind::VariableDeclarator, + declarator.span, + Some(parent), + addr_of(declarator), + ); + if let Expression::Identifier(name) = &declarator.id { + self.visit_identifier(name, id); + } + self.close(id); + } + + /// Visit a `for-in`/`for-of` left-hand side (its declarator id when it is a + /// variable declaration). + fn visit_for_left(&mut self, left: &ForInOfLeft<'_>, parent: NodeId) { + if let ForInOfLeft::VariableDeclaration(decl) = left { + self.visit_variable_declaration(decl, parent); + } + } + + /// Visit a declared-name identifier (a leaf). + fn visit_identifier(&mut self, ident: &tsv_ts::ast::Identifier<'_>, parent: NodeId) { + let id = self.add(NodeKind::Identifier, ident.span, Some(parent), addr_of(ident)); + self.close(id); + } +} + +/// The [`NodeKind`] for a statement variant. +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, + } +} + +#[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, FileId::ROOT) + } + + #[test] + fn preorder_ids_parents_and_kinds() { + // Program(1) -> VariableDeclaration(2) -> VariableDeclarator(3) -> Identifier(4) + let bound = bind("const x = 1;"); + assert_eq!(bound.node_count, 4); + 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.parents[0], None); + assert_eq!(bound.parents[1], Some(NodeId::FIRST)); // parent is Program + assert_eq!(bound.parents[3], Some(NodeId::from_index(2))); // Identifier under declarator + } + + #[test] + fn subtree_end_enables_descendant_test() { + let bound = bind("const x = 1;"); + let root = NodeId::FIRST; + let ident = NodeId::from_index(3); + let decl = NodeId::from_index(1); + // Whole program's subtree ends at the identifier. + assert_eq!(bound.subtree_end[root.index()], ident); + 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, FileId::ROOT); + // The second top-level statement's address resolves to its id. + let second = &program.body[1]; + let addr = std::ptr::from_ref(second) as usize; + let id = bound.address_map.get(&addr).copied().expect("mapped"); + assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration); + } + + #[test] + fn nested_statements_are_walked() { + // Program, function decl, its id, and the nested return statement. + let bound = bind("function f() { return; }"); + assert!(bound.kinds.contains(&NodeKind::FunctionDeclaration)); + assert!(bound.kinds.contains(&NodeKind::ReturnStatement)); + // The return sits inside the function's pre-order subtree. + 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 module_ness_detects_exports() { + assert_eq!(bind("export const x = 1;").facts.module_ness, ModuleNess::Module); + assert_eq!(bind("const x = 1;").facts.module_ness, ModuleNess::Script); + } +} diff --git a/crates/tsv_check/src/diag.rs b/crates/tsv_check/src/diag.rs new file mode 100644 index 000000000..38dde22fa --- /dev/null +++ b/crates/tsv_check/src/diag.rs @@ -0,0 +1,412 @@ +//! 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(), + } + } +} + +/// The diagnostic's sort path: the file's name, or `""` for a global one. +fn diag_path<'a>(d: &Diagnostic, paths: &'a [String]) -> &'a str { + match d.file { + Some(f) => paths.get(f.index()).map_or("", String::as_str), + 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: &[String]) -> 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: &[String]) -> 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: &[String]) -> 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: &[String]) -> 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: &[String]) { + 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: &[String]) { + 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: &[String]) { + diags.dedup_by(|a, b| equal_diagnostics(a, b, paths)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths() -> Vec { + vec!["a.ts".to_string(), "b.ts".to_string()] + } + + 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 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/hash.rs b/crates/tsv_check/src/hash.rs new file mode 100644 index 000000000..312aba26c --- /dev/null +++ b/crates/tsv_check/src/hash.rs @@ -0,0 +1,162 @@ +//! A hand-rolled Fx-style multiply-xor hasher for the checker's integer-keyed +//! maps. +//! +//! std's SipHash is DoS-resistant but slow for the small integer keys (node +//! ids, symbol ids, arena addresses) the checker hashes on its hot paths. This +//! is the well-known Fx fold — rotate, xor the next word, multiply by a fixed +//! odd constant — re-implemented in ~20 lines with no external dependency (the +//! crate-private infrastructure the design calls for). It is deliberately +//! **not** DoS-resistant: these maps are fed program-internal ids and addresses, +//! never adversarial network input. The fold consumes fixed 8-byte little-endian +//! words, so a 32-bit wasm target and a 64-bit native target produce identical +//! hashes. +// +// tsgo uses xxh3-128 for variable-arity list hashing; the Fx fold is tsv's +// hand-rolled substitute here (no new dependency). + +use std::collections::{HashMap, HashSet}; +use std::hash::{BuildHasherDefault, Hasher}; + +/// The Fx seed: a fixed odd 64-bit constant (the fractional bits of the golden +/// ratio — the constant rustc-hash uses). +const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95; + +/// The per-step left rotation. +const ROTATE: u32 = 5; + +/// A fast, non-cryptographic hasher over the Fx multiply-xor fold. +#[derive(Default)] +pub struct FxHasher { + hash: u64, +} + +impl FxHasher { + /// Fold one 64-bit word into the running hash. + #[inline] + fn fold(&mut self, word: u64) { + self.hash = (self.hash.rotate_left(ROTATE) ^ word).wrapping_mul(SEED); + } +} + +impl Hasher for FxHasher { + #[inline] + fn write(&mut self, bytes: &[u8]) { + let mut chunks = bytes.chunks_exact(8); + for chunk in &mut chunks { + // `chunks_exact(8)` yields slices of exactly 8 bytes, so the array + // conversion always succeeds; the fallback keeps this total. + let word = u64::from_le_bytes(<[u8; 8]>::try_from(chunk).unwrap_or([0; 8])); + self.fold(word); + } + let rest = chunks.remainder(); + if !rest.is_empty() { + let mut buf = [0u8; 8]; + buf[..rest.len()].copy_from_slice(rest); + self.fold(u64::from_le_bytes(buf)); + } + } + + #[inline] + fn write_u8(&mut self, i: u8) { + self.fold(u64::from(i)); + } + + #[inline] + fn write_u16(&mut self, i: u16) { + self.fold(u64::from(i)); + } + + #[inline] + fn write_u32(&mut self, i: u32) { + self.fold(u64::from(i)); + } + + #[inline] + fn write_u64(&mut self, i: u64) { + self.fold(i); + } + + #[inline] + fn write_usize(&mut self, i: usize) { + self.fold(i as u64); + } + + #[inline] + fn finish(&self) -> u64 { + self.hash + } +} + +/// The `BuildHasher` for [`FxHasher`] (zero-state, so `Default`-constructible). +pub type FxBuildHasher = BuildHasherDefault; + +/// A `HashMap` keyed through the Fx fold. +pub type FxHashMap = HashMap; + +/// A `HashSet` keyed through the Fx fold. Part of the map/set alias pair; the +/// binder uses `FxHashMap` today, and scope-membership sets will use this. +#[allow(dead_code)] +pub type FxHashSet = HashSet; + +#[cfg(test)] +mod tests { + use super::*; + use std::hash::Hash; + + /// Re-derive one word's fold by hand — pins the algorithm (rotate-xor-mul by + /// `SEED`), not just self-consistency. + #[test] + fn write_u32_matches_manual_fold() { + let mut h = FxHasher::default(); + h.write_u32(0xdead_beef); + let expected = (0u64.rotate_left(ROTATE) ^ 0xdead_beef_u64).wrapping_mul(SEED); + assert_eq!(h.finish(), expected); + } + + /// Two folds compose in order (the running hash is carried, not reset). + #[test] + fn two_writes_fold_in_order() { + let mut h = FxHasher::default(); + h.write_u32(1); + h.write_u32(2); + let s1 = (0u64.rotate_left(ROTATE) ^ 1).wrapping_mul(SEED); + let s2 = (s1.rotate_left(ROTATE) ^ 2).wrapping_mul(SEED); + assert_eq!(h.finish(), s2); + } + + /// `write` folds full 8-byte words then a zero-padded tail. + #[test] + fn write_bytes_chunks_then_tail() { + let mut h = FxHasher::default(); + h.write(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + let w1 = u64::from_le_bytes([1, 2, 3, 4, 5, 6, 7, 8]); + let w2 = u64::from_le_bytes([9, 10, 11, 12, 0, 0, 0, 0]); + let s1 = (0u64.rotate_left(ROTATE) ^ w1).wrapping_mul(SEED); + let s2 = (s1.rotate_left(ROTATE) ^ w2).wrapping_mul(SEED); + assert_eq!(h.finish(), s2); + } + + /// A single scalar hashes the same through the derived `Hash` impl. + #[test] + fn hash_trait_routes_through_fold() { + let mut h = FxHasher::default(); + 0x0063_u32.hash(&mut h); + let expected = (0u64.rotate_left(ROTATE) ^ 0x0063_u64).wrapping_mul(SEED); + assert_eq!(h.finish(), expected); + } + + #[test] + fn map_and_set_round_trip() { + let mut m: FxHashMap = FxHashMap::default(); + m.insert(7, 70); + m.insert(9, 90); + assert_eq!(m.get(&7), Some(&70)); + assert_eq!(m.get(&9), Some(&90)); + assert_eq!(m.get(&8), None); + + let mut s: FxHashSet = FxHashSet::default(); + s.insert(0xabcd); + assert!(s.contains(&0xabcd)); + assert!(!s.contains(&0x1234)); + } +} diff --git a/crates/tsv_check/src/ids.rs b/crates/tsv_check/src/ids.rs new file mode 100644 index 000000000..65da57ceb --- /dev/null +++ b/crates/tsv_check/src/ids.rs @@ -0,0 +1,108 @@ +//! 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 + /// forbids `unwrap`/`panic`). + #[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 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..592266d2c --- /dev/null +++ b/crates/tsv_check/src/lib.rs @@ -0,0 +1,60 @@ +//! `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 (one fused pre-order walk per file) [binder] +//! -> check (no-op skeleton) [program] +//! -> sort + dedup (tsgo's comparer) [diag] +//! -> owned diagnostics +//! ``` +//! +//! [`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. +//! - `hash` (private) — the crate's Fx-style hasher and `FxHashMap`/`FxHashSet`. +//! - `binder` (private) — the fused lower+bind pre-order walk. +//! - `program` (private) — pipeline assembly and the parse-error short-circuit. +//! +//! ## 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 hash; +mod program; + +pub mod diag; +pub mod ids; + +pub use binder::{ + bind_file, module_ness, BoundFile, FileFacts, ModuleNess, NodeKind, +}; +pub use diag::{Category, Diagnostic}; +pub use ids::{FileId, NodeId}; +pub use program::{ + check_program, CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, +}; + +// 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/program.rs b/crates/tsv_check/src/program.rs new file mode 100644 index 000000000..b71aeaef2 --- /dev/null +++ b/crates/tsv_check/src/program.rs @@ -0,0 +1,268 @@ +//! Program pipeline assembly: parse (goal rule) -> bind -> check -> sort/dedup. +//! +//! Ports the shape of tsgo's `GetDiagnosticsOfAnyProgram`, in particular the +//! **parse-error short-circuit**: when any unit fails to parse, the program +//! emits no bind/check diagnostics at all (tsgo `program.go:1770` — the +//! syntactic-append-added-nothing guard). This matters because tsv's parser is +//! deliberately permissive: on a program tsgo parse-rejects, tsv can parse +//! clean and would otherwise emit family diagnostics the baseline lacks. tsv's +//! suppression mirrors the short-circuit exactly — a parse rejection anywhere +//! yields zero semantic output for the program. +//! +//! 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/compiler/program.go GetDiagnosticsOfAnyProgram (:1755), +// the syntactic short-circuit at :1770; the bind-then-check concat at +// getBindAndCheckDiagnosticsWithChecker (:1337); final sort+dedup is +// caller-side (execute/tsc/emit.go:120). + +use crate::binder::{bind_file, module_ness, ModuleNess}; +use crate::diag::{sort_and_deduplicate, Diagnostic}; +use crate::ids::FileId; +use bumpalo::Bump; +use tsv_ts::ast::Program; +use tsv_ts::{parse_with_goal, 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, a +/// per-unit report, and whether the parse-error short-circuit fired. +pub struct CheckResult { + /// Diagnostics in canonical sorted order — always empty at this slice (the + /// checker is a no-op), and empty by construction whenever `parse_rejected`. + pub diagnostics: Vec, + /// Per-unit parse/bind report, in input order. + pub files: Vec, + /// Whether any unit parse-rejected (the program short-circuit fired). + 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. +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. +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, +} + +/// Check a program: parse every unit via the goal rule, and — unless any unit +/// parse-rejects — bind and check each, concatenating diagnostics and returning +/// them in canonical sorted order. +#[must_use] +pub fn check_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> CheckResult { + let mut attempts: Vec> = Vec::with_capacity(units.len()); + let mut parse_rejected = false; + + 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)) => attempts.push(Attempt { + file, + name: unit.name, + goal, + used_script_retry, + module_ness: module_ness(&program), + program: Some(program), + message: None, + node_count: 0, + }), + Err(message) => { + parse_rejected = true; + attempts.push(Attempt { + file, + name: unit.name, + goal: Goal::Module, + used_script_retry: false, + module_ness: ModuleNess::Script, + program: None, + message: Some(message), + node_count: 0, + }); + } + } + } + + // The parse-error short-circuit: any rejection -> no bind/check diagnostics. + let mut diagnostics: Vec = Vec::new(); + if !parse_rejected { + for attempt in &mut attempts { + if let Some(program) = &attempt.program { + let bound = bind_file(program, attempt.file); + attempt.node_count = bound.node_count; + // Per file: bind diagnostics then check diagnostics (both empty + // at this slice) — the getBindAndCheckDiagnostics concat. + let check_diags = check_file(&bound); + diagnostics.extend(bound.diagnostics); + diagnostics.extend(check_diags); + } + } + // Final caller-side sort + dedup over the whole program's diagnostics. + let paths: Vec = units.iter().map(|u| u.name.to_string()).collect(); + sort_and_deduplicate(&mut diagnostics, &paths); + } + + let files = attempts.into_iter().map(Attempt::into_report).collect(); + CheckResult { diagnostics, files, parse_rejected } +} + +/// Check one bound file — a no-op skeleton (no semantic diagnostics yet). +fn check_file(bound: &crate::binder::BoundFile) -> Vec { + // The checker is not built yet; the seam exists so the pipeline is proven + // end-to-end. The bound columns are available here for the future checker. + let _ = bound; + Vec::new() +} + +/// 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()), + }, + } +} + +/// The mutable per-unit state carried from parse through bind into the report. +struct Attempt<'a> { + file: FileId, + name: &'a str, + goal: Goal, + used_script_retry: bool, + module_ness: ModuleNess, + program: Option>, + message: Option, + node_count: u32, +} + +impl Attempt<'_> { + fn into_report(self) -> FileReport { + let parse = match self.message { + Some(message) => ParseReport::Rejected { message }, + None => ParseReport::Parsed(ParsedFacts { + goal: self.goal, + used_script_retry: self.used_script_retry, + module_ness: self.module_ness, + node_count: self.node_count, + }), + }; + FileReport { file: self.file, name: self.name.to_string(), parse } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(source: &str) -> CheckResult { + let arena = Bump::new(); + check_program(&[SourceUnit::new("test.ts", source)], &arena) + } + + #[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_short_circuits() { + // A hard syntax error both goals reject. + 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 multi_unit_short_circuit_is_program_wide() { + // One clean unit, one rejected unit -> the whole program short-circuits. + let arena = Bump::new(); + let result = check_program( + &[SourceUnit::new("a.ts", "const x = 1;"), SourceUnit::new("b.ts", "const = ;")], + &arena, + ); + assert!(result.parse_rejected); + assert!(result.diagnostics.is_empty()); + assert_eq!(result.files.len(), 2); + assert!(matches!(result.files[0].parse, ParseReport::Parsed(_))); + assert!(matches!(result.files[1].parse, ParseReport::Rejected { .. })); + } +} diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index c404f81f7..94ff47f87 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -15,7 +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 / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. - `fixtures/` — The fixture workflow surface — `model.rs` holds the data model (`InputType`, `Fixture`, divergence-suffix rules), `discovery.rs` walks the fixtures tree, `variants.rs` discovers variant files, `mod.rs` keeps IO/parse helpers, `validation/` is the validator (`fixtures_validate`: structure rules, per-phase checks, errors, summary), `audit_signature.rs` pins prettier's multi-pass chain. - `test262/` — ECMAScript conformance runner: `discovery.rs` walks the test262 tree, `frontmatter.rs` parses the YAML harness metadata, `runner.rs` drives our parser (`run_test`) and grades the strict subset into the differential `Manifest` (`grade_for_manifest`, sharing the `classify` skip logic with `run_test`). Pure Rust — no Deno needed. The `test262 --emit-manifest` JSON feeds `benches/js/diagnostics/test262_compare.ts` for the tsv-vs-oxc-parser comparison. -- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates. Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. +- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `runner.rs` drives the `tsv_check` crate over the in-scope corpus (`run` = the walking-skeleton sweep with catch_unwind containment + the parse-divergence census; `check_one` backs `check-test`, the one-test dev loop). `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. - `diff.rs` — Colored text / JSON diffing with line-width annotations (used by `compare`, `ast_diff`, validation failures). - `error.rs` — `DebugError` — wraps `DenoError`, `io::Error`, `serde_json::Error`; exposes `hint()` for actionable messages. - `cli/` — argh `TopLevel`/`Subcommand` dispatch in `mod.rs` + per-command `FromArgs` modules under `cli/commands/`. Shared three-mode input plumbing (file path / `--content` / `--stdin`) comes from `tsv_cli::cli::input::InputArgs`. diff --git a/crates/tsv_debug/Cargo.toml b/crates/tsv_debug/Cargo.toml index b845e27ba..4868b2afc 100644 --- a/crates/tsv_debug/Cargo.toml +++ b/crates/tsv_debug/Cargo.toml @@ -18,6 +18,7 @@ swallow_check = ["tsv_lang/swallow_check"] [dependencies] tsv_cli = { path = "../tsv_cli" } +tsv_check = { workspace = true } tsv_lang = { workspace = true } tsv_svelte = { workspace = true, features = ["convert"] } tsv_ts = { workspace = true, features = ["convert", "debug_lex"] } diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index b1cda0e83..c3c65fecc 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -6,9 +6,10 @@ use crate::cli::CliError; use crate::tsc_conformance::index::IndexReport; +use crate::tsc_conformance::runner::SkeletonReport; use crate::tsc_conformance::{ - baselines_dir, corpus_materialized, denominators, discover_baselines, histogram, run_index, - run_roundtrip, tests_by_code, + baselines_dir, check_one, corpus_materialized, denominators, discover_baselines, histogram, + run_index, run_roundtrip, run_skeleton, tests_by_code, }; use argh::FromArgs; use std::path::PathBuf; @@ -67,6 +68,27 @@ const INDEX_JOIN_MATCHED_PIN: usize = 7033; const INDEX_UNIT_ROUNDTRIP_PIN: usize = 7019; const INDEX_UNIT_ROUNDTRIP_PRETTY_PIN: usize = 14; +/// REGRESSION PINS (exact, two-sided) for the walking-skeleton sweep (`run`). +/// Measured 2026-07-10, ../typescript-go at 168e7015 (`_submodules/TypeScript` +/// corpus materialized). The checker emits nothing yet, so the meaningful gate +/// is `clean_pass == expect_clean_graded` with zero panics; the counts below pin +/// the in-scope denominators + parse-divergence census so any drift (a +/// harness-port change, a tsv parser change, or a typescript-go pull) forces a +/// deliberate re-pin. +const RUN_IN_SCOPE_TESTS_PIN: usize = 9388; +const RUN_IN_SCOPE_VARIANTS_PIN: usize = 9887; +const RUN_EXPECT_CLEAN_PIN: usize = 4435; +const RUN_BASELINED_PARSED_PIN: usize = 4446; +const RUN_PARSE_REJECTED_PIN: usize = 1006; +const RUN_PARSE_REJECTED_NO_BASELINE_PIN: usize = 45; +const RUN_PARSE_REJECTED_TS1XXX_PIN: usize = 451; +const RUN_PARSE_REJECTED_OTHER_PIN: usize = 510; +const RUN_SCRIPT_RETRY_PIN: usize = 25; +/// Tracked parser crashes carved out of the sweep (the `CRASH_EXCLUSIONS` +/// ledger). Pinned so the ledger can't grow or shrink silently — a move means a +/// tsv parser robustness change (a fix removes an entry; a regression adds one). +const RUN_CRASH_EXCLUDED_PIN: usize = 1; + /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "tsc_conformance")] @@ -81,6 +103,8 @@ enum TscConformanceSub { Query(QueryCommand), Roundtrip(RoundtripCommand), Index(IndexCommand), + Run(RunCommand), + CheckTest(CheckTestCommand), } /// Answer an ad-hoc question over the baselines. @@ -152,16 +176,188 @@ pub struct IndexCommand { verbose: bool, } +/// Walking-skeleton sweep (the S2 gate): drive `tsv_check` over every in-scope +/// variant (single-file, non-JSX, non-JS-flavored, not skipped, not an +/// unsupported-option variant) and grade the checker plumbing end-to-end. The +/// checker emits nothing yet, so the gate is: every expect-clean in-scope +/// variant grades clean (zero diagnostics), zero panics, and the pinned +/// denominators + parse-divergence census hold. Runs on a generous-stack worker +/// thread; each test's check is `catch_unwind`-contained. Exit 0 only when the +/// invariants hold and (on a full run) the pins match. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "run")] +pub struct RunCommand { + /// path to the typescript-go checkout (default: ../typescript-go) + #[argh(option, default = "PathBuf::from(\"../typescript-go\")")] + path: PathBuf, + + /// emit a JSON report instead of the human summary + #[argh(switch)] + json: bool, +} + +/// Inner dev loop: run one corpus test (optionally one variant) through +/// `tsv_check` and print our diagnostics vs the baseline summary. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "check-test")] +pub struct CheckTestCommand { + /// path to the typescript-go checkout (default: ../typescript-go) + #[argh(option, default = "PathBuf::from(\"../typescript-go\")")] + path: PathBuf, + + /// select one variant, `key=value` (e.g. `target=es2015`) + #[argh(option)] + variant: Option, + + /// emit a JSON report instead of the human diff + #[argh(switch)] + json: bool, + + /// the test to run (exact relative path or basename) + #[argh(positional)] + name: String, +} + impl TscConformanceCommand { pub(crate) fn run(self) -> Result<(), CliError> { match self.nested { TscConformanceSub::Query(query) => query.run(), TscConformanceSub::Roundtrip(rt) => rt.run(), TscConformanceSub::Index(index) => index.run(), + TscConformanceSub::Run(run) => run.run(), + TscConformanceSub::CheckTest(check) => check.run(), + } + } +} + +impl RunCommand { + fn run(self) -> Result<(), CliError> { + require_corpus(&self.path)?; + let report = run_skeleton(&self.path).map_err(|e| { + eprintln!("Error running skeleton sweep: {e}"); + CliError::Failed + })?; + if self.json { + print_json(&report)?; + } else { + report.print(); + } + enforce_run_gates(&report) + } +} + +/// Enforce the skeleton gates: the clean-grade invariant, zero panics, and (a +/// full run's) exact denominator + census pins. +fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { + let mut errs: Vec = Vec::new(); + + // Invariant gates (always). + if report.clean_pass != report.expect_clean_graded { + errs.push(format!( + "clean pass {} != expect-clean graded {} ({} non-clean)", + report.clean_pass, + report.expect_clean_graded, + report.clean_fail.len() + )); + } + if !report.panics.is_empty() { + errs.push(format!( + "{} test(s) panicked, e.g. {}", + report.panics.len(), + report.panics.first().map_or("", |p| p.test.as_str()) + )); + } + + // Exact two-sided pins. + let pin = |errs: &mut Vec, label: &str, got: usize, want: usize| { + if got != want { + errs.push(format!("{label} {got} != pinned {want}")); } + }; + pin(&mut errs, "in-scope tests", report.in_scope_tests, RUN_IN_SCOPE_TESTS_PIN); + pin(&mut errs, "in-scope variants", report.in_scope_variants, RUN_IN_SCOPE_VARIANTS_PIN); + pin(&mut errs, "expect-clean graded", report.expect_clean_graded, RUN_EXPECT_CLEAN_PIN); + pin(&mut errs, "clean pass", report.clean_pass, RUN_EXPECT_CLEAN_PIN); + pin(&mut errs, "baselined parsed", report.baselined_parsed, RUN_BASELINED_PARSED_PIN); + pin(&mut errs, "parse-rejected", report.parse_rejected_total, RUN_PARSE_REJECTED_PIN); + pin( + &mut errs, + "parse-rejected (no baseline)", + report.parse_rejected_no_baseline, + RUN_PARSE_REJECTED_NO_BASELINE_PIN, + ); + pin( + &mut errs, + "parse-rejected (TS1xxx-only)", + report.parse_rejected_ts1xxx_only, + RUN_PARSE_REJECTED_TS1XXX_PIN, + ); + pin( + &mut errs, + "parse-rejected (other)", + report.parse_rejected_other, + RUN_PARSE_REJECTED_OTHER_PIN, + ); + pin(&mut errs, "script retries", report.script_retry, RUN_SCRIPT_RETRY_PIN); + pin(&mut errs, "crash-excluded", report.excluded_crashes, RUN_CRASH_EXCLUDED_PIN); + + if errs.is_empty() { + Ok(()) + } else { + eprintln!( + "\nError: {}. If deliberate (a harness-port change, a tsv parser change, or a \ + typescript-go pull), re-pin the RUN_* constants.", + errs.join("; ") + ); + Err(CliError::Failed) } } +impl CheckTestCommand { + fn run(self) -> Result<(), CliError> { + require_corpus(&self.path)?; + let variant = match self.variant.as_deref().map(parse_variant_filter) { + Some(Ok(v)) => Some(v), + Some(Err(e)) => { + eprintln!("{e}"); + return Err(CliError::Failed); + } + None => None, + }; + let report = check_one(&self.path, &self.name, variant).map_err(|e| { + eprintln!("Error: {e}"); + CliError::Failed + })?; + if self.json { + print_json(&report) + } else { + report.print(); + Ok(()) + } + } +} + +/// Parse a `key=value` variant filter. +fn parse_variant_filter(arg: &str) -> Result<(String, String), String> { + arg.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .ok_or_else(|| format!("Error: --variant expects key=value, got {arg:?}")) +} + +/// Fail (with the submodule hint) when the corpus inputs are not materialized — +/// both `run` and `check-test` need them, unlike the baseline-only tools. +fn require_corpus(path: &std::path::Path) -> Result<(), CliError> { + if corpus_materialized(path) { + return Ok(()); + } + eprintln!( + "Error: the tsc corpus inputs are not materialized under {}.", + path.display() + ); + eprintln!("Run `git submodule update --init` in ../typescript-go to materialize them."); + Err(CliError::Failed) +} + impl IndexCommand { fn run(self) -> Result<(), CliError> { // The corpus inputs must be materialized (unlike the baseline-only query diff --git a/crates/tsv_debug/src/tsc_conformance/index.rs b/crates/tsv_debug/src/tsc_conformance/index.rs index f59ad8d4b..5cba5c56f 100644 --- a/crates/tsv_debug/src/tsc_conformance/index.rs +++ b/crates/tsv_debug/src/tsc_conformance/index.rs @@ -451,8 +451,9 @@ fn split_content_lines(content: &str) -> Vec { } /// Whether a test is JSX-scoped: a `.tsx` file, an `@jsx` directive, or a path -/// under a `jsx/` directory. -fn is_jsx_scoped(test: &CorpusTest, settings: &BTreeMap) -> bool { +/// under a `jsx/` directory. Shared with the skeleton runner's in-scope predicate. +#[must_use] +pub fn is_jsx_scoped(test: &CorpusTest, settings: &BTreeMap) -> bool { test.extension == "tsx" || settings.contains_key("jsx") || test.relative_path.contains("/jsx/") @@ -460,7 +461,9 @@ fn is_jsx_scoped(test: &CorpusTest, settings: &BTreeMap) -> bool } /// Whether a test is JS-flavored: `@checkJs` / `@allowJs`, or a `.js` file. -fn is_js_flavored(test: &CorpusTest, settings: &BTreeMap) -> bool { +/// Shared with the skeleton runner's in-scope predicate. +#[must_use] +pub fn is_js_flavored(test: &CorpusTest, settings: &BTreeMap) -> bool { settings.contains_key("checkjs") || settings.contains_key("allowjs") || test.extension == "js" } diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 53d8535ad..244d01a7c 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -29,9 +29,11 @@ pub mod pretty; pub mod query; pub mod render; pub mod roundtrip; +pub mod runner; pub mod variants; pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; +pub use runner::{check_one, run_skeleton}; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs new file mode 100644 index 000000000..422adb5e5 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -0,0 +1,523 @@ +//! The walking-skeleton runner: drive `tsv_check` over the in-scope corpus and +//! grade the checker plumbing end-to-end. +//! +//! This is tool #4 of the harness (the runner) in its first form. It reuses the +//! S1 substrate — corpus index, directive parser, variant expansion, the +//! unsupported-option skip classes — and adds the checker leg: for every +//! **in-scope** variant (single-file, non-JSX, non-JS-flavored, not skipped, +//! not an unsupported-option variant) it parses the unit via `tsv_check`'s goal +//! rule, runs `check_program`, and grades the result. The checker emits nothing +//! yet, so the meaningful gate is that every **expect-clean** in-scope variant +//! (one with no on-disk baseline) grades clean (zero diagnostics) with zero +//! panics — proving the harness<->checker plumbing before any family gate. +//! +//! A single-file test's variants all parse identically (the goal rule is +//! directive-independent), so the parse+check runs **once per test** and its +//! outcome is attributed to each in-scope variant — correct while `check` is a +//! no-op and cheap regardless. +//! +//! The **parse-divergence census** (informational, not gated) counts in-scope +//! variants tsv parse-rejects, split by baseline shape (none / TS1xxx-only / +//! other), plus how many parses needed the `Goal::Script` retry — the standing +//! window on tsv's parser vs tsgo's implied parse verdict (a tsv over-rejection +//! shows up as a rejected variant against an absent-or-non-1xxx baseline). +//! +//! Crash containment: the whole sweep runs on a generous-stack worker thread +//! (the corpus has pathological-nesting tests and tsv's parser has no depth +//! guard), and each test's check is wrapped in `catch_unwind` so a panic lands +//! in its own bucket instead of killing the run. A stack-overflow *abort* can't +//! be caught; the [`CRASH_EXCLUSIONS`] list carves out any test that aborts even +//! the big stack (empty on the pinned corpus). +// +// tsgo: internal/compiler/program.go GetDiagnosticsOfAnyProgram (the pipeline) +// tsgo: internal/testrunner/compiler_runner.go (the in-scope selection) + +use crate::tsc_conformance::baseline::parse_summary_block; +use crate::tsc_conformance::corpus::{discover_corpus, read_corpus_file, CorpusTest}; +use crate::tsc_conformance::directives::{extract_settings, split_units, Unit}; +use crate::tsc_conformance::discovery::{baselines_dir, discover_baselines, Baseline}; +use crate::tsc_conformance::index::{is_js_flavored, is_jsx_scoped}; +use crate::tsc_conformance::options_meta::{ + is_config_file_name, variant_is_unsupported, SKIPPED_TESTS, +}; +use crate::tsc_conformance::variants::{config_name, expand, Variant}; +use bumpalo::Bump; +use std::collections::HashMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::Path; +use std::time::Instant; +use tsv_check::{check_program, ParseReport, SourceUnit}; + +/// Worker-thread stack for the sweep: the corpus has deeply-nested tests and +/// tsv's recursive-descent parser has no depth guard, so the default 8 MiB +/// overflows. 512 MiB is virtual-only reserve on Linux. +const SKELETON_STACK: usize = 512 * 1024 * 1024; + +/// Tests that crash the tsv parser — carved out by basename, counted, and +/// reported (never silently). Two crash classes land here: uncatchable +/// stack-overflow aborts (even on [`SKELETON_STACK`]), and debug-build +/// `debug_assert!` panics in `tsv_ts` that `catch_unwind` *does* contain but +/// which are tracked parser bugs to fix rather than absorb. Each entry names its +/// cause; the list is a tracked-defect ledger, not a way to hide bugs. +const CRASH_EXCLUSIONS: &[&str] = &[ + // tsv_ts robustness bug: `export * from ;` (a non-string module + // specifier) trips a `debug_assert!(TokenKind::String)` in + // `parse_string_literal` (parser/mod.rs). Dev-profile only (debug_assert is + // compiled out in release), so `cargo run` — the gate's profile — panics. + // A future tsv_ts fix should reject the form gracefully; then drop this entry. + "exportDeclarationInInternalModule.ts", +]; + +/// One expect-clean variant that graded non-clean (should never happen while the +/// checker is a no-op — a non-empty list is a gate failure). +#[derive(Debug, Clone, serde::Serialize)] +pub struct CleanFail { + /// The `suite/config_name` baseline-space identity. + pub variant: String, + /// The number of diagnostics the checker (wrongly) emitted. + pub diagnostics: usize, +} + +/// One test whose check panicked (caught) — a gate failure. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PanicRecord { + /// The corpus test's relative path. + pub test: String, +} + +/// The skeleton sweep report. +#[derive(Debug, Clone, serde::Serialize, Default)] +pub struct SkeletonReport { + /// Tests that passed the test-level in-scope filter and have >=1 in-scope + /// variant. + pub in_scope_tests: usize, + /// In-scope variants graded (parsed or parse-rejected). + pub in_scope_variants: usize, + /// In-scope variants that parsed and have no on-disk baseline (expect-clean). + pub expect_clean_graded: usize, + /// Expect-clean variants that graded clean (zero diagnostics). Gate: must + /// equal `expect_clean_graded`. + pub clean_pass: usize, + /// Expect-clean variants that graded non-clean (gate failure list). + pub clean_fail: Vec, + /// In-scope variants that parsed and DO have a baseline (informational — the + /// checker emits nothing, so these would all be "missing"; not gated yet). + pub baselined_parsed: usize, + /// In-scope variants tsv parse-rejected (census; informational). + pub parse_rejected_total: usize, + /// ...of those, with no on-disk baseline (a likely tsv over-rejection). + pub parse_rejected_no_baseline: usize, + /// ...with a TS1xxx-only baseline (ambiguous: tsgo parse error or grammar). + pub parse_rejected_ts1xxx_only: usize, + /// ...with a baseline carrying non-TS1xxx codes (tsv rejects what tsgo checked). + pub parse_rejected_other: usize, + /// In-scope parsed variants that needed the `Goal::Script` retry (census). + pub script_retry: usize, + /// Tests whose check panicked (caught) and are NOT crash-excluded. Gate: + /// must be empty. + pub panics: Vec, + /// Tests skipped by the crash-exclusion ledger (tracked parser aborts/panics). + pub excluded_crashes: usize, + /// Total bound nodes across in-scope tests (informational). + pub total_nodes: u64, + /// Wall-clock of the sweep in milliseconds. + pub wall_ms: u128, +} + +/// The baseline shape used to bucket a parse-rejected variant. +enum BaselineShape { + None, + Ts1xxxOnly, + Other, +} + +/// Run the skeleton sweep on a generous-stack worker thread. +/// +/// # Errors +/// +/// Returns an error string if the worker cannot spawn, the worker panics +/// outside a contained per-test check, or corpus discovery fails. +pub fn run_skeleton(checkout: &Path) -> Result { + let checkout = checkout.to_path_buf(); + let handle = std::thread::Builder::new() + .stack_size(SKELETON_STACK) + .name("tsc-skeleton".to_string()) + .spawn(move || run_skeleton_inner(&checkout)) + .map_err(|e| format!("spawn skeleton worker: {e}"))?; + handle.join().map_err(|_| "skeleton worker panicked".to_string())? +} + +fn run_skeleton_inner(checkout: &Path) -> Result { + let start = Instant::now(); + let corpus = discover_corpus(checkout)?; + let baselines = discover_baselines(&baselines_dir(checkout))?; + + // Baseline lookup keyed by (suite, config-name) — exactly the runner's join. + let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); + for baseline in &baselines { + if let Some((suite, name)) = baseline.relative_path.split_once('/') { + ondisk.insert((suite, name.to_string()), baseline); + } + } + + let mut report = SkeletonReport::default(); + + for test in &corpus { + if SKIPPED_TESTS.contains(&test.basename.as_str()) { + continue; + } + if CRASH_EXCLUSIONS.contains(&test.basename.as_str()) { + report.excluded_crashes += 1; + continue; + } + + let content = read_corpus_file(&test.path)?; + let settings = extract_settings(&content); + let units = split_units(&content, &test.basename); + + // Test-level in-scope filter: single-file (one non-config unit), not + // JSX-scoped, not JS-flavored. + if units.len() != 1 || is_config_file_name(&units[0].name) { + continue; + } + if is_jsx_scoped(test, &settings) || is_js_flavored(test, &settings) { + continue; + } + + let expansion = expand(&settings); + if expansion.cap_exceeded { + continue; + } + let in_scope: Vec<&Variant> = + expansion.variants.iter().filter(|v| !variant_is_unsupported(&v.config)).collect(); + if in_scope.is_empty() { + continue; + } + + report.in_scope_tests += 1; + grade_test(test, &units[0], &in_scope, &ondisk, &mut report); + } + + report.wall_ms = start.elapsed().as_millis(); + Ok(report) +} + +/// Parse+check one single-file test once and attribute the outcome to each of +/// its in-scope variants. +fn grade_test( + test: &CorpusTest, + unit: &Unit, + in_scope: &[&Variant], + ondisk: &HashMap<(&str, String), &Baseline>, + report: &mut SkeletonReport, +) { + // Parse + check on a fresh arena, contained against panics. + let arena = Bump::new(); + let checked = catch_unwind(AssertUnwindSafe(|| { + check_program(&[SourceUnit::new(&unit.name, &unit.content)], &arena) + })); + let Ok(result) = checked else { + report.panics.push(PanicRecord { test: test.relative_path.clone() }); + return; + }; + + // The single unit's parse outcome (files is never empty for one input). + let Some(file) = result.files.first() else { return }; + for variant in in_scope { + report.in_scope_variants += 1; + let name = config_name(&test.basename, &variant.description); + let baseline = ondisk.get(&(test.suite, name.clone())).copied(); + + match &file.parse { + ParseReport::Rejected { .. } => { + report.parse_rejected_total += 1; + match baseline_shape(baseline) { + BaselineShape::None => report.parse_rejected_no_baseline += 1, + BaselineShape::Ts1xxxOnly => report.parse_rejected_ts1xxx_only += 1, + BaselineShape::Other => report.parse_rejected_other += 1, + } + } + ParseReport::Parsed(facts) => { + if facts.used_script_retry { + report.script_retry += 1; + } + if baseline.is_none() { + report.expect_clean_graded += 1; + if result.diagnostics.is_empty() { + report.clean_pass += 1; + } else { + report.clean_fail.push(CleanFail { + variant: format!("{}/{name}", test.suite), + diagnostics: result.diagnostics.len(), + }); + } + } else { + report.baselined_parsed += 1; + } + } + } + } + + // Node total: counted once per test (all variants share the parse). + if let ParseReport::Parsed(facts) = &file.parse { + report.total_nodes += u64::from(facts.node_count); + } +} + +/// Classify a parse-rejected variant's baseline shape for the census. +fn baseline_shape(baseline: Option<&Baseline>) -> BaselineShape { + let Some(baseline) = baseline else { + return BaselineShape::None; + }; + let Ok(content) = std::fs::read_to_string(&baseline.path) else { + return BaselineShape::Other; + }; + let diags = parse_summary_block(&content); + if !diags.is_empty() && diags.iter().all(|d| (1000..2000).contains(&d.code)) { + BaselineShape::Ts1xxxOnly + } else { + BaselineShape::Other + } +} + +impl SkeletonReport { + /// Print the human summary. + pub fn print(&self) { + println!("tsc_conformance — walking skeleton"); + println!("=================================="); + println!("In-scope tests: {}", self.in_scope_tests); + println!("In-scope variants: {}", self.in_scope_variants); + println!(" parsed, expect-clean: {}", self.expect_clean_graded); + println!(" graded clean: {}", self.clean_pass); + println!(" graded NON-clean: {}", self.clean_fail.len()); + println!(" parsed, baselined: {} (informational)", self.baselined_parsed); + println!(" parse-rejected: {}", self.parse_rejected_total); + println!(" no baseline: {}", self.parse_rejected_no_baseline); + println!(" TS1xxx-only baseline: {}", self.parse_rejected_ts1xxx_only); + println!(" other baseline: {}", self.parse_rejected_other); + println!("Script-goal retries: {}", self.script_retry); + println!("Bound nodes (total): {}", self.total_nodes); + println!(); + println!("Panics (caught): {}", self.panics.len()); + println!("Crash-excluded (tracked): {}", self.excluded_crashes); + println!("Wall-clock: {} ms", self.wall_ms); + if !self.clean_fail.is_empty() { + println!(); + for f in &self.clean_fail { + println!(" CLEAN-FAIL {} ({} diagnostics)", f.variant, f.diagnostics); + } + } + for p in &self.panics { + println!(" PANIC {}", p.test); + } + } +} + +// =========================================================================== +// check-test: the inner dev loop over one test. +// =========================================================================== + +/// One diagnostic line (ours or the baseline's) for the check-test diff. +#[derive(Debug, Clone, serde::Serialize)] +pub struct DiagLine { + /// The file the diagnostic points at (or `null` for a global one). + pub file: Option, + /// 1-based line (`null` for a global diagnostic). + pub line: Option, + /// 1-based column (`null` for a global diagnostic). + pub col: Option, + /// The `TS` number. + pub code: u32, +} + +/// The `check-test` report for one test/variant. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CheckTestReport { + /// The corpus test's relative path. + pub test: String, + /// The suite (`compiler` / `conformance`). + pub suite: String, + /// The variant description, or `(default)`. + pub variant: String, + /// The joined baseline name, or `None` when the variant is expect-clean. + pub baseline: Option, + /// Whether the variant is expect-clean (no on-disk baseline). + pub expect_clean: bool, + /// Whether tsv parse-rejected the program. + pub parse_rejected: bool, + /// The parse error message, when rejected. + pub parse_error: Option, + /// Our diagnostics (empty while the checker is a no-op). + pub ours: Vec, + /// The baseline's summary-block diagnostics (the expected set). + pub baseline_summary: Vec, +} + +/// Run one corpus test (optionally one variant) and build its check-test report. +/// +/// `name` matches a corpus test by exact relative path or exact basename. +/// +/// # Errors +/// +/// Returns an error string when the test is not found, the match is ambiguous, +/// the requested variant does not exist, or corpus discovery fails. +pub fn check_one( + checkout: &Path, + name: &str, + variant_filter: Option<(String, String)>, +) -> Result { + let corpus = discover_corpus(checkout)?; + let baselines = discover_baselines(&baselines_dir(checkout))?; + + let matches: Vec<&CorpusTest> = corpus + .iter() + .filter(|t| t.relative_path == name || t.basename == name) + .collect(); + let test = match matches.as_slice() { + [] => return Err(format!("no corpus test matches {name:?}")), + [one] => *one, + many => { + let paths: Vec = + many.iter().map(|t| format!("{}/{}", t.suite, t.relative_path)).collect(); + return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); + } + }; + + let content = read_corpus_file(&test.path)?; + let settings = extract_settings(&content); + let units = split_units(&content, &test.basename); + + // Pick the variant. + let expansion = expand(&settings); + let variant = select_variant(&expansion.variants, variant_filter.as_ref())?; + let baseline_name = config_name(&test.basename, &variant.description); + + // Join the baseline. + let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); + for baseline in &baselines { + if let Some((suite, n)) = baseline.relative_path.split_once('/') { + ondisk.insert((suite, n.to_string()), baseline); + } + } + let baseline = ondisk.get(&(test.suite, baseline_name.clone())).copied(); + + // Parse + check every unit (single- or multi-file). + let arena = Bump::new(); + let source_units: Vec> = + units.iter().map(|u| SourceUnit::new(&u.name, &u.content)).collect(); + let result = check_program(&source_units, &arena); + + let ours: Vec = result + .diagnostics + .iter() + .map(|d| DiagLine { + file: d.file.and_then(|f| source_units.get(f.index()).map(|u| u.name.to_string())), + line: None, + col: None, + code: d.code, + }) + .collect(); + let parse_error = result.files.iter().find_map(|f| match &f.parse { + ParseReport::Rejected { message } => Some(message.clone()), + ParseReport::Parsed(_) => None, + }); + + let baseline_summary = match baseline { + Some(b) => std::fs::read_to_string(&b.path) + .map(|c| { + parse_summary_block(&c) + .into_iter() + .map(|d| DiagLine { file: d.file, line: d.line, col: d.col, code: d.code }) + .collect() + }) + .unwrap_or_default(), + None => Vec::new(), + }; + + Ok(CheckTestReport { + test: test.relative_path.clone(), + suite: test.suite.to_string(), + variant: if variant.description.is_empty() { + "(default)".to_string() + } else { + variant.description.clone() + }, + baseline: baseline.map(|_| baseline_name), + expect_clean: baseline.is_none(), + parse_rejected: result.parse_rejected, + parse_error, + ours, + baseline_summary, + }) +} + +/// Select a variant by an optional `k=v` filter (config match, lowercased key); +/// with no filter the first (usually the unvaried) variant. +fn select_variant<'a>( + variants: &'a [Variant], + filter: Option<&(String, String)>, +) -> Result<&'a Variant, String> { + match filter { + None => variants.first().ok_or_else(|| "test has no variants".to_string()), + Some((key, value)) => { + let key = key.to_lowercase(); + variants + .iter() + .find(|v| v.config.get(&key).map(String::as_str) == Some(value.as_str())) + .ok_or_else(|| { + let available: Vec<&str> = variants + .iter() + .map(|v| { + if v.description.is_empty() { + "(default)" + } else { + &v.description + } + }) + .collect(); + format!("no variant with {key}={value}; available: {}", available.join(", ")) + }) + } + } +} + +impl CheckTestReport { + /// Print the human diff (ours vs the baseline summary). + pub fn print(&self) { + println!("check-test: {}/{} variant={}", self.suite, self.test, self.variant); + if self.parse_rejected { + println!( + " tsv PARSE-REJECTED: {}", + self.parse_error.as_deref().unwrap_or("(no message)") + ); + } + if self.expect_clean { + println!(" baseline: (none — expect-clean)"); + } else { + println!(" baseline: {}", self.baseline.as_deref().unwrap_or("?")); + } + println!(); + println!(" ours ({}):", self.ours.len()); + for d in &self.ours { + println!(" {}", fmt_diag(d)); + } + if self.ours.is_empty() { + println!(" (none)"); + } + println!(" baseline ({}):", self.baseline_summary.len()); + for d in &self.baseline_summary { + println!(" {}", fmt_diag(d)); + } + if self.baseline_summary.is_empty() { + println!(" (none)"); + } + } +} + +/// Format one diagnostic line for the human diff. +fn fmt_diag(d: &DiagLine) -> String { + match (&d.file, d.line, d.col) { + (Some(file), Some(line), Some(col)) => format!("{file}({line},{col}): TS{}", d.code), + _ => format!("error TS{} (global)", d.code), + } +} From 3616fa2194770866092485587b6474059e5946f3 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 14:21:41 -0400 Subject: [PATCH 15/79] =?UTF-8?q?feat:=20bind-time=20conflict=20family=20?= =?UTF-8?q?=E2=80=94=20declareSymbolEx=20cascade,=20Excludes=20masks,=20fa?= =?UTF-8?q?mily=20grading=20(match=20414,=20extra=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 12 +- Cargo.lock | 2 + crates/tsv_check/CLAUDE.md | 8 +- crates/tsv_check/Cargo.toml | 5 + crates/tsv_check/src/binder/atoms.rs | 81 + crates/tsv_check/src/binder/mod.rs | 401 +++- crates/tsv_check/src/binder/sym.rs | 1700 +++++++++++++++++ crates/tsv_check/src/binder/symbols.rs | 233 +++ crates/tsv_check/src/program.rs | 91 +- crates/tsv_debug/src/cli/commands/profile.rs | 57 +- .../src/cli/commands/tsc_conformance.rs | 65 + .../tsv_debug/src/tsc_conformance/runner.rs | 382 +++- 12 files changed, 2877 insertions(+), 160 deletions(-) create mode 100644 crates/tsv_check/src/binder/atoms.rs create mode 100644 crates/tsv_check/src/binder/sym.rs create mode 100644 crates/tsv_check/src/binder/symbols.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3459b37e7..3de51e9c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -819,10 +819,13 @@ cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by pat # pretty-path count — plus a 100% invariant, failing on any drift; a re-pin is # deliberate (a code change or a tsgo pull). -# run - the walking-skeleton gate over tsv_check: sweeps every in-scope variant +# run - the conformance gate over tsv_check: sweeps every in-scope variant # (single-file, non-JSX, non-JS-flavored, non-skipped), drives parse → lower+bind -# → no-op check → sort/dedup via the tsv_check crate, grades expect-clean variants -# (must be zero-diagnostic), and publishes the parse-divergence census (tsv +# → check → sort/dedup via the tsv_check crate, grades expect-clean variants +# (must be zero-diagnostic) AND the bind/merge duplicate-conflict family +# (TS2300/2451/2567/2528 + merge-path codes) as codes+spans multisets — extra=0 +# is a hard gate, missing is classified by deferred cause (check-time family / +# merge / lib) — and publishes the parse-divergence census (tsv # parse-rejections bucketed by baseline shape + Script-goal retries). Each test # runs catch_unwind-wrapped on a generous-stack worker; tracked parser crashes # live in a pinned CRASH_EXCLUSIONS ledger. Exact two-sided RUN_* pins on full @@ -864,7 +867,8 @@ their most salient format feature (a triage taxonomy, not a proof of cause). # profile - measure parse vs format phase timing (pure Rust, no Deno needed) cargo run -p tsv_debug profile ~/dev/zzz/src/lib # profile a directory cargo run -p tsv_debug profile file1.ts file2.svelte # profile specific files -# Options: --iterations (default: 10), --json +cargo run -p tsv_debug profile --bind ~/dev/zzz/src # parse vs lower+bind timing (TS-only) + peak RSS (VmHWM) +# Options: --iterations (default: 10), --json, --bind # json_profile - time the FFI parse path (parse + convert_ast_json_bytes) per # file: parse vs the wire-JSON write (the sole emission path — one internal-AST diff --git a/Cargo.lock b/Cargo.lock index 629d66b8a..7a8dc3f7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -671,6 +671,8 @@ name = "tsv_check" version = "0.1.0" dependencies = [ "bumpalo", + "smallvec", + "string-interner", "tsv_lang", "tsv_ts", ] diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 120e4e161..6a78bc66a 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -74,8 +74,12 @@ result is fully owned — nothing borrows out. - `tsv_debug tsc_conformance run` — the standing gate: sweeps the in-scope corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades - expect-clean variants, publishes the parse-divergence census; exact - `RUN_*` pins. + expect-clean variants AND the bind/merge duplicate-conflict family + (TS2300/2451/2567/2528 + merge-path codes) as codes+spans multisets + (extra = 0 is a hard gate; missing is classified by deferred cause), + publishes the parse-divergence census; exact `RUN_*` pins. +- `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 diff --git a/crates/tsv_check/Cargo.toml b/crates/tsv_check/Cargo.toml index 0edebf844..c47733b05 100644 --- a/crates/tsv_check/Cargo.toml +++ b/crates/tsv_check/Cargo.toml @@ -13,6 +13,11 @@ 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 binder's program-scoped name interner (its own instance, not the parser's +# per-document SharedInterner) and the per-symbol declaration list. Both vetted +# workspace deps. +string-interner = { workspace = true } +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..d3ebc0b6b --- /dev/null +++ b/crates/tsv_check/src/binder/atoms.rs @@ -0,0 +1,81 @@ +//! The binder's program-scoped 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. +//! +//! This is the checker's **own** interner (a fresh `string-interner` instance), +//! not the parser's per-document `SharedInterner` — their tenant lifecycles stay +//! decoupled. 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 string_interner::backend::StringBackend; +use string_interner::symbol::SymbolU32; +use string_interner::{StringInterner, Symbol}; + +/// A dense, program-scoped interned name identity. +/// +/// Equal atoms mean equal names — the symbol-table key. Wraps the interner's +/// `u32` symbol so distinct-name lookups are integer compares. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct Atom(u32); + +/// The binder's name interner (its own `string-interner` instance). +pub struct Atoms { + interner: StringInterner>, + /// 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 interner = StringInterner::>::new(); + let default = Atom(interner.get_or_intern("default").to_usize() as u32); + let export_equals = Atom(interner.get_or_intern("export=").to_usize() as u32); + Atoms { interner, default, export_equals } + } + + /// Intern a name to its [`Atom`]. + pub fn intern(&mut self, name: &str) -> Atom { + Atom(self.interner.get_or_intern(name).to_usize() as u32) + } + + /// 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 `get_or_intern`. + SymbolU32::try_from_usize(atom.0 as usize) + .and_then(|sym| self.interner.resolve(sym)) + .unwrap_or("") + } + + /// 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/mod.rs b/crates/tsv_check/src/binder/mod.rs index 70c26d82a..19f8040c3 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -1,29 +1,42 @@ -//! The fused lower+bind walk (skeleton). +//! The lower+bind pass: node identity (SoA columns) plus the symbol bind. //! -//! One pre-order walk per file assigns dense pre-order [`NodeId`]s to the node -//! kinds the checker addresses — for now statements, the declarations nested in -//! them, and declared-name identifiers — records each node's parent, kind, and -//! span in struct-of-arrays side columns, computes the pre-order `subtree_end` -//! interval (so "is X a descendant of Y" is an O(1) id-range test), and maps -//! each node's arena address to its id. This is tsc's own architecture made -//! eager: tsc's binder is a single walk that stamps parents and lazily mints -//! per-node ids into flat link arrays; we assign the ids eagerly in the same -//! walk (unobservable, and it makes every column dense from the start). Symbol -//! tables and bind diagnostics are later slices; this walk returns none. +//! Two cooperating walks run per file, kept in one module: +//! +//! - **the SoA walk** ([`SoaWalk`]) — one pre-order descent assigning dense +//! pre-order [`NodeId`]s to the node kinds the future checker addresses, +//! filling the parent/kind/span/`subtree_end` side columns and the address→id +//! map. Source order, so the `subtree_end` interval test (`is X a descendant of +//! Y`) stays valid. +//! - **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 (a best-effort link; +//! positions the SoA walk does not cover fall back to the root id — the id is not +//! consumed by the family cascade, which keys on name spans). //! //! **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, which is exactly the checking scope. 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. +//! 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. // -// tsgo: internal/binder/binder.go bindSourceFile / bindChildren / bindEachChild +// 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 symbols; +mod sym; + use crate::diag::Diagnostic; use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; @@ -31,9 +44,9 @@ use tsv_lang::Span; use tsv_ts::ast::internal::{ForInOfLeft, ForInit}; use tsv_ts::ast::{Expression, Program, Statement, VariableDeclaration, VariableDeclarator}; -/// The pre-order node kinds the skeleton walk assigns. Mirrors the tsv_ts -/// `Statement` variants plus the program root and declared-name identifiers; -/// the set grows as checks address more node kinds. +/// The pre-order node kinds the SoA walk assigns. Mirrors the tsv_ts `Statement` +/// variants plus the program root and declared-name identifiers; the set grows as +/// the checker addresses more node kinds. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[repr(u16)] pub enum NodeKind { @@ -76,14 +89,15 @@ pub enum NodeKind { DebuggerStatement, } -/// Whether a file is an external module (has a top-level `import`/`export`) — the -/// tsgo `externalModuleIndicator`, derived post-parse. Nothing consumes it yet; -/// recorded so the future binder's module-vs-script gating has the fact ready. +/// 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 `import`/`export` (or `export =` / `import =`). + /// Has a top-level module indicator (`import`/`export`/`export =`/an + /// `import =` with an external-module reference/`import.meta`). Module, - /// No module indicator at the top level. + /// No module indicator. Script, } @@ -94,8 +108,8 @@ pub struct FileFacts { pub module_ness: ModuleNess, } -/// The product of binding one file: the pre-order node columns, the -/// address->id map, per-file facts, and (empty for now) bind diagnostics. +/// 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, @@ -112,7 +126,8 @@ pub struct BoundFile { pub subtree_end: Vec, /// Node arena address -> id (the random-access escape hatch). pub address_map: FxHashMap, - /// Bind diagnostics — empty at this slice. + /// 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, @@ -127,59 +142,203 @@ impl BoundFile { } } -/// Derive a file's [`ModuleNess`] from its top-level statements (import/export -/// presence). A cheap body scan — no bind state needed. +/// 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. +/// +/// `source` and the program's interner are unused here (the indicators are all +/// structural); they are accepted for signature symmetry with the binder. #[must_use] pub fn module_ness(program: &Program<'_>) -> ModuleNess { for stmt in program.body { - if matches!( - stmt, - Statement::ImportDeclaration(_) - | Statement::TSImportEqualsDeclaration(_) - | Statement::ExportNamedDeclaration(_) - | Statement::ExportDefaultDeclaration(_) - | Statement::ExportAllDeclaration(_) - | Statement::TSExportAssignment(_) - | Statement::TSNamespaceExportDeclaration(_) - ) { + if is_external_module_indicator(stmt) { return ModuleNess::Module; } } + if program.body.iter().any(stmt_contains_import_meta) { + return ModuleNess::Module; + } ModuleNess::Script } -/// Bind one file: run the fused lower+bind walk and return its [`BoundFile`]. +/// 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, + tsv_ts::ast::internal::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>, file: FileId) -> BoundFile { - let mut binder = Binder::default(); - let root = binder.add(NodeKind::Program, program.span, None, addr_of(program)); +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)); for stmt in program.body { - binder.visit_statement(stmt, root); + walk.visit_statement(stmt, root); } - binder.close(root); + walk.close(root); + + let facts = FileFacts { module_ness: module_ness(program) }; + + // Pass 2: the symbol bind (functions-first, container-threaded). + let diagnostics = { + let interner = program.interner.borrow(); + let mut binder = sym::SymbolBinder::new(source, &interner, &walk.address_map, file, facts); + binder.bind_program(program); + binder.finish() + }; + BoundFile { file, - node_count: binder.kinds.len() as u32, - parents: binder.parents, - kinds: binder.kinds, - spans: binder.spans, - subtree_end: binder.subtree_end, - address_map: binder.address_map, - diagnostics: Vec::new(), - facts: FileFacts { module_ness: module_ness(program) }, + 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, } } /// 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] -fn addr_of(node: &T) -> usize { +pub(crate) fn addr_of(node: &T) -> usize { std::ptr::from_ref(node) as usize } -/// The mutable SoA columns being filled by the walk. +/// The mutable SoA columns being filled by pass 1. #[derive(Default)] -struct Binder { +struct SoaWalk { parents: Vec>, kinds: Vec, spans: Vec, @@ -187,15 +346,9 @@ struct Binder { address_map: FxHashMap, } -impl Binder { +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 { + 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); @@ -213,7 +366,7 @@ impl Binder { } /// Visit a statement: assign its id, then descend into the declarations and - /// nested statements the skeleton tracks. + /// nested statements the SoA walk tracks. 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 { @@ -270,9 +423,6 @@ impl Binder { self.visit_identifier(&labeled.label, id); self.visit_statement(labeled.body, id); } - // The remaining statement kinds carry no nested statement or - // declared-name node the skeleton tracks yet (expressions, types, - // module items, throw/return arguments): assigned an id, no descent. Statement::ExpressionStatement(_) | Statement::TSTypeAliasDeclaration(_) | Statement::TSInterfaceDeclaration(_) @@ -296,30 +446,24 @@ impl Binder { self.close(id); } - /// Visit a slice of statements under a parent. fn visit_statements(&mut self, stmts: &[Statement<'_>], parent: NodeId) { for stmt in stmts { self.visit_statement(stmt, parent); } } - /// Visit a `VariableDeclaration` node (used by a `for` init clause, which is - /// not itself a `Statement`). 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); } - /// Visit the declarators of a `VariableDeclaration` (already-assigned parent). fn visit_declarators(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) { for declarator in decl.declarations { self.visit_declarator(declarator, parent); } } - /// Visit one declarator, recording its declared-name identifier when the - /// binding is a plain identifier (destructuring patterns are a later slice). fn visit_declarator(&mut self, declarator: &VariableDeclarator<'_>, parent: NodeId) { let id = self.add( NodeKind::VariableDeclarator, @@ -333,15 +477,12 @@ impl Binder { self.close(id); } - /// Visit a `for-in`/`for-of` left-hand side (its declarator id when it is a - /// variable declaration). fn visit_for_left(&mut self, left: &ForInOfLeft<'_>, parent: NodeId) { if let ForInOfLeft::VariableDeclaration(decl) = left { self.visit_variable_declaration(decl, parent); } } - /// Visit a declared-name identifier (a leaf). fn visit_identifier(&mut self, ident: &tsv_ts::ast::Identifier<'_>, parent: NodeId) { let id = self.add(NodeKind::Identifier, ident.span, Some(parent), addr_of(ident)); self.close(id); @@ -394,7 +535,7 @@ mod tests { fn bind(source: &str) -> BoundFile { let arena = Bump::new(); let program = tsv_ts::parse(source, &arena).expect("parse"); - bind_file(&program, FileId::ROOT) + bind_file(&program, source, FileId::ROOT) } #[test] @@ -407,8 +548,8 @@ mod tests { assert_eq!(bound.kinds[2], NodeKind::VariableDeclarator); assert_eq!(bound.kinds[3], NodeKind::Identifier); assert_eq!(bound.parents[0], None); - assert_eq!(bound.parents[1], Some(NodeId::FIRST)); // parent is Program - assert_eq!(bound.parents[3], Some(NodeId::from_index(2))); // Identifier under declarator + assert_eq!(bound.parents[1], Some(NodeId::FIRST)); + assert_eq!(bound.parents[3], Some(NodeId::from_index(2))); } #[test] @@ -417,7 +558,6 @@ mod tests { let root = NodeId::FIRST; let ident = NodeId::from_index(3); let decl = NodeId::from_index(1); - // Whole program's subtree ends at the identifier. assert_eq!(bound.subtree_end[root.index()], ident); assert!(bound.is_descendant_of(ident, root)); assert!(bound.is_descendant_of(ident, decl)); @@ -429,8 +569,7 @@ mod tests { 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, FileId::ROOT); - // The second top-level statement's address resolves to its id. + 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).copied().expect("mapped"); @@ -439,11 +578,9 @@ mod tests { #[test] fn nested_statements_are_walked() { - // Program, function decl, its id, and the nested return statement. let bound = bind("function f() { return; }"); assert!(bound.kinds.contains(&NodeKind::FunctionDeclaration)); assert!(bound.kinds.contains(&NodeKind::ReturnStatement)); - // The return sits inside the function's pre-order subtree. let func = NodeId::from_index(1); let ret = bound .kinds @@ -454,9 +591,101 @@ mod tests { assert!(bound.is_descendant_of(ret, func)); } + /// 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, + ); + 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 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 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_exports() { + 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.rs b/crates/tsv_check/src/binder/sym.rs new file mode 100644 index 000000000..3e32541c4 --- /dev/null +++ b/crates/tsv_check/src/binder/sym.rs @@ -0,0 +1,1700 @@ +//! 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's local half carries only `ExportValue`, which appears +//! in **no** `*Excludes` mask, so it can never trigger a conflict — routing +//! exported members straight to the exports table is observationally identical +//! for the cascade. +//! - 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 merge phase (cross-declaration-space / cross-file) is out of scope, so +//! the merge-path family codes (TS2397/2649/2664/2671 and merge-only +//! TS2300/2451/2567) are not emitted here — they land as graded *misses*. +// +// tsgo: internal/binder/binder.go declareSymbolEx (the cascade), +// declareSymbolAndAddToSymbolTable / declareModuleMember / declareClassMember +// (the routing), bindEachStatementFunctionsFirst (functions-first), +// bindClassLikeDeclaration (the static-`prototype` clash, :971) + +// 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::atoms::{Atom, Atoms}; +use super::symbols::{Decl, Symbol, SymbolFlags, SymbolId, TableId}; +use super::{addr_of, FileFacts}; +use crate::diag::{Category, Diagnostic}; +use crate::hash::FxHashMap; +use crate::ids::{FileId, NodeId}; +use string_interner::DefaultStringInterner; +use tsv_lang::Span; +use tsv_ts::ast::internal::{ + ClassBody, ClassMember, Expression, ExportDefaultValue, ExportSpecifier, ForInOfLeft, ForInit, + Identifier, ImportSpecifier, Literal, LiteralValue, MethodKind, ModuleExportName, + ObjectPatternProperty, Statement, TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, + TSModuleName, TSTypeElement, TSTypeParameterDeclaration, +}; +use tsv_ts::ast::Program; + +/// 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, +} + +/// Modifiers threaded from an `export` wrapper into the wrapped declaration. +#[derive(Clone, Copy, Default)] +struct DeclMods { + exported: bool, + default: 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, +} + +/// The symbol bind for one file. +pub(super) struct SymbolBinder<'a> { + source: &'a str, + interner: &'a DefaultStringInterner, + address_map: &'a FxHashMap, + file: FileId, + + atoms: Atoms, + symbols: Vec, + tables: Vec>, + diagnostics: Vec, + + container: Scope, + block_scope: Scope, +} + +impl<'a> SymbolBinder<'a> { + /// Build a binder for one file, seeding the source-file scope. + pub(super) fn new( + source: &'a str, + interner: &'a DefaultStringInterner, + address_map: &'a FxHashMap, + file: FileId, + facts: FileFacts, + ) -> SymbolBinder<'a> { + let is_external_module = matches!(facts.module_ness, super::ModuleNess::Module); + let mut binder = SymbolBinder { + source, + interner, + address_map, + file, + 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, + }, + }; + let locals = binder.new_table(); + 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); + } + + /// Finish, returning the collected bind diagnostics. + pub(super) fn finish(self) -> Vec { + self.diagnostics + } + + // --- 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 + } + + fn node_id_of(&self, node: &T) -> NodeId { + self.address_map.get(&addr_of(node)).copied().unwrap_or(NodeId::FIRST) + } + + // --- name resolution ----------------------------------------------------- + + fn ident_atom(&mut self, id: &Identifier<'_>) -> Atom { + let name = id.name(self.source, self.interner); + 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), + } + } + + // --- 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). + 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 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 }); + } + + /// 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); + } + + 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, + category: Category::Message, + message: message_for(code, None), + args: Vec::new(), + chain: Vec::new(), + related: Vec::new(), + } + } + + // --- routing ------------------------------------------------------------- + + /// tsgo `declareSymbolAndAddToSymbolTable` — route by the current container. + 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. + 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`. + 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) + } + + // --- statement lists (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); + } + } + + /// Sub-step A: declare a hoisted function's symbol only (no body descent), + /// unwrapping any `export`/`export default` wrapper for its modifiers. + 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 ---------------------------------------------------------- + + 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) => { + 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()); + } + 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) => { + 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); + } + Statement::TSModuleDeclaration(m) => self.bind_module(m, mods), + Statement::TSTypeAliasDeclaration(t) => { + 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()); + } + Statement::ImportDeclaration(imp) => { + for spec in imp.specifiers { + self.bind_import_specifier(spec); + } + } + Statement::TSImportEqualsDeclaration(ie) => { + 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); + } + Statement::ExportNamedDeclaration(e) => { + 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); + } + } + } + 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.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); + }), + 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.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); + } + }); + } + Statement::TryStatement(s) => { + 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)); + } + } + 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) => { + // `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), + }; + let table = self.exports_of(sym); + self.declare_symbol(table, Some(sym), d, SymbolFlags::PROPERTY, SymbolFlags::ALL); + } + self.visit_expression(&ea.expression); + } + Statement::ExportAllDeclaration(_) + | Statement::TSNamespaceExportDeclaration(_) + | Statement::BreakStatement(_) + | Statement::ContinueStatement(_) + | Statement::EmptyStatement(_) + | Statement::DebuggerStatement(_) => {} + } + } + + 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), + }; + 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.interner); + 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 + scopes -------------------------------------------- + + 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); + } + + 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; + } + + // --- params + bindings --------------------------------------------------- + + fn bind_params(&mut self, params: &[Expression<'a>]) { + for param in params { + self.bind_param(param); + } + } + + 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); + } + } + 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); + } + _ => {} + } + } + + 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), + } + } + + // --- classes ------------------------------------------------------------- + + 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 --------------------------------------- + + 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; + } + + 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. + } + + fn bind_type_element(&mut self, element: &TSTypeElement<'a>) { + let (key_expr, computed, span, inc, exc) = match element { + TSTypeElement::PropertySignature(p) => { + (&p.key, p.computed, p.span, SymbolFlags::PROPERTY, SymbolFlags::PROPERTY_EXCLUDES) + } + TSTypeElement::MethodSignature(m) => { + (&m.key, m.computed, m.span, SymbolFlags::METHOD, SymbolFlags::METHOD_EXCLUDES) + } + // Call/construct/index signatures are anonymous (Signature, no conflict). + TSTypeElement::CallSignature(_) + | TSTypeElement::ConstructSignature(_) + | TSTypeElement::IndexSignature(_) => return, + }; + 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, + }; + let _ = span; + self.declare_in_container(d, inc, exc); + } + } + + 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; + } + + 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), + }; + // 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); + + 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)) => { + self.bind_module(nested, DeclMods::default()); + } + 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, self.interner) == "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); + } + + /// Declare an alias symbol (import/export specifier, `import =`). Exported + /// aliases route to `exports`, others to `locals`. + 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); + } + } + } + } + + // --- type parameters ----------------------------------------------------- + + 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); + } + } + } + + 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, |_| {}); + } + + // --- expressions (nested scopes) ----------------------------------------- + + 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) => { + for p in o.properties { + if let tsv_ts::ast::internal::ObjectProperty::Property(pr) = p { + self.visit_expression(&pr.value); + } + } + } + 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); + } + } + _ => {} + } + } + + // --- 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(_)) => + { + let a = self.string_atom(lit); + Some(KeyInfo { key: a, display: a, span: lit.span }) + } + _ => 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, self.interner); + let display = self.atoms.intern(raw); + // Mangle with the class symbol id so same-name privates in one + // class collide (tsgo GetSymbolNameForPrivateIdentifier). + 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 `#`. + Some(KeyInfo { key, display, span: pid.span }) + } + _ => None, + } + } +} + +/// A resolved member key. +struct KeyInfo { + key: Atom, + display: Atom, + span: Span, +} + +/// 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, + ), + } +} + +/// 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, + } +} + +/// 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(), + } +} + +/// 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 +} + +/// 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, + } +} + +/// 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/symbols.rs b/crates/tsv_check/src/binder/symbols.rs new file mode 100644 index 000000000..8e69aaf7f --- /dev/null +++ b/crates/tsv_check/src/binder/symbols.rs @@ -0,0 +1,233 @@ +//! Symbols, symbol flags, and symbol tables — the binder's substrate. +//! +//! Ported from tsgo's `internal/ast`: [`SymbolFlags`] is the exact 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 verbatim so the merge-vs-conflict verdict matches tsgo by +//! construction. 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, +} + +/// A bound symbol: accumulated flags, its table key, its declarations, and the +/// child tables a container owns. +#[derive(Clone, Debug)] +// `name` and `parent` mirror tsgo's `Symbol` shape and are set by the bind; the +// family cascade reads `flags`/`decls`/`members`/`exports` only. +#[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/program.rs b/crates/tsv_check/src/program.rs index b71aeaef2..1b533acae 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -1,13 +1,21 @@ //! Program pipeline assembly: parse (goal rule) -> bind -> check -> sort/dedup. //! -//! Ports the shape of tsgo's `GetDiagnosticsOfAnyProgram`, in particular the -//! **parse-error short-circuit**: when any unit fails to parse, the program -//! emits no bind/check diagnostics at all (tsgo `program.go:1770` — the -//! syntactic-append-added-nothing guard). This matters because tsv's parser is -//! deliberately permissive: on a program tsgo parse-rejects, tsv can parse -//! clean and would otherwise emit family diagnostics the baseline lacks. tsv's -//! suppression mirrors the short-circuit exactly — a parse rejection anywhere -//! yields zero semantic output for the program. +//! **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 @@ -18,10 +26,12 @@ //! (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/compiler/program.go GetDiagnosticsOfAnyProgram (:1755), -// the syntactic short-circuit at :1770; the bind-then-check concat at -// getBindAndCheckDiagnosticsWithChecker (:1337); final sort+dedup is -// caller-side (execute/tsc/emit.go:120). +// 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::{bind_file, module_ness, ModuleNess}; use crate::diag::{sort_and_deduplicate, Diagnostic}; @@ -47,14 +57,15 @@ impl<'a> SourceUnit<'a> { } /// The result of checking a program: its (sorted, deduped) diagnostics, a -/// per-unit report, and whether the parse-error short-circuit fired. +/// per-unit report, and whether any unit parse-rejected. pub struct CheckResult { - /// Diagnostics in canonical sorted order — always empty at this slice (the - /// checker is a no-op), and empty by construction whenever `parse_rejected`. + /// Diagnostics in canonical sorted order — the unconditional concat of every + /// unit that parsed (a rejected unit contributes none, having no AST). pub diagnostics: Vec, /// Per-unit parse/bind report, in input order. pub files: Vec, - /// Whether any unit parse-rejected (the program short-circuit fired). + /// 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, } @@ -128,24 +139,25 @@ pub fn check_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> CheckResu } } - // The parse-error short-circuit: any rejection -> no bind/check diagnostics. + // Unconditional concat (the CompileFilesEx parity path): every unit that + // parsed contributes its bind/check diagnostics, independent of a sibling's + // rejection. A rejected unit has no AST, so it contributes none. let mut diagnostics: Vec = Vec::new(); - if !parse_rejected { - for attempt in &mut attempts { - if let Some(program) = &attempt.program { - let bound = bind_file(program, attempt.file); - attempt.node_count = bound.node_count; - // Per file: bind diagnostics then check diagnostics (both empty - // at this slice) — the getBindAndCheckDiagnostics concat. - let check_diags = check_file(&bound); - diagnostics.extend(bound.diagnostics); - diagnostics.extend(check_diags); - } + for attempt in &mut attempts { + if let Some(program) = &attempt.program { + let source = units[attempt.file.index()].source; + let bound = bind_file(program, source, attempt.file); + attempt.node_count = bound.node_count; + // Per file: bind diagnostics then check diagnostics (check is a no-op + // this slice) — the getBindAndCheckDiagnostics concat. + let check_diags = check_file(&bound); + diagnostics.extend(bound.diagnostics); + diagnostics.extend(check_diags); } - // Final caller-side sort + dedup over the whole program's diagnostics. - let paths: Vec = units.iter().map(|u| u.name.to_string()).collect(); - sort_and_deduplicate(&mut diagnostics, &paths); } + // Final caller-side sort + dedup over the whole program's diagnostics. + let paths: Vec = units.iter().map(|u| u.name.to_string()).collect(); + sort_and_deduplicate(&mut diagnostics, &paths); let files = attempts.into_iter().map(Attempt::into_report).collect(); CheckResult { diagnostics, files, parse_rejected } @@ -229,8 +241,9 @@ mod tests { } #[test] - fn parse_rejection_short_circuits() { - // A hard syntax error both goals reject. + 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()); @@ -252,16 +265,18 @@ mod tests { } #[test] - fn multi_unit_short_circuit_is_program_wide() { - // One clean unit, one rejected unit -> the whole program short-circuits. + 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", "const x = 1;"), SourceUnit::new("b.ts", "const = ;")], + &[SourceUnit::new("a.ts", "let x; let x;"), SourceUnit::new("b.ts", "const = ;")], &arena, ); assert!(result.parse_rejected); - assert!(result.diagnostics.is_empty()); - assert_eq!(result.files.len(), 2); + // 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 { .. })); } diff --git a/crates/tsv_debug/src/cli/commands/profile.rs b/crates/tsv_debug/src/cli/commands/profile.rs index 49ec26990..d205a7bcc 100644 --- a/crates/tsv_debug/src/cli/commands/profile.rs +++ b/crates/tsv_debug/src/cli/commands/profile.rs @@ -17,6 +17,11 @@ pub struct ProfileCommand { #[argh(switch)] json: bool, + /// measure parse vs lower+bind (tsv_check) instead of parse vs format; + /// TypeScript files only. Prints peak RSS (VmHWM) once at the end. + #[argh(switch)] + bind: bool, + /// file paths, directories, or glob patterns #[argh(positional)] paths: Vec, @@ -24,7 +29,11 @@ pub struct ProfileCommand { impl ProfileCommand { pub(crate) fn run(self) -> Result<(), CliError> { - let (files, skipped) = resolve_profile_files(&self.paths, |_| false)?; + // Bind mode is TypeScript-only (the checker binds no Svelte/CSS): exclude + // the other languages up front so the "format" column is a bind time. + let (files, skipped) = resolve_profile_files(&self.paths, |p| { + self.bind && ParserType::from_extension(&p.to_string_lossy()) != ParserType::TypeScript + })?; let mut results = Vec::new(); @@ -37,7 +46,7 @@ impl ProfileCommand { let mut doc_arena = tsv_lang::doc::arena::DocArena::new(); for path in &files { - match profile_file(path, self.iterations, &mut arena, &mut doc_arena) { + match profile_file(path, self.iterations, self.bind, &mut arena, &mut doc_arena) { Ok(result) => results.push(result), Err(err) => { eprintln!("Error profiling {}: {err}", path.display()); @@ -66,10 +75,32 @@ impl ProfileCommand { print_table(&results, self.iterations, skipped); } + // Peak resident set (VmHWM), printed once — the checker's memory anchor. + if self.bind { + #[allow(clippy::cast_precision_loss)] + match read_vm_hwm_kb() { + Some(kb) => eprintln!("peak RSS (VmHWM): {:.1} MiB", kb as f64 / 1024.0), + None => eprintln!("peak RSS (VmHWM): unavailable"), + } + eprintln!("(the `format` column is parse->bind time)"); + } + Ok(()) } } +/// Peak resident set size in KiB from `/proc/self/status` (`VmHWM`), or `None` +/// off Linux / when the field is absent. +fn read_vm_hwm_kb() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("VmHWM:") { + return rest.split_whitespace().next()?.parse().ok(); + } + } + None +} + /// Timing results for a single file struct FileResult { path: PathBuf, @@ -172,6 +203,7 @@ fn files_label(n: usize) -> String { fn profile_file( path: &Path, iterations: usize, + bind: bool, arena: &mut bumpalo::Bump, doc_arena: &mut tsv_lang::doc::arena::DocArena, ) -> Result { @@ -182,7 +214,11 @@ fn profile_file( let mut format_times = Vec::with_capacity(iterations); for _ in 0..iterations { - let (parse_dur, format_dur) = profile_once(&source, parser_type, arena, doc_arena)?; + let (parse_dur, format_dur) = if bind { + profile_bind_once(&source, arena)? + } else { + profile_once(&source, parser_type, arena, doc_arena)? + }; parse_times.push(parse_dur); format_times.push(format_dur); // Arena teardown outside the timed regions, mirroring how arena setup is @@ -207,6 +243,21 @@ fn profile_file( }) } +/// Run one parse + lower+bind iteration for TypeScript, returning +/// `(parse_duration, bind_duration)`. The bind phase runs the `tsv_check` binder +/// (SoA walk + symbol bind) over the parsed program. +fn profile_bind_once(source: &str, arena: &bumpalo::Bump) -> Result<(Duration, Duration), String> { + let t0 = Instant::now(); + let ast = tsv_ts::parse(source, arena).map_err(|e| format!("parse error: {e}"))?; + let parse_dur = t0.elapsed(); + + let t1 = Instant::now(); + let _ = tsv_check::bind_file(&ast, source, tsv_check::FileId::ROOT); + let bind_dur = t1.elapsed(); + + Ok((parse_dur, bind_dur)) +} + /// Run one parse + format iteration, return (parse_duration, format_duration). /// /// Arenas are caller-owned and reset between iterations (see `profile_file`); diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index c3c65fecc..38c9bb465 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -89,6 +89,27 @@ const RUN_SCRIPT_RETRY_PIN: usize = 25; /// tsv parser robustness change (a fix removes an entry; a regression adds one). const RUN_CRASH_EXCLUDED_PIN: usize = 1; +/// REGRESSION PINS (exact, two-sided) for the family grading (the S3 gate). +/// Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 (hard); the +/// rest pin the buckets so any move (a cascade change, a tsv parser change, a +/// typescript-go pull) forces a deliberate re-pin. The missing bucket is +/// classified: `merge` (merge-phase family, S4), `lib` (absent-lib conflicts, +/// S5), and `check-time` (checker-emitted TS2300/2451 the bind-only slice can't +/// produce — duplicate members, type parameters, computed/private names). A drop +/// in `check-time` (matches gained) or `merge`/`lib` is a real improvement that +/// re-pins; a rise is a regression to explain. +const RUN_FAMILY_GRADED_PIN: usize = 4066; +const RUN_FAMILY_POSITIVE_PIN: usize = 125; +const RUN_FAMILY_MATCH_PIN: usize = 414; +const RUN_FAMILY_MISSING_PIN: usize = 136; +const RUN_MISSING_MERGE_PIN: usize = 7; +const RUN_MISSING_LIB_PIN: usize = 4; +const RUN_MISSING_CHECKTIME_PIN: usize = 125; +const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; +const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; +const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; +const RUN_MODULE_DETECTION_PIN: usize = 1; + /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "tsc_conformance")] @@ -267,6 +288,22 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { report.panics.first().map_or("", |p| p.test.as_str()) )); } + // A stale crash-exclusion (a fixed defect that no longer panics) must be dropped. + if !report.stale_exclusions.is_empty() { + errs.push(format!( + "{} crash-exclusion(s) no longer panic — drop from CRASH_EXCLUSIONS: {}", + report.stale_exclusions.len(), + report.stale_exclusions.join(", ") + )); + } + // The hard family gate: never emit a family diagnostic the baseline lacks. + if report.family_extra != 0 { + errs.push(format!( + "family EXTRA {} != 0 (a bind-time over-emission — fix the cascade), e.g. {}", + report.family_extra, + report.extra_samples.first().map_or("", String::as_str) + )); + } // Exact two-sided pins. let pin = |errs: &mut Vec, label: &str, got: usize, want: usize| { @@ -301,6 +338,34 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { pin(&mut errs, "script retries", report.script_retry, RUN_SCRIPT_RETRY_PIN); pin(&mut errs, "crash-excluded", report.excluded_crashes, RUN_CRASH_EXCLUDED_PIN); + // Family grading pins. + pin(&mut errs, "family graded", report.family_graded_variants, RUN_FAMILY_GRADED_PIN); + pin(&mut errs, "family positive", report.family_positive_variants, RUN_FAMILY_POSITIVE_PIN); + pin(&mut errs, "family match", report.family_match, RUN_FAMILY_MATCH_PIN); + pin(&mut errs, "family missing", report.family_missing, RUN_FAMILY_MISSING_PIN); + pin(&mut errs, "missing merge", report.missing_merge, RUN_MISSING_MERGE_PIN); + pin(&mut errs, "missing lib", report.missing_lib, RUN_MISSING_LIB_PIN); + pin(&mut errs, "missing check-time", report.missing_other, RUN_MISSING_CHECKTIME_PIN); + pin( + &mut errs, + "family span_mismatch", + report.family_span_mismatch, + RUN_FAMILY_SPAN_MISMATCH_PIN, + ); + pin(&mut errs, "carve-out rule (a)", report.carve_out_rule_a, RUN_CARVE_OUT_RULE_A_PIN); + pin( + &mut errs, + "carve-out rule (a) family", + report.carve_out_rule_a_family, + RUN_CARVE_OUT_RULE_A_FAMILY_PIN, + ); + pin( + &mut errs, + "moduleDetection variants", + report.module_detection_variants, + RUN_MODULE_DETECTION_PIN, + ); + if errs.is_empty() { Ok(()) } else { diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 422adb5e5..ee7d43c3c 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -47,27 +47,72 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use std::time::Instant; use tsv_check::{check_program, ParseReport, SourceUnit}; +use tsv_lang::{LocationMapper, LocationTracker}; + +/// The bind-time duplicate/conflict family this slice grades: TS2300 (duplicate +/// identifier), TS2451 (block-scoped redeclare), TS2567 (enum-merge), TS2528 +/// (multiple default exports), plus the merge-path codes TS2397/2649/2664/2671 +/// (emitted only from the merge phase, out of this slice — they land as *misses*). +const FAMILY_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; + +/// The merge-path family codes — a *missing* of one of these is a merge-phase gap +/// (S4), not a same-table cascade bug. +const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; + +/// The TS1xxx codes the binder itself emits (strict-mode + private-identifier +/// checks) — they prove nothing about parse state, so a baseline carrying only +/// these does not trigger the recovery-AST carve-out (predicate v1, rule a). +const BIND_EMITTED_TS1XXX: [u32; 12] = + [1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012]; + +/// The five P1-family baselines whose family diagnostics need lib binding (S5). +/// A *missing* in one of these is attributed to the absent lib, not a cascade bug. +const LIB_CONFLICT_BASELINES: [&str; 5] = [ + "intersectionsOfLargeUnions2.ts", + "jsExportMemberMergedWithModuleAugmentation2.ts", + "promiseDefinitionTest.ts", + "recursiveComplicatedClasses.ts", + "variableDeclarationInStrictMode1.ts", +]; /// Worker-thread stack for the sweep: the corpus has deeply-nested tests and /// tsv's recursive-descent parser has no depth guard, so the default 8 MiB /// overflows. 512 MiB is virtual-only reserve on Linux. const SKELETON_STACK: usize = 512 * 1024 * 1024; +/// How a crash-excluded test fails, and whether its liveness is probeable. +// `GenuineAbort` is the designed flag for a future stack-overflow entry (none on +// the pinned corpus); it is un-probeable, so it is never re-run. +#[derive(Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +enum CrashKind { + /// A debug-build `debug_assert!` panic `catch_unwind` contains — probeable: + /// the sweep re-runs it under `catch_unwind` and FAILS if it no longer + /// panics (a fix landed, so the entry is stale and must be dropped). + CatchablePanic, + /// An uncatchable stack-overflow *abort* even on [`SKELETON_STACK`] — not + /// probeable (probing would abort the whole run), so it is trusted, not tested. + GenuineAbort, +} + /// Tests that crash the tsv parser — carved out by basename, counted, and -/// reported (never silently). Two crash classes land here: uncatchable -/// stack-overflow aborts (even on [`SKELETON_STACK`]), and debug-build -/// `debug_assert!` panics in `tsv_ts` that `catch_unwind` *does* contain but -/// which are tracked parser bugs to fix rather than absorb. Each entry names its -/// cause; the list is a tracked-defect ledger, not a way to hide bugs. -const CRASH_EXCLUSIONS: &[&str] = &[ +/// reported (never silently). Each entry names its cause + kind; the list is a +/// tracked-defect ledger, not a way to hide bugs. A [`CrashKind::CatchablePanic`] +/// entry is liveness-probed every run (see [`probe_crash_exclusion`]). +const CRASH_EXCLUSIONS: &[(&str, CrashKind)] = &[ // tsv_ts robustness bug: `export * from ;` (a non-string module // specifier) trips a `debug_assert!(TokenKind::String)` in // `parse_string_literal` (parser/mod.rs). Dev-profile only (debug_assert is // compiled out in release), so `cargo run` — the gate's profile — panics. // A future tsv_ts fix should reject the form gracefully; then drop this entry. - "exportDeclarationInInternalModule.ts", + ("exportDeclarationInInternalModule.ts", CrashKind::CatchablePanic), ]; +/// The [`CrashKind`] of a crash-excluded test, or `None` if not excluded. +fn crash_exclusion_kind(basename: &str) -> Option { + CRASH_EXCLUSIONS.iter().find(|(n, _)| *n == basename).map(|(_, k)| *k) +} + /// One expect-clean variant that graded non-clean (should never happen while the /// checker is a no-op — a non-empty list is a gate failure). #[derive(Debug, Clone, serde::Serialize)] @@ -100,9 +145,44 @@ pub struct SkeletonReport { pub clean_pass: usize, /// Expect-clean variants that graded non-clean (gate failure list). pub clean_fail: Vec, - /// In-scope variants that parsed and DO have a baseline (informational — the - /// checker emits nothing, so these would all be "missing"; not gated yet). + /// In-scope variants that parsed and DO have a baseline. pub baselined_parsed: usize, + + // --- family grading (the S3 gate) --- + /// Parsed-with-baseline variants family-graded (not carved by predicate v1). + pub family_graded_variants: usize, + /// ...of those, whose baseline carries at least one family code. + pub family_positive_variants: usize, + /// Family diagnostics that matched (file, line, col, code). + pub family_match: usize, + /// Family baseline diagnostics with no matching diagnostic of ours (classified + /// below). Expected to be all merge/lib until S4/S5 land. + pub family_missing: usize, + /// Family diagnostics we emit that the baseline lacks. **Gate: must be 0.** + pub family_extra: usize, + /// Right code + file, wrong position (greedy-paired). + pub family_span_mismatch: usize, + /// ...missing attributed to the merge phase (TS2397/2649/2664/2671). + pub missing_merge: usize, + /// ...missing attributed to absent lib binding (a `LIB_CONFLICT_BASELINES` test). + pub missing_lib: usize, + /// ...missing not attributable to merge/lib — investigate (a same-table miss + /// is a cascade bug). + pub missing_other: usize, + /// Variants carved out by predicate v1 rule (a): tsv parses clean but the + /// baseline carries a non-bind TS1xxx code (recovery-AST incomparability). + pub carve_out_rule_a: usize, + /// ...of those, whose baseline also carries a family code. + pub carve_out_rule_a_family: usize, + /// In-scope variants that set `moduleDetection` (a watch item — module-ness is + /// inert for the family cascade, so the parse-once shortcut stays valid). + pub module_detection_variants: usize, + /// Sample extra diagnostics (gate failures to fix). + pub extra_samples: Vec, + /// Sample unattributed misses (candidate cascade bugs). + pub missing_other_samples: Vec, + /// Sample span mismatches. + pub span_mismatch_samples: Vec, /// In-scope variants tsv parse-rejected (census; informational). pub parse_rejected_total: usize, /// ...of those, with no on-disk baseline (a likely tsv over-rejection). @@ -118,6 +198,9 @@ pub struct SkeletonReport { pub panics: Vec, /// Tests skipped by the crash-exclusion ledger (tracked parser aborts/panics). pub excluded_crashes: usize, + /// Catchable-panic exclusions that no longer panic (a fix landed) — the entry + /// is stale and must be dropped. **Gate: must be empty.** + pub stale_exclusions: Vec, /// Total bound nodes across in-scope tests (informational). pub total_nodes: u64, /// Wall-clock of the sweep in milliseconds. @@ -166,8 +249,13 @@ fn run_skeleton_inner(checkout: &Path) -> Result { if SKIPPED_TESTS.contains(&test.basename.as_str()) { continue; } - if CRASH_EXCLUSIONS.contains(&test.basename.as_str()) { + if let Some(kind) = crash_exclusion_kind(&test.basename) { report.excluded_crashes += 1; + // Liveness probe: a catchable-panic entry must still panic; if it no + // longer does, the ledger entry is stale and the run fails. + if kind == CrashKind::CatchablePanic && !probe_crash_exclusion(test) { + report.stale_exclusions.push(test.basename.clone()); + } continue; } @@ -202,6 +290,30 @@ fn run_skeleton_inner(checkout: &Path) -> Result { Ok(report) } +/// Re-run a catchable-panic crash exclusion under `catch_unwind`, returning +/// whether it **still panics**. A `false` (it completed) means the tracked defect +/// is fixed and the ledger entry is stale. +fn probe_crash_exclusion(test: &CorpusTest) -> bool { + let Ok(content) = read_corpus_file(&test.path) else { + // Can't read it -> can't disprove the panic; treat as still-live. + return true; + }; + let units = split_units(&content, &test.basename); + let arena = Bump::new(); + let source_units: Vec> = + units.iter().map(|u| SourceUnit::new(&u.name, &u.content)).collect(); + // Silence the default panic hook for the deliberate probe (we expect it to + // panic; the message would otherwise leak to stderr and read as a failure). + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let panicked = catch_unwind(AssertUnwindSafe(|| { + let _ = check_program(&source_units, &arena); + })) + .is_err(); + std::panic::set_hook(prev); + panicked +} + /// Parse+check one single-file test once and attribute the outcome to each of /// its in-scope variants. fn grade_test( @@ -223,8 +335,36 @@ fn grade_test( // The single unit's parse outcome (files is never empty for one input). let Some(file) = result.files.first() else { return }; + + // Our family diagnostics are variant-independent (the checker consults no + // directives, and module-ness is inert for the cascade), so build the + // (file, line, col, code) multiset once per test via the unit's line map. + let ours_family: Vec = if matches!(file.parse, ParseReport::Parsed(_)) { + let (tracker, map) = LocationTracker::new_ecmascript_with_map(&unit.content); + let mapper = LocationMapper { tracker: &tracker, map: &map }; + result + .diagnostics + .iter() + .filter(|d| FAMILY_CODES.contains(&d.code)) + .map(|d| { + let (_, pos) = mapper.pos_and_position(d.span.start); + FamilyDiag { + file: unit.name.clone(), + line: pos.line as u32, + col: pos.column as u32 + 1, + code: d.code, + } + }) + .collect() + } else { + Vec::new() + }; + for variant in in_scope { report.in_scope_variants += 1; + if variant.config.contains_key("moduledetection") { + report.module_detection_variants += 1; + } let name = config_name(&test.basename, &variant.description); let baseline = ondisk.get(&(test.suite, name.clone())).copied(); @@ -241,18 +381,22 @@ fn grade_test( if facts.used_script_retry { report.script_retry += 1; } - if baseline.is_none() { - report.expect_clean_graded += 1; - if result.diagnostics.is_empty() { - report.clean_pass += 1; - } else { - report.clean_fail.push(CleanFail { - variant: format!("{}/{name}", test.suite), - diagnostics: result.diagnostics.len(), - }); + match baseline { + None => { + report.expect_clean_graded += 1; + if result.diagnostics.is_empty() { + report.clean_pass += 1; + } else { + report.clean_fail.push(CleanFail { + variant: format!("{}/{name}", test.suite), + diagnostics: result.diagnostics.len(), + }); + } + } + Some(b) => { + report.baselined_parsed += 1; + grade_family(test, &name, b, &ours_family, report); } - } else { - report.baselined_parsed += 1; } } } @@ -264,6 +408,155 @@ fn grade_test( } } +/// One family diagnostic in baseline coordinates: `(file, 1-based line, 1-based +/// UTF-16 col, code)`. +#[derive(Clone, PartialEq, Eq, Hash)] +struct FamilyDiag { + file: String, + line: u32, + col: u32, + code: u32, +} + +/// Grade one parsed-with-baseline variant's family diagnostics against its +/// baseline, folding the buckets into `report`. Applies predicate v1 rule (a) +/// (recovery-AST carve-out) first. +fn grade_family( + test: &CorpusTest, + name: &str, + baseline: &Baseline, + ours: &[FamilyDiag], + report: &mut SkeletonReport, +) { + let Ok(content) = std::fs::read_to_string(&baseline.path) else { return }; + let base_all = parse_summary_block(&content); + + // Predicate v1 rule (a): tsv parses clean (it did — this variant parsed) and + // the baseline carries a non-bind TS1xxx code -> recovery-AST incomparable. + let has_nonbind_ts1xxx = base_all + .iter() + .any(|d| (1000..2000).contains(&d.code) && !BIND_EMITTED_TS1XXX.contains(&d.code)); + let base_family: Vec = base_all + .iter() + .filter(|d| FAMILY_CODES.contains(&d.code)) + .filter_map(|d| { + Some(FamilyDiag { + file: d.file.clone()?, + line: d.line?, + col: d.col?, + code: d.code, + }) + }) + .collect(); + let has_family = !base_family.is_empty(); + + if has_nonbind_ts1xxx { + report.carve_out_rule_a += 1; + if has_family { + report.carve_out_rule_a_family += 1; + } + return; + } + + report.family_graded_variants += 1; + if has_family { + report.family_positive_variants += 1; + } + + let buckets = family_buckets(ours, &base_family); + report.family_match += buckets.matched; + report.family_span_mismatch += buckets.span_mismatch; + report.family_extra += buckets.extra; + if buckets.extra > 0 && report.extra_samples.len() < 20 { + report.extra_samples.push(format!("{}/{name} (+{})", test.suite, buckets.extra)); + } + if buckets.span_mismatch > 0 && report.span_mismatch_samples.len() < 20 { + report + .span_mismatch_samples + .push(format!("{}/{name} (~{})", test.suite, buckets.span_mismatch)); + } + let is_lib = LIB_CONFLICT_BASELINES.contains(&test.basename.as_str()); + for (code, count) in buckets.missing_by_code { + report.family_missing += count; + if MERGE_CODES.contains(&code) { + report.missing_merge += count; + } else if is_lib { + report.missing_lib += count; + } else { + report.missing_other += count; + if report.missing_other_samples.len() < 20 { + report + .missing_other_samples + .push(format!("{}/{name} TS{code} x{count}", test.suite)); + } + } + } +} + +/// The four family buckets for one variant. +struct FamilyBuckets { + matched: usize, + extra: usize, + span_mismatch: usize, + /// The unattributed misses, per code (for cause classification). + missing_by_code: HashMap, +} + +/// Compare our family multiset against the baseline's: exact `(file,line,col,code)` +/// matches, then greedy `(file,code)` span-mismatch pairing of the leftovers, with +/// the residue split into extra (ours) and missing (baseline). +fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { + let mut ours_counts: HashMap<&FamilyDiag, usize> = HashMap::new(); + for d in ours { + *ours_counts.entry(d).or_default() += 1; + } + let mut base_counts: HashMap<&FamilyDiag, usize> = HashMap::new(); + for d in base { + *base_counts.entry(d).or_default() += 1; + } + + let mut matched = 0usize; + // Leftover counts grouped by (file, code) for span-mismatch pairing. + let mut left_ours: HashMap<(&str, u32), usize> = HashMap::new(); + let mut left_base: HashMap<(&str, u32), usize> = HashMap::new(); + + for (d, &oc) in &ours_counts { + let bc = base_counts.get(d).copied().unwrap_or(0); + let m = oc.min(bc); + matched += m; + if oc > m { + *left_ours.entry((d.file.as_str(), d.code)).or_default() += oc - m; + } + } + for (d, &bc) in &base_counts { + let oc = ours_counts.get(d).copied().unwrap_or(0); + let m = oc.min(bc); + if bc > m { + *left_base.entry((d.file.as_str(), d.code)).or_default() += bc - m; + } + } + + // Pair leftovers within each (file, code) group: min = span mismatch, the + // ours residue = extra, the baseline residue = missing. + let mut span_mismatch = 0usize; + let mut extra = 0usize; + let mut missing_by_code: HashMap = HashMap::new(); + let keys: std::collections::HashSet<(&str, u32)> = + left_ours.keys().chain(left_base.keys()).copied().collect(); + for &(file, code) in &keys { + let oc = left_ours.get(&(file, code)).copied().unwrap_or(0); + let bc = left_base.get(&(file, code)).copied().unwrap_or(0); + let sm = oc.min(bc); + span_mismatch += sm; + extra += oc - sm; + if bc - sm > 0 { + *missing_by_code.entry(code).or_default() += bc - sm; + } + } + + FamilyBuckets { matched, extra, span_mismatch, missing_by_code } +} + /// Classify a parse-rejected variant's baseline shape for the census. fn baseline_shape(baseline: Option<&Baseline>) -> BaselineShape { let Some(baseline) = baseline else { @@ -290,7 +583,7 @@ impl SkeletonReport { println!(" parsed, expect-clean: {}", self.expect_clean_graded); println!(" graded clean: {}", self.clean_pass); println!(" graded NON-clean: {}", self.clean_fail.len()); - println!(" parsed, baselined: {} (informational)", self.baselined_parsed); + println!(" parsed, baselined: {}", self.baselined_parsed); println!(" parse-rejected: {}", self.parse_rejected_total); println!(" no baseline: {}", self.parse_rejected_no_baseline); println!(" TS1xxx-only baseline: {}", self.parse_rejected_ts1xxx_only); @@ -298,8 +591,35 @@ impl SkeletonReport { println!("Script-goal retries: {}", self.script_retry); println!("Bound nodes (total): {}", self.total_nodes); println!(); + println!("Family grading (2300/2451/2567/2528 + merge 2397/2649/2664/2671)"); + println!("---------------------------------------------------------------"); + println!("Graded variants: {}", self.family_graded_variants); + println!(" ...family-positive: {}", self.family_positive_variants); + println!(" match: {}", self.family_match); + println!(" missing: {}", self.family_missing); + println!(" merge-path (S4): {}", self.missing_merge); + println!(" lib-conflict (S5): {}", self.missing_lib); + println!(" check-time (S3+): {} (checker-emitted TS2300/2451: duplicate members, type params, computed/private names)", self.missing_other); + println!(" extra (GATE=0): {}", self.family_extra); + println!(" span_mismatch: {}", self.family_span_mismatch); + println!("Carve-out rule (a): {}", self.carve_out_rule_a); + println!(" ...family-positive: {}", self.carve_out_rule_a_family); + println!("moduleDetection variants: {} (watch; inert for family)", self.module_detection_variants); + for s in &self.extra_samples { + println!(" EXTRA {s}"); + } + for s in &self.missing_other_samples { + println!(" MISSING-OTHER {s}"); + } + for s in &self.span_mismatch_samples { + println!(" SPAN {s}"); + } + println!(); println!("Panics (caught): {}", self.panics.len()); println!("Crash-excluded (tracked): {}", self.excluded_crashes); + if !self.stale_exclusions.is_empty() { + println!("Stale crash-exclusions: {} (drop them)", self.stale_exclusions.len()); + } println!("Wall-clock: {} ms", self.wall_ms); if !self.clean_fail.is_empty() { println!(); @@ -407,14 +727,22 @@ pub fn check_one( units.iter().map(|u| SourceUnit::new(&u.name, &u.content)).collect(); let result = check_program(&source_units, &arena); + // Byte-span -> (1-based line, 1-based UTF-16 col) per the owning unit. let ours: Vec = result .diagnostics .iter() - .map(|d| DiagLine { - file: d.file.and_then(|f| source_units.get(f.index()).map(|u| u.name.to_string())), - line: None, - col: None, - code: d.code, + .map(|d| { + let (line, col) = d.file.and_then(|f| units.get(f.index())).map_or((None, None), |u| { + let (t, m) = LocationTracker::new_ecmascript_with_map(&u.content); + let (_, pos) = LocationMapper { tracker: &t, map: &m }.pos_and_position(d.span.start); + (Some(pos.line as u32), Some(pos.column as u32 + 1)) + }); + DiagLine { + file: d.file.and_then(|f| source_units.get(f.index()).map(|u| u.name.to_string())), + line, + col, + code: d.code, + } }) .collect(); let parse_error = result.files.iter().find_map(|f| match &f.parse { From 2a995477d19f31d61bc3df26bfeb6ed2df9253ff Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 15:23:23 -0400 Subject: [PATCH 16/79] =?UTF-8?q?feat:=20globals=20merge=20=E2=80=94=20rep?= =?UTF-8?q?ortMergeSymbolError=20path,=20TS2397/TS2664=20close,=20lookupOr?= =?UTF-8?q?IssueError=20dedup,=20pinned=20related-info=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/CLAUDE.md | 17 +- crates/tsv_check/src/binder/mod.rs | 7 +- crates/tsv_check/src/binder/sym.rs | 171 ++++- crates/tsv_check/src/binder/symbols.rs | 18 +- crates/tsv_check/src/diag.rs | 50 ++ crates/tsv_check/src/ids.rs | 5 +- crates/tsv_check/src/lib.rs | 4 + crates/tsv_check/src/merge.rs | 655 ++++++++++++++++++ crates/tsv_check/src/program.rs | 9 + .../src/cli/commands/tsc_conformance.rs | 50 +- .../tsv_debug/src/tsc_conformance/runner.rs | 312 ++++++++- 11 files changed, 1236 insertions(+), 62 deletions(-) create mode 100644 crates/tsv_check/src/merge.rs diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 6a78bc66a..0a4ac238c 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -35,6 +35,16 @@ program parse-rejection), the **parse-error short-circuit** (any unit rejects ⇒ zero bind/check diagnostics program-wide), per-file bind-then-check concatenation, final sort+dedup. +- `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). - `binder/` — the fused lower+bind pre-order walk: assigns dense 1-based `NodeId`s, fills SoA side columns (`parents`/`kinds`/`spans`/ `subtree_end` — the latter makes descendant tests O(1) interval checks), @@ -75,9 +85,10 @@ result is fully owned — nothing borrows out. - `tsv_debug tsc_conformance run` — the standing gate: sweeps the in-scope corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades expect-clean variants AND the bind/merge duplicate-conflict family - (TS2300/2451/2567/2528 + merge-path codes) as codes+spans multisets - (extra = 0 is a hard gate; missing is classified by deferred cause), - publishes the parse-divergence census; exact `RUN_*` pins. + (TS2300/2451/2567/2528 + merge-path TS2397/2649/2664/2671) as codes+spans + multisets (extra = 0 is a hard gate; missing is classified by deferred + cause), grades related-info on matched primaries as its own pinned + channel, and publishes the parse-divergence census; exact `RUN_*` pins. - `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]` — diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 19f8040c3..914d2f699 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -40,6 +40,7 @@ mod sym; use crate::diag::Diagnostic; use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; +use crate::merge::FileMerge; use tsv_lang::Span; use tsv_ts::ast::internal::{ForInOfLeft, ForInit}; use tsv_ts::ast::{Expression, Program, Statement, VariableDeclaration, VariableDeclarator}; @@ -131,6 +132,9 @@ pub struct BoundFile { 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 { @@ -309,7 +313,7 @@ pub fn bind_file<'arena>( let facts = FileFacts { module_ness: module_ness(program) }; // Pass 2: the symbol bind (functions-first, container-threaded). - let diagnostics = { + let (diagnostics, merge) = { let interner = program.interner.borrow(); let mut binder = sym::SymbolBinder::new(source, &interner, &walk.address_map, file, facts); binder.bind_program(program); @@ -326,6 +330,7 @@ pub fn bind_file<'arena>( address_map: walk.address_map, diagnostics, facts, + merge, } } diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index 3e32541c4..ffcb69a45 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -12,17 +12,39 @@ //! //! 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's local half carries only `ExportValue`, which appears -//! in **no** `*Excludes` mask, so it can never trigger a conflict — routing -//! exported members straight to the exports table is observationally identical -//! for the cascade. +//! 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 merge phase (cross-declaration-space / cross-file) is out of scope, so -//! the merge-path family codes (TS2397/2649/2664/2671 and merge-only -//! TS2300/2451/2567) are not emitted here — they land as graded *misses*. +//! - 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. // // tsgo: internal/binder/binder.go declareSymbolEx (the cascade), // declareSymbolAndAddToSymbolTable / declareModuleMember / declareClassMember @@ -43,6 +65,7 @@ use super::{addr_of, FileFacts}; use crate::diag::{Category, Diagnostic}; use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; +use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; use string_interner::DefaultStringInterner; use tsv_lang::Span; use tsv_ts::ast::internal::{ @@ -118,6 +141,7 @@ pub(super) struct SymbolBinder<'a> { interner: &'a DefaultStringInterner, address_map: &'a FxHashMap, file: FileId, + is_external: bool, atoms: Atoms, symbols: Vec, @@ -126,6 +150,13 @@ pub(super) struct SymbolBinder<'a> { 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> { @@ -143,6 +174,7 @@ impl<'a> SymbolBinder<'a> { interner, address_map, file, + is_external: is_external_module, atoms: Atoms::new(), symbols: Vec::new(), tables: Vec::new(), @@ -161,8 +193,13 @@ impl<'a> SymbolBinder<'a> { 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\""); @@ -184,9 +221,68 @@ impl<'a> SymbolBinder<'a> { self.bind_statement_list(program.body, true); } - /// Finish, returning the collected bind diagnostics. - pub(super) fn finish(self) -> Vec { - self.diagnostics + /// 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 start) — deterministic iteration, never the hash-map's. + 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, + display: self.atoms.resolve(d.display).to_string(), + is_type_decl: d.is_type_decl, + }) + .collect(); + MergeSymbol { + name: self.atoms.resolve(sym.name).to_string(), + flags: sym.flags, + decls, + } + }) + .collect(); + symbols.sort_by_key(|s| s.decls.first().map_or(u32::MAX, |d| d.error_span.start)); + symbols } // --- table / symbol pool ------------------------------------------------- @@ -305,9 +401,15 @@ impl<'a> SymbolBinder<'a> { } 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 }); + 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`. @@ -370,7 +472,14 @@ impl<'a> SymbolBinder<'a> { file: Some(self.file), span, code, - category: Category::Message, + // 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(), @@ -608,6 +717,14 @@ impl<'a> SymbolBinder<'a> { } Statement::TSModuleDeclaration(m) => self.bind_module(m, mods), Statement::TSTypeAliasDeclaration(t) => { + // 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()); @@ -1324,6 +1441,22 @@ impl<'a> SymbolBinder<'a> { }; 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 { @@ -1603,6 +1736,20 @@ fn var_flags(kind: tsv_ts::ast::internal::VariableDeclarationKind) -> (SymbolFla } } +/// 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, + )) +} + /// Whether a statement is a function declaration (possibly `export`-wrapped) — /// the set tsgo's `bindEachStatementFunctionsFirst` binds first. fn is_function_statement(stmt: &Statement<'_>) -> bool { diff --git a/crates/tsv_check/src/binder/symbols.rs b/crates/tsv_check/src/binder/symbols.rs index 8e69aaf7f..5e5b21868 100644 --- a/crates/tsv_check/src/binder/symbols.rs +++ b/crates/tsv_check/src/binder/symbols.rs @@ -1,10 +1,16 @@ //! Symbols, symbol flags, and symbol tables — the binder's substrate. //! -//! Ported from tsgo's `internal/ast`: [`SymbolFlags`] is the exact bit table plus -//! the `*Excludes` conflict masks (a construct's flags cannot coexist in one table +//! 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 verbatim so the merge-vs-conflict verdict matches tsgo by -//! construction. A [`Symbol`] carries its accumulated flags, name [`Atom`], the +//! 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. @@ -193,6 +199,10 @@ pub struct Decl { 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 diff --git a/crates/tsv_check/src/diag.rs b/crates/tsv_check/src/diag.rs index 38dde22fa..fdf5d9112 100644 --- a/crates/tsv_check/src/diag.rs +++ b/crates/tsv_check/src/diag.rs @@ -389,6 +389,56 @@ mod tests { 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(); diff --git a/crates/tsv_check/src/ids.rs b/crates/tsv_check/src/ids.rs index 65da57ceb..c69dfeed2 100644 --- a/crates/tsv_check/src/ids.rs +++ b/crates/tsv_check/src/ids.rs @@ -31,8 +31,9 @@ impl NodeId { /// 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 - /// forbids `unwrap`/`panic`). + /// 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 { diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 592266d2c..21eb6360a 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -16,6 +16,7 @@ //! source units (+ arena) //! -> parse (goal rule: Module first, Script retry) [program] //! -> lower + bind (one fused pre-order walk per file) [binder] +//! -> merge (single-threaded global-scope fold) [merge] //! -> check (no-op skeleton) [program] //! -> sort + dedup (tsgo's comparer) [diag] //! -> owned diagnostics @@ -31,6 +32,8 @@ //! - [`diag`] — the `Diagnostic` shape and the canonical sort/dedup kernel. //! - `hash` (private) — the crate's Fx-style hasher and `FxHashMap`/`FxHashSet`. //! - `binder` (private) — the fused lower+bind pre-order walk. +//! - [`merge`] — the single-threaded global-scope fold (cross-declaration-space +//! conflicts, `globalThis`/`undefined`, module augmentations). //! - `program` (private) — pipeline assembly and the parse-error short-circuit. //! //! ## Reference-anchor convention @@ -45,6 +48,7 @@ mod program; pub mod diag; pub mod ids; +pub mod merge; pub use binder::{ bind_file, module_ness, BoundFile, FileFacts, ModuleNess, NodeKind, diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs new file mode 100644 index 000000000..c8cba4962 --- /dev/null +++ b/crates/tsv_check/src/merge.rs @@ -0,0 +1,655 @@ +//! 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). +//! +//! **Single-file P1 scope.** With no lib bound (S5) the globals table starts +//! empty, so `mergeGlobalSymbol` on a single file's locals never finds a prior +//! symbol to conflict with — the cross-space TS2300/2451/2567 path +//! ([`report_merge_symbol_error`]) is exercised only by a **multi-file** program +//! (two scripts sharing global scope, or two `declare global` blocks). It is a +//! genuine, unit-tested port here so it is correct the moment a second file (or a +//! lib) lands. Module resolution is likewise 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 (every string-literal module in one is itself +//! an augmentation) — 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 +//! P1; their machinery is noted at the site, not emitted. +//! +//! `mergeSymbol`'s member/export **recursion** (merging a `declare global` +//! interface into a lib interface of the same name) is deferred with lib (S5) — +//! P1's globals hold no members to merge into. +// +// 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::hash::FxHashMap; +use crate::ids::FileId; +use tsv_lang::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 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. +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). +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, + /// The display name for the `{0}` message argument. + pub display: String, + /// 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. +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 with tsgo's `lookupOrIssueError` dedup: a +/// diagnostic already issued at a `(file, span, code, args)` key is reused so its +/// related info accretes instead of a second copy being emitted. +struct MergeOut { + diags: Vec, + /// `(file, start, end, code, args-joined)` -> index into `diags`. + issued: FxHashMap<(Option, u32, u32, u32, String), usize>, +} + +impl MergeOut { + fn new() -> MergeOut { + MergeOut { diags: Vec::new(), issued: FxHashMap::default() } + } + + /// tsgo `lookupOrIssueError`: return the index of an existing equal diagnostic, + /// else push a fresh one and return its index. Dedup keys on the fields the + /// checker's `diagnostics.Lookup` compares (file, span, code, args). + fn lookup_or_issue(&mut self, diag: Diagnostic) -> usize { + let key = ( + diag.file, + diag.span.start, + diag.span.end, + diag.code, + diag.args.join("\u{1}"), + ); + if let Some(&i) = self.issued.get(&key) { + return i; + } + let i = self.diags.len(); + self.issued.insert(key, i); + self.diags.push(diag); + i + } + + /// Push a diagnostic that is never a dedup target (TS2397 / TS2664). + fn push(&mut self, diag: Diagnostic) { + self.diags.push(diag); + } +} + +/// Run the global merge across a program's per-file bind products, returning the +/// merge diagnostics (unsorted — the caller concatenates and canonically sorts). +#[must_use] +pub fn merge_program(files: &[FileMerge]) -> Vec { + let mut out = MergeOut::new(); + let mut globals: FxHashMap = FxHashMap::default(); + + // --- 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 { + merge_global_symbol(&mut globals, 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, sym, &mut out); + } + } + } + + // --- Phase 3: the `undefined` redeclaration check --- + // tsgo seeds `c.globals["undefined"]` with the builtin `undefinedSymbol`; with + // no lib (S5) 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 defers these past global-type creation (regression #2953). At single- + // file P1 no ambient module reaches global scope with a conflict (an external + // module's string-literal modules are augmentations, handled below; a script's + // ambient module merges into empty globals), so this phase is structurally + // present but produces nothing here — it lands with lib + multi-file (S5). + + // --- 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. +fn merge_global_symbol( + globals: &mut FxHashMap, + source: &MergeSymbol, + out: &mut MergeOut, +) { + match globals.get_mut(&source.name) { + Some(target) => merge_symbol(target, source, out), + None => { + globals.insert( + source.name.clone(), + GlobalEntry { + name: source.name.clone(), + flags: source.flags, + decls: source.decls.clone(), + }, + ); + } + } +} + +/// 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). Reachable only through a resolved + // augmentation target (multi-file); at single-file P1 the globals table + // holds no NamespaceModule the merge reaches, so this arm is faithful but + // dormant. + if 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 on **every** declaration of both symbols with +/// related info, deduped through [`MergeOut::lookup_or_issue`]. +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 diagnostic per declaration +/// node (deduped), 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, +) { + let needs_name = code != 2567; + for decl in decls { + let args = if needs_name { vec![symbol_name.to_string()] } else { Vec::new() }; + let 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(), + }; + let idx = out.lookup_or_issue(primary); + // tsgo `addDuplicateDeclarationError`: related info for each *other* + // declaration — leading (TS6203) for the first, follow-on (TS6204) after, + // capped at 5 and deduped by target node. + for related in related_nodes { + if related.file == decl.file && related.error_span == decl.error_span { + continue; + } + let existing = &out.diags[idx].related; + if existing.len() >= 5 + || existing + .iter() + .any(|r| r.file == Some(related.file) && r.span == related.error_span) + { + continue; + } + let related_diag = if existing.is_empty() { + related_info(related, 6203, Some(symbol_name)) + } else { + related_info(related, 6204, None) + }; + out.diags[idx].related.push(related_diag); + } + } +} + +/// 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::*; + + 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), + display: name.to_string(), + 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]); + 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)]); + 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]); + 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())); + } + + /// The lookupOrIssueError dedup: a name conflicting three ways across files + /// yields one diagnostic per declaration node with accreting related info, + /// capped at 5. + #[test] + fn dedup_and_related_cap() { + // Five files each declaring `let x` — the sixth+ related info is dropped. + let files: Vec = (0..6) + .map(|f| { + script( + f, + vec![MergeSymbol { + name: "x".to_string(), + flags: SymbolFlags::BLOCK_SCOPED_VARIABLE, + decls: vec![decl(f, 4, "x", false)], + }], + ) + }) + .collect(); + let diags = merge_program(&files); + // One diagnostic per file (no duplicate emission at a node). + assert_eq!(diags.len(), 6); + // Related info capped at 5. + assert!(diags.iter().all(|d| d.related.len() <= 5)); + } + + /// 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]); + 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]); + 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]); + 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]).is_empty()); + } +} diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 1b533acae..66f8d3fed 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -36,6 +36,7 @@ use crate::binder::{bind_file, module_ness, ModuleNess}; use crate::diag::{sort_and_deduplicate, Diagnostic}; use crate::ids::FileId; +use crate::merge::{merge_program, FileMerge}; use bumpalo::Bump; use tsv_ts::ast::Program; use tsv_ts::{parse_with_goal, Goal}; @@ -143,6 +144,7 @@ pub fn check_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> CheckResu // parsed contributes its bind/check diagnostics, independent of a sibling's // rejection. A rejected unit has no AST, so it contributes none. let mut diagnostics: Vec = Vec::new(); + let mut merges: Vec = Vec::new(); for attempt in &mut attempts { if let Some(program) = &attempt.program { let source = units[attempt.file.index()].source; @@ -153,8 +155,15 @@ pub fn check_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> CheckResu let check_diags = check_file(&bound); diagnostics.extend(bound.diagnostics); diagnostics.extend(check_diags); + merges.push(bound.merge); } } + // The single-threaded global merge (checker-init phase) over every parsed + // file's bind product — cross-declaration-space conflicts, the + // globalThis/undefined checks, and module augmentations. Its diagnostics join + // the pool before the canonical sort (order-independent). + diagnostics.extend(merge_program(&merges)); + // Final caller-side sort + dedup over the whole program's diagnostics. let paths: Vec = units.iter().map(|u| u.name.to_string()).collect(); sort_and_deduplicate(&mut diagnostics, &paths); diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 38c9bb465..3596d1cf1 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -89,20 +89,22 @@ const RUN_SCRIPT_RETRY_PIN: usize = 25; /// tsv parser robustness change (a fix removes an entry; a regression adds one). const RUN_CRASH_EXCLUDED_PIN: usize = 1; -/// REGRESSION PINS (exact, two-sided) for the family grading (the S3 gate). -/// Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 (hard); the -/// rest pin the buckets so any move (a cascade change, a tsv parser change, a -/// typescript-go pull) forces a deliberate re-pin. The missing bucket is -/// classified: `merge` (merge-phase family, S4), `lib` (absent-lib conflicts, -/// S5), and `check-time` (checker-emitted TS2300/2451 the bind-only slice can't -/// produce — duplicate members, type parameters, computed/private names). A drop -/// in `check-time` (matches gained) or `merge`/`lib` is a real improvement that -/// re-pins; a rise is a regression to explain. +/// REGRESSION PINS (exact, two-sided) for the family grading (the bind + merge +/// gate). Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 +/// (hard); the rest pin the buckets so any move (a cascade change, a merge +/// change, a tsv parser change, a typescript-go pull) forces a deliberate re-pin. +/// The missing bucket is classified: `merge` (merge-phase family — **now 0**, S4 +/// closed the single-file merge path: TS2397 globalThis/undefined + TS2664 +/// augmentation-not-found), `lib` (absent-lib conflicts, S5), and `check-time` +/// (checker-emitted TS2300/2451 the bind+merge slice can't produce — duplicate +/// members, type parameters, computed/private names). A drop in `check-time` +/// (matches gained) or `lib` is a real improvement that re-pins; a rise anywhere +/// is a regression to explain. const RUN_FAMILY_GRADED_PIN: usize = 4066; const RUN_FAMILY_POSITIVE_PIN: usize = 125; -const RUN_FAMILY_MATCH_PIN: usize = 414; -const RUN_FAMILY_MISSING_PIN: usize = 136; -const RUN_MISSING_MERGE_PIN: usize = 7; +const RUN_FAMILY_MATCH_PIN: usize = 421; +const RUN_FAMILY_MISSING_PIN: usize = 129; +const RUN_MISSING_MERGE_PIN: usize = 0; const RUN_MISSING_LIB_PIN: usize = 4; const RUN_MISSING_CHECKTIME_PIN: usize = 125; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; @@ -110,6 +112,18 @@ const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; const RUN_MODULE_DETECTION_PIN: usize = 1; +/// REGRESSION PINS (exact, two-sided) for the related-info channel — graded on the +/// matched family primaries only (the primary code gates the per-variant verdict; +/// related info is its own pinned channel). Measured 2026-07-10 vs pin 168e7015: +/// the 42 matches are the multiple-default-export chains (TS2752/2753/2528's +/// TS6204). `missing`/`extra`/`span_mismatch` are 0 (the merge-path family the +/// merge emits carries no related info, and the sole TS1369-hint baseline is a +/// tsv parse-rejection). A rise in `missing`/`extra` is a regression to explain. +const RUN_RELATED_MATCH_PIN: usize = 42; +const RUN_RELATED_MISSING_PIN: usize = 0; +const RUN_RELATED_EXTRA_PIN: usize = 0; +const RUN_RELATED_SPAN_MISMATCH_PIN: usize = 0; + /// Query the tsgo TypeScript conformance baselines. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "tsc_conformance")] @@ -352,6 +366,18 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { report.family_span_mismatch, RUN_FAMILY_SPAN_MISMATCH_PIN, ); + + // Related-info channel pins (two-sided; does not gate the primary verdict). + pin(&mut errs, "related match", report.related_match, RUN_RELATED_MATCH_PIN); + pin(&mut errs, "related missing", report.related_missing, RUN_RELATED_MISSING_PIN); + pin(&mut errs, "related extra", report.related_extra, RUN_RELATED_EXTRA_PIN); + pin( + &mut errs, + "related span_mismatch", + report.related_span_mismatch, + RUN_RELATED_SPAN_MISMATCH_PIN, + ); + pin(&mut errs, "carve-out rule (a)", report.carve_out_rule_a, RUN_CARVE_OUT_RULE_A_PIN); pin( &mut errs, diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index ee7d43c3c..2a2f2d2bd 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -32,7 +32,7 @@ // tsgo: internal/compiler/program.go GetDiagnosticsOfAnyProgram (the pipeline) // tsgo: internal/testrunner/compiler_runner.go (the in-scope selection) -use crate::tsc_conformance::baseline::parse_summary_block; +use crate::tsc_conformance::baseline::{parse_baseline, parse_summary_block}; use crate::tsc_conformance::corpus::{discover_corpus, read_corpus_file, CorpusTest}; use crate::tsc_conformance::directives::{extract_settings, split_units, Unit}; use crate::tsc_conformance::discovery::{baselines_dir, discover_baselines, Baseline}; @@ -128,6 +128,8 @@ pub struct CleanFail { pub struct PanicRecord { /// The corpus test's relative path. pub test: String, + /// The panic payload's message (downcast to `&str`/`String`), for triage. + pub payload: String, } /// The skeleton sweep report. @@ -162,6 +164,22 @@ pub struct SkeletonReport { pub family_extra: usize, /// Right code + file, wrong position (greedy-paired). pub family_span_mismatch: usize, + + // --- related-info grading (its own pinned channel; does NOT gate the + // per-variant primary verdict) — graded only for matched primaries --- + /// Related-info entries that matched (code, file, line, col). + pub related_match: usize, + /// Baseline related entries with no matching related of ours. + pub related_missing: usize, + /// Related entries we emit the baseline lacks. + pub related_extra: usize, + /// Right code + file, wrong position (greedy-paired). + pub related_span_mismatch: usize, + /// Sample related over-emissions. + pub related_extra_samples: Vec, + /// Sample related misses. + pub related_missing_samples: Vec, + /// ...missing attributed to the merge phase (TS2397/2649/2664/2671). pub missing_merge: usize, /// ...missing attributed to absent lib binding (a `LIB_CONFLICT_BASELINES` test). @@ -314,6 +332,18 @@ fn probe_crash_exclusion(test: &CorpusTest) -> bool { panicked } +/// Extract a caught panic payload's message (the `&str` / `String` cases the +/// standard panic machinery produces). +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "(non-string panic payload)".to_string() + } +} + /// Parse+check one single-file test once and attribute the outcome to each of /// its in-scope variants. fn grade_test( @@ -328,9 +358,15 @@ fn grade_test( let checked = catch_unwind(AssertUnwindSafe(|| { check_program(&[SourceUnit::new(&unit.name, &unit.content)], &arena) })); - let Ok(result) = checked else { - report.panics.push(PanicRecord { test: test.relative_path.clone() }); - return; + let result = match checked { + Ok(result) => result, + Err(payload) => { + report.panics.push(PanicRecord { + test: test.relative_path.clone(), + payload: panic_payload_message(&*payload), + }); + return; + } }; // The single unit's parse outcome (files is never empty for one input). @@ -338,22 +374,35 @@ fn grade_test( // Our family diagnostics are variant-independent (the checker consults no // directives, and module-ness is inert for the cascade), so build the - // (file, line, col, code) multiset once per test via the unit's line map. - let ours_family: Vec = if matches!(file.parse, ParseReport::Parsed(_)) { + // (file, line, col, code) multiset — plus each diagnostic's related info — + // once per test via the unit's line map. + let ours_family: Vec = if matches!(file.parse, ParseReport::Parsed(_)) { let (tracker, map) = LocationTracker::new_ecmascript_with_map(&unit.content); let mapper = LocationMapper { tracker: &tracker, map: &map }; + let family = |code: u32, span: tsv_lang::Span, file_name: &str| { + let (_, pos) = mapper.pos_and_position(span.start); + (pos.line as u32, pos.column as u32 + 1, code, file_name.to_string()) + }; result .diagnostics .iter() .filter(|d| FAMILY_CODES.contains(&d.code)) .map(|d| { - let (_, pos) = mapper.pos_and_position(d.span.start); - FamilyDiag { - file: unit.name.clone(), - line: pos.line as u32, - col: pos.column as u32 + 1, - code: d.code, - } + let (line, col, code, file_name) = family(d.code, d.span, &unit.name); + // Single-file: every related node lives in the one unit. + let related = d + .related + .iter() + .map(|r| { + let (_, pos) = mapper.pos_and_position(r.span.start); + RelatedKey { + code: r.code, + file: unit.name.clone(), + loc: Some((pos.line as u32, pos.column as u32 + 1)), + } + }) + .collect(); + FamilyEntry { key: FamilyDiag { file: file_name, line, col, code }, related } }) .collect() } else { @@ -418,33 +467,52 @@ struct FamilyDiag { code: u32, } +/// One related-info entry in baseline coordinates: `(code, file, location)`. A +/// `--,--` (default-library) location is [`None`] and compares by code+file only. +#[derive(Clone, PartialEq, Eq, Hash)] +struct RelatedKey { + code: u32, + file: String, + loc: Option<(u32, u32)>, +} + +/// A family primary plus its related-info entries — the unit the related-info +/// channel grades (a matched primary's related sets are compared as multisets). +struct FamilyEntry { + key: FamilyDiag, + related: Vec, +} + /// Grade one parsed-with-baseline variant's family diagnostics against its /// baseline, folding the buckets into `report`. Applies predicate v1 rule (a) -/// (recovery-AST carve-out) first. +/// (recovery-AST carve-out) first, then the primary-code channel and — for the +/// matched primaries — the independent related-info channel. fn grade_family( test: &CorpusTest, name: &str, baseline: &Baseline, - ours: &[FamilyDiag], + ours: &[FamilyEntry], report: &mut SkeletonReport, ) { let Ok(content) = std::fs::read_to_string(&baseline.path) else { return }; - let base_all = parse_summary_block(&content); + let base_all = parse_base_diags(&content); // Predicate v1 rule (a): tsv parses clean (it did — this variant parsed) and // the baseline carries a non-bind TS1xxx code -> recovery-AST incomparable. - let has_nonbind_ts1xxx = base_all - .iter() - .any(|d| (1000..2000).contains(&d.code) && !BIND_EMITTED_TS1XXX.contains(&d.code)); - let base_family: Vec = base_all + let has_nonbind_ts1xxx = base_all.iter().any(|d| { + (1000..2000).contains(&d.code) + && u32::try_from(d.code).is_ok_and(|c| !BIND_EMITTED_TS1XXX.contains(&c)) + }); + let base_family: Vec = base_all .iter() - .filter(|d| FAMILY_CODES.contains(&d.code)) .filter_map(|d| { - Some(FamilyDiag { - file: d.file.clone()?, - line: d.line?, - col: d.col?, - code: d.code, + let code = u32::try_from(d.code).ok()?; + if !FAMILY_CODES.contains(&code) { + return None; + } + Some(FamilyEntry { + key: FamilyDiag { file: d.file.clone()?, line: d.line?, col: d.col?, code }, + related: d.related.clone(), }) }) .collect(); @@ -463,7 +531,9 @@ fn grade_family( report.family_positive_variants += 1; } - let buckets = family_buckets(ours, &base_family); + let ours_keys: Vec = ours.iter().map(|e| e.key.clone()).collect(); + let base_keys: Vec = base_family.iter().map(|e| e.key.clone()).collect(); + let buckets = family_buckets(&ours_keys, &base_keys); report.family_match += buckets.matched; report.family_span_mismatch += buckets.span_mismatch; report.family_extra += buckets.extra; @@ -491,6 +561,181 @@ fn grade_family( } } } + + // The related-info channel (independent of the primary verdict): grade related + // multisets only for the primaries that matched. + let rel = grade_related(ours, &base_family); + report.related_match += rel.matched; + report.related_span_mismatch += rel.span_mismatch; + report.related_extra += rel.extra; + report.related_missing += rel.missing; + if rel.extra > 0 && report.related_extra_samples.len() < 20 { + report.related_extra_samples.push(format!("{}/{name} (+{})", test.suite, rel.extra)); + } + if rel.missing > 0 && report.related_missing_samples.len() < 20 { + report.related_missing_samples.push(format!("{}/{name} (-{})", test.suite, rel.missing)); + } +} + +/// A baseline summary diagnostic with its parsed related-info entries. +struct BaseDiag { + file: Option, + line: Option, + col: Option, + /// The `TS` (i32 — the harness's `TS-1` and non-family codes appear here). + code: i32, + related: Vec, +} + +/// Parse a baseline into summary diagnostics with related info, via the full +/// [`parse_baseline`] model (100% of the pinned baselines round-trip through it). +/// Falls back to the related-free summary parse on the rare structural surprise, +/// so the primary channel never shifts. +fn parse_base_diags(content: &str) -> Vec { + use crate::tsc_conformance::baseline::Loc; + match parse_baseline(content) { + Ok(parsed) => parsed + .diags + .iter() + .map(|d| { + let (line, col) = match d.loc { + Some(Loc::Numbered { line, col }) => (Some(line), Some(col)), + _ => (None, None), + }; + let related = d.related.iter().filter_map(|s| parse_related_line(s)).collect(); + BaseDiag { file: d.file.clone(), line, col, code: d.code, related } + }) + .collect(), + Err(_) => parse_summary_block(content) + .into_iter() + .map(|d| BaseDiag { + file: d.file, + line: d.line, + col: d.col, + code: d.code as i32, + related: Vec::new(), + }) + .collect(), + } +} + +/// Parse one `!!! related TS ::: ` line into a +/// [`RelatedKey`], or `None` for a chain-continuation line (no `!!! related` +/// prefix). A `--:--` location parses to [`None`] (a masked default-lib position). +fn parse_related_line(line: &str) -> Option { + let rest = line.strip_prefix("!!! related TS")?; + let end = rest.find(|c: char| !c.is_ascii_digit())?; + let code: u32 = rest.get(..end)?.parse().ok()?; + let after = rest.get(end..)?.strip_prefix(' ')?; // `::: ` + // The first `": "` separates the location from the message (a filename holds + // no space, and line/col are digits-or-`--`). + let boundary = after.find(": ")?; + let locpart = after.get(..boundary)?; // `::` + let (rest2, col) = locpart.rsplit_once(':')?; + let (file, line_s) = rest2.rsplit_once(':')?; + let loc = if line_s == "--" && col == "--" { + None + } else { + Some((line_s.parse().ok()?, col.parse().ok()?)) + }; + Some(RelatedKey { code, file: file.to_string(), loc }) +} + +/// The related-info buckets across a variant's matched primaries. +#[derive(Default)] +struct RelatedBuckets { + matched: usize, + extra: usize, + span_mismatch: usize, + missing: usize, +} + +/// Grade related-info multisets for the primaries that match by +/// `(file,line,col,code)`. Ours and the baseline are grouped by primary key; +/// matched primaries are paired positionally and their related sets diffed +/// (exact `(code,file,loc)` match, masked `--,--` by `(code,file)`, then +/// `(code,file)` span-mismatch pairing of the leftovers). +fn grade_related(ours: &[FamilyEntry], base: &[FamilyEntry]) -> RelatedBuckets { + let mut ours_by: HashMap<&FamilyDiag, Vec<&[RelatedKey]>> = HashMap::new(); + for e in ours { + ours_by.entry(&e.key).or_default().push(&e.related); + } + let mut base_by: HashMap<&FamilyDiag, Vec<&[RelatedKey]>> = HashMap::new(); + for e in base { + base_by.entry(&e.key).or_default().push(&e.related); + } + + let mut out = RelatedBuckets::default(); + for (key, ours_sets) in &ours_by { + let Some(base_sets) = base_by.get(key) else { continue }; + let paired = ours_sets.len().min(base_sets.len()); + for i in 0..paired { + related_diff(ours_sets[i], base_sets[i], &mut out); + } + } + out +} + +/// Diff one matched primary's related multisets, folding into `out`. +fn related_diff(ours: &[RelatedKey], base: &[RelatedKey], out: &mut RelatedBuckets) { + // Exact `(code,file,loc)` matches first. + let mut ours_counts: HashMap<&RelatedKey, usize> = HashMap::new(); + for r in ours { + *ours_counts.entry(r).or_default() += 1; + } + let mut base_counts: HashMap<&RelatedKey, usize> = HashMap::new(); + for r in base { + *base_counts.entry(r).or_default() += 1; + } + // Leftovers grouped by `(code, file)` for masked-match and span-mismatch pairing. + let mut left_ours: HashMap<(u32, &str), usize> = HashMap::new(); + let mut left_base_located: HashMap<(u32, &str), usize> = HashMap::new(); + let mut left_base_masked: HashMap<(u32, &str), usize> = HashMap::new(); + + for (r, &oc) in &ours_counts { + let bc = base_counts.get(*r).copied().unwrap_or(0); + let m = oc.min(bc); + out.matched += m; + if oc > m { + *left_ours.entry((r.code, r.file.as_str())).or_default() += oc - m; + } + } + for (r, &bc) in &base_counts { + let oc = ours_counts.get(*r).copied().unwrap_or(0); + let m = oc.min(bc); + if bc > m { + let bucket = if r.loc.is_none() { &mut left_base_masked } else { &mut left_base_located }; + *bucket.entry((r.code, r.file.as_str())).or_default() += bc - m; + } + } + + // Masked baseline related (default-lib `--,--`) matches ours by `(code,file)`. + for (key, bcount) in &mut left_base_masked { + if let Some(ocount) = left_ours.get_mut(key) { + let m = (*ocount).min(*bcount); + out.matched += m; + *ocount -= m; + *bcount -= m; + } + } + + // Remaining located leftovers: `(code,file)` pairing = span mismatch; the rest + // is extra (ours) / missing (baseline). + let keys: std::collections::HashSet<(u32, &str)> = left_ours + .keys() + .chain(left_base_located.keys()) + .chain(left_base_masked.keys()) + .copied() + .collect(); + for key in keys { + let oc = left_ours.get(&key).copied().unwrap_or(0); + let bc = left_base_located.get(&key).copied().unwrap_or(0) + + left_base_masked.get(&key).copied().unwrap_or(0); + let sm = oc.min(bc); + out.span_mismatch += sm; + out.extra += oc - sm; + out.missing += bc - sm; + } } /// The four family buckets for one variant. @@ -602,6 +847,17 @@ impl SkeletonReport { println!(" check-time (S3+): {} (checker-emitted TS2300/2451: duplicate members, type params, computed/private names)", self.missing_other); println!(" extra (GATE=0): {}", self.family_extra); println!(" span_mismatch: {}", self.family_span_mismatch); + println!("Related-info (matched primaries; own channel, non-gating)"); + println!(" related match: {}", self.related_match); + println!(" related missing: {}", self.related_missing); + println!(" related extra: {}", self.related_extra); + println!(" related span_mismatch: {}", self.related_span_mismatch); + for s in &self.related_missing_samples { + println!(" REL-MISSING {s}"); + } + for s in &self.related_extra_samples { + println!(" REL-EXTRA {s}"); + } println!("Carve-out rule (a): {}", self.carve_out_rule_a); println!(" ...family-positive: {}", self.carve_out_rule_a_family); println!("moduleDetection variants: {} (watch; inert for family)", self.module_detection_variants); @@ -628,7 +884,7 @@ impl SkeletonReport { } } for p in &self.panics { - println!(" PANIC {}", p.test); + println!(" PANIC {} — {}", p.test, p.payload); } } } From ac7394286246c9d0562e704fbf670a2b1e21da62 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 16:15:53 -0400 Subject: [PATCH 17/79] fix: merge related-info model (fresh primary per merge, all-6203 uncapped), globalThis guard, ambient deferral, dead field --- crates/tsv_check/src/binder/sym.rs | 1 - crates/tsv_check/src/merge.rs | 242 ++++++++++++++++++----------- 2 files changed, 153 insertions(+), 90 deletions(-) diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index ffcb69a45..f49e95c64 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -270,7 +270,6 @@ impl<'a> SymbolBinder<'a> { .map(|d| MergeDecl { file: self.file, error_span: d.error_span, - display: self.atoms.resolve(d.display).to_string(), is_type_decl: d.is_type_decl, }) .collect(); diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index c8cba4962..6352c4ba0 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -52,6 +52,16 @@ use tsv_lang::Span; 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('"') +} + /// 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. @@ -91,8 +101,6 @@ pub struct MergeDecl { pub file: FileId, /// The span a diagnostic points at (the declaration name). pub error_span: Span, - /// The display name for the `{0}` message argument. - pub display: String, /// tsgo `IsTypeDeclaration` (class / interface / enum / type-alias / /// type-parameter) — the `undefined` check skips these. pub is_type_decl: bool, @@ -116,41 +124,25 @@ struct GlobalEntry { decls: Vec, } -/// The merge phase's diagnostic sink with tsgo's `lookupOrIssueError` dedup: a -/// diagnostic already issued at a `(file, span, code, args)` key is reused so its -/// related info accretes instead of a second copy being emitted. +/// 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, - /// `(file, start, end, code, args-joined)` -> index into `diags`. - issued: FxHashMap<(Option, u32, u32, u32, String), usize>, } impl MergeOut { fn new() -> MergeOut { - MergeOut { diags: Vec::new(), issued: FxHashMap::default() } - } - - /// tsgo `lookupOrIssueError`: return the index of an existing equal diagnostic, - /// else push a fresh one and return its index. Dedup keys on the fields the - /// checker's `diagnostics.Lookup` compares (file, span, code, args). - fn lookup_or_issue(&mut self, diag: Diagnostic) -> usize { - let key = ( - diag.file, - diag.span.start, - diag.span.end, - diag.code, - diag.args.join("\u{1}"), - ); - if let Some(&i) = self.issued.get(&key) { - return i; - } - let i = self.diags.len(); - self.issued.insert(key, i); - self.diags.push(diag); - i + MergeOut { diags: Vec::new() } } - /// Push a diagnostic that is never a dedup target (TS2397 / TS2664). + /// Push a diagnostic. fn push(&mut self, diag: Diagnostic) { self.diags.push(diag); } @@ -163,6 +155,11 @@ pub fn merge_program(files: &[FileMerge]) -> 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 { @@ -175,7 +172,11 @@ pub fn merge_program(files: &[FileMerge]) -> Vec { } } for sym in &file.source_locals { - merge_global_symbol(&mut globals, sym, &mut out); + if sym.flags.intersects(MODULE_FLAGS) && is_ambient_module_symbol_name(&sym.name) { + deferred_ambient.push(sym); + } else { + merge_global_symbol(&mut globals, sym, &mut out); + } } } @@ -201,11 +202,13 @@ pub fn merge_program(files: &[FileMerge]) -> Vec { } // --- Phase 4: global ambient-module declarations (deferred) --- - // tsgo defers these past global-type creation (regression #2953). At single- - // file P1 no ambient module reaches global scope with a conflict (an external - // module's string-literal modules are augmentations, handled below; a script's - // ambient module merges into empty globals), so this phase is structurally - // present but produces nothing here — it lands with lib + multi-file (S5). + // 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, sym, &mut out); + } // --- Phase 5: non-global module augmentations (`declare module "X"`) --- for file in files { @@ -255,11 +258,13 @@ fn merge_symbol(target: &mut GlobalEntry, source: &MergeSymbol, out: &mut MergeO 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). Reachable only through a resolved - // augmentation target (multi-file); at single-file P1 the globals table - // holds no NamespaceModule the merge reaches, so this arm is faithful but - // dormant. - if let Some(decl) = source.decls.first() { + // with value exports" (TS2649) — but NOT when the target is the built-in + // `globalThis` (tsgo `mergeSymbol`'s `target != globalThisSymbol` guard): + // the phase-1 TS2397 already reports that conflict, and a second TS2649 + // would not make sense. + 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 { @@ -287,9 +292,9 @@ fn report_merge_symbol_error(target: &GlobalEntry, source: &MergeSymbol, out: &m add_dup_errors(&target.decls, code, &symbol_name, &source.decls, out); } -/// tsgo `addDuplicateDeclarationErrorsForSymbols` — one diagnostic per declaration -/// node (deduped), each carrying related info pointing at the *other* symbol's -/// declarations. +/// 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, @@ -297,43 +302,60 @@ fn add_dup_errors( related_nodes: &[MergeDecl], out: &mut MergeOut, ) { - let needs_name = code != 2567; for decl in decls { - let args = if needs_name { vec![symbol_name.to_string()] } else { Vec::new() }; - let 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(), - }; - let idx = out.lookup_or_issue(primary); - // tsgo `addDuplicateDeclarationError`: related info for each *other* - // declaration — leading (TS6203) for the first, follow-on (TS6204) after, - // capped at 5 and deduped by target node. - for related in related_nodes { - if related.file == decl.file && related.error_span == decl.error_span { - continue; - } - let existing = &out.diags[idx].related; - if existing.len() >= 5 - || existing - .iter() - .any(|r| r.file == Some(related.file) && r.span == related.error_span) - { - continue; - } - let related_diag = if existing.is_empty() { - related_info(related, 6203, Some(symbol_name)) - } else { - related_info(related, 6204, None) - }; - out.diags[idx].related.push(related_diag); + 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 @@ -458,11 +480,12 @@ fn message_for(code: u32, name: Option<&str>) -> String { 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), - display: name.to_string(), is_type_decl, } } @@ -551,13 +574,18 @@ mod tests { assert!(diags.iter().all(|d| d.args.is_empty())); } - /// The lookupOrIssueError dedup: a name conflicting three ways across files - /// yields one diagnostic per declaration node with accreting related info, - /// capped at 5. + /// 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 dedup_and_related_cap() { - // Five files each declaring `let x` — the sixth+ related info is dropped. - let files: Vec = (0..6) + 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, @@ -569,11 +597,47 @@ mod tests { ) }) .collect(); - let diags = merge_program(&files); - // One diagnostic per file (no duplicate emission at a node). - assert_eq!(diags.len(), 6); - // Related info capped at 5. - assert!(diags.iter().all(|d| d.related.len() <= 5)); + let mut diags = merge_program(&files); + // 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. + sort_and_deduplicate(&mut diags, &paths); + 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".to_string(), "b.ts".to_string(), "c.ts".to_string()]; + 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)]); + 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)); } /// A single script declaring `var globalThis` triggers TS2397 per declaration. From 4da82ee2f562246de55779d1ae0667f15a227a17 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 18:14:32 -0400 Subject: [PATCH 18/79] =?UTF-8?q?feat:=20lib=20binding=20=E2=80=94=20LibBa?= =?UTF-8?q?se=20per=20resolved=20set,=20cached=20bound=20lib=20products,?= =?UTF-8?q?=20lib-conflict=20family=20closes=20(match=20425,=20lib=20missi?= =?UTF-8?q?ng=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/CLAUDE.md | 12 +- crates/tsv_check/src/lib.rs | 4 +- crates/tsv_check/src/merge.rs | 435 +++++++++++++++-- crates/tsv_check/src/program.rs | 213 ++++++--- crates/tsv_check/tests/lib_base.rs | 91 ++++ crates/tsv_debug/CLAUDE.md | 2 +- .../src/cli/commands/tsc_conformance.rs | 66 ++- crates/tsv_debug/src/tsc_conformance/libs.rs | 442 ++++++++++++++++++ crates/tsv_debug/src/tsc_conformance/mod.rs | 1 + .../tsv_debug/src/tsc_conformance/runner.rs | 292 +++++++++--- 10 files changed, 1348 insertions(+), 210 deletions(-) create mode 100644 crates/tsv_check/tests/lib_base.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/libs.rs diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 0a4ac238c..4af08e9dd 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -44,7 +44,12 @@ 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). + 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/` — the fused lower+bind pre-order walk: assigns dense 1-based `NodeId`s, fills SoA side columns (`parents`/`kinds`/`spans`/ `subtree_end` — the latter makes descendant tests O(1) interval checks), @@ -78,7 +83,10 @@ let result: CheckResult = check_program(&units, &arena); ``` The caller owns the arena (the same contract as `tsv_ts::parse`); the -result is fully owned — nothing borrows out. +result is fully owned — nothing borrows out. For lib-aware checking: +`bind_program` (parse+bind once, variant-independent, fully owned) → +`check_bound(&bound, Some(&lib_base))`; `bind_lib` produces a cacheable +`LibFile`; `check_program_with_lib` is the one-shot form. ## Which tool answers which question diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 21eb6360a..e6f6e3fb9 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -55,8 +55,10 @@ pub use binder::{ }; pub use diag::{Category, Diagnostic}; pub use ids::{FileId, NodeId}; +pub use merge::{LibBase, LibFile}; pub use program::{ - check_program, CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, + bind_lib, bind_program, check_bound, check_program, check_program_with_lib, BoundProgram, + CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, }; // Re-exported so consumers can name the parse goal a `ParsedFacts` reports diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 6352c4ba0..55db02146 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -17,23 +17,31 @@ //! Iteration is **deterministic** (file order, then declaration order) — never a //! hash-map's iteration order (the grimoire-recorded tsgo determinism landmine). //! -//! **Single-file P1 scope.** With no lib bound (S5) the globals table starts -//! empty, so `mergeGlobalSymbol` on a single file's locals never finds a prior -//! symbol to conflict with — the cross-space TS2300/2451/2567 path -//! ([`report_merge_symbol_error`]) is exercised only by a **multi-file** program -//! (two scripts sharing global scope, or two `declare global` blocks). It is a -//! genuine, unit-tested port here so it is correct the moment a second file (or a -//! lib) lands. Module resolution is likewise 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 (every string-literal module in one is itself -//! an augmentation) — so a single-file augmentation is **always** "not found" -//! (TS2664). The resolves-to-a-non-module errors (TS2649 / TS2671) need a +//! **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 -//! P1; their machinery is noted at the site, not emitted. +//! scope; their machinery is noted at the site, not emitted. //! //! `mergeSymbol`'s member/export **recursion** (merging a `declare global` -//! interface into a lib interface of the same name) is deferred with lib (S5) — -//! P1's globals hold no members to merge into. +//! 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), @@ -62,9 +70,120 @@ 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) + } + + /// The number of distinct global names — informational (base sizing). + #[must_use] + pub fn global_count(&self) -> usize { + self.globals.len() + } +} + +/// 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, @@ -84,6 +203,7 @@ pub struct FileMerge { /// 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, @@ -108,6 +228,7 @@ pub struct MergeDecl { /// 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, @@ -148,10 +269,23 @@ impl MergeOut { } } -/// Run the global merge across a program's per-file bind products, returning the -/// merge diagnostics (unsorted — the caller concatenates and canonically sorts). +/// 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]) -> Vec { +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(); @@ -175,7 +309,7 @@ pub fn merge_program(files: &[FileMerge]) -> Vec { if sym.flags.intersects(MODULE_FLAGS) && is_ambient_module_symbol_name(&sym.name) { deferred_ambient.push(sym); } else { - merge_global_symbol(&mut globals, sym, &mut out); + merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out); } } } @@ -184,7 +318,7 @@ pub fn merge_program(files: &[FileMerge]) -> Vec { for file in files { for aug in &file.global_augmentations { for sym in aug { - merge_global_symbol(&mut globals, sym, &mut out); + merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out); } } } @@ -207,7 +341,7 @@ pub fn merge_program(files: &[FileMerge]) -> Vec { // 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, sym, &mut out); + merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out); } // --- Phase 5: non-global module augmentations (`declare module "X"`) --- @@ -228,25 +362,103 @@ pub fn merge_program(files: &[FileMerge]) -> Vec { } /// tsgo `mergeGlobalSymbol` — merge one symbol into the globals table, reporting a -/// cross-declaration-space conflict when the flags exclude each other. +/// 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 base without mutating it. A clean merge + // emits nothing; the in-file cascade already collapsed same-name test + // symbols to one, so (at the single-file scope this grades) not cloning the + // base into the overlay is sound — a later same-name symbol cannot arise. + 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, ) { - match globals.get_mut(&source.name) { - Some(target) => merge_symbol(target, source, out), - None => { - globals.insert( - source.name.clone(), - GlobalEntry { - name: source.name.clone(), - flags: source.flags, - decls: source.decls.clone(), - }, - ); + 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 built-in `globalThis` (the phase-1 TS2397 already + // reports that, and a second TS2649 would not make sense; the Part A 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 @@ -520,7 +732,7 @@ mod tests { decls: vec![decl(1, 4, "x", false)], }], ); - let diags = merge_program(&[a, b]); + 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]); @@ -545,7 +757,7 @@ mod tests { }], ) }; - let diags = merge_program(&[mk(0), mk(1)]); + let diags = merge_program(&[mk(0), mk(1)], None, 0); assert_eq!(diags.iter().map(|d| d.code).collect::>(), vec![2300, 2300]); } @@ -568,7 +780,7 @@ mod tests { decls: vec![decl(1, 11, "E", true)], }], ); - let diags = merge_program(&[a, b]); + 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())); @@ -597,7 +809,7 @@ mod tests { ) }) .collect(); - let mut diags = merge_program(&files); + let mut diags = merge_program(&files, 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); @@ -629,7 +841,7 @@ mod tests { }], ) }; - let mut diags = merge_program(&[mk(0), mk(1), mk(2)]); + 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 @@ -651,7 +863,7 @@ mod tests { decls: vec![decl(0, 4, "globalThis", false)], }], ); - let diags = merge_program(&[f]); + 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()]); @@ -672,7 +884,7 @@ mod tests { ], }], ); - let diags = merge_program(&[f]); + 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); @@ -692,7 +904,7 @@ mod tests { ModuleAug { file: FileId(0), name: "M".to_string(), name_span: Span::new(50, 53) }, ], }; - let diags = merge_program(&[f]); + 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); @@ -714,6 +926,147 @@ mod tests { global_augmentations: Vec::new(), module_augmentations: Vec::new(), }; - assert!(merge_program(&[f]).is_empty()); + 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".to_string(), + "lib.es5.d.ts".to_string(), + "lib.es2015.symbol.d.ts".to_string(), + "lib.es2015.symbol.wellknown.d.ts".to_string(), + ]; + 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); + } + + /// 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/program.rs b/crates/tsv_check/src/program.rs index 66f8d3fed..1a391a7f0 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -36,7 +36,7 @@ use crate::binder::{bind_file, module_ness, ModuleNess}; use crate::diag::{sort_and_deduplicate, Diagnostic}; use crate::ids::FileId; -use crate::merge::{merge_program, FileMerge}; +use crate::merge::{merge_program, FileMerge, LibBase, LibFile}; use bumpalo::Bump; use tsv_ts::ast::Program; use tsv_ts::{parse_with_goal, Goal}; @@ -81,6 +81,7 @@ pub struct FileReport { } /// A unit's parse outcome. +#[derive(Clone)] pub enum ParseReport { /// The unit parsed (possibly via the `Goal::Script` retry). Parsed(ParsedFacts), @@ -92,6 +93,7 @@ pub enum ParseReport { } /// Facts recorded for a parsed unit. +#[derive(Clone)] pub struct ParsedFacts { /// The goal the unit parsed under. pub goal: Goal, @@ -103,73 +105,159 @@ pub struct ParsedFacts { pub node_count: u32, } -/// Check a program: parse every unit via the goal rule, and — unless any unit -/// parse-rejects — bind and check each, concatenating diagnostics and returning -/// them in canonical sorted order. +/// 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, +} + +impl BoundProgram { + /// Total bound nodes across parsed units (informational). + #[must_use] + pub fn total_node_count(&self) -> u64 { + self.total_nodes + } + + /// 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 check_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> CheckResult { - let mut attempts: Vec> = Vec::with_capacity(units.len()); +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)) => attempts.push(Attempt { - file, - name: unit.name, - goal, - used_script_retry, - module_ness: module_ness(&program), - program: Some(program), - message: None, - node_count: 0, - }), + 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); + // Per file: bind diagnostics then check diagnostics (check is a + // no-op this slice) — the getBindAndCheckDiagnostics concat. + let check_diags = check_file(&bound); + 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), + }); + } Err(message) => { parse_rejected = true; - attempts.push(Attempt { + bound_units.push(BoundUnit { file, - name: unit.name, - goal: Goal::Module, - used_script_retry: false, - module_ness: ModuleNess::Script, - program: None, - message: Some(message), - node_count: 0, + name: unit.name.to_string(), + parse: ParseReport::Rejected { message }, + bind_diagnostics: Vec::new(), + merge: None, }); } } } - // Unconditional concat (the CompileFilesEx parity path): every unit that - // parsed contributes its bind/check diagnostics, independent of a sibling's - // rejection. A rejected unit has no AST, so it contributes none. + BoundProgram { parse_rejected, units: bound_units, total_nodes } +} + +/// Merge a [`BoundProgram`] against an optional [`LibBase`] 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. +#[must_use] +pub fn check_bound(bound: &BoundProgram, lib: Option<&LibBase>) -> CheckResult { let mut diagnostics: Vec = Vec::new(); - let mut merges: Vec = Vec::new(); - for attempt in &mut attempts { - if let Some(program) = &attempt.program { - let source = units[attempt.file.index()].source; - let bound = bind_file(program, source, attempt.file); - attempt.node_count = bound.node_count; - // Per file: bind diagnostics then check diagnostics (check is a no-op - // this slice) — the getBindAndCheckDiagnostics concat. - let check_diags = check_file(&bound); - diagnostics.extend(bound.diagnostics); - diagnostics.extend(check_diags); - merges.push(bound.merge); - } + for unit in &bound.units { + diagnostics.extend(unit.bind_diagnostics.iter().cloned()); } - // The single-threaded global merge (checker-init phase) over every parsed - // file's bind product — cross-declaration-space conflicts, the - // globalThis/undefined checks, and module augmentations. Its diagnostics join - // the pool before the canonical sort (order-independent). - diagnostics.extend(merge_program(&merges)); + // Only test-unit merges are cloned here (lib globals live in the base, not in + // `files`), so this stays cheap even run per-variant. + let merges: Vec = bound.units.iter().filter_map(|u| u.merge.clone()).collect(); + let lib_file_offset = bound.units.len() as u32; + diagnostics.extend(merge_program(&merges, lib, lib_file_offset)); - // Final caller-side sort + dedup over the whole program's diagnostics. - let paths: Vec = units.iter().map(|u| u.name.to_string()).collect(); + // Path space: program units first, then the lib files (their FileIds are + // `lib_file_offset + lib-local index`). + let mut paths: Vec = bound.units.iter().map(|u| u.name.clone()).collect(); + if let Some(base) = lib { + paths.extend(base.lib_files.iter().cloned()); + } sort_and_deduplicate(&mut diagnostics, &paths); - let files = attempts.into_iter().map(Attempt::into_report).collect(); - CheckResult { diagnostics, files, parse_rejected } + let files = bound + .units + .iter() + .map(|u| FileReport { file: u.file, name: u.name.clone(), parse: u.parse.clone() }) + .collect(); + CheckResult { diagnostics, 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. +#[must_use] +pub fn check_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> CheckResult { + check_bound(&bind_program(units, arena), None) +} + +/// Check a program against an optional lib base (the lib-aware entry point). +#[must_use] +pub fn check_program_with_lib<'a>( + units: &[SourceUnit<'a>], + lib: Option<&LibBase>, + arena: &'a Bump, +) -> CheckResult { + check_bound(&bind_program(units, arena), lib) +} + +/// 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); + Ok(LibFile { name: name.to_string(), merge: bound.merge }) } /// Check one bound file — a no-op skeleton (no semantic diagnostics yet). @@ -197,33 +285,6 @@ fn parse_unit<'a>( } } -/// The mutable per-unit state carried from parse through bind into the report. -struct Attempt<'a> { - file: FileId, - name: &'a str, - goal: Goal, - used_script_retry: bool, - module_ness: ModuleNess, - program: Option>, - message: Option, - node_count: u32, -} - -impl Attempt<'_> { - fn into_report(self) -> FileReport { - let parse = match self.message { - Some(message) => ParseReport::Rejected { message }, - None => ParseReport::Parsed(ParsedFacts { - goal: self.goal, - used_script_retry: self.used_script_retry, - module_ness: self.module_ness, - node_count: self.node_count, - }), - }; - FileReport { file: self.file, name: self.name.to_string(), parse } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tsv_check/tests/lib_base.rs b/crates/tsv_check/tests/lib_base.rs new file mode 100644 index 000000000..945d82525 --- /dev/null +++ b/crates/tsv_check/tests/lib_base.rs @@ -0,0 +1,91 @@ +//! 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::{bind_lib, check_program_with_lib, FileId, LibBase, SourceUnit}; + +/// `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); + + // 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); + + 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); + 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); + assert!(result.diagnostics.is_empty()); +} diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index 94ff47f87..a774aa331 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -15,7 +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 / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. - `fixtures/` — The fixture workflow surface — `model.rs` holds the data model (`InputType`, `Fixture`, divergence-suffix rules), `discovery.rs` walks the fixtures tree, `variants.rs` discovers variant files, `mod.rs` keeps IO/parse helpers, `validation/` is the validator (`fixtures_validate`: structure rules, per-phase checks, errors, summary), `audit_signature.rs` pins prettier's multi-pass chain. - `test262/` — ECMAScript conformance runner: `discovery.rs` walks the test262 tree, `frontmatter.rs` parses the YAML harness metadata, `runner.rs` drives our parser (`run_test`) and grades the strict subset into the differential `Manifest` (`grade_for_manifest`, sharing the `classify` skip logic with `run_test`). Pure Rust — no Deno needed. The `test262 --emit-manifest` JSON feeds `benches/js/diagnostics/test262_compare.ts` for the tsv-vs-oxc-parser comparison. -- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `runner.rs` drives the `tsv_check` crate over the in-scope corpus (`run` = the walking-skeleton sweep with catch_unwind containment + the parse-divergence census; `check_one` backs `check-test`, the one-test dev loop). `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. +- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `libs.rs` resolves each variant's lib set (targetToLibMap/GetDefaultLibFileName port, `@lib` override, transitive `/// ` expansion, priority order) and caches bound lib products + per-set `LibBase` folds; `runner.rs` drives the `tsv_check` crate over the in-scope corpus (`run` = the walking-skeleton sweep with catch_unwind containment + the parse-divergence census; `check_one` backs `check-test`, the one-test dev loop). `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. - `diff.rs` — Colored text / JSON diffing with line-width annotations (used by `compare`, `ast_diff`, validation failures). - `error.rs` — `DebugError` — wraps `DenoError`, `io::Error`, `serde_json::Error`; exposes `hint()` for actionable messages. - `cli/` — argh `TopLevel`/`Subcommand` dispatch in `mod.rs` + per-command `FromArgs` modules under `cli/commands/`. Shared three-mode input plumbing (file path / `--content` / `--stdin`) comes from `tsv_cli::cli::input::InputArgs`. diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 3596d1cf1..caa7163b0 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -93,33 +93,46 @@ const RUN_CRASH_EXCLUDED_PIN: usize = 1; /// gate). Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 /// (hard); the rest pin the buckets so any move (a cascade change, a merge /// change, a tsv parser change, a typescript-go pull) forces a deliberate re-pin. -/// The missing bucket is classified: `merge` (merge-phase family — **now 0**, S4 +/// The missing bucket is classified: `merge` (merge-phase family — **0**, S4 /// closed the single-file merge path: TS2397 globalThis/undefined + TS2664 -/// augmentation-not-found), `lib` (absent-lib conflicts, S5), and `check-time` -/// (checker-emitted TS2300/2451 the bind+merge slice can't produce — duplicate -/// members, type parameters, computed/private names). A drop in `check-time` -/// (matches gained) or `lib` is a real improvement that re-pins; a rise anywhere -/// is a regression to explain. +/// augmentation-not-found), `lib` (absent-lib conflicts — **now 0**, S5 closed the +/// four classified lib-conflict misses: TS2300 eval/Symbol/Promise/ElementTagNameMap +/// against the standard-library globals), and `check-time` (checker-emitted +/// TS2300/2451 the bind+merge slice can't produce — duplicate members, type +/// parameters, computed/private names). A drop in `check-time` (matches gained) is a +/// real improvement that re-pins; a rise anywhere is a regression to explain. const RUN_FAMILY_GRADED_PIN: usize = 4066; const RUN_FAMILY_POSITIVE_PIN: usize = 125; -const RUN_FAMILY_MATCH_PIN: usize = 421; -const RUN_FAMILY_MISSING_PIN: usize = 129; +const RUN_FAMILY_MATCH_PIN: usize = 425; +const RUN_FAMILY_MISSING_PIN: usize = 125; const RUN_MISSING_MERGE_PIN: usize = 0; -const RUN_MISSING_LIB_PIN: usize = 4; +const RUN_MISSING_LIB_PIN: usize = 0; const RUN_MISSING_CHECKTIME_PIN: usize = 125; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; const RUN_MODULE_DETECTION_PIN: usize = 1; +/// REGRESSION PINS (exact, two-sided) for the lib base (S5). Measured 2026-07-10 vs +/// pin 168e7015 (`_submodules/TypeScript` corpus materialized): the distinct lib +/// `.d.ts` files parsed+bound and the distinct resolved lib sets folded across the +/// in-scope variants. A move is a deliberate re-pin (a harness-port change, a lib +/// set change, or a typescript-go pull). The three error channels are gated to +/// empty (a lib parse-reject, a missing referenced lib, or an unrecognized +/// `@lib`/reference name — all expected never on the pinned checkout). +const RUN_LIB_FILES_BOUND_PIN: usize = 107; +const RUN_LIB_SETS_PIN: usize = 50; + /// REGRESSION PINS (exact, two-sided) for the related-info channel — graded on the /// matched family primaries only (the primary code gates the per-variant verdict; /// related info is its own pinned channel). Measured 2026-07-10 vs pin 168e7015: -/// the 42 matches are the multiple-default-export chains (TS2752/2753/2528's -/// TS6204). `missing`/`extra`/`span_mismatch` are 0 (the merge-path family the -/// merge emits carries no related info, and the sole TS1369-hint baseline is a -/// tsv parse-rejection). A rise in `missing`/`extra` is a regression to explain. -const RUN_RELATED_MATCH_PIN: usize = 42; +/// the 42 multiple-default-export chains (TS2752/2753/2528's TS6204) plus the 9 +/// lib-conflict related infos S5 added (TS6203/6204 pointing at the masked lib +/// files: eval 1, Symbol 3, Promise 4, ElementTagNameMap 1). `missing`/`extra`/ +/// `span_mismatch` are 0 (the lib relateds match the baseline's masked +/// `lib.x.d.ts:--:--` entries by (code, file), loc-agnostic). A rise in +/// `missing`/`extra` is a regression to explain. +const RUN_RELATED_MATCH_PIN: usize = 51; const RUN_RELATED_MISSING_PIN: usize = 0; const RUN_RELATED_EXTRA_PIN: usize = 0; const RUN_RELATED_SPAN_MISMATCH_PIN: usize = 0; @@ -352,6 +365,31 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { pin(&mut errs, "script retries", report.script_retry, RUN_SCRIPT_RETRY_PIN); pin(&mut errs, "crash-excluded", report.excluded_crashes, RUN_CRASH_EXCLUDED_PIN); + // Lib-base (S5) sizing pins + the empty-error-channel invariants. + pin(&mut errs, "lib files bound", report.lib_files_bound, RUN_LIB_FILES_BOUND_PIN); + pin(&mut errs, "lib sets folded", report.lib_sets_built, RUN_LIB_SETS_PIN); + if !report.lib_parse_errors.is_empty() { + errs.push(format!( + "{} lib file(s) failed to parse, e.g. {}", + report.lib_parse_errors.len(), + report.lib_parse_errors.first().map_or("", String::as_str) + )); + } + if !report.lib_missing_files.is_empty() { + errs.push(format!( + "{} referenced lib file(s) missing, e.g. {}", + report.lib_missing_files.len(), + report.lib_missing_files.first().map_or("", String::as_str) + )); + } + if !report.lib_unknown_names.is_empty() { + errs.push(format!( + "{} unrecognized @lib/reference name(s), e.g. {}", + report.lib_unknown_names.len(), + report.lib_unknown_names.first().map_or("", String::as_str) + )); + } + // Family grading pins. pin(&mut errs, "family graded", report.family_graded_variants, RUN_FAMILY_GRADED_PIN); pin(&mut errs, "family positive", report.family_positive_variants, RUN_FAMILY_POSITIVE_PIN); diff --git a/crates/tsv_debug/src/tsc_conformance/libs.rs b/crates/tsv_debug/src/tsc_conformance/libs.rs new file mode 100644 index 000000000..9b2efb7c0 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/libs.rs @@ -0,0 +1,442 @@ +//! Lib (`.d.ts`) resolution + a per-run [`tsv_check::LibBase`] cache — the S5 seam +//! that lets the checker leg conflict a test's globals against the standard library. +//! +//! Ported from tsgo's file loader: a variant's resolved lib set is its default lib +//! (from `target`, `internal/tsoptions/enummaps.go targetToLibMap` / +//! `GetDefaultLibFileName`) or its explicit `@lib` list (`GetLibFileName`), +//! transitively expanded over each lib file's `/// ` directives +//! and **priority-ordered** (`internal/compiler/fileloader.go getDefaultLibFilePriority` +//! — `lib.d.ts`/`lib.es6.d.ts` first, then the `LibMap`-key index). Priority order is +//! the fold order, which fixes each global symbol's declaration order and therefore +//! the TS6203/6204 related-info attribution. +//! +//! The default `target` when unset is `ScriptTargetLatestStandard` (ES2025 → +//! `lib.es2025.full.d.ts`), matching `core.CompilerOptions.GetEmitScriptTarget`. +//! `@noLib` resolves to no libs; `@libFiles` is absent from the in-scope corpus and +//! unsupported (a `@libFiles` variant would silently get the default set — the index +//! gate pins the corpus so a new one would surface). +//! +//! The libs are read at runtime from `/internal/bundled/libs/` (no +//! vendoring). Each lib file is parsed + bound **once per run** (file-keyed owned +//! product), and each distinct resolved set folds into a [`tsv_check::LibBase`] +//! **once per run**, shared across every variant that resolves to it. +// +// tsgo: internal/tsoptions/enummaps.go (LibMap, targetToLibMap, GetDefaultLibFileName, +// GetLibFileName), internal/compiler/fileloader.go (sortLibs, +// getDefaultLibFilePriority), internal/core/compileroptions.go +// (GetEmitScriptTarget default = ScriptTargetLatestStandard/ES2025) + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use tsv_check::{bind_lib, LibBase, LibFile}; + +/// The ported `LibMap` (`enummaps.go`), in insertion order — the tuple order is the +/// `Libs` slice the priority index reads. Maps a lib **name** (`@lib` / +/// `/// ` value) to its `.d.ts` file. +const LIB_MAP: &[(&str, &str)] = &[ + ("es5", "lib.es5.d.ts"), + ("es6", "lib.es2015.d.ts"), + ("es2015", "lib.es2015.d.ts"), + ("es7", "lib.es2016.d.ts"), + ("es2016", "lib.es2016.d.ts"), + ("es2017", "lib.es2017.d.ts"), + ("es2018", "lib.es2018.d.ts"), + ("es2019", "lib.es2019.d.ts"), + ("es2020", "lib.es2020.d.ts"), + ("es2021", "lib.es2021.d.ts"), + ("es2022", "lib.es2022.d.ts"), + ("es2023", "lib.es2023.d.ts"), + ("es2024", "lib.es2024.d.ts"), + ("es2025", "lib.es2025.d.ts"), + ("esnext", "lib.esnext.d.ts"), + ("dom", "lib.dom.d.ts"), + ("dom.iterable", "lib.dom.iterable.d.ts"), + ("dom.asynciterable", "lib.dom.asynciterable.d.ts"), + ("webworker", "lib.webworker.d.ts"), + ("webworker.importscripts", "lib.webworker.importscripts.d.ts"), + ("webworker.iterable", "lib.webworker.iterable.d.ts"), + ("webworker.asynciterable", "lib.webworker.asynciterable.d.ts"), + ("scripthost", "lib.scripthost.d.ts"), + ("es2015.core", "lib.es2015.core.d.ts"), + ("es2015.collection", "lib.es2015.collection.d.ts"), + ("es2015.generator", "lib.es2015.generator.d.ts"), + ("es2015.iterable", "lib.es2015.iterable.d.ts"), + ("es2015.promise", "lib.es2015.promise.d.ts"), + ("es2015.proxy", "lib.es2015.proxy.d.ts"), + ("es2015.reflect", "lib.es2015.reflect.d.ts"), + ("es2015.symbol", "lib.es2015.symbol.d.ts"), + ("es2015.symbol.wellknown", "lib.es2015.symbol.wellknown.d.ts"), + ("es2016.array.include", "lib.es2016.array.include.d.ts"), + ("es2016.intl", "lib.es2016.intl.d.ts"), + ("es2017.arraybuffer", "lib.es2017.arraybuffer.d.ts"), + ("es2017.date", "lib.es2017.date.d.ts"), + ("es2017.object", "lib.es2017.object.d.ts"), + ("es2017.sharedmemory", "lib.es2017.sharedmemory.d.ts"), + ("es2017.string", "lib.es2017.string.d.ts"), + ("es2017.intl", "lib.es2017.intl.d.ts"), + ("es2017.typedarrays", "lib.es2017.typedarrays.d.ts"), + ("es2018.asyncgenerator", "lib.es2018.asyncgenerator.d.ts"), + ("es2018.asynciterable", "lib.es2018.asynciterable.d.ts"), + ("es2018.intl", "lib.es2018.intl.d.ts"), + ("es2018.promise", "lib.es2018.promise.d.ts"), + ("es2018.regexp", "lib.es2018.regexp.d.ts"), + ("es2019.array", "lib.es2019.array.d.ts"), + ("es2019.object", "lib.es2019.object.d.ts"), + ("es2019.string", "lib.es2019.string.d.ts"), + ("es2019.symbol", "lib.es2019.symbol.d.ts"), + ("es2019.intl", "lib.es2019.intl.d.ts"), + ("es2020.bigint", "lib.es2020.bigint.d.ts"), + ("es2020.date", "lib.es2020.date.d.ts"), + ("es2020.promise", "lib.es2020.promise.d.ts"), + ("es2020.sharedmemory", "lib.es2020.sharedmemory.d.ts"), + ("es2020.string", "lib.es2020.string.d.ts"), + ("es2020.symbol.wellknown", "lib.es2020.symbol.wellknown.d.ts"), + ("es2020.intl", "lib.es2020.intl.d.ts"), + ("es2020.number", "lib.es2020.number.d.ts"), + ("es2021.promise", "lib.es2021.promise.d.ts"), + ("es2021.string", "lib.es2021.string.d.ts"), + ("es2021.weakref", "lib.es2021.weakref.d.ts"), + ("es2021.intl", "lib.es2021.intl.d.ts"), + ("es2022.array", "lib.es2022.array.d.ts"), + ("es2022.error", "lib.es2022.error.d.ts"), + ("es2022.intl", "lib.es2022.intl.d.ts"), + ("es2022.object", "lib.es2022.object.d.ts"), + ("es2022.string", "lib.es2022.string.d.ts"), + ("es2022.regexp", "lib.es2022.regexp.d.ts"), + ("es2023.array", "lib.es2023.array.d.ts"), + ("es2023.collection", "lib.es2023.collection.d.ts"), + ("es2023.intl", "lib.es2023.intl.d.ts"), + ("es2024.arraybuffer", "lib.es2024.arraybuffer.d.ts"), + ("es2024.collection", "lib.es2024.collection.d.ts"), + ("es2024.object", "lib.es2024.object.d.ts"), + ("es2024.promise", "lib.es2024.promise.d.ts"), + ("es2024.regexp", "lib.es2024.regexp.d.ts"), + ("es2024.sharedmemory", "lib.es2024.sharedmemory.d.ts"), + ("es2024.string", "lib.es2024.string.d.ts"), + ("es2025.collection", "lib.es2025.collection.d.ts"), + ("es2025.float16", "lib.es2025.float16.d.ts"), + ("es2025.intl", "lib.es2025.intl.d.ts"), + ("es2025.iterator", "lib.es2025.iterator.d.ts"), + ("es2025.promise", "lib.es2025.promise.d.ts"), + ("es2025.regexp", "lib.es2025.regexp.d.ts"), + ("esnext.asynciterable", "lib.es2018.asynciterable.d.ts"), + ("esnext.symbol", "lib.es2019.symbol.d.ts"), + ("esnext.bigint", "lib.es2020.bigint.d.ts"), + ("esnext.weakref", "lib.es2021.weakref.d.ts"), + ("esnext.object", "lib.es2024.object.d.ts"), + ("esnext.regexp", "lib.es2024.regexp.d.ts"), + ("esnext.string", "lib.es2024.string.d.ts"), + ("esnext.float16", "lib.es2025.float16.d.ts"), + ("esnext.iterator", "lib.es2025.iterator.d.ts"), + ("esnext.promise", "lib.es2025.promise.d.ts"), + ("esnext.array", "lib.esnext.array.d.ts"), + ("esnext.collection", "lib.esnext.collection.d.ts"), + ("esnext.date", "lib.esnext.date.d.ts"), + ("esnext.decorators", "lib.esnext.decorators.d.ts"), + ("esnext.disposable", "lib.esnext.disposable.d.ts"), + ("esnext.error", "lib.esnext.error.d.ts"), + ("esnext.intl", "lib.esnext.intl.d.ts"), + ("esnext.sharedmemory", "lib.esnext.sharedmemory.d.ts"), + ("esnext.temporal", "lib.esnext.temporal.d.ts"), + ("esnext.typedarrays", "lib.esnext.typedarrays.d.ts"), + ("decorators", "lib.decorators.d.ts"), + ("decorators.legacy", "lib.decorators.legacy.d.ts"), +]; + +/// tsgo `GetDefaultLibFileName` composed with `GetEmitScriptTarget`: the default +/// lib file for a (possibly unset) `target`. Unset → ES2025 (LatestStandard) → +/// `lib.es2025.full.d.ts`; a target below ES2015 (or otherwise absent from +/// `targetToLibMap`) → `lib.d.ts`. +fn default_lib_for_target(target: Option<&str>) -> &'static str { + match target.map(str::to_ascii_lowercase).as_deref() { + None => "lib.es2025.full.d.ts", // GetEmitScriptTarget default = ScriptTargetLatestStandard + Some("es6" | "es2015") => "lib.es6.d.ts", + Some("es2016") => "lib.es2016.full.d.ts", + Some("es2017") => "lib.es2017.full.d.ts", + Some("es2018") => "lib.es2018.full.d.ts", + Some("es2019") => "lib.es2019.full.d.ts", + Some("es2020") => "lib.es2020.full.d.ts", + Some("es2021") => "lib.es2021.full.d.ts", + Some("es2022") => "lib.es2022.full.d.ts", + Some("es2023") => "lib.es2023.full.d.ts", + Some("es2024") => "lib.es2024.full.d.ts", + Some("es2025") => "lib.es2025.full.d.ts", + Some("esnext") => "lib.esnext.full.d.ts", + // es3 / es5 / json / anything not in targetToLibMap. + Some(_) => "lib.d.ts", + } +} + +/// tsgo `GetLibFileName` — a lib **name** (or an already-resolved file name) to its +/// `.d.ts` file, or `None` when unrecognized. +fn get_lib_file_name(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if let Some((_, file)) = LIB_MAP.iter().find(|(_, f)| *f == lower) { + return Some(file); // already a file name + } + LIB_MAP.iter().find(|(k, _)| *k == lower).map(|(_, f)| *f) +} + +/// tsgo `getDefaultLibFilePriority` (scoped to file basenames): `lib.d.ts` / +/// `lib.es6.d.ts` sort first, then a lib file's `LibMap`-key index; an unrecognized +/// name (e.g. an aggregator like `lib.es2025.full.d.ts`, which carries no +/// declarations) sorts last. +fn lib_priority(file: &str) -> i32 { + if file == "lib.d.ts" || file == "lib.es6.d.ts" { + return 0; + } + let name = file.strip_prefix("lib.").and_then(|s| s.strip_suffix(".d.ts")).unwrap_or(file); + match LIB_MAP.iter().position(|(k, _)| *k == name) { + Some(i) => i32::try_from(i).unwrap_or(i32::MAX - 2) + 1, + None => i32::try_from(LIB_MAP.len()).unwrap_or(i32::MAX - 2) + 2, + } +} + +/// Extract the `/// ` names from a lib source (the bundled libs +/// use a single space and appear only in the header directive block). +fn extract_lib_references(source: &str) -> Vec { + const NEEDLE: &str = "reference lib=\""; + let mut out = Vec::new(); + let mut from = 0; + while let Some(pos) = source[from..].find(NEEDLE) { + let start = from + pos + NEEDLE.len(); + match source[start..].find('"') { + Some(end) => { + out.push(source[start..start + end].to_string()); + from = start + end + 1; + } + None => break, + } + } + out +} + +/// Whether `@noLib` is set truthily. +fn is_no_lib(config: &BTreeMap) -> bool { + config.get("nolib").is_some_and(|v| v.eq_ignore_ascii_case("true")) +} + +/// Per-run lib resolver + cache: parses + binds each lib file once, and folds each +/// distinct resolved set into a [`LibBase`] once, sharing both across variants. +pub struct LibResolver { + libs_dir: PathBuf, + /// Lib file name -> its `/// ` names (cached read). + ref_cache: HashMap>, + /// Lib file name -> its bound product (`None` = parse-rejected / missing). + file_cache: HashMap>>, + /// Set key (joined priority-ordered file names) -> its folded base. + base_cache: HashMap>, + /// Lib files that failed to parse: `(file, error)` — expected empty. + parse_errors: Vec<(String, String)>, + /// Referenced lib files not found on disk — expected empty. + missing_files: Vec, + /// `@lib` / reference names `GetLibFileName` did not recognize — expected empty. + unknown_libs: Vec, + /// Distinct resolved sets folded into a base (informational). + sets_built: usize, +} + +impl LibResolver { + /// Build a resolver rooted at `/internal/bundled/libs`. + #[must_use] + pub fn new(checkout: &Path) -> LibResolver { + LibResolver { + libs_dir: checkout.join("internal").join("bundled").join("libs"), + ref_cache: HashMap::new(), + file_cache: HashMap::new(), + base_cache: HashMap::new(), + parse_errors: Vec::new(), + missing_files: Vec::new(), + unknown_libs: Vec::new(), + sets_built: 0, + } + } + + /// The resolved, priority-ordered lib files for a variant config (empty for + /// `@noLib`). Unrecognized `@lib`/reference names are recorded and skipped. + fn resolve_set(&mut self, config: &BTreeMap) -> Vec { + if is_no_lib(config) { + return Vec::new(); + } + let mut roots: Vec = Vec::new(); + if let Some(lib) = config.get("lib") { + for part in lib.split(',') { + let p = part.trim(); + if p.is_empty() { + continue; + } + match get_lib_file_name(p) { + Some(f) => roots.push(f.to_string()), + None => self.unknown_libs.push(p.to_string()), + } + } + // An all-unrecognized @lib leaves no roots — nothing to resolve. + } else { + roots.push(default_lib_for_target(config.get("target").map(String::as_str)).to_string()); + } + + // Transitive `/// ` closure. + let mut closure: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque = roots.into_iter().collect(); + while let Some(file) = queue.pop_front() { + if !seen.insert(file.clone()) { + continue; + } + closure.push(file.clone()); + for name in self.references_of(&file).to_vec() { + match get_lib_file_name(&name) { + Some(rf) if !seen.contains(rf) => queue.push_back(rf.to_string()), + Some(_) => {} + None => self.unknown_libs.push(name), + } + } + } + // Priority order = fold order (fixes the TS6203/6204 attribution). + closure.sort_by_key(|f| lib_priority(f)); + closure + } + + /// The cached `/// ` names of a lib file. + fn references_of(&mut self, file: &str) -> &[String] { + if !self.ref_cache.contains_key(file) { + let refs = match std::fs::read_to_string(self.libs_dir.join(file)) { + Ok(src) => extract_lib_references(&src), + Err(_) => { + self.missing_files.push(file.to_string()); + Vec::new() + } + }; + self.ref_cache.insert(file.to_string(), refs); + } + &self.ref_cache[file] + } + + /// The cached bound product of a lib file (parse + bind once). + fn bound_file(&mut self, file: &str) -> Option> { + if let Some(cached) = self.file_cache.get(file) { + return cached.clone(); + } + let result = match std::fs::read_to_string(self.libs_dir.join(file)) { + Ok(src) => match bind_lib(file, &src) { + Ok(lf) => Some(Rc::new(lf)), + Err(e) => { + self.parse_errors.push((file.to_string(), e)); + None + } + }, + Err(_) => { + self.missing_files.push(file.to_string()); + None + } + }; + self.file_cache.insert(file.to_string(), result.clone()); + result + } + + /// The [`LibBase`] for a variant config, built once per distinct resolved set. + /// `None` for `@noLib` (or an empty resolution): the phase-1 `globalThis` check + /// still fires, but no lib globals participate. + #[must_use] + pub fn base_for(&mut self, config: &BTreeMap) -> Option> { + let set = self.resolve_set(config); + if set.is_empty() { + return None; + } + let key = set.join(","); + if let Some(base) = self.base_cache.get(&key) { + return Some(Rc::clone(base)); + } + let files: Vec> = set.iter().filter_map(|f| self.bound_file(f)).collect(); + let refs: Vec<&LibFile> = files.iter().map(AsRef::as_ref).collect(); + let base = Rc::new(LibBase::build(&refs)); + self.sets_built += 1; + self.base_cache.insert(key, Rc::clone(&base)); + Some(base) + } + + /// The lib files that failed to parse (`(file, error)`), expected empty. + #[must_use] + pub fn parse_errors(&self) -> &[(String, String)] { + &self.parse_errors + } + + /// Referenced lib files not found on disk, expected empty. + #[must_use] + pub fn missing_files(&self) -> &[String] { + &self.missing_files + } + + /// Unrecognized `@lib` / reference names, expected empty. + #[must_use] + pub fn unknown_libs(&self) -> &[String] { + &self.unknown_libs + } + + /// Distinct lib files parsed + bound this run. + #[must_use] + pub fn files_bound(&self) -> usize { + self.file_cache.values().filter(|v| v.is_some()).count() + } + + /// Distinct resolved lib sets folded into a base this run. + #[must_use] + pub fn sets_built(&self) -> usize { + self.sets_built + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(pairs: &[(&str, &str)]) -> BTreeMap { + pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect() + } + + #[test] + fn default_target_is_es2025_full() { + assert_eq!(default_lib_for_target(None), "lib.es2025.full.d.ts"); + assert_eq!(default_lib_for_target(Some("es2015")), "lib.es6.d.ts"); + assert_eq!(default_lib_for_target(Some("ES2015")), "lib.es6.d.ts"); + assert_eq!(default_lib_for_target(Some("es5")), "lib.d.ts"); + assert_eq!(default_lib_for_target(Some("esnext")), "lib.esnext.full.d.ts"); + } + + #[test] + fn get_lib_file_name_names_and_files() { + assert_eq!(get_lib_file_name("es5"), Some("lib.es5.d.ts")); + assert_eq!(get_lib_file_name("ES2015"), Some("lib.es2015.d.ts")); + assert_eq!(get_lib_file_name("dom"), Some("lib.dom.d.ts")); + assert_eq!(get_lib_file_name("lib.dom.d.ts"), Some("lib.dom.d.ts")); + assert_eq!(get_lib_file_name("notareallib"), None); + } + + #[test] + fn priority_orders_symbol_and_promise_related_chains() { + // es5 leads (priority 1); the es2015 features follow by LibMap-key index. + assert!(lib_priority("lib.es5.d.ts") < lib_priority("lib.es2015.symbol.d.ts")); + assert!(lib_priority("lib.es2015.symbol.d.ts") < lib_priority("lib.es2015.symbol.wellknown.d.ts")); + // Promise chain: es5 < es2015.iterable < es2015.promise < es2015.symbol.wellknown. + assert!(lib_priority("lib.es5.d.ts") < lib_priority("lib.es2015.iterable.d.ts")); + assert!(lib_priority("lib.es2015.iterable.d.ts") < lib_priority("lib.es2015.promise.d.ts")); + assert!(lib_priority("lib.es2015.promise.d.ts") < lib_priority("lib.es2015.symbol.wellknown.d.ts")); + // The aggregator roots (no declarations) sort last; lib.es6.d.ts is special. + assert_eq!(lib_priority("lib.es6.d.ts"), 0); + assert!(lib_priority("lib.es2025.full.d.ts") > lib_priority("lib.dom.d.ts")); + } + + #[test] + fn reference_extraction() { + let src = "/// \n/// \ninterface X {}"; + assert_eq!(extract_lib_references(src), vec!["es2015".to_string(), "dom".to_string()]); + } + + #[test] + fn no_lib_resolves_empty() { + // A bare resolver needs no libs_dir for the noLib short-circuit. + let mut r = LibResolver::new(Path::new("/nonexistent")); + assert!(r.resolve_set(&cfg(&[("nolib", "true")])).is_empty()); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 244d01a7c..28da2ca79 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -24,6 +24,7 @@ pub mod corpus; pub mod directives; pub mod discovery; pub mod index; +pub mod libs; pub mod options_meta; pub mod pretty; pub mod query; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 2a2f2d2bd..d3b7e282b 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -37,6 +37,7 @@ use crate::tsc_conformance::corpus::{discover_corpus, read_corpus_file, CorpusTe use crate::tsc_conformance::directives::{extract_settings, split_units, Unit}; use crate::tsc_conformance::discovery::{baselines_dir, discover_baselines, Baseline}; use crate::tsc_conformance::index::{is_js_flavored, is_jsx_scoped}; +use crate::tsc_conformance::libs::LibResolver; use crate::tsc_conformance::options_meta::{ is_config_file_name, variant_is_unsupported, SKIPPED_TESTS, }; @@ -46,7 +47,9 @@ use std::collections::HashMap; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use std::time::Instant; -use tsv_check::{check_program, ParseReport, SourceUnit}; +use tsv_check::{ + bind_program, check_bound, check_program, Diagnostic, ParseReport, SourceUnit, +}; use tsv_lang::{LocationMapper, LocationTracker}; /// The bind-time duplicate/conflict family this slice grades: TS2300 (duplicate @@ -65,8 +68,10 @@ const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; const BIND_EMITTED_TS1XXX: [u32; 12] = [1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012]; -/// The five P1-family baselines whose family diagnostics need lib binding (S5). -/// A *missing* in one of these is attributed to the absent lib, not a cascade bug. +/// The P1-family baselines whose family diagnostics come from a standard-library +/// conflict (S5). These now **match** via the lib base; the classifier is kept as a +/// regression guard — a *missing* in one of these buckets to `missing_lib` (pinned +/// 0) rather than `missing_other`, so a lib-detection regression fails loudly. const LIB_CONFLICT_BASELINES: [&str; 5] = [ "intersectionsOfLargeUnions2.ts", "jsExportMemberMergedWithModuleAugmentation2.ts", @@ -216,6 +221,18 @@ pub struct SkeletonReport { pub panics: Vec, /// Tests skipped by the crash-exclusion ledger (tracked parser aborts/panics). pub excluded_crashes: usize, + + // --- lib base (S5) --- + /// Distinct lib `.d.ts` files parsed + bound this run (informational). + pub lib_files_bound: usize, + /// Distinct resolved lib sets folded into a base this run (informational). + pub lib_sets_built: usize, + /// Lib files that failed to parse (`file: error`). **Gate: must be empty.** + pub lib_parse_errors: Vec, + /// Referenced lib files not found on disk. **Gate: must be empty.** + pub lib_missing_files: Vec, + /// Unrecognized `@lib` / `/// ` names. **Gate: must be empty.** + pub lib_unknown_names: Vec, /// Catchable-panic exclusions that no longer panic (a fix landed) — the entry /// is stale and must be dropped. **Gate: must be empty.** pub stale_exclusions: Vec, @@ -262,6 +279,7 @@ fn run_skeleton_inner(checkout: &Path) -> Result { } let mut report = SkeletonReport::default(); + let mut resolver = LibResolver::new(checkout); for test in &corpus { if SKIPPED_TESTS.contains(&test.basename.as_str()) { @@ -301,9 +319,22 @@ fn run_skeleton_inner(checkout: &Path) -> Result { } report.in_scope_tests += 1; - grade_test(test, &units[0], &in_scope, &ondisk, &mut report); + grade_test(test, &units[0], &in_scope, &ondisk, &mut resolver, &mut report); } + // Fold in the resolver's lib-base census (parse-once/fold-once counts + gates). + report.lib_files_bound = resolver.files_bound(); + report.lib_sets_built = resolver.sets_built(); + report.lib_parse_errors = + resolver.parse_errors().iter().map(|(f, e)| format!("{f}: {e}")).collect(); + report.lib_missing_files = resolver.missing_files().to_vec(); + report.lib_unknown_names = { + let mut names: Vec = resolver.unknown_libs().to_vec(); + names.sort_unstable(); + names.dedup(); + names + }; + report.wall_ms = start.elapsed().as_millis(); Ok(report) } @@ -344,22 +375,26 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { } } -/// Parse+check one single-file test once and attribute the outcome to each of -/// its in-scope variants. +/// Parse+bind one single-file test once, then — per in-scope variant — merge it +/// against that variant's resolved lib base and grade the result. Parse+bind is +/// variant-independent; only the merge (and thus the lib-conflict family) varies by +/// the resolved lib set, so a variant with a Promise/Symbol/… global conflicts at +/// one target and is clean at another. fn grade_test( test: &CorpusTest, unit: &Unit, in_scope: &[&Variant], ondisk: &HashMap<(&str, String), &Baseline>, + resolver: &mut LibResolver, report: &mut SkeletonReport, ) { - // Parse + check on a fresh arena, contained against panics. + // Parse + bind on a fresh arena, contained against panics (the tsv parser is the + // panic source; the merge over owned data that follows is deterministic). let arena = Bump::new(); - let checked = catch_unwind(AssertUnwindSafe(|| { - check_program(&[SourceUnit::new(&unit.name, &unit.content)], &arena) - })); - let result = match checked { - Ok(result) => result, + let bound = match catch_unwind(AssertUnwindSafe(|| { + bind_program(&[SourceUnit::new(&unit.name, &unit.content)], &arena) + })) { + Ok(bound) => bound, Err(payload) => { report.panics.push(PanicRecord { test: test.relative_path.clone(), @@ -369,45 +404,13 @@ fn grade_test( } }; - // The single unit's parse outcome (files is never empty for one input). - let Some(file) = result.files.first() else { return }; - - // Our family diagnostics are variant-independent (the checker consults no - // directives, and module-ness is inert for the cascade), so build the - // (file, line, col, code) multiset — plus each diagnostic's related info — - // once per test via the unit's line map. - let ours_family: Vec = if matches!(file.parse, ParseReport::Parsed(_)) { - let (tracker, map) = LocationTracker::new_ecmascript_with_map(&unit.content); - let mapper = LocationMapper { tracker: &tracker, map: &map }; - let family = |code: u32, span: tsv_lang::Span, file_name: &str| { - let (_, pos) = mapper.pos_and_position(span.start); - (pos.line as u32, pos.column as u32 + 1, code, file_name.to_string()) - }; - result - .diagnostics - .iter() - .filter(|d| FAMILY_CODES.contains(&d.code)) - .map(|d| { - let (line, col, code, file_name) = family(d.code, d.span, &unit.name); - // Single-file: every related node lives in the one unit. - let related = d - .related - .iter() - .map(|r| { - let (_, pos) = mapper.pos_and_position(r.span.start); - RelatedKey { - code: r.code, - file: unit.name.clone(), - loc: Some((pos.line as u32, pos.column as u32 + 1)), - } - }) - .collect(); - FamilyEntry { key: FamilyDiag { file: file_name, line, col, code }, related } - }) - .collect() - } else { - Vec::new() - }; + // The single unit's parse outcome (parse_reports is never empty for one input). + let reports = bound.parse_reports(); + let Some(&(_, parse)) = reports.first() else { return }; + let parsed = matches!(parse, ParseReport::Parsed(_)); + + // The unit's line map — reused across the test's variants for the parsed case. + let line_map = parsed.then(|| LocationTracker::new_ecmascript_with_map(&unit.content)); for variant in in_scope { report.in_scope_variants += 1; @@ -417,7 +420,7 @@ fn grade_test( let name = config_name(&test.basename, &variant.description); let baseline = ondisk.get(&(test.suite, name.clone())).copied(); - match &file.parse { + match parse { ParseReport::Rejected { .. } => { report.parse_rejected_total += 1; match baseline_shape(baseline) { @@ -430,6 +433,12 @@ fn grade_test( if facts.used_script_retry { report.script_retry += 1; } + // Resolve this variant's lib set (cached) and merge the bound program + // against it — the merge diagnostics are the lib-conflict family. + let base = resolver.base_for(&variant.config); + let result = check_bound(&bound, base.as_deref()); + let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); + match baseline { None => { report.expect_clean_graded += 1; @@ -444,6 +453,14 @@ fn grade_test( } Some(b) => { report.baselined_parsed += 1; + // `parsed` => `line_map` is `Some`; the `None` arm is dead. + let ours_family = match line_map.as_ref() { + Some((tracker, map)) => { + let mapper = LocationMapper { tracker, map }; + build_ours_family(&result.diagnostics, &unit.name, &mapper, lib_files) + } + None => Vec::new(), + }; grade_family(test, &name, b, &ours_family, report); } } @@ -451,9 +468,78 @@ fn grade_test( } } - // Node total: counted once per test (all variants share the parse). - if let ParseReport::Parsed(facts) = &file.parse { - report.total_nodes += u64::from(facts.node_count); + // Node total: counted once per test (all variants share the parse+bind). + report.total_nodes += bound.total_node_count(); +} + +/// The number of program units in the single-file sweep (a lib FileId is +/// `>= UNITS_LEN`, translating to `lib_files[FileId - UNITS_LEN]`). +const UNITS_LEN: u32 = 1; + +/// Build our family multiset for one variant's diagnostics, resolving each FileId to +/// a display name. A **lib-file primary** (FileId beyond the program units) is +/// dropped — the baseline masks it (`lib.x.d.ts(--,--)`) — and a lib-sourced related +/// carries the lib file name with a masked location so it matches the baseline's +/// `lib.x.d.ts:--:--` related by `(code, file)`. +fn build_ours_family( + diagnostics: &[Diagnostic], + unit_name: &str, + mapper: &LocationMapper<'_>, + lib_files: &[String], +) -> Vec { + diagnostics + .iter() + .filter(|d| FAMILY_CODES.contains(&d.code)) + .filter_map(|d| { + let file = d.file?; + // A lib-file primary is masked in the baseline — exclude it. + if file.index() >= UNITS_LEN as usize { + return None; + } + let (_, pos) = mapper.pos_and_position(d.span.start); + let key = FamilyDiag { + file: unit_name.to_string(), + line: pos.line as u32, + col: pos.column as u32 + 1, + code: d.code, + }; + let related = d + .related + .iter() + .map(|r| resolve_related(r, unit_name, mapper, lib_files)) + .collect(); + Some(FamilyEntry { key, related }) + }) + .collect() +} + +/// Resolve one related-info entry's FileId to a [`RelatedKey`]: an in-unit related +/// carries its computed location; a lib-sourced related carries the lib file name +/// and a masked (`None`) location. +fn resolve_related( + r: &Diagnostic, + unit_name: &str, + mapper: &LocationMapper<'_>, + lib_files: &[String], +) -> RelatedKey { + match r.file { + Some(f) if f.index() < UNITS_LEN as usize => { + let (_, pos) = mapper.pos_and_position(r.span.start); + RelatedKey { + code: r.code, + file: unit_name.to_string(), + loc: Some((pos.line as u32, pos.column as u32 + 1)), + } + } + Some(f) => { + let idx = f.index() - UNITS_LEN as usize; + RelatedKey { + code: r.code, + file: lib_files.get(idx).cloned().unwrap_or_default(), + loc: None, + } + } + None => RelatedKey { code: r.code, file: unit_name.to_string(), loc: None }, } } @@ -871,6 +957,22 @@ impl SkeletonReport { println!(" SPAN {s}"); } println!(); + println!("Lib base (S5)"); + println!(" lib files bound: {}", self.lib_files_bound); + println!(" lib sets folded: {}", self.lib_sets_built); + println!(" lib parse errors: {} (GATE=0)", self.lib_parse_errors.len()); + println!(" lib missing files: {} (GATE=0)", self.lib_missing_files.len()); + println!(" lib unknown names: {} (GATE=0)", self.lib_unknown_names.len()); + for e in &self.lib_parse_errors { + println!(" LIB-PARSE-ERR {e}"); + } + for f in &self.lib_missing_files { + println!(" LIB-MISSING {f}"); + } + for n in &self.lib_unknown_names { + println!(" LIB-UNKNOWN {n}"); + } + println!(); println!("Panics (caught): {}", self.panics.len()); println!("Crash-excluded (tracked): {}", self.excluded_crashes); if !self.stale_exclusions.is_empty() { @@ -894,16 +996,20 @@ impl SkeletonReport { // =========================================================================== /// One diagnostic line (ours or the baseline's) for the check-test diff. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct DiagLine { - /// The file the diagnostic points at (or `null` for a global one). + /// The file the diagnostic points at (or `null` for a global one). A lib file + /// (`lib.es5.d.ts`) with `null` line/col is a masked lib-sourced entry. pub file: Option, - /// 1-based line (`null` for a global diagnostic). + /// 1-based line (`null` for a global or masked-lib diagnostic). pub line: Option, - /// 1-based column (`null` for a global diagnostic). + /// 1-based column (`null` for a global or masked-lib diagnostic). pub col: Option, /// The `TS` number. pub code: u32, + /// The diagnostic's related-info entries (empty for a baseline summary line). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub related: Vec, } /// The `check-test` report for one test/variant. @@ -977,28 +1083,53 @@ pub fn check_one( } let baseline = ondisk.get(&(test.suite, baseline_name.clone())).copied(); - // Parse + check every unit (single- or multi-file). + // Parse + bind every unit, then merge against the selected variant's lib base. let arena = Bump::new(); let source_units: Vec> = units.iter().map(|u| SourceUnit::new(&u.name, &u.content)).collect(); - let result = check_program(&source_units, &arena); - - // Byte-span -> (1-based line, 1-based UTF-16 col) per the owning unit. + let bound = bind_program(&source_units, &arena); + let mut resolver = LibResolver::new(checkout); + let base = resolver.base_for(&variant.config); + let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); + let result = check_bound(&bound, base.as_deref()); + + // Resolve each diagnostic's FileId to a display line: a program unit carries its + // (line, col); a lib file carries the lib name with a masked location. + let resolve_line = |d: &Diagnostic| -> DiagLine { + let units_len = units.len(); + match d.file { + Some(f) if f.index() < units_len => { + let (line, col) = units.get(f.index()).map_or((None, None), |u| { + let (t, m) = LocationTracker::new_ecmascript_with_map(&u.content); + let (_, pos) = + LocationMapper { tracker: &t, map: &m }.pos_and_position(d.span.start); + (Some(pos.line as u32), Some(pos.column as u32 + 1)) + }); + DiagLine { + file: units.get(f.index()).map(|u| u.name.clone()), + line, + col, + code: d.code, + related: Vec::new(), + } + } + Some(f) => DiagLine { + file: lib_files.get(f.index() - units_len).cloned(), + line: None, + col: None, + code: d.code, + related: Vec::new(), + }, + None => DiagLine { file: None, line: None, col: None, code: d.code, related: Vec::new() }, + } + }; let ours: Vec = result .diagnostics .iter() .map(|d| { - let (line, col) = d.file.and_then(|f| units.get(f.index())).map_or((None, None), |u| { - let (t, m) = LocationTracker::new_ecmascript_with_map(&u.content); - let (_, pos) = LocationMapper { tracker: &t, map: &m }.pos_and_position(d.span.start); - (Some(pos.line as u32), Some(pos.column as u32 + 1)) - }); - DiagLine { - file: d.file.and_then(|f| source_units.get(f.index()).map(|u| u.name.to_string())), - line, - col, - code: d.code, - } + let mut line = resolve_line(d); + line.related = d.related.iter().map(&resolve_line).collect(); + line }) .collect(); let parse_error = result.files.iter().find_map(|f| match &f.parse { @@ -1011,7 +1142,13 @@ pub fn check_one( .map(|c| { parse_summary_block(&c) .into_iter() - .map(|d| DiagLine { file: d.file, line: d.line, col: d.col, code: d.code }) + .map(|d| DiagLine { + file: d.file, + line: d.line, + col: d.col, + code: d.code, + related: Vec::new(), + }) .collect() }) .unwrap_or_default(), @@ -1084,6 +1221,9 @@ impl CheckTestReport { println!(" ours ({}):", self.ours.len()); for d in &self.ours { println!(" {}", fmt_diag(d)); + for r in &d.related { + println!(" related {}", fmt_diag(r)); + } } if self.ours.is_empty() { println!(" (none)"); @@ -1102,6 +1242,8 @@ impl CheckTestReport { fn fmt_diag(d: &DiagLine) -> String { match (&d.file, d.line, d.col) { (Some(file), Some(line), Some(col)) => format!("{file}({line},{col}): TS{}", d.code), + // A masked lib entry (file, no location) or a global one. + (Some(file), _, _) => format!("{file}(--,--): TS{}", d.code), _ => format!("error TS{} (global)", d.code), } } From 011a908c7c03804d6d27a64275c4a7678393dd7b Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 19:09:41 -0400 Subject: [PATCH 19/79] =?UTF-8?q?feat:=20conformance:tsc-check=20gating=20?= =?UTF-8?q?leg=20=E2=80=94=20runner=20filters/manifest/report,=20Step=203b?= =?UTF-8?q?=20+=20doctor=20probes,=20committed=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CLAUDE.md | 13 +- benches/js/CLAUDE.md | 17 +- benches/js/conformance.ts | 27 + .../js/results/report.tsc-conformance.json | 86 +++ benches/js/results/report.tsc-conformance.md | 48 ++ crates/tsv_check/CLAUDE.md | 4 + crates/tsv_check/src/merge.rs | 60 +- crates/tsv_check/src/program.rs | 10 + crates/tsv_debug/CLAUDE.md | 2 +- .../src/cli/commands/tsc_conformance.rs | 541 +++++++++++++++--- crates/tsv_debug/src/tsc_conformance/mod.rs | 2 +- .../tsv_debug/src/tsc_conformance/runner.rs | 290 +++++++++- deno.json | 1 + scripts/doctor.ts | 16 + scripts/publish.ts | 11 +- 16 files changed, 1004 insertions(+), 126 deletions(-) create mode 100644 benches/js/results/report.tsc-conformance.json create mode 100644 benches/js/results/report.tsc-conformance.md 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 75a150ac2..30ff146e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,7 +246,7 @@ Package shape: built from the wasm-pack `web` target, then `scripts/patch_npm_pa `scripts/publish.ts` orchestrates the release end to end (preflight → bump → check → conformance → build (npm packages + deno bundles, so artifact validation never sees stale bundles) → verify → artifact validation: size bounds + Deno smoke + Node tests → idempotent npm publish → git commit + tag + push), printing a wasm size summary (raw + gzipped) at the end. It stamps CHANGELOG.md's `## Unreleased` section into the released version's section — that section must be non-empty and carry a `` marker that matches `--bump` (the bump is required in **both** places and they must agree; on stamp the marker is dropped and a fresh empty `## Unreleased` reset to `bump: patch` is seeded for the next cycle). The user keeps it updated as work lands — agents don't touch `CHANGELOG.md` (see [Committing](#committing)). A failed wetrun is resumable: re-run `--wetrun` without `--bump`. -**Conformance gates (Step 3b).** The release-cadence correctness gates that run against **external oracles** — so they can't live in `deno task check` — run here via `deno task conformance`: the Svelte parser vs `svelte/compiler` (`conformance:svelte-fixtures`), the TS parser vs acorn-typescript's own suite (`conformance:ts-fixtures`) and the tsc corpus (`conformance:ts-repo`), the `tsc_conformance` roundtrip self-check vs tsgo's `.errors.txt` baselines (`conformance:tsc-roundtrip`), plus all three languages' AST + formatter vs the canonical parsers / prettier (`corpus:compare:parse` + `:format`, `--all`). Skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go` checkouts + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. `test262` (needs `../test262`) and the CSS-WPT harvest stay manual, out of the automated step. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; the historical FFI heisenbug now surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). +**Conformance gates (Step 3b).** The release-cadence correctness gates that run against **external oracles** — so they can't live in `deno task check` — run here via `deno task conformance`: the Svelte parser vs `svelte/compiler` (`conformance:svelte-fixtures`), the TS parser vs acorn-typescript's own suite (`conformance:ts-fixtures`) and the tsc corpus (`conformance:ts-repo`), the `tsc_conformance` roundtrip self-check vs tsgo's `.errors.txt` baselines (`conformance:tsc-roundtrip`), the tsv_check binder-family conformance gate vs those baselines (`conformance:tsc-check` — the duplicate/conflict code family, exact pins + the committed `report.tsc-conformance.{json,md}`), plus all three languages' AST + formatter vs the canonical parsers / prettier (`corpus:compare:parse` + `:format`, `--all`). Skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go` checkouts — for the tsc-check leg also the materialized `_submodules/TypeScript` corpus + `internal/bundled/libs` — + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. `test262` (needs `../test262`) and the CSS-WPT harvest stay manual, out of the automated step. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; the historical FFI heisenbug now surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). ```bash deno task publish # dry-run: validate everything, no mutation @@ -831,9 +831,16 @@ cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by pat # parse-rejections bucketed by baseline shape + Script-goal retries). Each test # runs catch_unwind-wrapped on a generous-stack worker; tracked parser crashes # live in a pinned CRASH_EXCLUSIONS ledger. Exact two-sided RUN_* pins on full -# runs. Not yet a deno-task/conformance leg (joins when the first code family -# gates). +# runs. Triage filters --test / --code / --variant k=v skip the +# pins (invariant gates still hold); --emit-manifest writes per-variant +# verdicts + buckets + census (pins verified before writing); --report +# writes the deterministic committed report (full-run only). On gate failure, +# per-test .diff artifacts land under target/tsc_conformance/diffs/. `run` is a +# leg of `deno task conformance` (`conformance:tsc-check`) and publish Step 3b; +# its preflight additionally needs the materialized _submodules/TypeScript +# corpus AND internal/bundled/libs (a --wetrun FAILS if either is missing). cargo run -p tsv_debug --quiet tsc_conformance run +deno task conformance:tsc-check # the same + writes benches/js/results/report.tsc-conformance.{json,md} # check-test - the inner dev loop: one corpus test (optionally one variant), # our diagnostics vs the baseline's summary block as a readable diff. diff --git a/benches/js/CLAUDE.md b/benches/js/CLAUDE.md index 953ca479c..159334408 100644 --- a/benches/js/CLAUDE.md +++ b/benches/js/CLAUDE.md @@ -399,13 +399,16 @@ 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`.** The three parse-conformance -gates (svelte-fixtures, ts-fixtures, ts-repo), the `tsc_conformance` roundtrip -self-check (`tsc-roundtrip` — checker-conformance vs tsgo's `.errors.txt` -baselines, not parse; the one pure-Rust leg, shelled out via `cargo`, so the task -carries `--allow-run=cargo`), 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`). `deno task -conformance` builds the corpus FFI once and runs all six legs in **ONE process** +gates (svelte-fixtures, ts-fixtures, ts-repo), the two `tsc_conformance` legs +(`tsc-roundtrip` — the baseline parse↔render self-check — then `tsc-check` — the +tsv_check binder-family conformance gate, which also writes the committed +`results/report.tsc-conformance.{json,md}`; both pure-Rust, shelled out via +`cargo`, so the task carries `--allow-run=cargo`; `tsc-check` additionally needs +the materialized `_submodules/TypeScript` corpus + `internal/bundled/libs`), 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`). `deno task +conformance` builds the corpus FFI once and runs the 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 diff --git a/benches/js/conformance.ts b/benches/js/conformance.ts index 50597bb76..b510f86e2 100644 --- a/benches/js/conformance.ts +++ b/benches/js/conformance.ts @@ -53,11 +53,38 @@ async function run_tsc_roundtrip(): Promise { if (code !== 0) Deno.exit(1); } +/** + * The tsc_conformance checker sweep — the standing bind+merge family gate over the + * in-scope corpus, writing the committed deterministic report Rust-side (so no + * extra Deno write perms are needed — the cargo subprocess writes it). Same + * pure-Rust shell-out + fail-fast contract as the roundtrip leg; unlike roundtrip + * it also needs the corpus inputs + bundled libs (publish Step 3b's preflight + * probes them). + */ +async function run_tsc_check(): Promise { + const {code} = await new Deno.Command('cargo', { + args: [ + 'run', + '-p', + 'tsv_debug', + '--quiet', + 'tsc_conformance', + 'run', + '--report', + 'benches/js/results/report.tsc-conformance', + ], + stdout: 'inherit', + stderr: 'inherit', + }).output(); + if (code !== 0) Deno.exit(1); +} + const legs: [string, () => Promise][] = [ ['conformance:svelte-fixtures', () => run_fixtures_gate(SVELTE_FIXTURES_GATE)], ['conformance:ts-fixtures', () => run_fixtures_gate(TS_FIXTURES_GATE)], ['conformance:ts-repo', () => run_ts_repo_compare([])], ['conformance:tsc-roundtrip', run_tsc_roundtrip], + ['conformance:tsc-check', run_tsc_check], ['corpus:compare:parse --all', () => run_corpus_compare_parse(['--all'])], ['corpus:compare:format --all', () => run_corpus_compare_format(['--all'])], ]; diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json new file mode 100644 index 000000000..ff6d07a91 --- /dev/null +++ b/benches/js/results/report.tsc-conformance.json @@ -0,0 +1,86 @@ +{ + "oracle": "tsgo committed .errors.txt baselines (bind + merge family)", + "denominators": { + "in_scope_tests": 9388, + "in_scope_variants": 9887, + "expect_clean_graded": 4435, + "clean_pass": 4435, + "baselined_parsed": 4446, + "family_graded_variants": 4066, + "family_positive_variants": 125 + }, + "family": { + "match": 425, + "missing": { + "total": 125, + "merge_path": 0, + "lib_conflict": 0, + "check_time": 125 + }, + "extra": 0, + "span_mismatch": 0 + }, + "per_code": { + "match": { + "2300": 301, + "2397": 4, + "2451": 56, + "2528": 35, + "2567": 26, + "2664": 3 + }, + "missing": { + "2300": 125 + } + }, + "related": { + "match": 51, + "missing": 0, + "extra": 0, + "span_mismatch": 0 + }, + "carve_outs": { + "recovery_ast_rule_a": 380, + "recovery_ast_rule_a_family": 9, + "module_detection_variants": 1 + }, + "census": { + "parse_rejected_total": 1006, + "parse_rejected_no_baseline": 45, + "parse_rejected_ts1xxx_only": 451, + "parse_rejected_other": 510, + "script_retry": 25, + "crash_excluded": 1 + }, + "lib": { + "files_bound": 107, + "sets_folded": 50 + }, + "pins": { + "in_scope_tests": 9388, + "in_scope_variants": 9887, + "expect_clean": 4435, + "baselined_parsed": 4446, + "parse_rejected": 1006, + "family_graded": 4066, + "family_positive": 125, + "family_match": 425, + "family_missing": 125, + "missing_merge": 0, + "missing_lib": 0, + "missing_check_time": 125, + "family_extra": 0, + "family_span_mismatch": 0, + "related_match": 51, + "related_missing": 0, + "related_extra": 0, + "related_span_mismatch": 0, + "carve_out_rule_a": 380, + "carve_out_rule_a_family": 9, + "module_detection": 1, + "script_retry": 25, + "crash_excluded": 1, + "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..a844bf591 --- /dev/null +++ b/benches/js/results/report.tsc-conformance.md @@ -0,0 +1,48 @@ +# tsc_conformance run — committed report + +Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Deterministic — wall-clock excluded. + +## Denominators + +- in-scope tests: 9388 +- in-scope variants: 9887 +- expect-clean graded / clean pass: 4435 / 4435 +- baselined + parsed: 4446 +- family graded / family-positive: 4066 / 125 + +## Family (2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671) + +- match: 425 +- missing: 125 (merge-path 0, lib-conflict 0, check-time 125) +- extra (GATE=0): 0 +- span mismatch: 0 + +## Per-code table + +| code | match | missing | +| --- | --- | --- | +| TS2300 | 301 | 125 | +| TS2397 | 4 | 0 | +| TS2451 | 56 | 0 | +| TS2528 | 35 | 0 | +| TS2567 | 26 | 0 | +| TS2664 | 3 | 0 | + +## Related-info channel (matched primaries) + +- match / missing / extra / span-mismatch: 51 / 0 / 0 / 0 + +## Carve-outs + +- recovery-AST rule (a): 380 (family-positive 9) +- moduleDetection variants (inert for family): 1 + +## Parse-divergence census + +- parse-rejected: 1006 (no baseline 45, TS1xxx-only 451, other 510) +- script-goal retries: 25 +- crash-excluded (tracked): 1 + +## Lib base + +- lib files bound / sets folded: 107 / 50 diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 4af08e9dd..a1fb7d446 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -97,6 +97,10 @@ result is fully owned — nothing borrows out. For lib-aware checking: multisets (extra = 0 is a hard gate; missing is classified by deferred cause), grades related-info on matched primaries as its own pinned channel, and publishes the parse-divergence census; exact `RUN_*` pins. + Triage filters (`--test`/`--code`/`--variant`) skip the pins; + `--emit-manifest` and `--report` (the committed + `benches/js/results/report.tsc-conformance.{json,md}`) serve tooling. A + release-gating leg of `deno task conformance` (`conformance:tsc-check`). - `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]` — diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 55db02146..61ee064ef 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -377,10 +377,19 @@ fn merge_global_symbol( return; } if let Some(base) = lib.and_then(|l| l.get(&source.name)) { - // Conflict-or-merge against the base without mutating it. A clean merge - // emits nothing; the in-file cascade already collapsed same-name test - // symbols to one, so (at the single-file scope this grades) not cloning the - // base into the overlay is sound — a later same-name symbol cannot arise. + // 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; } @@ -852,6 +861,49 @@ mod tests { 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 diags = merge_program(&files, 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() { diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 1a391a7f0..c0481453d 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -257,6 +257,16 @@ 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; guard the + // no-op the census can't see. + 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 }) } diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index a774aa331..82f07b1c6 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -15,7 +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 / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. - `fixtures/` — The fixture workflow surface — `model.rs` holds the data model (`InputType`, `Fixture`, divergence-suffix rules), `discovery.rs` walks the fixtures tree, `variants.rs` discovers variant files, `mod.rs` keeps IO/parse helpers, `validation/` is the validator (`fixtures_validate`: structure rules, per-phase checks, errors, summary), `audit_signature.rs` pins prettier's multi-pass chain. - `test262/` — ECMAScript conformance runner: `discovery.rs` walks the test262 tree, `frontmatter.rs` parses the YAML harness metadata, `runner.rs` drives our parser (`run_test`) and grades the strict subset into the differential `Manifest` (`grade_for_manifest`, sharing the `classify` skip logic with `run_test`). Pure Rust — no Deno needed. The `test262 --emit-manifest` JSON feeds `benches/js/diagnostics/test262_compare.ts` for the tsv-vs-oxc-parser comparison. -- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `libs.rs` resolves each variant's lib set (targetToLibMap/GetDefaultLibFileName port, `@lib` override, transitive `/// ` expansion, priority order) and caches bound lib products + per-set `LibBase` folds; `runner.rs` drives the `tsv_check` crate over the in-scope corpus (`run` = the walking-skeleton sweep with catch_unwind containment + the parse-divergence census; `check_one` backs `check-test`, the one-test dev loop). `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. +- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `libs.rs` resolves each variant's lib set (targetToLibMap/GetDefaultLibFileName port, `@lib` override, transitive `/// ` expansion, priority order) and caches bound lib products + per-set `LibBase` folds; `runner.rs` drives the `tsv_check` crate over the in-scope corpus (`run` = the family-conformance sweep with catch_unwind containment + the parse-divergence census, triage filters that skip pins, `--emit-manifest`, the committed `--report`, and failure `.diff` artifacts under `target/tsc_conformance/diffs/`; `check_one` backs `check-test`, the one-test dev loop). `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. - `diff.rs` — Colored text / JSON diffing with line-width annotations (used by `compare`, `ast_diff`, validation failures). - `error.rs` — `DebugError` — wraps `DenoError`, `io::Error`, `serde_json::Error`; exposes `hint()` for actionable messages. - `cli/` — argh `TopLevel`/`Subcommand` dispatch in `mod.rs` + per-command `FromArgs` modules under `cli/commands/`. Shared three-mode input plumbing (file path / `--content` / `--stdin`) comes from `tsv_cli::cli::input::InputArgs`. diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index caa7163b0..ccb959492 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -9,10 +9,10 @@ use crate::tsc_conformance::index::IndexReport; use crate::tsc_conformance::runner::SkeletonReport; use crate::tsc_conformance::{ baselines_dir, check_one, corpus_materialized, denominators, discover_baselines, histogram, - run_index, run_roundtrip, run_skeleton, tests_by_code, + run_index, run_roundtrip, run_skeleton, tests_by_code, RunFilter, RunOptions, }; use argh::FromArgs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// REGRESSION PIN (exact): total tsgo .errors.txt baselines. Measured /// 2026-07-09, ../typescript-go at 168e7015 (_submodules/TypeScript corpus pin @@ -242,6 +242,31 @@ pub struct RunCommand { /// emit a JSON report instead of the human summary #[argh(switch)] json: bool, + + /// triage filter: keep only tests whose relative path contains this substring + /// (SKIPS the pins) + #[argh(option)] + test: Option, + + /// triage filter: keep only variants whose baseline carries this TS code + /// (SKIPS the pins) + #[argh(option)] + code: Option, + + /// triage filter: keep only variants whose config has this `key=value` + /// (SKIPS the pins) + #[argh(option)] + variant: Option, + + /// write a JSON manifest of every graded variant (per-variant verdict + buckets + /// + census + pins) to this path — the tsc analog of `test262 --emit-manifest` + #[argh(option)] + emit_manifest: Option, + + /// write the committed compact report to `.json` + `.md` (full runs + /// only; deterministic, wall-clock excluded) + #[argh(option)] + report: Option, } /// Inner dev loop: run one corpus test (optionally one variant) through @@ -281,7 +306,20 @@ impl TscConformanceCommand { impl RunCommand { fn run(self) -> Result<(), CliError> { require_corpus(&self.path)?; - let report = run_skeleton(&self.path).map_err(|e| { + + let filter = self.build_filter()?; + let filtered = filter.is_active(); + // The committed report is the full-run artifact; refuse to write a partial one. + if self.report.is_some() && filtered { + eprintln!( + "Error: --report writes the committed full report; it cannot be combined with \ + --test/--code/--variant filters." + ); + return Err(CliError::Failed); + } + + let options = RunOptions { filter, collect_manifest: self.emit_manifest.is_some() }; + let report = run_skeleton(&self.path, &options).map_err(|e| { eprintln!("Error running skeleton sweep: {e}"); CliError::Failed })?; @@ -290,16 +328,50 @@ impl RunCommand { } else { report.print(); } - enforce_run_gates(&report) + + // Filters skip the exact pins (the roundtrip/query convention); the invariant + // gates still hold. Committed artifacts land only when the gates pass (so a + // pin miss never writes a bad manifest/report), while a failure dumps per-test + // diff artifacts for triage. + match enforce_run_gates(&report, !filtered) { + Ok(()) => { + if let Some(path) = &self.emit_manifest { + write_manifest(&report, path)?; + } + if let Some(path) = &self.report { + write_report(&report, path)?; + } + Ok(()) + } + Err(e) => { + write_diff_artifacts(&report); + Err(e) + } + } + } + + /// Build the triage filter from the CLI flags (lowercasing the `--variant` key, + /// which the config maps store lowercased). + fn build_filter(&self) -> Result { + let variant = match self.variant.as_deref().map(parse_variant_filter) { + Some(Ok((k, v))) => Some((k.to_lowercase(), v)), + Some(Err(e)) => { + eprintln!("{e}"); + return Err(CliError::Failed); + } + None => None, + }; + Ok(RunFilter { test: self.test.clone(), code: self.code, variant }) } } -/// Enforce the skeleton gates: the clean-grade invariant, zero panics, and (a -/// full run's) exact denominator + census pins. -fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { +/// Enforce the skeleton gates: the clean-grade + empty-channel invariants and zero +/// panics (always), plus — on a full run (`enforce_pins`) — the exact denominator, +/// family, related, and census pins. A filtered (triage) run skips the pins. +fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), CliError> { let mut errs: Vec = Vec::new(); - // Invariant gates (always). + // --- Invariant gates (always, even on a filtered run) --- if report.clean_pass != report.expect_clean_graded { errs.push(format!( "clean pass {} != expect-clean graded {} ({} non-clean)", @@ -331,43 +403,8 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { report.extra_samples.first().map_or("", String::as_str) )); } - - // Exact two-sided pins. - let pin = |errs: &mut Vec, label: &str, got: usize, want: usize| { - if got != want { - errs.push(format!("{label} {got} != pinned {want}")); - } - }; - pin(&mut errs, "in-scope tests", report.in_scope_tests, RUN_IN_SCOPE_TESTS_PIN); - pin(&mut errs, "in-scope variants", report.in_scope_variants, RUN_IN_SCOPE_VARIANTS_PIN); - pin(&mut errs, "expect-clean graded", report.expect_clean_graded, RUN_EXPECT_CLEAN_PIN); - pin(&mut errs, "clean pass", report.clean_pass, RUN_EXPECT_CLEAN_PIN); - pin(&mut errs, "baselined parsed", report.baselined_parsed, RUN_BASELINED_PARSED_PIN); - pin(&mut errs, "parse-rejected", report.parse_rejected_total, RUN_PARSE_REJECTED_PIN); - pin( - &mut errs, - "parse-rejected (no baseline)", - report.parse_rejected_no_baseline, - RUN_PARSE_REJECTED_NO_BASELINE_PIN, - ); - pin( - &mut errs, - "parse-rejected (TS1xxx-only)", - report.parse_rejected_ts1xxx_only, - RUN_PARSE_REJECTED_TS1XXX_PIN, - ); - pin( - &mut errs, - "parse-rejected (other)", - report.parse_rejected_other, - RUN_PARSE_REJECTED_OTHER_PIN, - ); - pin(&mut errs, "script retries", report.script_retry, RUN_SCRIPT_RETRY_PIN); - pin(&mut errs, "crash-excluded", report.excluded_crashes, RUN_CRASH_EXCLUDED_PIN); - - // Lib-base (S5) sizing pins + the empty-error-channel invariants. - pin(&mut errs, "lib files bound", report.lib_files_bound, RUN_LIB_FILES_BOUND_PIN); - pin(&mut errs, "lib sets folded", report.lib_sets_built, RUN_LIB_SETS_PIN); + // The lib error channels must stay empty (a lib parse-reject, a missing referenced + // lib, or an unrecognized `@lib`/reference name). if !report.lib_parse_errors.is_empty() { errs.push(format!( "{} lib file(s) failed to parse, e.g. {}", @@ -390,45 +427,84 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { )); } - // Family grading pins. - pin(&mut errs, "family graded", report.family_graded_variants, RUN_FAMILY_GRADED_PIN); - pin(&mut errs, "family positive", report.family_positive_variants, RUN_FAMILY_POSITIVE_PIN); - pin(&mut errs, "family match", report.family_match, RUN_FAMILY_MATCH_PIN); - pin(&mut errs, "family missing", report.family_missing, RUN_FAMILY_MISSING_PIN); - pin(&mut errs, "missing merge", report.missing_merge, RUN_MISSING_MERGE_PIN); - pin(&mut errs, "missing lib", report.missing_lib, RUN_MISSING_LIB_PIN); - pin(&mut errs, "missing check-time", report.missing_other, RUN_MISSING_CHECKTIME_PIN); - pin( - &mut errs, - "family span_mismatch", - report.family_span_mismatch, - RUN_FAMILY_SPAN_MISMATCH_PIN, - ); + // --- Exact two-sided pins (full run only; filters skip them) --- + if enforce_pins { + let pin = |errs: &mut Vec, label: &str, got: usize, want: usize| { + if got != want { + errs.push(format!("{label} {got} != pinned {want}")); + } + }; + pin(&mut errs, "in-scope tests", report.in_scope_tests, RUN_IN_SCOPE_TESTS_PIN); + pin(&mut errs, "in-scope variants", report.in_scope_variants, RUN_IN_SCOPE_VARIANTS_PIN); + pin(&mut errs, "expect-clean graded", report.expect_clean_graded, RUN_EXPECT_CLEAN_PIN); + pin(&mut errs, "clean pass", report.clean_pass, RUN_EXPECT_CLEAN_PIN); + pin(&mut errs, "baselined parsed", report.baselined_parsed, RUN_BASELINED_PARSED_PIN); + pin(&mut errs, "parse-rejected", report.parse_rejected_total, RUN_PARSE_REJECTED_PIN); + pin( + &mut errs, + "parse-rejected (no baseline)", + report.parse_rejected_no_baseline, + RUN_PARSE_REJECTED_NO_BASELINE_PIN, + ); + pin( + &mut errs, + "parse-rejected (TS1xxx-only)", + report.parse_rejected_ts1xxx_only, + RUN_PARSE_REJECTED_TS1XXX_PIN, + ); + pin( + &mut errs, + "parse-rejected (other)", + report.parse_rejected_other, + RUN_PARSE_REJECTED_OTHER_PIN, + ); + pin(&mut errs, "script retries", report.script_retry, RUN_SCRIPT_RETRY_PIN); + pin(&mut errs, "crash-excluded", report.excluded_crashes, RUN_CRASH_EXCLUDED_PIN); - // Related-info channel pins (two-sided; does not gate the primary verdict). - pin(&mut errs, "related match", report.related_match, RUN_RELATED_MATCH_PIN); - pin(&mut errs, "related missing", report.related_missing, RUN_RELATED_MISSING_PIN); - pin(&mut errs, "related extra", report.related_extra, RUN_RELATED_EXTRA_PIN); - pin( - &mut errs, - "related span_mismatch", - report.related_span_mismatch, - RUN_RELATED_SPAN_MISMATCH_PIN, - ); + // Lib-base (S5) sizing pins. + pin(&mut errs, "lib files bound", report.lib_files_bound, RUN_LIB_FILES_BOUND_PIN); + pin(&mut errs, "lib sets folded", report.lib_sets_built, RUN_LIB_SETS_PIN); - pin(&mut errs, "carve-out rule (a)", report.carve_out_rule_a, RUN_CARVE_OUT_RULE_A_PIN); - pin( - &mut errs, - "carve-out rule (a) family", - report.carve_out_rule_a_family, - RUN_CARVE_OUT_RULE_A_FAMILY_PIN, - ); - pin( - &mut errs, - "moduleDetection variants", - report.module_detection_variants, - RUN_MODULE_DETECTION_PIN, - ); + // Family grading pins. + pin(&mut errs, "family graded", report.family_graded_variants, RUN_FAMILY_GRADED_PIN); + pin(&mut errs, "family positive", report.family_positive_variants, RUN_FAMILY_POSITIVE_PIN); + pin(&mut errs, "family match", report.family_match, RUN_FAMILY_MATCH_PIN); + pin(&mut errs, "family missing", report.family_missing, RUN_FAMILY_MISSING_PIN); + pin(&mut errs, "missing merge", report.missing_merge, RUN_MISSING_MERGE_PIN); + pin(&mut errs, "missing lib", report.missing_lib, RUN_MISSING_LIB_PIN); + pin(&mut errs, "missing check-time", report.missing_other, RUN_MISSING_CHECKTIME_PIN); + pin( + &mut errs, + "family span_mismatch", + report.family_span_mismatch, + RUN_FAMILY_SPAN_MISMATCH_PIN, + ); + + // Related-info channel pins (two-sided; does not gate the primary verdict). + pin(&mut errs, "related match", report.related_match, RUN_RELATED_MATCH_PIN); + pin(&mut errs, "related missing", report.related_missing, RUN_RELATED_MISSING_PIN); + pin(&mut errs, "related extra", report.related_extra, RUN_RELATED_EXTRA_PIN); + pin( + &mut errs, + "related span_mismatch", + report.related_span_mismatch, + RUN_RELATED_SPAN_MISMATCH_PIN, + ); + + pin(&mut errs, "carve-out rule (a)", report.carve_out_rule_a, RUN_CARVE_OUT_RULE_A_PIN); + pin( + &mut errs, + "carve-out rule (a) family", + report.carve_out_rule_a_family, + RUN_CARVE_OUT_RULE_A_FAMILY_PIN, + ); + pin( + &mut errs, + "moduleDetection variants", + report.module_detection_variants, + RUN_MODULE_DETECTION_PIN, + ); + } if errs.is_empty() { Ok(()) @@ -442,6 +518,303 @@ fn enforce_run_gates(report: &SkeletonReport) -> Result<(), CliError> { } } +/// The exact `RUN_*` pins this run is held to — recorded in the committed report and +/// the manifest so the artifact states what it was measured against. +#[derive(serde::Serialize)] +struct RunPins { + in_scope_tests: usize, + in_scope_variants: usize, + expect_clean: usize, + baselined_parsed: usize, + parse_rejected: usize, + family_graded: usize, + family_positive: usize, + family_match: usize, + family_missing: usize, + missing_merge: usize, + missing_lib: usize, + missing_check_time: usize, + family_extra: usize, + family_span_mismatch: usize, + related_match: usize, + related_missing: usize, + related_extra: usize, + related_span_mismatch: usize, + carve_out_rule_a: usize, + carve_out_rule_a_family: usize, + module_detection: usize, + script_retry: usize, + crash_excluded: usize, + lib_files_bound: usize, + lib_sets: usize, +} + +/// Snapshot the `RUN_*` pin constants (the hard family gate `family_extra` is a fixed +/// zero, folded in for a complete record). +fn run_pins() -> RunPins { + RunPins { + in_scope_tests: RUN_IN_SCOPE_TESTS_PIN, + in_scope_variants: RUN_IN_SCOPE_VARIANTS_PIN, + expect_clean: RUN_EXPECT_CLEAN_PIN, + baselined_parsed: RUN_BASELINED_PARSED_PIN, + parse_rejected: RUN_PARSE_REJECTED_PIN, + family_graded: RUN_FAMILY_GRADED_PIN, + family_positive: RUN_FAMILY_POSITIVE_PIN, + family_match: RUN_FAMILY_MATCH_PIN, + family_missing: RUN_FAMILY_MISSING_PIN, + missing_merge: RUN_MISSING_MERGE_PIN, + missing_lib: RUN_MISSING_LIB_PIN, + missing_check_time: RUN_MISSING_CHECKTIME_PIN, + family_extra: 0, + family_span_mismatch: RUN_FAMILY_SPAN_MISMATCH_PIN, + related_match: RUN_RELATED_MATCH_PIN, + related_missing: RUN_RELATED_MISSING_PIN, + related_extra: RUN_RELATED_EXTRA_PIN, + related_span_mismatch: RUN_RELATED_SPAN_MISMATCH_PIN, + carve_out_rule_a: RUN_CARVE_OUT_RULE_A_PIN, + carve_out_rule_a_family: RUN_CARVE_OUT_RULE_A_FAMILY_PIN, + module_detection: RUN_MODULE_DETECTION_PIN, + script_retry: RUN_SCRIPT_RETRY_PIN, + crash_excluded: RUN_CRASH_EXCLUDED_PIN, + lib_files_bound: RUN_LIB_FILES_BOUND_PIN, + lib_sets: RUN_LIB_SETS_PIN, + } +} + +/// The `--emit-manifest` wrapper: the full per-variant report plus the pins snapshot. +#[derive(serde::Serialize)] +struct RunManifest<'a> { + pins: RunPins, + report: &'a SkeletonReport, +} + +/// Write the `--emit-manifest` JSON (per-variant verdicts + buckets + census + pins). +/// Called only after the gates pass, so a bad manifest never lands. +fn write_manifest(report: &SkeletonReport, path: &Path) -> Result<(), CliError> { + let manifest = RunManifest { pins: run_pins(), report }; + let file = std::fs::File::create(path).map_err(|e| { + eprintln!("Error creating manifest {}: {e}", path.display()); + CliError::Failed + })?; + serde_json::to_writer(std::io::BufWriter::new(file), &manifest).map_err(|e| { + eprintln!("Error writing manifest: {e}"); + CliError::Failed + })?; + println!( + "Wrote manifest ({} variant rows) to {}", + report.manifest_entries.len(), + path.display() + ); + Ok(()) +} + +/// Build the committed compact report as a JSON value — deterministic (sorted +/// per-code maps, wall-clock excluded) so re-runs are diff-clean. +fn build_report_value(report: &SkeletonReport) -> serde_json::Value { + serde_json::json!({ + "oracle": "tsgo committed .errors.txt baselines (bind + merge family)", + "denominators": { + "in_scope_tests": report.in_scope_tests, + "in_scope_variants": report.in_scope_variants, + "expect_clean_graded": report.expect_clean_graded, + "clean_pass": report.clean_pass, + "baselined_parsed": report.baselined_parsed, + "family_graded_variants": report.family_graded_variants, + "family_positive_variants": report.family_positive_variants, + }, + "family": { + "match": report.family_match, + "missing": { + "total": report.family_missing, + "merge_path": report.missing_merge, + "lib_conflict": report.missing_lib, + "check_time": report.missing_other, + }, + "extra": report.family_extra, + "span_mismatch": report.family_span_mismatch, + }, + "per_code": { + "match": report.family_match_by_code, + "missing": report.family_missing_by_code, + }, + "related": { + "match": report.related_match, + "missing": report.related_missing, + "extra": report.related_extra, + "span_mismatch": report.related_span_mismatch, + }, + "carve_outs": { + "recovery_ast_rule_a": report.carve_out_rule_a, + "recovery_ast_rule_a_family": report.carve_out_rule_a_family, + "module_detection_variants": report.module_detection_variants, + }, + "census": { + "parse_rejected_total": report.parse_rejected_total, + "parse_rejected_no_baseline": report.parse_rejected_no_baseline, + "parse_rejected_ts1xxx_only": report.parse_rejected_ts1xxx_only, + "parse_rejected_other": report.parse_rejected_other, + "script_retry": report.script_retry, + "crash_excluded": report.excluded_crashes, + }, + "lib": { + "files_bound": report.lib_files_bound, + "sets_folded": report.lib_sets_built, + }, + "pins": run_pins(), + }) +} + +/// Render the committed report's compact Markdown (the same deterministic data as +/// [`build_report_value`], for readers). +fn render_report_md(report: &SkeletonReport) -> String { + use std::collections::BTreeSet; + use std::fmt::Write as _; + let mut s = String::new(); + s.push_str("# tsc_conformance run — committed report\n\n"); + s.push_str( + "Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). \ + Deterministic — wall-clock excluded.\n\n", + ); + + s.push_str("## Denominators\n\n"); + let _ = writeln!(s, "- in-scope tests: {}", report.in_scope_tests); + let _ = writeln!(s, "- in-scope variants: {}", report.in_scope_variants); + let _ = writeln!( + s, + "- expect-clean graded / clean pass: {} / {}", + report.expect_clean_graded, report.clean_pass + ); + let _ = writeln!(s, "- baselined + parsed: {}", report.baselined_parsed); + let _ = writeln!( + s, + "- family graded / family-positive: {} / {}\n", + report.family_graded_variants, report.family_positive_variants + ); + + s.push_str("## Family (2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671)\n\n"); + let _ = writeln!(s, "- match: {}", report.family_match); + let _ = writeln!( + s, + "- missing: {} (merge-path {}, lib-conflict {}, check-time {})", + report.family_missing, report.missing_merge, report.missing_lib, report.missing_other + ); + let _ = writeln!(s, "- extra (GATE=0): {}", report.family_extra); + let _ = writeln!(s, "- span mismatch: {}\n", report.family_span_mismatch); + + s.push_str("## Per-code table\n\n"); + s.push_str("| code | match | missing |\n| --- | --- | --- |\n"); + let codes: BTreeSet = report + .family_match_by_code + .keys() + .chain(report.family_missing_by_code.keys()) + .copied() + .collect(); + for code in codes { + let m = report.family_match_by_code.get(&code).copied().unwrap_or(0); + let miss = report.family_missing_by_code.get(&code).copied().unwrap_or(0); + let _ = writeln!(s, "| TS{code} | {m} | {miss} |"); + } + s.push('\n'); + + s.push_str("## Related-info channel (matched primaries)\n\n"); + let _ = writeln!( + s, + "- match / missing / extra / span-mismatch: {} / {} / {} / {}\n", + report.related_match, + report.related_missing, + report.related_extra, + report.related_span_mismatch + ); + + s.push_str("## Carve-outs\n\n"); + let _ = writeln!( + s, + "- recovery-AST rule (a): {} (family-positive {})", + report.carve_out_rule_a, report.carve_out_rule_a_family + ); + let _ = writeln!( + s, + "- moduleDetection variants (inert for family): {}\n", + report.module_detection_variants + ); + + s.push_str("## Parse-divergence census\n\n"); + let _ = writeln!( + s, + "- parse-rejected: {} (no baseline {}, TS1xxx-only {}, other {})", + report.parse_rejected_total, + report.parse_rejected_no_baseline, + report.parse_rejected_ts1xxx_only, + report.parse_rejected_other + ); + let _ = writeln!(s, "- script-goal retries: {}", report.script_retry); + let _ = writeln!(s, "- crash-excluded (tracked): {}\n", report.excluded_crashes); + + s.push_str("## Lib base\n\n"); + let _ = writeln!( + s, + "- lib files bound / sets folded: {} / {}", + report.lib_files_bound, report.lib_sets_built + ); + + s +} + +/// Write the committed compact report to `.json` + `.md` (full runs only; +/// deterministic). Called only after the gates pass. +fn write_report(report: &SkeletonReport, base: &Path) -> Result<(), CliError> { + let json_path = PathBuf::from(format!("{}.json", base.display())); + let md_path = PathBuf::from(format!("{}.md", base.display())); + let value = build_report_value(report); + let mut json = serde_json::to_string_pretty(&value).map_err(|e| { + eprintln!("Error serializing report JSON: {e}"); + CliError::Failed + })?; + json.push('\n'); + std::fs::write(&json_path, json).map_err(|e| { + eprintln!("Error writing {}: {e}", json_path.display()); + CliError::Failed + })?; + std::fs::write(&md_path, render_report_md(report)).map_err(|e| { + eprintln!("Error writing {}: {e}", md_path.display()); + CliError::Failed + })?; + println!("Wrote committed report to {} + {}", json_path.display(), md_path.display()); + Ok(()) +} + +/// Dump each failing variant's ours-vs-baseline diff under +/// `target/tsc_conformance/diffs/` (a regression aid; a no-op when the run is green). +fn write_diff_artifacts(report: &SkeletonReport) { + if report.failing_variants.is_empty() { + return; + } + let dir = Path::new("target/tsc_conformance/diffs"); + if let Err(e) = std::fs::create_dir_all(dir) { + eprintln!(" (could not create {}: {e})", dir.display()); + return; + } + eprintln!( + "\nWrote {} failure diff artifact(s) under {}/:", + report.failing_variants.len(), + dir.display() + ); + for fv in &report.failing_variants { + let path = dir.join(format!("{}__{}.diff", fv.suite, sanitize_artifact_name(&fv.config))); + match std::fs::write(&path, &fv.diff) { + Ok(()) => eprintln!(" {} ({})", path.display(), fv.reason), + Err(e) => eprintln!(" (failed to write {}: {e})", path.display()), + } + } +} + +/// Replace path-hostile characters so a baseline identity is a safe artifact basename. +fn sanitize_artifact_name(name: &str) -> String { + name.chars() + .map(|c| if c == '/' || c == '\\' || c.is_whitespace() { '_' } else { c }) + .collect() +} + impl CheckTestCommand { fn run(self) -> Result<(), CliError> { require_corpus(&self.path)?; @@ -475,7 +848,7 @@ fn parse_variant_filter(arg: &str) -> Result<(String, String), String> { /// Fail (with the submodule hint) when the corpus inputs are not materialized — /// both `run` and `check-test` need them, unlike the baseline-only tools. -fn require_corpus(path: &std::path::Path) -> Result<(), CliError> { +fn require_corpus(path: &Path) -> Result<(), CliError> { if corpus_materialized(path) { return Ok(()); } @@ -703,7 +1076,7 @@ fn filter_baselines( /// /// `example` names the subcommand for the "Or specify a custom path" hint. fn load_baselines( - checkout: &std::path::Path, + checkout: &Path, example: &str, ) -> Result, CliError> { let dir = baselines_dir(checkout); diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 28da2ca79..e50f5255d 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -37,4 +37,4 @@ pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; -pub use runner::{check_one, run_skeleton}; +pub use runner::{check_one, run_skeleton, RunFilter, RunOptions}; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index d3b7e282b..64f06b37a 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -43,7 +43,7 @@ use crate::tsc_conformance::options_meta::{ }; use crate::tsc_conformance::variants::{config_name, expand, Variant}; use bumpalo::Bump; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use std::time::Instant; @@ -137,6 +137,69 @@ pub struct PanicRecord { pub payload: String, } +/// Filters for a scoped `run` sweep. Any active filter SKIPS the exact pins (the +/// `roundtrip`/`query` convention), so a filtered run is a triage view — the +/// invariant gates (clean grading, no panics, `family_extra == 0`) still hold. +#[derive(Default, Clone)] +pub struct RunFilter { + /// Keep only tests whose relative path contains this substring. + pub test: Option, + /// Keep only variants whose joined baseline carries this TS code. + pub code: Option, + /// Keep only variants whose config has this `key=value` (key lowercased). + pub variant: Option<(String, String)>, +} + +impl RunFilter { + /// Whether any filter is active (drives pin skipping). + #[must_use] + pub fn is_active(&self) -> bool { + self.test.is_some() || self.code.is_some() || self.variant.is_some() + } +} + +/// Options for the skeleton sweep. +#[derive(Default, Clone)] +pub struct RunOptions { + /// The triage filter (empty = full pinned run). + pub filter: RunFilter, + /// Collect the per-variant verdict rows (for `--emit-manifest`). + pub collect_manifest: bool, +} + +/// One graded variant's verdict for the `--emit-manifest` JSON (the per-variant +/// row — the test262-manifest analog). Collected only when a manifest is requested. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ManifestEntry { + /// The suite (`compiler` / `conformance`). + pub suite: String, + /// The corpus test's relative path. + pub test: String, + /// The joined baseline name (the variant identity). + pub config: String, + /// Whether the variant has an on-disk baseline. + pub baselined: bool, + /// Whether tsv parsed the unit (`false` = parse-rejected). + pub parsed: bool, + /// The per-variant verdict (see [`grade_test`] / [`grade_family`]). + pub verdict: &'static str, +} + +/// One failing variant with a pre-rendered ours-vs-baseline diff — written to a +/// `.diff` artifact when a run's gates fail (a regression aid; empty when green). +#[derive(Debug, Clone, serde::Serialize)] +pub struct FailingVariant { + /// The suite (`compiler` / `conformance`). + pub suite: String, + /// The joined baseline name (the artifact basename). + pub config: String, + /// Why it failed (`family_extra` / `span_mismatch` / `clean_fail` / `panic`). + pub reason: &'static str, + /// The rendered ours-vs-baseline text (file-artifact only — not in `--json`). + #[serde(skip)] + pub diff: String, +} + /// The skeleton sweep report. #[derive(Debug, Clone, serde::Serialize, Default)] pub struct SkeletonReport { @@ -238,8 +301,24 @@ pub struct SkeletonReport { pub stale_exclusions: Vec, /// Total bound nodes across in-scope tests (informational). pub total_nodes: u64, - /// Wall-clock of the sweep in milliseconds. + /// Wall-clock of the sweep in milliseconds (EXCLUDED from the committed report — + /// machine-varying). pub wall_ms: u128, + + // --- deterministic per-code breakdown (the committed report's per-code table) --- + /// Family diagnostics that matched, keyed by TS code (sorted for determinism). + pub family_match_by_code: BTreeMap, + /// Family baseline diagnostics with no match, keyed by TS code (sorted). + pub family_missing_by_code: BTreeMap, + + // --- optional artifacts (empty on a normal green run) --- + /// Per-variant verdict rows for `--emit-manifest` (empty unless requested). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub manifest_entries: Vec, + /// Failing variants with a pre-rendered diff — written to `.diff` artifacts when + /// the gates fail (empty when green). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failing_variants: Vec, } /// The baseline shape used to bucket a parse-rejected variant. @@ -255,17 +334,18 @@ enum BaselineShape { /// /// Returns an error string if the worker cannot spawn, the worker panics /// outside a contained per-test check, or corpus discovery fails. -pub fn run_skeleton(checkout: &Path) -> Result { +pub fn run_skeleton(checkout: &Path, options: &RunOptions) -> Result { let checkout = checkout.to_path_buf(); + let options = options.clone(); let handle = std::thread::Builder::new() .stack_size(SKELETON_STACK) .name("tsc-skeleton".to_string()) - .spawn(move || run_skeleton_inner(&checkout)) + .spawn(move || run_skeleton_inner(&checkout, &options)) .map_err(|e| format!("spawn skeleton worker: {e}"))?; handle.join().map_err(|_| "skeleton worker panicked".to_string())? } -fn run_skeleton_inner(checkout: &Path) -> Result { +fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result { let start = Instant::now(); let corpus = discover_corpus(checkout)?; let baselines = discover_baselines(&baselines_dir(checkout))?; @@ -282,6 +362,12 @@ fn run_skeleton_inner(checkout: &Path) -> Result { let mut resolver = LibResolver::new(checkout); for test in &corpus { + // Test-level triage filter (`--test `): match the roundtrip identity. + if let Some(sub) = &options.filter.test + && !test.relative_path.contains(sub.as_str()) + { + continue; + } if SKIPPED_TESTS.contains(&test.basename.as_str()) { continue; } @@ -319,7 +405,7 @@ fn run_skeleton_inner(checkout: &Path) -> Result { } report.in_scope_tests += 1; - grade_test(test, &units[0], &in_scope, &ondisk, &mut resolver, &mut report); + grade_test(test, &units[0], &in_scope, &ondisk, &mut resolver, options, &mut report); } // Fold in the resolver's lib-base census (parse-once/fold-once counts + gates). @@ -386,6 +472,7 @@ fn grade_test( in_scope: &[&Variant], ondisk: &HashMap<(&str, String), &Baseline>, resolver: &mut LibResolver, + options: &RunOptions, report: &mut SkeletonReport, ) { // Parse + bind on a fresh arena, contained against panics (the tsv parser is the @@ -413,14 +500,28 @@ fn grade_test( let line_map = parsed.then(|| LocationTracker::new_ecmascript_with_map(&unit.content)); for variant in in_scope { + let name = config_name(&test.basename, &variant.description); + let baseline = ondisk.get(&(test.suite, name.clone())).copied(); + + // Variant-level triage filters, applied BEFORE counting so a filtered sweep's + // denominators reflect only the graded slice (any active filter skips the pins). + if let Some((k, v)) = &options.filter.variant + && variant.config.get(k).map(String::as_str) != Some(v.as_str()) + { + continue; + } + if let Some(code) = options.filter.code + && !baseline.is_some_and(|b| baseline_carries_code(b, code)) + { + continue; + } + report.in_scope_variants += 1; if variant.config.contains_key("moduledetection") { report.module_detection_variants += 1; } - let name = config_name(&test.basename, &variant.description); - let baseline = ondisk.get(&(test.suite, name.clone())).copied(); - match parse { + let verdict: &'static str = match parse { ParseReport::Rejected { .. } => { report.parse_rejected_total += 1; match baseline_shape(baseline) { @@ -428,15 +529,39 @@ fn grade_test( BaselineShape::Ts1xxxOnly => report.parse_rejected_ts1xxx_only += 1, BaselineShape::Other => report.parse_rejected_other += 1, } + "parse_rejected" } ParseReport::Parsed(facts) => { if facts.used_script_retry { report.script_retry += 1; } // Resolve this variant's lib set (cached) and merge the bound program - // against it — the merge diagnostics are the lib-conflict family. - let base = resolver.base_for(&variant.config); - let result = check_bound(&bound, base.as_deref()); + // against it — the merge diagnostics are the lib-conflict family. The + // lib resolution (parse+bind of each `.d.ts`) is the only remaining + // panic source past the initial bind, so contain it per variant: a + // future lib parse panic is recorded, not sweep-fatal. + let checked = catch_unwind(AssertUnwindSafe(|| { + let base = resolver.base_for(&variant.config); + let result = check_bound(&bound, base.as_deref()); + (base, result) + })); + let (base, result) = match checked { + Ok(pair) => pair, + Err(payload) => { + report.panics.push(PanicRecord { + test: test.relative_path.clone(), + payload: panic_payload_message(&*payload), + }); + report.failing_variants.push(FailingVariant { + suite: test.suite.to_string(), + config: name.clone(), + reason: "panic", + diff: format!("# {}/{name} (lib-resolution panic)\n", test.suite), + }); + record_manifest(report, options, test, &name, baseline.is_some(), true, "panic"); + continue; + } + }; let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); match baseline { @@ -444,11 +569,19 @@ fn grade_test( report.expect_clean_graded += 1; if result.diagnostics.is_empty() { report.clean_pass += 1; + "clean_pass" } else { report.clean_fail.push(CleanFail { variant: format!("{}/{name}", test.suite), diagnostics: result.diagnostics.len(), }); + report.failing_variants.push(FailingVariant { + suite: test.suite.to_string(), + config: name.clone(), + reason: "clean_fail", + diff: render_clean_fail_diff(test, &name, &result.diagnostics), + }); + "clean_fail" } } Some(b) => { @@ -461,17 +594,86 @@ fn grade_test( } None => Vec::new(), }; - grade_family(test, &name, b, &ours_family, report); + grade_family(test, &name, b, &ours_family, report) } } } - } + }; + + record_manifest(report, options, test, &name, baseline.is_some(), parsed, verdict); } // Node total: counted once per test (all variants share the parse+bind). report.total_nodes += bound.total_node_count(); } +/// Record one per-variant verdict row for `--emit-manifest` (a no-op unless a +/// manifest is being collected). +fn record_manifest( + report: &mut SkeletonReport, + options: &RunOptions, + test: &CorpusTest, + config: &str, + baselined: bool, + parsed: bool, + verdict: &'static str, +) { + if options.collect_manifest { + report.manifest_entries.push(ManifestEntry { + suite: test.suite.to_string(), + test: test.relative_path.clone(), + config: config.to_string(), + baselined, + parsed, + verdict, + }); + } +} + +/// Whether a baseline's summary block carries a given TS code (the `--code` filter). +fn baseline_carries_code(baseline: &Baseline, code: u32) -> bool { + let Ok(content) = std::fs::read_to_string(&baseline.path) else { + return false; + }; + parse_summary_block(&content).iter().any(|d| d.code == code) +} + +/// Render one failing family variant's ours-vs-baseline diff for a `.diff` artifact. +fn render_family_diff( + test: &CorpusTest, + name: &str, + reason: &str, + ours: &[FamilyEntry], + base: &[FamilyEntry], +) -> String { + use std::fmt::Write as _; + let mut s = format!("# {}/{name} ({reason})\n", test.suite); + let _ = writeln!(s, "## ours family ({})", ours.len()); + for e in ours { + let _ = writeln!(s, " {}({},{}): TS{}", e.key.file, e.key.line, e.key.col, e.key.code); + } + let _ = writeln!(s, "## baseline family ({})", base.len()); + for e in base { + let _ = writeln!(s, " {}({},{}): TS{}", e.key.file, e.key.line, e.key.col, e.key.code); + } + s +} + +/// Render an expect-clean variant's spurious diagnostics for a `.diff` artifact. +fn render_clean_fail_diff(test: &CorpusTest, name: &str, diags: &[Diagnostic]) -> String { + use std::fmt::Write as _; + let mut s = format!( + "# {}/{name} (clean_fail — expect-clean but {} diagnostic(s))\n## ours ({})\n", + test.suite, + diags.len(), + diags.len(), + ); + for d in diags { + let _ = writeln!(s, " TS{} @ [{}..{}]", d.code, d.span.start, d.span.end); + } + s +} + /// The number of program units in the single-file sweep (a lib FileId is /// `>= UNITS_LEN`, translating to `lib_files[FileId - UNITS_LEN]`). const UNITS_LEN: u32 = 1; @@ -570,7 +772,8 @@ struct FamilyEntry { } /// Grade one parsed-with-baseline variant's family diagnostics against its -/// baseline, folding the buckets into `report`. Applies predicate v1 rule (a) +/// baseline, folding the buckets into `report` and returning the per-variant +/// verdict (for the `--emit-manifest` row). Applies predicate v1 rule (a) /// (recovery-AST carve-out) first, then the primary-code channel and — for the /// matched primaries — the independent related-info channel. fn grade_family( @@ -579,8 +782,10 @@ fn grade_family( baseline: &Baseline, ours: &[FamilyEntry], report: &mut SkeletonReport, -) { - let Ok(content) = std::fs::read_to_string(&baseline.path) else { return }; +) -> &'static str { + let Ok(content) = std::fs::read_to_string(&baseline.path) else { + return "baseline_unreadable"; + }; let base_all = parse_base_diags(&content); // Predicate v1 rule (a): tsv parses clean (it did — this variant parsed) and @@ -609,7 +814,7 @@ fn grade_family( if has_family { report.carve_out_rule_a_family += 1; } - return; + return "carve_out"; } report.family_graded_variants += 1; @@ -623,6 +828,9 @@ fn grade_family( report.family_match += buckets.matched; report.family_span_mismatch += buckets.span_mismatch; report.family_extra += buckets.extra; + for (code, count) in &buckets.matched_by_code { + *report.family_match_by_code.entry(*code).or_default() += *count; + } if buckets.extra > 0 && report.extra_samples.len() < 20 { report.extra_samples.push(format!("{}/{name} (+{})", test.suite, buckets.extra)); } @@ -631,15 +839,28 @@ fn grade_family( .span_mismatch_samples .push(format!("{}/{name} (~{})", test.suite, buckets.span_mismatch)); } + // An unexplained hard-fail bucket (extra / span mismatch) gets a rendered + // ours-vs-baseline diff artifact; the pinned check-time `missing` is expected, + // so it does not. + if buckets.extra > 0 || buckets.span_mismatch > 0 { + let reason = if buckets.extra > 0 { "family_extra" } else { "span_mismatch" }; + report.failing_variants.push(FailingVariant { + suite: test.suite.to_string(), + config: name.to_string(), + reason, + diff: render_family_diff(test, name, reason, ours, &base_family), + }); + } let is_lib = LIB_CONFLICT_BASELINES.contains(&test.basename.as_str()); - for (code, count) in buckets.missing_by_code { - report.family_missing += count; - if MERGE_CODES.contains(&code) { - report.missing_merge += count; + for (code, count) in &buckets.missing_by_code { + report.family_missing += *count; + *report.family_missing_by_code.entry(*code).or_default() += *count; + if MERGE_CODES.contains(code) { + report.missing_merge += *count; } else if is_lib { - report.missing_lib += count; + report.missing_lib += *count; } else { - report.missing_other += count; + report.missing_other += *count; if report.missing_other_samples.len() < 20 { report .missing_other_samples @@ -661,6 +882,19 @@ fn grade_family( if rel.missing > 0 && report.related_missing_samples.len() < 20 { report.related_missing_samples.push(format!("{}/{name} (-{})", test.suite, rel.missing)); } + + // The per-variant verdict (extra dominates — it is the hard gate). + if buckets.extra > 0 { + "family_extra" + } else if buckets.span_mismatch > 0 { + "family_span_mismatch" + } else if !buckets.missing_by_code.is_empty() { + "family_missing" + } else if has_family { + "family_match" + } else { + "baselined_clean" + } } /// A baseline summary diagnostic with its parsed related-info entries. @@ -829,6 +1063,8 @@ struct FamilyBuckets { matched: usize, extra: usize, span_mismatch: usize, + /// The exact matches, per code (for the committed report's per-code table). + matched_by_code: HashMap, /// The unattributed misses, per code (for cause classification). missing_by_code: HashMap, } @@ -847,6 +1083,7 @@ fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { } let mut matched = 0usize; + let mut matched_by_code: HashMap = HashMap::new(); // Leftover counts grouped by (file, code) for span-mismatch pairing. let mut left_ours: HashMap<(&str, u32), usize> = HashMap::new(); let mut left_base: HashMap<(&str, u32), usize> = HashMap::new(); @@ -855,6 +1092,9 @@ fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { let bc = base_counts.get(d).copied().unwrap_or(0); let m = oc.min(bc); matched += m; + if m > 0 { + *matched_by_code.entry(d.code).or_default() += m; + } if oc > m { *left_ours.entry((d.file.as_str(), d.code)).or_default() += oc - m; } @@ -885,7 +1125,7 @@ fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { } } - FamilyBuckets { matched, extra, span_mismatch, missing_by_code } + FamilyBuckets { matched, extra, span_mismatch, matched_by_code, missing_by_code } } /// Classify a parse-rejected variant's baseline shape for the census. diff --git a/deno.json b/deno.json index a1239c160..93c808049 100644 --- a/deno.json +++ b/deno.json @@ -78,6 +78,7 @@ "conformance:ts-repo": "deno task build:ffi:corpus && TSV_FFI_PROFILE=corpus deno task conformance:ts-repo:run", "conformance:ts-repo:run": "deno run --allow-ffi --allow-read --allow-env --allow-net --allow-sys benches/js/diagnostics/ts_repo_compare.ts", "conformance:tsc-roundtrip": "cargo run -p tsv_debug --quiet tsc_conformance roundtrip", + "conformance:tsc-check": "cargo run -p tsv_debug --quiet tsc_conformance run --report benches/js/results/report.tsc-conformance", "conformance": "deno task build:ffi:corpus && TSV_FFI_PROFILE=corpus PRETTIER_DEBUG=1 deno run --allow-ffi --allow-read --allow-env --allow-net --allow-sys --allow-run=cargo --allow-write=benches/js/.cache benches/js/conformance.ts", "divergence:audit": "deno run --allow-read benches/js/divergence_audit.ts", "test:deno": "deno test --allow-read benches/js/lib/divergence/", diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 1915d512a..f524fe8ee 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -172,6 +172,22 @@ if (exists('../typescript-go/testdata/baselines/reference/submodule')) { warn('../typescript-go present but testdata/baselines/reference/submodule missing — conformance:tsc-roundtrip will FAIL (partial checkout)'); } else warn('../typescript-go checkout missing — conformance:tsc-roundtrip needs it'); +// The tsc-check leg additionally sweeps the corpus INPUTS + bundled libs (unlike +// roundtrip, which reads only the committed baselines). The corpus is the +// often-unmaterialized _submodules/TypeScript submodule. +if (exists('../typescript-go')) { + if (exists('../typescript-go/_submodules/TypeScript/tests/cases')) { + ok('../typescript-go corpus inputs (_submodules/TypeScript materialized)'); + } else { + warn('../typescript-go corpus inputs missing — conformance:tsc-check needs them (git submodule update --init in ../typescript-go)'); + } + if (exists('../typescript-go/internal/bundled/libs')) { + ok('../typescript-go bundled libs present'); + } else { + warn('../typescript-go bundled libs (internal/bundled/libs) missing — conformance:tsc-check needs them'); + } +} + // Informational (NOT gated by pins:audit — see its docstring): the prettier // checkout is a reading reference + corpus-suite source whose oracle output is // computed live per file, and it legitimately rides `-dev` versions. diff --git a/scripts/publish.ts b/scripts/publish.ts index 9f69c4c54..04e12ef95 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -340,7 +340,16 @@ if (no_check) { exists('../typescript/tests') ? null : '../typescript checkout', exists('../typescript-go/testdata/baselines/reference/submodule') ? null - : '../typescript-go checkout', + : '../typescript-go baselines (tsc-roundtrip)', + // The tsc-check leg additionally sweeps the corpus INPUTS + bundled libs + // (unlike roundtrip, which reads only the committed baselines) — the corpus is + // the often-unmaterialized _submodules/TypeScript submodule. + exists('../typescript-go/_submodules/TypeScript/tests/cases') + ? null + : '../typescript-go corpus inputs (git submodule update --init in ../typescript-go)', + exists('../typescript-go/internal/bundled/libs') + ? null + : '../typescript-go bundled libs (internal/bundled/libs)', exists('benches/js/node_modules') ? null : 'benches/js/node_modules (deno task bench:install)', ].filter((m): m is string => m !== null); if (missing.length > 0 && wetrun) { From b1c8f9e3d06bb1cecf8fe2a22841711d10a971ea Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 19:26:21 -0400 Subject: [PATCH 20/79] format --- crates/tsv_check/src/binder/atoms.rs | 6 +- crates/tsv_check/src/binder/mod.rs | 115 +++-- crates/tsv_check/src/binder/sym.rs | 372 ++++++++++++---- crates/tsv_check/src/binder/symbols.rs | 5 +- crates/tsv_check/src/diag.rs | 37 +- crates/tsv_check/src/lib.rs | 8 +- crates/tsv_check/src/merge.rs | 210 +++++++-- crates/tsv_check/src/program.rs | 56 ++- crates/tsv_check/tests/lib_base.rs | 39 +- .../src/cli/commands/tsc_conformance.rs | 295 +++++++++++-- .../src/tsc_conformance/directives.rs | 5 +- crates/tsv_debug/src/tsc_conformance/index.rs | 42 +- crates/tsv_debug/src/tsc_conformance/libs.rs | 59 ++- crates/tsv_debug/src/tsc_conformance/mod.rs | 2 +- .../src/tsc_conformance/options_meta.rs | 402 +++++++++++++++--- .../tsv_debug/src/tsc_conformance/pretty.rs | 109 ++++- .../tsv_debug/src/tsc_conformance/runner.rs | 275 +++++++++--- .../tsv_debug/src/tsc_conformance/variants.rs | 42 +- 18 files changed, 1666 insertions(+), 413 deletions(-) diff --git a/crates/tsv_check/src/binder/atoms.rs b/crates/tsv_check/src/binder/atoms.rs index d3ebc0b6b..6c9c19552 100644 --- a/crates/tsv_check/src/binder/atoms.rs +++ b/crates/tsv_check/src/binder/atoms.rs @@ -44,7 +44,11 @@ impl Atoms { let mut interner = StringInterner::>::new(); let default = Atom(interner.get_or_intern("default").to_usize() as u32); let export_equals = Atom(interner.get_or_intern("export=").to_usize() as u32); - Atoms { interner, default, export_equals } + Atoms { + interner, + default, + export_equals, + } } /// Intern a name to its [`Atom`]. diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 914d2f699..962824a7e 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -34,8 +34,8 @@ // a recorded deviation with identical results) mod atoms; -pub mod symbols; mod sym; +pub mod symbols; use crate::diag::Diagnostic; use crate::hash::FxHashMap; @@ -212,7 +212,8 @@ fn stmt_contains_import_meta(stmt: &Statement<'_>) -> bool { || 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.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) @@ -235,12 +236,10 @@ fn stmt_contains_import_meta(stmt: &Statement<'_>) -> bool { } S::TryStatement(s) => { s.block.body.iter().any(stmt_contains_import_meta) - || s - .handler + || s.handler .as_ref() .is_some_and(|h| h.body.body.iter().any(stmt_contains_import_meta)) - || s - .finalizer + || s.finalizer .as_ref() .is_some_and(|f| f.body.iter().any(stmt_contains_import_meta)) } @@ -310,7 +309,9 @@ pub fn bind_file<'arena>( } walk.close(root); - let facts = FileFacts { module_ness: module_ness(program) }; + let facts = FileFacts { + module_ness: module_ness(program), + }; // Pass 2: the symbol bind (functions-first, container-threaded). let (diagnostics, merge) = { @@ -353,7 +354,13 @@ struct SoaWalk { 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 { + 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); @@ -373,7 +380,12 @@ impl SoaWalk { /// Visit a statement: assign its id, then descend into the declarations and /// nested statements the SoA walk tracks. fn visit_statement(&mut self, stmt: &Statement<'_>, parent: NodeId) { - let id = self.add(statement_kind(stmt), stmt.span(), Some(parent), addr_of(stmt)); + let id = self.add( + statement_kind(stmt), + stmt.span(), + Some(parent), + addr_of(stmt), + ); match stmt { Statement::VariableDeclaration(decl) => self.visit_declarators(decl, id), Statement::FunctionDeclaration(func) => { @@ -458,7 +470,12 @@ impl SoaWalk { } fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) { - let id = self.add(NodeKind::VariableDeclaration, decl.span, Some(parent), addr_of(decl)); + let id = self.add( + NodeKind::VariableDeclaration, + decl.span, + Some(parent), + addr_of(decl), + ); self.visit_declarators(decl, id); self.close(id); } @@ -489,7 +506,12 @@ impl SoaWalk { } fn visit_identifier(&mut self, ident: &tsv_ts::ast::Identifier<'_>, parent: NodeId) { - let id = self.add(NodeKind::Identifier, ident.span, Some(parent), addr_of(ident)); + let id = self.add( + NodeKind::Identifier, + ident.span, + Some(parent), + addr_of(ident), + ); self.close(id); } } @@ -618,16 +640,25 @@ mod tests { 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]); + 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]); + 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]); + 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]); } @@ -642,7 +673,10 @@ mod tests { #[test] fn cascade_multiple_default_exports_is_2528() { - assert_eq!(diag_codes("export default 0; export default 1;"), vec![2528, 2528]); + assert_eq!( + diag_codes("export default 0; export default 1;"), + vec![2528, 2528] + ); } #[test] @@ -652,7 +686,10 @@ mod tests { 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]); + assert_eq!( + diag_codes("namespace M { const v = 1; } let M;"), + vec![2300, 2300] + ); } #[test] @@ -663,10 +700,16 @@ mod tests { // 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(); + 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]); + 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], "#"); } @@ -677,20 +720,40 @@ mod tests { // `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()); + 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("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); + 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); + assert_eq!( + bind("const u = import.meta.url;").facts.module_ness, + ModuleNess::Module + ); } } diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index f49e95c64..d94b5fa12 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -61,20 +61,20 @@ use super::atoms::{Atom, Atoms}; use super::symbols::{Decl, Symbol, SymbolFlags, SymbolId, TableId}; -use super::{addr_of, FileFacts}; +use super::{FileFacts, addr_of}; use crate::diag::{Category, Diagnostic}; use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; use string_interner::DefaultStringInterner; use tsv_lang::Span; +use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ - ClassBody, ClassMember, Expression, ExportDefaultValue, ExportSpecifier, ForInOfLeft, ForInit, + ClassBody, ClassMember, ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, Identifier, ImportSpecifier, Literal, LiteralValue, MethodKind, ModuleExportName, ObjectPatternProperty, Statement, TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSTypeElement, TSTypeParameterDeclaration, }; -use tsv_ts::ast::Program; /// The container kinds that route member declarations (a subset of tsgo's node /// kinds, enough to dispatch `declareSymbolAndAddToSymbolTable`). @@ -319,7 +319,10 @@ impl<'a> SymbolBinder<'a> { } fn node_id_of(&self, node: &T) -> NodeId { - self.address_map.get(&addr_of(node)).copied().unwrap_or(NodeId::FIRST) + self.address_map + .get(&addr_of(node)) + .copied() + .unwrap_or(NodeId::FIRST) } // --- name resolution ----------------------------------------------------- @@ -378,9 +381,12 @@ impl<'a> SymbolBinder<'a> { // 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) + && (sflags.0 & SymbolFlags::ACCESSOR.0) + != (includes.0 & SymbolFlags::ACCESSOR.0) { - self.symbols[sid.index()].flags.insert(SymbolFlags::ACCESSOR); + 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 @@ -414,8 +420,11 @@ impl<'a> SymbolBinder<'a> { /// 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 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; @@ -431,13 +440,20 @@ impl<'a> SymbolBinder<'a> { } let new_span = decl.error_span; - let new_name = if needs_name { Some(self.atoms.resolve(decl.display).to_string()) } else { None }; + 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 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 }; @@ -505,7 +521,10 @@ impl<'a> SymbolBinder<'a> { self.declare_symbol(table, Some(sym), decl, includes, excludes) } ContainerKind::Interface => { - let sym = self.container.symbol.expect("members container has a symbol"); + 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) } @@ -567,7 +586,10 @@ impl<'a> SymbolBinder<'a> { 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"); + 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(); @@ -589,7 +611,11 @@ impl<'a> SymbolBinder<'a> { 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) }; + let table = if is_static { + self.exports_of(sym) + } else { + self.members_of(sym) + }; self.declare_symbol(table, Some(sym), decl, includes, excludes) } @@ -623,11 +649,20 @@ impl<'a> SymbolBinder<'a> { } Statement::ExportNamedDeclaration(e) => { if let Some(inner) = e.declaration { - self.declare_hoisted_function_inner(inner, DeclMods { exported: true, default: false }); + self.declare_hoisted_function_inner( + inner, + DeclMods { + exported: true, + default: false, + }, + ); } } Statement::ExportDefaultDeclaration(e) => { - let mods = DeclMods { exported: true, default: true }; + let mods = DeclMods { + exported: true, + default: true, + }; match &e.declaration { ExportDefaultValue::FunctionDeclaration(f) => { self.bind_default_function(f.id.as_ref(), e.span, mods); @@ -690,7 +725,11 @@ impl<'a> SymbolBinder<'a> { } 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.declare_block_scoped( + d, + SymbolFlags::CLASS, + SymbolFlags::CLASS_EXCLUDES, + ) }) }; self.bind_class_body(&c.body, sym, c.type_parameters.as_ref()); @@ -708,7 +747,10 @@ impl<'a> SymbolBinder<'a> { let (inc, exc) = if e.r#const { (SymbolFlags::CONST_ENUM, SymbolFlags::CONST_ENUM_EXCLUDES) } else { - (SymbolFlags::REGULAR_ENUM, SymbolFlags::REGULAR_ENUM_EXCLUDES) + ( + 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); @@ -725,7 +767,11 @@ impl<'a> SymbolBinder<'a> { // (`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.declare_block_scoped( + d, + SymbolFlags::TYPE_ALIAS, + SymbolFlags::TYPE_ALIAS_EXCLUDES, + ); self.bind_type_params_in_new_locals(t.type_parameters.as_ref()); } Statement::ImportDeclaration(imp) => { @@ -734,7 +780,14 @@ impl<'a> SymbolBinder<'a> { } } Statement::TSImportEqualsDeclaration(ie) => { - let d = self.decl_from_ident(&ie.id, ie.span, DeclMods { exported: ie.is_export, default: false }); + 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; @@ -742,7 +795,14 @@ impl<'a> SymbolBinder<'a> { } Statement::ExportNamedDeclaration(e) => { if let Some(inner) = e.declaration { - self.visit_statement(inner, DeclMods { exported: true, default: false }, skip_symbol); + self.visit_statement( + inner, + DeclMods { + exported: true, + default: false, + }, + skip_symbol, + ); } else { for spec in e.specifiers { self.bind_export_specifier(spec); @@ -751,7 +811,9 @@ impl<'a> SymbolBinder<'a> { } 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::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); @@ -861,7 +923,13 @@ impl<'a> SymbolBinder<'a> { node: self.node_id_of(ea), }; let table = self.exports_of(sym); - self.declare_symbol(table, Some(sym), d, SymbolFlags::PROPERTY, SymbolFlags::ALL); + self.declare_symbol( + table, + Some(sym), + d, + SymbolFlags::PROPERTY, + SymbolFlags::ALL, + ); } self.visit_expression(&ea.expression); } @@ -877,7 +945,14 @@ impl<'a> SymbolBinder<'a> { 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); + self.bind_binding( + &d.id, + includes, + excludes, + block_scoped, + DeclMods::default(), + decl.span, + ); if let Some(init) = &d.init { self.visit_expression(init); } @@ -898,7 +973,10 @@ impl<'a> SymbolBinder<'a> { e: &tsv_ts::ast::internal::ExportDefaultDeclaration<'a>, skip_symbol: bool, ) { - let mods = DeclMods { exported: true, default: true }; + let mods = DeclMods { + exported: true, + default: true, + }; match &e.declaration { ExportDefaultValue::Expression(expr) => { // tsgo `bindExportAssignment` (non-`export =`): excludes = ALL. An @@ -912,7 +990,11 @@ impl<'a> SymbolBinder<'a> { expr, Expression::Identifier(_) | Expression::MemberExpression(_) ); - let flags = if is_alias { SymbolFlags::ALIAS } else { SymbolFlags::PROPERTY }; + 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. @@ -951,20 +1033,29 @@ impl<'a> SymbolBinder<'a> { } 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) - }); + 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.declare_symbol( + table, + Some(cs), + d, + SymbolFlags::INTERFACE, + SymbolFlags::INTERFACE_EXCLUDES, + ); } self.bind_interface_body_symbol_less(&i.body, i.type_parameters.as_ref()); } @@ -990,11 +1081,22 @@ impl<'a> SymbolBinder<'a> { } } - fn bind_default_function(&mut self, id: Option<&Identifier<'a>>, node_span: Span, _mods: DeclMods) { + 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); + self.declare_symbol( + table, + Some(cs), + d, + SymbolFlags::FUNCTION, + SymbolFlags::FUNCTION_EXCLUDES, + ); } } @@ -1092,10 +1194,24 @@ impl<'a> SymbolBinder<'a> { for prop in p.properties { match prop { ObjectPatternProperty::Property(pr) => { - self.bind_binding(&pr.value, includes, excludes, block_scoped, mods, pr.span); + 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); + self.bind_binding( + r.argument, + includes, + excludes, + block_scoped, + mods, + r.span, + ); } } } @@ -1116,7 +1232,12 @@ impl<'a> SymbolBinder<'a> { } } - fn decl_from_ident(&mut self, id: &Identifier<'a>, _node_span: Span, mods: DeclMods) -> DeclInput { + fn decl_from_ident( + &mut self, + id: &Identifier<'a>, + _node_span: Span, + mods: DeclMods, + ) -> DeclInput { let name = self.ident_atom(id); DeclInput { name, @@ -1153,10 +1274,7 @@ impl<'a> SymbolBinder<'a> { 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, - ); + 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); @@ -1184,10 +1302,20 @@ impl<'a> SymbolBinder<'a> { 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::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 }; + let opt = if m.optional { + SymbolFlags::OPTIONAL + } else { + SymbolFlags::NONE + }; (SymbolFlags::METHOD.union(opt), SymbolFlags::METHOD_EXCLUDES) } }; @@ -1207,7 +1335,9 @@ impl<'a> SymbolBinder<'a> { 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)) { + } 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, @@ -1240,7 +1370,10 @@ impl<'a> SymbolBinder<'a> { } else { SymbolFlags::NONE }; - (SymbolFlags::PROPERTY.union(opt), SymbolFlags::PROPERTY_EXCLUDES) + ( + 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 { @@ -1273,7 +1406,11 @@ impl<'a> SymbolBinder<'a> { 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 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( @@ -1348,12 +1485,20 @@ impl<'a> SymbolBinder<'a> { fn bind_type_element(&mut self, element: &TSTypeElement<'a>) { let (key_expr, computed, span, inc, exc) = match element { - TSTypeElement::PropertySignature(p) => { - (&p.key, p.computed, p.span, SymbolFlags::PROPERTY, SymbolFlags::PROPERTY_EXCLUDES) - } - TSTypeElement::MethodSignature(m) => { - (&m.key, m.computed, m.span, SymbolFlags::METHOD, SymbolFlags::METHOD_EXCLUDES) - } + TSTypeElement::PropertySignature(p) => ( + &p.key, + p.computed, + p.span, + SymbolFlags::PROPERTY, + SymbolFlags::PROPERTY_EXCLUDES, + ), + TSTypeElement::MethodSignature(m) => ( + &m.key, + m.computed, + m.span, + SymbolFlags::METHOD, + SymbolFlags::METHOD_EXCLUDES, + ), // Call/construct/index signatures are anonymous (Signature, no conflict). TSTypeElement::CallSignature(_) | TSTypeElement::ConstructSignature(_) @@ -1374,7 +1519,11 @@ impl<'a> SymbolBinder<'a> { } } - fn bind_enum_members(&mut self, members: &[tsv_ts::ast::internal::TSEnumMember<'a>], enum_symbol: SymbolId) { + 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, @@ -1399,7 +1548,11 @@ impl<'a> SymbolBinder<'a> { exported: false, node: NodeId::FIRST, }; - self.declare_in_container(d, SymbolFlags::ENUM_MEMBER, SymbolFlags::ENUM_MEMBER_EXCLUDES); + self.declare_in_container( + d, + SymbolFlags::ENUM_MEMBER, + SymbolFlags::ENUM_MEMBER_EXCLUDES, + ); if let Some(init) = &member.initializer { self.visit_expression(init); } @@ -1434,9 +1587,15 @@ impl<'a> SymbolBinder<'a> { // 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) + ( + SymbolFlags::VALUE_MODULE, + SymbolFlags::VALUE_MODULE_EXCLUDES, + ) } else { - (SymbolFlags::NAMESPACE_MODULE, SymbolFlags::NAMESPACE_MODULE_EXCLUDES) + ( + SymbolFlags::NAMESPACE_MODULE, + SymbolFlags::NAMESPACE_MODULE_EXCLUDES, + ) }; let sym = self.declare_block_scoped(d, inc, exc); @@ -1514,7 +1673,9 @@ impl<'a> SymbolBinder<'a> { /// aliases route to `exports`, others to `locals`. fn declare_alias(&mut self, decl: DeclInput, to_exports: bool) { match self.container.kind { - ContainerKind::Module | ContainerKind::SourceFile if self.container.symbol.is_some() => { + ContainerKind::Module | ContainerKind::SourceFile + if self.container.symbol.is_some() => + { if to_exports { let sym = self.container.symbol.unwrap(); let mut d = decl; @@ -1522,15 +1683,33 @@ impl<'a> SymbolBinder<'a> { d.name = self.atoms.default_export(); } let table = self.exports_of(sym); - self.declare_symbol(table, Some(sym), d, SymbolFlags::ALIAS, SymbolFlags::ALIAS_EXCLUDES); + 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); + 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); + self.declare_symbol( + table, + None, + decl, + SymbolFlags::ALIAS, + SymbolFlags::ALIAS_EXCLUDES, + ); } } } @@ -1542,12 +1721,19 @@ impl<'a> SymbolBinder<'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); + self.declare_in_container( + d, + SymbolFlags::TYPE_PARAMETER, + SymbolFlags::TYPE_PARAMETER_EXCLUDES, + ); } } } - fn bind_type_params_in_new_locals(&mut self, type_params: Option<&TSTypeParameterDeclaration<'a>>) { + fn bind_type_params_in_new_locals( + &mut self, + type_params: Option<&TSTypeParameterDeclaration<'a>>, + ) { if type_params.is_none() { return; } @@ -1569,7 +1755,9 @@ impl<'a> SymbolBinder<'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::Expression(e) => { + b.visit_expression(e) + } tsv_ts::ast::internal::ArrowFunctionBody::BlockStatement(block) => { b.bind_statement_list(block.body, true); } @@ -1669,10 +1857,15 @@ impl<'a> SymbolBinder<'a> { 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(_)) => + Expression::Literal(lit) + if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) => { let a = self.string_atom(lit); - Some(KeyInfo { key: a, display: a, span: lit.span }) + Some(KeyInfo { + key: a, + display: a, + span: lit.span, + }) } _ => None, }; @@ -1680,27 +1873,35 @@ impl<'a> SymbolBinder<'a> { match key { Expression::Identifier(id) => { let a = self.ident_atom(id); - Some(KeyInfo { key: a, display: a, span: id.name_span() }) + 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 }) + Some(KeyInfo { + key: a, + display: a, + span: lit.span, + }) } Expression::PrivateIdentifier(pid) => { let raw = pid.name(self.source, self.interner); let display = self.atoms.intern(raw); // Mangle with the class symbol id so same-name privates in one // class collide (tsgo GetSymbolNameForPrivateIdentifier). - let mangled = format!( - "\u{FE}#{}@{}", - class_symbol.map_or(0, |s| s.0), - raw - ); + 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 `#`. - Some(KeyInfo { key, display, span: pid.span }) + Some(KeyInfo { + key, + display, + span: pid.span, + }) } _ => None, } @@ -1717,7 +1918,9 @@ struct KeyInfo { /// 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) { +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). @@ -1755,7 +1958,10 @@ 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(_)) + matches!( + inner, + Statement::FunctionDeclaration(_) | Statement::TSDeclareFunction(_) + ) }), Statement::ExportDefaultDeclaration(e) => matches!( e.declaration, @@ -1833,10 +2039,12 @@ fn statement_is_non_instantiated(stmt: &Statement<'_>) -> bool { 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() - } + 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(), diff --git a/crates/tsv_check/src/binder/symbols.rs b/crates/tsv_check/src/binder/symbols.rs index 5e5b21868..05c5affe4 100644 --- a/crates/tsv_check/src/binder/symbols.rs +++ b/crates/tsv_check/src/binder/symbols.rs @@ -127,9 +127,8 @@ impl SymbolFlags { 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 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), diff --git a/crates/tsv_check/src/diag.rs b/crates/tsv_check/src/diag.rs index fdf5d9112..63a667afd 100644 --- a/crates/tsv_check/src/diag.rs +++ b/crates/tsv_check/src/diag.rs @@ -172,9 +172,9 @@ pub fn equal_no_related_info(a: &Diagnostic, b: &Diagnostic, paths: &[String]) - /// **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) - }) + && 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 @@ -183,7 +183,10 @@ fn equal_chain(a: &[Diagnostic], b: &[Diagnostic]) -> bool { pub fn equal_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[String]) -> 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)) + && 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 @@ -328,8 +331,14 @@ mod tests { #[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"])]); + 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); } @@ -363,7 +372,11 @@ mod tests { #[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)]; + 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); @@ -408,13 +421,19 @@ mod tests { // 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 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 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. diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index e6f6e3fb9..ee77d4d29 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -50,15 +50,13 @@ pub mod diag; pub mod ids; pub mod merge; -pub use binder::{ - bind_file, module_ness, BoundFile, FileFacts, ModuleNess, NodeKind, -}; +pub use binder::{BoundFile, FileFacts, ModuleNess, NodeKind, bind_file, module_ness}; pub use diag::{Category, Diagnostic}; pub use ids::{FileId, NodeId}; pub use merge::{LibBase, LibFile}; pub use program::{ - bind_lib, bind_program, check_bound, check_program, check_program_with_lib, BoundProgram, - CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, + 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 diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 61ee064ef..15d7dc70f 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -133,7 +133,10 @@ impl LibBase { // `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() }, + LibEntry { + flags: MODULE_FLAGS, + decls: Vec::new(), + }, ); for (index, lib) in libs.iter().enumerate() { let lib_file = index as u32; @@ -149,7 +152,10 @@ impl LibBase { } } } - LibBase { lib_files: libs.iter().map(|l| l.name.clone()).collect(), globals } + LibBase { + lib_files: libs.iter().map(|l| l.name.clone()).collect(), + globals, + } } /// Look up a global by name. @@ -167,9 +173,10 @@ impl LibBase { /// 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() }); + 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 { @@ -300,7 +307,11 @@ pub fn merge_program( 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) { + 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)); } @@ -486,7 +497,12 @@ fn merge_symbol(target: &mut GlobalEntry, source: &MergeSymbol, out: &mut MergeO if target.name != NAME_GLOBAL_THIS && let Some(decl) = source.decls.first() { - out.push(augment_error(decl.file, decl.error_span, 2649, &target.name)); + out.push(augment_error( + decl.file, + decl.error_span, + 2649, + &target.name, + )); } } else { report_merge_symbol_error(target, source, out); @@ -546,7 +562,11 @@ fn add_duplicate_declaration_error( out: &mut MergeOut, ) { let needs_name = code != 2567; - let args = if needs_name { vec![symbol_name.to_string()] } else { Vec::new() }; + 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, @@ -641,25 +661,77 @@ fn excluded_symbol_flags(flags: SymbolFlags) -> SymbolFlags { *result = result.union(mask); } }; - add(&mut result, SymbolFlags::BLOCK_SCOPED_VARIABLE, SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES); + 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::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::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) { @@ -676,16 +748,21 @@ fn message_for(code: u32, name: Option<&str>) -> String { "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() - } + 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("")) + 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.", @@ -745,7 +822,11 @@ mod tests { 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)); + 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(); @@ -767,7 +848,10 @@ mod tests { ) }; let diags = merge_program(&[mk(0), mk(1)], None, 0); - assert_eq!(diags.iter().map(|d| d.code).collect::>(), vec![2300, 2300]); + 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). @@ -790,7 +874,10 @@ mod tests { }], ); let diags = merge_program(&[a, b], None, 0); - assert_eq!(diags.iter().map(|d| d.code).collect::>(), vec![2567, 2567]); + 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())); } @@ -856,7 +943,10 @@ mod tests { // 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"); + 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)); } @@ -896,8 +986,10 @@ mod tests { let diags = merge_program(&files, 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"); + 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. @@ -931,7 +1023,7 @@ mod tests { name: "undefined".to_string(), flags: SymbolFlags::CLASS.union(SymbolFlags::VALUE_MODULE), decls: vec![ - decl(0, 6, "undefined", true), // class + decl(0, 6, "undefined", true), // class decl(0, 40, "undefined", false), // namespace ], }], @@ -952,8 +1044,16 @@ mod tests { 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) }, + 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); @@ -1013,10 +1113,18 @@ mod tests { 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 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)], + vec![lib_symbol( + "Symbol", + SymbolFlags::FUNCTION_SCOPED_VARIABLE, + 20, + false, + )], ); let wk = lib( "lib.es2015.symbol.wellknown.d.ts", @@ -1052,16 +1160,26 @@ mod tests { 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))]); + 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)); + 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 array = lib( + "lib.es5.d.ts", + vec![lib_symbol("Array", SymbolFlags::INTERFACE, 10, true)], + ); let base = LibBase::build(&[&array]); let test = script( 0, @@ -1098,7 +1216,12 @@ mod tests { 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)], + vec![lib_symbol( + "ElementTagNameMap", + SymbolFlags::TYPE_ALIAS, + 10, + true, + )], ); let base = LibBase::build(&[&dom]); // An external module carrying a `declare global { interface ElementTagNameMap }`. @@ -1114,7 +1237,10 @@ mod tests { 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"); + 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); diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index c0481453d..63ec166d9 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -33,13 +33,13 @@ // The product-mode short-circuit lives at GetDiagnosticsOfAnyProgram // (program.go:1755, :1770) and is deliberately NOT ported here. -use crate::binder::{bind_file, module_ness, ModuleNess}; -use crate::diag::{sort_and_deduplicate, Diagnostic}; +use crate::binder::{ModuleNess, bind_file, module_ness}; +use crate::diag::{Diagnostic, sort_and_deduplicate}; use crate::ids::FileId; -use crate::merge::{merge_program, FileMerge, LibBase, LibFile}; +use crate::merge::{FileMerge, LibBase, LibFile, merge_program}; use bumpalo::Bump; use tsv_ts::ast::Program; -use tsv_ts::{parse_with_goal, Goal}; +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> { @@ -141,7 +141,10 @@ impl BoundProgram { /// 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() + self.units + .iter() + .map(|u| (u.name.as_str(), &u.parse)) + .collect() } } @@ -192,7 +195,11 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr } } - BoundProgram { parse_rejected, units: bound_units, total_nodes } + BoundProgram { + parse_rejected, + units: bound_units, + total_nodes, + } } /// Merge a [`BoundProgram`] against an optional [`LibBase`] and return the final @@ -223,9 +230,17 @@ pub fn check_bound(bound: &BoundProgram, lib: Option<&LibBase>) -> CheckResult { let files = bound .units .iter() - .map(|u| FileReport { file: u.file, name: u.name.clone(), parse: u.parse.clone() }) + .map(|u| FileReport { + file: u.file, + name: u.name.clone(), + parse: u.parse.clone(), + }) .collect(); - CheckResult { diagnostics, files, parse_rejected: bound.parse_rejected } + CheckResult { + diagnostics, + files, + parse_rejected: bound.parse_rejected, + } } /// Check a program with no lib base — parse every unit via the goal rule, bind, @@ -267,7 +282,10 @@ pub fn bind_lib(name: &str, source: &str) -> Result { !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 }) + Ok(LibFile { + name: name.to_string(), + merge: bound.merge, + }) } /// Check one bound file — a no-op skeleton (no semantic diagnostics yet). @@ -281,10 +299,7 @@ fn check_file(bound: &crate::binder::BoundFile) -> Vec { /// 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> { +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) { @@ -327,7 +342,10 @@ mod tests { let result = check("const = = = ;"); assert!(result.parse_rejected); assert!(result.diagnostics.is_empty()); - assert!(matches!(result.files[0].parse, ParseReport::Rejected { .. })); + assert!(matches!( + result.files[0].parse, + ParseReport::Rejected { .. } + )); } #[test] @@ -350,7 +368,10 @@ mod tests { // 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 = ;")], + &[ + SourceUnit::new("a.ts", "let x; let x;"), + SourceUnit::new("b.ts", "const = ;"), + ], &arena, ); assert!(result.parse_rejected); @@ -358,6 +379,9 @@ mod tests { 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 { .. })); + assert!(matches!( + result.files[1].parse, + ParseReport::Rejected { .. } + )); } } diff --git a/crates/tsv_check/tests/lib_base.rs b/crates/tsv_check/tests/lib_base.rs index 945d82525..af803aa7f 100644 --- a/crates/tsv_check/tests/lib_base.rs +++ b/crates/tsv_check/tests/lib_base.rs @@ -7,13 +7,14 @@ //! the same bind path the harness uses. use bumpalo::Bump; -use tsv_check::{bind_lib, check_program_with_lib, FileId, LibBase, SourceUnit}; +use tsv_check::{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 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(); @@ -32,7 +33,12 @@ fn var_eval_conflicts_with_lib_function_eval() { 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)); + 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, @@ -43,12 +49,18 @@ fn class_promise_conflicts_across_lib_files() { // (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 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 units = [SourceUnit::new( + "promiseDefinitionTest.ts", + "class Promise {}", + )]; let result = check_program_with_lib(&units, Some(&base), &arena); let primary = result @@ -60,7 +72,10 @@ fn class_promise_conflicts_across_lib_files() { 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))]); + assert_eq!( + files, + vec![Some(FileId(1)), Some(FileId(2)), Some(FileId(3))] + ); } /// A clean augmentation (`interface Array {}` merging into the lib's `Array`) @@ -75,9 +90,15 @@ fn interface_augmentation_of_lib_is_silent() { let base = LibBase::build(&[&es5]); let arena = Bump::new(); - let units = [SourceUnit::new("t.ts", "interface Array { extra(): void; }")]; + let units = [SourceUnit::new( + "t.ts", + "interface Array { extra(): void; }", + )]; let result = check_program_with_lib(&units, Some(&base), &arena); - assert!(result.diagnostics.is_empty(), "a legal interface merge must be silent"); + 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) — diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index ccb959492..68c65c4f3 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -8,8 +8,8 @@ use crate::cli::CliError; use crate::tsc_conformance::index::IndexReport; use crate::tsc_conformance::runner::SkeletonReport; use crate::tsc_conformance::{ - baselines_dir, check_one, corpus_materialized, denominators, discover_baselines, histogram, - run_index, run_roundtrip, run_skeleton, tests_by_code, RunFilter, RunOptions, + RunFilter, RunOptions, baselines_dir, check_one, corpus_materialized, denominators, + discover_baselines, histogram, run_index, run_roundtrip, run_skeleton, tests_by_code, }; use argh::FromArgs; use std::path::{Path, PathBuf}; @@ -318,7 +318,10 @@ impl RunCommand { return Err(CliError::Failed); } - let options = RunOptions { filter, collect_manifest: self.emit_manifest.is_some() }; + let options = RunOptions { + filter, + collect_manifest: self.emit_manifest.is_some(), + }; let report = run_skeleton(&self.path, &options).map_err(|e| { eprintln!("Error running skeleton sweep: {e}"); CliError::Failed @@ -361,7 +364,11 @@ impl RunCommand { } None => None, }; - Ok(RunFilter { test: self.test.clone(), code: self.code, variant }) + Ok(RunFilter { + test: self.test.clone(), + code: self.code, + variant, + }) } } @@ -434,12 +441,42 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), errs.push(format!("{label} {got} != pinned {want}")); } }; - pin(&mut errs, "in-scope tests", report.in_scope_tests, RUN_IN_SCOPE_TESTS_PIN); - pin(&mut errs, "in-scope variants", report.in_scope_variants, RUN_IN_SCOPE_VARIANTS_PIN); - pin(&mut errs, "expect-clean graded", report.expect_clean_graded, RUN_EXPECT_CLEAN_PIN); - pin(&mut errs, "clean pass", report.clean_pass, RUN_EXPECT_CLEAN_PIN); - pin(&mut errs, "baselined parsed", report.baselined_parsed, RUN_BASELINED_PARSED_PIN); - pin(&mut errs, "parse-rejected", report.parse_rejected_total, RUN_PARSE_REJECTED_PIN); + pin( + &mut errs, + "in-scope tests", + report.in_scope_tests, + RUN_IN_SCOPE_TESTS_PIN, + ); + pin( + &mut errs, + "in-scope variants", + report.in_scope_variants, + RUN_IN_SCOPE_VARIANTS_PIN, + ); + pin( + &mut errs, + "expect-clean graded", + report.expect_clean_graded, + RUN_EXPECT_CLEAN_PIN, + ); + pin( + &mut errs, + "clean pass", + report.clean_pass, + RUN_EXPECT_CLEAN_PIN, + ); + pin( + &mut errs, + "baselined parsed", + report.baselined_parsed, + RUN_BASELINED_PARSED_PIN, + ); + pin( + &mut errs, + "parse-rejected", + report.parse_rejected_total, + RUN_PARSE_REJECTED_PIN, + ); pin( &mut errs, "parse-rejected (no baseline)", @@ -458,21 +495,76 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), report.parse_rejected_other, RUN_PARSE_REJECTED_OTHER_PIN, ); - pin(&mut errs, "script retries", report.script_retry, RUN_SCRIPT_RETRY_PIN); - pin(&mut errs, "crash-excluded", report.excluded_crashes, RUN_CRASH_EXCLUDED_PIN); + pin( + &mut errs, + "script retries", + report.script_retry, + RUN_SCRIPT_RETRY_PIN, + ); + pin( + &mut errs, + "crash-excluded", + report.excluded_crashes, + RUN_CRASH_EXCLUDED_PIN, + ); // Lib-base (S5) sizing pins. - pin(&mut errs, "lib files bound", report.lib_files_bound, RUN_LIB_FILES_BOUND_PIN); - pin(&mut errs, "lib sets folded", report.lib_sets_built, RUN_LIB_SETS_PIN); + pin( + &mut errs, + "lib files bound", + report.lib_files_bound, + RUN_LIB_FILES_BOUND_PIN, + ); + pin( + &mut errs, + "lib sets folded", + report.lib_sets_built, + RUN_LIB_SETS_PIN, + ); // Family grading pins. - pin(&mut errs, "family graded", report.family_graded_variants, RUN_FAMILY_GRADED_PIN); - pin(&mut errs, "family positive", report.family_positive_variants, RUN_FAMILY_POSITIVE_PIN); - pin(&mut errs, "family match", report.family_match, RUN_FAMILY_MATCH_PIN); - pin(&mut errs, "family missing", report.family_missing, RUN_FAMILY_MISSING_PIN); - pin(&mut errs, "missing merge", report.missing_merge, RUN_MISSING_MERGE_PIN); - pin(&mut errs, "missing lib", report.missing_lib, RUN_MISSING_LIB_PIN); - pin(&mut errs, "missing check-time", report.missing_other, RUN_MISSING_CHECKTIME_PIN); + pin( + &mut errs, + "family graded", + report.family_graded_variants, + RUN_FAMILY_GRADED_PIN, + ); + pin( + &mut errs, + "family positive", + report.family_positive_variants, + RUN_FAMILY_POSITIVE_PIN, + ); + pin( + &mut errs, + "family match", + report.family_match, + RUN_FAMILY_MATCH_PIN, + ); + pin( + &mut errs, + "family missing", + report.family_missing, + RUN_FAMILY_MISSING_PIN, + ); + pin( + &mut errs, + "missing merge", + report.missing_merge, + RUN_MISSING_MERGE_PIN, + ); + pin( + &mut errs, + "missing lib", + report.missing_lib, + RUN_MISSING_LIB_PIN, + ); + pin( + &mut errs, + "missing check-time", + report.missing_other, + RUN_MISSING_CHECKTIME_PIN, + ); pin( &mut errs, "family span_mismatch", @@ -481,9 +573,24 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), ); // Related-info channel pins (two-sided; does not gate the primary verdict). - pin(&mut errs, "related match", report.related_match, RUN_RELATED_MATCH_PIN); - pin(&mut errs, "related missing", report.related_missing, RUN_RELATED_MISSING_PIN); - pin(&mut errs, "related extra", report.related_extra, RUN_RELATED_EXTRA_PIN); + pin( + &mut errs, + "related match", + report.related_match, + RUN_RELATED_MATCH_PIN, + ); + pin( + &mut errs, + "related missing", + report.related_missing, + RUN_RELATED_MISSING_PIN, + ); + pin( + &mut errs, + "related extra", + report.related_extra, + RUN_RELATED_EXTRA_PIN, + ); pin( &mut errs, "related span_mismatch", @@ -491,7 +598,12 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), RUN_RELATED_SPAN_MISMATCH_PIN, ); - pin(&mut errs, "carve-out rule (a)", report.carve_out_rule_a, RUN_CARVE_OUT_RULE_A_PIN); + pin( + &mut errs, + "carve-out rule (a)", + report.carve_out_rule_a, + RUN_CARVE_OUT_RULE_A_PIN, + ); pin( &mut errs, "carve-out rule (a) family", @@ -591,7 +703,10 @@ struct RunManifest<'a> { /// Write the `--emit-manifest` JSON (per-variant verdicts + buckets + census + pins). /// Called only after the gates pass, so a bad manifest never lands. fn write_manifest(report: &SkeletonReport, path: &Path) -> Result<(), CliError> { - let manifest = RunManifest { pins: run_pins(), report }; + let manifest = RunManifest { + pins: run_pins(), + report, + }; let file = std::fs::File::create(path).map_err(|e| { eprintln!("Error creating manifest {}: {e}", path.display()); CliError::Failed @@ -711,7 +826,11 @@ fn render_report_md(report: &SkeletonReport) -> String { .collect(); for code in codes { let m = report.family_match_by_code.get(&code).copied().unwrap_or(0); - let miss = report.family_missing_by_code.get(&code).copied().unwrap_or(0); + let miss = report + .family_missing_by_code + .get(&code) + .copied() + .unwrap_or(0); let _ = writeln!(s, "| TS{code} | {m} | {miss} |"); } s.push('\n'); @@ -748,7 +867,11 @@ fn render_report_md(report: &SkeletonReport) -> String { report.parse_rejected_other ); let _ = writeln!(s, "- script-goal retries: {}", report.script_retry); - let _ = writeln!(s, "- crash-excluded (tracked): {}\n", report.excluded_crashes); + let _ = writeln!( + s, + "- crash-excluded (tracked): {}\n", + report.excluded_crashes + ); s.push_str("## Lib base\n\n"); let _ = writeln!( @@ -779,7 +902,11 @@ fn write_report(report: &SkeletonReport, base: &Path) -> Result<(), CliError> { eprintln!("Error writing {}: {e}", md_path.display()); CliError::Failed })?; - println!("Wrote committed report to {} + {}", json_path.display(), md_path.display()); + println!( + "Wrote committed report to {} + {}", + json_path.display(), + md_path.display() + ); Ok(()) } @@ -800,7 +927,11 @@ fn write_diff_artifacts(report: &SkeletonReport) { dir.display() ); for fv in &report.failing_variants { - let path = dir.join(format!("{}__{}.diff", fv.suite, sanitize_artifact_name(&fv.config))); + let path = dir.join(format!( + "{}__{}.diff", + fv.suite, + sanitize_artifact_name(&fv.config) + )); match std::fs::write(&path, &fv.diff) { Ok(()) => eprintln!(" {} ({})", path.display(), fv.reason), Err(e) => eprintln!(" (failed to write {}: {e})", path.display()), @@ -811,7 +942,13 @@ fn write_diff_artifacts(report: &SkeletonReport) { /// Replace path-hostile characters so a baseline identity is a safe artifact basename. fn sanitize_artifact_name(name: &str) -> String { name.chars() - .map(|c| if c == '/' || c == '\\' || c.is_whitespace() { '_' } else { c }) + .map(|c| { + if c == '/' || c == '\\' || c.is_whitespace() { + '_' + } else { + c + } + }) .collect() } @@ -898,25 +1035,75 @@ fn enforce_index_pins(report: &IndexReport) -> Result<(), CliError> { }; // Denominators (gate 3). - pin(&mut errs, "total scanned", report.total_scanned, INDEX_TOTAL_SCANNED_PIN); + pin( + &mut errs, + "total scanned", + report.total_scanned, + INDEX_TOTAL_SCANNED_PIN, + ); pin(&mut errs, ".ts count", report.ts_count, INDEX_TS_PIN); pin(&mut errs, ".tsx count", report.tsx_count, INDEX_TSX_PIN); pin(&mut errs, ".js count", report.js_count, INDEX_JS_PIN); - pin(&mut errs, "skipped tests", report.skipped_tests, INDEX_SKIPPED_TESTS_PIN); - pin(&mut errs, "single-file", report.single_file, INDEX_SINGLE_FILE_PIN); - pin(&mut errs, "multi-file", report.multi_file, INDEX_MULTI_FILE_PIN); - pin(&mut errs, "jsx-scoped", report.jsx_scoped, INDEX_JSX_SCOPED_PIN); - pin(&mut errs, "js-flavored", report.js_flavored, INDEX_JS_FLAVORED_PIN); - pin(&mut errs, "pretty tests", report.pretty_tests, INDEX_PRETTY_TESTS_PIN); + pin( + &mut errs, + "skipped tests", + report.skipped_tests, + INDEX_SKIPPED_TESTS_PIN, + ); + pin( + &mut errs, + "single-file", + report.single_file, + INDEX_SINGLE_FILE_PIN, + ); + pin( + &mut errs, + "multi-file", + report.multi_file, + INDEX_MULTI_FILE_PIN, + ); + pin( + &mut errs, + "jsx-scoped", + report.jsx_scoped, + INDEX_JSX_SCOPED_PIN, + ); + pin( + &mut errs, + "js-flavored", + report.js_flavored, + INDEX_JS_FLAVORED_PIN, + ); + pin( + &mut errs, + "pretty tests", + report.pretty_tests, + INDEX_PRETTY_TESTS_PIN, + ); pin( &mut errs, "basename collisions", report.basename_collisions, INDEX_BASENAME_COLLISIONS_PIN, ); - pin(&mut errs, "cap-exceeded", report.cap_exceeded, INDEX_CAP_EXCEEDED_PIN); - pin(&mut errs, "unknown includes", report.unknown_includes, INDEX_UNKNOWN_INCLUDES_PIN); - pin(&mut errs, "variant total", report.variant_total, INDEX_VARIANT_TOTAL_PIN); + pin( + &mut errs, + "cap-exceeded", + report.cap_exceeded, + INDEX_CAP_EXCEEDED_PIN, + ); + pin( + &mut errs, + "unknown includes", + report.unknown_includes, + INDEX_UNKNOWN_INCLUDES_PIN, + ); + pin( + &mut errs, + "variant total", + report.variant_total, + INDEX_VARIANT_TOTAL_PIN, + ); pin( &mut errs, "skipped variants", @@ -929,11 +1116,26 @@ fn enforce_index_pins(report: &IndexReport) -> Result<(), CliError> { report.nonskip_variants, INDEX_NONSKIP_VARIANTS_PIN, ); - pin(&mut errs, "expect-clean", report.expect_clean, INDEX_EXPECT_CLEAN_PIN); + pin( + &mut errs, + "expect-clean", + report.expect_clean, + INDEX_EXPECT_CLEAN_PIN, + ); // Gate 1: baseline join. - pin(&mut errs, "baselines total", report.baselines_total, INDEX_JOIN_MATCHED_PIN); - pin(&mut errs, "join matched", report.join_matched, INDEX_JOIN_MATCHED_PIN); + pin( + &mut errs, + "baselines total", + report.baselines_total, + INDEX_JOIN_MATCHED_PIN, + ); + pin( + &mut errs, + "join matched", + report.join_matched, + INDEX_JOIN_MATCHED_PIN, + ); if !report.join_unmatched.is_empty() { errs.push(format!( "{} unmatched baseline(s), e.g. {}", @@ -945,7 +1147,10 @@ fn enforce_index_pins(report: &IndexReport) -> Result<(), CliError> { errs.push(format!( "{} baseline(s) map only to skipped variants, e.g. {}", report.join_skipped_with_baseline.len(), - report.join_skipped_with_baseline.first().map_or("", String::as_str) + report + .join_skipped_with_baseline + .first() + .map_or("", String::as_str) )); } if !report.join_ambiguous.is_empty() { diff --git a/crates/tsv_debug/src/tsc_conformance/directives.rs b/crates/tsv_debug/src/tsc_conformance/directives.rs index 43e8634c4..90a10b68a 100644 --- a/crates/tsv_debug/src/tsc_conformance/directives.rs +++ b/crates/tsv_debug/src/tsc_conformance/directives.rs @@ -533,7 +533,10 @@ mod tests { let mut settings = BTreeMap::new(); settings.insert("currentdirectory".to_string(), "/".to_string()); let cwd = harness_current_directory(&settings); - assert_eq!(section_display_name("/deps/dep/dep.d.ts", &cwd), "/deps/dep/dep.d.ts"); + assert_eq!( + section_display_name("/deps/dep/dep.d.ts", &cwd), + "/deps/dep/dep.d.ts" + ); assert_eq!(section_display_name("/app.ts", &cwd), "/app.ts"); } diff --git a/crates/tsv_debug/src/tsc_conformance/index.rs b/crates/tsv_debug/src/tsc_conformance/index.rs index 5cba5c56f..c21e34285 100644 --- a/crates/tsv_debug/src/tsc_conformance/index.rs +++ b/crates/tsv_debug/src/tsc_conformance/index.rs @@ -19,15 +19,15 @@ //! failure (it means the ported option universe is incomplete). use crate::tsc_conformance::corpus::{ - basename_collisions, discover_corpus, read_corpus_file, BasenameCollision, CorpusTest, + BasenameCollision, CorpusTest, basename_collisions, discover_corpus, read_corpus_file, }; use crate::tsc_conformance::directives::{ classify_units, extract_settings, harness_current_directory, section_display_name, split_units, }; -use crate::tsc_conformance::discovery::{discover_baselines, Baseline}; +use crate::tsc_conformance::discovery::{Baseline, discover_baselines}; use crate::tsc_conformance::options_meta::{ - is_known_directive, strict_members, variant_is_unsupported, DEFAULT_USE_CASE_SENSITIVE_FILE_NAMES, - HARNESS_FORCED_DEFAULTS, SKIPPED_TESTS, + DEFAULT_USE_CASE_SENSITIVE_FILE_NAMES, HARNESS_FORCED_DEFAULTS, SKIPPED_TESTS, + is_known_directive, strict_members, variant_is_unsupported, }; use crate::tsc_conformance::roundtrip::is_pretty; use crate::tsc_conformance::variants::{config_name, expand}; @@ -159,7 +159,8 @@ fn split_baseline_key(relative_path: &str) -> Option<(&str, &str)> { /// Run the corpus-input index over a typescript-go checkout. pub fn run_index(checkout: &Path) -> Result { let corpus = discover_corpus(checkout)?; - let baselines = discover_baselines(&crate::tsc_conformance::discovery::baselines_dir(checkout))?; + let baselines = + discover_baselines(&crate::tsc_conformance::discovery::baselines_dir(checkout))?; let collisions = basename_collisions(&corpus); let mut report = IndexReport { @@ -217,7 +218,9 @@ pub fn run_index(checkout: &Path) -> Result { for key in settings.keys() { if !is_known_directive(key) { - unknown.entry(key.clone()).or_insert_with(|| test.relative_path.clone()); + unknown + .entry(key.clone()) + .or_insert_with(|| test.relative_path.clone()); } } @@ -305,7 +308,9 @@ pub fn run_index(checkout: &Path) -> Result { Some(entries) => { let nonskip: Vec<&Derived> = entries.iter().filter(|d| !d.skipped).collect(); if nonskip.is_empty() { - report.join_skipped_with_baseline.push(baseline.relative_path.clone()); + report + .join_skipped_with_baseline + .push(baseline.relative_path.clone()); } else if nonskip.len() > 1 { report.join_ambiguous.push(baseline.relative_path.clone()); } else { @@ -376,7 +381,8 @@ fn check_unit_roundtrip(baseline: &Baseline, record: &TestRecord, report: &mut I let reason = if record.tsconfig_unresolved { // Body multiset only (FileNames ordering deferred). let mut unit_lines: Vec<&Vec> = record.units.iter().map(|(_, body)| body).collect(); - let mut section_lines: Vec<&Vec> = parsed.sections.iter().map(|s| &s.src_lines).collect(); + let mut section_lines: Vec<&Vec> = + parsed.sections.iter().map(|s| &s.src_lines).collect(); unit_lines.sort(); section_lines.sort(); (unit_lines != section_lines).then(|| { @@ -507,11 +513,23 @@ impl IndexReport { println!(" ambiguous: {}", self.join_ambiguous.len()); println!(); println!("Gate 2 — unit-text round-trip"); - println!(" checked (non-pretty): {}", self.unit_roundtrip_checked); - println!(" pretty skipped: {}", self.unit_roundtrip_pretty_skipped); - println!(" mismatches: {}", self.unit_roundtrip_mismatches.len()); + println!( + " checked (non-pretty): {}", + self.unit_roundtrip_checked + ); + println!( + " pretty skipped: {}", + self.unit_roundtrip_pretty_skipped + ); + println!( + " mismatches: {}", + self.unit_roundtrip_mismatches.len() + ); println!(); - println!("Unknown directives: {}", self.unknown_directives.len()); + println!( + "Unknown directives: {}", + self.unknown_directives.len() + ); println!(); println!( "Substrate: {} harness-forced defaults, {} strict-family members, case-sensitive={}", diff --git a/crates/tsv_debug/src/tsc_conformance/libs.rs b/crates/tsv_debug/src/tsc_conformance/libs.rs index 9b2efb7c0..5c1b18123 100644 --- a/crates/tsv_debug/src/tsc_conformance/libs.rs +++ b/crates/tsv_debug/src/tsc_conformance/libs.rs @@ -29,7 +29,7 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use tsv_check::{bind_lib, LibBase, LibFile}; +use tsv_check::{LibBase, LibFile, bind_lib}; /// The ported `LibMap` (`enummaps.go`), in insertion order — the tuple order is the /// `Libs` slice the priority index reads. Maps a lib **name** (`@lib` / @@ -54,9 +54,15 @@ const LIB_MAP: &[(&str, &str)] = &[ ("dom.iterable", "lib.dom.iterable.d.ts"), ("dom.asynciterable", "lib.dom.asynciterable.d.ts"), ("webworker", "lib.webworker.d.ts"), - ("webworker.importscripts", "lib.webworker.importscripts.d.ts"), + ( + "webworker.importscripts", + "lib.webworker.importscripts.d.ts", + ), ("webworker.iterable", "lib.webworker.iterable.d.ts"), - ("webworker.asynciterable", "lib.webworker.asynciterable.d.ts"), + ( + "webworker.asynciterable", + "lib.webworker.asynciterable.d.ts", + ), ("scripthost", "lib.scripthost.d.ts"), ("es2015.core", "lib.es2015.core.d.ts"), ("es2015.collection", "lib.es2015.collection.d.ts"), @@ -66,7 +72,10 @@ const LIB_MAP: &[(&str, &str)] = &[ ("es2015.proxy", "lib.es2015.proxy.d.ts"), ("es2015.reflect", "lib.es2015.reflect.d.ts"), ("es2015.symbol", "lib.es2015.symbol.d.ts"), - ("es2015.symbol.wellknown", "lib.es2015.symbol.wellknown.d.ts"), + ( + "es2015.symbol.wellknown", + "lib.es2015.symbol.wellknown.d.ts", + ), ("es2016.array.include", "lib.es2016.array.include.d.ts"), ("es2016.intl", "lib.es2016.intl.d.ts"), ("es2017.arraybuffer", "lib.es2017.arraybuffer.d.ts"), @@ -91,7 +100,10 @@ const LIB_MAP: &[(&str, &str)] = &[ ("es2020.promise", "lib.es2020.promise.d.ts"), ("es2020.sharedmemory", "lib.es2020.sharedmemory.d.ts"), ("es2020.string", "lib.es2020.string.d.ts"), - ("es2020.symbol.wellknown", "lib.es2020.symbol.wellknown.d.ts"), + ( + "es2020.symbol.wellknown", + "lib.es2020.symbol.wellknown.d.ts", + ), ("es2020.intl", "lib.es2020.intl.d.ts"), ("es2020.number", "lib.es2020.number.d.ts"), ("es2021.promise", "lib.es2021.promise.d.ts"), @@ -186,7 +198,10 @@ fn lib_priority(file: &str) -> i32 { if file == "lib.d.ts" || file == "lib.es6.d.ts" { return 0; } - let name = file.strip_prefix("lib.").and_then(|s| s.strip_suffix(".d.ts")).unwrap_or(file); + let name = file + .strip_prefix("lib.") + .and_then(|s| s.strip_suffix(".d.ts")) + .unwrap_or(file); match LIB_MAP.iter().position(|(k, _)| *k == name) { Some(i) => i32::try_from(i).unwrap_or(i32::MAX - 2) + 1, None => i32::try_from(LIB_MAP.len()).unwrap_or(i32::MAX - 2) + 2, @@ -214,7 +229,9 @@ fn extract_lib_references(source: &str) -> Vec { /// Whether `@noLib` is set truthily. fn is_no_lib(config: &BTreeMap) -> bool { - config.get("nolib").is_some_and(|v| v.eq_ignore_ascii_case("true")) + config + .get("nolib") + .is_some_and(|v| v.eq_ignore_ascii_case("true")) } /// Per-run lib resolver + cache: parses + binds each lib file once, and folds each @@ -273,7 +290,8 @@ impl LibResolver { } // An all-unrecognized @lib leaves no roots — nothing to resolve. } else { - roots.push(default_lib_for_target(config.get("target").map(String::as_str)).to_string()); + roots + .push(default_lib_for_target(config.get("target").map(String::as_str)).to_string()); } // Transitive `/// ` closure. @@ -392,7 +410,10 @@ mod tests { use super::*; fn cfg(pairs: &[(&str, &str)]) -> BTreeMap { - pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect() + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() } #[test] @@ -401,7 +422,10 @@ mod tests { assert_eq!(default_lib_for_target(Some("es2015")), "lib.es6.d.ts"); assert_eq!(default_lib_for_target(Some("ES2015")), "lib.es6.d.ts"); assert_eq!(default_lib_for_target(Some("es5")), "lib.d.ts"); - assert_eq!(default_lib_for_target(Some("esnext")), "lib.esnext.full.d.ts"); + assert_eq!( + default_lib_for_target(Some("esnext")), + "lib.esnext.full.d.ts" + ); } #[test] @@ -417,11 +441,17 @@ mod tests { fn priority_orders_symbol_and_promise_related_chains() { // es5 leads (priority 1); the es2015 features follow by LibMap-key index. assert!(lib_priority("lib.es5.d.ts") < lib_priority("lib.es2015.symbol.d.ts")); - assert!(lib_priority("lib.es2015.symbol.d.ts") < lib_priority("lib.es2015.symbol.wellknown.d.ts")); + assert!( + lib_priority("lib.es2015.symbol.d.ts") + < lib_priority("lib.es2015.symbol.wellknown.d.ts") + ); // Promise chain: es5 < es2015.iterable < es2015.promise < es2015.symbol.wellknown. assert!(lib_priority("lib.es5.d.ts") < lib_priority("lib.es2015.iterable.d.ts")); assert!(lib_priority("lib.es2015.iterable.d.ts") < lib_priority("lib.es2015.promise.d.ts")); - assert!(lib_priority("lib.es2015.promise.d.ts") < lib_priority("lib.es2015.symbol.wellknown.d.ts")); + assert!( + lib_priority("lib.es2015.promise.d.ts") + < lib_priority("lib.es2015.symbol.wellknown.d.ts") + ); // The aggregator roots (no declarations) sort last; lib.es6.d.ts is special. assert_eq!(lib_priority("lib.es6.d.ts"), 0); assert!(lib_priority("lib.es2025.full.d.ts") > lib_priority("lib.dom.d.ts")); @@ -430,7 +460,10 @@ mod tests { #[test] fn reference_extraction() { let src = "/// \n/// \ninterface X {}"; - assert_eq!(extract_lib_references(src), vec!["es2015".to_string(), "dom".to_string()]); + assert_eq!( + extract_lib_references(src), + vec!["es2015".to_string(), "dom".to_string()] + ); } #[test] diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index e50f5255d..5c26c6a61 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -37,4 +37,4 @@ pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; -pub use runner::{check_one, run_skeleton, RunFilter, RunOptions}; +pub use runner::{RunFilter, RunOptions, check_one, run_skeleton}; diff --git a/crates/tsv_debug/src/tsc_conformance/options_meta.rs b/crates/tsv_debug/src/tsc_conformance/options_meta.rs index 303c766de..cda1a4a26 100644 --- a/crates/tsv_debug/src/tsc_conformance/options_meta.rs +++ b/crates/tsv_debug/src/tsc_conformance/options_meta.rs @@ -75,26 +75,56 @@ const fn om( pub const OPTIONS: &[OptionMeta] = &[ om("help", OptionKind::Boolean, true, false, false), om("watch", OptionKind::Boolean, true, false, false), - om("preserveWatchOutput", OptionKind::Boolean, false, false, false), + om( + "preserveWatchOutput", + OptionKind::Boolean, + false, + false, + false, + ), om("listFiles", OptionKind::Boolean, false, false, false), om("explainFiles", OptionKind::Boolean, false, false, false), om("listEmittedFiles", OptionKind::Boolean, false, false, false), om("pretty", OptionKind::Boolean, false, false, false), om("traceResolution", OptionKind::Boolean, false, false, false), om("diagnostics", OptionKind::Boolean, false, false, false), - om("extendedDiagnostics", OptionKind::Boolean, false, false, false), + om( + "extendedDiagnostics", + OptionKind::Boolean, + false, + false, + false, + ), om("generateCpuProfile", OptionKind::Str, false, false, false), om("generateTrace", OptionKind::Str, false, false, false), om("incremental", OptionKind::Boolean, false, false, false), om("declaration", OptionKind::Boolean, false, true, false), om("declarationMap", OptionKind::Boolean, false, true, false), - om("emitDeclarationOnly", OptionKind::Boolean, false, true, false), + om( + "emitDeclarationOnly", + OptionKind::Boolean, + false, + true, + false, + ), om("sourceMap", OptionKind::Boolean, false, true, false), om("inlineSourceMap", OptionKind::Boolean, false, true, false), om("noCheck", OptionKind::Boolean, false, false, false), - om("deduplicatePackages", OptionKind::Boolean, false, true, false), + om( + "deduplicatePackages", + OptionKind::Boolean, + false, + true, + false, + ), om("noEmit", OptionKind::Boolean, false, false, false), - om("assumeChangesOnlyAffectDirectDependencies", OptionKind::Boolean, false, true, false), + om( + "assumeChangesOnlyAffectDirectDependencies", + OptionKind::Boolean, + false, + true, + false, + ), om("locale", OptionKind::Str, true, false, false), om("quiet", OptionKind::Boolean, false, false, false), om("singleThreaded", OptionKind::Boolean, false, false, false), @@ -120,60 +150,222 @@ pub const OPTIONS: &[OptionMeta] = &[ om("tsBuildInfoFile", OptionKind::Str, false, true, false), om("removeComments", OptionKind::Boolean, false, true, false), om("importHelpers", OptionKind::Boolean, false, true, false), - om("downlevelIteration", OptionKind::Boolean, false, true, false), + om( + "downlevelIteration", + OptionKind::Boolean, + false, + true, + false, + ), om("isolatedModules", OptionKind::Boolean, false, false, false), - om("verbatimModuleSyntax", OptionKind::Boolean, false, true, false), - om("isolatedDeclarations", OptionKind::Boolean, false, true, false), - om("erasableSyntaxOnly", OptionKind::Boolean, false, true, false), + om( + "verbatimModuleSyntax", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "isolatedDeclarations", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "erasableSyntaxOnly", + OptionKind::Boolean, + false, + true, + false, + ), om("libReplacement", OptionKind::Boolean, false, true, false), om("strict", OptionKind::Boolean, false, true, false), om("noImplicitAny", OptionKind::Boolean, false, true, true), om("strictNullChecks", OptionKind::Boolean, false, true, true), - om("strictFunctionTypes", OptionKind::Boolean, false, true, true), - om("strictBindCallApply", OptionKind::Boolean, false, true, true), - om("strictPropertyInitialization", OptionKind::Boolean, false, true, true), - om("strictBuiltinIteratorReturn", OptionKind::Boolean, false, true, true), + om( + "strictFunctionTypes", + OptionKind::Boolean, + false, + true, + true, + ), + om( + "strictBindCallApply", + OptionKind::Boolean, + false, + true, + true, + ), + om( + "strictPropertyInitialization", + OptionKind::Boolean, + false, + true, + true, + ), + om( + "strictBuiltinIteratorReturn", + OptionKind::Boolean, + false, + true, + true, + ), om("noImplicitThis", OptionKind::Boolean, false, true, true), - om("useUnknownInCatchVariables", OptionKind::Boolean, false, true, true), + om( + "useUnknownInCatchVariables", + OptionKind::Boolean, + false, + true, + true, + ), om("alwaysStrict", OptionKind::Boolean, false, true, false), - om("stableTypeOrdering", OptionKind::Boolean, false, true, false), + om( + "stableTypeOrdering", + OptionKind::Boolean, + false, + true, + false, + ), om("noUnusedLocals", OptionKind::Boolean, false, true, false), - om("noUnusedParameters", OptionKind::Boolean, false, true, false), - om("exactOptionalPropertyTypes", OptionKind::Boolean, false, true, false), + om( + "noUnusedParameters", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "exactOptionalPropertyTypes", + OptionKind::Boolean, + false, + true, + false, + ), om("noImplicitReturns", OptionKind::Boolean, false, true, false), - om("noFallthroughCasesInSwitch", OptionKind::Boolean, false, true, false), - om("noUncheckedIndexedAccess", OptionKind::Boolean, false, true, false), - om("noImplicitOverride", OptionKind::Boolean, false, true, false), - om("noPropertyAccessFromIndexSignature", OptionKind::Boolean, false, true, false), + om( + "noFallthroughCasesInSwitch", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "noUncheckedIndexedAccess", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "noImplicitOverride", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "noPropertyAccessFromIndexSignature", + OptionKind::Boolean, + false, + true, + false, + ), om("moduleResolution", OptionKind::Enum, false, true, false), om("baseUrl", OptionKind::Str, false, true, false), om("paths", OptionKind::Object, false, true, false), om("rootDirs", OptionKind::List, false, true, false), om("typeRoots", OptionKind::List, false, true, false), om("types", OptionKind::List, false, true, false), - om("allowSyntheticDefaultImports", OptionKind::Boolean, false, true, false), + om( + "allowSyntheticDefaultImports", + OptionKind::Boolean, + false, + true, + false, + ), om("esModuleInterop", OptionKind::Boolean, false, true, false), om("preserveSymlinks", OptionKind::Boolean, false, false, false), - om("allowUmdGlobalAccess", OptionKind::Boolean, false, true, false), + om( + "allowUmdGlobalAccess", + OptionKind::Boolean, + false, + true, + false, + ), om("moduleSuffixes", OptionKind::List, false, true, false), - om("allowImportingTsExtensions", OptionKind::Boolean, false, true, false), - om("rewriteRelativeImportExtensions", OptionKind::Boolean, false, true, false), - om("resolvePackageJsonExports", OptionKind::Boolean, false, true, false), - om("resolvePackageJsonImports", OptionKind::Boolean, false, true, false), + om( + "allowImportingTsExtensions", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "rewriteRelativeImportExtensions", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "resolvePackageJsonExports", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "resolvePackageJsonImports", + OptionKind::Boolean, + false, + true, + false, + ), om("customConditions", OptionKind::List, false, true, false), - om("noUncheckedSideEffectImports", OptionKind::Boolean, false, true, false), + om( + "noUncheckedSideEffectImports", + OptionKind::Boolean, + false, + true, + false, + ), om("sourceRoot", OptionKind::Str, false, true, false), om("mapRoot", OptionKind::Str, false, true, false), om("inlineSources", OptionKind::Boolean, false, true, false), - om("experimentalDecorators", OptionKind::Boolean, false, true, false), - om("emitDecoratorMetadata", OptionKind::Boolean, false, true, false), + om( + "experimentalDecorators", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "emitDecoratorMetadata", + OptionKind::Boolean, + false, + true, + false, + ), om("jsxFactory", OptionKind::Str, false, false, false), om("jsxFragmentFactory", OptionKind::Str, false, false, false), om("jsxImportSource", OptionKind::Str, false, true, false), om("resolveJsonModule", OptionKind::Boolean, false, true, false), - om("allowArbitraryExtensions", OptionKind::Boolean, false, true, false), + om( + "allowArbitraryExtensions", + OptionKind::Boolean, + false, + true, + false, + ), om("reactNamespace", OptionKind::Str, false, true, false), - om("skipDefaultLibCheck", OptionKind::Boolean, false, true, false), + om( + "skipDefaultLibCheck", + OptionKind::Boolean, + false, + true, + false, + ), om("emitBOM", OptionKind::Boolean, false, true, false), om("newLine", OptionKind::Enum, false, true, false), om("noErrorTruncation", OptionKind::Boolean, false, true, false), @@ -181,19 +373,67 @@ pub const OPTIONS: &[OptionMeta] = &[ om("noResolve", OptionKind::Boolean, false, true, false), om("stripInternal", OptionKind::Boolean, false, true, false), om("disableSizeLimit", OptionKind::Boolean, false, true, false), - om("disableSourceOfProjectReferenceRedirect", OptionKind::Boolean, false, false, false), - om("disableSolutionSearching", OptionKind::Boolean, false, false, false), - om("disableReferencedProjectLoad", OptionKind::Boolean, false, false, false), + om( + "disableSourceOfProjectReferenceRedirect", + OptionKind::Boolean, + false, + false, + false, + ), + om( + "disableSolutionSearching", + OptionKind::Boolean, + false, + false, + false, + ), + om( + "disableReferencedProjectLoad", + OptionKind::Boolean, + false, + false, + false, + ), om("noEmitHelpers", OptionKind::Boolean, false, true, false), om("noEmitOnError", OptionKind::Boolean, false, true, false), - om("preserveConstEnums", OptionKind::Boolean, false, true, false), + om( + "preserveConstEnums", + OptionKind::Boolean, + false, + true, + false, + ), om("declarationDir", OptionKind::Str, false, true, false), om("skipLibCheck", OptionKind::Boolean, false, true, false), om("allowUnusedLabels", OptionKind::Boolean, false, true, false), - om("allowUnreachableCode", OptionKind::Boolean, false, true, false), - om("forceConsistentCasingInFileNames", OptionKind::Boolean, false, true, false), - om("maxNodeModuleJsDepth", OptionKind::Number, false, true, false), - om("useDefineForClassFields", OptionKind::Boolean, false, true, false), + om( + "allowUnreachableCode", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "forceConsistentCasingInFileNames", + OptionKind::Boolean, + false, + true, + false, + ), + om( + "maxNodeModuleJsDepth", + OptionKind::Number, + false, + true, + false, + ), + om( + "useDefineForClassFields", + OptionKind::Boolean, + false, + true, + false, + ), om("plugins", OptionKind::List, false, false, false), om("moduleDetection", OptionKind::Enum, false, true, false), om("ignoreDeprecations", OptionKind::Str, false, false, false), @@ -250,19 +490,47 @@ pub const ENUM_ENTRIES: &[EnumEntry] = &[ ee("module", "node20", "core.ModuleKindNode20"), ee("module", "nodenext", "core.ModuleKindNodeNext"), ee("module", "preserve", "core.ModuleKindPreserve"), - ee("moduleResolution", "node16", "core.ModuleResolutionKindNode16"), - ee("moduleResolution", "nodenext", "core.ModuleResolutionKindNodeNext"), - ee("moduleResolution", "bundler", "core.ModuleResolutionKindBundler"), - ee("moduleResolution", "classic", "core.ModuleResolutionKindClassic"), - ee("moduleResolution", "node", "core.ModuleResolutionKindNode10"), - ee("moduleResolution", "node10", "core.ModuleResolutionKindNode10"), + ee( + "moduleResolution", + "node16", + "core.ModuleResolutionKindNode16", + ), + ee( + "moduleResolution", + "nodenext", + "core.ModuleResolutionKindNodeNext", + ), + ee( + "moduleResolution", + "bundler", + "core.ModuleResolutionKindBundler", + ), + ee( + "moduleResolution", + "classic", + "core.ModuleResolutionKindClassic", + ), + ee( + "moduleResolution", + "node", + "core.ModuleResolutionKindNode10", + ), + ee( + "moduleResolution", + "node10", + "core.ModuleResolutionKindNode10", + ), ee("jsx", "preserve", "core.JsxEmitPreserve"), ee("jsx", "react-native", "core.JsxEmitReactNative"), ee("jsx", "react-jsx", "core.JsxEmitReactJSX"), ee("jsx", "react-jsxdev", "core.JsxEmitReactJSXDev"), ee("jsx", "react", "core.JsxEmitReact"), ee("moduleDetection", "auto", "core.ModuleDetectionKindAuto"), - ee("moduleDetection", "legacy", "core.ModuleDetectionKindLegacy"), + ee( + "moduleDetection", + "legacy", + "core.ModuleDetectionKindLegacy", + ), ee("moduleDetection", "force", "core.ModuleDetectionKindForce"), ee("newLine", "crlf", "core.NewLineKindCRLF"), ee("newLine", "lf", "core.NewLineKindLF"), @@ -420,8 +688,12 @@ pub fn is_known_directive(name_lower: &str) -> bool { return true; } lookup(name_lower).is_some() - || SYNTHETIC_OPTIONS.iter().any(|s| s.eq_ignore_ascii_case(name_lower)) - || HARNESS_OPTIONS.iter().any(|s| s.eq_ignore_ascii_case(name_lower)) + || SYNTHETIC_OPTIONS + .iter() + .any(|s| s.eq_ignore_ascii_case(name_lower)) + || HARNESS_OPTIONS + .iter() + .any(|s| s.eq_ignore_ascii_case(name_lower)) } /// A normalized option value — the identity used for variant dedup within one @@ -441,7 +713,10 @@ pub enum NormValue { #[must_use] pub fn normalize_value(option_lower: &str, value: &str) -> Option { let vlower = value.to_ascii_lowercase(); - if ENUM_ENTRIES.iter().any(|e| e.option.eq_ignore_ascii_case(option_lower)) { + if ENUM_ENTRIES + .iter() + .any(|e| e.option.eq_ignore_ascii_case(option_lower)) + { return ENUM_ENTRIES .iter() .find(|e| e.option.eq_ignore_ascii_case(option_lower) && e.key == vlower) @@ -462,7 +737,10 @@ pub fn normalize_value(option_lower: &str, value: &str) -> Option { /// `getAllValuesForOption` (the `*` expansion source). #[must_use] pub fn all_values(option_lower: &str) -> Vec<&'static str> { - if ENUM_ENTRIES.iter().any(|e| e.option.eq_ignore_ascii_case(option_lower)) { + if ENUM_ENTRIES + .iter() + .any(|e| e.option.eq_ignore_ascii_case(option_lower)) + { return ENUM_ENTRIES .iter() .filter(|e| e.option.eq_ignore_ascii_case(option_lower)) @@ -481,7 +759,11 @@ pub fn all_values(option_lower: &str) -> Vec<&'static str> { /// explicit tri-state). #[must_use] pub fn strict_members() -> Vec<&'static str> { - OPTIONS.iter().filter(|o| o.strict_flag).map(|o| o.name).collect() + OPTIONS + .iter() + .filter(|o| o.strict_flag) + .map(|o| o.name) + .collect() } /// Resolve a boolean option's explicit tri-state from a variant config: `True` / @@ -538,7 +820,11 @@ pub fn variant_is_unsupported(config: &BTreeMap) -> bool { } // Explicitly-false booleans (`IsFalse()`: an unset value inherits, so only an // explicit `False` triggers the skip). - for key in ["esmoduleinterop", "allowsyntheticdefaultimports", "alwaysstrict"] { + for key in [ + "esmoduleinterop", + "allowsyntheticdefaultimports", + "alwaysstrict", + ] { if resolve_bool(config, key) == Tristate::False { return true; } @@ -602,9 +888,15 @@ mod tests { normalize_value("target", "es6"), normalize_value("target", "ES2015") ); - assert_eq!(normalize_value("strict", "TRUE"), Some(NormValue::Bool(true))); + assert_eq!( + normalize_value("strict", "TRUE"), + Some(NormValue::Bool(true)) + ); assert_eq!(normalize_value("target", "nope"), None); - assert_eq!(all_values("moduledetection"), vec!["auto", "legacy", "force"]); + assert_eq!( + all_values("moduledetection"), + vec!["auto", "legacy", "force"] + ); assert_eq!(all_values("strict"), vec!["true", "false"]); } diff --git a/crates/tsv_debug/src/tsc_conformance/pretty.rs b/crates/tsv_debug/src/tsc_conformance/pretty.rs index 7fb533848..0d228cee9 100644 --- a/crates/tsv_debug/src/tsc_conformance/pretty.rs +++ b/crates/tsv_debug/src/tsc_conformance/pretty.rs @@ -41,8 +41,8 @@ //! displayed line (`diagnosticwriter.go:208`), where the plain path preserves //! tabs in the reprinted source. -use super::baseline::{read_code, Diag, Loc, ParsedBaseline, Section, CATEGORIES}; -use super::render::{col_to_byte, lf_line_starts, push_code, render_middle, CRLF}; +use super::baseline::{CATEGORIES, Diag, Loc, ParsedBaseline, Section, read_code}; +use super::render::{CRLF, col_to_byte, lf_line_starts, push_code, render_middle}; use std::collections::BTreeMap; use std::fmt::Write as _; @@ -405,7 +405,14 @@ fn render_pretty_diagnostic( let (sec, pos, len) = diag_span(b, i).ok_or_else(|| format!("no section span for diagnostic {i}"))?; out.push_str(CRLF); - write_code_snippet(out, &sec.src_lines, pos, len, category_color(&d.category), ""); + write_code_snippet( + out, + &sec.src_lines, + pos, + len, + category_color(&d.category), + "", + ); out.push_str(CRLF); } @@ -441,7 +448,10 @@ fn render_pretty_related( len_idx += 1; let (sec, pos, byte_len) = related_span(b, &loc.file, loc.line, loc.col, len_utf16) .ok_or_else(|| { - format!("no related source for {}:{}:{}", loc.file, loc.line, loc.col) + format!( + "no related source for {}:{}:{}", + loc.file, loc.line, loc.col + ) })?; write_code_snippet(out, &sec.src_lines, pos, byte_len, CYAN, " "); } @@ -553,7 +563,11 @@ fn write_code_snippet( out.push_str(squiggle_color); let content_units = utf16_len(&content); if i == first_line { - let last_char_for_line = if i == last_line { last_char } else { content_units }; + let last_char_for_line = if i == last_line { + last_char + } else { + content_units + }; push_repeat(out, ' ', first_char); push_repeat(out, '~', last_char_for_line.saturating_sub(first_char)); } else if i == last_line { @@ -799,7 +813,10 @@ fn line_and_utf16col(src_lines: &[String], starts: &[usize], pos: usize) -> (usi let line_start = *starts.get(line).unwrap_or(&0); let in_line = pos.saturating_sub(line_start); let src = src_lines.get(line).map_or("", String::as_str); - (line, utf16_len(src.get(..in_line.min(src.len())).unwrap_or(""))) + ( + line, + utf16_len(src.get(..in_line.min(src.len())).unwrap_or("")), + ) } /// Advance `units` UTF-16 code units from `start_byte` in `line`, returning the @@ -867,9 +884,10 @@ mod tests { assert_eq!(pos.code, 2345); assert_eq!(pos.first_msg, "Argument bad."); - let global = - parse_pretty_head("\u{1b}[91merror\u{1b}[0m\u{1b}[90m TS-1: \u{1b}[0mPre-emit mismatch!") - .expect("global"); + let global = parse_pretty_head( + "\u{1b}[91merror\u{1b}[0m\u{1b}[90m TS-1: \u{1b}[0mPre-emit mismatch!", + ) + .expect("global"); assert!(global.file.is_none() && global.loc.is_none()); assert_eq!(global.code, -1); assert_eq!(global.first_msg, "Pre-emit mismatch!"); @@ -877,7 +895,8 @@ mod tests { #[test] fn related_verbatim_file_and_fileless() { - let (loc, msg) = parse_related_verbatim("TS6203 file2.ts:1:6: 'Foo' was also declared here."); + let (loc, msg) = + parse_related_verbatim("TS6203 file2.ts:1:6: 'Foo' was also declared here."); let loc = loc.expect("file-bearing"); assert_eq!((loc.file.as_str(), loc.line, loc.col), ("file2.ts", 1, 6)); assert_eq!(msg, "'Foo' was also declared here."); @@ -891,8 +910,13 @@ mod tests { fn gutter_and_tilde_classification() { // Content gutter (digit) vs squiggle gutter (blank). assert!(!gutter_is_blank("\u{1b}[7m3\u{1b}[0m const x;")); - assert!(gutter_is_blank("\u{1b}[7m \u{1b}[0m \u{1b}[91m ~~~\u{1b}[0m")); - assert_eq!(count_tildes(" \u{1b}[7m \u{1b}[0m \u{1b}[96m ~~~\u{1b}[0m"), 3); + assert!(gutter_is_blank( + "\u{1b}[7m \u{1b}[0m \u{1b}[91m ~~~\u{1b}[0m" + )); + assert_eq!( + count_tildes(" \u{1b}[7m \u{1b}[0m \u{1b}[96m ~~~\u{1b}[0m"), + 3 + ); } // --- standalone renderer coverage (model built by hand, not via the parser) --- @@ -902,7 +926,13 @@ mod tests { // byte contract is pinned independently of the parser. /// Build an `error`-category diagnostic. - fn diag(file: Option<&str>, loc: Option, code: i32, msgs: &[&str], related: &[&str]) -> Diag { + fn diag( + file: Option<&str>, + loc: Option, + code: i32, + msgs: &[&str], + related: &[&str], + ) -> Diag { Diag { file: file.map(str::to_string), loc, @@ -961,7 +991,12 @@ mod tests { // summary trailer "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", ), - c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + c = CYAN, + r = RESET, + y = YELLOW, + red = RED, + g = GREY, + gs = GUTTER_STYLE ); assert_eq!(render_pretty(&b).expect("render"), expected); assert!(self_assertion_violations(&b.base).is_empty()); @@ -1004,7 +1039,12 @@ mod tests { "!!! related TS7038 index.ts:1:1: Type originates here.", "\r\nFound 1 error in index.ts{g}:1{r}\r\n\r\n", ), - c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + c = CYAN, + r = RESET, + y = YELLOW, + red = RED, + g = GREY, + gs = GUTTER_STYLE ); assert_eq!(render_pretty(&b).expect("render"), expected); assert!(self_assertion_violations(&b.base).is_empty()); @@ -1075,7 +1115,12 @@ mod tests { " 1 b.ts{g}:1{r}\r\n", "\r\n", ), - c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + c = CYAN, + r = RESET, + y = YELLOW, + red = RED, + g = GREY, + gs = GUTTER_STYLE ); assert_eq!(render_pretty(&b).expect("render"), expected); assert!(self_assertion_violations(&b.base).is_empty()); @@ -1123,7 +1168,12 @@ mod tests { "!!! error TS2554: Expected 0 arguments.", "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", ), - c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + c = CYAN, + r = RESET, + y = YELLOW, + red = RED, + g = GREY, + gs = GUTTER_STYLE ); assert_eq!(render_pretty(&b).expect("render"), expected); } @@ -1160,7 +1210,9 @@ mod tests { "{gs} 6{r} fff\r\n", "{gs} {r} {red}~~~{r}", ), - r = RESET, red = RED, gs = GUTTER_STYLE + r = RESET, + red = RED, + gs = GUTTER_STYLE ); assert_eq!(out, expected); } @@ -1175,7 +1227,9 @@ mod tests { write_code_snippet(&mut out, &src, 1, 0, RED, ""); let expected = format!( concat!("\r\n", "{gs}1{r} abc\r\n", "{gs} {r} {red} ~{r}",), - r = RESET, red = RED, gs = GUTTER_STYLE + r = RESET, + red = RED, + gs = GUTTER_STYLE ); assert_eq!(out, expected); } @@ -1213,7 +1267,12 @@ mod tests { "!!! error TS2304: Cannot find name.", "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", ), - c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + c = CYAN, + r = RESET, + y = YELLOW, + red = RED, + g = GREY, + gs = GUTTER_STYLE ); assert_eq!(render_pretty(&b).expect("render"), expected); assert!(self_assertion_violations(&b.base).is_empty()); @@ -1259,7 +1318,12 @@ mod tests { "!!! error TS2322: Property 'x' is missing.", "\r\nFound 1 error in a.ts{g}:1{r}\r\n\r\n", ), - c = CYAN, r = RESET, y = YELLOW, red = RED, g = GREY, gs = GUTTER_STYLE + c = CYAN, + r = RESET, + y = YELLOW, + red = RED, + g = GREY, + gs = GUTTER_STYLE ); assert_eq!(render_pretty(&b).expect("render"), expected); assert!(self_assertion_violations(&b.base).is_empty()); @@ -1307,7 +1371,8 @@ mod tests { " 1 b.ts{g}:7{r}\r\n", "\r\n", ), - g = GREY, r = RESET + g = GREY, + r = RESET ); assert_eq!(out, expected); } diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 64f06b37a..268c70dbd 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -33,23 +33,21 @@ // tsgo: internal/testrunner/compiler_runner.go (the in-scope selection) use crate::tsc_conformance::baseline::{parse_baseline, parse_summary_block}; -use crate::tsc_conformance::corpus::{discover_corpus, read_corpus_file, CorpusTest}; -use crate::tsc_conformance::directives::{extract_settings, split_units, Unit}; -use crate::tsc_conformance::discovery::{baselines_dir, discover_baselines, Baseline}; +use crate::tsc_conformance::corpus::{CorpusTest, discover_corpus, read_corpus_file}; +use crate::tsc_conformance::directives::{Unit, extract_settings, split_units}; +use crate::tsc_conformance::discovery::{Baseline, baselines_dir, discover_baselines}; use crate::tsc_conformance::index::{is_js_flavored, is_jsx_scoped}; use crate::tsc_conformance::libs::LibResolver; use crate::tsc_conformance::options_meta::{ - is_config_file_name, variant_is_unsupported, SKIPPED_TESTS, + SKIPPED_TESTS, is_config_file_name, variant_is_unsupported, }; -use crate::tsc_conformance::variants::{config_name, expand, Variant}; +use crate::tsc_conformance::variants::{Variant, config_name, expand}; use bumpalo::Bump; use std::collections::{BTreeMap, HashMap}; -use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::Path; use std::time::Instant; -use tsv_check::{ - bind_program, check_bound, check_program, Diagnostic, ParseReport, SourceUnit, -}; +use tsv_check::{Diagnostic, ParseReport, SourceUnit, bind_program, check_bound, check_program}; use tsv_lang::{LocationMapper, LocationTracker}; /// The bind-time duplicate/conflict family this slice grades: TS2300 (duplicate @@ -65,8 +63,9 @@ const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; /// The TS1xxx codes the binder itself emits (strict-mode + private-identifier /// checks) — they prove nothing about parse state, so a baseline carrying only /// these does not trigger the recovery-AST carve-out (predicate v1, rule a). -const BIND_EMITTED_TS1XXX: [u32; 12] = - [1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012]; +const BIND_EMITTED_TS1XXX: [u32; 12] = [ + 1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012, +]; /// The P1-family baselines whose family diagnostics come from a standard-library /// conflict (S5). These now **match** via the lib base; the classifier is kept as a @@ -110,12 +109,18 @@ const CRASH_EXCLUSIONS: &[(&str, CrashKind)] = &[ // `parse_string_literal` (parser/mod.rs). Dev-profile only (debug_assert is // compiled out in release), so `cargo run` — the gate's profile — panics. // A future tsv_ts fix should reject the form gracefully; then drop this entry. - ("exportDeclarationInInternalModule.ts", CrashKind::CatchablePanic), + ( + "exportDeclarationInInternalModule.ts", + CrashKind::CatchablePanic, + ), ]; /// The [`CrashKind`] of a crash-excluded test, or `None` if not excluded. fn crash_exclusion_kind(basename: &str) -> Option { - CRASH_EXCLUSIONS.iter().find(|(n, _)| *n == basename).map(|(_, k)| *k) + CRASH_EXCLUSIONS + .iter() + .find(|(n, _)| *n == basename) + .map(|(_, k)| *k) } /// One expect-clean variant that graded non-clean (should never happen while the @@ -342,7 +347,9 @@ pub fn run_skeleton(checkout: &Path, options: &RunOptions) -> Result Result { @@ -398,21 +405,35 @@ fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result = - expansion.variants.iter().filter(|v| !variant_is_unsupported(&v.config)).collect(); + let in_scope: Vec<&Variant> = expansion + .variants + .iter() + .filter(|v| !variant_is_unsupported(&v.config)) + .collect(); if in_scope.is_empty() { continue; } report.in_scope_tests += 1; - grade_test(test, &units[0], &in_scope, &ondisk, &mut resolver, options, &mut report); + grade_test( + test, + &units[0], + &in_scope, + &ondisk, + &mut resolver, + options, + &mut report, + ); } // Fold in the resolver's lib-base census (parse-once/fold-once counts + gates). report.lib_files_bound = resolver.files_bound(); report.lib_sets_built = resolver.sets_built(); - report.lib_parse_errors = - resolver.parse_errors().iter().map(|(f, e)| format!("{f}: {e}")).collect(); + report.lib_parse_errors = resolver + .parse_errors() + .iter() + .map(|(f, e)| format!("{f}: {e}")) + .collect(); report.lib_missing_files = resolver.missing_files().to_vec(); report.lib_unknown_names = { let mut names: Vec = resolver.unknown_libs().to_vec(); @@ -435,8 +456,10 @@ fn probe_crash_exclusion(test: &CorpusTest) -> bool { }; let units = split_units(&content, &test.basename); let arena = Bump::new(); - let source_units: Vec> = - units.iter().map(|u| SourceUnit::new(&u.name, &u.content)).collect(); + let source_units: Vec> = units + .iter() + .map(|u| SourceUnit::new(&u.name, &u.content)) + .collect(); // Silence the default panic hook for the deliberate probe (we expect it to // panic; the message would otherwise leak to stderr and read as a failure). let prev = std::panic::take_hook(); @@ -493,7 +516,9 @@ fn grade_test( // The single unit's parse outcome (parse_reports is never empty for one input). let reports = bound.parse_reports(); - let Some(&(_, parse)) = reports.first() else { return }; + let Some(&(_, parse)) = reports.first() else { + return; + }; let parsed = matches!(parse, ParseReport::Parsed(_)); // The unit's line map — reused across the test's variants for the parsed case. @@ -558,7 +583,15 @@ fn grade_test( reason: "panic", diff: format!("# {}/{name} (lib-resolution panic)\n", test.suite), }); - record_manifest(report, options, test, &name, baseline.is_some(), true, "panic"); + record_manifest( + report, + options, + test, + &name, + baseline.is_some(), + true, + "panic", + ); continue; } }; @@ -590,7 +623,12 @@ fn grade_test( let ours_family = match line_map.as_ref() { Some((tracker, map)) => { let mapper = LocationMapper { tracker, map }; - build_ours_family(&result.diagnostics, &unit.name, &mapper, lib_files) + build_ours_family( + &result.diagnostics, + &unit.name, + &mapper, + lib_files, + ) } None => Vec::new(), }; @@ -600,7 +638,15 @@ fn grade_test( } }; - record_manifest(report, options, test, &name, baseline.is_some(), parsed, verdict); + record_manifest( + report, + options, + test, + &name, + baseline.is_some(), + parsed, + verdict, + ); } // Node total: counted once per test (all variants share the parse+bind). @@ -650,11 +696,19 @@ fn render_family_diff( let mut s = format!("# {}/{name} ({reason})\n", test.suite); let _ = writeln!(s, "## ours family ({})", ours.len()); for e in ours { - let _ = writeln!(s, " {}({},{}): TS{}", e.key.file, e.key.line, e.key.col, e.key.code); + let _ = writeln!( + s, + " {}({},{}): TS{}", + e.key.file, e.key.line, e.key.col, e.key.code + ); } let _ = writeln!(s, "## baseline family ({})", base.len()); for e in base { - let _ = writeln!(s, " {}({},{}): TS{}", e.key.file, e.key.line, e.key.col, e.key.code); + let _ = writeln!( + s, + " {}({},{}): TS{}", + e.key.file, e.key.line, e.key.col, e.key.code + ); } s } @@ -741,7 +795,11 @@ fn resolve_related( loc: None, } } - None => RelatedKey { code: r.code, file: unit_name.to_string(), loc: None }, + None => RelatedKey { + code: r.code, + file: unit_name.to_string(), + loc: None, + }, } } @@ -802,7 +860,12 @@ fn grade_family( return None; } Some(FamilyEntry { - key: FamilyDiag { file: d.file.clone()?, line: d.line?, col: d.col?, code }, + key: FamilyDiag { + file: d.file.clone()?, + line: d.line?, + col: d.col?, + code, + }, related: d.related.clone(), }) }) @@ -832,18 +895,25 @@ fn grade_family( *report.family_match_by_code.entry(*code).or_default() += *count; } if buckets.extra > 0 && report.extra_samples.len() < 20 { - report.extra_samples.push(format!("{}/{name} (+{})", test.suite, buckets.extra)); + report + .extra_samples + .push(format!("{}/{name} (+{})", test.suite, buckets.extra)); } if buckets.span_mismatch > 0 && report.span_mismatch_samples.len() < 20 { - report - .span_mismatch_samples - .push(format!("{}/{name} (~{})", test.suite, buckets.span_mismatch)); + report.span_mismatch_samples.push(format!( + "{}/{name} (~{})", + test.suite, buckets.span_mismatch + )); } // An unexplained hard-fail bucket (extra / span mismatch) gets a rendered // ours-vs-baseline diff artifact; the pinned check-time `missing` is expected, // so it does not. if buckets.extra > 0 || buckets.span_mismatch > 0 { - let reason = if buckets.extra > 0 { "family_extra" } else { "span_mismatch" }; + let reason = if buckets.extra > 0 { + "family_extra" + } else { + "span_mismatch" + }; report.failing_variants.push(FailingVariant { suite: test.suite.to_string(), config: name.to_string(), @@ -877,10 +947,14 @@ fn grade_family( report.related_extra += rel.extra; report.related_missing += rel.missing; if rel.extra > 0 && report.related_extra_samples.len() < 20 { - report.related_extra_samples.push(format!("{}/{name} (+{})", test.suite, rel.extra)); + report + .related_extra_samples + .push(format!("{}/{name} (+{})", test.suite, rel.extra)); } if rel.missing > 0 && report.related_missing_samples.len() < 20 { - report.related_missing_samples.push(format!("{}/{name} (-{})", test.suite, rel.missing)); + report + .related_missing_samples + .push(format!("{}/{name} (-{})", test.suite, rel.missing)); } // The per-variant verdict (extra dominates — it is the hard gate). @@ -922,8 +996,18 @@ fn parse_base_diags(content: &str) -> Vec { Some(Loc::Numbered { line, col }) => (Some(line), Some(col)), _ => (None, None), }; - let related = d.related.iter().filter_map(|s| parse_related_line(s)).collect(); - BaseDiag { file: d.file.clone(), line, col, code: d.code, related } + let related = d + .related + .iter() + .filter_map(|s| parse_related_line(s)) + .collect(); + BaseDiag { + file: d.file.clone(), + line, + col, + code: d.code, + related, + } }) .collect(), Err(_) => parse_summary_block(content) @@ -958,7 +1042,11 @@ fn parse_related_line(line: &str) -> Option { } else { Some((line_s.parse().ok()?, col.parse().ok()?)) }; - Some(RelatedKey { code, file: file.to_string(), loc }) + Some(RelatedKey { + code, + file: file.to_string(), + loc, + }) } /// The related-info buckets across a variant's matched primaries. @@ -987,7 +1075,9 @@ fn grade_related(ours: &[FamilyEntry], base: &[FamilyEntry]) -> RelatedBuckets { let mut out = RelatedBuckets::default(); for (key, ours_sets) in &ours_by { - let Some(base_sets) = base_by.get(key) else { continue }; + let Some(base_sets) = base_by.get(key) else { + continue; + }; let paired = ours_sets.len().min(base_sets.len()); for i in 0..paired { related_diff(ours_sets[i], base_sets[i], &mut out); @@ -1024,7 +1114,11 @@ fn related_diff(ours: &[RelatedKey], base: &[RelatedKey], out: &mut RelatedBucke let oc = ours_counts.get(*r).copied().unwrap_or(0); let m = oc.min(bc); if bc > m { - let bucket = if r.loc.is_none() { &mut left_base_masked } else { &mut left_base_located }; + let bucket = if r.loc.is_none() { + &mut left_base_masked + } else { + &mut left_base_located + }; *bucket.entry((r.code, r.file.as_str())).or_default() += bc - m; } } @@ -1125,7 +1219,13 @@ fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { } } - FamilyBuckets { matched, extra, span_mismatch, matched_by_code, missing_by_code } + FamilyBuckets { + matched, + extra, + span_mismatch, + matched_by_code, + missing_by_code, + } } /// Classify a parse-rejected variant's baseline shape for the census. @@ -1156,8 +1256,14 @@ impl SkeletonReport { println!(" graded NON-clean: {}", self.clean_fail.len()); println!(" parsed, baselined: {}", self.baselined_parsed); println!(" parse-rejected: {}", self.parse_rejected_total); - println!(" no baseline: {}", self.parse_rejected_no_baseline); - println!(" TS1xxx-only baseline: {}", self.parse_rejected_ts1xxx_only); + println!( + " no baseline: {}", + self.parse_rejected_no_baseline + ); + println!( + " TS1xxx-only baseline: {}", + self.parse_rejected_ts1xxx_only + ); println!(" other baseline: {}", self.parse_rejected_other); println!("Script-goal retries: {}", self.script_retry); println!("Bound nodes (total): {}", self.total_nodes); @@ -1165,12 +1271,18 @@ impl SkeletonReport { println!("Family grading (2300/2451/2567/2528 + merge 2397/2649/2664/2671)"); println!("---------------------------------------------------------------"); println!("Graded variants: {}", self.family_graded_variants); - println!(" ...family-positive: {}", self.family_positive_variants); + println!( + " ...family-positive: {}", + self.family_positive_variants + ); println!(" match: {}", self.family_match); println!(" missing: {}", self.family_missing); println!(" merge-path (S4): {}", self.missing_merge); println!(" lib-conflict (S5): {}", self.missing_lib); - println!(" check-time (S3+): {} (checker-emitted TS2300/2451: duplicate members, type params, computed/private names)", self.missing_other); + println!( + " check-time (S3+): {} (checker-emitted TS2300/2451: duplicate members, type params, computed/private names)", + self.missing_other + ); println!(" extra (GATE=0): {}", self.family_extra); println!(" span_mismatch: {}", self.family_span_mismatch); println!("Related-info (matched primaries; own channel, non-gating)"); @@ -1185,8 +1297,14 @@ impl SkeletonReport { println!(" REL-EXTRA {s}"); } println!("Carve-out rule (a): {}", self.carve_out_rule_a); - println!(" ...family-positive: {}", self.carve_out_rule_a_family); - println!("moduleDetection variants: {} (watch; inert for family)", self.module_detection_variants); + println!( + " ...family-positive: {}", + self.carve_out_rule_a_family + ); + println!( + "moduleDetection variants: {} (watch; inert for family)", + self.module_detection_variants + ); for s in &self.extra_samples { println!(" EXTRA {s}"); } @@ -1200,9 +1318,18 @@ impl SkeletonReport { println!("Lib base (S5)"); println!(" lib files bound: {}", self.lib_files_bound); println!(" lib sets folded: {}", self.lib_sets_built); - println!(" lib parse errors: {} (GATE=0)", self.lib_parse_errors.len()); - println!(" lib missing files: {} (GATE=0)", self.lib_missing_files.len()); - println!(" lib unknown names: {} (GATE=0)", self.lib_unknown_names.len()); + println!( + " lib parse errors: {} (GATE=0)", + self.lib_parse_errors.len() + ); + println!( + " lib missing files: {} (GATE=0)", + self.lib_missing_files.len() + ); + println!( + " lib unknown names: {} (GATE=0)", + self.lib_unknown_names.len() + ); for e in &self.lib_parse_errors { println!(" LIB-PARSE-ERR {e}"); } @@ -1216,7 +1343,10 @@ impl SkeletonReport { println!("Panics (caught): {}", self.panics.len()); println!("Crash-excluded (tracked): {}", self.excluded_crashes); if !self.stale_exclusions.is_empty() { - println!("Stale crash-exclusions: {} (drop them)", self.stale_exclusions.len()); + println!( + "Stale crash-exclusions: {} (drop them)", + self.stale_exclusions.len() + ); } println!("Wall-clock: {} ms", self.wall_ms); if !self.clean_fail.is_empty() { @@ -1299,8 +1429,10 @@ pub fn check_one( [] => return Err(format!("no corpus test matches {name:?}")), [one] => *one, many => { - let paths: Vec = - many.iter().map(|t| format!("{}/{}", t.suite, t.relative_path)).collect(); + let paths: Vec = many + .iter() + .map(|t| format!("{}/{}", t.suite, t.relative_path)) + .collect(); return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); } }; @@ -1325,8 +1457,10 @@ pub fn check_one( // Parse + bind every unit, then merge against the selected variant's lib base. let arena = Bump::new(); - let source_units: Vec> = - units.iter().map(|u| SourceUnit::new(&u.name, &u.content)).collect(); + let source_units: Vec> = units + .iter() + .map(|u| SourceUnit::new(&u.name, &u.content)) + .collect(); let bound = bind_program(&source_units, &arena); let mut resolver = LibResolver::new(checkout); let base = resolver.base_for(&variant.config); @@ -1341,8 +1475,11 @@ pub fn check_one( Some(f) if f.index() < units_len => { let (line, col) = units.get(f.index()).map_or((None, None), |u| { let (t, m) = LocationTracker::new_ecmascript_with_map(&u.content); - let (_, pos) = - LocationMapper { tracker: &t, map: &m }.pos_and_position(d.span.start); + let (_, pos) = LocationMapper { + tracker: &t, + map: &m, + } + .pos_and_position(d.span.start); (Some(pos.line as u32), Some(pos.column as u32 + 1)) }); DiagLine { @@ -1360,7 +1497,13 @@ pub fn check_one( code: d.code, related: Vec::new(), }, - None => DiagLine { file: None, line: None, col: None, code: d.code, related: Vec::new() }, + None => DiagLine { + file: None, + line: None, + col: None, + code: d.code, + related: Vec::new(), + }, } }; let ours: Vec = result @@ -1419,7 +1562,9 @@ fn select_variant<'a>( filter: Option<&(String, String)>, ) -> Result<&'a Variant, String> { match filter { - None => variants.first().ok_or_else(|| "test has no variants".to_string()), + None => variants + .first() + .ok_or_else(|| "test has no variants".to_string()), Some((key, value)) => { let key = key.to_lowercase(); variants @@ -1436,7 +1581,10 @@ fn select_variant<'a>( } }) .collect(); - format!("no variant with {key}={value}; available: {}", available.join(", ")) + format!( + "no variant with {key}={value}; available: {}", + available.join(", ") + ) }) } } @@ -1445,7 +1593,10 @@ fn select_variant<'a>( impl CheckTestReport { /// Print the human diff (ours vs the baseline summary). pub fn print(&self) { - println!("check-test: {}/{} variant={}", self.suite, self.test, self.variant); + println!( + "check-test: {}/{} variant={}", + self.suite, self.test, self.variant + ); if self.parse_rejected { println!( " tsv PARSE-REJECTED: {}", diff --git a/crates/tsv_debug/src/tsc_conformance/variants.rs b/crates/tsv_debug/src/tsc_conformance/variants.rs index 76c424f34..7a4146e38 100644 --- a/crates/tsv_debug/src/tsc_conformance/variants.rs +++ b/crates/tsv_debug/src/tsc_conformance/variants.rs @@ -13,7 +13,7 @@ // tsgo: internal/testutil/harnessutil/harnessutil.go getFileBasedTestConfigurationDescription // tsgo: internal/testrunner/compiler_runner.go newCompilerTest (configuredName) -use crate::tsc_conformance::options_meta::{all_values, is_vary_by, normalize_value, NormValue}; +use crate::tsc_conformance::options_meta::{NormValue, all_values, is_vary_by, normalize_value}; use std::collections::BTreeMap; /// The variant cap: a product above this is a hard harness failure. @@ -201,7 +201,10 @@ pub fn expand(settings: &BTreeMap) -> Expansion { for (k, v) in &non_varying { config.entry(k.clone()).or_insert_with(|| v.clone()); } - Variant { description, config } + Variant { + description, + config, + } }) .collect() }; @@ -293,7 +296,10 @@ mod tests { #[test] fn split_dedup_aliases() { // es6 and es2015 alias; first (es6) wins, so one value. - assert_eq!(split_option_values("es6, es2015", "target").values, vec!["es6"]); + assert_eq!( + split_option_values("es6, es2015", "target").values, + vec!["es6"] + ); } #[test] @@ -315,7 +321,10 @@ mod tests { #[test] fn expand_product_sorted_description() { - let e = expand(&settings(&[("strict", "true, false"), ("module", "commonjs, esnext")])); + let e = expand(&settings(&[ + ("strict", "true, false"), + ("module", "commonjs, esnext"), + ])); assert_eq!(e.variants.len(), 4); // Keys are sorted in the description. assert!(e @@ -330,8 +339,14 @@ mod tests { let e = expand(&settings(&[("target", "es2015"), ("jsxfactory", "h")])); assert_eq!(e.variants.len(), 1); assert_eq!(e.variants[0].description, ""); - assert_eq!(e.variants[0].config.get("target").map(String::as_str), Some("es2015")); - assert_eq!(e.variants[0].config.get("jsxfactory").map(String::as_str), Some("h")); + assert_eq!( + e.variants[0].config.get("target").map(String::as_str), + Some("es2015") + ); + assert_eq!( + e.variants[0].config.get("jsxfactory").map(String::as_str), + Some("h") + ); } #[test] @@ -368,9 +383,18 @@ mod tests { #[test] fn config_name_synthesis() { assert_eq!(config_name("foo.ts", ""), "foo.errors.txt"); - assert_eq!(config_name("foo.ts", "target=es2015"), "foo(target=es2015).errors.txt"); - assert_eq!(config_name("foo.tsx", "jsx=react"), "foo(jsx=react).errors.txt"); - assert_eq!(config_name("foo.d.ts", "target=es5"), "foo.d(target=es5).errors.txt"); + assert_eq!( + config_name("foo.ts", "target=es2015"), + "foo(target=es2015).errors.txt" + ); + assert_eq!( + config_name("foo.tsx", "jsx=react"), + "foo(jsx=react).errors.txt" + ); + assert_eq!( + config_name("foo.d.ts", "target=es5"), + "foo.d(target=es5).errors.txt" + ); // A .js basename keeps its extension (never joins an .errors.txt). assert_eq!(config_name("foo.js", ""), "foo.js"); } From 29aa1b29d7ad88af395e8a1eeb3fae56c5e5e1a8 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 19:36:07 -0400 Subject: [PATCH 21/79] =?UTF-8?q?docs:=20sweep=20repo=20docs=20for=20tsv?= =?UTF-8?q?=5Fcheck/tsc=5Fconformance=20reality=20=E2=80=94=20crate=20maps?= =?UTF-8?q?,=20leg=20lists,=20dep=20tenants,=20command=20catalogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 28 ++++++++++++++++------------ README.md | 5 ++++- benches/js/CLAUDE.md | 14 ++++++++------ crates/tsv_check/CLAUDE.md | 26 +++++++++++++++++++++----- crates/tsv_debug/CLAUDE.md | 2 +- docs/architecture.md | 9 ++++++++- docs/cli.md | 2 +- docs/performance.md | 3 +++ 8 files changed, 62 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30ff146e3..6c855949d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,10 +301,12 @@ deno task conformance:ts-repo # tsv's TS parser vs the tsc corpus (../t # point). Options: -v, --json, . See ./benches/js/CLAUDE.md. deno task conformance # the pre-release aggregate: svelte-fixtures + ts-fixtures + ts-repo + -# corpus:compare:parse --all + corpus:compare:format --all — ONE process (benches/js/conformance.ts; -# oracle modules load once, per-leg timings, fail-fast like a && chain), corpus FFI built once. The +# conformance:tsc-roundtrip + conformance:tsc-check + corpus:compare:parse --all + +# corpus:compare:format --all — ONE process (benches/js/conformance.ts; +# oracle modules load once, per-leg timings, fail-fast like a && chain; the two tsc legs are +# pure-Rust cargo shell-outs), corpus FFI built once. The # release-cadence correctness gates across Svelte/CSS/TS that need external oracles (svelte/compiler, -# acorn-ts/parseCss, tsc baselines, prettier) so they can't live in `deno task check`. The format leg's +# acorn-ts/parseCss, tsgo baselines, prettier) so they can't live in `deno task check`. The format leg's # prettier calls ride a content-addressed output cache (benches/js/lib/prettier_cache.ts — repeat runs # skip re-formatting unchanged files; TSV_PRETTIER_CACHE=0 disables). Wired into `publish.ts` Step 3b; # test262 (../test262) + CSS-WPT harvest stay manual. @@ -792,13 +794,15 @@ See ./docs/conformance_test262.md. Pure-Rust harness over tsgo's committed `.errors.txt` error baselines (in `../typescript-go`, oracle pin `168e7015`, read from the checked-in -`testdata/baselines/reference/submodule`). No Deno, **zero checker code**. +`testdata/baselines/reference/submodule`). No Deno. The oracle-side tools +(`query`/`roundtrip`/`index`) are **zero checker code**; `run`/`check-test` +drive the in-development **`tsv_check`** crate against the same baselines. `query`/`roundtrip` need only the baselines, so they run on a bare checkout; -`index` additionally requires the materialized `_submodules/TypeScript` corpus -(`git submodule update --init`). Distinct from the parser-conformance surfaces: -`conformance:ts-repo` grades tsv's *parser* against the tsc corpus, whereas this -reads tsgo's *checker* error output — the seam a future tsv checker will emit -through. +`index`/`run` additionally require the materialized `_submodules/TypeScript` +corpus (`git submodule update --init`), and `run` the bundled libs. Distinct +from the parser-conformance surfaces: `conformance:ts-repo` grades tsv's +*parser* against the tsc corpus, whereas this reads tsgo's *checker* error +output — the seam `tsv_check` emits through. ```bash # query - aggregations over every baseline's summary block @@ -1163,14 +1167,14 @@ Higher-fidelity models (attached comments, trivia tokens) may be needed for IDE/ ### 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 -- `string-interner` — String interning for the residual symbol tenants (Svelte element/attribute names, escaped identifiers); identifier names are span-identity (`IdentName`) +- `smallvec` — Stack-allocated vectors (printers + `tsv_check`) +- `string-interner` — String interning for the residual symbol tenants (Svelte element/attribute names, escaped identifiers); identifier names are span-identity (`IdentName`). `tsv_check` runs its own separate program-scoped instance for binder name atoms (never the parser's per-document `SharedInterner`) - `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) diff --git a/README.md b/README.md index 065275065..7dfcf6181 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ Future features (unknown order): - JS parsing diagnostics (test262 negative cases) - CSS error recovery (recover past invalid CSS per the spec) - later: - - TypeScript 7 integration (the Go impl), unlocking: + - TypeScript type checking (`tsv_check`, in development — a from-scratch Rust + binder/checker graded for exact conformance against TypeScript 7 (the Go + impl)'s own test baselines), unlocking: - svelte-check replacement - LSP - linter - type aware, initially focused on serializable data-only plugins for extensibility @@ -245,6 +247,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/ # TypeScript binder/checker (in development; 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 159334408..51e89c46e 100644 --- a/benches/js/CLAUDE.md +++ b/benches/js/CLAUDE.md @@ -454,8 +454,10 @@ every real move in a number is a deliberate, visible edit. small shrink warns and still writes, only a >10% collapse fails). - Rust-side counts are consts in their commands — grep `REGRESSION PIN`: 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). + protects the primary gate against a discovery collapse), `swallow_audit` + (formatted files — closes its vacuous-pass), and `tsc_conformance` (the + largest set: baseline/roundtrip/pretty pins + the `INDEX_*` denominators + + the `RUN_*` family-gate pins, all in `cli/commands/tsc_conformance.rs`). **Semantics — three pin categories, chosen per surface:** @@ -697,9 +699,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) @@ -1089,7 +1091,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 seven 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) ├── corpus_compare_format.ts # Formatting comparison vs prettier (Deno-only entry point) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index a1fb7d446..eff403bfc 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -50,11 +50,27 @@ 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/` — the fused lower+bind pre-order walk: assigns dense 1-based - `NodeId`s, fills SoA side columns (`parents`/`kinds`/`spans`/ - `subtree_end` — the latter makes descendant tests O(1) interval checks), - builds the address→NodeId map, derives per-file facts (module-ness from - import/export presence). **Borrow-only discipline**: visitors take +- `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): + - `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). + - `sym.rs` — 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). + - `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 program-scoped name interner (a fresh + `string-interner` instance — never the parser's per-document + `SharedInterner`), reserved internal-name atoms. + + **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 diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index 82f07b1c6..2e857efa3 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -25,5 +25,5 @@ The Deno sidecar is a **long-running subprocess pool** spawned lazily on first u - **Three input modes, uniform across commands.** Every content-processing command accepts a file path (parser auto-detected from extension), `--content --parser `, or `--stdin --parser `. The shared implementation is `tsv_cli::cli::input::InputArgs` — prefer it for new commands. - **Sidecar wire format.** `WireRequest { id, tool, content, options? }` in, `WireResponse { id, ok, output?, error? }` out, newline-delimited JSON on stdio (the sidecar also emits a diagnostic `duration_ms` the Rust client doesn't deserialize). New JS tools are added by extending `sidecar.ts`'s `dispatch` switch and exposing a typed wrapper in `deno/mod.rs`. - **Fixtures module is not just validation.** `fixtures/` owns the data model (`InputType`, fixture discovery, expected-file naming, divergence-suffix rules) used by `fixture_init`, every `fixtures_update*` command, and `fixtures_audit` — not only the validator. -- **`tsv_debug` re-exports infrastructure for tests.** `lib.rs` exposes `deno`, `diff`, `fixtures`, `test262` publicly so integration tests in `tests/` can drive the sidecar and validation logic directly. +- **`tsv_debug` re-exports infrastructure for tests.** `lib.rs` exposes `deno`, `diff`, `error`, `fixtures`, `render_normalize`, `test262`, and `tsc_conformance` publicly so integration tests in `tests/` can drive the sidecar, validation, and conformance logic directly. - **Our formatter runs in-process.** Commands that need "ours" (`compare`, `ast_diff`, fixture validation) all call `tsv_cli::cli::format_source::format_source` — the same function the production `format` command uses — so there is exactly one definition of our formatter's output across the CLI and every debug tool. diff --git a/docs/architecture.md b/docs/architecture.md index 1ef7fc418..560949215 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,9 +65,12 @@ 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 # TypeScript binder/checker (in development; uses tsv_ts + tsv_lang; consumed only by tsv_debug) ├── tsv_cli # Production CLI binary (pure Rust) ├── tsv_debug # Dev utilities (uses embedded Deno sidecar for JS tools) +├── tsv_arena # Per-thread reusable AST/doc arenas for the bindings' hot loop ├── tsv_ffi # C FFI bindings +├── tsv_napi # N-API bindings (Node/Bun native path) └── tsv_wasm # WebAssembly bindings ``` @@ -97,6 +100,10 @@ dispatch), so it doesn't bear on the closed-scope/open-convention stance below. ┌───────────┬─────────────┬──────────┬──────────────────┬─────────────────────┐ tsv_cli tsv_debug tsv_ffi tsv_napi tsv_wasm (production) (dev, Deno) (C FFI) (N-API, Node/Bun) (browser/Node/Deno) + ↑ + tsv_check (TypeScript binder/checker, in development — depends on + tsv_lang + tsv_ts; consumed ONLY by tsv_debug, so no + shipped format/parse artifact links it) tsv_cli and tsv_wasm also consume tsv_discover (→ tsv_ignore). tsv_ffi, tsv_napi, and tsv_wasm also consume tsv_arena — per-thread @@ -111,7 +118,7 @@ dispatch), so it doesn't bear on the closed-scope/open-convention stance below. **Clean API Boundaries** — Each language exports `parse()`, `format()`, and `convert_ast_json_bytes()` / `convert_ast_json_string()` (with `convert_ast_json()` a thin `Value` wrapper over the bytes). tsv_ts and tsv_css also provide embedding APIs (`parse_with_interner`, `parse_embedded`, expression formatting, `build_*_doc`) used by tsv_svelte for nested language support. -**Scalability** — Easy to add new crates (`tsv_ffi`, `tsv_wasm` already done; `tsv_linter`/`tsv_lsp`/`tsv_md` planned). +**Scalability** — Easy to add new crates (`tsv_ffi`, `tsv_wasm`, and the in-development `tsv_check` typechecker already done as crate additions; `tsv_linter`/`tsv_lsp`/`tsv_md` planned). ### Closed Scope, Open Convention diff --git a/docs/cli.md b/docs/cli.md index 984658452..b75a72459 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -31,7 +31,7 @@ The CLI uses [argh](https://crates.io/crates/argh) for declarative arg parsing: in `scripts/test_npm.ts`'s CLI tests. - **`tsv_debug` (development)**: Uses embedded Deno sidecar for external tools - Reuses `tsv_cli` infrastructure - - Commands: `check`, `compare`, `ast_diff`, `line_width`, `canonical_parse`, `format_prettier`, `fixture_init`, `fixtures_validate`, `fixtures_update`, `fixtures_update_parsed`, `fixtures_update_formatted`, `fixtures_audit`, `ts_fixture_audit`, `conformance_audit`, `swallow_audit` (requires `--features swallow_check`, so default builds profile production-shaped render code), `scan_audit`, `authoring_audit`, `build_fanout_audit`, `metrics`, `profile`, `json_profile`, `arena_stats`, `buffer_sizes`, `lex_diff`, `test262` + - Commands: `check`, `compare`, `ast_diff`, `line_width`, `canonical_parse`, `format_prettier`, `fixture_init`, `fixtures_validate`, `fixtures_update`, `fixtures_update_parsed`, `fixtures_update_formatted`, `fixtures_audit`, `ts_fixture_audit`, `conformance_audit`, `swallow_audit` (requires `--features swallow_check`, so default builds profile production-shaped render code), `scan_audit`, `authoring_audit`, `build_fanout_audit`, `metrics`, `profile`, `json_profile`, `arena_stats`, `buffer_sizes`, `lex_diff`, `test262`, `tsc_conformance` (nested: `query`, `roundtrip`, `index`, `run`, `check-test` — the tsgo typechecker-conformance harness; command surface in the root CLAUDE.md) ### External Tools (via Embedded Deno Sidecar) diff --git a/docs/performance.md b/docs/performance.md index 41a69917b..1e91d6306 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -67,6 +67,9 @@ Four tools, in order of use: ### 1. `tsv_debug profile` — phase timing Measures parse vs format timing across files. Pure Rust, no external dependencies. +The `--bind` form instead measures parse vs lower+bind timing through the +`tsv_check` crate (TypeScript files only) and reports peak RSS (`VmHWM` from +`/proc/self/status`) — the binder's standing perf-anchor form. ```bash # Profile a directory From f9d65a4a273059710190743406a77b3fbf61f024 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 20:16:47 -0400 Subject: [PATCH 22/79] =?UTF-8?q?test:=20cover=20unpinned=20tsc=5Fconforma?= =?UTF-8?q?nce=20runner=20paths=20=E2=80=94=20triage=20filters,=20report?= =?UTF-8?q?=20determinism,=20artifact=20names,=20report-vs-filters=20rejec?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/cli/commands/tsc_conformance.rs | 122 ++++++++++++++++- .../tsv_debug/src/tsc_conformance/runner.rs | 129 ++++++++++++++++-- 2 files changed, 242 insertions(+), 9 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 68c65c4f3..bb6e32e45 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -310,7 +310,7 @@ impl RunCommand { let filter = self.build_filter()?; let filtered = filter.is_active(); // The committed report is the full-run artifact; refuse to write a partial one. - if self.report.is_some() && filtered { + if !may_write_report(self.report.is_some(), filtered) { eprintln!( "Error: --report writes the committed full report; it cannot be combined with \ --test/--code/--variant filters." @@ -883,6 +883,13 @@ fn render_report_md(report: &SkeletonReport) -> String { s } +/// Whether a run may write the committed `--report` artifact: it is a full-run +/// product (the pins hold only on a full run), so a filtered (triage) run must +/// refuse it. A run that didn't request `--report` trivially may. +fn may_write_report(report_requested: bool, filtered: bool) -> bool { + !(report_requested && filtered) +} + /// Write the committed compact report to `.json` + `.md` (full runs only; /// deterministic). Called only after the gates pass. fn write_report(report: &SkeletonReport, base: &Path) -> Result<(), CliError> { @@ -1401,3 +1408,116 @@ fn print_json(report: &T) -> Result<(), CliError> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A report with the deterministic sections populated (per-code maps out of + /// natural order, a few counters, and a nonzero wall-clock). + fn sample_report() -> SkeletonReport { + SkeletonReport { + in_scope_tests: 10, + in_scope_variants: 12, + expect_clean_graded: 4, + clean_pass: 4, + baselined_parsed: 8, + family_graded_variants: 7, + family_positive_variants: 3, + family_match: 5, + family_missing: 2, + missing_merge: 0, + missing_lib: 0, + missing_other: 2, + family_extra: 0, + related_match: 1, + // Inserted out of ascending order — the BTreeMap and the render must sort. + family_match_by_code: [(2528u32, 1usize), (2300, 2), (2451, 3)] + .into_iter() + .collect(), + family_missing_by_code: [(2664u32, 1usize), (2451, 1)].into_iter().collect(), + wall_ms: 123_456, + ..SkeletonReport::default() + } + } + + #[test] + fn report_json_is_deterministic() { + // Two builds from the same report serialize byte-for-byte identically (sorted + // maps, no timing) — the committed artifact must be diff-clean across re-runs. + let r = sample_report(); + let a = serde_json::to_string_pretty(&build_report_value(&r)).unwrap(); + let b = serde_json::to_string_pretty(&build_report_value(&r)).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn report_md_is_deterministic() { + let r = sample_report(); + assert_eq!(render_report_md(&r), render_report_md(&r)); + } + + #[test] + fn per_code_table_iterates_sorted() { + // The per-code table lists codes ascending regardless of map insertion order. + let md = render_report_md(&sample_report()); + let p2300 = md.find("TS2300").expect("TS2300 row"); + let p2451 = md.find("TS2451").expect("TS2451 row"); + let p2528 = md.find("TS2528").expect("TS2528 row"); + assert!( + p2300 < p2451 && p2451 < p2528, + "per-code rows not ascending" + ); + } + + #[test] + fn wall_clock_excluded_from_report() { + // Two reports differing ONLY in wall-clock produce identical JSON and Markdown — + // machine-varying timing never reaches the committed content. + let mut fast = sample_report(); + fast.wall_ms = 0; + let mut slow = sample_report(); + slow.wall_ms = 987_654_321; + assert_eq!( + serde_json::to_string_pretty(&build_report_value(&fast)).unwrap(), + serde_json::to_string_pretty(&build_report_value(&slow)).unwrap(), + ); + assert_eq!(render_report_md(&fast), render_report_md(&slow)); + } + + #[test] + fn sanitize_replaces_path_separators() { + // Both slash flavors become `_` so a `suite/config` identity is one path segment. + assert_eq!(sanitize_artifact_name("a/b\\c"), "a_b_c"); + } + + #[test] + fn sanitize_replaces_whitespace() { + assert_eq!(sanitize_artifact_name("a b\tc"), "a_b_c"); + } + + #[test] + fn sanitize_preserves_baseline_identity() { + // A real baseline name has no path-hostile chars; parens/`=`/`.` pass through. + let name = "duplicateVar(target=es2015).errors.txt"; + assert_eq!(sanitize_artifact_name(name), name); + } + + #[test] + fn sanitize_is_not_injective() { + // The mapping has no collision handling by design: distinct inputs whose only + // difference is a slash-vs-space collapse to the same basename. Pins current + // behavior (do not add collision handling here). + assert_eq!(sanitize_artifact_name("a/b"), sanitize_artifact_name("a b")); + } + + #[test] + fn report_write_requires_full_run() { + // A committed report is a full-run product: allowed unless requested on a + // filtered run. + assert!(may_write_report(false, false)); + assert!(may_write_report(false, true)); + assert!(may_write_report(true, false)); + assert!(!may_write_report(true, true)); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 268c70dbd..781660430 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -161,6 +161,29 @@ impl RunFilter { pub fn is_active(&self) -> bool { self.test.is_some() || self.code.is_some() || self.variant.is_some() } + + /// Whether a test passes the `--test` substring filter (absent filter ⇒ keep). + fn keeps_test(&self, relative_path: &str) -> bool { + self.test + .as_deref() + .is_none_or(|sub| relative_path.contains(sub)) + } + + /// Whether a variant passes the `--variant key=value` filter (absent ⇒ keep). + /// The key is already lowercased (the config maps store lowercased keys). + fn keeps_variant(&self, config: &BTreeMap) -> bool { + self.variant + .as_ref() + .is_none_or(|(k, v)| config.get(k).map(String::as_str) == Some(v.as_str())) + } + + /// Whether a variant passes the `--code` filter. `baseline_carries` reports + /// whether the variant's baseline carries a given code; it is consulted only + /// when the filter is active, so a run without `--code` never reads a baseline + /// on its behalf. Absent filter ⇒ keep. + fn keeps_code(&self, baseline_carries: impl FnOnce(u32) -> bool) -> bool { + self.code.is_none_or(baseline_carries) + } } /// Options for the skeleton sweep. @@ -370,9 +393,7 @@ fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result`): match the roundtrip identity. - if let Some(sub) = &options.filter.test - && !test.relative_path.contains(sub.as_str()) - { + if !options.filter.keeps_test(&test.relative_path) { continue; } if SKIPPED_TESTS.contains(&test.basename.as_str()) { @@ -530,13 +551,12 @@ fn grade_test( // Variant-level triage filters, applied BEFORE counting so a filtered sweep's // denominators reflect only the graded slice (any active filter skips the pins). - if let Some((k, v)) = &options.filter.variant - && variant.config.get(k).map(String::as_str) != Some(v.as_str()) - { + if !options.filter.keeps_variant(&variant.config) { continue; } - if let Some(code) = options.filter.code - && !baseline.is_some_and(|b| baseline_carries_code(b, code)) + if !options + .filter + .keeps_code(|code| baseline.is_some_and(|b| baseline_carries_code(b, code))) { continue; } @@ -1638,3 +1658,96 @@ fn fmt_diag(d: &DiagLine) -> String { _ => format!("error TS{} (global)", d.code), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A variant config from `key=value` pairs (the maps store lowercased keys). + fn config(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn keeps_test_substring() { + // No `--test` filter keeps every path; an active one keeps only substrings. + let none = RunFilter::default(); + assert!(none.keeps_test("compiler/anything.ts")); + + let f = RunFilter { + test: Some("duplicate".to_string()), + ..RunFilter::default() + }; + assert!(f.keeps_test("compiler/duplicateVar.ts")); + assert!(!f.keeps_test("compiler/asyncAwait.ts")); + } + + #[test] + fn keeps_variant_key_value() { + // No `--variant` filter keeps everything. + let none = RunFilter::default(); + assert!(none.keeps_variant(&config(&[("target", "es5")]))); + + let f = RunFilter { + variant: Some(("target".to_string(), "es2015".to_string())), + ..RunFilter::default() + }; + // Exact key=value match keeps. + assert!(f.keeps_variant(&config(&[("target", "es2015")]))); + // Wrong value excludes. + assert!(!f.keeps_variant(&config(&[("target", "es5")]))); + // Absent key excludes (the variant doesn't set it). + assert!(!f.keeps_variant(&config(&[("strict", "true")]))); + } + + #[test] + fn keeps_code_consults_baseline_only_when_active() { + // No `--code` filter keeps without ever consulting the baseline resolver + // (the closure must not run — it would panic if it did). + let none = RunFilter::default(); + assert!(none.keeps_code(|_| panic!("resolver consulted with no --code filter"))); + + let f = RunFilter { + code: Some(2300), + ..RunFilter::default() + }; + // Active filter keeps iff the baseline carries the code. + let carried = [2300u32, 2451]; + assert!(f.keeps_code(|code| carried.contains(&code))); + let other = [2451u32]; + assert!(!f.keeps_code(|code| other.contains(&code))); + // A variant with no baseline (resolver reports false) is excluded. + assert!(!f.keeps_code(|_| false)); + } + + #[test] + fn filters_compose_as_and() { + // The call site ANDs the three predicates; all must keep for a variant to be + // graded, and any one failing excludes it. + let f = RunFilter { + test: Some("dup".to_string()), + code: Some(2300), + variant: Some(("target".to_string(), "es5".to_string())), + }; + let cfg = config(&[("target", "es5")]); + let carried = [2300u32]; + let keeps = |path: &str, cfg: &BTreeMap, codes: &[u32]| { + f.keeps_test(path) && f.keeps_variant(cfg) && f.keeps_code(|c| codes.contains(&c)) + }; + // All three match. + assert!(keeps("compiler/dupVar.ts", &cfg, &carried)); + // Test substring misses. + assert!(!keeps("compiler/other.ts", &cfg, &carried)); + // Variant value misses. + assert!(!keeps( + "compiler/dupVar.ts", + &config(&[("target", "es2015")]), + &carried + )); + // Code missing from the baseline. + assert!(!keeps("compiler/dupVar.ts", &cfg, &[2451])); + } +} From 36c791e15855d06298de51468aae69bbb6a41b38 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 20:16:47 -0400 Subject: [PATCH 23/79] chore: clippy semicolon lints in binder sym.rs --- crates/tsv_check/src/binder/sym.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index d94b5fa12..873f9bf62 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -812,7 +812,7 @@ impl<'a> SymbolBinder<'a> { 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)) + self.with_block_scope(|bd| bd.bind_statement_list(b.body, true)); } Statement::IfStatement(s) => { self.visit_expression(&s.test); @@ -1756,7 +1756,7 @@ impl<'a> SymbolBinder<'a> { b.bind_params(a.params); match &a.body { tsv_ts::ast::internal::ArrowFunctionBody::Expression(e) => { - b.visit_expression(e) + b.visit_expression(e); } tsv_ts::ast::internal::ArrowFunctionBody::BlockStatement(block) => { b.bind_statement_list(block.body, true); From 1554d36bcf7bbb4bfbc02232b75db69f3c0dfd7a Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 20:23:29 -0400 Subject: [PATCH 24/79] feat: gate the lib external-no-globals invariant as a zero-gate lib channel --- crates/tsv_check/src/program.rs | 6 +- .../src/cli/commands/tsc_conformance.rs | 12 +++ crates/tsv_debug/src/tsc_conformance/libs.rs | 88 +++++++++++++++++++ .../tsv_debug/src/tsc_conformance/runner.rs | 11 +++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 63ec166d9..fd1c50d2f 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -276,8 +276,10 @@ pub fn bind_lib(name: &str, source: &str) -> Result { // (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; guard the - // no-op the census can't see. + // 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", diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index bb6e32e45..b695d0580 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -433,6 +433,18 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), report.lib_unknown_names.first().map_or("", String::as_str) )); } + // A lib that binds external with no `declare global` block folds its globals to + // nothing — the census can't otherwise see the no-op, so gate it to zero. + if !report.lib_external_no_globals.is_empty() { + errs.push(format!( + "{} lib file(s) bound external with no `declare global` block, e.g. {}", + report.lib_external_no_globals.len(), + report + .lib_external_no_globals + .first() + .map_or("", String::as_str) + )); + } // --- Exact two-sided pins (full run only; filters skip them) --- if enforce_pins { diff --git a/crates/tsv_debug/src/tsc_conformance/libs.rs b/crates/tsv_debug/src/tsc_conformance/libs.rs index 5c1b18123..e7a676073 100644 --- a/crates/tsv_debug/src/tsc_conformance/libs.rs +++ b/crates/tsv_debug/src/tsc_conformance/libs.rs @@ -234,6 +234,17 @@ fn is_no_lib(config: &BTreeMap) -> bool { .is_some_and(|v| v.eq_ignore_ascii_case("true")) } +/// Whether a bound lib contributed its globals through **nothing**: it bound as an +/// external module (so its top-level members reach module exports, not global scope, +/// leaving `source_locals` empty) yet carries no `declare global {}` block to route +/// globals back in. Such a lib silently folds to zero globals — a no-op the resolver's +/// other census counters (files bound / sets folded) can't see. `bind_lib` guards the +/// same invariant with a `debug_assert!` for fast dev feedback; this predicate backs +/// the harness gate that holds on every build. +fn binds_external_without_globals(lib: &LibFile) -> bool { + lib.merge.is_external && lib.merge.global_augmentations.is_empty() +} + /// Per-run lib resolver + cache: parses + binds each lib file once, and folds each /// distinct resolved set into a [`LibBase`] once, sharing both across variants. pub struct LibResolver { @@ -392,6 +403,22 @@ impl LibResolver { &self.unknown_libs } + /// Lib files that bound as an external module with no `declare global {}` block — + /// their globals silently fold to nothing (see [`binds_external_without_globals`]). + /// Expected empty; sorted for a deterministic gate. + #[must_use] + pub fn external_no_globals(&self) -> Vec { + let mut names: Vec = self + .file_cache + .values() + .filter_map(|slot| slot.as_deref()) + .filter(|lib| binds_external_without_globals(lib)) + .map(|lib| lib.name.clone()) + .collect(); + names.sort_unstable(); + names + } + /// Distinct lib files parsed + bound this run. #[must_use] pub fn files_bound(&self) -> usize { @@ -472,4 +499,65 @@ mod tests { let mut r = LibResolver::new(Path::new("/nonexistent")); assert!(r.resolve_set(&cfg(&[("nolib", "true")])).is_empty()); } + + /// A hand-built [`LibFile`] with a chosen module-ness and `declare global` + /// presence — enough to exercise [`binds_external_without_globals`] without + /// touching the filesystem or the binder. + fn lib_file(name: &str, is_external: bool, has_global_block: bool) -> LibFile { + use tsv_check::FileId; + use tsv_check::merge::FileMerge; + LibFile { + name: name.to_string(), + merge: FileMerge { + file: FileId::ROOT, + is_external, + source_locals: Vec::new(), + // A present-but-empty `declare global {}` block still counts (the + // guard is presence, matching `bind_lib`'s `debug_assert!`). + global_augmentations: if has_global_block { + vec![Vec::new()] + } else { + Vec::new() + }, + module_augmentations: Vec::new(), + }, + } + } + + #[test] + fn external_without_globals_is_flagged() { + // External module, no `declare global` block: globals fold to nothing — flagged. + assert!(binds_external_without_globals(&lib_file( + "lib.bad.d.ts", + true, + false + ))); + } + + #[test] + fn external_with_global_block_is_clean() { + // External module WITH a `declare global {}` block (the lib.es2025.iterator + // shape) routes globals back in — not flagged. + assert!(!binds_external_without_globals(&lib_file( + "lib.es2025.iterator.d.ts", + true, + true + ))); + } + + #[test] + fn ambient_script_lib_is_clean() { + // A plain ambient script (globals in source_locals, not external) — not flagged, + // whether or not it also carries a `declare global` block. + assert!(!binds_external_without_globals(&lib_file( + "lib.es5.d.ts", + false, + false + ))); + assert!(!binds_external_without_globals(&lib_file( + "lib.es5.d.ts", + false, + true + ))); + } } diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 781660430..767afaaec 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -324,6 +324,9 @@ pub struct SkeletonReport { pub lib_missing_files: Vec, /// Unrecognized `@lib` / `/// ` names. **Gate: must be empty.** pub lib_unknown_names: Vec, + /// Lib files that bound as an external module with no `declare global {}` block — + /// their globals would silently fold to nothing. **Gate: must be empty.** + pub lib_external_no_globals: Vec, /// Catchable-panic exclusions that no longer panic (a fix landed) — the entry /// is stale and must be dropped. **Gate: must be empty.** pub stale_exclusions: Vec, @@ -462,6 +465,7 @@ fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result Date: Fri, 10 Jul 2026 20:39:42 -0400 Subject: [PATCH 25/79] feat: manifest filtered-flag, deeper corpus probe, single lib disk read, verdict-string alignment, globalThis guard doc+test --- crates/tsv_check/src/merge.rs | 54 +++++++++++-- .../src/cli/commands/tsc_conformance.rs | 77 +++++++++++++++++-- .../src/tsc_conformance/discovery.rs | 55 +++++++++++-- crates/tsv_debug/src/tsc_conformance/libs.rs | 67 ++++++++-------- .../tsv_debug/src/tsc_conformance/runner.rs | 5 +- 5 files changed, 206 insertions(+), 52 deletions(-) diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 15d7dc70f..2e805c507 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -430,9 +430,11 @@ fn merge_symbol_against_base( return; } if base.flags.intersects(SymbolFlags::NAMESPACE_MODULE) { - // A value merging into a non-instantiated namespace (TS2649) — but never - // when the base is the built-in `globalThis` (the phase-1 TS2397 already - // reports that, and a second TS2649 would not make sense; the Part A guard). + // 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() { @@ -490,10 +492,15 @@ fn merge_symbol(target: &mut GlobalEntry, source: &MergeSymbol, out: &mut MergeO 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 the built-in - // `globalThis` (tsgo `mergeSymbol`'s `target != globalThisSymbol` guard): - // the phase-1 TS2397 already reports that conflict, and a second TS2649 - // would not make sense. + // 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() { @@ -1210,6 +1217,39 @@ mod tests { 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] diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index b695d0580..409992097 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -339,7 +339,7 @@ impl RunCommand { match enforce_run_gates(&report, !filtered) { Ok(()) => { if let Some(path) = &self.emit_manifest { - write_manifest(&report, path)?; + write_manifest(&report, &options.filter, path)?; } if let Some(path) = &self.report { write_report(&report, path)?; @@ -705,20 +705,57 @@ fn run_pins() -> RunPins { } } -/// The `--emit-manifest` wrapper: the full per-variant report plus the pins snapshot. +/// The active triage filters echoed into a filtered manifest, so a consumer sees +/// exactly which slice it was run over. Absent on a full (unfiltered) run. +#[derive(serde::Serialize)] +struct ManifestFilters { + #[serde(skip_serializing_if = "Option::is_none")] + test: Option, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + variant: Option, +} + +/// The `--emit-manifest` wrapper: the per-variant report, the pins snapshot, and a +/// `filtered` marker (plus the echoed filters). A triage-filtered manifest holds only +/// a partial slice of variant rows and its pins were NOT enforced, so the marker keeps +/// a consumer from mistaking it for a full-run one. #[derive(serde::Serialize)] struct RunManifest<'a> { + filtered: bool, + #[serde(skip_serializing_if = "Option::is_none")] + filters: Option, pins: RunPins, report: &'a SkeletonReport, } -/// Write the `--emit-manifest` JSON (per-variant verdicts + buckets + census + pins). -/// Called only after the gates pass, so a bad manifest never lands. -fn write_manifest(report: &SkeletonReport, path: &Path) -> Result<(), CliError> { - let manifest = RunManifest { +/// Assemble the manifest wrapper, marking whether the run was triage-filtered and +/// echoing the active filters (`--variant key=value` re-joined for display). +fn run_manifest<'a>(report: &'a SkeletonReport, filter: &RunFilter) -> RunManifest<'a> { + let filtered = filter.is_active(); + let filters = filtered.then(|| ManifestFilters { + test: filter.test.clone(), + code: filter.code, + variant: filter.variant.as_ref().map(|(k, v)| format!("{k}={v}")), + }); + RunManifest { + filtered, + filters, pins: run_pins(), report, - }; + } +} + +/// Write the `--emit-manifest` JSON (per-variant verdicts + buckets + census + pins + +/// the `filtered` marker). Called only after the gates pass, so a bad manifest never +/// lands. +fn write_manifest( + report: &SkeletonReport, + filter: &RunFilter, + path: &Path, +) -> Result<(), CliError> { + let manifest = run_manifest(report, filter); let file = std::fs::File::create(path).map_err(|e| { eprintln!("Error creating manifest {}: {e}", path.display()); CliError::Failed @@ -1532,4 +1569,30 @@ mod tests { assert!(may_write_report(true, false)); assert!(!may_write_report(true, true)); } + + #[test] + fn manifest_marks_and_echoes_filtered_runs() { + // A full (unfiltered) run: `filtered` is false and no `filters` object is + // emitted — nothing distinguishes it from a plain full-run manifest. + let r = sample_report(); + let full = serde_json::to_value(run_manifest(&r, &RunFilter::default())).unwrap(); + assert_eq!(full["filtered"], serde_json::json!(false)); + assert!(full.get("filters").is_none()); + + // A triage-filtered run: `filtered` is true and the active filters are echoed + // (an unset filter — here `--test` — is omitted; `--variant` is re-joined). + let filter = RunFilter { + test: None, + code: Some(2300), + variant: Some(("target".to_string(), "es2015".to_string())), + }; + let filtered = serde_json::to_value(run_manifest(&r, &filter)).unwrap(); + assert_eq!(filtered["filtered"], serde_json::json!(true)); + assert_eq!(filtered["filters"]["code"], serde_json::json!(2300)); + assert_eq!( + filtered["filters"]["variant"], + serde_json::json!("target=es2015") + ); + assert!(filtered["filters"].get("test").is_none()); + } } diff --git a/crates/tsv_debug/src/tsc_conformance/discovery.rs b/crates/tsv_debug/src/tsc_conformance/discovery.rs index 45a4782d4..05873b485 100644 --- a/crates/tsv_debug/src/tsc_conformance/discovery.rs +++ b/crates/tsv_debug/src/tsc_conformance/discovery.rs @@ -28,14 +28,24 @@ pub fn baselines_dir(checkout: &Path) -> PathBuf { checkout.join(BASELINES_SUBDIR) } -/// Whether the corpus *input* submodule is materialized (has any entries). +/// Whether the corpus *input* submodule is materialized (its known test tree is +/// populated). /// -/// The core queries never touch it, but precise JSX detection and the (deferred) -/// pin-diff query would — so callers note when it's empty and skip rather than -/// pretend. A missing or empty directory both read as not materialized. +/// The core queries never touch it, but the `index` / `run` legs (and precise JSX +/// detection) do — so callers note when it's absent and skip rather than pretend. A +/// bare submodule directory can hold only a `.git` gitlink or a stray file on a +/// partial checkout, so the probe reaches for a **known corpus directory** +/// (`tests/cases/compiler`) and requires real entries: a missing, empty, or +/// partially-checked-out submodule all read as not materialized. pub fn corpus_materialized(checkout: &Path) -> bool { - let dir = checkout.join(CORPUS_SUBDIR); - fs::read_dir(&dir).is_ok_and(|mut it| it.next().is_some()) + // `_submodules/TypeScript/tests/cases/compiler` — the largest corpus suite; a + // materialized submodule always populates it, a partial one does not. + let cases_compiler = checkout + .join(CORPUS_SUBDIR) + .join("tests") + .join("cases") + .join("compiler"); + fs::read_dir(&cases_compiler).is_ok_and(|mut it| it.next().is_some()) } /// Walk the baselines directory and discover every `.errors.txt` file, sorted by @@ -90,3 +100,36 @@ fn discover_recursive(dir: &Path, root: &Path, out: &mut Vec) -> Resul Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corpus_materialized_requires_the_known_case_tree() { + let root = std::env::temp_dir().join(format!("tsv_corpus_probe_{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + + // Absent submodule → not materialized. + assert!(!corpus_materialized(&root)); + + // A bare submodule dir with only a stray file (a partial checkout) → still not + // materialized: the deep `tests/cases/compiler` tree is what counts, so a + // gitlink-or-README-only checkout can't pass. + let submodule = root.join(CORPUS_SUBDIR); + fs::create_dir_all(&submodule).unwrap(); + fs::write(submodule.join("README.md"), "partial\n").unwrap(); + assert!(!corpus_materialized(&root)); + + // An empty `tests/cases/compiler` dir → not materialized (no test entries). + let cases = submodule.join("tests").join("cases").join("compiler"); + fs::create_dir_all(&cases).unwrap(); + assert!(!corpus_materialized(&root)); + + // A populated corpus suite → materialized. + fs::write(cases.join("someTest.ts"), "export {};\n").unwrap(); + assert!(corpus_materialized(&root)); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/libs.rs b/crates/tsv_debug/src/tsc_conformance/libs.rs index e7a676073..103730b3e 100644 --- a/crates/tsv_debug/src/tsc_conformance/libs.rs +++ b/crates/tsv_debug/src/tsc_conformance/libs.rs @@ -327,41 +327,48 @@ impl LibResolver { closure } - /// The cached `/// ` names of a lib file. - fn references_of(&mut self, file: &str) -> &[String] { - if !self.ref_cache.contains_key(file) { - let refs = match std::fs::read_to_string(self.libs_dir.join(file)) { - Ok(src) => extract_lib_references(&src), - Err(_) => { - self.missing_files.push(file.to_string()); - Vec::new() - } - }; - self.ref_cache.insert(file.to_string(), refs); + /// Read + bind a lib file exactly once, caching both its bound product and its + /// `/// ` names. A single disk read feeds both the reference scan + /// (`references_of`, during set resolution) and the bind (`bound_file`, during the + /// base fold) — which target the same files — so each lib file hits the disk once + /// per run. A read failure records the file as missing (once) and caches an empty + /// reference list + a `None` product; a bind failure records the parse error and + /// caches `None`. + fn ensure_loaded(&mut self, file: &str) { + if self.file_cache.contains_key(file) { + return; } - &self.ref_cache[file] - } - - /// The cached bound product of a lib file (parse + bind once). - fn bound_file(&mut self, file: &str) -> Option> { - if let Some(cached) = self.file_cache.get(file) { - return cached.clone(); - } - let result = match std::fs::read_to_string(self.libs_dir.join(file)) { - Ok(src) => match bind_lib(file, &src) { - Ok(lf) => Some(Rc::new(lf)), - Err(e) => { - self.parse_errors.push((file.to_string(), e)); - None - } - }, + let (bound, refs) = match std::fs::read_to_string(self.libs_dir.join(file)) { + Ok(src) => { + let refs = extract_lib_references(&src); + let bound = match bind_lib(file, &src) { + Ok(lf) => Some(Rc::new(lf)), + Err(e) => { + self.parse_errors.push((file.to_string(), e)); + None + } + }; + (bound, refs) + } Err(_) => { self.missing_files.push(file.to_string()); - None + (None, Vec::new()) } }; - self.file_cache.insert(file.to_string(), result.clone()); - result + self.ref_cache.insert(file.to_string(), refs); + self.file_cache.insert(file.to_string(), bound); + } + + /// The cached `/// ` names of a lib file (read + bound once). + fn references_of(&mut self, file: &str) -> &[String] { + self.ensure_loaded(file); + &self.ref_cache[file] + } + + /// The cached bound product of a lib file (read + bound once). + fn bound_file(&mut self, file: &str) -> Option> { + self.ensure_loaded(file); + self.file_cache[file].clone() } /// The [`LibBase`] for a variant config, built once per distinct resolved set. diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 767afaaec..0493354dc 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -221,7 +221,8 @@ pub struct FailingVariant { pub suite: String, /// The joined baseline name (the artifact basename). pub config: String, - /// Why it failed (`family_extra` / `span_mismatch` / `clean_fail` / `panic`). + /// Why it failed — the same vocabulary as the per-variant verdict + /// (`family_extra` / `family_span_mismatch` / `clean_fail` / `panic`). pub reason: &'static str, /// The rendered ours-vs-baseline text (file-artifact only — not in `--json`). #[serde(skip)] @@ -936,7 +937,7 @@ fn grade_family( let reason = if buckets.extra > 0 { "family_extra" } else { - "span_mismatch" + "family_span_mismatch" }; report.failing_variants.push(FailingVariant { suite: test.suite.to_string(), From bfa4888913e608d94c0006c303926da3a3f47497 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 20:49:16 -0400 Subject: [PATCH 26/79] chore: sort lib parse-error/missing-file triage lists for deterministic output --- .../tsv_debug/src/tsc_conformance/runner.rs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 0493354dc..4be5e77de 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -454,12 +454,20 @@ fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result = resolver + .parse_errors() + .iter() + .map(|(f, e)| format!("{f}: {e}")) + .collect(); + errors.sort_unstable(); + errors + }; + report.lib_missing_files = { + let mut files: Vec = resolver.missing_files().to_vec(); + files.sort_unstable(); + files + }; report.lib_unknown_names = { let mut names: Vec = resolver.unknown_libs().to_vec(); names.sort_unstable(); From 9fbdb591b021e368d93a57744814ab7a0f5348ba Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 21:08:58 -0400 Subject: [PATCH 27/79] docs: describe the landed family/lib gates in harness comments and run output; drop slice labels --- crates/tsv_check/src/merge.rs | 2 +- .../src/cli/commands/tsc_conformance.rs | 62 +++++++++--------- crates/tsv_debug/src/tsc_conformance/libs.rs | 2 +- .../tsv_debug/src/tsc_conformance/runner.rs | 64 ++++++++++--------- 4 files changed, 69 insertions(+), 61 deletions(-) diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 2e805c507..4f8d96fbf 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -336,7 +336,7 @@ pub fn merge_program( // --- Phase 3: the `undefined` redeclaration check --- // tsgo seeds `c.globals["undefined"]` with the builtin `undefinedSymbol`; with - // no lib (S5) globals["undefined"] is present iff a file declared it, so a + // 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 { diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 409992097..361595845 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -68,13 +68,12 @@ const INDEX_JOIN_MATCHED_PIN: usize = 7033; const INDEX_UNIT_ROUNDTRIP_PIN: usize = 7019; const INDEX_UNIT_ROUNDTRIP_PRETTY_PIN: usize = 14; -/// REGRESSION PINS (exact, two-sided) for the walking-skeleton sweep (`run`). +/// REGRESSION PINS (exact, two-sided) for the conformance sweep (`run`). /// Measured 2026-07-10, ../typescript-go at 168e7015 (`_submodules/TypeScript` -/// corpus materialized). The checker emits nothing yet, so the meaningful gate -/// is `clean_pass == expect_clean_graded` with zero panics; the counts below pin -/// the in-scope denominators + parse-divergence census so any drift (a -/// harness-port change, a tsv parser change, or a typescript-go pull) forces a -/// deliberate re-pin. +/// corpus materialized). `clean_pass == expect_clean_graded` and zero panics +/// are invariant gates; the counts below pin the in-scope denominators + +/// parse-divergence census so any drift (a harness-port change, a tsv parser +/// change, or a typescript-go pull) forces a deliberate re-pin. const RUN_IN_SCOPE_TESTS_PIN: usize = 9388; const RUN_IN_SCOPE_VARIANTS_PIN: usize = 9887; const RUN_EXPECT_CLEAN_PIN: usize = 4435; @@ -93,14 +92,15 @@ const RUN_CRASH_EXCLUDED_PIN: usize = 1; /// gate). Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 /// (hard); the rest pin the buckets so any move (a cascade change, a merge /// change, a tsv parser change, a typescript-go pull) forces a deliberate re-pin. -/// The missing bucket is classified: `merge` (merge-phase family — **0**, S4 -/// closed the single-file merge path: TS2397 globalThis/undefined + TS2664 -/// augmentation-not-found), `lib` (absent-lib conflicts — **now 0**, S5 closed the -/// four classified lib-conflict misses: TS2300 eval/Symbol/Promise/ElementTagNameMap -/// against the standard-library globals), and `check-time` (checker-emitted -/// TS2300/2451 the bind+merge slice can't produce — duplicate members, type -/// parameters, computed/private names). A drop in `check-time` (matches gained) is a -/// real improvement that re-pins; a rise anywhere is a regression to explain. +/// The missing bucket is classified: `merge` (merge-phase family — **0**: the +/// single-file merge path matches, TS2397 globalThis/undefined + TS2664 +/// augmentation-not-found), `lib` (absent-lib conflicts — **0**: the classified +/// lib-conflict baselines, TS2300 eval/Symbol/Promise/ElementTagNameMap, match +/// against the standard-library globals), and `check-time` (the deferred +/// remainder — duplicate members, type parameters, computed/private names — +/// family instances the current bind+merge implementation does not yet emit). +/// A drop in `check-time` (matches gained) is a real improvement that re-pins; +/// a rise anywhere is a regression to explain. const RUN_FAMILY_GRADED_PIN: usize = 4066; const RUN_FAMILY_POSITIVE_PIN: usize = 125; const RUN_FAMILY_MATCH_PIN: usize = 425; @@ -113,13 +113,14 @@ const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; const RUN_MODULE_DETECTION_PIN: usize = 1; -/// REGRESSION PINS (exact, two-sided) for the lib base (S5). Measured 2026-07-10 vs +/// REGRESSION PINS (exact, two-sided) for the lib base. Measured 2026-07-10 vs /// pin 168e7015 (`_submodules/TypeScript` corpus materialized): the distinct lib /// `.d.ts` files parsed+bound and the distinct resolved lib sets folded across the /// in-scope variants. A move is a deliberate re-pin (a harness-port change, a lib -/// set change, or a typescript-go pull). The three error channels are gated to -/// empty (a lib parse-reject, a missing referenced lib, or an unrecognized -/// `@lib`/reference name — all expected never on the pinned checkout). +/// set change, or a typescript-go pull). The four error channels are gated to +/// empty (a lib parse-reject, a missing referenced lib, an unrecognized +/// `@lib`/reference name, or a lib binding external with no `declare global` +/// block — all expected never on the pinned checkout). const RUN_LIB_FILES_BOUND_PIN: usize = 107; const RUN_LIB_SETS_PIN: usize = 50; @@ -202,7 +203,7 @@ pub struct RoundtripCommand { filters: Vec, } -/// Corpus-input self-check (the S1 gates): index the tsc corpus, expand every +/// Corpus-input self-check (the index gates): index the tsc corpus, expand every /// test's varyBy variants, and prove three invariants against the on-disk /// baselines — the join (every baseline maps to one non-skipped variant), the /// unit-text round-trip (units reproduce the `====` section bodies), and the @@ -224,14 +225,16 @@ pub struct IndexCommand { verbose: bool, } -/// Walking-skeleton sweep (the S2 gate): drive `tsv_check` over every in-scope -/// variant (single-file, non-JSX, non-JS-flavored, not skipped, not an -/// unsupported-option variant) and grade the checker plumbing end-to-end. The -/// checker emits nothing yet, so the gate is: every expect-clean in-scope -/// variant grades clean (zero diagnostics), zero panics, and the pinned -/// denominators + parse-divergence census hold. Runs on a generous-stack worker -/// thread; each test's check is `catch_unwind`-contained. Exit 0 only when the -/// invariants hold and (on a full run) the pins match. +/// Conformance sweep: drive `tsv_check` over every in-scope variant +/// (single-file, non-JSX, non-JS-flavored, not skipped, not an +/// unsupported-option variant) and grade it against tsgo's baselines. The +/// gates: every expect-clean in-scope variant grades clean (zero diagnostics); +/// the bind/merge duplicate-conflict family matches as codes+spans multisets +/// (extra = 0 hard, missing classified by deferred cause); the related-info +/// and lib-base channels stay clean; zero panics; and (on a full run) the +/// pinned denominators + parse-divergence census hold. Runs on a +/// generous-stack worker thread; each test's check is `catch_unwind`-contained. +/// Exit 0 only when the invariants hold and (on a full run) the pins match. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "run")] pub struct RunCommand { @@ -411,7 +414,8 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), )); } // The lib error channels must stay empty (a lib parse-reject, a missing referenced - // lib, or an unrecognized `@lib`/reference name). + // lib, an unrecognized `@lib`/reference name, or a lib binding external with no + // `declare global` block). if !report.lib_parse_errors.is_empty() { errs.push(format!( "{} lib file(s) failed to parse, e.g. {}", @@ -520,7 +524,7 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), RUN_CRASH_EXCLUDED_PIN, ); - // Lib-base (S5) sizing pins. + // Lib-base sizing pins. pin( &mut errs, "lib files bound", diff --git a/crates/tsv_debug/src/tsc_conformance/libs.rs b/crates/tsv_debug/src/tsc_conformance/libs.rs index 103730b3e..520d8ba6e 100644 --- a/crates/tsv_debug/src/tsc_conformance/libs.rs +++ b/crates/tsv_debug/src/tsc_conformance/libs.rs @@ -1,4 +1,4 @@ -//! Lib (`.d.ts`) resolution + a per-run [`tsv_check::LibBase`] cache — the S5 seam +//! Lib (`.d.ts`) resolution + a per-run [`tsv_check::LibBase`] cache — the layer //! that lets the checker leg conflict a test's globals against the standard library. //! //! Ported from tsgo's file loader: a variant's resolved lib set is its default lib diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 4be5e77de..fe32218fa 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -1,20 +1,23 @@ -//! The walking-skeleton runner: drive `tsv_check` over the in-scope corpus and -//! grade the checker plumbing end-to-end. +//! The conformance runner: drive `tsv_check` over the in-scope corpus and +//! grade it against tsgo's committed `.errors.txt` baselines. //! -//! This is tool #4 of the harness (the runner) in its first form. It reuses the -//! S1 substrate — corpus index, directive parser, variant expansion, the -//! unsupported-option skip classes — and adds the checker leg: for every -//! **in-scope** variant (single-file, non-JSX, non-JS-flavored, not skipped, -//! not an unsupported-option variant) it parses the unit via `tsv_check`'s goal -//! rule, runs `check_program`, and grades the result. The checker emits nothing -//! yet, so the meaningful gate is that every **expect-clean** in-scope variant -//! (one with no on-disk baseline) grades clean (zero diagnostics) with zero -//! panics — proving the harness<->checker plumbing before any family gate. +//! The runner layers the checker leg on the corpus substrate — corpus index, +//! directive parser, variant expansion, the unsupported-option skip classes: +//! for every **in-scope** variant (single-file, non-JSX, non-JS-flavored, not +//! skipped, not an unsupported-option variant) it parses the unit via +//! `tsv_check`'s goal rule, binds, merges against the variant's lib base, and +//! grades the result on three channels. Every **expect-clean** in-scope +//! variant (one with no on-disk baseline) must grade clean (zero diagnostics); +//! the bind/merge duplicate-conflict **family** ([`FAMILY_CODES`]) is compared +//! as codes+spans multisets — extra = 0 is the hard gate, missing is +//! classified by deferred cause; and **related-info** on matched family +//! primaries is graded as its own channel. Zero panics, always. //! //! A single-file test's variants all parse identically (the goal rule is -//! directive-independent), so the parse+check runs **once per test** and its -//! outcome is attributed to each in-scope variant — correct while `check` is a -//! no-op and cheap regardless. +//! directive-independent), so parse+bind runs **once per test** +//! (`bind_program`) and merge+check runs once per distinct lib set among its +//! variants (`check_bound`), with the outcome attributed to each in-scope +//! variant. //! //! The **parse-divergence census** (informational, not gated) counts in-scope //! variants tsv parse-rejects, split by baseline shape (none / TS1xxx-only / @@ -50,14 +53,14 @@ use std::time::Instant; use tsv_check::{Diagnostic, ParseReport, SourceUnit, bind_program, check_bound, check_program}; use tsv_lang::{LocationMapper, LocationTracker}; -/// The bind-time duplicate/conflict family this slice grades: TS2300 (duplicate +/// The bind/merge duplicate/conflict family the gate grades: TS2300 (duplicate /// identifier), TS2451 (block-scoped redeclare), TS2567 (enum-merge), TS2528 /// (multiple default exports), plus the merge-path codes TS2397/2649/2664/2671 -/// (emitted only from the merge phase, out of this slice — they land as *misses*). +/// (emitted from the globals-merge phase rather than the same-table cascade). const FAMILY_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; -/// The merge-path family codes — a *missing* of one of these is a merge-phase gap -/// (S4), not a same-table cascade bug. +/// The merge-path family codes — a *missing* of one of these is classified as a +/// merge-phase gap, not a same-table cascade bug. const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; /// The TS1xxx codes the binder itself emits (strict-mode + private-identifier @@ -67,10 +70,11 @@ const BIND_EMITTED_TS1XXX: [u32; 12] = [ 1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012, ]; -/// The P1-family baselines whose family diagnostics come from a standard-library -/// conflict (S5). These now **match** via the lib base; the classifier is kept as a -/// regression guard — a *missing* in one of these buckets to `missing_lib` (pinned -/// 0) rather than `missing_other`, so a lib-detection regression fails loudly. +/// The family baselines whose family diagnostics come from a standard-library +/// conflict. These **match** via the lib base; the classifier is kept as a +/// regression guard — a *missing* in one of these is bucketed to `missing_lib` +/// (pinned 0) rather than `missing_other`, so a lib-detection regression fails +/// loudly. const LIB_CONFLICT_BASELINES: [&str; 5] = [ "intersectionsOfLargeUnions2.ts", "jsExportMemberMergedWithModuleAugmentation2.ts", @@ -247,7 +251,7 @@ pub struct SkeletonReport { /// In-scope variants that parsed and DO have a baseline. pub baselined_parsed: usize, - // --- family grading (the S3 gate) --- + // --- family grading --- /// Parsed-with-baseline variants family-graded (not carved by predicate v1). pub family_graded_variants: usize, /// ...of those, whose baseline carries at least one family code. @@ -314,7 +318,7 @@ pub struct SkeletonReport { /// Tests skipped by the crash-exclusion ledger (tracked parser aborts/panics). pub excluded_crashes: usize, - // --- lib base (S5) --- + // --- lib base --- /// Distinct lib `.d.ts` files parsed + bound this run (informational). pub lib_files_bound: usize, /// Distinct resolved lib sets folded into a base this run (informational). @@ -1280,8 +1284,8 @@ fn baseline_shape(baseline: Option<&Baseline>) -> BaselineShape { impl SkeletonReport { /// Print the human summary. pub fn print(&self) { - println!("tsc_conformance — walking skeleton"); - println!("=================================="); + println!("tsc_conformance run"); + println!("==================="); println!("In-scope tests: {}", self.in_scope_tests); println!("In-scope variants: {}", self.in_scope_variants); println!(" parsed, expect-clean: {}", self.expect_clean_graded); @@ -1310,10 +1314,10 @@ impl SkeletonReport { ); println!(" match: {}", self.family_match); println!(" missing: {}", self.family_missing); - println!(" merge-path (S4): {}", self.missing_merge); - println!(" lib-conflict (S5): {}", self.missing_lib); + println!(" merge-path: {}", self.missing_merge); + println!(" lib-conflict: {}", self.missing_lib); println!( - " check-time (S3+): {} (checker-emitted TS2300/2451: duplicate members, type params, computed/private names)", + " check-time: {} (deferred family misses — duplicate members, type params, computed/private names)", self.missing_other ); println!(" extra (GATE=0): {}", self.family_extra); @@ -1348,7 +1352,7 @@ impl SkeletonReport { println!(" SPAN {s}"); } println!(); - println!("Lib base (S5)"); + println!("Lib base"); println!(" lib files bound: {}", self.lib_files_bound); println!(" lib sets folded: {}", self.lib_sets_built); println!( From 1d2d1137be03608b643ee4e614d9ce7d78b295c7 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 22:52:27 -0400 Subject: [PATCH 28/79] =?UTF-8?q?feat:=20bind=20signature=20params,=20obje?= =?UTF-8?q?ct-literal=20methods,=20dotted-namespace=20merge=20(family=20mi?= =?UTF-8?q?ssing=20125=E2=86=92105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/src/binder/mod.rs | 55 +++++ crates/tsv_check/src/binder/sym.rs | 207 +++++++++++++++--- .../src/cli/commands/tsc_conformance.rs | 6 +- 3 files changed, 234 insertions(+), 34 deletions(-) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 962824a7e..e166905b7 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -692,6 +692,61 @@ mod tests { ); } + #[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 (they are a + // check-time TS2300, out of the bind/merge family). + assert!(diag_codes("var a: { x: number; x: string; };").is_empty()); + } + + #[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 diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index 873f9bf62..180f325f4 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -72,8 +72,9 @@ use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ ClassBody, ClassMember, ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, Identifier, ImportSpecifier, Literal, LiteralValue, MethodKind, ModuleExportName, - ObjectPatternProperty, Statement, TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, - TSModuleName, TSTypeElement, TSTypeParameterDeclaration, + ObjectExpression, ObjectPatternProperty, ObjectProperty, PropertyKind, Statement, + TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSType, TSTypeAnnotation, + TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration, }; /// The container kinds that route member declarations (a subset of tsgo's node @@ -1189,6 +1190,14 @@ impl<'a> SymbolBinder<'a> { } 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 { @@ -1483,27 +1492,98 @@ impl<'a> SymbolBinder<'a> { // `export default interface` with no container symbol: nothing to bind. } - fn bind_type_element(&mut self, element: &TSTypeElement<'a>) { - let (key_expr, computed, span, inc, exc) = match element { - TSTypeElement::PropertySignature(p) => ( - &p.key, - p.computed, - p.span, - SymbolFlags::PROPERTY, - SymbolFlags::PROPERTY_EXCLUDES, - ), - TSTypeElement::MethodSignature(m) => ( - &m.key, - m.computed, - m.span, - SymbolFlags::METHOD, - SymbolFlags::METHOD_EXCLUDES, - ), - // Call/construct/index signatures are anonymous (Signature, no conflict). - TSTypeElement::CallSignature(_) - | TSTypeElement::ConstructSignature(_) - | TSTypeElement::IndexSignature(_) => return, + // --- type annotations ---------------------------------------------------- + + /// Descend a binding's type annotation. + 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. + 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, + ); + } + 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)); + } + // 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)); + } + TSTypeElement::ConstructSignature(c) => { + self.with_function_scope(c.type_parameters.as_ref(), |b| b.bind_params(c.params)); + } + 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, @@ -1514,7 +1594,6 @@ impl<'a> SymbolBinder<'a> { exported: false, node: NodeId::FIRST, }; - let _ = span; self.declare_in_container(d, inc, exc); } } @@ -1632,7 +1711,24 @@ impl<'a> SymbolBinder<'a> { self.bind_statement_list(block.body, true); } Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => { - self.bind_module(nested, DeclMods::default()); + // 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 => {} } @@ -1824,13 +1920,7 @@ impl<'a> SymbolBinder<'a> { self.visit_expression(e); } } - E::ObjectExpression(o) => { - for p in o.properties { - if let tsv_ts::ast::internal::ObjectProperty::Property(pr) = p { - self.visit_expression(&pr.value); - } - } - } + E::ObjectExpression(o) => self.bind_object_expression(o), E::TemplateLiteral(t) => { for e in t.expressions { self.visit_expression(e); @@ -1846,6 +1936,61 @@ impl<'a> SymbolBinder<'a> { } } + // --- 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), + } + } + } + // --- member keys --------------------------------------------------------- fn resolve_member_key( diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 361595845..fd29a7d31 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -103,11 +103,11 @@ const RUN_CRASH_EXCLUDED_PIN: usize = 1; /// a rise anywhere is a regression to explain. const RUN_FAMILY_GRADED_PIN: usize = 4066; const RUN_FAMILY_POSITIVE_PIN: usize = 125; -const RUN_FAMILY_MATCH_PIN: usize = 425; -const RUN_FAMILY_MISSING_PIN: usize = 125; +const RUN_FAMILY_MATCH_PIN: usize = 445; +const RUN_FAMILY_MISSING_PIN: usize = 105; const RUN_MISSING_MERGE_PIN: usize = 0; const RUN_MISSING_LIB_PIN: usize = 0; -const RUN_MISSING_CHECKTIME_PIN: usize = 125; +const RUN_MISSING_CHECKTIME_PIN: usize = 105; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; From f01a24f0a6a9385e605ae5db52d3b59fa6e8fb41 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 23:49:07 -0400 Subject: [PATCH 29/79] =?UTF-8?q?feat:=20check-time=20duplicate-member=20p?= =?UTF-8?q?ass=20(checkObjectTypeForDuplicateDeclarations)=20=E2=80=94=20f?= =?UTF-8?q?amily=20missing=20105=E2=86=9232?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/results/report.tsc-conformance.json | 16 +- benches/js/results/report.tsc-conformance.md | 6 +- crates/tsv_check/src/binder/mod.rs | 59 +- crates/tsv_check/src/binder/sym.rs | 8 +- .../tsv_check/src/check/duplicate_members.rs | 466 +++++++++++++ crates/tsv_check/src/check/mod.rs | 634 ++++++++++++++++++ crates/tsv_check/src/lib.rs | 6 +- crates/tsv_check/src/program.rs | 17 +- .../src/cli/commands/tsc_conformance.rs | 32 +- .../tsv_debug/src/tsc_conformance/runner.rs | 2 +- 10 files changed, 1200 insertions(+), 46 deletions(-) create mode 100644 crates/tsv_check/src/check/duplicate_members.rs create mode 100644 crates/tsv_check/src/check/mod.rs diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json index ff6d07a91..54a3bd49c 100644 --- a/benches/js/results/report.tsc-conformance.json +++ b/benches/js/results/report.tsc-conformance.json @@ -10,19 +10,19 @@ "family_positive_variants": 125 }, "family": { - "match": 425, + "match": 518, "missing": { - "total": 125, + "total": 32, "merge_path": 0, "lib_conflict": 0, - "check_time": 125 + "check_time": 32 }, "extra": 0, "span_mismatch": 0 }, "per_code": { "match": { - "2300": 301, + "2300": 394, "2397": 4, "2451": 56, "2528": 35, @@ -30,7 +30,7 @@ "2664": 3 }, "missing": { - "2300": 125 + "2300": 32 } }, "related": { @@ -64,11 +64,11 @@ "parse_rejected": 1006, "family_graded": 4066, "family_positive": 125, - "family_match": 425, - "family_missing": 125, + "family_match": 518, + "family_missing": 32, "missing_merge": 0, "missing_lib": 0, - "missing_check_time": 125, + "missing_check_time": 32, "family_extra": 0, "family_span_mismatch": 0, "related_match": 51, diff --git a/benches/js/results/report.tsc-conformance.md b/benches/js/results/report.tsc-conformance.md index a844bf591..8776537f0 100644 --- a/benches/js/results/report.tsc-conformance.md +++ b/benches/js/results/report.tsc-conformance.md @@ -12,8 +12,8 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis ## Family (2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671) -- match: 425 -- missing: 125 (merge-path 0, lib-conflict 0, check-time 125) +- match: 518 +- missing: 32 (merge-path 0, lib-conflict 0, check-time 32) - extra (GATE=0): 0 - span mismatch: 0 @@ -21,7 +21,7 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis | code | match | missing | | --- | --- | --- | -| TS2300 | 301 | 125 | +| TS2300 | 394 | 32 | | TS2397 | 4 | 0 | | TS2451 | 56 | 0 | | TS2528 | 35 | 0 | diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index e166905b7..54f82291a 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -715,16 +715,22 @@ mod tests { // 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 (they are a - // check-time TS2300, out of the bind/merge family). - assert!(diag_codes("var a: { x: number; x: string; };").is_empty()); + // 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]); + 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. @@ -770,6 +776,51 @@ mod tests { } } + #[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 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 diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index 180f325f4..be154a5d1 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -73,8 +73,8 @@ use tsv_ts::ast::internal::{ ClassBody, ClassMember, ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, Identifier, ImportSpecifier, Literal, LiteralValue, MethodKind, ModuleExportName, ObjectExpression, ObjectPatternProperty, ObjectProperty, PropertyKind, Statement, - TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSType, TSTypeAnnotation, - TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration, + TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSType, + TSTypeAnnotation, TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration, }; /// The container kinds that route member declarations (a subset of tsgo's node @@ -1968,7 +1968,9 @@ impl<'a> SymbolBinder<'a> { SymbolFlags::SET_ACCESSOR, SymbolFlags::SET_ACCESSOR_EXCLUDES, ), - PropertyKind::Init if pr.method => (SymbolFlags::METHOD, SymbolFlags::VALUE), + PropertyKind::Init if pr.method => { + (SymbolFlags::METHOD, SymbolFlags::VALUE) + } PropertyKind::Init => { (SymbolFlags::PROPERTY, SymbolFlags::PROPERTY_EXCLUDES) } 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..e43ff8c07 --- /dev/null +++ b/crates/tsv_check/src/check/duplicate_members.rs @@ -0,0 +1,466 @@ +//! 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::hash::FxHashMap; +use crate::ids::FileId; +use string_interner::DefaultStringInterner; +use tsv_lang::Span; +use tsv_ts::ast::internal::{ + ClassMember, Expression, Literal, LiteralValue, MethodKind, TSTypeElement, +}; + +/// The per-body derivation context (source + interner for names, file for spans). +pub(super) struct MemberCtx<'a> { + pub source: &'a str, + pub interner: &'a DefaultStringInterner, + 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 key string, 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, + 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 { + key, + span, + is_static: false, + classify: Some(MemberKind::Property), + ctor_param: true, + }); + } + } + } + MethodKind::Method => { + if let Some((key, span)) = member_key(ctx, &m.key, m.computed) { + entries.push(Entry { + key, + span, + is_static: m.is_static, + classify: None, + ctor_param: false, + }); + } + } + MethodKind::Get | MethodKind::Set => { + if let Some((key, span)) = member_key(ctx, &m.key, m.computed) { + entries.push(Entry { + key, + span, + is_static: m.is_static, + classify: Some(MemberKind::Accessor), + ctor_param: false, + }); + } + } + }, + ClassMember::PropertyDefinition(p) => { + if let Some((key, span)) = member_key(ctx, &p.key, p.computed) { + let classify = if p.accessor { + MemberKind::Accessor + } else { + MemberKind::Property + }; + entries.push(Entry { + key, + 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, span)) = member_key(ctx, &p.key, p.computed) { + entries.push(Entry { + key, + span, + is_static: false, + classify: Some(MemberKind::Property), + ctor_param: false, + }); + } + } + TSTypeElement::MethodSignature(m) => { + if let Some((key, 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, + 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.key)); + } + } +} + +/// 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(), + } +} + +/// Derive a member's canonical key string and the span its diagnostic points at +/// (the `member.Name()` node). Returns `None` for a member with no stable key — a +/// dynamic (non-literal) computed name, or a non-name key. +fn member_key(ctx: &MemberCtx<'_>, key: &Expression<'_>, computed: bool) -> Option<(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 starts at + // the `[`. + return match key { + Expression::Literal(lit) + if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) => + { + let k = literal_key(ctx, lit)?; + let bracket = bracket_start(ctx.source, lit.span.start); + Some((k, Span::new(bracket, lit.span.end))) + } + // A dynamic computed name is late-bound (bucket G, deferred) — skip. + _ => None, + }; + } + match key { + Expression::Identifier(id) => Some(( + id.name(ctx.source, ctx.interner).to_string(), + id.name_span(), + )), + Expression::Literal(lit) => literal_key(ctx, lit).map(|k| (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, ctx.interner); + Some((format!("#{name}"), 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, ctx.interner).to_string(), + id.name_span(), + )), + _ => None, + } +} + +/// The canonical key string of a literal property name: a string's decoded value, +/// a number's ECMA-262 `Number::toString` form (so `0`, `0.0`, `0x0` all key `0` +/// and collide with the string `'0'`), a bigint's verbatim source (conservative — +/// never over-collides). +fn literal_key(ctx: &MemberCtx<'_>, lit: &Literal<'_>) -> Option { + match &lit.value { + LiteralValue::String(cooked) => Some(cooked.resolve(lit.span, 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, + } +} + +/// 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). +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 +} + +/// 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 `0x0` all parse to the same f64 → same key (they collide). + 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"); // 0xff + 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..193c17c04 --- /dev/null +++ b/crates/tsv_check/src/check/mod.rs @@ -0,0 +1,634 @@ +//! 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, +//! and every type-annotation site (variable / parameter / return-type / predicate / +//! function-type / union / intersection / assertion target / …) — and runs a set +//! of per-node checks. Today that is the duplicate-member check +//! ([`duplicate_members`]); the traversal is general so a second per-node check +//! (type-parameter identity) hooks into the same walk without a second 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; + +use crate::diag::Diagnostic; +use crate::ids::FileId; +use duplicate_members::MemberCtx; +use string_interner::DefaultStringInterner; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{ + ArrowFunctionBody, ClassBody, ClassMember, ExportDefaultValue, Expression, ForInOfLeft, + ForInit, ObjectPatternProperty, ObjectProperty, Statement, 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 interner = program.interner.borrow(); + let mut walk = CheckWalk { + source, + interner: &interner, + 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, + interner: &'a DefaultStringInterner, + file: FileId, + diagnostics: Vec, +} + +impl<'a> CheckWalk<'a> { + /// The derivation context the per-node checks need (source + interner + 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, + interner: self.interner, + 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.visit_type_params(f.type_parameters.as_ref()); + self.visit_params(f.params); + self.visit_type_annotation_opt(f.return_type.as_ref()); + for s in f.body.body { + self.visit_statement(s); + } + } + 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.visit_type_params(c.type_parameters.as_ref()); + self.visit_class_body(&c.body); + } + Statement::TSInterfaceDeclaration(i) => { + self.visit_type_params(i.type_parameters.as_ref()); + self.visit_type_elements(i.body.body); + } + 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.visit_type_params(f.type_parameters.as_ref()); + self.visit_params(f.params); + self.visit_type_annotation_opt(f.return_type.as_ref()); + for s in f.body.body { + self.visit_statement(s); + } + } + 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.visit_type_params(c.type_parameters.as_ref()); + self.visit_class_body(&c.body); + } + ExportDefaultValue::TSInterfaceDeclaration(i) => { + self.visit_type_params(i.type_parameters.as_ref()); + self.visit_type_elements(i.body.body); + } + } + } + + // --- expressions --------------------------------------------------------- + + fn visit_expression(&mut self, expr: &Expression<'_>) { + use Expression as E; + match expr { + E::FunctionExpression(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()); + for s in f.body.body { + self.visit_statement(s); + } + } + 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.visit_type_params(c.type_parameters.as_ref()); + self.visit_class_body(&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 -------------------------------- + + 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_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_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) => { + if let Some(ann) = id.type_annotation() { + self.visit_type_annotation(ann); + } + } + Expression::ObjectPattern(op) => { + 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) => { + 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_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 (constraints + defaults are types). + /// Slice 3's type-parameter-identity check attaches here alongside the descent. + fn visit_type_params(&mut self, params: Option<&TSTypeParameterDeclaration<'_>>) { + if let Some(decl) = params { + 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::Keyword(_) | TSType::Literal(_) | TSType::ThisType(_) => {} + } + } +} diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index ee77d4d29..3b65a50f6 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -17,7 +17,7 @@ //! -> parse (goal rule: Module first, Script retry) [program] //! -> lower + bind (one fused pre-order walk per file) [binder] //! -> merge (single-threaded global-scope fold) [merge] -//! -> check (no-op skeleton) [program] +//! -> check (syntactic per-node checks) [check] //! -> sort + dedup (tsgo's comparer) [diag] //! -> owned diagnostics //! ``` @@ -32,6 +32,8 @@ //! - [`diag`] — the `Diagnostic` shape and the canonical sort/dedup kernel. //! - `hash` (private) — the crate's Fx-style hasher and `FxHashMap`/`FxHashSet`. //! - `binder` (private) — the fused lower+bind pre-order walk. +//! - `check` (private) — the syntactic per-node check pass ([`check_file_members`]: +//! duplicate member declarations today; a general walk for future per-node checks). //! - [`merge`] — the single-threaded global-scope fold (cross-declaration-space //! conflicts, `globalThis`/`undefined`, module augmentations). //! - `program` (private) — pipeline assembly and the parse-error short-circuit. @@ -43,6 +45,7 @@ //! to the checker), so the port stays diffable against the oracle. mod binder; +mod check; mod hash; mod program; @@ -51,6 +54,7 @@ pub mod ids; pub mod merge; pub use binder::{BoundFile, FileFacts, ModuleNess, NodeKind, bind_file, module_ness}; +pub use check::check_file_members; pub use diag::{Category, Diagnostic}; pub use ids::{FileId, NodeId}; pub use merge::{LibBase, LibFile}; diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index fd1c50d2f..4d8958cff 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -164,9 +164,12 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr let module_ness = module_ness(&program); let bound = bind_file(&program, unit.source, file); total_nodes += u64::from(bound.node_count); - // Per file: bind diagnostics then check diagnostics (check is a - // no-op this slice) — the getBindAndCheckDiagnostics concat. - let check_diags = check_file(&bound); + // 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 { @@ -290,14 +293,6 @@ pub fn bind_lib(name: &str, source: &str) -> Result { }) } -/// Check one bound file — a no-op skeleton (no semantic diagnostics yet). -fn check_file(bound: &crate::binder::BoundFile) -> Vec { - // The checker is not built yet; the seam exists so the pipeline is proven - // end-to-end. The bound columns are available here for the future checker. - let _ = bound; - Vec::new() -} - /// 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. diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index fd29a7d31..4922a0436 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -88,26 +88,28 @@ const RUN_SCRIPT_RETRY_PIN: usize = 25; /// tsv parser robustness change (a fix removes an entry; a regression adds one). const RUN_CRASH_EXCLUDED_PIN: usize = 1; -/// REGRESSION PINS (exact, two-sided) for the family grading (the bind + merge -/// gate). Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 +/// REGRESSION PINS (exact, two-sided) for the family grading (the bind + merge + +/// check gate). Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 /// (hard); the rest pin the buckets so any move (a cascade change, a merge -/// change, a tsv parser change, a typescript-go pull) forces a deliberate re-pin. -/// The missing bucket is classified: `merge` (merge-phase family — **0**: the -/// single-file merge path matches, TS2397 globalThis/undefined + TS2664 -/// augmentation-not-found), `lib` (absent-lib conflicts — **0**: the classified -/// lib-conflict baselines, TS2300 eval/Symbol/Promise/ElementTagNameMap, match -/// against the standard-library globals), and `check-time` (the deferred -/// remainder — duplicate members, type parameters, computed/private names — -/// family instances the current bind+merge implementation does not yet emit). -/// A drop in `check-time` (matches gained) is a real improvement that re-pins; -/// a rise anywhere is a regression to explain. +/// change, a check-pass change, a tsv parser change, a typescript-go pull) forces +/// a deliberate re-pin. The missing bucket is classified: `merge` (merge-phase +/// family — **0**: the single-file merge path matches, TS2397 globalThis/undefined +/// plus TS2664 augmentation-not-found), `lib` (absent-lib conflicts — **0**: the +/// classified lib-conflict baselines, TS2300 eval/Symbol/Promise/ElementTagNameMap, +/// match against the standard-library globals), and `check-time` (the deferred +/// remainder — type parameters, and type-dependent / late-bound computed & symbol +/// names — family instances the current bind+check implementation does not yet +/// emit). The syntactic check pass now emits the duplicate-member family (TS2300 +/// over class / interface / type-literal properties and accessors), so those no +/// longer sit in `check-time`. A drop in `check-time` (matches gained) is a real +/// improvement that re-pins; a rise anywhere is a regression to explain. const RUN_FAMILY_GRADED_PIN: usize = 4066; const RUN_FAMILY_POSITIVE_PIN: usize = 125; -const RUN_FAMILY_MATCH_PIN: usize = 445; -const RUN_FAMILY_MISSING_PIN: usize = 105; +const RUN_FAMILY_MATCH_PIN: usize = 518; +const RUN_FAMILY_MISSING_PIN: usize = 32; const RUN_MISSING_MERGE_PIN: usize = 0; const RUN_MISSING_LIB_PIN: usize = 0; -const RUN_MISSING_CHECKTIME_PIN: usize = 105; +const RUN_MISSING_CHECKTIME_PIN: usize = 32; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index fe32218fa..f53ab933d 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -1317,7 +1317,7 @@ impl SkeletonReport { println!(" merge-path: {}", self.missing_merge); println!(" lib-conflict: {}", self.missing_lib); println!( - " check-time: {} (deferred family misses — duplicate members, type params, computed/private names)", + " check-time: {} (deferred family misses — type params, type-dependent / late-bound computed & symbol names)", self.missing_other ); println!(" extra (GATE=0): {}", self.family_extra); From ef2bf06a92cb787a8523a4b20f191c0cba058db3 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 00:27:53 -0400 Subject: [PATCH 30/79] =?UTF-8?q?feat:=20checkTypeParameters=20+=20honest?= =?UTF-8?q?=20deferred-late-bound=20split=20(family=20missing=2032?= =?UTF-8?q?=E2=86=9211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/results/report.tsc-conformance.json | 18 ++-- benches/js/results/report.tsc-conformance.md | 6 +- crates/tsv_check/src/binder/mod.rs | 38 ++++++++ .../tsv_check/src/check/duplicate_members.rs | 75 +++++++++++++-- crates/tsv_check/src/check/mod.rs | 95 +++++++++++++++++-- .../src/cli/commands/tsc_conformance.rs | 73 +++++++++----- .../tsv_debug/src/tsc_conformance/runner.rs | 30 +++++- 7 files changed, 280 insertions(+), 55 deletions(-) diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json index 54a3bd49c..218b3a2d8 100644 --- a/benches/js/results/report.tsc-conformance.json +++ b/benches/js/results/report.tsc-conformance.json @@ -10,19 +10,20 @@ "family_positive_variants": 125 }, "family": { - "match": 518, + "match": 539, "missing": { - "total": 32, + "total": 11, "merge_path": 0, "lib_conflict": 0, - "check_time": 32 + "late_bound": 11, + "other": 0 }, "extra": 0, "span_mismatch": 0 }, "per_code": { "match": { - "2300": 394, + "2300": 415, "2397": 4, "2451": 56, "2528": 35, @@ -30,7 +31,7 @@ "2664": 3 }, "missing": { - "2300": 32 + "2300": 11 } }, "related": { @@ -64,11 +65,12 @@ "parse_rejected": 1006, "family_graded": 4066, "family_positive": 125, - "family_match": 518, - "family_missing": 32, + "family_match": 539, + "family_missing": 11, "missing_merge": 0, "missing_lib": 0, - "missing_check_time": 32, + "missing_deferred_late_bound": 11, + "missing_other": 0, "family_extra": 0, "family_span_mismatch": 0, "related_match": 51, diff --git a/benches/js/results/report.tsc-conformance.md b/benches/js/results/report.tsc-conformance.md index 8776537f0..81daad3f5 100644 --- a/benches/js/results/report.tsc-conformance.md +++ b/benches/js/results/report.tsc-conformance.md @@ -12,8 +12,8 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis ## Family (2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671) -- match: 518 -- missing: 32 (merge-path 0, lib-conflict 0, check-time 32) +- match: 539 +- missing: 11 (merge-path 0, lib-conflict 0, late-bound 11, other 0) - extra (GATE=0): 0 - span mismatch: 0 @@ -21,7 +21,7 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis | code | match | missing | | --- | --- | --- | -| TS2300 | 394 | 32 | +| TS2300 | 415 | 11 | | TS2397 | 4 | 0 | | TS2451 | 56 | 0 | | TS2528 | 35 | 0 | diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 54f82291a..b13ef715a 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -807,6 +807,44 @@ mod tests { ); } + #[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` decode to NaN upstream; keyed on their verbatim source they + // stay distinct, so no false TS2300 (a `NaN`-keyed collision would flag them). + assert!(diag_codes("type T = { 0x0: number; 0xff: 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 diff --git a/crates/tsv_check/src/check/duplicate_members.rs b/crates/tsv_check/src/check/duplicate_members.rs index e43ff8c07..2e865ecd5 100644 --- a/crates/tsv_check/src/check/duplicate_members.rs +++ b/crates/tsv_check/src/check/duplicate_members.rs @@ -37,6 +37,7 @@ use string_interner::DefaultStringInterner; use tsv_lang::Span; use tsv_ts::ast::internal::{ ClassMember, Expression, Literal, LiteralValue, MethodKind, TSTypeElement, + TSTypeParameterDeclaration, }; /// The per-body derivation context (source + interner for names, file for spans). @@ -278,21 +279,52 @@ fn make_2300(file: FileId, span: Span, display: &str) -> Diagnostic { } } +/// 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, ctx.interner); + for prior in &decl.params[..i] { + if prior.name.name(ctx.source, ctx.interner) == name { + out.push(make_2300(ctx.file, param.name.name_span(), name)); + } + } + } +} + /// Derive a member's canonical key string and the span its diagnostic points at /// (the `member.Name()` node). Returns `None` for a member with no stable key — a /// dynamic (non-literal) computed name, or a non-name key. fn member_key(ctx: &MemberCtx<'_>, key: &Expression<'_>, computed: bool) -> Option<(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 starts at - // the `[`. + // diagnostic points at the whole `[ … ]` name node, so the span runs from + // the `[` to just past the `]`. return match key { Expression::Literal(lit) if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) => { let k = literal_key(ctx, lit)?; - let bracket = bracket_start(ctx.source, lit.span.start); - Some((k, Span::new(bracket, lit.span.end))) + let start = bracket_start(ctx.source, lit.span.start); + let end = bracket_end(ctx.source, lit.span.end); + Some((k, Span::new(start, end))) } // A dynamic computed name is late-bound (bucket G, deferred) — skip. _ => None, @@ -332,12 +364,18 @@ fn param_property_key(ctx: &MemberCtx<'_>, parameter: &Expression<'_>) -> Option } /// The canonical key string of a literal property name: a string's decoded value, -/// a number's ECMA-262 `Number::toString` form (so `0`, `0.0`, `0x0` all key `0` -/// and collide with the string `'0'`), a bigint's verbatim source (conservative — -/// never over-collides). +/// 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, @@ -359,6 +397,23 @@ fn bracket_start(source: &str, expr_start: u32) -> 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). +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 +} + /// 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"`, @@ -446,10 +501,12 @@ mod tests { 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 `0x0` all parse to the same f64 → same key (they collide). + // `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"); // 0xff + 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"); diff --git a/crates/tsv_check/src/check/mod.rs b/crates/tsv_check/src/check/mod.rs index 193c17c04..78628bc98 100644 --- a/crates/tsv_check/src/check/mod.rs +++ b/crates/tsv_check/src/check/mod.rs @@ -5,11 +5,11 @@ //! 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, -//! and every type-annotation site (variable / parameter / return-type / predicate / -//! function-type / union / intersection / assertion target / …) — and runs a set -//! of per-node checks. Today that is the duplicate-member check -//! ([`duplicate_members`]); the traversal is general so a second per-node check -//! (type-parameter identity) hooks into the same walk without a second descent. +//! every type-annotation site (variable / parameter / return-type / predicate / +//! function-type / union / intersection / assertion target / …), 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 @@ -532,10 +532,13 @@ impl<'a> CheckWalk<'a> { } } - /// The per-type-parameter-declaration hook (constraints + defaults are types). - /// Slice 3's type-parameter-identity check attaches here alongside the descent. + /// 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); @@ -632,3 +635,81 @@ impl<'a> CheckWalk<'a> { } } } + +#[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 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_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 4922a0436..de8192727 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -89,27 +89,33 @@ const RUN_SCRIPT_RETRY_PIN: usize = 25; const RUN_CRASH_EXCLUDED_PIN: usize = 1; /// REGRESSION PINS (exact, two-sided) for the family grading (the bind + merge + -/// check gate). Measured 2026-07-10 vs pin 168e7015. `family_extra` is gated to 0 -/// (hard); the rest pin the buckets so any move (a cascade change, a merge -/// change, a check-pass change, a tsv parser change, a typescript-go pull) forces -/// a deliberate re-pin. The missing bucket is classified: `merge` (merge-phase -/// family — **0**: the single-file merge path matches, TS2397 globalThis/undefined -/// plus TS2664 augmentation-not-found), `lib` (absent-lib conflicts — **0**: the -/// classified lib-conflict baselines, TS2300 eval/Symbol/Promise/ElementTagNameMap, -/// match against the standard-library globals), and `check-time` (the deferred -/// remainder — type parameters, and type-dependent / late-bound computed & symbol -/// names — family instances the current bind+check implementation does not yet -/// emit). The syntactic check pass now emits the duplicate-member family (TS2300 -/// over class / interface / type-literal properties and accessors), so those no -/// longer sit in `check-time`. A drop in `check-time` (matches gained) is a real -/// improvement that re-pins; a rise anywhere is a regression to explain. +/// check gate). Measured vs pin 168e7015. `family_extra` is gated to 0 (hard); the +/// rest pin the buckets so any move (a cascade change, a merge change, a check-pass +/// change, a tsv parser change, a typescript-go pull) forces a deliberate re-pin. +/// The syntactic check pass emits the duplicate-member family (TS2300 over class / +/// interface / type-literal properties and accessors) **and** the type-parameter +/// identity check (TS2300 over a declaration's own duplicate type parameters), so +/// those match rather than sitting in the missing bucket. The missing bucket is +/// classified four ways: `merge` (merge-phase family — **0**: the single-file merge +/// path matches, TS2397 globalThis/undefined plus TS2664 augmentation-not-found), +/// `lib` (absent-lib conflicts — **0**: the classified lib-conflict baselines, TS2300 +/// eval/Symbol/Promise/ElementTagNameMap, match against the standard-library +/// globals), `late-bound` (**11**: the `LATE_BOUND_BASELINES` tests, genuinely +/// deferred to the type engine — literal-type / `unique symbol` computed member +/// names), and `other` (**0**, a HARD gate — any unclassified miss is a same-table +/// cascade bug). A drop in `late-bound` (matches gained) is a real improvement that +/// re-pins; a rise in `other` fails the run. const RUN_FAMILY_GRADED_PIN: usize = 4066; const RUN_FAMILY_POSITIVE_PIN: usize = 125; -const RUN_FAMILY_MATCH_PIN: usize = 518; -const RUN_FAMILY_MISSING_PIN: usize = 32; +const RUN_FAMILY_MATCH_PIN: usize = 539; +const RUN_FAMILY_MISSING_PIN: usize = 11; const RUN_MISSING_MERGE_PIN: usize = 0; const RUN_MISSING_LIB_PIN: usize = 0; -const RUN_MISSING_CHECKTIME_PIN: usize = 32; +/// Genuinely-deferred family misses (need the type engine); exact-pinned. +const RUN_MISSING_DEFERRED_LATE_BOUND_PIN: usize = 11; +/// Unclassified family misses — a HARD-zero invariant gate (not just a full-run pin), +/// so a same-table cascade regression fails even a filtered triage run. +const RUN_MISSING_OTHER_PIN: usize = 0; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; @@ -415,6 +421,16 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), report.extra_samples.first().map_or("", String::as_str) )); } + // The honest-residual gate: every missing must be explained by merge / lib / + // late-bound. An `other` miss is a same-table cascade bug — a HARD zero (an + // invariant, so a filtered triage run catches it too), not just a full-run pin. + if report.missing_other != RUN_MISSING_OTHER_PIN { + errs.push(format!( + "missing OTHER {} != 0 (an unclassified family miss — a same-table cascade bug), e.g. {}", + report.missing_other, + report.missing_other_samples.first().map_or("", String::as_str) + )); + } // The lib error channels must stay empty (a lib parse-reject, a missing referenced // lib, an unrecognized `@lib`/reference name, or a lib binding external with no // `declare global` block). @@ -579,9 +595,9 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), ); pin( &mut errs, - "missing check-time", - report.missing_other, - RUN_MISSING_CHECKTIME_PIN, + "missing late-bound", + report.missing_deferred_late_bound, + RUN_MISSING_DEFERRED_LATE_BOUND_PIN, ); pin( &mut errs, @@ -663,7 +679,8 @@ struct RunPins { family_missing: usize, missing_merge: usize, missing_lib: usize, - missing_check_time: usize, + missing_deferred_late_bound: usize, + missing_other: usize, family_extra: usize, family_span_mismatch: usize, related_match: usize, @@ -694,7 +711,8 @@ fn run_pins() -> RunPins { family_missing: RUN_FAMILY_MISSING_PIN, missing_merge: RUN_MISSING_MERGE_PIN, missing_lib: RUN_MISSING_LIB_PIN, - missing_check_time: RUN_MISSING_CHECKTIME_PIN, + missing_deferred_late_bound: RUN_MISSING_DEFERRED_LATE_BOUND_PIN, + missing_other: RUN_MISSING_OTHER_PIN, family_extra: 0, family_span_mismatch: RUN_FAMILY_SPAN_MISMATCH_PIN, related_match: RUN_RELATED_MATCH_PIN, @@ -798,7 +816,8 @@ fn build_report_value(report: &SkeletonReport) -> serde_json::Value { "total": report.family_missing, "merge_path": report.missing_merge, "lib_conflict": report.missing_lib, - "check_time": report.missing_other, + "late_bound": report.missing_deferred_late_bound, + "other": report.missing_other, }, "extra": report.family_extra, "span_mismatch": report.family_span_mismatch, @@ -865,8 +884,12 @@ fn render_report_md(report: &SkeletonReport) -> String { let _ = writeln!(s, "- match: {}", report.family_match); let _ = writeln!( s, - "- missing: {} (merge-path {}, lib-conflict {}, check-time {})", - report.family_missing, report.missing_merge, report.missing_lib, report.missing_other + "- missing: {} (merge-path {}, lib-conflict {}, late-bound {}, other {})", + report.family_missing, + report.missing_merge, + report.missing_lib, + report.missing_deferred_late_bound, + report.missing_other ); let _ = writeln!(s, "- extra (GATE=0): {}", report.family_extra); let _ = writeln!(s, "- span mismatch: {}\n", report.family_span_mismatch); diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index f53ab933d..c1bef0b53 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -83,6 +83,20 @@ const LIB_CONFLICT_BASELINES: [&str; 5] = [ "variableDeclarationInStrictMode1.ts", ]; +/// The family baselines whose remaining missing family diagnostics are genuinely +/// deferred: they need the type engine (a literal-type or `unique symbol` computed +/// member name, resolved via tsgo's `lateBindMember`) that this bind+check +/// implementation has no counterpart for. A *missing* in one of these is bucketed to +/// `missing_deferred_late_bound` (exact-pinned) rather than the hard-zero +/// `missing_other`, so the honest residual stays visible without gating a release on +/// a type-engine gap. Basenames, mirroring `LIB_CONFLICT_BASELINES`. +const LATE_BOUND_BASELINES: [&str; 4] = [ + "dynamicNamesErrors.ts", + "symbolDeclarationEmit12.ts", + "symbolProperty37.ts", + "symbolProperty44.ts", +]; + /// Worker-thread stack for the sweep: the corpus has deeply-nested tests and /// tsv's recursive-descent parser has no depth guard, so the default 8 MiB /// overflows. 512 MiB is virtual-only reserve on Linux. @@ -285,8 +299,11 @@ pub struct SkeletonReport { pub missing_merge: usize, /// ...missing attributed to absent lib binding (a `LIB_CONFLICT_BASELINES` test). pub missing_lib: usize, - /// ...missing not attributable to merge/lib — investigate (a same-table miss - /// is a cascade bug). + /// ...missing genuinely deferred to the type engine (a `LATE_BOUND_BASELINES` + /// test — literal-type / `unique symbol` computed member names). Exact-pinned. + pub missing_deferred_late_bound: usize, + /// ...missing not attributable to merge/lib/late-bound — a same-table cascade + /// bug or an unclassified gap. **Gate: must be 0** (any such miss is a regression). pub missing_other: usize, /// Variants carved out by predicate v1 rule (a): tsv parses clean but the /// baseline carries a non-bind TS1xxx code (recovery-AST incomparability). @@ -959,6 +976,7 @@ fn grade_family( }); } let is_lib = LIB_CONFLICT_BASELINES.contains(&test.basename.as_str()); + let is_late_bound = LATE_BOUND_BASELINES.contains(&test.basename.as_str()); for (code, count) in &buckets.missing_by_code { report.family_missing += *count; *report.family_missing_by_code.entry(*code).or_default() += *count; @@ -966,6 +984,8 @@ fn grade_family( report.missing_merge += *count; } else if is_lib { report.missing_lib += *count; + } else if is_late_bound { + report.missing_deferred_late_bound += *count; } else { report.missing_other += *count; if report.missing_other_samples.len() < 20 { @@ -1317,7 +1337,11 @@ impl SkeletonReport { println!(" merge-path: {}", self.missing_merge); println!(" lib-conflict: {}", self.missing_lib); println!( - " check-time: {} (deferred family misses — type params, type-dependent / late-bound computed & symbol names)", + " late-bound (deferred): {} (needs the type engine — literal-type / unique-symbol computed member names)", + self.missing_deferred_late_bound + ); + println!( + " other (GATE=0): {} (unclassified family miss — a same-table cascade bug)", self.missing_other ); println!(" extra (GATE=0): {}", self.family_extra); From 44635be92a20afe62f9bc44a59991636170dd1da Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 00:42:13 -0400 Subject: [PATCH 31/79] chore: cover octal/separator numeric keys; refresh dated missing-bucket comment --- crates/tsv_check/src/binder/mod.rs | 6 ++++-- crates/tsv_debug/src/tsc_conformance/runner.rs | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index b13ef715a..161197548 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -835,9 +835,11 @@ mod tests { #[test] fn check_pass_non_decimal_numeric_keys_stay_distinct() { - // `0x0` / `0xff` decode to NaN upstream; keyed on their verbatim source they - // stay distinct, so no false TS2300 (a `NaN`-keyed collision would flag them). + // `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 };"), diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index c1bef0b53..dc533a55c 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -960,8 +960,8 @@ fn grade_family( )); } // An unexplained hard-fail bucket (extra / span mismatch) gets a rendered - // ours-vs-baseline diff artifact; the pinned check-time `missing` is expected, - // so it does not. + // ours-vs-baseline diff artifact; the pinned deferred-late-bound `missing` is + // expected, so it does not. if buckets.extra > 0 || buckets.span_mismatch > 0 { let reason = if buckets.extra > 0 { "family_extra" From ea2f30e4857dab69e6c7d37a4dc8b8952e0d4124 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 00:46:09 -0400 Subject: [PATCH 32/79] docs: add the check module + check-time family to the tsv_check crate map --- crates/tsv_check/CLAUDE.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index eff403bfc..d821a95c9 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -75,6 +75,19 @@ 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. +- `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 — class / + interface / type-literal bodies and every type-annotation / assertion / + predicate / function-type site (a general `TSType` recursion) — 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. - `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` @@ -108,11 +121,15 @@ result is fully owned — nothing borrows out. For lib-aware checking: - `tsv_debug tsc_conformance run` — the standing gate: sweeps the in-scope corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades - expect-clean variants AND the bind/merge duplicate-conflict family + expect-clean variants AND the duplicate-conflict family (TS2300/2451/2567/2528 + merge-path TS2397/2649/2664/2671) as codes+spans - multisets (extra = 0 is a hard gate; missing is classified by deferred - cause), grades related-info on matched primaries as its own pinned - channel, and publishes the parse-divergence census; exact `RUN_*` pins. + multisets — bind + merge + lib **and** the check-time TS2300 subset + (duplicate members / type parameters, from the `check` pass). extra = 0 is a + hard gate; a missing is classified `merge` / `lib` / `deferred_late_bound` + (an exact pin — the type-engine-dependent `lateBindMember` residual) / + `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; exact `RUN_*` pins. Triage filters (`--test`/`--code`/`--variant`) skip the pins; `--emit-manifest` and `--report` (the committed `benches/js/results/report.tsc-conformance.{json,md}`) serve tooling. A From 824383c9afc9da5a3c7f7a12759ecd5012e9758d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 01:35:48 -0400 Subject: [PATCH 33/79] fix: bracket-inclusive computed-key spans (closes latent extra) + heritage/decorator/nested-literal check coverage --- crates/tsv_check/src/binder/sym.rs | 38 ++++- .../tsv_check/src/check/duplicate_members.rs | 98 ++++++------ crates/tsv_check/src/check/mod.rs | 149 ++++++++++++++++-- crates/tsv_check/src/lib.rs | 3 + crates/tsv_check/src/program.rs | 44 ++++++ crates/tsv_check/src/span_scan.rs | 47 ++++++ 6 files changed, 312 insertions(+), 67 deletions(-) create mode 100644 crates/tsv_check/src/span_scan.rs diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index be154a5d1..54ed6ba89 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -1544,6 +1544,14 @@ impl<'a> SymbolBinder<'a> { 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( @@ -1558,6 +1566,12 @@ impl<'a> SymbolBinder<'a> { // 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 — @@ -1567,9 +1581,15 @@ impl<'a> SymbolBinder<'a> { // 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(_) => {} } @@ -2007,11 +2027,21 @@ impl<'a> SymbolBinder<'a> { Expression::Literal(lit) if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) => { - let a = self.string_atom(lit); + // 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: a, - display: a, - span: lit.span, + key: key_atom, + display, + span: Span::new(start, end), }) } _ => None, diff --git a/crates/tsv_check/src/check/duplicate_members.rs b/crates/tsv_check/src/check/duplicate_members.rs index 2e865ecd5..9f8b45f48 100644 --- a/crates/tsv_check/src/check/duplicate_members.rs +++ b/crates/tsv_check/src/check/duplicate_members.rs @@ -33,6 +33,7 @@ use crate::diag::{Category, Diagnostic}; use crate::hash::FxHashMap; use crate::ids::FileId; +use crate::span_scan::{bracket_end, bracket_start}; use string_interner::DefaultStringInterner; use tsv_lang::Span; use tsv_ts::ast::internal::{ @@ -55,12 +56,16 @@ enum MemberKind { Accessor, } -/// A body member reduced to what the check needs: its canonical key string, 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). +/// 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, @@ -111,6 +116,7 @@ fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec && let Some((key, span)) = param_property_key(ctx, pp.parameter) { entries.push(Entry { + display: key.clone(), key, span, is_static: false, @@ -121,9 +127,10 @@ fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec } } MethodKind::Method => { - if let Some((key, span)) = member_key(ctx, &m.key, m.computed) { + 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, @@ -132,9 +139,10 @@ fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec } } MethodKind::Get | MethodKind::Set => { - if let Some((key, span)) = member_key(ctx, &m.key, m.computed) { + 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), @@ -144,7 +152,7 @@ fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec } }, ClassMember::PropertyDefinition(p) => { - if let Some((key, span)) = member_key(ctx, &p.key, p.computed) { + if let Some((key, display, span)) = member_key(ctx, &p.key, p.computed) { let classify = if p.accessor { MemberKind::Accessor } else { @@ -152,6 +160,7 @@ fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec }; entries.push(Entry { key, + display, span, is_static: p.is_static, classify: Some(classify), @@ -172,9 +181,10 @@ fn type_element_entries(ctx: &MemberCtx<'_>, members: &[TSTypeElement<'_>]) -> V for member in members { match member { TSTypeElement::PropertySignature(p) => { - if let Some((key, span)) = member_key(ctx, &p.key, p.computed) { + 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), @@ -183,7 +193,7 @@ fn type_element_entries(ctx: &MemberCtx<'_>, members: &[TSTypeElement<'_>]) -> V } } TSTypeElement::MethodSignature(m) => { - if let Some((key, span)) = member_key(ctx, &m.key, m.computed) { + 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. @@ -191,6 +201,7 @@ fn type_element_entries(ctx: &MemberCtx<'_>, members: &[TSTypeElement<'_>]) -> V }; entries.push(Entry { key, + display, span, is_static: false, classify, @@ -260,7 +271,7 @@ fn fire_batch( entry.key == key && entry.is_static == is_static }; if matches { - out.push(make_2300(ctx.file, entry.span, &entry.key)); + out.push(make_2300(ctx.file, entry.span, &entry.display)); } } } @@ -309,14 +320,23 @@ pub(super) fn check_type_parameters( } } -/// Derive a member's canonical key string and the span its diagnostic points at -/// (the `member.Name()` node). Returns `None` for a member with no stable key — a -/// dynamic (non-literal) computed name, or a non-name key. -fn member_key(ctx: &MemberCtx<'_>, key: &Expression<'_>, computed: bool) -> Option<(String, Span)> { +/// 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 `]`. + // 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(_)) => @@ -324,23 +344,25 @@ fn member_key(ctx: &MemberCtx<'_>, key: &Expression<'_>, computed: bool) -> Opti let k = literal_key(ctx, lit)?; let start = bracket_start(ctx.source, lit.span.start); let end = bracket_end(ctx.source, lit.span.end); - Some((k, Span::new(start, 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) => Some(( - id.name(ctx.source, ctx.interner).to_string(), - id.name_span(), - )), - Expression::Literal(lit) => literal_key(ctx, lit).map(|k| (k, lit.span)), + Expression::Identifier(id) => { + let name = id.name(ctx.source, ctx.interner).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, ctx.interner); - Some((format!("#{name}"), pid.span)) + let keyed = format!("#{name}"); + Some((keyed.clone(), keyed, pid.span)) } _ => None, } @@ -382,38 +404,6 @@ fn literal_key(ctx: &MemberCtx<'_>, lit: &Literal<'_>) -> Option { } } -/// 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). -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). -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 -} - /// 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"`, diff --git a/crates/tsv_check/src/check/mod.rs b/crates/tsv_check/src/check/mod.rs index 78628bc98..8a665ebb6 100644 --- a/crates/tsv_check/src/check/mod.rs +++ b/crates/tsv_check/src/check/mod.rs @@ -6,10 +6,12 @@ //! (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 / …), 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. +//! 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 @@ -27,10 +29,10 @@ use duplicate_members::MemberCtx; use string_interner::DefaultStringInterner; use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ - ArrowFunctionBody, ClassBody, ClassMember, ExportDefaultValue, Expression, ForInOfLeft, - ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSModuleDeclaration, - TSModuleDeclarationBody, TSType, TSTypeAnnotation, TSTypeElement, TSTypeParameterDeclaration, - TSTypeParameterInstantiation, VariableDeclaration, + ArrowFunctionBody, ClassBody, ClassMember, Decorator, ExportDefaultValue, Expression, + ForInOfLeft, ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSInterfaceHeritage, + TSLiteralType, TSModuleDeclaration, TSModuleDeclarationBody, TSType, TSTypeAnnotation, + TSTypeElement, TSTypeParameterDeclaration, TSTypeParameterInstantiation, VariableDeclaration, }; /// Run the syntactic check pass over one parsed file, returning its check-time @@ -91,10 +93,17 @@ impl<'a> CheckWalk<'a> { } Statement::ClassDeclaration(c) => { self.visit_type_params(c.type_parameters.as_ref()); + self.visit_class_heritage( + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + ); self.visit_class_body(&c.body); } Statement::TSInterfaceDeclaration(i) => { self.visit_type_params(i.type_parameters.as_ref()); + self.visit_heritage_type_args(i.extends); self.visit_type_elements(i.body.body); } Statement::TSTypeAliasDeclaration(t) => { @@ -258,10 +267,17 @@ impl<'a> CheckWalk<'a> { } ExportDefaultValue::ClassDeclaration(c) => { self.visit_type_params(c.type_parameters.as_ref()); + self.visit_class_heritage( + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + ); self.visit_class_body(&c.body); } ExportDefaultValue::TSInterfaceDeclaration(i) => { self.visit_type_params(i.type_parameters.as_ref()); + self.visit_heritage_type_args(i.extends); self.visit_type_elements(i.body.body); } } @@ -295,6 +311,12 @@ impl<'a> CheckWalk<'a> { } E::ClassExpression(c) => { self.visit_type_params(c.type_parameters.as_ref()); + self.visit_class_heritage( + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + ); self.visit_class_body(&c.body); } E::TSAsExpression(t) => { @@ -404,12 +426,54 @@ impl<'a> CheckWalk<'a> { // --- 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()); @@ -418,6 +482,7 @@ impl<'a> CheckWalk<'a> { } } 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); @@ -476,11 +541,13 @@ impl<'a> CheckWalk<'a> { 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); } @@ -492,6 +559,7 @@ impl<'a> CheckWalk<'a> { } } Expression::ArrayPattern(ap) => { + self.visit_decorators(ap.decorators); if let Some(ann) = &ap.type_annotation { self.visit_type_annotation(ann); } @@ -500,6 +568,7 @@ impl<'a> CheckWalk<'a> { } } Expression::AssignmentPattern(a) => { + self.visit_decorators(a.decorators); self.visit_param(a.left); self.visit_expression(a.right); } @@ -631,7 +700,17 @@ impl<'a> CheckWalk<'a> { self.visit_type_args(args); } } - TSType::Keyword(_) | TSType::Literal(_) | TSType::ThisType(_) => {} + 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(_) => {} } } } @@ -702,6 +781,58 @@ mod tests { ); } + #[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 diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 3b65a50f6..90aaeee64 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -31,6 +31,8 @@ //! - [`ids`] — `NodeId` / `FileId` dense-integer identities. //! - [`diag`] — the `Diagnostic` shape and the canonical sort/dedup kernel. //! - `hash` (private) — the crate's Fx-style hasher and `FxHashMap`/`FxHashSet`. +//! - `span_scan` (private) — the bracket-inclusive computed-key scan the binder +//! and check pass share, so their spans agree by construction. //! - `binder` (private) — the fused lower+bind pre-order walk. //! - `check` (private) — the syntactic per-node check pass ([`check_file_members`]: //! duplicate member declarations today; a general walk for future per-node checks). @@ -48,6 +50,7 @@ mod binder; mod check; mod hash; mod program; +mod span_scan; pub mod diag; pub mod ids; diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 4d8958cff..1077d46c8 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -381,4 +381,48 @@ mod tests { 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 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..86c6f2654 --- /dev/null +++ b/crates/tsv_check/src/span_scan.rs @@ -0,0 +1,47 @@ +//! 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). + +/// 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 +} From 1bb8e47329efc2d0c6f1ee452fbfe9ec6b515bd8 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 01:48:48 -0400 Subject: [PATCH 34/79] docs: note the check pass now descends heritage/decorator/template-literal positions --- crates/tsv_check/CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index d821a95c9..6a7e724a0 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -78,10 +78,12 @@ - `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 — class / - interface / type-literal bodies and every type-annotation / assertion / - predicate / function-type site (a general `TSType` recursion) — and runs the - per-node check-time checks: `duplicate_members.rs` ports + 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 From 4da0c274c60c9c2a8fc7aa15af3f45395fc540d8 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 04:30:33 -0400 Subject: [PATCH 35/79] docs: flag the deferred general bind-side type descent at bind_type (method-vs-property missed in type-alias RHS/heritage/nested-class-expr) --- crates/tsv_check/src/binder/sym.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index 54ed6ba89..2e8253624 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -1502,6 +1502,14 @@ impl<'a> SymbolBinder<'a> { /// 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); From df88bcfd151fae4e7dab8f092832c252bc2e6d2d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 09:56:40 -0400 Subject: [PATCH 36/79] feat: full expression+type descent in the binder SoA walk (+ node_flags column, strict require_node_id) --- crates/tsv_check/src/binder/mod.rs | 1583 ++++++++++++++++++++++++++-- crates/tsv_check/src/lib.rs | 4 +- 2 files changed, 1503 insertions(+), 84 deletions(-) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 161197548..a937c8e7c 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -2,11 +2,19 @@ //! //! Two cooperating walks run per file, kept in one module: //! -//! - **the SoA walk** ([`SoaWalk`]) — one pre-order descent assigning dense -//! pre-order [`NodeId`]s to the node kinds the future checker addresses, -//! filling the parent/kind/span/`subtree_end` side columns and the address→id -//! map. Source order, so the `subtree_end` interval test (`is X a descendant of -//! Y`) stays valid. +//! - **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, the zero-initialized `node_flags` column, and the address→id map. +//! Source order, so the `subtree_end` interval test (`is X a descendant of Y`) +//! stays valid. 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 @@ -17,9 +25,13 @@ //! 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 (a best-effort link; -//! positions the SoA walk does not cover fall back to the root id — the id is not -//! consumed by the family cascade, which keys on name spans). +//! 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 @@ -28,6 +40,13 @@ //! 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 — @@ -42,19 +61,39 @@ use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; use crate::merge::FileMerge; use tsv_lang::Span; -use tsv_ts::ast::internal::{ForInOfLeft, ForInit}; -use tsv_ts::ast::{Expression, Program, Statement, VariableDeclaration, VariableDeclarator}; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{ + ArrowFunctionBody, CatchClause, ClassBody, ClassDeclaration, ClassMember, Decorator, + ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, FunctionDeclaration, + FunctionExpression, Identifier, ImportAttribute, ImportAttributeKey, ImportSpecifier, + ModuleExportName, ObjectPatternProperty, ObjectProperty, Property, RestElement, SpreadElement, + Statement, SwitchCase, TSDeclareFunction, TSEntityName, TSEnumMember, TSEnumMemberId, + TSImportType, TSIndexSignature, TSInterfaceDeclaration, TSInterfaceHeritage, TSLiteralType, + TSMappedTypeParameter, TSModuleDeclaration, TSModuleDeclarationBody, TSModuleName, + TSModuleReference, TSQualifiedName, TSType, TSTypeAnnotation, TSTypeElement, TSTypeParameter, + TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypeQueryExprName, TemplateElement, + TemplateLiteral, VariableDeclaration, VariableDeclarator, +}; -/// The pre-order node kinds the SoA walk assigns. Mirrors the tsv_ts `Statement` -/// variants plus the program root and declared-name identifiers; the set grows as -/// the checker addresses more node kinds. +/// 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. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[repr(u16)] pub enum NodeKind { /// The source file root. Program, - /// A declared-name identifier (a binding), or a `break`/`continue`/label id. + /// 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, @@ -88,8 +127,109 @@ pub enum NodeKind { 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 [`BoundFile::node_flags`] column (one `u8` per +/// [`NodeId`]). F0 mints the column zero-initialized and sets nothing; the flow +/// construction pass (F1) sets [`NODE_FLAGS_UNREACHABLE`] during unreachable +/// tagging, and the ambient/context node-identity bits move here later. +#[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. @@ -125,6 +265,9 @@ pub struct BoundFile { /// 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, + /// Per-node flag byte (see [`NODE_FLAGS_UNREACHABLE`]), one per [`NodeId`], + /// zero-initialized. F0 sets nothing; the flow pass (F1) writes it. + pub node_flags: Vec, /// Node arena address -> id (the random-access escape hatch). pub address_map: FxHashMap, /// Bind diagnostics — the duplicate/conflict family, in emission order (the @@ -144,6 +287,44 @@ impl BoundFile { pub fn is_descendant_of(&self, descendant: NodeId, ancestor: NodeId) -> bool { ancestor < descendant && descendant <= self.subtree_end[ancestor.index()] } + + /// The flag byte for `id` (see [`NODE_FLAGS_UNREACHABLE`]). + #[must_use] + pub fn node_flags(&self, id: NodeId) -> u8 { + self.node_flags[id.index()] + } + + /// Strict address -> [`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. + #[must_use] + pub fn require_node_id(&self, address: usize) -> NodeId { + match self.address_map.get(&address) { + Some(&id) => id, + None => node_id_miss(address), + } + } +} + +/// 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) -> ! { + panic!( + "require_node_id: address {address:#x} 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 @@ -185,7 +366,7 @@ fn is_external_module_indicator(stmt: &Statement<'_>) -> bool { // `import x = A.B` (an entity name) does not. Statement::TSImportEqualsDeclaration(decl) => matches!( decl.module_reference, - tsv_ts::ast::internal::TSModuleReference::ExternalModuleReference(_) + TSModuleReference::ExternalModuleReference(_) ), _ => false, } @@ -304,6 +485,8 @@ pub fn bind_file<'arena>( // 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); } @@ -328,6 +511,7 @@ pub fn bind_file<'arena>( kinds: walk.kinds, spans: walk.spans, subtree_end: walk.subtree_end, + node_flags: walk.node_flags, address_map: walk.address_map, diagnostics, facts, @@ -349,6 +533,7 @@ struct SoaWalk { kinds: Vec, spans: Vec, subtree_end: Vec, + node_flags: Vec, address_map: FxHashMap, } @@ -366,6 +551,7 @@ impl SoaWalk { self.kinds.push(kind); self.spans.push(span); self.subtree_end.push(id); // provisional (a leaf owns only itself) + self.node_flags.push(0); // F0 mints the column zeroed; F1 sets it self.address_map.insert(address, id); id } @@ -377,8 +563,22 @@ impl SoaWalk { self.subtree_end[id.index()] = last; } - /// Visit a statement: assign its id, then descend into the declarations and - /// nested statements the SoA walk tracks. + /// 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); + } + + // --- statements ---------------------------------------------------------- + + 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. fn visit_statement(&mut self, stmt: &Statement<'_>, parent: NodeId) { let id = self.add( statement_kind(stmt), @@ -387,88 +587,240 @@ impl SoaWalk { addr_of(stmt), ); match stmt { + Statement::ExpressionStatement(s) => self.visit_expression(&s.expression, id), Statement::VariableDeclaration(decl) => self.visit_declarators(decl, id), - Statement::FunctionDeclaration(func) => { - if let Some(name) = &func.id { - self.visit_identifier(name, 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); } - self.visit_statements(func.body.body, id); } - Statement::ClassDeclaration(class) => { - if let Some(name) = &class.id { - self.visit_identifier(name, 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(if_stmt) => { - self.visit_statement(if_stmt.consequent, id); - if let Some(alt) = if_stmt.alternate { + 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(for_stmt) => { - if let Some(ForInit::VariableDeclaration(decl)) = &for_stmt.init { - self.visit_variable_declaration(decl, 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); } - self.visit_statement(for_stmt.body, 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::ForInStatement(for_in) => { - self.visit_for_left(&for_in.left, id); - self.visit_statement(for_in.body, id); + Statement::WhileStatement(s) => { + self.visit_expression(&s.test, id); + self.visit_statement(s.body, id); } - Statement::ForOfStatement(for_of) => { - self.visit_for_left(&for_of.left, id); - self.visit_statement(for_of.body, id); + Statement::DoWhileStatement(s) => { + self.visit_statement(s.body, id); + self.visit_expression(&s.test, id); } - Statement::WhileStatement(while_stmt) => self.visit_statement(while_stmt.body, id), - Statement::DoWhileStatement(do_while) => self.visit_statement(do_while.body, id), - Statement::SwitchStatement(switch) => { - for case in switch.cases { - self.visit_statements(case.consequent, id); + Statement::SwitchStatement(s) => { + self.visit_expression(&s.discriminant, id); + for case in s.cases { + self.visit_switch_case(case, id); } } - Statement::TryStatement(try_stmt) => { - self.visit_statements(try_stmt.block.body, id); - if let Some(handler) = &try_stmt.handler { - self.visit_statements(handler.body.body, 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) = &try_stmt.finalizer { + if let Some(finalizer) = &s.finalizer { self.visit_statements(finalizer.body, id); } } - Statement::LabeledStatement(labeled) => { - self.visit_identifier(&labeled.label, id); - self.visit_statement(labeled.body, id); - } - Statement::ExpressionStatement(_) - | Statement::TSTypeAliasDeclaration(_) - | Statement::TSInterfaceDeclaration(_) - | Statement::TSDeclareFunction(_) - | Statement::TSEnumDeclaration(_) - | Statement::TSModuleDeclaration(_) - | Statement::ReturnStatement(_) - | Statement::ExportNamedDeclaration(_) - | Statement::ExportDefaultDeclaration(_) - | Statement::ExportAllDeclaration(_) - | Statement::TSExportAssignment(_) - | Statement::TSNamespaceExportDeclaration(_) - | Statement::ImportDeclaration(_) - | Statement::TSImportEqualsDeclaration(_) - | Statement::ThrowStatement(_) - | Statement::BreakStatement(_) - | Statement::ContinueStatement(_) - | Statement::EmptyStatement(_) - | Statement::DebuggerStatement(_) => {} + 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); } - fn visit_statements(&mut self, stmts: &[Statement<'_>], parent: NodeId) { - for stmt in stmts { - self.visit_statement(stmt, parent); + // --- declaration descents (shared between statement + export-default) ----- + + fn descend_function(&mut self, f: &FunctionDeclaration<'_>, id: NodeId) { + if let Some(name) = &f.id { + self.visit_identifier(name, 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); + self.visit_statements(f.body.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); } + 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, @@ -493,27 +845,1050 @@ impl SoaWalk { Some(parent), addr_of(declarator), ); - if let Expression::Identifier(name) = &declarator.id { - self.visit_identifier(name, id); + // 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) { - if let ForInOfLeft::VariableDeclaration(decl) = left { - self.visit_variable_declaration(decl, parent); + 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. + 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. + 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), + } + } + + 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); + } + + // --- expressions (full pattern-aware descent) ---------------------------- + + 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. + 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) => { + 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); + } + E::ArrayExpression(a) => { + 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); + } + E::UnaryExpression(u) => { + let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + E::UpdateExpression(u) => { + let id = self.add(NodeKind::UpdateExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + E::BinaryExpression(b) => { + 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); + } + E::CallExpression(c) => { + 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); + } + E::NewExpression(n) => { + 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); + } + E::MemberExpression(m) => { + 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); + } + E::ConditionalExpression(c) => { + 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); + } + E::ArrowFunctionExpression(a) => { + 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); + } + E::FunctionExpression(f) => self.visit_function_expression(f, parent), + E::ClassExpression(c) => { + let id = self.add(NodeKind::ClassExpression, c.span, Some(parent), addr_of(c)); + if let Some(name) = &c.id { + self.visit_identifier(name, 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); + } + E::SpreadElement(s) => self.visit_spread(s, parent), + E::TemplateLiteral(t) => self.visit_template_literal(t, parent), + E::TaggedTemplateExpression(t) => { + 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); + } + E::AwaitExpression(a) => { + let id = self.add(NodeKind::AwaitExpression, a.span, Some(parent), addr_of(a)); + self.visit_expression(a.argument, id); + self.close(id); + } + E::YieldExpression(y) => { + 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); + } + E::SequenceExpression(s) => { + 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); + } + E::AssignmentExpression(a) => { + 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); + } + E::ObjectPattern(op) => { + 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); + } + E::ArrayPattern(ap) => { + 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); + } + E::AssignmentPattern(a) => { + 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); + } + E::RestElement(r) => self.visit_rest_element(r, parent), + E::TSTypeAssertion(t) => { + 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); + } + E::TSAsExpression(t) => { + 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); + } + E::TSSatisfiesExpression(t) => { + 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); + } + E::TSInstantiationExpression(t) => { + 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); + } + E::TSNonNullExpression(t) => { + let id = self.add( + NodeKind::TSNonNullExpression, + t.span, + Some(parent), + addr_of(t), + ); + self.visit_expression(t.expression, id); + self.close(id); + } + E::TSParameterProperty(pp) => { + let id = self.add( + NodeKind::TSParameterProperty, + pp.span, + Some(parent), + addr_of(pp), + ); + self.visit_expression(pp.parameter, id); + self.close(id); + } + E::ImportExpression(i) => { + 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); + } + E::MetaProperty(m) => { + 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); + } + E::JsdocCast(c) => { + let id = self.add(NodeKind::JsdocCast, c.span, Some(parent), addr_of(c)); + self.visit_expression(c.inner, id); + self.close(id); + } + E::ParenthesizedExpression(p) => { + let id = self.add( + NodeKind::ParenthesizedExpression, + p.span, + Some(parent), + addr_of(p), + ); + self.visit_expression(p.expression, id); + self.close(id); + } } } - fn visit_identifier(&mut self, ident: &tsv_ts::ast::Identifier<'_>, parent: NodeId) { + fn visit_function_expression(&mut self, f: &FunctionExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::FunctionExpression, + f.span, + Some(parent), + addr_of(f), + ); + if let Some(name) = &f.id { + self.visit_identifier(name, 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); + self.visit_statements(f.body.body, id); + 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); + } + + fn visit_template_element(&mut self, q: &TemplateElement<'_>, parent: NodeId) { + self.leaf(NodeKind::TemplateElement, q.span, addr_of(q), parent); + } + + 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. + 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); } + + // --- 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), + } + } + + // --- types --------------------------------------------------------------- + + /// A `TSTypeAnnotation` (`: T`) is a transparent wrapper — not idd; the walk + /// descends straight into the inner `TSType`, which is the node. + fn visit_type_annotation(&mut self, ann: &TSTypeAnnotation<'_>, parent: NodeId) { + self.visit_type(ann.type_annotation, parent); + } + + fn visit_type_annotation_opt(&mut self, ann: Option<&TSTypeAnnotation<'_>>, parent: NodeId) { + if let Some(a) = ann { + self.visit_type_annotation(a, parent); + } + } + + 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); + } + + 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); + } + + 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); + } + + 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), + } + } + + 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); + } + } + } } /// The [`NodeKind`] for a statement variant. @@ -567,25 +1942,30 @@ mod tests { #[test] fn preorder_ids_parents_and_kinds() { - // Program(1) -> VariableDeclaration(2) -> VariableDeclarator(3) -> Identifier(4) + // 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, 4); + 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); - let decl = NodeId::from_index(1); - assert_eq!(bound.subtree_end[root.index()], ident); + 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)); @@ -618,6 +1998,43 @@ mod tests { 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 node_flags_column_is_zeroed_and_sized() { + let bound = bind("const x = 1; function f(a: T) { return a; }"); + assert_eq!(bound.node_flags.len(), bound.node_count as usize); + assert!(bound.node_flags.iter().all(|&b| b == 0)); + // The accessor agrees with the column. + assert_eq!(bound.node_flags(NodeId::FIRST), 0); + } + + #[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); + 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); + } + /// 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). diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 90aaeee64..649b2caa5 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -56,7 +56,9 @@ pub mod diag; pub mod ids; pub mod merge; -pub use binder::{BoundFile, FileFacts, ModuleNess, NodeKind, bind_file, module_ness}; +pub use binder::{ + BoundFile, FileFacts, ModuleNess, NODE_FLAGS_UNREACHABLE, NodeKind, bind_file, module_ness, +}; pub use check::check_file_members; pub use diag::{Category, Diagnostic}; pub use ids::{FileId, NodeId}; From a75d8306053ece825d31a06b1e2d5704f260b8c9 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 10:07:46 -0400 Subject: [PATCH 37/79] fix: descend class type parameters in the binder SoA walk (both decl + class-expr sites) --- crates/tsv_check/src/binder/mod.rs | 44 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index a937c8e7c..f291a6382 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -9,8 +9,10 @@ //! (heritage clauses, type parameters, member signatures, decorators, import/ //! export specifiers, …). It fills the parent/kind/span/`subtree_end` side //! columns, the zero-initialized `node_flags` column, and the address→id map. -//! Source order, so the `subtree_end` interval test (`is X a descendant of Y`) -//! stays valid. The one deliberate carve-out is the pure list-wrapper nodes +//! 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 @@ -759,6 +761,9 @@ impl SoaWalk { 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, @@ -1149,6 +1154,8 @@ impl SoaWalk { 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, @@ -2035,6 +2042,39 @@ mod tests { let _ = bound.require_node_id(0); } + #[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). From 8cd930494a67eaf191c56927231b18fa4878b05c Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 11:27:24 -0400 Subject: [PATCH 38/79] feat: flow-graph SoA substrate + linear/unreachable construction + check-test --dump-flow --- crates/tsv_check/src/binder/flow.rs | 1782 +++++++++++++++++ crates/tsv_check/src/binder/mod.rs | 1 + crates/tsv_check/src/ids.rs | 65 + crates/tsv_check/src/lib.rs | 3 +- crates/tsv_check/src/program.rs | 19 + crates/tsv_debug/src/cli/commands/profile.rs | 5 +- .../src/cli/commands/tsc_conformance.rs | 16 +- crates/tsv_debug/src/tsc_conformance/mod.rs | 2 +- .../tsv_debug/src/tsc_conformance/runner.rs | 47 +- 9 files changed, 1935 insertions(+), 5 deletions(-) create mode 100644 crates/tsv_check/src/binder/flow.rs diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs new file mode 100644 index 000000000..de9f794d8 --- /dev/null +++ b/crates/tsv_check/src/binder/flow.rs @@ -0,0 +1,1782 @@ +//! 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). +//! +//! **F1a scope: the LINEAR + unreachable core.** Real branching topology +//! (if / loops / conditions / switch / try / labeled / break / continue) is +//! **F1b** — those constructs are handled here by a linear placeholder that +//! threads `current_flow` through their children (marked `// F1b: real +//! topology`), so every contained node still gets a flow attachment and the +//! walk never panics. What *is* real here: the flow substrate + constructors, +//! container save/restore (fresh `Start`, constructor/static-block return +//! targets), linear statement threading, the assertion-`Call` and +//! variable-`Assignment` mutations, and `return`/`throw` unreachable +//! propagation. +//! +//! **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), but the checker performs **no** control-flow analysis in +//! type positions, so those stamps are inert — the same soundness that lets +//! lib files skip flow construction entirely. Consequences: the +//! `QualifiedName`-inside-`typeof` stamp (binder.go:611) and type-position +//! identifier stamps are omitted. F1b/P3 can add a type descent if a consumer +//! ever needs it (none does today). +//! - **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. +//! - **IIFE inlining dropped** (binder.go:1525-1528). `is_flow_transparent` is +//! narrowed to `ClassStaticBlockDeclaration` only; ordinary +//! function-expression IIFEs stay flow-isolated (a safe F1 approximation; +//! true inlining is F2). +// +// tsgo: internal/binder/binder.go bind / bindContainer / bindChildren +// (+ the newFlowNode* / createFlow* / finishFlowLabel / addAntecedent +// constructor family and the per-statement flow shapers) + +use crate::binder::{BoundFile, addr_of}; +use crate::ids::{FlowNodeId, NodeId}; +use smallvec::SmallVec; +use tsv_lang::Span; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{ + ArrowFunctionBody, BinaryOperator, ClassDeclaration, ClassExpression, ClassMember, Decorator, + Expression, ForInit, FunctionDeclaration, FunctionExpression, Identifier, LiteralValue, + MethodDefinition, MethodKind, ObjectPatternProperty, ObjectProperty, Property, Statement, + TSModuleDeclarationBody, VariableDeclarator, +}; + +// --- 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; the F2-only bits (`SwitchClause`, +/// `ArrayMutation`, `ReduceLabel`) are never *set* in F1a. +/// +/// # 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 (F2 — never set in F1a). + pub const SWITCH_CLAUSE: FlowFlags = FlowFlags(1 << 7); + /// Potential array mutation (F2 — never set in F1a). + pub const ARRAY_MUTATION: FlowFlags = FlowFlags(1 << 8); + /// Potential assertion call. + pub const CALL: FlowFlags = FlowFlags(1 << 9); + /// Temporarily reduce antecedents of a label (F2 — never set in F1a). + 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] + 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) + } + + /// The raw bits (for the DOT renderer's header labels). + #[inline] + #[must_use] + pub const fn bits(self) -> u16 { + self.0 + } +} + +// --- F2 payload shapes (defined for the SoA shape; not populated in F1a) ---- + +/// A switch-clause payload (F2). Defined for the settled [`FlowGraph`] shape; +/// populated by the switch flow builder in a later slice. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] // F2: written by the switch flow builder (binder.go:2087-2108) +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 (F2). Defined for the settled [`FlowGraph`] shape; +/// populated by the try/finally flow builder in a later slice. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] // F2: written by the try/finally flow builder (binder.go:2042-2045) +pub struct FlowReduceLabel { + /// The target label. + pub target: FlowNodeId, + /// Pool-run index of the temporary antecedent list. + 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 { + flags: Vec, + /// Kind-discriminated by `flags`: a `NodeId` (raw, 1-based) | payload index + /// | 0 = none. In F1a it is always a `NodeId` or 0. + subject: Vec, + /// Non-label: the single antecedent's raw `FlowNodeId` (0 = none). + /// Label: a 1-based pool-run index (0 = collapsed / unfinalized). + antecedent: Vec, + /// Length-prefixed antecedent runs for labels (`[len, e0, e1, …]`). + pool: Vec, + /// F2 — switch-clause payloads (empty in F1a; kept for shape). + #[allow(dead_code)] // F2: switch flow + switch_payloads: Vec, + /// F2 — reduce-label payloads (empty in F1a; kept for shape). + #[allow(dead_code)] // F2: try/finally flow + 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). + #[inline] + #[must_use] + pub fn subject(&self, id: FlowNodeId) -> Option { + NodeId::from_raw_opt(self.subject[id.index()]) + } + + /// The antecedents of a flow node, in append order. + /// + /// Non-label nodes have 0 or 1 antecedent (the single-antecedent slot); + /// label nodes decode their length-prefixed pool run. + #[must_use] + pub fn antecedents(&self, id: FlowNodeId) -> Vec { + let flags = self.flags[id.index()]; + let slot = self.antecedent[id.index()]; + if flags.is_label() { + if slot == 0 { + return Vec::new(); + } + let off = (slot - 1) as usize; + let len = self.pool[off] as usize; + self.pool[off + 1..off + 1 + len] + .iter() + .filter_map(|&raw| FlowNodeId::from_raw(raw)) + .collect() + } else { + FlowNodeId::from_raw(slot).into_iter().collect() + } + } +} + +// --- 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>, + /// The F0 `node_flags` column 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)>, + /// F2 — case-clause fallthrough anchors (empty in F1a; kept for shape). + 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) + } +} + +/// 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(program: &Program<'_>, _source: &str, bound: &BoundFile) -> FlowProduct { + let mut b = FlowBuilder::new(bound); + b.run(program); + b.finish() +} + +// --- FlowBuilder ----------------------------------------------------------- + +/// Saved control-flow state restored at a flow-container boundary +/// (binder.go:1517-1524, the F1 subset — the deferred break/continue/label +/// targets are always `None` in F1a and land with F1b's branching). +struct SavedFlow { + current_flow: FlowNodeId, + current_return_target: Option, + current_exception_target: Option, +} + +/// The flow-graph construction walk. +struct FlowBuilder<'a> { + bound: &'a BoundFile, + + // graph columns + 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: crate::hash::FxHashMap>, + + // products + flow_of_node: Vec>, + node_flags: Vec, + end_flow: Vec<(NodeId, FlowNodeId)>, + return_flow: Vec<(NodeId, FlowNodeId)>, + + // construction state (the F1 subset of the container-boundary set) + current_flow: FlowNodeId, + unreachable_flow: FlowNodeId, + current_return_target: Option, + /// Always `None` in F1 (`createFlowMutation` reads it; only try/finally sets + /// it, which is F2), but ported so the exception hook is faithful. + current_exception_target: Option, + + // stats + branch_labels: u32, + dead_labels: u32, +} + +impl<'a> FlowBuilder<'a> { + fn new(bound: &'a BoundFile) -> FlowBuilder<'a> { + let n = bound.node_count as usize; + let mut b = FlowBuilder { + bound, + flags: Vec::new(), + subject: Vec::new(), + antecedent: Vec::new(), + pool: Vec::new(), + label_scratch: crate::hash::FxHashMap::default(), + flow_of_node: vec![None; n], + node_flags: bound.node_flags.clone(), + end_flow: Vec::new(), + return_flow: Vec::new(), + current_flow: FlowNodeId::UNREACHABLE, + unreachable_flow: FlowNodeId::UNREACHABLE, + current_return_target: None, + current_exception_target: None, + 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 + } + + fn finish(self) -> FlowProduct { + let mut end_flow = self.end_flow; + let mut return_flow = self.return_flow; + end_flow.sort_unstable_by_key(|&(n, _)| n); + return_flow.sort_unstable_by_key(|&(n, _)| n); + FlowProduct { + graph: FlowGraph { + flags: self.flags, + subject: self.subject, + antecedent: self.antecedent, + pool: self.pool, + switch_payloads: Vec::new(), + reduce_payloads: Vec::new(), + }, + flow_of_node: self.flow_of_node, + node_flags: self.node_flags, + end_flow, + return_flow, + fallthrough_flow: Vec::new(), + 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. + 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). + fn create_branch_label(&mut self) -> FlowNodeId { + self.branch_labels += 1; + self.new_flow_node(FlowFlags::BRANCH_LABEL) + } + + /// `createLoopLabel` (binder.go:467). F1b (loops) exercises this. + #[allow(dead_code)] // F1b: loop constructs + 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 F1 (that field is always `None`; try/finally sets it, F2). + fn create_flow_mutation( + &mut self, + flags: FlowFlags, + antecedent: FlowNodeId, + node: NodeId, + ) -> FlowNodeId { + self.set_flow_node_referenced(antecedent); + 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). + fn create_flow_call(&mut self, antecedent: FlowNodeId, node: NodeId) -> FlowNodeId { + self.set_flow_node_referenced(antecedent); + self.new_flow_node_ex(FlowFlags::CALL, Some(node), antecedent) + } + + /// `createFlowCondition` (binder.go:479) — ported for F1b's condition + /// binding. 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 (F1b). Unexercised by F1a's linear + /// walk (conditions are F1b). + #[allow(dead_code)] // F1b: condition binding (bindCondition) + 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. + 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. + 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) -> NodeId { + self.bound.require_node_id(address) + } + + #[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, + }; + 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; + 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; + } + + // --- 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)); + let start = self.new_flow_node(FlowFlags::START); + self.current_flow = start; + self.current_return_target = None; + self.current_exception_target = None; + 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); + } + } + } + + // --- statements ------------------------------------------------------- + + fn visit_statement(&mut self, stmt: &Statement<'_>) { + let id = self.require(addr_of(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) => { + 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; + } + Statement::ThrowStatement(s) => { + self.visit_expression(&s.argument); + self.current_flow = self.unreachable_flow; + } + // Everything else (declarations, blocks, and the F1b branching + // placeholders) 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. Shared by the + /// dead-code path and the F1b branching placeholders (which build no real + /// topology yet). 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)); + 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), + // --- F1b: real topology (branching) — linear placeholder descent - + 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); // F1b: real topology + } + Statement::ForInStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expression(&s.right); + self.visit_statement(s.body); // F1b: real topology + } + Statement::ForOfStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expression(&s.right); + self.visit_statement(s.body); // F1b: real topology + } + Statement::WhileStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.body); // F1b: real topology + } + Statement::DoWhileStatement(s) => { + self.visit_statement(s.body); // F1b: real topology + 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); // F1b: real topology + } + } + 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) => { + self.visit_identifier(&s.label); // F1b: real label topology + self.visit_statement(s.body); + } + Statement::BreakStatement(s) => { + if let Some(label) = &s.label { + self.visit_identifier(label); // F1b: real break/continue topology + } + } + Statement::ContinueStatement(s) => { + if let Some(label) = &s.label { + self.visit_identifier(label); // F1b: real break/continue topology + } + } + 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: &tsv_ts::ast::internal::ForInOfLeft<'_>) { + use tsv_ts::ast::internal::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 ------------------------------------------- + + /// `maybeBindExpressionFlowIfCall` (binder.go:2143): a top-level dotted-name + /// (non-`super`) call is a potential assertion → `createFlowCall`. + 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)); + self.current_flow = self.create_flow_call(self.current_flow, call_id); + } + } + + /// `bindVariableDeclarationFlow` + `bindInitializedVariableFlow` + /// (binder.go:2314) — a `var/let/const x = e` with an initializer emits one + /// unconditional `Assignment` (no default-value fork; that is F2). 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.visit_expression(&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)); + self.current_flow = + self.create_flow_mutation(FlowFlags::ASSIGNMENT, self.current_flow, decl_id); + } + } + + // --- 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); + } + + fn visit_function_expression(&mut self, f: &FunctionExpression<'_>, node_id: NodeId) { + // 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), false, false); + self.bind_params(f.params); + self.visit_statement_list(f.body.body); + self.exit_container(saved, false, true, true, node_id, false); + } + + fn visit_arrow( + &mut self, + a: &tsv_ts::ast::internal::ArrowFunctionExpression<'_>, + node_id: NodeId, + ) { + self.set_flow_leaf(node_id); // binder.go:915 (arrows dispatch here too) + let saved = self.enter_container(Some(node_id), false, false); + 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, false, true, true, node_id, false); + } + + fn bind_params(&mut self, params: &[Expression<'_>]) { + for param in params { + self.visit_expression(param); + } + } + + fn visit_class_decl(&mut self, c: &ClassDeclaration<'_>) { + if let Some(name) = &c.id { + self.visit_identifier(name); + } + self.visit_decorators(c.decorators); + if let Some(sc) = c.super_class { + self.visit_expression(sc); + } + // type params / super type args / implements are type positions (skip). + for member in c.body.body { + self.visit_class_member(member); + } + } + + fn visit_class_expr(&mut self, c: &ClassExpression<'_>) { + if let Some(name) = &c.id { + self.visit_identifier(name); + } + self.visit_decorators(c.decorators); + if let Some(sc) = c.super_class { + self.visit_expression(sc); + } + for member in c.body.body { + self.visit_class_member(member); + } + } + + fn visit_class_member(&mut self, member: &ClassMember<'_>) { + match member { + ClassMember::MethodDefinition(m) => self.visit_method(m), + 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)); + 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)); + 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<'_>) { + 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. tsv wraps + // a method body in a FunctionExpression (tsc's method node holds the body + // directly), and — F0 hazard — the address map collides the + // MethodDefinition with its inline `value` (a repr reorder puts `value` + // at offset 0, so F0's later insert overwrites the map slot). So the + // value FunctionExpression is the reliably-addressable body-bearing node; + // anchor there. The obj-literal/class-expression method flow-write + + // Start.Node subject (binder.go:982, 1534) is a P3 narrowing hint, + // deferred to F1b. + let anchor = self.require(addr_of(&m.value)); + let saved = self.enter_container(None, 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)); + 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)); + 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(_) => {} + } + } + + // --- expressions ------------------------------------------------------ + + fn visit_expression(&mut self, expr: &Expression<'_>) { + use Expression as E; + match expr { + E::Identifier(idn) => self.visit_identifier(idn), + E::ThisExpression(t) => { + let id = self.require(addr_of(t)); + self.set_flow_leaf(id); + } + E::Super(s) => { + let id = self.require(addr_of(s)); + 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)); + self.set_flow_nonleaf(id); + } + E::MemberExpression(m) => { + // 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)); + self.set_flow_nonleaf(id); + } + self.visit_expression(m.object); + self.visit_expression(m.property); + } + 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) => self.visit_expression(u.argument), + E::UpdateExpression(u) => self.visit_expression(u.argument), + E::BinaryExpression(b) => { + // F1b: logical short-circuit topology; F1a threads linearly. + self.visit_expression(b.left); + self.visit_expression(b.right); + } + 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::ConditionalExpression(c) => { + // F1b: conditional branch topology; F1a threads linearly. + self.visit_expression(c.test); + self.visit_expression(c.consequent); + self.visit_expression(c.alternate); + } + E::ArrowFunctionExpression(a) => { + let id = self.require(addr_of(a)); + self.visit_arrow(a, id); + } + E::FunctionExpression(f) => { + let id = self.require(addr_of(f)); + self.visit_function_expression(f, id); + } + 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_expression(t.tag); + for e in t.quasi.expressions { + self.visit_expression(e); + } + } + E::AwaitExpression(a) => self.visit_expression(a.argument), + E::YieldExpression(y) => { + if let Some(a) = y.argument { + self.visit_expression(a); + } + } + E::SequenceExpression(s) => { + for e in s.expressions { + self.visit_expression(e); + } + } + E::AssignmentExpression(a) => { + // F1b: assignment createFlowMutation; F1a descends only. + self.visit_expression(a.left); + self.visit_expression(a.right); + } + E::ObjectPattern(op) => { + self.visit_decorators(op.decorators); + for prop in op.properties { + self.visit_object_pattern_property(prop); + } + } + E::ArrayPattern(ap) => { + self.visit_decorators(ap.decorators); + for el in ap.elements.iter().flatten() { + self.visit_expression(el); + } + } + E::AssignmentPattern(a) => { + self.visit_decorators(a.decorators); + self.visit_expression(a.left); + self.visit_expression(a.right); + } + 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_expression(i.source); + if let Some(o) = i.options { + self.visit_expression(o); + } + } + E::JsdocCast(c) => self.visit_expression(c.inner), + E::ParenthesizedExpression(p) => self.visit_expression(p.expression), + } + } + + 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)); + self.set_flow_leaf(id); + self.visit_decorators(ident.decorators()); + } + + 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 same MethodDefinition- + // -vs-value address collision applies to `Property` — anchor on the + // reliably-addressable FunctionExpression). The obj-literal method + // flow-write (binder.go:982) is a P3 narrowing hint, deferred. + self.visit_expression(&pr.key); + let anchor = self.require(addr_of(f)); + 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); + } 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), + } + } +} + +// --- pure AST predicates (binder.go / utilities.go ports) ------------------ + +/// `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. +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). +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). +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`). +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(_) + ) +} + +fn is_true_keyword(expr: &Expression<'_>) -> bool { + matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(true))) +} + +fn is_false_keyword(expr: &Expression<'_>) -> bool { + matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(false))) +} + +/// Whether a binary expression is a nullish-coalescing (`??`) — the +/// `IsNullishCoalesce` guard's operator test (utilities.go:387). Used by F1b's +/// condition binding to compute the `create_flow_condition` parent guard. +#[allow(dead_code)] // F1b: condition binding +fn is_nullish_coalesce(expr: &Expression<'_>) -> bool { + matches!( + expr, + Expression::BinaryExpression(b) if b.operator == BinaryOperator::QuestionQuestion + ) +} + +// --- 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(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 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); + let text = if text.len() > 32 { &text[..32] } else { 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::CALL) { + "call" + } else { + "flow" + } +} + +fn escape_dot(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + 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) + } + + 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 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 + ); + 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); + 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 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)), + Expression::Identifier(idn) => bound.require_node_id(addr_of(idn)), + _ => 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); + 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/mod.rs b/crates/tsv_check/src/binder/mod.rs index f291a6382..ef0d99adb 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -55,6 +55,7 @@ // a recorded deviation with identical results) mod atoms; +pub mod flow; mod sym; pub mod symbols; diff --git a/crates/tsv_check/src/ids.rs b/crates/tsv_check/src/ids.rs index c69dfeed2..4d0c23146 100644 --- a/crates/tsv_check/src/ids.rs +++ b/crates/tsv_check/src/ids.rs @@ -57,6 +57,71 @@ impl NodeId { 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 diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 649b2caa5..78e8f71a6 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -56,12 +56,13 @@ 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 diag::{Category, Diagnostic}; -pub use ids::{FileId, NodeId}; +pub use ids::{FileId, FlowNodeId, NodeId}; pub use merge::{LibBase, LibFile}; pub use program::{ BoundProgram, CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, bind_lib, diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 1077d46c8..693de5720 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -33,6 +33,7 @@ // 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::diag::{Diagnostic, sort_and_deduplicate}; use crate::ids::FileId; @@ -128,6 +129,10 @@ struct BoundUnit { bind_diagnostics: Vec, /// The merge product, `None` when the unit parse-rejected. merge: Option, + /// The per-file flow product, carried **dark** — nothing consumes it until + /// F3; F1a builds it and `--dump-flow` renders it. `None` when the unit + /// parse-rejected (no AST to walk). + flow: Option, } impl BoundProgram { @@ -137,6 +142,14 @@ impl BoundProgram { self.total_nodes } + /// A unit's dark-carried flow product (`None` for a rejected unit or an + /// out-of-range index). Nothing in the check pipeline reads it (F3 will); + /// `--dump-flow` reaches it through this accessor. + #[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] @@ -164,6 +177,10 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr 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); // 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 @@ -183,6 +200,7 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr }), bind_diagnostics, merge: Some(bound.merge), + flow: Some(flow), }); } Err(message) => { @@ -193,6 +211,7 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr parse: ParseReport::Rejected { message }, bind_diagnostics: Vec::new(), merge: None, + flow: None, }); } } diff --git a/crates/tsv_debug/src/cli/commands/profile.rs b/crates/tsv_debug/src/cli/commands/profile.rs index d205a7bcc..2badcbdb6 100644 --- a/crates/tsv_debug/src/cli/commands/profile.rs +++ b/crates/tsv_debug/src/cli/commands/profile.rs @@ -252,7 +252,10 @@ fn profile_bind_once(source: &str, arena: &bumpalo::Bump) -> Result<(Duration, D let parse_dur = t0.elapsed(); let t1 = Instant::now(); - let _ = tsv_check::bind_file(&ast, source, tsv_check::FileId::ROOT); + // Parse -> lower+bind (F0) -> flow graph (F1). The flow walk is the third + // pass, so it belongs in the bind column the checker's perf anchor tracks. + let bound = tsv_check::bind_file(&ast, source, tsv_check::FileId::ROOT); + let _ = tsv_check::build_flow(&ast, source, &bound); let bind_dur = t1.elapsed(); Ok((parse_dur, bind_dur)) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index de8192727..10dbd8b49 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -9,7 +9,8 @@ use crate::tsc_conformance::index::IndexReport; use crate::tsc_conformance::runner::SkeletonReport; use crate::tsc_conformance::{ RunFilter, RunOptions, baselines_dir, check_one, corpus_materialized, denominators, - discover_baselines, histogram, run_index, run_roundtrip, run_skeleton, tests_by_code, + discover_baselines, dump_flow_dot, histogram, run_index, run_roundtrip, run_skeleton, + tests_by_code, }; use argh::FromArgs; use std::path::{Path, PathBuf}; @@ -297,6 +298,11 @@ pub struct CheckTestCommand { #[argh(switch)] json: bool, + /// dump the first unit's control-flow graph as Graphviz DOT (F1) to stdout, + /// instead of the diagnostic diff + #[argh(switch)] + dump_flow: bool, + /// the test to run (exact relative path or basename) #[argh(positional)] name: String, @@ -1040,6 +1046,14 @@ fn sanitize_artifact_name(name: &str) -> String { impl CheckTestCommand { fn run(self) -> Result<(), CliError> { require_corpus(&self.path)?; + if self.dump_flow { + let dot = dump_flow_dot(&self.path, &self.name).map_err(|e| { + eprintln!("Error: {e}"); + CliError::Failed + })?; + print!("{dot}"); + return Ok(()); + } let variant = match self.variant.as_deref().map(parse_variant_filter) { Some(Ok(v)) => Some(v), Some(Err(e)) => { diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 5c26c6a61..3dbbcb967 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -37,4 +37,4 @@ pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; -pub use runner::{RunFilter, RunOptions, check_one, run_skeleton}; +pub use runner::{RunFilter, RunOptions, check_one, dump_flow_dot, run_skeleton}; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index dc533a55c..87156f233 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -50,7 +50,10 @@ use std::collections::{BTreeMap, HashMap}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::Path; use std::time::Instant; -use tsv_check::{Diagnostic, ParseReport, SourceUnit, bind_program, check_bound, check_program}; +use tsv_check::{ + Diagnostic, FileId, ParseReport, SourceUnit, bind_file, bind_program, build_flow, check_bound, + check_program, render_flow_dot, +}; use tsv_lang::{LocationMapper, LocationTracker}; /// The bind/merge duplicate/conflict family the gate grades: TS2300 (duplicate @@ -1623,6 +1626,48 @@ pub fn check_one( }) } +/// Build the flow graph of a corpus test's **first** unit and render it to DOT +/// (the `check-test --dump-flow` product). Parses under the goal rule (Module, +/// then a Script retry), binds (F0), builds the flow product (F1), and renders +/// through `tsv_check`'s source-aware DOT renderer. Keeps the `BoundFile` alive +/// so the renderer can slice subject-node source text from its span column. +pub fn dump_flow_dot(checkout: &Path, name: &str) -> Result { + let corpus = discover_corpus(checkout)?; + let matches: Vec<&CorpusTest> = corpus + .iter() + .filter(|t| t.relative_path == name || t.basename == name) + .collect(); + let test = match matches.as_slice() { + [] => return Err(format!("no corpus test matches {name:?}")), + [one] => *one, + many => { + let paths: Vec = many + .iter() + .map(|t| format!("{}/{}", t.suite, t.relative_path)) + .collect(); + return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); + } + }; + + let content = read_corpus_file(&test.path)?; + let units = split_units(&content, &test.basename); + let unit = units + .first() + .ok_or_else(|| "test has no units".to_string())?; + + let arena = Bump::new(); + // The goal rule (Module first, Script retry) — the same rule bind_program + // uses, inlined here because --dump-flow keeps the BoundFile for rendering. + let program = match tsv_ts::parse_with_goal(&unit.content, tsv_ts::Goal::Module, &arena) { + Ok(p) => p, + Err(module_err) => tsv_ts::parse_with_goal(&unit.content, tsv_ts::Goal::Script, &arena) + .map_err(|_| format!("parse error: {module_err}"))?, + }; + let bound = bind_file(&program, &unit.content, FileId::ROOT); + let flow = build_flow(&program, &unit.content, &bound); + Ok(render_flow_dot(&flow, &bound.spans, &unit.content)) +} + /// Select a variant by an optional `k=v` filter (config match, lowercased key); /// with no filter the first (usually the unvaried) variant. fn select_variant<'a>( From 7191ad911044a27062bc65c321b5d05163c5bfce Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 11:55:47 -0400 Subject: [PATCH 39/79] fix: char-boundary-safe --dump-flow truncation + pin the method-address collision (F1a review) --- crates/tsv_check/src/binder/flow.rs | 38 ++++++++++++++++++++--------- crates/tsv_check/src/binder/mod.rs | 35 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index de9f794d8..16c6927c5 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -23,12 +23,15 @@ //! 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), but the checker performs **no** control-flow analysis in -//! type positions, so those stamps are inert — the same soundness that lets -//! lib files skip flow construction entirely. Consequences: the -//! `QualifiedName`-inside-`typeof` stamp (binder.go:611) and type-position -//! identifier stamps are omitted. F1b/P3 can add a type descent if a consumer -//! ever needs it (none does today). +//! (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. @@ -448,6 +451,10 @@ impl<'a> FlowBuilder<'a> { /// `createFlowMutation` (binder.go:499). The `currentExceptionTarget` hook /// is a no-op in F1 (that field is always `None`; try/finally sets it, F2). + // F1b: tsgo also sets `hasFlowEffects = true` here (binder.go:501) — F1b's + // condition/logical/for binding reads it to decide whether a post-expression + // label materializes, so F1b must reintroduce it (omitting it over-produces + // flow nodes: a sound superset, but diverges from tsgo's shape). fn create_flow_mutation( &mut self, flags: FlowFlags, @@ -462,7 +469,8 @@ impl<'a> FlowBuilder<'a> { result } - /// `createFlowCall` (binder.go:514). + /// `createFlowCall` (binder.go:514). F1b: also 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.new_flow_node_ex(FlowFlags::CALL, Some(node), antecedent) @@ -1229,10 +1237,11 @@ impl<'a> FlowBuilder<'a> { 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 same MethodDefinition- - // -vs-value address collision applies to `Property` — anchor on the - // reliably-addressable FunctionExpression). The obj-literal method - // flow-write (binder.go:982) is a P3 narrowing hint, deferred. + // 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 obj-literal method flow-write + // (binder.go:982) is a P3 narrowing hint, deferred. self.visit_expression(&pr.key); let anchor = self.require(addr_of(f)); let saved = self.enter_container(None, false, false); @@ -1471,7 +1480,12 @@ fn flow_node_label(g: &FlowGraph, id: FlowNodeId, node_spans: &[Span], source: & let span = node_spans[node.index()]; let text = span.extract(source); let text = text.split('\n').next().unwrap_or(text); - let text = if text.len() > 32 { &text[..32] } else { 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) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index ef0d99adb..9fac9631f 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -306,6 +306,18 @@ impl BoundFile { /// /// `address` is `std::ptr::from_ref(node) as usize` for the same arena node /// reference the walk keyed on. + /// + /// **Known collision (pre-P3 fix pending).** Pointer-identity keying 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), so + /// `require_node_id(addr_of(&method))` returns the *value's* id, not the + /// method's — a silent wrong node, worse than a miss. Inert today (the flow + /// walk deliberately anchors methods on `value`; nothing else resolves a method + /// by address), pinned by `method_address_collides_with_value` below. The fix — + /// keying the map on `(address, NodeKind)` (no same-kind collisions exist) — is + /// deferred to a pre-P3 slice (P3's method flow-write + typeof narrowing need + /// the method node's own id). #[must_use] pub fn require_node_id(&self, address: usize) -> NodeId { match self.address_map.get(&address) { @@ -2043,6 +2055,29 @@ mod tests { let _ = bound.require_node_id(0); } + #[test] + fn method_address_collides_with_value() { + // KNOWN F0 collision (pre-P3 fix pending; see `require_node_id`'s doc). A + // `MethodDefinition` and its inline offset-0 `value: FunctionExpression` + // share an address, so the walk's second insert wins and + // `require_node_id(addr_of(&method))` returns the FunctionExpression, not + // the MethodDefinition. Pinned so the `(address, NodeKind)` fix is a + // visible ratchet (this assertion flips to `MethodDefinition` when it lands). + 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"); + }; + let id = bound.require_node_id(std::ptr::from_ref(method) as usize); + assert_eq!(bound.kinds[id.index()], NodeKind::FunctionExpression); + } + #[test] fn class_type_parameters_are_descended() { // Regression guard (F0 review): a class's own `` was dropped — no From 58306e2a5d8102b083fde8477da1444a7dda173d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 12:42:36 -0400 Subject: [PATCH 40/79] feat: flow-graph branching constructs (conditions, if, loops, break/continue) + hasFlowEffects --- crates/tsv_check/src/binder/flow.rs | 1132 +++++++++++++++++++++++++-- 1 file changed, 1067 insertions(+), 65 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 16c6927c5..7dbe0c616 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -7,16 +7,32 @@ //! **strict** [`BoundFile::require_node_id`] (a miss aborts — a flow graph must //! never silently splice onto the wrong node). //! -//! **F1a scope: the LINEAR + unreachable core.** Real branching topology -//! (if / loops / conditions / switch / try / labeled / break / continue) is -//! **F1b** — those constructs are handled here by a linear placeholder that -//! threads `current_flow` through their children (marked `// F1b: real -//! topology`), so every contained node still gets a flow attachment and the -//! walk never panics. What *is* real here: the flow substrate + constructors, -//! container save/restore (fresh `Start`, constructor/static-block return -//! targets), linear statement threading, the assertion-`Call` and -//! variable-`Assignment` mutations, and `return`/`throw` unreachable -//! propagation. +//! **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`**. A `switch` gets a local post-switch break +//! target (so a contained `break` resolves locally) but its clause topology, +//! fallthrough and exhaustiveness stay linear placeholders. Still **F2**: switch +//! clause topology, try/finally exception edges, labeled-statement resolution, +//! IIFE inlining, and parameter-default forks (all marked `// F2:`). 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 the +//! invariant that the true/false targets are `Some` **exactly** inside a +//! condition bind (they are set only by `do_with_conditional_branches` and the +//! `!`-swap): a logical expression is top-level iff `current_true_target` is +//! `None`. To keep that invariant sound across a nested function/initializer +//! container (which can be entered mid-condition, e.g. +//! `if (arr.some(x => x && y))`), the true/false targets are **saved + reset to +//! `None` at every flow container** and restored on exit — a deliberate +//! departure from tsgo (which never saves them, 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 @@ -55,10 +71,12 @@ use smallvec::SmallVec; use tsv_lang::Span; use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ - ArrowFunctionBody, BinaryOperator, ClassDeclaration, ClassExpression, ClassMember, Decorator, - Expression, ForInit, FunctionDeclaration, FunctionExpression, Identifier, LiteralValue, + ArrowFunctionBody, AssignmentOperator, BinaryExpression, BinaryOperator, BreakStatement, + ClassDeclaration, ClassExpression, ClassMember, ConditionalExpression, ContinueStatement, + Decorator, DoWhileStatement, Expression, ForInOfLeft, ForInit, ForStatement, + FunctionDeclaration, FunctionExpression, Identifier, IfStatement, LiteralValue, MethodDefinition, MethodKind, ObjectPatternProperty, ObjectProperty, Property, Statement, - TSModuleDeclarationBody, VariableDeclarator, + SwitchStatement, TSModuleDeclarationBody, UnaryOperator, VariableDeclarator, WhileStatement, }; // --- FlowFlags ------------------------------------------------------------- @@ -315,12 +333,20 @@ pub fn build_flow(program: &Program<'_>, _source: &str, bound: &BoundFile) -> Fl // --- FlowBuilder ----------------------------------------------------------- /// Saved control-flow state restored at a flow-container boundary -/// (binder.go:1517-1524, the F1 subset — the deferred break/continue/label -/// targets are always `None` in F1a and land with F1b's branching). +/// (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, } /// The flow-graph construction walk. @@ -344,13 +370,34 @@ struct FlowBuilder<'a> { end_flow: Vec<(NodeId, FlowNodeId)>, return_flow: Vec<(NodeId, FlowNodeId)>, - // construction state (the F1 subset of the container-boundary set) + // construction state (the F1b subset of the container-boundary set) current_flow: FlowNodeId, unreachable_flow: FlowNodeId, current_return_target: Option, - /// Always `None` in F1 (`createFlowMutation` reads it; only try/finally sets + /// 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, + /// 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, @@ -375,6 +422,12 @@ impl<'a> FlowBuilder<'a> { unreachable_flow: FlowNodeId::UNREACHABLE, current_return_target: None, current_exception_target: None, + current_break_target: None, + current_continue_target: None, + current_true_target: None, + current_false_target: None, + has_explicit_return: false, + has_flow_effects: false, branch_labels: 0, dead_labels: 0, }; @@ -386,7 +439,26 @@ impl<'a> FlowBuilder<'a> { b } - fn finish(self) -> FlowProduct { + fn finish(mut self) -> FlowProduct { + // Flush any label whose antecedents still live in scratch — the **loop + // labels** (`preWhile`/`preDo`/`preLoop`). A loop label is referenced (its + // condition flows from it, and it is a back/continue edge target) but the + // loop binders never call `finish_flow_label` on it (a back edge can be + // added after it is already used), so its entry + back edges never reach + // the pool via the collapse path. 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; end_flow.sort_unstable_by_key(|&(n, _)| n); @@ -443,18 +515,15 @@ impl<'a> FlowBuilder<'a> { self.new_flow_node(FlowFlags::BRANCH_LABEL) } - /// `createLoopLabel` (binder.go:467). F1b (loops) exercises this. - #[allow(dead_code)] // F1b: loop constructs + /// `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 F1 (that field is always `None`; try/finally sets it, F2). - // F1b: tsgo also sets `hasFlowEffects = true` here (binder.go:501) — F1b's - // condition/logical/for binding reads it to decide whether a post-expression - // label materializes, so F1b must reintroduce it (omitting it over-produces - // flow nodes: a sound superset, but diverges from tsgo's shape). + /// 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, @@ -462,6 +531,7 @@ impl<'a> FlowBuilder<'a> { 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); @@ -469,20 +539,19 @@ impl<'a> FlowBuilder<'a> { result } - /// `createFlowCall` (binder.go:514). F1b: also sets `hasFlowEffects = true` + /// `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) } - /// `createFlowCondition` (binder.go:479) — ported for F1b's condition - /// binding. 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 (F1b). Unexercised by F1a's linear - /// walk (conditions are F1b). - #[allow(dead_code)] // F1b: condition binding (bindCondition) + /// `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. fn create_flow_condition( &mut self, flags: FlowFlags, @@ -611,6 +680,11 @@ impl<'a> FlowBuilder<'a> { 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, }; if !transparent { let start = self.new_flow_node(FlowFlags::START); @@ -625,6 +699,14 @@ impl<'a> FlowBuilder<'a> { 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 } @@ -655,6 +737,11 @@ impl<'a> FlowBuilder<'a> { } 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; } // --- entry (SourceFile container) ------------------------------------- @@ -667,6 +754,11 @@ impl<'a> FlowBuilder<'a> { 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)); @@ -717,6 +809,7 @@ impl<'a> FlowBuilder<'a> { } } Statement::ReturnStatement(s) => { + // `bindReturnStatement` (binder.go:1939). if let Some(a) = &s.argument { self.visit_expression(a); } @@ -724,21 +817,45 @@ impl<'a> FlowBuilder<'a> { 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); } - // Everything else (declarations, blocks, and the F1b branching - // placeholders) threads flow linearly through its children. + Statement::BreakStatement(s) => self.bind_break_statement(s), + Statement::ContinueStatement(s) => self.bind_continue_statement(s), + Statement::SwitchStatement(s) => self.bind_switch_statement(s), + // Everything else (declarations, blocks, and the F2 sequential + // placeholders — labeled / try / 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. Shared by the - /// dead-code path and the F1b branching placeholders (which build no real - /// topology yet). Containers nested here still open their own `Start` + /// 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 { @@ -763,7 +880,8 @@ impl<'a> FlowBuilder<'a> { } Statement::ThrowStatement(s) => self.visit_expression(&s.argument), Statement::BlockStatement(b) => self.visit_statement_list(b.body), - // --- F1b: real topology (branching) — linear placeholder descent - + // --- 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); @@ -790,24 +908,24 @@ impl<'a> FlowBuilder<'a> { if let Some(u) = &s.update { self.visit_expression(u); } - self.visit_statement(s.body); // F1b: real topology + self.visit_statement(s.body); } Statement::ForInStatement(s) => { self.visit_for_left(&s.left); self.visit_expression(&s.right); - self.visit_statement(s.body); // F1b: real topology + self.visit_statement(s.body); } Statement::ForOfStatement(s) => { self.visit_for_left(&s.left); self.visit_expression(&s.right); - self.visit_statement(s.body); // F1b: real topology + self.visit_statement(s.body); } Statement::WhileStatement(s) => { self.visit_expression(&s.test); - self.visit_statement(s.body); // F1b: real topology + self.visit_statement(s.body); } Statement::DoWhileStatement(s) => { - self.visit_statement(s.body); // F1b: real topology + self.visit_statement(s.body); self.visit_expression(&s.test); } Statement::SwitchStatement(s) => { @@ -816,7 +934,7 @@ impl<'a> FlowBuilder<'a> { if let Some(t) = &case.test { self.visit_expression(t); } - self.visit_statement_list(case.consequent); // F1b: real topology + self.visit_statement_list(case.consequent); } } Statement::TryStatement(s) => { @@ -832,17 +950,17 @@ impl<'a> FlowBuilder<'a> { } } Statement::LabeledStatement(s) => { - self.visit_identifier(&s.label); // F1b: real label topology + self.visit_identifier(&s.label); // F2: real labeled-statement topology self.visit_statement(s.body); } Statement::BreakStatement(s) => { if let Some(label) = &s.label { - self.visit_identifier(label); // F1b: real break/continue topology + self.visit_identifier(label); } } Statement::ContinueStatement(s) => { if let Some(label) = &s.label { - self.visit_identifier(label); // F1b: real break/continue topology + self.visit_identifier(label); } } Statement::ExportNamedDeclaration(e) => { @@ -869,8 +987,8 @@ impl<'a> FlowBuilder<'a> { } } - fn visit_for_left(&mut self, left: &tsv_ts::ast::internal::ForInOfLeft<'_>) { - use tsv_ts::ast::internal::ForInOfLeft as L; + fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) { + use ForInOfLeft as L; match left { L::VariableDeclaration(d) => { for decl in d.declarations { @@ -915,6 +1033,466 @@ impl<'a> FlowBuilder<'a> { } } + // --- 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 pre_while = set_continue_target(stmt_id, self.create_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 pre_condition = set_continue_target(stmt_id, self.create_branch_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 pre_loop = set_continue_target(stmt_id, self.create_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 pre_loop = set_continue_target(stmt_id, self.create_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.visit_expression(&decl.id); + if let Some(init) = &decl.init { + self.visit_expression(init); + } + let decl_id = self.require(addr_of(decl)); + 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); + } + + /// `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), unlabeled path only. + 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); + } + // F2: labeled break resolution (findActiveLabel + the label's target). + Some(label) => self.visit_identifier(label), + } + } + + /// `bindContinueStatement` (binder.go:1959), unlabeled path only. + 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); + } + // F2: labeled continue resolution. + Some(label) => self.visit_identifier(label), + } + } + + /// `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; + } + } + + /// A `switch` with a **local** post-switch break target, so a contained + /// `break` resolves here rather than at an enclosing loop. F2: the real + /// clause topology (per-clause `SwitchClause` flow, fallthrough, + /// exhaustiveness) — F1b threads the discriminant + clauses linearly through + /// `current_flow`. + fn bind_switch_statement(&mut self, s: &SwitchStatement<'_>) { + let save_break = self.current_break_target; + let post_switch = self.create_branch_label(); + self.current_break_target = Some(post_switch); + 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); + } + self.add_antecedent(post_switch, self.current_flow); + self.current_break_target = save_break; + self.current_flow = self.finish_flow_label(post_switch); + } + + // --- 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). + 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 in + // the outer condition context, mutate the target, then test `node` + // (never a boolean keyword → the parent-nullish guard is irrelevant). + self.do_with_conditional_branches(Some(right), true_target, false_target); + 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. + 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 in + /// the address map (the key F0's lowering walk used). Condition / mutation + /// subjects are always value expressions F0 lowered, so this never misses. + fn expr_id(&self, e: &Expression<'_>) -> NodeId { + use Expression as E; + let addr = match e { + E::JsdocCast(c) => return self.expr_id(c.inner), + E::Literal(x) => addr_of(x), + E::Identifier(x) => addr_of(x), + E::PrivateIdentifier(x) => addr_of(x), + E::ObjectExpression(x) => addr_of(x), + E::ArrayExpression(x) => addr_of(x), + E::UnaryExpression(x) => addr_of(x), + E::UpdateExpression(x) => addr_of(x), + E::BinaryExpression(x) => addr_of(x), + E::CallExpression(x) => addr_of(x), + E::NewExpression(x) => addr_of(x), + E::MemberExpression(x) => addr_of(x), + E::ConditionalExpression(x) => addr_of(x), + E::ArrowFunctionExpression(x) => addr_of(x), + E::FunctionExpression(x) => addr_of(x), + E::ClassExpression(x) => addr_of(x), + E::SpreadElement(x) => addr_of(x), + E::TemplateLiteral(x) => addr_of(x), + E::TaggedTemplateExpression(x) => addr_of(x), + E::AwaitExpression(x) => addr_of(x), + E::YieldExpression(x) => addr_of(x), + E::SequenceExpression(x) => addr_of(x), + E::RegexLiteral(x) => addr_of(x), + E::ThisExpression(x) => addr_of(x), + E::Super(x) => addr_of(x), + E::AssignmentExpression(x) => addr_of(x), + E::ObjectPattern(x) => addr_of(x), + E::ArrayPattern(x) => addr_of(x), + E::AssignmentPattern(x) => addr_of(x), + E::RestElement(x) => addr_of(x), + E::TSTypeAssertion(x) => addr_of(x), + E::TSAsExpression(x) => addr_of(x), + E::TSSatisfiesExpression(x) => addr_of(x), + E::TSInstantiationExpression(x) => addr_of(x), + E::TSNonNullExpression(x) => addr_of(x), + E::TSParameterProperty(x) => addr_of(x), + E::ImportExpression(x) => addr_of(x), + E::MetaProperty(x) => addr_of(x), + E::ParenthesizedExpression(x) => addr_of(x), + }; + self.require(addr) + } + // --- containers ------------------------------------------------------- fn visit_function_declaration(&mut self, f: &FunctionDeclaration<'_>, anchor: NodeId) { @@ -1110,10 +1688,31 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(el); } } + E::UnaryExpression(u) if u.operator == UnaryOperator::Bang => { + // `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, + ); + } E::UnaryExpression(u) => self.visit_expression(u.argument), E::UpdateExpression(u) => self.visit_expression(u.argument), + E::BinaryExpression(b) if b.operator.is_logical() => { + // `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); + } E::BinaryExpression(b) => { - // F1b: logical short-circuit topology; F1a threads linearly. self.visit_expression(b.left); self.visit_expression(b.right); } @@ -1129,12 +1728,7 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(a); } } - E::ConditionalExpression(c) => { - // F1b: conditional branch topology; F1a threads linearly. - self.visit_expression(c.test); - self.visit_expression(c.consequent); - self.visit_expression(c.alternate); - } + E::ConditionalExpression(c) => self.bind_conditional_expression_flow(c), E::ArrowFunctionExpression(a) => { let id = self.require(addr_of(a)); self.visit_arrow(a, id); @@ -1167,10 +1761,25 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(e); } } + E::AssignmentExpression(a) if is_logical_assign_op(a.operator) => { + // `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), + ); + } E::AssignmentExpression(a) => { - // F1b: assignment createFlowMutation; F1a descends only. + // `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); } E::ObjectPattern(op) => { self.visit_decorators(op.decorators); @@ -1411,17 +2020,165 @@ fn is_false_keyword(expr: &Expression<'_>) -> bool { matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(false))) } -/// Whether a binary expression is a nullish-coalescing (`??`) — the -/// `IsNullishCoalesce` guard's operator test (utilities.go:387). Used by F1b's -/// condition binding to compute the `create_flow_condition` parent guard. -#[allow(dead_code)] // F1b: condition binding -fn is_nullish_coalesce(expr: &Expression<'_>) -> bool { +/// `setContinueTarget` (binder.go:1779). F2 walks the active-label list (labeled +/// loops) assigning each label's continue target; F1b has no active labels, so +/// this is the identity on `target`. The `_loop` node keeps the call-site shape. +fn set_continue_target(_loop: NodeId, target: FlowNodeId) -> FlowNodeId { + target +} + +/// 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. +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 assignment operator is a logical compound-assignment +/// (`||=`/`&&=`/`??=`) — `IsLogicalOrCoalescingAssignmentOperator`. +fn is_logical_assign_op(op: AssignmentOperator) -> bool { matches!( - expr, - Expression::BinaryExpression(b) if b.operator == BinaryOperator::QuestionQuestion + 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. +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 +} + // --- DOT renderer (formatControlFlowGraph reference) ----------------------- /// Render one unit's flow graph to Graphviz DOT — the `--dump-flow` product. @@ -1541,6 +2298,45 @@ mod tests { 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 @@ -1793,4 +2589,210 @@ mod tests { } } } + + // --- 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); + } + + #[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"); + } } From 90612b31b7c58c77621102ed8fb393c90756bc3c Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 13:05:27 -0400 Subject: [PATCH 41/79] fix: reset condition targets at value sub-positions so nested logicals classify top-level (F1b review) --- crates/tsv_check/src/binder/flow.rs | 106 +++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 17 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 7dbe0c616..a0a6d2946 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -23,16 +23,19 @@ //! `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 the -//! invariant that the true/false targets are `Some` **exactly** inside a -//! condition bind (they are set only by `do_with_conditional_branches` and the -//! `!`-swap): a logical expression is top-level iff `current_true_target` is -//! `None`. To keep that invariant sound across a nested function/initializer -//! container (which can be entered mid-condition, e.g. -//! `if (arr.some(x => x && y))`), the true/false targets are **saved + reset to -//! `None` at every flow container** and restored on exit — a deliberate -//! departure from tsgo (which never saves them, relying on the parent walk) -//! required by the pointer-free heuristic. +//! 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 two 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; and +//! (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. With both resets a logical expression is top-level iff +//! `current_true_target` is `None`. Both 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 @@ -440,13 +443,17 @@ impl<'a> FlowBuilder<'a> { } fn finish(mut self) -> FlowProduct { - // Flush any label whose antecedents still live in scratch — the **loop - // labels** (`preWhile`/`preDo`/`preLoop`). A loop label is referenced (its - // condition flows from it, and it is a back/continue edge target) but the - // loop binders never call `finish_flow_label` on it (a back edge can be - // added after it is already used), so its entry + back edges never reach - // the pool via the collapse path. Deterministic order (sort by id) so the - // pool layout is reproducible; the per-label edge order is push-order. + // 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 { @@ -1650,6 +1657,23 @@ impl<'a> FlowBuilder<'a> { 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) => { @@ -1814,6 +1838,10 @@ impl<'a> FlowBuilder<'a> { 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; + } } fn visit_identifier(&mut self, ident: &Identifier<'_>) { @@ -2040,6 +2068,20 @@ fn is_logical_condition(e: &Expression<'_>) -> bool { } } +/// 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`). +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`. fn is_logical_assign_op(op: AssignmentOperator) -> bool { @@ -2645,6 +2687,36 @@ mod tests { assert_eq!(product.graph.antecedents(b_flow), vec![c1, c2]); } + #[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 while_loop_topology() { // `while (x) a;` — L1=LoopLabel; entry F0 added first, back edge (C1) From 3ee6250b82c16ebc9246ed20b0a1bd8c9216d90f Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 13:40:53 -0400 Subject: [PATCH 42/79] feat: switch flow topology (clauses reachable from the head + (0,0) exhaustiveness sentinel + SwitchClause payload) --- crates/tsv_check/src/binder/flow.rs | 413 +++++++++++++++++++++++++--- 1 file changed, 380 insertions(+), 33 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index a0a6d2946..878a60ec1 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -12,12 +12,25 @@ //! `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`**. A `switch` gets a local post-switch break -//! target (so a contained `break` resolves locally) but its clause topology, -//! fallthrough and exhaustiveness stay linear placeholders. Still **F2**: switch -//! clause topology, try/finally exception edges, labeled-statement resolution, -//! IIFE inlining, and parameter-default forks (all marked `// F2:`). Flow stays -//! **dark** — nothing consumes it until F3, so this slice emits no diagnostics. +//! **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. Still **F2b**: try/finally +//! exception edges, labeled-statement resolution, IIFE inlining, and +//! parameter-default forks (all marked `// F2:`). 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 @@ -79,15 +92,17 @@ use tsv_ts::ast::internal::{ Decorator, DoWhileStatement, Expression, ForInOfLeft, ForInit, ForStatement, FunctionDeclaration, FunctionExpression, Identifier, IfStatement, LiteralValue, MethodDefinition, MethodKind, ObjectPatternProperty, ObjectProperty, Property, Statement, - SwitchStatement, TSModuleDeclarationBody, UnaryOperator, VariableDeclarator, WhileStatement, + SwitchCase, SwitchStatement, TSModuleDeclarationBody, UnaryOperator, VariableDeclarator, + WhileStatement, }; // --- 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; the F2-only bits (`SwitchClause`, -/// `ArrayMutation`, `ReduceLabel`) are never *set* in F1a. +/// bits are defined for shape; `SwitchClause` is set by the switch flow builder +/// (F2a), while the remaining F2 bits (`ArrayMutation`, `ReduceLabel`) are never +/// *set* yet. /// /// # tsgo /// `internal/ast/flow.go` `FlowFlags`. @@ -109,7 +124,7 @@ impl FlowFlags { pub const TRUE_CONDITION: FlowFlags = FlowFlags(1 << 5); /// Condition known to be false. pub const FALSE_CONDITION: FlowFlags = FlowFlags(1 << 6); - /// Switch-statement clause (F2 — never set in F1a). + /// Switch-statement clause (set by the switch flow builder, F2a). pub const SWITCH_CLAUSE: FlowFlags = FlowFlags(1 << 7); /// Potential array mutation (F2 — never set in F1a). pub const ARRAY_MUTATION: FlowFlags = FlowFlags(1 << 8); @@ -163,10 +178,11 @@ impl FlowFlags { // --- F2 payload shapes (defined for the SoA shape; not populated in F1a) ---- -/// A switch-clause payload (F2). Defined for the settled [`FlowGraph`] shape; -/// populated by the switch flow builder in a later slice. +/// 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)] -#[allow(dead_code)] // F2: written by the switch flow builder (binder.go:2087-2108) pub struct FlowSwitchClause { /// The switch statement node. pub switch: NodeId, @@ -209,8 +225,8 @@ pub struct FlowGraph { antecedent: Vec, /// Length-prefixed antecedent runs for labels (`[len, e0, e1, …]`). pool: Vec, - /// F2 — switch-clause payloads (empty in F1a; kept for shape). - #[allow(dead_code)] // F2: switch flow + /// Switch-clause payloads, addressed by a `SwitchClause` node's 1-based + /// `subject` slot (read via [`FlowGraph::switch_clause_data`]). switch_payloads: Vec, /// F2 — reduce-label payloads (empty in F1a; kept for shape). #[allow(dead_code)] // F2: try/finally flow @@ -233,12 +249,30 @@ impl FlowGraph { } /// 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 antecedents of a flow node, in append order. /// /// Non-label nodes have 0 or 1 antecedent (the single-antecedent slot); @@ -296,7 +330,9 @@ pub struct FlowProduct { /// (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)>, - /// F2 — case-clause fallthrough anchors (empty in F1a; kept for shape). + /// 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, @@ -320,6 +356,16 @@ impl FlowProduct { .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) + } } /// Build the flow product for one parsed file, from its `Program` and the F0 @@ -372,6 +418,12 @@ struct FlowBuilder<'a> { 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, // construction state (the F1b subset of the container-boundary set) current_flow: FlowNodeId, @@ -384,6 +436,11 @@ struct FlowBuilder<'a> { /// 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 @@ -421,12 +478,15 @@ impl<'a> FlowBuilder<'a> { node_flags: bound.node_flags.clone(), end_flow: Vec::new(), return_flow: Vec::new(), + fallthrough_flow: Vec::new(), + switch_payloads: 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, @@ -468,22 +528,24 @@ impl<'a> FlowBuilder<'a> { } 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: Vec::new(), + switch_payloads: self.switch_payloads, reduce_payloads: Vec::new(), }, flow_of_node: self.flow_of_node, node_flags: self.node_flags, end_flow, return_flow, - fallthrough_flow: Vec::new(), + fallthrough_flow, stats: FlowStats { branch_labels: self.branch_labels, dead_labels: self.dead_labels, @@ -554,6 +616,33 @@ impl<'a> FlowBuilder<'a> { 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 + } + /// `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 @@ -846,7 +935,7 @@ impl<'a> FlowBuilder<'a> { } Statement::BreakStatement(s) => self.bind_break_statement(s), Statement::ContinueStatement(s) => self.bind_continue_statement(s), - Statement::SwitchStatement(s) => self.bind_switch_statement(s), + Statement::SwitchStatement(s) => self.bind_switch_statement(id, s), // Everything else (declarations, blocks, and the F2 sequential // placeholders — labeled / try / exports / modules) threads flow // linearly through its children. @@ -1222,27 +1311,94 @@ impl<'a> FlowBuilder<'a> { } } - /// A `switch` with a **local** post-switch break target, so a contained - /// `break` resolves here rather than at an enclosing loop. F2: the real - /// clause topology (per-clause `SwitchClause` flow, fallthrough, - /// exhaustiveness) — F1b threads the discriminant + clauses linearly through - /// `current_flow`. - fn bind_switch_statement(&mut self, s: &SwitchStatement<'_>) { - let save_break = self.current_break_target; + /// `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.current_break_target = Some(post_switch); 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); - } + 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])); + 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); + } + // --- condition binding (the bindCondition machinery) ------------------ /// `doWithConditionalBranches` (binder.go:1789) — bind `value` with the given @@ -2275,6 +2431,26 @@ pub fn render_flow_dot(product: &FlowProduct, node_spans: &[Span], source: &str) 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::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); @@ -2307,6 +2483,8 @@ fn flow_flag_header(flags: FlowFlags) -> &'static str { "true" } else if flags.contains(FlowFlags::FALSE_CONDITION) { "false" + } else if flags.contains(FlowFlags::SWITCH_CLAUSE) { + "switch" } else if flags.contains(FlowFlags::CALL) { "call" } else { @@ -2867,4 +3045,173 @@ mod tests { } assert!(saw_shared, "the fn Start is shared by both condition nodes"); } + + // --- 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)); + } } From 1bac3ca267b7b9ba8fd20f73e6661b5120bfc482 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 14:47:27 -0400 Subject: [PATCH 43/79] test: cover non-narrowing switch + default-present sentinel absence (F2a review) + harmonize FlowFlags docs --- crates/tsv_check/src/binder/flow.rs | 62 ++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 878a60ec1..3e2e2c211 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -126,11 +126,12 @@ impl FlowFlags { 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 (F2 — never set in F1a). + /// 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 (F2 — never set in F1a). + /// 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); @@ -3214,4 +3215,61 @@ mod tests { 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" + ); + } } From d11695d793a508ac3a752ed00a1d5732e301c0cb Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 15:21:05 -0400 Subject: [PATCH 44/79] =?UTF-8?q?feat:=20flow=20landmines=20=E2=80=94=20tr?= =?UTF-8?q?y/finally=20ReduceLabels,=20IIFE=20inlining,=20initializer=20fo?= =?UTF-8?q?rks,=20labeled=20statements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/src/binder/flow.rs | 777 +++++++++++++++++++++++++--- 1 file changed, 713 insertions(+), 64 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 3e2e2c211..af75caa4f 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -27,10 +27,24 @@ //! `(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. Still **F2b**: try/finally -//! exception edges, labeled-statement resolution, IIFE inlining, and -//! parameter-default forks (all marked `// F2:`). Flow stays **dark** — nothing -//! consumes it until F3, so this slice emits no diagnostics. +//! (`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 @@ -71,17 +85,15 @@ //! 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. -//! - **IIFE inlining dropped** (binder.go:1525-1528). `is_flow_transparent` is -//! narrowed to `ClassStaticBlockDeclaration` only; ordinary -//! function-expression IIFEs stay flow-isolated (a safe F1 approximation; -//! true inlining is F2). +//! 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) -use crate::binder::{BoundFile, addr_of}; +use crate::binder::{BoundFile, NodeKind, addr_of}; use crate::ids::{FlowNodeId, NodeId}; use smallvec::SmallVec; use tsv_lang::Span; @@ -90,10 +102,10 @@ use tsv_ts::ast::internal::{ ArrowFunctionBody, AssignmentOperator, BinaryExpression, BinaryOperator, BreakStatement, ClassDeclaration, ClassExpression, ClassMember, ConditionalExpression, ContinueStatement, Decorator, DoWhileStatement, Expression, ForInOfLeft, ForInit, ForStatement, - FunctionDeclaration, FunctionExpression, Identifier, IfStatement, LiteralValue, - MethodDefinition, MethodKind, ObjectPatternProperty, ObjectProperty, Property, Statement, - SwitchCase, SwitchStatement, TSModuleDeclarationBody, UnaryOperator, VariableDeclarator, - WhileStatement, + FunctionDeclaration, FunctionExpression, Identifier, IfStatement, LabeledStatement, + LiteralValue, MethodDefinition, MethodKind, ObjectPatternProperty, ObjectProperty, Property, + Statement, SwitchCase, SwitchStatement, TSModuleDeclarationBody, TryStatement, UnaryOperator, + VariableDeclarator, WhileStatement, }; // --- FlowFlags ------------------------------------------------------------- @@ -193,14 +205,16 @@ pub struct FlowSwitchClause { pub clause_end: u32, } -/// A reduce-label payload (F2). Defined for the settled [`FlowGraph`] shape; -/// populated by the try/finally flow builder in a later slice. +/// 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)] -#[allow(dead_code)] // F2: written by the try/finally flow builder (binder.go:2042-2045) pub struct FlowReduceLabel { - /// The target label. + /// The label whose antecedent set is temporarily reduced. pub target: FlowNodeId, - /// Pool-run index of the temporary antecedent list. + /// **1-based** pool-run index of the reduced antecedent list (the same + /// length-prefixed pool convention the label pool uses). pub antecedents: u32, } @@ -229,8 +243,8 @@ pub struct FlowGraph { /// Switch-clause payloads, addressed by a `SwitchClause` node's 1-based /// `subject` slot (read via [`FlowGraph::switch_clause_data`]). switch_payloads: Vec, - /// F2 — reduce-label payloads (empty in F1a; kept for shape). - #[allow(dead_code)] // F2: try/finally flow + /// Reduce-label payloads (try/finally), addressed by a `ReduceLabel` node's + /// 1-based `subject` slot (read via [`FlowGraph::reduce_label_data`]). reduce_payloads: Vec, } @@ -274,6 +288,34 @@ impl FlowGraph { &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] + } + + /// The reduced antecedent list of a `ReduceLabel` node, in append order (the + /// temporary antecedent subset the checker substitutes for `target` while it + /// passes this node). + #[must_use] + pub fn reduce_label_antecedents(&self, id: FlowNodeId) -> Vec { + let data = self.reduce_label_data(id); + let off = (data.antecedents - 1) as usize; // 1-based pool-run index + let len = self.pool[off] as usize; + self.pool[off + 1..off + 1 + len] + .iter() + .filter_map(|&raw| FlowNodeId::from_raw(raw)) + .collect() + } + /// The antecedents of a flow node, in append order. /// /// Non-label nodes have 0 or 1 antecedent (the single-antecedent slot); @@ -374,8 +416,8 @@ impl FlowProduct { /// 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(program: &Program<'_>, _source: &str, bound: &BoundFile) -> FlowProduct { - let mut b = FlowBuilder::new(bound); +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() } @@ -397,11 +439,35 @@ struct SavedFlow { 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. struct FlowBuilder<'a> { bound: &'a BoundFile, + /// The host document — the label-name lookup extracts `spans[id]` slices. + source: &'a str, // graph columns flags: Vec, @@ -425,6 +491,12 @@ struct FlowBuilder<'a> { /// 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, @@ -466,10 +538,11 @@ struct FlowBuilder<'a> { } impl<'a> FlowBuilder<'a> { - fn new(bound: &'a BoundFile) -> FlowBuilder<'a> { + 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(), @@ -481,6 +554,8 @@ impl<'a> FlowBuilder<'a> { 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, @@ -540,7 +615,7 @@ impl<'a> FlowBuilder<'a> { antecedent: self.antecedent, pool: self.pool, switch_payloads: self.switch_payloads, - reduce_payloads: Vec::new(), + reduce_payloads: self.reduce_payloads, }, flow_of_node: self.flow_of_node, node_flags: self.node_flags, @@ -644,6 +719,35 @@ impl<'a> FlowBuilder<'a> { 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 @@ -782,6 +886,9 @@ impl<'a> FlowBuilder<'a> { 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); @@ -839,6 +946,7 @@ impl<'a> FlowBuilder<'a> { 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) ------------------------------------- @@ -937,9 +1045,10 @@ impl<'a> FlowBuilder<'a> { 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), - // Everything else (declarations, blocks, and the F2 sequential - // placeholders — labeled / try / exports / modules) threads flow - // linearly through its children. + 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), } } @@ -1047,7 +1156,9 @@ impl<'a> FlowBuilder<'a> { } } Statement::LabeledStatement(s) => { - self.visit_identifier(&s.label); // F2: real labeled-statement topology + // Dead-path fallback; the reachable topology lives in + // `bind_labeled_statement`. + self.visit_identifier(&s.label); self.visit_statement(s.body); } Statement::BreakStatement(s) => { @@ -1115,11 +1226,13 @@ impl<'a> FlowBuilder<'a> { /// `bindVariableDeclarationFlow` + `bindInitializedVariableFlow` /// (binder.go:2314) — a `var/let/const x = e` with an initializer emits one - /// unconditional `Assignment` (no default-value fork; that is F2). A - /// destructuring pattern emits one `Assignment` per declarator (tsv has no - /// binding-element node — see the module scope note). + /// 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.visit_expression(&decl.id); + self.bind_binding_target(&decl.id); if let Some(init) = &decl.init { self.visit_expression(init); } @@ -1154,7 +1267,8 @@ impl<'a> FlowBuilder<'a> { /// 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 pre_while = set_continue_target(stmt_id, self.create_loop_label()); + 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) @@ -1171,7 +1285,8 @@ impl<'a> FlowBuilder<'a> { /// 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 pre_condition = set_continue_target(stmt_id, self.create_branch_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; @@ -1185,7 +1300,8 @@ impl<'a> FlowBuilder<'a> { /// `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 pre_loop = set_continue_target(stmt_id, self.create_loop_label()); + 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(); @@ -1225,7 +1341,8 @@ impl<'a> FlowBuilder<'a> { right: &Expression<'_>, body: &Statement<'_>, ) { - let pre_loop = set_continue_target(stmt_id, self.create_loop_label()); + 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); @@ -1238,7 +1355,7 @@ impl<'a> FlowBuilder<'a> { match left { ForInOfLeft::VariableDeclaration(d) => { for decl in d.declarations { - self.visit_expression(&decl.id); + self.bind_binding_target(&decl.id); if let Some(init) = &decl.init { self.visit_expression(init); } @@ -1260,6 +1377,28 @@ impl<'a> FlowBuilder<'a> { 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( @@ -1277,27 +1416,46 @@ impl<'a> FlowBuilder<'a> { self.current_continue_target = save_continue; } - /// `bindBreakStatement` (binder.go:1955), unlabeled path only. + /// `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); } - // F2: labeled break resolution (findActiveLabel + the label's target). - Some(label) => self.visit_identifier(label), + 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), unlabeled path only. + /// `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); } - // F2: labeled continue resolution. - Some(label) => self.visit_identifier(label), + 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); + } + } } } @@ -1400,6 +1558,146 @@ impl<'a> FlowBuilder<'a> { 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 { + self.visit_expression(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)); + 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)); + self.bound.spans[id.index()].extract(self.source) + } + // --- condition binding (the bindCondition machinery) ------------------ /// `doWithConditionalBranches` (binder.go:1789) — bind `value` with the given @@ -1666,35 +1964,107 @@ impl<'a> FlowBuilder<'a> { self.exit_container(saved, false, true, true, anchor, false); } - fn visit_function_expression(&mut self, f: &FunctionExpression<'_>, node_id: NodeId) { + /// 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), false, false); + 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, false, true, true, node_id, false); + 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), false, false); + 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, false, true, true, node_id, false); + self.exit_container(saved, is_iife, true, true, node_id, false); } fn bind_params(&mut self, params: &[Expression<'_>]) { for param in params { - self.visit_expression(param); + 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), } } @@ -1898,9 +2268,37 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(b.right); } E::CallExpression(c) => { - self.visit_expression(c.callee); - for a in c.arguments { - self.visit_expression(a); + // 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)); + 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)); + self.visit_function_expression(f, id, true); + } + _ => { + self.visit_expression(c.callee); + for arg in c.arguments { + self.visit_expression(arg); + } + } } } E::NewExpression(n) => { @@ -1912,11 +2310,11 @@ impl<'a> FlowBuilder<'a> { E::ConditionalExpression(c) => self.bind_conditional_expression_flow(c), E::ArrowFunctionExpression(a) => { let id = self.require(addr_of(a)); - self.visit_arrow(a, id); + self.visit_arrow(a, id, false); } E::FunctionExpression(f) => { let id = self.require(addr_of(f)); - self.visit_function_expression(f, id); + self.visit_function_expression(f, id, false); } E::ClassExpression(c) => self.visit_class_expr(c), E::SpreadElement(s) => self.visit_expression(s.argument), @@ -2205,13 +2603,6 @@ fn is_false_keyword(expr: &Expression<'_>) -> bool { matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(false))) } -/// `setContinueTarget` (binder.go:1779). F2 walks the active-label list (labeled -/// loops) assigning each label's continue target; F1b has no active labels, so -/// this is the identity on `target`. The `_loop` node keeps the call-site shape. -fn set_continue_target(_loop: NodeId, target: FlowNodeId) -> FlowNodeId { - target -} - /// Whether a condition node is a logical `&&`/`||`/`??` or a logical /// compound-assignment `&&=`/`||=`/`??=` — the `bindCondition` non-atomic test /// (binder.go:1801, combining `IsLogicalExpression` + `isLogicalAssignment`). @@ -2432,6 +2823,12 @@ pub fn render_flow_dot(product: &FlowProduct, node_spans: &[Span], source: &str) 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(). @@ -2486,6 +2883,8 @@ fn flow_flag_header(flags: FlowFlags) -> &'static str { "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 { @@ -2696,7 +3095,7 @@ mod tests { 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); + 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(); @@ -2736,7 +3135,7 @@ mod tests { let false_lit = expr_at(1); let y = expr_at(2); - let mut b = FlowBuilder::new(&bound); + let mut b = FlowBuilder::new(&bound, src); let ante = b.new_flow_node(FlowFlags::START); // nil-expr True → passthrough; nil-expr False → unreachable. @@ -3272,4 +3671,254 @@ mod tests { "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 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 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" + ); + } } From fbaf5f4d87d562c2738d650e303803d72d21b083 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 15:35:49 -0400 Subject: [PATCH 45/79] fix: fork the catch parameter's destructuring default (bind_binding_target) + branch-guard tests (F2b review) --- crates/tsv_check/src/binder/flow.rs | 45 ++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index af75caa4f..afdb6f761 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -112,9 +112,9 @@ use tsv_ts::ast::internal::{ /// 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` is set by the switch flow builder -/// (F2a), while the remaining F2 bits (`ArrayMutation`, `ReduceLabel`) are never -/// *set* yet. +/// 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`. @@ -1599,7 +1599,11 @@ impl<'a> FlowBuilder<'a> { self.add_antecedent(exception_label, self.current_flow); self.current_exception_target = Some(exception_label); if let Some(param) = &handler.param { - self.visit_expression(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); @@ -3813,6 +3817,39 @@ mod tests { ); } + #[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)); + } + + #[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 From 9fa6a51dcb074388dabfa6dfe8d0bb65c9688fb7 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 16:28:19 -0400 Subject: [PATCH 46/79] feat: TS7027/TS7028 unreachable-code + unused-label shim + tsv_check options surface + suggestion sink --- crates/tsv_check/src/binder/mod.rs | 1 + crates/tsv_check/src/check/mod.rs | 1 + crates/tsv_check/src/check/unreachable.rs | 1020 +++++++++++++++++ crates/tsv_check/src/diag.rs | 23 + crates/tsv_check/src/lib.rs | 3 + crates/tsv_check/src/options.rs | 42 + crates/tsv_check/src/program.rs | 77 +- crates/tsv_check/tests/lib_base.rs | 10 +- .../tsv_debug/src/tsc_conformance/runner.rs | 29 +- 9 files changed, 1181 insertions(+), 25 deletions(-) create mode 100644 crates/tsv_check/src/check/unreachable.rs create mode 100644 crates/tsv_check/src/options.rs diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 9fac9631f..b45925239 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -2119,6 +2119,7 @@ mod tests { 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() } diff --git a/crates/tsv_check/src/check/mod.rs b/crates/tsv_check/src/check/mod.rs index 8a665ebb6..1ab1f4743 100644 --- a/crates/tsv_check/src/check/mod.rs +++ b/crates/tsv_check/src/check/mod.rs @@ -22,6 +22,7 @@ // checks this walk ports piecemeal) mod duplicate_members; +pub(crate) mod unreachable; use crate::diag::Diagnostic; use crate::ids::FileId; diff --git a/crates/tsv_check/src/check/unreachable.rs b/crates/tsv_check/src/check/unreachable.rs new file mode 100644 index 000000000..d3aca4731 --- /dev/null +++ b/crates/tsv_check/src/check/unreachable.rs @@ -0,0 +1,1020 @@ +//! 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}; +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, + ) { + // 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 it entirely (neither sink). + if !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 { + if 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)> { + let mut directive_starts: Vec = comments + .iter() + .filter(|c| is_directive_comment(c.content(source))) + .map(|c| c.span.start) + .collect(); + if directive_starts.is_empty() { + return Vec::new(); + } + directive_starts.sort_unstable(); + let bytes = source.as_bytes(); + let line_starts = compute_line_starts(source); + let mut ranges: Vec<(u32, u32)> = Vec::new(); + for &start in &directive_starts { + let mut l = line_of(&line_starts, start) + 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)).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 walks the + // enclosing scope per name; tsv takes tsgo's "couldn't locate, assume a + // value" fallback. The residual is a dead namespace whose only member is + // a type-only re-export. + None => ModuleInstanceState::Instantiated, + }, + _ => 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 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 index 63a667afd..d205295a2 100644 --- a/crates/tsv_check/src/diag.rs +++ b/crates/tsv_check/src/diag.rs @@ -83,6 +83,29 @@ impl Diagnostic { 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. diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 78e8f71a6..d793de3bf 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -49,6 +49,7 @@ mod binder; mod check; mod hash; +mod options; mod program; mod span_scan; @@ -61,9 +62,11 @@ 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, 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 index 693de5720..875a95c1c 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -35,9 +35,11 @@ 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}; @@ -58,12 +60,17 @@ impl<'a> SourceUnit<'a> { } } -/// The result of checking a program: its (sorted, deduped) diagnostics, a -/// per-unit report, and whether any unit parse-rejected. +/// 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 @@ -129,10 +136,14 @@ struct BoundUnit { bind_diagnostics: Vec, /// The merge product, `None` when the unit parse-rejected. merge: Option, - /// The per-file flow product, carried **dark** — nothing consumes it until - /// F3; F1a builds it and `--dump-flow` renders it. `None` when the unit - /// parse-rejected (no AST to walk). + /// 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 { @@ -181,6 +192,11 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr // 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 @@ -201,6 +217,7 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr bind_diagnostics, merge: Some(bound.merge), flow: Some(flow), + candidates: Some(candidates), }); } Err(message) => { @@ -212,6 +229,7 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr bind_diagnostics: Vec::new(), merge: None, flow: None, + candidates: None, }); } } @@ -224,16 +242,31 @@ pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgr } } -/// Merge a [`BoundProgram`] against an optional [`LibBase`] 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. +/// 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>) -> CheckResult { +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); + } } // Only test-unit merges are cloned here (lib globals live in the base, not in // `files`), so this stays cheap even run per-variant. @@ -248,6 +281,7 @@ pub fn check_bound(bound: &BoundProgram, lib: Option<&LibBase>) -> CheckResult { paths.extend(base.lib_files.iter().cloned()); } sort_and_deduplicate(&mut diagnostics, &paths); + sort_and_deduplicate(&mut suggestions, &paths); let files = bound .units @@ -260,6 +294,7 @@ pub fn check_bound(bound: &BoundProgram, lib: Option<&LibBase>) -> CheckResult { .collect(); CheckResult { diagnostics, + suggestions, files, parse_rejected: bound.parse_rejected, } @@ -267,19 +302,26 @@ pub fn check_bound(bound: &BoundProgram, lib: Option<&LibBase>) -> CheckResult { /// 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) -> CheckResult { - check_bound(&bind_program(units, arena), None) +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) + check_bound(&bind_program(units, arena), lib, options) } /// Parse + bind one lib `.d.ts` file, returning its owned global-eligible product @@ -332,7 +374,11 @@ mod tests { fn check(source: &str) -> CheckResult { let arena = Bump::new(); - check_program(&[SourceUnit::new("test.ts", source)], &arena) + check_program( + &[SourceUnit::new("test.ts", source)], + &arena, + &CheckOptions::default(), + ) } #[test] @@ -389,6 +435,7 @@ mod tests { SourceUnit::new("b.ts", "const = ;"), ], &arena, + &CheckOptions::default(), ); assert!(result.parse_rejected); // a.ts's two TS2451 survive despite b.ts rejecting. diff --git a/crates/tsv_check/tests/lib_base.rs b/crates/tsv_check/tests/lib_base.rs index af803aa7f..cd3b938f8 100644 --- a/crates/tsv_check/tests/lib_base.rs +++ b/crates/tsv_check/tests/lib_base.rs @@ -7,7 +7,7 @@ //! the same bind path the harness uses. use bumpalo::Bump; -use tsv_check::{FileId, LibBase, SourceUnit, bind_lib, check_program_with_lib}; +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. @@ -19,7 +19,7 @@ fn var_eval_conflicts_with_lib_function_eval() { let arena = Bump::new(); let units = [SourceUnit::new("t.ts", "\"use strict\";\nvar eval;")]; - let result = check_program_with_lib(&units, Some(&base), &arena); + 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. @@ -61,7 +61,7 @@ fn class_promise_conflicts_across_lib_files() { "promiseDefinitionTest.ts", "class Promise {}", )]; - let result = check_program_with_lib(&units, Some(&base), &arena); + let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default()); let primary = result .diagnostics @@ -94,7 +94,7 @@ fn interface_augmentation_of_lib_is_silent() { "t.ts", "interface Array { extra(): void; }", )]; - let result = check_program_with_lib(&units, Some(&base), &arena); + let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default()); assert!( result.diagnostics.is_empty(), "a legal interface merge must be silent" @@ -107,6 +107,6 @@ fn interface_augmentation_of_lib_is_silent() { 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); + let result = check_program_with_lib(&units, None, &arena, &CheckOptions::default()); assert!(result.diagnostics.is_empty()); } diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 87156f233..1d2ceebd3 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -51,8 +51,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::Path; use std::time::Instant; use tsv_check::{ - Diagnostic, FileId, ParseReport, SourceUnit, bind_file, bind_program, build_flow, check_bound, - check_program, render_flow_dot, + CheckOptions, Diagnostic, FileId, ParseReport, SourceUnit, bind_file, bind_program, build_flow, + check_bound, check_program, render_flow_dot, }; use tsv_lang::{LocationMapper, LocationTracker}; @@ -523,7 +523,7 @@ fn probe_crash_exclusion(test: &CorpusTest) -> bool { let prev = std::panic::take_hook(); std::panic::set_hook(Box::new(|_| {})); let panicked = catch_unwind(AssertUnwindSafe(|| { - let _ = check_program(&source_units, &arena); + let _ = check_program(&source_units, &arena, &CheckOptions::default()); })) .is_err(); std::panic::set_hook(prev); @@ -547,6 +547,24 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { /// variant-independent; only the merge (and thus the lib-conflict family) varies by /// the resolved lib set, so a variant with a Promise/Symbol/… global conflicts at /// one target and is clean at another. +/// Build `tsv_check`'s options from a variant's resolved directive config, mapping +/// the harness tri-state to the checker's. `preserveConstEnums` feeds +/// `ShouldPreserveConstEnums` (the `isolatedModules` contribution is not modeled — +/// a rare-in-dead-code residual that can only under-report). +fn check_options_for(config: &BTreeMap) -> CheckOptions { + use crate::tsc_conformance::options_meta::{Tristate as OptTri, resolve_bool}; + let map = |t: OptTri| match t { + OptTri::Unset => tsv_check::Tristate::Unknown, + OptTri::False => tsv_check::Tristate::False, + OptTri::True => tsv_check::Tristate::True, + }; + CheckOptions { + allow_unreachable_code: map(resolve_bool(config, "allowunreachablecode")), + allow_unused_labels: map(resolve_bool(config, "allowunusedlabels")), + preserve_const_enums: resolve_bool(config, "preserveconstenums") == OptTri::True, + } +} + fn grade_test( test: &CorpusTest, unit: &Unit, @@ -622,9 +640,10 @@ fn grade_test( // lib resolution (parse+bind of each `.d.ts`) is the only remaining // panic source past the initial bind, so contain it per variant: a // future lib parse panic is recorded, not sweep-fatal. + let check_opts = check_options_for(&variant.config); let checked = catch_unwind(AssertUnwindSafe(|| { let base = resolver.base_for(&variant.config); - let result = check_bound(&bound, base.as_deref()); + let result = check_bound(&bound, base.as_deref(), &check_opts); (base, result) })); let (base, result) = match checked { @@ -1536,7 +1555,7 @@ pub fn check_one( let mut resolver = LibResolver::new(checkout); let base = resolver.base_for(&variant.config); let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); - let result = check_bound(&bound, base.as_deref()); + let result = check_bound(&bound, base.as_deref(), &check_options_for(&variant.config)); // Resolve each diagnostic's FileId to a display line: a program unit carries its // (line, col); a lib file carries the lib name with a masked location. From 441137f26476bd0dde30d37a3fea6fc5bddd2c53 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 16:45:09 -0400 Subject: [PATCH 47/79] fix: bare export{} -> NonInstantiated (close false-extra) + suggestion/directive fidelity + 2 guard tests (F3 review) --- crates/tsv_check/src/check/unreachable.rs | 75 ++++++++++++++++++----- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/tsv_check/src/check/unreachable.rs b/crates/tsv_check/src/check/unreachable.rs index d3aca4731..643f990a3 100644 --- a/crates/tsv_check/src/check/unreachable.rs +++ b/crates/tsv_check/src/check/unreachable.rs @@ -155,8 +155,10 @@ impl UnreachableCandidates { j += 1; } // A directive (`@ts-ignore` / `@ts-expect-error`) preceding the - // sub-run's start line drops it entirely (neither sink). - if !self.is_suppressed(start) { + // 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, @@ -176,7 +178,8 @@ impl UnreachableCandidates { if options.allow_unused_labels != Tristate::True { let is_error = options.allow_unused_labels == Tristate::False; for &span in &self.unused_labels { - if self.is_suppressed(span.start) { + // Error-category only; a suggestion bypasses directive suppression. + if is_error && self.is_suppressed(span.start) { continue; } push( @@ -264,20 +267,24 @@ pub fn build_candidates( /// 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)> { - let mut directive_starts: Vec = comments + // 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.start) + .map(|c| c.span.end) .collect(); - if directive_starts.is_empty() { + if directive_ends.is_empty() { return Vec::new(); } - directive_starts.sort_unstable(); + 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 &start in &directive_starts { - let mut l = line_of(&line_starts, start) + 1; + 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 @@ -837,11 +844,15 @@ fn statement_instance_state(stmt: &Statement<'_>) -> ModuleInstanceState { // `export interface` / `export const enum` / `export const` — classify // the wrapped declaration. Some(inner) => statement_instance_state(inner), - // Bare `export { … }` alias targets: a faithful resolution walks the - // enclosing scope per name; tsv takes tsgo's "couldn't locate, assume a - // value" fallback. The residual is a dead namespace whose only member is - // a type-only re-export. - None => ModuleInstanceState::Instantiated, + // 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, } @@ -936,6 +947,42 @@ mod tests { ); } + #[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; }"; From 6a291eb3211abbf798f60aca53f9f263eef398c3 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 17:39:14 -0400 Subject: [PATCH 48/79] =?UTF-8?q?feat:=20flip=20the=20flow=20family=20gate?= =?UTF-8?q?=20=E2=80=94=20TS7027/7028=20release-gating=20(match=2034=20/?= =?UTF-8?q?=20missing=2026=20deferred=5Fcfa=20/=20extra=200)=20+=20--famil?= =?UTF-8?q?y=20filter=20+=20RUN=5FFLOW=5F*=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 19 +- .../js/results/report.tsc-conformance.json | 35 ++- benches/js/results/report.tsc-conformance.md | 15 +- crates/tsv_check/CLAUDE.md | 30 ++- .../src/cli/commands/tsc_conformance.rs | 161 ++++++++++--- crates/tsv_debug/src/tsc_conformance/mod.rs | 2 +- .../tsv_debug/src/tsc_conformance/runner.rs | 222 ++++++++++++++++-- 7 files changed, 405 insertions(+), 79 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6c855949d..c600f5e87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,7 +246,7 @@ Package shape: built from the wasm-pack `web` target, then `scripts/patch_npm_pa `scripts/publish.ts` orchestrates the release end to end (preflight → bump → check → conformance → build (npm packages + deno bundles, so artifact validation never sees stale bundles) → verify → artifact validation: size bounds + Deno smoke + Node tests → idempotent npm publish → git commit + tag + push), printing a wasm size summary (raw + gzipped) at the end. It stamps CHANGELOG.md's `## Unreleased` section into the released version's section — that section must be non-empty and carry a `` marker that matches `--bump` (the bump is required in **both** places and they must agree; on stamp the marker is dropped and a fresh empty `## Unreleased` reset to `bump: patch` is seeded for the next cycle). The user keeps it updated as work lands — agents don't touch `CHANGELOG.md` (see [Committing](#committing)). A failed wetrun is resumable: re-run `--wetrun` without `--bump`. -**Conformance gates (Step 3b).** The release-cadence correctness gates that run against **external oracles** — so they can't live in `deno task check` — run here via `deno task conformance`: the Svelte parser vs `svelte/compiler` (`conformance:svelte-fixtures`), the TS parser vs acorn-typescript's own suite (`conformance:ts-fixtures`) and the tsc corpus (`conformance:ts-repo`), the `tsc_conformance` roundtrip self-check vs tsgo's `.errors.txt` baselines (`conformance:tsc-roundtrip`), the tsv_check binder-family conformance gate vs those baselines (`conformance:tsc-check` — the duplicate/conflict code family, exact pins + the committed `report.tsc-conformance.{json,md}`), plus all three languages' AST + formatter vs the canonical parsers / prettier (`corpus:compare:parse` + `:format`, `--all`). Skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go` checkouts — for the tsc-check leg also the materialized `_submodules/TypeScript` corpus + `internal/bundled/libs` — + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. `test262` (needs `../test262`) and the CSS-WPT harvest stay manual, out of the automated step. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; the historical FFI heisenbug now surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). +**Conformance gates (Step 3b).** The release-cadence correctness gates that run against **external oracles** — so they can't live in `deno task check` — run here via `deno task conformance`: the Svelte parser vs `svelte/compiler` (`conformance:svelte-fixtures`), the TS parser vs acorn-typescript's own suite (`conformance:ts-fixtures`) and the tsc corpus (`conformance:ts-repo`), the `tsc_conformance` roundtrip self-check vs tsgo's `.errors.txt` baselines (`conformance:tsc-roundtrip`), the tsv_check binder-family conformance gate vs those baselines (`conformance:tsc-check` — the duplicate/conflict code family + the flow family TS7027/7028, exact pins + the committed `report.tsc-conformance.{json,md}`), plus all three languages' AST + formatter vs the canonical parsers / prettier (`corpus:compare:parse` + `:format`, `--all`). Skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go` checkouts — for the tsc-check leg also the materialized `_submodules/TypeScript` corpus + `internal/bundled/libs` — + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. `test262` (needs `../test262`) and the CSS-WPT harvest stay manual, out of the automated step. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; the historical FFI heisenbug now surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). ```bash deno task publish # dry-run: validate everything, no mutation @@ -828,14 +828,19 @@ cargo run -p tsv_debug tsc_conformance roundtrip compiler/async # filter by pat # run - the conformance gate over tsv_check: sweeps every in-scope variant # (single-file, non-JSX, non-JS-flavored, non-skipped), drives parse → lower+bind # → check → sort/dedup via the tsv_check crate, grades expect-clean variants -# (must be zero-diagnostic) AND the bind/merge duplicate-conflict family -# (TS2300/2451/2567/2528 + merge-path codes) as codes+spans multisets — extra=0 -# is a hard gate, missing is classified by deferred cause (check-time family / -# merge / lib) — and publishes the parse-divergence census (tsv -# parse-rejections bucketed by baseline shape + Script-goal retries). Each test +# (must be zero-diagnostic) AND two graded families as codes+spans multisets — +# the bind/merge duplicate-conflict family (TS2300/2451/2567/2528 + merge-path +# codes) and the flow family (TS7027 unreachable code + TS7028 unused label, from +# the check/unreachable shim over the binder flow product). extra=0 is a hard +# gate; missing is classified by deferred cause — merge / lib / deferred_late_bound +# (lateBindMember residual) / deferred_cfa (isReachableFlowNode residual: +# never-returning sigs / assertion predicates / switch exhaustiveness / structural +# reachability) / other (HARD-zero). It also publishes the parse-divergence census +# (tsv parse-rejections bucketed by baseline shape + Script-goal retries). Each test # runs catch_unwind-wrapped on a generous-stack worker; tracked parser crashes # live in a pinned CRASH_EXCLUSIONS ledger. Exact two-sided RUN_* pins on full -# runs. Triage filters --test / --code / --variant k=v skip the +# runs. Triage filters --test / --code / --variant k=v / +# --family {dup,flow,all} skip the # pins (invariant gates still hold); --emit-manifest writes per-variant # verdicts + buckets + census (pins verified before writing); --report # writes the deterministic committed report (full-run only). On gate failure, diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json index 218b3a2d8..1324be12c 100644 --- a/benches/js/results/report.tsc-conformance.json +++ b/benches/js/results/report.tsc-conformance.json @@ -1,5 +1,5 @@ { - "oracle": "tsgo committed .errors.txt baselines (bind + merge family)", + "oracle": "tsgo committed .errors.txt baselines (bind + merge + flow family)", "denominators": { "in_scope_tests": 9388, "in_scope_variants": 9887, @@ -7,15 +7,20 @@ "clean_pass": 4435, "baselined_parsed": 4446, "family_graded_variants": 4066, - "family_positive_variants": 125 + "family_positive_variants": 140 }, "family": { - "match": 539, + "match": 573, + "dup_match": 539, + "flow_match": 34, "missing": { - "total": 11, + "total": 37, + "dup": 11, + "flow": 26, "merge_path": 0, "lib_conflict": 0, "late_bound": 11, + "cfa": 26, "other": 0 }, "extra": 0, @@ -28,10 +33,13 @@ "2451": 56, "2528": 35, "2567": 26, - "2664": 3 + "2664": 3, + "7027": 31, + "7028": 3 }, "missing": { - "2300": 11 + "2300": 11, + "7027": 26 } }, "related": { @@ -42,7 +50,7 @@ }, "carve_outs": { "recovery_ast_rule_a": 380, - "recovery_ast_rule_a_family": 9, + "recovery_ast_rule_a_family": 11, "module_detection_variants": 1 }, "census": { @@ -64,12 +72,17 @@ "baselined_parsed": 4446, "parse_rejected": 1006, "family_graded": 4066, - "family_positive": 125, - "family_match": 539, - "family_missing": 11, + "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, @@ -78,7 +91,7 @@ "related_extra": 0, "related_span_mismatch": 0, "carve_out_rule_a": 380, - "carve_out_rule_a_family": 9, + "carve_out_rule_a_family": 11, "module_detection": 1, "script_retry": 25, "crash_excluded": 1, diff --git a/benches/js/results/report.tsc-conformance.md b/benches/js/results/report.tsc-conformance.md index 81daad3f5..0540038dc 100644 --- a/benches/js/results/report.tsc-conformance.md +++ b/benches/js/results/report.tsc-conformance.md @@ -1,6 +1,6 @@ # tsc_conformance run — committed report -Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Deterministic — wall-clock excluded. +Oracle: tsgo committed `.errors.txt` baselines (bind + merge + flow family). Deterministic — wall-clock excluded. ## Denominators @@ -8,12 +8,13 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis - in-scope variants: 9887 - expect-clean graded / clean pass: 4435 / 4435 - baselined + parsed: 4446 -- family graded / family-positive: 4066 / 125 +- family graded / family-positive: 4066 / 140 -## Family (2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671) +## Family (dup 2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671; flow 7027 / 7028) -- match: 539 -- missing: 11 (merge-path 0, lib-conflict 0, late-bound 11, other 0) +- 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 @@ -27,6 +28,8 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis | TS2528 | 35 | 0 | | TS2567 | 26 | 0 | | TS2664 | 3 | 0 | +| TS7027 | 31 | 26 | +| TS7028 | 3 | 0 | ## Related-info channel (matched primaries) @@ -34,7 +37,7 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). Determinis ## Carve-outs -- recovery-AST rule (a): 380 (family-positive 9) +- recovery-AST rule (a): 380 (family-positive 11) - moduleDetection variants (inert for family): 1 ## Parse-divergence census diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 6a7e724a0..3c6d058a0 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -89,7 +89,18 @@ `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. + 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.rs`): 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` @@ -123,12 +134,17 @@ result is fully owned — nothing borrows out. For lib-aware checking: - `tsv_debug tsc_conformance run` — the standing gate: sweeps the in-scope corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades - expect-clean variants AND the duplicate-conflict family - (TS2300/2451/2567/2528 + merge-path TS2397/2649/2664/2671) as codes+spans - multisets — bind + merge + lib **and** the check-time TS2300 subset - (duplicate members / type parameters, from the `check` pass). extra = 0 is a - hard gate; a missing is classified `merge` / `lib` / `deferred_late_bound` - (an exact pin — the type-engine-dependent `lateBindMember` residual) / + 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; exact `RUN_*` pins. diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 10dbd8b49..b7175f91c 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -8,9 +8,9 @@ use crate::cli::CliError; use crate::tsc_conformance::index::IndexReport; use crate::tsc_conformance::runner::SkeletonReport; use crate::tsc_conformance::{ - RunFilter, RunOptions, baselines_dir, check_one, corpus_materialized, denominators, - discover_baselines, dump_flow_dot, histogram, run_index, run_roundtrip, run_skeleton, - tests_by_code, + FamilyFilter, RunFilter, RunOptions, baselines_dir, check_one, corpus_materialized, + denominators, discover_baselines, dump_flow_dot, histogram, run_index, run_roundtrip, + run_skeleton, tests_by_code, }; use argh::FromArgs; use std::path::{Path, PathBuf}; @@ -90,36 +90,48 @@ const RUN_SCRIPT_RETRY_PIN: usize = 25; const RUN_CRASH_EXCLUDED_PIN: usize = 1; /// REGRESSION PINS (exact, two-sided) for the family grading (the bind + merge + -/// check gate). Measured vs pin 168e7015. `family_extra` is gated to 0 (hard); the -/// rest pin the buckets so any move (a cascade change, a merge change, a check-pass -/// change, a tsv parser change, a typescript-go pull) forces a deliberate re-pin. -/// The syntactic check pass emits the duplicate-member family (TS2300 over class / -/// interface / type-literal properties and accessors) **and** the type-parameter -/// identity check (TS2300 over a declaration's own duplicate type parameters), so -/// those match rather than sitting in the missing bucket. The missing bucket is -/// classified four ways: `merge` (merge-phase family — **0**: the single-file merge -/// path matches, TS2397 globalThis/undefined plus TS2664 augmentation-not-found), -/// `lib` (absent-lib conflicts — **0**: the classified lib-conflict baselines, TS2300 -/// eval/Symbol/Promise/ElementTagNameMap, match against the standard-library -/// globals), `late-bound` (**11**: the `LATE_BOUND_BASELINES` tests, genuinely -/// deferred to the type engine — literal-type / `unique symbol` computed member -/// names), and `other` (**0**, a HARD gate — any unclassified miss is a same-table -/// cascade bug). A drop in `late-bound` (matches gained) is a real improvement that -/// re-pins; a rise in `other` fails the run. +/// check + flow gate). Measured vs pin 168e7015. `family_extra` is gated to 0 +/// (hard); the rest pin the buckets so any move (a cascade change, a merge change, a +/// check-pass change, a flow-shim change, a tsv parser change, a typescript-go pull) +/// forces a deliberate re-pin. The graded family is now two sub-families: the +/// duplicate-conflict codes ([`DUP_CODES`], dup match/missing **539 / 11**) and the +/// flow-construction codes ([`FLOW_CODES`], TS7027/TS7028, flow match/missing +/// **34 / 26**). The syntactic check pass emits the duplicate-member family (TS2300 +/// over class / interface / type-literal properties and accessors) **and** the +/// type-parameter identity check (TS2300 over a declaration's own duplicate type +/// parameters); the flow shim emits TS7027 (unreachable code, construction-only +/// fast path) + TS7028 (unused label). The missing bucket is classified five ways: +/// `merge` (merge-phase family — **0**), `lib` (absent-lib conflicts — **0**), +/// `late-bound` (**11**: the `LATE_BOUND_BASELINES` tests, deferred to the type +/// engine — literal-type / `unique symbol` computed member names), `cfa` (**26**: +/// the `DEFERRED_CFA_BASELINES` tests, deferred to the CFA type engine — +/// never-returning signatures / assertion predicates / switch exhaustiveness / +/// structural reachability fallback), and `other` (**0**, a HARD gate — any +/// unclassified miss is a cascade/construction bug). A drop in `late-bound` / `cfa` +/// (matches gained) is a real improvement that re-pins; a rise in `other` fails the +/// run. `family_match` / `family_missing` are the aggregate totals (573 / 37); +/// `dup_*` / `flow_*` pin the sub-family partitions. const RUN_FAMILY_GRADED_PIN: usize = 4066; -const RUN_FAMILY_POSITIVE_PIN: usize = 125; -const RUN_FAMILY_MATCH_PIN: usize = 539; -const RUN_FAMILY_MISSING_PIN: usize = 11; +const RUN_FAMILY_POSITIVE_PIN: usize = 140; +const RUN_FAMILY_MATCH_PIN: usize = 573; +const RUN_FAMILY_MISSING_PIN: usize = 37; +const RUN_DUP_MATCH_PIN: usize = 539; +const RUN_DUP_MISSING_PIN: usize = 11; +const RUN_FLOW_MATCH_PIN: usize = 34; +const RUN_FLOW_MISSING_PIN: usize = 26; const RUN_MISSING_MERGE_PIN: usize = 0; const RUN_MISSING_LIB_PIN: usize = 0; -/// Genuinely-deferred family misses (need the type engine); exact-pinned. +/// Genuinely-deferred dup-family misses (need the type engine); exact-pinned. const RUN_MISSING_DEFERRED_LATE_BOUND_PIN: usize = 11; +/// Genuinely-deferred flow-family misses (need the CFA type engine); exact-pinned. +const RUN_MISSING_DEFERRED_CFA_PIN: usize = 26; /// Unclassified family misses — a HARD-zero invariant gate (not just a full-run pin), -/// so a same-table cascade regression fails even a filtered triage run. +/// so a same-table cascade / flow-construction regression fails even a filtered +/// triage run. const RUN_MISSING_OTHER_PIN: usize = 0; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; -const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 9; +const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 11; const RUN_MODULE_DETECTION_PIN: usize = 1; /// REGRESSION PINS (exact, two-sided) for the lib base. Measured 2026-07-10 vs @@ -270,6 +282,11 @@ pub struct RunCommand { #[argh(option)] variant: Option, + /// triage filter: keep only variants whose baseline carries a code in this + /// sub-family — `dup`, `flow`, or `all` (SKIPS the pins) + #[argh(option)] + family: Option, + /// write a JSON manifest of every graded variant (per-variant verdict + buckets /// + census + pins) to this path — the tsc analog of `test262 --emit-manifest` #[argh(option)] @@ -381,14 +398,35 @@ impl RunCommand { } None => None, }; + let family = match self.family.as_deref().map(parse_family_filter) { + Some(Ok(f)) => Some(f), + Some(Err(e)) => { + eprintln!("{e}"); + return Err(CliError::Failed); + } + None => None, + }; Ok(RunFilter { test: self.test.clone(), code: self.code, variant, + family, }) } } +/// Parse a `--family` value into a [`FamilyFilter`] (`dup` / `flow` / `all`). +fn parse_family_filter(arg: &str) -> Result { + match arg.to_lowercase().as_str() { + "dup" => Ok(FamilyFilter::Dup), + "flow" => Ok(FamilyFilter::Flow), + "all" => Ok(FamilyFilter::All), + other => Err(format!( + "Error: --family expects dup, flow, or all, got {other:?}" + )), + } +} + /// Enforce the skeleton gates: the clean-grade + empty-channel invariants and zero /// panics (always), plus — on a full run (`enforce_pins`) — the exact denominator, /// family, related, and census pins. A filtered (triage) run skips the pins. @@ -587,6 +625,31 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), report.family_missing, RUN_FAMILY_MISSING_PIN, ); + // Sub-family partitions (dup = bind/merge/check; flow = TS7027/TS7028). + pin( + &mut errs, + "dup match", + report.dup_match(), + RUN_DUP_MATCH_PIN, + ); + pin( + &mut errs, + "dup missing", + report.dup_missing(), + RUN_DUP_MISSING_PIN, + ); + pin( + &mut errs, + "flow match", + report.flow_match(), + RUN_FLOW_MATCH_PIN, + ); + pin( + &mut errs, + "flow missing", + report.flow_missing(), + RUN_FLOW_MISSING_PIN, + ); pin( &mut errs, "missing merge", @@ -605,6 +668,12 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), report.missing_deferred_late_bound, RUN_MISSING_DEFERRED_LATE_BOUND_PIN, ); + pin( + &mut errs, + "missing cfa", + report.missing_deferred_cfa, + RUN_MISSING_DEFERRED_CFA_PIN, + ); pin( &mut errs, "family span_mismatch", @@ -683,9 +752,14 @@ struct RunPins { family_positive: usize, family_match: usize, family_missing: usize, + dup_match: usize, + dup_missing: usize, + flow_match: usize, + flow_missing: usize, missing_merge: usize, missing_lib: usize, missing_deferred_late_bound: usize, + missing_deferred_cfa: usize, missing_other: usize, family_extra: usize, family_span_mismatch: usize, @@ -715,9 +789,14 @@ fn run_pins() -> RunPins { family_positive: RUN_FAMILY_POSITIVE_PIN, family_match: RUN_FAMILY_MATCH_PIN, family_missing: RUN_FAMILY_MISSING_PIN, + dup_match: RUN_DUP_MATCH_PIN, + dup_missing: RUN_DUP_MISSING_PIN, + flow_match: RUN_FLOW_MATCH_PIN, + flow_missing: RUN_FLOW_MISSING_PIN, missing_merge: RUN_MISSING_MERGE_PIN, missing_lib: RUN_MISSING_LIB_PIN, missing_deferred_late_bound: RUN_MISSING_DEFERRED_LATE_BOUND_PIN, + missing_deferred_cfa: RUN_MISSING_DEFERRED_CFA_PIN, missing_other: RUN_MISSING_OTHER_PIN, family_extra: 0, family_span_mismatch: RUN_FAMILY_SPAN_MISMATCH_PIN, @@ -806,7 +885,7 @@ fn write_manifest( /// per-code maps, wall-clock excluded) so re-runs are diff-clean. fn build_report_value(report: &SkeletonReport) -> serde_json::Value { serde_json::json!({ - "oracle": "tsgo committed .errors.txt baselines (bind + merge family)", + "oracle": "tsgo committed .errors.txt baselines (bind + merge + flow family)", "denominators": { "in_scope_tests": report.in_scope_tests, "in_scope_variants": report.in_scope_variants, @@ -818,11 +897,16 @@ fn build_report_value(report: &SkeletonReport) -> serde_json::Value { }, "family": { "match": report.family_match, + "dup_match": report.dup_match(), + "flow_match": report.flow_match(), "missing": { "total": report.family_missing, + "dup": report.dup_missing(), + "flow": report.flow_missing(), "merge_path": report.missing_merge, "lib_conflict": report.missing_lib, "late_bound": report.missing_deferred_late_bound, + "cfa": report.missing_deferred_cfa, "other": report.missing_other, }, "extra": report.family_extra, @@ -867,7 +951,7 @@ fn render_report_md(report: &SkeletonReport) -> String { let mut s = String::new(); s.push_str("# tsc_conformance run — committed report\n\n"); s.push_str( - "Oracle: tsgo committed `.errors.txt` baselines (bind + merge family). \ + "Oracle: tsgo committed `.errors.txt` baselines (bind + merge + flow family). \ Deterministic — wall-clock excluded.\n\n", ); @@ -886,15 +970,31 @@ fn render_report_md(report: &SkeletonReport) -> String { report.family_graded_variants, report.family_positive_variants ); - s.push_str("## Family (2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671)\n\n"); - let _ = writeln!(s, "- match: {}", report.family_match); + s.push_str( + "## Family (dup 2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671; \ + flow 7027 / 7028)\n\n", + ); + let _ = writeln!( + s, + "- match: {} (dup {}, flow {})", + report.family_match, + report.dup_match(), + report.flow_match() + ); let _ = writeln!( s, - "- missing: {} (merge-path {}, lib-conflict {}, late-bound {}, other {})", + "- missing: {} (dup {}, flow {})", report.family_missing, + report.dup_missing(), + report.flow_missing() + ); + let _ = writeln!( + s, + " - by cause: merge-path {}, lib-conflict {}, late-bound {}, cfa {}, other {}", report.missing_merge, report.missing_lib, report.missing_deferred_late_bound, + report.missing_deferred_cfa, report.missing_other ); let _ = writeln!(s, "- extra (GATE=0): {}", report.family_extra); @@ -1628,6 +1728,7 @@ mod tests { test: None, code: Some(2300), variant: Some(("target".to_string(), "es2015".to_string())), + family: None, }; let filtered = serde_json::to_value(run_manifest(&r, &filter)).unwrap(); assert_eq!(filtered["filtered"], serde_json::json!(true)); diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 3dbbcb967..855c4b43b 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -37,4 +37,4 @@ pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; -pub use runner::{RunFilter, RunOptions, check_one, dump_flow_dot, run_skeleton}; +pub use runner::{FamilyFilter, RunFilter, RunOptions, check_one, dump_flow_dot, run_skeleton}; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 1d2ceebd3..6e517e26a 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -8,10 +8,12 @@ //! `tsv_check`'s goal rule, binds, merges against the variant's lib base, and //! grades the result on three channels. Every **expect-clean** in-scope //! variant (one with no on-disk baseline) must grade clean (zero diagnostics); -//! the bind/merge duplicate-conflict **family** ([`FAMILY_CODES`]) is compared -//! as codes+spans multisets — extra = 0 is the hard gate, missing is -//! classified by deferred cause; and **related-info** on matched family -//! primaries is graded as its own channel. Zero panics, always. +//! the graded **family** ([`FAMILY_CODES`] — the bind/merge duplicate-conflict +//! sub-family [`DUP_CODES`] plus the flow-construction sub-family [`FLOW_CODES`], +//! TS7027/TS7028) is compared as codes+spans multisets — extra = 0 is the hard +//! gate, missing is classified by deferred cause (merge / lib / late-bound / cfa, +//! else the hard-zero `other`); and **related-info** on matched family primaries +//! is graded as its own channel. Zero panics, always. //! //! A single-file test's variants all parse identically (the goal rule is //! directive-independent), so parse+bind runs **once per test** @@ -56,11 +58,24 @@ use tsv_check::{ }; use tsv_lang::{LocationMapper, LocationTracker}; -/// The bind/merge duplicate/conflict family the gate grades: TS2300 (duplicate -/// identifier), TS2451 (block-scoped redeclare), TS2567 (enum-merge), TS2528 -/// (multiple default exports), plus the merge-path codes TS2397/2649/2664/2671 -/// (emitted from the globals-merge phase rather than the same-table cascade). -const FAMILY_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; +/// The full set of codes the gate grades — the bind/merge duplicate-conflict +/// family ([`DUP_CODES`]) plus the flow-construction family ([`FLOW_CODES`]). +/// Duplicate-conflict: TS2300 (duplicate identifier), TS2451 (block-scoped +/// redeclare), TS2567 (enum-merge), TS2528 (multiple default exports), plus the +/// merge-path codes TS2397/2649/2664/2671 (emitted from the globals-merge phase +/// rather than the same-table cascade). Flow: TS7027 (unreachable code), TS7028 +/// (unused label), emitted from the post-bind flow-construction shim. +const FAMILY_CODES: [u32; 10] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671, 7027, 7028]; + +/// The duplicate/conflict sub-family (bind + merge + the check-time TS2300 +/// members/type-parameters pass) — the partition used for the `--family dup` +/// filter and the sub-family report lines. +const DUP_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; + +/// The flow-construction sub-family (TS7027 unreachable code / TS7028 unused +/// label) — the partition used for the `--family flow` filter and the sub-family +/// report lines. +const FLOW_CODES: [u32; 2] = [7027, 7028]; /// The merge-path family codes — a *missing* of one of these is classified as a /// merge-phase gap, not a same-table cascade bug. @@ -100,6 +115,36 @@ const LATE_BOUND_BASELINES: [&str; 4] = [ "symbolProperty44.ts", ]; +/// The flow-family baselines whose remaining missing TS7027 diagnostics are +/// genuinely deferred to the CFA type engine: the construction-only fast-path +/// shim (a strict subset of what tsgo reports) can't resolve them because they +/// come from tsgo's `isReachableFlowNode` fallback — never-returning call +/// signatures, `asserts` type predicates, switch exhaustiveness, and the +/// structural reachability fallback. A *missing* in one of these is bucketed to +/// `missing_deferred_cfa` (exact-pinned) rather than the hard-zero `missing_other`, +/// so the honest CFA residual stays visible without gating a release on a +/// type-engine gap. Basenames, mirroring `LATE_BOUND_BASELINES`. +/// +/// `assertionTypePredicates1.ts` never reaches the cfa bucket — its entry is a +/// defensive no-op kept for completeness. tsv currently **parse-rejects** it (an +/// over-rejection of the setter assertion-predicate form `set p(x: this is T)`, +/// counted in the parse-divergence census), so it is graded not at all; and were +/// the parser fixed, its baseline's non-bind TS1xxx (TS1228) would carve it out by +/// predicate v1 rule (a) instead — either way its 5 flow instances never land in +/// `missing_deferred_cfa`. The live cfa residual is **26**: neverReturningFunctions1 +/// 22, exhaustiveSwitchStatements1 1, unreachableSwitchTypeofAny 1, +/// unreachableSwitchTypeofUnknown 1 (**25** across the four non-carved named +/// baselines), plus reachabilityChecks8 1 (the structural `isReachableFlowNode` +/// fallback tsv's faithful for-construction correctly doesn't emit). +const DEFERRED_CFA_BASELINES: [&str; 6] = [ + "neverReturningFunctions1.ts", + "assertionTypePredicates1.ts", + "exhaustiveSwitchStatements1.ts", + "unreachableSwitchTypeofAny.ts", + "unreachableSwitchTypeofUnknown.ts", + "reachabilityChecks8.ts", +]; + /// Worker-thread stack for the sweep: the corpus has deeply-nested tests and /// tsv's recursive-descent parser has no depth guard, so the default 8 MiB /// overflows. 512 MiB is virtual-only reserve on Linux. @@ -163,6 +208,27 @@ pub struct PanicRecord { pub payload: String, } +/// Which graded sub-family the `--family` filter isolates. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FamilyFilter { + /// The duplicate/conflict sub-family ([`DUP_CODES`]). + Dup, + /// The flow-construction sub-family ([`FLOW_CODES`]). + Flow, + /// The whole graded family ([`FAMILY_CODES`]) — isolates the family-graded slice. + All, +} + +/// The code set a [`FamilyFilter`] keeps a variant for (its baseline must carry at +/// least one). +fn family_filter_codes(f: FamilyFilter) -> &'static [u32] { + match f { + FamilyFilter::Dup => &DUP_CODES, + FamilyFilter::Flow => &FLOW_CODES, + FamilyFilter::All => &FAMILY_CODES, + } +} + /// Filters for a scoped `run` sweep. Any active filter SKIPS the exact pins (the /// `roundtrip`/`query` convention), so a filtered run is a triage view — the /// invariant gates (clean grading, no panics, `family_extra == 0`) still hold. @@ -174,13 +240,19 @@ pub struct RunFilter { pub code: Option, /// Keep only variants whose config has this `key=value` (key lowercased). pub variant: Option<(String, String)>, + /// Keep only variants whose baseline carries a code in this sub-family + /// (`dup` / `flow` / `all`). + pub family: Option, } impl RunFilter { /// Whether any filter is active (drives pin skipping). #[must_use] pub fn is_active(&self) -> bool { - self.test.is_some() || self.code.is_some() || self.variant.is_some() + self.test.is_some() + || self.code.is_some() + || self.variant.is_some() + || self.family.is_some() } /// Whether a test passes the `--test` substring filter (absent filter ⇒ keep). @@ -205,6 +277,18 @@ impl RunFilter { fn keeps_code(&self, baseline_carries: impl FnOnce(u32) -> bool) -> bool { self.code.is_none_or(baseline_carries) } + + /// Whether a variant passes the `--family` filter: its baseline must carry at + /// least one code in the selected sub-family (an expect-clean variant carries + /// none, so it is filtered out). `baseline_carries` is consulted only when the + /// filter is active. Absent filter ⇒ keep. + fn keeps_family(&self, baseline_carries: impl Fn(u32) -> bool) -> bool { + self.family.is_none_or(|f| { + family_filter_codes(f) + .iter() + .any(|&code| baseline_carries(code)) + }) + } } /// Options for the skeleton sweep. @@ -305,8 +389,14 @@ pub struct SkeletonReport { /// ...missing genuinely deferred to the type engine (a `LATE_BOUND_BASELINES` /// test — literal-type / `unique symbol` computed member names). Exact-pinned. pub missing_deferred_late_bound: usize, - /// ...missing not attributable to merge/lib/late-bound — a same-table cascade - /// bug or an unclassified gap. **Gate: must be 0** (any such miss is a regression). + /// ...flow missing genuinely deferred to the CFA type engine (a + /// `DEFERRED_CFA_BASELINES` test — never-returning signatures / assertion + /// predicates / switch exhaustiveness / structural reachability fallback). + /// Exact-pinned. + pub missing_deferred_cfa: usize, + /// ...missing not attributable to merge/lib/late-bound/cfa — a same-table + /// cascade bug or an unclassified gap. **Gate: must be 0** (any such miss is a + /// regression). pub missing_other: usize, /// Variants carved out by predicate v1 rule (a): tsv parses clean but the /// baseline carries a non-bind TS1xxx code (recovery-AST incomparability). @@ -615,6 +705,12 @@ fn grade_test( { continue; } + if !options + .filter + .keeps_family(|code| baseline.is_some_and(|b| baseline_carries_code(b, code))) + { + continue; + } report.in_scope_variants += 1; if variant.config.contains_key("moduledetection") { @@ -752,12 +848,16 @@ fn record_manifest( } } -/// Whether a baseline's summary block carries a given TS code (the `--code` filter). +/// Whether a baseline carries a given TS code (the `--code` / `--family` filters). +/// Uses the category-generic [`parse_base_diags`] (not the error-only +/// `parse_summary_block`), so a `--code 7027` / `--family flow` triage matches +/// suggestion- and message-category flow lines too, not just error-category ones. fn baseline_carries_code(baseline: &Baseline, code: u32) -> bool { let Ok(content) = std::fs::read_to_string(&baseline.path) else { return false; }; - parse_summary_block(&content).iter().any(|d| d.code == code) + let want = i32::try_from(code).unwrap_or(-1); + parse_base_diags(&content).iter().any(|d| d.code == want) } /// Render one failing family variant's ours-vs-baseline diff for a `.diff` artifact. @@ -999,6 +1099,7 @@ fn grade_family( } let is_lib = LIB_CONFLICT_BASELINES.contains(&test.basename.as_str()); let is_late_bound = LATE_BOUND_BASELINES.contains(&test.basename.as_str()); + let is_deferred_cfa = DEFERRED_CFA_BASELINES.contains(&test.basename.as_str()); for (code, count) in &buckets.missing_by_code { report.family_missing += *count; *report.family_missing_by_code.entry(*code).or_default() += *count; @@ -1008,6 +1109,8 @@ fn grade_family( report.missing_lib += *count; } else if is_late_bound { report.missing_deferred_late_bound += *count; + } else if is_deferred_cfa { + report.missing_deferred_cfa += *count; } else { report.missing_other += *count; if report.missing_other_samples.len() < 20 { @@ -1323,7 +1426,41 @@ fn baseline_shape(baseline: Option<&Baseline>) -> BaselineShape { } } +/// Sum a per-code map's entries whose code is in `codes` (the sub-family partition +/// behind `dup_*` / `flow_*`). +fn sub_family_sum(by_code: &BTreeMap, codes: &[u32]) -> usize { + by_code + .iter() + .filter(|(code, _)| codes.contains(code)) + .map(|(_, count)| *count) + .sum() +} + impl SkeletonReport { + /// The duplicate/conflict sub-family matches (partition of `family_match_by_code`). + #[must_use] + pub fn dup_match(&self) -> usize { + sub_family_sum(&self.family_match_by_code, &DUP_CODES) + } + + /// The flow sub-family matches (partition of `family_match_by_code`). + #[must_use] + pub fn flow_match(&self) -> usize { + sub_family_sum(&self.family_match_by_code, &FLOW_CODES) + } + + /// The duplicate/conflict sub-family misses (partition of `family_missing_by_code`). + #[must_use] + pub fn dup_missing(&self) -> usize { + sub_family_sum(&self.family_missing_by_code, &DUP_CODES) + } + + /// The flow sub-family misses (partition of `family_missing_by_code`). + #[must_use] + pub fn flow_missing(&self) -> usize { + sub_family_sum(&self.family_missing_by_code, &FLOW_CODES) + } + /// Print the human summary. pub fn print(&self) { println!("tsc_conformance run"); @@ -1347,21 +1484,37 @@ impl SkeletonReport { println!("Script-goal retries: {}", self.script_retry); println!("Bound nodes (total): {}", self.total_nodes); println!(); - println!("Family grading (2300/2451/2567/2528 + merge 2397/2649/2664/2671)"); + println!( + "Family grading (dup 2300/2451/2567/2528 + merge 2397/2649/2664/2671; flow 7027/7028)" + ); println!("---------------------------------------------------------------"); println!("Graded variants: {}", self.family_graded_variants); println!( " ...family-positive: {}", self.family_positive_variants ); - println!(" match: {}", self.family_match); - println!(" missing: {}", self.family_missing); + println!( + " match: {} (dup {}, flow {})", + self.family_match, + self.dup_match(), + self.flow_match() + ); + println!( + " missing: {} (dup {}, flow {})", + self.family_missing, + self.dup_missing(), + self.flow_missing() + ); println!(" merge-path: {}", self.missing_merge); println!(" lib-conflict: {}", self.missing_lib); println!( " late-bound (deferred): {} (needs the type engine — literal-type / unique-symbol computed member names)", self.missing_deferred_late_bound ); + println!( + " cfa (deferred): {} (needs the CFA type engine — never-returning sigs / assertion predicates / switch exhaustiveness / structural reachability)", + self.missing_deferred_cfa + ); println!( " other (GATE=0): {} (unclassified family miss — a same-table cascade bug)", self.missing_other @@ -1835,6 +1988,40 @@ mod tests { assert!(!f.keeps_code(|_| false)); } + #[test] + fn keeps_family_selects_sub_family() { + // No `--family` filter keeps without consulting the baseline resolver. + let none = RunFilter::default(); + assert!(none.keeps_family(|_| panic!("resolver consulted with no --family filter"))); + + // `flow` keeps iff the baseline carries a FLOW_CODES member; a dup-only + // baseline is excluded, a flow baseline is kept. + let flow = RunFilter { + family: Some(FamilyFilter::Flow), + ..RunFilter::default() + }; + assert!(flow.keeps_family(|c| c == 7027)); + assert!(!flow.keeps_family(|c| c == 2300)); + + // `dup` is the complementary partition. + let dup = RunFilter { + family: Some(FamilyFilter::Dup), + ..RunFilter::default() + }; + assert!(dup.keeps_family(|c| c == 2300)); + assert!(!dup.keeps_family(|c| c == 7027)); + + // `all` keeps any family code (either partition). + let all = RunFilter { + family: Some(FamilyFilter::All), + ..RunFilter::default() + }; + assert!(all.keeps_family(|c| c == 7028)); + assert!(all.keeps_family(|c| c == 2451)); + // A non-family code (or no baseline) is excluded. + assert!(!all.keeps_family(|c| c == 9999)); + } + #[test] fn filters_compose_as_and() { // The call site ANDs the three predicates; all must keep for a variant to be @@ -1843,6 +2030,7 @@ mod tests { test: Some("dup".to_string()), code: Some(2300), variant: Some(("target".to_string(), "es5".to_string())), + family: None, }; let cfg = config(&[("target", "es5")]); let carried = [2300u32]; From a4326d0da0fd023ebb2c70f723a6ebb72929ac16 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 17:50:45 -0400 Subject: [PATCH 49/79] docs: note cfa in the honest-residual gate comment (F4 review) --- crates/tsv_debug/src/cli/commands/tsc_conformance.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index b7175f91c..3279842ea 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -466,7 +466,7 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), )); } // The honest-residual gate: every missing must be explained by merge / lib / - // late-bound. An `other` miss is a same-table cascade bug — a HARD zero (an + // late-bound / cfa. An `other` miss is a same-table cascade bug — a HARD zero (an // invariant, so a filtered triage run catches it too), not just a full-run pin. if report.missing_other != RUN_MISSING_OTHER_PIN { errs.push(format!( From dff5adbb0c27409962f97cdfcc6080af4b8d2f4f Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 18:01:04 -0400 Subject: [PATCH 50/79] docs: complete tsv_check module map (binder/flow.rs third walk + options.rs) + pipeline + check_bound options signature --- crates/tsv_check/CLAUDE.md | 43 +++++++++++++++++++++++++++++++------ crates/tsv_check/src/lib.rs | 14 ++++++------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 3c6d058a0..a1cb1491e 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -53,7 +53,8 @@ - `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): + intervals; see `binder/mod.rs`'s header), plus a **third flow walk** + (`flow.rs`) 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 @@ -69,6 +70,25 @@ - `atoms.rs` — the checker's own program-scoped name interner (a fresh `string-interner` instance — never the parser's per-document `SharedInterner`), reserved internal-name atoms. + - `flow.rs` — the **third walk**: the per-file control-flow graph + (`build_flow`), 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). + Consumers resolve NodeIds through the SoA walk's **strict** + `require_node_id` (a miss aborts). The flow product rides `BoundUnit`, + consumed by `check/unreachable.rs` (TS7027/7028) and, later, the CFA type + engine. **Known pre-P3 fix**: `require_node_id` cannot distinguish a + `MethodDefinition` from its offset-0 inline `value: FunctionExpression` + (same address) — key the map on `(address, NodeKind)` before P3 (pinned by + `method_address_collides_with_value`). **Borrow-only discipline**: visitors take `&'arena` references and never clone AST nodes — the AST derives `Clone`, @@ -108,8 +128,13 @@ 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` (`NonZeroU32`, 1-based pre-order; `Option` +- `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. - `hash.rs` — crate-private Fx-style multiply-xor hasher + `FxHashMap`/`FxHashSet` aliases (no external hashing dependency). @@ -118,17 +143,21 @@ ```rust let arena = bumpalo::Bump::new(); let units = [SourceUnit::new("a.ts", source)]; -let result: CheckResult = check_program(&units, &arena); -// result.diagnostics — canonically sorted + deduplicated +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 once, variant-independent, fully owned) → -`check_bound(&bound, Some(&lib_base))`; `bind_lib` produces a cacheable -`LibFile`; `check_program_with_lib` is the one-shot form. +`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 diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index d793de3bf..1d9f0099e 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -14,12 +14,14 @@ //! //! ```text //! source units (+ arena) -//! -> parse (goal rule: Module first, Script retry) [program] -//! -> lower + bind (one fused pre-order walk per file) [binder] -//! -> merge (single-threaded global-scope fold) [merge] -//! -> check (syntactic per-node checks) [check] -//! -> sort + dedup (tsgo's comparer) [diag] -//! -> owned diagnostics +//! -> 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 From 4a26c87212c923b45d04b796bd26273b446a1570 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 18:23:25 -0400 Subject: [PATCH 51/79] =?UTF-8?q?fix:=20key=20tsv=5Fcheck=20address=20map?= =?UTF-8?q?=20on=20(address,=20NodeKind)=20=E2=80=94=20resolves=20method?= =?UTF-8?q?=E2=86=94value=20offset-0=20collision=20(pre-P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/CLAUDE.md | 9 +- crates/tsv_check/src/binder/flow.rs | 166 +++++++++++----------- crates/tsv_check/src/binder/mod.rs | 105 ++++++++------ crates/tsv_check/src/binder/sym.rs | 22 +-- crates/tsv_check/src/check/unreachable.rs | 8 +- 5 files changed, 172 insertions(+), 138 deletions(-) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index a1cb1491e..4b0ab1a95 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -85,10 +85,11 @@ Consumers resolve NodeIds through the SoA walk's **strict** `require_node_id` (a miss aborts). The flow product rides `BoundUnit`, consumed by `check/unreachable.rs` (TS7027/7028) and, later, the CFA type - engine. **Known pre-P3 fix**: `require_node_id` cannot distinguish a - `MethodDefinition` from its offset-0 inline `value: FunctionExpression` - (same address) — key the map on `(address, NodeKind)` before P3 (pinned by - `method_address_collides_with_value`). + 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`, diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index afdb6f761..950ebadfa 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -93,7 +93,7 @@ // (+ the newFlowNode* / createFlow* / finishFlowLabel / addAntecedent // constructor family and the per-statement flow shapers) -use crate::binder::{BoundFile, NodeKind, addr_of}; +use crate::binder::{BoundFile, NodeKind, addr_of, statement_kind}; use crate::ids::{FlowNodeId, NodeId}; use smallvec::SmallVec; use tsv_lang::Span; @@ -840,8 +840,8 @@ impl<'a> FlowBuilder<'a> { // --- helpers ---------------------------------------------------------- #[inline] - fn require(&self, address: usize) -> NodeId { - self.bound.require_node_id(address) + fn require(&self, address: usize, kind: NodeKind) -> NodeId { + self.bound.require_node_id(address, kind) } #[inline] @@ -954,7 +954,7 @@ impl<'a> FlowBuilder<'a> { 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)); + 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; @@ -987,7 +987,7 @@ impl<'a> FlowBuilder<'a> { // --- statements ------------------------------------------------------- fn visit_statement(&mut self, stmt: &Statement<'_>) { - let id = self.require(addr_of(stmt)); + 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- @@ -1075,7 +1075,7 @@ impl<'a> FlowBuilder<'a> { } } Statement::FunctionDeclaration(f) => { - let id = self.require(addr_of(stmt)); + let id = self.require(addr_of(stmt), statement_kind(stmt)); self.visit_function_declaration(f, id); } Statement::ClassDeclaration(c) => self.visit_class_decl(c), @@ -1219,7 +1219,7 @@ impl<'a> FlowBuilder<'a> { && !matches!(c.callee, Expression::Super(_)) && is_dotted_name(c.callee) { - let call_id = self.require(addr_of(c)); + let call_id = self.require(addr_of(c), NodeKind::CallExpression); self.current_flow = self.create_flow_call(self.current_flow, call_id); } } @@ -1237,7 +1237,7 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(init); } if decl.init.is_some() { - let decl_id = self.require(addr_of(decl)); + let decl_id = self.require(addr_of(decl), NodeKind::VariableDeclarator); self.current_flow = self.create_flow_mutation(FlowFlags::ASSIGNMENT, self.current_flow, decl_id); } @@ -1359,7 +1359,7 @@ impl<'a> FlowBuilder<'a> { if let Some(init) = &decl.init { self.visit_expression(init); } - let decl_id = self.require(addr_of(decl)); + let decl_id = self.require(addr_of(decl), NodeKind::VariableDeclarator); self.current_flow = self.create_flow_mutation( FlowFlags::ASSIGNMENT, self.current_flow, @@ -1538,7 +1538,7 @@ impl<'a> FlowBuilder<'a> { 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])); + let clause_id = self.require(addr_of(&clauses[i]), NodeKind::SwitchCase); self.fallthrough_flow.push((clause_id, self.current_flow)); } i += 1; @@ -1668,7 +1668,7 @@ impl<'a> FlowBuilder<'a> { /// 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)); + let label_id = self.require(addr_of(&s.label), NodeKind::Identifier); self.active_label_list.push(ActiveLabelEntry { break_target: post, continue_target: None, @@ -1698,7 +1698,7 @@ impl<'a> FlowBuilder<'a> { /// 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)); + let id = self.require(addr_of(ident), NodeKind::Identifier); self.bound.spans[id.index()].extract(self.source) } @@ -1910,53 +1910,55 @@ impl<'a> FlowBuilder<'a> { } } - /// The F0 [`NodeId`] of an expression node — its variant payload's address in - /// the address map (the key F0's lowering walk used). Condition / mutation - /// subjects are always value expressions F0 lowered, so this never misses. + /// The F0 [`NodeId`] of an expression node — its variant payload's + /// `(address, kind)` in the address map (the compound key F0's lowering walk + /// used). Each arm's [`NodeKind`] mirrors the one `visit_expression` assigns to + /// that variant in `binder/mod.rs`. Condition / mutation subjects are always + /// value expressions F0 lowered, so this never misses. fn expr_id(&self, e: &Expression<'_>) -> NodeId { use Expression as E; - let addr = match e { + let (addr, kind) = match e { E::JsdocCast(c) => return self.expr_id(c.inner), - E::Literal(x) => addr_of(x), - E::Identifier(x) => addr_of(x), - E::PrivateIdentifier(x) => addr_of(x), - E::ObjectExpression(x) => addr_of(x), - E::ArrayExpression(x) => addr_of(x), - E::UnaryExpression(x) => addr_of(x), - E::UpdateExpression(x) => addr_of(x), - E::BinaryExpression(x) => addr_of(x), - E::CallExpression(x) => addr_of(x), - E::NewExpression(x) => addr_of(x), - E::MemberExpression(x) => addr_of(x), - E::ConditionalExpression(x) => addr_of(x), - E::ArrowFunctionExpression(x) => addr_of(x), - E::FunctionExpression(x) => addr_of(x), - E::ClassExpression(x) => addr_of(x), - E::SpreadElement(x) => addr_of(x), - E::TemplateLiteral(x) => addr_of(x), - E::TaggedTemplateExpression(x) => addr_of(x), - E::AwaitExpression(x) => addr_of(x), - E::YieldExpression(x) => addr_of(x), - E::SequenceExpression(x) => addr_of(x), - E::RegexLiteral(x) => addr_of(x), - E::ThisExpression(x) => addr_of(x), - E::Super(x) => addr_of(x), - E::AssignmentExpression(x) => addr_of(x), - E::ObjectPattern(x) => addr_of(x), - E::ArrayPattern(x) => addr_of(x), - E::AssignmentPattern(x) => addr_of(x), - E::RestElement(x) => addr_of(x), - E::TSTypeAssertion(x) => addr_of(x), - E::TSAsExpression(x) => addr_of(x), - E::TSSatisfiesExpression(x) => addr_of(x), - E::TSInstantiationExpression(x) => addr_of(x), - E::TSNonNullExpression(x) => addr_of(x), - E::TSParameterProperty(x) => addr_of(x), - E::ImportExpression(x) => addr_of(x), - E::MetaProperty(x) => addr_of(x), - E::ParenthesizedExpression(x) => addr_of(x), + 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), }; - self.require(addr) + self.require(addr, kind) } // --- containers ------------------------------------------------------- @@ -2109,7 +2111,7 @@ impl<'a> FlowBuilder<'a> { 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)); + 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); @@ -2118,7 +2120,7 @@ impl<'a> FlowBuilder<'a> { 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)); + 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); @@ -2133,16 +2135,16 @@ impl<'a> FlowBuilder<'a> { 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. tsv wraps - // a method body in a FunctionExpression (tsc's method node holds the body - // directly), and — F0 hazard — the address map collides the - // MethodDefinition with its inline `value` (a repr reorder puts `value` - // at offset 0, so F0's later insert overwrites the map slot). So the - // value FunctionExpression is the reliably-addressable body-bearing node; - // anchor there. The obj-literal/class-expression method flow-write + - // Start.Node subject (binder.go:982, 1534) is a P3 narrowing hint, - // deferred to F1b. - let anchor = self.require(addr_of(&m.value)); + // 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 (the method itself + // is now separately resolvable by `NodeKind::MethodDefinition`). The + // obj-literal/class-expression method flow-write + Start.Node subject + // (binder.go:982, 1534) is a P3 narrowing hint, deferred to F1b. + let anchor = self.require(addr_of(&m.value), NodeKind::FunctionExpression); let saved = self.enter_container(None, false, is_ctor); self.bind_params(m.value.params); self.visit_statement_list(m.value.body.body); @@ -2158,7 +2160,7 @@ impl<'a> FlowBuilder<'a> { 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)); + 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); @@ -2175,7 +2177,7 @@ impl<'a> FlowBuilder<'a> { match &e.declaration { V::Expression(expr) => self.visit_expression(expr), V::FunctionDeclaration(f) => { - let id = self.require(addr_of(f)); + let id = self.require(addr_of(f), NodeKind::FunctionDeclaration); self.visit_function_declaration(f, id); } V::ClassDeclaration(c) => self.visit_class_decl(c), @@ -2208,25 +2210,25 @@ impl<'a> FlowBuilder<'a> { match expr { E::Identifier(idn) => self.visit_identifier(idn), E::ThisExpression(t) => { - let id = self.require(addr_of(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)); + 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)); + let id = self.require(addr_of(m), NodeKind::MetaProperty); self.set_flow_nonleaf(id); } E::MemberExpression(m) => { // 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)); + let id = self.require(addr_of(m), NodeKind::MemberExpression); self.set_flow_nonleaf(id); } self.visit_expression(m.object); @@ -2287,14 +2289,14 @@ impl<'a> FlowBuilder<'a> { for arg in c.arguments { self.visit_expression(arg); } - let id = self.require(addr_of(a)); + 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)); + let id = self.require(addr_of(f), NodeKind::FunctionExpression); self.visit_function_expression(f, id, true); } _ => { @@ -2313,11 +2315,11 @@ impl<'a> FlowBuilder<'a> { } E::ConditionalExpression(c) => self.bind_conditional_expression_flow(c), E::ArrowFunctionExpression(a) => { - let id = self.require(addr_of(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)); + let id = self.require(addr_of(f), NodeKind::FunctionExpression); self.visit_function_expression(f, id, false); } E::ClassExpression(c) => self.visit_class_expr(c), @@ -2408,7 +2410,7 @@ impl<'a> FlowBuilder<'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)); + let id = self.require(addr_of(ident), NodeKind::Identifier); self.set_flow_leaf(id); self.visit_decorators(ident.decorators()); } @@ -2439,7 +2441,7 @@ impl<'a> FlowBuilder<'a> { // collision workaround). The obj-literal method flow-write // (binder.go:982) is a P3 narrowing hint, deferred. self.visit_expression(&pr.key); - let anchor = self.require(addr_of(f)); + let anchor = self.require(addr_of(f), NodeKind::FunctionExpression); let saved = self.enter_container(None, false, false); self.bind_params(f.params); self.visit_statement_list(f.body.body); @@ -3129,8 +3131,10 @@ mod tests { panic!("expression statement"); }; let id = match &s.expression { - Expression::Literal(l) => bound.require_node_id(addr_of(l)), - Expression::Identifier(idn) => bound.require_node_id(addr_of(idn)), + 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) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index b45925239..d1ac188e1 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -87,7 +87,11 @@ use tsv_ts::ast::internal::{ /// 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. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +/// +/// `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. @@ -271,8 +275,11 @@ pub struct BoundFile { /// Per-node flag byte (see [`NODE_FLAGS_UNREACHABLE`]), one per [`NodeId`], /// zero-initialized. F0 sets nothing; the flow pass (F1) writes it. pub node_flags: Vec, - /// Node arena address -> id (the random-access escape hatch). - pub address_map: FxHashMap, + /// 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`]). + 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, @@ -297,32 +304,32 @@ impl BoundFile { self.node_flags[id.index()] } - /// Strict address -> [`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. + /// 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. + /// reference the walk keyed on, and `kind` is the [`NodeKind`] the walk + /// assigned it. /// - /// **Known collision (pre-P3 fix pending).** Pointer-identity keying 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), so - /// `require_node_id(addr_of(&method))` returns the *value's* id, not the - /// method's — a silent wrong node, worse than a miss. Inert today (the flow - /// walk deliberately anchors methods on `value`; nothing else resolves a method - /// by address), pinned by `method_address_collides_with_value` below. The fix — - /// keying the map on `(address, NodeKind)` (no same-kind collisions exist) — is - /// deferred to a pre-P3 slice (P3's method flow-write + typeof narrowing need - /// the method node's own id). + /// **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) -> NodeId { - match self.address_map.get(&address) { + 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), + None => node_id_miss(address, kind), } } } @@ -335,9 +342,9 @@ impl BoundFile { #[cold] #[inline(never)] #[allow(clippy::panic)] -fn node_id_miss(address: usize) -> ! { +fn node_id_miss(address: usize, kind: NodeKind) -> ! { panic!( - "require_node_id: address {address:#x} not covered by the SoA walk — a flow \ + "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)" ); } @@ -549,7 +556,7 @@ struct SoaWalk { spans: Vec, subtree_end: Vec, node_flags: Vec, - address_map: FxHashMap, + address_map: FxHashMap<(usize, NodeKind), NodeId>, } impl SoaWalk { @@ -567,7 +574,7 @@ impl SoaWalk { self.spans.push(span); self.subtree_end.push(id); // provisional (a leaf owns only itself) self.node_flags.push(0); // F0 mints the column zeroed; F1 sets it - self.address_map.insert(address, id); + self.address_map.insert((address, kind), id); id } @@ -1911,8 +1918,10 @@ impl SoaWalk { } } -/// The [`NodeKind`] for a statement variant. -fn statement_kind(stmt: &Statement<'_>) -> NodeKind { +/// 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, @@ -1999,7 +2008,11 @@ mod tests { 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).copied().expect("mapped"); + let id = bound + .address_map + .get(&(addr, NodeKind::VariableDeclaration)) + .copied() + .expect("mapped"); assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration); } @@ -2042,7 +2055,7 @@ mod tests { 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); + let id = bound.require_node_id(addr, NodeKind::VariableDeclaration); assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration); } @@ -2052,17 +2065,16 @@ mod tests { // 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); + let _ = bound.require_node_id(0, NodeKind::Program); } #[test] - fn method_address_collides_with_value() { - // KNOWN F0 collision (pre-P3 fix pending; see `require_node_id`'s doc). A - // `MethodDefinition` and its inline offset-0 `value: FunctionExpression` - // share an address, so the walk's second insert wins and - // `require_node_id(addr_of(&method))` returns the FunctionExpression, not - // the MethodDefinition. Pinned so the `(address, NodeKind)` fix is a - // visible ratchet (this assertion flips to `MethodDefinition` when it lands). + 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() {} }"; @@ -2074,8 +2086,17 @@ mod tests { let ClassMember::MethodDefinition(method) = &class.body.body[0] else { panic!("expected a method definition"); }; - let id = bound.require_node_id(std::ptr::from_ref(method) as usize); - assert_eq!(bound.kinds[id.index()], NodeKind::FunctionExpression); + // 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] diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index 2e8253624..32873b45b 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -61,7 +61,7 @@ use super::atoms::{Atom, Atoms}; use super::symbols::{Decl, Symbol, SymbolFlags, SymbolId, TableId}; -use super::{FileFacts, addr_of}; +use super::{FileFacts, NodeKind, addr_of}; use crate::diag::{Category, Diagnostic}; use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; @@ -140,7 +140,7 @@ struct DeclInput { pub(super) struct SymbolBinder<'a> { source: &'a str, interner: &'a DefaultStringInterner, - address_map: &'a FxHashMap, + address_map: &'a FxHashMap<(usize, NodeKind), NodeId>, file: FileId, is_external: bool, @@ -165,7 +165,7 @@ impl<'a> SymbolBinder<'a> { pub(super) fn new( source: &'a str, interner: &'a DefaultStringInterner, - address_map: &'a FxHashMap, + address_map: &'a FxHashMap<(usize, NodeKind), NodeId>, file: FileId, facts: FileFacts, ) -> SymbolBinder<'a> { @@ -319,9 +319,13 @@ impl<'a> SymbolBinder<'a> { t } - fn node_id_of(&self, node: &T) -> NodeId { + /// 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)) + .get(&(addr_of(node), kind)) .copied() .unwrap_or(NodeId::FIRST) } @@ -921,7 +925,7 @@ impl<'a> SymbolBinder<'a> { is_default_export: false, is_export_assignment_default: false, exported: true, - node: self.node_id_of(ea), + node: self.node_id_of(ea, NodeKind::TSExportAssignment), }; let table = self.exports_of(sym); self.declare_symbol( @@ -1010,7 +1014,7 @@ impl<'a> SymbolBinder<'a> { is_default_export: false, is_export_assignment_default: true, exported: false, - node: self.node_id_of(e), + node: self.node_id_of(e, NodeKind::ExportDefaultDeclaration), }; let table = self.exports_of(sym); self.declare_symbol(table, Some(sym), d, flags, SymbolFlags::ALL); @@ -1255,7 +1259,7 @@ impl<'a> SymbolBinder<'a> { is_default_export: mods.default, is_export_assignment_default: false, exported: mods.exported, - node: self.node_id_of(id), + node: self.node_id_of(id, NodeKind::Identifier), } } @@ -1688,7 +1692,7 @@ impl<'a> SymbolBinder<'a> { is_default_export: mods.default, is_export_assignment_default: false, exported: mods.exported, - node: self.node_id_of(m), + 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 diff --git a/crates/tsv_check/src/check/unreachable.rs b/crates/tsv_check/src/check/unreachable.rs index 643f990a3..1da4db16c 100644 --- a/crates/tsv_check/src/check/unreachable.rs +++ b/crates/tsv_check/src/check/unreachable.rs @@ -40,7 +40,7 @@ // (2294-2428), IsPotentiallyExecutableNode (4210). use crate::binder::flow::FlowProduct; -use crate::binder::{BoundFile, NODE_FLAGS_UNREACHABLE, NodeKind, addr_of}; +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}; @@ -385,7 +385,11 @@ impl CandidateWalk<'_> { /// 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)).copied()?; + 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) } From 5cbc861f52468cd0baaf34bfbea4fcec57fe3470 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 18:44:02 -0400 Subject: [PATCH 52/79] =?UTF-8?q?fix:=20private-name=20TS2300=20display=20?= =?UTF-8?q?carries=20the=20#=20=E2=80=94=20closes=20bind/check=20latent=20?= =?UTF-8?q?extra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/src/binder/sym.rs | 18 +++++++++++--- crates/tsv_check/src/program.rs | 40 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym.rs index 32873b45b..4b2375b09 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym.rs @@ -2078,14 +2078,26 @@ impl<'a> SymbolBinder<'a> { } Expression::PrivateIdentifier(pid) => { let raw = pid.name(self.source, self.interner); - let display = self.atoms.intern(raw); + // 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 display-string derivation is duplicated across the + // bind (`sym.rs`) and check (`duplicate_members.rs`) sides with no + // shared helper (span derivation is centralized in `span_scan.rs`; + // display is not) — a shared display helper would prevent this class of + // mismatch. + let display = self.atoms.intern(&format!("#{raw}")); // Mangle with the class symbol id so same-name privates in one - // class collide (tsgo GetSymbolNameForPrivateIdentifier). + // 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 `#`. + // covers the `#` — and `display` now matches that span. Some(KeyInfo { key, display, diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 875a95c1c..5a0dbcb44 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -474,6 +474,46 @@ mod tests { ); } + #[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 From 0bb34331263167f98d9381e01f24699899686bbf Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 18:54:54 -0400 Subject: [PATCH 53/79] refactor: borrow in check_bound (merges/paths) + code-aware conformance classifier --- crates/tsv_check/src/diag.rs | 22 ++++---- crates/tsv_check/src/merge.rs | 53 ++++++++++--------- crates/tsv_check/src/program.rs | 16 +++--- .../tsv_debug/src/tsc_conformance/runner.rs | 11 ++-- 4 files changed, 57 insertions(+), 45 deletions(-) diff --git a/crates/tsv_check/src/diag.rs b/crates/tsv_check/src/diag.rs index d205295a2..de8e0987d 100644 --- a/crates/tsv_check/src/diag.rs +++ b/crates/tsv_check/src/diag.rs @@ -109,9 +109,9 @@ impl Diagnostic { } /// The diagnostic's sort path: the file's name, or `""` for a global one. -fn diag_path<'a>(d: &Diagnostic, paths: &'a [String]) -> &'a str { +fn diag_path<'a>(d: &Diagnostic, paths: &[&'a str]) -> &'a str { match d.file { - Some(f) => paths.get(f.index()).map_or("", String::as_str), + Some(f) => paths.get(f.index()).copied().unwrap_or(""), None => "", } } @@ -124,7 +124,7 @@ fn diag_path<'a>(d: &Diagnostic, paths: &'a [String]) -> &'a str { /// `Vec` ordering is exactly that. // tsgo: internal/ast/diagnostic.go CompareDiagnostics (:329) #[must_use] -pub fn compare_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[String]) -> Ordering { +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)) @@ -168,7 +168,7 @@ fn compare_chain_content(a: &[Diagnostic], b: &[Diagnostic]) -> Ordering { /// 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: &[String]) -> Ordering { +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); @@ -183,7 +183,7 @@ fn compare_related_info(a: &[Diagnostic], b: &[Diagnostic], paths: &[String]) -> /// 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: &[String]) -> bool { +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 @@ -203,7 +203,7 @@ fn equal_chain(a: &[Diagnostic], b: &[Diagnostic]) -> bool { /// 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: &[String]) -> bool { +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 @@ -217,7 +217,7 @@ pub fn equal_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[String]) -> bo /// 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: &[String]) { +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); } @@ -228,7 +228,7 @@ pub fn sort_and_deduplicate(diags: &mut Vec, paths: &[String]) { // 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: &[String]) { +fn compact_and_merge_related_infos(diags: &mut Vec, paths: &[&str]) { if diags.len() < 2 { return; } @@ -268,7 +268,7 @@ fn compact_and_merge_related_infos(diags: &mut Vec, paths: &[String] /// 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: &[String]) { +fn dedup_by_equal(diags: &mut Vec, paths: &[&str]) { diags.dedup_by(|a, b| equal_diagnostics(a, b, paths)); } @@ -276,8 +276,8 @@ fn dedup_by_equal(diags: &mut Vec, paths: &[String]) { mod tests { use super::*; - fn paths() -> Vec { - vec!["a.ts".to_string(), "b.ts".to_string()] + fn paths() -> Vec<&'static str> { + vec!["a.ts", "b.ts"] } fn diag(file: Option, start: u32, end: u32, code: u32) -> Diagnostic { diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 4f8d96fbf..b720d09df 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -289,7 +289,7 @@ impl MergeOut { /// file appends after the test units in the diagnostic path space. #[must_use] pub fn merge_program( - files: &[FileMerge], + files: &[&FileMerge], lib: Option<&LibBase>, lib_file_offset: u32, ) -> Vec { @@ -302,7 +302,7 @@ pub fn merge_program( let mut deferred_ambient: Vec<&MergeSymbol> = Vec::new(); // --- Phase 1: script locals + the globalThis check (file order) --- - for file in files { + for &file in files { if file.is_external { continue; } @@ -326,7 +326,7 @@ pub fn merge_program( } // --- Phase 2: global (`declare global`) augmentations --- - for file in files { + 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); @@ -356,7 +356,7 @@ pub fn merge_program( } // --- Phase 5: non-global module augmentations (`declare module "X"`) --- - for file in files { + 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(); @@ -825,7 +825,7 @@ mod tests { decls: vec![decl(1, 4, "x", false)], }], ); - let diags = merge_program(&[a, b], None, 0); + 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]); @@ -854,7 +854,7 @@ mod tests { }], ) }; - let diags = merge_program(&[mk(0), mk(1)], None, 0); + let diags = merge_program(&[&mk(0), &mk(1)], None, 0); assert_eq!( diags.iter().map(|d| d.code).collect::>(), vec![2300, 2300] @@ -880,7 +880,7 @@ mod tests { decls: vec![decl(1, 11, "E", true)], }], ); - let diags = merge_program(&[a, b], None, 0); + let diags = merge_program(&[&a, &b], None, 0); assert_eq!( diags.iter().map(|d| d.code).collect::>(), vec![2567, 2567] @@ -912,12 +912,14 @@ mod tests { ) }) .collect(); - let mut diags = merge_program(&files, None, 0); + 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. - sort_and_deduplicate(&mut diags, &paths); + 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))); @@ -933,7 +935,7 @@ mod tests { /// 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".to_string(), "b.ts".to_string(), "c.ts".to_string()]; + let paths = vec!["a.ts", "b.ts", "c.ts"]; let mk = |f: u32| { script( f, @@ -944,7 +946,7 @@ mod tests { }], ) }; - let mut diags = merge_program(&[mk(0), mk(1), mk(2)], None, 0); + 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 @@ -990,7 +992,8 @@ mod tests { decls: vec![decl(6, 11, "E", true)], }], )); - let diags = merge_program(&files, None, 0); + 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 @@ -1014,7 +1017,7 @@ mod tests { decls: vec![decl(0, 4, "globalThis", false)], }], ); - let diags = merge_program(&[f], None, 0); + 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()]); @@ -1035,7 +1038,7 @@ mod tests { ], }], ); - let diags = merge_program(&[f], None, 0); + 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); @@ -1063,7 +1066,7 @@ mod tests { }, ], }; - let diags = merge_program(&[f], None, 0); + 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); @@ -1085,7 +1088,7 @@ mod tests { global_augmentations: Vec::new(), module_augmentations: Vec::new(), }; - assert!(merge_program(&[f], None, 0).is_empty()); + assert!(merge_program(&[&f], None, 0).is_empty()); } // --- lib base --------------------------------------------------------- @@ -1147,12 +1150,12 @@ mod tests { decls: vec![decl(0, 6, "Symbol", true)], }], ); - let mut diags = merge_program(&[test], Some(&base), 1); + let mut diags = merge_program(&[&test], Some(&base), 1); let paths = vec![ - "test.ts".to_string(), - "lib.es5.d.ts".to_string(), - "lib.es2015.symbol.d.ts".to_string(), - "lib.es2015.symbol.wellknown.d.ts".to_string(), + "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`. @@ -1196,7 +1199,7 @@ mod tests { decls: vec![decl(0, 10, "Array", true)], }], ); - assert!(merge_program(&[test], Some(&base), 1).is_empty()); + assert!(merge_program(&[&test], Some(&base), 1).is_empty()); } /// A test `var globalThis` hits the seeded-globalThis NamespaceModule guard (no @@ -1212,7 +1215,7 @@ mod tests { decls: vec![decl(0, 4, "globalThis", false)], }], ); - let diags = merge_program(&[test], Some(&base), 1); + let diags = merge_program(&[&test], Some(&base), 1); assert_eq!(diags.len(), 1); assert_eq!(diags[0].code, 2397); } @@ -1243,7 +1246,7 @@ mod tests { ); // 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); + 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); @@ -1276,7 +1279,7 @@ mod tests { }]], module_augmentations: Vec::new(), }; - let diags = merge_program(&[test], Some(&base), 1); + let diags = merge_program(&[&test], Some(&base), 1); let test_primary = diags .iter() .find(|d| d.file == Some(FileId(0))) diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index 5a0dbcb44..a24438efc 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -268,17 +268,21 @@ pub fn check_bound( candidates.emit(unit.file, options, &mut diagnostics, &mut suggestions); } } - // Only test-unit merges are cloned here (lib globals live in the base, not in - // `files`), so this stays cheap even run per-variant. - let merges: Vec = bound.units.iter().filter_map(|u| u.merge.clone()).collect(); + // 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`). - let mut paths: Vec = bound.units.iter().map(|u| u.name.clone()).collect(); + // `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().cloned()); + paths.extend(base.lib_files.iter().map(String::as_str)); } sort_and_deduplicate(&mut diagnostics, &paths); sort_and_deduplicate(&mut suggestions, &paths); diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 6e517e26a..f8dc702b6 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -1100,16 +1100,21 @@ fn grade_family( let is_lib = LIB_CONFLICT_BASELINES.contains(&test.basename.as_str()); let is_late_bound = LATE_BOUND_BASELINES.contains(&test.basename.as_str()); let is_deferred_cfa = DEFERRED_CFA_BASELINES.contains(&test.basename.as_str()); + // Each name-keyed bucket also requires the code to be in its family (dup for + // lib/late-bound, flow for cfa), mirroring the merge branch — a wrong-family + // missing inside a named baseline falls through to the hard-zero `missing_other` + // instead of being silently absorbed. This keeps the classification honest on + // filtered/triage runs, not only on a full run where the code-keyed pins catch it. for (code, count) in &buckets.missing_by_code { report.family_missing += *count; *report.family_missing_by_code.entry(*code).or_default() += *count; if MERGE_CODES.contains(code) { report.missing_merge += *count; - } else if is_lib { + } else if is_lib && DUP_CODES.contains(code) { report.missing_lib += *count; - } else if is_late_bound { + } else if is_late_bound && DUP_CODES.contains(code) { report.missing_deferred_late_bound += *count; - } else if is_deferred_cfa { + } else if is_deferred_cfa && FLOW_CODES.contains(code) { report.missing_deferred_cfa += *count; } else { report.missing_other += *count; From 52f5b9163c3fc3e3f81389408bb7e1d869bcfda7 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 19:08:38 -0400 Subject: [PATCH 54/79] refactor: dedup class/fn descent helpers + drop dead flow/lib accessors + debug_assert + doc fixes --- crates/tsv_check/src/binder/flow.rs | 43 +++--- crates/tsv_check/src/binder/mod.rs | 59 ++++++-- crates/tsv_check/src/binder/symbols.rs | 5 +- crates/tsv_check/src/check/mod.rs | 161 +++++++++++++--------- crates/tsv_check/src/check/unreachable.rs | 6 + crates/tsv_check/src/merge.rs | 6 - crates/tsv_check/src/program.rs | 6 +- 7 files changed, 174 insertions(+), 112 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 950ebadfa..a8e7def09 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -180,13 +180,6 @@ impl FlowFlags { pub const fn is_label(self) -> bool { self.intersects(FlowFlags::LABEL) } - - /// The raw bits (for the DOT renderer's header labels). - #[inline] - #[must_use] - pub const fn bits(self) -> u16 { - self.0 - } } // --- F2 payload shapes (defined for the SoA shape; not populated in F1a) ---- @@ -2075,28 +2068,32 @@ impl<'a> FlowBuilder<'a> { } fn visit_class_decl(&mut self, c: &ClassDeclaration<'_>) { - if let Some(name) = &c.id { - self.visit_identifier(name); - } - self.visit_decorators(c.decorators); - if let Some(sc) = c.super_class { - self.visit_expression(sc); - } - // type params / super type args / implements are type positions (skip). - for member in c.body.body { - self.visit_class_member(member); - } + self.visit_class_common(c.id.as_ref(), c.decorators, c.super_class, c.body.body); } fn visit_class_expr(&mut self, c: &ClassExpression<'_>) { - if let Some(name) = &c.id { + self.visit_class_common(c.id.as_ref(), c.decorators, c.super_class, c.body.body); + } + + /// 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. + fn visit_class_common( + &mut self, + name: Option<&Identifier<'_>>, + decorators: Option<&[Decorator<'_>]>, + super_class: Option<&Expression<'_>>, + members: &[ClassMember<'_>], + ) { + if let Some(name) = name { self.visit_identifier(name); } - self.visit_decorators(c.decorators); - if let Some(sc) = c.super_class { + self.visit_decorators(decorators); + if let Some(sc) = super_class { self.visit_expression(sc); } - for member in c.body.body { + for member in members { self.visit_class_member(member); } } @@ -3091,6 +3088,8 @@ mod tests { 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); } diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index d1ac188e1..7958746da 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -574,7 +574,15 @@ impl SoaWalk { self.spans.push(span); self.subtree_end.push(id); // provisional (a leaf owns only itself) self.node_flags.push(0); // F0 mints the column zeroed; F1 sets it - self.address_map.insert((address, kind), id); + // 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). + // Compiles out of release. + debug_assert!( + self.address_map.insert((address, kind), id).is_none(), + "same-kind address collision at {address:#x} / {kind:?}" + ); id } @@ -761,13 +769,37 @@ impl SoaWalk { // --- declaration descents (shared between statement + export-default) ----- fn descend_function(&mut self, f: &FunctionDeclaration<'_>, id: NodeId) { - if let Some(name) = &f.id { + 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 + /// ([`Self::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. + 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(f.type_parameters.as_ref(), id); - self.visit_params(f.params, id); - self.visit_type_annotation_opt(f.return_type.as_ref(), id); - self.visit_statements(f.body.body, 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) { @@ -1369,13 +1401,14 @@ impl SoaWalk { Some(parent), addr_of(f), ); - if let Some(name) = &f.id { - self.visit_identifier(name, 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); - self.visit_statements(f.body.body, id); + 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); } diff --git a/crates/tsv_check/src/binder/symbols.rs b/crates/tsv_check/src/binder/symbols.rs index 05c5affe4..ab374306f 100644 --- a/crates/tsv_check/src/binder/symbols.rs +++ b/crates/tsv_check/src/binder/symbols.rs @@ -207,8 +207,9 @@ pub struct Decl { /// A bound symbol: accumulated flags, its table key, its declarations, and the /// child tables a container owns. #[derive(Clone, Debug)] -// `name` and `parent` mirror tsgo's `Symbol` shape and are set by the bind; the -// family cascade reads `flags`/`decls`/`members`/`exports` only. +// `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. diff --git a/crates/tsv_check/src/check/mod.rs b/crates/tsv_check/src/check/mod.rs index 1ab1f4743..817760130 100644 --- a/crates/tsv_check/src/check/mod.rs +++ b/crates/tsv_check/src/check/mod.rs @@ -31,9 +31,10 @@ use string_interner::DefaultStringInterner; use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ ArrowFunctionBody, ClassBody, ClassMember, Decorator, ExportDefaultValue, Expression, - ForInOfLeft, ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSInterfaceHeritage, - TSLiteralType, TSModuleDeclaration, TSModuleDeclarationBody, TSType, TSTypeAnnotation, - TSTypeElement, TSTypeParameterDeclaration, TSTypeParameterInstantiation, VariableDeclaration, + 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 @@ -79,34 +80,26 @@ impl<'a> CheckWalk<'a> { match stmt { Statement::ExpressionStatement(s) => self.visit_expression(&s.expression), Statement::VariableDeclaration(d) => self.visit_variable_declaration(d), - Statement::FunctionDeclaration(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()); - for s in f.body.body { - self.visit_statement(s); - } - } + 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.visit_type_params(c.type_parameters.as_ref()); - self.visit_class_heritage( - c.decorators, - c.super_class, - c.super_type_parameters.as_ref(), - c.implements, - ); - self.visit_class_body(&c.body); - } - Statement::TSInterfaceDeclaration(i) => { - self.visit_type_params(i.type_parameters.as_ref()); - self.visit_heritage_type_args(i.extends); - self.visit_type_elements(i.body.body); - } + 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); @@ -253,34 +246,72 @@ impl<'a> CheckWalk<'a> { fn visit_export_default(&mut self, value: &ExportDefaultValue<'_>) { match value { ExportDefaultValue::Expression(e) => self.visit_expression(e), - ExportDefaultValue::FunctionDeclaration(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()); - for s in f.body.body { - self.visit_statement(s); - } - } + 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.visit_type_params(c.type_parameters.as_ref()); - self.visit_class_heritage( - c.decorators, - c.super_class, - c.super_type_parameters.as_ref(), - c.implements, - ); - self.visit_class_body(&c.body); - } - ExportDefaultValue::TSInterfaceDeclaration(i) => { - self.visit_type_params(i.type_parameters.as_ref()); - self.visit_heritage_type_args(i.extends); - self.visit_type_elements(i.body.body); - } + 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); } } @@ -289,14 +320,12 @@ impl<'a> CheckWalk<'a> { fn visit_expression(&mut self, expr: &Expression<'_>) { use Expression as E; match expr { - E::FunctionExpression(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()); - for s in f.body.body { - self.visit_statement(s); - } - } + 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); @@ -310,16 +339,14 @@ impl<'a> CheckWalk<'a> { } } } - E::ClassExpression(c) => { - self.visit_type_params(c.type_parameters.as_ref()); - self.visit_class_heritage( - c.decorators, - c.super_class, - c.super_type_parameters.as_ref(), - c.implements, - ); - self.visit_class_body(&c.body); - } + 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); diff --git a/crates/tsv_check/src/check/unreachable.rs b/crates/tsv_check/src/check/unreachable.rs index 1da4db16c..085686c87 100644 --- a/crates/tsv_check/src/check/unreachable.rs +++ b/crates/tsv_check/src/check/unreachable.rs @@ -133,6 +133,12 @@ impl UnreachableCandidates { 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; diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index b720d09df..1bd485df5 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -162,12 +162,6 @@ impl LibBase { fn get(&self, name: &str) -> Option<&LibEntry> { self.globals.get(name) } - - /// The number of distinct global names — informational (base sizing). - #[must_use] - pub fn global_count(&self) -> usize { - self.globals.len() - } } /// Fold one lib symbol into the base globals (accumulate flags + priority-ordered diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs index a24438efc..ee7b3e176 100644 --- a/crates/tsv_check/src/program.rs +++ b/crates/tsv_check/src/program.rs @@ -154,8 +154,10 @@ impl BoundProgram { } /// A unit's dark-carried flow product (`None` for a rejected unit or an - /// out-of-range index). Nothing in the check pipeline reads it (F3 will); - /// `--dump-flow` reaches it through this accessor. + /// 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()) From 903605038eb9d163fbdfbfe3477e5cb57c0e58ba Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 19:23:24 -0400 Subject: [PATCH 55/79] fix: hoist address_map insert out of debug_assert (release-elision regression) --- crates/tsv_check/src/binder/mod.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 7958746da..7926d89da 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -574,13 +574,17 @@ impl SoaWalk { self.spans.push(span); self.subtree_end.push(id); // provisional (a leaf owns only itself) self.node_flags.push(0); // F0 mints the column zeroed; F1 sets it - // 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). - // Compiles out of release. + // 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!( - self.address_map.insert((address, kind), id).is_none(), + prev.is_none(), "same-kind address collision at {address:#x} / {kind:?}" ); id From 3a9519e9071b1989d358dcd474571d602597f384 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 19:34:25 -0400 Subject: [PATCH 56/79] =?UTF-8?q?fix:=20mint=20CALL=20flow=20nodes=20for?= =?UTF-8?q?=20comma=20(SequenceExpression)=20operands=20=E2=80=94=20tsgo?= =?UTF-8?q?=20comma=20faithfulness=20(FF-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/src/binder/flow.rs | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index a8e7def09..8673db3b9 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -2339,8 +2339,18 @@ impl<'a> FlowBuilder<'a> { } } E::SequenceExpression(s) => { + // `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); } } E::AssignmentExpression(a) if is_logical_assign_op(a.operator) => { @@ -3029,6 +3039,30 @@ mod tests { assert!(has_call, "expected a createFlowCall node"); } + /// 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); + } + #[test] fn unreachable_after_return_propagates() { let src = "function f() { return; a; }"; From 81cca344c01982e4d6a1f0ce01630dc18194b87e Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 19:45:50 -0400 Subject: [PATCH 57/79] =?UTF-8?q?fix:=20bind=20logical-compound-assign=20R?= =?UTF-8?q?HS=20top-level,=20not=20threaded=20=E2=80=94=20tsgo=20isTopLeve?= =?UTF-8?q?lLogicalExpression=20(FF-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/src/binder/flow.rs | 104 ++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 8673db3b9..5ff87fd18 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -1834,10 +1834,25 @@ impl<'a> FlowBuilder<'a> { } 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 in - // the outer condition context, mutate the target, then test `node` - // (never a boolean keyword → the parent-nullish guard is irrelevant). - self.do_with_conditional_branches(Some(right), true_target, false_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); @@ -3336,6 +3351,87 @@ mod tests { 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 while_loop_topology() { // `while (x) a;` — L1=LoopLabel; entry F0 added first, back edge (C1) From 2537837ade445f0844decf69c07c6c96b99a56cb Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 20:07:56 -0400 Subject: [PATCH 58/79] test: pin ??=/nested compound-assign RHS top-level flow shapes + fix isTopLevelLogical doc-header (FF-1) --- crates/tsv_check/src/binder/flow.rs | 142 ++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 5ff87fd18..142d36988 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -53,16 +53,22 @@ //! 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 two boundaries so they never leak into a non-condition: (1) at every +//! `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; and +//! (`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. With both resets a logical expression is top-level iff -//! `current_true_target` is `None`. Both are deliberate departures from tsgo -//! (which never saves the targets, relying on the parent walk) required by the -//! pointer-free heuristic. +//! 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 @@ -3432,6 +3438,130 @@ mod tests { ); } + #[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 while_loop_topology() { // `while (x) a;` — L1=LoopLabel; entry F0 added first, back edge (C1) From 0735fe50b51a278af8d4ff4859653b2e7e702332 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 21:06:25 -0400 Subject: [PATCH 59/79] =?UTF-8?q?docs:=20fix=20tsv=5Fcheck=20doc=20drift?= =?UTF-8?q?=20=E2=80=94=20per-file=20atoms,=20two-walk=20binder,=20Compile?= =?UTF-8?q?FilesEx=20parity=20(no=20short-circuit),=20dual=20NodeId=20reso?= =?UTF-8?q?lution=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/CLAUDE.md | 30 ++++++++++++------- crates/tsv_check/src/binder/atoms.rs | 18 ++++++++--- crates/tsv_check/src/lib.rs | 12 ++++++-- crates/tsv_check/src/merge.rs | 6 ++-- .../tsv_debug/src/tsc_conformance/runner.rs | 5 ++-- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 4b0ab1a95..408238e30 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -30,11 +30,15 @@ - `lib.rs` — public API surface. - `program.rs` — pipeline assembly, ported from tsgo - `program.go GetDiagnosticsOfAnyProgram`: per-unit parse with the goal - rule (`Goal::Module` first, `Goal::Script` retry on failure, both-fail = - program parse-rejection), the **parse-error short-circuit** (any unit - rejects ⇒ zero bind/check diagnostics program-wide), per-file - bind-then-check concatenation, final sort+dedup. + `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 @@ -67,9 +71,12 @@ - `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 program-scoped name interner (a fresh - `string-interner` instance — never the parser's per-document - `SharedInterner`), reserved internal-name atoms. + - `atoms.rs` — the checker's own **per-file** name interner (a fresh + `string-interner` instance per `bind_file` — never the parser's + per-document `SharedInterner`), 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.rs` — the **third walk**: the per-file control-flow graph (`build_flow`), a faithful port of tsgo's binder flow construction (`bind`/`bindContainer`/`bindChildren` + the per-statement flow shapers). @@ -82,8 +89,11 @@ 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). - Consumers resolve NodeIds through the SoA walk's **strict** - `require_node_id` (a miss aborts). The flow product rides `BoundUnit`, + 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 diff --git a/crates/tsv_check/src/binder/atoms.rs b/crates/tsv_check/src/binder/atoms.rs index 6c9c19552..9ec1d0c69 100644 --- a/crates/tsv_check/src/binder/atoms.rs +++ b/crates/tsv_check/src/binder/atoms.rs @@ -1,4 +1,4 @@ -//! The binder's program-scoped name interner. +//! 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 @@ -7,6 +7,15 @@ //! (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 fresh `string-interner` instance), //! not the parser's per-document `SharedInterner` — their tenant lifecycles stay //! decoupled. The reserved internal names tsgo mangles (`"default"`, `"export="`, @@ -20,14 +29,15 @@ use string_interner::backend::StringBackend; use string_interner::symbol::SymbolU32; use string_interner::{StringInterner, Symbol}; -/// A dense, program-scoped interned name identity. +/// A dense interned name identity, valid within one file's bind. /// /// Equal atoms mean equal names — the symbol-table key. Wraps the interner's -/// `u32` symbol so distinct-name lookups are integer compares. +/// `u32` symbol 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 name interner (its own `string-interner` instance). +/// The binder's per-file name interner (its own `string-interner` instance). pub struct Atoms { interner: StringInterner>, /// tsgo's `InternalSymbolNameDefault` — the forced name of every default diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 1d9f0099e..7e91bef00 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -35,12 +35,18 @@ //! - `hash` (private) — the crate's Fx-style hasher and `FxHashMap`/`FxHashSet`. //! - `span_scan` (private) — the bracket-inclusive computed-key scan the binder //! and check pass share, so their spans agree by construction. -//! - `binder` (private) — the fused lower+bind pre-order walk. +//! - `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). +//! 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 and the parse-error short-circuit. +//! - `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 //! diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 1bd485df5..5acff17b3 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -511,8 +511,10 @@ fn merge_symbol(target: &mut GlobalEntry, source: &MergeSymbol, out: &mut MergeO } /// tsgo `reportMergeSymbolError` — the same three-way message selection as the -/// bind-time cascade, emitting on **every** declaration of both symbols with -/// related info, deduped through [`MergeOut::lookup_or_issue`]. +/// 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); diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index f8dc702b6..38831a26e 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -31,8 +31,9 @@ //! (the corpus has pathological-nesting tests and tsv's parser has no depth //! guard), and each test's check is wrapped in `catch_unwind` so a panic lands //! in its own bucket instead of killing the run. A stack-overflow *abort* can't -//! be caught; the [`CRASH_EXCLUSIONS`] list carves out any test that aborts even -//! the big stack (empty on the pinned corpus). +//! be caught; the [`CRASH_EXCLUSIONS`] list carves out crashers by kind — the +//! genuine-abort class is empty on the pinned corpus (every current entry is a +//! catchable panic tracking a tsv parser bug, liveness-probed each run). // // tsgo: internal/compiler/program.go GetDiagnosticsOfAnyProgram (the pipeline) // tsgo: internal/testrunner/compiler_runner.go (the in-scope selection) From 016675b8fbebbf644a0bd797725e5505afc2f510 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 21:11:43 -0400 Subject: [PATCH 60/79] refactor: collapse dead BoundFile.node_flags into FlowProduct + shared expression_addr_kind with lockstep guard --- crates/tsv_check/src/binder/flow.rs | 67 ++++++-------------- crates/tsv_check/src/binder/mod.rs | 96 +++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 75 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 142d36988..bcebb732b 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -99,7 +99,7 @@ // (+ the newFlowNode* / createFlow* / finishFlowLabel / addAntecedent // constructor family and the per-statement flow shapers) -use crate::binder::{BoundFile, NodeKind, addr_of, statement_kind}; +use crate::binder::{BoundFile, NodeKind, addr_of, expression_addr_kind, statement_kind}; use crate::ids::{FlowNodeId, NodeId}; use smallvec::SmallVec; use tsv_lang::Span; @@ -362,7 +362,8 @@ pub struct FlowProduct { /// non-leaf nodes cleared in dead code; a dead *leaf* keeps /// `Some(unreachable)`). pub flow_of_node: Vec>, - /// The F0 `node_flags` column with the `Unreachable` bit set during the + /// 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), @@ -548,7 +549,7 @@ impl<'a> FlowBuilder<'a> { pool: Vec::new(), label_scratch: crate::hash::FxHashMap::default(), flow_of_node: vec![None; n], - node_flags: bound.node_flags.clone(), + node_flags: vec![0u8; n], end_flow: Vec::new(), return_flow: Vec::new(), fallthrough_flow: Vec::new(), @@ -1925,53 +1926,12 @@ impl<'a> FlowBuilder<'a> { } /// The F0 [`NodeId`] of an expression node — its variant payload's - /// `(address, kind)` in the address map (the compound key F0's lowering walk - /// used). Each arm's [`NodeKind`] mirrors the one `visit_expression` assigns to - /// that variant in `binder/mod.rs`. Condition / mutation subjects are always - /// value expressions F0 lowered, so this never misses. + /// `(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 { - use Expression as E; - let (addr, kind) = match e { - E::JsdocCast(c) => return self.expr_id(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), - }; + let (addr, kind) = expression_addr_kind(e); self.require(addr, kind) } @@ -3021,6 +2981,15 @@ mod tests { assert!(product.graph.node_count() >= 2); } + #[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; }"; diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 7926d89da..7a3f1ac1e 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -8,7 +8,7 @@ //! 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, the zero-initialized `node_flags` column, and the address→id map. +//! 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 @@ -230,10 +230,11 @@ pub enum NodeKind { TSMappedTypeParameter, } -/// Per-node flag bits in the [`BoundFile::node_flags`] column (one `u8` per -/// [`NodeId`]). F0 mints the column zero-initialized and sets nothing; the flow -/// construction pass (F1) sets [`NODE_FLAGS_UNREACHABLE`] during unreachable -/// tagging, and the ambient/context node-identity bits move here later. +/// 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; @@ -272,9 +273,6 @@ pub struct BoundFile { /// 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, - /// Per-node flag byte (see [`NODE_FLAGS_UNREACHABLE`]), one per [`NodeId`], - /// zero-initialized. F0 sets nothing; the flow pass (F1) writes it. - pub node_flags: 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 @@ -298,12 +296,6 @@ impl BoundFile { ancestor < descendant && descendant <= self.subtree_end[ancestor.index()] } - /// The flag byte for `id` (see [`NODE_FLAGS_UNREACHABLE`]). - #[must_use] - pub fn node_flags(&self, id: NodeId) -> u8 { - self.node_flags[id.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 @@ -533,7 +525,6 @@ pub fn bind_file<'arena>( kinds: walk.kinds, spans: walk.spans, subtree_end: walk.subtree_end, - node_flags: walk.node_flags, address_map: walk.address_map, diagnostics, facts, @@ -555,7 +546,6 @@ struct SoaWalk { kinds: Vec, spans: Vec, subtree_end: Vec, - node_flags: Vec, address_map: FxHashMap<(usize, NodeKind), NodeId>, } @@ -573,7 +563,6 @@ impl SoaWalk { self.kinds.push(kind); self.spans.push(span); self.subtree_end.push(id); // provisional (a leaf owns only itself) - self.node_flags.push(0); // F0 mints the column zeroed; F1 sets it // 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)` @@ -1396,6 +1385,15 @@ impl SoaWalk { self.close(id); } } + // 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" + ); } fn visit_function_expression(&mut self, f: &FunctionExpression<'_>, parent: NodeId) { @@ -1995,6 +1993,61 @@ pub(crate) fn statement_kind(stmt: &Statement<'_>) -> NodeKind { } } +/// 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::*; @@ -2077,15 +2130,6 @@ mod tests { assert!(bound.kinds.contains(&NodeKind::Literal)); // `1` } - #[test] - fn node_flags_column_is_zeroed_and_sized() { - let bound = bind("const x = 1; function f(a: T) { return a; }"); - assert_eq!(bound.node_flags.len(), bound.node_count as usize); - assert!(bound.node_flags.iter().all(|&b| b == 0)); - // The accessor agrees with the column. - assert_eq!(bound.node_flags(NodeId::FIRST), 0); - } - #[test] fn require_node_id_resolves_a_known_node() { let arena = Bump::new(); From bbd2c5369ec573a5c62e9f281ee164e506104fc9 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 21:13:38 -0400 Subject: [PATCH 61/79] feat: zero-alloc flow antecedent read-API (single_antecedent + borrowing iterators) for the P3 CFA walk --- crates/tsv_check/src/binder/flow.rs | 118 +++++++++++++++++++++------- 1 file changed, 90 insertions(+), 28 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index bcebb732b..289f9af70 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -301,41 +301,76 @@ impl FlowGraph { &self.reduce_payloads[index - 1] } - /// The reduced antecedent list of a `ReduceLabel` node, in append order (the - /// temporary antecedent subset the checker substitutes for `target` while it - /// passes this node). - #[must_use] - pub fn reduce_label_antecedents(&self, id: FlowNodeId) -> Vec { - let data = self.reduce_label_data(id); - let off = (data.antecedents - 1) as usize; // 1-based pool-run index + /// 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] - .iter() - .filter_map(|&raw| FlowNodeId::from_raw(raw)) - .collect() + &self.pool[off + 1..off + 1 + len] } - /// The antecedents of a flow node, in append order. + /// 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. /// - /// Non-label nodes have 0 or 1 antecedent (the single-antecedent slot); - /// label nodes decode their length-prefixed pool run. + /// Not valid on a label node, whose slot holds a pool-run index — decode + /// those via [`FlowGraph::antecedents_iter`]. + #[inline] #[must_use] - pub fn antecedents(&self, id: FlowNodeId) -> Vec { + 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()]; - if flags.is_label() { - if slot == 0 { - return Vec::new(); - } - let off = (slot - 1) as usize; - let len = self.pool[off] as usize; - self.pool[off + 1..off + 1 + len] - .iter() - .filter_map(|&raw| FlowNodeId::from_raw(raw)) - .collect() + let (run, single) = if flags.is_label() { + (self.pool_run(slot), None) } else { - FlowNodeId::from_raw(slot).into_iter().collect() - } + (&[][..], 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() } } @@ -2795,7 +2830,7 @@ pub fn render_flow_dot(product: &FlowProduct, node_spans: &[Span], source: &str) 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(id) { + for ante in g.antecedents_iter(id) { let _ = writeln!(out, " N{} -> N{};", id.get(), ante.get()); stack.push(ante); // cycle-guarded by `seen` } @@ -2981,6 +3016,33 @@ mod tests { 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); From e93358b05a60cdf57e0c619026a663e4f8586939 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 21:17:28 -0400 Subject: [PATCH 62/79] =?UTF-8?q?feat:=20wire=20obj-literal/class-expr=20m?= =?UTF-8?q?ethod=20flow-write=20+=20Start.Node=20subject=20=E2=80=94=20tsg?= =?UTF-8?q?o=20binder.go:981/:1534=20(P3=20narrowing=20hint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tsv_check/src/binder/flow.rs | 128 ++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 15 deletions(-) diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow.rs index 289f9af70..14f6bca54 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow.rs @@ -2084,23 +2084,27 @@ impl<'a> FlowBuilder<'a> { } fn visit_class_decl(&mut self, c: &ClassDeclaration<'_>) { - self.visit_class_common(c.id.as_ref(), c.decorators, c.super_class, c.body.body); + self.visit_class_common(c.id.as_ref(), c.decorators, c.super_class, c.body.body, 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); + self.visit_class_common(c.id.as_ref(), c.decorators, c.super_class, c.body.body, true); } /// 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. + /// 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. 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); @@ -2110,13 +2114,13 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(sc); } for member in members { - self.visit_class_member(member); + self.visit_class_member(member, is_class_expression); } } - fn visit_class_member(&mut self, member: &ClassMember<'_>) { + fn visit_class_member(&mut self, member: &ClassMember<'_>, is_class_expression: bool) { match member { - ClassMember::MethodDefinition(m) => self.visit_method(m), + ClassMember::MethodDefinition(m) => self.visit_method(m, is_class_expression), ClassMember::PropertyDefinition(p) => { self.visit_decorators(p.decorators); self.visit_expression(&p.key); @@ -2143,7 +2147,7 @@ impl<'a> FlowBuilder<'a> { } } - fn visit_method(&mut self, m: &MethodDefinition<'_>) { + 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); @@ -2153,12 +2157,23 @@ impl<'a> FlowBuilder<'a> { // 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 (the method itself - // is now separately resolvable by `NodeKind::MethodDefinition`). The - // obj-literal/class-expression method flow-write + Start.Node subject - // (binder.go:982, 1534) is a P3 narrowing hint, deferred to F1b. + // 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); - let saved = self.enter_container(None, false, is_ctor); + // 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); @@ -2461,11 +2476,17 @@ impl<'a> FlowBuilder<'a> { // 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 obj-literal method flow-write - // (binder.go:982) is a P3 narrowing hint, deferred. + // 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 saved = self.enter_container(None, false, false); + 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); @@ -3203,6 +3224,83 @@ mod tests { assert!(product.graph.flags(a1).contains(FlowFlags::REFERENCED)); } + /// 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 create_flow_condition_ports_verbatim() { let src = "true; false; y;"; From 69a6bf979e0255765c1b0ba9f2f12a4e830be0a0 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 21:26:36 -0400 Subject: [PATCH 63/79] refactor: keyed MissingCause classification + FAMILIES descriptor table + pin tables + FAMILY_CODES const assertion --- .../src/cli/commands/tsc_conformance.rs | 146 +++++---- crates/tsv_debug/src/tsc_conformance/mod.rs | 5 +- .../tsv_debug/src/tsc_conformance/runner.rs | 280 ++++++++++++------ 3 files changed, 270 insertions(+), 161 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 3279842ea..2386a1589 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -8,9 +8,9 @@ use crate::cli::CliError; use crate::tsc_conformance::index::IndexReport; use crate::tsc_conformance::runner::SkeletonReport; use crate::tsc_conformance::{ - FamilyFilter, RunFilter, RunOptions, baselines_dir, check_one, corpus_materialized, - denominators, discover_baselines, dump_flow_dot, histogram, run_index, run_roundtrip, - run_skeleton, tests_by_code, + FAMILIES, FamilyFilter, MissingCause, RunFilter, RunOptions, baselines_dir, check_one, + corpus_materialized, denominators, discover_baselines, dump_flow_dot, histogram, run_index, + run_roundtrip, run_skeleton, tests_by_code, }; use argh::FromArgs; use std::path::{Path, PathBuf}; @@ -130,6 +130,33 @@ const RUN_MISSING_DEFERRED_CFA_PIN: usize = 26; /// triage run. const RUN_MISSING_OTHER_PIN: usize = 0; const RUN_FAMILY_SPAN_MISMATCH_PIN: usize = 0; + +/// Per-family `(match, missing)` pins, one row per [`FAMILIES`] entry (same +/// order — dup, flow); a length mismatch with `FAMILIES` fails to compile. +/// Adding a family = its two pin consts + a row here. +const RUN_FAMILY_PARTITION_PINS: [(usize, usize); FAMILIES.len()] = [ + (RUN_DUP_MATCH_PIN, RUN_DUP_MISSING_PIN), + (RUN_FLOW_MATCH_PIN, RUN_FLOW_MISSING_PIN), +]; + +/// Exact pins for the missing-cause partition. [`MissingCause::Other`] is NOT +/// here — it gates unconditionally (a hard invariant, enforced on filtered +/// triage runs too) rather than as a full-run pin. Adding a deferred cause = +/// its pin const + a row here. +const RUN_MISSING_CAUSE_PINS: [(MissingCause, &str, usize); 4] = [ + (MissingCause::Merge, "missing merge", RUN_MISSING_MERGE_PIN), + (MissingCause::Lib, "missing lib", RUN_MISSING_LIB_PIN), + ( + MissingCause::DeferredLateBound, + "missing late-bound", + RUN_MISSING_DEFERRED_LATE_BOUND_PIN, + ), + ( + MissingCause::DeferredCfa, + "missing cfa", + RUN_MISSING_DEFERRED_CFA_PIN, + ), +]; const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 11; const RUN_MODULE_DETECTION_PIN: usize = 1; @@ -415,16 +442,15 @@ impl RunCommand { } } -/// Parse a `--family` value into a [`FamilyFilter`] (`dup` / `flow` / `all`). +/// Parse a `--family` value into a [`FamilyFilter`] (a `FAMILIES` key or `all`). fn parse_family_filter(arg: &str) -> Result { - match arg.to_lowercase().as_str() { - "dup" => Ok(FamilyFilter::Dup), - "flow" => Ok(FamilyFilter::Flow), - "all" => Ok(FamilyFilter::All), - other => Err(format!( - "Error: --family expects dup, flow, or all, got {other:?}" - )), - } + let lower = arg.to_lowercase(); + FamilyFilter::parse(&lower).ok_or_else(|| { + format!( + "Error: --family expects {}, got {lower:?}", + FamilyFilter::tokens() + ) + }) } /// Enforce the skeleton gates: the clean-grade + empty-channel invariants and zero @@ -468,10 +494,10 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), // The honest-residual gate: every missing must be explained by merge / lib / // late-bound / cfa. An `other` miss is a same-table cascade bug — a HARD zero (an // invariant, so a filtered triage run catches it too), not just a full-run pin. - if report.missing_other != RUN_MISSING_OTHER_PIN { + if report.missing(MissingCause::Other) != RUN_MISSING_OTHER_PIN { errs.push(format!( "missing OTHER {} != 0 (an unclassified family miss — a same-table cascade bug), e.g. {}", - report.missing_other, + report.missing(MissingCause::Other), report.missing_other_samples.first().map_or("", String::as_str) )); } @@ -625,55 +651,25 @@ fn enforce_run_gates(report: &SkeletonReport, enforce_pins: bool) -> Result<(), report.family_missing, RUN_FAMILY_MISSING_PIN, ); - // Sub-family partitions (dup = bind/merge/check; flow = TS7027/TS7028). - pin( - &mut errs, - "dup match", - report.dup_match(), - RUN_DUP_MATCH_PIN, - ); - pin( - &mut errs, - "dup missing", - report.dup_missing(), - RUN_DUP_MISSING_PIN, - ); - pin( - &mut errs, - "flow match", - report.flow_match(), - RUN_FLOW_MATCH_PIN, - ); - pin( - &mut errs, - "flow missing", - report.flow_missing(), - RUN_FLOW_MISSING_PIN, - ); - pin( - &mut errs, - "missing merge", - report.missing_merge, - RUN_MISSING_MERGE_PIN, - ); - pin( - &mut errs, - "missing lib", - report.missing_lib, - RUN_MISSING_LIB_PIN, - ); - pin( - &mut errs, - "missing late-bound", - report.missing_deferred_late_bound, - RUN_MISSING_DEFERRED_LATE_BOUND_PIN, - ); - pin( - &mut errs, - "missing cfa", - report.missing_deferred_cfa, - RUN_MISSING_DEFERRED_CFA_PIN, - ); + // Sub-family partitions, one row per graded family (dup, flow). + for (family, (match_pin, missing_pin)) in FAMILIES.iter().zip(RUN_FAMILY_PARTITION_PINS) { + pin( + &mut errs, + &format!("{} match", family.key), + report.family_match_for(family), + match_pin, + ); + pin( + &mut errs, + &format!("{} missing", family.key), + report.family_missing_for(family), + missing_pin, + ); + } + // The missing-cause partition (`Other` gates unconditionally above). + for (cause, label, want) in RUN_MISSING_CAUSE_PINS { + pin(&mut errs, label, report.missing(cause), want); + } pin( &mut errs, "family span_mismatch", @@ -903,11 +899,11 @@ fn build_report_value(report: &SkeletonReport) -> serde_json::Value { "total": report.family_missing, "dup": report.dup_missing(), "flow": report.flow_missing(), - "merge_path": report.missing_merge, - "lib_conflict": report.missing_lib, - "late_bound": report.missing_deferred_late_bound, - "cfa": report.missing_deferred_cfa, - "other": report.missing_other, + "merge_path": report.missing(MissingCause::Merge), + "lib_conflict": report.missing(MissingCause::Lib), + "late_bound": report.missing(MissingCause::DeferredLateBound), + "cfa": report.missing(MissingCause::DeferredCfa), + "other": report.missing(MissingCause::Other), }, "extra": report.family_extra, "span_mismatch": report.family_span_mismatch, @@ -991,11 +987,11 @@ fn render_report_md(report: &SkeletonReport) -> String { let _ = writeln!( s, " - by cause: merge-path {}, lib-conflict {}, late-bound {}, cfa {}, other {}", - report.missing_merge, - report.missing_lib, - report.missing_deferred_late_bound, - report.missing_deferred_cfa, - report.missing_other + report.missing(MissingCause::Merge), + report.missing(MissingCause::Lib), + report.missing(MissingCause::DeferredLateBound), + report.missing(MissingCause::DeferredCfa), + report.missing(MissingCause::Other) ); let _ = writeln!(s, "- extra (GATE=0): {}", report.family_extra); let _ = writeln!(s, "- span mismatch: {}\n", report.family_span_mismatch); @@ -1618,9 +1614,7 @@ mod tests { family_positive_variants: 3, family_match: 5, family_missing: 2, - missing_merge: 0, - missing_lib: 0, - missing_other: 2, + missing_by_cause: std::collections::BTreeMap::from([(MissingCause::Other, 2usize)]), family_extra: 0, related_match: 1, // Inserted out of ascending order — the BTreeMap and the render must sort. diff --git a/crates/tsv_debug/src/tsc_conformance/mod.rs b/crates/tsv_debug/src/tsc_conformance/mod.rs index 855c4b43b..9bf3bcc9c 100644 --- a/crates/tsv_debug/src/tsc_conformance/mod.rs +++ b/crates/tsv_debug/src/tsc_conformance/mod.rs @@ -37,4 +37,7 @@ pub use discovery::{baselines_dir, corpus_materialized, discover_baselines}; pub use index::run_index; pub use query::{denominators, histogram, tests_by_code}; pub use roundtrip::run_roundtrip; -pub use runner::{FamilyFilter, RunFilter, RunOptions, check_one, dump_flow_dot, run_skeleton}; +pub use runner::{ + FAMILIES, FamilyFilter, MissingCause, RunFilter, RunOptions, check_one, dump_flow_dot, + run_skeleton, +}; diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 38831a26e..9f66561ac 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -78,6 +78,47 @@ const DUP_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; /// report lines. const FLOW_CODES: [u32; 2] = [7027, 7028]; +/// One graded code family: its `--family` filter token (also its report label) +/// and its code set. **Adding a family** = a codes const + a row here + a row in +/// the CLI command's per-family pin table (+ a [`MissingCause`] variant and +/// ledger if it brings a new deferred cause) — the sweep, filter parsing, +/// sub-family accessors, and report lines all read this table. +pub struct GradedFamily { + /// The `--family` filter token / report label. + pub key: &'static str, + /// The family's TS codes. + pub codes: &'static [u32], +} + +/// The graded families, in report order. +pub const FAMILIES: [GradedFamily; 2] = [ + GradedFamily { + key: "dup", + codes: &DUP_CODES, + }, + GradedFamily { + key: "flow", + codes: &FLOW_CODES, + }, +]; + +// `FAMILY_CODES` is maintained by hand as the union of every `FAMILIES` row — +// pin the agreement at compile time (order-preserving concatenation, dup then +// flow), so a family edit that forgets one side cannot build. +const _: () = { + assert!(FAMILY_CODES.len() == DUP_CODES.len() + FLOW_CODES.len()); + let mut i = 0; + while i < DUP_CODES.len() { + assert!(FAMILY_CODES[i] == DUP_CODES[i]); + i += 1; + } + let mut j = 0; + while j < FLOW_CODES.len() { + assert!(FAMILY_CODES[DUP_CODES.len() + j] == FLOW_CODES[j]); + j += 1; + } +}; + /// The merge-path family codes — a *missing* of one of these is classified as a /// merge-phase gap, not a same-table cascade bug. const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; @@ -91,9 +132,9 @@ const BIND_EMITTED_TS1XXX: [u32; 12] = [ /// The family baselines whose family diagnostics come from a standard-library /// conflict. These **match** via the lib base; the classifier is kept as a -/// regression guard — a *missing* in one of these is bucketed to `missing_lib` -/// (pinned 0) rather than `missing_other`, so a lib-detection regression fails -/// loudly. +/// regression guard — a *missing* in one of these is bucketed to +/// [`MissingCause::Lib`] (pinned 0) rather than the hard-zero +/// [`MissingCause::Other`], so a lib-detection regression fails loudly. const LIB_CONFLICT_BASELINES: [&str; 5] = [ "intersectionsOfLargeUnions2.ts", "jsExportMemberMergedWithModuleAugmentation2.ts", @@ -106,9 +147,9 @@ const LIB_CONFLICT_BASELINES: [&str; 5] = [ /// deferred: they need the type engine (a literal-type or `unique symbol` computed /// member name, resolved via tsgo's `lateBindMember`) that this bind+check /// implementation has no counterpart for. A *missing* in one of these is bucketed to -/// `missing_deferred_late_bound` (exact-pinned) rather than the hard-zero -/// `missing_other`, so the honest residual stays visible without gating a release on -/// a type-engine gap. Basenames, mirroring `LIB_CONFLICT_BASELINES`. +/// [`MissingCause::DeferredLateBound`] (exact-pinned) rather than the hard-zero +/// [`MissingCause::Other`], so the honest residual stays visible without gating a +/// release on a type-engine gap. Basenames, mirroring `LIB_CONFLICT_BASELINES`. const LATE_BOUND_BASELINES: [&str; 4] = [ "dynamicNamesErrors.ts", "symbolDeclarationEmit12.ts", @@ -122,9 +163,9 @@ const LATE_BOUND_BASELINES: [&str; 4] = [ /// come from tsgo's `isReachableFlowNode` fallback — never-returning call /// signatures, `asserts` type predicates, switch exhaustiveness, and the /// structural reachability fallback. A *missing* in one of these is bucketed to -/// `missing_deferred_cfa` (exact-pinned) rather than the hard-zero `missing_other`, -/// so the honest CFA residual stays visible without gating a release on a -/// type-engine gap. Basenames, mirroring `LATE_BOUND_BASELINES`. +/// [`MissingCause::DeferredCfa`] (exact-pinned) rather than the hard-zero +/// [`MissingCause::Other`], so the honest CFA residual stays visible without gating +/// a release on a type-engine gap. Basenames, mirroring `LATE_BOUND_BASELINES`. /// /// `assertionTypePredicates1.ts` never reaches the cfa bucket — its entry is a /// defensive no-op kept for completeness. tsv currently **parse-rejects** it (an @@ -132,7 +173,7 @@ const LATE_BOUND_BASELINES: [&str; 4] = [ /// counted in the parse-divergence census), so it is graded not at all; and were /// the parser fixed, its baseline's non-bind TS1xxx (TS1228) would carve it out by /// predicate v1 rule (a) instead — either way its 5 flow instances never land in -/// `missing_deferred_cfa`. The live cfa residual is **26**: neverReturningFunctions1 +/// the cfa bucket. The live cfa residual is **26**: neverReturningFunctions1 /// 22, exhaustiveSwitchStatements1 1, unreachableSwitchTypeofAny 1, /// unreachableSwitchTypeofUnknown 1 (**25** across the four non-carved named /// baselines), plus reachabilityChecks8 1 (the structural `isReachableFlowNode` @@ -146,6 +187,52 @@ const DEFERRED_CFA_BASELINES: [&str; 6] = [ "reachabilityChecks8.ts", ]; +/// Why a graded family baseline diagnostic is missing — the classifier's +/// verdict, tallied keyed in `SkeletonReport::missing_by_cause`. Every non- +/// [`MissingCause::Other`] cause is exact-pinned in the CLI command's cause-pin +/// table; `Other` is the HARD-zero invariant (enforced even on filtered runs). +/// **Adding a deferred cause** (a new family's type-engine residual) = a variant +/// here + a ledger const + an arm in [`classify_missing`] + a pin-table row. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MissingCause { + /// The merge phase owns the code ([`MERGE_CODES`]). + Merge, + /// A [`LIB_CONFLICT_BASELINES`] test missing a dup-family code (absent lib + /// binding — a lib-detection regression guard, pinned 0). + Lib, + /// A [`LATE_BOUND_BASELINES`] test missing a dup-family code — the + /// type-engine `lateBindMember` residual (exact-pinned). + DeferredLateBound, + /// A [`DEFERRED_CFA_BASELINES`] test missing a flow-family code — the + /// `isReachableFlowNode` residual (exact-pinned). + DeferredCfa, + /// Unclassified — a same-table cascade / flow-construction bug. **HARD + /// gate: zero**, an invariant even on filtered triage runs. + Other, +} + +/// Classify one missing family diagnostic by its baseline + code. Each +/// name-keyed cause also requires the code to be in its family (dup for +/// lib/late-bound, flow for cfa), mirroring the merge branch — a wrong-family +/// missing inside a named baseline falls through to the hard-zero `Other` +/// instead of being silently absorbed. This keeps the classification honest on +/// filtered/triage runs, not only on a full run where the code-keyed pins +/// catch it. +fn classify_missing(basename: &str, code: u32) -> MissingCause { + if MERGE_CODES.contains(&code) { + MissingCause::Merge + } else if LIB_CONFLICT_BASELINES.contains(&basename) && DUP_CODES.contains(&code) { + MissingCause::Lib + } else if LATE_BOUND_BASELINES.contains(&basename) && DUP_CODES.contains(&code) { + MissingCause::DeferredLateBound + } else if DEFERRED_CFA_BASELINES.contains(&basename) && FLOW_CODES.contains(&code) { + MissingCause::DeferredCfa + } else { + MissingCause::Other + } +} + /// Worker-thread stack for the sweep: the corpus has deeply-nested tests and /// tsv's recursive-descent parser has no depth guard, so the default 8 MiB /// overflows. 512 MiB is virtual-only reserve on Linux. @@ -212,20 +299,39 @@ pub struct PanicRecord { /// Which graded sub-family the `--family` filter isolates. #[derive(Clone, Copy, PartialEq, Eq)] pub enum FamilyFilter { - /// The duplicate/conflict sub-family ([`DUP_CODES`]). - Dup, - /// The flow-construction sub-family ([`FLOW_CODES`]). - Flow, + /// One [`FAMILIES`] row, by index (`dup` = 0, `flow` = 1). + One(usize), /// The whole graded family ([`FAMILY_CODES`]) — isolates the family-graded slice. All, } +impl FamilyFilter { + /// Parse a `--family` token: a [`FAMILIES`] key, or `all`. + #[must_use] + pub fn parse(arg: &str) -> Option { + if arg == "all" { + return Some(FamilyFilter::All); + } + FAMILIES + .iter() + .position(|f| f.key == arg) + .map(FamilyFilter::One) + } + + /// The valid `--family` tokens, for error messages (`dup / flow / all`). + #[must_use] + pub fn tokens() -> String { + let mut tokens: Vec<&str> = FAMILIES.iter().map(|f| f.key).collect(); + tokens.push("all"); + tokens.join(" / ") + } +} + /// The code set a [`FamilyFilter`] keeps a variant for (its baseline must carry at /// least one). fn family_filter_codes(f: FamilyFilter) -> &'static [u32] { match f { - FamilyFilter::Dup => &DUP_CODES, - FamilyFilter::Flow => &FLOW_CODES, + FamilyFilter::One(i) => FAMILIES[i].codes, FamilyFilter::All => &FAMILY_CODES, } } @@ -383,22 +489,10 @@ pub struct SkeletonReport { /// Sample related misses. pub related_missing_samples: Vec, - /// ...missing attributed to the merge phase (TS2397/2649/2664/2671). - pub missing_merge: usize, - /// ...missing attributed to absent lib binding (a `LIB_CONFLICT_BASELINES` test). - pub missing_lib: usize, - /// ...missing genuinely deferred to the type engine (a `LATE_BOUND_BASELINES` - /// test — literal-type / `unique symbol` computed member names). Exact-pinned. - pub missing_deferred_late_bound: usize, - /// ...flow missing genuinely deferred to the CFA type engine (a - /// `DEFERRED_CFA_BASELINES` test — never-returning signatures / assertion - /// predicates / switch exhaustiveness / structural reachability fallback). - /// Exact-pinned. - pub missing_deferred_cfa: usize, - /// ...missing not attributable to merge/lib/late-bound/cfa — a same-table - /// cascade bug or an unclassified gap. **Gate: must be 0** (any such miss is a - /// regression). - pub missing_other: usize, + /// ...missing, tallied by classified cause (see [`MissingCause`]; keyed so + /// new causes are a variant + a pin row, not a new field). Read via + /// [`SkeletonReport::missing`]. [`MissingCause::Other`] **gates at 0**. + pub missing_by_cause: BTreeMap, /// Variants carved out by predicate v1 rule (a): tsv parses clean but the /// baseline carries a non-bind TS1xxx code (recovery-AST incomparability). pub carve_out_rule_a: usize, @@ -1098,32 +1192,15 @@ fn grade_family( diff: render_family_diff(test, name, reason, ours, &base_family), }); } - let is_lib = LIB_CONFLICT_BASELINES.contains(&test.basename.as_str()); - let is_late_bound = LATE_BOUND_BASELINES.contains(&test.basename.as_str()); - let is_deferred_cfa = DEFERRED_CFA_BASELINES.contains(&test.basename.as_str()); - // Each name-keyed bucket also requires the code to be in its family (dup for - // lib/late-bound, flow for cfa), mirroring the merge branch — a wrong-family - // missing inside a named baseline falls through to the hard-zero `missing_other` - // instead of being silently absorbed. This keeps the classification honest on - // filtered/triage runs, not only on a full run where the code-keyed pins catch it. for (code, count) in &buckets.missing_by_code { report.family_missing += *count; *report.family_missing_by_code.entry(*code).or_default() += *count; - if MERGE_CODES.contains(code) { - report.missing_merge += *count; - } else if is_lib && DUP_CODES.contains(code) { - report.missing_lib += *count; - } else if is_late_bound && DUP_CODES.contains(code) { - report.missing_deferred_late_bound += *count; - } else if is_deferred_cfa && FLOW_CODES.contains(code) { - report.missing_deferred_cfa += *count; - } else { - report.missing_other += *count; - if report.missing_other_samples.len() < 20 { - report - .missing_other_samples - .push(format!("{}/{name} TS{code} x{count}", test.suite)); - } + let cause = classify_missing(&test.basename, *code); + *report.missing_by_cause.entry(cause).or_default() += *count; + if cause == MissingCause::Other && report.missing_other_samples.len() < 20 { + report + .missing_other_samples + .push(format!("{}/{name} TS{code} x{count}", test.suite)); } } @@ -1443,28 +1520,46 @@ fn sub_family_sum(by_code: &BTreeMap, codes: &[u32]) -> usize { } impl SkeletonReport { + /// A family's matches (partition of `family_match_by_code` by its code set). + #[must_use] + pub fn family_match_for(&self, family: &GradedFamily) -> usize { + sub_family_sum(&self.family_match_by_code, family.codes) + } + + /// A family's misses (partition of `family_missing_by_code` by its code set). + #[must_use] + pub fn family_missing_for(&self, family: &GradedFamily) -> usize { + sub_family_sum(&self.family_missing_by_code, family.codes) + } + + /// The missing count attributed to `cause` (0 when the cause never fired). + #[must_use] + pub fn missing(&self, cause: MissingCause) -> usize { + self.missing_by_cause.get(&cause).copied().unwrap_or(0) + } + /// The duplicate/conflict sub-family matches (partition of `family_match_by_code`). #[must_use] pub fn dup_match(&self) -> usize { - sub_family_sum(&self.family_match_by_code, &DUP_CODES) + self.family_match_for(&FAMILIES[0]) } /// The flow sub-family matches (partition of `family_match_by_code`). #[must_use] pub fn flow_match(&self) -> usize { - sub_family_sum(&self.family_match_by_code, &FLOW_CODES) + self.family_match_for(&FAMILIES[1]) } /// The duplicate/conflict sub-family misses (partition of `family_missing_by_code`). #[must_use] pub fn dup_missing(&self) -> usize { - sub_family_sum(&self.family_missing_by_code, &DUP_CODES) + self.family_missing_for(&FAMILIES[0]) } /// The flow sub-family misses (partition of `family_missing_by_code`). #[must_use] pub fn flow_missing(&self) -> usize { - sub_family_sum(&self.family_missing_by_code, &FLOW_CODES) + self.family_missing_for(&FAMILIES[1]) } /// Print the human summary. @@ -1499,32 +1594,46 @@ impl SkeletonReport { " ...family-positive: {}", self.family_positive_variants ); + let per_family = |get: &dyn Fn(&GradedFamily) -> usize| -> String { + FAMILIES + .iter() + .map(|f| format!("{} {}", f.key, get(f))) + .collect::>() + .join(", ") + }; println!( - " match: {} (dup {}, flow {})", + " match: {} ({})", self.family_match, - self.dup_match(), - self.flow_match() + per_family(&|f| self.family_match_for(f)) ); println!( - " missing: {} (dup {}, flow {})", + " missing: {} ({})", self.family_missing, - self.dup_missing(), - self.flow_missing() - ); - println!(" merge-path: {}", self.missing_merge); - println!(" lib-conflict: {}", self.missing_lib); - println!( - " late-bound (deferred): {} (needs the type engine — literal-type / unique-symbol computed member names)", - self.missing_deferred_late_bound - ); - println!( - " cfa (deferred): {} (needs the CFA type engine — never-returning sigs / assertion predicates / switch exhaustiveness / structural reachability)", - self.missing_deferred_cfa - ); - println!( - " other (GATE=0): {} (unclassified family miss — a same-table cascade bug)", - self.missing_other + per_family(&|f| self.family_missing_for(f)) ); + // One line per classified cause (label-aligned; `Other` is the gate). + const CAUSE_LINES: [(MissingCause, &str, &str); 5] = [ + (MissingCause::Merge, " merge-path: ", ""), + (MissingCause::Lib, " lib-conflict: ", ""), + ( + MissingCause::DeferredLateBound, + " late-bound (deferred): ", + " (needs the type engine — literal-type / unique-symbol computed member names)", + ), + ( + MissingCause::DeferredCfa, + " cfa (deferred): ", + " (needs the CFA type engine — never-returning sigs / assertion predicates / switch exhaustiveness / structural reachability)", + ), + ( + MissingCause::Other, + " other (GATE=0): ", + " (unclassified family miss — a same-table cascade bug)", + ), + ]; + for (cause, label, note) in CAUSE_LINES { + println!("{label}{}{note}", self.missing(cause)); + } println!(" extra (GATE=0): {}", self.family_extra); println!(" span_mismatch: {}", self.family_span_mismatch); println!("Related-info (matched primaries; own channel, non-gating)"); @@ -2001,9 +2110,10 @@ mod tests { assert!(none.keeps_family(|_| panic!("resolver consulted with no --family filter"))); // `flow` keeps iff the baseline carries a FLOW_CODES member; a dup-only - // baseline is excluded, a flow baseline is kept. + // baseline is excluded, a flow baseline is kept. (Parsed through the + // `FAMILIES`-table tokens — the same path the CLI takes.) let flow = RunFilter { - family: Some(FamilyFilter::Flow), + family: FamilyFilter::parse("flow"), ..RunFilter::default() }; assert!(flow.keeps_family(|c| c == 7027)); @@ -2011,15 +2121,17 @@ mod tests { // `dup` is the complementary partition. let dup = RunFilter { - family: Some(FamilyFilter::Dup), + family: FamilyFilter::parse("dup"), ..RunFilter::default() }; assert!(dup.keeps_family(|c| c == 2300)); assert!(!dup.keeps_family(|c| c == 7027)); - // `all` keeps any family code (either partition). + // `all` keeps any family code (either partition); an unknown token + // refuses to parse. + assert!(FamilyFilter::parse("nope").is_none()); let all = RunFilter { - family: Some(FamilyFilter::All), + family: FamilyFilter::parse("all"), ..RunFilter::default() }; assert!(all.keeps_family(|c| c == 7028)); From b332d62d894a06b345e8a5d5cdc87a88da4f85dc Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 21:46:58 -0400 Subject: [PATCH 64/79] feat: profile --bind --flow-stats deterministic anchors + tsc_conformance hang watchdog (R13 wall-clock half) --- crates/tsv_debug/src/cli/commands/profile.rs | 75 +++++++++++++++++-- .../tsv_debug/src/tsc_conformance/runner.rs | 72 ++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/profile.rs b/crates/tsv_debug/src/cli/commands/profile.rs index 2badcbdb6..22197f280 100644 --- a/crates/tsv_debug/src/cli/commands/profile.rs +++ b/crates/tsv_debug/src/cli/commands/profile.rs @@ -22,6 +22,12 @@ pub struct ProfileCommand { #[argh(switch)] bind: bool, + /// with --bind: print the deterministic flow-construction anchors + /// (flow-node density = flow nodes / AST nodes, dead-label fraction) + /// summed over the corpus + #[argh(switch)] + flow_stats: bool, + /// file paths, directories, or glob patterns #[argh(positional)] paths: Vec, @@ -83,12 +89,54 @@ impl ProfileCommand { None => eprintln!("peak RSS (VmHWM): unavailable"), } eprintln!("(the `format` column is parse->bind time)"); + if self.flow_stats { + print_flow_stats(&results); + } } Ok(()) } } +/// Sum and print the deterministic flow-construction counters (`--flow-stats`) +/// — the standing density / dead-label anchors, machine-invariant unlike wall. +#[allow(clippy::cast_precision_loss)] +fn print_flow_stats(results: &[FileResult]) { + let sum = results + .iter() + .filter_map(|r| r.bind_stats) + .fold(BindStats::default(), |a, s| BindStats { + ast_nodes: a.ast_nodes + s.ast_nodes, + flow_nodes: a.flow_nodes + s.flow_nodes, + branch_labels: a.branch_labels + s.branch_labels, + dead_labels: a.dead_labels + s.dead_labels, + }); + let density = if sum.ast_nodes > 0 { + sum.flow_nodes as f64 / sum.ast_nodes as f64 + } else { + 0.0 + }; + let dead_fraction = if sum.branch_labels > 0 { + sum.dead_labels as f64 / sum.branch_labels as f64 + } else { + 0.0 + }; + eprintln!( + "flow stats: {} AST nodes, {} flow nodes (density {density:.3}), {} branch labels ({} dead, fraction {dead_fraction:.3})", + sum.ast_nodes, sum.flow_nodes, sum.branch_labels, sum.dead_labels + ); +} + +/// Deterministic per-file flow-construction counters from one bind iteration +/// (identical across iterations — construction is pure). +#[derive(Clone, Copy, Default)] +struct BindStats { + ast_nodes: u64, + flow_nodes: u64, + branch_labels: u64, + dead_labels: u64, +} + /// Peak resident set size in KiB from `/proc/self/status` (`VmHWM`), or `None` /// off Linux / when the field is absent. fn read_vm_hwm_kb() -> Option { @@ -109,6 +157,8 @@ struct FileResult { parse_us: f64, format_us: f64, total_us: f64, + /// Flow-construction counters (`--bind` mode only; `None` in format mode). + bind_stats: Option, } /// Aggregate timing over a set of file results (whole run or one language). @@ -212,10 +262,13 @@ fn profile_file( let mut parse_times = Vec::with_capacity(iterations); let mut format_times = Vec::with_capacity(iterations); + let mut bind_stats = None; for _ in 0..iterations { let (parse_dur, format_dur) = if bind { - profile_bind_once(&source, arena)? + let (parse_dur, format_dur, stats) = profile_bind_once(&source, arena)?; + bind_stats = Some(stats); // deterministic — any iteration's copy + (parse_dur, format_dur) } else { profile_once(&source, parser_type, arena, doc_arena)? }; @@ -240,13 +293,17 @@ fn profile_file( parse_us, format_us, total_us: parse_us + format_us, + bind_stats, }) } /// Run one parse + lower+bind iteration for TypeScript, returning -/// `(parse_duration, bind_duration)`. The bind phase runs the `tsv_check` binder -/// (SoA walk + symbol bind) over the parsed program. -fn profile_bind_once(source: &str, arena: &bumpalo::Bump) -> Result<(Duration, Duration), String> { +/// `(parse_duration, bind_duration, flow_counters)`. The bind phase runs the +/// `tsv_check` binder (SoA walk + symbol bind) over the parsed program. +fn profile_bind_once( + source: &str, + arena: &bumpalo::Bump, +) -> Result<(Duration, Duration, BindStats), String> { let t0 = Instant::now(); let ast = tsv_ts::parse(source, arena).map_err(|e| format!("parse error: {e}"))?; let parse_dur = t0.elapsed(); @@ -255,10 +312,16 @@ fn profile_bind_once(source: &str, arena: &bumpalo::Bump) -> Result<(Duration, D // Parse -> lower+bind (F0) -> flow graph (F1). The flow walk is the third // pass, so it belongs in the bind column the checker's perf anchor tracks. let bound = tsv_check::bind_file(&ast, source, tsv_check::FileId::ROOT); - let _ = tsv_check::build_flow(&ast, source, &bound); + let flow = tsv_check::build_flow(&ast, source, &bound); let bind_dur = t1.elapsed(); - Ok((parse_dur, bind_dur)) + let stats = BindStats { + ast_nodes: u64::from(bound.node_count), + flow_nodes: u64::from(flow.graph.node_count()), + branch_labels: u64::from(flow.stats.branch_labels), + dead_labels: u64::from(flow.stats.dead_labels), + }; + Ok((parse_dur, bind_dur, stats)) } /// Run one parse + format iteration, return (parse_duration, format_duration). diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs index 9f66561ac..5b21ce7df 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner.rs @@ -238,6 +238,75 @@ fn classify_missing(basename: &str, code: u32) -> MissingCause { /// overflows. 512 MiB is virtual-only reserve on Linux. const SKELETON_STACK: usize = 512 * 1024 * 1024; +/// Per-test wall-clock budget for the [`TestWatchdog`]. The full ~12k-test +/// sweep runs in seconds, so a single test at 60 s is pathological with huge +/// margin (~10⁴× the mean) — the limit exists to convert a *hang* into a loud +/// named failure, not to police slow tests. +const WATCHDOG_LIMIT: std::time::Duration = std::time::Duration::from_secs(60); + +/// The sweep's hang watchdog — the wall-clock half of the "watchdog +/// independent of ported budgets" requirement. `catch_unwind` converts panics +/// into per-test buckets, but a **hang** (a mis-ported budget at P3, a parser +/// loop) would otherwise freeze the gate silently. The worker heartbeats each +/// test's name + start; a monitor thread checks ~1 Hz and, past +/// [`WATCHDOG_LIMIT`], prints the offending test and exits the process (a hung +/// thread cannot be killed safely — a loud named exit is the correct failure). +/// The instruction-count half (budget-arithmetic cross-check) rides P3 with +/// the budgets themselves. +struct TestWatchdog { + /// `(current test relative_path, its start)`; `None` after `finish`. + current: std::sync::Arc>>, +} + +impl TestWatchdog { + fn spawn() -> TestWatchdog { + let current: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let monitor = std::sync::Arc::clone(¤t); + // Detached monitor: exits within a tick of the sweep clearing the slot + // (or dies with the process — it holds nothing that needs cleanup). + drop( + std::thread::Builder::new() + .name("tsc-watchdog".to_string()) + .spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + let guard = monitor.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((test, start)) = guard.as_ref() else { + return; // sweep finished + }; + if start.elapsed() > WATCHDOG_LIMIT { + eprintln!( + "tsc_conformance watchdog: test {test:?} exceeded {}s — a hang \ + (mis-ported budget / parser loop); aborting the run", + WATCHDOG_LIMIT.as_secs() + ); + std::process::exit(3); + } + } + }), + ); + TestWatchdog { current } + } + + /// Heartbeat: the sweep is entering `test` now. + fn enter(&self, test: &str) { + *self + .current + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((test.to_string(), Instant::now())); + } + + /// The sweep is done — clear the slot so the monitor thread exits. + fn finish(&self) { + *self + .current + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } +} + /// How a crash-excluded test fails, and whether its liveness is probeable. // `GenuineAbort` is the designed flag for a future stack-overflow entry (none on // the pinned corpus); it is un-probeable, so it is never re-run. @@ -603,12 +672,14 @@ fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result`): match the roundtrip identity. if !options.filter.keeps_test(&test.relative_path) { continue; } + watchdog.enter(&test.relative_path); if SKIPPED_TESTS.contains(&test.basename.as_str()) { continue; } @@ -659,6 +730,7 @@ fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result Date: Sat, 11 Jul 2026 22:45:13 -0400 Subject: [PATCH 65/79] refactor: split tsv_check binder flow.rs into flow/ directory-module --- crates/tsv_check/CLAUDE.md | 14 +- .../src/binder/{flow.rs => flow/build.rs} | 2007 +---------------- crates/tsv_check/src/binder/flow/flags.rs | 73 + crates/tsv_check/src/binder/flow/graph.rs | 187 ++ crates/tsv_check/src/binder/flow/mod.rs | 114 + crates/tsv_check/src/binder/flow/product.rs | 201 ++ crates/tsv_check/src/binder/flow/tests.rs | 1416 ++++++++++++ 7 files changed, 2027 insertions(+), 1985 deletions(-) rename crates/tsv_check/src/binder/{flow.rs => flow/build.rs} (55%) create mode 100644 crates/tsv_check/src/binder/flow/flags.rs create mode 100644 crates/tsv_check/src/binder/flow/graph.rs create mode 100644 crates/tsv_check/src/binder/flow/mod.rs create mode 100644 crates/tsv_check/src/binder/flow/product.rs create mode 100644 crates/tsv_check/src/binder/flow/tests.rs diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 408238e30..989a4b651 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -58,7 +58,7 @@ 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.rs`) invoked separately from `program.rs`: + (`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 @@ -77,8 +77,14 @@ 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.rs` — the **third walk**: the per-file control-flow graph - (`build_flow`), a faithful port of tsgo's binder flow construction + - `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.rs` (`FlowBuilder` + the + `pub fn build_flow` entry point + the pure AST predicates the walk + dispatches on), and `tests.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 @@ -122,7 +128,7 @@ 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.rs`): it reads the + 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 diff --git a/crates/tsv_check/src/binder/flow.rs b/crates/tsv_check/src/binder/flow/build.rs similarity index 55% rename from crates/tsv_check/src/binder/flow.rs rename to crates/tsv_check/src/binder/flow/build.rs index 14f6bca54..097745860 100644 --- a/crates/tsv_check/src/binder/flow.rs +++ b/crates/tsv_check/src/binder/flow/build.rs @@ -1,108 +1,6 @@ -//! 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) - +use super::*; use crate::binder::{BoundFile, NodeKind, addr_of, expression_addr_kind, statement_kind}; -use crate::ids::{FlowNodeId, NodeId}; use smallvec::SmallVec; -use tsv_lang::Span; use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ ArrowFunctionBody, AssignmentOperator, BinaryExpression, BinaryOperator, BreakStatement, @@ -114,338 +12,6 @@ use tsv_ts::ast::internal::{ VariableDeclarator, WhileStatement, }; -// --- 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] - 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) - } -} - -// --- 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 { - flags: Vec, - /// Kind-discriminated by `flags`: a `NodeId` (raw, 1-based) | payload index - /// | 0 = none. In F1a it is always a `NodeId` or 0. - subject: Vec, - /// Non-label: the single antecedent's raw `FlowNodeId` (0 = none). - /// Label: a 1-based pool-run index (0 = collapsed / unfinalized). - antecedent: Vec, - /// Length-prefixed antecedent runs for labels (`[len, e0, e1, …]`). - pool: Vec, - /// Switch-clause payloads, addressed by a `SwitchClause` node's 1-based - /// `subject` slot (read via [`FlowGraph::switch_clause_data`]). - switch_payloads: Vec, - /// Reduce-label payloads (try/finally), addressed by a `ReduceLabel` node's - /// 1-based `subject` slot (read via [`FlowGraph::reduce_label_data`]). - 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() - } -} - -// --- 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) - } -} - /// 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 — @@ -499,13 +65,13 @@ struct ActiveLabelEntry { } /// The flow-graph construction walk. -struct FlowBuilder<'a> { +pub(super) struct FlowBuilder<'a> { bound: &'a BoundFile, /// The host document — the label-name lookup extracts `spans[id]` slices. source: &'a str, // graph columns - flags: Vec, + pub(super) flags: Vec, subject: Vec, antecedent: Vec, pool: Vec, @@ -535,7 +101,7 @@ struct FlowBuilder<'a> { // construction state (the F1b subset of the container-boundary set) current_flow: FlowNodeId, - unreachable_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. @@ -573,7 +139,7 @@ struct FlowBuilder<'a> { } impl<'a> FlowBuilder<'a> { - fn new(bound: &'a BoundFile, source: &'a str) -> 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, @@ -613,7 +179,7 @@ impl<'a> FlowBuilder<'a> { b } - fn finish(mut self) -> FlowProduct { + 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 @@ -667,7 +233,7 @@ impl<'a> FlowBuilder<'a> { // --- flow node constructors (binder.go:454-575) ----------------------- /// `newFlowNode` (binder.go:454) — a bare node with only flags. - fn new_flow_node(&mut self, flags: FlowFlags) -> FlowNodeId { + 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); @@ -690,7 +256,7 @@ impl<'a> FlowBuilder<'a> { } /// `createBranchLabel` (binder.go:471). - fn create_branch_label(&mut self) -> FlowNodeId { + pub(super) fn create_branch_label(&mut self) -> FlowNodeId { self.branch_labels += 1; self.new_flow_node(FlowFlags::BRANCH_LABEL) } @@ -788,7 +354,7 @@ impl<'a> FlowBuilder<'a> { /// 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. - fn create_flow_condition( + pub(super) fn create_flow_condition( &mut self, flags: FlowFlags, antecedent: FlowNodeId, @@ -835,7 +401,7 @@ impl<'a> FlowBuilder<'a> { /// `addAntecedent` (binder.go:547) — order-preserving, first-write-wins /// **id-equality** dedup append; unreachable edges are dropped; /// `setFlowNodeReferenced` fires only on a genuine append. - fn add_antecedent(&mut self, label: FlowNodeId, antecedent: FlowNodeId) { + pub(super) fn add_antecedent(&mut self, label: FlowNodeId, antecedent: FlowNodeId) { if self.flags[antecedent.index()].contains(FlowFlags::UNREACHABLE) { return; } @@ -851,7 +417,7 @@ impl<'a> FlowBuilder<'a> { /// (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. - fn finish_flow_label(&mut self, label: FlowNodeId) -> FlowNodeId { + 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() { [] => { @@ -2084,11 +1650,23 @@ impl<'a> FlowBuilder<'a> { } 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); + self.visit_class_common( + c.id.as_ref(), + c.decorators, + c.super_class, + c.body.body, + 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); + self.visit_class_common( + c.id.as_ref(), + c.decorators, + c.super_class, + c.body.body, + true, + ); } /// The value-flow class descent shared by the declaration and expression forms @@ -2579,7 +2157,7 @@ fn is_dotted_name(expr: &Expression<'_>) -> bool { /// `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`). -fn is_narrowable_reference(node: &Expression<'_>) -> bool { +pub(super) fn is_narrowable_reference(node: &Expression<'_>) -> bool { use Expression as E; match node { E::Identifier(_) | E::ThisExpression(_) | E::Super(_) | E::MetaProperty(_) => true, @@ -2818,1536 +2396,3 @@ fn skip_parens<'a, 'arena>(e: &'a Expression<'arena>) -> &'a Expression<'arena> } e } - -// --- 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('"', "\\\"") -} - -#[cfg(test)] -mod tests { - 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"); - } - - /// 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); - } - - #[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)); - } - - /// 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 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" - ); - } - } - } - - // --- 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 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 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); - } - - #[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"); - } - - // --- 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 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)); - } - - #[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/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.rs b/crates/tsv_check/src/binder/flow/tests.rs new file mode 100644 index 000000000..768fa3b21 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/tests.rs @@ -0,0 +1,1416 @@ +use super::build::{FlowBuilder, is_narrowable_reference}; +use super::*; +use crate::binder::{BoundFile, NodeKind, addr_of, bind_file}; +use crate::ids::FileId; +use bumpalo::Bump; +use tsv_ts::ast::internal::{Expression, Statement}; + +/// 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"); +} + +/// 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); +} + +#[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)); +} + +/// 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 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" + ); + } + } +} + +// --- 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 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 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); +} + +#[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"); +} + +// --- 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 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)); +} + +#[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" + ); +} From afa5bab9ba037e2fbab1890b8c0da7a61569b5fd Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 23:08:27 -0400 Subject: [PATCH 66/79] refactor: split tsc_conformance runner.rs into runner/ directory-module --- crates/tsv_debug/CLAUDE.md | 2 +- .../tsv_debug/src/tsc_conformance/runner.rs | 2243 ----------------- .../src/tsc_conformance/runner/filter.rs | 112 + .../src/tsc_conformance/runner/grade.rs | 1063 ++++++++ .../src/tsc_conformance/runner/mod.rs | 463 ++++ .../src/tsc_conformance/runner/report.rs | 425 ++++ .../src/tsc_conformance/runner/tests.rs | 127 + .../src/tsc_conformance/runner/watchdog.rs | 72 + 8 files changed, 2263 insertions(+), 2244 deletions(-) delete mode 100644 crates/tsv_debug/src/tsc_conformance/runner.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner/filter.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner/grade.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner/mod.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner/report.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner/tests.rs create mode 100644 crates/tsv_debug/src/tsc_conformance/runner/watchdog.rs diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index 2e857efa3..1993240f1 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -15,7 +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 / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. - `fixtures/` — The fixture workflow surface — `model.rs` holds the data model (`InputType`, `Fixture`, divergence-suffix rules), `discovery.rs` walks the fixtures tree, `variants.rs` discovers variant files, `mod.rs` keeps IO/parse helpers, `validation/` is the validator (`fixtures_validate`: structure rules, per-phase checks, errors, summary), `audit_signature.rs` pins prettier's multi-pass chain. - `test262/` — ECMAScript conformance runner: `discovery.rs` walks the test262 tree, `frontmatter.rs` parses the YAML harness metadata, `runner.rs` drives our parser (`run_test`) and grades the strict subset into the differential `Manifest` (`grade_for_manifest`, sharing the `classify` skip logic with `run_test`). Pure Rust — no Deno needed. The `test262 --emit-manifest` JSON feeds `benches/js/diagnostics/test262_compare.ts` for the tsv-vs-oxc-parser comparison. -- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `libs.rs` resolves each variant's lib set (targetToLibMap/GetDefaultLibFileName port, `@lib` override, transitive `/// ` expansion, priority order) and caches bound lib products + per-set `LibBase` folds; `runner.rs` drives the `tsv_check` crate over the in-scope corpus (`run` = the family-conformance sweep with catch_unwind containment + the parse-divergence census, triage filters that skip pins, `--emit-manifest`, the committed `--report`, and failure `.diff` artifacts under `target/tsc_conformance/diffs/`; `check_one` backs `check-test`, the one-test dev loop). `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. +- `tsc_conformance/` — the tsgo typechecker-conformance harness (pure Rust, no Deno; see the root CLAUDE.md §tsgo Typechecker-Conformance Harness for the command surface). Two sides: the **baseline** side — `discovery.rs` walks tsgo's checked-in `.errors.txt`, `baseline.rs` parses them (`ParsedBaseline`), `render.rs` re-renders byte-identically (the emit seam a future tsv checker uses), `pretty.rs` is the ANSI `pretty=true` model/parser/colored renderer (UTF-16 units, distinct from the plain path's runes), `roundtrip.rs` proves parse→render byte-identity; and the **corpus-input** side porting the tsgo harness — `corpus.rs` walks `tests/cases/{compiler,conformance}` (BOM/UTF-16 decode), `directives.rs` parses `// @` directives + splits `@filename` units, `options_meta.rs` is the ported option-declarations table (varyBy derivation, skip lists, tri-state, harness-forced defaults), `variants.rs` expands varyBy variants + synthesizes baseline filenames, `index.rs` runs the join/unit-text/denominator self-check gates; and the **checker leg** — `libs.rs` resolves each variant's lib set (targetToLibMap/GetDefaultLibFileName port, `@lib` override, transitive `/// ` expansion, priority order) and caches bound lib products + per-set `LibBase` folds; `runner/` drives the `tsv_check` crate over the in-scope corpus, a directory module by concern: `mod.rs` holds the cross-cutting shared types (the graded-family table, `MissingCause`) and the three CLI entry points — `run_skeleton` = the family-conformance sweep with catch_unwind containment + the parse-divergence census, triage filters that skip pins, `--emit-manifest`, the committed `--report`, and failure `.diff` artifacts under `target/tsc_conformance/diffs/`; `check_one` backs `check-test`, the one-test dev loop; `dump_flow_dot` backs `--dump-flow` — while `filter.rs` holds `RunFilter`/`FamilyFilter`, `grade.rs` the grading/diffing kernel (`run_skeleton_inner`, `grade_test`, `grade_family`, …), `report.rs` the `SkeletonReport`/`CheckTestReport` structs + their printers, `watchdog.rs` the hang watchdog, and `tests.rs` the unit tests. `tsv_debug` depends on `tsv_check` (its only consumer — the zero-cost invariant). Exact two-sided pins live in `cli/commands/tsc_conformance.rs`. - `diff.rs` — Colored text / JSON diffing with line-width annotations (used by `compare`, `ast_diff`, validation failures). - `error.rs` — `DebugError` — wraps `DenoError`, `io::Error`, `serde_json::Error`; exposes `hint()` for actionable messages. - `cli/` — argh `TopLevel`/`Subcommand` dispatch in `mod.rs` + per-command `FromArgs` modules under `cli/commands/`. Shared three-mode input plumbing (file path / `--content` / `--stdin`) comes from `tsv_cli::cli::input::InputArgs`. diff --git a/crates/tsv_debug/src/tsc_conformance/runner.rs b/crates/tsv_debug/src/tsc_conformance/runner.rs deleted file mode 100644 index 5b21ce7df..000000000 --- a/crates/tsv_debug/src/tsc_conformance/runner.rs +++ /dev/null @@ -1,2243 +0,0 @@ -//! The conformance runner: drive `tsv_check` over the in-scope corpus and -//! grade it against tsgo's committed `.errors.txt` baselines. -//! -//! The runner layers the checker leg on the corpus substrate — corpus index, -//! directive parser, variant expansion, the unsupported-option skip classes: -//! for every **in-scope** variant (single-file, non-JSX, non-JS-flavored, not -//! skipped, not an unsupported-option variant) it parses the unit via -//! `tsv_check`'s goal rule, binds, merges against the variant's lib base, and -//! grades the result on three channels. Every **expect-clean** in-scope -//! variant (one with no on-disk baseline) must grade clean (zero diagnostics); -//! the graded **family** ([`FAMILY_CODES`] — the bind/merge duplicate-conflict -//! sub-family [`DUP_CODES`] plus the flow-construction sub-family [`FLOW_CODES`], -//! TS7027/TS7028) is compared as codes+spans multisets — extra = 0 is the hard -//! gate, missing is classified by deferred cause (merge / lib / late-bound / cfa, -//! else the hard-zero `other`); and **related-info** on matched family primaries -//! is graded as its own channel. Zero panics, always. -//! -//! A single-file test's variants all parse identically (the goal rule is -//! directive-independent), so parse+bind runs **once per test** -//! (`bind_program`) and merge+check runs once per distinct lib set among its -//! variants (`check_bound`), with the outcome attributed to each in-scope -//! variant. -//! -//! The **parse-divergence census** (informational, not gated) counts in-scope -//! variants tsv parse-rejects, split by baseline shape (none / TS1xxx-only / -//! other), plus how many parses needed the `Goal::Script` retry — the standing -//! window on tsv's parser vs tsgo's implied parse verdict (a tsv over-rejection -//! shows up as a rejected variant against an absent-or-non-1xxx baseline). -//! -//! Crash containment: the whole sweep runs on a generous-stack worker thread -//! (the corpus has pathological-nesting tests and tsv's parser has no depth -//! guard), and each test's check is wrapped in `catch_unwind` so a panic lands -//! in its own bucket instead of killing the run. A stack-overflow *abort* can't -//! be caught; the [`CRASH_EXCLUSIONS`] list carves out crashers by kind — the -//! genuine-abort class is empty on the pinned corpus (every current entry is a -//! catchable panic tracking a tsv parser bug, liveness-probed each run). -// -// tsgo: internal/compiler/program.go GetDiagnosticsOfAnyProgram (the pipeline) -// tsgo: internal/testrunner/compiler_runner.go (the in-scope selection) - -use crate::tsc_conformance::baseline::{parse_baseline, parse_summary_block}; -use crate::tsc_conformance::corpus::{CorpusTest, discover_corpus, read_corpus_file}; -use crate::tsc_conformance::directives::{Unit, extract_settings, split_units}; -use crate::tsc_conformance::discovery::{Baseline, baselines_dir, discover_baselines}; -use crate::tsc_conformance::index::{is_js_flavored, is_jsx_scoped}; -use crate::tsc_conformance::libs::LibResolver; -use crate::tsc_conformance::options_meta::{ - SKIPPED_TESTS, is_config_file_name, variant_is_unsupported, -}; -use crate::tsc_conformance::variants::{Variant, config_name, expand}; -use bumpalo::Bump; -use std::collections::{BTreeMap, HashMap}; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::path::Path; -use std::time::Instant; -use tsv_check::{ - CheckOptions, Diagnostic, FileId, ParseReport, SourceUnit, bind_file, bind_program, build_flow, - check_bound, check_program, render_flow_dot, -}; -use tsv_lang::{LocationMapper, LocationTracker}; - -/// The full set of codes the gate grades — the bind/merge duplicate-conflict -/// family ([`DUP_CODES`]) plus the flow-construction family ([`FLOW_CODES`]). -/// Duplicate-conflict: TS2300 (duplicate identifier), TS2451 (block-scoped -/// redeclare), TS2567 (enum-merge), TS2528 (multiple default exports), plus the -/// merge-path codes TS2397/2649/2664/2671 (emitted from the globals-merge phase -/// rather than the same-table cascade). Flow: TS7027 (unreachable code), TS7028 -/// (unused label), emitted from the post-bind flow-construction shim. -const FAMILY_CODES: [u32; 10] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671, 7027, 7028]; - -/// The duplicate/conflict sub-family (bind + merge + the check-time TS2300 -/// members/type-parameters pass) — the partition used for the `--family dup` -/// filter and the sub-family report lines. -const DUP_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; - -/// The flow-construction sub-family (TS7027 unreachable code / TS7028 unused -/// label) — the partition used for the `--family flow` filter and the sub-family -/// report lines. -const FLOW_CODES: [u32; 2] = [7027, 7028]; - -/// One graded code family: its `--family` filter token (also its report label) -/// and its code set. **Adding a family** = a codes const + a row here + a row in -/// the CLI command's per-family pin table (+ a [`MissingCause`] variant and -/// ledger if it brings a new deferred cause) — the sweep, filter parsing, -/// sub-family accessors, and report lines all read this table. -pub struct GradedFamily { - /// The `--family` filter token / report label. - pub key: &'static str, - /// The family's TS codes. - pub codes: &'static [u32], -} - -/// The graded families, in report order. -pub const FAMILIES: [GradedFamily; 2] = [ - GradedFamily { - key: "dup", - codes: &DUP_CODES, - }, - GradedFamily { - key: "flow", - codes: &FLOW_CODES, - }, -]; - -// `FAMILY_CODES` is maintained by hand as the union of every `FAMILIES` row — -// pin the agreement at compile time (order-preserving concatenation, dup then -// flow), so a family edit that forgets one side cannot build. -const _: () = { - assert!(FAMILY_CODES.len() == DUP_CODES.len() + FLOW_CODES.len()); - let mut i = 0; - while i < DUP_CODES.len() { - assert!(FAMILY_CODES[i] == DUP_CODES[i]); - i += 1; - } - let mut j = 0; - while j < FLOW_CODES.len() { - assert!(FAMILY_CODES[DUP_CODES.len() + j] == FLOW_CODES[j]); - j += 1; - } -}; - -/// The merge-path family codes — a *missing* of one of these is classified as a -/// merge-phase gap, not a same-table cascade bug. -const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; - -/// The TS1xxx codes the binder itself emits (strict-mode + private-identifier -/// checks) — they prove nothing about parse state, so a baseline carrying only -/// these does not trigger the recovery-AST carve-out (predicate v1, rule a). -const BIND_EMITTED_TS1XXX: [u32; 12] = [ - 1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012, -]; - -/// The family baselines whose family diagnostics come from a standard-library -/// conflict. These **match** via the lib base; the classifier is kept as a -/// regression guard — a *missing* in one of these is bucketed to -/// [`MissingCause::Lib`] (pinned 0) rather than the hard-zero -/// [`MissingCause::Other`], so a lib-detection regression fails loudly. -const LIB_CONFLICT_BASELINES: [&str; 5] = [ - "intersectionsOfLargeUnions2.ts", - "jsExportMemberMergedWithModuleAugmentation2.ts", - "promiseDefinitionTest.ts", - "recursiveComplicatedClasses.ts", - "variableDeclarationInStrictMode1.ts", -]; - -/// The family baselines whose remaining missing family diagnostics are genuinely -/// deferred: they need the type engine (a literal-type or `unique symbol` computed -/// member name, resolved via tsgo's `lateBindMember`) that this bind+check -/// implementation has no counterpart for. A *missing* in one of these is bucketed to -/// [`MissingCause::DeferredLateBound`] (exact-pinned) rather than the hard-zero -/// [`MissingCause::Other`], so the honest residual stays visible without gating a -/// release on a type-engine gap. Basenames, mirroring `LIB_CONFLICT_BASELINES`. -const LATE_BOUND_BASELINES: [&str; 4] = [ - "dynamicNamesErrors.ts", - "symbolDeclarationEmit12.ts", - "symbolProperty37.ts", - "symbolProperty44.ts", -]; - -/// The flow-family baselines whose remaining missing TS7027 diagnostics are -/// genuinely deferred to the CFA type engine: the construction-only fast-path -/// shim (a strict subset of what tsgo reports) can't resolve them because they -/// come from tsgo's `isReachableFlowNode` fallback — never-returning call -/// signatures, `asserts` type predicates, switch exhaustiveness, and the -/// structural reachability fallback. A *missing* in one of these is bucketed to -/// [`MissingCause::DeferredCfa`] (exact-pinned) rather than the hard-zero -/// [`MissingCause::Other`], so the honest CFA residual stays visible without gating -/// a release on a type-engine gap. Basenames, mirroring `LATE_BOUND_BASELINES`. -/// -/// `assertionTypePredicates1.ts` never reaches the cfa bucket — its entry is a -/// defensive no-op kept for completeness. tsv currently **parse-rejects** it (an -/// over-rejection of the setter assertion-predicate form `set p(x: this is T)`, -/// counted in the parse-divergence census), so it is graded not at all; and were -/// the parser fixed, its baseline's non-bind TS1xxx (TS1228) would carve it out by -/// predicate v1 rule (a) instead — either way its 5 flow instances never land in -/// the cfa bucket. The live cfa residual is **26**: neverReturningFunctions1 -/// 22, exhaustiveSwitchStatements1 1, unreachableSwitchTypeofAny 1, -/// unreachableSwitchTypeofUnknown 1 (**25** across the four non-carved named -/// baselines), plus reachabilityChecks8 1 (the structural `isReachableFlowNode` -/// fallback tsv's faithful for-construction correctly doesn't emit). -const DEFERRED_CFA_BASELINES: [&str; 6] = [ - "neverReturningFunctions1.ts", - "assertionTypePredicates1.ts", - "exhaustiveSwitchStatements1.ts", - "unreachableSwitchTypeofAny.ts", - "unreachableSwitchTypeofUnknown.ts", - "reachabilityChecks8.ts", -]; - -/// Why a graded family baseline diagnostic is missing — the classifier's -/// verdict, tallied keyed in `SkeletonReport::missing_by_cause`. Every non- -/// [`MissingCause::Other`] cause is exact-pinned in the CLI command's cause-pin -/// table; `Other` is the HARD-zero invariant (enforced even on filtered runs). -/// **Adding a deferred cause** (a new family's type-engine residual) = a variant -/// here + a ledger const + an arm in [`classify_missing`] + a pin-table row. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MissingCause { - /// The merge phase owns the code ([`MERGE_CODES`]). - Merge, - /// A [`LIB_CONFLICT_BASELINES`] test missing a dup-family code (absent lib - /// binding — a lib-detection regression guard, pinned 0). - Lib, - /// A [`LATE_BOUND_BASELINES`] test missing a dup-family code — the - /// type-engine `lateBindMember` residual (exact-pinned). - DeferredLateBound, - /// A [`DEFERRED_CFA_BASELINES`] test missing a flow-family code — the - /// `isReachableFlowNode` residual (exact-pinned). - DeferredCfa, - /// Unclassified — a same-table cascade / flow-construction bug. **HARD - /// gate: zero**, an invariant even on filtered triage runs. - Other, -} - -/// Classify one missing family diagnostic by its baseline + code. Each -/// name-keyed cause also requires the code to be in its family (dup for -/// lib/late-bound, flow for cfa), mirroring the merge branch — a wrong-family -/// missing inside a named baseline falls through to the hard-zero `Other` -/// instead of being silently absorbed. This keeps the classification honest on -/// filtered/triage runs, not only on a full run where the code-keyed pins -/// catch it. -fn classify_missing(basename: &str, code: u32) -> MissingCause { - if MERGE_CODES.contains(&code) { - MissingCause::Merge - } else if LIB_CONFLICT_BASELINES.contains(&basename) && DUP_CODES.contains(&code) { - MissingCause::Lib - } else if LATE_BOUND_BASELINES.contains(&basename) && DUP_CODES.contains(&code) { - MissingCause::DeferredLateBound - } else if DEFERRED_CFA_BASELINES.contains(&basename) && FLOW_CODES.contains(&code) { - MissingCause::DeferredCfa - } else { - MissingCause::Other - } -} - -/// Worker-thread stack for the sweep: the corpus has deeply-nested tests and -/// tsv's recursive-descent parser has no depth guard, so the default 8 MiB -/// overflows. 512 MiB is virtual-only reserve on Linux. -const SKELETON_STACK: usize = 512 * 1024 * 1024; - -/// Per-test wall-clock budget for the [`TestWatchdog`]. The full ~12k-test -/// sweep runs in seconds, so a single test at 60 s is pathological with huge -/// margin (~10⁴× the mean) — the limit exists to convert a *hang* into a loud -/// named failure, not to police slow tests. -const WATCHDOG_LIMIT: std::time::Duration = std::time::Duration::from_secs(60); - -/// The sweep's hang watchdog — the wall-clock half of the "watchdog -/// independent of ported budgets" requirement. `catch_unwind` converts panics -/// into per-test buckets, but a **hang** (a mis-ported budget at P3, a parser -/// loop) would otherwise freeze the gate silently. The worker heartbeats each -/// test's name + start; a monitor thread checks ~1 Hz and, past -/// [`WATCHDOG_LIMIT`], prints the offending test and exits the process (a hung -/// thread cannot be killed safely — a loud named exit is the correct failure). -/// The instruction-count half (budget-arithmetic cross-check) rides P3 with -/// the budgets themselves. -struct TestWatchdog { - /// `(current test relative_path, its start)`; `None` after `finish`. - current: std::sync::Arc>>, -} - -impl TestWatchdog { - fn spawn() -> TestWatchdog { - let current: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let monitor = std::sync::Arc::clone(¤t); - // Detached monitor: exits within a tick of the sweep clearing the slot - // (or dies with the process — it holds nothing that needs cleanup). - drop( - std::thread::Builder::new() - .name("tsc-watchdog".to_string()) - .spawn(move || { - loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - let guard = monitor.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let Some((test, start)) = guard.as_ref() else { - return; // sweep finished - }; - if start.elapsed() > WATCHDOG_LIMIT { - eprintln!( - "tsc_conformance watchdog: test {test:?} exceeded {}s — a hang \ - (mis-ported budget / parser loop); aborting the run", - WATCHDOG_LIMIT.as_secs() - ); - std::process::exit(3); - } - } - }), - ); - TestWatchdog { current } - } - - /// Heartbeat: the sweep is entering `test` now. - fn enter(&self, test: &str) { - *self - .current - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - Some((test.to_string(), Instant::now())); - } - - /// The sweep is done — clear the slot so the monitor thread exits. - fn finish(&self) { - *self - .current - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - } -} - -/// How a crash-excluded test fails, and whether its liveness is probeable. -// `GenuineAbort` is the designed flag for a future stack-overflow entry (none on -// the pinned corpus); it is un-probeable, so it is never re-run. -#[derive(Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -enum CrashKind { - /// A debug-build `debug_assert!` panic `catch_unwind` contains — probeable: - /// the sweep re-runs it under `catch_unwind` and FAILS if it no longer - /// panics (a fix landed, so the entry is stale and must be dropped). - CatchablePanic, - /// An uncatchable stack-overflow *abort* even on [`SKELETON_STACK`] — not - /// probeable (probing would abort the whole run), so it is trusted, not tested. - GenuineAbort, -} - -/// Tests that crash the tsv parser — carved out by basename, counted, and -/// reported (never silently). Each entry names its cause + kind; the list is a -/// tracked-defect ledger, not a way to hide bugs. A [`CrashKind::CatchablePanic`] -/// entry is liveness-probed every run (see [`probe_crash_exclusion`]). -const CRASH_EXCLUSIONS: &[(&str, CrashKind)] = &[ - // tsv_ts robustness bug: `export * from ;` (a non-string module - // specifier) trips a `debug_assert!(TokenKind::String)` in - // `parse_string_literal` (parser/mod.rs). Dev-profile only (debug_assert is - // compiled out in release), so `cargo run` — the gate's profile — panics. - // A future tsv_ts fix should reject the form gracefully; then drop this entry. - ( - "exportDeclarationInInternalModule.ts", - CrashKind::CatchablePanic, - ), -]; - -/// The [`CrashKind`] of a crash-excluded test, or `None` if not excluded. -fn crash_exclusion_kind(basename: &str) -> Option { - CRASH_EXCLUSIONS - .iter() - .find(|(n, _)| *n == basename) - .map(|(_, k)| *k) -} - -/// One expect-clean variant that graded non-clean (should never happen while the -/// checker is a no-op — a non-empty list is a gate failure). -#[derive(Debug, Clone, serde::Serialize)] -pub struct CleanFail { - /// The `suite/config_name` baseline-space identity. - pub variant: String, - /// The number of diagnostics the checker (wrongly) emitted. - pub diagnostics: usize, -} - -/// One test whose check panicked (caught) — a gate failure. -#[derive(Debug, Clone, serde::Serialize)] -pub struct PanicRecord { - /// The corpus test's relative path. - pub test: String, - /// The panic payload's message (downcast to `&str`/`String`), for triage. - pub payload: String, -} - -/// Which graded sub-family the `--family` filter isolates. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum FamilyFilter { - /// One [`FAMILIES`] row, by index (`dup` = 0, `flow` = 1). - One(usize), - /// The whole graded family ([`FAMILY_CODES`]) — isolates the family-graded slice. - All, -} - -impl FamilyFilter { - /// Parse a `--family` token: a [`FAMILIES`] key, or `all`. - #[must_use] - pub fn parse(arg: &str) -> Option { - if arg == "all" { - return Some(FamilyFilter::All); - } - FAMILIES - .iter() - .position(|f| f.key == arg) - .map(FamilyFilter::One) - } - - /// The valid `--family` tokens, for error messages (`dup / flow / all`). - #[must_use] - pub fn tokens() -> String { - let mut tokens: Vec<&str> = FAMILIES.iter().map(|f| f.key).collect(); - tokens.push("all"); - tokens.join(" / ") - } -} - -/// The code set a [`FamilyFilter`] keeps a variant for (its baseline must carry at -/// least one). -fn family_filter_codes(f: FamilyFilter) -> &'static [u32] { - match f { - FamilyFilter::One(i) => FAMILIES[i].codes, - FamilyFilter::All => &FAMILY_CODES, - } -} - -/// Filters for a scoped `run` sweep. Any active filter SKIPS the exact pins (the -/// `roundtrip`/`query` convention), so a filtered run is a triage view — the -/// invariant gates (clean grading, no panics, `family_extra == 0`) still hold. -#[derive(Default, Clone)] -pub struct RunFilter { - /// Keep only tests whose relative path contains this substring. - pub test: Option, - /// Keep only variants whose joined baseline carries this TS code. - pub code: Option, - /// Keep only variants whose config has this `key=value` (key lowercased). - pub variant: Option<(String, String)>, - /// Keep only variants whose baseline carries a code in this sub-family - /// (`dup` / `flow` / `all`). - pub family: Option, -} - -impl RunFilter { - /// Whether any filter is active (drives pin skipping). - #[must_use] - pub fn is_active(&self) -> bool { - self.test.is_some() - || self.code.is_some() - || self.variant.is_some() - || self.family.is_some() - } - - /// Whether a test passes the `--test` substring filter (absent filter ⇒ keep). - fn keeps_test(&self, relative_path: &str) -> bool { - self.test - .as_deref() - .is_none_or(|sub| relative_path.contains(sub)) - } - - /// Whether a variant passes the `--variant key=value` filter (absent ⇒ keep). - /// The key is already lowercased (the config maps store lowercased keys). - fn keeps_variant(&self, config: &BTreeMap) -> bool { - self.variant - .as_ref() - .is_none_or(|(k, v)| config.get(k).map(String::as_str) == Some(v.as_str())) - } - - /// Whether a variant passes the `--code` filter. `baseline_carries` reports - /// whether the variant's baseline carries a given code; it is consulted only - /// when the filter is active, so a run without `--code` never reads a baseline - /// on its behalf. Absent filter ⇒ keep. - fn keeps_code(&self, baseline_carries: impl FnOnce(u32) -> bool) -> bool { - self.code.is_none_or(baseline_carries) - } - - /// Whether a variant passes the `--family` filter: its baseline must carry at - /// least one code in the selected sub-family (an expect-clean variant carries - /// none, so it is filtered out). `baseline_carries` is consulted only when the - /// filter is active. Absent filter ⇒ keep. - fn keeps_family(&self, baseline_carries: impl Fn(u32) -> bool) -> bool { - self.family.is_none_or(|f| { - family_filter_codes(f) - .iter() - .any(|&code| baseline_carries(code)) - }) - } -} - -/// Options for the skeleton sweep. -#[derive(Default, Clone)] -pub struct RunOptions { - /// The triage filter (empty = full pinned run). - pub filter: RunFilter, - /// Collect the per-variant verdict rows (for `--emit-manifest`). - pub collect_manifest: bool, -} - -/// One graded variant's verdict for the `--emit-manifest` JSON (the per-variant -/// row — the test262-manifest analog). Collected only when a manifest is requested. -#[derive(Debug, Clone, serde::Serialize)] -pub struct ManifestEntry { - /// The suite (`compiler` / `conformance`). - pub suite: String, - /// The corpus test's relative path. - pub test: String, - /// The joined baseline name (the variant identity). - pub config: String, - /// Whether the variant has an on-disk baseline. - pub baselined: bool, - /// Whether tsv parsed the unit (`false` = parse-rejected). - pub parsed: bool, - /// The per-variant verdict (see [`grade_test`] / [`grade_family`]). - pub verdict: &'static str, -} - -/// One failing variant with a pre-rendered ours-vs-baseline diff — written to a -/// `.diff` artifact when a run's gates fail (a regression aid; empty when green). -#[derive(Debug, Clone, serde::Serialize)] -pub struct FailingVariant { - /// The suite (`compiler` / `conformance`). - pub suite: String, - /// The joined baseline name (the artifact basename). - pub config: String, - /// Why it failed — the same vocabulary as the per-variant verdict - /// (`family_extra` / `family_span_mismatch` / `clean_fail` / `panic`). - pub reason: &'static str, - /// The rendered ours-vs-baseline text (file-artifact only — not in `--json`). - #[serde(skip)] - pub diff: String, -} - -/// The skeleton sweep report. -#[derive(Debug, Clone, serde::Serialize, Default)] -pub struct SkeletonReport { - /// Tests that passed the test-level in-scope filter and have >=1 in-scope - /// variant. - pub in_scope_tests: usize, - /// In-scope variants graded (parsed or parse-rejected). - pub in_scope_variants: usize, - /// In-scope variants that parsed and have no on-disk baseline (expect-clean). - pub expect_clean_graded: usize, - /// Expect-clean variants that graded clean (zero diagnostics). Gate: must - /// equal `expect_clean_graded`. - pub clean_pass: usize, - /// Expect-clean variants that graded non-clean (gate failure list). - pub clean_fail: Vec, - /// In-scope variants that parsed and DO have a baseline. - pub baselined_parsed: usize, - - // --- family grading --- - /// Parsed-with-baseline variants family-graded (not carved by predicate v1). - pub family_graded_variants: usize, - /// ...of those, whose baseline carries at least one family code. - pub family_positive_variants: usize, - /// Family diagnostics that matched (file, line, col, code). - pub family_match: usize, - /// Family baseline diagnostics with no matching diagnostic of ours (classified - /// below). Expected to be all merge/lib until S4/S5 land. - pub family_missing: usize, - /// Family diagnostics we emit that the baseline lacks. **Gate: must be 0.** - pub family_extra: usize, - /// Right code + file, wrong position (greedy-paired). - pub family_span_mismatch: usize, - - // --- related-info grading (its own pinned channel; does NOT gate the - // per-variant primary verdict) — graded only for matched primaries --- - /// Related-info entries that matched (code, file, line, col). - pub related_match: usize, - /// Baseline related entries with no matching related of ours. - pub related_missing: usize, - /// Related entries we emit the baseline lacks. - pub related_extra: usize, - /// Right code + file, wrong position (greedy-paired). - pub related_span_mismatch: usize, - /// Sample related over-emissions. - pub related_extra_samples: Vec, - /// Sample related misses. - pub related_missing_samples: Vec, - - /// ...missing, tallied by classified cause (see [`MissingCause`]; keyed so - /// new causes are a variant + a pin row, not a new field). Read via - /// [`SkeletonReport::missing`]. [`MissingCause::Other`] **gates at 0**. - pub missing_by_cause: BTreeMap, - /// Variants carved out by predicate v1 rule (a): tsv parses clean but the - /// baseline carries a non-bind TS1xxx code (recovery-AST incomparability). - pub carve_out_rule_a: usize, - /// ...of those, whose baseline also carries a family code. - pub carve_out_rule_a_family: usize, - /// In-scope variants that set `moduleDetection` (a watch item — module-ness is - /// inert for the family cascade, so the parse-once shortcut stays valid). - pub module_detection_variants: usize, - /// Sample extra diagnostics (gate failures to fix). - pub extra_samples: Vec, - /// Sample unattributed misses (candidate cascade bugs). - pub missing_other_samples: Vec, - /// Sample span mismatches. - pub span_mismatch_samples: Vec, - /// In-scope variants tsv parse-rejected (census; informational). - pub parse_rejected_total: usize, - /// ...of those, with no on-disk baseline (a likely tsv over-rejection). - pub parse_rejected_no_baseline: usize, - /// ...with a TS1xxx-only baseline (ambiguous: tsgo parse error or grammar). - pub parse_rejected_ts1xxx_only: usize, - /// ...with a baseline carrying non-TS1xxx codes (tsv rejects what tsgo checked). - pub parse_rejected_other: usize, - /// In-scope parsed variants that needed the `Goal::Script` retry (census). - pub script_retry: usize, - /// Tests whose check panicked (caught) and are NOT crash-excluded. Gate: - /// must be empty. - pub panics: Vec, - /// Tests skipped by the crash-exclusion ledger (tracked parser aborts/panics). - pub excluded_crashes: usize, - - // --- lib base --- - /// Distinct lib `.d.ts` files parsed + bound this run (informational). - pub lib_files_bound: usize, - /// Distinct resolved lib sets folded into a base this run (informational). - pub lib_sets_built: usize, - /// Lib files that failed to parse (`file: error`). **Gate: must be empty.** - pub lib_parse_errors: Vec, - /// Referenced lib files not found on disk. **Gate: must be empty.** - pub lib_missing_files: Vec, - /// Unrecognized `@lib` / `/// ` names. **Gate: must be empty.** - pub lib_unknown_names: Vec, - /// Lib files that bound as an external module with no `declare global {}` block — - /// their globals would silently fold to nothing. **Gate: must be empty.** - pub lib_external_no_globals: Vec, - /// Catchable-panic exclusions that no longer panic (a fix landed) — the entry - /// is stale and must be dropped. **Gate: must be empty.** - pub stale_exclusions: Vec, - /// Total bound nodes across in-scope tests (informational). - pub total_nodes: u64, - /// Wall-clock of the sweep in milliseconds (EXCLUDED from the committed report — - /// machine-varying). - pub wall_ms: u128, - - // --- deterministic per-code breakdown (the committed report's per-code table) --- - /// Family diagnostics that matched, keyed by TS code (sorted for determinism). - pub family_match_by_code: BTreeMap, - /// Family baseline diagnostics with no match, keyed by TS code (sorted). - pub family_missing_by_code: BTreeMap, - - // --- optional artifacts (empty on a normal green run) --- - /// Per-variant verdict rows for `--emit-manifest` (empty unless requested). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub manifest_entries: Vec, - /// Failing variants with a pre-rendered diff — written to `.diff` artifacts when - /// the gates fail (empty when green). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failing_variants: Vec, -} - -/// The baseline shape used to bucket a parse-rejected variant. -enum BaselineShape { - None, - Ts1xxxOnly, - Other, -} - -/// Run the skeleton sweep on a generous-stack worker thread. -/// -/// # Errors -/// -/// Returns an error string if the worker cannot spawn, the worker panics -/// outside a contained per-test check, or corpus discovery fails. -pub fn run_skeleton(checkout: &Path, options: &RunOptions) -> Result { - let checkout = checkout.to_path_buf(); - let options = options.clone(); - let handle = std::thread::Builder::new() - .stack_size(SKELETON_STACK) - .name("tsc-skeleton".to_string()) - .spawn(move || run_skeleton_inner(&checkout, &options)) - .map_err(|e| format!("spawn skeleton worker: {e}"))?; - handle - .join() - .map_err(|_| "skeleton worker panicked".to_string())? -} - -fn run_skeleton_inner(checkout: &Path, options: &RunOptions) -> Result { - let start = Instant::now(); - let corpus = discover_corpus(checkout)?; - let baselines = discover_baselines(&baselines_dir(checkout))?; - - // Baseline lookup keyed by (suite, config-name) — exactly the runner's join. - let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); - for baseline in &baselines { - if let Some((suite, name)) = baseline.relative_path.split_once('/') { - ondisk.insert((suite, name.to_string()), baseline); - } - } - - let mut report = SkeletonReport::default(); - let mut resolver = LibResolver::new(checkout); - let watchdog = TestWatchdog::spawn(); - - for test in &corpus { - // Test-level triage filter (`--test `): match the roundtrip identity. - if !options.filter.keeps_test(&test.relative_path) { - continue; - } - watchdog.enter(&test.relative_path); - if SKIPPED_TESTS.contains(&test.basename.as_str()) { - continue; - } - if let Some(kind) = crash_exclusion_kind(&test.basename) { - report.excluded_crashes += 1; - // Liveness probe: a catchable-panic entry must still panic; if it no - // longer does, the ledger entry is stale and the run fails. - if kind == CrashKind::CatchablePanic && !probe_crash_exclusion(test) { - report.stale_exclusions.push(test.basename.clone()); - } - continue; - } - - let content = read_corpus_file(&test.path)?; - let settings = extract_settings(&content); - let units = split_units(&content, &test.basename); - - // Test-level in-scope filter: single-file (one non-config unit), not - // JSX-scoped, not JS-flavored. - if units.len() != 1 || is_config_file_name(&units[0].name) { - continue; - } - if is_jsx_scoped(test, &settings) || is_js_flavored(test, &settings) { - continue; - } - - let expansion = expand(&settings); - if expansion.cap_exceeded { - continue; - } - let in_scope: Vec<&Variant> = expansion - .variants - .iter() - .filter(|v| !variant_is_unsupported(&v.config)) - .collect(); - if in_scope.is_empty() { - continue; - } - - report.in_scope_tests += 1; - grade_test( - test, - &units[0], - &in_scope, - &ondisk, - &mut resolver, - options, - &mut report, - ); - } - watchdog.finish(); - - // Fold in the resolver's lib-base census (parse-once/fold-once counts + gates). - report.lib_files_bound = resolver.files_bound(); - report.lib_sets_built = resolver.sets_built(); - report.lib_parse_errors = { - let mut errors: Vec = resolver - .parse_errors() - .iter() - .map(|(f, e)| format!("{f}: {e}")) - .collect(); - errors.sort_unstable(); - errors - }; - report.lib_missing_files = { - let mut files: Vec = resolver.missing_files().to_vec(); - files.sort_unstable(); - files - }; - report.lib_unknown_names = { - let mut names: Vec = resolver.unknown_libs().to_vec(); - names.sort_unstable(); - names.dedup(); - names - }; - report.lib_external_no_globals = resolver.external_no_globals(); - - report.wall_ms = start.elapsed().as_millis(); - Ok(report) -} - -/// Re-run a catchable-panic crash exclusion under `catch_unwind`, returning -/// whether it **still panics**. A `false` (it completed) means the tracked defect -/// is fixed and the ledger entry is stale. -fn probe_crash_exclusion(test: &CorpusTest) -> bool { - let Ok(content) = read_corpus_file(&test.path) else { - // Can't read it -> can't disprove the panic; treat as still-live. - return true; - }; - let units = split_units(&content, &test.basename); - let arena = Bump::new(); - let source_units: Vec> = units - .iter() - .map(|u| SourceUnit::new(&u.name, &u.content)) - .collect(); - // Silence the default panic hook for the deliberate probe (we expect it to - // panic; the message would otherwise leak to stderr and read as a failure). - let prev = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); - let panicked = catch_unwind(AssertUnwindSafe(|| { - let _ = check_program(&source_units, &arena, &CheckOptions::default()); - })) - .is_err(); - std::panic::set_hook(prev); - panicked -} - -/// Extract a caught panic payload's message (the `&str` / `String` cases the -/// standard panic machinery produces). -fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { - if let Some(s) = payload.downcast_ref::<&str>() { - (*s).to_string() - } else if let Some(s) = payload.downcast_ref::() { - s.clone() - } else { - "(non-string panic payload)".to_string() - } -} - -/// Parse+bind one single-file test once, then — per in-scope variant — merge it -/// against that variant's resolved lib base and grade the result. Parse+bind is -/// variant-independent; only the merge (and thus the lib-conflict family) varies by -/// the resolved lib set, so a variant with a Promise/Symbol/… global conflicts at -/// one target and is clean at another. -/// Build `tsv_check`'s options from a variant's resolved directive config, mapping -/// the harness tri-state to the checker's. `preserveConstEnums` feeds -/// `ShouldPreserveConstEnums` (the `isolatedModules` contribution is not modeled — -/// a rare-in-dead-code residual that can only under-report). -fn check_options_for(config: &BTreeMap) -> CheckOptions { - use crate::tsc_conformance::options_meta::{Tristate as OptTri, resolve_bool}; - let map = |t: OptTri| match t { - OptTri::Unset => tsv_check::Tristate::Unknown, - OptTri::False => tsv_check::Tristate::False, - OptTri::True => tsv_check::Tristate::True, - }; - CheckOptions { - allow_unreachable_code: map(resolve_bool(config, "allowunreachablecode")), - allow_unused_labels: map(resolve_bool(config, "allowunusedlabels")), - preserve_const_enums: resolve_bool(config, "preserveconstenums") == OptTri::True, - } -} - -fn grade_test( - test: &CorpusTest, - unit: &Unit, - in_scope: &[&Variant], - ondisk: &HashMap<(&str, String), &Baseline>, - resolver: &mut LibResolver, - options: &RunOptions, - report: &mut SkeletonReport, -) { - // Parse + bind on a fresh arena, contained against panics (the tsv parser is the - // panic source; the merge over owned data that follows is deterministic). - let arena = Bump::new(); - let bound = match catch_unwind(AssertUnwindSafe(|| { - bind_program(&[SourceUnit::new(&unit.name, &unit.content)], &arena) - })) { - Ok(bound) => bound, - Err(payload) => { - report.panics.push(PanicRecord { - test: test.relative_path.clone(), - payload: panic_payload_message(&*payload), - }); - return; - } - }; - - // The single unit's parse outcome (parse_reports is never empty for one input). - let reports = bound.parse_reports(); - let Some(&(_, parse)) = reports.first() else { - return; - }; - let parsed = matches!(parse, ParseReport::Parsed(_)); - - // The unit's line map — reused across the test's variants for the parsed case. - let line_map = parsed.then(|| LocationTracker::new_ecmascript_with_map(&unit.content)); - - for variant in in_scope { - let name = config_name(&test.basename, &variant.description); - let baseline = ondisk.get(&(test.suite, name.clone())).copied(); - - // Variant-level triage filters, applied BEFORE counting so a filtered sweep's - // denominators reflect only the graded slice (any active filter skips the pins). - if !options.filter.keeps_variant(&variant.config) { - continue; - } - if !options - .filter - .keeps_code(|code| baseline.is_some_and(|b| baseline_carries_code(b, code))) - { - continue; - } - if !options - .filter - .keeps_family(|code| baseline.is_some_and(|b| baseline_carries_code(b, code))) - { - continue; - } - - report.in_scope_variants += 1; - if variant.config.contains_key("moduledetection") { - report.module_detection_variants += 1; - } - - let verdict: &'static str = match parse { - ParseReport::Rejected { .. } => { - report.parse_rejected_total += 1; - match baseline_shape(baseline) { - BaselineShape::None => report.parse_rejected_no_baseline += 1, - BaselineShape::Ts1xxxOnly => report.parse_rejected_ts1xxx_only += 1, - BaselineShape::Other => report.parse_rejected_other += 1, - } - "parse_rejected" - } - ParseReport::Parsed(facts) => { - if facts.used_script_retry { - report.script_retry += 1; - } - // Resolve this variant's lib set (cached) and merge the bound program - // against it — the merge diagnostics are the lib-conflict family. The - // lib resolution (parse+bind of each `.d.ts`) is the only remaining - // panic source past the initial bind, so contain it per variant: a - // future lib parse panic is recorded, not sweep-fatal. - let check_opts = check_options_for(&variant.config); - let checked = catch_unwind(AssertUnwindSafe(|| { - let base = resolver.base_for(&variant.config); - let result = check_bound(&bound, base.as_deref(), &check_opts); - (base, result) - })); - let (base, result) = match checked { - Ok(pair) => pair, - Err(payload) => { - report.panics.push(PanicRecord { - test: test.relative_path.clone(), - payload: panic_payload_message(&*payload), - }); - report.failing_variants.push(FailingVariant { - suite: test.suite.to_string(), - config: name.clone(), - reason: "panic", - diff: format!("# {}/{name} (lib-resolution panic)\n", test.suite), - }); - record_manifest( - report, - options, - test, - &name, - baseline.is_some(), - true, - "panic", - ); - continue; - } - }; - let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); - - match baseline { - None => { - report.expect_clean_graded += 1; - if result.diagnostics.is_empty() { - report.clean_pass += 1; - "clean_pass" - } else { - report.clean_fail.push(CleanFail { - variant: format!("{}/{name}", test.suite), - diagnostics: result.diagnostics.len(), - }); - report.failing_variants.push(FailingVariant { - suite: test.suite.to_string(), - config: name.clone(), - reason: "clean_fail", - diff: render_clean_fail_diff(test, &name, &result.diagnostics), - }); - "clean_fail" - } - } - Some(b) => { - report.baselined_parsed += 1; - // `parsed` => `line_map` is `Some`; the `None` arm is dead. - let ours_family = match line_map.as_ref() { - Some((tracker, map)) => { - let mapper = LocationMapper { tracker, map }; - build_ours_family( - &result.diagnostics, - &unit.name, - &mapper, - lib_files, - ) - } - None => Vec::new(), - }; - grade_family(test, &name, b, &ours_family, report) - } - } - } - }; - - record_manifest( - report, - options, - test, - &name, - baseline.is_some(), - parsed, - verdict, - ); - } - - // Node total: counted once per test (all variants share the parse+bind). - report.total_nodes += bound.total_node_count(); -} - -/// Record one per-variant verdict row for `--emit-manifest` (a no-op unless a -/// manifest is being collected). -fn record_manifest( - report: &mut SkeletonReport, - options: &RunOptions, - test: &CorpusTest, - config: &str, - baselined: bool, - parsed: bool, - verdict: &'static str, -) { - if options.collect_manifest { - report.manifest_entries.push(ManifestEntry { - suite: test.suite.to_string(), - test: test.relative_path.clone(), - config: config.to_string(), - baselined, - parsed, - verdict, - }); - } -} - -/// Whether a baseline carries a given TS code (the `--code` / `--family` filters). -/// Uses the category-generic [`parse_base_diags`] (not the error-only -/// `parse_summary_block`), so a `--code 7027` / `--family flow` triage matches -/// suggestion- and message-category flow lines too, not just error-category ones. -fn baseline_carries_code(baseline: &Baseline, code: u32) -> bool { - let Ok(content) = std::fs::read_to_string(&baseline.path) else { - return false; - }; - let want = i32::try_from(code).unwrap_or(-1); - parse_base_diags(&content).iter().any(|d| d.code == want) -} - -/// Render one failing family variant's ours-vs-baseline diff for a `.diff` artifact. -fn render_family_diff( - test: &CorpusTest, - name: &str, - reason: &str, - ours: &[FamilyEntry], - base: &[FamilyEntry], -) -> String { - use std::fmt::Write as _; - let mut s = format!("# {}/{name} ({reason})\n", test.suite); - let _ = writeln!(s, "## ours family ({})", ours.len()); - for e in ours { - let _ = writeln!( - s, - " {}({},{}): TS{}", - e.key.file, e.key.line, e.key.col, e.key.code - ); - } - let _ = writeln!(s, "## baseline family ({})", base.len()); - for e in base { - let _ = writeln!( - s, - " {}({},{}): TS{}", - e.key.file, e.key.line, e.key.col, e.key.code - ); - } - s -} - -/// Render an expect-clean variant's spurious diagnostics for a `.diff` artifact. -fn render_clean_fail_diff(test: &CorpusTest, name: &str, diags: &[Diagnostic]) -> String { - use std::fmt::Write as _; - let mut s = format!( - "# {}/{name} (clean_fail — expect-clean but {} diagnostic(s))\n## ours ({})\n", - test.suite, - diags.len(), - diags.len(), - ); - for d in diags { - let _ = writeln!(s, " TS{} @ [{}..{}]", d.code, d.span.start, d.span.end); - } - s -} - -/// The number of program units in the single-file sweep (a lib FileId is -/// `>= UNITS_LEN`, translating to `lib_files[FileId - UNITS_LEN]`). -const UNITS_LEN: u32 = 1; - -/// Build our family multiset for one variant's diagnostics, resolving each FileId to -/// a display name. A **lib-file primary** (FileId beyond the program units) is -/// dropped — the baseline masks it (`lib.x.d.ts(--,--)`) — and a lib-sourced related -/// carries the lib file name with a masked location so it matches the baseline's -/// `lib.x.d.ts:--:--` related by `(code, file)`. -fn build_ours_family( - diagnostics: &[Diagnostic], - unit_name: &str, - mapper: &LocationMapper<'_>, - lib_files: &[String], -) -> Vec { - diagnostics - .iter() - .filter(|d| FAMILY_CODES.contains(&d.code)) - .filter_map(|d| { - let file = d.file?; - // A lib-file primary is masked in the baseline — exclude it. - if file.index() >= UNITS_LEN as usize { - return None; - } - let (_, pos) = mapper.pos_and_position(d.span.start); - let key = FamilyDiag { - file: unit_name.to_string(), - line: pos.line as u32, - col: pos.column as u32 + 1, - code: d.code, - }; - let related = d - .related - .iter() - .map(|r| resolve_related(r, unit_name, mapper, lib_files)) - .collect(); - Some(FamilyEntry { key, related }) - }) - .collect() -} - -/// Resolve one related-info entry's FileId to a [`RelatedKey`]: an in-unit related -/// carries its computed location; a lib-sourced related carries the lib file name -/// and a masked (`None`) location. -fn resolve_related( - r: &Diagnostic, - unit_name: &str, - mapper: &LocationMapper<'_>, - lib_files: &[String], -) -> RelatedKey { - match r.file { - Some(f) if f.index() < UNITS_LEN as usize => { - let (_, pos) = mapper.pos_and_position(r.span.start); - RelatedKey { - code: r.code, - file: unit_name.to_string(), - loc: Some((pos.line as u32, pos.column as u32 + 1)), - } - } - Some(f) => { - let idx = f.index() - UNITS_LEN as usize; - RelatedKey { - code: r.code, - file: lib_files.get(idx).cloned().unwrap_or_default(), - loc: None, - } - } - None => RelatedKey { - code: r.code, - file: unit_name.to_string(), - loc: None, - }, - } -} - -/// One family diagnostic in baseline coordinates: `(file, 1-based line, 1-based -/// UTF-16 col, code)`. -#[derive(Clone, PartialEq, Eq, Hash)] -struct FamilyDiag { - file: String, - line: u32, - col: u32, - code: u32, -} - -/// One related-info entry in baseline coordinates: `(code, file, location)`. A -/// `--,--` (default-library) location is [`None`] and compares by code+file only. -#[derive(Clone, PartialEq, Eq, Hash)] -struct RelatedKey { - code: u32, - file: String, - loc: Option<(u32, u32)>, -} - -/// A family primary plus its related-info entries — the unit the related-info -/// channel grades (a matched primary's related sets are compared as multisets). -struct FamilyEntry { - key: FamilyDiag, - related: Vec, -} - -/// Grade one parsed-with-baseline variant's family diagnostics against its -/// baseline, folding the buckets into `report` and returning the per-variant -/// verdict (for the `--emit-manifest` row). Applies predicate v1 rule (a) -/// (recovery-AST carve-out) first, then the primary-code channel and — for the -/// matched primaries — the independent related-info channel. -fn grade_family( - test: &CorpusTest, - name: &str, - baseline: &Baseline, - ours: &[FamilyEntry], - report: &mut SkeletonReport, -) -> &'static str { - let Ok(content) = std::fs::read_to_string(&baseline.path) else { - return "baseline_unreadable"; - }; - let base_all = parse_base_diags(&content); - - // Predicate v1 rule (a): tsv parses clean (it did — this variant parsed) and - // the baseline carries a non-bind TS1xxx code -> recovery-AST incomparable. - let has_nonbind_ts1xxx = base_all.iter().any(|d| { - (1000..2000).contains(&d.code) - && u32::try_from(d.code).is_ok_and(|c| !BIND_EMITTED_TS1XXX.contains(&c)) - }); - let base_family: Vec = base_all - .iter() - .filter_map(|d| { - let code = u32::try_from(d.code).ok()?; - if !FAMILY_CODES.contains(&code) { - return None; - } - Some(FamilyEntry { - key: FamilyDiag { - file: d.file.clone()?, - line: d.line?, - col: d.col?, - code, - }, - related: d.related.clone(), - }) - }) - .collect(); - let has_family = !base_family.is_empty(); - - if has_nonbind_ts1xxx { - report.carve_out_rule_a += 1; - if has_family { - report.carve_out_rule_a_family += 1; - } - return "carve_out"; - } - - report.family_graded_variants += 1; - if has_family { - report.family_positive_variants += 1; - } - - let ours_keys: Vec = ours.iter().map(|e| e.key.clone()).collect(); - let base_keys: Vec = base_family.iter().map(|e| e.key.clone()).collect(); - let buckets = family_buckets(&ours_keys, &base_keys); - report.family_match += buckets.matched; - report.family_span_mismatch += buckets.span_mismatch; - report.family_extra += buckets.extra; - for (code, count) in &buckets.matched_by_code { - *report.family_match_by_code.entry(*code).or_default() += *count; - } - if buckets.extra > 0 && report.extra_samples.len() < 20 { - report - .extra_samples - .push(format!("{}/{name} (+{})", test.suite, buckets.extra)); - } - if buckets.span_mismatch > 0 && report.span_mismatch_samples.len() < 20 { - report.span_mismatch_samples.push(format!( - "{}/{name} (~{})", - test.suite, buckets.span_mismatch - )); - } - // An unexplained hard-fail bucket (extra / span mismatch) gets a rendered - // ours-vs-baseline diff artifact; the pinned deferred-late-bound `missing` is - // expected, so it does not. - if buckets.extra > 0 || buckets.span_mismatch > 0 { - let reason = if buckets.extra > 0 { - "family_extra" - } else { - "family_span_mismatch" - }; - report.failing_variants.push(FailingVariant { - suite: test.suite.to_string(), - config: name.to_string(), - reason, - diff: render_family_diff(test, name, reason, ours, &base_family), - }); - } - for (code, count) in &buckets.missing_by_code { - report.family_missing += *count; - *report.family_missing_by_code.entry(*code).or_default() += *count; - let cause = classify_missing(&test.basename, *code); - *report.missing_by_cause.entry(cause).or_default() += *count; - if cause == MissingCause::Other && report.missing_other_samples.len() < 20 { - report - .missing_other_samples - .push(format!("{}/{name} TS{code} x{count}", test.suite)); - } - } - - // The related-info channel (independent of the primary verdict): grade related - // multisets only for the primaries that matched. - let rel = grade_related(ours, &base_family); - report.related_match += rel.matched; - report.related_span_mismatch += rel.span_mismatch; - report.related_extra += rel.extra; - report.related_missing += rel.missing; - if rel.extra > 0 && report.related_extra_samples.len() < 20 { - report - .related_extra_samples - .push(format!("{}/{name} (+{})", test.suite, rel.extra)); - } - if rel.missing > 0 && report.related_missing_samples.len() < 20 { - report - .related_missing_samples - .push(format!("{}/{name} (-{})", test.suite, rel.missing)); - } - - // The per-variant verdict (extra dominates — it is the hard gate). - if buckets.extra > 0 { - "family_extra" - } else if buckets.span_mismatch > 0 { - "family_span_mismatch" - } else if !buckets.missing_by_code.is_empty() { - "family_missing" - } else if has_family { - "family_match" - } else { - "baselined_clean" - } -} - -/// A baseline summary diagnostic with its parsed related-info entries. -struct BaseDiag { - file: Option, - line: Option, - col: Option, - /// The `TS` (i32 — the harness's `TS-1` and non-family codes appear here). - code: i32, - related: Vec, -} - -/// Parse a baseline into summary diagnostics with related info, via the full -/// [`parse_baseline`] model (100% of the pinned baselines round-trip through it). -/// Falls back to the related-free summary parse on the rare structural surprise, -/// so the primary channel never shifts. -fn parse_base_diags(content: &str) -> Vec { - use crate::tsc_conformance::baseline::Loc; - match parse_baseline(content) { - Ok(parsed) => parsed - .diags - .iter() - .map(|d| { - let (line, col) = match d.loc { - Some(Loc::Numbered { line, col }) => (Some(line), Some(col)), - _ => (None, None), - }; - let related = d - .related - .iter() - .filter_map(|s| parse_related_line(s)) - .collect(); - BaseDiag { - file: d.file.clone(), - line, - col, - code: d.code, - related, - } - }) - .collect(), - Err(_) => parse_summary_block(content) - .into_iter() - .map(|d| BaseDiag { - file: d.file, - line: d.line, - col: d.col, - code: d.code as i32, - related: Vec::new(), - }) - .collect(), - } -} - -/// Parse one `!!! related TS ::: ` line into a -/// [`RelatedKey`], or `None` for a chain-continuation line (no `!!! related` -/// prefix). A `--:--` location parses to [`None`] (a masked default-lib position). -fn parse_related_line(line: &str) -> Option { - let rest = line.strip_prefix("!!! related TS")?; - let end = rest.find(|c: char| !c.is_ascii_digit())?; - let code: u32 = rest.get(..end)?.parse().ok()?; - let after = rest.get(end..)?.strip_prefix(' ')?; // `::: ` - // The first `": "` separates the location from the message (a filename holds - // no space, and line/col are digits-or-`--`). - let boundary = after.find(": ")?; - let locpart = after.get(..boundary)?; // `::` - let (rest2, col) = locpart.rsplit_once(':')?; - let (file, line_s) = rest2.rsplit_once(':')?; - let loc = if line_s == "--" && col == "--" { - None - } else { - Some((line_s.parse().ok()?, col.parse().ok()?)) - }; - Some(RelatedKey { - code, - file: file.to_string(), - loc, - }) -} - -/// The related-info buckets across a variant's matched primaries. -#[derive(Default)] -struct RelatedBuckets { - matched: usize, - extra: usize, - span_mismatch: usize, - missing: usize, -} - -/// Grade related-info multisets for the primaries that match by -/// `(file,line,col,code)`. Ours and the baseline are grouped by primary key; -/// matched primaries are paired positionally and their related sets diffed -/// (exact `(code,file,loc)` match, masked `--,--` by `(code,file)`, then -/// `(code,file)` span-mismatch pairing of the leftovers). -fn grade_related(ours: &[FamilyEntry], base: &[FamilyEntry]) -> RelatedBuckets { - let mut ours_by: HashMap<&FamilyDiag, Vec<&[RelatedKey]>> = HashMap::new(); - for e in ours { - ours_by.entry(&e.key).or_default().push(&e.related); - } - let mut base_by: HashMap<&FamilyDiag, Vec<&[RelatedKey]>> = HashMap::new(); - for e in base { - base_by.entry(&e.key).or_default().push(&e.related); - } - - let mut out = RelatedBuckets::default(); - for (key, ours_sets) in &ours_by { - let Some(base_sets) = base_by.get(key) else { - continue; - }; - let paired = ours_sets.len().min(base_sets.len()); - for i in 0..paired { - related_diff(ours_sets[i], base_sets[i], &mut out); - } - } - out -} - -/// Diff one matched primary's related multisets, folding into `out`. -fn related_diff(ours: &[RelatedKey], base: &[RelatedKey], out: &mut RelatedBuckets) { - // Exact `(code,file,loc)` matches first. - let mut ours_counts: HashMap<&RelatedKey, usize> = HashMap::new(); - for r in ours { - *ours_counts.entry(r).or_default() += 1; - } - let mut base_counts: HashMap<&RelatedKey, usize> = HashMap::new(); - for r in base { - *base_counts.entry(r).or_default() += 1; - } - // Leftovers grouped by `(code, file)` for masked-match and span-mismatch pairing. - let mut left_ours: HashMap<(u32, &str), usize> = HashMap::new(); - let mut left_base_located: HashMap<(u32, &str), usize> = HashMap::new(); - let mut left_base_masked: HashMap<(u32, &str), usize> = HashMap::new(); - - for (r, &oc) in &ours_counts { - let bc = base_counts.get(*r).copied().unwrap_or(0); - let m = oc.min(bc); - out.matched += m; - if oc > m { - *left_ours.entry((r.code, r.file.as_str())).or_default() += oc - m; - } - } - for (r, &bc) in &base_counts { - let oc = ours_counts.get(*r).copied().unwrap_or(0); - let m = oc.min(bc); - if bc > m { - let bucket = if r.loc.is_none() { - &mut left_base_masked - } else { - &mut left_base_located - }; - *bucket.entry((r.code, r.file.as_str())).or_default() += bc - m; - } - } - - // Masked baseline related (default-lib `--,--`) matches ours by `(code,file)`. - for (key, bcount) in &mut left_base_masked { - if let Some(ocount) = left_ours.get_mut(key) { - let m = (*ocount).min(*bcount); - out.matched += m; - *ocount -= m; - *bcount -= m; - } - } - - // Remaining located leftovers: `(code,file)` pairing = span mismatch; the rest - // is extra (ours) / missing (baseline). - let keys: std::collections::HashSet<(u32, &str)> = left_ours - .keys() - .chain(left_base_located.keys()) - .chain(left_base_masked.keys()) - .copied() - .collect(); - for key in keys { - let oc = left_ours.get(&key).copied().unwrap_or(0); - let bc = left_base_located.get(&key).copied().unwrap_or(0) - + left_base_masked.get(&key).copied().unwrap_or(0); - let sm = oc.min(bc); - out.span_mismatch += sm; - out.extra += oc - sm; - out.missing += bc - sm; - } -} - -/// The four family buckets for one variant. -struct FamilyBuckets { - matched: usize, - extra: usize, - span_mismatch: usize, - /// The exact matches, per code (for the committed report's per-code table). - matched_by_code: HashMap, - /// The unattributed misses, per code (for cause classification). - missing_by_code: HashMap, -} - -/// Compare our family multiset against the baseline's: exact `(file,line,col,code)` -/// matches, then greedy `(file,code)` span-mismatch pairing of the leftovers, with -/// the residue split into extra (ours) and missing (baseline). -fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { - let mut ours_counts: HashMap<&FamilyDiag, usize> = HashMap::new(); - for d in ours { - *ours_counts.entry(d).or_default() += 1; - } - let mut base_counts: HashMap<&FamilyDiag, usize> = HashMap::new(); - for d in base { - *base_counts.entry(d).or_default() += 1; - } - - let mut matched = 0usize; - let mut matched_by_code: HashMap = HashMap::new(); - // Leftover counts grouped by (file, code) for span-mismatch pairing. - let mut left_ours: HashMap<(&str, u32), usize> = HashMap::new(); - let mut left_base: HashMap<(&str, u32), usize> = HashMap::new(); - - for (d, &oc) in &ours_counts { - let bc = base_counts.get(d).copied().unwrap_or(0); - let m = oc.min(bc); - matched += m; - if m > 0 { - *matched_by_code.entry(d.code).or_default() += m; - } - if oc > m { - *left_ours.entry((d.file.as_str(), d.code)).or_default() += oc - m; - } - } - for (d, &bc) in &base_counts { - let oc = ours_counts.get(d).copied().unwrap_or(0); - let m = oc.min(bc); - if bc > m { - *left_base.entry((d.file.as_str(), d.code)).or_default() += bc - m; - } - } - - // Pair leftovers within each (file, code) group: min = span mismatch, the - // ours residue = extra, the baseline residue = missing. - let mut span_mismatch = 0usize; - let mut extra = 0usize; - let mut missing_by_code: HashMap = HashMap::new(); - let keys: std::collections::HashSet<(&str, u32)> = - left_ours.keys().chain(left_base.keys()).copied().collect(); - for &(file, code) in &keys { - let oc = left_ours.get(&(file, code)).copied().unwrap_or(0); - let bc = left_base.get(&(file, code)).copied().unwrap_or(0); - let sm = oc.min(bc); - span_mismatch += sm; - extra += oc - sm; - if bc - sm > 0 { - *missing_by_code.entry(code).or_default() += bc - sm; - } - } - - FamilyBuckets { - matched, - extra, - span_mismatch, - matched_by_code, - missing_by_code, - } -} - -/// Classify a parse-rejected variant's baseline shape for the census. -fn baseline_shape(baseline: Option<&Baseline>) -> BaselineShape { - let Some(baseline) = baseline else { - return BaselineShape::None; - }; - let Ok(content) = std::fs::read_to_string(&baseline.path) else { - return BaselineShape::Other; - }; - let diags = parse_summary_block(&content); - if !diags.is_empty() && diags.iter().all(|d| (1000..2000).contains(&d.code)) { - BaselineShape::Ts1xxxOnly - } else { - BaselineShape::Other - } -} - -/// Sum a per-code map's entries whose code is in `codes` (the sub-family partition -/// behind `dup_*` / `flow_*`). -fn sub_family_sum(by_code: &BTreeMap, codes: &[u32]) -> usize { - by_code - .iter() - .filter(|(code, _)| codes.contains(code)) - .map(|(_, count)| *count) - .sum() -} - -impl SkeletonReport { - /// A family's matches (partition of `family_match_by_code` by its code set). - #[must_use] - pub fn family_match_for(&self, family: &GradedFamily) -> usize { - sub_family_sum(&self.family_match_by_code, family.codes) - } - - /// A family's misses (partition of `family_missing_by_code` by its code set). - #[must_use] - pub fn family_missing_for(&self, family: &GradedFamily) -> usize { - sub_family_sum(&self.family_missing_by_code, family.codes) - } - - /// The missing count attributed to `cause` (0 when the cause never fired). - #[must_use] - pub fn missing(&self, cause: MissingCause) -> usize { - self.missing_by_cause.get(&cause).copied().unwrap_or(0) - } - - /// The duplicate/conflict sub-family matches (partition of `family_match_by_code`). - #[must_use] - pub fn dup_match(&self) -> usize { - self.family_match_for(&FAMILIES[0]) - } - - /// The flow sub-family matches (partition of `family_match_by_code`). - #[must_use] - pub fn flow_match(&self) -> usize { - self.family_match_for(&FAMILIES[1]) - } - - /// The duplicate/conflict sub-family misses (partition of `family_missing_by_code`). - #[must_use] - pub fn dup_missing(&self) -> usize { - self.family_missing_for(&FAMILIES[0]) - } - - /// The flow sub-family misses (partition of `family_missing_by_code`). - #[must_use] - pub fn flow_missing(&self) -> usize { - self.family_missing_for(&FAMILIES[1]) - } - - /// Print the human summary. - pub fn print(&self) { - println!("tsc_conformance run"); - println!("==================="); - println!("In-scope tests: {}", self.in_scope_tests); - println!("In-scope variants: {}", self.in_scope_variants); - println!(" parsed, expect-clean: {}", self.expect_clean_graded); - println!(" graded clean: {}", self.clean_pass); - println!(" graded NON-clean: {}", self.clean_fail.len()); - println!(" parsed, baselined: {}", self.baselined_parsed); - println!(" parse-rejected: {}", self.parse_rejected_total); - println!( - " no baseline: {}", - self.parse_rejected_no_baseline - ); - println!( - " TS1xxx-only baseline: {}", - self.parse_rejected_ts1xxx_only - ); - println!(" other baseline: {}", self.parse_rejected_other); - println!("Script-goal retries: {}", self.script_retry); - println!("Bound nodes (total): {}", self.total_nodes); - println!(); - println!( - "Family grading (dup 2300/2451/2567/2528 + merge 2397/2649/2664/2671; flow 7027/7028)" - ); - println!("---------------------------------------------------------------"); - println!("Graded variants: {}", self.family_graded_variants); - println!( - " ...family-positive: {}", - self.family_positive_variants - ); - let per_family = |get: &dyn Fn(&GradedFamily) -> usize| -> String { - FAMILIES - .iter() - .map(|f| format!("{} {}", f.key, get(f))) - .collect::>() - .join(", ") - }; - println!( - " match: {} ({})", - self.family_match, - per_family(&|f| self.family_match_for(f)) - ); - println!( - " missing: {} ({})", - self.family_missing, - per_family(&|f| self.family_missing_for(f)) - ); - // One line per classified cause (label-aligned; `Other` is the gate). - const CAUSE_LINES: [(MissingCause, &str, &str); 5] = [ - (MissingCause::Merge, " merge-path: ", ""), - (MissingCause::Lib, " lib-conflict: ", ""), - ( - MissingCause::DeferredLateBound, - " late-bound (deferred): ", - " (needs the type engine — literal-type / unique-symbol computed member names)", - ), - ( - MissingCause::DeferredCfa, - " cfa (deferred): ", - " (needs the CFA type engine — never-returning sigs / assertion predicates / switch exhaustiveness / structural reachability)", - ), - ( - MissingCause::Other, - " other (GATE=0): ", - " (unclassified family miss — a same-table cascade bug)", - ), - ]; - for (cause, label, note) in CAUSE_LINES { - println!("{label}{}{note}", self.missing(cause)); - } - println!(" extra (GATE=0): {}", self.family_extra); - println!(" span_mismatch: {}", self.family_span_mismatch); - println!("Related-info (matched primaries; own channel, non-gating)"); - println!(" related match: {}", self.related_match); - println!(" related missing: {}", self.related_missing); - println!(" related extra: {}", self.related_extra); - println!(" related span_mismatch: {}", self.related_span_mismatch); - for s in &self.related_missing_samples { - println!(" REL-MISSING {s}"); - } - for s in &self.related_extra_samples { - println!(" REL-EXTRA {s}"); - } - println!("Carve-out rule (a): {}", self.carve_out_rule_a); - println!( - " ...family-positive: {}", - self.carve_out_rule_a_family - ); - println!( - "moduleDetection variants: {} (watch; inert for family)", - self.module_detection_variants - ); - for s in &self.extra_samples { - println!(" EXTRA {s}"); - } - for s in &self.missing_other_samples { - println!(" MISSING-OTHER {s}"); - } - for s in &self.span_mismatch_samples { - println!(" SPAN {s}"); - } - println!(); - println!("Lib base"); - println!(" lib files bound: {}", self.lib_files_bound); - println!(" lib sets folded: {}", self.lib_sets_built); - println!( - " lib parse errors: {} (GATE=0)", - self.lib_parse_errors.len() - ); - println!( - " lib missing files: {} (GATE=0)", - self.lib_missing_files.len() - ); - println!( - " lib unknown names: {} (GATE=0)", - self.lib_unknown_names.len() - ); - println!( - " lib external no-globals: {} (GATE=0)", - self.lib_external_no_globals.len() - ); - for e in &self.lib_parse_errors { - println!(" LIB-PARSE-ERR {e}"); - } - for f in &self.lib_missing_files { - println!(" LIB-MISSING {f}"); - } - for n in &self.lib_unknown_names { - println!(" LIB-UNKNOWN {n}"); - } - for f in &self.lib_external_no_globals { - println!(" LIB-EXT-NO-GLOBALS {f}"); - } - println!(); - println!("Panics (caught): {}", self.panics.len()); - println!("Crash-excluded (tracked): {}", self.excluded_crashes); - if !self.stale_exclusions.is_empty() { - println!( - "Stale crash-exclusions: {} (drop them)", - self.stale_exclusions.len() - ); - } - println!("Wall-clock: {} ms", self.wall_ms); - if !self.clean_fail.is_empty() { - println!(); - for f in &self.clean_fail { - println!(" CLEAN-FAIL {} ({} diagnostics)", f.variant, f.diagnostics); - } - } - for p in &self.panics { - println!(" PANIC {} — {}", p.test, p.payload); - } - } -} - -// =========================================================================== -// check-test: the inner dev loop over one test. -// =========================================================================== - -/// One diagnostic line (ours or the baseline's) for the check-test diff. -#[derive(Debug, Clone, Default, serde::Serialize)] -pub struct DiagLine { - /// The file the diagnostic points at (or `null` for a global one). A lib file - /// (`lib.es5.d.ts`) with `null` line/col is a masked lib-sourced entry. - pub file: Option, - /// 1-based line (`null` for a global or masked-lib diagnostic). - pub line: Option, - /// 1-based column (`null` for a global or masked-lib diagnostic). - pub col: Option, - /// The `TS` number. - pub code: u32, - /// The diagnostic's related-info entries (empty for a baseline summary line). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub related: Vec, -} - -/// The `check-test` report for one test/variant. -#[derive(Debug, Clone, serde::Serialize)] -pub struct CheckTestReport { - /// The corpus test's relative path. - pub test: String, - /// The suite (`compiler` / `conformance`). - pub suite: String, - /// The variant description, or `(default)`. - pub variant: String, - /// The joined baseline name, or `None` when the variant is expect-clean. - pub baseline: Option, - /// Whether the variant is expect-clean (no on-disk baseline). - pub expect_clean: bool, - /// Whether tsv parse-rejected the program. - pub parse_rejected: bool, - /// The parse error message, when rejected. - pub parse_error: Option, - /// Our diagnostics (empty while the checker is a no-op). - pub ours: Vec, - /// The baseline's summary-block diagnostics (the expected set). - pub baseline_summary: Vec, -} - -/// Run one corpus test (optionally one variant) and build its check-test report. -/// -/// `name` matches a corpus test by exact relative path or exact basename. -/// -/// # Errors -/// -/// Returns an error string when the test is not found, the match is ambiguous, -/// the requested variant does not exist, or corpus discovery fails. -pub fn check_one( - checkout: &Path, - name: &str, - variant_filter: Option<(String, String)>, -) -> Result { - let corpus = discover_corpus(checkout)?; - let baselines = discover_baselines(&baselines_dir(checkout))?; - - let matches: Vec<&CorpusTest> = corpus - .iter() - .filter(|t| t.relative_path == name || t.basename == name) - .collect(); - let test = match matches.as_slice() { - [] => return Err(format!("no corpus test matches {name:?}")), - [one] => *one, - many => { - let paths: Vec = many - .iter() - .map(|t| format!("{}/{}", t.suite, t.relative_path)) - .collect(); - return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); - } - }; - - let content = read_corpus_file(&test.path)?; - let settings = extract_settings(&content); - let units = split_units(&content, &test.basename); - - // Pick the variant. - let expansion = expand(&settings); - let variant = select_variant(&expansion.variants, variant_filter.as_ref())?; - let baseline_name = config_name(&test.basename, &variant.description); - - // Join the baseline. - let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); - for baseline in &baselines { - if let Some((suite, n)) = baseline.relative_path.split_once('/') { - ondisk.insert((suite, n.to_string()), baseline); - } - } - let baseline = ondisk.get(&(test.suite, baseline_name.clone())).copied(); - - // Parse + bind every unit, then merge against the selected variant's lib base. - let arena = Bump::new(); - let source_units: Vec> = units - .iter() - .map(|u| SourceUnit::new(&u.name, &u.content)) - .collect(); - let bound = bind_program(&source_units, &arena); - let mut resolver = LibResolver::new(checkout); - let base = resolver.base_for(&variant.config); - let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); - let result = check_bound(&bound, base.as_deref(), &check_options_for(&variant.config)); - - // Resolve each diagnostic's FileId to a display line: a program unit carries its - // (line, col); a lib file carries the lib name with a masked location. - let resolve_line = |d: &Diagnostic| -> DiagLine { - let units_len = units.len(); - match d.file { - Some(f) if f.index() < units_len => { - let (line, col) = units.get(f.index()).map_or((None, None), |u| { - let (t, m) = LocationTracker::new_ecmascript_with_map(&u.content); - let (_, pos) = LocationMapper { - tracker: &t, - map: &m, - } - .pos_and_position(d.span.start); - (Some(pos.line as u32), Some(pos.column as u32 + 1)) - }); - DiagLine { - file: units.get(f.index()).map(|u| u.name.clone()), - line, - col, - code: d.code, - related: Vec::new(), - } - } - Some(f) => DiagLine { - file: lib_files.get(f.index() - units_len).cloned(), - line: None, - col: None, - code: d.code, - related: Vec::new(), - }, - None => DiagLine { - file: None, - line: None, - col: None, - code: d.code, - related: Vec::new(), - }, - } - }; - let ours: Vec = result - .diagnostics - .iter() - .map(|d| { - let mut line = resolve_line(d); - line.related = d.related.iter().map(&resolve_line).collect(); - line - }) - .collect(); - let parse_error = result.files.iter().find_map(|f| match &f.parse { - ParseReport::Rejected { message } => Some(message.clone()), - ParseReport::Parsed(_) => None, - }); - - let baseline_summary = match baseline { - Some(b) => std::fs::read_to_string(&b.path) - .map(|c| { - parse_summary_block(&c) - .into_iter() - .map(|d| DiagLine { - file: d.file, - line: d.line, - col: d.col, - code: d.code, - related: Vec::new(), - }) - .collect() - }) - .unwrap_or_default(), - None => Vec::new(), - }; - - Ok(CheckTestReport { - test: test.relative_path.clone(), - suite: test.suite.to_string(), - variant: if variant.description.is_empty() { - "(default)".to_string() - } else { - variant.description.clone() - }, - baseline: baseline.map(|_| baseline_name), - expect_clean: baseline.is_none(), - parse_rejected: result.parse_rejected, - parse_error, - ours, - baseline_summary, - }) -} - -/// Build the flow graph of a corpus test's **first** unit and render it to DOT -/// (the `check-test --dump-flow` product). Parses under the goal rule (Module, -/// then a Script retry), binds (F0), builds the flow product (F1), and renders -/// through `tsv_check`'s source-aware DOT renderer. Keeps the `BoundFile` alive -/// so the renderer can slice subject-node source text from its span column. -pub fn dump_flow_dot(checkout: &Path, name: &str) -> Result { - let corpus = discover_corpus(checkout)?; - let matches: Vec<&CorpusTest> = corpus - .iter() - .filter(|t| t.relative_path == name || t.basename == name) - .collect(); - let test = match matches.as_slice() { - [] => return Err(format!("no corpus test matches {name:?}")), - [one] => *one, - many => { - let paths: Vec = many - .iter() - .map(|t| format!("{}/{}", t.suite, t.relative_path)) - .collect(); - return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); - } - }; - - let content = read_corpus_file(&test.path)?; - let units = split_units(&content, &test.basename); - let unit = units - .first() - .ok_or_else(|| "test has no units".to_string())?; - - let arena = Bump::new(); - // The goal rule (Module first, Script retry) — the same rule bind_program - // uses, inlined here because --dump-flow keeps the BoundFile for rendering. - let program = match tsv_ts::parse_with_goal(&unit.content, tsv_ts::Goal::Module, &arena) { - Ok(p) => p, - Err(module_err) => tsv_ts::parse_with_goal(&unit.content, tsv_ts::Goal::Script, &arena) - .map_err(|_| format!("parse error: {module_err}"))?, - }; - let bound = bind_file(&program, &unit.content, FileId::ROOT); - let flow = build_flow(&program, &unit.content, &bound); - Ok(render_flow_dot(&flow, &bound.spans, &unit.content)) -} - -/// Select a variant by an optional `k=v` filter (config match, lowercased key); -/// with no filter the first (usually the unvaried) variant. -fn select_variant<'a>( - variants: &'a [Variant], - filter: Option<&(String, String)>, -) -> Result<&'a Variant, String> { - match filter { - None => variants - .first() - .ok_or_else(|| "test has no variants".to_string()), - Some((key, value)) => { - let key = key.to_lowercase(); - variants - .iter() - .find(|v| v.config.get(&key).map(String::as_str) == Some(value.as_str())) - .ok_or_else(|| { - let available: Vec<&str> = variants - .iter() - .map(|v| { - if v.description.is_empty() { - "(default)" - } else { - &v.description - } - }) - .collect(); - format!( - "no variant with {key}={value}; available: {}", - available.join(", ") - ) - }) - } - } -} - -impl CheckTestReport { - /// Print the human diff (ours vs the baseline summary). - pub fn print(&self) { - println!( - "check-test: {}/{} variant={}", - self.suite, self.test, self.variant - ); - if self.parse_rejected { - println!( - " tsv PARSE-REJECTED: {}", - self.parse_error.as_deref().unwrap_or("(no message)") - ); - } - if self.expect_clean { - println!(" baseline: (none — expect-clean)"); - } else { - println!(" baseline: {}", self.baseline.as_deref().unwrap_or("?")); - } - println!(); - println!(" ours ({}):", self.ours.len()); - for d in &self.ours { - println!(" {}", fmt_diag(d)); - for r in &d.related { - println!(" related {}", fmt_diag(r)); - } - } - if self.ours.is_empty() { - println!(" (none)"); - } - println!(" baseline ({}):", self.baseline_summary.len()); - for d in &self.baseline_summary { - println!(" {}", fmt_diag(d)); - } - if self.baseline_summary.is_empty() { - println!(" (none)"); - } - } -} - -/// Format one diagnostic line for the human diff. -fn fmt_diag(d: &DiagLine) -> String { - match (&d.file, d.line, d.col) { - (Some(file), Some(line), Some(col)) => format!("{file}({line},{col}): TS{}", d.code), - // A masked lib entry (file, no location) or a global one. - (Some(file), _, _) => format!("{file}(--,--): TS{}", d.code), - _ => format!("error TS{} (global)", d.code), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A variant config from `key=value` pairs (the maps store lowercased keys). - fn config(pairs: &[(&str, &str)]) -> BTreeMap { - pairs - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect() - } - - #[test] - fn keeps_test_substring() { - // No `--test` filter keeps every path; an active one keeps only substrings. - let none = RunFilter::default(); - assert!(none.keeps_test("compiler/anything.ts")); - - let f = RunFilter { - test: Some("duplicate".to_string()), - ..RunFilter::default() - }; - assert!(f.keeps_test("compiler/duplicateVar.ts")); - assert!(!f.keeps_test("compiler/asyncAwait.ts")); - } - - #[test] - fn keeps_variant_key_value() { - // No `--variant` filter keeps everything. - let none = RunFilter::default(); - assert!(none.keeps_variant(&config(&[("target", "es5")]))); - - let f = RunFilter { - variant: Some(("target".to_string(), "es2015".to_string())), - ..RunFilter::default() - }; - // Exact key=value match keeps. - assert!(f.keeps_variant(&config(&[("target", "es2015")]))); - // Wrong value excludes. - assert!(!f.keeps_variant(&config(&[("target", "es5")]))); - // Absent key excludes (the variant doesn't set it). - assert!(!f.keeps_variant(&config(&[("strict", "true")]))); - } - - #[test] - fn keeps_code_consults_baseline_only_when_active() { - // No `--code` filter keeps without ever consulting the baseline resolver - // (the closure must not run — it would panic if it did). - let none = RunFilter::default(); - assert!(none.keeps_code(|_| panic!("resolver consulted with no --code filter"))); - - let f = RunFilter { - code: Some(2300), - ..RunFilter::default() - }; - // Active filter keeps iff the baseline carries the code. - let carried = [2300u32, 2451]; - assert!(f.keeps_code(|code| carried.contains(&code))); - let other = [2451u32]; - assert!(!f.keeps_code(|code| other.contains(&code))); - // A variant with no baseline (resolver reports false) is excluded. - assert!(!f.keeps_code(|_| false)); - } - - #[test] - fn keeps_family_selects_sub_family() { - // No `--family` filter keeps without consulting the baseline resolver. - let none = RunFilter::default(); - assert!(none.keeps_family(|_| panic!("resolver consulted with no --family filter"))); - - // `flow` keeps iff the baseline carries a FLOW_CODES member; a dup-only - // baseline is excluded, a flow baseline is kept. (Parsed through the - // `FAMILIES`-table tokens — the same path the CLI takes.) - let flow = RunFilter { - family: FamilyFilter::parse("flow"), - ..RunFilter::default() - }; - assert!(flow.keeps_family(|c| c == 7027)); - assert!(!flow.keeps_family(|c| c == 2300)); - - // `dup` is the complementary partition. - let dup = RunFilter { - family: FamilyFilter::parse("dup"), - ..RunFilter::default() - }; - assert!(dup.keeps_family(|c| c == 2300)); - assert!(!dup.keeps_family(|c| c == 7027)); - - // `all` keeps any family code (either partition); an unknown token - // refuses to parse. - assert!(FamilyFilter::parse("nope").is_none()); - let all = RunFilter { - family: FamilyFilter::parse("all"), - ..RunFilter::default() - }; - assert!(all.keeps_family(|c| c == 7028)); - assert!(all.keeps_family(|c| c == 2451)); - // A non-family code (or no baseline) is excluded. - assert!(!all.keeps_family(|c| c == 9999)); - } - - #[test] - fn filters_compose_as_and() { - // The call site ANDs the three predicates; all must keep for a variant to be - // graded, and any one failing excludes it. - let f = RunFilter { - test: Some("dup".to_string()), - code: Some(2300), - variant: Some(("target".to_string(), "es5".to_string())), - family: None, - }; - let cfg = config(&[("target", "es5")]); - let carried = [2300u32]; - let keeps = |path: &str, cfg: &BTreeMap, codes: &[u32]| { - f.keeps_test(path) && f.keeps_variant(cfg) && f.keeps_code(|c| codes.contains(&c)) - }; - // All three match. - assert!(keeps("compiler/dupVar.ts", &cfg, &carried)); - // Test substring misses. - assert!(!keeps("compiler/other.ts", &cfg, &carried)); - // Variant value misses. - assert!(!keeps( - "compiler/dupVar.ts", - &config(&[("target", "es2015")]), - &carried - )); - // Code missing from the baseline. - assert!(!keeps("compiler/dupVar.ts", &cfg, &[2451])); - } -} diff --git a/crates/tsv_debug/src/tsc_conformance/runner/filter.rs b/crates/tsv_debug/src/tsc_conformance/runner/filter.rs new file mode 100644 index 000000000..5f45ced25 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner/filter.rs @@ -0,0 +1,112 @@ +use super::*; + +/// Which graded sub-family the `--family` filter isolates. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FamilyFilter { + /// One [`FAMILIES`] row, by index (`dup` = 0, `flow` = 1). + One(usize), + /// The whole graded family ([`FAMILY_CODES`]) — isolates the family-graded slice. + All, +} + +impl FamilyFilter { + /// Parse a `--family` token: a [`FAMILIES`] key, or `all`. + #[must_use] + pub fn parse(arg: &str) -> Option { + if arg == "all" { + return Some(FamilyFilter::All); + } + FAMILIES + .iter() + .position(|f| f.key == arg) + .map(FamilyFilter::One) + } + + /// The valid `--family` tokens, for error messages (`dup / flow / all`). + #[must_use] + pub fn tokens() -> String { + let mut tokens: Vec<&str> = FAMILIES.iter().map(|f| f.key).collect(); + tokens.push("all"); + tokens.join(" / ") + } +} + +/// The code set a [`FamilyFilter`] keeps a variant for (its baseline must carry at +/// least one). +fn family_filter_codes(f: FamilyFilter) -> &'static [u32] { + match f { + FamilyFilter::One(i) => FAMILIES[i].codes, + FamilyFilter::All => &FAMILY_CODES, + } +} + +/// Filters for a scoped `run` sweep. Any active filter SKIPS the exact pins (the +/// `roundtrip`/`query` convention), so a filtered run is a triage view — the +/// invariant gates (clean grading, no panics, `family_extra == 0`) still hold. +#[derive(Default, Clone)] +pub struct RunFilter { + /// Keep only tests whose relative path contains this substring. + pub test: Option, + /// Keep only variants whose joined baseline carries this TS code. + pub code: Option, + /// Keep only variants whose config has this `key=value` (key lowercased). + pub variant: Option<(String, String)>, + /// Keep only variants whose baseline carries a code in this sub-family + /// (`dup` / `flow` / `all`). + pub family: Option, +} + +impl RunFilter { + /// Whether any filter is active (drives pin skipping). + #[must_use] + pub fn is_active(&self) -> bool { + self.test.is_some() + || self.code.is_some() + || self.variant.is_some() + || self.family.is_some() + } + + /// Whether a test passes the `--test` substring filter (absent filter ⇒ keep). + pub(super) fn keeps_test(&self, relative_path: &str) -> bool { + self.test + .as_deref() + .is_none_or(|sub| relative_path.contains(sub)) + } + + /// Whether a variant passes the `--variant key=value` filter (absent ⇒ keep). + /// The key is already lowercased (the config maps store lowercased keys). + pub(super) fn keeps_variant(&self, config: &BTreeMap) -> bool { + self.variant + .as_ref() + .is_none_or(|(k, v)| config.get(k).map(String::as_str) == Some(v.as_str())) + } + + /// Whether a variant passes the `--code` filter. `baseline_carries` reports + /// whether the variant's baseline carries a given code; it is consulted only + /// when the filter is active, so a run without `--code` never reads a baseline + /// on its behalf. Absent filter ⇒ keep. + pub(super) fn keeps_code(&self, baseline_carries: impl FnOnce(u32) -> bool) -> bool { + self.code.is_none_or(baseline_carries) + } + + /// Whether a variant passes the `--family` filter: its baseline must carry at + /// least one code in the selected sub-family (an expect-clean variant carries + /// none, so it is filtered out). `baseline_carries` is consulted only when the + /// filter is active. Absent filter ⇒ keep. + pub(super) fn keeps_family(&self, baseline_carries: impl Fn(u32) -> bool) -> bool { + self.family.is_none_or(|f| { + family_filter_codes(f) + .iter() + .any(|&code| baseline_carries(code)) + }) + } +} + +/// Options for the skeleton sweep. +#[derive(Default, Clone)] +pub struct RunOptions { + /// The triage filter (empty = full pinned run). + pub filter: RunFilter, + /// Collect the per-variant verdict rows (for `--emit-manifest`). + pub collect_manifest: bool, +} diff --git a/crates/tsv_debug/src/tsc_conformance/runner/grade.rs b/crates/tsv_debug/src/tsc_conformance/runner/grade.rs new file mode 100644 index 000000000..08bf96062 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner/grade.rs @@ -0,0 +1,1063 @@ +use super::*; + +/// The merge-path family codes — a *missing* of one of these is classified as a +/// merge-phase gap, not a same-table cascade bug. +const MERGE_CODES: [u32; 4] = [2397, 2649, 2664, 2671]; + +/// The TS1xxx codes the binder itself emits (strict-mode + private-identifier +/// checks) — they prove nothing about parse state, so a baseline carrying only +/// these does not trigger the recovery-AST carve-out (predicate v1, rule a). +const BIND_EMITTED_TS1XXX: [u32; 12] = [ + 1100, 1101, 1102, 1210, 1212, 1213, 1214, 1215, 1262, 1344, 1359, 18012, +]; + +/// The family baselines whose family diagnostics come from a standard-library +/// conflict. These **match** via the lib base; the classifier is kept as a +/// regression guard — a *missing* in one of these is bucketed to +/// [`MissingCause::Lib`] (pinned 0) rather than the hard-zero +/// [`MissingCause::Other`], so a lib-detection regression fails loudly. +const LIB_CONFLICT_BASELINES: [&str; 5] = [ + "intersectionsOfLargeUnions2.ts", + "jsExportMemberMergedWithModuleAugmentation2.ts", + "promiseDefinitionTest.ts", + "recursiveComplicatedClasses.ts", + "variableDeclarationInStrictMode1.ts", +]; + +/// The family baselines whose remaining missing family diagnostics are genuinely +/// deferred: they need the type engine (a literal-type or `unique symbol` computed +/// member name, resolved via tsgo's `lateBindMember`) that this bind+check +/// implementation has no counterpart for. A *missing* in one of these is bucketed to +/// [`MissingCause::DeferredLateBound`] (exact-pinned) rather than the hard-zero +/// [`MissingCause::Other`], so the honest residual stays visible without gating a +/// release on a type-engine gap. Basenames, mirroring `LIB_CONFLICT_BASELINES`. +const LATE_BOUND_BASELINES: [&str; 4] = [ + "dynamicNamesErrors.ts", + "symbolDeclarationEmit12.ts", + "symbolProperty37.ts", + "symbolProperty44.ts", +]; + +/// The flow-family baselines whose remaining missing TS7027 diagnostics are +/// genuinely deferred to the CFA type engine: the construction-only fast-path +/// shim (a strict subset of what tsgo reports) can't resolve them because they +/// come from tsgo's `isReachableFlowNode` fallback — never-returning call +/// signatures, `asserts` type predicates, switch exhaustiveness, and the +/// structural reachability fallback. A *missing* in one of these is bucketed to +/// [`MissingCause::DeferredCfa`] (exact-pinned) rather than the hard-zero +/// [`MissingCause::Other`], so the honest CFA residual stays visible without gating +/// a release on a type-engine gap. Basenames, mirroring `LATE_BOUND_BASELINES`. +/// +/// `assertionTypePredicates1.ts` never reaches the cfa bucket — its entry is a +/// defensive no-op kept for completeness. tsv currently **parse-rejects** it (an +/// over-rejection of the setter assertion-predicate form `set p(x: this is T)`, +/// counted in the parse-divergence census), so it is graded not at all; and were +/// the parser fixed, its baseline's non-bind TS1xxx (TS1228) would carve it out by +/// predicate v1 rule (a) instead — either way its 5 flow instances never land in +/// the cfa bucket. The live cfa residual is **26**: neverReturningFunctions1 +/// 22, exhaustiveSwitchStatements1 1, unreachableSwitchTypeofAny 1, +/// unreachableSwitchTypeofUnknown 1 (**25** across the four non-carved named +/// baselines), plus reachabilityChecks8 1 (the structural `isReachableFlowNode` +/// fallback tsv's faithful for-construction correctly doesn't emit). +const DEFERRED_CFA_BASELINES: [&str; 6] = [ + "neverReturningFunctions1.ts", + "assertionTypePredicates1.ts", + "exhaustiveSwitchStatements1.ts", + "unreachableSwitchTypeofAny.ts", + "unreachableSwitchTypeofUnknown.ts", + "reachabilityChecks8.ts", +]; + +/// Classify one missing family diagnostic by its baseline + code. Each +/// name-keyed cause also requires the code to be in its family (dup for +/// lib/late-bound, flow for cfa), mirroring the merge branch — a wrong-family +/// missing inside a named baseline falls through to the hard-zero `Other` +/// instead of being silently absorbed. This keeps the classification honest on +/// filtered/triage runs, not only on a full run where the code-keyed pins +/// catch it. +fn classify_missing(basename: &str, code: u32) -> MissingCause { + if MERGE_CODES.contains(&code) { + MissingCause::Merge + } else if LIB_CONFLICT_BASELINES.contains(&basename) && DUP_CODES.contains(&code) { + MissingCause::Lib + } else if LATE_BOUND_BASELINES.contains(&basename) && DUP_CODES.contains(&code) { + MissingCause::DeferredLateBound + } else if DEFERRED_CFA_BASELINES.contains(&basename) && FLOW_CODES.contains(&code) { + MissingCause::DeferredCfa + } else { + MissingCause::Other + } +} + +/// How a crash-excluded test fails, and whether its liveness is probeable. +// `GenuineAbort` is the designed flag for a future stack-overflow entry (none on +// the pinned corpus); it is un-probeable, so it is never re-run. +#[derive(Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +enum CrashKind { + /// A debug-build `debug_assert!` panic `catch_unwind` contains — probeable: + /// the sweep re-runs it under `catch_unwind` and FAILS if it no longer + /// panics (a fix landed, so the entry is stale and must be dropped). + CatchablePanic, + /// An uncatchable stack-overflow *abort* even on [`SKELETON_STACK`] — not + /// probeable (probing would abort the whole run), so it is trusted, not tested. + GenuineAbort, +} + +/// Tests that crash the tsv parser — carved out by basename, counted, and +/// reported (never silently). Each entry names its cause + kind; the list is a +/// tracked-defect ledger, not a way to hide bugs. A [`CrashKind::CatchablePanic`] +/// entry is liveness-probed every run (see [`probe_crash_exclusion`]). +const CRASH_EXCLUSIONS: &[(&str, CrashKind)] = &[ + // tsv_ts robustness bug: `export * from ;` (a non-string module + // specifier) trips a `debug_assert!(TokenKind::String)` in + // `parse_string_literal` (parser/mod.rs). Dev-profile only (debug_assert is + // compiled out in release), so `cargo run` — the gate's profile — panics. + // A future tsv_ts fix should reject the form gracefully; then drop this entry. + ( + "exportDeclarationInInternalModule.ts", + CrashKind::CatchablePanic, + ), +]; + +/// The [`CrashKind`] of a crash-excluded test, or `None` if not excluded. +fn crash_exclusion_kind(basename: &str) -> Option { + CRASH_EXCLUSIONS + .iter() + .find(|(n, _)| *n == basename) + .map(|(_, k)| *k) +} + +/// The baseline shape used to bucket a parse-rejected variant. +enum BaselineShape { + None, + Ts1xxxOnly, + Other, +} + +pub(super) fn run_skeleton_inner( + checkout: &Path, + options: &RunOptions, +) -> Result { + let start = Instant::now(); + let corpus = discover_corpus(checkout)?; + let baselines = discover_baselines(&baselines_dir(checkout))?; + + // Baseline lookup keyed by (suite, config-name) — exactly the runner's join. + let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); + for baseline in &baselines { + if let Some((suite, name)) = baseline.relative_path.split_once('/') { + ondisk.insert((suite, name.to_string()), baseline); + } + } + + let mut report = SkeletonReport::default(); + let mut resolver = LibResolver::new(checkout); + let watchdog = TestWatchdog::spawn(); + + for test in &corpus { + // Test-level triage filter (`--test `): match the roundtrip identity. + if !options.filter.keeps_test(&test.relative_path) { + continue; + } + watchdog.enter(&test.relative_path); + if SKIPPED_TESTS.contains(&test.basename.as_str()) { + continue; + } + if let Some(kind) = crash_exclusion_kind(&test.basename) { + report.excluded_crashes += 1; + // Liveness probe: a catchable-panic entry must still panic; if it no + // longer does, the ledger entry is stale and the run fails. + if kind == CrashKind::CatchablePanic && !probe_crash_exclusion(test) { + report.stale_exclusions.push(test.basename.clone()); + } + continue; + } + + let content = read_corpus_file(&test.path)?; + let settings = extract_settings(&content); + let units = split_units(&content, &test.basename); + + // Test-level in-scope filter: single-file (one non-config unit), not + // JSX-scoped, not JS-flavored. + if units.len() != 1 || is_config_file_name(&units[0].name) { + continue; + } + if is_jsx_scoped(test, &settings) || is_js_flavored(test, &settings) { + continue; + } + + let expansion = expand(&settings); + if expansion.cap_exceeded { + continue; + } + let in_scope: Vec<&Variant> = expansion + .variants + .iter() + .filter(|v| !variant_is_unsupported(&v.config)) + .collect(); + if in_scope.is_empty() { + continue; + } + + report.in_scope_tests += 1; + grade_test( + test, + &units[0], + &in_scope, + &ondisk, + &mut resolver, + options, + &mut report, + ); + } + watchdog.finish(); + + // Fold in the resolver's lib-base census (parse-once/fold-once counts + gates). + report.lib_files_bound = resolver.files_bound(); + report.lib_sets_built = resolver.sets_built(); + report.lib_parse_errors = { + let mut errors: Vec = resolver + .parse_errors() + .iter() + .map(|(f, e)| format!("{f}: {e}")) + .collect(); + errors.sort_unstable(); + errors + }; + report.lib_missing_files = { + let mut files: Vec = resolver.missing_files().to_vec(); + files.sort_unstable(); + files + }; + report.lib_unknown_names = { + let mut names: Vec = resolver.unknown_libs().to_vec(); + names.sort_unstable(); + names.dedup(); + names + }; + report.lib_external_no_globals = resolver.external_no_globals(); + + report.wall_ms = start.elapsed().as_millis(); + Ok(report) +} + +/// Re-run a catchable-panic crash exclusion under `catch_unwind`, returning +/// whether it **still panics**. A `false` (it completed) means the tracked defect +/// is fixed and the ledger entry is stale. +fn probe_crash_exclusion(test: &CorpusTest) -> bool { + let Ok(content) = read_corpus_file(&test.path) else { + // Can't read it -> can't disprove the panic; treat as still-live. + return true; + }; + let units = split_units(&content, &test.basename); + let arena = Bump::new(); + let source_units: Vec> = units + .iter() + .map(|u| SourceUnit::new(&u.name, &u.content)) + .collect(); + // Silence the default panic hook for the deliberate probe (we expect it to + // panic; the message would otherwise leak to stderr and read as a failure). + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let panicked = catch_unwind(AssertUnwindSafe(|| { + let _ = check_program(&source_units, &arena, &CheckOptions::default()); + })) + .is_err(); + std::panic::set_hook(prev); + panicked +} + +/// Extract a caught panic payload's message (the `&str` / `String` cases the +/// standard panic machinery produces). +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "(non-string panic payload)".to_string() + } +} + +/// Parse+bind one single-file test once, then — per in-scope variant — merge it +/// against that variant's resolved lib base and grade the result. Parse+bind is +/// variant-independent; only the merge (and thus the lib-conflict family) varies by +/// the resolved lib set, so a variant with a Promise/Symbol/… global conflicts at +/// one target and is clean at another. +/// Build `tsv_check`'s options from a variant's resolved directive config, mapping +/// the harness tri-state to the checker's. `preserveConstEnums` feeds +/// `ShouldPreserveConstEnums` (the `isolatedModules` contribution is not modeled — +/// a rare-in-dead-code residual that can only under-report). +pub(super) fn check_options_for(config: &BTreeMap) -> CheckOptions { + use crate::tsc_conformance::options_meta::{Tristate as OptTri, resolve_bool}; + let map = |t: OptTri| match t { + OptTri::Unset => tsv_check::Tristate::Unknown, + OptTri::False => tsv_check::Tristate::False, + OptTri::True => tsv_check::Tristate::True, + }; + CheckOptions { + allow_unreachable_code: map(resolve_bool(config, "allowunreachablecode")), + allow_unused_labels: map(resolve_bool(config, "allowunusedlabels")), + preserve_const_enums: resolve_bool(config, "preserveconstenums") == OptTri::True, + } +} + +fn grade_test( + test: &CorpusTest, + unit: &Unit, + in_scope: &[&Variant], + ondisk: &HashMap<(&str, String), &Baseline>, + resolver: &mut LibResolver, + options: &RunOptions, + report: &mut SkeletonReport, +) { + // Parse + bind on a fresh arena, contained against panics (the tsv parser is the + // panic source; the merge over owned data that follows is deterministic). + let arena = Bump::new(); + let bound = match catch_unwind(AssertUnwindSafe(|| { + bind_program(&[SourceUnit::new(&unit.name, &unit.content)], &arena) + })) { + Ok(bound) => bound, + Err(payload) => { + report.panics.push(PanicRecord { + test: test.relative_path.clone(), + payload: panic_payload_message(&*payload), + }); + return; + } + }; + + // The single unit's parse outcome (parse_reports is never empty for one input). + let reports = bound.parse_reports(); + let Some(&(_, parse)) = reports.first() else { + return; + }; + let parsed = matches!(parse, ParseReport::Parsed(_)); + + // The unit's line map — reused across the test's variants for the parsed case. + let line_map = parsed.then(|| LocationTracker::new_ecmascript_with_map(&unit.content)); + + for variant in in_scope { + let name = config_name(&test.basename, &variant.description); + let baseline = ondisk.get(&(test.suite, name.clone())).copied(); + + // Variant-level triage filters, applied BEFORE counting so a filtered sweep's + // denominators reflect only the graded slice (any active filter skips the pins). + if !options.filter.keeps_variant(&variant.config) { + continue; + } + if !options + .filter + .keeps_code(|code| baseline.is_some_and(|b| baseline_carries_code(b, code))) + { + continue; + } + if !options + .filter + .keeps_family(|code| baseline.is_some_and(|b| baseline_carries_code(b, code))) + { + continue; + } + + report.in_scope_variants += 1; + if variant.config.contains_key("moduledetection") { + report.module_detection_variants += 1; + } + + let verdict: &'static str = match parse { + ParseReport::Rejected { .. } => { + report.parse_rejected_total += 1; + match baseline_shape(baseline) { + BaselineShape::None => report.parse_rejected_no_baseline += 1, + BaselineShape::Ts1xxxOnly => report.parse_rejected_ts1xxx_only += 1, + BaselineShape::Other => report.parse_rejected_other += 1, + } + "parse_rejected" + } + ParseReport::Parsed(facts) => { + if facts.used_script_retry { + report.script_retry += 1; + } + // Resolve this variant's lib set (cached) and merge the bound program + // against it — the merge diagnostics are the lib-conflict family. The + // lib resolution (parse+bind of each `.d.ts`) is the only remaining + // panic source past the initial bind, so contain it per variant: a + // future lib parse panic is recorded, not sweep-fatal. + let check_opts = check_options_for(&variant.config); + let checked = catch_unwind(AssertUnwindSafe(|| { + let base = resolver.base_for(&variant.config); + let result = check_bound(&bound, base.as_deref(), &check_opts); + (base, result) + })); + let (base, result) = match checked { + Ok(pair) => pair, + Err(payload) => { + report.panics.push(PanicRecord { + test: test.relative_path.clone(), + payload: panic_payload_message(&*payload), + }); + report.failing_variants.push(FailingVariant { + suite: test.suite.to_string(), + config: name.clone(), + reason: "panic", + diff: format!("# {}/{name} (lib-resolution panic)\n", test.suite), + }); + record_manifest( + report, + options, + test, + &name, + baseline.is_some(), + true, + "panic", + ); + continue; + } + }; + let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); + + match baseline { + None => { + report.expect_clean_graded += 1; + if result.diagnostics.is_empty() { + report.clean_pass += 1; + "clean_pass" + } else { + report.clean_fail.push(CleanFail { + variant: format!("{}/{name}", test.suite), + diagnostics: result.diagnostics.len(), + }); + report.failing_variants.push(FailingVariant { + suite: test.suite.to_string(), + config: name.clone(), + reason: "clean_fail", + diff: render_clean_fail_diff(test, &name, &result.diagnostics), + }); + "clean_fail" + } + } + Some(b) => { + report.baselined_parsed += 1; + // `parsed` => `line_map` is `Some`; the `None` arm is dead. + let ours_family = match line_map.as_ref() { + Some((tracker, map)) => { + let mapper = LocationMapper { tracker, map }; + build_ours_family( + &result.diagnostics, + &unit.name, + &mapper, + lib_files, + ) + } + None => Vec::new(), + }; + grade_family(test, &name, b, &ours_family, report) + } + } + } + }; + + record_manifest( + report, + options, + test, + &name, + baseline.is_some(), + parsed, + verdict, + ); + } + + // Node total: counted once per test (all variants share the parse+bind). + report.total_nodes += bound.total_node_count(); +} + +/// Record one per-variant verdict row for `--emit-manifest` (a no-op unless a +/// manifest is being collected). +fn record_manifest( + report: &mut SkeletonReport, + options: &RunOptions, + test: &CorpusTest, + config: &str, + baselined: bool, + parsed: bool, + verdict: &'static str, +) { + if options.collect_manifest { + report.manifest_entries.push(ManifestEntry { + suite: test.suite.to_string(), + test: test.relative_path.clone(), + config: config.to_string(), + baselined, + parsed, + verdict, + }); + } +} + +/// Whether a baseline carries a given TS code (the `--code` / `--family` filters). +/// Uses the category-generic [`parse_base_diags`] (not the error-only +/// `parse_summary_block`), so a `--code 7027` / `--family flow` triage matches +/// suggestion- and message-category flow lines too, not just error-category ones. +fn baseline_carries_code(baseline: &Baseline, code: u32) -> bool { + let Ok(content) = std::fs::read_to_string(&baseline.path) else { + return false; + }; + let want = i32::try_from(code).unwrap_or(-1); + parse_base_diags(&content).iter().any(|d| d.code == want) +} + +/// Render one failing family variant's ours-vs-baseline diff for a `.diff` artifact. +fn render_family_diff( + test: &CorpusTest, + name: &str, + reason: &str, + ours: &[FamilyEntry], + base: &[FamilyEntry], +) -> String { + use std::fmt::Write as _; + let mut s = format!("# {}/{name} ({reason})\n", test.suite); + let _ = writeln!(s, "## ours family ({})", ours.len()); + for e in ours { + let _ = writeln!( + s, + " {}({},{}): TS{}", + e.key.file, e.key.line, e.key.col, e.key.code + ); + } + let _ = writeln!(s, "## baseline family ({})", base.len()); + for e in base { + let _ = writeln!( + s, + " {}({},{}): TS{}", + e.key.file, e.key.line, e.key.col, e.key.code + ); + } + s +} + +/// Render an expect-clean variant's spurious diagnostics for a `.diff` artifact. +fn render_clean_fail_diff(test: &CorpusTest, name: &str, diags: &[Diagnostic]) -> String { + use std::fmt::Write as _; + let mut s = format!( + "# {}/{name} (clean_fail — expect-clean but {} diagnostic(s))\n## ours ({})\n", + test.suite, + diags.len(), + diags.len(), + ); + for d in diags { + let _ = writeln!(s, " TS{} @ [{}..{}]", d.code, d.span.start, d.span.end); + } + s +} + +/// The number of program units in the single-file sweep (a lib FileId is +/// `>= UNITS_LEN`, translating to `lib_files[FileId - UNITS_LEN]`). +const UNITS_LEN: u32 = 1; + +/// Build our family multiset for one variant's diagnostics, resolving each FileId to +/// a display name. A **lib-file primary** (FileId beyond the program units) is +/// dropped — the baseline masks it (`lib.x.d.ts(--,--)`) — and a lib-sourced related +/// carries the lib file name with a masked location so it matches the baseline's +/// `lib.x.d.ts:--:--` related by `(code, file)`. +fn build_ours_family( + diagnostics: &[Diagnostic], + unit_name: &str, + mapper: &LocationMapper<'_>, + lib_files: &[String], +) -> Vec { + diagnostics + .iter() + .filter(|d| FAMILY_CODES.contains(&d.code)) + .filter_map(|d| { + let file = d.file?; + // A lib-file primary is masked in the baseline — exclude it. + if file.index() >= UNITS_LEN as usize { + return None; + } + let (_, pos) = mapper.pos_and_position(d.span.start); + let key = FamilyDiag { + file: unit_name.to_string(), + line: pos.line as u32, + col: pos.column as u32 + 1, + code: d.code, + }; + let related = d + .related + .iter() + .map(|r| resolve_related(r, unit_name, mapper, lib_files)) + .collect(); + Some(FamilyEntry { key, related }) + }) + .collect() +} + +/// Resolve one related-info entry's FileId to a [`RelatedKey`]: an in-unit related +/// carries its computed location; a lib-sourced related carries the lib file name +/// and a masked (`None`) location. +fn resolve_related( + r: &Diagnostic, + unit_name: &str, + mapper: &LocationMapper<'_>, + lib_files: &[String], +) -> RelatedKey { + match r.file { + Some(f) if f.index() < UNITS_LEN as usize => { + let (_, pos) = mapper.pos_and_position(r.span.start); + RelatedKey { + code: r.code, + file: unit_name.to_string(), + loc: Some((pos.line as u32, pos.column as u32 + 1)), + } + } + Some(f) => { + let idx = f.index() - UNITS_LEN as usize; + RelatedKey { + code: r.code, + file: lib_files.get(idx).cloned().unwrap_or_default(), + loc: None, + } + } + None => RelatedKey { + code: r.code, + file: unit_name.to_string(), + loc: None, + }, + } +} + +/// One family diagnostic in baseline coordinates: `(file, 1-based line, 1-based +/// UTF-16 col, code)`. +#[derive(Clone, PartialEq, Eq, Hash)] +struct FamilyDiag { + file: String, + line: u32, + col: u32, + code: u32, +} + +/// One related-info entry in baseline coordinates: `(code, file, location)`. A +/// `--,--` (default-library) location is [`None`] and compares by code+file only. +#[derive(Clone, PartialEq, Eq, Hash)] +struct RelatedKey { + code: u32, + file: String, + loc: Option<(u32, u32)>, +} + +/// A family primary plus its related-info entries — the unit the related-info +/// channel grades (a matched primary's related sets are compared as multisets). +struct FamilyEntry { + key: FamilyDiag, + related: Vec, +} + +/// Grade one parsed-with-baseline variant's family diagnostics against its +/// baseline, folding the buckets into `report` and returning the per-variant +/// verdict (for the `--emit-manifest` row). Applies predicate v1 rule (a) +/// (recovery-AST carve-out) first, then the primary-code channel and — for the +/// matched primaries — the independent related-info channel. +fn grade_family( + test: &CorpusTest, + name: &str, + baseline: &Baseline, + ours: &[FamilyEntry], + report: &mut SkeletonReport, +) -> &'static str { + let Ok(content) = std::fs::read_to_string(&baseline.path) else { + return "baseline_unreadable"; + }; + let base_all = parse_base_diags(&content); + + // Predicate v1 rule (a): tsv parses clean (it did — this variant parsed) and + // the baseline carries a non-bind TS1xxx code -> recovery-AST incomparable. + let has_nonbind_ts1xxx = base_all.iter().any(|d| { + (1000..2000).contains(&d.code) + && u32::try_from(d.code).is_ok_and(|c| !BIND_EMITTED_TS1XXX.contains(&c)) + }); + let base_family: Vec = base_all + .iter() + .filter_map(|d| { + let code = u32::try_from(d.code).ok()?; + if !FAMILY_CODES.contains(&code) { + return None; + } + Some(FamilyEntry { + key: FamilyDiag { + file: d.file.clone()?, + line: d.line?, + col: d.col?, + code, + }, + related: d.related.clone(), + }) + }) + .collect(); + let has_family = !base_family.is_empty(); + + if has_nonbind_ts1xxx { + report.carve_out_rule_a += 1; + if has_family { + report.carve_out_rule_a_family += 1; + } + return "carve_out"; + } + + report.family_graded_variants += 1; + if has_family { + report.family_positive_variants += 1; + } + + let ours_keys: Vec = ours.iter().map(|e| e.key.clone()).collect(); + let base_keys: Vec = base_family.iter().map(|e| e.key.clone()).collect(); + let buckets = family_buckets(&ours_keys, &base_keys); + report.family_match += buckets.matched; + report.family_span_mismatch += buckets.span_mismatch; + report.family_extra += buckets.extra; + for (code, count) in &buckets.matched_by_code { + *report.family_match_by_code.entry(*code).or_default() += *count; + } + if buckets.extra > 0 && report.extra_samples.len() < 20 { + report + .extra_samples + .push(format!("{}/{name} (+{})", test.suite, buckets.extra)); + } + if buckets.span_mismatch > 0 && report.span_mismatch_samples.len() < 20 { + report.span_mismatch_samples.push(format!( + "{}/{name} (~{})", + test.suite, buckets.span_mismatch + )); + } + // An unexplained hard-fail bucket (extra / span mismatch) gets a rendered + // ours-vs-baseline diff artifact; the pinned deferred-late-bound `missing` is + // expected, so it does not. + if buckets.extra > 0 || buckets.span_mismatch > 0 { + let reason = if buckets.extra > 0 { + "family_extra" + } else { + "family_span_mismatch" + }; + report.failing_variants.push(FailingVariant { + suite: test.suite.to_string(), + config: name.to_string(), + reason, + diff: render_family_diff(test, name, reason, ours, &base_family), + }); + } + for (code, count) in &buckets.missing_by_code { + report.family_missing += *count; + *report.family_missing_by_code.entry(*code).or_default() += *count; + let cause = classify_missing(&test.basename, *code); + *report.missing_by_cause.entry(cause).or_default() += *count; + if cause == MissingCause::Other && report.missing_other_samples.len() < 20 { + report + .missing_other_samples + .push(format!("{}/{name} TS{code} x{count}", test.suite)); + } + } + + // The related-info channel (independent of the primary verdict): grade related + // multisets only for the primaries that matched. + let rel = grade_related(ours, &base_family); + report.related_match += rel.matched; + report.related_span_mismatch += rel.span_mismatch; + report.related_extra += rel.extra; + report.related_missing += rel.missing; + if rel.extra > 0 && report.related_extra_samples.len() < 20 { + report + .related_extra_samples + .push(format!("{}/{name} (+{})", test.suite, rel.extra)); + } + if rel.missing > 0 && report.related_missing_samples.len() < 20 { + report + .related_missing_samples + .push(format!("{}/{name} (-{})", test.suite, rel.missing)); + } + + // The per-variant verdict (extra dominates — it is the hard gate). + if buckets.extra > 0 { + "family_extra" + } else if buckets.span_mismatch > 0 { + "family_span_mismatch" + } else if !buckets.missing_by_code.is_empty() { + "family_missing" + } else if has_family { + "family_match" + } else { + "baselined_clean" + } +} + +/// A baseline summary diagnostic with its parsed related-info entries. +struct BaseDiag { + file: Option, + line: Option, + col: Option, + /// The `TS` (i32 — the harness's `TS-1` and non-family codes appear here). + code: i32, + related: Vec, +} + +/// Parse a baseline into summary diagnostics with related info, via the full +/// [`parse_baseline`] model (100% of the pinned baselines round-trip through it). +/// Falls back to the related-free summary parse on the rare structural surprise, +/// so the primary channel never shifts. +fn parse_base_diags(content: &str) -> Vec { + use crate::tsc_conformance::baseline::Loc; + match parse_baseline(content) { + Ok(parsed) => parsed + .diags + .iter() + .map(|d| { + let (line, col) = match d.loc { + Some(Loc::Numbered { line, col }) => (Some(line), Some(col)), + _ => (None, None), + }; + let related = d + .related + .iter() + .filter_map(|s| parse_related_line(s)) + .collect(); + BaseDiag { + file: d.file.clone(), + line, + col, + code: d.code, + related, + } + }) + .collect(), + Err(_) => parse_summary_block(content) + .into_iter() + .map(|d| BaseDiag { + file: d.file, + line: d.line, + col: d.col, + code: d.code as i32, + related: Vec::new(), + }) + .collect(), + } +} + +/// Parse one `!!! related TS ::: ` line into a +/// [`RelatedKey`], or `None` for a chain-continuation line (no `!!! related` +/// prefix). A `--:--` location parses to [`None`] (a masked default-lib position). +fn parse_related_line(line: &str) -> Option { + let rest = line.strip_prefix("!!! related TS")?; + let end = rest.find(|c: char| !c.is_ascii_digit())?; + let code: u32 = rest.get(..end)?.parse().ok()?; + let after = rest.get(end..)?.strip_prefix(' ')?; // `::: ` + // The first `": "` separates the location from the message (a filename holds + // no space, and line/col are digits-or-`--`). + let boundary = after.find(": ")?; + let locpart = after.get(..boundary)?; // `::` + let (rest2, col) = locpart.rsplit_once(':')?; + let (file, line_s) = rest2.rsplit_once(':')?; + let loc = if line_s == "--" && col == "--" { + None + } else { + Some((line_s.parse().ok()?, col.parse().ok()?)) + }; + Some(RelatedKey { + code, + file: file.to_string(), + loc, + }) +} + +/// The related-info buckets across a variant's matched primaries. +#[derive(Default)] +struct RelatedBuckets { + matched: usize, + extra: usize, + span_mismatch: usize, + missing: usize, +} + +/// Grade related-info multisets for the primaries that match by +/// `(file,line,col,code)`. Ours and the baseline are grouped by primary key; +/// matched primaries are paired positionally and their related sets diffed +/// (exact `(code,file,loc)` match, masked `--,--` by `(code,file)`, then +/// `(code,file)` span-mismatch pairing of the leftovers). +fn grade_related(ours: &[FamilyEntry], base: &[FamilyEntry]) -> RelatedBuckets { + let mut ours_by: HashMap<&FamilyDiag, Vec<&[RelatedKey]>> = HashMap::new(); + for e in ours { + ours_by.entry(&e.key).or_default().push(&e.related); + } + let mut base_by: HashMap<&FamilyDiag, Vec<&[RelatedKey]>> = HashMap::new(); + for e in base { + base_by.entry(&e.key).or_default().push(&e.related); + } + + let mut out = RelatedBuckets::default(); + for (key, ours_sets) in &ours_by { + let Some(base_sets) = base_by.get(key) else { + continue; + }; + let paired = ours_sets.len().min(base_sets.len()); + for i in 0..paired { + related_diff(ours_sets[i], base_sets[i], &mut out); + } + } + out +} + +/// Diff one matched primary's related multisets, folding into `out`. +fn related_diff(ours: &[RelatedKey], base: &[RelatedKey], out: &mut RelatedBuckets) { + // Exact `(code,file,loc)` matches first. + let mut ours_counts: HashMap<&RelatedKey, usize> = HashMap::new(); + for r in ours { + *ours_counts.entry(r).or_default() += 1; + } + let mut base_counts: HashMap<&RelatedKey, usize> = HashMap::new(); + for r in base { + *base_counts.entry(r).or_default() += 1; + } + // Leftovers grouped by `(code, file)` for masked-match and span-mismatch pairing. + let mut left_ours: HashMap<(u32, &str), usize> = HashMap::new(); + let mut left_base_located: HashMap<(u32, &str), usize> = HashMap::new(); + let mut left_base_masked: HashMap<(u32, &str), usize> = HashMap::new(); + + for (r, &oc) in &ours_counts { + let bc = base_counts.get(*r).copied().unwrap_or(0); + let m = oc.min(bc); + out.matched += m; + if oc > m { + *left_ours.entry((r.code, r.file.as_str())).or_default() += oc - m; + } + } + for (r, &bc) in &base_counts { + let oc = ours_counts.get(*r).copied().unwrap_or(0); + let m = oc.min(bc); + if bc > m { + let bucket = if r.loc.is_none() { + &mut left_base_masked + } else { + &mut left_base_located + }; + *bucket.entry((r.code, r.file.as_str())).or_default() += bc - m; + } + } + + // Masked baseline related (default-lib `--,--`) matches ours by `(code,file)`. + for (key, bcount) in &mut left_base_masked { + if let Some(ocount) = left_ours.get_mut(key) { + let m = (*ocount).min(*bcount); + out.matched += m; + *ocount -= m; + *bcount -= m; + } + } + + // Remaining located leftovers: `(code,file)` pairing = span mismatch; the rest + // is extra (ours) / missing (baseline). + let keys: std::collections::HashSet<(u32, &str)> = left_ours + .keys() + .chain(left_base_located.keys()) + .chain(left_base_masked.keys()) + .copied() + .collect(); + for key in keys { + let oc = left_ours.get(&key).copied().unwrap_or(0); + let bc = left_base_located.get(&key).copied().unwrap_or(0) + + left_base_masked.get(&key).copied().unwrap_or(0); + let sm = oc.min(bc); + out.span_mismatch += sm; + out.extra += oc - sm; + out.missing += bc - sm; + } +} + +/// The four family buckets for one variant. +struct FamilyBuckets { + matched: usize, + extra: usize, + span_mismatch: usize, + /// The exact matches, per code (for the committed report's per-code table). + matched_by_code: HashMap, + /// The unattributed misses, per code (for cause classification). + missing_by_code: HashMap, +} + +/// Compare our family multiset against the baseline's: exact `(file,line,col,code)` +/// matches, then greedy `(file,code)` span-mismatch pairing of the leftovers, with +/// the residue split into extra (ours) and missing (baseline). +fn family_buckets(ours: &[FamilyDiag], base: &[FamilyDiag]) -> FamilyBuckets { + let mut ours_counts: HashMap<&FamilyDiag, usize> = HashMap::new(); + for d in ours { + *ours_counts.entry(d).or_default() += 1; + } + let mut base_counts: HashMap<&FamilyDiag, usize> = HashMap::new(); + for d in base { + *base_counts.entry(d).or_default() += 1; + } + + let mut matched = 0usize; + let mut matched_by_code: HashMap = HashMap::new(); + // Leftover counts grouped by (file, code) for span-mismatch pairing. + let mut left_ours: HashMap<(&str, u32), usize> = HashMap::new(); + let mut left_base: HashMap<(&str, u32), usize> = HashMap::new(); + + for (d, &oc) in &ours_counts { + let bc = base_counts.get(d).copied().unwrap_or(0); + let m = oc.min(bc); + matched += m; + if m > 0 { + *matched_by_code.entry(d.code).or_default() += m; + } + if oc > m { + *left_ours.entry((d.file.as_str(), d.code)).or_default() += oc - m; + } + } + for (d, &bc) in &base_counts { + let oc = ours_counts.get(d).copied().unwrap_or(0); + let m = oc.min(bc); + if bc > m { + *left_base.entry((d.file.as_str(), d.code)).or_default() += bc - m; + } + } + + // Pair leftovers within each (file, code) group: min = span mismatch, the + // ours residue = extra, the baseline residue = missing. + let mut span_mismatch = 0usize; + let mut extra = 0usize; + let mut missing_by_code: HashMap = HashMap::new(); + let keys: std::collections::HashSet<(&str, u32)> = + left_ours.keys().chain(left_base.keys()).copied().collect(); + for &(file, code) in &keys { + let oc = left_ours.get(&(file, code)).copied().unwrap_or(0); + let bc = left_base.get(&(file, code)).copied().unwrap_or(0); + let sm = oc.min(bc); + span_mismatch += sm; + extra += oc - sm; + if bc - sm > 0 { + *missing_by_code.entry(code).or_default() += bc - sm; + } + } + + FamilyBuckets { + matched, + extra, + span_mismatch, + matched_by_code, + missing_by_code, + } +} + +/// Classify a parse-rejected variant's baseline shape for the census. +fn baseline_shape(baseline: Option<&Baseline>) -> BaselineShape { + let Some(baseline) = baseline else { + return BaselineShape::None; + }; + let Ok(content) = std::fs::read_to_string(&baseline.path) else { + return BaselineShape::Other; + }; + let diags = parse_summary_block(&content); + if !diags.is_empty() && diags.iter().all(|d| (1000..2000).contains(&d.code)) { + BaselineShape::Ts1xxxOnly + } else { + BaselineShape::Other + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/runner/mod.rs b/crates/tsv_debug/src/tsc_conformance/runner/mod.rs new file mode 100644 index 000000000..7def15774 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner/mod.rs @@ -0,0 +1,463 @@ +//! The conformance runner: drive `tsv_check` over the in-scope corpus and +//! grade it against tsgo's committed `.errors.txt` baselines. +//! +//! The runner layers the checker leg on the corpus substrate — corpus index, +//! directive parser, variant expansion, the unsupported-option skip classes: +//! for every **in-scope** variant (single-file, non-JSX, non-JS-flavored, not +//! skipped, not an unsupported-option variant) it parses the unit via +//! `tsv_check`'s goal rule, binds, merges against the variant's lib base, and +//! grades the result on three channels. Every **expect-clean** in-scope +//! variant (one with no on-disk baseline) must grade clean (zero diagnostics); +//! the graded **family** ([`FAMILY_CODES`] — the bind/merge duplicate-conflict +//! sub-family [`DUP_CODES`] plus the flow-construction sub-family [`FLOW_CODES`], +//! TS7027/TS7028) is compared as codes+spans multisets — extra = 0 is the hard +//! gate, missing is classified by deferred cause (merge / lib / late-bound / cfa, +//! else the hard-zero `other`); and **related-info** on matched family primaries +//! is graded as its own channel. Zero panics, always. +//! +//! A single-file test's variants all parse identically (the goal rule is +//! directive-independent), so parse+bind runs **once per test** +//! (`bind_program`) and merge+check runs once per distinct lib set among its +//! variants (`check_bound`), with the outcome attributed to each in-scope +//! variant. +//! +//! The **parse-divergence census** (informational, not gated) counts in-scope +//! variants tsv parse-rejects, split by baseline shape (none / TS1xxx-only / +//! other), plus how many parses needed the `Goal::Script` retry — the standing +//! window on tsv's parser vs tsgo's implied parse verdict (a tsv over-rejection +//! shows up as a rejected variant against an absent-or-non-1xxx baseline). +//! +//! Crash containment: the whole sweep runs on a generous-stack worker thread +//! (the corpus has pathological-nesting tests and tsv's parser has no depth +//! guard), and each test's check is wrapped in `catch_unwind` so a panic lands +//! in its own bucket instead of killing the run. A stack-overflow *abort* can't +//! be caught; the [`CRASH_EXCLUSIONS`] list carves out crashers by kind — the +//! genuine-abort class is empty on the pinned corpus (every current entry is a +//! catchable panic tracking a tsv parser bug, liveness-probed each run). +// +// tsgo: internal/compiler/program.go GetDiagnosticsOfAnyProgram (the pipeline) +// tsgo: internal/testrunner/compiler_runner.go (the in-scope selection) + +mod filter; +mod grade; +mod report; +#[cfg(test)] +mod tests; +mod watchdog; + +pub use filter::{FamilyFilter, RunFilter, RunOptions}; +pub use report::{CheckTestReport, SkeletonReport}; + +use grade::{check_options_for, run_skeleton_inner}; +use report::DiagLine; +use watchdog::TestWatchdog; + +use crate::tsc_conformance::baseline::{parse_baseline, parse_summary_block}; +use crate::tsc_conformance::corpus::{CorpusTest, discover_corpus, read_corpus_file}; +use crate::tsc_conformance::directives::{Unit, extract_settings, split_units}; +use crate::tsc_conformance::discovery::{Baseline, baselines_dir, discover_baselines}; +use crate::tsc_conformance::index::{is_js_flavored, is_jsx_scoped}; +use crate::tsc_conformance::libs::LibResolver; +use crate::tsc_conformance::options_meta::{ + SKIPPED_TESTS, is_config_file_name, variant_is_unsupported, +}; +use crate::tsc_conformance::variants::{Variant, config_name, expand}; +use bumpalo::Bump; +use std::collections::{BTreeMap, HashMap}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; +use std::time::Instant; +use tsv_check::{ + CheckOptions, Diagnostic, FileId, ParseReport, SourceUnit, bind_file, bind_program, build_flow, + check_bound, check_program, render_flow_dot, +}; +use tsv_lang::{LocationMapper, LocationTracker}; + +/// The full set of codes the gate grades — the bind/merge duplicate-conflict +/// family ([`DUP_CODES`]) plus the flow-construction family ([`FLOW_CODES`]). +/// Duplicate-conflict: TS2300 (duplicate identifier), TS2451 (block-scoped +/// redeclare), TS2567 (enum-merge), TS2528 (multiple default exports), plus the +/// merge-path codes TS2397/2649/2664/2671 (emitted from the globals-merge phase +/// rather than the same-table cascade). Flow: TS7027 (unreachable code), TS7028 +/// (unused label), emitted from the post-bind flow-construction shim. +const FAMILY_CODES: [u32; 10] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671, 7027, 7028]; + +/// The duplicate/conflict sub-family (bind + merge + the check-time TS2300 +/// members/type-parameters pass) — the partition used for the `--family dup` +/// filter and the sub-family report lines. +const DUP_CODES: [u32; 8] = [2300, 2451, 2567, 2528, 2397, 2649, 2664, 2671]; + +/// The flow-construction sub-family (TS7027 unreachable code / TS7028 unused +/// label) — the partition used for the `--family flow` filter and the sub-family +/// report lines. +const FLOW_CODES: [u32; 2] = [7027, 7028]; + +/// One graded code family: its `--family` filter token (also its report label) +/// and its code set. **Adding a family** = a codes const + a row here + a row in +/// the CLI command's per-family pin table (+ a [`MissingCause`] variant and +/// ledger if it brings a new deferred cause) — the sweep, filter parsing, +/// sub-family accessors, and report lines all read this table. +pub struct GradedFamily { + /// The `--family` filter token / report label. + pub key: &'static str, + /// The family's TS codes. + pub codes: &'static [u32], +} + +/// The graded families, in report order. +pub const FAMILIES: [GradedFamily; 2] = [ + GradedFamily { + key: "dup", + codes: &DUP_CODES, + }, + GradedFamily { + key: "flow", + codes: &FLOW_CODES, + }, +]; + +// `FAMILY_CODES` is maintained by hand as the union of every `FAMILIES` row — +// pin the agreement at compile time (order-preserving concatenation, dup then +// flow), so a family edit that forgets one side cannot build. +const _: () = { + assert!(FAMILY_CODES.len() == DUP_CODES.len() + FLOW_CODES.len()); + let mut i = 0; + while i < DUP_CODES.len() { + assert!(FAMILY_CODES[i] == DUP_CODES[i]); + i += 1; + } + let mut j = 0; + while j < FLOW_CODES.len() { + assert!(FAMILY_CODES[DUP_CODES.len() + j] == FLOW_CODES[j]); + j += 1; + } +}; + +/// Why a graded family baseline diagnostic is missing — the classifier's +/// verdict, tallied keyed in `SkeletonReport::missing_by_cause`. Every non- +/// [`MissingCause::Other`] cause is exact-pinned in the CLI command's cause-pin +/// table; `Other` is the HARD-zero invariant (enforced even on filtered runs). +/// **Adding a deferred cause** (a new family's type-engine residual) = a variant +/// here + a ledger const + an arm in [`classify_missing`] + a pin-table row. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MissingCause { + /// The merge phase owns the code ([`MERGE_CODES`]). + Merge, + /// A [`LIB_CONFLICT_BASELINES`] test missing a dup-family code (absent lib + /// binding — a lib-detection regression guard, pinned 0). + Lib, + /// A [`LATE_BOUND_BASELINES`] test missing a dup-family code — the + /// type-engine `lateBindMember` residual (exact-pinned). + DeferredLateBound, + /// A [`DEFERRED_CFA_BASELINES`] test missing a flow-family code — the + /// `isReachableFlowNode` residual (exact-pinned). + DeferredCfa, + /// Unclassified — a same-table cascade / flow-construction bug. **HARD + /// gate: zero**, an invariant even on filtered triage runs. + Other, +} + +/// Worker-thread stack for the sweep: the corpus has deeply-nested tests and +/// tsv's recursive-descent parser has no depth guard, so the default 8 MiB +/// overflows. 512 MiB is virtual-only reserve on Linux. +const SKELETON_STACK: usize = 512 * 1024 * 1024; + +/// One expect-clean variant that graded non-clean (should never happen while the +/// checker is a no-op — a non-empty list is a gate failure). +#[derive(Debug, Clone, serde::Serialize)] +pub struct CleanFail { + /// The `suite/config_name` baseline-space identity. + pub variant: String, + /// The number of diagnostics the checker (wrongly) emitted. + pub diagnostics: usize, +} + +/// One test whose check panicked (caught) — a gate failure. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PanicRecord { + /// The corpus test's relative path. + pub test: String, + /// The panic payload's message (downcast to `&str`/`String`), for triage. + pub payload: String, +} + +/// One graded variant's verdict for the `--emit-manifest` JSON (the per-variant +/// row — the test262-manifest analog). Collected only when a manifest is requested. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ManifestEntry { + /// The suite (`compiler` / `conformance`). + pub suite: String, + /// The corpus test's relative path. + pub test: String, + /// The joined baseline name (the variant identity). + pub config: String, + /// Whether the variant has an on-disk baseline. + pub baselined: bool, + /// Whether tsv parsed the unit (`false` = parse-rejected). + pub parsed: bool, + /// The per-variant verdict (see [`grade_test`] / [`grade_family`]). + pub verdict: &'static str, +} + +/// One failing variant with a pre-rendered ours-vs-baseline diff — written to a +/// `.diff` artifact when a run's gates fail (a regression aid; empty when green). +#[derive(Debug, Clone, serde::Serialize)] +pub struct FailingVariant { + /// The suite (`compiler` / `conformance`). + pub suite: String, + /// The joined baseline name (the artifact basename). + pub config: String, + /// Why it failed — the same vocabulary as the per-variant verdict + /// (`family_extra` / `family_span_mismatch` / `clean_fail` / `panic`). + pub reason: &'static str, + /// The rendered ours-vs-baseline text (file-artifact only — not in `--json`). + #[serde(skip)] + pub diff: String, +} + +/// Run the skeleton sweep on a generous-stack worker thread. +/// +/// # Errors +/// +/// Returns an error string if the worker cannot spawn, the worker panics +/// outside a contained per-test check, or corpus discovery fails. +pub fn run_skeleton(checkout: &Path, options: &RunOptions) -> Result { + let checkout = checkout.to_path_buf(); + let options = options.clone(); + let handle = std::thread::Builder::new() + .stack_size(SKELETON_STACK) + .name("tsc-skeleton".to_string()) + .spawn(move || run_skeleton_inner(&checkout, &options)) + .map_err(|e| format!("spawn skeleton worker: {e}"))?; + handle + .join() + .map_err(|_| "skeleton worker panicked".to_string())? +} + +/// Run one corpus test (optionally one variant) and build its check-test report. +/// +/// `name` matches a corpus test by exact relative path or exact basename. +/// +/// # Errors +/// +/// Returns an error string when the test is not found, the match is ambiguous, +/// the requested variant does not exist, or corpus discovery fails. +pub fn check_one( + checkout: &Path, + name: &str, + variant_filter: Option<(String, String)>, +) -> Result { + let corpus = discover_corpus(checkout)?; + let baselines = discover_baselines(&baselines_dir(checkout))?; + + let matches: Vec<&CorpusTest> = corpus + .iter() + .filter(|t| t.relative_path == name || t.basename == name) + .collect(); + let test = match matches.as_slice() { + [] => return Err(format!("no corpus test matches {name:?}")), + [one] => *one, + many => { + let paths: Vec = many + .iter() + .map(|t| format!("{}/{}", t.suite, t.relative_path)) + .collect(); + return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); + } + }; + + let content = read_corpus_file(&test.path)?; + let settings = extract_settings(&content); + let units = split_units(&content, &test.basename); + + // Pick the variant. + let expansion = expand(&settings); + let variant = select_variant(&expansion.variants, variant_filter.as_ref())?; + let baseline_name = config_name(&test.basename, &variant.description); + + // Join the baseline. + let mut ondisk: HashMap<(&str, String), &Baseline> = HashMap::new(); + for baseline in &baselines { + if let Some((suite, n)) = baseline.relative_path.split_once('/') { + ondisk.insert((suite, n.to_string()), baseline); + } + } + let baseline = ondisk.get(&(test.suite, baseline_name.clone())).copied(); + + // Parse + bind every unit, then merge against the selected variant's lib base. + let arena = Bump::new(); + let source_units: Vec> = units + .iter() + .map(|u| SourceUnit::new(&u.name, &u.content)) + .collect(); + let bound = bind_program(&source_units, &arena); + let mut resolver = LibResolver::new(checkout); + let base = resolver.base_for(&variant.config); + let lib_files = base.as_ref().map_or(&[][..], |b| b.lib_files.as_slice()); + let result = check_bound(&bound, base.as_deref(), &check_options_for(&variant.config)); + + // Resolve each diagnostic's FileId to a display line: a program unit carries its + // (line, col); a lib file carries the lib name with a masked location. + let resolve_line = |d: &Diagnostic| -> DiagLine { + let units_len = units.len(); + match d.file { + Some(f) if f.index() < units_len => { + let (line, col) = units.get(f.index()).map_or((None, None), |u| { + let (t, m) = LocationTracker::new_ecmascript_with_map(&u.content); + let (_, pos) = LocationMapper { + tracker: &t, + map: &m, + } + .pos_and_position(d.span.start); + (Some(pos.line as u32), Some(pos.column as u32 + 1)) + }); + DiagLine { + file: units.get(f.index()).map(|u| u.name.clone()), + line, + col, + code: d.code, + related: Vec::new(), + } + } + Some(f) => DiagLine { + file: lib_files.get(f.index() - units_len).cloned(), + line: None, + col: None, + code: d.code, + related: Vec::new(), + }, + None => DiagLine { + file: None, + line: None, + col: None, + code: d.code, + related: Vec::new(), + }, + } + }; + let ours: Vec = result + .diagnostics + .iter() + .map(|d| { + let mut line = resolve_line(d); + line.related = d.related.iter().map(&resolve_line).collect(); + line + }) + .collect(); + let parse_error = result.files.iter().find_map(|f| match &f.parse { + ParseReport::Rejected { message } => Some(message.clone()), + ParseReport::Parsed(_) => None, + }); + + let baseline_summary = match baseline { + Some(b) => std::fs::read_to_string(&b.path) + .map(|c| { + parse_summary_block(&c) + .into_iter() + .map(|d| DiagLine { + file: d.file, + line: d.line, + col: d.col, + code: d.code, + related: Vec::new(), + }) + .collect() + }) + .unwrap_or_default(), + None => Vec::new(), + }; + + Ok(CheckTestReport { + test: test.relative_path.clone(), + suite: test.suite.to_string(), + variant: if variant.description.is_empty() { + "(default)".to_string() + } else { + variant.description.clone() + }, + baseline: baseline.map(|_| baseline_name), + expect_clean: baseline.is_none(), + parse_rejected: result.parse_rejected, + parse_error, + ours, + baseline_summary, + }) +} + +/// Build the flow graph of a corpus test's **first** unit and render it to DOT +/// (the `check-test --dump-flow` product). Parses under the goal rule (Module, +/// then a Script retry), binds (F0), builds the flow product (F1), and renders +/// through `tsv_check`'s source-aware DOT renderer. Keeps the `BoundFile` alive +/// so the renderer can slice subject-node source text from its span column. +pub fn dump_flow_dot(checkout: &Path, name: &str) -> Result { + let corpus = discover_corpus(checkout)?; + let matches: Vec<&CorpusTest> = corpus + .iter() + .filter(|t| t.relative_path == name || t.basename == name) + .collect(); + let test = match matches.as_slice() { + [] => return Err(format!("no corpus test matches {name:?}")), + [one] => *one, + many => { + let paths: Vec = many + .iter() + .map(|t| format!("{}/{}", t.suite, t.relative_path)) + .collect(); + return Err(format!("{name:?} is ambiguous: {}", paths.join(", "))); + } + }; + + let content = read_corpus_file(&test.path)?; + let units = split_units(&content, &test.basename); + let unit = units + .first() + .ok_or_else(|| "test has no units".to_string())?; + + let arena = Bump::new(); + // The goal rule (Module first, Script retry) — the same rule bind_program + // uses, inlined here because --dump-flow keeps the BoundFile for rendering. + let program = match tsv_ts::parse_with_goal(&unit.content, tsv_ts::Goal::Module, &arena) { + Ok(p) => p, + Err(module_err) => tsv_ts::parse_with_goal(&unit.content, tsv_ts::Goal::Script, &arena) + .map_err(|_| format!("parse error: {module_err}"))?, + }; + let bound = bind_file(&program, &unit.content, FileId::ROOT); + let flow = build_flow(&program, &unit.content, &bound); + Ok(render_flow_dot(&flow, &bound.spans, &unit.content)) +} + +/// Select a variant by an optional `k=v` filter (config match, lowercased key); +/// with no filter the first (usually the unvaried) variant. +fn select_variant<'a>( + variants: &'a [Variant], + filter: Option<&(String, String)>, +) -> Result<&'a Variant, String> { + match filter { + None => variants + .first() + .ok_or_else(|| "test has no variants".to_string()), + Some((key, value)) => { + let key = key.to_lowercase(); + variants + .iter() + .find(|v| v.config.get(&key).map(String::as_str) == Some(value.as_str())) + .ok_or_else(|| { + let available: Vec<&str> = variants + .iter() + .map(|v| { + if v.description.is_empty() { + "(default)" + } else { + &v.description + } + }) + .collect(); + format!( + "no variant with {key}={value}; available: {}", + available.join(", ") + ) + }) + } + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/runner/report.rs b/crates/tsv_debug/src/tsc_conformance/runner/report.rs new file mode 100644 index 000000000..a640a8d50 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner/report.rs @@ -0,0 +1,425 @@ +use super::*; + +/// The skeleton sweep report. +#[derive(Debug, Clone, serde::Serialize, Default)] +pub struct SkeletonReport { + /// Tests that passed the test-level in-scope filter and have >=1 in-scope + /// variant. + pub in_scope_tests: usize, + /// In-scope variants graded (parsed or parse-rejected). + pub in_scope_variants: usize, + /// In-scope variants that parsed and have no on-disk baseline (expect-clean). + pub expect_clean_graded: usize, + /// Expect-clean variants that graded clean (zero diagnostics). Gate: must + /// equal `expect_clean_graded`. + pub clean_pass: usize, + /// Expect-clean variants that graded non-clean (gate failure list). + pub clean_fail: Vec, + /// In-scope variants that parsed and DO have a baseline. + pub baselined_parsed: usize, + + // --- family grading --- + /// Parsed-with-baseline variants family-graded (not carved by predicate v1). + pub family_graded_variants: usize, + /// ...of those, whose baseline carries at least one family code. + pub family_positive_variants: usize, + /// Family diagnostics that matched (file, line, col, code). + pub family_match: usize, + /// Family baseline diagnostics with no matching diagnostic of ours (classified + /// below). Expected to be all merge/lib until S4/S5 land. + pub family_missing: usize, + /// Family diagnostics we emit that the baseline lacks. **Gate: must be 0.** + pub family_extra: usize, + /// Right code + file, wrong position (greedy-paired). + pub family_span_mismatch: usize, + + // --- related-info grading (its own pinned channel; does NOT gate the + // per-variant primary verdict) — graded only for matched primaries --- + /// Related-info entries that matched (code, file, line, col). + pub related_match: usize, + /// Baseline related entries with no matching related of ours. + pub related_missing: usize, + /// Related entries we emit the baseline lacks. + pub related_extra: usize, + /// Right code + file, wrong position (greedy-paired). + pub related_span_mismatch: usize, + /// Sample related over-emissions. + pub related_extra_samples: Vec, + /// Sample related misses. + pub related_missing_samples: Vec, + + /// ...missing, tallied by classified cause (see [`MissingCause`]; keyed so + /// new causes are a variant + a pin row, not a new field). Read via + /// [`SkeletonReport::missing`]. [`MissingCause::Other`] **gates at 0**. + pub missing_by_cause: BTreeMap, + /// Variants carved out by predicate v1 rule (a): tsv parses clean but the + /// baseline carries a non-bind TS1xxx code (recovery-AST incomparability). + pub carve_out_rule_a: usize, + /// ...of those, whose baseline also carries a family code. + pub carve_out_rule_a_family: usize, + /// In-scope variants that set `moduleDetection` (a watch item — module-ness is + /// inert for the family cascade, so the parse-once shortcut stays valid). + pub module_detection_variants: usize, + /// Sample extra diagnostics (gate failures to fix). + pub extra_samples: Vec, + /// Sample unattributed misses (candidate cascade bugs). + pub missing_other_samples: Vec, + /// Sample span mismatches. + pub span_mismatch_samples: Vec, + /// In-scope variants tsv parse-rejected (census; informational). + pub parse_rejected_total: usize, + /// ...of those, with no on-disk baseline (a likely tsv over-rejection). + pub parse_rejected_no_baseline: usize, + /// ...with a TS1xxx-only baseline (ambiguous: tsgo parse error or grammar). + pub parse_rejected_ts1xxx_only: usize, + /// ...with a baseline carrying non-TS1xxx codes (tsv rejects what tsgo checked). + pub parse_rejected_other: usize, + /// In-scope parsed variants that needed the `Goal::Script` retry (census). + pub script_retry: usize, + /// Tests whose check panicked (caught) and are NOT crash-excluded. Gate: + /// must be empty. + pub panics: Vec, + /// Tests skipped by the crash-exclusion ledger (tracked parser aborts/panics). + pub excluded_crashes: usize, + + // --- lib base --- + /// Distinct lib `.d.ts` files parsed + bound this run (informational). + pub lib_files_bound: usize, + /// Distinct resolved lib sets folded into a base this run (informational). + pub lib_sets_built: usize, + /// Lib files that failed to parse (`file: error`). **Gate: must be empty.** + pub lib_parse_errors: Vec, + /// Referenced lib files not found on disk. **Gate: must be empty.** + pub lib_missing_files: Vec, + /// Unrecognized `@lib` / `/// ` names. **Gate: must be empty.** + pub lib_unknown_names: Vec, + /// Lib files that bound as an external module with no `declare global {}` block — + /// their globals would silently fold to nothing. **Gate: must be empty.** + pub lib_external_no_globals: Vec, + /// Catchable-panic exclusions that no longer panic (a fix landed) — the entry + /// is stale and must be dropped. **Gate: must be empty.** + pub stale_exclusions: Vec, + /// Total bound nodes across in-scope tests (informational). + pub total_nodes: u64, + /// Wall-clock of the sweep in milliseconds (EXCLUDED from the committed report — + /// machine-varying). + pub wall_ms: u128, + + // --- deterministic per-code breakdown (the committed report's per-code table) --- + /// Family diagnostics that matched, keyed by TS code (sorted for determinism). + pub family_match_by_code: BTreeMap, + /// Family baseline diagnostics with no match, keyed by TS code (sorted). + pub family_missing_by_code: BTreeMap, + + // --- optional artifacts (empty on a normal green run) --- + /// Per-variant verdict rows for `--emit-manifest` (empty unless requested). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub manifest_entries: Vec, + /// Failing variants with a pre-rendered diff — written to `.diff` artifacts when + /// the gates fail (empty when green). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failing_variants: Vec, +} + +/// Sum a per-code map's entries whose code is in `codes` (the sub-family partition +/// behind `dup_*` / `flow_*`). +fn sub_family_sum(by_code: &BTreeMap, codes: &[u32]) -> usize { + by_code + .iter() + .filter(|(code, _)| codes.contains(code)) + .map(|(_, count)| *count) + .sum() +} + +impl SkeletonReport { + /// A family's matches (partition of `family_match_by_code` by its code set). + #[must_use] + pub fn family_match_for(&self, family: &GradedFamily) -> usize { + sub_family_sum(&self.family_match_by_code, family.codes) + } + + /// A family's misses (partition of `family_missing_by_code` by its code set). + #[must_use] + pub fn family_missing_for(&self, family: &GradedFamily) -> usize { + sub_family_sum(&self.family_missing_by_code, family.codes) + } + + /// The missing count attributed to `cause` (0 when the cause never fired). + #[must_use] + pub fn missing(&self, cause: MissingCause) -> usize { + self.missing_by_cause.get(&cause).copied().unwrap_or(0) + } + + /// The duplicate/conflict sub-family matches (partition of `family_match_by_code`). + #[must_use] + pub fn dup_match(&self) -> usize { + self.family_match_for(&FAMILIES[0]) + } + + /// The flow sub-family matches (partition of `family_match_by_code`). + #[must_use] + pub fn flow_match(&self) -> usize { + self.family_match_for(&FAMILIES[1]) + } + + /// The duplicate/conflict sub-family misses (partition of `family_missing_by_code`). + #[must_use] + pub fn dup_missing(&self) -> usize { + self.family_missing_for(&FAMILIES[0]) + } + + /// The flow sub-family misses (partition of `family_missing_by_code`). + #[must_use] + pub fn flow_missing(&self) -> usize { + self.family_missing_for(&FAMILIES[1]) + } + + /// Print the human summary. + pub fn print(&self) { + println!("tsc_conformance run"); + println!("==================="); + println!("In-scope tests: {}", self.in_scope_tests); + println!("In-scope variants: {}", self.in_scope_variants); + println!(" parsed, expect-clean: {}", self.expect_clean_graded); + println!(" graded clean: {}", self.clean_pass); + println!(" graded NON-clean: {}", self.clean_fail.len()); + println!(" parsed, baselined: {}", self.baselined_parsed); + println!(" parse-rejected: {}", self.parse_rejected_total); + println!( + " no baseline: {}", + self.parse_rejected_no_baseline + ); + println!( + " TS1xxx-only baseline: {}", + self.parse_rejected_ts1xxx_only + ); + println!(" other baseline: {}", self.parse_rejected_other); + println!("Script-goal retries: {}", self.script_retry); + println!("Bound nodes (total): {}", self.total_nodes); + println!(); + println!( + "Family grading (dup 2300/2451/2567/2528 + merge 2397/2649/2664/2671; flow 7027/7028)" + ); + println!("---------------------------------------------------------------"); + println!("Graded variants: {}", self.family_graded_variants); + println!( + " ...family-positive: {}", + self.family_positive_variants + ); + let per_family = |get: &dyn Fn(&GradedFamily) -> usize| -> String { + FAMILIES + .iter() + .map(|f| format!("{} {}", f.key, get(f))) + .collect::>() + .join(", ") + }; + println!( + " match: {} ({})", + self.family_match, + per_family(&|f| self.family_match_for(f)) + ); + println!( + " missing: {} ({})", + self.family_missing, + per_family(&|f| self.family_missing_for(f)) + ); + // One line per classified cause (label-aligned; `Other` is the gate). + const CAUSE_LINES: [(MissingCause, &str, &str); 5] = [ + (MissingCause::Merge, " merge-path: ", ""), + (MissingCause::Lib, " lib-conflict: ", ""), + ( + MissingCause::DeferredLateBound, + " late-bound (deferred): ", + " (needs the type engine — literal-type / unique-symbol computed member names)", + ), + ( + MissingCause::DeferredCfa, + " cfa (deferred): ", + " (needs the CFA type engine — never-returning sigs / assertion predicates / switch exhaustiveness / structural reachability)", + ), + ( + MissingCause::Other, + " other (GATE=0): ", + " (unclassified family miss — a same-table cascade bug)", + ), + ]; + for (cause, label, note) in CAUSE_LINES { + println!("{label}{}{note}", self.missing(cause)); + } + println!(" extra (GATE=0): {}", self.family_extra); + println!(" span_mismatch: {}", self.family_span_mismatch); + println!("Related-info (matched primaries; own channel, non-gating)"); + println!(" related match: {}", self.related_match); + println!(" related missing: {}", self.related_missing); + println!(" related extra: {}", self.related_extra); + println!(" related span_mismatch: {}", self.related_span_mismatch); + for s in &self.related_missing_samples { + println!(" REL-MISSING {s}"); + } + for s in &self.related_extra_samples { + println!(" REL-EXTRA {s}"); + } + println!("Carve-out rule (a): {}", self.carve_out_rule_a); + println!( + " ...family-positive: {}", + self.carve_out_rule_a_family + ); + println!( + "moduleDetection variants: {} (watch; inert for family)", + self.module_detection_variants + ); + for s in &self.extra_samples { + println!(" EXTRA {s}"); + } + for s in &self.missing_other_samples { + println!(" MISSING-OTHER {s}"); + } + for s in &self.span_mismatch_samples { + println!(" SPAN {s}"); + } + println!(); + println!("Lib base"); + println!(" lib files bound: {}", self.lib_files_bound); + println!(" lib sets folded: {}", self.lib_sets_built); + println!( + " lib parse errors: {} (GATE=0)", + self.lib_parse_errors.len() + ); + println!( + " lib missing files: {} (GATE=0)", + self.lib_missing_files.len() + ); + println!( + " lib unknown names: {} (GATE=0)", + self.lib_unknown_names.len() + ); + println!( + " lib external no-globals: {} (GATE=0)", + self.lib_external_no_globals.len() + ); + for e in &self.lib_parse_errors { + println!(" LIB-PARSE-ERR {e}"); + } + for f in &self.lib_missing_files { + println!(" LIB-MISSING {f}"); + } + for n in &self.lib_unknown_names { + println!(" LIB-UNKNOWN {n}"); + } + for f in &self.lib_external_no_globals { + println!(" LIB-EXT-NO-GLOBALS {f}"); + } + println!(); + println!("Panics (caught): {}", self.panics.len()); + println!("Crash-excluded (tracked): {}", self.excluded_crashes); + if !self.stale_exclusions.is_empty() { + println!( + "Stale crash-exclusions: {} (drop them)", + self.stale_exclusions.len() + ); + } + println!("Wall-clock: {} ms", self.wall_ms); + if !self.clean_fail.is_empty() { + println!(); + for f in &self.clean_fail { + println!(" CLEAN-FAIL {} ({} diagnostics)", f.variant, f.diagnostics); + } + } + for p in &self.panics { + println!(" PANIC {} — {}", p.test, p.payload); + } + } +} + +// =========================================================================== +// check-test: the inner dev loop over one test. +// =========================================================================== + +/// One diagnostic line (ours or the baseline's) for the check-test diff. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct DiagLine { + /// The file the diagnostic points at (or `null` for a global one). A lib file + /// (`lib.es5.d.ts`) with `null` line/col is a masked lib-sourced entry. + pub file: Option, + /// 1-based line (`null` for a global or masked-lib diagnostic). + pub line: Option, + /// 1-based column (`null` for a global or masked-lib diagnostic). + pub col: Option, + /// The `TS` number. + pub code: u32, + /// The diagnostic's related-info entries (empty for a baseline summary line). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub related: Vec, +} + +/// The `check-test` report for one test/variant. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CheckTestReport { + /// The corpus test's relative path. + pub test: String, + /// The suite (`compiler` / `conformance`). + pub suite: String, + /// The variant description, or `(default)`. + pub variant: String, + /// The joined baseline name, or `None` when the variant is expect-clean. + pub baseline: Option, + /// Whether the variant is expect-clean (no on-disk baseline). + pub expect_clean: bool, + /// Whether tsv parse-rejected the program. + pub parse_rejected: bool, + /// The parse error message, when rejected. + pub parse_error: Option, + /// Our diagnostics (empty while the checker is a no-op). + pub ours: Vec, + /// The baseline's summary-block diagnostics (the expected set). + pub baseline_summary: Vec, +} + +impl CheckTestReport { + /// Print the human diff (ours vs the baseline summary). + pub fn print(&self) { + println!( + "check-test: {}/{} variant={}", + self.suite, self.test, self.variant + ); + if self.parse_rejected { + println!( + " tsv PARSE-REJECTED: {}", + self.parse_error.as_deref().unwrap_or("(no message)") + ); + } + if self.expect_clean { + println!(" baseline: (none — expect-clean)"); + } else { + println!(" baseline: {}", self.baseline.as_deref().unwrap_or("?")); + } + println!(); + println!(" ours ({}):", self.ours.len()); + for d in &self.ours { + println!(" {}", fmt_diag(d)); + for r in &d.related { + println!(" related {}", fmt_diag(r)); + } + } + if self.ours.is_empty() { + println!(" (none)"); + } + println!(" baseline ({}):", self.baseline_summary.len()); + for d in &self.baseline_summary { + println!(" {}", fmt_diag(d)); + } + if self.baseline_summary.is_empty() { + println!(" (none)"); + } + } +} + +/// Format one diagnostic line for the human diff. +fn fmt_diag(d: &DiagLine) -> String { + match (&d.file, d.line, d.col) { + (Some(file), Some(line), Some(col)) => format!("{file}({line},{col}): TS{}", d.code), + // A masked lib entry (file, no location) or a global one. + (Some(file), _, _) => format!("{file}(--,--): TS{}", d.code), + _ => format!("error TS{} (global)", d.code), + } +} diff --git a/crates/tsv_debug/src/tsc_conformance/runner/tests.rs b/crates/tsv_debug/src/tsc_conformance/runner/tests.rs new file mode 100644 index 000000000..3e7ee111b --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner/tests.rs @@ -0,0 +1,127 @@ +use super::*; + +/// A variant config from `key=value` pairs (the maps store lowercased keys). +fn config(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +#[test] +fn keeps_test_substring() { + // No `--test` filter keeps every path; an active one keeps only substrings. + let none = RunFilter::default(); + assert!(none.keeps_test("compiler/anything.ts")); + + let f = RunFilter { + test: Some("duplicate".to_string()), + ..RunFilter::default() + }; + assert!(f.keeps_test("compiler/duplicateVar.ts")); + assert!(!f.keeps_test("compiler/asyncAwait.ts")); +} + +#[test] +fn keeps_variant_key_value() { + // No `--variant` filter keeps everything. + let none = RunFilter::default(); + assert!(none.keeps_variant(&config(&[("target", "es5")]))); + + let f = RunFilter { + variant: Some(("target".to_string(), "es2015".to_string())), + ..RunFilter::default() + }; + // Exact key=value match keeps. + assert!(f.keeps_variant(&config(&[("target", "es2015")]))); + // Wrong value excludes. + assert!(!f.keeps_variant(&config(&[("target", "es5")]))); + // Absent key excludes (the variant doesn't set it). + assert!(!f.keeps_variant(&config(&[("strict", "true")]))); +} + +#[test] +fn keeps_code_consults_baseline_only_when_active() { + // No `--code` filter keeps without ever consulting the baseline resolver + // (the closure must not run — it would panic if it did). + let none = RunFilter::default(); + assert!(none.keeps_code(|_| panic!("resolver consulted with no --code filter"))); + + let f = RunFilter { + code: Some(2300), + ..RunFilter::default() + }; + // Active filter keeps iff the baseline carries the code. + let carried = [2300u32, 2451]; + assert!(f.keeps_code(|code| carried.contains(&code))); + let other = [2451u32]; + assert!(!f.keeps_code(|code| other.contains(&code))); + // A variant with no baseline (resolver reports false) is excluded. + assert!(!f.keeps_code(|_| false)); +} + +#[test] +fn keeps_family_selects_sub_family() { + // No `--family` filter keeps without consulting the baseline resolver. + let none = RunFilter::default(); + assert!(none.keeps_family(|_| panic!("resolver consulted with no --family filter"))); + + // `flow` keeps iff the baseline carries a FLOW_CODES member; a dup-only + // baseline is excluded, a flow baseline is kept. (Parsed through the + // `FAMILIES`-table tokens — the same path the CLI takes.) + let flow = RunFilter { + family: FamilyFilter::parse("flow"), + ..RunFilter::default() + }; + assert!(flow.keeps_family(|c| c == 7027)); + assert!(!flow.keeps_family(|c| c == 2300)); + + // `dup` is the complementary partition. + let dup = RunFilter { + family: FamilyFilter::parse("dup"), + ..RunFilter::default() + }; + assert!(dup.keeps_family(|c| c == 2300)); + assert!(!dup.keeps_family(|c| c == 7027)); + + // `all` keeps any family code (either partition); an unknown token + // refuses to parse. + assert!(FamilyFilter::parse("nope").is_none()); + let all = RunFilter { + family: FamilyFilter::parse("all"), + ..RunFilter::default() + }; + assert!(all.keeps_family(|c| c == 7028)); + assert!(all.keeps_family(|c| c == 2451)); + // A non-family code (or no baseline) is excluded. + assert!(!all.keeps_family(|c| c == 9999)); +} + +#[test] +fn filters_compose_as_and() { + // The call site ANDs the three predicates; all must keep for a variant to be + // graded, and any one failing excludes it. + let f = RunFilter { + test: Some("dup".to_string()), + code: Some(2300), + variant: Some(("target".to_string(), "es5".to_string())), + family: None, + }; + let cfg = config(&[("target", "es5")]); + let carried = [2300u32]; + let keeps = |path: &str, cfg: &BTreeMap, codes: &[u32]| { + f.keeps_test(path) && f.keeps_variant(cfg) && f.keeps_code(|c| codes.contains(&c)) + }; + // All three match. + assert!(keeps("compiler/dupVar.ts", &cfg, &carried)); + // Test substring misses. + assert!(!keeps("compiler/other.ts", &cfg, &carried)); + // Variant value misses. + assert!(!keeps( + "compiler/dupVar.ts", + &config(&[("target", "es2015")]), + &carried + )); + // Code missing from the baseline. + assert!(!keeps("compiler/dupVar.ts", &cfg, &[2451])); +} diff --git a/crates/tsv_debug/src/tsc_conformance/runner/watchdog.rs b/crates/tsv_debug/src/tsc_conformance/runner/watchdog.rs new file mode 100644 index 000000000..8e6dcd295 --- /dev/null +++ b/crates/tsv_debug/src/tsc_conformance/runner/watchdog.rs @@ -0,0 +1,72 @@ +use super::*; + +/// Per-test wall-clock budget for the [`TestWatchdog`]. The full ~12k-test +/// sweep runs in seconds, so a single test at 60 s is pathological with huge +/// margin (~10⁴× the mean) — the limit exists to convert a *hang* into a loud +/// named failure, not to police slow tests. +const WATCHDOG_LIMIT: std::time::Duration = std::time::Duration::from_secs(60); + +/// The sweep's hang watchdog — the wall-clock half of the "watchdog +/// independent of ported budgets" requirement. `catch_unwind` converts panics +/// into per-test buckets, but a **hang** (a mis-ported budget at P3, a parser +/// loop) would otherwise freeze the gate silently. The worker heartbeats each +/// test's name + start; a monitor thread checks ~1 Hz and, past +/// [`WATCHDOG_LIMIT`], prints the offending test and exits the process (a hung +/// thread cannot be killed safely — a loud named exit is the correct failure). +/// The instruction-count half (budget-arithmetic cross-check) rides P3 with +/// the budgets themselves. +pub(super) struct TestWatchdog { + /// `(current test relative_path, its start)`; `None` after `finish`. + current: std::sync::Arc>>, +} + +impl TestWatchdog { + pub(super) fn spawn() -> TestWatchdog { + let current: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let monitor = std::sync::Arc::clone(¤t); + // Detached monitor: exits within a tick of the sweep clearing the slot + // (or dies with the process — it holds nothing that needs cleanup). + drop( + std::thread::Builder::new() + .name("tsc-watchdog".to_string()) + .spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + let guard = monitor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((test, start)) = guard.as_ref() else { + return; // sweep finished + }; + if start.elapsed() > WATCHDOG_LIMIT { + eprintln!( + "tsc_conformance watchdog: test {test:?} exceeded {}s — a hang \ + (mis-ported budget / parser loop); aborting the run", + WATCHDOG_LIMIT.as_secs() + ); + std::process::exit(3); + } + } + }), + ); + TestWatchdog { current } + } + + /// Heartbeat: the sweep is entering `test` now. + pub(super) fn enter(&self, test: &str) { + *self + .current + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((test.to_string(), Instant::now())); + } + + /// The sweep is done — clear the slot so the monitor thread exits. + pub(super) fn finish(&self) { + *self + .current + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } +} From ebb1be5774d38ae6214c23ba6ecf6bfc1002e268 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 23:36:48 -0400 Subject: [PATCH 67/79] refactor: extract tsv_check binder SoA visitors into lower/ submodule --- crates/tsv_check/CLAUDE.md | 7 +- .../tsv_check/src/binder/lower/expression.rs | 422 +++++ crates/tsv_check/src/binder/lower/mod.rs | 11 + .../tsv_check/src/binder/lower/statement.rs | 592 +++++++ crates/tsv_check/src/binder/lower/types.rs | 393 +++++ crates/tsv_check/src/binder/mod.rs | 1374 +---------------- 6 files changed, 1426 insertions(+), 1373 deletions(-) create mode 100644 crates/tsv_check/src/binder/lower/expression.rs create mode 100644 crates/tsv_check/src/binder/lower/mod.rs create mode 100644 crates/tsv_check/src/binder/lower/statement.rs create mode 100644 crates/tsv_check/src/binder/lower/types.rs diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 989a4b651..9a9805d3e 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -62,7 +62,12 @@ - `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). + (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.rs` — the container-threaded, functions-first symbol-bind walk: `getContainerFlags`, `declareSymbolEx` + the duplicate/conflict cascade (TS2451/2300/2567/2528 with per-prior-declaration related info), 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..5125be92b --- /dev/null +++ b/crates/tsv_check/src/binder/lower/expression.rs @@ -0,0 +1,422 @@ +//! 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::{ + ArrowFunctionBody, Decorator, Expression, FunctionExpression, Identifier, + ObjectPatternProperty, ObjectProperty, Property, RestElement, SpreadElement, TemplateElement, + TemplateLiteral, +}; + +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) => { + 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); + } + E::ArrayExpression(a) => { + 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); + } + E::UnaryExpression(u) => { + let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + E::UpdateExpression(u) => { + let id = self.add(NodeKind::UpdateExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + E::BinaryExpression(b) => { + 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); + } + E::CallExpression(c) => { + 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); + } + E::NewExpression(n) => { + 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); + } + E::MemberExpression(m) => { + 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); + } + E::ConditionalExpression(c) => { + 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); + } + E::ArrowFunctionExpression(a) => { + 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); + } + E::FunctionExpression(f) => self.visit_function_expression(f, parent), + E::ClassExpression(c) => { + 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); + } + E::SpreadElement(s) => self.visit_spread(s, parent), + E::TemplateLiteral(t) => self.visit_template_literal(t, parent), + E::TaggedTemplateExpression(t) => { + 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); + } + E::AwaitExpression(a) => { + let id = self.add(NodeKind::AwaitExpression, a.span, Some(parent), addr_of(a)); + self.visit_expression(a.argument, id); + self.close(id); + } + E::YieldExpression(y) => { + 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); + } + E::SequenceExpression(s) => { + 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); + } + E::AssignmentExpression(a) => { + 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); + } + E::ObjectPattern(op) => { + 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); + } + E::ArrayPattern(ap) => { + 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); + } + E::AssignmentPattern(a) => { + 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); + } + E::RestElement(r) => self.visit_rest_element(r, parent), + E::TSTypeAssertion(t) => { + 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); + } + E::TSAsExpression(t) => { + 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); + } + E::TSSatisfiesExpression(t) => { + 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); + } + E::TSInstantiationExpression(t) => { + 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); + } + E::TSNonNullExpression(t) => { + let id = self.add( + NodeKind::TSNonNullExpression, + t.span, + Some(parent), + addr_of(t), + ); + self.visit_expression(t.expression, id); + self.close(id); + } + E::TSParameterProperty(pp) => { + let id = self.add( + NodeKind::TSParameterProperty, + pp.span, + Some(parent), + addr_of(pp), + ); + self.visit_expression(pp.parameter, id); + self.close(id); + } + E::ImportExpression(i) => { + 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); + } + E::MetaProperty(m) => { + 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); + } + E::JsdocCast(c) => { + let id = self.add(NodeKind::JsdocCast, c.span, Some(parent), addr_of(c)); + self.visit_expression(c.inner, id); + self.close(id); + } + E::ParenthesizedExpression(p) => { + let id = self.add( + NodeKind::ParenthesizedExpression, + p.span, + Some(parent), + addr_of(p), + ); + self.visit_expression(p.expression, id); + self.close(id); + } + } + // 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" + ); + } + + 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 index 7a3f1ac1e..72e60ca15 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -56,6 +56,7 @@ mod atoms; pub mod flow; +mod lower; mod sym; pub mod symbols; @@ -65,18 +66,7 @@ use crate::ids::{FileId, NodeId}; use crate::merge::FileMerge; use tsv_lang::Span; use tsv_ts::ast::Program; -use tsv_ts::ast::internal::{ - ArrowFunctionBody, CatchClause, ClassBody, ClassDeclaration, ClassMember, Decorator, - ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, FunctionDeclaration, - FunctionExpression, Identifier, ImportAttribute, ImportAttributeKey, ImportSpecifier, - ModuleExportName, ObjectPatternProperty, ObjectProperty, Property, RestElement, SpreadElement, - Statement, SwitchCase, TSDeclareFunction, TSEntityName, TSEnumMember, TSEnumMemberId, - TSImportType, TSIndexSignature, TSInterfaceDeclaration, TSInterfaceHeritage, TSLiteralType, - TSMappedTypeParameter, TSModuleDeclaration, TSModuleDeclarationBody, TSModuleName, - TSModuleReference, TSQualifiedName, TSType, TSTypeAnnotation, TSTypeElement, TSTypeParameter, - TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypeQueryExprName, TemplateElement, - TemplateLiteral, VariableDeclaration, VariableDeclarator, -}; +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 @@ -591,1366 +581,6 @@ impl SoaWalk { let id = self.add(kind, span, Some(parent), address); self.close(id); } - - // --- statements ---------------------------------------------------------- - - 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. - 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 - /// ([`Self::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. - 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. - 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. - 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), - } - } - - 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); - } - - // --- expressions (full pattern-aware descent) ---------------------------- - - 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. - 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) => { - 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); - } - E::ArrayExpression(a) => { - 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); - } - E::UnaryExpression(u) => { - let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u)); - self.visit_expression(u.argument, id); - self.close(id); - } - E::UpdateExpression(u) => { - let id = self.add(NodeKind::UpdateExpression, u.span, Some(parent), addr_of(u)); - self.visit_expression(u.argument, id); - self.close(id); - } - E::BinaryExpression(b) => { - 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); - } - E::CallExpression(c) => { - 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); - } - E::NewExpression(n) => { - 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); - } - E::MemberExpression(m) => { - 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); - } - E::ConditionalExpression(c) => { - 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); - } - E::ArrowFunctionExpression(a) => { - 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); - } - E::FunctionExpression(f) => self.visit_function_expression(f, parent), - E::ClassExpression(c) => { - 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); - } - E::SpreadElement(s) => self.visit_spread(s, parent), - E::TemplateLiteral(t) => self.visit_template_literal(t, parent), - E::TaggedTemplateExpression(t) => { - 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); - } - E::AwaitExpression(a) => { - let id = self.add(NodeKind::AwaitExpression, a.span, Some(parent), addr_of(a)); - self.visit_expression(a.argument, id); - self.close(id); - } - E::YieldExpression(y) => { - 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); - } - E::SequenceExpression(s) => { - 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); - } - E::AssignmentExpression(a) => { - 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); - } - E::ObjectPattern(op) => { - 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); - } - E::ArrayPattern(ap) => { - 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); - } - E::AssignmentPattern(a) => { - 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); - } - E::RestElement(r) => self.visit_rest_element(r, parent), - E::TSTypeAssertion(t) => { - 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); - } - E::TSAsExpression(t) => { - 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); - } - E::TSSatisfiesExpression(t) => { - 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); - } - E::TSInstantiationExpression(t) => { - 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); - } - E::TSNonNullExpression(t) => { - let id = self.add( - NodeKind::TSNonNullExpression, - t.span, - Some(parent), - addr_of(t), - ); - self.visit_expression(t.expression, id); - self.close(id); - } - E::TSParameterProperty(pp) => { - let id = self.add( - NodeKind::TSParameterProperty, - pp.span, - Some(parent), - addr_of(pp), - ); - self.visit_expression(pp.parameter, id); - self.close(id); - } - E::ImportExpression(i) => { - 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); - } - E::MetaProperty(m) => { - 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); - } - E::JsdocCast(c) => { - let id = self.add(NodeKind::JsdocCast, c.span, Some(parent), addr_of(c)); - self.visit_expression(c.inner, id); - self.close(id); - } - E::ParenthesizedExpression(p) => { - let id = self.add( - NodeKind::ParenthesizedExpression, - p.span, - Some(parent), - addr_of(p), - ); - self.visit_expression(p.expression, id); - self.close(id); - } - } - // 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" - ); - } - - 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); - } - - fn visit_template_element(&mut self, q: &TemplateElement<'_>, parent: NodeId) { - self.leaf(NodeKind::TemplateElement, q.span, addr_of(q), parent); - } - - 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. - 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); - } - - // --- 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), - } - } - - // --- types --------------------------------------------------------------- - - /// A `TSTypeAnnotation` (`: T`) is a transparent wrapper — not idd; the walk - /// descends straight into the inner `TSType`, which is the node. - fn visit_type_annotation(&mut self, ann: &TSTypeAnnotation<'_>, parent: NodeId) { - self.visit_type(ann.type_annotation, parent); - } - - fn visit_type_annotation_opt(&mut self, ann: Option<&TSTypeAnnotation<'_>>, parent: NodeId) { - if let Some(a) = ann { - self.visit_type_annotation(a, parent); - } - } - - 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); - } - - 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); - } - - 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); - } - - 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), - } - } - - 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); - } - } - } } /// The [`NodeKind`] for a statement variant. Shared with the flow walk and the From 1dd9c28aa276380fcdfe518c3152cefa78f82fbe Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sat, 11 Jul 2026 23:56:35 -0400 Subject: [PATCH 68/79] refactor: split tsv_check binder sym.rs into sym/ directory-module --- crates/tsv_check/CLAUDE.md | 11 +- crates/tsv_check/src/binder/sym/declare.rs | 372 ++++++++ crates/tsv_check/src/binder/sym/mod.rs | 435 ++++++++++ .../src/binder/{sym.rs => sym/walk.rs} | 796 +----------------- 4 files changed, 833 insertions(+), 781 deletions(-) create mode 100644 crates/tsv_check/src/binder/sym/declare.rs create mode 100644 crates/tsv_check/src/binder/sym/mod.rs rename crates/tsv_check/src/binder/{sym.rs => sym/walk.rs} (65%) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 9a9805d3e..17f60ed47 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -68,11 +68,18 @@ (`statement.rs`/`expression.rs`/`types.rs` — additional `impl SoaWalk` blocks, split by the AST shape each visitor descends), unchanged in responsibility. - - `sym.rs` — the container-threaded, functions-first symbol-bind walk: + - `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). + 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 both descendants share, and the + member-key resolver), `walk.rs` (the bind-descent methods — + `visit_statement`/`visit_expression` and everything they call into — + plus the functions-first statement-list ordering), 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. 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/mod.rs b/crates/tsv_check/src/binder/sym/mod.rs new file mode 100644 index 000000000..2b186323e --- /dev/null +++ b/crates/tsv_check/src/binder/sym/mod.rs @@ -0,0 +1,435 @@ +//! 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 three files: this module (`mod.rs`) keeps the +//! `SymbolBinder` struct, its lifecycle (`new`/`bind_program`/`finish`), the +//! table/symbol/atom primitives both descendants share, and the member-key +//! resolver; `walk.rs` holds the bind-descent methods (`visit_statement`/ +//! `visit_expression` and everything they call into); `declare.rs` holds the +//! `declareSymbolEx` cascade and the container routing. No behavior distinction +//! between the three. + +mod declare; +mod walk; + +use super::atoms::{Atom, Atoms}; +use super::symbols::{Symbol, SymbolFlags, SymbolId, TableId}; +use super::{FileFacts, NodeKind, addr_of}; +use crate::diag::Diagnostic; +use crate::hash::FxHashMap; +use crate::ids::{FileId, NodeId}; +use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; +use string_interner::DefaultStringInterner; +use tsv_lang::Span; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{Expression, Identifier, Literal, LiteralValue, ModuleExportName}; + +/// 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, +} + +/// The symbol bind for one file. +pub(super) struct SymbolBinder<'a> { + source: &'a str, + interner: &'a DefaultStringInterner, + 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, + interner: &'a DefaultStringInterner, + 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, + interner, + 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); + } + + /// 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 start) — deterministic iteration, never the hash-map's. + 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(); + symbols.sort_by_key(|s| s.decls.first().map_or(u32::MAX, |d| d.error_span.start)); + 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.interner); + 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, self.interner); + // 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 display-string 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`; + // display is not) — a shared display helper would prevent this class of + // mismatch. + 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, +} diff --git a/crates/tsv_check/src/binder/sym.rs b/crates/tsv_check/src/binder/sym/walk.rs similarity index 65% rename from crates/tsv_check/src/binder/sym.rs rename to crates/tsv_check/src/binder/sym/walk.rs index 4b2375b09..b3c5f3b51 100644 --- a/crates/tsv_check/src/binder/sym.rs +++ b/crates/tsv_check/src/binder/sym/walk.rs @@ -1,113 +1,27 @@ -//! 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. +//! The bind walk — descends the AST discovering declarations and routing them +//! through the declare/conflict cascade ([`super::declare`]): `visit_statement` +//! (the statement-shaped descent — variable/function/class/interface/enum/module +//! declarations, imports/exports, control flow) and `visit_expression` (the +//! pattern-aware expression descent — function/arrow/class expressions, object +//! literals, and the assignment-target/for-left pattern shapes), plus the +//! class/interface/module/type-literal member descents and the functions-first +//! statement-list ordering (`bindEachStatementFunctionsFirst`). // -// tsgo: internal/binder/binder.go declareSymbolEx (the cascade), -// declareSymbolAndAddToSymbolTable / declareModuleMember / declareClassMember -// (the routing), bindEachStatementFunctionsFirst (functions-first), +// tsgo: internal/binder/binder.go bindEachStatementFunctionsFirst (functions-first), // bindClassLikeDeclaration (the static-`prototype` clash, :971) -// 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::atoms::{Atom, Atoms}; -use super::symbols::{Decl, Symbol, SymbolFlags, SymbolId, TableId}; -use super::{FileFacts, NodeKind, addr_of}; -use crate::diag::{Category, Diagnostic}; -use crate::hash::FxHashMap; -use crate::ids::{FileId, NodeId}; -use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; -use string_interner::DefaultStringInterner; +use super::super::symbols::{SymbolFlags, SymbolId}; +use super::{ContainerKind, DeclInput, NodeKind, Scope, SymbolBinder}; +use crate::ids::NodeId; use tsv_lang::Span; -use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ ClassBody, ClassMember, ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, - Identifier, ImportSpecifier, Literal, LiteralValue, MethodKind, ModuleExportName, - ObjectExpression, ObjectPatternProperty, ObjectProperty, PropertyKind, Statement, - TSEnumMemberId, TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSType, - TSTypeAnnotation, TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration, + Identifier, ImportSpecifier, MethodKind, ModuleExportName, ObjectExpression, + ObjectPatternProperty, ObjectProperty, PropertyKind, Statement, TSEnumMemberId, + TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSType, TSTypeAnnotation, + TSTypeElement, TSTypeLiteral, 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, -} - /// Modifiers threaded from an `export` wrapper into the wrapped declaration. #[derive(Clone, Copy, Default)] struct DeclMods { @@ -115,518 +29,10 @@ struct DeclMods { default: 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, -} - -/// The symbol bind for one file. -pub(super) struct SymbolBinder<'a> { - source: &'a str, - interner: &'a DefaultStringInterner, - 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, - interner: &'a DefaultStringInterner, - 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, - interner, - 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); - } - - /// 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 start) — deterministic iteration, never the hash-map's. - 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(); - symbols.sort_by_key(|s| s.decls.first().map_or(u32::MAX, |d| d.error_span.start)); - 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.interner); - 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), - } - } - - // --- 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). - 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); - } - - 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. - 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. - 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`. - 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) - } - // --- statement lists (functions-first) ----------------------------------- - fn bind_statement_list(&mut self, stmts: &[Statement<'a>], functions_first: bool) { + pub(super) fn bind_statement_list(&mut self, stmts: &[Statement<'a>], functions_first: bool) { if functions_first { for stmt in stmts { if is_function_statement(stmt) { @@ -1797,52 +1203,6 @@ impl<'a> SymbolBinder<'a> { self.declare_alias(d, true); } - /// Declare an alias symbol (import/export specifier, `import =`). Exported - /// aliases route to `exports`, others to `locals`. - 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, - ); - } - } - } - } - // --- type parameters ----------------------------------------------------- fn bind_type_params(&mut self, type_params: Option<&TSTypeParameterDeclaration<'a>>) { @@ -2024,96 +1384,6 @@ impl<'a> SymbolBinder<'a> { } } } - - // --- 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, self.interner); - // 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 display-string derivation is duplicated across the - // bind (`sym.rs`) and check (`duplicate_members.rs`) sides with no - // shared helper (span derivation is centralized in `span_scan.rs`; - // display is not) — a shared display helper would prevent this class of - // mismatch. - 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, } /// A [`SymbolFlags`] triple for a variable declaration kind: `(includes, @@ -2139,20 +1409,6 @@ fn var_flags( } } -/// 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, - )) -} - /// Whether a statement is a function declaration (possibly `export`-wrapped) — /// the set tsgo's `bindEachStatementFunctionsFirst` binds first. fn is_function_statement(stmt: &Statement<'_>) -> bool { @@ -2235,21 +1491,3 @@ fn statement_is_non_instantiated(stmt: &Statement<'_>) -> bool { _ => false, } } - -/// 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(), - } -} From f0184949767efbc4e065843f38a1d196fd2788e1 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 00:11:04 -0400 Subject: [PATCH 69/79] refactor: decompose tsv_check SoaWalk::visit_expression into per-arm helpers --- .../tsv_check/src/binder/lower/expression.rs | 653 ++++++++++-------- 1 file changed, 377 insertions(+), 276 deletions(-) diff --git a/crates/tsv_check/src/binder/lower/expression.rs b/crates/tsv_check/src/binder/lower/expression.rs index 5125be92b..f3200d21b 100644 --- a/crates/tsv_check/src/binder/lower/expression.rs +++ b/crates/tsv_check/src/binder/lower/expression.rs @@ -6,9 +6,15 @@ use super::super::*; use tsv_ts::ast::internal::{ - ArrowFunctionBody, Decorator, Expression, FunctionExpression, Identifier, - ObjectPatternProperty, ObjectProperty, Property, RestElement, SpreadElement, TemplateElement, - TemplateLiteral, + 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 { @@ -35,283 +41,39 @@ impl SoaWalk { 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) => { - 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); - } - E::ArrayExpression(a) => { - 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); - } - E::UnaryExpression(u) => { - let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u)); - self.visit_expression(u.argument, id); - self.close(id); - } - E::UpdateExpression(u) => { - let id = self.add(NodeKind::UpdateExpression, u.span, Some(parent), addr_of(u)); - self.visit_expression(u.argument, id); - self.close(id); - } - E::BinaryExpression(b) => { - 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); - } - E::CallExpression(c) => { - 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); - } - E::NewExpression(n) => { - 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); - } - E::MemberExpression(m) => { - 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); - } - E::ConditionalExpression(c) => { - 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); - } - E::ArrowFunctionExpression(a) => { - 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); - } + 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) => { - 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); - } + 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) => { - 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); - } - E::AwaitExpression(a) => { - let id = self.add(NodeKind::AwaitExpression, a.span, Some(parent), addr_of(a)); - self.visit_expression(a.argument, id); - self.close(id); - } - E::YieldExpression(y) => { - 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); - } - E::SequenceExpression(s) => { - 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); - } - E::AssignmentExpression(a) => { - 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); - } - E::ObjectPattern(op) => { - 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); - } - E::ArrayPattern(ap) => { - 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); - } - E::AssignmentPattern(a) => { - 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); - } + 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) => { - 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); - } - E::TSAsExpression(t) => { - 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); - } - E::TSSatisfiesExpression(t) => { - 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); - } - E::TSInstantiationExpression(t) => { - 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); - } - E::TSNonNullExpression(t) => { - let id = self.add( - NodeKind::TSNonNullExpression, - t.span, - Some(parent), - addr_of(t), - ); - self.visit_expression(t.expression, id); - self.close(id); - } - E::TSParameterProperty(pp) => { - let id = self.add( - NodeKind::TSParameterProperty, - pp.span, - Some(parent), - addr_of(pp), - ); - self.visit_expression(pp.parameter, id); - self.close(id); - } - E::ImportExpression(i) => { - 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); - } - E::MetaProperty(m) => { - 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); - } - E::JsdocCast(c) => { - let id = self.add(NodeKind::JsdocCast, c.span, Some(parent), addr_of(c)); - self.visit_expression(c.inner, id); - self.close(id); - } - E::ParenthesizedExpression(p) => { - let id = self.add( - NodeKind::ParenthesizedExpression, - p.span, - Some(parent), - addr_of(p), - ); - self.visit_expression(p.expression, id); - self.close(id); - } + 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` @@ -324,6 +86,345 @@ impl SoaWalk { ); } + #[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, From f55441a5a223c466b4cc774fc96837407a3c704e Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 00:17:49 -0400 Subject: [PATCH 70/79] refactor: decompose tsv_check SymbolBinder::visit_statement into per-arm helpers --- crates/tsv_check/src/binder/sym/walk.rs | 354 +++++++++++++----------- 1 file changed, 197 insertions(+), 157 deletions(-) diff --git a/crates/tsv_check/src/binder/sym/walk.rs b/crates/tsv_check/src/binder/sym/walk.rs index b3c5f3b51..073f18c48 100644 --- a/crates/tsv_check/src/binder/sym/walk.rs +++ b/crates/tsv_check/src/binder/sym/walk.rs @@ -130,21 +130,7 @@ impl<'a> SymbolBinder<'a> { b.bind_params(f.params); }); } - Statement::ClassDeclaration(c) => { - 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()); - } + 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( @@ -154,71 +140,17 @@ impl<'a> SymbolBinder<'a> { ); self.bind_interface_body(&i.body, sym, i.type_parameters.as_ref()); } - Statement::TSEnumDeclaration(e) => { - 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); - } + Statement::TSEnumDeclaration(e) => self.bind_enum_statement(e, mods), Statement::TSModuleDeclaration(m) => self.bind_module(m, mods), - Statement::TSTypeAliasDeclaration(t) => { - // 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()); - } + 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) => { - 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); - } + Statement::TSImportEqualsDeclaration(ie) => self.bind_import_equals_statement(ie), Statement::ExportNamedDeclaration(e) => { - 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); - } - } + 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. @@ -232,21 +164,7 @@ impl<'a> SymbolBinder<'a> { self.visit_statement(alt, DeclMods::default(), false); } } - Statement::ForStatement(s) => 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); - }), + 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); @@ -265,43 +183,8 @@ impl<'a> SymbolBinder<'a> { self.visit_statement(s.body, DeclMods::default(), false); self.visit_expression(&s.test); } - Statement::SwitchStatement(s) => { - 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); - } - }); - } - Statement::TryStatement(s) => { - 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)); - } - } + 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); } @@ -312,38 +195,7 @@ impl<'a> SymbolBinder<'a> { } Statement::ThrowStatement(s) => self.visit_expression(&s.argument), Statement::ExpressionStatement(s) => self.visit_expression(&s.expression), - Statement::TSExportAssignment(ea) => { - // `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); - } + Statement::TSExportAssignment(ea) => self.bind_export_assignment_statement(ea), Statement::ExportAllDeclaration(_) | Statement::TSNamespaceExportDeclaration(_) | Statement::BreakStatement(_) @@ -353,6 +205,194 @@ impl<'a> SymbolBinder<'a> { } } + #[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 { From ae791956825ea7c8ad06ce524c58fa362954e1e5 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 00:28:42 -0400 Subject: [PATCH 71/79] refactor: decompose tsv_check FlowBuilder::visit_expression into per-arm helpers --- crates/tsv_check/src/binder/flow/build.rs | 320 +++++++++++++--------- 1 file changed, 190 insertions(+), 130 deletions(-) diff --git a/crates/tsv_check/src/binder/flow/build.rs b/crates/tsv_check/src/binder/flow/build.rs index 097745860..a02cd5f13 100644 --- a/crates/tsv_check/src/binder/flow/build.rs +++ b/crates/tsv_check/src/binder/flow/build.rs @@ -1830,16 +1830,7 @@ impl<'a> FlowBuilder<'a> { let id = self.require(addr_of(m), NodeKind::MetaProperty); self.set_flow_nonleaf(id); } - E::MemberExpression(m) => { - // 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); - } + E::MemberExpression(m) => self.bind_member_expression_flow(expr, m), E::Literal(_) | E::PrivateIdentifier(_) | E::RegexLiteral(_) => {} E::ObjectExpression(o) => { for prop in o.properties { @@ -1852,73 +1843,19 @@ impl<'a> FlowBuilder<'a> { } } E::UnaryExpression(u) if u.operator == UnaryOperator::Bang => { - // `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, - ); + 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() => { - // `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); + self.bind_logical_binary_expression_flow(expr, b); } E::BinaryExpression(b) => { self.visit_expression(b.left); self.visit_expression(b.right); } - E::CallExpression(c) => { - // 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); - } - } - } - } - E::NewExpression(n) => { - self.visit_expression(n.callee); - for a in n.arguments { - self.visit_expression(a); - } - } + 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); @@ -1935,70 +1872,21 @@ impl<'a> FlowBuilder<'a> { self.visit_expression(e); } } - E::TaggedTemplateExpression(t) => { - self.visit_expression(t.tag); - for e in t.quasi.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) => { - // `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); - } - } + E::SequenceExpression(s) => self.bind_sequence_expression_flow(s), E::AssignmentExpression(a) if is_logical_assign_op(a.operator) => { - // `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), - ); - } - E::AssignmentExpression(a) => { - // `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); - } - E::ObjectPattern(op) => { - self.visit_decorators(op.decorators); - for prop in op.properties { - self.visit_object_pattern_property(prop); - } - } - E::ArrayPattern(ap) => { - self.visit_decorators(ap.decorators); - for el in ap.elements.iter().flatten() { - self.visit_expression(el); - } - } - E::AssignmentPattern(a) => { - self.visit_decorators(a.decorators); - self.visit_expression(a.left); - self.visit_expression(a.right); + 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), @@ -2006,12 +1894,7 @@ impl<'a> FlowBuilder<'a> { 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_expression(i.source); - if let Some(o) = i.options { - self.visit_expression(o); - } - } + E::ImportExpression(i) => self.visit_import_expression(i), E::JsdocCast(c) => self.visit_expression(c.inner), E::ParenthesizedExpression(p) => self.visit_expression(p.expression), } @@ -2021,6 +1904,183 @@ impl<'a> FlowBuilder<'a> { } } + #[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); + } + } + 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 From 3d6db6334c27efbd905057fd87fc98a58dd9bfeb Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Mon, 13 Jul 2026 09:03:21 -0400 Subject: [PATCH 72/79] refactor: split tsv_check binder flow/build.rs into build/ directory-module --- crates/tsv_check/CLAUDE.md | 8 +- crates/tsv_check/src/binder/flow/build.rs | 2458 ----------------- .../src/binder/flow/build/expressions.rs | 664 +++++ crates/tsv_check/src/binder/flow/build/mod.rs | 602 ++++ .../src/binder/flow/build/predicates.rs | 318 +++ .../src/binder/flow/build/statements.rs | 933 +++++++ 6 files changed, 2523 insertions(+), 2460 deletions(-) delete mode 100644 crates/tsv_check/src/binder/flow/build.rs create mode 100644 crates/tsv_check/src/binder/flow/build/expressions.rs create mode 100644 crates/tsv_check/src/binder/flow/build/mod.rs create mode 100644 crates/tsv_check/src/binder/flow/build/predicates.rs create mode 100644 crates/tsv_check/src/binder/flow/build/statements.rs diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 17f60ed47..8137543d4 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -93,8 +93,12 @@ `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.rs` (`FlowBuilder` + the - `pub fn build_flow` entry point + the pure AST predicates the walk + 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.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). diff --git a/crates/tsv_check/src/binder/flow/build.rs b/crates/tsv_check/src/binder/flow/build.rs deleted file mode 100644 index a02cd5f13..000000000 --- a/crates/tsv_check/src/binder/flow/build.rs +++ /dev/null @@ -1,2458 +0,0 @@ -use super::*; -use crate::binder::{BoundFile, NodeKind, addr_of, expression_addr_kind, statement_kind}; -use smallvec::SmallVec; -use tsv_ts::ast::Program; -use tsv_ts::ast::internal::{ - ArrowFunctionBody, AssignmentOperator, BinaryExpression, BinaryOperator, BreakStatement, - ClassDeclaration, ClassExpression, ClassMember, ConditionalExpression, ContinueStatement, - Decorator, DoWhileStatement, Expression, ForInOfLeft, ForInit, ForStatement, - FunctionDeclaration, FunctionExpression, Identifier, IfStatement, LabeledStatement, - LiteralValue, MethodDefinition, MethodKind, ObjectPatternProperty, ObjectProperty, Property, - Statement, SwitchCase, SwitchStatement, TSModuleDeclarationBody, TryStatement, UnaryOperator, - VariableDeclarator, WhileStatement, -}; - -/// 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: crate::hash::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: crate::hash::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); - } - } - } - - // --- statements ------------------------------------------------------- - - 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 ------------------------------------------- - - /// `maybeBindExpressionFlowIfCall` (binder.go:2143): a top-level dotted-name - /// (non-`super`) call is a potential assertion → `createFlowCall`. - 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); - } - } - - /// `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) - } - - // --- 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). - 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. - 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) - } - - // --- 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); - } - - /// 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 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, - ); - } - - 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, - ); - } - - /// 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. - 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(_) => {} - } - } - - // --- expressions ------------------------------------------------------ - - 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); - } - } - - 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()); - } - - 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), - } - } -} - -// --- pure AST predicates (binder.go / utilities.go ports) ------------------ - -/// `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. -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). -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). -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(super) 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(_) - ) -} - -fn is_true_keyword(expr: &Expression<'_>) -> bool { - matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(true))) -} - -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. -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`). -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`. -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. -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/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..62e3f758b --- /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: crate::hash::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: crate::hash::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(_) => {} + } + } +} From 0671f72f73e2e03b58aa217ec8a38b7363339a8a Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Mon, 13 Jul 2026 09:23:32 -0400 Subject: [PATCH 73/79] refactor: split tsv_check binder sym/walk.rs into statement/expression/members modules --- crates/tsv_check/CLAUDE.md | 15 +- crates/tsv_check/src/binder/sym/expression.rs | 169 ++++ crates/tsv_check/src/binder/sym/members.rs | 468 +++++++++++ crates/tsv_check/src/binder/sym/mod.rs | 107 ++- .../src/binder/sym/{walk.rs => statement.rs} | 730 +----------------- 5 files changed, 770 insertions(+), 719 deletions(-) create mode 100644 crates/tsv_check/src/binder/sym/expression.rs create mode 100644 crates/tsv_check/src/binder/sym/members.rs rename crates/tsv_check/src/binder/sym/{walk.rs => statement.rs} (52%) diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index 8137543d4..437aefe44 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -75,11 +75,16 @@ 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 both descendants share, and the - member-key resolver), `walk.rs` (the bind-descent methods — - `visit_statement`/`visit_expression` and everything they call into — - plus the functions-first statement-list ordering), and `declare.rs` (the - `declareSymbolEx` cascade and the container routing). + — 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. 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 index 2b186323e..5114c1113 100644 --- a/crates/tsv_check/src/binder/sym/mod.rs +++ b/crates/tsv_check/src/binder/sym/mod.rs @@ -46,16 +46,23 @@ //! `declare module "X"` augmentations) runs in [`crate::merge`] over the //! [`FileMerge`] product this bind returns. //! -//! Split for locality across three files: this module (`mod.rs`) keeps the +//! 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 both descendants share, and the member-key -//! resolver; `walk.rs` holds the bind-descent methods (`visit_statement`/ -//! `visit_expression` and everything they call into); `declare.rs` holds the -//! `declareSymbolEx` cascade and the container routing. No behavior distinction -//! between the three. +//! 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 walk; +mod expression; +mod members; +mod statement; use super::atoms::{Atom, Atoms}; use super::symbols::{Symbol, SymbolFlags, SymbolId, TableId}; @@ -67,7 +74,10 @@ use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; use string_interner::DefaultStringInterner; use tsv_lang::Span; use tsv_ts::ast::Program; -use tsv_ts::ast::internal::{Expression, Identifier, Literal, LiteralValue, ModuleExportName}; +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`). @@ -121,6 +131,13 @@ struct DeclInput { 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, @@ -207,6 +224,61 @@ impl<'a> SymbolBinder<'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 @@ -433,3 +505,22 @@ struct KeyInfo { 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/walk.rs b/crates/tsv_check/src/binder/sym/statement.rs similarity index 52% rename from crates/tsv_check/src/binder/sym/walk.rs rename to crates/tsv_check/src/binder/sym/statement.rs index 073f18c48..8b6ebe3e2 100644 --- a/crates/tsv_check/src/binder/sym/walk.rs +++ b/crates/tsv_check/src/binder/sym/statement.rs @@ -1,54 +1,24 @@ -//! The bind walk — descends the AST discovering declarations and routing them -//! through the declare/conflict cascade ([`super::declare`]): `visit_statement` -//! (the statement-shaped descent — variable/function/class/interface/enum/module -//! declarations, imports/exports, control flow) and `visit_expression` (the -//! pattern-aware expression descent — function/arrow/class expressions, object -//! literals, and the assignment-target/for-left pattern shapes), plus the -//! class/interface/module/type-literal member descents and the functions-first -//! statement-list ordering (`bindEachStatementFunctionsFirst`). -// -// tsgo: internal/binder/binder.go bindEachStatementFunctionsFirst (functions-first), -// bindClassLikeDeclaration (the static-`prototype` clash, :971) - -use super::super::symbols::{SymbolFlags, SymbolId}; -use super::{ContainerKind, DeclInput, NodeKind, Scope, SymbolBinder}; +//! 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::{ - ClassBody, ClassMember, ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, - Identifier, ImportSpecifier, MethodKind, ModuleExportName, ObjectExpression, - ObjectPatternProperty, ObjectProperty, PropertyKind, Statement, TSEnumMemberId, - TSInterfaceBody, TSModuleDeclarationBody, TSModuleName, TSType, TSTypeAnnotation, - TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration, + ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, Identifier, + ImportSpecifier, ModuleExportName, ObjectPatternProperty, Statement, TSModuleDeclarationBody, + TSModuleName, }; -/// Modifiers threaded from an `export` wrapper into the wrapped declaration. -#[derive(Clone, Copy, Default)] -struct DeclMods { - exported: bool, - default: bool, -} - impl<'a> SymbolBinder<'a> { - // --- statement lists (functions-first) ----------------------------------- - - pub(super) 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); - } - } - /// Sub-step A: declare a hoisted function's symbol only (no body descent), /// unwrapping any `export`/`export default` wrapper for its modifiers. - fn declare_hoisted_function(&mut self, stmt: &Statement<'a>) { + pub(super) fn declare_hoisted_function(&mut self, stmt: &Statement<'a>) { match stmt { Statement::FunctionDeclaration(f) => { if let Some(id) = &f.id { @@ -102,7 +72,12 @@ impl<'a> SymbolBinder<'a> { // --- statements ---------------------------------------------------------- - fn visit_statement(&mut self, stmt: &Statement<'a>, mods: DeclMods, skip_symbol: bool) { + 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); @@ -551,58 +526,22 @@ impl<'a> SymbolBinder<'a> { } } - // --- function names + scopes -------------------------------------------- + // --- 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); } - 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; - } - // --- params + bindings --------------------------------------------------- - fn bind_params(&mut self, params: &[Expression<'a>]) { + pub(super) fn bind_params(&mut self, params: &[Expression<'a>]) { for param in params { self.bind_param(param); } } - fn bind_param(&mut self, param: &Expression<'a>) { + 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 @@ -691,7 +630,7 @@ impl<'a> SymbolBinder<'a> { } } - fn decl_from_ident( + pub(super) fn decl_from_ident( &mut self, id: &Identifier<'a>, _node_span: Span, @@ -709,414 +648,7 @@ impl<'a> SymbolBinder<'a> { } } - // --- classes ------------------------------------------------------------- - - 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 --------------------------------------- - - 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; - } - - 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. - 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); - } - } - - 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; - } + // --- 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). @@ -1242,188 +774,6 @@ impl<'a> SymbolBinder<'a> { }; self.declare_alias(d, true); } - - // --- type parameters ----------------------------------------------------- - - 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, - ); - } - } - } - - 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, |_| {}); - } - - // --- expressions (nested scopes) ----------------------------------------- - - 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), - } - } - } } /// A [`SymbolFlags`] triple for a variable declaration kind: `(includes, @@ -1449,25 +799,6 @@ fn var_flags( } } -/// 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, - } -} - /// The span a bare parameter expression points a diagnostic at. fn param_span(param: &Expression<'_>) -> Span { match param { @@ -1484,19 +815,6 @@ fn el_span(el: &Expression<'_>) -> Span { } } -/// 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 -} - /// 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, From 98dd1755f349a43502b42a3ebccd1856ef57d677 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 22 Jul 2026 17:20:51 -0400 Subject: [PATCH 74/79] pin status --- .../src/cli/commands/tsc_conformance.rs | 22 +++++++++---------- .../src/tsc_conformance/runner/grade.rs | 17 +++++--------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs index 2386a1589..182f9c2a4 100644 --- a/crates/tsv_debug/src/cli/commands/tsc_conformance.rs +++ b/crates/tsv_debug/src/cli/commands/tsc_conformance.rs @@ -75,19 +75,19 @@ const INDEX_UNIT_ROUNDTRIP_PRETTY_PIN: usize = 14; /// are invariant gates; the counts below pin the in-scope denominators + /// parse-divergence census so any drift (a harness-port change, a tsv parser /// change, or a typescript-go pull) forces a deliberate re-pin. -const RUN_IN_SCOPE_TESTS_PIN: usize = 9388; -const RUN_IN_SCOPE_VARIANTS_PIN: usize = 9887; -const RUN_EXPECT_CLEAN_PIN: usize = 4435; -const RUN_BASELINED_PARSED_PIN: usize = 4446; -const RUN_PARSE_REJECTED_PIN: usize = 1006; -const RUN_PARSE_REJECTED_NO_BASELINE_PIN: usize = 45; -const RUN_PARSE_REJECTED_TS1XXX_PIN: usize = 451; -const RUN_PARSE_REJECTED_OTHER_PIN: usize = 510; +const RUN_IN_SCOPE_TESTS_PIN: usize = 9389; +const RUN_IN_SCOPE_VARIANTS_PIN: usize = 9888; +const RUN_EXPECT_CLEAN_PIN: usize = 4436; +const RUN_BASELINED_PARSED_PIN: usize = 4462; +const RUN_PARSE_REJECTED_PIN: usize = 990; +const RUN_PARSE_REJECTED_NO_BASELINE_PIN: usize = 44; +const RUN_PARSE_REJECTED_TS1XXX_PIN: usize = 456; +const RUN_PARSE_REJECTED_OTHER_PIN: usize = 490; const RUN_SCRIPT_RETRY_PIN: usize = 25; /// Tracked parser crashes carved out of the sweep (the `CRASH_EXCLUSIONS` /// ledger). Pinned so the ledger can't grow or shrink silently — a move means a /// tsv parser robustness change (a fix removes an entry; a regression adds one). -const RUN_CRASH_EXCLUDED_PIN: usize = 1; +const RUN_CRASH_EXCLUDED_PIN: usize = 0; /// REGRESSION PINS (exact, two-sided) for the family grading (the bind + merge + /// check + flow gate). Measured vs pin 168e7015. `family_extra` is gated to 0 @@ -111,7 +111,7 @@ const RUN_CRASH_EXCLUDED_PIN: usize = 1; /// (matches gained) is a real improvement that re-pins; a rise in `other` fails the /// run. `family_match` / `family_missing` are the aggregate totals (573 / 37); /// `dup_*` / `flow_*` pin the sub-family partitions. -const RUN_FAMILY_GRADED_PIN: usize = 4066; +const RUN_FAMILY_GRADED_PIN: usize = 4083; const RUN_FAMILY_POSITIVE_PIN: usize = 140; const RUN_FAMILY_MATCH_PIN: usize = 573; const RUN_FAMILY_MISSING_PIN: usize = 37; @@ -157,7 +157,7 @@ const RUN_MISSING_CAUSE_PINS: [(MissingCause, &str, usize); 4] = [ RUN_MISSING_DEFERRED_CFA_PIN, ), ]; -const RUN_CARVE_OUT_RULE_A_PIN: usize = 380; +const RUN_CARVE_OUT_RULE_A_PIN: usize = 379; const RUN_CARVE_OUT_RULE_A_FAMILY_PIN: usize = 11; const RUN_MODULE_DETECTION_PIN: usize = 1; diff --git a/crates/tsv_debug/src/tsc_conformance/runner/grade.rs b/crates/tsv_debug/src/tsc_conformance/runner/grade.rs index 08bf96062..64138883e 100644 --- a/crates/tsv_debug/src/tsc_conformance/runner/grade.rs +++ b/crates/tsv_debug/src/tsc_conformance/runner/grade.rs @@ -108,17 +108,12 @@ enum CrashKind { /// reported (never silently). Each entry names its cause + kind; the list is a /// tracked-defect ledger, not a way to hide bugs. A [`CrashKind::CatchablePanic`] /// entry is liveness-probed every run (see [`probe_crash_exclusion`]). -const CRASH_EXCLUSIONS: &[(&str, CrashKind)] = &[ - // tsv_ts robustness bug: `export * from ;` (a non-string module - // specifier) trips a `debug_assert!(TokenKind::String)` in - // `parse_string_literal` (parser/mod.rs). Dev-profile only (debug_assert is - // compiled out in release), so `cargo run` — the gate's profile — panics. - // A future tsv_ts fix should reject the form gracefully; then drop this entry. - ( - "exportDeclarationInInternalModule.ts", - CrashKind::CatchablePanic, - ), -]; +/// +/// Currently empty — no tracked parser crasher in the in-scope corpus. (The +/// former `exportDeclarationInInternalModule.ts` entry — `export * from +/// ;` tripping a `debug_assert!` in `parse_string_literal` — no +/// longer panics.) +const CRASH_EXCLUSIONS: &[(&str, CrashKind)] = &[]; /// The [`CrashKind`] of a crash-excluded test, or `None` if not excluded. fn crash_exclusion_kind(basename: &str) -> Option { From 97248d556ee94953a4fe4cfd2d7e7dff0c439ba3 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 28 Jul 2026 17:04:06 -0400 Subject: [PATCH 75/79] refactor: fold tsv_check hash onto tsv_lang, enforce clone discipline --- CLAUDE.md | 72 +- README.md | 10 +- benches/js/CLAUDE.md | 51 +- benches/js/conformance.ts | 44 - .../js/results/report.tsc-conformance.json | 43 +- benches/js/results/report.tsc-conformance.md | 16 +- crates/tsv_check/CLAUDE.md | 61 +- crates/tsv_check/Cargo.toml | 2 +- crates/tsv_check/src/binder/atoms.rs | 15 +- crates/tsv_check/src/binder/flow/build/mod.rs | 4 +- crates/tsv_check/src/binder/mod.rs | 9 +- crates/tsv_check/src/binder/sym/mod.rs | 23 +- .../tsv_check/src/check/duplicate_members.rs | 3 +- crates/tsv_check/src/hash.rs | 162 -- crates/tsv_check/src/lib.rs | 2 - crates/tsv_check/src/merge.rs | 3 +- crates/tsv_check/tests/clone_discipline.rs | 466 ++++++ crates/tsv_debug/CLAUDE.md | 2 +- .../src/cli/commands/tsc_conformance.rs | 1372 ++++++++++++----- .../src/cli/commands/tsc_conformance_pins.txt | 61 + crates/tsv_debug/src/tsc_conformance/mod.rs | 4 +- .../src/tsc_conformance/runner/grade.rs | 7 + .../src/tsc_conformance/runner/mod.rs | 1 + crates/tsv_lang/CLAUDE.md | 2 +- crates/tsv_lang/src/hash.rs | 153 +- deno.json | 1 + docs/architecture.md | 10 +- docs/cli.md | 2 +- docs/performance.md | 5 +- docs/typechecker.md | 203 +++ scripts/doctor.ts | 73 +- scripts/publish.ts | 21 +- 32 files changed, 2085 insertions(+), 818 deletions(-) delete mode 100644 crates/tsv_check/src/hash.rs create mode 100644 crates/tsv_check/tests/clone_discipline.rs create mode 100644 crates/tsv_debug/src/cli/commands/tsc_conformance_pins.txt create mode 100644 docs/typechecker.md diff --git a/CLAUDE.md b/CLAUDE.md index 9a24fc67c..1179c8fe0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,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 @@ -283,7 +283,7 @@ Package shape: built from the wasm-pack `web` target, then `scripts/patch_npm_pa `scripts/publish.ts` orchestrates the release end to end (preflight → bump → check → conformance:all → build npm packages + deno bundles → verify → artifact validation: size bounds + Deno smoke + Node tests → idempotent npm publish → git commit + tag + push), printing a wasm size summary (raw + gzipped) at the end. It stamps CHANGELOG.md's `## Unreleased` section into the released version's section — that section must be non-empty and carry a `` marker that matches `--bump` (required in **both** places, must agree; on stamp a fresh empty `## Unreleased` at `bump: patch` is seeded). The user keeps it updated as work lands — agents don't touch `CHANGELOG.md` (see [Committing](#committing)). A failed wetrun is resumable: re-run `--wetrun` without `--bump`. -**Conformance gates (Step 3b).** The external-oracle correctness gates (see [Corpus Comparison](#corpus-comparison)) run here via `deno task conformance:all`; skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../typescript-go`, `../test262` checkouts — for the tsc-check leg also the materialized `_submodules/TypeScript` corpus + `internal/bundled/libs` — + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. Only the CSS-WPT harvest stays manual. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; FFI nondeterminism surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). +**Conformance gates (Step 3b).** The external-oracle correctness gates (see [Corpus Comparison](#corpus-comparison)) run here via `deno task conformance:all`; skipped by `--no-check`. The step preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, `../test262` checkouts + the `benches/js` `node_modules` sidecar, `deno task bench:install`): a **`--wetrun` FAILS** when any is missing (releasing without gates requires the explicit `--no-check`), a dry-run warn-and-skips, and any skip is re-warned in the run's final summary. `deno task doctor` checks the same setup (and more) ahead of time. Only the CSS-WPT harvest stays manual. A `corpus:compare:format` SAFETY hit is self-verified in-run (the native format is re-run and must reproduce byte-identically), so treat it as real; FFI nondeterminism surfaces as a loud `native format nondeterminism` per-file error instead (see ./benches/js/CLAUDE.md §Known Issues). ```bash deno task publish # dry-run: validate everything, no mutation @@ -339,10 +339,9 @@ 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 + -# conformance:tsc-roundtrip + conformance:tsc-check + 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; the two tsc legs are pure-Rust cargo shell-outs), then render:audit over the version-pinned -# checkouts (also a subprocess — it drives its own sidecar). The external-oracle correctness gates that +# 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). @@ -522,7 +521,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/ # TypeScript binder + checker (tsgo-conformance target; consumed only 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) @@ -876,56 +875,22 @@ 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). -**tsgo Typechecker-Conformance Harness (`tsc_conformance`)** — pure-Rust harness over -tsgo's committed `.errors.txt` error baselines (`../typescript-go`, oracle pin `168e7015`, -read from the checked-in `testdata/baselines/reference/submodule`). No Deno. The -oracle-side tools (`query`/`roundtrip`/`index`) are **zero checker code**; -`run`/`check-test` drive the in-development **`tsv_check`** crate against the same -baselines. `query`/`roundtrip` run on a bare checkout; `index`/`run` also need the -materialized `_submodules/TypeScript` corpus (`git submodule update --init`), and `run` -the bundled libs. Distinct from the parser-conformance surfaces: `conformance:ts-repo` -grades tsv's *parser* against the tsc corpus, this reads tsgo's *checker* error output — -the seam `tsv_check` emits through. +**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 -cargo run -p tsv_debug tsc_conformance query histogram # per-TS-code instance counts + totals -cargo run -p tsv_debug tsc_conformance query tests-by-code 2454 # baselines mentioning a code -cargo run -p tsv_debug tsc_conformance query denominators # test-identity / variant / JSX sizing -cargo run -p tsv_debug tsc_conformance roundtrip # parse every baseline → re-render → byte-compare (the P0 self-check) -cargo run -p tsv_debug --quiet tsc_conformance run # the conformance gate over tsv_check -cargo run -p tsv_debug --quiet tsc_conformance check-test duplicateVar --variant target=es2015 # inner dev loop: one test, diagnostics vs baseline -cargo run -p tsv_debug tsc_conformance index # the corpus-INPUT side: directives, @filename units, varyBy variants -deno task conformance:tsc-roundtrip # roundtrip, as a deno task -deno task conformance:tsc-check # run + writes benches/js/results/report.tsc-conformance.{json,md} -# Common options: --path (default ../typescript-go), --json, --verbose. -# roundtrip: filter by path substring (skips the pins). run: triage filters --test / -# --code / --variant k=v / --family {dup,flow,all} skip the pins (invariant gates still -# hold); --emit-manifest , --report (full-run only). +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 ``` -Every full (unfiltered) run enforces exact two-sided pins — a re-pin is deliberate (a code -change or a tsgo pull). `roundtrip` proves the `.errors.txt` parser + renderer port in one -move; the 14 ANSI `pretty=true` baselines take their own colored model but stay in the -denominator, so round-trip is 100%. `run` sweeps every in-scope variant (single-file, -non-JSX, non-JS-flavored, non-skipped) through parse → lower+bind → check → sort/dedup, -grading expect-clean variants (zero-diagnostic) plus two families as codes+spans multisets -— the bind/merge duplicate-conflict family (TS2300/2451/2567/2528 + merge-path codes) and -the flow family (TS7027 unreachable code, TS7028 unused label). `extra=0` is a hard gate; -`missing` is classified by deferred cause (merge / lib / deferred_late_bound / -deferred_cfa / other, the last HARD-zero). It also publishes the parse-divergence census, -runs each test `catch_unwind`-wrapped on a generous-stack worker (tracked parser crashes in -a pinned `CRASH_EXCLUSIONS` ledger), and drops per-test `.diff` artifacts under -`target/tsc_conformance/diffs/` on failure. `index` proves three gates against the on-disk -baselines: the baseline join, the unit-text round-trip, and the exact denominator pins. - -`roundtrip` and `run` are legs of `deno task conformance` (the pre-release aggregate), so -they run in publish **Step 3b** — their pins fail a release on drift, and `run`'s preflight -additionally needs the materialized corpus AND `internal/bundled/libs`. `../typescript-go` -is therefore a release-required oracle (a missing checkout FAILS a `--wetrun`, warn-skips a -dry-run, is re-warned in the final summary), and `deno task doctor` reports its readiness. -Like `../typescript` it is a git-SHA checkout, so — matching that precedent — it is **not** -in `pins:audit` (npm-version only); its tsgo commit is pinned by the Rust count-pins. It -stays out of `deno task check` (which is external-oracle-free). +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): @@ -1172,6 +1137,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/README.md b/README.md index c9a629469..93c960a91 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,8 @@ Future features (unknown order): - CSS error recovery (recover past invalid CSS per the spec) - Svelte compiler (partially implemented, not ready for usage) - later: - - TypeScript type checking (`tsv_check`, in development — a from-scratch Rust - binder/checker graded for exact conformance against TypeScript 7 (the Go - impl)'s own test baselines), unlocking: - - svelte-check replacement - - LSP - - linter - type aware, initially focused on serializable data-only plugins for extensibility + - TypeScript type checking (`tsv_check` - experimental, may never ship; would + unlock a svelte-check replacement, LSP, and type-aware linting) - bundling is probably out of scope - [discussion](https://github.com/fuzdev/tsv/discussions) welcome @@ -262,7 +258,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/ # TypeScript binder/checker (in development; not in any shipped artifact) +│ ├── 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 aa7693da8..a7c5fce56 100644 --- a/benches/js/CLAUDE.md +++ b/benches/js/CLAUDE.md @@ -454,29 +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), the two `tsc_conformance` legs -(`tsc-roundtrip` — the baseline parse↔render self-check — then `tsc-check` — the -tsv_check binder-family conformance gate, which also writes the committed -`results/report.tsc-conformance.{json,md}`; both pure-Rust, shelled out via -`cargo`, so the task carries `--allow-run=cargo`; `tsc-check` additionally needs -the materialized `_submodules/TypeScript` corpus + `internal/bundled/libs`), 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`). `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 seven 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 (the two `tsc_conformance` legs are -pure-Rust `cargo` shell-outs), 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`, `../typescript-go`, `../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 @@ -514,10 +512,19 @@ every real move in a number is a deliberate, visible edit. small shrink warns and still writes, only a >10% collapse fails). - Rust-side counts are consts in their commands — grep `REGRESSION PIN`: test262 (discovered + graded-manifest), `fixtures_validate` (total fixtures — - protects the primary gate against a discovery collapse), `swallow_audit` - (formatted files — closes its vacuous-pass), and `tsc_conformance` (the - largest set: baseline/roundtrip/pretty pins + the `INDEX_*` denominators + - the `RUN_*` family-gate pins, all in `cli/commands/tsc_conformance.rs`). + 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.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:** @@ -1240,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 seven 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/conformance.ts b/benches/js/conformance.ts index 2b9fc92c5..f0e593dac 100644 --- a/benches/js/conformance.ts +++ b/benches/js/conformance.ts @@ -42,48 +42,6 @@ if (Deno.args.length > 0) { Deno.exit(1); } -/** - * The tsc_conformance roundtrip self-check — pure-Rust, reads tsgo's committed - * `.errors.txt` baselines from `../typescript-go`. Shelled out (the only non-JS - * leg) so it rides the same fail-fast + one summary as the parse gates. The - * command prints its own report and exits non-zero on a pin mismatch or a - * missing checkout, so mirror the other legs' "exit the process on a finding". - */ -async function run_tsc_roundtrip(): Promise { - const {code} = await new Deno.Command('cargo', { - args: ['run', '-p', 'tsv_debug', '--quiet', 'tsc_conformance', 'roundtrip'], - stdout: 'inherit', - stderr: 'inherit', - }).output(); - if (code !== 0) Deno.exit(1); -} - -/** - * The tsc_conformance checker sweep — the standing bind+merge family gate over the - * in-scope corpus, writing the committed deterministic report Rust-side (so no - * extra Deno write perms are needed — the cargo subprocess writes it). Same - * pure-Rust shell-out + fail-fast contract as the roundtrip leg; unlike roundtrip - * it also needs the corpus inputs + bundled libs (publish Step 3b's preflight - * probes them). - */ -async function run_tsc_check(): Promise { - const {code} = await new Deno.Command('cargo', { - args: [ - 'run', - '-p', - 'tsv_debug', - '--quiet', - 'tsc_conformance', - 'run', - '--report', - 'benches/js/results/report.tsc-conformance', - ], - stdout: 'inherit', - stderr: 'inherit', - }).output(); - if (code !== 0) Deno.exit(1); -} - /** * `render:audit` over the version-pinned checkouts — does `tsv format` change * what a Svelte component RENDERS? @@ -141,8 +99,6 @@ const legs: [string, () => Promise][] = [ ['conformance:svelte-fixtures', () => run_fixtures_gate(SVELTE_FIXTURES_GATE)], ['conformance:ts-fixtures', () => run_fixtures_gate(TS_FIXTURES_GATE)], ['conformance:ts-repo', () => run_ts_repo_compare([])], - ['conformance:tsc-roundtrip', run_tsc_roundtrip], - ['conformance:tsc-check', run_tsc_check], ['corpus:compare:parse --all', () => run_corpus_compare_parse(['--all'])], ['corpus:compare:format --all', () => run_corpus_compare_format(['--all'])], ['render:audit (pinned checkouts)', run_render_audit] diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json index 1324be12c..76ab90087 100644 --- a/benches/js/results/report.tsc-conformance.json +++ b/benches/js/results/report.tsc-conformance.json @@ -1,12 +1,12 @@ { "oracle": "tsgo committed .errors.txt baselines (bind + merge + flow family)", "denominators": { - "in_scope_tests": 9388, - "in_scope_variants": 9887, - "expect_clean_graded": 4435, - "clean_pass": 4435, - "baselined_parsed": 4446, - "family_graded_variants": 4066, + "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": { @@ -49,29 +49,32 @@ "span_mismatch": 0 }, "carve_outs": { - "recovery_ast_rule_a": 380, + "recovery_ast_rule_a": 379, "recovery_ast_rule_a_family": 11, "module_detection_variants": 1 }, "census": { - "parse_rejected_total": 1006, - "parse_rejected_no_baseline": 45, - "parse_rejected_ts1xxx_only": 451, - "parse_rejected_other": 510, + "parse_rejected_total": 988, + "parse_rejected_no_baseline": 44, + "parse_rejected_ts1xxx_only": 456, + "parse_rejected_other": 488, "script_retry": 25, - "crash_excluded": 1 + "crash_excluded": 0 }, "lib": { "files_bound": 107, "sets_folded": 50 }, "pins": { - "in_scope_tests": 9388, - "in_scope_variants": 9887, - "expect_clean": 4435, - "baselined_parsed": 4446, - "parse_rejected": 1006, - "family_graded": 4066, + "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, @@ -90,11 +93,11 @@ "related_missing": 0, "related_extra": 0, "related_span_mismatch": 0, - "carve_out_rule_a": 380, + "carve_out_rule_a": 379, "carve_out_rule_a_family": 11, "module_detection": 1, "script_retry": 25, - "crash_excluded": 1, + "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 index 0540038dc..512a6ac32 100644 --- a/benches/js/results/report.tsc-conformance.md +++ b/benches/js/results/report.tsc-conformance.md @@ -4,11 +4,11 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge + flow family). Det ## Denominators -- in-scope tests: 9388 -- in-scope variants: 9887 -- expect-clean graded / clean pass: 4435 / 4435 -- baselined + parsed: 4446 -- family graded / family-positive: 4066 / 140 +- 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) @@ -37,14 +37,14 @@ Oracle: tsgo committed `.errors.txt` baselines (bind + merge + flow family). Det ## Carve-outs -- recovery-AST rule (a): 380 (family-positive 11) +- recovery-AST rule (a): 379 (family-positive 11) - moduleDetection variants (inert for family): 1 ## Parse-divergence census -- parse-rejected: 1006 (no baseline 45, TS1xxx-only 451, other 510) +- parse-rejected: 988 (no baseline 44, TS1xxx-only 456, other 488) - script-goal retries: 25 -- crash-excluded (tracked): 1 +- crash-excluded (tracked): 0 ## Lib base diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md index b568f4cbd..6e2037bf9 100644 --- a/crates/tsv_check/CLAUDE.md +++ b/crates/tsv_check/CLAUDE.md @@ -1,11 +1,17 @@ # tsv_check -> 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. +> **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 @@ -22,8 +28,8 @@ 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 the root - CLAUDE.md §tsgo Typechecker-Conformance Harness). + test corpus, graded by `tsv_debug tsc_conformance` (see + ../../docs/typechecker.md). - `unsafe_code = "forbid"` (workspace lints inherited). ## Module map @@ -89,7 +95,7 @@ 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 the crate's `FxHashMap`, no external interning + 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 @@ -132,7 +138,9 @@ `&'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. + 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 @@ -173,8 +181,30 @@ `CheckOptions { allow_unreachable_code, allow_unused_labels, preserve_const_enums }`, threaded into `check_bound`. Default everywhere outside the conformance harness. -- `hash.rs` — crate-private Fx-style multiply-xor hasher + - `FxHashMap`/`FxHashSet` aliases (no external hashing dependency). +- 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 @@ -199,7 +229,7 @@ every non-conformance caller. ## Which tool answers which question -- `tsv_debug tsc_conformance run` — the standing gate: sweeps the in-scope +- `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 @@ -214,11 +244,14 @@ every non-conformance caller. 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; exact `RUN_*` pins. - Triage filters (`--test`/`--code`/`--variant`) skip the pins; + 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. A - release-gating leg of `deno task conformance` (`conformance:tsc-check`). + `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]` — diff --git a/crates/tsv_check/Cargo.toml b/crates/tsv_check/Cargo.toml index 887437324..f23d9146e 100644 --- a/crates/tsv_check/Cargo.toml +++ b/crates/tsv_check/Cargo.toml @@ -14,7 +14,7 @@ tsv_ts = { workspace = true } # 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 the crate's FxHashMap — no +# own name interner is a hand-rolled table over `tsv_lang`'s FxHashMap — no # external interning crate.) smallvec = { workspace = true } diff --git a/crates/tsv_check/src/binder/atoms.rs b/crates/tsv_check/src/binder/atoms.rs index 26a489502..8ead1b772 100644 --- a/crates/tsv_check/src/binder/atoms.rs +++ b/crates/tsv_check/src/binder/atoms.rs @@ -16,16 +16,17 @@ //! 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 the -//! crate'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. +//! 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 crate::hash::FxHashMap; +use tsv_lang::FxHashMap; /// A dense interned name identity, valid within one file's bind. /// diff --git a/crates/tsv_check/src/binder/flow/build/mod.rs b/crates/tsv_check/src/binder/flow/build/mod.rs index 62e3f758b..1ddaaf824 100644 --- a/crates/tsv_check/src/binder/flow/build/mod.rs +++ b/crates/tsv_check/src/binder/flow/build/mod.rs @@ -94,7 +94,7 @@ pub(super) struct FlowBuilder<'a> { /// 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: crate::hash::FxHashMap>, + label_scratch: tsv_lang::FxHashMap>, // products flow_of_node: Vec>, @@ -163,7 +163,7 @@ impl<'a> FlowBuilder<'a> { subject: Vec::new(), antecedent: Vec::new(), pool: Vec::new(), - label_scratch: crate::hash::FxHashMap::default(), + label_scratch: tsv_lang::FxHashMap::default(), flow_of_node: vec![None; n], node_flags: vec![0u8; n], end_flow: Vec::new(), diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs index 48f50b1e4..f0a11c7c8 100644 --- a/crates/tsv_check/src/binder/mod.rs +++ b/crates/tsv_check/src/binder/mod.rs @@ -61,10 +61,9 @@ mod sym; pub mod symbols; use crate::diag::Diagnostic; -use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; use crate::merge::FileMerge; -use tsv_lang::Span; +use tsv_lang::{FxHashMap, Span}; use tsv_ts::ast::Program; use tsv_ts::ast::internal::{Expression, Statement, TSModuleReference}; @@ -267,6 +266,12 @@ pub struct BoundFile { /// 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). diff --git a/crates/tsv_check/src/binder/sym/mod.rs b/crates/tsv_check/src/binder/sym/mod.rs index dfb843c6f..33dc755a7 100644 --- a/crates/tsv_check/src/binder/sym/mod.rs +++ b/crates/tsv_check/src/binder/sym/mod.rs @@ -68,10 +68,9 @@ use super::atoms::{Atom, Atoms}; use super::symbols::{Symbol, SymbolFlags, SymbolId, TableId}; use super::{FileFacts, NodeKind, addr_of}; use crate::diag::Diagnostic; -use crate::hash::FxHashMap; use crate::ids::{FileId, NodeId}; use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; -use tsv_lang::Span; +use tsv_lang::{FxHashMap, Span}; use tsv_ts::ast::Program; use tsv_ts::ast::internal::{ ExportDefaultValue, Expression, Identifier, Literal, LiteralValue, ModuleExportName, Statement, @@ -312,7 +311,12 @@ impl<'a> SymbolBinder<'a> { } /// Resolve a symbol table into merge symbols, in **declaration order** (first - /// declaration's span start) — deterministic iteration, never the hash-map's. + /// 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() @@ -334,7 +338,18 @@ impl<'a> SymbolBinder<'a> { } }) .collect(); - symbols.sort_by_key(|s| s.decls.first().map_or(u32::MAX, |d| d.error_span.start)); + // 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 } diff --git a/crates/tsv_check/src/check/duplicate_members.rs b/crates/tsv_check/src/check/duplicate_members.rs index e7dcd3f3a..eb8ea0c57 100644 --- a/crates/tsv_check/src/check/duplicate_members.rs +++ b/crates/tsv_check/src/check/duplicate_members.rs @@ -31,10 +31,9 @@ // + reportDuplicateMemberErrors use crate::diag::{Category, Diagnostic}; -use crate::hash::FxHashMap; use crate::ids::FileId; use crate::span_scan::{bracket_end, bracket_start}; -use tsv_lang::Span; +use tsv_lang::{FxHashMap, Span}; use tsv_ts::ast::internal::{ ClassMember, Expression, Literal, LiteralValue, MethodKind, TSTypeElement, TSTypeParameterDeclaration, diff --git a/crates/tsv_check/src/hash.rs b/crates/tsv_check/src/hash.rs deleted file mode 100644 index 312aba26c..000000000 --- a/crates/tsv_check/src/hash.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! A hand-rolled Fx-style multiply-xor hasher for the checker's integer-keyed -//! maps. -//! -//! std's SipHash is DoS-resistant but slow for the small integer keys (node -//! ids, symbol ids, arena addresses) the checker hashes on its hot paths. This -//! is the well-known Fx fold — rotate, xor the next word, multiply by a fixed -//! odd constant — re-implemented in ~20 lines with no external dependency (the -//! crate-private infrastructure the design calls for). It is deliberately -//! **not** DoS-resistant: these maps are fed program-internal ids and addresses, -//! never adversarial network input. The fold consumes fixed 8-byte little-endian -//! words, so a 32-bit wasm target and a 64-bit native target produce identical -//! hashes. -// -// tsgo uses xxh3-128 for variable-arity list hashing; the Fx fold is tsv's -// hand-rolled substitute here (no new dependency). - -use std::collections::{HashMap, HashSet}; -use std::hash::{BuildHasherDefault, Hasher}; - -/// The Fx seed: a fixed odd 64-bit constant (the fractional bits of the golden -/// ratio — the constant rustc-hash uses). -const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95; - -/// The per-step left rotation. -const ROTATE: u32 = 5; - -/// A fast, non-cryptographic hasher over the Fx multiply-xor fold. -#[derive(Default)] -pub struct FxHasher { - hash: u64, -} - -impl FxHasher { - /// Fold one 64-bit word into the running hash. - #[inline] - fn fold(&mut self, word: u64) { - self.hash = (self.hash.rotate_left(ROTATE) ^ word).wrapping_mul(SEED); - } -} - -impl Hasher for FxHasher { - #[inline] - fn write(&mut self, bytes: &[u8]) { - let mut chunks = bytes.chunks_exact(8); - for chunk in &mut chunks { - // `chunks_exact(8)` yields slices of exactly 8 bytes, so the array - // conversion always succeeds; the fallback keeps this total. - let word = u64::from_le_bytes(<[u8; 8]>::try_from(chunk).unwrap_or([0; 8])); - self.fold(word); - } - let rest = chunks.remainder(); - if !rest.is_empty() { - let mut buf = [0u8; 8]; - buf[..rest.len()].copy_from_slice(rest); - self.fold(u64::from_le_bytes(buf)); - } - } - - #[inline] - fn write_u8(&mut self, i: u8) { - self.fold(u64::from(i)); - } - - #[inline] - fn write_u16(&mut self, i: u16) { - self.fold(u64::from(i)); - } - - #[inline] - fn write_u32(&mut self, i: u32) { - self.fold(u64::from(i)); - } - - #[inline] - fn write_u64(&mut self, i: u64) { - self.fold(i); - } - - #[inline] - fn write_usize(&mut self, i: usize) { - self.fold(i as u64); - } - - #[inline] - fn finish(&self) -> u64 { - self.hash - } -} - -/// The `BuildHasher` for [`FxHasher`] (zero-state, so `Default`-constructible). -pub type FxBuildHasher = BuildHasherDefault; - -/// A `HashMap` keyed through the Fx fold. -pub type FxHashMap = HashMap; - -/// A `HashSet` keyed through the Fx fold. Part of the map/set alias pair; the -/// binder uses `FxHashMap` today, and scope-membership sets will use this. -#[allow(dead_code)] -pub type FxHashSet = HashSet; - -#[cfg(test)] -mod tests { - use super::*; - use std::hash::Hash; - - /// Re-derive one word's fold by hand — pins the algorithm (rotate-xor-mul by - /// `SEED`), not just self-consistency. - #[test] - fn write_u32_matches_manual_fold() { - let mut h = FxHasher::default(); - h.write_u32(0xdead_beef); - let expected = (0u64.rotate_left(ROTATE) ^ 0xdead_beef_u64).wrapping_mul(SEED); - assert_eq!(h.finish(), expected); - } - - /// Two folds compose in order (the running hash is carried, not reset). - #[test] - fn two_writes_fold_in_order() { - let mut h = FxHasher::default(); - h.write_u32(1); - h.write_u32(2); - let s1 = (0u64.rotate_left(ROTATE) ^ 1).wrapping_mul(SEED); - let s2 = (s1.rotate_left(ROTATE) ^ 2).wrapping_mul(SEED); - assert_eq!(h.finish(), s2); - } - - /// `write` folds full 8-byte words then a zero-padded tail. - #[test] - fn write_bytes_chunks_then_tail() { - let mut h = FxHasher::default(); - h.write(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); - let w1 = u64::from_le_bytes([1, 2, 3, 4, 5, 6, 7, 8]); - let w2 = u64::from_le_bytes([9, 10, 11, 12, 0, 0, 0, 0]); - let s1 = (0u64.rotate_left(ROTATE) ^ w1).wrapping_mul(SEED); - let s2 = (s1.rotate_left(ROTATE) ^ w2).wrapping_mul(SEED); - assert_eq!(h.finish(), s2); - } - - /// A single scalar hashes the same through the derived `Hash` impl. - #[test] - fn hash_trait_routes_through_fold() { - let mut h = FxHasher::default(); - 0x0063_u32.hash(&mut h); - let expected = (0u64.rotate_left(ROTATE) ^ 0x0063_u64).wrapping_mul(SEED); - assert_eq!(h.finish(), expected); - } - - #[test] - fn map_and_set_round_trip() { - let mut m: FxHashMap = FxHashMap::default(); - m.insert(7, 70); - m.insert(9, 90); - assert_eq!(m.get(&7), Some(&70)); - assert_eq!(m.get(&9), Some(&90)); - assert_eq!(m.get(&8), None); - - let mut s: FxHashSet = FxHashSet::default(); - s.insert(0xabcd); - assert!(s.contains(&0xabcd)); - assert!(!s.contains(&0x1234)); - } -} diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs index 7e91bef00..ef06530e0 100644 --- a/crates/tsv_check/src/lib.rs +++ b/crates/tsv_check/src/lib.rs @@ -32,7 +32,6 @@ //! //! - [`ids`] — `NodeId` / `FileId` dense-integer identities. //! - [`diag`] — the `Diagnostic` shape and the canonical sort/dedup kernel. -//! - `hash` (private) — the crate's Fx-style hasher and `FxHashMap`/`FxHashSet`. //! - `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 + @@ -56,7 +55,6 @@ mod binder; mod check; -mod hash; mod options; mod program; mod span_scan; diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs index 5acff17b3..60a8d2432 100644 --- a/crates/tsv_check/src/merge.rs +++ b/crates/tsv_check/src/merge.rs @@ -51,9 +51,8 @@ use crate::binder::symbols::SymbolFlags; use crate::diag::{Category, Diagnostic}; -use crate::hash::FxHashMap; use crate::ids::FileId; -use tsv_lang::Span; +use tsv_lang::{FxHashMap, Span}; /// tsgo's `InternalSymbolNameDefault`-style reserved global identifiers the merge /// checks by name. 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_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index dcf0d4e25..7c5a0e6b5 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -15,7 +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, `