From b9261974706252034e990d7c21d28c6eb787d926 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 04:56:03 +0000 Subject: [PATCH] Inferred actions (AC-1b), HELD_WRITE (AC-1c), STALE_OWN_READ (AC-1d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An arrow in imperative scope is an action when its own statements guess, declare `affects`, or both write a source and await. The compiler sees every suspension (await is a statement), so it lowers such an arrow to Solid's action() exactly as `action { … }` lowers; nothing is added at run time. An effect's apply block is never inferred (its return is the cleanup, EF-5). Two static checks keep the hold visible, stricter than Solid, which cannot see a body: - HELD_WRITE: in an inferred action, a plain write that reaches an await would be held; make it optimistic (ephemeral), onFlush (a reset), or declare the action to hold it on purpose. - STALE_OWN_READ: in any run, reading a source the run's own change wrote gets the committed value (W-4); Solid rc.19 agrees inside action(). Both follow calls into local closures. Shared analysis in ref/src/infer.ts, ported to compiler/src/infer.rs. Scenarios S-infer-1..5; S-check-1's row handler had a stale read; S-solid-32's handler guess is now a variant. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWKaPajtqtwB6YqA277 --- compiler/src/check.rs | 27 ++- compiler/src/emit.rs | 6 + compiler/src/infer.rs | 400 ++++++++++++++++++++++++++++++++++++++++ compiler/src/lib.rs | 1 + guide/async.md | 13 +- ref/compile/emit.ts | 4 +- ref/oracle/lower.ts | 4 +- ref/oracle/run.ts | 3 +- ref/src/check.ts | 21 ++- ref/src/infer.ts | 131 +++++++++++++ ref/src/runtime.ts | 9 +- scenarios/S-check-1.md | 2 +- scenarios/S-infer-1.md | 74 ++++++++ scenarios/S-infer-2.md | 94 ++++++++++ scenarios/S-infer-3.md | 34 ++++ scenarios/S-infer-4.md | 32 ++++ scenarios/S-infer-5.md | 70 +++++++ scenarios/S-solid-32.md | 21 +-- spec/02-core.md | 10 +- spec/05-holds.md | 2 +- spec/06-stores.md | 71 ++++++- spec/07-surface.md | 30 ++- spec/11-compiler.md | 2 +- spec/99-diagnostics.md | 4 +- 24 files changed, 1025 insertions(+), 40 deletions(-) create mode 100644 compiler/src/infer.rs create mode 100644 ref/src/infer.ts create mode 100644 scenarios/S-infer-1.md create mode 100644 scenarios/S-infer-2.md create mode 100644 scenarios/S-infer-3.md create mode 100644 scenarios/S-infer-4.md create mode 100644 scenarios/S-infer-5.md diff --git a/compiler/src/check.rs b/compiler/src/check.rs index fabede7..36862c6 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -8,6 +8,7 @@ use crate::ast::*; use crate::content::{misnested, misnested_inside, Shape, P_CLOSERS, P_SCOPE}; +use crate::infer::{inferred_action, run_findings, trigger_text, Code}; use crate::model::{ModelBuilder, ModelSymbol}; use crate::modules::ModuleSet; use crate::parse::{js_trim, JsDecl}; @@ -1641,6 +1642,7 @@ impl<'a> Checker<'a> { let inner = c.env(Some(env), region); c.stmts(body, inner, Ctx::imperative()); }); + c.run_checks(body, false, &[], false); }); self.own_source_writes(inputs, body, env); } @@ -1652,6 +1654,7 @@ impl<'a> Checker<'a> { } self.comp("action", env, Some(name.clone()), None, |c| c.stmts(body, inner, Ctx { in_action: true, ..Ctx::imperative() })); self.actions.push(ActionRec { name: name.clone(), body, env: inner, line: *line as i64 }); + self.run_checks(body, true, params, false); } Decl::Server { name, params, body, line, .. } => { let region = self.reg(env); @@ -2127,7 +2130,15 @@ impl<'a> Checker<'a> { self.err("REACTIVE_WRITE_IN_OWNED_SCOPE", "W-2", "a statement-bodied closure in reactive scope (SC-3: a closure created here is a pure function)".into()); return; } - self.stmts(ss, inner, Ctx { server: ctx.server, ..Ctx::imperative() }); + // AC-1b: an arrow that guesses, or writes and awaits, is an action + let why = if ctx.server { None } else { inferred_action(ss) }; + self.stmts(ss, inner, Ctx { server: ctx.server, in_action: why.is_some(), ..Ctx::imperative() }); + if let Some(w) = why { + self.actions.push(ActionRec { name: format!("inferred here (AC-1b: {})", trigger_text(w)), body: ss, env: inner, line: self.line }); + } + if !ctx.server { + self.run_checks(ss, why.is_some(), params, why.is_some()); + } return; } self.expr(body, inner, Ctx { imperative: ctx.imperative, in_fn: ctx.in_fn, server: ctx.server, in_action: false }, None); @@ -2164,6 +2175,17 @@ impl<'a> Checker<'a> { } // ---- imperative scope (SC-2) ---- + /// AC-1c, AC-1d over one imperative run (`crate::infer`). + fn run_checks(&mut self, stmts: &[Stmt], action: bool, bound: &[String], inferred: bool) { + for f in run_findings(stmts, action, bound, inferred) { + let n = &f.name; + match f.code { + Code::HeldWrite => self.err("HELD_WRITE", "AC-1c", format!("{n} := … comes before an `await` in a body that is an action because it writes and awaits (AC-1b), so it is held until the body ends (AC-1): for state that shows while the work runs, `optimistic {n} := …` (a source with a question, ST-5c); for a reset that lands now, `onFlush(() => {{ {n} := … }})` (EF-8); to hold it on purpose, declare the action (`action name() {{ … }}`); or write it after the `await`")), + Code::StaleOwnRead => self.err("STALE_OWN_READ", "AC-1d", format!("{n} is read after this change wrote it: the read sees the committed value, not the write (W-4) — keep the value you wrote in a local (`let v = …; {n} := v`) and read that, or write functionally (`{n} := v => …`, W-6)")), + } + } + } + fn stmts(&mut self, ss: &'a [Stmt], env0: EId, ctx: Ctx) { let region = self.reg(env0); let env = self.env(Some(env0), region); @@ -2249,7 +2271,7 @@ impl<'a> Checker<'a> { } if op == "optimistic" { if !ctx.in_action { - self.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action body: a guess needs a hold to live in".into()); + self.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action: a guess needs a hold to live in — an apply block is never an action (its return is the cleanup, EF-5): declare one, `action save(v) { optimistic … }`, and call it from the block".into()); } if let Some(t) = tsym { if self.syms[t].kind == SymKind::Source && !self.syms[t].question { @@ -2304,6 +2326,7 @@ impl<'a> Checker<'a> { } self.comp("action", env, Some("(inline)".into()), None, |c| c.stmts(body, env, Ctx { in_action: true, ..Ctx::imperative() })); self.actions.push(ActionRec { name: "(inline)".into(), body, env, line: self.line }); + self.run_checks(body, true, &[], false); } StmtKind::Sif { c, t, f } => { self.expr(c, env, ctx, None); diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index ccbefe6..e801f32 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -1282,6 +1282,12 @@ impl<'p> Gen<'p> { let inner = self.params(env, params); let ps: Vec

= params.iter().map(|p| P::Id(mg(p))).collect(); match stmts { + Some(ss) if crate::infer::inferred_action(ss).is_some() => { + // AC-1b: an inferred action lowers as `action { … }` does, to Solid's action() + let b = self.stmts(ss, inner, &Ctx { imp: true, gen: true, dynamic: ctx.dynamic, effect: ctx.effect, ..Ctx::default() }, false)?; + let gen = E::Func(Box::new(Func { name: None, params: vec![], directives: vec![], body: b, is_async: false, generator: true })); + E::Arrow { params: ps, body: Body::Expr(Box::new(call(rtc("action", vec![gen]), vec![]))), is_async: false } + } Some(ss) => { let is_async = has_await_json(&Value::Array(ss.iter().map(|s| s.to_json()).collect())); let b = self.stmts(ss, inner, &Ctx { imp: true, is_async, dynamic: ctx.dynamic, effect: ctx.effect, ..Ctx::default() }, false)?; diff --git a/compiler/src/infer.rs b/compiler/src/infer.rs new file mode 100644 index 0000000..4aa3eff --- /dev/null +++ b/compiler/src/infer.rs @@ -0,0 +1,400 @@ +//! 06 AC-1b…AC-1d (`ref/src/infer.ts`): what the compiler reads off an imperative body. Which arrows are actions without +//! the keyword (AC-1b), and the two checks every imperative run gets: a plain write an inferred action holds across an +//! `await` (HELD_WRITE, AC-1c), and a read of a source the same change already wrote (STALE_OWN_READ, AC-1d). Solid sees +//! neither: a TypeScript body's promises and reads are not visible to it, which is why it asks for `action(function* …)` +//! and lets a stale read through. + +use crate::ast::{Expr, ExprKind, Field, Lit, PathSeg, Stmt, StmtKind, TplPart}; +use indexmap::{IndexMap, IndexSet}; +use std::collections::HashSet; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Trigger { + Optimistic, + Affects, + WriteAwait, +} +pub fn trigger_text(t: Trigger) -> &'static str { + match t { + Trigger::WriteAwait => "it writes and awaits", + Trigger::Optimistic => "it has `optimistic`", + Trigger::Affects => "it has `affects`", + } +} + +/// AC-1: a run suspends only at a statement-level `await` (`await e`, `let v = await e`, either with a `catch`). +pub fn stmt_await(s: &Stmt) -> bool { + let e = match &s.k { + StmtKind::Let { e, .. } | StmtKind::Expr(e) => e, + _ => return false, + }; + match &e.k { + ExprKind::Await { .. } => true, + ExprKind::Catch { e, .. } => matches!(e.k, ExprKind::Await { .. }), + _ => false, + } +} + +type Closures<'a> = IndexMap; +fn fn_parts(e: &Expr) -> (&[String], &[Stmt]) { + match &e.k { + ExprKind::Fn { params, stmts: Some(ss), .. } => (params, ss), + _ => unreachable!(), + } +} +/// The local closures a body's own statements call by name: `let f = () => { … }`, statement-bodied, not an action of its own. +fn closure_of(s: &Stmt) -> Option<&Expr> { + match &s.k { + StmtKind::Let { e, .. } => match &e.k { + ExprKind::Fn { stmts: Some(ss), .. } if inferred_action(ss).is_none() => Some(e), + _ => None, + }, + _ => None, + } +} +fn called_closure<'a>(e: &Expr, cl: &Closures<'a>) -> Option<&'a Expr> { + match &e.k { + ExprKind::Call { f, .. } => match &f.k { + ExprKind::Id(n) => cl.get(n).copied(), + _ => None, + }, + _ => None, + } +} + +/// AC-1b: why an arrow's body is an action — it guesses, declares `affects`, or both writes a source and awaits — or None for +/// a plain run. Its own statements only: not the bodies of closures in it (each is inferred on its own), not an inline +/// `action { … }` (its own run), but through the local closures it calls (a call runs the closure's writes in this run). +pub fn inferred_action(stmts: &[Stmt]) -> Option { + struct S<'a> { + guess: Option, + write: bool, + wait: bool, + seen: HashSet<*const Expr>, + _p: std::marker::PhantomData<&'a ()>, + } + fn go<'a>(ss: &'a [Stmt], cl: &Closures<'a>, st: &mut S<'a>) { + let mut local = cl.clone(); + for s in ss { + if let Some(c) = closure_of(s) { + if let StmtKind::Let { name, .. } = &s.k { + local.insert(name.clone(), c); + } + } else if let StmtKind::Let { name, .. } = &s.k { + local.shift_remove(name); + } + let called = match &s.k { + StmtKind::Expr(e) | StmtKind::Let { e, .. } => called_closure(e, &local), + _ => None, + }; + if let Some(c) = called { + if st.seen.insert(c as *const Expr) { + go(fn_parts(c).1, &local, st); + } + } + match &s.k { + StmtKind::Optimistic { .. } => { + if st.guess.is_none() { + st.guess = Some(Trigger::Optimistic); + } + } + StmtKind::Affects(_) => { + if st.guess.is_none() { + st.guess = Some(Trigger::Affects); + } + } + StmtKind::Write { .. } => st.write = true, + StmtKind::Let { .. } | StmtKind::Expr(_) => { + if stmt_await(s) { + st.wait = true; + } + } + StmtKind::Sif { t, f, .. } => { + go(t, &local, st); + go(f, &local, st); + } + StmtKind::Try { body, handler, fin, .. } => { + go(body, &local, st); + if let Some(h) = handler { + go(h, &local, st); + } + if let Some(f) = fin { + go(f, &local, st); + } + } + _ => {} + } + } + } + let mut st = S { guess: None, write: false, wait: false, seen: HashSet::new(), _p: std::marker::PhantomData }; + go(stmts, &IndexMap::new(), &mut st); + st.guess.or(if st.write && st.wait { Some(Trigger::WriteAwait) } else { None }) +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Code { + HeldWrite, + StaleOwnRead, +} +pub struct Finding { + pub code: Code, + pub name: String, +} + +/// keys from the source; "*" a computed key +type Path = Vec; +#[derive(Clone)] +struct State { + written: IndexMap>, + held: IndexSet, + dead: bool, +} +fn merge(ss: &[&State]) -> State { + let live: Vec<&&State> = ss.iter().filter(|s| !s.dead).collect(); + if live.is_empty() { + let mut s = ss[0].clone(); + s.dead = true; + return s; + } + let mut out = State { written: IndexMap::new(), held: IndexSet::new(), dead: false }; + for s in live { + for (k, v) in &s.written { + out.written.entry(k.clone()).or_default().extend(v.iter().cloned()); + } + for h in &s.held { + out.held.insert(h.clone()); + } + } + out +} +fn alive(s: &State) -> State { + let mut c = s.clone(); + c.dead = false; + c +} +fn overlaps(a: &Path, b: &Path) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| x == y || x == "*" || y == "*") +} +fn key(name: &str, ctx: bool) -> String { + if ctx { format!("use {}", name) } else { name.to_string() } +} +fn lit_key(e: &Expr) -> String { + match &e.k { + ExprKind::Lit(Lit::Str(s)) => s.clone(), + _ => "*".into(), + } +} + +struct Walker { + out: Vec, + said: HashSet<(Code, String)>, + action: bool, + inferred: bool, +} +impl Walker { + fn report(&mut self, code: Code, name: String) { + if self.said.insert((code, name.clone())) { + self.out.push(Finding { code, name }); + } + } + fn read<'a>(&mut self, e: &'a Expr, st: &mut State, sh: &IndexSet, local: &Closures<'a>, depth: &[*const Expr]) { + let mut path: Path = vec![]; + let mut x = e; + loop { + match &x.k { + ExprKind::Member { o, p, .. } => { + path.insert(0, p.clone()); + x = o; + } + ExprKind::Index { o, i, .. } => { + path.insert(0, lit_key(i)); + self.read(i, st, sh, local, depth); + x = o; + } + _ => break, + } + } + let root = match &x.k { + ExprKind::Id(n) if !sh.contains(n) => Some(key(n, false)), + ExprKind::Use(n) => Some(key(n, true)), + _ => None, + }; + if let Some(k) = root { + if st.written.get(&k).is_some_and(|ws| ws.iter().any(|w| overlaps(w, &path))) { + self.report(Code::StaleOwnRead, k); + } + return; + } + if !std::ptr::eq(x, e) { + self.read(x, st, sh, local, depth); + return; + } + match &x.k { + ExprKind::Fn { .. } | ExprKind::ServerFn { .. } | ExprKind::Draft { .. } | ExprKind::Pending(_) | ExprKind::Latest(_) | ExprKind::Lit(_) | ExprKind::Pre { .. } | ExprKind::Peek(_) | ExprKind::StyleMap { .. } => {} + ExprKind::Call { f, args } => { + let c = called_closure(x, local); + for a in args { + self.read(a, st, sh, local, depth); + } + if let Some(c) = c { + if !depth.contains(&(c as *const Expr)) { + let (params, body) = fn_parts(c); + let mut inner = sh.clone(); + for p in params { + inner.insert(p.clone()); + } + let mut d = depth.to_vec(); + d.push(c as *const Expr); + let after = self.walk(body, alive(st), &inner, local, &d); + st.written = after.written; + st.held = after.held; + } + return; + } + self.read(f, st, sh, local, depth); + } + ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::Member { .. } | ExprKind::Index { .. } => {} + ExprKind::Bin { l, r, .. } => { + self.read(l, st, sh, local, depth); + self.read(r, st, sh, local, depth); + } + ExprKind::Un { e, .. } | ExprKind::Spread(e) | ExprKind::Throw(e) => self.read(e, st, sh, local, depth), + ExprKind::Await { e, init, .. } => { + self.read(e, st, sh, local, depth); + if let Some(i) = init { + self.read(i, st, sh, local, depth); + } + } + ExprKind::If { c, t, f } => { + self.read(c, st, sh, local, depth); + self.read(t, st, sh, local, depth); + self.read(f, st, sh, local, depth); + } + ExprKind::Tpl(parts) => { + for p in parts { + if let TplPart::Expr(e) = p { + self.read(e, st, sh, local, depth); + } + } + } + ExprKind::Arr(items) => { + for i in items { + self.read(i, st, sh, local, depth); + } + } + ExprKind::Rec(fields) => { + for f in fields { + match f { + Field::Kv { v, .. } => self.read(v, st, sh, local, depth), + Field::Spread(e) => self.read(e, st, sh, local, depth), + } + } + } + ExprKind::Catch { e, alt, .. } => { + self.read(e, st, sh, local, depth); + self.read(alt, st, sh, local, depth); + } + } + } + fn walk<'a>(&mut self, ss: &'a [Stmt], mut st: State, shadow: &IndexSet, cl: &Closures<'a>, depth: &[*const Expr]) -> State { + let mut sh = shadow.clone(); + let mut local = cl.clone(); + for s in ss { + if st.dead { + break; + } + match &s.k { + StmtKind::Let { e, .. } | StmtKind::Expr(e) => { + self.read(e, &mut st, &sh, &local, depth); + if stmt_await(s) { + if self.action { + for h in std::mem::take(&mut st.held) { + self.report(Code::HeldWrite, h); + } + } else { + st.written.clear(); + } + } + if let StmtKind::Let { name, .. } = &s.k { + sh.insert(name.clone()); + match closure_of(s) { + Some(c) => { + local.insert(name.clone(), c); + } + None => { + local.shift_remove(name); + } + } + } + } + StmtKind::Write { target, e } | StmtKind::Optimistic { target, e } => { + for p in &target.path { + if let PathSeg::Expr(p) = p { + self.read(p, &mut st, &sh, &local, depth); + } + } + if !matches!(e.k, ExprKind::Fn { .. }) { + self.read(e, &mut st, &sh, &local, depth); + } + let k = key(&target.name, target.is_ctx()); + let path: Path = target.path.iter().map(|p| match p { PathSeg::Name(n) => n.clone(), PathSeg::Expr(e) => lit_key(e) }).collect(); + st.written.entry(k.clone()).or_default().push(path); + if matches!(s.k, StmtKind::Write { .. }) && self.inferred { + st.held.insert(k); + } + } + StmtKind::Return(e) => { + if let Some(e) = e { + self.read(e, &mut st, &sh, &local, depth); + } + st.dead = true; + } + StmtKind::Throw(e) => { + self.read(e, &mut st, &sh, &local, depth); + st.dead = true; + } + StmtKind::Sif { c, t, f } => { + self.read(c, &mut st, &sh, &local, depth); + let a = self.walk(t, st.clone(), &sh, &local, depth); + let b = self.walk(f, st.clone(), &sh, &local, depth); + st = merge(&[&a, &b]); + } + StmtKind::Try { body, catch_name, handler, fin, .. } => { + let before = st.clone(); + let b = self.walk(body, st.clone(), &sh, &local, depth); + let caught = handler.as_ref().map(|h| { + let mut hs = sh.clone(); + if let Some(n) = catch_name { + hs.insert(n.clone()); + } + self.walk(h, merge(&[&before, &alive(&b)]), &hs, &local, depth) + }); + let end = match &caught { + Some(c) => merge(&[&b, c]), + None => b, + }; + st = match fin { + Some(f) => { + let mut r = self.walk(f, merge(&[&before, &alive(&end)]), &sh, &local, depth); + r.dead = r.dead || end.dead; + r + } + None => end, + }; + } + StmtKind::Action(_) | StmtKind::Refresh(_) | StmtKind::Affects(_) => {} + } + } + st + } +} + +/// AC-1c and AC-1d over one imperative run (`runFindings` in `ref/src/infer.ts`). `action`: the body is one change across +/// its awaits (AC-1); a plain run's change ends at each `await`. `inferred`: an action by AC-1b, the only kind HELD_WRITE +/// applies to. `bound`: the run's parameters. Each finding once per name, in source order. +pub fn run_findings(stmts: &[Stmt], action: bool, bound: &[String], inferred: bool) -> Vec { + let mut w = Walker { out: vec![], said: HashSet::new(), action, inferred }; + let sh: IndexSet = bound.iter().cloned().collect(); + w.walk(stmts, State { written: IndexMap::new(), held: IndexSet::new(), dead: false }, &sh, &IndexMap::new(), &[]); + w.out +} diff --git a/compiler/src/lib.rs b/compiler/src/lib.rs index b0e64ef..02f7784 100644 --- a/compiler/src/lib.rs +++ b/compiler/src/lib.rs @@ -8,6 +8,7 @@ pub mod awaits; pub mod check; pub mod content; pub mod emit; +pub mod infer; pub mod js; pub mod model; pub mod modules; diff --git a/guide/async.md b/guide/async.md index 96bbaee..9ee6cc4 100644 --- a/guide/async.md +++ b/guide/async.md @@ -53,6 +53,9 @@ export action save(next: Settings) { } ``` +A handler can be the action itself, with no keyword: an arrow that writes `optimistic`, declares `affects`, or both +writes and awaits is an action (AC-1b). Declare a named `action` when you call it from more than one place. + An action's body is **one change**: every plain write in it, before or after an `await`, is held until the body returns (AC-1). That is what makes it safe, and it is why a plain write there doesn't show at once. Pick the write by what the screen should do: @@ -79,9 +82,13 @@ by what the screen should do: ## 5. Plain handlers -A handler that awaits outside an action has no hold: each write lands when its line runs, in whatever order the -answers come back, and ordering is then your problem (AC-7). Use plain handlers for local state only: typing, -toggles, selection. Anything that waits on the server is an equation (reads) or an action (writes). +A handler that writes and awaits is an action (AC-1b), so it never lands a write early by accident. A write before +its `await` is a compile error (`HELD_WRITE`), because it would wait for the server: make it `optimistic` if it shows +while the work runs, `onFlush` if it is a reset, or declare the action if it should wait. Plain handlers are for +local state: typing, toggles, selection. + +A run never sees its own writes: after `x := v`, reading `x` in the same run gives the old value (W-4), and the +compiler refuses it (`STALE_OWN_READ`). Keep the value in a local: `let n = count + 1; count := n; log(n)`. ## 6. Forms diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index 2308160..b865105 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -6,6 +6,7 @@ import type { Decl, Expr, Markup, Program, Stmt } from "../src/ast.ts"; import { singleModule, type ModuleSet } from "../src/modules.ts"; import { nonTailAwaits, viewReaders } from "../src/awaits.ts"; +import { inferredAction } from "../src/infer.ts"; import { transform } from "@solidjs/compiler"; import { unwrapServer } from "../src/check.ts"; @@ -86,7 +87,7 @@ export function compileModules(set: ModuleSet, opts: { extraStmts?: Stmt[][] } = return { files, entry: out(set.entry), css: g.css.join("\n") }; // CSS-4: the modules' stylesheets, beside their JavaScript } /** Handler rows and resolve values in scenarios: statements over names looked up at run time (rt.lookup). */ -export function compileDynamic(stmts: Stmt[]): string { const g = new Gen(usage({ decls: [], view: [] }, [stmts])); const env = new Env(null); return g.stmts(stmts, env, { imp: true, dyn: true }, 0); } +export function compileDynamic(stmts: Stmt[]): string { const g = new Gen(usage({ decls: [], view: [] }, [stmts])); const env = new Env(null); return inferredAction(stmts) ? g.stmts([{ k: "action", body: stmts }], env, { imp: true, dyn: true }, 0) : g.stmts(stmts, env, { imp: true, dyn: true }, 0); } // AC-1b: a handler row is a handler's body export function compileDynamicExpr(e: Expr): string { const g = new Gen(usage({ decls: [], view: [] })); return g.expr(e, new Env(null), { imp: true, dyn: true }); } class Gen { @@ -431,6 +432,7 @@ class Gen { case "rec": return `({ ${e.fields.map(f => "spread" in f ? `...${go(f.spread)}` : `${J(f.key)}: ${go(f.v)}`).join(", ")} })`; case "fn": { const inner = this.params(env, e.params); const ps = e.params.map(mg).join(", "); + if (e.stmts && inferredAction(e.stmts)) return `(${ps}) => __rt.action(function* () {\n${this.stmts(e.stmts, inner, { imp: true, gen: true, dyn: ctx.dyn, effect: ctx.effect }, 1)}\n})()`; // AC-1b: an inferred action lowers as `action { … }` does, to Solid's action() if (e.stmts) { const async = this.hasAwait(e.stmts); return `${async ? "async " : ""}(${ps}) => {\n${this.stmts(e.stmts, inner, { imp: true, async, dyn: ctx.dyn, effect: ctx.effect }, 0)}\n}`; } return `(${ps}) => ${this.expr(e.body, inner, { imp: ctx.imp, dyn: ctx.dyn, effect: ctx.effect })}`; } diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index 803122f..216f433 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -3,6 +3,7 @@ // This is the compiler's back half, run as an interpreter: every form maps to the primitive // its rule names (ST-1, ST-4c, ST-6c, AC-3 …). Unsupported shapes throw `Unsupported`. import { classMap } from "../src/style.ts"; +import { inferredAction } from "../src/infer.ts"; import type { Decl, Expr, Markup, Program, Stmt } from "../src/ast.ts"; import { createRenderer } from "@solidjs/universal"; import { Show, For, Loading, Errored } from "solid-js"; @@ -411,7 +412,8 @@ export class Oracle { } case "arr": { const out: unknown[] = []; for (const i of e.items) { if (i.k === "spread") out.push(...(this.ev(i.e, scope, env) as unknown[])); else out.push(this.ev(i, scope, env)); } return out; } case "rec": { const o: Record = {}; for (const f of e.fields) { if ("spread" in f) Object.assign(o, this.ev(f.spread, scope, env)); else o[f.key] = this.ev(f.v, scope, env); } return o; } - case "fn": return (...args: unknown[]) => { const en = new Map(env); e.params.forEach((p, i) => en.set(p, args[i])); return e.stmts ? this.runStmts(e.stmts, scope, en) : this.ev(e.body, scope, en); }; + case "fn": if (e.stmts && inferredAction(e.stmts)) return this.lowerAction(e.params, e.stmts, scope, env); // AC-1b + return (...args: unknown[]) => { const en = new Map(env); e.params.forEach((p, i) => en.set(p, args[i])); return e.stmts ? this.runStmts(e.stmts, scope, en) : this.ev(e.body, scope, en); }; case "stylemap": return classMap(e.css); // 07 CSS-5 case "call": { let f: any, self: any; diff --git a/ref/oracle/run.ts b/ref/oracle/run.ts index 2388da2..e6d5baf 100644 --- a/ref/oracle/run.ts +++ b/ref/oracle/run.ts @@ -4,6 +4,7 @@ import { SOLID } from "../solid-version.ts"; import { readdirSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { parseScenario, moduleSet, type Scenario } from "../src/scenario.ts"; +import { inferredAction } from "../src/infer.ts"; import { parseProgram, parseStmts } from "../src/parse.ts"; import { Oracle, flush, resetErrorHalt, Unsupported } from "./lower.ts"; const isUnsupported = (e: unknown) => (e as Error)?.name === "Unsupported" || (Unsupported.last !== "" && String((e as Error)?.message ?? e).includes(Unsupported.last)); @@ -30,7 +31,7 @@ async function runTable(sc: Scenario, t: Scenario["tables"][number], base: Scena const logBefore = r.action === "mount" ? 0 : o.log.length; // effects' first runs happen inside render() try { if (r.action === "mount") { flush(); await settle(); } - else if (r.action.startsWith("handler")) { const stmts = parseStmts(r.action.slice(7)); o.runStmts(stmts, rootScope(o), new Map()); flush(); await settle(); } + else if (r.action.startsWith("handler")) { const body = parseStmts(r.action.slice(7)); const stmts = inferredAction(body) ? [{ k: "action" as const, body }] : body; o.runStmts(stmts, rootScope(o), new Map()); flush(); await settle(); } // AC-1b: a handler row is a handler's body else if (/^(resolve|reject)\s/.test(r.action)) { const m = /^(resolve|reject)\s+(\S+)\s+([\s\S]*)$/.exec(r.action)!; const valueExpr = parseStmts(m[3])[0]; diff --git a/ref/src/check.ts b/ref/src/check.ts index 58b0c07..d7ce227 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -8,6 +8,7 @@ import type { ModuleSet } from "./modules.ts"; import type { JsDecl } from "./parse.ts"; import { misnested, misnestedInside, P_CLOSERS, P_SCOPE, EMPTY_SHAPE, type Shape } from "./content.ts"; import { ModelBuilder, type Model, type RegionKind, type CompKind } from "./model.ts"; +import { inferredAction, runFindings, triggerText } from "./infer.ts"; const BUILTINS = new Set(["len", "str", "map", "filter", "sum", "fail", "fetch", "every"]); // the scenario prelude; all pure const isHandler = (attr: string) => /^on[A-Z:]/.test(attr); // SC-4 @@ -461,6 +462,7 @@ class Checker { this.comp("effect", env, {}, () => { for (const i of d.inputs) this.expr(i, env, reactive); // EF-1: the compute half is reactive scope this.comp("apply", env, {}, () => this.stmts(d.body, new Env(env, env.region), { scope: "imperative" })); + this.runChecks(d.body, false, []); }); this.ownSourceWrites(d, env); return; @@ -470,6 +472,7 @@ class Checker { for (const p of d.params) inner.vars.set(p, { kind: "value", name: p, region: env.region, line: d.line }); this.comp("action", env, { name: d.name }, () => this.stmts(d.body, inner, { scope: "imperative", inAction: true })); this.actions.push({ name: d.name, body: d.body, env: inner, line: d.line }); + this.runChecks(d.body, true, d.params); return; } case "server": { // 12 SF-1/SF-3/SF-4: a statement block that runs on the server @@ -664,7 +667,11 @@ class Checker { for (const p of e.params) inner.vars.set(p, { kind: "value", name: p, region: env.region, line: this.line }); if (e.stmts) { if (ctx.scope === "reactive") { this.err("REACTIVE_WRITE_IN_OWNED_SCOPE", "W-2", "a statement-bodied closure in reactive scope (SC-3: a closure created here is a pure function)"); return; } - this.stmts(e.stmts, inner, { scope: "imperative", server: ctx.server }); return; + const why = ctx.server ? null : inferredAction(e.stmts); // AC-1b: an arrow that guesses, or writes and awaits, is an action + this.stmts(e.stmts, inner, { scope: "imperative", server: ctx.server, inAction: !!why }); + if (why) this.actions.push({ name: `inferred here (AC-1b: ${triggerText(why)})`, body: e.stmts, env: inner, line: this.line }); + if (!ctx.server) this.runChecks(e.stmts, !!why, e.params, !!why); + return; } this.expr(e.body, inner, { scope: ctx.scope, inFn: ctx.inFn, server: ctx.server }); return; } @@ -682,6 +689,14 @@ class Checker { } } + /** AC-1c, AC-1d over one imperative run (ref/src/infer.ts). */ + private runChecks(stmts: Stmt[], action: boolean, bound: string[], inferred = false) { + for (const f of runFindings(stmts, action, bound, inferred)) { + if (f.code === "HELD_WRITE") this.err("HELD_WRITE", "AC-1c", `${f.name} := … comes before an \`await\` in a body that is an action because it writes and awaits (AC-1b), so it is held until the body ends (AC-1): for state that shows while the work runs, \`optimistic ${f.name} := …\` (a source with a question, ST-5c); for a reset that lands now, \`onFlush(() => { ${f.name} := … })\` (EF-8); to hold it on purpose, declare the action (\`action name() { … }\`); or write it after the \`await\``); + else this.err("STALE_OWN_READ", "AC-1d", `${f.name} is read after this change wrote it: the read sees the committed value, not the write (W-4) — keep the value you wrote in a local (\`let v = …; ${f.name} := v\`) and read that, or write functionally (\`${f.name} := v => …\`, W-6)`); + } + } + // ---- imperative scope (SC-2) ---- /** AC-1: the awaits a run suspends at — a statement's whole expression (`await e`, `let v = await e`), with its catch. */ private stmtAwaits = new WeakSet(); @@ -713,7 +728,7 @@ class Checker { else if (target?.imported) this.err("WRITE_TO_NON_SOURCE", "MO-2", `${s.target.name} is a source of ${target.imported}: a module boundary is a value boundary — write it through an action ${target.imported} exports`); } if (s.k === "optimistic") { - if (!ctx.inAction) this.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action body: a guess needs a hold to live in"); + if (!ctx.inAction) this.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action: a guess needs a hold to live in — an apply block is never an action (its return is the cleanup, EF-5): declare one, `action save(v) { optimistic … }`, and call it from the block"); if (target && target.kind === "source" && !target.question) this.err("OPTIMISTIC_NO_QUESTION", "AC-3", `${s.target.name} is declared with := — a guess needs a question to confirm it (source ${s.target.name} = …, ST-5c)`); } s.target.path.forEach(p => typeof p !== "string" && this.expr(p, env, ctx)); @@ -722,7 +737,7 @@ class Checker { break; } case "refresh": case "affects": case "action": if (ctx.server) { this.err("SERVER_CAPTURE", "SF-3", `\`${s.k}${s.k === "action" ? " { … }" : ` ${(s as { name: string }).name}`}\` in a server function's body: it is the client's (do it in the action that awaited the call)`); break; } - if (s.k === "action") { this.comp("action", env, { name: "(inline)" }, () => this.stmts(s.body, env, { scope: "imperative", inAction: true })); this.actions.push({ name: "(inline)", body: s.body, env, line: this.line }); break; } + if (s.k === "action") { this.comp("action", env, { name: "(inline)" }, () => this.stmts(s.body, env, { scope: "imperative", inAction: true })); this.actions.push({ name: "(inline)", body: s.body, env, line: this.line }); this.runChecks(s.body, true, []); break; } if (s.k === "affects") { const t = this.resolve(s.name, env, ctx); this.model.write(t, "affects"); if (t && !reactiveKinds.has(t.kind)) this.err("INVALID_AFFECTS_TARGET", "AC-5", `${s.name} is not reactive`); break; } { const t = this.resolve(s.name, env, ctx); this.model.write(t, "refresh"); if (t && !(t.kind === "eq" || (t.kind === "source" && t.question))) this.err("INVALID_REFRESH_TARGET", "AC-4", `${s.name} has no question to re-ask`); break; } case "sif": this.expr(s.c, env, ctx); this.stmts(s.t, env, ctx); this.stmts(s.f, env, ctx); break; diff --git a/ref/src/infer.ts b/ref/src/infer.ts new file mode 100644 index 0000000..5278e96 --- /dev/null +++ b/ref/src/infer.ts @@ -0,0 +1,131 @@ +// 06 AC-1b…AC-1d: what the compiler reads off an imperative body. Which arrows are actions without the keyword (AC-1b), and +// the two checks every imperative run gets: a plain write an action holds across an `await` (HELD_WRITE, AC-1c), and a read +// of a source the same change already wrote (STALE_OWN_READ, AC-1d). Solid sees neither: a TypeScript body's promises and +// reads are not visible to it, which is why it asks for `action(function* …)` and lets a stale read through. +import type { Expr, Stmt } from "./ast.ts"; + +export type Trigger = "optimistic" | "affects" | "write+await"; +type Fn = Extract; +type Closures = Map; + +/** AC-1: a run suspends only at a statement-level `await` (`await e`, `let v = await e`, either with a `catch`). */ +export const stmtAwait = (s: Stmt): boolean => (s.k === "let" || s.k === "expr") && (s.e.k === "await" || (s.e.k === "catch" && s.e.e.k === "await")); + +/** The local closures a body's own statements call by name: `let f = () => { … }`, statement-bodied, not an action of its own. */ +const closureOf = (s: Stmt): Fn | null => s.k === "let" && s.e.k === "fn" && s.e.stmts && !inferredAction(s.e.stmts) ? s.e : null; +const calledClosure = (e: Expr, cl: Closures): Fn | null => e.k === "call" && e.f.k === "id" && cl.has(e.f.name) ? cl.get(e.f.name)! : null; + +/** AC-1b: why an arrow's body is an action — it guesses, declares `affects`, or both writes a source and awaits — or null for + * a plain run. Its own statements only: not the bodies of closures in it (each is inferred on its own), not an inline + * `action { … }` (its own run), but through the local closures it calls (a call runs the closure's writes in this run). */ +export function inferredAction(stmts: Stmt[]): Trigger | null { + let guess: Trigger | null = null, write = false, wait = false; + const seen = new Set(); + const go = (ss: Stmt[], cl: Closures): void => { + const local = new Map(cl); + for (const s of ss) { + const c = closureOf(s); if (c) local.set((s as { name: string }).name, c); else if (s.k === "let") local.delete(s.name); + const called = (s.k === "expr" || s.k === "let") ? calledClosure(s.e, local) : null; + if (called && !seen.has(called)) { seen.add(called); go(called.stmts!, local); } + switch (s.k) { + case "optimistic": guess ??= "optimistic"; break; + case "affects": guess ??= "affects"; break; + case "write": write = true; break; + case "let": case "expr": if (stmtAwait(s)) wait = true; break; + case "sif": go(s.t, local); go(s.f, local); break; + case "try": go(s.body, local); if (s.handler) go(s.handler, local); if (s.fin) go(s.fin, local); break; + } + } + }; + go(stmts, new Map()); + return guess ?? (write && wait ? "write+await" : null); +} +export const triggerText = (t: Trigger): string => t === "write+await" ? "it writes and awaits" : `it has \`${t}\``; + +export type Finding = { code: "HELD_WRITE" | "STALE_OWN_READ"; name: string }; +type Path = string[]; // keys from the source; "*" a computed key +type State = { written: Map; held: Set; dead: boolean }; +const clone = (s: State): State => ({ written: new Map([...s.written].map(([k, v]) => [k, [...v]])), held: new Set(s.held), dead: s.dead }); +const merge = (...ss: State[]): State => { + const live = ss.filter(s => !s.dead); if (!live.length) return { ...clone(ss[0]), dead: true }; + const out: State = { written: new Map(), held: new Set(), dead: false }; + for (const s of live) { for (const [k, v] of s.written) out.written.set(k, [...(out.written.get(k) ?? []), ...v]); s.held.forEach(h => out.held.add(h)); } + return out; +}; +const overlaps = (a: Path, b: Path): boolean => { for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i] && a[i] !== "*" && b[i] !== "*") return false; return true; }; + +/** AC-1c and AC-1d over one imperative run. `action`: the body is one change across its awaits (AC-1); a plain run's change + * ends at each `await` (07 §5). `inferred`: an action by AC-1b, the only kind HELD_WRITE applies to — a declared `action` + * holds an early write on purpose (a transition: `action go() { id := 2; await prefetch() }`, S-solid-20). `bound`: the + * run's parameters. In source order, per path: a plain write that reaches an `await` in an inferred action is HELD_WRITE; + * a read of a source this change wrote, at an overlapping key, is STALE_OWN_READ. A guess is not a held write (it shows now, H-11) but is a write a read does not see (H-10). A + * functional write (`x := v => …`, W-6) reads nothing now; a closure's body runs later, unless it is a local closure called + * here; `pending?` and `latest` are probes, not snapshots. Each finding once per name. */ +export function runFindings(stmts: Stmt[], action: boolean, bound: string[] = [], inferred = false): Finding[] { + const out: Finding[] = []; const said = new Set(); + const report = (code: Finding["code"], name: string) => { const k = code + name; if (!said.has(k)) { said.add(k); out.push({ code, name }); } }; + const key = (name: string, ctx?: boolean) => (ctx ? "use " : "") + name; + const walk = (ss: Stmt[], st: State, shadow: Set, cl: Closures, depth: Set): State => { + const sh = new Set(shadow); const local = new Map(cl); + const read = (e: Expr): void => { + const path: Path = []; let x = e; + while (x.k === "member" || x.k === "index") { if (x.k === "member") path.unshift(x.p); else { path.unshift(x.i.k === "lit" && typeof x.i.v === "string" ? x.i.v : "*"); read(x.i); } x = x.o; } + if ((x.k === "id" && !sh.has(x.name)) || x.k === "use") { + const k = key(x.name, x.k === "use"); const ws = st.written.get(k); + if (ws?.some(w => overlaps(w, path))) report("STALE_OWN_READ", k); + return; + } + if (x !== e) { read(x); return; } + switch (x.k) { + case "fn": case "serverfn": case "draft": case "pending": case "latest": case "lit": case "pre": case "peek": case "stylemap": return; + case "call": { + const c = calledClosure(x, local); + x.args.forEach(read); + if (c) { if (!depth.has(c)) { const inner = new Set(sh); c.params.forEach(p => inner.add(p)); const after = walk(c.stmts!, { ...clone(st), dead: false }, inner, local, new Set([...depth, c])); st.written = after.written; st.held = after.held; } return; } + read(x.f); return; + } + default: + for (const v of Object.values(x as object)) { + if (Array.isArray(v)) v.forEach(y => { if (y && typeof y === "object") { if ("k" in y) read(y as Expr); else if ("v" in y) read((y as { v: Expr }).v); else if ("spread" in y) read((y as { spread: Expr }).spread); } }); + else if (v && typeof v === "object" && "k" in (v as object)) read(v as Expr); + } + } + }; + const onAwait = () => { if (action) { st.held.forEach(h => report("HELD_WRITE", h)); st.held.clear(); } else st.written.clear(); }; + for (const s of ss) { + if (st.dead) break; + switch (s.k) { + case "let": case "expr": { + read(s.e); + if (stmtAwait(s)) onAwait(); + if (s.k === "let") { const c = closureOf(s); sh.add(s.name); if (c) local.set(s.name, c); else local.delete(s.name); } + break; + } + case "write": case "optimistic": { + for (const p of s.target.path) if (typeof p !== "string") read(p); + if (s.e.k !== "fn") read(s.e); + const k = key(s.target.name, s.target.ctx); + st.written.set(k, [...(st.written.get(k) ?? []), s.target.path.map(p => typeof p === "string" ? p : p.k === "lit" && typeof p.v === "string" ? p.v : "*")]); + if (s.k === "write" && inferred) st.held.add(k); + break; + } + case "return": if (s.e) read(s.e); st.dead = true; break; + case "throw": read(s.e); st.dead = true; break; + case "sif": { read(s.c); const t = walk(s.t, clone(st), sh, local, depth), f = walk(s.f, clone(st), sh, local, depth); Object.assign(st, merge(t, f)); break; } + case "try": { + const before = clone(st); + const body = walk(s.body, clone(st), sh, local, depth); + const caught = s.handler ? walk(s.handler, merge(before, { ...body, dead: false }), s.catchName ? new Set([...sh, s.catchName]) : sh, local, depth) : null; + const end = caught ? merge(body, caught) : body; + if (s.fin) { const fin = walk(s.fin, merge(before, { ...end, dead: false }), sh, local, depth); Object.assign(st, fin, { dead: fin.dead || end.dead }); } + else Object.assign(st, end); + break; + } + case "action": case "refresh": case "affects": break; // an inline action is its own run, checked on its own + } + } + return st; + }; + walk(stmts, { written: new Map(), held: new Set(), dead: false }, new Set(bound), new Map(), new Set()); + return out; +} diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts index e2e748d..04a2866 100644 --- a/ref/src/runtime.ts +++ b/ref/src/runtime.ts @@ -1,6 +1,7 @@ // Reference runtime: spec/02-core §4 (instants), 03 (regions), 04 (rows), 05 (holds: two versions of state). // Pull-based settle with push invalidation; dependencies per (object identity, key) — ST-2. import { classMap } from "./style.ts"; +import { inferredAction } from "./infer.ts"; import type { Decl, Expr, Markup, Program, Stmt } from "./ast.ts"; type FnDecl = Extract; type CompDecl = Extract; type ActionDecl = Extract; type ServerDecl = Extract; @@ -856,7 +857,7 @@ export class Runtime { } case "fn": { const imperative = !this.demandMode; // SC-3 - if (e.stmts) { if (!imperative) throw new RuleError("W-2", "a statement-bodied arrow in reactive scope"); return (...args: unknown[]) => { const en = new Map(env); e.params.forEach((p, i) => en.set(p, args[i])); return this.runStmts(e.stmts!, region, en); }; } + if (e.stmts) { if (!imperative) throw new RuleError("W-2", "a statement-bodied arrow in reactive scope"); const act = !!inferredAction(e.stmts); return (...args: unknown[]) => { const en = new Map(env); e.params.forEach((p, i) => en.set(p, args[i])); return this.runStmts(e.stmts!, region, en, [], act); }; } // AC-1b: an inferred action return (...args: unknown[]) => { const en = new Map(env); e.params.forEach((p, i) => en.set(p, args[i])); if (!imperative) return this.ev(e.body, region, en); @@ -970,10 +971,10 @@ export class Runtime { } // ---- imperative scope (SC-2) ---- - runStmts(stmts: Stmt[], region: Region, env: Map, params: unknown[] = []): unknown { + runStmts(stmts: Stmt[], region: Region, env: Map, params: unknown[] = [], action = false): unknown { params.forEach((p, i) => env.set(`$${i}`, p)); env.set("log", Object.assign((...a: unknown[]) => { this.log.push(`log(${a.map(fmt).join(", ")})`); }, { io: true })); - return this.startRun(stmts, region, env, false); + return this.startRun(stmts, region, env, action); } /** Start an imperative run (a handler, an apply block, or an action body — AC-1); it may suspend at `await`. */ private startRun(stmts: Stmt[], region: Region, env: Map, action: boolean): unknown { @@ -1099,7 +1100,7 @@ export class Runtime { for (const r of all) for (const [k, v] of r.scope) if (!scope.has(k)) scope.set(k, v); const virt = new Region(null, this, null); virt.scope = scope; return virt; } - handler(stmts: Stmt[]) { this.runStmts(stmts, this.virtualScope(), new Map()); const w = this.pendingWrites; this.pendingWrites = []; return this.step(w); } + handler(stmts: Stmt[]) { this.runStmts(stmts, this.virtualScope(), new Map(), [], !!inferredAction(stmts)); const w = this.pendingWrites; this.pendingWrites = []; return this.step(w); } /** Scenario helper: land the flight an action run is suspended on. */ awaitedFlights(): Flight[] { return this.suspended.map(r => r.flight!).filter(Boolean); } findNode(name: string): Node | null { const b = this.virtualScope().lookup(name); return b instanceof Node ? b : null; } diff --git a/scenarios/S-check-1.md b/scenarios/S-check-1.md index bfdf6a1..d510bde 100644 --- a/scenarios/S-check-1.md +++ b/scenarios/S-check-1.md @@ -37,7 +37,7 @@ export default component App() { {for i in items { source hits := 0 - { hits := hits + 1; note(hits); handlers := [...handlers, () => total] }}>{i}:{hits} + { let h = hits + 1; hits := h; note(h); handlers := [...handlers, () => total] }}>{i}:{hits} }}

{a} {cb()} {len(notes)}

diff --git a/scenarios/S-infer-1.md b/scenarios/S-infer-1.md new file mode 100644 index 0000000..e8be2d9 --- /dev/null +++ b/scenarios/S-infer-1.md @@ -0,0 +1,74 @@ +# S-infer-1 — a guess in a handler makes the handler an action + +Rules: AC-1b, AC-1, AC-3, AC-7, H-11, R-f-6. + +## Program + +``` +export default component App() { + source liked = "no" + source error := "" + + +} +``` + +No `action` keyword: the arrow guesses, so it is an action (AC-1b), the +same as wrapping its body in `action { … }`. The handler rows below run +that body. + +## Frames + +| instant | action | frame | +| ------- | -------------------------------------------------------------------------------------------------------- | ------ | +| 0 | mount | `no ` | +| 1 | handler `optimistic liked := "yes"; try { let r = await fetch("like"); liked := r } catch (e) { error := e.message }` | `yes ` | +| 2 | resolve "like" "yes" | `yes ` | + +Instant 1: the guess shows at once (H-11). Instant 2: the truth confirms +it silently and the body ends. + +## Variant: the server declines + +| instant | action | frame | +| ------- | -------------------------------------------------------------------------------------------------------- | ------------ | +| 0 | mount | `no ` | +| 1 | handler `optimistic liked := "yes"; try { let r = await fetch("like"); liked := r } catch (e) { error := e.message }` | `yes ` | +| 2 | reject "like" "offline" | `no offline` | + +The guess reverts and the catch's write lands in the same frame (AC-7), +as in S-action-1. + +## Rejected variants + +``` +-- reject: UNCAUGHT_ACTION_FAILURE +export default component App() { + source liked = "no" + +} +``` + +An inferred action catches what its body can fail with, like a declared +one (R-f-6): nothing awaits a handler. + +``` +-- reject: OPTIMISTIC_OUTSIDE_HOLD +export default component App() { + source draft := "a" + source saved = "a" + effect (draft) { optimistic saved := $0 } +

{saved}

+} +``` + +An apply block is never inferred: its return is the effect's cleanup +(EF-5). Declare the action and call it from the block. diff --git a/scenarios/S-infer-2.md b/scenarios/S-infer-2.md new file mode 100644 index 0000000..5c64b21 --- /dev/null +++ b/scenarios/S-infer-2.md @@ -0,0 +1,94 @@ +# S-infer-2 — a write and an await make an action; an early write must say what it means + +Rules: AC-1b, AC-1c, AC-1, EF-8, W-6. + +## Program + +``` +export default component App() { + source status := "" + + +} +``` + +The arrow writes `status` and awaits, so it is an action (AC-1b). The +"saving" write is a reset that should land now, so it is an `onFlush` run +of its own (EF-8): it does not wait for the save. + +## Frames + +| instant | action | frame | +| ------- | ----------------------------------------------------------------------------------------------- | ---------------- | +| 0 | mount | `status: ` | +| 1 | handler `onFlush(() => { status := "saving" }); let r = await fetch("save") catch (e) => e.message; status := r` | `status: ` | +| 2 | (flush) | `status: saving` | +| 3 | resolve "save" "ok" | `status: ok` | + +Instant 1: the action's change opens with nothing written yet. Instant 2: +the `onFlush` run is a change of its own and commits at once, while the +save is still out (S-flush-1). + +## Variant: held on purpose + +``` +export default component App() { + source status := "" + action save() { + status := "saving" + let r = await fetch("save") catch (e) => e.message + status := r + } + +} +``` + +| instant | action | frame | +| ------- | ------------------- | ------------ | +| 0 | mount | `status: ` | +| 1 | handler `save()` | `status: ` | +| 2 | resolve "save" "ok" | `status: ok` | + +A declared action holds its early write (AC-1): `saving` and `ok` commit +together and compose in order (W-6), so `saving` is never on screen. That +is what the inferred form would do silently, which is why it is an error +there. + +## Rejected variants + +``` +-- reject: HELD_WRITE +export default component App() { + source status := "" + +} +``` + +Written as a plain handler, this showed `saving` at once. As the action +the body now is, `saving` would wait for the server. The fixes are named +by what the write is for: `optimistic` for state that shows while the work +runs, `onFlush` for a reset, a declared action to hold it on purpose. + +``` +-- reject: HELD_WRITE +export default component App() { + source saving := false + +} +``` + +A local closure called in the body runs its writes in this run, so the +checks follow the call. diff --git a/scenarios/S-infer-3.md b/scenarios/S-infer-3.md new file mode 100644 index 0000000..9d093c7 --- /dev/null +++ b/scenarios/S-infer-3.md @@ -0,0 +1,34 @@ +# S-infer-3 — a guess over an action the handler launches + +Rules: AC-1b, AC-1 (D-7 adoption), AC-3, H-11. + +## Program + +``` +export default component App() { + source liked = "no" + action like() { try { let r = await fetch("like"); liked := r } catch (e) {} } + + +} +``` + +The handler has no `await`: the save is a named action it launches. The +guess makes the handler an action (AC-1b), and the action it launches is +the same instant's change (D-7), so the guess lives until `like` ends. + +## Frames + +| instant | action | frame | +| ------- | ---------------------------------------------- | ----- | +| 0 | mount | `no` | +| 1 | handler `optimistic liked := "yes"; like()` | `yes` | +| 2 | resolve "like" "yes" | `yes` | + +## Variant: the server declines + +| instant | action | frame | +| ------- | ---------------------------------------------- | ----- | +| 0 | mount | `no` | +| 1 | handler `optimistic liked := "yes"; like()` | `yes` | +| 2 | reject "like" "offline" | `no` | diff --git a/scenarios/S-infer-4.md b/scenarios/S-infer-4.md new file mode 100644 index 0000000..ba99b6e --- /dev/null +++ b/scenarios/S-infer-4.md @@ -0,0 +1,32 @@ +# S-infer-4 — `affects` makes a handler an action, so a refresh holds + +Rules: AC-1b, AC-4, AC-5, D-31; the inline form of S-solid-26's `reload`. + +## Program + +``` +export default component App() { + user = await fetch("u") + + errored fallback(e) {

error: {e.message}

} { + {loading fallback {

…

} { + + }} + } +} +``` + +## Frames + +| instant | action | frame | +| ------- | ----------------------------------------------------------------------- | -------------- | +| 0 | mount | `…` | +| 1 | resolve user { name: "Ann" } | `Ann ` | +| 2 | handler `try { affects user; refresh user; await fetch("gate") } catch (e) {}` | `Ann updating` | +| 3 | resolve user { name: "Ann2" } | `Ann updating` | +| 4 | resolve "gate" 0 | `Ann2 ` | + +Instant 2: `affects` declares the work: pending. Instant 3: the re-ask +lands into the action's change (D-31) and waits for it. Instant 4: the +body ends and both reveal. As a plain handler, the change would have ended +at the `await`, and `updating` would never have shown. diff --git a/scenarios/S-infer-5.md b/scenarios/S-infer-5.md new file mode 100644 index 0000000..23c53e4 --- /dev/null +++ b/scenarios/S-infer-5.md @@ -0,0 +1,70 @@ +# S-infer-5 — a run never reads its own write + +Rules: AC-1d, W-4, H-10, W-6; Solid 2.0.0-rc.19: an `action()` that writes a signal, yields, and reads it again gets the old value until the action commits. + +## Program + +``` +export default component App() { + source count := 0 + source seen := 0 + + +} +``` + +## Frames + +| instant | action | frame | +| ------- | ------------------------------------------------ | ----- | +| 0 | mount | `0 0` | +| 1 | handler `let n = count + 1; count := n; seen := n` | `1 1` | + +The value written is kept in a local and read from there. + +## Rejected variants + +``` +-- reject: STALE_OWN_READ +export default component App() { + source count := 0 + source seen := 0 + +} +``` + +`seen := count` would read `0`: a write is not visible in the run that +made it (W-4). The checker can see the run's own writes, so this read is +an error; a read of anything else is the committed snapshot it always was. + +``` +-- reject: STALE_OWN_READ +export default component App() { + source seq := 0 + source busy := false + action save() { + let n = seq + 1 + seq := n + try { await fetch("save") } catch (e) {} + if (seq == n) { busy := false } + } + +} +``` + +In an action the change lasts the whole body, so after the `await` the run +still reads the old `seq` and the comparison is always false. Solid does +the same. A plain handler's change ends at its `await`, so there the read +would see the committed write; but a handler that writes and awaits is +now an action (AC-1b). + +``` +-- reject: STALE_OWN_READ +export default component App() { + source form := { name: "", email: "" } + +} +``` + +Keys count: after `form.name := …`, reading `form.name` or `form` is +stale; reading `form.email` is not. diff --git a/scenarios/S-solid-32.md b/scenarios/S-solid-32.md index 56e4746..b4c62de 100644 --- a/scenarios/S-solid-32.md +++ b/scenarios/S-solid-32.md @@ -39,20 +39,19 @@ in one frame. Never `Ann / unsaved` after 1 (guess held) nor `Bob / saved` The guess `Bob` reverts to the truth (`name` is still `Ann`); `saved = true` was a plain write and commits (AC-1, DV-2). -## Rejected variants +## Variant: a guess in a handler, with nothing to hold it -``` --- reject: OPTIMISTIC_OUTSIDE_HOLD --- handler: optimistic shown := "Zed" -export default component App() { - source name := "Ann" - source shown = name +| instant | action | frame | +| ------- | ----------------------------------- | ---------------------- | +| 1 | handler `optimistic shown := "Zed"` | `Ann / unsaved / - -` | -

{shown}

-} -``` +A guess outside a hold does not exist (D-32). A handler with a guess is an +action (AC-1b), so this compiles; the action's change opens and commits in +the same instant, and the guess ends with it before any frame shows it. +The `effect` form, which is never an action, is still rejected (S-check-1). + +## Rejected variants -A guess outside a hold does not exist (D-32); here it is unwritable. ``` -- reject: OPTIMISTIC_NO_QUESTION diff --git a/spec/02-core.md b/spec/02-core.md index 559d563..b1ffaf2 100644 --- a/spec/02-core.md +++ b/spec/02-core.md @@ -79,7 +79,8 @@ Function application `f(e…)` covers both language functions and FFI imports; - **SC-4 Handler positions.** An event attribute in a view (`on*={…}`) takes an imperative function. An arrow literal written there is imperative scope regardless of the enclosing view; a named function there must be `io` or - pure. The event is the function's parameter. + pure. The event is the function's parameter. An arrow there, as anywhere + in imperative scope, is a plain run or an action by its body (06 AC-1b). --- @@ -179,14 +180,17 @@ Function application `f(e…)` covers both language functions and FFI imports; semantic one. - **W-3 (Instant).** All writes performed by one imperative run — one handler invocation, one apply block, one resumed continuation — form one instant - (V-6). There is no API to split or merge instants from inside a run: + (V-6). An action's body, declared or inferred (06 AC-1b), is one change + across its continuations (AC-1). There is no API to split or merge instants from inside a run: `onFlush` (EF-8) does not split one, it schedules another run. A run whose writes all equal their slots' values (E-6) changes nothing and opens no instant (ruled with DV-5, 2026-10-23: the earlier "an equal write still opens an instant" existed only for the frame-indexed `pre`). - **W-4 (Visibility).** A write is not visible to any read, in any scope, until its instant commits. Within the run that wrote it, `x` still reads the - committed value. (Restates Solid F-10 for the synchronous case.) + committed value. (Restates Solid F-10 for the synchronous case.) So a + read of a source the run's own change has written is a compile error + (`STALE_OWN_READ`, 06 AC-1d): it could only mean the write. - **W-5 (Writer scope).** A source may be written only by imperative code in its declaring region or a region nested in it, or by a handler that was handed the source explicitly. Exported sources export a reader; the writer is diff --git a/spec/05-holds.md b/spec/05-holds.md index f29be60..df621af 100644 --- a/spec/05-holds.md +++ b/spec/05-holds.md @@ -183,7 +183,7 @@ which showed `A 0` where Solid shows `A 1` — S-hold-3.) (W-3); from then `v` is in `σ′` (probes see it) and in `σ` at commit. - **H-11 (Optimistic).** [F-11, D-32…D-35] `optimistic x := e`, legal only - inside an action body (AC-3) and only on a source with a question (ST-5), + inside an action body, declared or inferred (AC-3, AC-1b), and only on a source with a question (ST-5), writes a *guess* for `x`: from the instant's settle it is the graph's value (equations derive from it — S-solid-33: `related` re-asks from the guess) and the screen's, display-ahead — its readers are in a lane whose diff --git a/spec/06-stores.md b/spec/06-stores.md index 44dae9a..820cfb4 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -4,7 +4,7 @@ question, drafts, keys, streams, and the imperative forms that tie writes to async work: `action`, `optimistic`, `refresh`, `affects`. Rules cite Solid's F-9, F-11 and the derived rules they restate. In `ref/`: ST-1…ST-6, -AC-1…AC-5 (S-store-1…5, S-solid-8/9/15/20/26/32…35/37). Optimistic path writes: S-store-6. AC-6 `until`: S-action-2. AC-7 try/catch: S-action-1. +AC-1…AC-5 (S-store-1…5, S-solid-8/9/15/20/26/32…35/37). Optimistic path writes: S-store-6. AC-6 `until`: S-action-2. AC-7 try/catch: S-action-1. AC-1b…AC-1d (inferred actions, held writes, stale own reads): S-infer-1…5. --- @@ -122,7 +122,9 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } writes — before or after the call — are the action's and are held with it (D-7's same-tick adoption); to land something now, make it a guess, a separate event, or an `onFlush` run (02 EF-8). - `action { … }` inline in a handler is the same for one run. There is no + `action { … }` inline in a handler is the same for one run, and so is an + arrow whose body guesses, declares `affects`, or writes and awaits + (AC-1b). There is no escape: a write after an `await` is still the action's (the language has the scope, so none of 2.0's generator plumbing — S-9's "fresh write escapes" is unwritable; S-solid-9 is the inverted port). A rejection @@ -138,11 +140,66 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } handler is launched, not awaited: the handler continues, and the handler's own writes are the same instant's change (D-7's same-tick adoption). +- **AC-1b (Inferred action).** [ruled 2026-10-28] An arrow function in + imperative scope, including an element's event handler (02 SC-4), is an + action when its own statements include `optimistic x := e` (AC-3), + `affects x` (AC-5), or both a write to a source (W-1) and a + statement-level `await` (07 decision 16). It is then `(ps) => action { B }` + with every consequence of AC-1: one change across its awaited work, its + writes held with it, its guesses living over that hold, and every failure + it can produce caught (R-f-6). "Own statements" excludes the bodies of + closures inside it, each inferred on its own, and an inline `action { … }`, + which is its own run; it includes the statements of a local closure the + body calls by name (`let f = () => { … }; f()`), whose writes are this + run's. Calling an inferred arrow launches the action, as calling a + declared one does. A body that awaits and writes nothing is a plain run. + An `effect`'s apply block is never inferred: its return is the effect's + cleanup (EF-5), which an action's body cannot give, and the next run's + cleanup could not cancel an action in flight. A server function's body is + never inferred. A guess in either stays `OPTIMISTIC_OUTSIDE_HOLD`. + Lowers to Solid's `action` exactly as `action { … }` does, nothing added + (11). Solid: a TypeScript function's promises are invisible to the + transition, so its author writes `action(function* () { … })`; here every + suspension is a statement the compiler sees, so the keyword is needed only + to hold a write on purpose (AC-1c) or to name an action (S-infer-1, 3, 4). +- **AC-1c (Held write).** [ruled 2026-10-28] In an inferred action (AC-1b), + a plain write from which some path of the body reaches an `await` is a + compile error (`HELD_WRITE`): it would be held until the work ends (AC-1), + so a later write in the body hides it and otherwise it lands late, and + nothing in the text says so. The fix names what the write is for: + ephemeral state that shows while the work runs ("saving", a spinner, a + pending row) is a guess, `optimistic x := v` on a source with a question + (ST-5c; often a constant one, `source status = ""`); a reset that lands + now and stays (clear an error, clear an input, close a menu) is + `onFlush(() => { x := v })` (02 EF-8). A write meant to be held — a + transition: `action go() { id := 2; await prefetch() }` (S-solid-20) — is + written in a declared action, which this check does not cover: `action` + is the author saying "hold it". Guesses and `affects` are not plain writes + (S-infer-2). The check is the language's, not Solid's (Solid cannot see a + body): it adds nothing at run time. +- **AC-1d (Stale own read).** [ruled 2026-10-28] In any imperative run — a + handler, an apply block, an action body, declared or inferred — reading a + source that the same change has already written, on some path before the + read, is a compile error (`STALE_OWN_READ`). The read gets the committed + value, not the write (02 W-4, 05 H-10, SC-2). Solid agrees: in 2.0.0-rc.19 + an `action()` that writes a signal, yields, and reads it again gets the + old value until the action commits. "The same change" is the whole body + for an action (AC-1) and the stretch up to the next `await` for a plain + run, where the `await` ends the change (07 §5). Keys count: after + `x.a := v`, reading `x.a` or `x` is stale and reading `x.b` is not. A + guess is a write the run does not see either (H-10). A functional write + (`x := v => …`, W-6) reads nothing now; a closure's body runs later, + except a local closure the run calls, whose reads are the run's; + `pending?` and `latest` are probes, not snapshots. Every other read is + the committed snapshot it always was, with no error. The fix is the local + the value came from (`let n = seq + 1; seq := n; … n`) or a functional + write (S-infer-5). - **AC-2 (Not special).** An action is only a way to tie writes to async work; the hold is the thing (H-2). `refresh`, `affects` and probes behave the same inside and outside one. - **AC-3 (optimistic).** `optimistic x := e`, legal only inside an action - body (`OPTIMISTIC_OUTSIDE_HOLD`, unwritable) and only on a source with a + body, declared or inferred (AC-1b); in an `effect`'s apply block, which + is never an action, it is `OPTIMISTIC_OUTSIDE_HOLD`, and only on a source with a question (ST-5; `OPTIMISTIC_NO_QUESTION`): writes a guess that is the graph's and the screen's value for `x` from the instant's settle, display-ahead and verdict-inert, until the hold it was made over commits @@ -171,7 +228,9 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } An awaited `refresh x` resolves when the re-ask lands (not in `ref/`). - **AC-5 (affects).** `affects x`, in imperative scope anywhere, declares that work now in flight will change `x`: `pending? x` is `true` until - the change it was declared in commits (S-solid-26 instants 6–8). + the change it was declared in commits (S-solid-26 instants 6–8). In an + arrow it makes the arrow an action (AC-1b), since only an action's change + lasts across the work (S-infer-4); in an apply block it lasts one instant. - **AC-7 (try/catch).** [ruled 2026-10-23] Actions are imperative code, so they fail the way imperative code does: `try { … } catch (e) { … } finally { … }` and `throw e` are statements of imperative scope. A @@ -215,7 +274,9 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } | `ACTION_CALLED_IN_OWNED_SCOPE` | U | AC-1: an action is `io` (IO-2) | | `INVALID_REFRESH_TARGET` | S | AC-4: not a source with a question / await eq | | `INVALID_AFFECTS_TARGET` | S | AC-5 | -| `OPTIMISTIC_OUTSIDE_HOLD` (new) | U | AC-3: no open change | +| `OPTIMISTIC_OUTSIDE_HOLD` (new) | S | AC-3: an apply block (never an action, AC-1b) | +| `HELD_WRITE` (new) | S | AC-1c: an early write in an inferred action | +| `STALE_OWN_READ` (new) | S | AC-1d: a read of the change's own write | | `OPTIMISTIC_NO_QUESTION` (new) | S | AC-3 / ST-5c | ## Scenarios diff --git a/spec/07-surface.md b/spec/07-surface.md index ad2dbc7..2471fe7 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -205,6 +205,11 @@ action rename(n: string) { } action { refresh user; affects user } // inline, one run (AC-1) + + ``` - A write is `target := e`; `target` is a source or a path into one (W-1), @@ -223,8 +228,16 @@ action { refresh user; affects user } // inline, one run (AC-1) of the same: a `try` around the `await` whose catch gives `r` (S-await-1). - An action catches every failure its body can produce (R-f-6): there is no one after the body to catch it. +- An arrow that guesses, declares `affects`, or both writes and awaits is + an action (06 AC-1b); an `effect`'s apply block never is. In such an + arrow a plain write before an `await` is `HELD_WRITE` (AC-1c): make it + `optimistic` if it is ephemeral state that shows while the work runs, + `onFlush(() => { … })` if it is a reset that lands now, or declare the + action to hold it on purpose. In every run, reading a source the run's + own change wrote is `STALE_OWN_READ` (AC-1d): keep the value in a local. - `await` in an action body suspends the run and keeps its change open; in - a plain handler it ends the change (the continuation is a new one). + a plain run it ends the change (the continuation is a new one). A plain + run that awaits writes nothing, or it would be an action. - A handler that launches an action and also writes (`() => { draft := ""; send() }`) makes one change: the handler's plain writes are adopted by the action and held with it (AC-1). Writes that should land now go in a @@ -497,4 +510,17 @@ Expr ::= TSExpr with §3's removals and additions suspension becomes part of the semantics (each is a snapshot of σ, H-10, taken before or after the instant the await opens), and the reference interpreter would need a continuation per expression. Binding first - keeps every snapshot on one side of the suspension. \ No newline at end of file + keeps every snapshot on one side of the suspension. +17. **Inferred actions** (ruled 2026-10-28): an arrow whose body guesses, + declares `affects`, or writes and awaits is an action without the + keyword (06 AC-1b), with two checks that keep the hold visible: + `HELD_WRITE` for an early write in such a body (AC-1c) and + `STALE_OWN_READ` for a read of a run's own write anywhere (AC-1d). + Weighed against keeping `action` explicit: agents in the AI eval wrote + server writes as plain async handlers with hand-made counters, which + worked only because the handler's `await` committed the counter; the + compiler sees every `await` (decision 16), so it can hold the run as + Solid would and refuse the code that would then break. An `effect`'s + apply block is not inferred (its return is the cleanup, EF-5). The + declared `action` stays for names, reuse, and holding a write on + purpose. diff --git a/spec/11-compiler.md b/spec/11-compiler.md index 868f476..df91c4d 100644 --- a/spec/11-compiler.md +++ b/spec/11-compiler.md @@ -132,7 +132,7 @@ record argued each way; this is the decision. | `awaited(v)` | `await e` — the flight the memo returns or the action yields | identity on a promise | | `hoisted(name, get)` | an equation's hoisted awaits, which `refresh` re-asks with it | — | | `effect(n, inputs, apply)` | `effect (…) { … }` (EF, E-12) | `createEffect(compute, { effect, error })` | - | `action(gen)` | `action f(…) { … }` (AC-1, AC-7) | `action` over a generator | + | `action(gen)` | `action f(…) { … }` (AC-1, AC-7); an inferred action, `(ps) => action(function* () { … })()` (AC-1b) | `action` over a generator | | `refresh(name, get)`, `affects(get)`, `until(p)` | AC-4, AC-5, AC-6 | `refresh`, `affects`, `until` (`until`: client build only) | | `snapshot(get)` | an imperative read (H-10, R-f-7) | `untrack` | | `untrack`, `latest`, `isPending` | `peek` (E-5), `latest` (R-12), `pending?` (R-9) | the same | diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index f0c605d..5bea50b 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -98,7 +98,9 @@ named. | `WRITE_TO_NON_SOURCE` | S | W-1 | S-core-1, S-check-1 | | `DRAFT_WRITE_OUTSIDE_DRAFT` | S | ST-3a | S-store-2 | | `ASYNC_STORE_SETTER` | S | ST-3b | S-check-1 | -| `OPTIMISTIC_OUTSIDE_HOLD` | S | AC-3 | S-check-1 | +| `OPTIMISTIC_OUTSIDE_HOLD` | S | AC-3 (an `effect`'s apply block: never an action, AC-1b) | S-check-1, S-infer-1 | +| `HELD_WRITE` | S | AC-1c | S-infer-2 | +| `STALE_OWN_READ` | S | AC-1d | S-infer-5 | | `OPTIMISTIC_NO_QUESTION` | S | AC-3 / ST-5c | S-check-1, S-solid-32 | | `INVALID_REFRESH_TARGET`, `INVALID_AFFECTS_TARGET` | S | AC-4, AC-5 | S-check-1 | | `REGION_ESCAPE` | S | O-3, O-3a, O-3b | S-check-1 (seven shapes), S-module-1 |