feat: v2 codegen hardening — scoped local variables

Replace flat HashSet<String> local_vars with Vec<HashSet<String>> scope
stack. For-in loops push/pop scopes for loop variables. Nested loops
no longer clobber outer loop variable visibility.

Methods: push_scope(), pop_scope(), is_local_var()
110 tests, 0 failures.
This commit is contained in:
enzotar 2026-02-25 20:39:41 -08:00
parent 6368b798cf
commit 55ec9353ae

View file

@ -8,8 +8,8 @@ pub struct JsEmitter {
output: String, output: String,
indent: usize, indent: usize,
node_id_counter: usize, node_id_counter: usize,
/// Non-signal local variables (e.g., for-in loop vars) /// Scoped local variables (e.g., for-in loop vars). Stack of scopes for nesting.
local_vars: HashSet<String>, local_var_scopes: Vec<HashSet<String>>,
} }
impl JsEmitter { impl JsEmitter {
@ -18,10 +18,26 @@ impl JsEmitter {
output: String::new(), output: String::new(),
indent: 0, indent: 0,
node_id_counter: 0, node_id_counter: 0,
local_vars: HashSet::new(), local_var_scopes: Vec::new(),
} }
} }
/// Push a new variable scope (e.g., entering a for-in loop)
fn push_scope(&mut self, vars: &[&str]) {
let set: HashSet<String> = vars.iter().map(|s| s.to_string()).collect();
self.local_var_scopes.push(set);
}
/// Pop the top variable scope (e.g., leaving a for-in loop)
fn pop_scope(&mut self) {
self.local_var_scopes.pop();
}
/// Check if a name is a local variable in any active scope
fn is_local_var(&self, name: &str) -> bool {
self.local_var_scopes.iter().any(|scope| scope.contains(name))
}
/// Generate a complete HTML page with embedded runtime and compiled app. /// Generate a complete HTML page with embedded runtime and compiled app.
pub fn emit_html(program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String { pub fn emit_html(program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String {
let mut emitter = Self::new(); let mut emitter = Self::new();
@ -384,7 +400,7 @@ impl JsEmitter {
} }
} }
Expr::Ident(name) => { Expr::Ident(name) => {
if self.local_vars.contains(name) { if self.is_local_var(name) {
// Non-reactive local variable (e.g., for-in loop var) // Non-reactive local variable (e.g., for-in loop var)
self.emit_line(&format!( self.emit_line(&format!(
"{}.textContent = {};", "{}.textContent = {};",
@ -533,20 +549,18 @@ impl JsEmitter {
self.emit_line(&format!("__list.forEach(({item}, {idx_var}) => {{")); self.emit_line(&format!("__list.forEach(({item}, {idx_var}) => {{"));
self.indent += 1; self.indent += 1;
// Mark loop vars as non-reactive so text bindings use plain access // Push scope for loop variables
self.local_vars.insert(item.clone()); let mut scope_vars: Vec<&str> = vec![item.as_str()];
if let Some(idx) = index { if let Some(idx) = index {
self.local_vars.insert(idx.clone()); scope_vars.push(idx.as_str());
} }
self.push_scope(&scope_vars);
let child_var = self.emit_view_expr(body, graph); let child_var = self.emit_view_expr(body, graph);
self.emit_line(&format!("{}.appendChild({});", container_var, child_var)); self.emit_line(&format!("{}.appendChild({});", container_var, child_var));
// Clean up // Pop scope
self.local_vars.remove(item); self.pop_scope();
if let Some(idx) = index {
self.local_vars.remove(idx);
}
self.indent -= 1; self.indent -= 1;
self.emit_line("});"); self.emit_line("});");
@ -647,12 +661,11 @@ impl JsEmitter {
} }
format!("`{}`", parts.join("")) format!("`{}`", parts.join(""))
} }
Expr::Ident(name) if self.is_local_var(name) => {
format!("{name}")
}
Expr::Ident(name) => { Expr::Ident(name) => {
if self.local_vars.contains(name) { format!("{name}.value")
format!("{name}")
} else {
format!("{name}.value")
}
} }
Expr::DotAccess(base, field) => { Expr::DotAccess(base, field) => {
let base_js = self.emit_expr(base); let base_js = self.emit_expr(base);