= BTreeMap::new();
+ for (name, value) in entries {
+ let Ok(name_ident) = Ident::new(name) else {
+ continue;
+ };
+ match fields.get(&name_ident) {
+ Some(field_ty) => {
+ self.check(value, field_ty);
+ }
+ None => {
+ self.error(
+ codes::UNKNOWN_FIELD,
+ format!("no field `{name}` on {}", expected.describe()),
+ value.span,
+ );
+ }
+ }
+ bound.insert(name_ident, value.span);
+ }
+ for (field, field_ty) in fields {
+ if !bound.contains_key(field) && !matches!(field_ty, Ty::Option(_)) {
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!("record literal is missing required field `{field}`"),
+ e.span,
+ );
+ }
+ }
+ }
+ (ast::ExprKind::Record(entries), Ty::Map(..)) if entries.is_empty() => {}
+ (ast::ExprKind::If { cond, then, els }, _) => {
+ self.check(cond, &Ty::Bool);
+ self.check(then, expected);
+ self.check(els, expected);
+ }
+ _ => {
+ let ty = self.infer(e);
+ if !compatible(expected, &ty) {
+ self.mismatch(expected, &ty, e.span);
+ }
+ }
+ }
+ }
+
+ fn mismatch(&mut self, expected: &Ty, actual: &Ty, span: Span) {
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!(
+ "expected {}, got {}",
+ expected.describe(),
+ actual.describe()
+ ),
+ span,
+ );
+ }
+
+ // ── statements (§4.2) ──────────────────────────────────────────────
+
+ pub fn check_stmt(&mut self, stmt: &ast::Stmt) {
+ match stmt {
+ ast::Stmt::Error { .. } => {}
+ ast::Stmt::Set {
+ path, value, span, ..
+ } => self.check_set(path, value, *span),
+ ast::Stmt::Send {
+ command,
+ args,
+ bind,
+ span,
+ ..
+ } => self.check_send(command, args, bind.as_deref(), *span),
+ ast::Stmt::OpenSurface {
+ name, args, span, ..
+ } => {
+ self.check_open_surface(name, args, *span);
+ }
+ ast::Stmt::Dismiss { span, .. } => {
+ if !matches!(self.env.kind, SubjectKind::Surface { .. }) {
+ self.error(
+ codes::DISMISS_OUTSIDE_SURFACE,
+ "`dismiss` pops the surface instance — only surfaces have one".to_string(),
+ *span,
+ );
+ }
+ }
+ ast::Stmt::Navigate { target, span, .. } => self.check_navigate(target, *span),
+ }
+ }
+
+ fn check_set(&mut self, path: &ast::SetPath, value: &ast::Expr, _span: Span) {
+ let Ok(field) = Ident::new(&path.field) else {
+ return;
+ };
+ let Some(field_ty) = self.env.state.get(&field).cloned() else {
+ let suggestion = did_you_mean(&field, self.env.state.keys()).cloned();
+ let mut d = Diagnostic::error(
+ codes::UNRESOLVED_NAME.0,
+ codes::UNRESOLVED_NAME.1,
+ format!("`set` writes own-scope state; no state field `{field}`"),
+ path.span,
+ );
+ if let Some(s) = suggestion {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ }
+ self.diags.push(d);
+ self.infer(value);
+ return;
+ };
+ match (&path.key, field_ty) {
+ (None, ty) => self.check(value, &ty),
+ (Some(key), Ty::Map(k, v)) => {
+ let key_ty = match k {
+ MapKey::Id => Ty::Id,
+ MapKey::Tag => Ty::Tag,
+ };
+ self.check(key, &key_ty);
+ // `= none` removes the entry (§4.2), so the value position
+ // is effectively optional.
+ self.check(value, &Ty::Option(v));
+ }
+ (Some(_), other) => {
+ self.error(
+ codes::BAD_INDEX,
+ format!(
+ "`{field}[…]` writes a map entry, but `{field}` is {}",
+ other.describe()
+ ),
+ path.span,
+ );
+ self.infer(value);
+ }
+ }
+ }
+
+ fn check_send(&mut self, command: &str, args: &[ast::Arg], bind: Option<&str>, span: Span) {
+ let Ok(cmd_ident) = Ident::new(command) else {
+ return;
+ };
+ let Some(info) = self.env.commands.get(&cmd_ident) else {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_COMMAND.0,
+ codes::UNKNOWN_COMMAND.1,
+ format!("no command `{command}` is imported"),
+ span,
+ );
+ if let Some(s) = did_you_mean(&cmd_ident, self.env.commands.keys()) {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ } else {
+ d = d.with_note("import it: `use port { command }`".to_string());
+ }
+ self.diags.push(d);
+ for arg in args {
+ self.infer(&arg.value);
+ }
+ return;
+ };
+ let payload = info.payload.clone();
+ self.check_named_args(args, &payload, "command payload", span);
+ if let Some(bind) = bind {
+ self.push_local(bind, Ty::Tag, span);
+ }
+ }
+
+ fn check_open_surface(&mut self, name: &str, args: &[ast::Arg], span: Span) {
+ let Ok(surface) = Ident::new(name) else {
+ return;
+ };
+ if !self.env.surface_imports.contains_key(&surface) {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_SURFACE.0,
+ codes::UNKNOWN_SURFACE.1,
+ format!("no surface `{surface}` is imported"),
+ span,
+ );
+ if self.resolved.surfaces.contains_key(&surface) {
+ d = d.with_note(format!("add `use surface {surface}`"));
+ }
+ self.diags.push(d);
+ for arg in args {
+ self.infer(&arg.value);
+ }
+ return;
+ }
+ let Some(target) = self.resolved.surfaces.get(&surface) else {
+ return; // unknown-import already diagnosed
+ };
+ let props: Vec<(Ident, Ty)> = target
+ .props
+ .iter()
+ .map(|(n, t)| (n.clone(), t.clone()))
+ .collect();
+ self.check_named_args(args, &props, "surface props", span);
+ }
+
+ fn check_navigate(&mut self, target: &ast::NavTarget, span: Span) {
+ let ast::NavTarget::Route { name, args } = target else {
+ return;
+ };
+ let Ok(route) = Ident::new(name) else {
+ return;
+ };
+ if !self.resolved.routes.contains_key(&route) {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_ROUTE.0,
+ codes::UNKNOWN_ROUTE.1,
+ format!("no route `{route}` (routes come from `app/**/page.uhura` paths)"),
+ span,
+ );
+ if let Some(s) = did_you_mean(&route, self.resolved.routes.keys()) {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ }
+ self.diags.push(d);
+ for arg in args {
+ self.infer(&arg.value);
+ }
+ return;
+ }
+ let params: Vec<(Ident, Ty)> = match self.resolved.pages.get(&route) {
+ Some(page) => page
+ .params
+ .iter()
+ .map(|(n, t)| (n.clone(), t.clone()))
+ .collect(),
+ None => Vec::new(),
+ };
+ self.check_named_args(args, ¶ms, "route params", span);
+ }
+
+ /// Named-argument lists cover the declared fields exactly (§4.2).
+ pub fn check_named_args(
+ &mut self,
+ args: &[ast::Arg],
+ declared: &[(Ident, Ty)],
+ what: &str,
+ span: Span,
+ ) {
+ let mut seen: BTreeMap<&str, ()> = BTreeMap::new();
+ for arg in args {
+ match declared.iter().find(|(n, _)| n.as_str() == arg.name) {
+ Some((_, ty)) => {
+ let ty = ty.clone();
+ self.check(&arg.value, &ty);
+ }
+ None => {
+ self.error(
+ codes::WRONG_ARGS,
+ format!("`{}` is not part of the {what}", arg.name),
+ arg.span,
+ );
+ self.infer(&arg.value);
+ }
+ }
+ if seen.insert(&arg.name, ()).is_some() {
+ self.error(
+ codes::WRONG_ARGS,
+ format!("`{}` is given twice", arg.name),
+ arg.span,
+ );
+ }
+ }
+ for (name, ty) in declared {
+ if !args.iter().any(|a| a.name == name.as_str()) && !matches!(ty, Ty::Option(_)) {
+ self.error(codes::WRONG_ARGS, format!("missing `{name}`"), span);
+ }
+ }
+ }
+}
+
+/// Checks a store block and returns the machine-event signature table
+/// (event → params) that markup emit-checking consumes.
+pub fn check_store(
+ env: &DefEnv,
+ resolved: &Resolved,
+ store: &ast::Store,
+ diags: &mut Vec,
+) -> BTreeMap> {
+ let mut events: BTreeMap> = BTreeMap::new();
+ // Event key → span of an unguarded handler already seen.
+ let mut unguarded: BTreeMap = BTreeMap::new();
+
+ for handler in &store.handlers {
+ let mut typer = Typer::new(env, resolved, diags);
+
+ // ── signature ──────────────────────────────────────────────────
+ match &handler.event {
+ ast::EventRef::Semantic { name, span } => {
+ let Ok(event) = Ident::new(name) else {
+ continue;
+ };
+ let mut sig: Vec<(Ident, Ty)> = Vec::new();
+ for param in &handler.params {
+ let Ok(param_name) = Ident::new(¶m.name) else {
+ continue;
+ };
+ let ty = match ¶m.ty {
+ Some(t) => crate::resolve::source_type(t, env, typer.diags),
+ None => {
+ typer.diags.push(Diagnostic::error(
+ codes::HANDLER_SIGNATURE_MISMATCH.0,
+ codes::HANDLER_SIGNATURE_MISMATCH.1,
+ format!("UI-event param `{param_name}` needs a type (§4.2)"),
+ param.span,
+ ));
+ Ty::Error
+ }
+ };
+ sig.push((param_name, ty));
+ }
+ match events.get(&event) {
+ None => {
+ events.insert(event.clone(), sig.clone());
+ }
+ Some(first) if *first != sig => {
+ typer.diags.push(Diagnostic::error(
+ codes::HANDLER_SIGNATURE_MISMATCH.0,
+ codes::HANDLER_SIGNATURE_MISMATCH.1,
+ format!(
+ "every `on {event}` handler must declare the identical \
+ signature (§4.2)"
+ ),
+ *span,
+ ));
+ }
+ Some(_) => {}
+ }
+ for (i, (name, ty)) in sig.iter().enumerate() {
+ typer.push_local(name.as_str(), ty.clone(), handler.params[i].span);
+ }
+ }
+ ast::EventRef::Outcome {
+ command,
+ which,
+ span,
+ } => {
+ let Ok(cmd) = Ident::new(command) else {
+ continue;
+ };
+ let expected: &[&str] = match which {
+ ast::OutcomeKind::Ok => &["tag", "cmd"],
+ ast::OutcomeKind::Err => &["tag", "cmd", "refusal"],
+ };
+ let names: Vec<&str> = handler.params.iter().map(|p| p.name.as_str()).collect();
+ let annotated = handler.params.iter().any(|p| p.ty.is_some());
+ if names != expected || annotated {
+ typer.diags.push(Diagnostic::error(
+ codes::BAD_OUTCOME_SIGNATURE.0,
+ codes::BAD_OUTCOME_SIGNATURE.1,
+ format!(
+ "outcome handlers have the fixed name-only signature \
+ `on {command}.{}({})` (§4.2)",
+ match which {
+ ast::OutcomeKind::Ok => "ok",
+ ast::OutcomeKind::Err => "err",
+ },
+ expected.join(", ")
+ ),
+ *span,
+ ));
+ }
+ match env.commands.get(&cmd) {
+ None => {
+ typer.diags.push(Diagnostic::error(
+ codes::UNKNOWN_COMMAND.0,
+ codes::UNKNOWN_COMMAND.1,
+ format!("no command `{command}` is imported to have outcomes"),
+ *span,
+ ));
+ }
+ Some(info) => {
+ let cmd_record = Ty::Record(info.payload.iter().cloned().collect());
+ typer
+ .locals
+ .push((Ident::new("tag").expect("kebab"), Ty::Tag));
+ typer
+ .locals
+ .push((Ident::new("cmd").expect("kebab"), cmd_record));
+ if matches!(which, ast::OutcomeKind::Err) {
+ // Refusal names or "unavailable" — compared as text.
+ typer
+ .locals
+ .push((Ident::new("refusal").expect("kebab"), Ty::Text));
+ }
+ }
+ }
+ }
+ }
+
+ // ── guard order: unguarded-above-anything is unreachable ───────
+ let event_key = match &handler.event {
+ ast::EventRef::Semantic { name, .. } => format!("on {name}"),
+ ast::EventRef::Outcome { command, which, .. } => format!(
+ "on {command}.{}",
+ match which {
+ ast::OutcomeKind::Ok => "ok",
+ ast::OutcomeKind::Err => "err",
+ }
+ ),
+ };
+ if let Some(prev) = unguarded.get(&event_key) {
+ typer.diags.push(
+ Diagnostic::error(
+ codes::UNREACHABLE_HANDLER.0,
+ codes::UNREACHABLE_HANDLER.1,
+ format!("this `{event_key}` handler is unreachable"),
+ handler.span,
+ )
+ .with_label(*prev, "an unguarded handler above always wins"),
+ );
+ }
+ if handler.guard.is_none() {
+ unguarded.entry(event_key).or_insert(handler.span);
+ }
+
+ // ── guard + body ───────────────────────────────────────────────
+ if let Some(guard) = &handler.guard {
+ typer.check(guard, &Ty::Bool);
+ }
+ let mut navigates = 0usize;
+ for stmt in &handler.body {
+ typer.check_stmt(stmt);
+ if let ast::Stmt::Navigate { span, .. } = stmt {
+ navigates += 1;
+ if navigates > 1 {
+ typer.diags.push(Diagnostic::error(
+ codes::MULTIPLE_NAVIGATES.0,
+ codes::MULTIPLE_NAVIGATES.1,
+ "at most one `navigate` per handler (§4.2: ≤ 1/step)".to_string(),
+ *span,
+ ));
+ }
+ }
+ }
+ }
+ events
+}
diff --git a/uhura/crates/uhura-check/src/lib.rs b/uhura/crates/uhura-check/src/lib.rs
new file mode 100644
index 0000000..abfcc80
--- /dev/null
+++ b/uhura/crates/uhura-check/src/lib.rs
@@ -0,0 +1,23 @@
+//! uhura-check: the whole front half as a pure function over in-memory
+//! inputs — routes from file paths, resolution, the catalog-as-data model +
+//! meta-schema (module `catalog`), port linking, typecheck, markup/style
+//! rules, example resolution (replay folds uhura-core's step_u), and
+//! lowering to the checked IR (design §12.1). The CLI walks the filesystem;
+//! this crate never does.
+#![deny(clippy::float_arithmetic)]
+
+pub mod catalog;
+pub mod examples;
+pub mod fixture;
+pub mod infer;
+pub mod lower;
+pub mod manifest;
+pub mod markup;
+pub mod pipeline;
+pub mod preview;
+pub mod replay;
+pub mod resolve;
+pub mod style;
+pub mod types;
+
+pub use pipeline::{CheckInput, CheckOutput, LockStatus, SourceInput, check};
diff --git a/uhura/crates/uhura-check/src/lower.rs b/uhura/crates/uhura-check/src/lower.rs
new file mode 100644
index 0000000..b68e5ee
--- /dev/null
+++ b/uhura/crates/uhura-check/src/lower.rs
@@ -0,0 +1,895 @@
+//! Lowering — checked AST → `uhura-ir/0` (§12.2). Gated on zero errors, so
+//! resolution here is a straight replay of the rules the passes already
+//! enforced; anything unresolvable lowers to a placeholder rather than
+//! panicking. Node ordinals are assigned depth-first pre-order per
+//! definition (§8.1 keys). Spans go to a side table keyed by IR path — the
+//! IR bytes stay location-independent (examples-invariance, §6.1).
+
+use std::collections::BTreeMap;
+
+use serde::Serialize;
+use uhura_base::{Ident, Span};
+use uhura_core::ir;
+use uhura_syntax::{Parsed, ast};
+
+use crate::catalog::{Catalog, EventKind, PropType};
+use crate::infer::Typer;
+use crate::manifest::Manifest;
+use crate::resolve::{DefEnv, ParsedSource, Resolved, RouteSeg};
+use crate::types::{MapKey, Ty};
+
+/// One side-table entry; `file` is the corpus-relative path.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct SpanEntry {
+ pub file: String,
+ pub start: u32,
+ pub end: u32,
+}
+
+pub struct Lowered {
+ pub program: ir::ProgramIr,
+ pub spans: BTreeMap,
+}
+
+pub fn lower(
+ manifest: &Manifest,
+ resolved: &Resolved,
+ catalog: &Catalog,
+ sources: &[ParsedSource],
+) -> Lowered {
+ let mut spans = BTreeMap::new();
+
+ let ports = resolved
+ .ports
+ .iter()
+ .map(|(name, (contract, _))| {
+ (
+ name.clone(),
+ ir::PortPin {
+ version: contract.version.clone(),
+ hash: contract.canonical_hash(),
+ },
+ )
+ })
+ .collect();
+
+ let mut projections = BTreeMap::new();
+ for (port_name, (contract, port_types)) in &resolved.ports {
+ for (proj_name, decl) in &contract.projections {
+ projections.insert(
+ proj_name.clone(),
+ ir::ProjectionIr {
+ port: port_name.clone(),
+ boot: decl.boot,
+ ty: lower_ty(&port_types.from_expr(contract, &decl.ty)),
+ key: decl
+ .key
+ .as_ref()
+ .map(|k| lower_ty(&port_types.from_expr(contract, k))),
+ },
+ );
+ }
+ }
+
+ let mut element_events = BTreeMap::new();
+ let mut element_props = BTreeMap::new();
+ for (el_name, el) in &catalog.elements {
+ if !el.props.is_empty() {
+ let props: BTreeMap = el
+ .props
+ .iter()
+ .map(|(prop, decl)| {
+ (
+ prop.clone(),
+ match decl.ty {
+ PropType::Text => ir::PropKindIr::Plain,
+ PropType::Bool => ir::PropKindIr::Bool,
+ PropType::Int => ir::PropKindIr::Int,
+ PropType::Enum(_) | PropType::Icon => ir::PropKindIr::Token,
+ PropType::Asset => ir::PropKindIr::Asset,
+ },
+ )
+ })
+ .collect();
+ element_props.insert(el_name.clone(), props);
+ }
+ if el.events.is_empty() {
+ continue;
+ }
+ let events: BTreeMap = el
+ .events
+ .iter()
+ .map(|(event, decl)| {
+ (
+ event.clone(),
+ ir::ElementEventIr {
+ kind: match decl.kind {
+ EventKind::Input => ir::EventKindIr::Input,
+ EventKind::Observe => ir::EventKindIr::Observe,
+ },
+ carries: decl
+ .carries
+ .iter()
+ .map(|(f, ty)| {
+ (
+ f.clone(),
+ match ty {
+ PropType::Bool => ir::CarryTypeIr::Bool,
+ PropType::Int => ir::CarryTypeIr::Int,
+ _ => ir::CarryTypeIr::Text,
+ },
+ )
+ })
+ .collect(),
+ },
+ )
+ })
+ .collect();
+ element_events.insert(el_name.clone(), events);
+ }
+
+ let routes = resolved
+ .routes
+ .iter()
+ .map(|(name, info)| {
+ (
+ name.clone(),
+ ir::RouteIr {
+ segments: info
+ .segments
+ .iter()
+ .map(|seg| match seg {
+ RouteSeg::Static(s) => ir::RouteSegIr::Static(s.clone()),
+ RouteSeg::Param(p) => ir::RouteSegIr::Param(p.clone()),
+ })
+ .collect(),
+ params: info.params.clone(),
+ },
+ )
+ })
+ .collect();
+
+ let lower_defs =
+ |defs: &BTreeMap, prefix: &str, spans: &mut BTreeMap| {
+ defs.iter()
+ .filter_map(|(name, env)| {
+ let src = &sources[env.source];
+ let Parsed::Module(ast) = &src.parsed else {
+ return None;
+ };
+ let path = format!("{prefix}.{name}");
+ let def = lower_def(env, ast, resolved, src, &path, spans);
+ Some((name.clone(), def))
+ })
+ .collect::>()
+ };
+
+ let pages = lower_defs(&resolved.pages, "pages", &mut spans);
+ let components = lower_defs(&resolved.components, "components", &mut spans);
+ let surfaces = lower_defs(&resolved.surfaces, "surfaces", &mut spans);
+
+ let program = ir::ProgramIr {
+ protocol: ir::IR_PROTOCOL.to_string(),
+ app: manifest.app_name.clone(),
+ entry: manifest.entry.clone(),
+ catalog: ir::CatalogPin {
+ name: catalog.name.clone(),
+ version: catalog.version.clone(),
+ hash: catalog.canonical_hash(),
+ },
+ ports,
+ projections,
+ element_events,
+ element_props,
+ routes,
+ pages,
+ components,
+ surfaces,
+ };
+ Lowered { program, spans }
+}
+
+/// Check-land `Ty` → the IR's runtime decode grammar. Nominal id/cursor
+/// types collapse to `Id` (nominal identity is a check-time concern; the
+/// wire form is a string either way). `Error`/`NoneLit` cannot survive a
+/// zero-error check in a declared signature; they lower to `Text` to keep
+/// the IR total.
+fn lower_ty(ty: &Ty) -> ir::TyIr {
+ match ty {
+ Ty::Bool => ir::TyIr::Bool,
+ Ty::Int => ir::TyIr::Int,
+ Ty::Text => ir::TyIr::Text,
+ Ty::Id | Ty::Nominal { .. } => ir::TyIr::Id,
+ Ty::Tag => ir::TyIr::Tag,
+ Ty::Asset => ir::TyIr::Asset,
+ Ty::Enum(values) => ir::TyIr::Enum(values.iter().cloned().collect()),
+ Ty::Record(fields) => ir::TyIr::Record(
+ fields
+ .iter()
+ .map(|(name, ty)| (name.clone(), lower_ty(ty)))
+ .collect(),
+ ),
+ Ty::Union(variants) => ir::TyIr::Union(
+ variants
+ .iter()
+ .map(|(variant, fields)| {
+ (
+ variant.clone(),
+ fields
+ .iter()
+ .map(|(name, ty)| (name.clone(), lower_ty(ty)))
+ .collect(),
+ )
+ })
+ .collect(),
+ ),
+ Ty::List(inner) => ir::TyIr::List(Box::new(lower_ty(inner))),
+ Ty::Map(key, inner) => ir::TyIr::Map {
+ key: match key {
+ MapKey::Id => ir::MapKeyIr::Id,
+ MapKey::Tag => ir::MapKeyIr::Tag,
+ },
+ value: Box::new(lower_ty(inner)),
+ },
+ Ty::Option(inner) => ir::TyIr::Option(Box::new(lower_ty(inner))),
+ Ty::NoneLit | Ty::Error => ir::TyIr::Text,
+ }
+}
+
+fn record_span(spans: &mut BTreeMap, key: String, rel_path: &str, span: Span) {
+ spans.insert(
+ key,
+ SpanEntry {
+ file: rel_path.to_string(),
+ start: span.start,
+ end: span.end,
+ },
+ );
+}
+
+fn lower_def(
+ env: &DefEnv,
+ ast: &ast::File,
+ resolved: &Resolved,
+ src: &ParsedSource,
+ path: &str,
+ spans: &mut BTreeMap,
+) -> ir::DefIr {
+ record_span(spans, path.to_string(), &src.rel_path, def_span(ast));
+
+ let modality = match &ast.kind {
+ ast::DefKind::Surface { modality, .. } => {
+ Some(modality.clone().unwrap_or_else(|| "sheet".to_string()))
+ }
+ _ => None,
+ };
+
+ let props = ast
+ .props
+ .iter()
+ .filter_map(|p| Ident::new(&p.name).ok())
+ .collect();
+ let emits = ast
+ .emits
+ .iter()
+ .filter_map(|e| Ident::new(&e.name).ok())
+ .collect();
+ let params = ast
+ .params
+ .iter()
+ .filter_map(|p| Ident::new(&p.name).ok())
+ .collect();
+
+ let mut state = BTreeMap::new();
+ let mut handlers = Vec::new();
+ if let Some(store) = &ast.store {
+ for field in &store.state {
+ let Ok(name) = Ident::new(&field.name) else {
+ continue;
+ };
+ state.insert(name, lower_init(&field.init));
+ }
+ for (i, handler) in store.handlers.iter().enumerate() {
+ record_span(
+ spans,
+ format!("{path}/handler/{i}"),
+ &src.rel_path,
+ handler.span,
+ );
+ if let Some(h) = lower_handler(env, resolved, handler) {
+ handlers.push(h);
+ }
+ }
+ }
+
+ let mut ctx = LowerCtx {
+ env,
+ resolved,
+ locals: Vec::new(),
+ next_ord: 0,
+ };
+ let root_nodes = ctx.lower_nodes(&ast.markup);
+ let root = root_nodes.into_iter().next().unwrap_or_else(|| {
+ // Zero-error gating means this only happens for a markupless def,
+ // which the one-root rule already rejected; keep IR total anyway.
+ ir::NodeIr::Element(ir::ElementIr {
+ element: Ident::new("view").expect("kebab"),
+ ord: 0,
+ class: None,
+ props: vec![],
+ events: vec![],
+ text: vec![],
+ children: vec![],
+ })
+ });
+
+ // Machine-event signatures (typed by the check pass) bake into the IR
+ // so the runtime can type payload JSON and enforce eligibility (§7.2).
+ let events = env
+ .events
+ .iter()
+ .map(|(event, params)| {
+ (
+ event.clone(),
+ params
+ .iter()
+ .map(|(name, ty)| ir::EventParamIr {
+ name: name.clone(),
+ ty: lower_ty(ty),
+ })
+ .collect(),
+ )
+ })
+ .collect();
+
+ ir::DefIr {
+ modality,
+ props,
+ emits,
+ params,
+ state,
+ events,
+ handlers,
+ root,
+ }
+}
+
+fn def_span(ast: &ast::File) -> Span {
+ match &ast.kind {
+ ast::DefKind::Component { span, .. }
+ | ast::DefKind::Page { span }
+ | ast::DefKind::Surface { span, .. }
+ | ast::DefKind::Error { span } => *span,
+ }
+}
+
+fn lower_init(lit: &ast::Literal) -> ir::InitValue {
+ match lit {
+ ast::Literal::Int(i) => ir::InitValue::Int(*i),
+ ast::Literal::Str(s) => ir::InitValue::Text(s.clone()),
+ ast::Literal::Bool(b) => ir::InitValue::Bool(*b),
+ ast::Literal::None | ast::Literal::Error => ir::InitValue::None,
+ ast::Literal::EmptyMap => ir::InitValue::EmptyMap,
+ }
+}
+
+fn lower_handler(
+ env: &DefEnv,
+ resolved: &Resolved,
+ handler: &ast::Handler,
+) -> Option {
+ let on = match &handler.event {
+ ast::EventRef::Semantic { name, .. } => ir::EventKeyIr::Semantic {
+ event: Ident::new(name).ok()?,
+ },
+ ast::EventRef::Outcome { command, which, .. } => ir::EventKeyIr::Outcome {
+ command: Ident::new(command).ok()?,
+ which: match which {
+ ast::OutcomeKind::Ok => ir::OutcomeKindIr::Ok,
+ ast::OutcomeKind::Err => ir::OutcomeKindIr::Err,
+ },
+ },
+ };
+ let params: Vec = handler
+ .params
+ .iter()
+ .filter_map(|p| Ident::new(&p.name).ok())
+ .collect();
+
+ // Rebuild the handler-scope binding types the checker established.
+ let mut scratch = Vec::new();
+ let locals: Vec<(Ident, Ty)> = match &handler.event {
+ ast::EventRef::Semantic { .. } => handler
+ .params
+ .iter()
+ .filter_map(|p| {
+ let name = Ident::new(&p.name).ok()?;
+ let ty =
+ p.ty.as_ref()
+ .map(|t| crate::resolve::source_type(t, env, &mut scratch))
+ .unwrap_or(Ty::Error);
+ Some((name, ty))
+ })
+ .collect(),
+ ast::EventRef::Outcome { command, which, .. } => {
+ let payload = Ident::new(command)
+ .ok()
+ .and_then(|c| env.commands.get(&c))
+ .map(|info| Ty::Record(info.payload.iter().cloned().collect()))
+ .unwrap_or(Ty::Error);
+ let mut locals = vec![
+ (Ident::new("tag").expect("kebab"), Ty::Tag),
+ (Ident::new("cmd").expect("kebab"), payload),
+ ];
+ if matches!(which, ast::OutcomeKind::Err) {
+ locals.push((Ident::new("refusal").expect("kebab"), Ty::Text));
+ }
+ locals
+ }
+ };
+ let mut ctx = LowerCtx {
+ env,
+ resolved,
+ locals,
+ next_ord: 0,
+ };
+ let guard = handler.guard.as_ref().map(|g| ctx.lower_expr(g));
+ let mut body = Vec::new();
+ for stmt in &handler.body {
+ if let Some(s) = ctx.lower_stmt(stmt) {
+ body.push(s);
+ }
+ }
+ Some(ir::HandlerIr {
+ on,
+ params,
+ guard,
+ body,
+ })
+}
+
+struct LowerCtx<'a> {
+ env: &'a DefEnv,
+ resolved: &'a Resolved,
+ /// Names bound locally (handler params, `as` tags, each items, match
+ /// bindings) — they lower to `BindingRef`. Types ride along so
+ /// re-inference (each-over classification, union arms) stays exact.
+ locals: Vec<(Ident, Ty)>,
+ next_ord: u32,
+}
+
+impl LowerCtx<'_> {
+ fn ord(&mut self) -> u32 {
+ let ord = self.next_ord;
+ self.next_ord += 1;
+ ord
+ }
+
+ fn lower_expr(&mut self, e: &ast::Expr) -> ir::ExprIr {
+ match &e.kind {
+ ast::ExprKind::Error => ir::ExprIr::None,
+ ast::ExprKind::Int(i) => ir::ExprIr::Int(*i),
+ ast::ExprKind::Str(s) => ir::ExprIr::Text(s.clone()),
+ ast::ExprKind::Bool(b) => ir::ExprIr::Bool(*b),
+ ast::ExprKind::None => ir::ExprIr::None,
+ ast::ExprKind::Ident(name) => self.lower_name(name),
+ ast::ExprKind::Field { base, name } => ir::ExprIr::Field {
+ base: Box::new(self.lower_expr(base)),
+ name: Ident::new(name).unwrap_or_else(|_| Ident::new("x").expect("kebab")),
+ },
+ ast::ExprKind::Index { base, key } => ir::ExprIr::Index {
+ base: Box::new(self.lower_expr(base)),
+ key: Box::new(self.lower_expr(key)),
+ },
+ ast::ExprKind::Call { name, args } => match name.as_str() {
+ "to-text" => ir::ExprIr::ToText(Box::new(
+ args.first()
+ .map_or(ir::ExprIr::Text(String::new()), |a| self.lower_expr(a)),
+ )),
+ "count" => ir::ExprIr::Count(Box::new(
+ args.first()
+ .map_or(ir::ExprIr::Int(0), |a| self.lower_expr(a)),
+ )),
+ other => {
+ let projection =
+ Ident::new(other).unwrap_or_else(|_| Ident::new("x").expect("kebab"));
+ ir::ExprIr::ProjectionKeyed {
+ projection,
+ key: Box::new(
+ args.first()
+ .map_or(ir::ExprIr::None, |a| self.lower_expr(a)),
+ ),
+ }
+ }
+ },
+ ast::ExprKind::Unary { op, expr } => ir::ExprIr::Unary {
+ op: match op {
+ ast::UnaryOp::Not => ir::UnaryOpIr::Not,
+ ast::UnaryOp::Neg => ir::UnaryOpIr::Neg,
+ },
+ expr: Box::new(self.lower_expr(expr)),
+ },
+ ast::ExprKind::Binary { op, lhs, rhs } => ir::ExprIr::Binary {
+ op: lower_binop(*op),
+ lhs: Box::new(self.lower_expr(lhs)),
+ rhs: Box::new(self.lower_expr(rhs)),
+ },
+ ast::ExprKind::If { cond, then, els } => ir::ExprIr::If {
+ cond: Box::new(self.lower_expr(cond)),
+ then: Box::new(self.lower_expr(then)),
+ els: Box::new(self.lower_expr(els)),
+ },
+ ast::ExprKind::Record(entries) => ir::ExprIr::RecordLit(
+ entries
+ .iter()
+ .filter_map(|(name, value)| {
+ Some(ir::ArgIr {
+ name: Ident::new(name).ok()?,
+ value: self.lower_expr(value),
+ })
+ })
+ .collect(),
+ ),
+ }
+ }
+
+ fn lower_name(&mut self, name: &str) -> ir::ExprIr {
+ let Ok(ident) = Ident::new(name) else {
+ return ir::ExprIr::None;
+ };
+ if self.locals.iter().any(|(l, _)| *l == ident) {
+ return ir::ExprIr::BindingRef(ident);
+ }
+ if self.env.state.contains_key(&ident) {
+ return ir::ExprIr::StateRef(ident);
+ }
+ if self.env.props.contains_key(&ident) {
+ return ir::ExprIr::PropRef(ident);
+ }
+ if self.env.params.contains_key(&ident) {
+ return ir::ExprIr::ParamRef(ident);
+ }
+ if self.env.projections.contains_key(&ident) {
+ return ir::ExprIr::ProjectionRef(ident);
+ }
+ // Zero-error gating: unreachable for checked programs.
+ ir::ExprIr::None
+ }
+
+ fn lower_args(&mut self, args: &[ast::Arg]) -> Vec {
+ args.iter()
+ .filter_map(|arg| {
+ Some(ir::ArgIr {
+ name: Ident::new(&arg.name).ok()?,
+ value: self.lower_expr(&arg.value),
+ })
+ })
+ .collect()
+ }
+
+ fn lower_stmt(&mut self, stmt: &ast::Stmt) -> Option {
+ match stmt {
+ ast::Stmt::Error { .. } => None,
+ ast::Stmt::Set { path, value, .. } => Some(ir::StmtIr::Set {
+ field: Ident::new(&path.field).ok()?,
+ key: path.key.as_ref().map(|k| self.lower_expr(k)),
+ value: self.lower_expr(value),
+ }),
+ ast::Stmt::Send {
+ command,
+ args,
+ bind,
+ ..
+ } => {
+ let command = Ident::new(command).ok()?;
+ let port = self.env.commands.get(&command)?.port.clone();
+ let args = self.lower_args(args);
+ let bind = bind.as_ref().and_then(|b| Ident::new(b).ok());
+ if let Some(b) = &bind {
+ self.locals.push((b.clone(), Ty::Tag));
+ }
+ Some(ir::StmtIr::Send {
+ port,
+ command,
+ args,
+ bind,
+ })
+ }
+ ast::Stmt::OpenSurface { name, args, .. } => Some(ir::StmtIr::OpenSurface {
+ surface: Ident::new(name).ok()?,
+ args: self.lower_args(args),
+ }),
+ ast::Stmt::Dismiss { .. } => Some(ir::StmtIr::Dismiss),
+ ast::Stmt::Navigate { target, .. } => match target {
+ ast::NavTarget::Back => Some(ir::StmtIr::NavigateBack),
+ ast::NavTarget::Route { name, args } => Some(ir::StmtIr::Navigate {
+ route: Ident::new(name).ok()?,
+ args: self.lower_args(args),
+ }),
+ },
+ }
+ }
+
+ fn lower_nodes(&mut self, nodes: &[ast::Node]) -> Vec {
+ nodes.iter().filter_map(|n| self.lower_node(n)).collect()
+ }
+
+ fn lower_node(&mut self, node: &ast::Node) -> Option {
+ match node {
+ ast::Node::Error { .. } | ast::Node::Text { .. } => None,
+ ast::Node::If {
+ cond, then, els, ..
+ } => Some(ir::NodeIr::If {
+ cond: self.lower_expr(cond),
+ then: self.lower_nodes(then),
+ els: els
+ .as_ref()
+ .map(|e| self.lower_nodes(e))
+ .unwrap_or_default(),
+ }),
+ ast::Node::Each {
+ item,
+ seq,
+ key,
+ body,
+ ..
+ } => {
+ let ord = self.ord();
+ let seq_ty = self.infer_ty(seq);
+ let over = match &seq_ty {
+ Ty::Map(MapKey::Id, _) => ir::OverIr::MapIdKeys,
+ Ty::Map(MapKey::Tag, _) => ir::OverIr::MapTagKeys,
+ _ => ir::OverIr::List,
+ };
+ let item_ty = match seq_ty {
+ Ty::List(t) => *t,
+ Ty::Map(MapKey::Id, _) => Ty::Id,
+ Ty::Map(MapKey::Tag, _) => Ty::Tag,
+ _ => Ty::Error,
+ };
+ let seq_ir = self.lower_expr(seq);
+ let item_ident = Ident::new(item).ok()?;
+ self.locals.push((item_ident.clone(), item_ty));
+ let key_ir = self.lower_expr(key);
+ let body_ir = self.lower_nodes(body);
+ self.locals.pop();
+ Some(ir::NodeIr::Each(ir::EachIr {
+ ord,
+ item: item_ident,
+ over,
+ seq: seq_ir,
+ key: key_ir,
+ body: body_ir,
+ }))
+ }
+ ast::Node::Match {
+ scrutinee, arms, ..
+ } => {
+ let source = self.match_source(scrutinee);
+ // Arm binding types mirror the markup pass: availability
+ // ready binds the projection value / failed binds text;
+ // union arms bind the variant's field record.
+ let ready_ty = match &source {
+ ir::MatchSourceIr::Availability { projection, .. } => self
+ .env
+ .projections
+ .get(projection)
+ .map(|p| p.ty.clone())
+ .unwrap_or(Ty::Error),
+ ir::MatchSourceIr::Union { .. } => self.infer_ty(scrutinee),
+ };
+ let is_availability = matches!(source, ir::MatchSourceIr::Availability { .. });
+ let arms = arms
+ .iter()
+ .map(|arm| {
+ let variant = match &arm.pattern {
+ ast::MatchPattern::Else => None,
+ ast::MatchPattern::Variant(v) => Ident::new(v).ok(),
+ };
+ let binding = arm.binding.as_ref().and_then(|b| Ident::new(b).ok());
+ if let Some(b) = &binding {
+ let ty = if is_availability {
+ match variant.as_ref().map(Ident::as_str) {
+ Some("ready") => ready_ty.clone(),
+ _ => Ty::Text,
+ }
+ } else {
+ match (&ready_ty, &variant) {
+ (Ty::Union(variants), Some(v)) => variants
+ .get(v)
+ .map(|fields| Ty::Record(fields.clone()))
+ .unwrap_or(Ty::Error),
+ _ => Ty::Error,
+ }
+ };
+ self.locals.push((b.clone(), ty));
+ }
+ let body = self.lower_nodes(&arm.body);
+ if binding.is_some() {
+ self.locals.pop();
+ }
+ ir::MatchArmIr {
+ variant,
+ binding,
+ body,
+ }
+ })
+ .collect();
+ Some(ir::NodeIr::Match(ir::MatchIr { source, arms }))
+ }
+ ast::Node::Element(el) => {
+ let name = Ident::new(&el.name).ok()?;
+ if self.resolved.components.contains_key(&name)
+ && self.env.component_imports.contains_key(&name)
+ {
+ Some(self.lower_component_call(el, name))
+ } else {
+ Some(self.lower_element(el, name))
+ }
+ }
+ }
+ }
+
+ /// Availability vs union classification — mirrors the markup pass.
+ fn match_source(&mut self, scrutinee: &ast::Expr) -> ir::MatchSourceIr {
+ match &scrutinee.kind {
+ ast::ExprKind::Ident(name) => {
+ if let Ok(ident) = Ident::new(name)
+ && self.env.projections.contains_key(&ident)
+ && !self.locals.iter().any(|(l, _)| *l == ident)
+ {
+ return ir::MatchSourceIr::Availability {
+ projection: ident,
+ key: None,
+ };
+ }
+ ir::MatchSourceIr::Union {
+ value: self.lower_expr(scrutinee),
+ }
+ }
+ ast::ExprKind::Call { name, args } => {
+ if let Ok(ident) = Ident::new(name)
+ && self.env.projections.contains_key(&ident)
+ {
+ return ir::MatchSourceIr::Availability {
+ projection: ident,
+ key: args.first().map(|a| self.lower_expr(a)),
+ };
+ }
+ ir::MatchSourceIr::Union {
+ value: self.lower_expr(scrutinee),
+ }
+ }
+ _ => ir::MatchSourceIr::Union {
+ value: self.lower_expr(scrutinee),
+ },
+ }
+ }
+
+ fn lower_element(&mut self, el: &ast::Element, element: Ident) -> ir::NodeIr {
+ let ord = self.ord();
+ let mut class = None;
+ let mut props = Vec::new();
+ for attr in &el.attrs {
+ let Ok(attr_name) = Ident::new(&attr.name) else {
+ continue;
+ };
+ let value = match &attr.value {
+ ast::AttrValue::Bare => ir::ExprIr::Bool(true),
+ ast::AttrValue::Literal(s) => ir::ExprIr::Text(s.clone()),
+ ast::AttrValue::Expr(e) => self.lower_expr(e),
+ };
+ if attr_name.as_str() == "class" {
+ class = Some(value);
+ } else {
+ props.push(ir::ArgIr {
+ name: attr_name,
+ value,
+ });
+ }
+ }
+ let events = el
+ .events
+ .iter()
+ .filter_map(|event_attr| {
+ let event = Ident::new(&event_attr.event).ok()?;
+ match &event_attr.binding {
+ ast::EventBinding::Forward => None, // rejected by markup pass
+ ast::EventBinding::Emit { name, args } => Some(ir::ElementEventBindingIr {
+ event,
+ emit: Ident::new(name).ok()?,
+ args: self.lower_args(args),
+ }),
+ }
+ })
+ .collect();
+ let text = el
+ .children
+ .iter()
+ .filter_map(|c| match c {
+ ast::Node::Text { runs, .. } => Some(runs),
+ _ => None,
+ })
+ .flatten()
+ .map(|run| match run {
+ ast::TextRun::Literal(s) => ir::TextRunIr::Literal(s.clone()),
+ ast::TextRun::Interp(e) => ir::TextRunIr::Interp(self.lower_expr(e)),
+ })
+ .collect();
+ let children = self.lower_nodes(&el.children);
+ ir::NodeIr::Element(ir::ElementIr {
+ element,
+ ord,
+ class,
+ props,
+ events,
+ text,
+ children,
+ })
+ }
+
+ fn lower_component_call(&mut self, el: &ast::Element, component: Ident) -> ir::NodeIr {
+ let ord = self.ord();
+ let props = el
+ .attrs
+ .iter()
+ .filter_map(|attr| {
+ let name = Ident::new(&attr.name).ok()?;
+ let value = match &attr.value {
+ ast::AttrValue::Bare => ir::ExprIr::Bool(true),
+ ast::AttrValue::Literal(s) => ir::ExprIr::Text(s.clone()),
+ ast::AttrValue::Expr(e) => self.lower_expr(e),
+ };
+ Some(ir::ArgIr { name, value })
+ })
+ .collect();
+ let emits = el
+ .events
+ .iter()
+ .filter_map(|event_attr| {
+ let emit = Ident::new(&event_attr.event).ok()?;
+ let target = match &event_attr.binding {
+ ast::EventBinding::Forward => ir::EmitTargetIr::Forward,
+ ast::EventBinding::Emit { name, args } => ir::EmitTargetIr::Rebind {
+ event: Ident::new(name).ok()?,
+ args: self.lower_args(args),
+ },
+ };
+ Some(ir::EmitBindingIr { emit, target })
+ })
+ .collect();
+ ir::NodeIr::Component(ir::ComponentCallIr {
+ component,
+ ord,
+ props,
+ emits,
+ })
+ }
+
+ /// Re-infers an expression's type with the current typed bindings —
+ /// the program is already clean, so this is a lookup, not a re-check.
+ fn infer_ty(&self, e: &ast::Expr) -> Ty {
+ let mut scratch = Vec::new();
+ let mut typer = Typer::new(self.env, self.resolved, &mut scratch);
+ typer.locals = self.locals.clone();
+ typer.infer(e)
+ }
+}
+
+fn lower_binop(op: ast::BinaryOp) -> ir::BinaryOpIr {
+ match op {
+ ast::BinaryOp::Add => ir::BinaryOpIr::Add,
+ ast::BinaryOp::Sub => ir::BinaryOpIr::Sub,
+ ast::BinaryOp::Concat => ir::BinaryOpIr::Concat,
+ ast::BinaryOp::Eq => ir::BinaryOpIr::Eq,
+ ast::BinaryOp::NotEq => ir::BinaryOpIr::NotEq,
+ ast::BinaryOp::Lt => ir::BinaryOpIr::Lt,
+ ast::BinaryOp::Le => ir::BinaryOpIr::Le,
+ ast::BinaryOp::Gt => ir::BinaryOpIr::Gt,
+ ast::BinaryOp::Ge => ir::BinaryOpIr::Ge,
+ ast::BinaryOp::And => ir::BinaryOpIr::And,
+ ast::BinaryOp::Or => ir::BinaryOpIr::Or,
+ ast::BinaryOp::Coalesce => ir::BinaryOpIr::Coalesce,
+ }
+}
diff --git a/uhura/crates/uhura-check/src/manifest.rs b/uhura/crates/uhura-check/src/manifest.rs
new file mode 100644
index 0000000..148f436
--- /dev/null
+++ b/uhura/crates/uhura-check/src/manifest.rs
@@ -0,0 +1,188 @@
+//! The `uhura.toml` app manifest (design §3): entry route, catalog pin,
+//! port bindings, fixtures, play profiles. Parsed from text; the CLI reads
+//! the file.
+
+use std::collections::BTreeMap;
+
+use uhura_base::Ident;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Manifest {
+ pub app_name: Ident,
+ /// The entry route name; validated against the route table.
+ pub entry: Ident,
+ /// Corpus-relative path to the catalog TOML.
+ pub catalog_path: String,
+ /// Port name → corpus-relative contract path. The name must equal the
+ /// contract's own `[port] name` (link rule L1).
+ pub ports: BTreeMap,
+ /// Fixture name → corpus-relative data path.
+ pub fixtures: BTreeMap,
+ /// Corpus-relative path to the asset manifest, if any.
+ pub assets_manifest: Option,
+ /// Play profile name → (fixture, script).
+ pub play: BTreeMap,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PlayProfile {
+ pub fixture: Ident,
+ pub script: Ident,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ManifestIssue {
+ pub path: String,
+ pub message: String,
+}
+
+pub fn load_manifest(text: &str) -> Result> {
+ let mut issues: Vec = Vec::new();
+ let push = |issues: &mut Vec, path: &str, message: String| {
+ issues.push(ManifestIssue {
+ path: path.to_string(),
+ message,
+ });
+ };
+
+ let table: toml::Table = match text.parse() {
+ Ok(t) => t,
+ Err(e) => {
+ return Err(vec![ManifestIssue {
+ path: String::new(),
+ message: format!("invalid TOML: {e}"),
+ }]);
+ }
+ };
+ for key in table.keys() {
+ if !["app", "catalog", "ports", "fixtures", "assets", "play"].contains(&key.as_str()) {
+ push(&mut issues, key, format!("unknown key `{key}`"));
+ }
+ }
+
+ let ident_at = |issues: &mut Vec, path: &str, v: Option<&toml::Value>| match v
+ .and_then(toml::Value::as_str)
+ {
+ Some(s) => match Ident::new(s) {
+ Ok(i) => Some(i),
+ Err(e) => {
+ issues.push(ManifestIssue {
+ path: path.to_string(),
+ message: e.to_string(),
+ });
+ None
+ }
+ },
+ None => {
+ issues.push(ManifestIssue {
+ path: path.to_string(),
+ message: "missing required string".into(),
+ });
+ None
+ }
+ };
+
+ let app = table.get("app").and_then(toml::Value::as_table);
+ let app_name = app.and_then(|t| ident_at(&mut issues, "app.name", t.get("name")));
+ let entry = app.and_then(|t| ident_at(&mut issues, "app.entry", t.get("entry")));
+ if app.is_none() {
+ push(&mut issues, "app", "missing `[app]` section".into());
+ }
+
+ let catalog_path = table
+ .get("catalog")
+ .and_then(toml::Value::as_table)
+ .and_then(|t| t.get("path"))
+ .and_then(toml::Value::as_str)
+ .map(ToString::to_string);
+ if catalog_path.is_none() {
+ push(&mut issues, "catalog.path", "missing catalog path".into());
+ }
+
+ let string_map = |issues: &mut Vec, section: &str| {
+ let mut out = BTreeMap::new();
+ if let Some(toml::Value::Table(t)) = table.get(section) {
+ for (name, v) in t {
+ let path = format!("{section}.{name}");
+ match (Ident::new(name), v.as_str()) {
+ (Ok(ident), Some(s)) => {
+ out.insert(ident, s.to_string());
+ }
+ (Err(e), _) => issues.push(ManifestIssue {
+ path,
+ message: e.to_string(),
+ }),
+ (_, None) => issues.push(ManifestIssue {
+ path,
+ message: "expected a path string".into(),
+ }),
+ }
+ }
+ }
+ out
+ };
+ let ports = string_map(&mut issues, "ports");
+ let fixtures = string_map(&mut issues, "fixtures");
+
+ let assets_manifest = table
+ .get("assets")
+ .and_then(toml::Value::as_table)
+ .and_then(|t| t.get("manifest"))
+ .and_then(toml::Value::as_str)
+ .map(ToString::to_string);
+
+ let mut play = BTreeMap::new();
+ if let Some(toml::Value::Table(t)) = table.get("play") {
+ for (name, v) in t {
+ let path = format!("play.{name}");
+ let Ok(profile_name) = Ident::new(name) else {
+ push(&mut issues, &path, format!("`{name}` is not kebab-case"));
+ continue;
+ };
+ let Some(profile) = v.as_table() else {
+ push(
+ &mut issues,
+ &path,
+ "expected `{ fixture = …, script = … }`".into(),
+ );
+ continue;
+ };
+ let fixture = ident_at(
+ &mut issues,
+ &format!("{path}.fixture"),
+ profile.get("fixture"),
+ );
+ let script = ident_at(
+ &mut issues,
+ &format!("{path}.script"),
+ profile.get("script"),
+ );
+ if let (Some(fixture), Some(script)) = (fixture, script) {
+ play.insert(profile_name, PlayProfile { fixture, script });
+ }
+ }
+ }
+
+ for (name, profile) in &play {
+ if !fixtures.contains_key(&profile.fixture) {
+ push(
+ &mut issues,
+ &format!("play.{name}.fixture"),
+ format!("`{}` is not a declared fixture", profile.fixture),
+ );
+ }
+ }
+
+ match (app_name, entry, catalog_path) {
+ (Some(app_name), Some(entry), Some(catalog_path)) if issues.is_empty() => Ok(Manifest {
+ app_name,
+ entry,
+ catalog_path,
+ ports,
+ fixtures,
+ assets_manifest,
+ play,
+ }),
+ _ => Err(issues),
+ }
+}
diff --git a/uhura/crates/uhura-check/src/markup.rs b/uhura/crates/uhura-check/src/markup.rs
new file mode 100644
index 0000000..be798ef
--- /dev/null
+++ b/uhura/crates/uhura-check/src/markup.rs
@@ -0,0 +1,1262 @@
+//! The markup rules (§4.4, §4.8, §10): catalog authority, event
+//! eligibility, children models, nested interactives, controlled
+//! promotion, a11y completeness, one-root, the emit binding model, and the
+//! availability-match requirement. Expressions inside markup type through
+//! `Typer` with the view-position projection rule armed.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use uhura_base::{Diagnostic, Ident, Span, codes};
+use uhura_syntax::ast;
+
+use crate::catalog::{Catalog, ChildrenModel, ElementClass, PropType};
+use crate::infer::Typer;
+use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean};
+use crate::types::{MapKey, Ty};
+
+/// Documented patterns for things that are deliberately not elements (§10);
+/// surfaced as notes on `unknown-element`.
+const PATTERN_NOTES: &[(&str, &str)] = &[
+ (
+ "avatar",
+ "the avatar pattern is `` — see docs/patterns",
+ ),
+ (
+ "card",
+ "the card pattern is `` — see docs/patterns",
+ ),
+ (
+ "column",
+ "layout is CSS: `` with flex-direction",
+ ),
+ ("row", "layout is CSS: `` with flex-direction"),
+ ("stack", "layout is CSS: ``"),
+ ("grid", "layout is CSS: `` with display: grid"),
+ (
+ "spacer",
+ "spacing is CSS: gap/padding on the parent ``",
+ ),
+ ("list", "`` with one keyed `{#each}`"),
+ (
+ "sheet",
+ "sheets are surfaces — `surface modality sheet`",
+ ),
+ ("dialog", "dialogs are surfaces (core surface stack)"),
+ (
+ "video",
+ "video is deferred: poster `` + `video-off` badge pattern",
+ ),
+];
+
+/// What one definition's markup walk produces for later passes.
+pub struct MarkupFacts {
+ /// Class names referenced from markup (for the style existence check).
+ pub class_refs: Vec<(String, Span)>,
+}
+
+struct EmitUse {
+ name: Ident,
+ on_supplementary_region: bool,
+}
+
+pub struct MarkupChecker<'a> {
+ pub typer: Typer<'a>,
+ pub catalog: &'a Catalog,
+ /// Component name → its expansion contains an interactive element.
+ pub interactive_memo: &'a BTreeMap,
+ class_refs: Vec<(String, Span)>,
+ emit_uses: Vec,
+}
+
+pub fn check_markup(
+ env: &DefEnv,
+ resolved: &Resolved,
+ catalog: &Catalog,
+ interactive_memo: &BTreeMap,
+ markup: &[ast::Node],
+ file_span: Span,
+ diags: &mut Vec,
+) -> MarkupFacts {
+ let mut typer = Typer::new(env, resolved, diags);
+ typer.in_view = true;
+ let mut checker = MarkupChecker {
+ typer,
+ catalog,
+ interactive_memo,
+ class_refs: Vec::new(),
+ emit_uses: Vec::new(),
+ };
+
+ // One root, and it must be keyable (§4.4/§8.1).
+ let roots: Vec<&ast::Node> = markup
+ .iter()
+ .filter(|n| !matches!(n, ast::Node::Error { .. }))
+ .collect();
+ if roots.len() != 1 {
+ checker.typer.diags.push(Diagnostic::error(
+ codes::ONE_ROOT.0,
+ codes::ONE_ROOT.1,
+ format!(
+ "a definition has exactly one root element, found {}",
+ roots.len()
+ ),
+ roots.get(1).map_or(file_span, |n| node_span(n)),
+ ));
+ }
+ if let Some(root) = roots.first()
+ && !matches!(root, ast::Node::Element(_) | ast::Node::Match { .. })
+ {
+ checker.typer.diags.push(Diagnostic::error(
+ codes::ONE_ROOT.0,
+ codes::ONE_ROOT.1,
+ "the root must be an element (or a `{#match}` whose arms each have one root)"
+ .to_string(),
+ node_span(root),
+ ));
+ }
+ checker.walk_nodes(markup, false);
+
+ // Supplementary regions need a same-named emit reachable from a
+ // focusable element (§10 — name-level check).
+ let focusable: BTreeSet<&Ident> = checker
+ .emit_uses
+ .iter()
+ .filter(|u| !u.on_supplementary_region)
+ .map(|u| &u.name)
+ .collect();
+ let mut flagged = BTreeSet::new();
+ for emit_use in &checker.emit_uses {
+ if emit_use.on_supplementary_region
+ && !focusable.contains(&emit_use.name)
+ && flagged.insert(emit_use.name.clone())
+ {
+ checker.typer.diags.push(Diagnostic::error(
+ codes::SUPPLEMENTARY_UNREACHABLE.0,
+ codes::SUPPLEMENTARY_UNREACHABLE.1,
+ format!(
+ "`{}` is only reachable through a supplementary region; a focusable \
+ element in this definition must also emit it (§10)",
+ emit_use.name
+ ),
+ file_span,
+ ));
+ }
+ }
+
+ MarkupFacts {
+ class_refs: checker.class_refs,
+ }
+}
+
+fn node_span(node: &ast::Node) -> Span {
+ match node {
+ ast::Node::Element(el) => el.span,
+ ast::Node::Text { span, .. }
+ | ast::Node::If { span, .. }
+ | ast::Node::Each { span, .. }
+ | ast::Node::Match { span, .. }
+ | ast::Node::Error { span } => *span,
+ }
+}
+
+impl MarkupChecker<'_> {
+ fn error(&mut self, code: (&'static str, &'static str), message: String, span: Span) {
+ self.typer
+ .diags
+ .push(Diagnostic::error(code.0, code.1, message, span));
+ }
+
+ fn walk_nodes(&mut self, nodes: &[ast::Node], in_interactive: bool) {
+ for node in nodes {
+ self.walk_node(node, in_interactive);
+ }
+ }
+
+ fn walk_node(&mut self, node: &ast::Node, in_interactive: bool) {
+ match node {
+ ast::Node::Error { .. } => {}
+ ast::Node::Text { span, .. } => {
+ self.error(
+ codes::INTERP_OUTSIDE_TEXT,
+ "text content (and `{expr}` interpolation) lives inside `` only (§4.4)"
+ .to_string(),
+ *span,
+ );
+ }
+ ast::Node::If {
+ cond, then, els, ..
+ } => {
+ self.typer.check(cond, &Ty::Bool);
+ self.walk_nodes(then, in_interactive);
+ if let Some(els) = els {
+ self.walk_nodes(els, in_interactive);
+ }
+ }
+ ast::Node::Each { .. } => self.walk_each(node, in_interactive),
+ ast::Node::Match { .. } => self.walk_match(node, in_interactive),
+ ast::Node::Element(el) => {
+ let Ok(name) = Ident::new(&el.name) else {
+ return;
+ };
+ if self.catalog.elements.contains_key(&name) {
+ self.walk_element(el, &name, in_interactive);
+ } else if self.typer.resolved.components.contains_key(&name) {
+ self.walk_component_call(el, &name, in_interactive);
+ } else {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_ELEMENT.0,
+ codes::UNKNOWN_ELEMENT.1,
+ format!(
+ "`<{name}>` is neither a catalog element nor an imported component"
+ ),
+ el.span,
+ );
+ if let Some((_, note)) = PATTERN_NOTES.iter().find(|(p, _)| *p == name.as_str())
+ {
+ d = d.with_note((*note).to_string());
+ } else if let Some(s) = did_you_mean(
+ &name,
+ self.catalog
+ .elements
+ .keys()
+ .chain(self.typer.resolved.components.keys()),
+ ) {
+ d = d.with_note(format!("did you mean `<{s}>`?"));
+ }
+ if self.typer.resolved.surfaces.contains_key(&name) {
+ d = d.with_note(format!(
+ "`{name}` is a surface — surfaces mount via `open-surface`, \
+ not markup"
+ ));
+ }
+ self.typer.diags.push(d);
+ }
+ }
+ }
+ }
+
+ // ── {#each} ────────────────────────────────────────────────────────
+
+ fn walk_each(&mut self, node: &ast::Node, in_interactive: bool) {
+ let ast::Node::Each {
+ item,
+ seq,
+ key,
+ body,
+ span,
+ } = node
+ else {
+ return;
+ };
+ let seq_ty = self.typer.infer(seq);
+ let item_ty = match seq_ty {
+ Ty::List(t) => *t,
+ Ty::Map(k, _) => match k {
+ MapKey::Id => Ty::Id,
+ MapKey::Tag => Ty::Tag,
+ },
+ Ty::Error => Ty::Error,
+ other => {
+ self.error(
+ codes::BAD_OPERAND,
+ format!(
+ "`{{#each}}` iterates lists (items) and maps (keys), not {}",
+ other.describe()
+ ),
+ *span,
+ );
+ Ty::Error
+ }
+ };
+ let mark = self.typer.locals.len();
+ self.typer.push_local(item, item_ty, *span);
+ let key_ty = self.typer.infer(key);
+ if !matches!(key_ty, Ty::Id | Ty::Tag | Ty::Text | Ty::Int | Ty::Error) {
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!(
+ "each-keys are identity values (id | tag | text | int), got {}",
+ key_ty.describe()
+ ),
+ *span,
+ );
+ }
+ self.walk_nodes(body, in_interactive);
+ self.typer.truncate_locals(mark);
+ }
+
+ // ── {#match} ───────────────────────────────────────────────────────
+
+ fn walk_match(&mut self, node: &ast::Node, in_interactive: bool) {
+ let ast::Node::Match {
+ scrutinee,
+ arms,
+ span,
+ } = node
+ else {
+ return;
+ };
+ if let Some(proj_ty) = self.availability_scrutinee(scrutinee) {
+ self.walk_availability_arms(arms, &proj_ty, *span, in_interactive);
+ } else {
+ let ty = self.typer.infer(scrutinee);
+ match ty {
+ Ty::Union(variants) => {
+ self.walk_union_arms(arms, &variants, *span, in_interactive);
+ }
+ Ty::Error => {
+ for arm in arms {
+ let mark = self.typer.locals.len();
+ if let Some(binding) = &arm.binding {
+ self.typer.push_local(binding, Ty::Error, arm.span);
+ }
+ self.walk_nodes(&arm.body, in_interactive);
+ self.typer.truncate_locals(mark);
+ }
+ }
+ other => {
+ self.error(
+ codes::BAD_UNION_ARMS,
+ format!(
+ "`{{#match}}` works on port unions and projection availability, \
+ not {}",
+ other.describe()
+ ),
+ *span,
+ );
+ }
+ }
+ }
+ }
+
+ /// A scrutinee that is a projection read makes this an availability
+ /// match (§9.2); returns the projection's value type.
+ fn availability_scrutinee(&mut self, scrutinee: &ast::Expr) -> Option {
+ match &scrutinee.kind {
+ ast::ExprKind::Ident(name) => {
+ let ident = Ident::new(name).ok()?;
+ let proj = self.typer.env.projections.get(&ident)?;
+ if proj.key.is_some() {
+ self.error(
+ codes::WRONG_ARGS,
+ format!("projection `{ident}` is keyed — match on `{ident}()`"),
+ scrutinee.span,
+ );
+ return Some(Ty::Error);
+ }
+ Some(proj.ty.clone())
+ }
+ ast::ExprKind::Call { name, args } => {
+ let ident = Ident::new(name).ok()?;
+ let proj = self.typer.env.projections.get(&ident)?;
+ let ty = proj.ty.clone();
+ let key = proj.key.clone();
+ match key {
+ Some(key_ty) if args.len() == 1 => {
+ // The key expression types in *non-view* position:
+ // it is data feeding the read, not a read itself.
+ self.typer.in_view = false;
+ self.typer.check(&args[0], &key_ty);
+ self.typer.in_view = true;
+ }
+ _ => {
+ self.error(
+ codes::WRONG_ARGS,
+ format!("keyed read `{ident}()` takes exactly one key"),
+ scrutinee.span,
+ );
+ }
+ }
+ Some(ty)
+ }
+ _ => None,
+ }
+ }
+
+ fn walk_availability_arms(
+ &mut self,
+ arms: &[ast::MatchArm],
+ ready_ty: &Ty,
+ span: Span,
+ in_interactive: bool,
+ ) {
+ let mut seen = BTreeSet::new();
+ for arm in arms {
+ let variant = match &arm.pattern {
+ ast::MatchPattern::Else => {
+ self.error(
+ codes::BAD_AVAILABILITY_ARMS,
+ "availability matches spell out `loading | failed | ready` — no `{:else}`"
+ .to_string(),
+ arm.span,
+ );
+ continue;
+ }
+ ast::MatchPattern::Variant(v) => v.as_str(),
+ };
+ if !seen.insert(variant.to_string()) {
+ self.error(
+ codes::BAD_AVAILABILITY_ARMS,
+ format!("duplicate `{{:when {variant}}}` arm"),
+ arm.span,
+ );
+ }
+ let binding_ty = match variant {
+ "loading" => {
+ if arm.binding.is_some() {
+ self.error(
+ codes::BAD_AVAILABILITY_ARMS,
+ "`loading` carries no value to bind".to_string(),
+ arm.span,
+ );
+ }
+ None
+ }
+ "failed" => Some(Ty::Text),
+ "ready" => Some(ready_ty.clone()),
+ other => {
+ self.error(
+ codes::BAD_AVAILABILITY_ARMS,
+ format!(
+ "`{other}` is not an availability arm (loading | failed | ready — \
+ §9.2: these are language arms, never contract types)"
+ ),
+ arm.span,
+ );
+ None
+ }
+ };
+ let mark = self.typer.locals.len();
+ if let (Some(binding), Some(ty)) = (&arm.binding, binding_ty) {
+ self.typer.push_local(binding, ty, arm.span);
+ }
+ self.walk_nodes(&arm.body, in_interactive);
+ self.typer.truncate_locals(mark);
+ }
+ for required in ["loading", "failed", "ready"] {
+ if !seen.contains(required) {
+ self.error(
+ codes::BAD_AVAILABILITY_ARMS,
+ format!(
+ "availability match is missing its `{{:when {required}}}` arm — \
+ absence is a state the design must show (§9.2)"
+ ),
+ span,
+ );
+ }
+ }
+ }
+
+ fn walk_union_arms(
+ &mut self,
+ arms: &[ast::MatchArm],
+ variants: &BTreeMap>,
+ span: Span,
+ in_interactive: bool,
+ ) {
+ let mut seen: BTreeSet = BTreeSet::new();
+ let mut has_else = false;
+ for arm in arms {
+ let mark = self.typer.locals.len();
+ match &arm.pattern {
+ ast::MatchPattern::Else => {
+ has_else = true;
+ }
+ ast::MatchPattern::Variant(v) => {
+ if let Ok(variant) = Ident::new(v) {
+ match variants.get(&variant) {
+ Some(fields) => {
+ if !seen.insert(variant.clone()) {
+ self.error(
+ codes::BAD_UNION_ARMS,
+ format!("duplicate `{{:when {variant}}}` arm"),
+ arm.span,
+ );
+ }
+ if let Some(binding) = &arm.binding {
+ self.typer.push_local(
+ binding,
+ Ty::Record(fields.clone()),
+ arm.span,
+ );
+ }
+ }
+ None => {
+ let names: Vec<&str> = variants.keys().map(Ident::as_str).collect();
+ self.error(
+ codes::BAD_UNION_ARMS,
+ format!(
+ "`{variant}` is not a variant (union has {})",
+ names.join(" | ")
+ ),
+ arm.span,
+ );
+ }
+ }
+ }
+ }
+ }
+ self.walk_nodes(&arm.body, in_interactive);
+ self.typer.truncate_locals(mark);
+ }
+ if !has_else {
+ for variant in variants.keys() {
+ if !seen.contains(variant) {
+ self.error(
+ codes::BAD_UNION_ARMS,
+ format!(
+ "non-exhaustive match: `{variant}` is unhandled (add the arm or \
+ `{{:else}}`)"
+ ),
+ span,
+ );
+ }
+ }
+ }
+ }
+
+ // ── catalog elements ───────────────────────────────────────────────
+
+ fn walk_element(&mut self, el: &ast::Element, name: &Ident, in_interactive: bool) {
+ let decl = self.catalog.elements[name].clone();
+
+ if decl.class == ElementClass::Interactive && in_interactive {
+ self.error(
+ codes::NESTED_INTERACTIVE,
+ format!("`<{name}>` cannot nest inside another interactive element (§10)"),
+ el.span,
+ );
+ }
+
+ // ── attributes ─────────────────────────────────────────────────
+ let mut bound: BTreeMap = BTreeMap::new();
+ let mut role_literal: Option = None;
+ for attr in &el.attrs {
+ let Ok(attr_name) = Ident::new(&attr.name) else {
+ continue;
+ };
+ if let Some(prev) = bound.insert(attr_name.clone(), attr.span) {
+ self.typer.diags.push(
+ Diagnostic::error(
+ codes::DUPLICATE_ATTR.0,
+ codes::DUPLICATE_ATTR.1,
+ format!("`{attr_name}` is bound twice"),
+ attr.span,
+ )
+ .with_label(prev, "first bound here"),
+ );
+ continue;
+ }
+ if attr_name.as_str() == "class" {
+ self.collect_class_attr(attr);
+ continue;
+ }
+ let Some(prop) = decl.props.get(&attr_name) else {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_PROP.0,
+ codes::UNKNOWN_PROP.1,
+ format!("`<{name}>` has no semantic prop `{attr_name}`"),
+ attr.span,
+ );
+ if let Some(s) = did_you_mean(&attr_name, decl.props.keys()) {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ } else {
+ d = d.with_note(
+ "styling props do not exist — layout and aesthetics are CSS (§10)"
+ .to_string(),
+ );
+ }
+ self.typer.diags.push(d);
+ continue;
+ };
+ if name.as_str() == "view"
+ && attr_name.as_str() == "role"
+ && let ast::AttrValue::Literal(v) = &attr.value
+ {
+ role_literal = Some(v.clone());
+ }
+ self.check_prop_value(name, &attr_name, &prop.ty, &attr.value, attr.span);
+ }
+
+ for (prop_name, prop) in &decl.props {
+ if prop.required && !bound.contains_key(prop_name) {
+ let in_xor_group = decl
+ .exactly_one_of
+ .iter()
+ .any(|group| group.contains(prop_name));
+ if !in_xor_group {
+ self.error(
+ codes::MISSING_REQUIRED_PROP,
+ format!("`<{name}>` requires `{prop_name}`"),
+ el.span,
+ );
+ }
+ }
+ }
+ for group in &decl.exactly_one_of {
+ let present = group.iter().filter(|p| bound.contains_key(*p)).count();
+ if present != 1 {
+ let names: Vec<&str> = group.iter().map(Ident::as_str).collect();
+ self.error(
+ codes::A11Y_ALT,
+ format!("`<{name}>` takes exactly one of {}", names.join(" / ")),
+ el.span,
+ );
+ }
+ }
+
+ // ── events ─────────────────────────────────────────────────────
+ let mut events_bound: BTreeSet = BTreeSet::new();
+ for event_attr in &el.events {
+ let Ok(event_name) = Ident::new(&event_attr.event) else {
+ continue;
+ };
+ let Some(event_decl) = decl.events.get(&event_name) else {
+ let mut d = Diagnostic::error(
+ codes::EVENT_NOT_DECLARED.0,
+ codes::EVENT_NOT_DECLARED.1,
+ format!("`<{name}>` declares no `{event_name}` event"),
+ event_attr.span,
+ );
+ if decl.class == ElementClass::Layout && !decl.viewport {
+ d = d.with_note(
+ "`on:` never attaches to layout elements — wrap the content in \
+ `` (§4.8; never auto-repaired)"
+ .to_string(),
+ );
+ }
+ self.typer.diags.push(d);
+ continue;
+ };
+ events_bound.insert(event_name.clone());
+ match &event_attr.binding {
+ ast::EventBinding::Forward => {
+ self.error(
+ codes::ELEMENT_EVENT_NEEDS_EMIT,
+ format!(
+ "element events bind explicitly: \
+ `on:{event_name}={{emit (…)}}` (§4.4)"
+ ),
+ event_attr.span,
+ );
+ }
+ ast::EventBinding::Emit {
+ name: emit_name,
+ args,
+ } => {
+ self.check_emit_binding(emit_name, args, &event_decl.carries, event_attr.span);
+ if let Ok(emit) = Ident::new(emit_name) {
+ self.emit_uses.push(EmitUse {
+ name: emit,
+ on_supplementary_region: name.as_str() == "region"
+ && bound.iter().any(|(b, _)| b.as_str() == "supplementary"),
+ });
+ }
+ }
+ }
+ }
+
+ // Controlled promotion (§10): binding `value` obligates `change`.
+ if let Some((prop, event)) = &decl.controlled
+ && bound.contains_key(prop)
+ && !events_bound.contains(event)
+ {
+ self.error(
+ codes::CONTROLLED_PROMOTION,
+ format!(
+ "binding `{prop}` makes `<{name}>` controlled — it must handle \
+ `on:{event}` (§10)"
+ ),
+ el.span,
+ );
+ }
+
+ // ── children ───────────────────────────────────────────────────
+ let now_interactive = in_interactive || decl.class == ElementClass::Interactive;
+ let children: Vec<&ast::Node> = el
+ .children
+ .iter()
+ .filter(|c| !matches!(c, ast::Node::Error { .. }))
+ .collect();
+ match decl.children {
+ ChildrenModel::Any => self.walk_nodes(&el.children, now_interactive),
+ ChildrenModel::None => {
+ if !children.is_empty() {
+ self.error(
+ codes::BAD_CHILDREN,
+ format!("`<{name}>` takes no children"),
+ el.span,
+ );
+ }
+ }
+ ChildrenModel::Text => {
+ for child in &children {
+ match child {
+ ast::Node::Text { runs, .. } => {
+ for run in runs {
+ if let ast::TextRun::Interp(expr) = run {
+ self.typer.check(expr, &Ty::Text);
+ }
+ }
+ }
+ other => {
+ self.error(
+ codes::BAD_CHILDREN,
+ format!("`<{name}>` holds text runs only"),
+ node_span(other),
+ );
+ }
+ }
+ }
+ }
+ ChildrenModel::Content => {
+ for child in &children {
+ let ok = match child {
+ ast::Node::Element(child_el) => Ident::new(&child_el.name)
+ .ok()
+ .and_then(|n| self.catalog.elements.get(&n))
+ .is_some_and(|d| d.class == ElementClass::Content),
+ _ => false,
+ };
+ if !ok {
+ self.error(
+ codes::BAD_CHILDREN,
+ format!(
+ "`<{name}>` children are content elements (text / image / icon)"
+ ),
+ node_span(child),
+ );
+ }
+ }
+ self.walk_nodes(&el.children, now_interactive);
+ }
+ ChildrenModel::One => {
+ if children.len() != 1 || !matches!(children.first(), Some(ast::Node::Element(_))) {
+ self.error(
+ codes::BAD_CHILDREN,
+ format!("`<{name}>` wraps exactly one element"),
+ el.span,
+ );
+ }
+ self.walk_nodes(&el.children, now_interactive);
+ }
+ ChildrenModel::KeyedEach => {
+ if children.len() != 1 || !matches!(children.first(), Some(ast::Node::Each { .. }))
+ {
+ self.error(
+ codes::BAD_CHILDREN,
+ format!(
+ "`<{name}>` children come from exactly one keyed `{{#each}}` (§10)"
+ ),
+ el.span,
+ );
+ }
+ self.walk_nodes(&el.children, now_interactive);
+ }
+ }
+
+ // role="list" requires one keyed each (§10 a11y completeness).
+ if role_literal.as_deref() == Some("list")
+ && (children.len() != 1 || !matches!(children.first(), Some(ast::Node::Each { .. })))
+ {
+ self.error(
+ codes::LIST_NEEDS_KEYED_EACH,
+ "`role=\"list\"` promises list semantics — children come from exactly one \
+ keyed `{#each}` (§10)"
+ .to_string(),
+ el.span,
+ );
+ }
+ }
+
+ fn check_prop_value(
+ &mut self,
+ element: &Ident,
+ prop: &Ident,
+ ty: &PropType,
+ value: &ast::AttrValue,
+ span: Span,
+ ) {
+ let expected = match ty {
+ PropType::Text => Ty::Text,
+ PropType::Bool => Ty::Bool,
+ PropType::Int => Ty::Int,
+ PropType::Asset => Ty::Asset,
+ PropType::Enum(values) => Ty::Enum(values.clone()),
+ PropType::Icon => Ty::Enum(self.catalog.icons.clone()),
+ };
+ match value {
+ ast::AttrValue::Bare => {
+ if !matches!(ty, PropType::Bool) {
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!(
+ "bare `{prop}` means `true`; `<{element}>`'s `{prop}` is {}",
+ ty.describe()
+ ),
+ span,
+ );
+ }
+ }
+ ast::AttrValue::Literal(s) => match ty {
+ PropType::Icon => {
+ if let Ok(icon) = Ident::new(s) {
+ if !self.catalog.icons.contains(&icon) {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_ICON.0,
+ codes::UNKNOWN_ICON.1,
+ format!("`{s}` is not in the catalog icon set"),
+ span,
+ );
+ if let Some(near) = did_you_mean(&icon, self.catalog.icons.iter()) {
+ d = d.with_note(format!("did you mean `{near}`?"));
+ }
+ self.typer.diags.push(d);
+ }
+ } else {
+ self.error(
+ codes::UNKNOWN_ICON,
+ format!("`{s}` is not an icon name"),
+ span,
+ );
+ }
+ }
+ PropType::Enum(values) => {
+ if !values.iter().any(|v| v.as_str() == s) {
+ let names: Vec<&str> = values.iter().map(Ident::as_str).collect();
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!("`\"{s}\"` is not one of {}", names.join(" | ")),
+ span,
+ );
+ }
+ }
+ PropType::Text => {}
+ other => {
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!("`{prop}` is {}, not a text literal", other.describe()),
+ span,
+ );
+ }
+ },
+ ast::AttrValue::Expr(expr) => self.typer.check(expr, &expected),
+ }
+ }
+
+ /// `on:={emit (args)}` on a catalog element:
+ /// the target signature must equal author args ∪ carried fields (§4.2).
+ fn check_emit_binding(
+ &mut self,
+ emit_name: &str,
+ args: &[ast::Arg],
+ carries: &BTreeMap,
+ span: Span,
+ ) {
+ let Ok(emit) = Ident::new(emit_name) else {
+ return;
+ };
+ for arg in args {
+ if Ident::new(&arg.name).is_ok_and(|a| carries.contains_key(&a)) {
+ self.error(
+ codes::CARRIED_FIELD_NAMED,
+ format!(
+ "`{}` is carried by the renderer — the author may not bind it (§4.2)",
+ arg.name
+ ),
+ arg.span,
+ );
+ }
+ }
+
+ let signature = self.target_signature(&emit, span);
+ let Some(signature) = signature else {
+ for arg in args {
+ self.typer.infer(&arg.value);
+ }
+ return;
+ };
+
+ // Author args typecheck against the signature.
+ for arg in args {
+ match signature.iter().find(|(n, _)| n.as_str() == arg.name) {
+ Some((_, ty)) => {
+ let ty = ty.clone();
+ self.typer.check(&arg.value, &ty);
+ }
+ None => {
+ self.error(
+ codes::WRONG_ARGS,
+ format!("`{emit}` has no param `{}`", arg.name),
+ arg.span,
+ );
+ self.typer.infer(&arg.value);
+ }
+ }
+ }
+ // Coverage: args ∪ carries must equal the signature.
+ for (param, param_ty) in &signature {
+ let by_author = args.iter().any(|a| a.name == param.as_str());
+ let by_carry = carries.get(param).map(|c| match c {
+ PropType::Text => Ty::Text,
+ PropType::Bool => Ty::Bool,
+ _ => Ty::Int,
+ });
+ match (by_author, by_carry) {
+ (true, _) => {}
+ (false, Some(carry_ty)) => {
+ if carry_ty != *param_ty {
+ self.error(
+ codes::WRONG_ARGS,
+ format!(
+ "carried field `{param}` is {}, but the handler declares {}",
+ carry_ty.describe(),
+ param_ty.describe()
+ ),
+ span,
+ );
+ }
+ }
+ (false, None) => {
+ self.error(
+ codes::WRONG_ARGS,
+ format!("`{emit}` needs `{param}` (§4.2: payload = args ∪ carried fields)"),
+ span,
+ );
+ }
+ }
+ }
+ }
+
+ /// The machine-event signature an emit targets: own handlers for
+ /// pages/surfaces, the `emits` declaration for components.
+ fn target_signature(&mut self, emit: &Ident, span: Span) -> Option> {
+ let env = self.typer.env;
+ if matches!(env.kind, SubjectKind::Component { .. }) {
+ match env.emits.get(emit) {
+ Some(sig) => Some(sig.clone()),
+ None => {
+ let mut d = Diagnostic::error(
+ codes::UNDECLARED_EMIT.0,
+ codes::UNDECLARED_EMIT.1,
+ format!("`{emit}` is not declared in this component's `emits` block"),
+ span,
+ );
+ if let Some(s) = did_you_mean(emit, env.emits.keys()) {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ }
+ self.typer.diags.push(d);
+ None
+ }
+ }
+ } else {
+ match env.events.get(emit) {
+ Some(sig) => Some(sig.clone()),
+ None => {
+ let mut d = Diagnostic::error(
+ codes::UNRESOLVED_NAME.0,
+ codes::UNRESOLVED_NAME.1,
+ format!("no handler for `{emit}` in this file's store"),
+ span,
+ );
+ if let Some(s) = did_you_mean(emit, env.events.keys()) {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ }
+ self.typer.diags.push(d);
+ None
+ }
+ }
+ }
+ }
+
+ // ── component calls ────────────────────────────────────────────────
+
+ fn walk_component_call(&mut self, el: &ast::Element, name: &Ident, in_interactive: bool) {
+ if !self.typer.env.component_imports.contains_key(name) {
+ self.error(
+ codes::UNKNOWN_ELEMENT,
+ format!("`<{name}>` exists but is not imported — add `use component {name}`"),
+ el.span,
+ );
+ return;
+ }
+ let target = &self.typer.resolved.components[name];
+ let target_props: Vec<(Ident, Ty)> = target
+ .props
+ .iter()
+ .map(|(n, t)| (n.clone(), t.clone()))
+ .collect();
+ let target_emits: BTreeMap> = target.emits.clone();
+
+ if in_interactive && self.interactive_memo.get(name).copied().unwrap_or(false) {
+ self.error(
+ codes::NESTED_INTERACTIVE,
+ format!(
+ "`<{name}>` expands to interactive content — it cannot nest inside an \
+ interactive element (§10)"
+ ),
+ el.span,
+ );
+ }
+ if !el.children.is_empty() {
+ self.error(
+ codes::BAD_CHILDREN,
+ "components take no children in the spike (no slots — §14 deferred)".to_string(),
+ el.span,
+ );
+ }
+
+ // ── props ──────────────────────────────────────────────────────
+ let mut bound: BTreeSet = BTreeSet::new();
+ for attr in &el.attrs {
+ let Ok(attr_name) = Ident::new(&attr.name) else {
+ continue;
+ };
+ if !bound.insert(attr_name.clone()) {
+ self.error(
+ codes::DUPLICATE_ATTR,
+ format!("`{attr_name}` is bound twice"),
+ attr.span,
+ );
+ continue;
+ }
+ let Some((_, ty)) = target_props.iter().find(|(n, _)| *n == attr_name) else {
+ let mut d = Diagnostic::error(
+ codes::UNKNOWN_PROP.0,
+ codes::UNKNOWN_PROP.1,
+ format!("`<{name}>` declares no prop `{attr_name}`"),
+ attr.span,
+ );
+ if attr_name.as_str() == "class" {
+ d = d.with_note(
+ "a component's root class is its own markup's business".to_string(),
+ );
+ } else if let Some(s) =
+ did_you_mean(&attr_name, target_props.iter().map(|(n, _)| n))
+ {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ }
+ self.typer.diags.push(d);
+ continue;
+ };
+ let ty = ty.clone();
+ match &attr.value {
+ ast::AttrValue::Bare => {
+ if ty != Ty::Bool {
+ self.error(
+ codes::TYPE_MISMATCH,
+ format!(
+ "bare `{attr_name}` means `true`; the prop is {}",
+ ty.describe()
+ ),
+ attr.span,
+ );
+ }
+ }
+ ast::AttrValue::Literal(s) => {
+ let lit = ast::Expr {
+ kind: ast::ExprKind::Str(s.clone()),
+ span: attr.span,
+ };
+ self.typer.check(&lit, &ty);
+ }
+ ast::AttrValue::Expr(expr) => self.typer.check(expr, &ty),
+ }
+ }
+ for (prop_name, ty) in &target_props {
+ if !bound.contains(prop_name) && !matches!(ty, Ty::Option(_)) {
+ self.error(
+ codes::MISSING_REQUIRED_PROP,
+ format!("`<{name}>` requires `{prop_name}`"),
+ el.span,
+ );
+ }
+ }
+
+ // ── emit consumption (§4.4: one model, explicit) ───────────────
+ let mut consumed: BTreeSet = BTreeSet::new();
+ for event_attr in &el.events {
+ let Ok(emit) = Ident::new(&event_attr.event) else {
+ continue;
+ };
+ let Some(emit_sig) = target_emits.get(&emit) else {
+ let mut d = Diagnostic::error(
+ codes::UNDECLARED_EMIT.0,
+ codes::UNDECLARED_EMIT.1,
+ format!("`<{name}>` declares no emit `{emit}`"),
+ event_attr.span,
+ );
+ if let Some(s) = did_you_mean(&emit, target_emits.keys()) {
+ d = d.with_note(format!("did you mean `{s}`?"));
+ }
+ self.typer.diags.push(d);
+ continue;
+ };
+ if !consumed.insert(emit.clone()) {
+ self.error(
+ codes::DUPLICATE_ATTR,
+ format!("`on:{emit}` is bound twice"),
+ event_attr.span,
+ );
+ }
+ match &event_attr.binding {
+ ast::EventBinding::Forward => {
+ // Same name, same payload, enclosing machine scope.
+ let Some(own_sig) = self.target_signature(&emit, event_attr.span) else {
+ continue;
+ };
+ if own_sig != *emit_sig {
+ self.error(
+ codes::WRONG_ARGS,
+ format!(
+ "forwarding `{emit}` requires the identical signature in the \
+ enclosing scope (§4.4)"
+ ),
+ event_attr.span,
+ );
+ }
+ }
+ ast::EventBinding::Emit {
+ name: rebind_name,
+ args,
+ } => {
+ // Rebind: new event, args in caller scope, component
+ // payload discarded — so no carries here.
+ self.check_emit_binding(rebind_name, args, &BTreeMap::new(), event_attr.span);
+ }
+ }
+ }
+ for emit in target_emits.keys() {
+ if !consumed.contains(emit) {
+ self.typer.diags.push(Diagnostic::warning(
+ codes::UNHANDLED_EVENT.0,
+ codes::UNHANDLED_EVENT.1,
+ format!(
+ "`<{name}>` emits `{emit}` but this call site leaves it unbound — \
+ the control will be dead (§4.4)"
+ ),
+ el.span,
+ ));
+ }
+ }
+ }
+
+ fn collect_class_attr(&mut self, attr: &ast::Attr) {
+ match &attr.value {
+ ast::AttrValue::Literal(s) => {
+ for class in s.split_whitespace() {
+ self.class_refs.push((class.to_string(), attr.span));
+ }
+ }
+ ast::AttrValue::Expr(expr) => {
+ self.typer.check(expr, &Ty::Text);
+ collect_string_literals(expr, &mut |s, span| {
+ for class in s.split_whitespace() {
+ self.class_refs.push((class.to_string(), span));
+ }
+ });
+ }
+ ast::AttrValue::Bare => {
+ self.error(
+ codes::TYPE_MISMATCH,
+ "`class` needs a value".to_string(),
+ attr.span,
+ );
+ }
+ }
+ }
+}
+
+/// Computes, for every component, whether its expansion contains an
+/// interactive element (for the nested-interactives rule across component
+/// boundaries). The import graph is a DAG, so plain recursion with a memo
+/// terminates.
+pub fn interactive_content_memo(
+ resolved: &Resolved,
+ sources: &[crate::resolve::ParsedSource],
+ catalog: &Catalog,
+) -> BTreeMap {
+ fn nodes_interactive(
+ nodes: &[ast::Node],
+ catalog: &Catalog,
+ resolved: &Resolved,
+ sources: &[crate::resolve::ParsedSource],
+ memo: &mut BTreeMap,
+ ) -> bool {
+ nodes.iter().any(|node| match node {
+ ast::Node::Element(el) => {
+ let Ok(name) = Ident::new(&el.name) else {
+ return false;
+ };
+ if let Some(decl) = catalog.elements.get(&name) {
+ decl.class == ElementClass::Interactive
+ || nodes_interactive(&el.children, catalog, resolved, sources, memo)
+ } else {
+ component_interactive(&name, catalog, resolved, sources, memo)
+ }
+ }
+ ast::Node::If { then, els, .. } => {
+ nodes_interactive(then, catalog, resolved, sources, memo)
+ || els
+ .as_ref()
+ .is_some_and(|e| nodes_interactive(e, catalog, resolved, sources, memo))
+ }
+ ast::Node::Each { body, .. } => {
+ nodes_interactive(body, catalog, resolved, sources, memo)
+ }
+ ast::Node::Match { arms, .. } => arms
+ .iter()
+ .any(|arm| nodes_interactive(&arm.body, catalog, resolved, sources, memo)),
+ _ => false,
+ })
+ }
+
+ fn component_interactive(
+ name: &Ident,
+ catalog: &Catalog,
+ resolved: &Resolved,
+ sources: &[crate::resolve::ParsedSource],
+ memo: &mut BTreeMap,
+ ) -> bool {
+ if let Some(&known) = memo.get(name) {
+ return known;
+ }
+ memo.insert(name.clone(), false); // cycle backstop (DAG-checked anyway)
+ let result =
+ resolved
+ .components
+ .get(name)
+ .is_some_and(|env| match &sources[env.source].parsed {
+ uhura_syntax::Parsed::Module(ast) => {
+ nodes_interactive(&ast.markup, catalog, resolved, sources, memo)
+ }
+ uhura_syntax::Parsed::Examples(_) => false,
+ });
+ memo.insert(name.clone(), result);
+ result
+ }
+
+ let mut memo = BTreeMap::new();
+ let names: Vec = resolved.components.keys().cloned().collect();
+ for name in names {
+ component_interactive(&name, catalog, resolved, sources, &mut memo);
+ }
+ memo
+}
+
+fn collect_string_literals(expr: &ast::Expr, f: &mut impl FnMut(&str, Span)) {
+ match &expr.kind {
+ ast::ExprKind::Str(s) => f(s, expr.span),
+ ast::ExprKind::If { cond, then, els } => {
+ collect_string_literals(cond, f);
+ collect_string_literals(then, f);
+ collect_string_literals(els, f);
+ }
+ ast::ExprKind::Binary { lhs, rhs, .. } => {
+ collect_string_literals(lhs, f);
+ collect_string_literals(rhs, f);
+ }
+ ast::ExprKind::Unary { expr, .. } => collect_string_literals(expr, f),
+ _ => {}
+ }
+}
diff --git a/uhura/crates/uhura-check/src/pipeline.rs b/uhura/crates/uhura-check/src/pipeline.rs
new file mode 100644
index 0000000..35ecb18
--- /dev/null
+++ b/uhura/crates/uhura-check/src/pipeline.rs
@@ -0,0 +1,408 @@
+//! The check pipeline driver (§12.2 order): parse → routes/resolve →
+//! catalog pin → port link + lock → typecheck (stores) → markup rules →
+//! style checks → examples legality → lower (zero-error gated). Pure over
+//! in-memory inputs; the CLI does every file read and write.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use uhura_base::{Diagnostic, Ident, SourceMap, Span, codes, has_errors};
+use uhura_port::PortContract;
+use uhura_syntax::{Parsed, SourceKind, parse};
+
+use crate::catalog::{Catalog, load_catalog};
+use crate::examples::check_examples;
+use crate::infer::check_store;
+use crate::lower::{Lowered, lower};
+use crate::manifest::Manifest;
+use crate::markup::{check_markup, interactive_content_memo};
+use crate::resolve::{ParsedSource, resolve};
+use crate::style::{check_class_existence, check_style_block, compile_stylesheet, theme_classes};
+use crate::types::PortTypes;
+
+pub struct SourceInput {
+ pub rel_path: String,
+ pub text: String,
+ pub kind: SourceKind,
+}
+
+pub struct CheckInput {
+ pub manifest: Manifest,
+ pub manifest_rel_path: String,
+ pub manifest_text: String,
+ /// (corpus-relative path, text) — `None` text = unreadable/missing.
+ pub catalog_file: (String, Option),
+ /// Port name → (rel path, text).
+ pub port_files: BTreeMap)>,
+ pub sources: Vec,
+ pub theme_css: Option<(String, String)>,
+ /// Fixture name → (rel path, text) — from the manifest's `[fixtures]`.
+ pub fixture_files: BTreeMap)>,
+ /// Existing `uhura.lock` content, if any.
+ pub lock_text: Option,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum LockStatus {
+ /// No lock existed — the CLI writes the computed one (micro-decision #6).
+ Absent,
+ Match,
+ /// Pins differ — diagnosed as an error.
+ Drift,
+}
+
+pub struct CheckOutput {
+ pub diagnostics: Vec,
+ pub source_map: SourceMap,
+ /// Present iff the pipeline finished with zero errors.
+ pub lowered: Option,
+ /// Resolved example previews (empty unless the check came up clean).
+ pub previews: Vec,
+ /// theme.css + `
+
+
+
+
+ {app}uhura canvas — {frame_count} previews
+ drag to pan · wheel to zoom · double-click to fit
+
+
+
+
+
+