From 07f53b25ec615959d4d33afb338a2e1025bd39b1 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 13:31:23 -0400 Subject: [PATCH 001/174] docs: permit agent commits on the svelte-compiler branch --- CLAUDE.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa6ec5d7e..4d3911be0 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 (`svelte-compiler` branch / the `~/dev/tsv-svelte-compiler` +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 2c7185b332cd57fac18beed16990f76539c49039 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 13:45:03 -0400 Subject: [PATCH 002/174] feat: add svelte-compile oracle sidecar tool + canonical_compile command --- .../src/cli/commands/canonical_compile.rs | 107 +++++++++++++++++ crates/tsv_debug/src/cli/commands/mod.rs | 1 + crates/tsv_debug/src/cli/mod.rs | 10 +- crates/tsv_debug/src/deno/mod.rs | 111 ++++++++++++++++++ crates/tsv_debug/src/deno/sidecar.ts | 33 +++++- 5 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 crates/tsv_debug/src/cli/commands/canonical_compile.rs diff --git a/crates/tsv_debug/src/cli/commands/canonical_compile.rs b/crates/tsv_debug/src/cli/commands/canonical_compile.rs new file mode 100644 index 000000000..89d492974 --- /dev/null +++ b/crates/tsv_debug/src/cli/commands/canonical_compile.rs @@ -0,0 +1,107 @@ +use crate::cli::CliError; +use crate::deno::{self, SvelteCompileOutput, SvelteGenerate}; +use argh::FromArgs; +use tsv_cli::cli::input::{InputArgs, ParserType}; +use tsv_cli::json_utils::to_json_with_tabs; + +/// Compile Svelte with the canonical Svelte compiler (the deterministic oracle). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "canonical_compile")] +#[allow(clippy::struct_excessive_bools)] // independent CLI flags +pub struct CanonicalCompileCommand { + /// compile target: server | client (default: server) + #[argh(option, default = "SvelteGenerate::Server")] + target: SvelteGenerate, + + /// also print the compiled CSS + #[argh(switch)] + css: bool, + + /// development-mode output + #[argh(switch)] + dev: bool, + + /// emit { js, css, warnings } as JSON + #[argh(switch)] + json: bool, + + /// content to compile + #[argh(option)] + content: Option, + + /// read from stdin + #[argh(switch)] + stdin: bool, + + /// file path + #[argh(positional)] + file: Option, +} + +impl CanonicalCompileCommand { + pub(crate) fn run(self) -> Result<(), CliError> { + // Compile is Svelte-only, so force the parser: --content/--stdin don't + // require an explicit --parser, and a file argument's extension is ignored. + let input_args = InputArgs { + content: self.content, + stdin: self.stdin, + parser: Some(ParserType::Svelte), + file: self.file, + }; + let input = match input_args.resolve() { + Ok((input, _parser)) => input, + Err(e) => { + eprintln!("Error: {e}"); + return Err(CliError::Failed); + } + }; + + let rt = super::create_runtime(); + match rt.block_on(deno::svelte_compile(input.content(), self.target, self.dev)) { + Ok(output) => print_output(&output, self.css, self.json), + Err(err) => { + eprintln!("Error compiling: {err}"); + Err(CliError::Failed) + } + } + } +} + +/// Print the compile output: JSON when `as_json`, else the JS (and the CSS when +/// `show_css`, under a delimiting comment). +fn print_output( + output: &SvelteCompileOutput, + show_css: bool, + as_json: bool, +) -> Result<(), CliError> { + if as_json { + match to_json_with_tabs(output) { + Ok(json) => { + println!("{json}"); + Ok(()) + } + Err(err) => { + eprintln!("Error serializing output: {err}"); + Err(CliError::Failed) + } + } + } else { + print_block(&output.js); + if show_css { + println!("/* --- css --- */"); + match &output.css { + Some(css) => print_block(css), + None => println!("/* (none) */"), + } + } + Ok(()) + } +} + +/// Print `text`, ensuring it ends with exactly one trailing newline. +fn print_block(text: &str) { + print!("{text}"); + if !text.ends_with('\n') { + println!(); + } +} diff --git a/crates/tsv_debug/src/cli/commands/mod.rs b/crates/tsv_debug/src/cli/commands/mod.rs index d09ceb240..64ebc268a 100644 --- a/crates/tsv_debug/src/cli/commands/mod.rs +++ b/crates/tsv_debug/src/cli/commands/mod.rs @@ -3,6 +3,7 @@ pub mod ast_diff; pub mod authoring_audit; pub mod buffer_sizes; pub mod build_fanout_audit; +pub mod canonical_compile; pub mod canonical_parse; pub mod check; pub mod compare; diff --git a/crates/tsv_debug/src/cli/mod.rs b/crates/tsv_debug/src/cli/mod.rs index 188ba2baf..5e0c31eca 100644 --- a/crates/tsv_debug/src/cli/mod.rs +++ b/crates/tsv_debug/src/cli/mod.rs @@ -6,10 +6,10 @@ use commands::swallow_audit::SwallowAuditCommand; use commands::{ arena_stats::ArenaStatsCommand, ast_diff::AstDiffCommand, authoring_audit::AuthoringAuditCommand, buffer_sizes::BufferSizesCommand, - build_fanout_audit::BuildFanoutAuditCommand, canonical_parse::CanonicalParseCommand, - check::CheckCommand, compare::CompareCommand, conformance_audit::ConformanceAuditCommand, - fixture_init::FixtureInitCommand, fixtures_audit::FixturesAuditCommand, - fixtures_update::FixturesUpdateCommand, + build_fanout_audit::BuildFanoutAuditCommand, canonical_compile::CanonicalCompileCommand, + canonical_parse::CanonicalParseCommand, check::CheckCommand, compare::CompareCommand, + conformance_audit::ConformanceAuditCommand, fixture_init::FixtureInitCommand, + fixtures_audit::FixturesAuditCommand, fixtures_update::FixturesUpdateCommand, fixtures_update_formatted::FixturesUpdateFormattedCommand, fixtures_update_parsed::FixturesUpdateParsedCommand, fixtures_validate::FixturesValidateCommand, format_prettier::FormatPrettierCommand, @@ -66,6 +66,7 @@ pub enum Subcommand { AstDiff(AstDiffCommand), LineWidth(LineWidthCommand), CanonicalParse(CanonicalParseCommand), + CanonicalCompile(CanonicalCompileCommand), FormatPrettier(FormatPrettierCommand), FixtureInit(FixtureInitCommand), FixturesUpdate(FixturesUpdateCommand), @@ -107,6 +108,7 @@ impl TopLevel { Subcommand::AstDiff(c) => c.run(), Subcommand::LineWidth(c) => c.run(), Subcommand::CanonicalParse(c) => c.run(), + Subcommand::CanonicalCompile(c) => c.run(), Subcommand::FormatPrettier(c) => c.run(), Subcommand::FixtureInit(c) => c.run(), Subcommand::FixturesUpdate(c) => c.run(), diff --git a/crates/tsv_debug/src/deno/mod.rs b/crates/tsv_debug/src/deno/mod.rs index 62ee741bc..99c1784a8 100644 --- a/crates/tsv_debug/src/deno/mod.rs +++ b/crates/tsv_debug/src/deno/mod.rs @@ -150,6 +150,90 @@ pub enum PrettierParser<'a> { Filepath(&'a str), } +/// Target runtime for [`svelte_compile`]: which output the Svelte compiler emits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SvelteGenerate { + /// Server-side rendering output (the default). + Server, + /// Client-side output. + Client, +} + +impl SvelteGenerate { + /// The `generate` value the Svelte compiler expects. + fn name(self) -> &'static str { + match self { + SvelteGenerate::Server => "server", + SvelteGenerate::Client => "client", + } + } +} + +impl std::str::FromStr for SvelteGenerate { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "server" => Ok(SvelteGenerate::Server), + "client" => Ok(SvelteGenerate::Client), + _ => Err(format!( + "Unknown target: '{s}'. Valid targets: server, client" + )), + } + } +} + +/// A warning emitted by the Svelte compiler, projected to the fields the oracle +/// needs. `start`/`end` are `{ line, column, character }` objects when the +/// warning is tied to a source position, otherwise absent. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SvelteCompileWarning { + /// Warning category code (e.g. `css_unused_selector`). + pub code: String, + /// Human-readable message. + pub message: String, + /// Start location, when the warning is positional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start: Option, + /// End location, when the warning is positional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end: Option, +} + +/// The normalized product of a Svelte `compile()` — the deterministic compile +/// oracle's output. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SvelteCompileOutput { + /// Generated JavaScript. + pub js: String, + /// Generated CSS, or `None` when the component has no styles. + pub css: Option, + /// Compiler warnings. + #[serde(default)] + pub warnings: Vec, +} + +/// Compile Svelte source with the official Svelte compiler (the deterministic +/// compile oracle). +/// +/// Runs a runes-only, external-CSS compile with a fixed CSS scoping hash and a +/// constant filename, so identical `source` produces byte-identical output +/// across calls. `generate` selects the server or client runtime; `dev` toggles +/// development output. +/// +/// # Errors +/// Returns an error if Deno is not available or the Svelte compiler rejects the +/// source (a compile error surfaces as [`DenoError::ToolError`]). +pub async fn svelte_compile( + source: &str, + generate: SvelteGenerate, + dev: bool, +) -> Result { + let options = serde_json::json!({ "generate": generate.name(), "dev": dev }); + let result = call_tool("svelte-compile", source, Some(options)).await?; + serde_json::from_value(result).map_err(DenoError::ResponseParse) +} + /// Run prettier on content /// /// # Arguments @@ -352,6 +436,33 @@ mod tests { Some("StyleSheetFile") ); + // Test the Svelte compile oracle: a tiny runes component with a \n"; + let result = svelte_compile(COMPONENT, SvelteGenerate::Server, false).await; + assert!(result.is_ok(), "svelte_compile failed: {result:?}"); + let compiled = result.unwrap(); + assert!( + compiled.js.contains("$$renderer"), + "server compile should emit SSR output: {}", + compiled.js + ); + // The component has a diff --git a/tests/fixtures_compile/expressions/expression_tag/expected_server.js b/tests/fixtures_compile/expressions/expression_tag/expected_server.js new file mode 100644 index 000000000..1d75a740b --- /dev/null +++ b/tests/fixtures_compile/expressions/expression_tag/expected_server.js @@ -0,0 +1,5 @@ +import * as $ from 'svelte/internal/server'; +export default function Input($$renderer, $$props) { + let { prop } = $$props; + $$renderer.push(`

${$.escape(prop)}

`); +} diff --git a/tests/fixtures_compile/expressions/expression_tag/input.svelte b/tests/fixtures_compile/expressions/expression_tag/input.svelte new file mode 100644 index 000000000..8ea5a2d52 --- /dev/null +++ b/tests/fixtures_compile/expressions/expression_tag/input.svelte @@ -0,0 +1,5 @@ + + +

{prop}

diff --git a/tests/fixtures_compile/text/hello_world/expected_server.js b/tests/fixtures_compile/text/hello_world/expected_server.js new file mode 100644 index 000000000..8c18c286f --- /dev/null +++ b/tests/fixtures_compile/text/hello_world/expected_server.js @@ -0,0 +1,4 @@ +import * as $ from 'svelte/internal/server'; +export default function Input($$renderer) { + $$renderer.push(`

text

`); +} diff --git a/tests/fixtures_compile/text/hello_world/input.svelte b/tests/fixtures_compile/text/hello_world/input.svelte new file mode 100644 index 000000000..46e3b91f9 --- /dev/null +++ b/tests/fixtures_compile/text/hello_world/input.svelte @@ -0,0 +1 @@ +

text

From f429933f86eddceee83fcd8b3b5f7a4688f2537c Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 15:39:18 -0400 Subject: [PATCH 010/174] feat: gate canonicalize_audit and compile_conformance_audit in check --- .../src/cli/commands/canonicalize_audit.rs | 180 ++++++++++++++++++ .../cli/commands/compile_conformance_audit.rs | 113 +++++++++++ deno.json | 6 +- docs/conformance_svelte_compiler.md | 31 +++ 4 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 crates/tsv_debug/src/cli/commands/canonicalize_audit.rs create mode 100644 crates/tsv_debug/src/cli/commands/compile_conformance_audit.rs create mode 100644 docs/conformance_svelte_compiler.md diff --git a/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs b/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs new file mode 100644 index 000000000..a4583844b --- /dev/null +++ b/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs @@ -0,0 +1,180 @@ +use crate::cli::CliError; +use argh::FromArgs; +use std::path::{Path, PathBuf}; +use tsv_cli::json_utils::to_json_with_tabs; +use tsv_svelte_compile::{CanonicalizeError, canonicalize_js}; + +/// Audit `canonicalize_js` at corpus scale: idempotence + output validity. +/// +/// Walks the given paths (default `tests/fixtures`) for TS/JS sources and runs +/// the canonicalizer twice on each file. Buckets: +/// +/// - **input-rejected** — the file doesn't parse as a strict TS module +/// (deliberately-invalid fixtures, script-goal JS). Informational skip. +/// - **NON-IDEMPOTENT** — canonicalize(canonicalize(x)) != canonicalize(x). +/// A canonicalizer bug (failure). +/// - **CORRUPT-OUTPUT** — the canonical reprint failed to reparse (the +/// canonicalizer's self-validation fired, or the second pass rejected the +/// first's output). A canonicalizer bug (failure). +/// +/// Pure Rust, no sidecar. Exits 1 on any failure. The `canonicalize:audit` deno +/// task gates this over `tests/fixtures` in `deno task check`; point it at real +/// corpora on demand (`canonicalize_audit ~/dev/zzz/src ~/dev/gro/src`). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "canonicalize_audit")] +pub struct CanonicalizeAuditCommand { + /// emit a machine-readable JSON report + #[argh(switch)] + json: bool, + + /// directories or files to audit (default: tests/fixtures) + #[argh(positional)] + paths: Vec, +} + +/// The audit's aggregate outcome (also the `--json` shape). +#[derive(Default, serde::Serialize)] +struct AuditReport { + files: usize, + clean: usize, + input_rejected: usize, + non_idempotent: Vec, + corrupt_output: Vec, +} + +#[derive(serde::Serialize)] +struct CorruptEntry { + path: String, + error: String, +} + +impl CanonicalizeAuditCommand { + pub(crate) fn run(self) -> Result<(), CliError> { + let paths = if self.paths.is_empty() { + vec!["tests/fixtures".to_string()] + } else { + self.paths.clone() + }; + + let mut files = Vec::new(); + for path in &paths { + let p = Path::new(path); + if !p.exists() { + eprintln!("Error: path not found: {path}"); + return Err(CliError::Failed); + } + collect_sources(p, &mut files); + } + files.sort(); + + let mut report = AuditReport { + files: files.len(), + ..AuditReport::default() + }; + + for file in &files { + let Ok(source) = std::fs::read_to_string(file) else { + // Unreadable (permissions, non-UTF-8): treat like a rejected input. + report.input_rejected += 1; + continue; + }; + match canonicalize_js(&source) { + Ok(once) => match canonicalize_js(&once) { + Ok(twice) if twice == once => report.clean += 1, + Ok(_) => report.non_idempotent.push(display(file)), + Err(e) => report.corrupt_output.push(CorruptEntry { + path: display(file), + error: e.to_string(), + }), + }, + // Self-validation catches corruption inside the first call. + Err(CanonicalizeError::CorruptOutput(e)) => { + report.corrupt_output.push(CorruptEntry { + path: display(file), + error: e.to_string(), + }); + } + Err(CanonicalizeError::Parse(_)) => report.input_rejected += 1, + } + } + + let failures = report.non_idempotent.len() + report.corrupt_output.len(); + + if self.json { + match to_json_with_tabs(&report) { + Ok(json) => println!("{json}"), + Err(e) => { + eprintln!("Error serializing report: {e}"); + return Err(CliError::Failed); + } + } + } else { + for path in &report.non_idempotent { + println!("NON-IDEMPOTENT {path}"); + } + for entry in &report.corrupt_output { + println!("CORRUPT-OUTPUT {} ({})", entry.path, entry.error); + } + println!( + "canonicalize_audit: {} files — {} clean, {} input-rejected, {} non-idempotent, {} corrupt-output", + report.files, + report.clean, + report.input_rejected, + report.non_idempotent.len(), + report.corrupt_output.len() + ); + } + + if failures > 0 { + Err(CliError::Failed) + } else { + Ok(()) + } + } +} + +fn display(path: &Path) -> String { + path.display().to_string() +} + +/// Recursively collect auditable sources: `.ts` / `.js` / `.mts` / `.cts` +/// (`.svelte.ts` is `.ts`-suffixed and included). Skips the usual non-source +/// directories so the audit can point at real repos. +fn collect_sources(path: &Path, out: &mut Vec) { + if path.is_file() { + if is_auditable(path) { + out.push(path.to_path_buf()); + } + return; + } + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let child = entry.path(); + if child.is_dir() { + let name = child.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.starts_with('.') + || name == "node_modules" + || name == "dist" + || name == "build" + || name == "target" + { + continue; + } + collect_sources(&child, out); + } else if is_auditable(&child) { + out.push(child); + } + } +} + +fn is_auditable(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + name.ends_with(".ts") + || name.ends_with(".js") + || name.ends_with(".mts") + || name.ends_with(".cts") +} diff --git a/crates/tsv_debug/src/cli/commands/compile_conformance_audit.rs b/crates/tsv_debug/src/cli/commands/compile_conformance_audit.rs new file mode 100644 index 000000000..5a4b90788 --- /dev/null +++ b/crates/tsv_debug/src/cli/commands/compile_conformance_audit.rs @@ -0,0 +1,113 @@ +use crate::cli::CliError; +use crate::compile_fixtures::{COMPILE_FIXTURES_DIR, walk_compile_fixtures}; +use argh::FromArgs; +use std::path::Path; +use tsv_cli::json_utils::to_json_with_tabs; + +/// Audit compile-fixture divergence integrity (the compiler analog of +/// `conformance_audit`, deliberately minimal). +/// +/// A `_compiled_divergence`-suffixed compile fixture asserts a deliberate, +/// sanctioned difference from the canonical Svelte compiler's output. That +/// catalog (`docs/conformance_svelte_compiler.md`) is expected to stay empty — +/// it is a safety valve, not a tolerance budget — so this audit mostly asserts +/// emptiness: +/// +/// 1. **Orphans** — every `_compiled_divergence` fixture must be linked in the +/// catalog document. +/// 2. **Missing back-links** — every such fixture must carry a `README.md` +/// containing a link that resolves to the catalog document. +/// +/// Pure Rust, no sidecar. Exits non-zero on any finding. Gated in +/// `deno task check`. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "compile_conformance_audit")] +pub struct CompileConformanceAuditCommand { + /// emit a machine-readable JSON report + #[argh(switch)] + json: bool, +} + +const CATALOG_DOC: &str = "docs/conformance_svelte_compiler.md"; +const DIVERGENCE_SUFFIX: &str = "_compiled_divergence"; + +#[derive(serde::Serialize)] +struct AuditReport { + divergence_fixtures: usize, + orphans: Vec, + missing_backlinks: Vec, +} + +impl CompileConformanceAuditCommand { + pub(crate) fn run(self) -> Result<(), CliError> { + let doc = match std::fs::read_to_string(CATALOG_DOC) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading {CATALOG_DOC}: {e}"); + return Err(CliError::Failed); + } + }; + + // A missing tree is an empty tree (nothing to audit). + let root = Path::new(COMPILE_FIXTURES_DIR); + let fixtures = if root.exists() { + walk_compile_fixtures(root).map_err(|e| { + eprintln!("Error walking compile fixtures: {e}"); + CliError::Failed + })? + } else { + Vec::new() + }; + + let mut report = AuditReport { + divergence_fixtures: 0, + orphans: Vec::new(), + missing_backlinks: Vec::new(), + }; + + for fixture in &fixtures { + if !fixture.relative_path.ends_with(DIVERGENCE_SUFFIX) { + continue; + } + report.divergence_fixtures += 1; + if !doc.contains(&fixture.relative_path) { + report.orphans.push(fixture.relative_path.clone()); + } + let readme = fixture.path.join("README.md"); + let has_backlink = std::fs::read_to_string(&readme) + .is_ok_and(|content| content.contains("conformance_svelte_compiler.md")); + if !has_backlink { + report.missing_backlinks.push(fixture.relative_path.clone()); + } + } + + let findings = report.orphans.len() + report.missing_backlinks.len(); + + if self.json { + match to_json_with_tabs(&report) { + Ok(json) => println!("{json}"), + Err(e) => { + eprintln!("Error serializing report: {e}"); + return Err(CliError::Failed); + } + } + } else { + for orphan in &report.orphans { + println!("ORPHAN: {orphan} not cataloged in {CATALOG_DOC}"); + } + for missing in &report.missing_backlinks { + println!("MISSING BACK-LINK: {missing} README must link {CATALOG_DOC}"); + } + println!( + "compile_conformance_audit: {} divergence fixture(s), {} finding(s)", + report.divergence_fixtures, findings + ); + } + + if findings > 0 { + Err(CliError::Failed) + } else { + Ok(()) + } + } +} diff --git a/deno.json b/deno.json index 73e06c2cf..97f5accba 100644 --- a/deno.json +++ b/deno.json @@ -25,7 +25,7 @@ "typecheck": "cargo check --workspace", "test": "cargo test --workspace", "lint": "cargo clippy --workspace -- -D warnings", - "check": "cargo fmt --check && deno task pins:audit && deno task typecheck && deno task conformance:audit && deno task scan:audit && deno task fanout:audit && deno task roundtrip:audit && deno task test:deno && deno task test && deno task swallow:audit && deno task check:ast-types && deno task lint", + "check": "cargo fmt --check && deno task pins:audit && deno task typecheck && deno task conformance:audit && deno task conformance:audit:compiler && deno task scan:audit && deno task fanout:audit && deno task roundtrip:audit && deno task canonicalize:audit && deno task test:deno && deno task test && deno task swallow:audit && deno task check:ast-types && deno task lint", "check:ast-types": "deno run --allow-read --allow-write=scripts --allow-run=cargo,deno scripts/check_ast_types.ts", "doctor": "deno run --allow-read --allow-sys --allow-run=deno,node,bun,cargo,wasm-pack,npm --config benches/js/deno.json scripts/doctor.ts", @@ -38,6 +38,10 @@ "fixtures:audit": "cargo run -p tsv_debug --quiet fixtures_audit", "fixtures:ts-audit": "cargo run -p tsv_debug --quiet ts_fixture_audit", "conformance:audit": "cargo run -p tsv_debug --quiet conformance_audit", + "conformance:audit:compiler": "cargo run -p tsv_debug --quiet compile_conformance_audit", + "canonicalize:audit": "cargo run -p tsv_debug --quiet canonicalize_audit tests/fixtures tests/fixtures_compile", + "compile:fixtures:init": "cargo run -p tsv_debug --quiet compile_fixture_init", + "compile:fixtures:validate": "cargo run -p tsv_debug --quiet compile_fixtures_validate", "pins:audit": "deno run --allow-read scripts/check_canonical_pins.ts", "swallow:audit": "cargo test -p tsv_lang --features swallow_check --quiet && cargo run -p tsv_debug --features swallow_check --quiet swallow_audit", "scan:audit": "cargo run -p tsv_debug --quiet scan_audit", diff --git a/docs/conformance_svelte_compiler.md b/docs/conformance_svelte_compiler.md new file mode 100644 index 000000000..9b88b6a4d --- /dev/null +++ b/docs/conformance_svelte_compiler.md @@ -0,0 +1,31 @@ +# Svelte Compiler Conformance + +Where `tsv_svelte_compile` deviates from the canonical Svelte compiler's output — +and why. + +## Contract + +The oracle is Svelte's own `compile()` (server and client generation, run +deterministically: fixed `cssHash`, constant filename, non-dev). Parity is judged +on the **canonical reprint** of both sides' JS +(`tsv_svelte_compile::canonicalize_js`), so a divergence here is a *real code +difference*, never formatting. + +**This catalog is expected to stay empty.** It exists as a safety valve — the +place to sanction a deliberate refusal to reproduce a genuine bug in the +canonical compiler's output — not as a tolerance budget. Any entry requires: + +1. A fixture directory suffixed `_compiled_divergence` under + `tests/fixtures_compile/`, carrying the input plus both expected outputs + (the oracle's and ours). +2. A `README.md` in that fixture linking back to its catalog entry in this + document. +3. A catalog entry below explaining exactly why matching the oracle is wrong. + +`compile_conformance_audit` (gated in `deno task check`) enforces 1↔3 linkage +and the README back-link. + +## Catalog + +None. Every compile fixture matches the canonical compiler's output exactly +(after canonical reprint). From 4fde5b477d61b1c20a16bf42c8dc3e45b2378afe Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 15:39:18 -0400 Subject: [PATCH 011/174] docs: document compile fixture and canonicalize audit tooling --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49f27e18b..ec349ee0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,6 +185,10 @@ deno task fixtures:update:parsed # regenerate expected.json only (run when p deno task fixtures:update:formatted # regenerate output_prettier.svelte only deno task fixtures:audit # audit _prettier_divergence fixtures (diagnostic; --all for every fixture) deno task conformance:audit # doc/fixture integrity: divergence fixtures cataloged + every doc/README link resolves + each divergence README back-links its sanctioning doc + no stray READMEs on matching fixtures (gated in `deno task check`) +deno task conformance:audit:compiler # compile-fixture divergence integrity: any _compiled_divergence fixture must be cataloged in docs/conformance_svelte_compiler.md + carry a back-linking README — the catalog is expected to stay EMPTY (gated in `deno task check`); see Debug Tooling +deno task canonicalize:audit # canonicalize_js idempotence + output validity over tests/fixtures + tests/fixtures_compile (pure Rust; gated in `deno task check`) — point the command at real corpora on demand; see Debug Tooling +deno task compile:fixtures:init # create/reinit a compile fixture (oracle-compiles + canonicalizes; tests/fixtures_compile) +deno task compile:fixtures:validate # validate compile fixtures: oracle freshness + expected idempotence (gating), ours parity (reported; flips to gating at M1) deno task pins:audit # canonical-oracle version sync (gated in `deno task check`): (1) pin agreement — sidecar.ts VERSIONS + npm: imports, benches/js/package.json, actor.rs acorn import-map must be identical; (2) checkout alignment — a PRESENT ../svelte or ../acorn-typescript checkout must match its pin (absent → skipped, so clean machines pass) deno task scan:audit # guard against new raw find/rfind/match_indices substring scans over source (gated in `deno task check`); see Debug Tooling deno task fanout:audit # guard against super-linear doc-node fanout (the per-layout-candidate rebuild blowup); gated in `deno task check`; see Debug Tooling @@ -481,7 +485,8 @@ tsv/ │ └── tsv_napi/ # N-API bindings (Node/Bun native path; measurement-only for the Node bench, 0.2 publish target) ├── scripts/ # Publish orchestrator, npm package patcher, Node artifact + N-API addon tests, AST type drift check ├── tests/ # Integration tests (parser, formatter, CLI) -│ └── fixtures/ # Test fixtures organized by language/feature +│ ├── fixtures/ # Test fixtures organized by language/feature +│ └── fixtures_compile/ # Compiler fixtures (input.svelte + canonicalized oracle expected_server.js + expected.css) — a separate tree so parser/formatter fixture counts stay unperturbed └── docs/ # Documentation (fixtures, cli, architecture, etc.) ``` @@ -702,7 +707,27 @@ cargo run -p tsv_debug canonical_compile file.svelte --json # { js, # canonical form). --json emits { target, parity, ours_status, hunks }. cargo run -p tsv_debug compile_compare file.svelte # human diff / oracle canonical form cargo run -p tsv_debug compile_compare --content '

hi

' --json # machine-readable report -# Also: --target server|client (default server), --content , --stdin. +# Also: --target server|client (default server), --content , --stdin. The ad-hoc one-file view; +# durable expectations live in the compile fixtures (tests/fixtures_compile, below). + +# compile_fixture_init - create or reinitialize a compile fixture (tests/fixtures_compile///): +# prettier-formats the runes component, compiles it with the deterministic oracle (server, non-dev), +# writes input.svelte + expected_server.js (the CANONICALIZED oracle JS) + expected.css (raw oracle +# CSS, styled components only). Expected files are ALWAYS oracle-generated, never hand-written. +cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case --content '

text

' +echo '

text

' | cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case --stdin +cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case # regenerate from existing input +# Also: --force (overwrite existing input). + +# compile_fixtures_validate - validate compile fixtures. Per fixture: (a) oracle freshness — +# canonicalize(oracle(input)) must equal the committed expected_server.js (+ css match); (b) ours — +# tsv_svelte_compile::compile → canonicalize vs expected (today every fixture reports +# "unimplemented", a distinct expected status; the gate flips when M1 codegen lands); (c) the +# committed expected_server.js must be a canonicalize fixed point. Exits non-zero on (a)/(c) only. +# The pure-Rust slice of the contract (input parses, expected idempotent) also runs sidecar-free in +# `cargo test --workspace --test compile_fixtures_tests`. +cargo run -p tsv_debug compile_fixtures_validate [pattern...] +# Also: --list, --json. # format_prettier - format using prettier (shows line widths by default; --no-line-widths to hide) cargo run -p tsv_debug format_prettier file.svelte @@ -761,6 +786,25 @@ cargo run -p tsv_debug ts_fixture_audit [pattern...] # Pure Rust (no Deno). Exits non-zero on any finding. Gated in `deno task check`. cargo run -p tsv_debug conformance_audit # Also: --json (machine-readable: {orphans, dead_links, missing_backlinks, stray_readmes}) + +# compile_conformance_audit - the compiler analog of conformance_audit, deliberately minimal: +# any _compiled_divergence-suffixed compile fixture must be cataloged in +# docs/conformance_svelte_compiler.md AND carry a README back-linking it. The catalog is expected +# to stay EMPTY (a safety valve for declining to reproduce a genuine oracle output bug — never a +# tolerance budget), so this mostly asserts emptiness. Pure Rust (no Deno). Exits non-zero on any +# finding. Gated in `deno task check`. +cargo run -p tsv_debug compile_conformance_audit +# Also: --json + +# canonicalize_audit - canonicalize_js (the compile-parity reprint) at corpus scale: run the +# canonicalizer twice per TS/JS file (.ts/.js/.mts/.cts, .svelte.ts included) and bucket — +# input-rejected (informational: invalid fixtures, script-goal files), NON-IDEMPOTENT (failure), +# CORRUPT-OUTPUT / unreparseable reprint (failure; the canonicalizer self-validates by reparse). +# Pure Rust (no Deno). Exits 1 on any failure. Gated in `deno task check` over tests/fixtures + +# tests/fixtures_compile (fast); point it at real corpora for the full sweep. +cargo run -p tsv_debug canonicalize_audit # the gate scope (tests/fixtures) +cargo run -p tsv_debug canonicalize_audit ~/dev/zzz/src ~/dev/gro/src # real-corpus sweep +# Also: --json ``` > **Troubleshooting:** See ./docs/fixture_overview.md#quick-decision-tree From 0446c4b43a0ad05f22336c228e4776746b45a991 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 15:59:58 -0400 Subject: [PATCH 012/174] chore: strip milestone labels, fix audit doc scope, add mjs/cjs, suppress empty hints --- CLAUDE.md | 9 +++++---- .../src/cli/commands/canonicalize_audit.rs | 2 ++ .../src/cli/commands/compile_fixture_init.rs | 13 +++++++++++-- .../cli/commands/compile_fixtures_validate.rs | 16 ++++++++++++---- tests/compile_fixtures_tests.rs | 2 +- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec349ee0c..1876dab65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,7 @@ deno task conformance:audit # doc/fixture integrity: divergence fixture deno task conformance:audit:compiler # compile-fixture divergence integrity: any _compiled_divergence fixture must be cataloged in docs/conformance_svelte_compiler.md + carry a back-linking README — the catalog is expected to stay EMPTY (gated in `deno task check`); see Debug Tooling deno task canonicalize:audit # canonicalize_js idempotence + output validity over tests/fixtures + tests/fixtures_compile (pure Rust; gated in `deno task check`) — point the command at real corpora on demand; see Debug Tooling deno task compile:fixtures:init # create/reinit a compile fixture (oracle-compiles + canonicalizes; tests/fixtures_compile) -deno task compile:fixtures:validate # validate compile fixtures: oracle freshness + expected idempotence (gating), ours parity (reported; flips to gating at M1) +deno task compile:fixtures:validate # validate compile fixtures: oracle freshness + expected idempotence (gating), ours parity (reported; flips to gating once codegen lands) deno task pins:audit # canonical-oracle version sync (gated in `deno task check`): (1) pin agreement — sidecar.ts VERSIONS + npm: imports, benches/js/package.json, actor.rs acorn import-map must be identical; (2) checkout alignment — a PRESENT ../svelte or ../acorn-typescript checkout must match its pin (absent → skipped, so clean machines pass) deno task scan:audit # guard against new raw find/rfind/match_indices substring scans over source (gated in `deno task check`); see Debug Tooling deno task fanout:audit # guard against super-linear doc-node fanout (the per-layout-candidate rebuild blowup); gated in `deno task check`; see Debug Tooling @@ -722,7 +722,7 @@ cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case # compile_fixtures_validate - validate compile fixtures. Per fixture: (a) oracle freshness — # canonicalize(oracle(input)) must equal the committed expected_server.js (+ css match); (b) ours — # tsv_svelte_compile::compile → canonicalize vs expected (today every fixture reports -# "unimplemented", a distinct expected status; the gate flips when M1 codegen lands); (c) the +# "unimplemented", a distinct expected status; the gate flips once codegen lands); (c) the # committed expected_server.js must be a canonicalize fixed point. Exits non-zero on (a)/(c) only. # The pure-Rust slice of the contract (input parses, expected idempotent) also runs sidecar-free in # `cargo test --workspace --test compile_fixtures_tests`. @@ -797,12 +797,13 @@ cargo run -p tsv_debug compile_conformance_audit # Also: --json # canonicalize_audit - canonicalize_js (the compile-parity reprint) at corpus scale: run the -# canonicalizer twice per TS/JS file (.ts/.js/.mts/.cts, .svelte.ts included) and bucket — +# canonicalizer twice per TS/JS file (.ts/.js/.mts/.cts/.mjs/.cjs, .svelte.ts included) and bucket — # input-rejected (informational: invalid fixtures, script-goal files), NON-IDEMPOTENT (failure), # CORRUPT-OUTPUT / unreparseable reprint (failure; the canonicalizer self-validates by reparse). # Pure Rust (no Deno). Exits 1 on any failure. Gated in `deno task check` over tests/fixtures + # tests/fixtures_compile (fast); point it at real corpora for the full sweep. -cargo run -p tsv_debug canonicalize_audit # the gate scope (tests/fixtures) +cargo run -p tsv_debug canonicalize_audit # default scope (tests/fixtures only) +cargo run -p tsv_debug canonicalize_audit tests/fixtures tests/fixtures_compile # the check-gate scope cargo run -p tsv_debug canonicalize_audit ~/dev/zzz/src ~/dev/gro/src # real-corpus sweep # Also: --json ``` diff --git a/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs b/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs index a4583844b..889a89e3c 100644 --- a/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs +++ b/crates/tsv_debug/src/cli/commands/canonicalize_audit.rs @@ -177,4 +177,6 @@ fn is_auditable(path: &Path) -> bool { || name.ends_with(".js") || name.ends_with(".mts") || name.ends_with(".cts") + || name.ends_with(".mjs") + || name.ends_with(".cjs") } diff --git a/crates/tsv_debug/src/cli/commands/compile_fixture_init.rs b/crates/tsv_debug/src/cli/commands/compile_fixture_init.rs index b9d560837..6e3e6cf2f 100644 --- a/crates/tsv_debug/src/cli/commands/compile_fixture_init.rs +++ b/crates/tsv_debug/src/cli/commands/compile_fixture_init.rs @@ -65,7 +65,7 @@ impl CompileFixtureInitCommand { Ok(f) => f, Err(e) => { eprintln!("Error: prettier formatting failed: {e}"); - eprintln!("Hint: {}", e.hint()); + print_hint(&e); return Err(CliError::Failed); } }; @@ -83,7 +83,7 @@ impl CompileFixtureInitCommand { Ok(o) => o, Err(e) => { eprintln!("Error: oracle compile failed: {e}"); - eprintln!("Hint: {}", e.hint()); + print_hint(&e); eprintln!("({INPUT_FILE} was written; fix the component and re-run)"); return Err(CliError::Failed); } @@ -132,6 +132,15 @@ impl CompileFixtureInitCommand { } } +/// Print a Deno error's hint on its own line, suppressed when the error carries +/// none (an empty `Hint:` line is noise). +fn print_hint(e: &deno::DenoError) { + let hint = e.hint(); + if !hint.is_empty() { + eprintln!("Hint: {hint}"); + } +} + /// Resolve content from --content, --stdin, or the existing input file. fn resolve_content( content_flag: Option<&str>, diff --git a/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs b/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs index 025d2d980..d10682926 100644 --- a/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs +++ b/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs @@ -245,19 +245,27 @@ async fn validate_fixture(fixture: &CompileFixture) -> FixtureReport { fresh } Err(e) => { - errors.push(format!("oracle compile failed: {e} (hint: {})", e.hint())); + let hint = e.hint(); + if hint.is_empty() { + errors.push(format!("oracle compile failed: {e}")); + } else { + errors.push(format!("oracle compile failed: {e} (hint: {hint})")); + } false } }; // (b) Ours — reported, not gating, until codegen lands. - // TODO: when M1 lands, a "mismatch"/"error" here becomes a gating failure - // and "unimplemented" becomes one too (every fixture must compile). + // TODO: flip to gating once compile() produces output — "mismatch"/"error" + // become gating failures, and so does "unimplemented" (every fixture must + // compile). let ours_status = match compile(&input, &CompileOptions::default()) { Ok(ours) => match canonicalize_js(&ours.js) { Ok(canonical) if canonical == expected_js => "parity", Ok(_) => { - errors.push("ours differs from expected (non-gating until M1)".to_string()); + errors.push( + "ours differs from expected (non-gating until codegen lands)".to_string(), + ); "mismatch" } Err(e) => { diff --git a/tests/compile_fixtures_tests.rs b/tests/compile_fixtures_tests.rs index f6289db39..220c5f9ba 100644 --- a/tests/compile_fixtures_tests.rs +++ b/tests/compile_fixtures_tests.rs @@ -79,7 +79,7 @@ fn test_all_compile_fixtures() { } } - // TODO(M1): once codegen lands, assert here that + // TODO: once compile() produces output, assert here that // `canonicalize_js(tsv_svelte_compile::compile(input).js) == expected` — // the pure-Rust ours-vs-expected parity gate. } From 2d2a5aa51c6ade204c43c2ede7b23cda432ce6f3 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Sun, 12 Jul 2026 16:25:38 -0400 Subject: [PATCH 013/174] feat: server codegen for static markup, props, and scoped css via appendix buffer --- Cargo.lock | 1 + .../src/cli/commands/compile_compare.rs | 27 +- .../cli/commands/compile_fixtures_validate.rs | 64 ++- crates/tsv_svelte_compile/Cargo.toml | 1 + crates/tsv_svelte_compile/src/build.rs | 203 +++++++ crates/tsv_svelte_compile/src/lib.rs | 124 ++++- .../src/transform_server.rs | 517 ++++++++++++++++++ tests/compile_fixtures_tests.rs | 74 ++- 8 files changed, 938 insertions(+), 73 deletions(-) create mode 100644 crates/tsv_svelte_compile/src/build.rs create mode 100644 crates/tsv_svelte_compile/src/transform_server.rs diff --git a/Cargo.lock b/Cargo.lock index 40596dcd8..439da6fe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -792,6 +792,7 @@ dependencies = [ "bumpalo", "thiserror", "tsv_css", + "tsv_html", "tsv_lang", "tsv_svelte", "tsv_ts", diff --git a/crates/tsv_debug/src/cli/commands/compile_compare.rs b/crates/tsv_debug/src/cli/commands/compile_compare.rs index 28811ee89..9ff8eec66 100644 --- a/crates/tsv_debug/src/cli/commands/compile_compare.rs +++ b/crates/tsv_debug/src/cli/commands/compile_compare.rs @@ -11,7 +11,7 @@ use tsv_svelte_compile::{CompileError, CompileOptions, Generate, canonicalize_js /// Both sides' compiled JS is canonicalized (an intent-erased reprint) before /// comparison, so a diff reflects a real code difference, not incidental /// whitespace. Exit codes: 0 = parity, 1 = a real difference, 2 = an error -/// (which currently includes the still-unimplemented tsv compile side). +/// (including a component shape tsv's compiler doesn't cover yet). #[derive(FromArgs, Debug)] #[argh(subcommand, name = "compile_compare")] pub struct CompileCompareCommand { @@ -41,9 +41,9 @@ pub struct CompileCompareCommand { struct CompareReport { /// The compile target ("server" | "client"). target: &'static str, - /// Whether the two canonical forms match (`false` while tsv compile is unimplemented). + /// Whether the two canonical forms match. parity: bool, - /// The tsv side's outcome ("ok" | "unimplemented"). + /// The tsv side's outcome ("ok" | "unsupported"). ours_status: &'static str, /// The unified diff of the two canonical forms, when both sides exist and differ. hunks: Option, @@ -100,7 +100,7 @@ impl CompileCompareCommand { } } - // Ours side: compile with tsv (a walking skeleton for now). + // Ours side: compile with tsv. let options = CompileOptions { generate: to_generate(target), dev: false, @@ -113,7 +113,9 @@ impl CompileCompareCommand { Err(CliError::Errored) } }, - Err(CompileError::Codegen) => report_unimplemented(target, &oracle_canonical, json), + Err(err @ CompileError::Unsupported(_)) => { + report_unsupported(target, &err, &oracle_canonical, json) + } Err(err) => { eprintln!("Error: tsv compile failed: {err}"); Err(CliError::Errored) @@ -166,20 +168,25 @@ fn report_both( } } -/// Report the walking-skeleton state: tsv compile is unimplemented (exit 2). The -/// oracle canonical form is shown so it's visible what tsv must reproduce. -fn report_unimplemented(target: SvelteGenerate, oracle: &str, json: bool) -> Result<(), CliError> { +/// Report a shape tsv's compiler doesn't cover yet (exit 2). The oracle +/// canonical form is shown so it's visible what tsv must reproduce. +fn report_unsupported( + target: SvelteGenerate, + err: &CompileError, + oracle: &str, + json: bool, +) -> Result<(), CliError> { if json { let report = CompareReport { target: target_name(target), parity: false, - ours_status: "unimplemented", + ours_status: "unsupported", hunks: None, }; print_json(&report)?; } else { println!( - "compile_compare [{}] tsv compile unimplemented — oracle canonical form:", + "compile_compare [{}] {err} — oracle canonical form:", target_name(target) ); print_block(oracle); diff --git a/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs b/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs index d10682926..abe2a1f61 100644 --- a/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs +++ b/crates/tsv_debug/src/cli/commands/compile_fixtures_validate.rs @@ -8,24 +8,22 @@ use crate::diff::{DiffOptions, diff_to_string}; use argh::FromArgs; use std::path::Path; use tsv_cli::json_utils::to_json_with_tabs; -use tsv_svelte_compile::{CompileError, CompileOptions, canonicalize_js, compile}; +use tsv_svelte_compile::{CompileOptions, canonicalize_js, compile}; /// Validate compile fixtures against the canonical Svelte compiler. /// -/// Per fixture, three checks: +/// Per fixture, three checks — all gating: /// /// (a) **Oracle freshness** — `canonicalize_js(oracle(input.svelte).js)` must equal /// the committed `expected_server.js`, and the oracle CSS must match /// `expected.css` (both absent counts as a match). Catches a stale expectation /// after an oracle (Svelte pin) or canonicalizer change. -/// (b) **Ours** — `tsv_svelte_compile::compile` → canonicalize vs expected. Today -/// every fixture reports `unimplemented` (the walking skeleton) — a distinct, -/// expected status, NOT a failure; the gate flips when codegen lands. +/// (b) **Ours** — `tsv_svelte_compile::compile` must succeed and its canonicalized +/// JS + CSS must equal the committed expectations (`parity`; anything else — +/// `mismatch` / `error` — fails the run). /// (c) **Expected idempotence** — the committed `expected_server.js` must be a /// `canonicalize_js` fixed point (it reparses by construction — canonicalize /// self-validates). -/// -/// Exits non-zero on (a)/(c) failures only, for now. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "compile_fixtures_validate")] pub struct CompileFixturesValidateCommand { @@ -50,7 +48,7 @@ struct FixtureReport { oracle_fresh: bool, /// Check (c): the committed expected_server.js is a canonicalize fixed point. expected_idempotent: bool, - /// Check (b): "unimplemented" | "parity" | "mismatch" | "error". + /// Check (b): "parity" | "mismatch" | "error". ours_status: &'static str, /// Human-readable failure details (empty when everything passed). errors: Vec, @@ -107,13 +105,11 @@ impl CompileFixturesValidateCommand { reports.push(validate_fixture(fixture).await); } + // All three checks gate: oracle freshness, expected idempotence, AND + // ours parity (the compiler must reproduce every fixture). let gating_failures = reports .iter() - .filter(|r| !r.oracle_fresh || !r.expected_idempotent) - .count(); - let unimplemented = reports - .iter() - .filter(|r| r.ours_status == "unimplemented") + .filter(|r| !r.oracle_fresh || !r.expected_idempotent || r.ours_status != "parity") .count(); let parity = reports.iter().filter(|r| r.ours_status == "parity").count(); @@ -123,14 +119,12 @@ impl CompileFixturesValidateCommand { total: usize, gating_failures: usize, ours_parity: usize, - ours_unimplemented: usize, fixtures: Vec, } let summary = Summary { total: reports.len(), gating_failures, ours_parity: parity, - ours_unimplemented: unimplemented, fixtures: reports, }; match to_json_with_tabs(&summary) { @@ -142,7 +136,9 @@ impl CompileFixturesValidateCommand { } } else { for report in &reports { - let ok = report.oracle_fresh && report.expected_idempotent; + let ok = report.oracle_fresh + && report.expected_idempotent + && report.ours_status == "parity"; let mark = if ok { "✓" } else { "✗" }; println!("{mark} {} [ours: {}]", report.fixture, report.ours_status); for error in &report.errors { @@ -150,7 +146,7 @@ impl CompileFixturesValidateCommand { } } println!( - "\n{} fixtures: {} gating failure(s), ours: {parity} parity / {unimplemented} unimplemented", + "\n{} fixtures: {} gating failure(s), {parity} ours-parity", reports.len(), gating_failures ); @@ -255,25 +251,37 @@ async fn validate_fixture(fixture: &CompileFixture) -> FixtureReport { } }; - // (b) Ours — reported, not gating, until codegen lands. - // TODO: flip to gating once compile() produces output — "mismatch"/"error" - // become gating failures, and so does "unimplemented" (every fixture must - // compile). + // (b) Ours — the compiler must reproduce the expectations (gating): the + // canonicalized JS must equal expected_server.js and the CSS must match + // expected.css. let ours_status = match compile(&input, &CompileOptions::default()) { Ok(ours) => match canonicalize_js(&ours.js) { - Ok(canonical) if canonical == expected_js => "parity", - Ok(_) => { - errors.push( - "ours differs from expected (non-gating until codegen lands)".to_string(), - ); - "mismatch" + Ok(canonical) => { + let js_parity = canonical == expected_js; + if !js_parity { + errors.push("ours differs from expected_server.js".to_string()); + errors.push(diff_to_string( + &canonical, + &expected_js, + &DiffOptions::compile_compare(), + )); + } + let ours_css = ours.css.as_deref().map(with_trailing_newline); + let css_parity = ours_css == expected_css; + if !css_parity { + errors.push("ours css differs from expected.css".to_string()); + } + if js_parity && css_parity { + "parity" + } else { + "mismatch" + } } Err(e) => { errors.push(format!("could not canonicalize our output: {e}")); "error" } }, - Err(CompileError::Codegen) => "unimplemented", Err(e) => { errors.push(format!("tsv compile failed: {e}")); "error" diff --git a/crates/tsv_svelte_compile/Cargo.toml b/crates/tsv_svelte_compile/Cargo.toml index 5e5b52f39..bc15730ee 100644 --- a/crates/tsv_svelte_compile/Cargo.toml +++ b/crates/tsv_svelte_compile/Cargo.toml @@ -6,6 +6,7 @@ description = "Svelte-to-JS compiler (pinned to Svelte's compile() as oracle) an [dependencies] tsv_lang.workspace = true +tsv_html.workspace = true tsv_ts.workspace = true tsv_svelte.workspace = true tsv_css.workspace = true diff --git a/crates/tsv_svelte_compile/src/build.rs b/crates/tsv_svelte_compile/src/build.rs new file mode 100644 index 000000000..17eec33a3 --- /dev/null +++ b/crates/tsv_svelte_compile/src/build.rs @@ -0,0 +1,203 @@ +//! Synthetic `tsv_ts` AST construction over the **hybrid appendix buffer**. +//! +//! The compiler generates JS by constructing `tsv_ts` internal-AST nodes and +//! printing them through the existing printer (`tsv_ts::format_canonical`). +//! That printer recovers most leaf text by slicing spans out of its `source` +//! argument, so the generator maintains a *buffer* = the host `.svelte` source +//! followed by an **appendix** of minted lexemes: +//! +//! - **Borrowed user subtrees** (script statements, template expressions) keep +//! their real host spans — they print verbatim through the normal machinery. +//! - **Minted literals / template quasis** get spans pointing into the +//! appendix, which contains their exact text (appended monotonically, so +//! every span is in-bounds and on char boundaries). +//! - **Synthetic identifiers** ride the interned-name channel +//! (`IdentName { escaped: Some(symbol), raw_len: 0 }`) — resolved through the +//! shared interner, source-free. Their spans are still backed by minted text +//! for debuggability (the appendix reads as the generated skeleton). +//! - Keywords/punctuation are printer statics and need no buffer text; the +//! skeleton around them is minted anyway so node spans cover plausible text. +//! +//! Construction discipline: child collections are built as arena slices +//! (`bumpalo::collections::Vec` → `into_bump_slice`), single children via +//! `arena.alloc`. Borrowed nodes are never deep-copied; where a *wrapper* node +//! must be rebuilt with one field changed (the `$props()` init rewrite), the +//! by-value fields are shallow-cloned — children remain shared `&'arena` refs +//! into the parsed AST, and the original wrapper never enters the printed tree +//! (no duplicate spans in what the printer walks). + +use bumpalo::Bump; +use tsv_lang::{SharedInterner, Span}; +use tsv_ts::ast::internal::{ + CallExpression, Expression, IdentName, Identifier, ImportDeclaration, ImportKind, ImportPhase, + ImportSpecifier, ImportNamespaceSpecifier, Literal, LiteralValue, MemberExpression, + StringCooked, TemplateCooked, TemplateElement, TemplateLiteral, +}; + +/// The appendix-buffer bookkeeping plus interner access — everything node +/// constructors need. Owns the growing buffer; the arena and interner are +/// shared with the parsed host AST so borrowed subtrees and synthetic nodes +/// coexist in one graph. +pub(crate) struct Builder<'arena> { + pub arena: &'arena Bump, + /// Host source + appendix of minted lexemes. Passed to `format_canonical` + /// as the source every span in the synthetic program indexes into. + pub buffer: String, + /// The parse's interner — synthetic identifier names are interned here so + /// the printer's symbol resolver (built from `Program.interner`) finds them. + pub interner: SharedInterner, +} + +impl<'arena> Builder<'arena> { + pub fn new(arena: &'arena Bump, host_source: &str, interner: SharedInterner) -> Self { + Self { + arena, + buffer: host_source.to_string(), + interner, + } + } + + /// Append minted text to the appendix, returning its span. + pub fn mint(&mut self, text: &str) -> Span { + let start = self.buffer.len() as u32; + self.buffer.push_str(text); + Span::new(start, self.buffer.len() as u32) + } + + /// A synthetic identifier: interned name (source-free resolution) with its + /// text minted into the appendix so the span is backed. + pub fn ident(&mut self, name: &str) -> Identifier<'arena> { + let span = self.mint(name); + let ident_name = IdentName { + escaped: Some(self.interner.borrow_mut().get_or_intern(name)), + raw_len: 0, + }; + Identifier::simple(ident_name, span) + } + + /// A synthetic identifier as an arena-allocated expression. + pub fn ident_expr(&mut self, name: &str) -> &'arena Expression<'arena> { + let ident = self.ident(name); + self.arena.alloc(Expression::Identifier(ident)) + } + + /// A single-quoted string literal minted into the appendix. `content` must + /// not itself require escaping (module specifiers do not). + pub fn string_literal(&mut self, content: &str) -> Literal<'arena> { + debug_assert!( + !content.contains(['\'', '\\', '\n']), + "string_literal content must not need escaping: {content:?}" + ); + let span = self.mint(&format!("'{content}'")); + Literal { + value: LiteralValue::String(StringCooked::Verbatim), + span, + } + } + + /// `import * as from '';` + pub fn import_namespace(&mut self, local: &str, specifier: &str) -> ImportDeclaration<'arena> { + let start = self.mint("import * as ").start; + let local = self.ident(local); + let local_span = local.span; + self.mint(" from "); + let source = self.string_literal(specifier); + let end = self.mint(";").end; + let mut specifiers = bumpalo::collections::Vec::new_in(self.arena); + specifiers.push(ImportSpecifier::Namespace(ImportNamespaceSpecifier { + local, + span: local_span, + })); + ImportDeclaration { + specifiers: specifiers.into_bump_slice(), + source, + attributes: None, + import_kind: ImportKind::Value, + phase: ImportPhase::None, + span: Span::new(start, end), + } + } + + /// `.()` — a call on a synthetic member chain. + /// The arguments slice may hold borrowed expressions (host spans). + pub fn member_call( + &mut self, + object: &str, + property: &str, + arguments: &'arena [Expression<'arena>], + ) -> Expression<'arena> { + let obj = self.ident_expr(object); + self.mint("."); + let prop = self.ident_expr(property); + let member_span = Span::new(obj.span().start, prop.span().end); + self.mint("("); + let end = self.mint(")").end; + let callee = self.arena.alloc(Expression::MemberExpression(MemberExpression { + object: obj, + property: prop, + computed: false, + optional: false, + span: member_span, + })); + Expression::CallExpression(CallExpression { + callee, + type_arguments: None, + arguments, + optional: false, + span: Span::new(member_span.start, end), + }) + } + + /// A template literal from alternating static parts and expressions + /// (`texts.len() == expressions.len() + 1`). Static text is minted into the + /// appendix (already template-escaped by the caller); expressions may be + /// borrowed user subtrees. The `${`/`}` delimiters are minted as + /// placeholders so the appendix stays a readable mirror of the output — the + /// printer emits them statically and never reads those bytes. + pub fn template_literal( + &mut self, + texts: &[String], + expressions: &'arena [Expression<'arena>], + ) -> Expression<'arena> { + debug_assert_eq!(texts.len(), expressions.len() + 1); + let start = self.mint("`").start; + let mut quasis = bumpalo::collections::Vec::new_in(self.arena); + let last = texts.len() - 1; + for (i, text) in texts.iter().enumerate() { + let raw_span = self.mint(text); + let tail = i == last; + quasis.push(TemplateElement { + raw_span, + cooked: TemplateCooked::Verbatim, + has_newline: text.contains('\n'), + tail, + span: raw_span, + }); + if !tail { + self.mint("${}"); + } + } + let end = self.mint("`").end; + Expression::TemplateLiteral(TemplateLiteral { + quasis: quasis.into_bump_slice(), + expressions, + span: Span::new(start, end), + }) + } +} + +/// Escape static text for inclusion in a template-literal quasi: backslash, +/// backtick, and `${` (the `$` is escaped only when a `{` follows). +pub(crate) fn escape_template_text(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => out.push_str("\\\\"), + '`' => out.push_str("\\`"), + '$' if chars.peek() == Some(&'{') => out.push_str("\\$"), + _ => out.push(c), + } + } + out +} diff --git a/crates/tsv_svelte_compile/src/lib.rs b/crates/tsv_svelte_compile/src/lib.rs index e22f75a92..9a365c3d1 100644 --- a/crates/tsv_svelte_compile/src/lib.rs +++ b/crates/tsv_svelte_compile/src/lib.rs @@ -7,10 +7,16 @@ //! diff between two canonical forms reflects only a real code difference, never //! incidental whitespace. //! -//! The compiler itself is a walking skeleton for this slice: [`compile`] parses -//! the component (so genuine parse errors surface) and then reports that code -//! generation is not yet implemented. The canonicalizer is complete and is the -//! comparison substrate the compiler will be measured against. +//! [`compile`] generates server (SSR) output by constructing a synthetic +//! `tsv_ts` AST over the hybrid appendix buffer (`build`) and printing it +//! through `tsv_ts::format_canonical` — generated JS is canonical-form by +//! construction, so the parity comparison verifies rather than transforms it. +//! The server transform (`transform_server`) covers a deliberately small +//! language subset today; unhandled shapes surface as +//! [`CompileError::Unsupported`] rather than guessed output. + +mod build; +mod transform_server; use tsv_ts::Goal; @@ -68,9 +74,10 @@ pub enum CompileError { /// source, its `\n\n

{prop}

\n", + &CompileOptions::default(), + ) + .unwrap(); + assert_eq!( + out.js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \tlet { prop } = $$props;\n\ + \t$$renderer.push(`

${$.escape(prop)}

`);\n\ + }\n" + ); + assert_eq!(canonicalize_js(&out.js).unwrap(), out.js); + } + + #[test] + fn compile_template_escapes_backtick_and_backslash() { + // Static text containing template-literal metacharacters must be escaped + // in the minted quasi so the output reparses to the same text. (`${` can't + // appear as static Svelte text — `{` opens an expression tag — so the + // template-escape cases reachable from a component are backtick/backslash.) + let out = compile("

a`b\\c

", &CompileOptions::default()).unwrap(); + assert!( + out.js.contains("`

a\\`b\\\\c

`"), + "template metachars must be escaped: {}", + out.js + ); + assert_eq!(canonicalize_js(&out.js).unwrap(), out.js); + } + + #[test] + fn compile_rejects_unsupported_block() { + let err = compile("{#if a}

text

{/if}", &CompileOptions::default()).unwrap_err(); + assert!( + matches!(&err, CompileError::Unsupported(what) if what.contains("{#if}")), + "expected Unsupported({{#if}}), got {err:?}" + ); + } + + #[test] + fn compile_rejects_unsupported_rune() { + let err = compile( + "\n

{a}

", + &CompileOptions::default(), + ) + .unwrap_err(); + assert!( + matches!(&err, CompileError::Unsupported(what) if what.contains("$state")), + "expected Unsupported($state), got {err:?}" + ); + } + + #[test] + fn compile_rejects_client_generation() { + let options = CompileOptions { + generate: Generate::Client, + dev: false, + }; + let err = compile("

text

", &options).unwrap_err(); assert!( - matches!(err, CompileError::Codegen), - "expected Codegen, got {err:?}" + matches!(err, CompileError::Unsupported(_)), + "expected Unsupported, got {err:?}" ); } diff --git a/crates/tsv_svelte_compile/src/transform_server.rs b/crates/tsv_svelte_compile/src/transform_server.rs new file mode 100644 index 000000000..6df72730d --- /dev/null +++ b/crates/tsv_svelte_compile/src/transform_server.rs @@ -0,0 +1,517 @@ +//! The server (SSR) transform: parsed component → server-module JS + scoped CSS. +//! +//! Mirrors the canonical Svelte compiler's server output shape (the oracle): +//! +//! ```text +//! import * as $ from 'svelte/internal/server'; +//! export default function Input($$renderer[, $$props]) { +//! …instance script statements (rune-rewritten)… +//! $$renderer.push(`…static html${$.escape(expr)}…`); +//! } +//! ``` +//! +//! Codegen owns zero precedence knowledge — the printer's `needs_parens` +//! handles it. Shapes the transform does not yet cover return a clear +//! [`CompileError::Unsupported`] rather than guessing. + +use std::collections::BTreeSet; + +use bumpalo::collections::Vec as BumpVec; +use tsv_css::ast::internal::{CssBlockChild, CssNode, SimpleSelector}; +use tsv_lang::{InfallibleResolve, Span}; +use tsv_svelte::ast::internal::{ + Attribute, AttributeNode, AttributeValue, Element, Fragment, FragmentNode, Root, Style, +}; +use tsv_ts::ast::internal::{ + BlockStatement, Expression, ExportDefaultDeclaration, ExportDefaultValue, ExpressionStatement, + FunctionDeclaration, Statement, VariableDeclaration, VariableDeclarator, +}; + +use crate::build::{Builder, escape_template_text}; +use crate::{CompileError, CompileOutput}; + +/// The deterministic scoping class — the fixed `cssHash` the oracle sidecar +/// compiles with, so outputs are byte-comparable across runs. +const SCOPE_HASH_CLASS: &str = "svelte-tsvhash"; + +/// The component function name. Derived from the constant filename the +/// deterministic oracle compiles under (`input.svelte` → `Input`). +const COMPONENT_NAME: &str = "Input"; + +/// Compile a parsed component to server output. +pub(crate) fn compile_server<'arena>( + root: &Root<'arena>, + source: &str, + arena: &'arena bumpalo::Bump, +) -> Result { + let mut b = Builder::new(arena, source, root.interner.clone()); + + if root.module.is_some() { + return Err(unsupported("module \n

text

", + "$effect", + ); + } + + #[test] + fn compile_rejects_rune_in_nested_function() { + assert_unsupported( + "\n

text

", + "$state", + ); + } + + #[test] + fn compile_rejects_member_form_rune_init() { + assert_unsupported( + "\n

{a}

", + "$state", + ); + assert_unsupported( + "\n

{b}

", + "$props", + ); + } + + #[test] + fn compile_rejects_rune_in_arrow_and_template_expression() { + assert_unsupported( + "\n

text

", + "$inspect", + ); + assert_unsupported("

{$state(0)}

", "$state"); + } + + #[test] + fn compile_rejects_script_comments() { + assert_unsupported( + "\n

text

", + "comments in the instance script", + ); + } + + #[test] + fn compile_collapses_sibling_whitespace() { + // Inter-sibling whitespace runs (newlines, blank lines) collapse to one + // space; element-boundary whitespace trims (the oracle's clean_nodes). + let out = compile( + "

text1

\n\n
\n\t

text2

\n\t

text3

\n
\n", + &CompileOptions::default(), + ) + .unwrap(); + assert!( + out.js + .contains("`

text1

text2

text3

`"), + "sibling/boundary whitespace not normalized: {}", + out.js + ); + } + + #[test] + fn compile_preserves_text_interior_whitespace() { + // Interior whitespace of a content text node is verbatim; edge runs + // adjacent to {expr} tags stay (text + expr count as one text). + let out = compile( + "\n

text x {a} y

", + &CompileOptions::default(), + ) + .unwrap(); + assert!( + out.js.contains("`

text x ${$.escape(a)} y

`"), + "interior/expr-adjacent whitespace mangled: {}", + out.js + ); + } + + #[test] + fn compile_preserves_pre_whitespace() { + let out = compile("
  a\n  b  
", &CompileOptions::default()).unwrap(); + assert!( + out.js.contains("`
  a\n  b  
`"), + "pre whitespace not preserved: {}", + out.js + ); + } + + #[test] + fn compile_marks_text_first_root_fragment() { + let out = compile(" x

text

", &CompileOptions::default()).unwrap(); + assert!( + out.js.contains("`x

text

`"), + "text-first root fragment must be prefixed: {}", + out.js + ); + } + + #[test] + fn compile_decodes_and_reescapes_entities() { + // Entities decode, then text re-escapes only & and < (the oracle's + // escape_html content rule): > becomes a literal >. + let out = compile("

& < > "

", &CompileOptions::default()).unwrap(); + assert!( + out.js.contains("`

& < > \"

`"), + "entity decode/re-escape wrong: {}", + out.js + ); + // Attribute values re-escape &, ", and < (escape_html attr rule). + let out = compile( + "

text

", + &CompileOptions::default(), + ) + .unwrap(); + assert!( + out.js.contains(" title=\"& < > "q\""), + "attribute entity escaping wrong: {}", + out.js + ); + } + + #[test] + fn compile_void_and_boolean_attributes() { + let out = compile( + "

text1
text2

\n", + &CompileOptions::default(), + ) + .unwrap(); + assert!( + out.js + .contains("`

text1
text2

`"), + "void self-close / boolean attribute wrong: {}", + out.js + ); + } + #[test] fn compile_rejects_client_generation() { let options = CompileOptions { diff --git a/crates/tsv_svelte_compile/src/rune_guard.rs b/crates/tsv_svelte_compile/src/rune_guard.rs new file mode 100644 index 000000000..ee0c04507 --- /dev/null +++ b/crates/tsv_svelte_compile/src/rune_guard.rs @@ -0,0 +1,375 @@ +//! Rune refusal walk over borrowed script statements. +//! +//! The server transform rewrites exactly one rune shape (a top-level +//! `let … = $props()` declarator init). Every other rune use — statement +//! position (`$effect(() => {})`), nested functions +//! (`function f() { let c = $state(0); }`), member-form calls +//! (`$state.raw([])`, `$props.id()`) — must REFUSE rather than pass through +//! into runtime-broken JS. This module is that guarantee: an exhaustive walk of +//! every expression-bearing position, refusing any call or `new` whose callee's +//! *root identifier* (through member/non-null/instantiation/paren chains) is +//! `$`-prefixed. +//! +//! The matches are exhaustive on purpose — a new `Statement`/`Expression` +//! variant fails compilation here instead of silently skipping the guard. +//! TS *type* positions are not walked (nothing in type position evaluates). + +use tsv_ts::ast::internal::{ + ArrowFunctionBody, ClassBody, ClassMember, ExportDefaultValue, Expression, ForInOfLeft, + ForInit, FunctionExpression, ObjectPatternProperty, ObjectProperty, Statement, +}; + +use crate::CompileError; + +/// Refuse any rune call anywhere in `stmt` (see module docs). The one sanctioned +/// exception — the top-level `$props()` declarator init — is excluded by the +/// caller walking around it, not by this guard. +pub(crate) fn refuse_runes_in_statement( + stmt: &Statement<'_>, + source: &str, +) -> Result<(), CompileError> { + walk_statement(stmt, source) +} + +/// Refuse any rune call anywhere in `expr`. +pub(crate) fn refuse_runes_in_expression( + expr: &Expression<'_>, + source: &str, +) -> Result<(), CompileError> { + walk_expression(expr, source) +} + +/// The `$`-prefixed root-identifier name of a callee, peeled through member +/// accesses (`$state.raw`), non-null assertions, instantiations, and preserved +/// parens — `None` when the root is not a plain `$`-identifier. +fn dollar_callee_root<'s>(callee: &Expression<'_>, source: &'s str) -> Option<&'s str> { + match callee { + Expression::Identifier(id) => { + // Parsed identifiers are span-identity (`escaped: None`); an interned + // (escaped) name is synthetic and never a rune. + if id.escaped_name.is_some() { + return None; + } + let start = id.span.start as usize; + let name = &source[start..start + id.name_len as usize]; + name.starts_with('$').then_some(name) + } + Expression::MemberExpression(member) => dollar_callee_root(member.object, source), + Expression::TSNonNullExpression(non_null) => { + dollar_callee_root(non_null.expression, source) + } + Expression::TSInstantiationExpression(inst) => dollar_callee_root(inst.expression, source), + Expression::ParenthesizedExpression(paren) => dollar_callee_root(paren.expression, source), + _ => None, + } +} + +fn rune_error(name: &str) -> CompileError { + CompileError::Unsupported(format!("rune {name}")) +} + +fn walk_statements(stmts: &[Statement<'_>], source: &str) -> Result<(), CompileError> { + for stmt in stmts { + walk_statement(stmt, source)?; + } + Ok(()) +} + +fn walk_statement(stmt: &Statement<'_>, source: &str) -> Result<(), CompileError> { + match stmt { + Statement::ExpressionStatement(s) => walk_expression(&s.expression, source), + Statement::VariableDeclaration(s) => walk_variable_declaration(s, source), + Statement::ReturnStatement(s) => walk_opt(s.argument.as_ref(), source), + Statement::BlockStatement(s) => walk_statements(s.body, source), + Statement::FunctionDeclaration(s) => { + walk_expressions(s.params, source)?; + walk_statements(s.body.body, source) + } + Statement::ClassDeclaration(s) => walk_class_body(&s.body, source), + Statement::ExportNamedDeclaration(s) => match &s.declaration { + Some(decl) => walk_statement(decl, source), + None => Ok(()), + }, + Statement::ExportDefaultDeclaration(s) => match &s.declaration { + ExportDefaultValue::Expression(e) => walk_expression(e, source), + ExportDefaultValue::FunctionDeclaration(f) => { + walk_expressions(f.params, source)?; + walk_statements(f.body.body, source) + } + ExportDefaultValue::ClassDeclaration(c) => walk_class_body(&c.body, source), + ExportDefaultValue::TSDeclareFunction(_) + | ExportDefaultValue::TSInterfaceDeclaration(_) => Ok(()), + }, + Statement::IfStatement(s) => { + walk_expression(&s.test, source)?; + walk_statement(s.consequent, source)?; + match s.alternate { + Some(alt) => walk_statement(alt, source), + None => Ok(()), + } + } + Statement::ForStatement(s) => { + match &s.init { + Some(ForInit::VariableDeclaration(decl)) => { + walk_variable_declaration(decl, source)?; + } + Some(ForInit::Expression(e)) => walk_expression(e, source)?, + None => {} + } + walk_opt(s.test.as_ref(), source)?; + walk_opt(s.update.as_ref(), source)?; + walk_statement(s.body, source) + } + Statement::ForInStatement(s) => { + walk_for_left(&s.left, source)?; + walk_expression(&s.right, source)?; + walk_statement(s.body, source) + } + Statement::ForOfStatement(s) => { + walk_for_left(&s.left, source)?; + walk_expression(&s.right, source)?; + walk_statement(s.body, source) + } + Statement::WhileStatement(s) => { + walk_expression(&s.test, source)?; + walk_statement(s.body, source) + } + Statement::DoWhileStatement(s) => { + walk_statement(s.body, source)?; + walk_expression(&s.test, source) + } + Statement::SwitchStatement(s) => { + walk_expression(&s.discriminant, source)?; + for case in s.cases { + walk_opt(case.test.as_ref(), source)?; + walk_statements(case.consequent, source)?; + } + Ok(()) + } + Statement::TryStatement(s) => { + walk_statements(s.block.body, source)?; + if let Some(handler) = &s.handler { + walk_opt(handler.param.as_ref(), source)?; + walk_statements(handler.body.body, source)?; + } + if let Some(finalizer) = &s.finalizer { + walk_statements(finalizer.body, source)?; + } + Ok(()) + } + Statement::ThrowStatement(s) => walk_expression(&s.argument, source), + Statement::LabeledStatement(s) => walk_statement(s.body, source), + // No expression-bearing children. + Statement::BreakStatement(_) + | Statement::ContinueStatement(_) + | Statement::EmptyStatement(_) + | Statement::DebuggerStatement(_) + | Statement::ImportDeclaration(_) + | Statement::ExportAllDeclaration(_) + | Statement::TSNamespaceExportDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) + | Statement::TSTypeAliasDeclaration(_) + | Statement::TSInterfaceDeclaration(_) + | Statement::TSDeclareFunction(_) => Ok(()), + // Enum/module bodies can carry initializer expressions; walking their + // internals isn't wired yet, so refuse rather than under-guard. + Statement::TSEnumDeclaration(_) | Statement::TSModuleDeclaration(_) => Err( + CompileError::Unsupported("TS enum/module declaration in instance script".to_string()), + ), + Statement::TSExportAssignment(s) => walk_expression(&s.expression, source), + } +} + +fn walk_variable_declaration( + decl: &tsv_ts::ast::internal::VariableDeclaration<'_>, + source: &str, +) -> Result<(), CompileError> { + for declarator in decl.declarations { + walk_expression(&declarator.id, source)?; + walk_opt(declarator.init.as_ref(), source)?; + } + Ok(()) +} + +fn walk_for_left(left: &ForInOfLeft<'_>, source: &str) -> Result<(), CompileError> { + match left { + ForInOfLeft::VariableDeclaration(decl) => walk_variable_declaration(decl, source), + ForInOfLeft::Pattern(pattern) => walk_expression(pattern, source), + } +} + +fn walk_opt(expr: Option<&Expression<'_>>, source: &str) -> Result<(), CompileError> { + match expr { + Some(e) => walk_expression(e, source), + None => Ok(()), + } +} + +fn walk_expressions(exprs: &[Expression<'_>], source: &str) -> Result<(), CompileError> { + for expr in exprs { + walk_expression(expr, source)?; + } + Ok(()) +} + +fn walk_function_expression(f: &FunctionExpression<'_>, source: &str) -> Result<(), CompileError> { + walk_expressions(f.params, source)?; + walk_statements(f.body.body, source) +} + +fn walk_class_body(body: &ClassBody<'_>, source: &str) -> Result<(), CompileError> { + for member in body.body { + match member { + ClassMember::MethodDefinition(m) => { + if m.computed { + walk_expression(&m.key, source)?; + } + walk_function_expression(&m.value, source)?; + } + ClassMember::PropertyDefinition(p) => { + if p.computed { + walk_expression(&p.key, source)?; + } + walk_opt(p.value.as_ref(), source)?; + } + ClassMember::StaticBlock(b) => walk_statements(b.body, source)?, + ClassMember::IndexSignature(_) => {} + } + } + Ok(()) +} + +fn walk_expression(expr: &Expression<'_>, source: &str) -> Result<(), CompileError> { + match expr { + // The guard itself: any call/new whose callee roots in a `$`-identifier. + Expression::CallExpression(call) => { + if let Some(name) = dollar_callee_root(call.callee, source) { + return Err(rune_error(name)); + } + walk_expression(call.callee, source)?; + walk_expressions(call.arguments, source) + } + Expression::NewExpression(new_expr) => { + if let Some(name) = dollar_callee_root(new_expr.callee, source) { + return Err(rune_error(name)); + } + walk_expression(new_expr.callee, source)?; + walk_expressions(new_expr.arguments, source) + } + + // Leaves. + Expression::Literal(_) + | Expression::Identifier(_) + | Expression::PrivateIdentifier(_) + | Expression::RegexLiteral(_) + | Expression::ThisExpression(_) + | Expression::Super(_) + | Expression::MetaProperty(_) => Ok(()), + + Expression::ObjectExpression(obj) => { + for prop in obj.properties { + match prop { + ObjectProperty::Property(p) => { + if p.computed { + walk_expression(&p.key, source)?; + } + walk_expression(&p.value, source)?; + } + ObjectProperty::SpreadElement(s) => walk_expression(s.argument, source)?, + } + } + Ok(()) + } + Expression::ArrayExpression(arr) => { + for element in arr.elements { + walk_opt(element.as_ref(), source)?; + } + Ok(()) + } + Expression::UnaryExpression(u) => walk_expression(u.argument, source), + Expression::UpdateExpression(u) => walk_expression(u.argument, source), + Expression::BinaryExpression(b) => { + walk_expression(b.left, source)?; + walk_expression(b.right, source) + } + Expression::MemberExpression(m) => { + walk_expression(m.object, source)?; + if m.computed { + walk_expression(m.property, source)?; + } + Ok(()) + } + Expression::ConditionalExpression(c) => { + walk_expression(c.test, source)?; + walk_expression(c.consequent, source)?; + walk_expression(c.alternate, source) + } + Expression::ArrowFunctionExpression(a) => { + walk_expressions(a.params, source)?; + match &a.body { + ArrowFunctionBody::Expression(e) => walk_expression(e, source), + ArrowFunctionBody::BlockStatement(b) => walk_statements(b.body, source), + } + } + Expression::FunctionExpression(f) => walk_function_expression(f, source), + Expression::ClassExpression(c) => walk_class_body(&c.body, source), + Expression::SpreadElement(s) => walk_expression(s.argument, source), + Expression::TemplateLiteral(t) => walk_expressions(t.expressions, source), + Expression::TaggedTemplateExpression(t) => { + walk_expression(t.tag, source)?; + walk_expressions(t.quasi.expressions, source) + } + Expression::AwaitExpression(a) => walk_expression(a.argument, source), + Expression::YieldExpression(y) => match y.argument { + Some(argument) => walk_expression(argument, source), + None => Ok(()), + }, + Expression::SequenceExpression(s) => walk_expressions(s.expressions, source), + Expression::AssignmentExpression(a) => { + walk_expression(a.left, source)?; + walk_expression(a.right, source) + } + Expression::ObjectPattern(p) => { + for prop in p.properties { + match prop { + ObjectPatternProperty::Property(prop) => { + if prop.computed { + walk_expression(&prop.key, source)?; + } + walk_expression(&prop.value, source)?; + } + ObjectPatternProperty::RestElement(rest) => { + walk_expression(rest.argument, source)?; + } + } + } + Ok(()) + } + Expression::ArrayPattern(p) => { + for element in p.elements { + walk_opt(element.as_ref(), source)?; + } + Ok(()) + } + Expression::AssignmentPattern(p) => { + walk_expression(p.left, source)?; + walk_expression(p.right, source) + } + Expression::RestElement(r) => walk_expression(r.argument, source), + Expression::TSTypeAssertion(t) => walk_expression(t.expression, source), + Expression::TSAsExpression(t) => walk_expression(t.expression, source), + Expression::TSSatisfiesExpression(t) => walk_expression(t.expression, source), + Expression::TSInstantiationExpression(t) => walk_expression(t.expression, source), + Expression::TSNonNullExpression(t) => walk_expression(t.expression, source), + Expression::TSParameterProperty(t) => walk_expression(t.parameter, source), + Expression::ImportExpression(i) => { + walk_expression(i.source, source)?; + match i.options { + Some(options) => walk_expression(options, source), + None => Ok(()), + } + } + Expression::JsdocCast(j) => walk_expression(j.inner, source), + Expression::ParenthesizedExpression(p) => walk_expression(p.expression, source), + } +} diff --git a/crates/tsv_svelte_compile/src/transform_server.rs b/crates/tsv_svelte_compile/src/transform_server.rs index df6fd96bb..9bb345d59 100644 --- a/crates/tsv_svelte_compile/src/transform_server.rs +++ b/crates/tsv_svelte_compile/src/transform_server.rs @@ -10,6 +10,24 @@ //! } //! ``` //! +//! Static template emission follows the oracle's normalization, derived from +//! Svelte's own `clean_nodes` + `escape_html` (empirically probe-verified): +//! +//! - **Whitespace** (per fragment, whitespace class `[ \t\r\n]`): drop +//! whitespace-only boundary text nodes and trim the boundary runs of edge +//! text; collapse a text node's leading/trailing run to one space where it +//! abuts a non-text node — except next to `{expr}` tags, which count as part +//! of the text; keep interior whitespace verbatim. Inside `
`/`",
+        "bind: directive value",
+    );
+}
+
+#[test]
+fn compile_bind_checked_requires_static_checkbox_type() {
+    // `bind:checked` requires a static `type="checkbox"` — a missing / non-checkbox
+    // type is `bind_invalid_target` (an oracle error).
+    assert_unsupported(
+        "\n",
+        "bind: directive checked",
+    );
+    assert_unsupported(
+        "\n",
+        "bind: directive checked",
+    );
+}
+
+#[test]
+fn compile_bind_group_dynamic_type_refuses() {
+    // A dynamic `type={x}` with `bind:group` is `attribute_invalid_type` (an oracle
+    // error) — refuse rather than over-accept.
+    assert_unsupported(
+        "\n",
+        "bind: directive group",
+    );
+}
+
+#[test]
+fn compile_bind_value_bare_type_and_file_refuse() {
+    // A BARE `type` with `bind:value` is `attribute_invalid_type` (an oracle error);
+    // a static `type="file"` is the files trap the oracle silently drops the bind
+    // for — refuse rather than emit a divergent `$.attr('value', …)`.
+    assert_unsupported(
+        "\n",
+        "bind: directive value",
+    );
+    assert_unsupported(
+        "\n",
+        "bind: directive value",
+    );
+}
+
+#[test]
+fn compile_bind_omit_in_ssr_and_special_targets_refuse() {
+    // The `omit_in_ssr` media/dimension binds, `bind:open` on `
`, and the + // content-editable trio are all deferred → the collapsing `bind:` bucket. + assert_unsupported( + "\n
", + "bind: directive clientWidth", + ); + assert_unsupported( + "\n
", + "bind: directive open", + ); + assert_unsupported( + "\n
", + "bind: directive innerHTML", + ); +} + +#[test] +fn compile_bind_non_state_expression_refuses() { + // The expression-validity gate: a non-lvalue target (a call) and a bind rooted + // at a non-`$state` binding (a `$derived`) both refuse — tsv emits only a + // `$state`-rooted lvalue (the SAFE side of the oracle's assignable rule). + assert_unsupported( + "\n", + "bind: directive value", + ); + assert_unsupported( + "\n", + "bind: directive value", + ); +} + #[test] fn compile_empty_class_attribute_drops() { // A static string-valued class that collapses+trims to empty is diff --git a/docs/checklist_svelte_compiler.md b/docs/checklist_svelte_compiler.md index 5863a8cd4..29b53249f 100644 --- a/docs/checklist_svelte_compiler.md +++ b/docs/checklist_svelte_compiler.md @@ -296,7 +296,9 @@ A **static** component invocation compiles to `Name($$renderer, props)` (`shared | `style:` alongside a **mixed-value** `style="a {b}"` base | **Refused**: `style: directive alongside a mixed-value style attribute` | | `style:prop="a {b}"` with a **mixed-value** (text + expression) directive value | **Refused**: `style: directive with a mixed-value (text + expression) value` | | `style:prop\|mod` with an invalid modifier — anything but a single `\|important` (the oracle's `style_directive_invalid_modifier`) | **Refused**: `style: directive with an invalid modifier (only \|important, once, is allowed)` | -| the still-refused directives / spread — `bind:`, a legacy `on:` event directive, `let:`, and an element `{...spread}` (a `class:`/`style:` alongside one of these still refuses via the sibling; `class:`/`style:` on a **component** refuses through `directive on <{name}> component`) | **Refused**: `non-plain attribute (directive/spread)` | +| `bind:` core kinds on a **regular element** (the oracle's server `BindDirective` handling, `shared/element.js`): **`bind:this`** → omit (emit nothing; valid on any variable / any element); **`bind:value`** on `` → `$.attr('value', expr)`; **`bind:checked`** on `` (static) → `$.attr('checked', expr, true)`; **`bind:group`** on `` with a static `type` → a synthesized `$.attr('checked', , true)`, `` = `group.includes()` for `type="checkbox"` else `group === `, where `` is the companion `value` attribute's value (which still emits at its own slot; no companion `value` → the oracle silently drops the bind). Emit only when the (erased) bind target is a `$state`-rooted `Identifier`/member chain (the crate's one supported bindable) | Supported (regular elements) | +| every other `bind:` — a bind on a non-`` target, `value` on ``); +} diff --git a/tests/fixtures_compile/attributes/spread/textarea/input.svelte b/tests/fixtures_compile/attributes/spread/textarea/input.svelte new file mode 100644 index 000000000..650368cc2 --- /dev/null +++ b/tests/fixtures_compile/attributes/spread/textarea/input.svelte @@ -0,0 +1,6 @@ + + + From 8c131ddfd6a0e492f888fd715d30999dc34d007d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 14 Jul 2026 12:35:23 -0400 Subject: [PATCH 088/174] feat: compile an element {...spread} co-present with class:/style:/bind:/drop-family directives --- crates/tsv_svelte_compile/CLAUDE.md | 60 +++- crates/tsv_svelte_compile/src/attribute.rs | 318 ++++++++++++++++-- crates/tsv_svelte_compile/src/element.rs | 139 ++++++-- crates/tsv_svelte_compile/src/refusal.rs | 17 +- crates/tsv_svelte_compile/src/tests.rs | 115 ++++++- docs/checklist_svelte_compiler.md | 4 +- .../spread/all_together/expected_server.js | 10 + .../spread/all_together/input.svelte | 8 + .../expected_server.js | 6 + .../input.svelte | 6 + .../spread/bind_group/expected_server.js | 8 + .../attributes/spread/bind_group/input.svelte | 6 + .../spread/bind_value/expected_server.js | 6 + .../attributes/spread/bind_value/input.svelte | 6 + .../spread/class_and_style/expected_server.js | 7 + .../spread/class_and_style/input.svelte | 7 + .../spread/class_directive/expected_server.js | 6 + .../spread/class_directive/input.svelte | 6 + .../spread/class_shorthand/expected_server.js | 6 + .../spread/class_shorthand/input.svelte | 6 + .../spread/drop_family/expected_server.js | 5 + .../spread/drop_family/input.svelte | 5 + .../spread/scoped_class/expected.css | 4 + .../spread/scoped_class/expected_server.js | 6 + .../spread/scoped_class/input.svelte | 12 + .../spread/style_directive/expected_server.js | 6 + .../spread/style_directive/input.svelte | 6 + .../spread/style_important/expected_server.js | 6 + .../spread/style_important/input.svelte | 6 + .../spread/style_shorthand/expected_server.js | 6 + .../spread/style_shorthand/input.svelte | 6 + 31 files changed, 713 insertions(+), 102 deletions(-) create mode 100644 tests/fixtures_compile/attributes/spread/all_together/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/all_together/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/authored_class_plus_directive/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/authored_class_plus_directive/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/bind_group/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/bind_group/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/bind_value/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/bind_value/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/class_and_style/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/class_and_style/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/class_directive/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/class_directive/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/class_shorthand/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/class_shorthand/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/drop_family/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/drop_family/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/scoped_class/expected.css create mode 100644 tests/fixtures_compile/attributes/spread/scoped_class/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/scoped_class/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/style_directive/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/style_directive/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/style_important/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/style_important/input.svelte create mode 100644 tests/fixtures_compile/attributes/spread/style_shorthand/expected_server.js create mode 100644 tests/fixtures_compile/attributes/spread/style_shorthand/input.svelte diff --git a/crates/tsv_svelte_compile/CLAUDE.md b/crates/tsv_svelte_compile/CLAUDE.md index 0da91a341..071dcf357 100644 --- a/crates/tsv_svelte_compile/CLAUDE.md +++ b/crates/tsv_svelte_compile/CLAUDE.md @@ -52,26 +52,39 @@ project-wide conventions. ordinary JS and clones through), `svelte/internal*` imports and `beforeUpdate`/`afterUpdate` imports from `svelte` (the oracle's runes-mode import rules), `{@debug}`, the still-refused attribute directives - (a legacy `on:` directive and `let:`) — an element `{...spread}` alongside - only plain attributes and other spreads is **emitted** as the fused + (a legacy `on:` directive and `let:`) — an element `{...spread}` (alone, or + co-present with `class:` / `style:` / `bind:` / the no-op drop family) is + **emitted** as the fused `$.attributes(object, css_hash, classes, styles, flags)` call (`element.rs::emit_spread_attributes`): the whole attribute set becomes the - object (plain attributes → `key: value` properties, event handlers / + object (plain attributes → `key: value` properties, a `bind:` core kind → + its synthesized `value`/`checked` property at the bind's slot, event handlers / `defaultValue` dropped, spreads → `...expr`), the scope hash rides the `css_hash` argument (not concatenated into the class value as in the - non-spread path), and `` / a custom element set the `flags` argument + non-spread path; a static-class token OR a `class:` directive name scopes), + the `class:` directives ride the `classes` argument (the oracle's + `b.init(name, expr)` — identifier keys, case-preserved, with the + object-shorthand collapse — `attribute::build_spread_class_object`), the + `style:` directives ride the `styles` argument (a **FLAT** object, **no** + `|important` partitioning — the divergence from the non-spread + `$.attr_style` array — `attribute::build_spread_style_object`), and `` + / a custom element set the `flags` argument (trailing absent args elide, interior ones become `void 0`); a spread - co-present with any directive, on a `` (the `$$renderer.select` + trap) or on a load-error element refuses (`SpreadOnSelect` / + `SpreadOnLoadErrorElement`) — a `class:` / `style:` + directive on a **regular element without a spread** is instead **emitted** as the fused `$.attr_class(base, hash, {…})` / `$.attr_style(base, {…})` call (`element.rs` / - `attribute.rs`), and a `bind:` **core kind** on a regular element is + `attribute.rs`), and a `bind:` **core kind** on a regular element without a + spread is **handled** by `attribute::emit_bind_directive` (`bind:this` omits; `bind:value`/`bind:checked`/`bind:group` on `` synthesize a `$.attr(...)` for a `$state`-rooted target; every other `bind:` refuses via - `Refusal::BindDirective { name }`); the no-op drop family (`use:`/`transition:`/`in:`/`out:`/ + `Refusal::BindDirective { name }`) — both the inline and spread `bind:` paths + share one `attribute::resolve_bind_directive` validity fork; the no-op drop + family (`use:`/`transition:`/`in:`/`out:`/ `animate:`/`{@attach}`) is instead **dropped** on a regular element, its expression still guarded (a stray rune / `await` refuses) and still walked for scope analysis, except a `use:` on a load-error element, which refuses because @@ -345,11 +358,18 @@ project-wide conventions. → one fused `$.attributes(object, css_hash, classes, styles, flags)` call (``): `build_element_spread_object` builds the source-order object (plain attributes via `attribute::build_spread_object_property`, - spreads as `...expr`), the scope hash rides `css_hash` (matched classes recorded - in `matched_classes`), `` / a custom element (hyphenated tag or `is` + a `bind:` core kind's synthesized `value`/`checked` property at its slot via + `attribute::build_bind_object_property`, spreads as `...expr`), the scope hash + rides `css_hash` (a static-class token OR a `class:` directive name scopes; + matched classes recorded in `matched_classes`), the `class:` directives ride + `classes` (`attribute::build_spread_class_object` — identifier keys + shorthand) + and the `style:` directives ride `styles` + (`attribute::build_spread_style_object` — a FLAT object, no `|important` + partition), `` / a custom element (hyphenated tag or `is` attribute) set `flags` (`4` / `2`), and `elide_call_args` applies the oracle's `b.call` elision (trailing `void 0` dropped, interior padded). A co-present - directive, a ``, or a load-error element refuses; the drop family is + guarded-and-dropped. The non-spread path (`emit_plain_attributes`) pre-scans a regular element's `class:` and `style:` directives and defers them to `attribute::emit_class_directives` / `attribute::emit_style_directives` (each @@ -390,7 +410,9 @@ project-wide conventions. attribute loop pre-scans the `class:` and `style:` directives and calls these at the authored slot (or after all plain attributes when synthetic). Also `emit_bind_directive` — a `bind:` **core kind** on a regular element, emitted - inline at its source slot: `bind:this` omits (any variable, any element — no + inline at its source slot (delegating to `resolve_bind_directive`, the validity + fork the spread `build_bind_object_property` shares so the two never drift): + `bind:this` omits (any variable, any element — no `$state` gate), but only for a valid bind target (an Identifier/member chain or a `{get, set}` pair); a non-lvalue target (a call/literal/logical) refuses (`bind_invalid_expression`). `bind:value` on `` → `$.attr('value', expr)`; @@ -415,7 +437,15 @@ project-wide conventions. `build_spread_object_property` — one `key: value` object property from a plain attribute (key lowercased, `shorthand` on a same-named identifier value), `None` for a dropped attribute (a single-expression event handler — still guarded — and - `defaultValue`/`defaultChecked`). + `defaultValue`/`defaultChecked`). And the three spread-directive builders: + `build_bind_object_property` (a `bind:` core kind's `value`/`checked` property via + the shared `resolve_bind_directive` — `bind:this`/a no-companion `bind:group`/an + `omit_in_ssr` bind yield `None`, the last **skipped** on the spread path where the + inline path refuses it), `build_spread_class_object` (the `classes` argument — + identifier keys, case-preserved, with the object-shorthand collapse the oracle's + `b.init` applies, checked on the RAW directive expression), and + `build_spread_style_object` (the `styles` argument — a FLAT object, `|important` + validated but NOT partitioned, reusing `build_style_property`). - `css_scope.rs` — minimal CSS scoping (single class selectors: the `svelte-tsvhash` class appended to matched elements and **source-spliced** into the style text — the author's whitespace is diff --git a/crates/tsv_svelte_compile/src/attribute.rs b/crates/tsv_svelte_compile/src/attribute.rs index 3aecdb83d..d856e0228 100644 --- a/crates/tsv_svelte_compile/src/attribute.rs +++ b/crates/tsv_svelte_compile/src/attribute.rs @@ -32,6 +32,55 @@ pub(crate) fn is_load_error_element(name: &str) -> bool { LOAD_ERROR_ELEMENTS.contains(&name) } +/// The `bind:` names Svelte's `binding_properties` marks `omit_in_ssr` — media, +/// dimension, window, and readback bindings (`bindings.js`) that produce **no** SSR +/// output. The oracle's server `BindDirective` handling skips them with an early +/// `continue`, before it visits the target (`shared/element.js`). `this` is +/// `omit_in_ssr` too but is handled separately (it carries `{get, set}`/lvalue +/// validation and skips on both the inline and spread paths), so it is excluded +/// here. Membership is by exact (case-sensitive) name, matching +/// `binding_properties[attribute.name]`. +const OMIT_IN_SSR_BINDS: &[&str] = &[ + "currentTime", + "duration", + "paused", + "buffered", + "seekable", + "played", + "volume", + "muted", + "playbackRate", + "seeking", + "ended", + "readyState", + "videoHeight", + "videoWidth", + "naturalWidth", + "naturalHeight", + "activeElement", + "fullscreenElement", + "pointerLockElement", + "visibilityState", + "innerWidth", + "innerHeight", + "outerWidth", + "outerHeight", + "scrollX", + "scrollY", + "online", + "devicePixelRatio", + "clientWidth", + "clientHeight", + "offsetWidth", + "offsetHeight", + "contentRect", + "contentBoxSize", + "borderBoxSize", + "devicePixelContentBoxSize", + "indeterminate", + "files", +]; + /// The DOM boolean attributes (the oracle's `DOM_BOOLEAN_ATTRIBUTES`): a /// dynamic value on one of these emits `$.attr(name, value, true)`. // TODO: consider a home in tsv_html beside the element classification tables. @@ -683,6 +732,101 @@ fn build_class_directives_object<'arena>( })) } +/// Build the `classes` (3rd) argument of a spread element's `$.attributes(…)` call +/// from its `class:` directives (the oracle's `prepare_element_spread`, which uses +/// `b.init(directive.name, …)`). Unlike the non-spread `$.attr_class` object +/// ([`build_class_directives_object`], string-literal keys), this uses an +/// **identifier key** (a quoted string literal only when the name isn't +/// identifier-safe, e.g. `class:foo-bar` → `{ 'foo-bar': x }`) with the +/// **object-shorthand** collapse the oracle's `b.init` applies: `class:active` +/// (shorthand) and `class:active={active}` both → `{ active }`, `class:active={x}` → +/// `{ active: x }`. Class names are **case-sensitive** (never lowercased). The +/// same-named-identifier fork reads the **raw** directive expression, mirroring the +/// oracle's `directive.expression.type === 'Identifier' && … === directive.name` +/// (a `class:active={active as T}` is not shorthand — the raw node is a +/// `TSAsExpression` — and its value is the erased/guarded expression). +pub(crate) fn build_spread_class_object<'arena>( + env: &mut EmitEnv<'arena, '_>, + class_directives: &[&'arena ClassDirective<'arena>], +) -> Result, CompileError> { + let arena = env.b.arena; + let obrace = env.b.mint("{").start; + let mut properties: BumpVec<'arena, ObjectProperty<'arena>> = BumpVec::new_in(arena); + for directive in class_directives { + let name = directive.name_span.extract(env.source).to_string(); + // The oracle's same-named-identifier shorthand fork, read on the RAW node: + // the value is the bare `b.id(directive.name)` (no transform / derived + // rewrite), exactly like a `style:` shorthand. + let same_named = matches!(&directive.expression, Expression::Identifier(id) + if plain_identifier_name(id, env.source).as_deref() == Some(name.as_str())); + let (value, is_shorthand_id) = if same_named { + (Expression::Identifier(env.b.ident(&name)), true) + } else { + // The template borrow point: erase once, then guard + rewrite a bare + // derived read to `d()`. + let expr = env.erase(&directive.expression)?; + (wrap_value_expr(env, expr)?[0].clone(), false) + }; + let key_is_ident = is_js_identifier(&name); + let shorthand = is_shorthand_id && key_is_ident; + let (key, key_span) = if key_is_ident { + let id = env.b.ident(&name); + let span = id.span; + (Expression::Identifier(id), span) + } else { + let key = env.b.string_literal_expr(&name); + let span = key.span(); + (key, span) + }; + properties.push(ObjectProperty::Property(Property { + key, + value, + kind: PropertyKind::Init, + shorthand, + computed: false, + method: false, + span: key_span, + })); + } + let cbrace = env.b.mint("}").end; + Ok(Expression::ObjectExpression(ObjectExpression { + properties: properties.into_bump_slice(), + spread_trailing_comma: false, + span: Span::new(obrace, cbrace), + })) +} + +/// Build the `styles` (4th) argument of a spread element's `$.attributes(…)` call +/// from its `style:` directives (the oracle's `prepare_element_spread`). A **flat** +/// object `{ prop: value, … }` in source order — **no** `|important` partitioning +/// (the CRITICAL divergence from the non-spread `$.attr_style` path, which builds +/// the `[ {normal}, {important} ]` array): in spread mode the oracle's +/// `build_attribute_value(directive.value, …, true)` folds every directive into one +/// object regardless of modifier. `|important` is still **validated** (only a single +/// `|important` is legal, else refuse) but does not partition. Reuses +/// [`build_style_property`] for the per-property build (key lowercasing / quoting, +/// shorthand). +pub(crate) fn build_spread_style_object<'arena>( + env: &mut EmitEnv<'arena, '_>, + style_directives: &[&'arena StyleDirective<'arena>], +) -> Result, CompileError> { + let arena = env.b.arena; + let obrace = env.b.mint("{").start; + let mut properties: BumpVec<'arena, ObjectProperty<'arena>> = BumpVec::new_in(arena); + for directive in style_directives { + validate_style_modifiers(directive)?; + properties.push(ObjectProperty::Property(build_style_property( + env, directive, + )?)); + } + let cbrace = env.b.mint("}").end; + Ok(Expression::ObjectExpression(ObjectExpression { + properties: properties.into_bump_slice(), + spread_trailing_comma: false, + span: Span::new(obrace, cbrace), + })) +} + /// Emit the fused `$.attr_class(base, css_hash, { name: expr, … })` call for a /// regular element carrying `class:` directives (the oracle's `build_attr_class`, /// `shared/element.js`). `class_attr` is the authored `class` attribute when @@ -1146,55 +1290,82 @@ fn build_companion_value<'arena>( } } -/// Emit a `bind:` directive on a regular ``/element (the oracle's server -/// `BindDirective` handling in `shared/element.js`). The handled core kinds: +/// The SSR emission a `bind:` **core kind** reduces to, independent of whether the +/// element carries a `{...spread}` — the fork the inline (`emit_bind_directive`) +/// and spread (`build_bind_object_property`) callers share so the two never drift. +enum BindEmission<'arena> { + /// The synthesized attribute `name = value` (`bind:value`/`bind:checked`, or + /// `bind:group`'s synthesized `checked`). `boolean` picks the inline + /// `$.attr(name, value, true)` boolean form; the spread object drops it (the + /// object property is just `name: value`). + Attr { + name: &'static str, + value: Expression<'arena>, + boolean: bool, + }, + /// Emit nothing on BOTH paths: a valid `bind:this`, or a `bind:group` with no + /// companion `value` attribute (the oracle silently drops it). + Omit, + /// An `omit_in_ssr` bind ([`OMIT_IN_SSR_BINDS`]): the oracle drops it from SSR + /// entirely (its early `continue`). The inline path **refuses** it (a safe + /// over-refusal, unchanged from the non-spread slice); the spread path **skips** + /// it, matching the oracle. + OmitInSsr, +} + +/// Resolve a `bind:` **core kind** on a regular ``/element to its SSR +/// emission (the oracle's server `BindDirective` handling in `shared/element.js`), +/// applying every validity gate. Shared by the inline and spread callers so a bind +/// is validated identically whether or not the element also carries a spread: /// -/// - **`bind:this`** → omit (emit nothing). Valid on any variable / any element, -/// with no `$state` gate, but the target must be one of the oracle's three -/// accepted forms — an Identifier, a member chain, or a `{get, set}` pair; any -/// other shape is `bind_invalid_expression` (refuse). -/// - **`bind:value`** on `` → `$.attr('value', expr)`. A bare `type` is +/// - **`bind:this`** → [`BindEmission::Omit`] (no output). Valid on any variable / +/// any element, no `$state` gate, but the (erased) target must be one of the +/// oracle's three accepted forms — an Identifier, a member chain, or a +/// `{get, set}` pair; any other shape is `bind_invalid_expression` (refuse). +/// - **`omit_in_ssr` binds** → [`BindEmission::OmitInSsr`] (each caller decides). +/// - **`bind:value`** on `` → `Attr { "value", .. }`. A bare `type` is /// `attribute_invalid_type` (refuse); a static `type="file"` is the files trap -/// the oracle silently drops the bind for (refuse rather than emit a divergent -/// value); a dynamic `type={x}` / static-non-file / no type is fine. -/// - **`bind:checked`** on `` → `$.attr('checked', expr, true)`; requires a -/// static `type="checkbox"` (else the oracle rejects). +/// the oracle silently drops (refuse rather than emit a divergent value); a +/// dynamic `type={x}` / static-non-file / no type is fine. +/// - **`bind:checked`** on `` → `Attr { "checked", .., boolean }`; requires +/// a static `type="checkbox"` (else the oracle rejects). /// - **`bind:group`** on `` with a static `type` → a synthesized -/// `$.attr('checked', , true)` where `` is -/// `group.includes()` for `type="checkbox"` else `group === `, and -/// `` is the companion `value` attribute's value. No companion `value` → -/// the oracle silently drops the bind (emit nothing). The companion `value` still -/// emits normally at its own source slot — it is only READ here. +/// `Attr { "checked", , .. }` where `` is `group.includes()` +/// for `type="checkbox"` else `group === `, and `` is the companion +/// `value` attribute's value. No companion `value` → [`BindEmission::Omit`]. The +/// companion `value` still emits normally at its own source slot — it is only +/// READ here. /// -/// Everything else `bind:`-related refuses with the collapsing -/// [`Refusal::BindDirective`] bucket: a bind on a non-`` target, `value` on -/// `", - "bind: directive value", - ); -} - -#[test] -fn compile_bind_checked_requires_static_checkbox_type() { - // `bind:checked` requires a static `type="checkbox"` — a missing / non-checkbox - // type is `bind_invalid_target` (an oracle error). - assert_unsupported( - "\n", - "bind: directive checked", - ); - assert_unsupported( - "\n", - "bind: directive checked", - ); -} - -#[test] -fn compile_bind_group_dynamic_type_refuses() { - // A dynamic `type={x}` with `bind:group` is `attribute_invalid_type` (an oracle - // error) — refuse rather than over-accept. - assert_unsupported( - "\n", - "bind: directive group", - ); -} - -#[test] -fn compile_bind_value_bare_type_and_file_refuse() { - // A BARE `type` with `bind:value` is `attribute_invalid_type` (an oracle error); - // a static `type="file"` is the files trap the oracle silently drops the bind - // for — refuse rather than emit a divergent `$.attr('value', …)`. - assert_unsupported( - "\n", - "bind: directive value", - ); - assert_unsupported( - "\n", - "bind: directive value", - ); -} - -#[test] -fn compile_bind_omit_in_ssr_and_special_targets_refuse() { - // The `omit_in_ssr` media/dimension binds, `bind:open` on `
`, and the - // content-editable trio are all deferred → the collapsing `bind:` bucket. - assert_unsupported( - "\n
", - "bind: directive clientWidth", - ); - assert_unsupported( - "\n
", - "bind: directive open", - ); - assert_unsupported( - "\n
", - "bind: directive innerHTML", - ); -} - -#[test] -fn compile_bind_non_state_expression_refuses() { - // The expression-validity gate: a non-lvalue target (a call) and a bind rooted - // at a non-`$state` binding (a `$derived`) both refuse — tsv emits only a - // `$state`-rooted lvalue (the SAFE side of the oracle's assignable rule). - assert_unsupported( - "\n", - "bind: directive value", - ); - assert_unsupported( - "\n", - "bind: directive value", - ); -} - -#[test] -fn compile_bind_this_non_lvalue_refuses() { - // `bind:this` binds any variable (no `$state` gate), but the target must still - // be an assignable lvalue — an Identifier or member chain. A non-lvalue target - // (call, literal, logical) is the oracle's `bind_invalid_expression`; refuse - // rather than silently omit the bind. - assert_unsupported( - "\n
", - "bind: directive this", - ); - assert_unsupported("
", "bind: directive this"); - assert_unsupported("
", "bind: directive this"); - // A plain `let` (no `$state`) is a valid `bind:this` target and still omits, as - // does a member-chain lvalue — both compile with no `this` attribute. - let js = compile_js("\n
t
"); - assert!(js.contains("`
t
`"), "{js}"); - let js = compile_js("\n
t
"); - assert!(js.contains("`
t
`"), "{js}"); - // A `{get, set}` pair (the oracle's third valid bind form) also omits in SSR — - // it is not an lvalue but is a legal bind target, so refuse-don't-omit would - // over-refuse a valid component (the corpus's `bind-getter-setter-loop`). - let js = - compile_js("\n
el, (v) => (el = v)}>t
"); - assert!(js.contains("`
t
`"), "{js}"); -} - -#[test] -fn compile_empty_class_attribute_drops() { - // A static string-valued class that collapses+trims to empty is - // dropped entirely (oracle-probed); a bare `class` (boolean form) - // keeps `class=""`, and empty style/id stay. - let js = compile_js("
\n
\n"); - assert!(js.contains("`
`"), "{js}"); - let js = compile_js("
\n"); - assert!(js.contains("`
`"), "{js}"); - let js = compile_js("
\n"); - assert!( - js.contains("`
`"), - "only class drops: {js}" - ); -} - -#[test] -fn compile_void_and_boolean_attributes() { - let out = compile( - "

text1
text2

\n", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js - .contains("`

text1
text2

`"), - "void self-close / boolean attribute wrong: {}", - out.js - ); -} - -#[test] -fn compile_drops_event_handler_attribute() { - // An `on*` single-expression handler is omitted from SSR output. - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("``") && !out.js.contains("onclick"), - "event handler not dropped: {}", - out.js - ); -} - -#[test] -fn compile_event_handler_new_forces_wrapper() { - // A `new` inside a dropped handler still triggers the component wrapper - // (needs_context walks the handler even though its markup is dropped). - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$$renderer.component("), - "needs_context wrapper missing: {}", - out.js - ); -} - -#[test] -fn compile_event_handler_decision_uses_raw_name() { - // The oracle's `is_event_attribute` tests the RAW authored name - // (case-sensitive `startsWith('on')`); lowercasing happens at emission - // only. So `onClick` drops but `ONCLICK` emits `$.attr('onclick', …)`. - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$.attr('onclick', h)"), - "ONCLICK must emit, not drop: {}", - out.js - ); - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("``") && !out.js.contains("onclick"), - "onClick must drop: {}", - out.js - ); - // Raw `onLoad` on a load-error element is a plain drop (the capture - // exception matches the raw name exactly). - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("``"), - "onLoad on img must plain-drop: {}", - out.js - ); - // A mixed-value `ONCLICK` is not an event (raw test) and emits through - // the normal interpolated-attribute path. - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$.attr('onclick'"), - "mixed ONCLICK must emit: {}", - out.js - ); -} - -#[test] -fn compile_handler_shadow_never_masks_the_outer_fold_wrongly() { - // A handler-local binding (param, destructured/default param, let-decl, - // function-expr param, nested-arrow param) may own the mutation target, - // so the outer binding goes Opaque: reads REFUSE (the script side's - // shadow envelope) rather than fold or escape on a guess. - for source in [ - "

{a}

", - "

{a}

", - "

{a}

", - "

{a}

", - "

{a}

", - ] { - assert_unsupported(source, "binding a is not statically modeled"); - } - // The non-shadow direction still masks: `(x) => a++` reassigns the - // OUTER `a`, so its read escapes instead of folding. - let out = compile( - "

{a}

", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$.escape(a)"), - "outer mutation must escape: {}", - out.js - ); - // Partial shadow: the shadowed name refuses only when read; the - // non-shadowed co-mutated name still masks. - let out = compile( - "

{b}

", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$.escape(b)"), - "co-mutated b must escape: {}", - out.js - ); -} - -#[test] -fn compile_rejects_load_error_event_capture() { - // `onload`/`onerror` on a load-error element needs `this.__e=event` - // capture markup, not a clean drop. - assert_unsupported("", "load-error element"); - assert_unsupported( - "", - "load-error element", - ); -} - -/// Assert `compile` refuses with any `Unsupported` reason (bucket-agnostic — a -/// durable pin that survives a later slice re-bucketing the same shape). -fn assert_refuses(source: &str) { - let err = compile(source, &CompileOptions::default()).unwrap_err(); - assert!( - matches!(&err, CompileError::Unsupported(_)), - "expected Unsupported, got {err:?} for:\n{source}" - ); -} - -#[test] -fn compile_const_bind_target_refuses() { - // The oracle's `constant_binding` rejects a bind whose target IDENTIFIER is - // `const`-declared or an import — keyed on the declaration keyword, so a - // reactive `const c = $state(0)` is refused too. Corpus-invisible (idiomatic - // bindables are `let`), so these tests are the only guard. - assert_refuses(""); - assert_refuses(""); - assert_refuses( - "", - ); - // `bind:this` takes any lvalue with no `$state` gate, so it needs the same - // guard — including for a plain (non-rune) const and an import. - assert_refuses("
"); - assert_refuses("
"); - assert_refuses("
"); - // The SSR-inert special-element path shares the primitive. - assert_refuses(""); - // A MODULE-script const or import is unassignable on exactly the same terms — - // the rule reads the declaration keyword, not which script declared it. Only - // `bind:this` exposes this (the `$state`-gated binds refuse a module binding - // anyway, since it never reaches `state_names`), and it exposes it on all - // three bind paths. - assert_refuses("
"); - assert_refuses( - "
", - ); - assert_refuses( - "\ - ", - ); - assert_refuses(""); -} - -#[test] -fn compile_const_bind_target_through_member_is_allowed() { - // The oracle refuses only REBINDING a const name. Writing THROUGH one mutates - // the object, so a member-chain target rooted at a const is accepted — its - // `validate_no_const_assignment` tests `Identifier` and lets a - // `MemberExpression` fall through. Walking the chain to its root here would - // over-refuse a common shape, so this pins the boundary. - for source in [ - "", - "
", - // A plain `let` target is untouched by the const gate. - "", - "
", - // Same boundary for a MODULE-script const — the member chain writes - // through it, so the oracle accepts and the widened set must not refuse. - "
", - // A top-level class or function is `declaration_kind` 'class'/'function', - // never 'const', so the oracle's test does not fire — in either script. - "
", - "
", - "
", - "
", - ] { - compile(source, &CompileOptions::default()) - .unwrap_or_else(|err| panic!("must still compile: {err:?} for:\n{source}")); - } -} - -#[test] -fn compile_optional_chain_bind_target_refuses() { - // acorn wraps a chain containing an optional link in a `ChainExpression`, and - // the oracle's bind-expression test admits only `Identifier` / - // `MemberExpression` / a `{get, set}` pair — so `bind:this={o?.el}` is - // `bind_invalid_expression`. tsv has no chain wrapper, so `bind_target_root` - // refuses any optional link and the recursion propagates a deeper one up. - assert_refuses("
"); - assert_refuses( - "
", - ); - assert_refuses( - "
", - ); - assert_refuses(""); - assert_refuses( - "", - ); - // The non-optional chain is untouched. - compile( - "
", - &CompileOptions::default(), - ) - .expect("a plain member chain must still compile"); -} - -#[test] -fn compile_template_scoped_const_bind_target_refuses() { - // A `bind:` reaches the SAME validator as an assignment - // (`BindDirective.js:181`), so the template-scoped consts obey the rule there - // too — the oracle raises `constant_binding` on each of these (live-verified: - // "Cannot bind to constant"). This was a TRACKED over-acceptance until the - // template scopes were modeled; it is the bind half of - // `compile_template_scoped_const_assignment_refuses`. - assert_unsupported( - "{#await p then v}
{/await}", - "a constant", - ); - assert_unsupported( - "{#if c}{@const o = {}}
{/if}", - "a constant", - ); - assert_unsupported( - "{#each xs as x, i}
{/each}", - "a constant", - ); -} - -#[test] -fn compile_duplicate_props_rune_refuses() { - // The oracle's `props_duplicate` rejects a second `$props()`. Corpus-invisible - // (no real component writes it), so this test is the only guard. - assert_refuses("{a}{b}"); - assert_refuses(""); - // Both declarators in ONE statement is the same duplicate. - assert_refuses("{a}{b}"); - // A non-destructured second `$props()` duplicates just the same. - assert_refuses("{a}"); - // A single `$props()` still compiles — the guard must not fire on one. - compile( - "{a}", - &CompileOptions::default(), - ) - .expect("a single $props() must still compile"); - // `$props()` and `$props.id()` are tracked separately (the oracle keeps two - // flags), so one of each is NOT a duplicate. - compile( - "{a}{i}", - &CompileOptions::default(), - ) - .expect("$props() alongside $props.id() must still compile"); -} - -#[test] -fn compile_use_directive_on_load_error_element_refuses() { - // `use:` on a load-error element makes the oracle add onload/onerror capture - // attributes (its `events_to_capture` set) — not implemented, so refuse. - // Only `use:` (and a spread) triggers this; the other drop-family kinds drop. - assert_unsupported("", "load-error element"); - assert_unsupported("", "load-error element"); - // `transition:`/`{@attach}` on the same element are a plain drop. - let out = compile("", &CompileOptions::default()).unwrap(); - assert!( - out.js.contains("``"), - "transition on img must plain-drop: {}", - out.js - ); - let out = compile("", &CompileOptions::default()).unwrap(); - assert!( - out.js.contains("``"), - "attach on img must plain-drop: {}", - out.js - ); -} - -#[test] -fn compile_await_in_dropped_directive_expression_refuses() { - // The oracle rejects `await` inside a directive expression - // (`illegal_await_expression` / the async gate); tsv's dropped-expression - // guard refuses the top-level await, the correct analog. - assert_unsupported("
", "top-level await"); - assert_unsupported("
", "top-level await"); -} - -#[test] -fn compile_rune_in_dropped_directive_expression_refuses() { - // A dropped directive expression is still validated: a misplaced rune is an - // oracle analysis-phase error (`state_invalid_placement`), so tsv refuses. - assert_unsupported("
", "rune $state"); - assert_unsupported("
", "rune $derived"); -} - -#[test] -fn compile_select_family_spread_and_bind_refuse() { - // The `` / `"); - assert_refuses(""); -} - -#[test] -fn compile_svelte_element_const_tag_direct_child_refuses() { - // The oracle rejects a `{@const}` as a direct `` child - // (`const_tag_invalid_placement`; a `` is not among its valid - // `{@const}` parents). Without a guard tsv would over-accept: the children - // closure pushes a block-scope overlay (load-bearing for snippet hoisting) that - // `emit_const_tag` reads as "inside a block". Pin the refusal. - assert_refuses("{@const y = 1}{y}"); - // A `{#snippet}` direct child stays valid (proves the guard didn't drop the - // overlay the hoist analysis needs). - compile( - "{#snippet s()}x{/snippet}{@render s()}", - &CompileOptions::default(), - ) - .expect("a {#snippet} child of still compiles"); -} - -#[test] -fn compile_svelte_element_specific_refusals() { - // A `bind:` other than `bind:this` refuses — the dynamic tag has no static - // `` identity, so the oracle rejects `bind:value`/etc. - // (`bind_invalid_target`). - assert_refuses( - "", - ); - // Legacy `on:`/`let:` refuse (the runes-only fence). - assert_refuses(""); - // A scoping `", - &CompileOptions::default(), - ) - .expect("scoped compiles"); - assert!( - out.js.contains(r#" class="svelte-tsvhash""#), - "expected synthesized hash class, got: {}", - out.js - ); - assert!( - out.css - .as_deref() - .is_some_and(|css| css.contains("div.svelte-tsvhash")), - "expected scoped selector, got: {:?}", - out.css - ); - // A `bind:this` omits and the element compiles. - compile( - "", - &CompileOptions::default(), - ) - .expect("bind:this on compiles"); -} - -#[test] -fn compile_slots_reference_injects_sanitize() { - // A `$$slots` reference injects the binding and takes `$$props`. - let out = compile("

{$$slots}

", &CompileOptions::default()).unwrap(); - assert!( - out.js.contains("const $$slots = $.sanitize_slots($$props)") - && out.js.contains("function Input($$renderer, $$props)"), - "sanitize_slots injection missing: {}", - out.js - ); -} - -#[test] -fn compile_rejects_slots_with_comments() { - // Script comments plus the injected first statement would sweep the - // comment windows — refused for now. - assert_unsupported( - "\n

{x}{$$slots}

", - "$$slots reference", - ); -} - -#[test] -fn compile_slots_with_props_rest_renames_destructured_slots() { - // The injected sanitize_slots const owns `$$slots`, so the rest-props - // injection deconflicts by renaming: `$$slots: $$slots_` (a shorthand - // `$$slots` would be a duplicate lexical declaration — invalid JS). - let out = compile( - "

{$$slots}{r}

", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("const $$slots = $.sanitize_slots($$props)") - && out.js.contains("{ $$slots: $$slots_, $$events, ...r }"), - "rest-props $$slots rename wrong: {}", - out.js - ); - // Non-destructured `let props = $props()` deconflicts the same way. - let out = compile( - "

{$$slots}{props}

", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("{ $$slots: $$slots_, $$events, ...props }"), - "non-destructured $$slots rename wrong: {}", - out.js - ); - // Without a `$$slots` reference the injection stays shorthand. - let out = compile( - "

{r}

", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("{ $$slots, $$events, ...r }"), - "shorthand injection regressed: {}", - out.js - ); -} - -#[test] -fn compile_svelte_head_emits_head_call() { - // `` → `$.head('', $$renderer, closure)`. The hash is - // the ported `hash("input.svelte")`. - let out = compile( - "", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js - .contains("$.head('4hbqx4', $$renderer, ($$renderer) =>"), - "head call wrong: {}", - out.js - ); -} - -#[test] -fn compile_svelte_head_title_emits_title_call() { - // `` inside `<svelte:head>` → `$$renderer.title(($$renderer) => …)`, - // hoisted before any sibling head content. - let out = compile( - "<svelte:head><title>Hi", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$$renderer.title(($$renderer) =>") - && out.js.contains("$$renderer.push(`Hi`)"), - "title call wrong: {}", - out.js - ); -} - -#[test] -fn compile_rejects_title_attribute() { - // Any attribute on `` is `title_illegal_attribute` in the oracle; tsv's - // parser accepts it, so the compiler refuses. - assert_unsupported( - "<svelte:head><title foo=\"x\">Hi", - "attribute on ", - ); -} - -#[test] -fn compile_rejects_title_invalid_content() { - // A `<title>` child that is neither text nor `{expression}` is - // `title_invalid_content` in the oracle; tsv's parser accepts it, so refuse. - assert_unsupported( - "<svelte:head><title>{#if x}a{/if}", - "invalid content", - ); -} - -#[test] -fn compile_rejects_client_generation() { - let options = CompileOptions { - generate: Generate::Client, - dev: false, - }; - let err = compile("<p>text</p>", &options).unwrap_err(); - assert!( - matches!(err, CompileError::Unsupported(_)), - "expected Unsupported, got {err:?}" - ); -} - -#[test] -fn compile_surfaces_parse_errors() { - let err = compile("<script>const x = ;</script>", &CompileOptions::default()).unwrap_err(); - assert!( - matches!(err, CompileError::Parse(_)), - "expected Parse, got {err:?}" - ); -} - -#[test] -fn canonicalize_surfaces_parse_errors() { - let err = canonicalize_js("const x = ;").unwrap_err(); - assert!( - matches!(err, CanonicalizeError::Parse(_)), - "expected Parse, got {err:?}" - ); -} - -#[test] -fn validate_output_js_rejects_corrupt_output_loudly() { - // The self-validation seam: hypothetical corrupt generated JS (the - // divergent-shape-slipped-every-guard class, e.g. a nested `export`) - // must surface as CorruptOutput, not as a silently invalid module. - // Note the net's reach: it catches output the parser REJECTS; output - // that parses as TypeScript (a passed-through type annotation) is not - // a parse rejection and is caught at parity-comparison time instead. - for corrupt in [ - // Invalid nesting the transform must never emit. - "export default function Input($$renderer) {\n\texport const a = 1;\n}\n", - // A hard syntax error. - "export default function Input($$renderer) {\n\tconst x = ;\n}\n", - ] { - let err = validate_output_js(corrupt).unwrap_err(); - assert!( - matches!(err, CompileError::CorruptOutput(_)), - "expected CorruptOutput for {corrupt:?}, got {err:?}" - ); - } - // Valid generated-shaped JS passes. - validate_output_js( - "import * as $ from 'svelte/internal/server';\nexport default function Input($$renderer) {\n\t$$renderer.push(`<p>x</p>`);\n}\n", - ) - .expect("valid output must validate"); -} - -/// Assert `compile` produces server output (a durable "compiles" pin — the exact -/// bytes are covered by the compile fixtures, so only presence is asserted here). -fn assert_compiles(source: &str) { - let out = compile(source, &CompileOptions::default()) - .unwrap_or_else(|err| panic!("expected compile, got {err:?} for:\n{source}")); - assert!( - out.js.contains("$$renderer"), - "expected server output for:\n{source}\ngot:\n{}", - out.js - ); -} - -#[test] -fn compile_rejects_conflicting_transition_directives() { - // The oracle's phase-2 placement check (shared/element.js:115-132): a - // `transition:` claims BOTH intro and outro, `in:` only intro, `out:` only - // outro; a channel claimed twice is `transition_duplicate`/`transition_conflict`. - // tsv folds the whole union into one refusal — modifiers are irrelevant. - assert_unsupported("<div transition:fade transition:fly></div>", "transition"); - assert_unsupported("<div in:fly transition:fade></div>", "transition"); - // Reverse order refuses the same. - assert_unsupported("<div transition:fade in:fly></div>", "transition"); - assert_unsupported("<div in:a in:b></div>", "transition"); - assert_unsupported("<div out:a out:b></div>", "transition"); - assert_unsupported("<div transition:fade out:fly></div>", "transition"); - // Three directives claiming intro twice. - assert_unsupported("<div in:a out:b in:c></div>", "transition"); - // A modifier does not change the direction, so it does not rescue the pair. - assert_unsupported( - "<div transition:fade|local transition:fly></div>", - "transition", - ); -} - -#[test] -fn compile_allows_legal_transition_directives() { - // A single transition/in/out, or an in:+out: pair with no `transition:`, claims - // each channel at most once — legal; the directive drops and the element compiles. - assert_compiles("<div transition:fade></div>"); - assert_compiles("<div in:fly></div>"); - assert_compiles("<div out:fade></div>"); - assert_compiles("<div in:fly out:fade></div>"); -} - -#[test] -fn compile_rejects_invalid_animate_placement() { - // `animate:` is legal only on the sole non-trivial child of a keyed `{#each}` - // (shared/element.js:92-114). Every other placement refuses. - // Duplicate `animate:` even in the sanctioned spot still refuses. - assert_unsupported( - "{#each xs as x (x)}<div animate:a animate:b></div>{/each}", - "animate", - ); - // Root (not inside any `{#each}`). - assert_unsupported("<div animate:flip></div>", "animate"); - // Unkeyed each (missing key). - assert_unsupported("{#each xs as x}<div animate:flip></div>{/each}", "animate"); - // A non-trivial sibling. - assert_unsupported( - "{#each xs as x (x)}<div animate:flip></div><span></span>{/each}", - "animate", - ); - // Not an immediate child — wrapped in `{#if}`. - assert_unsupported( - "{#each xs as x (x)}{#if x}<div animate:flip></div>{/if}{/each}", - "animate", - ); - // `{@html}` sibling counts as a non-trivial child. - assert_unsupported( - "{#each xs as x (x)}{@html s}<div animate:flip></div>{/each}", - "animate", - ); -} - -#[test] -fn compile_allows_valid_animate_placement() { - // The sole non-trivial child of a keyed `{#each}` — comment/`{@const}`/ - // whitespace siblings are tolerated, an index-only key counts as keyed, and a - // single `transition:` may coexist. - assert_compiles("{#each xs as x (x)}<div animate:flip></div>{/each}"); - assert_compiles("{#each xs as x, i (i)}<div animate:flip></div>{/each}"); - assert_compiles("{#each xs as x (x)}<div animate:flip transition:fade></div>{/each}"); - assert_compiles("{#each xs as x (x)}<!--c-->\n<div animate:flip></div>{/each}"); - assert_compiles("{#each xs as x (x)}{@const y = 1}<div animate:flip></div>{/each}"); -} - -#[test] -fn compile_store_read_subscribes() { - // A template `$name` read where `name` is a binding is a store - // auto-subscription: `$.store_get(($$store_subs ??= {}), '$name', name)`, plus - // the `var $$store_subs;` header and the `$.unsubscribe_stores` cleanup, - // injected at the component-body level (no wrapper forced on its own). - let out = compile( - "<script>\n\timport { count } from './s';\n</script>\n<p>{$count}</p>", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js - .contains("$.store_get(($$store_subs ??= {}), '$count', count)"), - "store read: {}", - out.js - ); - assert!(out.js.contains("var $$store_subs;"), "subs var: {}", out.js); - assert!( - out.js - .contains("if ($$store_subs) $.unsubscribe_stores($$store_subs);"), - "unsubscribe: {}", - out.js - ); - assert!( - !out.js.contains("$$renderer.component"), - "a bare store read must not force the wrapper: {}", - out.js - ); -} - -#[test] -fn compile_store_script_reads_and_writes() { - // Script-position reads, writes, and updates all compile now: a read → - // `$.store_get`, an assignment → `$.store_set`, an update → `$.update_store`. - let read = compile_js( - "<script>\n\timport { count } from './s';\n\tconst d = $count * 2;\n</script>\n<p>{d}</p>", - ); - assert!( - read.contains("const d = $.store_get(($$store_subs ??= {}), '$count', count) * 2"), - "script read: {read}" - ); - let write = compile_js( - "<script>\n\timport { count } from './s';\n\tfunction f() { $count = 5; }\n</script>\n<button onclick={f}>{$count}</button>", - ); - assert!( - write.contains("$.store_set(count, 5)"), - "store write: {write}" - ); - let update = compile_js( - "<script>\n\timport { count } from './s';\n\tfunction f() { $count++; }\n</script>\n<button onclick={f}>{$count}</button>", - ); - assert!( - update.contains("$.update_store(($$store_subs ??= {}), '$count', count)"), - "store update: {update}" - ); - // A store read in CALLEE / new position (`$fn()`, `new $C()`) is rewritten too - // (it forces the needs_context wrapper — a call rooted at the import). - let callee = compile_js( - "<script>\n\timport { fn } from './s';\n\tfunction f() { return $fn(); }\n</script>\n{f()}", - ); - assert!( - callee.contains("$.store_get(($$store_subs ??= {}), '$fn', fn)()"), - "callee store read: {callee}" - ); - let new_call = compile_js( - "<script>\n\timport { C } from './s';\n\tfunction f() { return new $C(); }\n</script>\n{f()}", - ); - assert!( - new_call.contains("new ($.store_get(($$store_subs ??= {}), '$C', C))()"), - "new store read: {new_call}" - ); -} - -#[test] -fn compile_store_write_refuses_member_and_destructuring() { - // A member write (`$obj.foo = 5` → `$.store_mutate`) and a destructuring write - // (`[$count] = arr` → an IIFE) are out of scope for this slice — refuse rather - // than emit the un-ported lowering. - assert_unsupported( - "<script>\n\timport { obj } from './s';\n\tfunction f() { $obj.foo = 5; }\n</script>\n<button onclick={f}>x</button>", - "store member write", - ); - assert_unsupported( - "<script>\n\timport { count } from './s';\n\tfunction f(arr) { [$count] = arr; }\n</script>\n<button onclick={f}>x</button>", - "store destructuring write", - ); - // A member UPDATE (`$obj.foo++`) refuses the same way. - assert_unsupported( - "<script>\n\timport { obj } from './s';\n\tfunction f() { $obj.foo++; }\n</script>\n<button onclick={f}>x</button>", - "store member write", - ); -} - -#[test] -fn compile_store_refuses_scoped_subscription() { - // `$count` where the base `count` is bound in a nested scope is the oracle's - // `store_invalid_scoped_subscription` error. Refuse (name-based shadow check). - assert_unsupported( - "<script>\n\timport { writable } from 'svelte/store';\n\tlet count = writable(0);\n\tfunction f(count) { return $count; }\n</script>\n<p>{f}</p>", - "not a top-level component binding", - ); - // A base that is not a component binding at all (`$missing`) stays refused as a - // bare `$`-prefixed identifier (the oracle's `global_reference_invalid`). - assert_unsupported( - "<script>\n\tfunction f(count) { return $count; }\n</script>\n<p>{f}</p>", - "$count", - ); - // A store read in CALLEE position with a shadowed LOCAL base refuses via the - // same `StoreScopedSubscription` path as a bare read (the callee-exemption in - // the guard mirrors the bare-read shadow handling). A local base keeps - // `needs_context` out of the way — a `$fn()` rooted at an IMPORT instead - // refuses earlier as `MemberCallAmbiguousRoot` (also a refusal, no - // over-acceptance). - assert_unsupported( - "<script>\n\timport { writable } from 'svelte/store';\n\tlet fn = writable(0);\n\tfunction f(fn) { return $fn(); }\n</script>\n<p>{f}</p>", - "not a top-level component binding", - ); - // The coordinator's literal example — a callee whose base is a bare param - // (not a top-level binding) — refuses as a rune call ($fn is not a store base). - assert_unsupported( - "<script>\n\tfunction f(fn) { return $fn(); }\n</script>\n<p>{f}</p>", - "rune $fn", - ); - // A genuine rune call in callee position stays refused (never exempted). - assert_unsupported( - "<script>\n\tlet x = $state(0);\n\tfunction f() { return $state(1); }\n</script>\n<p>{f}</p>", - "rune", - ); -} - -#[test] -fn compile_dollar_rune_is_not_a_store_read() { - // A rune callee (`$props()`) is NOT a store read even when its base name - // coincides with a binding (`const props = $props()`): stripping `$props` to - // `props` and treating it as a store on the props object would spuriously force - // the `$$renderer.component` wrapper. Regression guard for `store_read_base`. - let out = compile( - "<script>\n\tconst props = $props();\n</script>\n<p>text</p>", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - !out.js.contains("$$store_subs"), - "$props() must not mint store subscriptions: {}", - out.js - ); -} - -#[test] -fn compile_store_read_in_snippet_stays_nested() { - // A top-level `{#snippet}` whose only hoist-blocking reference is a store read - // must NOT hoist to module scope — its body reads the component-local - // `$$store_subs`. (Regression: the free-var collector recorded `$count`, which - // is not a binding name, so the store read failed to block hoisting.) - let out = compile( - "<script>\n\timport { count } from './s';\n</script>\n{#snippet foo()}{$count}{/snippet}{@render foo()}", - &CompileOptions::default(), - ) - .unwrap(); - // The snippet function nests inside the component (after `var $$store_subs;`), - // never as a module-scope sibling of the import. - let subs = out.js.find("var $$store_subs;").expect("subs var"); - let foo = out.js.find("function foo").expect("snippet fn"); - assert!( - foo > subs, - "snippet must stay nested (after $$store_subs), got:\n{}", - out.js - ); -} - -#[test] -fn compile_derived_store_reads_call() { - // A store whose base is a `$derived` binding reads `d()` as the store value — - // a `$derived` read is a call at every position. - let out = compile( - "<script>\n\tlet d = $derived(0);\n</script>\n<p>{$d}</p>", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js - .contains("$.store_get(($$store_subs ??= {}), '$d', d())"), - "derived-base store must read d(): {}", - out.js - ); -} - -#[test] -fn compile_shadowed_store_base_refuses() { - // A store base shadowed by a block-local (`{#each}`/`{#await}`/snippet param) is - // not a top-level store — the oracle errors `store_invalid_scoped_subscription`, - // so tsv refuses rather than subscribe to the block-local. - assert_unsupported( - "<script>\n\timport { count } from './s';\n</script>\n{#each xs as count}{$count}{/each}", - "$count", - ); - assert_unsupported( - "<script>\n\timport { count } from './s';\n</script>\n{#snippet foo(count)}{$count}{/snippet}{@render foo(1)}", - "$count", - ); -} - -// ── Module scripts (`<script module>`) ───────────────────────────────────── - -#[test] -fn compile_module_refuses_default_export() { - // `export default` in a module is the oracle's `module_illegal_default_export` - // error — refuse rather than emit. - assert_unsupported( - "<script module>\n\texport default 5;\n</script>\n<p>hi</p>", - "default export in <script module>", - ); -} - -#[test] -fn compile_module_refuses_state_rune() { - // v1 defers the oracle's module `$state`→v rewrite (the corpus is module-rune- - // free), so a module-scope rune refuses via the guard — a safe over-refusal. - assert_unsupported( - "<script module>\n\tlet count = $state(0);\n</script>\n<p>hi</p>", - "rune $state", - ); -} - -#[test] -fn compile_module_refuses_store_read() { - // A module-scope `$name` store read is the oracle's `store_invalid_subscription` - // error — the guard refuses it (no store exemption in a module). - assert_unsupported( - "<script module>\n\timport { writable } from 'svelte/store';\n\tconst c = writable(0);\n\tconst v = $c;\n</script>\n<p>hi</p>", - "$-prefixed identifier $c", - ); -} - -#[test] -fn compile_module_refuses_top_level_await() { - // Top-level `await` forces the oracle's async-component shapes (not implemented), - // so a module top-level await refuses — a safe over-refusal (the oracle compiles it). - assert_unsupported( - "<script module>\n\tconst x = await fetch('/');\n</script>\n<p>hi</p>", - "top-level await", - ); -} - -#[test] -fn compile_module_body_follows_hoisted_snippet() { - // Emission order (probe-verified): the module block prints AFTER the hoisted - // snippets, NOT merged into the instance import group — imports, hoisted - // snippet, module body, then the component function. - let js = compile_js( - "<script module>\n\tconst SHARED = 5;\n</script>\n{#snippet foo()}<p>{SHARED}</p>{/snippet}\n{@render foo()}", - ); - assert_eq!( - js, - "import * as $ from 'svelte/internal/server';\n\ - function foo($$renderer) {\n\ - \t$$renderer.push(`<p>5</p>`);\n\ - }\n\ - const SHARED = 5;\n\ - export default function Input($$renderer) {\n\ - \tfoo($$renderer);\n\ - }\n" - ); -} - -#[test] -fn compile_module_sets_document_ts_flag() { - // A `lang="ts"` module sets the document-wide TypeScript flag, so the instance - // script's TypeScript erases even though it carries no `lang` of its own. - let js = compile_js( - "<script module lang=\"ts\">\n\tconst K: number = 5;\n</script>\n<script>\n\tlet a: number = 1;\n</script>\n<p>{a}{K}</p>", - ); - assert!( - !js.contains(": number"), - "instance TypeScript must erase under the module's lang=\"ts\": {js}" - ); -} - -#[test] -fn compile_module_refuses_name_collision_with_instance() { - // A name declared in BOTH scripts: the oracle resolves `{K}` to the instance - // (inner-scope) binding (`$.escape(K)`), but the name-based table would fold - // the module `const K = 5` — a real MISMATCH, so refuse. - assert_unsupported( - "<script module>\n\tconst K = 5;\n</script>\n<script>\n\tlet { K } = $props();\n</script>\n<p>{K}</p>", - "declared in both the module and instance scripts", - ); -} - -#[test] -fn compile_module_before_instance_comment_carries() { - // A whitespace-only text run between the module `</script>` and the instance - // `<script>` must NOT trip the template-before-script comment guard — the - // instance comment carries through (parity with the oracle). - let js = compile_js( - "<script module>\n\tconst K = 5;\n</script>\n\n<script>\n\t// instance comment\n\tlet a = 1;\n</script>\n<p>{a}{K}</p>", - ); - assert!( - js.contains("// instance comment"), - "the instance comment must carry through past the module script: {js}" - ); -} - -#[test] -fn compile_refuses_module_comment_after_instance_script() { - // A module script placed AFTER the instance script puts its comments at - // offsets the oracle's printer re-seeks BACKWARD over (the component body - // block carries the instance script's `loc`), so esrap re-attaches them into - // whatever loc-bearing node it reaches next — a template expression it has - // nothing to do with. tsv drops the comment, which is a comment PRESENCE - // difference the parity bar grades as a MISMATCH. Refuse. - for source in [ - // The minimal shape: instance script, module script, template expression. - "<script>function w(x){return x;}</script><script module>\n// MYC\nconst K = 5;\n</script>{w(1)}", - // A block comment lands the same way. - "<script>function w(x){return x;}</script><script module>\n/* MYC */\nconst K = 5;\n</script>{w(1)}", - // The comment past the module body's last statement lands the same way. - "<script>function w(x){return x;}</script><script module>\nconst K = 5;\n// MYC\n</script>{w(1)}", - // An import-only instance script still supplies the `loc` that seeks back. - "<script>import {a} from './a.js';</script><script module>\n// MYC\nconst K = 5;\n</script>{a}", - ] { - assert_unsupported(source, "module script placed after the instance script"); - } -} - -#[test] -fn compile_module_comment_before_instance_script_still_drops() { - // The mirror of the refusal above: with the module script FIRST, the body - // block's seek moves FORWARD past the module comment, so the oracle drops it - // too — tsv's drop is parity and must keep compiling. - let js = compile_js( - "<script module>\n// MYC\nconst K = 5;\n</script><script>function w(x){return x;}</script>{w(1)}", - ); - assert!( - !js.contains("MYC"), - "a module comment before the instance script must drop: {js}" - ); -} - -// ── <svelte:boundary> ────────────────────────────────────────────────────── - -#[test] -fn compile_boundary_invalid_attributes_refuse() { - // The oracle's phase-2 `SvelteBoundary` visitor accepts a CLOSED list of three - // names, each carrying exactly one `{expression}`. tsv's parser accepts all of - // the shapes below, so without the guard each would be an over-acceptance of - // input the oracle rejects — the refusal contract's hard bar. - for source in [ - // `svelte_boundary_invalid_attribute`: an unknown plain attribute, a - // spread, and every directive kind (a modern event attribute included). - "<svelte:boundary foo={1}><p>a</p></svelte:boundary>", - "<svelte:boundary {...x}><p>a</p></svelte:boundary>", - "<svelte:boundary onclick={f}><p>a</p></svelte:boundary>", - "<svelte:boundary on:click={f}><p>a</p></svelte:boundary>", - "<svelte:boundary class:x={y}><p>a</p></svelte:boundary>", - "<svelte:boundary bind:this={el}><p>a</p></svelte:boundary>", - "<svelte:boundary use:action><p>a</p></svelte:boundary>", - // `svelte_boundary_invalid_attribute_value`: a valid NAME whose value is - // not exactly one expression tag. - "<svelte:boundary onerror><p>a</p></svelte:boundary>", - "<svelte:boundary onerror=\"x\"><p>a</p></svelte:boundary>", - "<svelte:boundary onerror=\"a{b}c\"><p>a</p></svelte:boundary>", - ] { - assert_refuses(source); - } -} - -#[test] -fn compile_boundary_snippet_attribute_forms_refuse() { - // The `failed={expr}` / `pending={expr}` ATTRIBUTE forms compile in the oracle - // but are a deliberate v1 gap (asymmetric precedence against a same-named - // snippet, plus the statically-nullish `pending` if/else fork). - assert_unsupported( - "<script>const f = () => {};</script><svelte:boundary failed={f}><p>a</p></svelte:boundary>", - "attribute form", - ); - assert_unsupported( - "<script>const g = () => {};</script><svelte:boundary pending={g}><p>a</p></svelte:boundary>", - "attribute form", - ); -} - -#[test] -fn compile_boundary_onerror_is_dropped_but_guarded() { - // A valid `onerror={handler}` never reaches SSR output, but the oracle still - // analyzes it — so it is guard-walked like an event-handler attribute. - let out = compile( - "<svelte:boundary onerror={(e) => e}><p>a</p></svelte:boundary>", - &CompileOptions::default(), - ) - .unwrap(); - assert!(!out.js.contains("onerror"), "onerror must drop: {}", out.js); - assert_refuses("<svelte:boundary onerror={() => $state(1)}><p>a</p></svelte:boundary>"); -} - -#[test] -fn compile_boundary_anchors_are_isolated_pushes() { - // Unlike `{#key}`'s `<!---->` marker, a boundary's anchors do NOT merge into an - // adjacent sibling's template — the oracle's `build_template` starts a fresh - // push for each. A merge would be invisible to the fixtures if no fixture put a - // sibling beside a boundary, so pin it here too. - let out = compile( - "<b>a</b><svelte:boundary><p>hi</p></svelte:boundary><i>z</i>", - &CompileOptions::default(), - ) - .unwrap(); - assert!( - out.js.contains("$$renderer.push(`<b>a</b>`)") - && out.js.contains("$$renderer.push(`<!--[-->`)"), - "anchors must not merge with siblings: {}", - out.js - ); -} - -#[test] -fn compile_refuses_rune_name_bound_as_store() { - // The oracle's `analyze_component` reclassifies `$state` as a STORE - // subscription on the imported `state` binding — `$.store_get(($$store_subs - // ??= {}), '$state', state)()` — because the binding's initializer is not a - // rune call. tsv would compile it as the rune (`const x = void 0`), so refuse. - assert_unsupported( - "<script>\n\timport { state } from './store';\n\tconst x = $state();\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); - // No store import is needed — ANY instance binding of the stem collides. - assert_unsupported( - "<script>\n\tlet state = 5;\n\tconst x = $state(1);\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); - // Every rune keyword, not just `$state`. - assert_unsupported( - "<script>\n\timport { derived } from './store';\n\tlet d = $derived(1);\n</script>\n<p>{d}</p>", - "rune $derived whose base is also an instance binding", - ); - assert_unsupported( - "<script>\n\timport { props } from './store';\n\tlet { a } = $props();\n</script>\n<p>{a}</p>", - "rune $props whose base is also an instance binding", - ); - // A function or class declaration binds the stem just as an import does. - assert_unsupported( - "<script>\n\tfunction state() {}\n\tconst x = $state(1);\n</script>\n<p>{x}{state}</p>", - "rune $state whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_rune_name_bound_in_module_scope() { - // `instance.scope.get(stem)` walks UP (`phases/scope.js:748`) and the instance - // scope's parent IS the module scope (`2-analyze/index.js:337`), so a MODULE - // binding reclassifies an INSTANCE `$state` too. Oracle (verified): - // `const x = $.store_get($$store_subs ??= {}, '$state', state)(1)`. - assert_unsupported( - "<script module>\n\timport { state } from './store.js';\n</script>\n<script>\n\tconst x = $state(1);\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); - // The module's own exempting initializer counts too — a module binding whose - // init is a rune call is not reclassified. (A module `$state` refuses on its - // own path, so the reachable exempt shape is a module-scope declaration - // consumed only by the instance script.) - assert_unsupported( - "<script module>\n\tlet state = 5;\n</script>\n<script>\n\tconst x = $state(1);\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_rune_name_bound_by_hoisted_var() { - // `var` is FUNCTION-scoped, so a `var state` in any block or for-head of the - // instance script lands in `instance.scope` exactly like a top-level one. - // Oracle (verified): both reclassify to `$.store_get(…)`. - assert_unsupported( - "<script>\n\tif (true) {\n\t\tvar state = 5;\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}{state}</p>", - "rune $state whose base is also an instance binding", - ); - assert_unsupported( - "<script>\n\tfor (var props of []) {\n\t}\n\tlet { a } = $props();\n</script>\n<p>{a}{props}</p>", - "rune $props whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_rune_name_bound_in_class_static_block() { - // ⚠️ A class STATIC BLOCK is NOT a scope in the oracle: `phases/scope.js` has - // no `StaticBlock` visitor (and none for `ClassBody`/`MethodDefinition`), so a - // `var` there declares directly in the ENCLOSING script scope. ECMAScript - // disagrees — a static block is its own VariableEnvironment — but the oracle - // is the parity target. Oracle (verified, all three): reclassified to - // `$.store_get(($$store_subs ??= {}), '$state', state)(1)` / `'$props'`. - assert_unsupported( - "<script>\n\tclass C {\n\t\tstatic {\n\t\t\tvar state = 5;\n\t\t\tconsole.log(state);\n\t\t}\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); - // The same class as an EXPRESSION in a declarator initializer. - assert_unsupported( - "<script>\n\tconst C = class {\n\t\tstatic {\n\t\t\tvar state = 5;\n\t\t\tconsole.log(state);\n\t\t}\n\t};\n\tconst x = $state(1);\n</script>\n<p>{x}{C}</p>", - "rune $state whose base is also an instance binding", - ); - // A class expression nested deeper than a declarator init — here an - // assignment RHS. Oracle (verified): reclassifies. The fence reaches it - // because it never asks WHERE the class sits. - assert_unsupported( - "<script>\n\tlet y;\n\ty = class {\n\t\tstatic {\n\t\t\tvar state = 5;\n\t\t\tconsole.log(state);\n\t\t}\n\t};\n\tconst x = $state(1);\n</script>\n<p>{x}{y}</p>", - "rune $state whose base is also an instance binding", - ); - // The `$props` stem, to show the rule is not `$state`-specific. - assert_unsupported( - "<script>\n\tclass C {\n\t\tstatic {\n\t\t\tvar props = 5;\n\t\t\tconsole.log(props);\n\t\t}\n\t}\n\tlet { a } = $props();\n</script>\n<p>{a}</p>", - "rune $props whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_rune_name_bound_by_porous_hoisted_var_with_rune_init() { - // ⚠️ A `var` hoisting through a POROUS scope arrives with NO initializer: - // `phases/scope.js:673-681` re-declares it on the parent via the 3-argument - // call, leaving `initial` at its `null` default. So `get_rune(binding.initial)` - // is null and the rune EXEMPTION does not apply, even though the declarator - // was written with a rune init. Oracle (verified): emits - // `var state = $.store_get(($$store_subs ??= {}), '$state', state)(0)` — it - // reclassifies. tsv also refuses this via its rune guard, but that is an - // ACCIDENTAL save on an unrelated path; this pins the modelled one. - assert_unsupported( - "<script>\n\t{\n\t\tvar state = $state(0);\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}{state}</p>", - "rune $state whose base is also an instance binding", - ); - assert_unsupported( - "<script>\n\t{\n\t\tvar props = $props.id();\n\t}\n\tlet { a } = $props();\n</script>\n<p>{a}{props}</p>", - "rune $props whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_every_class_expression_position_holding_a_static_block() { - // The fence's whole point: a static block declares at script scope from ANY - // position a class can occupy, and enumerating those positions is what shipped - // holes twice. Each shape below was a live MISMATCH under the enumerated walk - // (tsv compiled it as the rune; the oracle emitted `$.store_get`), and none - // needs its own arm now — the scan never asks where the class sits. - // - // A representative per structural family, since the fix is categorical: a - // for-head (the `init`/`test`/`update`/`right` positions all behaved alike), a - // class-DECLARATION member position (a property initializer is NOT a function - // scope — `phases/scope.js` has no `PropertyDefinition` visitor — and neither - // is a computed key or a `super_class`), and a function PARAMETER DEFAULT (the - // oracle gives a function's parameters a POROUS scope, `scope.js:1143/1155/1163`). - for source in [ - // for-head init expression - "<script>\n\tvar y;\n\tfor (y = class { static { var state = 5 } }; false; ) {}\n\tlet count = $state(0);\n</script>\n<p>{count}</p>", - // for-of right - "<script>\n\tvar z;\n\tfor (z of [class { static { var state = 5 } }]) {}\n\tlet count = $state(0);\n</script>\n<p>{count}</p>", - // class-declaration super_class - "<script>\n\tclass C extends (class { static { var state = 5 } }) {}\n\tlet count = $state(0);\n</script>\n<p>{count}</p>", - // class-declaration property initializer - "<script>\n\tclass C { p = class { static { var state = 5 } }; }\n\tlet count = $state(0);\n</script>\n<p>{count}</p>", - // class-declaration computed member key - "<script>\n\tclass C { [(class { static { var state = 5 } }, 'k')]() {} }\n\tlet count = $state(0);\n</script>\n<p>{count}</p>", - // function parameter default - "<script>\n\tfunction f(a = class { static { var state = 5 } }) {}\n\tlet count = $state(0);\n</script>\n<p>{count}</p>", - ] { - assert_unsupported(source, "rune $state whose base is also an instance binding"); - } -} - -#[test] -fn compile_refuses_a_static_block_that_declares_nothing() { - // The deliberate over-refusal the fence buys, pinned so it is a choice rather - // than a surprise: this static block binds no rune stem at all and the oracle - // compiles it fine, but the fence cannot tell without traversing the positions - // it exists to avoid traversing. Measured cost: zero — no `.svelte` file in the - // ~4900-file compile corpus contains a static block. - assert_unsupported( - "<script>\n\tclass C {\n\t\tstatic {\n\t\t\tconsole.log('hi');\n\t\t}\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}{C}</p>", - "rune $state whose base is also an instance binding", - ); - // ⚠️ It also swallows the oracle's rune EXEMPTION inside a static block — a - // `var state = $state(0)` there keeps its initializer (no scope, so no - // re-declare) and the oracle does NOT reclassify. tsv refused this already, on - // its rune-guard path (a `$state` call inside a class body); the fence now - // refuses it first. Either way it is a refusal, never a wrong compile, and the - // exemption arm for this shape was unreachable even before the fence. - assert_unsupported( - "<script>\n\tclass C {\n\t\tstatic {\n\t\t\tvar state = $state(0);\n\t\t\tconsole.log(state);\n\t\t}\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}{C}</p>", - "rune $state whose base is also an instance binding", - ); -} - -#[test] -fn compile_allows_ordinary_class_using_code_with_no_static_block() { - // The other side of the fence, and the one that protects parity: ordinary - // class-using code — including `static` MEMBERS, which are not static blocks — - // must not trip it. Only `static` followed by `{` does. - assert_compiles( - "<script>\n\tclass C {\n\t\tstatic label = 'c';\n\t\tstatic make() {\n\t\t\treturn new C();\n\t\t}\n\t}\n\tlet count = $state(0);\n</script>\n<p>{count}{C.label}</p>", - ); - // A class EXPRESSION in the shapes the enumerated walk used to descend into, - // with no static block anywhere: still compiles. - assert_compiles( - "<script>\n\tconst K = class {\n\t\tvalue = 1;\n\t};\n\tlet count = $state(0);\n</script>\n<p>{count}{K}</p>", - ); -} - -#[test] -fn compile_refuses_static_block_separated_by_zwnbsp() { - // The static-block fence's whitespace class must be ECMAScript's, not Rust's. - // U+FEFF (<ZWNBSP>) is ECMAScript `WhiteSpace` but carries no Unicode - // `White_Space` property, so `char::is_whitespace` says NO and a - // `static\u{feff}{ … }` block was INVISIBLE to the scan — the fence never - // fired, `script_declarations_of` stopped at the class body, and tsv compiled - // the rune where the oracle emits a store read. Oracle (verified live, both - // script kinds): `$.store_get(($$store_subs ??= {}), '$state', state)(0)`. - // - // The reverse code point needs no handling: U+0085 (<NEL>) is Unicode - // whitespace but NOT ECMAScript whitespace, so treating it as a boundary only - // ever OVER-reports — an extra refusal, on source the JS lexer rejects anyway. - assert_unsupported( - "<script>\n\tclass C { static\u{feff}{ var state = 5 } }\n\tlet x = $state(0);\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); - // The module script is scanned by the same fence — the collision pre-pass - // tests `instance.scope.get`, which walks up into module scope. - assert_unsupported( - "<script module>\n\tclass C { static\u{feff}{ var state = 5 } }\n</script>\n<script>\n\tlet x = $state(0);\n</script>\n<p>{x}</p>", - "rune $state whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_global_pseudo_class_with_a_non_ascii_ident_char() { - // A CSS name must NOT be trimmed with a Unicode-whitespace notion: every code - // point at or above U+0080 is a CSS *ident* code point, so `:global\u{a0}` is - // the pseudo-class `global\u{a0}`, not `:global`. Rust's `str::trim` stripped - // the NBSP, tsv read `:global`, and scoped the element while the oracle pruned - // the rule as unused — an oracle-verified MISMATCH, now a safe over-refusal - // (the selector matches no element). - assert_unsupported( - "<div class=\"x\">a</div>\n<style>\n\tdiv :global\u{a0}.x { color: red }\n</style>", - "matches no element", - ); -} - -#[test] -fn compile_refuses_rune_name_bound_by_escaped_identifier() { - // An ESCAPED binding identifier binds the decoded name. `plain_identifier_name` - // reports `None` for one, so the walk resolves through the interner instead — - // the reference here is plain, so refusing the escaped *reference* elsewhere - // does not cover this. Oracle (verified): `$.store_get(…, '$state', state)(1)`. - assert_unsupported( - "<script>\n\timport { state as \\u0073tate } from './store.js';\n\tconst x = $state(1);\n</script>\n<p>{x}{state}</p>", - "rune $state whose base is also an instance binding", - ); -} - -#[test] -fn compile_refuses_rune_reference_separated_by_unicode_whitespace() { - // NBSP (U+00A0) is ECMAScript whitespace, so `$state\u{a0}(1)` is a genuine - // `$state` reference. A byte-level boundary test that counts every byte - // `>= 0x80` as identifier text reads NBSP's `0xC2` lead byte as a continuation - // and MISSES it — an under-refusal. Oracle (verified): reclassifies. - assert_unsupported( - "<script>\n\timport { state } from './store.js';\n\tconst x = $state\u{a0}(1);\n</script>\n<p>{x}{state}</p>", - "rune $state whose base is also an instance binding", - ); -} - -#[test] -fn compile_allows_rune_stem_bound_only_in_a_child_scope() { - // ⚠️ The lookup walks UP, never DOWN. A function parameter, a block-scoped - // `let`, and a `var` inside a nested FUNCTION all live in CHILD scopes - // `instance.scope.get` never reaches, so none collide — refusing any of these - // would be a spurious over-refusal on ordinary code. All three verified at - // parity against the oracle. - assert_compiles( - "<script>\n\tfunction f(state) {\n\t\treturn state;\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}{f(2)}</p>", - ); - assert_compiles( - "<script>\n\tif (true) {\n\t\tlet state = 5;\n\t\tconsole.log(state);\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}</p>", - ); - assert_compiles( - "<script>\n\tfunction f() {\n\t\tvar state = 5;\n\t\treturn state;\n\t}\n\tconst x = $state(1);\n</script>\n<p>{x}{f()}</p>", - ); -} - -#[test] -fn compile_allows_rune_binding_initialized_by_its_own_rune() { - // ⚠️ THE EXEMPTION. `get_rune(init) !== null` is the oracle's carve-out and - // covers the overwhelmingly common real-world shapes — refusing them would - // crater corpus parity, so they are pinned here. - assert_compiles("<script>\n\tlet state = $state(0);\n</script>\n<p>{state}</p>"); - assert_compiles("<script>\n\tlet derived = $derived(1);\n</script>\n<p>{derived}</p>"); - assert_compiles("<script>\n\tlet props = $props();\n</script>\n<p>{props.a}</p>"); - assert_compiles("<script>\n\tlet state = $state.raw(0);\n</script>\n<p>{state}</p>"); - // A binding of a rune stem with NO `$stem` reference anywhere is untouched: - // the oracle's loop only ever sees names that are actually referenced. - assert_compiles("<script>\n\timport { state } from './store';\n</script>\n<p>{state}</p>"); - // `$derived` beside `import { derived } from 'svelte/store'` is the oracle's - // explicit exception ("one is not a subscription to the other"). - assert_compiles( - "<script>\n\timport { derived } from 'svelte/store';\n\tlet d = $derived(1);\n</script>\n<p>{d}{derived}</p>", - ); -} - -#[test] -fn compile_non_ascii_identifier_beside_erased_typescript() { - // ⚠️ REGRESSION GUARD (a PANIC, not a mismatch). The erased-region comment - // window scans bytes with `text_class::js_char_at`; a scan that advanced by - // ONE BYTE over a non-whitespace character landed on a continuation byte of - // a multi-byte identifier and panicked (`byte index N is not a char boundary; - // it is inside 'é'`) on ordinary, legal TypeScript. Both halves of the window - // are exercised: a `<T>` parameter list (the BACKWARD half — `prev_token_end` - // scans over the name to find the token before the detached region) and a - // `: T` annotation (the FORWARD half). - let js = compile_js( - "<script lang=\"ts\">\n\tfunction caf\u{e9}<T>(x: T): T {\n\t\treturn x;\n\t}\n\tlet v = caf\u{e9}(1);\n</script>\n<p>{v}</p>", - ); - assert!(js.contains("caf\u{e9}"), "identifier must survive:\n{js}"); - assert!(!js.contains(": T"), "type annotation must erase:\n{js}"); - - // The annotation shape: a multi-byte PARAMETER name immediately preceding the - // erased `: number`. - let js = compile_js( - "<script lang=\"ts\">\n\tfunction f(caf\u{e9}: number): number {\n\t\treturn caf\u{e9};\n\t}\n\tlet v = f(1);\n</script>\n<p>{v}</p>", - ); - assert!(js.contains("caf\u{e9}"), "identifier must survive:\n{js}"); - assert!(!js.contains(": number"), "annotation must erase:\n{js}"); - - // A multi-byte name before an erased `implements` clause and a return type — - // the two other detached regions the backward half exists for. - let js = compile_js( - "<script lang=\"ts\">\n\tinterface I {}\n\tclass \u{4e2d}\u{6587} implements I {\n\t\tm(): void {}\n\t}\n\tlet v = new \u{4e2d}\u{6587}();\n</script>\n<p>{v}</p>", - ); - assert!( - js.contains("\u{4e2d}\u{6587}"), - "identifier must survive:\n{js}" - ); - assert!(!js.contains("implements"), "implements must erase:\n{js}"); -} - -#[test] -fn compile_attr_whitespace_collapse_trims_the_js_class_not_the_narrow_one() { - // The oracle is `chunk.data.replace(/[ \t\n\r\f]+/g, ' ').trim()` - // (`server/visitors/shared/utils.js:208` + `phases/patterns.js:11`): a NARROW - // ASCII collapse, then JavaScript's WIDE `String.prototype.trim`. Fusing them - // into one narrow-class pass left every boundary character that is JS - // whitespace but not `[ \t\n\r\f]` in place — 7 oracle-verified MISMATCHes. - // All four affected sites, with two distinct exotic characters each. - - // Site 1 — an unscoped plain `class` attribute. - let js = compile_js("<div class=\"\u{a0}x\u{a0}\">t</div>"); - assert!(js.contains("class=\"x\""), "U+00A0 must trim:\n{js}"); - let js = compile_js("<div class=\"\u{feff}x\u{feff}\">t</div>"); - assert!(js.contains("class=\"x\""), "U+FEFF must trim:\n{js}"); - - // Site 2 — a plain `style` attribute. - let js = compile_js("<div style=\"\u{a0}color: red;\u{a0}\">t</div>"); - assert!( - js.contains("style=\"color: red;\""), - "U+00A0 must trim:\n{js}" - ); - let js = compile_js("<div style=\"\u{3000}color: red;\u{3000}\">t</div>"); - assert!( - js.contains("style=\"color: red;\""), - "U+3000 must trim:\n{js}" - ); - - // Site 3 — a SCOPED `class` attribute (the hash concat runs on the trimmed - // value, so a stray boundary character would sit between value and hash). - let js = compile_js( - "<div class=\"\u{a0}x\u{a0}\">t</div>\n<style>\n\t.x {\n\t\tcolor: red;\n\t}\n</style>", - ); - assert!( - js.contains("class=\"x svelte-tsvhash\""), - "scoped class must trim before the hash concat:\n{js}" - ); - - // Site 4 — the `class:` directive BASE (`$.attr_class(base, …)`). - let js = compile_js("<div class:on={true} class=\"\u{a0}x\u{a0}\">t</div>"); - assert!( - js.contains("$.attr_class('x'"), - "directive base must trim:\n{js}" - ); - // U+000B is ASCII yet outside the narrow collapse class — the same bug. - let js = compile_js("<div class:on={true} class=\"\u{b}x\u{b}\">t</div>"); - assert!(js.contains("$.attr_class('x'"), "U+000B must trim:\n{js}"); - - // The spread-object path and a `style:` directive value share the function. - let js = compile_js( - "<script>\n\tlet o = {};\n</script>\n\n<div {...o} class=\"\u{a0}x\u{a0}\">t</div>", - ); - assert!(js.contains("class: 'x'"), "spread object must trim:\n{js}"); - let js = compile_js("<div style:color=\"\u{a0}red\u{a0}\">t</div>"); - assert!( - js.contains("color: 'red'"), - "style directive value must trim:\n{js}" - ); - - // ⚠️ THE DISCRIMINATOR. `U+0085` (<NEL>) and `U+180E` carry Unicode's - // `White_Space` property but are NOT ECMAScript whitespace, so JS `.trim()` - // KEEPS them — and so must tsv. This is what a `str::trim` would get wrong in - // the opposite direction; both are oracle-verified at parity. - let js = compile_js("<div class=\"\u{85}y\u{85}\">t</div>"); - assert!( - js.contains("class=\"\u{85}y\u{85}\""), - "U+0085 must NOT trim (not ECMAScript whitespace):\n{js}" - ); - let js = compile_js("<div class=\"\u{180e}y\u{180e}\">t</div>"); - assert!( - js.contains("class=\"\u{180e}y\u{180e}\""), - "U+180E must NOT trim (not ECMAScript whitespace):\n{js}" - ); - - // Interior narrow-class runs still collapse to one space, and an interior - // ` ` still survives (why the oracle's collapse is not `\s+`). - let js = compile_js("<div class=\"a \t b\">t</div>"); - assert!( - js.contains("class=\"a b\""), - "interior run must collapse:\n{js}" - ); - let js = compile_js("<div class=\"a\u{a0}b\">t</div>"); - assert!( - js.contains("class=\"a\u{a0}b\""), - "interior U+00A0 must survive the narrow collapse:\n{js}" - ); -} - -// ── Invalid assignment targets (the oracle's `validate_assignment` family) ── - -#[test] -fn compile_constant_assignment_refuses() { - // `validate_no_const_assignment` (`shared/utils.js:84`): a write to a `const` - // declarator or an import local is `constant_assignment`, keyed on the - // DECLARATION KEYWORD — so a reactive `const a = $state(0)` is refused too. - // Both operator forms reach it (`AssignmentExpression.js:11`, - // `UpdateExpression.js:11`). - assert_unsupported("<script>const a = c(); a += 1;</script>", "a constant"); - assert_unsupported("<script>const a = c(); a++;</script>", "a constant"); - assert_unsupported( - "<script>const a = $state(0); a += 1;</script>", - "a constant", - ); - assert_unsupported("<script>const a = $state(0); a++;</script>", "a constant"); - assert_unsupported( - "<script>import {a} from './m.js'; a = 1;</script>", - "a constant", - ); - // A module-script const is unassignable on the same terms. - assert_unsupported( - "<script module>const a = 1;</script><script>a = 2;</script>", - "a constant", - ); - // Inside a dropped event handler too — the oracle validates in phase 2, - // before it decides what SSR emits. - assert_unsupported( - "<script>const a = $state(0);</script><button onclick={() => a++}>x</button>", - "a constant", - ); - // Destructuring assignment: the rule recurses through ArrayPattern elements - // and ObjectPattern property VALUES (`validator/samples/assignment-to-const-5` - // and `-7`). - assert_unsupported( - "<script>const arr = [1, 2]; [arr, arr[1]] = [arr[1], arr[0]];</script>", - "a constant", - ); - assert_unsupported( - "<script>const arr = [1]; ({a: {arr}} = x);</script>", - "a constant", - ); -} - -#[test] -fn compile_shadowing_js_local_assignment_compiles() { - // The oracle resolves an assignment target through its SCOPE CHAIN - // (`phases/scope.js`), so a nested re-declaration shadows a component-level - // `const`/import and the write is to the local — not `constant_assignment`. - // The shape is ordinary enough to appear in Svelte's own suite - // (`runtime-runes/samples/mutation-local`). - // - // A function parameter shadowing a component `const`. - assert_compiles("<script>const x = 1; function f(x) { x = 2; return x; }</script>"); - // A function-body `let`, written from a nested block — the suite's shape. - assert_compiles( - "<script>function f(i) { let x = i; if (x > 0) { x = 2; } return x; }\ - const x = f(1);</script>", - ); - // An arrow parameter shadowing an IMPORT local. - assert_compiles("<script>import {a} from './m.js'; const g = (a) => { a = 1; };</script>"); - // A plain nested block `let`. - assert_compiles("<script>const b = 1; function f() { { let b = 0; b = 2; } }</script>"); - // A `catch` parameter, and a `for` head binding. - assert_compiles( - "<script>const e = 1; function f() { try { g(); } catch (e) { e = null; } }</script>", - ); - assert_compiles( - "<script>const i = 1; function f() { for (let i = 0; i < 2; i++) {} }</script>", - ); -} - -#[test] -fn compile_shadow_scope_does_not_leak() { - // ⚠️ The UNSAFE direction: a shadow that outlives its scope would stop - // refusing a genuine `constant_assignment`. Each of these writes resolves to - // the component-level binding, so each must still refuse. - // - // A block-scoped `let` must not leak to the statements after its block. - assert_unsupported( - "<script>const a = 1; function f() { { let a = 0; } a = 2; }</script>", - "a constant", - ); - // A function's parameters must not leak past the function. - assert_unsupported( - "<script>const a = 1; function f(a) { return a; } a = 2;</script>", - "a constant", - ); - // Nor must a sibling function's locals reach another function. - assert_unsupported( - "<script>const a = 1; function f() { let a = 0; } function g() { a = 2; }</script>", - "a constant", - ); - // A `for` head binding must not leak past the loop. - assert_unsupported( - "<script>const a = 1; function f() { for (let a = 0; a < 2; a++) {} a = 3; }</script>", - "a constant", - ); - // A `catch` parameter must not leak past its handler. - assert_unsupported( - "<script>const a = 1; function f() { try { g(); } catch (a) {} a = 2; }</script>", - "a constant", - ); - // And a TEMPLATE-scoped binding must keep its own rule: the `{#each}` item and - // the `{#snippet}` parameter are declared through the same walk, so neither may - // be absorbed into the JS-local set (which suppresses refusal). - assert_unsupported( - "<script>let arr = $state([1]);</script>\ - {#each arr as value}<button onclick={() => value = 1}>x</button>{/each}", - "an {#each} item", - ); - assert_unsupported( - "{#snippet s(p)}<button onclick={() => p = 1}>x</button>{/snippet}{@render s(1)}", - "a {#snippet} parameter", - ); -} - -#[test] -fn compile_each_item_assignment_refuses() { - // `validate_assignment` (`shared/utils.js:33`): a write to an `{#each}` - // context binding is `each_item_invalid_assignment` in runes mode, which this - // compiler unconditionally is. Reached from an assignment, an update, and a - // `bind:` alike (`BindDirective.js:181`). - assert_unsupported( - "<script>let arr = $state([1]);</script>\ - {#each arr as value}<button onclick={() => value += 1}>x</button>{/each}", - "an {#each} item", - ); - assert_unsupported( - "<script>let arr = $state([1]);</script>\ - {#each arr as value}<button onclick={() => value++}>x</button>{/each}", - "an {#each} item", - ); - assert_unsupported( - "<script>let arr = $state([{element: null}]);</script>\ - {#each arr as {element}}<input bind:this={element} />{/each}", - "an {#each} item", - ); - // The fallback shares the block's scope (`scope.js:1280`). - assert_unsupported( - "<script>let arr = $state([1]);</script>\ - {#each arr as value}<p>a</p>{:else}<button onclick={() => value = 1}>x</button>{/each}", - "an {#each} item", - ); -} - -#[test] -fn compile_template_scoped_const_assignment_refuses() { - // The TEMPLATE-scoped consts: a `{@const}` name (`phases/scope.js:1205`, whose - // `declaration_kind` is the `VariableDeclaration`'s own `const`), a - // `{:then}`/`{:catch}` value (`:1310`/`:1324`) and an `{#each}` INDEX - // (`:1273`). All three are `declaration_kind: 'const'` to the oracle, so a - // write to one is `constant_assignment` — live-verified, the oracle rejects - // every case below with `Cannot assign to constant`. - // - // ⚠️ These are OVER-ACCEPTANCES when unrecorded, not over-refusals: the names - // are purely template-local, so nothing falls through to a component-level set - // and no rule fires at all. - // - // ⚠️ Two of the three write POSITIONS below are load-bearing. An assignment - // sitting directly in an emitted template expression (`{(c = 2)}`) also trips - // `mutation inside a template expression`, an unrelated general rule that fires - // whatever the target is (verified: a plain `let` write there refuses too, - // while the oracle ACCEPTS it) — so that position MASKS this rule and has - // already been mistaken for a refutation. The event-handler arrow and the - // dropped `{:catch}` are the unmasked positions, and each form is covered in - // both. - // Position 1: an event-handler arrow — dropped from SSR, still validated in - // phase 2. - assert_unsupported( - "{#if true}{@const c = 1}<button onclick={() => (c = 2)}>x</button>{/if}", - "a constant", - ); - assert_unsupported( - "<script>let p = Promise.resolve(1);</script>\ - {#await p then v}<button onclick={() => (v = 2)}>x</button>{/await}", - "a constant", - ); - assert_unsupported( - "<script>let xs = [1];</script>\ - {#each xs as x, i}<button onclick={() => (i = 2)}>{x}</button>{/each}", - "a constant", - ); - // The same three forms written inside a dropped `{:catch}`, the second - // unmasked position: the branch never reaches SSR emission, so no emission - // refusal can fire there and the write is graded by this rule alone. - assert_unsupported( - "<script>let p = Promise.resolve(1);</script>\ - {#await p}<i>w</i>{:catch e}{@const c = 1}{(c = 2)}{/await}", - "a constant", - ); - assert_unsupported( - "<script>let p = Promise.resolve(1);</script>\ - {#await p}<i>w</i>{:catch e}{(e = 2)}{/await}", - "a constant", - ); - assert_unsupported( - "<script>let p = Promise.resolve(1); let xs = [1];</script>\ - {#await p}<i>w</i>{:catch e}{#each xs as x, i}{(i = 2)}{x}{/each}{/await}", - "a constant", - ); - // The oracle's scope PRE-PASS again (see - // `compile_nested_const_before_its_declaration_refuses`): a `{@const}` is - // declared into its fragment's scope before any node of that fragment is - // visited, so a write textually EARLIER still resolves to it. Live-verified. - assert_unsupported( - "{#if true}<button onclick={() => (c = 2)}>x</button>{@const c = 1}{/if}", - "a constant", - ); - // A destructured `{@const}` binds each name the pattern declares. - assert_unsupported( - "<script>let o = {a: 1};</script>\ - {#if true}{@const {a} = o}<button onclick={() => (a = 2)}>x</button>{/if}", - "a constant", - ); -} - -#[test] -fn compile_each_index_is_not_an_each_item() { - // ⭐ The two bindings of ONE construct take DIFFERENT oracle rules, and - // conflating them is a bug in either direction. In `{#each xs as x, i}` the - // ITEM is declared `('each', 'const')` (`phases/scope.js:1244`) and - // `validate_no_const_assignment` EXCLUDES `kind === 'each'`, so it carries - // `each_item_invalid_assignment`; the INDEX is `('template' | 'static', - // 'const')` (`:1273`) and carries `constant_assignment`. Both live-verified - // against the oracle, which reports "Cannot reassign or bind to each block - // argument" for the first and "Cannot assign to constant" for the second. - assert_unsupported( - "<script>let xs = [1];</script>\ - {#each xs as x, i}<button onclick={() => (x = 2)}>{i}</button>{/each}", - "an {#each} item", - ); - assert_unsupported( - "<script>let xs = [1];</script>\ - {#each xs as x, i}<button onclick={() => (i = 2)}>{x}</button>{/each}", - "a constant", - ); -} - -#[test] -fn compile_template_scoped_const_boundary_accepts() { - // The acceptance half, so the new template-const scope cannot widen into an - // over-refusal. Each of these the oracle COMPILES (live-verified). - // - // A JS binding nests INSIDE the template scope, so a handler parameter of the - // same name shadows the `{@const}` and the write is to the parameter — which - // is why `js_scope` is consulted before the template-const set. - assert_compiles("{#if true}{@const c = 1}<button onclick={(c) => (c = 2)}>x</button>{/if}"); - // A MEMBER target writes THROUGH the binding, matching no branch of the - // oracle's validator. - assert_compiles( - "<script>let o = {v: 1};</script>\ - {#if true}{@const c = o}<button onclick={() => (c.v = 2)}>x</button>{/if}", - ); - // The scopes END at their block: an `{#each}` index is out of scope after the - // block, a `{:then}` value is not in scope in the `{:catch}` branch, and a - // `{@const}` is confined to its own fragment. - assert_compiles( - "<script>let xs = [1];</script>\ - {#each xs as x, idx}{x}{/each}<button onclick={() => (idx = 2)}>x</button>", - ); - assert_compiles( - "<script>let p = Promise.resolve(1); let v = $state(0);</script>\ - {#await p then v}{v}{:catch e}<button onclick={() => (v = 2)}>x</button>{/await}", - ); - assert_compiles( - "<script>let c = $state(0);</script>\ - {#if true}{@const c = 1}{c}{/if}<button onclick={() => (c = 2)}>x</button>", - ); -} - -#[test] -fn compile_snippet_parameter_assignment_refuses() { - // `validate_assignment` (`shared/utils.js:37`): a write to a `{#snippet}` - // parameter is `snippet_parameter_assignment` — and, unlike the each rule, - // NOT gated on runes mode. - assert_unsupported( - "{#snippet foo(value)}<button onclick={() => value += 1}>x</button>{/snippet}", - "a {#snippet} parameter", - ); -} - -#[test] -fn compile_invalid_assignment_boundary_accepts() { - // The acceptance half of the rule, so a widening over-refusal is caught. - // - // A MEMBER target writes THROUGH the binding and never rebinds it — it - // matches no branch of the oracle's validator. - assert_compiles( - "<script>const o = $state({v: 1});</script><button onclick={() => o.v = 2}>x</button>", - ); - assert_compiles( - "<script>let arr = $state([{v: 1}]);</script>\ - {#each arr as item}<button onclick={() => item.v = 2}>x</button>{/each}", - ); - // A `let` is not a constant, and the block scopes END at their block. - assert_compiles("<script>let a = $state(0);</script><button onclick={() => a++}>x</button>"); - assert_compiles( - "<script>let a = $state(0); let arr = $state([1]);</script>\ - {#each arr as a}<p>{a}</p>{/each}<button onclick={() => a++}>x</button>", - ); - assert_compiles( - "{#snippet foo(v)}<p>{v}</p>{/snippet}<script>let v = $state(0);</script>\ - <button onclick={() => v++}>x</button>", - ); - // The pattern recursion stops exactly where the oracle's does: a RestElement - // and an AssignmentPattern match no branch, so a const there is accepted. - assert_compiles( - "<script>const a = 1;</script><button onclick={() => { [...a] = x; }}>x</button>", - ); -} - -#[test] -fn compile_nested_const_assignment_refuses() { - // `validate_no_const_assignment` reads the SCOPE CHAIN, so its `const` test is - // not confined to script scope: a NESTED `const` — a block-local, a function - // body local, a `for (const … of …)` head — is `declaration_kind: 'const'` to - // the oracle exactly as a top-level one is, and a write to it is - // `constant_assignment`. Live-verified against the oracle: each of these is - // rejected with `Cannot assign to constant`. - // - // A nested `const` colliding with a component-level `const`/import must refuse - // as the INNER binding's rule, not fall through to the outer name's. - assert_unsupported( - "<script>const a = 1; function f() { { const a = 0; a = 2; } }</script>", - "a constant", - ); - assert_unsupported( - "<script>const a = 1; function f() { for (const a of xs) { a = 2; } }</script>", - "a constant", - ); - assert_unsupported( - "<script>const a = 1; function f() { const a = 0; a = 2; }</script>", - "a constant", - ); - assert_unsupported( - "<script>import {a} from './m.js'; function f() { const a = 0; a = 2; }</script>", - "a constant", - ); - // And with NO collision at all — the nested `const` is the only binding of the - // name, so nothing but its own scope entry can refuse it. - assert_unsupported( - "<script>const b = 1; function f() { const a = 0; a = 2; }</script>", - "a constant", - ); - // The template reaches the same walk. - assert_unsupported( - "<script>const b = 1;</script>\ - <button onclick={() => { const a = 0; a = 2; }}>x</button>", - "a constant", - ); -} - -#[test] -fn compile_innermost_js_binding_wins() { - // ⚠️ The discriminating control on the nested-`const` rule: a `let` nested - // INSIDE a `const` of the same name is the innermost binding, so the write is - // to the `let` and nothing refuses. Live-verified: the oracle COMPILES this. - // A rule that merely asked "is this name const anywhere open?" would refuse it. - assert_compiles( - "<script>const b = 1; function f() { const a = 1; { let a = 2; a = 3; } }</script>", - ); - // The mirror ordering (a `const` inside a `let`) still refuses — same shape, - // opposite innermost kind, so the two together pin that the ORDER is what is - // read, not the mere presence of a `const`. - assert_unsupported( - "<script>const b = 1; function f() { let a = 1; { const a = 2; a = 3; } }</script>", - "a constant", - ); - // A nested `const`'s scope must not outlive it: after the block closes the - // write resolves to the enclosing `let` again and compiles. - assert_compiles( - "<script>const b = 1; function f() { let a = 1; { const a = 2; } a = 3; }</script>", - ); -} - -#[test] -fn compile_nested_const_before_its_declaration_refuses() { - // The oracle builds its scopes in a PRE-PASS (`phases/scope.js`'s - // `create_scopes`), so every binding of a block exists before any reference in - // it is validated. A write textually EARLIER than the `const` therefore still - // resolves to it and is `constant_assignment` — a TDZ error at runtime, but a - // compile error to Svelte either way. Live-verified: the oracle rejects this - // with `Cannot assign to constant`. - // - // ⚠️ It is an OVER-ACCEPTANCE, not an over-refusal, when the walk misses it: - // the name is purely LOCAL, so leaving it unrecorded yields no rule at all - // rather than falling through to a component-level one. - assert_unsupported( - "<script>function g() { z = 1; const z = 2; return z; }</script>", - "a constant", - ); - // With a component-level collision the fall-through WOULD have caught it — - // the control that shows the local-only case above is the load-bearing one. - assert_unsupported( - "<script>const z = 0; function g() { z = 1; const z = 2; return z; }</script>", - "a constant", - ); - // The hoist is `const`-only and block-scoped, so it must not reach past its - // block: after the block closes the write resolves to the enclosing `let`. - assert_compiles( - "<script>const b = 1; function f() { let a = 1; a = 3; { const a = 2; } }</script>", - ); -} - -#[test] -fn compile_switch_case_const_shares_one_scope() { - // `phases/scope.js:1173` — `SwitchStatement: create_block_scope`. The oracle - // gives a `switch` ONE block scope shared by every case, so a `const` in one - // case is in scope for a write in another and refuses. Live-verified: the - // oracle rejects both orderings with `Cannot assign to constant`. - // - // ⚠️ Also an OVER-ACCEPTANCE when missed — `w` is purely local. - assert_unsupported( - "<script>function f(v) { switch (v) { case 1: const w = 1; break; case 2: w = 2; } }</script>", - "a constant", - ); - // The reverse ordering (write first) needs the hoist as well as the shared - // scope, so it pins both halves of the fix at once. - assert_unsupported( - "<script>function f(v) { switch (v) { case 1: w = 2; break; case 2: const w = 1; } }</script>", - "a constant", - ); - // The switch scope must not outlive the statement. - assert_compiles( - "<script>let b = 1; function f(v) { switch (v) { case 1: const w = 1; } w = 2; }</script>", - ); -} - -#[test] -fn compile_each_item_assignment_refuses_the_checklist_repro() { - // The exact repro `docs/checklist_svelte_compiler.md` carried as an open - // over-acceptance until this rule landed — pinned so the doc's claim that the - // row is closed cannot silently rot. - assert_unsupported( - "<script>let b = 1;</script>{#each [0] as b}<button onclick={() => { b++; }}>x</button>{/each}", - "an {#each} item", - ); -} - -#[test] -fn compile_duplicate_attribute_refuses() { - // The oracle's parse-time `attribute_duplicate` (`1-parse/state/element.js:250`) - // — a rule tsv did not enforce, so these all compiled. Each of the three - // participating attribute kinds keys separately. - assert_unsupported("<div id=\"a\" id=\"b\"></div>", "duplicate"); - assert_unsupported("<div class:a class:a></div>", "duplicate"); - assert_unsupported( - "<div style:color=\"a\" style:color=\"b\"></div>", - "duplicate", - ); - // A `bind:` normalizes onto `Attribute`, so it collides with the plain name. - assert_unsupported("<input value=\"1\" bind:value={v} />", "duplicate"); - // It fires in an SSR-DROPPED region too: the oracle raises it at PARSE, long - // before it decides what to emit. - assert_unsupported( - "{#await p}<i>x</i>{:catch e}<div id=\"a\" id=\"b\"></div>{/await}", - "duplicate", - ); -} - -#[test] -fn compile_duplicate_attribute_accepts_the_oracle_s_exemptions() { - // ⚠️ The must-NOT-over-refuse half. Each of these is legal to the oracle, and - // an over-eager port of the rule breaks one of them. - // Different KINDS of the same name are different keys. - let _ = compile_js("<div class:a style:a=\"1\" a=\"2\"></div>"); - // Only Attribute/Bind/Style/Class participate — `use:` may legally repeat. - let _ = compile_js("<script>let x, y;</script><div use:x use:y></div>"); - // `this` is never recorded, which is what makes this shape legal. - let _ = compile_js( - "<script>let el;</script><svelte:element this=\"div\" bind:this={el}></svelte:element>", - ); - // The rule is per-element, not per-document. - let _ = compile_js("<div class:a></div><div class:a></div>"); -} - -#[test] -fn compile_root_only_meta_tag_placement_refuses() { - // `svelte_meta_invalid_placement` / `svelte_meta_duplicate` for `<svelte:head>` - // — tsv already enforced both for the SSR-inert tags, but not for head. - assert_unsupported( - "<div><svelte:head><title>x", - "top-level", - ); - // Any container counts, including a `` — the placement test is - // "direct child of Root", so a boundary between makes it invalid. - assert_unsupported( - "x", - "top-level", - ); - assert_unsupported( - "ab", - "duplicate", - ); - // One at the root is of course still fine. - let _ = compile_js("a"); -} - -#[test] -fn compile_invalid_node_placement_refuses() { - // The oracle's `node_invalid_placement` — markup a browser would REPAIR, which - // breaks Svelte's assumptions about component structure. Every case below was - // confirmed oracle-REJECTED with `canonical_compile`. - // ⚠️ The DIRECT `

` case is unreachable: both parsers apply HTML - // tag-omission and auto-close the `

`, so no parent/child relation forms - // (tsv then parse-errors on the stray `

`). That is why every oracle sample - // puts an element in between and trips the ANCESTOR rule instead. - assert_unsupported("

x

", "descendant"); - assert_unsupported( - "", - "descendant", - ); - assert_unsupported("
", "must be the child of a"); - // The fallback switch: a tag legal only under a special-parsing parent. - assert_unsupported("
x
", "cannot be a child of"); - // `#text` and `{expression}` reach the parent test through their own visitors. - assert_unsupported( - "text
", - "only allows these children", - ); - assert_unsupported( - "{v}
", - "only allows these children", - ); - // It fires in an SSR-DROPPED region too — the oracle validates before it - // decides what to emit. - assert_unsupported( - "{#await p}x{:catch e}

y

{/await}", - "descendant", - ); -} - -#[test] -fn compile_invalid_node_placement_respects_the_oracle_s_escape_hatches() { - // ⚠️ The must-NOT-over-refuse half. Each case is oracle-ACCEPTED (verified with - // `canonical_compile`), and a plausible over-eager port breaks one of them. - - // A block downgrades the violation to a WARNING (`node_invalid_placement_ssr`): - // Svelte compiles each block into its own template string, so it works - // client-side. A refusal here would reject ordinary real components. The - // control is the identical shape MINUS the block, asserted refused above — - // so this pair isolates the downgrade rather than merely finding a legal - // document. - let _ = compile_js("

{#if true}

y
{/if}

"); - - // A custom element may contain anything and go anywhere. - let _ = compile_js("
x
"); - let _ = compile_js("

x

"); - - // `