git.delta.rocks / jrsonnet / refs/commits / cf33b6edf3e1

difftreelog

feat(analyze) explicit captures/locals

xmwyyqzkYaroslav Bolyukin2026-05-05parent: #704dc29.patch.diff
in: master
Inspired by GHC closures

30 files changed

modifiedCargo.lockdiffbeforeafterboth
852852
853[[package]]853[[package]]
854name = "jrsonnet-gcmodule"854name = "jrsonnet-gcmodule"
855version = "0.4.4"855version = "0.4.5"
856source = "registry+https://github.com/rust-lang/crates.io-index"856source = "registry+https://github.com/rust-lang/crates.io-index"
857checksum = "21dd97b40cbfb2043094219f95d96519858ba1aee4e8260eb048a1774832a517"857checksum = "95f9ce64915cdb0cab5367940a7cc024394fcf4f2608531e49f6dad39e2082d7"
858dependencies = [858dependencies = [
859 "jrsonnet-gcmodule-derive",859 "jrsonnet-gcmodule-derive",
860]860]
861861
862[[package]]862[[package]]
863name = "jrsonnet-gcmodule-derive"863name = "jrsonnet-gcmodule-derive"
864version = "0.4.4"864version = "0.4.5"
865source = "registry+https://github.com/rust-lang/crates.io-index"865source = "registry+https://github.com/rust-lang/crates.io-index"
866checksum = "ede3d0445c2a7d7adab0a3cc33bdb33df78ffebebc21a2848c221526cb1795d4"866checksum = "64364cfb68be0968a940d69ccb651ec445cde47830da5b294d55d2e47eee8708"
867dependencies = [867dependencies = [
868 "proc-macro2",868 "proc-macro2",
869 "quote",869 "quote",
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@
 
 [workspace.package]
 authors = ["Yaroslav Bolyukin <iam@lach.pw>"]
-edition = "2021"
+edition = "2024"
 license = "MIT"
 repository = "https://github.com/CertainLach/jrsonnet"
 version = "0.5.0-pre98"
@@ -22,7 +22,7 @@
 jrsonnet-cli = { path = "./crates/jrsonnet-cli", version = "0.5.0-pre98" }
 jrsonnet-types = { path = "./crates/jrsonnet-types", version = "0.5.0-pre98" }
 jrsonnet-formatter = { path = "./crates/jrsonnet-formatter", version = "0.5.0-pre98" }
-jrsonnet-gcmodule = { version = "0.4.4" }
+jrsonnet-gcmodule = { version = "0.4.5" }
 # Diagnostics.
 # hi-doc is my library, which handles text formatting very well, but isn't polished enough yet
 # Previous implementation was based on annotate-snippets, which I don't like for many reasons.
modifiedcrates/jrsonnet-evaluator/src/analyze.rsdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/analyze.rs
+++ b/crates/jrsonnet-evaluator/src/analyze.rs
@@ -14,10 +14,9 @@
 //! }
 //! ```
 
-use std::{fmt::Write, rc::Rc};
+use std::rc::Rc;
 
 use drop_bomb::DropBomb;
-use hi_doc::{Formatting, SnippetBuilder, Text};
 use jrsonnet_gcmodule::Acyclic;
 use jrsonnet_interner::IStr;
 use jrsonnet_ir::{
@@ -78,6 +77,20 @@
 	}
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Acyclic)]
+pub enum LSlot {
+	/// Enclosing frame locals (sibling letrec, params, etc.).
+	Local(LocalSlot),
+	/// Enclosing closure's capture pack.
+	Capture(CaptureSlot),
+}
+
+#[derive(Debug, Acyclic)]
+pub struct ClosureShape {
+	pub captures: Box<[LSlot]>,
+	pub n_locals: u16,
+}
+
 struct LocalDefinition {
 	name: IStr,
 	span: Option<Span>,
@@ -121,12 +134,15 @@
 
 #[derive(Debug, Acyclic)]
 pub enum LExpr {
-	Local(LocalId),
+	Slot(LSlot),
 	Null,
 	Bool(bool),
 	Str(IStr),
 	Num(NumValue),
-	Arr(Rc<Vec<LExpr>>),
+	Arr {
+		shape: ClosureShape,
+		items: Rc<Vec<LExpr>>,
+	},
 	ArrComp(Box<LArrComp>),
 	Obj(LObjBody),
 	ObjExtend(Box<LExpr>, LObjBody),
@@ -141,10 +157,7 @@
 		rest: Box<LExpr>,
 	},
 	Error(Span, Box<LExpr>),
-	LocalExpr {
-		binds: Vec<LBind>,
-		body: Box<LExpr>,
-	},
+	LocalExpr(Box<LLocalExpr>),
 	Import {
 		kind: Spanned<ImportKind>,
 		kind_span: Span,
@@ -160,6 +173,7 @@
 		parts: Vec<LIndexPart>,
 	},
 	Function(Rc<LFunction>),
+	IdentityFunction,
 	IfElse {
 		cond: Box<LExpr>,
 		cond_then: Box<LExpr>,
@@ -174,10 +188,19 @@
 }
 
 #[derive(Debug, Acyclic)]
+pub struct LLocalExpr {
+	pub frame_shape: ClosureShape,
+	pub binds: Vec<LBind>,
+	pub body: LExpr,
+}
+
+#[derive(Debug, Acyclic)]
 pub struct LFunction {
 	pub name: Option<IStr>,
 	pub params: Vec<LParam>,
 	pub signature: FunctionSignature,
+
+	pub body_shape: ClosureShape,
 	pub body: Rc<LExpr>,
 }
 
@@ -185,18 +208,25 @@
 pub struct LParam {
 	pub name: Option<IStr>,
 	pub destruct: LDestruct,
-	pub default: Option<Rc<LExpr>>,
+
+	pub default: Option<(ClosureShape, Rc<LExpr>)>,
 }
 
 #[derive(Debug, Acyclic)]
 pub struct LBind {
 	pub destruct: LDestruct,
+	pub value_shape: ClosureShape,
 	pub value: Rc<LExpr>,
 }
 
-#[derive(Debug, Clone, Acyclic)]
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Acyclic)]
+pub struct CaptureSlot(pub(crate) u16);
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Acyclic)]
+pub struct LocalSlot(pub(crate) u16);
+
+#[derive(Debug, Acyclic)]
 pub enum LDestruct {
-	Full(LocalId),
+	Full(LocalSlot),
 	#[cfg(feature = "exp-destruct")]
 	Skip,
 	#[cfg(feature = "exp-destruct")]
@@ -214,54 +244,54 @@
 
 #[derive(Debug, Clone, Copy, Acyclic)]
 pub enum LDestructRest {
-	Keep(LocalId),
+	Keep(LocalSlot),
 	Drop,
 }
 
-#[derive(Debug, Clone, Acyclic)]
+#[derive(Debug, Acyclic)]
 pub struct LDestructField {
 	pub name: IStr,
 	pub into: Option<LDestruct>,
-	pub default: Option<Rc<LExpr>>,
+	pub default: Option<(ClosureShape, Rc<LExpr>)>,
 }
 
 impl LDestruct {
-	pub fn each_id<F: FnMut(LocalId)>(&self, f: &mut F) {
+	pub fn each_slot<F: FnMut(LocalSlot)>(&self, f: &mut F) {
 		match self {
-			Self::Full(id) => f(*id),
+			Self::Full(s) => f(*s),
 			#[cfg(feature = "exp-destruct")]
 			Self::Skip => {}
 			#[cfg(feature = "exp-destruct")]
 			Self::Array { start, rest, end } => {
 				for d in start {
-					d.each_id(f);
+					d.each_slot(f);
 				}
-				if let Some(LDestructRest::Keep(id)) = rest {
-					f(*id);
+				if let Some(LDestructRest::Keep(s)) = rest {
+					f(*s);
 				}
 				for d in end {
-					d.each_id(f);
+					d.each_slot(f);
 				}
 			}
 			#[cfg(feature = "exp-destruct")]
 			Self::Object { fields, rest } => {
 				for field in fields {
 					if let Some(into) = &field.into {
-						into.each_id(f);
+						into.each_slot(f);
 					} else {
 						unreachable!("shorthand object destruct must store `into`");
 					}
 				}
-				if let Some(LDestructRest::Keep(id)) = rest {
-					f(*id);
+				if let Some(LDestructRest::Keep(s)) = rest {
+					f(*s);
 				}
 			}
 		}
 	}
 
-	pub fn ids(&self) -> SmallVec<[LocalId; 1]> {
+	pub fn slots(&self) -> SmallVec<[LocalSlot; 1]> {
 		let mut out = SmallVec::new();
-		self.each_id(&mut |id| out.push(id));
+		self.each_slot(&mut |s| out.push(s));
 		out
 	}
 }
@@ -303,21 +333,24 @@
 
 #[derive(Debug, Acyclic)]
 pub struct LObjMembers {
-	/// If current object identity (`super`/`this`/`$`) is used, `this` should be saved to the specified local
-	pub this: Option<LocalId>,
+	pub frame_shape: ClosureShape,
+	/// If current object identity (`super`/`this`/`$`) is used, `this` should
+	/// be saved to the specified local slot.
+	pub this: Option<LocalSlot>,
 	/// Set if dollar should also be assigned to object identity, `this` should also be set (TODO: proper type-level validation)
 	pub set_dollar: bool,
 	/// True iff `super` is referenced by this object's members.
 	pub uses_super: bool,
 
 	pub locals: Rc<Vec<LBind>>,
-	pub asserts: Rc<Vec<LAssertStmt>>,
+	pub asserts: Option<Rc<LObjAsserts>>,
 	pub fields: Vec<LFieldMember>,
 }
 
 #[derive(Debug, Acyclic)]
 pub struct LObjComp {
-	pub this: Option<LocalId>,
+	pub frame_shape: Rc<ClosureShape>,
+	pub this: Option<LocalSlot>,
 	pub set_dollar: bool,
 	pub uses_super: bool,
 
@@ -331,7 +364,19 @@
 	pub name: LFieldName,
 	pub plus: bool,
 	pub visibility: Visibility,
-	pub value: Rc<LExpr>,
+	pub value: Rc<(ClosureShape, LExpr)>,
+}
+
+#[derive(Debug, Acyclic)]
+pub struct LClosure<T: Acyclic> {
+	pub shape: ClosureShape,
+	pub value: T,
+}
+
+#[derive(Debug, Acyclic)]
+pub struct LObjAsserts {
+	pub shape: ClosureShape,
+	pub asserts: Vec<LAssertStmt>,
 }
 
 #[derive(Debug, Acyclic)]
@@ -350,6 +395,7 @@
 
 #[derive(Debug, Acyclic)]
 pub struct LArrComp {
+	pub value_shape: ClosureShape,
 	pub value: Rc<LExpr>,
 	pub compspecs: Vec<LCompSpec>,
 }
@@ -358,6 +404,7 @@
 pub enum LCompSpec {
 	If(LExpr),
 	For {
+		frame_shape: ClosureShape,
 		destruct: LDestruct,
 		over: LExpr,
 		/// Is `over` does not depend on any variable introduced by an earlier for-spec in this comprehension chain
@@ -365,23 +412,323 @@
 	},
 }
 
-// TODO: Binding frame state machine:
-// Pending => AllocIds => Initialize => Body => Exit
+struct FrameAlloc<'s> {
+	first_in_frame: LocalId,
+	stack: &'s mut AnalysisStack,
+	bomb: DropBomb,
+}
+impl<'s> FrameAlloc<'s> {
+	fn new(stack: &'s mut AnalysisStack) -> Self {
+		FrameAlloc {
+			first_in_frame: stack.next_local_id(),
+			stack,
+			bomb: DropBomb::new("binding frame state"),
+		}
+	}
+
+	fn push_locals_closure(&mut self) -> ClosureOnStack {
+		self.stack.push_closure_a(self.first_in_frame)
+	}
+
+	fn define_local(&mut self, name: IStr, span: Option<Span>) -> Option<(LocalId, LocalSlot)> {
+		let id = self.stack.next_local_id();
+		let stack = self.stack.local_by_name.entry(name.clone()).or_default();
+		if let Some(&existing) = stack.last()
+			&& !existing.defined_before(self.first_in_frame)
+		{
+			self.stack.report_error(
+				format!("local is already defined in the current frame: {name}"),
+				span,
+			);
+			return None;
+		}
+		stack.push(id);
+		self.stack.local_defs.push(LocalDefinition {
+			name,
+			span,
+			defined_at_depth: self.stack.depth,
+			used_at_depth: u32::MAX,
+			used_by_sibling: false,
+			analysis: AnalysisResult::default(),
+			analyzed: false,
+			scratch_referenced: false,
+		});
+		let def = self.stack.defining_closure_mut();
+		Some((id, def.define_local(id)))
+	}
+	fn alloc_bind(&mut self, bind: &BindSpec) -> Option<LDestruct> {
+		match bind {
+			BindSpec::Field { into, .. } => self.alloc_destruct(into),
+			BindSpec::Function { name, .. } => {
+				let (_, id) = self.define_local(name.clone(), None)?;
+				Some(LDestruct::Full(id))
+			}
+		}
+	}
+	fn alloc_destruct(&mut self, destruct: &Destruct) -> Option<LDestruct> {
+		Some(match destruct {
+			Destruct::Full(name) => {
+				let (_, id) = self.define_local(name.value.clone(), Some(name.span.clone()))?;
+				LDestruct::Full(id)
+			}
+			#[cfg(feature = "exp-destruct")]
+			Destruct::Skip => LDestruct::Skip,
+			#[cfg(feature = "exp-destruct")]
+			Destruct::Array { start, rest, end } => {
+				let start = start
+					.iter()
+					.map(|d| self.alloc_destruct(d))
+					.collect::<Option<Vec<_>>>()?;
+				let rest = match rest {
+					Some(jrsonnet_ir::DestructRest::Keep(name)) => {
+						let (_, id) = self.define_local(name.clone(), None)?;
+						Some(LDestructRest::Keep(id))
+					}
+					Some(jrsonnet_ir::DestructRest::Drop) => Some(LDestructRest::Drop),
+					None => None,
+				};
+				let end = end
+					.iter()
+					.map(|d| self.alloc_destruct(d))
+					.collect::<Option<Vec<_>>>()?;
+				LDestruct::Array { start, rest, end }
+			}
+			#[cfg(feature = "exp-destruct")]
+			Destruct::Object { fields, rest } => {
+				let mut l_fields: Vec<(IStr, LDestruct)> = Vec::with_capacity(fields.len());
+				// Allocate destruct LocalIds, then analyse defaults
+				for (name, into, _default) in fields {
+					let into = if let Some(inner) = into {
+						self.alloc_destruct(inner)?
+					} else {
+						let (_, id) = self.define_local(name.clone(), None)?;
+						LDestruct::Full(id)
+					};
+					l_fields.push((name.clone(), into));
+				}
+				// All locals exist, so defaults can reference any sibling.
+				let l_fields: Vec<LDestructField> = l_fields
+					.into_iter()
+					.zip(fields.iter())
+					.map(|((name, into), (_n, _i, default))| {
+						let default = match default {
+							Some(e) => {
+								let mut default_taint = AnalysisResult::default();
+								Some(self.stack.in_using_closure(|stack| {
+									Rc::new(analyze(&e.value, stack, &mut default_taint))
+								}))
+							}
+							None => None,
+						};
+						LDestructField {
+							name,
+							into: Some(into),
+							default,
+						}
+					})
+					.collect();
+				let rest = match rest {
+					Some(jrsonnet_ir::DestructRest::Keep(name)) => {
+						let (_, id) = self.define_local(name.clone(), None)?;
+						Some(LDestructRest::Keep(id))
+					}
+					Some(jrsonnet_ir::DestructRest::Drop) => Some(LDestructRest::Drop),
+					None => None,
+				};
+				LDestruct::Object {
+					fields: l_fields,
+					rest,
+				}
+			}
+		})
+	}
+
+	fn finish(self) -> PendingInit<'s> {
+		let Self {
+			first_in_frame,
+			stack,
+			bomb,
+		} = self;
+		let first_after_frame = stack.next_local_id();
+		PendingInit {
+			first_after_frame,
+			stack,
+			closures: Closures {
+				referenced: vec![],
+				spec_shapes: vec![],
+				first_in_frame,
+			},
+			bomb,
+		}
+	}
+}
 
 /// Frame state: `LocalIds` allocated, values not yet analysed.
-struct PendingInit {
-	first_in_frame: LocalId,
+struct PendingInit<'s> {
 	first_after_frame: LocalId,
+	stack: &'s mut AnalysisStack,
+	closures: Closures,
 	bomb: DropBomb,
 }
 
+impl<'s> PendingInit<'s> {
+	/// Record the analysis of a spec's value: stamp every id bound by the
+	/// spec with `analysis`, collect the spec's same-frame references, and
+	/// append them to `closures`.
+	fn record_spec_init(&mut self, destruct: &LDestruct, analysis: AnalysisResult) {
+		let mut refs: SmallVec<[LocalId; 4]> = SmallVec::new();
+		for i in self.closures.first_in_frame.0..self.first_after_frame.0 {
+			let def = &mut self.stack.local_defs[i as usize];
+			if def.scratch_referenced {
+				refs.push(LocalId(i));
+				def.scratch_referenced = false;
+			}
+		}
+
+		let mut ids_count = 0;
+		let first_local = self.stack.top_defining_local();
+		destruct.each_slot(&mut |slot| {
+			ids_count += 1;
+			let id = LocalId(first_local.0 + u32::from(slot.0));
+			let def = &mut self.stack.local_defs[id.idx()];
+			debug_assert!(!def.analyzed, "sanity: local {:?} analysed twice", def.name);
+			def.analysis = analysis;
+			def.analyzed = true;
+		});
+		self.closures.push_spec(ids_count, &refs);
+	}
+	/// After all specs are analysed, propagate dependency information between
+	/// siblings to a fix-point, then switch to "body" mode.
+	fn finish(self) -> PendingBody<'s> {
+		let Self {
+			first_after_frame,
+			closures,
+			stack,
+			bomb,
+		} = self;
+
+		debug_assert_eq!(
+			first_after_frame,
+			stack.next_local_id(),
+			"frame initialisation left unfinished locals"
+		);
+
+		debug_assert_eq!(
+			closures.spec_shapes.iter().map(|(_, d)| *d).sum::<usize>(),
+			(first_after_frame.0 - closures.first_in_frame.0) as usize,
+			"closures destruct-id counts must match frame local count"
+		);
+
+		let mut changed = true;
+		while changed {
+			changed = false;
+			for spec in closures.iter_specs() {
+				for id_raw in spec.ids.clone() {
+					let user = LocalId(id_raw);
+					for &used in spec.references {
+						changed |= stack.propagate_analysis(user, used);
+					}
+				}
+			}
+		}
+
+		stack.depth += 1;
+		PendingBody {
+			first_after_frame,
+			closures,
+			stack,
+			bomb,
+		}
+	}
+}
+
 /// Frame state: values analysed, body not yet walked.
-struct PendingBody {
-	first_in_frame: LocalId,
+struct PendingBody<'s> {
 	first_after_frame: LocalId,
 	closures: Closures,
+	stack: &'s mut AnalysisStack,
 	bomb: DropBomb,
 }
+impl<'s> PendingBody<'s> {
+	/// After the body is processed, drop the frame's locals and emit any
+	/// "unused local" warnings.
+	fn finish(self) {
+		let PendingBody {
+			first_after_frame,
+			closures,
+			stack,
+			mut bomb,
+		} = self;
+		bomb.defuse();
+		stack.depth -= 1;
+
+		debug_assert_eq!(
+			first_after_frame,
+			stack.next_local_id(),
+			"nested scopes must be popped before outer frames"
+		);
+
+		let mut changed = true;
+		while changed {
+			changed = false;
+			for spec in closures.iter_specs() {
+				// Effective used_at_depth for the spec = min over its ids.
+				let mut min_used_at = u32::MAX;
+				for id_raw in spec.ids.clone() {
+					min_used_at = min_used_at.min(stack.local_defs[id_raw as usize].used_at_depth);
+				}
+				if min_used_at == u32::MAX {
+					continue;
+				}
+				for &used in spec.references {
+					let used_def = &mut stack.local_defs[used.idx()];
+					if min_used_at < used_def.used_at_depth {
+						used_def.used_at_depth = min_used_at;
+						changed = true;
+					}
+				}
+			}
+		}
+
+		let drained: Vec<LocalDefinition> = stack
+			.local_defs
+			.drain(closures.first_in_frame.idx()..)
+			.collect();
+		for (i, def) in drained.iter().enumerate().rev() {
+			let id = LocalId(closures.first_in_frame.0 + i as u32);
+			let stack_locals = stack
+				.local_by_name
+				.get_mut(&def.name)
+				.expect("local must be in name map");
+			let popped = stack_locals.pop().expect("name stack should not be empty");
+			debug_assert_eq!(popped, id, "name stack integrity");
+			if stack_locals.is_empty() {
+				stack.local_by_name.remove(&def.name);
+			}
+
+			if def.used_at_depth == u32::MAX {
+				if def.used_by_sibling {
+					stack.report_warning(
+						format!("local is only referenced by unused siblings: {}", def.name),
+						def.span.clone(),
+					);
+				} else {
+					stack.report_warning(format!("unused local: {}", def.name), def.span.clone());
+				}
+			} else if def.analysis.local_dependent_depth > def.defined_at_depth
+				&& def.analysis.object_dependent_depth > def.defined_at_depth
+				&& def.defined_at_depth != 0
+			{
+				// The value doesn't depend on anything defined at or inside
+				// this local's scope - can be hoisted, unfortunately not automatically.
+				stack.report_warning(
+					format!("local could be hoisted to an outer scope: {}", def.name),
+					def.span.clone(),
+				);
+			}
+		}
+	}
+}
 
 struct Closures {
 	/// All the referenced locals, maybe repeated multiple times
@@ -451,14 +798,6 @@
 }
 
 impl Closures {
-	fn new(first_in_frame: LocalId) -> Self {
-		Self {
-			referenced: Vec::new(),
-			spec_shapes: Vec::new(),
-			first_in_frame,
-		}
-	}
-
 	fn push_spec(&mut self, destruct_ids_count: usize, refs: &[LocalId]) {
 		self.referenced.extend_from_slice(refs);
 		self.spec_shapes.push((refs.len(), destruct_ids_count));
@@ -493,6 +832,41 @@
 	pub span: Option<Span>,
 }
 
+struct DefiningClosure {
+	first_local: LocalId,
+	n_locals: u16,
+}
+
+impl DefiningClosure {
+	fn resolve(&self, target: LocalId) -> Option<LocalSlot> {
+		let end = self.first_local.0 + u32::from(self.n_locals);
+		if target.0 >= self.first_local.0 && target.0 < end {
+			Some(LocalSlot(
+				u16::try_from(target.0 - self.first_local.0).expect("local slots overflow"),
+			))
+		} else {
+			None
+		}
+	}
+	fn define_local(&mut self, local: LocalId) -> LocalSlot {
+		let slot = self.n_locals;
+		let id = self.first_local.0 + u32::from(slot);
+		debug_assert_eq!(local.0, id);
+		self.n_locals = self.n_locals.checked_add(1).expect("local slots overflow");
+		LocalSlot(slot)
+	}
+}
+
+/// Per-closure capture computation state.
+struct ClosureFrame {
+	/// Closure may allocate locals
+	defining: Option<DefiningClosure>,
+	/// `LocalId` => capture index
+	captures: FxHashMap<LocalId, CaptureSlot>,
+	/// Capture sources in insertion order; consumed by `pop_closure_frame`.
+	capture_sources: Vec<LSlot>,
+}
+
 #[allow(clippy::struct_excessive_bools)]
 pub struct AnalysisStack {
 	local_defs: Vec<LocalDefinition>,
@@ -519,11 +893,19 @@
 	/// True iff `$` has been referenced anywhere since the outermost object's scope was entered.
 	dollar_used: bool,
 
+	/// Stack of closure frames (innermost on top).
+	closure_stack: Vec<ClosureFrame>,
+
 	diagnostics: Vec<Diagnostic>,
 	/// Whenever analysis would be broken due to static analysis error.
 	errored: bool,
 }
 
+#[must_use]
+struct ClosureOnStack {
+	bomb: DropBomb,
+}
+
 impl AnalysisStack {
 	pub fn new() -> Self {
 		Self {
@@ -537,11 +919,120 @@
 			cur_self_used: false,
 			cur_super_used: false,
 			dollar_used: false,
+			closure_stack: Vec::new(),
 			diagnostics: Vec::new(),
 			errored: false,
 		}
 	}
 
+	fn push_root_closure(&mut self, externals: u16) -> ClosureOnStack {
+		assert!(
+			self.closure_stack.is_empty(),
+			"root is only possible with empty stack"
+		);
+
+		self.closure_stack.push(ClosureFrame {
+			defining: Some(DefiningClosure {
+				first_local: LocalId(0),
+				n_locals: externals,
+			}),
+			captures: FxHashMap::default(),
+			capture_sources: Vec::new(),
+		});
+
+		ClosureOnStack {
+			bomb: DropBomb::new("root closure"),
+		}
+	}
+
+	fn push_closure_a(&mut self, first_local: LocalId) -> ClosureOnStack {
+		self.closure_stack.push(ClosureFrame {
+			defining: Some(DefiningClosure {
+				first_local,
+				n_locals: 0,
+			}),
+			captures: FxHashMap::default(),
+			capture_sources: Vec::new(),
+		});
+		ClosureOnStack {
+			bomb: DropBomb::new("closure with locals"),
+		}
+	}
+
+	#[inline]
+	fn in_using_closure<T>(
+		&mut self,
+		inner: impl FnOnce(&mut AnalysisStack) -> T,
+	) -> (ClosureShape, T) {
+		fn push_closure_b(stack: &mut AnalysisStack) -> ClosureOnStack {
+			stack.closure_stack.push(ClosureFrame {
+				defining: None,
+				captures: FxHashMap::default(),
+				capture_sources: Vec::new(),
+			});
+			ClosureOnStack {
+				bomb: DropBomb::new("closure with locals"),
+			}
+		}
+		let closure = push_closure_b(self);
+		let v = inner(self);
+		let shape = self.pop_closure(closure);
+		(shape, v)
+	}
+
+	fn pop_closure(&mut self, mut closure: ClosureOnStack) -> ClosureShape {
+		closure.bomb.defuse();
+		let frame = self.closure_stack.pop().expect("closure frame");
+		ClosureShape {
+			captures: frame.capture_sources.into_boxed_slice(),
+			n_locals: frame.defining.map(|d| d.n_locals).unwrap_or_default(),
+		}
+	}
+
+	/// Resolve a `LocalId` reference to an `LSlot` against the innermost
+	/// closure frame. May insert capture entries up the closure stack as
+	/// needed.
+	fn resolve_to_slot(&mut self, target: LocalId) -> LSlot {
+		let top = self.closure_stack.len();
+		debug_assert!(top > 0, "resolve_to_slot called with no closure frame");
+		Self::resolve_at(&mut self.closure_stack, top - 1, target)
+	}
+
+	fn resolve_at(stack: &mut [ClosureFrame], idx: usize, target: LocalId) -> LSlot {
+		if let Some(def) = &stack[idx].defining {
+			if let Some(resolved) = def.resolve(target) {
+				return LSlot::Local(resolved);
+			}
+		} else {
+			// A sibling letrec slot must never be packed as a capture, or
+			// it would read an empty `OnceCell`.
+			for j in (0..idx).rev() {
+				if let Some(def) = &stack[j].defining {
+					if let Some(resolved) = def.resolve(target) {
+						return LSlot::Local(resolved);
+					}
+					break;
+				}
+			}
+		}
+		if let Some(&cap_idx) = stack[idx].captures.get(&target) {
+			return LSlot::Capture(cap_idx);
+		}
+		debug_assert!(idx > 0, "no enclosing closure frame for target {target:?}");
+		let parent_slot = Self::resolve_at(stack, idx - 1, target);
+		let frame = &mut stack[idx];
+		let cap_idx = CaptureSlot(
+			frame
+				.capture_sources
+				.len()
+				.try_into()
+				.expect("frame has more than u16::MAX captures"),
+		);
+		frame.capture_sources.push(parent_slot);
+		frame.captures.insert(target, cap_idx);
+		LSlot::Capture(cap_idx)
+	}
+
 	fn next_local_id(&self) -> LocalId {
 		LocalId(self.local_defs.len() as u32)
 	}
@@ -562,12 +1053,7 @@
 		});
 	}
 
-	fn use_local(
-		&mut self,
-		name: &IStr,
-		span: Span,
-		taint: &mut AnalysisResult,
-	) -> Option<LocalId> {
+	fn use_local(&mut self, name: &IStr, span: Span, taint: &mut AnalysisResult) -> Option<LSlot> {
 		let Some(ids) = self.local_by_name.get(name) else {
 			let names = suggest_names(name, self.local_by_name.keys());
 			self.report_error(
@@ -586,7 +1072,7 @@
 		} else {
 			def.scratch_referenced = true;
 		}
-		Some(id)
+		Some(self.resolve_to_slot(id))
 	}
 
 	/// Assign name to the value provided externally, e.g `std`.
@@ -613,37 +1099,20 @@
 		self.local_by_name.entry(name).or_default().push(id);
 	}
 
-	/// Define a new local inside a frame currently being built.
-	fn define_local(
-		&mut self,
-		name: IStr,
-		span: Option<Span>,
-		frame_start: LocalId,
-	) -> Option<LocalId> {
-		let id = self.next_local_id();
-		let stack = self.local_by_name.entry(name.clone()).or_default();
-		if let Some(&existing) = stack.last() {
-			if !existing.defined_before(frame_start) {
-				self.report_error(
-					format!("local is already defined in the current frame: {name}"),
-					span,
-				);
-				return None;
-			}
-		}
-		stack.push(id);
-		self.local_defs.push(LocalDefinition {
-			name,
-			span,
-			defined_at_depth: self.depth,
-			used_at_depth: u32::MAX,
-			used_by_sibling: false,
-			analysis: AnalysisResult::default(),
-			analyzed: false,
-			scratch_referenced: false,
-		});
-		Some(id)
+	fn defining_closure_mut(&mut self) -> &mut DefiningClosure {
+		self.closure_stack
+			.iter_mut()
+			.rev()
+			.find_map(|c| c.defining.as_mut())
+			.expect("no enclosing defining closure frame")
 	}
+	fn defining_closure(&self) -> &DefiningClosure {
+		self.closure_stack
+			.iter()
+			.rev()
+			.find_map(|c| c.defining.as_ref())
+			.expect("no enclosing defining closure frame")
+	}
 }
 
 impl Default for AnalysisStack {
@@ -653,169 +1122,8 @@
 }
 
 impl AnalysisStack {
-	fn alloc_destruct(&mut self, destruct: &Destruct, frame_start: LocalId) -> Option<LDestruct> {
-		match destruct {
-			Destruct::Full(name) => {
-				let id =
-					self.define_local(name.value.clone(), Some(name.span.clone()), frame_start)?;
-				Some(LDestruct::Full(id))
-			}
-			#[cfg(feature = "exp-destruct")]
-			Destruct::Skip => Some(LDestruct::Skip),
-			#[cfg(feature = "exp-destruct")]
-			Destruct::Array { start, rest, end } => {
-				let start = start
-					.iter()
-					.map(|d| self.alloc_destruct(d, frame_start))
-					.collect::<Option<Vec<_>>>()?;
-				let rest = match rest {
-					Some(jrsonnet_ir::DestructRest::Keep(name)) => {
-						let id = self.define_local(name.clone(), None, frame_start)?;
-						Some(LDestructRest::Keep(id))
-					}
-					Some(jrsonnet_ir::DestructRest::Drop) => Some(LDestructRest::Drop),
-					None => None,
-				};
-				let end = end
-					.iter()
-					.map(|d| self.alloc_destruct(d, frame_start))
-					.collect::<Option<Vec<_>>>()?;
-				Some(LDestruct::Array { start, rest, end })
-			}
-			#[cfg(feature = "exp-destruct")]
-			Destruct::Object { fields, rest } => {
-				let mut l_fields: Vec<(IStr, LDestruct)> = Vec::with_capacity(fields.len());
-				// Two passes: first allocate ALL destruct LocalIds, then
-				// analyse defaults (which may reference later fields).
-				let mut l_fields: Vec<(IStr, LDestruct)> = Vec::with_capacity(fields.len());
-				for (name, into, _default) in fields {
-					let into = if let Some(inner) = into {
-						self.alloc_destruct(inner, frame_start)?
-					} else {
-						let id = self.define_local(name.clone(), None, frame_start)?;
-						LDestruct::Full(id)
-					};
-					l_fields.push((name.clone(), into));
-				}
-				// Second pass: all locals exist, so defaults can reference
-				// any sibling.
-				let l_fields: Vec<LDestructField> = l_fields
-					.into_iter()
-					.zip(fields.iter())
-					.map(|((name, into), (_n, _i, default))| {
-						let default = default.as_ref().map(|e| {
-							let mut default_taint = AnalysisResult::default();
-							Rc::new(analyze(&e.value, self, &mut default_taint))
-						});
-						LDestructField {
-							name,
-							into: Some(into),
-							default,
-						}
-					})
-					.collect();
-				let rest = match rest {
-					Some(jrsonnet_ir::DestructRest::Keep(name)) => {
-						let id = self.define_local(name.clone(), None, frame_start)?;
-						Some(LDestructRest::Keep(id))
-					}
-					Some(jrsonnet_ir::DestructRest::Drop) => Some(LDestructRest::Drop),
-					None => None,
-				};
-				Some(LDestruct::Object {
-					fields: l_fields,
-					rest,
-				})
-			}
-		}
-	}
-
-	// TODO: Proper state machine
-	fn begin_frame_alloc(&mut self) -> LocalId {
-		self.next_local_id()
-	}
-
-	fn finish_frame_alloc(&mut self, first_in_frame: LocalId) -> PendingInit {
-		let first_after_frame = self.next_local_id();
-		PendingInit {
-			first_in_frame,
-			first_after_frame,
-			bomb: DropBomb::new("PendingInit must be passed to finish_frame_init"),
-		}
-	}
-
-	/// Record the analysis of a spec's value: stamp every id bound by the
-	/// spec with `analysis`, collect the spec's same-frame references, and
-	/// append them to `closures`.
-	fn record_spec_init(
-		&mut self,
-		pending: &PendingInit,
-		destruct: &LDestruct,
-		analysis: AnalysisResult,
-		closures: &mut Closures,
-	) {
-		let mut refs: SmallVec<[LocalId; 4]> = SmallVec::new();
-		for i in pending.first_in_frame.0..pending.first_after_frame.0 {
-			let def = &mut self.local_defs[i as usize];
-			if def.scratch_referenced {
-				refs.push(LocalId(i));
-				def.scratch_referenced = false;
-			}
-		}
-
-		let mut ids_count = 0;
-		destruct.each_id(&mut |id| {
-			ids_count += 1;
-			let def = &mut self.local_defs[id.idx()];
-			debug_assert!(!def.analyzed, "sanity: local {:?} analysed twice", def.name);
-			def.analysis = analysis;
-			def.analyzed = true;
-		});
-		closures.push_spec(ids_count, &refs);
-	}
-
-	/// After all specs are analysed, propagate dependency information between
-	/// siblings to a fix-point, then switch to "body" mode.
-	fn finish_frame_init(&mut self, pending: PendingInit, closures: Closures) -> PendingBody {
-		let PendingInit {
-			first_in_frame,
-			first_after_frame,
-			mut bomb,
-		} = pending;
-		bomb.defuse();
-
-		debug_assert_eq!(
-			first_after_frame,
-			self.next_local_id(),
-			"frame initialisation left unfinished locals"
-		);
-
-		debug_assert_eq!(
-			closures.spec_shapes.iter().map(|(_, d)| *d).sum::<usize>(),
-			(first_after_frame.0 - first_in_frame.0) as usize,
-			"closures destruct-id counts must match frame local count"
-		);
-
-		let mut changed = true;
-		while changed {
-			changed = false;
-			for spec in closures.iter_specs() {
-				for id_raw in spec.ids.clone() {
-					let user = LocalId(id_raw);
-					for &used in spec.references {
-						changed |= self.propagate_analysis(user, used);
-					}
-				}
-			}
-		}
-
-		self.depth += 1;
-		PendingBody {
-			first_in_frame,
-			first_after_frame,
-			closures,
-			bomb: DropBomb::new("PendingBody must be passed to finish_frame_body"),
-		}
+	fn top_defining_local(&self) -> LocalId {
+		self.defining_closure().first_local
 	}
 
 	/// Merge `used`'s analysis into `user`'s analysis and record that `user`
@@ -834,82 +1142,6 @@
 		before_obj != user_def.analysis.object_dependent_depth
 			|| before_loc != user_def.analysis.local_dependent_depth
 	}
-
-	/// After the body is processed, drop the frame's locals and emit any
-	/// "unused local" warnings.
-	fn finish_frame_body(&mut self, pending: PendingBody) {
-		let PendingBody {
-			first_in_frame,
-			first_after_frame,
-			closures,
-			mut bomb,
-		} = pending;
-		bomb.defuse();
-		self.depth -= 1;
-
-		debug_assert_eq!(
-			first_after_frame,
-			self.next_local_id(),
-			"nested scopes must be popped before outer frames"
-		);
-
-		let mut changed = true;
-		while changed {
-			changed = false;
-			for spec in closures.iter_specs() {
-				// Effective used_at_depth for the spec = min over its ids.
-				let mut min_used_at = u32::MAX;
-				for id_raw in spec.ids.clone() {
-					min_used_at = min_used_at.min(self.local_defs[id_raw as usize].used_at_depth);
-				}
-				if min_used_at == u32::MAX {
-					continue;
-				}
-				for &used in spec.references {
-					let used_def = &mut self.local_defs[used.idx()];
-					if min_used_at < used_def.used_at_depth {
-						used_def.used_at_depth = min_used_at;
-						changed = true;
-					}
-				}
-			}
-		}
-
-		let drained: Vec<LocalDefinition> = self.local_defs.drain(first_in_frame.idx()..).collect();
-		for (i, def) in drained.iter().enumerate().rev() {
-			let id = LocalId(first_in_frame.0 + i as u32);
-			let stack = self
-				.local_by_name
-				.get_mut(&def.name)
-				.expect("local must be in name map");
-			let popped = stack.pop().expect("name stack should not be empty");
-			debug_assert_eq!(popped, id, "name stack integrity");
-			if stack.is_empty() {
-				self.local_by_name.remove(&def.name);
-			}
-
-			if def.used_at_depth == u32::MAX {
-				if def.used_by_sibling {
-					self.report_warning(
-						format!("local is only referenced by unused siblings: {}", def.name),
-						def.span.clone(),
-					);
-				} else {
-					self.report_warning(format!("unused local: {}", def.name), def.span.clone());
-				}
-			} else if def.analysis.local_dependent_depth > def.defined_at_depth
-				&& def.analysis.object_dependent_depth > def.defined_at_depth
-				&& def.defined_at_depth != 0
-			{
-				// The value doesn't depend on anything defined at or inside
-				// this local's scope - can be hoisted, unfortunately not automatically.
-				self.report_warning(
-					format!("local could be hoisted to an outer scope: {}", def.name),
-					def.span.clone(),
-				);
-			}
-		}
-	}
 }
 
 mod names {
@@ -922,56 +1154,85 @@
 
 // Object scope helpers
 impl AnalysisStack {
-	// TODO: proper state machine
-	fn enter_object_scope(&mut self) -> ObjectScope {
-		let is_outermost = self.first_object_depth == u32::MAX;
-		let scope = ObjectScope {
-			this_id: self.push_pseudo_local(names::this()),
-			is_outermost,
-			prev_this_local: self.this_local,
-			prev_dollar_alias: self.dollar_alias,
-			prev_cur_self_used: self.cur_self_used,
-			prev_cur_super_used: self.cur_super_used,
-			prev_dollar_used: is_outermost.then_some(self.dollar_used),
-			prev_last_object: self.last_object_depth,
-			prev_first_object: self.first_object_depth,
-		};
+	#[inline]
+	fn in_object_scope<T>(
+		&mut self,
+		inner: impl FnOnce(&mut AnalysisStack) -> T,
+	) -> (ObjectUsage, ClosureShape, T) {
+		fn enter_object_scope(stack: &mut AnalysisStack) -> ObjectScope {
+			let is_outermost = stack.first_object_depth == u32::MAX;
+			let this_id = stack.next_local_id();
+			let closure = stack.push_closure_a(this_id);
+			let pushed = stack.push_pseudo_local(names::this());
+			debug_assert_eq!(pushed, this_id, "this pseudo-local id");
+			let scope = ObjectScope {
+				this_id,
+				is_outermost,
+				prev_this_local: stack.this_local,
+				prev_dollar_alias: stack.dollar_alias,
+				prev_cur_self_used: stack.cur_self_used,
+				prev_cur_super_used: stack.cur_super_used,
+				prev_dollar_used: is_outermost.then_some(stack.dollar_used),
+				prev_last_object: stack.last_object_depth,
+				prev_first_object: stack.first_object_depth,
+				closure,
+			};
 
-		self.this_local = Some(scope.this_id);
-		if is_outermost {
-			self.dollar_alias = Some(scope.this_id);
-			self.first_object_depth = self.depth;
-			self.dollar_used = false;
+			stack.this_local = Some(scope.this_id);
+			if is_outermost {
+				stack.dollar_alias = Some(scope.this_id);
+				stack.first_object_depth = stack.depth;
+				stack.dollar_used = false;
+			}
+			stack.last_object_depth = stack.depth;
+			stack.cur_self_used = false;
+			stack.cur_super_used = false;
+			scope
 		}
-		self.last_object_depth = self.depth;
-		self.cur_self_used = false;
-		self.cur_super_used = false;
-		scope
-	}
 
-	fn leave_object_scope(&mut self, scope: ObjectScope) -> ObjectUsage {
-		let _ = self.local_defs.pop().expect("this pseudo-local exists");
-		debug_assert_eq!(self.local_defs.len(), scope.this_id.0 as usize);
+		fn leave_object_scope(
+			stack: &mut AnalysisStack,
+			scope: ObjectScope,
+		) -> (ObjectUsage, ClosureShape) {
+			let ObjectScope {
+				this_id,
+				is_outermost,
+				prev_this_local,
+				prev_dollar_alias,
+				prev_cur_self_used,
+				prev_cur_super_used,
+				prev_dollar_used,
+				prev_last_object,
+				prev_first_object,
+				closure,
+			} = scope;
+			let _ = stack.local_defs.pop().expect("this pseudo-local exists");
+			debug_assert_eq!(stack.local_defs.len(), this_id.0 as usize);
 
-		let set_dollar = scope.is_outermost && self.dollar_used;
-		let usage = ObjectUsage {
-			this_id: scope.this_id,
-			this_used: self.cur_self_used || self.cur_super_used || set_dollar,
-			uses_super: self.cur_super_used,
-			set_dollar,
-		};
+			let set_dollar = is_outermost && stack.dollar_used;
+			let usage = ObjectUsage {
+				this_used: stack.cur_self_used || stack.cur_super_used || set_dollar,
+				uses_super: stack.cur_super_used,
+				set_dollar,
+			};
+
+			stack.this_local = prev_this_local;
+			stack.dollar_alias = prev_dollar_alias;
+			stack.cur_self_used = prev_cur_self_used;
+			stack.cur_super_used = prev_cur_super_used;
+			if let Some(prev) = prev_dollar_used {
+				stack.dollar_used = prev;
+			}
+			stack.last_object_depth = prev_last_object;
+			stack.first_object_depth = prev_first_object;
 
-		self.this_local = scope.prev_this_local;
-		self.dollar_alias = scope.prev_dollar_alias;
-		self.cur_self_used = scope.prev_cur_self_used;
-		self.cur_super_used = scope.prev_cur_super_used;
-		if let Some(prev) = scope.prev_dollar_used {
-			self.dollar_used = prev;
+			let frame_shape = stack.pop_closure(closure);
+			(usage, frame_shape)
 		}
-		self.last_object_depth = scope.prev_last_object;
-		self.first_object_depth = scope.prev_first_object;
-
-		usage
+		let scope = enter_object_scope(self);
+		let v = inner(self);
+		let (usage, shape) = leave_object_scope(self, scope);
+		(usage, shape, v)
 	}
 
 	fn push_pseudo_local(&mut self, name: IStr) -> LocalId {
@@ -986,14 +1247,18 @@
 			analyzed: true,
 			scratch_referenced: false,
 		});
+		{
+			let def = self.defining_closure_mut();
+			let _ = def.define_local(id);
+		}
 		id
 	}
 
-	fn use_this(&mut self, taint: &mut AnalysisResult) -> Option<LocalId> {
+	fn use_this(&mut self, taint: &mut AnalysisResult) -> Option<LSlot> {
 		let id = self.this_local?;
 		self.cur_self_used = true;
 		self.use_pseudo_local(id, taint);
-		Some(id)
+		Some(self.resolve_to_slot(id))
 	}
 
 	fn use_super(&mut self, taint: &mut AnalysisResult) -> Option<()> {
@@ -1003,11 +1268,11 @@
 		Some(())
 	}
 
-	fn use_dollar(&mut self, taint: &mut AnalysisResult) -> Option<LocalId> {
+	fn use_dollar(&mut self, taint: &mut AnalysisResult) -> Option<LSlot> {
 		let id = self.dollar_alias?;
 		self.dollar_used = true;
 		self.use_pseudo_local(id, taint);
-		Some(id)
+		Some(self.resolve_to_slot(id))
 	}
 
 	// TODO: Dedicated type for object references instead of "pseudo local" BS, idk
@@ -1020,6 +1285,7 @@
 	}
 }
 
+#[must_use]
 struct ObjectScope {
 	this_id: LocalId,
 	is_outermost: bool,
@@ -1030,10 +1296,10 @@
 	prev_dollar_used: Option<bool>,
 	prev_last_object: u32,
 	prev_first_object: u32,
+	closure: ClosureOnStack,
 }
 
 struct ObjectUsage {
-	this_id: LocalId,
 	this_used: bool,
 	uses_super: bool,
 	set_dollar: bool,
@@ -1073,7 +1339,7 @@
 					stack.report_error("`self` used outside of object", None);
 					LExpr::BadLocal("self")
 				},
-				LExpr::Local,
+				LExpr::Slot,
 			),
 			LiteralType::Super => {
 				if stack.use_super(taint).is_some() {
@@ -1088,7 +1354,7 @@
 					stack.report_error("`$` used outside of object", None);
 					LExpr::BadLocal("$")
 				},
-				LExpr::Local,
+				LExpr::Slot,
 			),
 			LiteralType::Null => LExpr::Null,
 			LiteralType::True => LExpr::Bool(true),
@@ -1098,10 +1364,15 @@
 		Expr::Num(n) => LExpr::Num(*n),
 		Expr::Var(v) => stack
 			.use_local(&v.value, v.span.clone(), taint)
-			.map_or_else(|| LExpr::BadLocal("ref"), LExpr::Local),
-		Expr::Arr(a) => LExpr::Arr(Rc::new(
-			a.iter().map(|v| analyze(v, stack, taint)).collect(),
-		)),
+			.map_or_else(|| LExpr::BadLocal("ref"), LExpr::Slot),
+		Expr::Arr(a) => {
+			let (shape, items) = stack
+				.in_using_closure(|stack| a.iter().map(|v| analyze(v, stack, taint)).collect());
+			LExpr::Arr {
+				shape,
+				items: Rc::new(items),
+			}
+		}
 		Expr::ArrComp(inner, comp) => analyze_arr_comp(inner, comp, stack, taint),
 		Expr::Obj(obj) => LExpr::Obj(analyze_obj_body(obj, stack, taint)),
 		Expr::ObjExtend(base, obj) => LExpr::ObjExtend(
@@ -1238,14 +1509,17 @@
 	if binds.is_empty() {
 		return analyze(body, stack, taint);
 	}
-	let (_frame_start, l_binds, body_expr) =
-		process_local_frame(binds, stack, taint, |stack, taint| {
-			analyze(body, stack, taint)
-		});
-	LExpr::LocalExpr {
+	let frame_start = stack.next_local_id();
+	let closure = stack.push_closure_a(frame_start);
+	let (l_binds, body_expr) = process_local_frame(binds, stack, taint, |stack, taint| {
+		analyze(body, stack, taint)
+	});
+	let frame_shape = stack.pop_closure(closure);
+	LExpr::LocalExpr(Box::new(LLocalExpr {
+		frame_shape,
 		binds: l_binds,
-		body: Box::new(body_expr),
-	}
+		body: body_expr,
+	}))
 }
 
 fn analyze_bind_value(
@@ -1267,55 +1541,44 @@
 	}
 }
 
-fn alloc_bind_destruct(
-	bind: &BindSpec,
-	stack: &mut AnalysisStack,
-	frame_start: LocalId,
-) -> Option<LDestruct> {
-	match bind {
-		BindSpec::Field { into, .. } => stack.alloc_destruct(into, frame_start),
-		BindSpec::Function { name, .. } => stack
-			.define_local(name.clone(), None, frame_start)
-			.map(LDestruct::Full),
-	}
-}
-
 fn process_local_frame<R>(
 	binds: &[BindSpec],
 	stack: &mut AnalysisStack,
 	taint: &mut AnalysisResult,
 	body_fn: impl FnOnce(&mut AnalysisStack, &mut AnalysisResult) -> R,
-) -> (LocalId, Vec<LBind>, R) {
-	let frame_start = stack.begin_frame_alloc();
+) -> (Vec<LBind>, R) {
+	let mut alloc = FrameAlloc::new(stack);
 
 	let mut destructs: Vec<Option<LDestruct>> = Vec::with_capacity(binds.len());
 	for bind in binds {
-		destructs.push(alloc_bind_destruct(bind, stack, frame_start));
+		destructs.push(alloc.alloc_bind(bind));
 	}
-	let pending = stack.finish_frame_alloc(frame_start);
+	let mut pending = alloc.finish();
 
-	let mut closures = Closures::new(frame_start);
 	let mut l_binds: Vec<LBind> = Vec::with_capacity(binds.len());
 	for (bind, destruct) in binds.iter().zip(destructs.into_iter()) {
 		let mut value_taint = AnalysisResult::default();
-		let value = analyze_bind_value(bind, stack, &mut value_taint);
+		let (value_shape, value) = pending
+			.stack
+			.in_using_closure(|stack| analyze_bind_value(bind, stack, &mut value_taint));
 		taint.taint_by(value_taint);
 		if let Some(destruct) = destruct {
-			stack.record_spec_init(&pending, &destruct, value_taint, &mut closures);
+			pending.record_spec_init(&destruct, value_taint);
 			l_binds.push(LBind {
 				destruct,
+				value_shape,
 				value: Rc::new(value),
 			});
 		} else {
-			closures.push_spec(0, &[]);
+			pending.closures.push_spec(0, &[]);
 		}
 	}
 
-	let body_frame = stack.finish_frame_init(pending, closures);
-	let result = body_fn(stack, taint);
-	stack.finish_frame_body(body_frame);
+	let body_frame = pending.finish();
+	let result = body_fn(body_frame.stack, taint);
+	body_frame.finish();
 
-	(frame_start, l_binds, result)
+	(l_binds, result)
 }
 
 fn analyze_function(
@@ -1325,23 +1588,29 @@
 	stack: &mut AnalysisStack,
 	taint: &mut AnalysisResult,
 ) -> LExpr {
-	let frame_start = stack.begin_frame_alloc();
+	let mut alloc = FrameAlloc::new(stack);
+	let closure = alloc.push_locals_closure();
 
 	let mut param_destructs: Vec<Option<LDestruct>> = Vec::with_capacity(params.exprs.len());
 	for p in &params.exprs {
-		param_destructs.push(stack.alloc_destruct(&p.destruct, frame_start));
+		param_destructs.push(alloc.alloc_destruct(&p.destruct));
 	}
 
-	let pending = stack.finish_frame_alloc(frame_start);
+	let mut pending = alloc.finish();
 
-	let mut closures = Closures::new(frame_start);
 	let mut l_params: Vec<LParam> = Vec::with_capacity(params.exprs.len());
 	for (p, destruct) in params.exprs.iter().zip(param_destructs.into_iter()) {
 		let mut value_taint = AnalysisResult::default();
-		let default = p
-			.default
-			.as_ref()
-			.map(|d| Rc::new(analyze(d, stack, &mut value_taint)));
+		let default = p.default.as_ref().map_or_else(
+			|| None,
+			|d| {
+				Some(
+					pending
+						.stack
+						.in_using_closure(|stack| Rc::new(analyze(d, stack, &mut value_taint))),
+				)
+			},
+		);
 		taint.taint_by(value_taint);
 		if let Some(destruct) = destruct {
 			let name = match &p.destruct {
@@ -1349,25 +1618,42 @@
 				#[cfg(feature = "exp-destruct")]
 				_ => None,
 			};
-			stack.record_spec_init(&pending, &destruct, value_taint, &mut closures);
+			pending.record_spec_init(&destruct, value_taint);
 			l_params.push(LParam {
 				name,
 				destruct,
 				default,
 			});
 		} else {
-			closures.push_spec(0, &[]);
+			pending.closures.push_spec(0, &[]);
 		}
 	}
 
-	let body_frame = stack.finish_frame_init(pending, closures);
-	let body_expr = analyze(body, stack, taint);
-	stack.finish_frame_body(body_frame);
+	let body_frame = pending.finish();
+	let body_expr = analyze(body, body_frame.stack, taint);
+	body_frame.finish();
+	let body_shape = stack.pop_closure(closure);
 
+	// function(x) x is an identity function
+	if l_params.len() == 1 && l_params[0].default.is_none() {
+		stack.report_warning(
+			"do not define identity functions manually, use std.id instead",
+			None,
+		);
+		#[allow(irrefutable_let_patterns, reason = "refutable with exp-destruct")]
+		if let LDestruct::Full(param_slot) = &l_params[0].destruct
+			&& let LExpr::Slot(LSlot::Local(s)) = &body_expr
+			&& s == param_slot
+		{
+			return LExpr::IdentityFunction {};
+		}
+	}
+
 	LExpr::Function(Rc::new(LFunction {
 		name,
 		params: l_params,
 		signature: params.signature.clone(),
+		body_shape,
 		body: Rc::new(body_expr),
 	}))
 }
@@ -1405,38 +1691,55 @@
 		})
 		.collect();
 
-	let scope = stack.enter_object_scope();
-	let (_frame_start, l_binds, (l_asserts, l_fields)) =
-		process_local_frame(locals, stack, taint, |stack, taint| {
-			let mut l_asserts = Vec::with_capacity(asserts.len());
-			for a in asserts {
-				let mut assert_taint = AnalysisResult::default();
-				l_asserts.push(analyze_assert(a, stack, &mut assert_taint));
-				taint.taint_by(assert_taint);
-			}
-			let mut l_fields = Vec::with_capacity(fields.len());
-			for (f, name) in fields.iter().zip(field_names) {
-				let value = if let Some(params) = &f.params {
-					analyze_function(name.function_name(), params, &f.value, stack, taint)
+	let (usage, frame_shape, (l_binds, (l_asserts_opt, l_fields))) =
+		stack.in_object_scope(|stack| {
+			process_local_frame(locals, stack, taint, |stack, taint| {
+				let l_asserts_opt = if asserts.is_empty() {
+					None
 				} else {
-					analyze(&f.value, stack, taint)
+					let (shape, l_asserts) = stack.in_using_closure(|stack| {
+						let mut l_asserts = Vec::with_capacity(asserts.len());
+						for a in asserts {
+							let mut assert_taint = AnalysisResult::default();
+							l_asserts.push(analyze_assert(a, stack, &mut assert_taint));
+							taint.taint_by(assert_taint);
+						}
+						l_asserts
+					});
+					Some(Rc::new(LObjAsserts {
+						shape,
+						asserts: l_asserts,
+					}))
 				};
-				l_fields.push(LFieldMember {
-					name,
-					plus: f.plus,
-					visibility: f.visibility,
-					value: Rc::new(value),
-				});
-			}
-			(l_asserts, l_fields)
+				let mut l_fields = Vec::with_capacity(fields.len());
+				for (f, name) in fields.iter().zip(field_names) {
+					let value = stack.in_using_closure(|stack| {
+						if let Some(params) = &f.params {
+							analyze_function(name.function_name(), params, &f.value, stack, taint)
+						} else {
+							analyze(&f.value, stack, taint)
+						}
+					});
+					l_fields.push(LFieldMember {
+						name,
+						plus: f.plus,
+						visibility: f.visibility,
+						value: Rc::new(value),
+					});
+				}
+				(l_asserts_opt, l_fields)
+			})
 		});
-	let usage = stack.leave_object_scope(scope);
+	// `this` was allocated as the first local of the object's frame,
+	// so its slot is 0 within that frame.
+	let this_slot = usage.this_used.then_some(LocalSlot(0));
 	LObjMembers {
-		this: usage.this_used.then_some(usage.this_id),
+		frame_shape,
+		this: this_slot,
 		set_dollar: usage.set_dollar,
 		uses_super: usage.uses_super,
 		locals: Rc::new(l_binds),
-		asserts: Rc::new(l_asserts),
+		asserts: l_asserts_opt,
 		fields: l_fields,
 	}
 }
@@ -1452,26 +1755,30 @@
 			FieldName::Dyn(e) => LFieldName::Dyn(analyze(e, stack, taint)),
 		};
 
-		let scope = stack.enter_object_scope();
-		let body = process_local_frame(&comp.locals, stack, taint, |stack, taint| {
-			let value = if let Some(params) = &comp.field.params {
-				analyze_function(None, params, &comp.field.value, stack, taint)
-			} else {
-				analyze(&comp.field.value, stack, taint)
-			};
-			LFieldMember {
-				name: field_name,
-				plus: comp.field.plus,
-				visibility: comp.field.visibility,
-				value: Rc::new(value),
-			}
+		let (usage, frame_shape, body) = stack.in_object_scope(|stack| {
+			process_local_frame(&comp.locals, stack, taint, |stack, taint| {
+				let value = stack.in_using_closure(|stack| {
+					if let Some(params) = &comp.field.params {
+						analyze_function(None, params, &comp.field.value, stack, taint)
+					} else {
+						analyze(&comp.field.value, stack, taint)
+					}
+				});
+				LFieldMember {
+					name: field_name,
+					plus: comp.field.plus,
+					visibility: comp.field.visibility,
+					value: Rc::new(value),
+				}
+			})
 		});
-		let usage = stack.leave_object_scope(scope);
-		(usage, body)
+		(usage, frame_shape, body)
 	});
-	let (usage, (_frame_start, locals, field)) = res.inner;
+	let (usage, frame_shape, (locals, field)) = res.inner;
+	let this_slot = usage.this_used.then_some(LocalSlot(0));
 	LObjComp {
-		this: usage.this_used.then_some(usage.this_id),
+		frame_shape: Rc::new(frame_shape),
+		this: this_slot,
 		set_dollar: usage.set_dollar,
 		uses_super: usage.uses_super,
 		locals: Rc::new(locals),
@@ -1487,10 +1794,12 @@
 	taint: &mut AnalysisResult,
 ) -> LExpr {
 	let res = analyze_comp_specs(specs, stack, taint, |stack, taint| {
-		analyze(inner, stack, taint)
+		stack.in_using_closure(|stack| analyze(inner, stack, taint))
 	});
+	let (value_shape, value) = res.inner;
 	LExpr::ArrComp(Box::new(LArrComp {
-		value: Rc::new(res.inner),
+		value_shape,
+		value: Rc::new(value),
 		compspecs: res.compspecs,
 	}))
 }
@@ -1525,23 +1834,27 @@
 				let loop_invariant = over_taint.local_dependent_depth > outer_depth;
 				taint.taint_by(over_taint);
 
-				let frame_start = stack.begin_frame_alloc();
-				let Some(l_destruct) = stack.alloc_destruct(destruct, frame_start) else {
+				let mut alloc = FrameAlloc::new(stack);
+				let closure = alloc.push_locals_closure();
+				let Some(l_destruct) = alloc.alloc_destruct(destruct) else {
+					stack.pop_closure(closure);
 					return go(idx + 1, specs, outer_depth, stack, taint, inside);
 				};
-				let pending = stack.finish_frame_alloc(frame_start);
+				let mut pending = alloc.finish();
 
 				let var_analysis = AnalysisResult::default();
-				let mut closures = Closures::new(frame_start);
-				stack.record_spec_init(&pending, &l_destruct, var_analysis, &mut closures);
+				pending.record_spec_init(&l_destruct, var_analysis);
 
-				let body_frame = stack.finish_frame_init(pending, closures);
-				let (r, mut rest) = go(idx + 1, specs, outer_depth, stack, taint, inside);
-				stack.finish_frame_body(body_frame);
+				let body_frame = pending.finish();
+				let (r, mut rest) =
+					go(idx + 1, specs, outer_depth, body_frame.stack, taint, inside);
+				body_frame.finish();
+				let frame_shape = stack.pop_closure(closure);
 
 				rest.insert(
 					0,
 					LCompSpec::For {
+						frame_shape,
 						destruct: l_destruct,
 						over: over_l,
 						loop_invariant,
@@ -1570,18 +1883,37 @@
 		stack.define_external_local(name, id);
 	}
 
+	let externals_count: u16 = stack
+		.local_defs
+		.len()
+		.try_into()
+		.expect("more than u16::MAX externals");
+	let closure = stack.push_root_closure(externals_count);
+
 	let mut taint = AnalysisResult::default();
 	let lir = analyze(expr, &mut stack, &mut taint);
 
+	let root_shape = stack.pop_closure(closure);
+	debug_assert!(
+		stack.closure_stack.is_empty(),
+		"closure stack imbalance after analyze"
+	);
+
 	AnalysisReport {
 		lir,
+		root_shape,
 		root_analysis: taint,
 		diagnostics_list: stack.diagnostics,
 		errored: stack.errored,
 	}
 }
 
+#[cfg(test)]
 fn render_diagnostics(src: &str, diags: &[Diagnostic]) -> String {
+	use std::fmt::Write;
+
+	use hi_doc::{Formatting, SnippetBuilder, Text};
+
 	let mut out = String::new();
 	let mut unspanned = Vec::new();
 	let mut spanned: Vec<&Diagnostic> = Vec::new();
@@ -1620,6 +1952,7 @@
 
 pub struct AnalysisReport {
 	pub lir: LExpr,
+	pub root_shape: ClosureShape,
 	pub root_analysis: AnalysisResult,
 	pub diagnostics_list: Vec<Diagnostic>,
 	pub errored: bool,
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@array_comp.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@array_comp.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@array_comp.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/array_comp.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/array_comp.jsonnet
 ---
 --- source ---
 [x * 2 for x in [1, 2, 3] if x > 1]
@@ -13,10 +13,16 @@
 --- lir ---
 ArrComp(
     LArrComp {
+        value_shape: ClosureShape {
+            captures: [],
+            n_locals: 0,
+        },
         value: BinaryOp {
-            lhs: Local(
-                LocalId(
-                    0,
+            lhs: Slot(
+                Local(
+                    LocalSlot(
+                        0,
+                    ),
                 ),
             ),
             op: Mul,
@@ -26,13 +32,21 @@
         },
         compspecs: [
             For {
+                frame_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 1,
+                },
                 destruct: Full(
-                    LocalId(
+                    LocalSlot(
                         0,
                     ),
                 ),
-                over: Arr(
-                    [
+                over: Arr {
+                    shape: ClosureShape {
+                        captures: [],
+                        n_locals: 0,
+                    },
+                    items: [
                         Num(
                             1.0,
                         ),
@@ -43,14 +57,16 @@
                             3.0,
                         ),
                     ],
-                ),
+                },
                 loop_invariant: true,
             },
             If(
                 BinaryOp {
-                    lhs: Local(
-                        LocalId(
-                            0,
+                    lhs: Slot(
+                        Local(
+                            LocalSlot(
+                                0,
+                            ),
                         ),
                     ),
                     op: Gt,
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@dollar_deeply_nested.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@dollar_deeply_nested.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@dollar_deeply_nested.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/dollar_deeply_nested.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/dollar_deeply_nested.jsonnet
 ---
 --- source ---
 {
@@ -22,15 +22,19 @@
 Obj(
     MemberList(
         LObjMembers {
+            frame_shape: ClosureShape {
+                captures: [],
+                n_locals: 1,
+            },
             this: Some(
-                LocalId(
+                LocalSlot(
                     0,
                 ),
             ),
             set_dollar: true,
             uses_super: false,
             locals: [],
-            asserts: [],
+            asserts: None,
             fields: [
                 LFieldMember {
                     name: Fixed(
@@ -38,8 +42,14 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Str(
-                        "outer",
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Str(
+                            "outer",
+                        ),
                     ),
                 },
                 LFieldMember {
@@ -48,75 +58,135 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Obj(
-                        MemberList(
-                            LObjMembers {
-                                this: None,
-                                set_dollar: false,
-                                uses_super: false,
-                                locals: [],
-                                asserts: [],
-                                fields: [
-                                    LFieldMember {
-                                        name: Fixed(
-                                            "b",
-                                        ),
-                                        plus: false,
-                                        visibility: Normal,
-                                        value: Obj(
-                                            MemberList(
-                                                LObjMembers {
-                                                    this: Some(
-                                                        LocalId(
-                                                            2,
-                                                        ),
-                                                    ),
-                                                    set_dollar: false,
-                                                    uses_super: false,
-                                                    locals: [],
-                                                    asserts: [],
-                                                    fields: [
-                                                        LFieldMember {
-                                                            name: Fixed(
-                                                                "c",
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Obj(
+                            MemberList(
+                                LObjMembers {
+                                    frame_shape: ClosureShape {
+                                        captures: [
+                                            Local(
+                                                LocalSlot(
+                                                    0,
+                                                ),
+                                            ),
+                                        ],
+                                        n_locals: 1,
+                                    },
+                                    this: None,
+                                    set_dollar: false,
+                                    uses_super: false,
+                                    locals: [],
+                                    asserts: None,
+                                    fields: [
+                                        LFieldMember {
+                                            name: Fixed(
+                                                "b",
+                                            ),
+                                            plus: false,
+                                            visibility: Normal,
+                                            value: (
+                                                ClosureShape {
+                                                    captures: [
+                                                        Capture(
+                                                            CaptureSlot(
+                                                                0,
                                                             ),
-                                                            plus: false,
-                                                            visibility: Normal,
-                                                            value: Index {
-                                                                indexable: Local(
-                                                                    LocalId(
-                                                                        0,
+                                                        ),
+                                                    ],
+                                                    n_locals: 0,
+                                                },
+                                                Obj(
+                                                    MemberList(
+                                                        LObjMembers {
+                                                            frame_shape: ClosureShape {
+                                                                captures: [
+                                                                    Capture(
+                                                                        CaptureSlot(
+                                                                            0,
+                                                                        ),
                                                                     ),
-                                                                ),
-                                                                parts: [
-                                                                    LIndexPart {
-                                                                        span: virtual:<test>:45-48,
-                                                                        value: Str(
-                                                                            "top",
-                                                                        ),
-                                                                    },
                                                                 ],
+                                                                n_locals: 1,
                                                             },
-                                                        },
-                                                        LFieldMember {
-                                                            name: Fixed(
-                                                                "d",
-                                                            ),
-                                                            plus: false,
-                                                            visibility: Normal,
-                                                            value: Local(
-                                                                LocalId(
-                                                                    2,
+                                                            this: Some(
+                                                                LocalSlot(
+                                                                    0,
                                                                 ),
                                                             ),
+                                                            set_dollar: false,
+                                                            uses_super: false,
+                                                            locals: [],
+                                                            asserts: None,
+                                                            fields: [
+                                                                LFieldMember {
+                                                                    name: Fixed(
+                                                                        "c",
+                                                                    ),
+                                                                    plus: false,
+                                                                    visibility: Normal,
+                                                                    value: (
+                                                                        ClosureShape {
+                                                                            captures: [
+                                                                                Capture(
+                                                                                    CaptureSlot(
+                                                                                        0,
+                                                                                    ),
+                                                                                ),
+                                                                            ],
+                                                                            n_locals: 0,
+                                                                        },
+                                                                        Index {
+                                                                            indexable: Slot(
+                                                                                Capture(
+                                                                                    CaptureSlot(
+                                                                                        0,
+                                                                                    ),
+                                                                                ),
+                                                                            ),
+                                                                            parts: [
+                                                                                LIndexPart {
+                                                                                    span: virtual:<test>:45-48,
+                                                                                    value: Str(
+                                                                                        "top",
+                                                                                    ),
+                                                                                },
+                                                                            ],
+                                                                        },
+                                                                    ),
+                                                                },
+                                                                LFieldMember {
+                                                                    name: Fixed(
+                                                                        "d",
+                                                                    ),
+                                                                    plus: false,
+                                                                    visibility: Normal,
+                                                                    value: (
+                                                                        ClosureShape {
+                                                                            captures: [],
+                                                                            n_locals: 0,
+                                                                        },
+                                                                        Slot(
+                                                                            Local(
+                                                                                LocalSlot(
+                                                                                    0,
+                                                                                ),
+                                                                            ),
+                                                                        ),
+                                                                    ),
+                                                                },
+                                                            ],
                                                         },
-                                                    ],
-                                                },
+                                                    ),
+                                                ),
                                             ),
-                                        ),
-                                    },
-                                ],
-                            },
+                                        },
+                                    ],
+                                },
+                            ),
                         ),
                     ),
                 },
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@function_def.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@function_def.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@function_def.jsonnet.snap
@@ -11,94 +11,114 @@
 errored: false
 --- diagnostics ---
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
-                    0,
-                ),
-            ),
-            value: Function(
-                LFunction {
-                    name: Some(
-                        "f",
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 1,
+        },
+        binds: [
+            LBind {
+                destruct: Full(
+                    LocalSlot(
+                        0,
                     ),
-                    params: [
-                        LParam {
-                            name: Some(
-                                "x",
-                            ),
-                            destruct: Full(
-                                LocalId(
-                                    1,
-                                ),
-                            ),
-                            default: None,
-                        },
-                        LParam {
-                            name: Some(
-                                "y",
-                            ),
-                            destruct: Full(
-                                LocalId(
-                                    2,
-                                ),
-                            ),
-                            default: None,
-                        },
-                    ],
-                    signature: FunctionSignature(
-                        [
-                            ParamParse {
-                                name: Named(
+                ),
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Function(
+                    LFunction {
+                        name: Some(
+                            "f",
+                        ),
+                        params: [
+                            LParam {
+                                name: Some(
                                     "x",
                                 ),
+                                destruct: Full(
+                                    LocalSlot(
+                                        0,
+                                    ),
+                                ),
                                 default: None,
                             },
-                            ParamParse {
-                                name: Named(
+                            LParam {
+                                name: Some(
                                     "y",
                                 ),
+                                destruct: Full(
+                                    LocalSlot(
+                                        1,
+                                    ),
+                                ),
                                 default: None,
                             },
                         ],
-                    ),
-                    body: BinaryOp {
-                        lhs: Local(
-                            LocalId(
-                                1,
-                            ),
+                        signature: FunctionSignature(
+                            [
+                                ParamParse {
+                                    name: Named(
+                                        "x",
+                                    ),
+                                    default: None,
+                                },
+                                ParamParse {
+                                    name: Named(
+                                        "y",
+                                    ),
+                                    default: None,
+                                },
+                            ],
                         ),
-                        op: Add,
-                        rhs: Local(
-                            LocalId(
-                                2,
+                        body_shape: ClosureShape {
+                            captures: [],
+                            n_locals: 2,
+                        },
+                        body: BinaryOp {
+                            lhs: Slot(
+                                Local(
+                                    LocalSlot(
+                                        0,
+                                    ),
+                                ),
                             ),
-                        ),
+                            op: Add,
+                            rhs: Slot(
+                                Local(
+                                    LocalSlot(
+                                        1,
+                                    ),
+                                ),
+                            ),
+                        },
                     },
-                },
-            ),
-        },
-    ],
-    body: Apply {
-        applicable: Local(
-            LocalId(
-                0,
-            ),
-        ),
-        args: LArgsDesc {
-            unnamed: [
-                Num(
-                    1.0,
                 ),
-                Num(
-                    2.0,
+            },
+        ],
+        body: Apply {
+            applicable: Slot(
+                Local(
+                    LocalSlot(
+                        0,
+                    ),
                 ),
-            ],
-            names: [],
-            values: [],
-        } from virtual:<test>:24-30,
-        tailstrict: false,
+            ),
+            args: LArgsDesc {
+                unnamed: [
+                    Num(
+                        1.0,
+                    ),
+                    Num(
+                        2.0,
+                    ),
+                ],
+                names: [],
+                values: [],
+            } from virtual:<test>:24-30,
+            tailstrict: false,
+        },
     },
-}
+)
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@hoistable_local.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@hoistable_local.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@hoistable_local.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/hoistable_local.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/hoistable_local.jsonnet
 ---
 --- source ---
 local outer = 1; local inner = 10 + 20; outer + inner
@@ -14,50 +14,80 @@
 1  │ local outer = 1; local inner = 10 + 20; outer + inner 
 2  │  
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
-                    0,
-                ),
-            ),
-            value: Num(
-                1.0,
-            ),
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 1,
         },
-    ],
-    body: LocalExpr {
         binds: [
             LBind {
                 destruct: Full(
-                    LocalId(
-                        1,
+                    LocalSlot(
+                        0,
                     ),
                 ),
-                value: BinaryOp {
-                    lhs: Num(
-                        10.0,
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Num(
+                    1.0,
+                ),
+            },
+        ],
+        body: LocalExpr(
+            LLocalExpr {
+                frame_shape: ClosureShape {
+                    captures: [
+                        Local(
+                            LocalSlot(
+                                0,
+                            ),
+                        ),
+                    ],
+                    n_locals: 1,
+                },
+                binds: [
+                    LBind {
+                        destruct: Full(
+                            LocalSlot(
+                                0,
+                            ),
+                        ),
+                        value_shape: ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        value: BinaryOp {
+                            lhs: Num(
+                                10.0,
+                            ),
+                            op: Add,
+                            rhs: Num(
+                                20.0,
+                            ),
+                        },
+                    },
+                ],
+                body: BinaryOp {
+                    lhs: Slot(
+                        Capture(
+                            CaptureSlot(
+                                0,
+                            ),
+                        ),
                     ),
                     op: Add,
-                    rhs: Num(
-                        20.0,
+                    rhs: Slot(
+                        Local(
+                            LocalSlot(
+                                0,
+                            ),
+                        ),
                     ),
                 },
             },
-        ],
-        body: BinaryOp {
-            lhs: Local(
-                LocalId(
-                    0,
-                ),
-            ),
-            op: Add,
-            rhs: Local(
-                LocalId(
-                    1,
-                ),
-            ),
-        },
+        ),
     },
-}
+)
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@loop_invariant.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@loop_invariant.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@loop_invariant.jsonnet.snap
@@ -18,23 +18,41 @@
 --- lir ---
 ArrComp(
     LArrComp {
+        value_shape: ClosureShape {
+            captures: [
+                Capture(
+                    CaptureSlot(
+                        0,
+                    ),
+                ),
+            ],
+            n_locals: 0,
+        },
         value: BinaryOp {
-            lhs: Local(
-                LocalId(
-                    0,
+            lhs: Slot(
+                Capture(
+                    CaptureSlot(
+                        0,
+                    ),
                 ),
             ),
             op: Lt,
-            rhs: Local(
-                LocalId(
-                    1,
+            rhs: Slot(
+                Local(
+                    LocalSlot(
+                        0,
+                    ),
                 ),
             ),
         },
         compspecs: [
             For {
+                frame_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 1,
+                },
                 destruct: Full(
-                    LocalId(
+                    LocalSlot(
                         0,
                     ),
                 ),
@@ -69,9 +87,19 @@
                 loop_invariant: true,
             },
             For {
+                frame_shape: ClosureShape {
+                    captures: [
+                        Local(
+                            LocalSlot(
+                                0,
+                            ),
+                        ),
+                    ],
+                    n_locals: 1,
+                },
                 destruct: Full(
-                    LocalId(
-                        1,
+                    LocalSlot(
+                        0,
                     ),
                 ),
                 over: Apply {
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@mutual_recursion.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@mutual_recursion.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@mutual_recursion.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/mutual_recursion.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/mutual_recursion.jsonnet
 ---
 --- source ---
 local a = b, b = 1; a + 2
@@ -11,40 +11,58 @@
 errored: false
 --- diagnostics ---
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
-                    0,
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 2,
+        },
+        binds: [
+            LBind {
+                destruct: Full(
+                    LocalSlot(
+                        0,
+                    ),
+                ),
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Slot(
+                    Local(
+                        LocalSlot(
+                            1,
+                        ),
+                    ),
+                ),
+            },
+            LBind {
+                destruct: Full(
+                    LocalSlot(
+                        1,
+                    ),
                 ),
-            ),
-            value: Local(
-                LocalId(
-                    1,
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Num(
+                    1.0,
                 ),
-            ),
-        },
-        LBind {
-            destruct: Full(
-                LocalId(
-                    1,
+            },
+        ],
+        body: BinaryOp {
+            lhs: Slot(
+                Local(
+                    LocalSlot(
+                        0,
+                    ),
                 ),
             ),
-            value: Num(
-                1.0,
+            op: Add,
+            rhs: Num(
+                2.0,
             ),
         },
-    ],
-    body: BinaryOp {
-        lhs: Local(
-            LocalId(
-                0,
-            ),
-        ),
-        op: Add,
-        rhs: Num(
-            2.0,
-        ),
     },
-}
+)
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@nested_object_independent.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@nested_object_independent.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@nested_object_independent.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analysis_golden/nested_object_independent.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/nested_object_independent.jsonnet
 ---
 --- source ---
 {
@@ -19,11 +19,15 @@
 Obj(
     MemberList(
         LObjMembers {
+            frame_shape: ClosureShape {
+                captures: [],
+                n_locals: 1,
+            },
             this: None,
             set_dollar: false,
             uses_super: false,
             locals: [],
-            asserts: [],
+            asserts: None,
             fields: [
                 LFieldMember {
                     name: Fixed(
@@ -31,8 +35,14 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Num(
-                        1.0,
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Num(
+                            1.0,
+                        ),
                     ),
                 },
                 LFieldMember {
@@ -41,53 +51,77 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Obj(
-                        MemberList(
-                            LObjMembers {
-                                this: Some(
-                                    LocalId(
-                                        1,
-                                    ),
-                                ),
-                                set_dollar: false,
-                                uses_super: false,
-                                locals: [],
-                                asserts: [],
-                                fields: [
-                                    LFieldMember {
-                                        name: Fixed(
-                                            "c",
-                                        ),
-                                        plus: false,
-                                        visibility: Normal,
-                                        value: Num(
-                                            2.0,
-                                        ),
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Obj(
+                            MemberList(
+                                LObjMembers {
+                                    frame_shape: ClosureShape {
+                                        captures: [],
+                                        n_locals: 1,
                                     },
-                                    LFieldMember {
-                                        name: Fixed(
-                                            "d",
+                                    this: Some(
+                                        LocalSlot(
+                                            0,
                                         ),
-                                        plus: false,
-                                        visibility: Normal,
-                                        value: Index {
-                                            indexable: Local(
-                                                LocalId(
-                                                    1,
+                                    ),
+                                    set_dollar: false,
+                                    uses_super: false,
+                                    locals: [],
+                                    asserts: None,
+                                    fields: [
+                                        LFieldMember {
+                                            name: Fixed(
+                                                "c",
+                                            ),
+                                            plus: false,
+                                            visibility: Normal,
+                                            value: (
+                                                ClosureShape {
+                                                    captures: [],
+                                                    n_locals: 0,
+                                                },
+                                                Num(
+                                                    2.0,
                                                 ),
                                             ),
-                                            parts: [
-                                                LIndexPart {
-                                                    span: virtual:<test>:35-36,
-                                                    value: Str(
-                                                        "c",
+                                        },
+                                        LFieldMember {
+                                            name: Fixed(
+                                                "d",
+                                            ),
+                                            plus: false,
+                                            visibility: Normal,
+                                            value: (
+                                                ClosureShape {
+                                                    captures: [],
+                                                    n_locals: 0,
+                                                },
+                                                Index {
+                                                    indexable: Slot(
+                                                        Local(
+                                                            LocalSlot(
+                                                                0,
+                                                            ),
+                                                        ),
                                                     ),
+                                                    parts: [
+                                                        LIndexPart {
+                                                            span: virtual:<test>:35-36,
+                                                            value: Str(
+                                                                "c",
+                                                            ),
+                                                        },
+                                                    ],
                                                 },
-                                            ],
+                                            ),
                                         },
-                                    },
-                                ],
-                            },
+                                    ],
+                                },
+                            ),
                         ),
                     ),
                 },
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_comp.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_comp.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_comp.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/object_comp.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/object_comp.jsonnet
 ---
 --- source ---
 { [k]: k for k in ['a', 'b'] }
@@ -14,35 +14,69 @@
 Obj(
     ObjComp(
         LObjComp {
+            frame_shape: ClosureShape {
+                captures: [
+                    Local(
+                        LocalSlot(
+                            0,
+                        ),
+                    ),
+                ],
+                n_locals: 1,
+            },
             this: None,
             set_dollar: false,
             uses_super: false,
             locals: [],
             field: LFieldMember {
                 name: Dyn(
-                    Local(
-                        LocalId(
-                            0,
+                    Slot(
+                        Local(
+                            LocalSlot(
+                                0,
+                            ),
                         ),
                     ),
                 ),
                 plus: false,
                 visibility: Normal,
-                value: Local(
-                    LocalId(
-                        0,
+                value: (
+                    ClosureShape {
+                        captures: [
+                            Capture(
+                                CaptureSlot(
+                                    0,
+                                ),
+                            ),
+                        ],
+                        n_locals: 0,
+                    },
+                    Slot(
+                        Capture(
+                            CaptureSlot(
+                                0,
+                            ),
+                        ),
                     ),
                 ),
             },
             compspecs: [
                 For {
+                    frame_shape: ClosureShape {
+                        captures: [],
+                        n_locals: 1,
+                    },
                     destruct: Full(
-                        LocalId(
+                        LocalSlot(
                             0,
                         ),
                     ),
-                    over: Arr(
-                        [
+                    over: Arr {
+                        shape: ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        items: [
                             Str(
                                 "a",
                             ),
@@ -50,7 +84,7 @@
                                 "b",
                             ),
                         ],
-                    ),
+                    },
                     loop_invariant: true,
                 },
             ],
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_dollar.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_dollar.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_dollar.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/object_dollar.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/object_dollar.jsonnet
 ---
 --- source ---
 { a: 1, b: { c: $.a } }
@@ -14,15 +14,19 @@
 Obj(
     MemberList(
         LObjMembers {
+            frame_shape: ClosureShape {
+                captures: [],
+                n_locals: 1,
+            },
             this: Some(
-                LocalId(
+                LocalSlot(
                     0,
                 ),
             ),
             set_dollar: true,
             uses_super: false,
             locals: [],
-            asserts: [],
+            asserts: None,
             fields: [
                 LFieldMember {
                     name: Fixed(
@@ -30,8 +34,14 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Num(
-                        1.0,
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Num(
+                            1.0,
+                        ),
                     ),
                 },
                 LFieldMember {
@@ -40,39 +50,69 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Obj(
-                        MemberList(
-                            LObjMembers {
-                                this: None,
-                                set_dollar: false,
-                                uses_super: false,
-                                locals: [],
-                                asserts: [],
-                                fields: [
-                                    LFieldMember {
-                                        name: Fixed(
-                                            "c",
-                                        ),
-                                        plus: false,
-                                        visibility: Normal,
-                                        value: Index {
-                                            indexable: Local(
-                                                LocalId(
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Obj(
+                            MemberList(
+                                LObjMembers {
+                                    frame_shape: ClosureShape {
+                                        captures: [
+                                            Local(
+                                                LocalSlot(
                                                     0,
                                                 ),
                                             ),
-                                            parts: [
-                                                LIndexPart {
-                                                    span: virtual:<test>:18-19,
-                                                    value: Str(
-                                                        "a",
+                                        ],
+                                        n_locals: 1,
+                                    },
+                                    this: None,
+                                    set_dollar: false,
+                                    uses_super: false,
+                                    locals: [],
+                                    asserts: None,
+                                    fields: [
+                                        LFieldMember {
+                                            name: Fixed(
+                                                "c",
+                                            ),
+                                            plus: false,
+                                            visibility: Normal,
+                                            value: (
+                                                ClosureShape {
+                                                    captures: [
+                                                        Capture(
+                                                            CaptureSlot(
+                                                                0,
+                                                            ),
+                                                        ),
+                                                    ],
+                                                    n_locals: 0,
+                                                },
+                                                Index {
+                                                    indexable: Slot(
+                                                        Capture(
+                                                            CaptureSlot(
+                                                                0,
+                                                            ),
+                                                        ),
                                                     ),
+                                                    parts: [
+                                                        LIndexPart {
+                                                            span: virtual:<test>:18-19,
+                                                            value: Str(
+                                                                "a",
+                                                            ),
+                                                        },
+                                                    ],
                                                 },
-                                            ],
+                                            ),
                                         },
-                                    },
-                                ],
-                            },
+                                    ],
+                                },
+                            ),
                         ),
                     ),
                 },
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_self.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_self.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_self.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/object_self.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/object_self.jsonnet
 ---
 --- source ---
 { a: 1, b: self.a }
@@ -14,15 +14,19 @@
 Obj(
     MemberList(
         LObjMembers {
+            frame_shape: ClosureShape {
+                captures: [],
+                n_locals: 1,
+            },
             this: Some(
-                LocalId(
+                LocalSlot(
                     0,
                 ),
             ),
             set_dollar: false,
             uses_super: false,
             locals: [],
-            asserts: [],
+            asserts: None,
             fields: [
                 LFieldMember {
                     name: Fixed(
@@ -30,8 +34,14 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Num(
-                        1.0,
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Num(
+                            1.0,
+                        ),
                     ),
                 },
                 LFieldMember {
@@ -40,21 +50,29 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Index {
-                        indexable: Local(
-                            LocalId(
-                                0,
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Index {
+                            indexable: Slot(
+                                Local(
+                                    LocalSlot(
+                                        0,
+                                    ),
+                                ),
                             ),
-                        ),
-                        parts: [
-                            LIndexPart {
-                                span: virtual:<test>:16-17,
-                                value: Str(
-                                    "a",
-                                ),
-                            },
-                        ],
-                    },
+                            parts: [
+                                LIndexPart {
+                                    span: virtual:<test>:16-17,
+                                    value: Str(
+                                        "a",
+                                    ),
+                                },
+                            ],
+                        },
+                    ),
                 },
             ],
         },
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_with_locals.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_with_locals.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@object_with_locals.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/object_with_locals.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/object_with_locals.jsonnet
 ---
 --- source ---
 {
@@ -18,22 +18,30 @@
 Obj(
     MemberList(
         LObjMembers {
+            frame_shape: ClosureShape {
+                captures: [],
+                n_locals: 2,
+            },
             this: None,
             set_dollar: false,
             uses_super: false,
             locals: [
                 LBind {
                     destruct: Full(
-                        LocalId(
+                        LocalSlot(
                             1,
                         ),
                     ),
+                    value_shape: ClosureShape {
+                        captures: [],
+                        n_locals: 0,
+                    },
                     value: Num(
                         10.0,
                     ),
                 },
             ],
-            asserts: [],
+            asserts: None,
             fields: [
                 LFieldMember {
                     name: Fixed(
@@ -41,9 +49,17 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: Local(
-                        LocalId(
-                            1,
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        Slot(
+                            Local(
+                                LocalSlot(
+                                    1,
+                                ),
+                            ),
                         ),
                     ),
                 },
@@ -53,17 +69,25 @@
                     ),
                     plus: false,
                     visibility: Normal,
-                    value: BinaryOp {
-                        lhs: Local(
-                            LocalId(
-                                1,
+                    value: (
+                        ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        BinaryOp {
+                            lhs: Slot(
+                                Local(
+                                    LocalSlot(
+                                        1,
+                                    ),
+                                ),
+                            ),
+                            op: Mul,
+                            rhs: Num(
+                                2.0,
                             ),
-                        ),
-                        op: Mul,
-                        rhs: Num(
-                            2.0,
-                        ),
-                    },
+                        },
+                    ),
                 },
             ],
         },
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@redeclared_local.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@redeclared_local.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@redeclared_local.jsonnet.snap
@@ -14,22 +14,34 @@
 1  │ local x = 1, x = 2; x 
 2  │  
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 1,
+        },
+        binds: [
+            LBind {
+                destruct: Full(
+                    LocalSlot(
+                        0,
+                    ),
+                ),
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Num(
+                    1.0,
+                ),
+            },
+        ],
+        body: Slot(
+            Local(
+                LocalSlot(
                     0,
                 ),
             ),
-            value: Num(
-                1.0,
-            ),
-        },
-    ],
-    body: Local(
-        LocalId(
-            0,
         ),
-    ),
-}
+    },
+)
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@shadowing.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@shadowing.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@shadowing.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/shadowing.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/shadowing.jsonnet
 ---
 --- source ---
 local x = 1; local x = 2; x
@@ -15,36 +15,58 @@
    ·       ╰── unused local: x
 2  │  
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
-                    0,
-                ),
-            ),
-            value: Num(
-                1.0,
-            ),
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 1,
         },
-    ],
-    body: LocalExpr {
         binds: [
             LBind {
                 destruct: Full(
-                    LocalId(
-                        1,
+                    LocalSlot(
+                        0,
                     ),
                 ),
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
                 value: Num(
-                    2.0,
+                    1.0,
                 ),
             },
         ],
-        body: Local(
-            LocalId(
-                1,
-            ),
+        body: LocalExpr(
+            LLocalExpr {
+                frame_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 1,
+                },
+                binds: [
+                    LBind {
+                        destruct: Full(
+                            LocalSlot(
+                                0,
+                            ),
+                        ),
+                        value_shape: ClosureShape {
+                            captures: [],
+                            n_locals: 0,
+                        },
+                        value: Num(
+                            2.0,
+                        ),
+                    },
+                ],
+                body: Slot(
+                    Local(
+                        LocalSlot(
+                            0,
+                        ),
+                    ),
+                ),
+            },
         ),
     },
-}
+)
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@simple_local.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@simple_local.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@simple_local.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/simple_local.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/simple_local.jsonnet
 ---
 --- source ---
 local x = 1; x + 2
@@ -11,28 +11,40 @@
 errored: false
 --- diagnostics ---
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
-                    0,
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 1,
+        },
+        binds: [
+            LBind {
+                destruct: Full(
+                    LocalSlot(
+                        0,
+                    ),
+                ),
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Num(
+                    1.0,
+                ),
+            },
+        ],
+        body: BinaryOp {
+            lhs: Slot(
+                Local(
+                    LocalSlot(
+                        0,
+                    ),
                 ),
             ),
-            value: Num(
-                1.0,
+            op: Add,
+            rhs: Num(
+                2.0,
             ),
         },
-    ],
-    body: BinaryOp {
-        lhs: Local(
-            LocalId(
-                0,
-            ),
-        ),
-        op: Add,
-        rhs: Num(
-            2.0,
-        ),
     },
-}
+)
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@slice.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@slice.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@slice.jsonnet.snap
@@ -1,7 +1,8 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
+assertion_line: 2017
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/slice.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/slice.jsonnet
 ---
 --- source ---
 [1, 2, 3, 4, 5][1:3]
@@ -13,8 +14,12 @@
 --- lir ---
 Slice(
     LSliceExpr {
-        value: Arr(
-            [
+        value: Arr {
+            shape: ClosureShape {
+                captures: [],
+                n_locals: 0,
+            },
+            items: [
                 Num(
                     1.0,
                 ),
@@ -31,7 +36,7 @@
                     5.0,
                 ),
             ],
-        ),
+        },
         start: Some(
             Num(
                 1.0,
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@super_usage.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@super_usage.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@super_usage.jsonnet.snap
@@ -15,11 +15,15 @@
     lhs: Obj(
         MemberList(
             LObjMembers {
+                frame_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 1,
+                },
                 this: None,
                 set_dollar: false,
                 uses_super: false,
                 locals: [],
-                asserts: [],
+                asserts: None,
                 fields: [
                     LFieldMember {
                         name: Fixed(
@@ -27,8 +31,14 @@
                         ),
                         plus: false,
                         visibility: Normal,
-                        value: Num(
-                            1.0,
+                        value: (
+                            ClosureShape {
+                                captures: [],
+                                n_locals: 0,
+                            },
+                            Num(
+                                1.0,
+                            ),
                         ),
                     },
                     LFieldMember {
@@ -37,8 +47,14 @@
                         ),
                         plus: false,
                         visibility: Normal,
-                        value: Num(
-                            2.0,
+                        value: (
+                            ClosureShape {
+                                captures: [],
+                                n_locals: 0,
+                            },
+                            Num(
+                                2.0,
+                            ),
                         ),
                     },
                 ],
@@ -49,15 +65,19 @@
     rhs: Obj(
         MemberList(
             LObjMembers {
+                frame_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 1,
+                },
                 this: Some(
-                    LocalId(
+                    LocalSlot(
                         0,
                     ),
                 ),
                 set_dollar: false,
                 uses_super: true,
                 locals: [],
-                asserts: [],
+                asserts: None,
                 fields: [
                     LFieldMember {
                         name: Fixed(
@@ -65,23 +85,29 @@
                         ),
                         plus: false,
                         visibility: Normal,
-                        value: BinaryOp {
-                            lhs: Index {
-                                indexable: Super,
-                                parts: [
-                                    LIndexPart {
-                                        span: virtual:<test>:28-29,
-                                        value: Str(
-                                            "a",
-                                        ),
-                                    },
-                                ],
+                        value: (
+                            ClosureShape {
+                                captures: [],
+                                n_locals: 0,
+                            },
+                            BinaryOp {
+                                lhs: Index {
+                                    indexable: Super,
+                                    parts: [
+                                        LIndexPart {
+                                            span: virtual:<test>:28-29,
+                                            value: Str(
+                                                "a",
+                                            ),
+                                        },
+                                    ],
+                                },
+                                op: Add,
+                                rhs: Num(
+                                    10.0,
+                                ),
                             },
-                            op: Add,
-                            rhs: Num(
-                                10.0,
-                            ),
-                        },
+                        ),
                     },
                     LFieldMember {
                         name: Fixed(
@@ -89,21 +115,29 @@
                         ),
                         plus: false,
                         visibility: Normal,
-                        value: Index {
-                            indexable: Local(
-                                LocalId(
-                                    0,
-                                ),
-                            ),
-                            parts: [
-                                LIndexPart {
-                                    span: virtual:<test>:44-45,
-                                    value: Str(
-                                        "b",
+                        value: (
+                            ClosureShape {
+                                captures: [],
+                                n_locals: 0,
+                            },
+                            Index {
+                                indexable: Slot(
+                                    Local(
+                                        LocalSlot(
+                                            0,
+                                        ),
                                     ),
-                                },
-                            ],
-                        },
+                                ),
+                                parts: [
+                                    LIndexPart {
+                                        span: virtual:<test>:44-45,
+                                        value: Str(
+                                            "b",
+                                        ),
+                                    },
+                                ],
+                            },
+                        ),
                     },
                 ],
             },
modifiedcrates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@unused_local.jsonnet.snapdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@unused_local.jsonnet.snap
+++ b/crates/jrsonnet-evaluator/src/snapshots/jrsonnet_evaluator__analyze__tests__snapshots@unused_local.jsonnet.snap
@@ -1,7 +1,7 @@
 ---
 source: crates/jrsonnet-evaluator/src/analyze.rs
 expression: rendered
-input_file: crates/jrsonnet-evaluator/src/analyze_tests/unused_local.jsonnet
+input_file: crates/jrsonnet-evaluator/src/analysis_tests/unused_local.jsonnet
 ---
 --- source ---
 local unused = 1; 2
@@ -14,20 +14,30 @@
 1  │ local unused = 1; 2 
 2  │  
 --- lir ---
-LocalExpr {
-    binds: [
-        LBind {
-            destruct: Full(
-                LocalId(
-                    0,
+LocalExpr(
+    LLocalExpr {
+        frame_shape: ClosureShape {
+            captures: [],
+            n_locals: 1,
+        },
+        binds: [
+            LBind {
+                destruct: Full(
+                    LocalSlot(
+                        0,
+                    ),
+                ),
+                value_shape: ClosureShape {
+                    captures: [],
+                    n_locals: 0,
+                },
+                value: Num(
+                    1.0,
                 ),
-            ),
-            value: Num(
-                1.0,
-            ),
-        },
-    ],
-    body: Num(
-        2.0,
-    ),
-}
+            },
+        ],
+        body: Num(
+            2.0,
+        ),
+    },
+)
modifiedtests/go_testdata_golden_override/bad_function_call.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/bad_function_call.jsonnet.golden
+++ b/tests/go_testdata_golden_override/bad_function_call.jsonnet.golden
@@ -1,3 +1,3 @@
 function argument is not passed: x
 Function has the following signature: (x)
-    bad_function_call.jsonnet:1:16-19: function <anonymous> preparation
\ No newline at end of file
+    bad_function_call.jsonnet:1:16-19: function <builtin_id> preparation
\ No newline at end of file
modifiedtests/go_testdata_golden_override/bad_function_call2.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/bad_function_call2.jsonnet.golden
+++ b/tests/go_testdata_golden_override/bad_function_call2.jsonnet.golden
@@ -1,3 +1,3 @@
 too many args, function has 1
 Function has the following signature: (x)
-    bad_function_call2.jsonnet:1:16-23: function <anonymous> preparation
\ No newline at end of file
+    bad_function_call2.jsonnet:1:16-23: function <builtin_id> preparation
\ No newline at end of file
modifiedtests/go_testdata_golden_override/bad_function_call_and_error.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/bad_function_call_and_error.jsonnet.golden
+++ b/tests/go_testdata_golden_override/bad_function_call_and_error.jsonnet.golden
@@ -1,3 +1,3 @@
 too many args, function has 1
 Function has the following signature: (x)
-    bad_function_call_and_error.jsonnet:1:16-39: function <anonymous> preparation
\ No newline at end of file
+    bad_function_call_and_error.jsonnet:1:16-39: function <builtin_id> preparation
\ No newline at end of file
modifiedtests/go_testdata_golden_override/optional_args9.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/optional_args9.jsonnet.golden
+++ b/tests/go_testdata_golden_override/optional_args9.jsonnet.golden
@@ -1,2 +1,2 @@
 argument x is already bound
-    optional_args9.jsonnet:1:16-27: function <anonymous> preparation
\ No newline at end of file
+    optional_args9.jsonnet:1:16-27: function <builtin_id> preparation
\ No newline at end of file
deletedtests/golden/comp_if_with_multiple_captures.jsonnetdiffbeforeafterboth
--- a/tests/golden/comp_if_with_multiple_captures.jsonnet
+++ /dev/null
@@ -1,3 +0,0 @@
-local features = { gc: 'serialgc', libc: 'musl' };
-local order = ['gc', 'libc', 'missing'];
-[features[f] for f in order if std.objectHas(features, f)]
deletedtests/golden/object_assert_after_member_local.jsonnetdiffbeforeafterboth
--- a/tests/golden/object_assert_after_member_local.jsonnet
+++ /dev/null
@@ -1,7 +0,0 @@
-local outer1 = 'one';
-local outer2 = 'two';
-{
-  local member_local = outer1,
-  assert outer2 == 'two' : 'wrong outer2: ' + outer2,
-  result: member_local,
-}.result
addedtests/suite/comp_eager_array_body_capture.jsonnetdiffbeforeafterboth
--- /dev/null
+++ b/tests/suite/comp_eager_array_body_capture.jsonnet
@@ -0,0 +1,2 @@
+std.assertEqual([[v] for v in ['a', 'b']], [['a'], ['b']])
+&& std.assertEqual(std.flattenArrays([[{ x: v }] for v in ['a', 'b']]), [{ x: 'a' }, { x: 'b' }])
addedtests/suite/comp_if_with_multiple_captures.jsonnetdiffbeforeafterboth
--- /dev/null
+++ b/tests/suite/comp_if_with_multiple_captures.jsonnet
@@ -0,0 +1,6 @@
+local features = { gc: 'serialgc', libc: 'musl' };
+local order = ['gc', 'libc', 'missing'];
+std.assertEqual(
+	[features[f] for f in order if std.objectHas(features, f)],
+	['serialgc', 'musl']
+)
addedtests/suite/object_assert_after_member_local.jsonnetdiffbeforeafterboth
--- /dev/null
+++ b/tests/suite/object_assert_after_member_local.jsonnet
@@ -0,0 +1,7 @@
+local outer1 = 'one';
+local outer2 = 'two';
+std.assertEqual({
+  local member_local = outer1,
+  assert outer2 == 'two' : 'wrong outer2: ' + outer2,
+  result: member_local,
+}.result, 'one')
modifiedtests/tests/common.rsdiffbeforeafterboth
--- a/tests/tests/common.rs
+++ b/tests/tests/common.rs
@@ -1,6 +1,6 @@
 use jrsonnet_evaluator::{
-	ContextBuilder, ContextInitializer as ContextInitializerT, InitialContextBuilder,
-	ObjValueBuilder, Result, Source, Thunk, Val, bail,
+	ContextInitializer as ContextInitializerT, InitialContextBuilder, ObjValueBuilder, Result,
+	Source, Thunk, Val, bail,
 	function::{FuncVal, builtin},
 };
 use jrsonnet_gcmodule::Trace;
@@ -29,7 +29,7 @@
 macro_rules! ensure_val_eq {
 	($a:expr, $b:expr) => {{
 		if !::jrsonnet_evaluator::val::equals(&$a.clone(), &$b.clone())? {
-			use ::jrsonnet_evaluator::manifest::JsonFormat;
+			use jrsonnet_evaluator::manifest::JsonFormat;
 			::jrsonnet_evaluator::bail!(
 				"assertion failed: a != b\na={:#?}\nb={:#?}",
 				$a.manifest(JsonFormat::default())?,