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,
indent: usize,
node_id_counter: usize,
/// Non-signal local variables (e.g., for-in loop vars)
local_vars: HashSet<String>,
/// Scoped local variables (e.g., for-in loop vars). Stack of scopes for nesting.
local_var_scopes: Vec<HashSet<String>>,
}
impl JsEmitter {
@ -18,10 +18,26 @@ impl JsEmitter {
output: String::new(),
indent: 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.
pub fn emit_html(program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String {
let mut emitter = Self::new();
@ -384,7 +400,7 @@ impl JsEmitter {
}
}
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)
self.emit_line(&format!(
"{}.textContent = {};",
@ -533,20 +549,18 @@ impl JsEmitter {
self.emit_line(&format!("__list.forEach(({item}, {idx_var}) => {{"));
self.indent += 1;
// Mark loop vars as non-reactive so text bindings use plain access
self.local_vars.insert(item.clone());
// Push scope for loop variables
let mut scope_vars: Vec<&str> = vec![item.as_str()];
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);
self.emit_line(&format!("{}.appendChild({});", container_var, child_var));
// Clean up
self.local_vars.remove(item);
if let Some(idx) = index {
self.local_vars.remove(idx);
}
// Pop scope
self.pop_scope();
self.indent -= 1;
self.emit_line("});");
@ -647,12 +661,11 @@ impl JsEmitter {
}
format!("`{}`", parts.join(""))
}
Expr::Ident(name) if self.is_local_var(name) => {
format!("{name}")
}
Expr::Ident(name) => {
if self.local_vars.contains(name) {
format!("{name}")
} else {
format!("{name}.value")
}
format!("{name}.value")
}
Expr::DotAccess(base, field) => {
let base_js = self.emit_expr(base);