diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7775781..afb7bbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,15 +42,17 @@ jobs: run: cargo test --locked --workspace --all-targets - name: CLI project gates run: | - cargo run --locked -p uhura-cli -- fmt --check examples/instagram/client + # Project-wide formatting remains gated on RFC 0003 comment + # attachment; the formatter refuses to erase the evidence catalogue's + # authored comments. cargo run --locked -p uhura-cli -- check examples/instagram/client --deny-warnings - cargo run --locked -p uhura-cli -- trace examples/instagram/client --script=demo > /dev/null + cargo run --locked -p uhura-cli -- trace examples/instagram/client --script=feed_like_refused_scenario --expanded > /dev/null - name: Check out Spock integration source uses: actions/checkout@v4 with: repository: gridaco/spock - ref: 66edad443660361ec98414f212203868408dcdc5 + ref: e99d1af1b045b7db744da77f1415b83b65064dc2 path: .ci/spock submodules: false persist-credentials: false @@ -200,7 +202,7 @@ jobs: import sys state = json.loads(pathlib.Path(sys.argv[1]).read_text()) - assert state["protocol"] == "uhura-editor-state/2" + assert state["protocol"] == "uhura-editor-state/5" assert isinstance(state["sourceRevision"], int) assert state["sourceRevision"] >= 1 render = state["render"] @@ -210,12 +212,18 @@ jobs: assert isinstance(render["authoring"]["targets"], list) assert render["authoring"]["targets"] assert isinstance(render["authoring"]["entries"], list) - assert render["authoring"]["entries"] + assert render["authoring"]["entries"] == [] assert isinstance(render["groups"], list) assert render["groups"] assert all(isinstance(preview["documentation"], dict) for preview in render["previews"]) assert all(isinstance(preview["provenance"]["occurrences"], list) for preview in render["previews"]) - assert any(preview["documentation"]["declarationDocId"] for preview in render["previews"]) + assert all( + preview["documentation"] == { + "declarationDocId": None, + "exampleDocId": None, + } + for preview in render["previews"] + ) assert any(preview["provenance"]["occurrences"] for preview in render["previews"]) PY diff --git a/.gitignore b/.gitignore index 313e33b..de80a41 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ /web/node_modules /web/dist /examples/instagram/client/providers/dist +/examples/instagram/client/build +/examples/applications/*/answers/*/build +/examples/programs/answers/*/build .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 4b6f12c..daba3c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,26 @@ dependencies = [ "pxfm", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -512,16 +532,14 @@ name = "uhura-cli" version = "0.0.0" dependencies = [ "image", + "serde", "serde_json", "tiny_http", "toml", "uhura-base", "uhura-check", "uhura-core", - "uhura-editor-model", - "uhura-fixture", "uhura-host", - "uhura-port", "uhura-syntax", ] @@ -529,8 +547,12 @@ dependencies = [ name = "uhura-core" version = "0.0.0" dependencies = [ + "num-bigint", + "num-integer", + "num-traits", "serde", "serde_json", + "sha2", "uhura-base", "uhura-port", ] @@ -542,21 +564,9 @@ dependencies = [ "serde", "serde_json", "uhura-base", - "uhura-check", "uhura-core", ] -[[package]] -name = "uhura-fixture" -version = "0.0.0" -dependencies = [ - "serde", - "serde_json", - "toml", - "uhura-base", - "uhura-port", -] - [[package]] name = "uhura-host" version = "0.0.0" @@ -568,7 +578,6 @@ dependencies = [ "uhura-check", "uhura-core", "uhura-editor-model", - "uhura-fixture", "uhura-port", "uhura-syntax", ] @@ -587,7 +596,9 @@ dependencies = [ name = "uhura-syntax" version = "0.0.0" dependencies = [ + "serde", "uhura-base", + "unicode-ident", ] [[package]] @@ -598,11 +609,8 @@ dependencies = [ "serde_json", "uhura-base", "uhura-check", - "uhura-cli", "uhura-core", - "uhura-editor-model", - "uhura-fixture", - "uhura-port", + "uhura-host", "uhura-syntax", ] @@ -613,10 +621,9 @@ dependencies = [ "serde_json", "uhura-base", "uhura-check", - "uhura-cli", "uhura-core", - "uhura-fixture", "uhura-port", + "uhura-syntax", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 427487e..9b68b65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ -# The Uhura spike workspace. Cargo language commands are independently -# buildable; browser assets are separate build/package products (RFC 0002). -# Design: docs/working-group/instagram-spike-design.md. +# The Uhura workspace. Cargo language commands are independently buildable; +# browser assets are separate build/package products (RFC 0002). +# Active incubation design: docs/spec/drafts/0.4/README.md. [workspace] resolver = "3" members = ["crates/*"] @@ -26,6 +26,10 @@ same-file = "1" ttf-parser = "0.25" brotli = "7" wuff = { version = "0.2.8", default-features = false } +num-bigint = { version = "0.4", features = ["serde"] } +num-integer = "0.1" +num-traits = "0.2" +unicode-ident = "1" # Internal uhura-base = { path = "crates/uhura-base" } @@ -33,7 +37,6 @@ uhura-syntax = { path = "crates/uhura-syntax" } uhura-port = { path = "crates/uhura-port" } uhura-check = { path = "crates/uhura-check" } uhura-core = { path = "crates/uhura-core" } -uhura-fixture = { path = "crates/uhura-fixture" } uhura-editor-model = { path = "crates/uhura-editor-model" } uhura-host = { path = "crates/uhura-host" } uhura-cli = { path = "crates/uhura-cli" } diff --git a/README.md b/README.md index a1390bc..3720f52 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,30 @@ # Uhura -Uhura is a declarative UI language and deterministic experience runtime. +Uhura is a frontend builder system whose core is a deterministic state-machine +language with an explicit, opt-in Web UI profile. -An Uhura program defines what an interface presents, the local UI state that -drives it, and how semantic events advance that state. The checker validates -elements, handlers, commands, and outcomes before the program runs. The runtime -then evaluates the program into a renderer-neutral semantic view. +An Uhura machine defines configuration, owned state, typed inputs, atomic +reactions, ordered commands, and a pure public observation. The checker +validates that complete program before it runs. The active 0.4 frontend +evaluates Rust-shaped core source and explicit `use uhura::ui;` Web +presentation without changing that semantic boundary. Evidence uses the same +frontend and adds source-authored scenarios, checkpoints, pins, and static +examples without creating a second execution model. -Uhura owns experience behavior, not pixels or product truth. Renderers own -layout and presentation; providers own authoritative data and operations. +Uhura owns experience behavior, not pixels or product truth. The browser owns +layout and presentation; admitted adapters own browser capabilities and access +to authoritative data and operations. ## What it provides -- A closed, checkable language for pages, components, surfaces, and events. -- Deterministic state transitions and replayable interaction traces. -- Typed ports for fixture-backed tests and live providers. +- A closed, checkable language for deterministic state machines. +- Optional checked Web presentations, semantic events, and surfaces. +- Deterministic reactions, checkpoints, evidence, and interaction traces. +- Typed ports admitted against exact adapter ownership and contract identities. - A read-only Editor for browsing checked previews. - A Play mode for running the experience against a provider. -- Native and Wasm runtimes with conformance tests. +- One canonical engine used natively and through Wasm, with cross-boundary + conformance tests. The full-stack Instagram project in [`examples/instagram/`](examples/instagram/) exercises the complete workflow; @@ -41,54 +48,60 @@ be authoritative in both systems. ## Run the example -From the repository root, install and build the browser application once: +From the Uhura repository root, build the Wasm engine and browser application +once: ```sh -cd web -corepack pnpm install --frozen-lockfile -corepack pnpm build -cd .. +corepack pnpm@10.11.0 -C web install --frozen-lockfile scripts/build-wasm.sh +corepack pnpm@10.11.0 -C web build ``` -Start the complete framework example with the npm-distributed Spock CLI: +Open the independently checkable Instagram client in the Uhura Editor: ```sh -npx --yes spock@0.5.0 start examples/instagram +cargo run --locked -p uhura-cli -- editor examples/instagram/client ``` -The Editor opens at . Use its Play action or open - to run the experience against the seeded Spock -authority on the same origin. +The Editor opens at . To run Play against the complete +seeded Spock authority on one origin, use the framework command documented by +the [Instagram example](examples/instagram/). Useful commands: ```sh -# Check a project -cargo run --locked -p uhura-cli -- check examples/instagram/client +# Check source, lower one machine program, and execute all authored evidence +cargo run --locked -p uhura-cli -- check \ + examples/instagram/client --deny-warnings -# Run Uhura Editor without the Spock authority -cargo run --locked -p uhura-cli -- editor examples/instagram/client +# Start Play as the primary route; Editor remains available at / +cargo run --locked -p uhura-cli -- play examples/instagram/client -# Run a deterministic interaction trace +# Serialize one source-authored evidence scenario as canonical JSONL cargo run --locked -p uhura-cli -- trace examples/instagram/client \ - --script=like-refused --expanded + --script=feed_like_refused_scenario --expanded # Test the Rust workspace -cargo test --workspace +cargo test --locked --workspace # Check the browser application -(cd web && corepack pnpm check) +corepack pnpm@10.11.0 -C web check ``` +`check` and `trace` use the same checked program and evidence runner that feed +Editor previews. `--script` selects an authored `scenario`; it is not a +fixture-script language or an alternate runtime. + ## Repository layout -- [`crates/`](crates/) — checker, runtime, Wasm bindings, and CLI. +- [`crates/`](crates/) — checker, runtime, host, Wasm bindings, CLI, and the + [single-engine acceptance crate](crates/uhura-tests/). - [`web/`](web/) — Editor and Play browser application. - [`examples/`](examples/) — language-design program and application harnesses, plus the full-stack Instagram example. - [`docs/doctrine/`](docs/doctrine/) — durable language doctrine and review principles. - [`docs/spec/`](docs/spec/) — stable router for disposable drafts and future version specifications. - [`docs/widgets/`](docs/widgets/) — stable capability taxonomy and version-scoped catalogues. +- [`docs/implementation/`](docs/implementation/) — current non-normative code ownership and contributor change routes. - [`docs/rfcs/`](docs/rfcs/) — historical proposals and supersedable decisions. - [`docs/studies/`](docs/studies/) — stable research router with disposable study leaves. @@ -101,12 +114,22 @@ Uhura's behavioral language is being reviewed from first principles. The [design principles](docs/doctrine/principles.md) define the questions, while these references provide the current evidence: +- [Uhura 0.4 incubation candidate](docs/spec/drafts/0.4/) consolidates the + active design: a source-neutral transaction kernel, Rust-shaped + machine source, Svelte-shaped `ui`, and modular source that lowers to one + global machine IR. - [Language necessity and surface reuse](docs/studies/language-necessity-and-surface-reuse.md) asks whether Uhura needs an independently owned language at all. - [Program harnesses](examples/programs/README.md) provide language-neutral L0–L2 problems for comparing candidate semantics. - [A0 Return Desk](examples/applications/a0-return-desk/README.md) provides the parallel practical application-transfer problem. +- [Uhura 0.4](examples/programs/answers/uhura-0.4/) exercises the current + candidate against L0–L2; the application harness carries the corresponding + A0 answer. [Relay B3](docs/spec/drafts/relay-b3/) is the short historical + pointer for the experiment that preceded this shape. It is not a runtime, + module, authored language, or product boundary. Retired source remains + recoverable from Git history rather than executable in the current tree. - [Transactional state-machine language prior art](docs/studies/transactional-state-machine-language-prior-art.md) compares Scilla, FSM-Hume, Lustre/SCADE, Kôika/Bluespec, Elm, and adjacent models. @@ -126,7 +149,8 @@ policy may change while the language and toolchain are being established. - [Documentation index and authority](docs/README.md) - [Language doctrine](docs/doctrine/README.md) -- [Specification router and current v0 draft](docs/spec/README.md) -- [Widget taxonomy and current v0 draft](docs/widgets/README.md) +- [Specification router and historical design drafts](docs/spec/README.md) +- [Widget taxonomy and version-scoped catalogues](docs/widgets/README.md) +- [Current implementation map](docs/implementation/README.md) - [RFC index](docs/rfcs/README.md) - [Studies](docs/studies/README.md) diff --git a/crates/uhura-base/src/canonical.rs b/crates/uhura-base/src/canonical.rs index 4b1d920..e2d37c6 100644 --- a/crates/uhura-base/src/canonical.rs +++ b/crates/uhura-base/src/canonical.rs @@ -5,29 +5,57 @@ //! minimal escapes (serde_json's), integers only, compact (no whitespace), //! no trailing newline (callers add LF where a file format wants it). -use std::fmt::Write as _; +use std::fmt::{self, Write as _}; use sha2::{Digest, Sha256}; -/// Renders a `serde_json::Value` to canonical form. Panics (debug) on any -/// non-integer number — floats are unrepresentable in the Uhura value model -/// and must never reach a hash. -pub fn to_canonical_json(v: &serde_json::Value) -> String { +/// A value that cannot be represented by Uhura's integer-only canonical JSON. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalJsonError { + pub path: String, + pub message: String, +} + +impl fmt::Display for CanonicalJsonError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.path, self.message) + } +} + +impl std::error::Error for CanonicalJsonError {} + +/// Renders a `serde_json::Value` to canonical form, rejecting floating-point +/// numbers at every depth in every build profile. +pub fn try_to_canonical_json(v: &serde_json::Value) -> Result { let mut out = String::new(); - write_canonical(v, &mut out); - out + write_canonical(v, &mut out, "$")?; + Ok(out) +} + +/// Infallible convenience for values already admitted by a checked Uhura +/// boundary. User-controlled JSON must use [`try_to_canonical_json`]. +pub fn to_canonical_json(v: &serde_json::Value) -> String { + try_to_canonical_json(v).expect("checked Uhura JSON must contain integers only") } -fn write_canonical(v: &serde_json::Value, out: &mut String) { +fn write_canonical( + v: &serde_json::Value, + out: &mut String, + path: &str, +) -> Result<(), CanonicalJsonError> { use serde_json::Value as J; match v { J::Null => out.push_str("null"), J::Bool(b) => out.push_str(if *b { "true" } else { "false" }), J::Number(n) => { - debug_assert!( - n.is_i64() || n.is_u64(), - "float reached canonical JSON: {n} — the value model has no floats (§7.5)" - ); + if !n.is_i64() && !n.is_u64() { + return Err(CanonicalJsonError { + path: path.to_string(), + message: format!( + "floating-point JSON number `{n}` is not canonical Uhura data" + ), + }); + } let _ = write!(out, "{n}"); } J::String(s) => { @@ -40,7 +68,7 @@ fn write_canonical(v: &serde_json::Value, out: &mut String) { if i > 0 { out.push(','); } - write_canonical(x, out); + write_canonical(x, out, &format!("{path}[{i}]"))?; } out.push(']'); } @@ -57,11 +85,12 @@ fn write_canonical(v: &serde_json::Value, out: &mut String) { } let _ = write!(out, "{}", serde_json::Value::String(k.clone())); out.push(':'); - write_canonical(&fields[k], out); + write_canonical(&fields[k], out, &format!("{path}.{k}"))?; } out.push('}'); } } + Ok(()) } /// SHA-256 of `bytes`, lowercase hex. @@ -74,11 +103,17 @@ pub fn sha256_hex(bytes: &[u8]) -> String { out } -/// Convenience: canonical JSON of `v`, hashed. +/// Convenience for already-admitted JSON: canonical JSON of `v`, hashed. +/// User-controlled values must use [`try_hash_json`]. pub fn hash_json(v: &serde_json::Value) -> String { sha256_hex(to_canonical_json(v).as_bytes()) } +/// Fallible canonical JSON hash for user-controlled values. +pub fn try_hash_json(v: &serde_json::Value) -> Result { + Ok(sha256_hex(try_to_canonical_json(v)?.as_bytes())) +} + #[cfg(test)] mod tests { use super::*; @@ -109,10 +144,26 @@ mod tests { } #[test] - #[should_panic(expected = "float reached canonical JSON")] - #[cfg(debug_assertions)] - fn floats_panic_in_debug() { - let v = json!(1.5); - let _ = to_canonical_json(&v); + fn rejects_float_recursively_in_every_build_profile() { + let error = try_to_canonical_json(&json!({ + "ok": [1, 2], + "nested": [{ "ratio": 1.5 }], + })) + .unwrap_err(); + assert_eq!(error.path, "$.nested[0].ratio"); + assert_eq!( + error.message, + "floating-point JSON number `1.5` is not canonical Uhura data" + ); + } + + #[test] + fn fallible_hash_rejects_the_same_float_tree() { + assert_eq!( + try_hash_json(&json!({ "nested": [1, { "ratio": 0.25 }] })) + .unwrap_err() + .path, + "$.nested[1].ratio" + ); } } diff --git a/crates/uhura-base/src/codes.rs b/crates/uhura-base/src/codes.rs index c041133..a3f5047 100644 --- a/crates/uhura-base/src/codes.rs +++ b/crates/uhura-base/src/codes.rs @@ -1,6 +1,6 @@ -//! The UHnxxx diagnostic code registry (plan micro-decision #5). +//! The centralized Uhura diagnostic code registry. //! -//! Blocks by pipeline pass: +//! Toolchain and host contracts use the `UHnxxx` namespace: //! - UH0xxx — lex/parse (incl. bounds) //! - UH1xxx — routes / resolution / imports //! - UH2xxx — catalog / ports / lock @@ -12,13 +12,74 @@ //! - UH8xxx — runtime (minted by core, appear in `G`/traces) //! - UH9xxx — internal invariants //! -//! Every constant pairs the stable code with its human `rule` slug. Codes -//! are appended, never renumbered; each new pass adds its block here so -//! collisions are impossible. +//! The machine checker uses the `R1xxx`/`R3xxx` family. Those values live in +//! [`machine`] rather than a second crate-local registry. Uhura 0.4 syntax +//! uses `R1001` for parse failures while exposing the precise parser +//! classification through stable rules in [`v04_parse`]. +//! +//! Every constant pairs the stable code with its human `rule` slug. Existing +//! codes are never renumbered. /// `(code, rule)` pair type for registry entries. pub type Code = (&'static str, &'static str); +/// Deterministic-machine diagnostic families. +/// +/// Several semantic rules intentionally share one code. Their rule slugs +/// remain the finer public discriminator. +pub mod machine { + pub const HEADER: &str = "R1002"; + pub const MODULE: &str = "R1002"; + pub const IMPORT: &str = "R1003"; + pub const FEATURE: &str = "R1002"; + pub const DUPLICATE: &str = "R1002"; + pub const UNKNOWN_NAME: &str = "R1003"; + pub const UNKNOWN_TYPE: &str = "R1003"; + pub const ARITY: &str = "R1004"; + pub const TYPE_MISMATCH: &str = "R1004"; + pub const INVALID_REFINEMENT: &str = "R1005"; + pub const NOT_EXHAUSTIVE: &str = "R1006"; + pub const INPUT_COVERAGE: &str = "R1007"; + pub const EFFECT: &str = "R1008"; + pub const DEPENDENCY_CYCLE: &str = "R1009"; + pub const TERMINATION: &str = "R1010"; + pub const NOT_TOTAL: &str = "R1011"; + pub const PARTIAL_OPERATION: &str = "R1011"; + pub const OUTCOME: &str = "R1012"; + pub const TRANSITION_SHAPE: &str = "R1012"; + pub const INVARIANT: &str = "R1013"; + pub const PROJECTION_NOT_TOTAL: &str = "R1013"; + pub const PORT: &str = "R1004"; + pub const UI: &str = "R3006"; + pub const EVIDENCE: &str = "R1004"; + pub const UI_NOT_ENABLED: &str = "R3001"; + pub const EVIDENCE_NOT_ENABLED: &str = "R3011"; + pub const ROUTE_CODEC_MISMATCH: &str = "R3012"; + pub const UNSUPPORTED: &str = "R1002"; +} + +/// Public identities for Uhura 0.4 parser diagnostics. +/// +/// `R1001` is the common parse code used by CLI and host consumers. The rule +/// is the stable, lossless parser-kind discriminator. +pub mod v04_parse { + use super::Code; + + pub const LEXICAL: Code = ("R1001", "uhura-0.4/parse/lexical"); + pub const UNEXPECTED_TOKEN: Code = ("R1001", "uhura-0.4/parse/unexpected-token"); + pub const MISSING_TOKEN: Code = ("R1001", "uhura-0.4/parse/missing-token"); + pub const INVALID_NAME: Code = ("R1001", "uhura-0.4/parse/invalid-name"); + pub const INVALID_DECLARATION: Code = ("R1001", "uhura-0.4/parse/invalid-declaration"); + pub const INVALID_MEMBER: Code = ("R1001", "uhura-0.4/parse/invalid-member"); + pub const INVALID_TYPE: Code = ("R1001", "uhura-0.4/parse/invalid-type"); + pub const INVALID_PATTERN: Code = ("R1001", "uhura-0.4/parse/invalid-pattern"); + pub const INVALID_EXPRESSION: Code = ("R1001", "uhura-0.4/parse/invalid-expression"); + pub const INVALID_STATEMENT: Code = ("R1001", "uhura-0.4/parse/invalid-statement"); + pub const INVALID_UI: Code = ("R1001", "uhura-0.4/parse/invalid-ui"); + pub const INVALID_EVIDENCE: Code = ("R1001", "uhura-0.4/parse/invalid-evidence"); + pub const COMPARISON_CHAIN: Code = ("R1001", "uhura-0.4/parse/comparison-chain"); +} + // ── UH0xxx: lex/parse ────────────────────────────────────────────────────── pub const UNEXPECTED_TOKEN: Code = ("UH0001", "syntax/unexpected-token"); pub const UNTERMINATED_STRING: Code = ("UH0002", "syntax/unterminated-string"); @@ -135,3 +196,4 @@ pub const INVALID_FIXTURE: Code = ("UH2009", "contract/invalid-fixture"); // ── UH9xxx: internal invariants ──────────────────────────────────────────────── pub const TEMPLATE_ORIGIN_COVERAGE: Code = ("UH9001", "internal/template-origin-coverage"); +pub const ICON_SOURCE_COVERAGE: Code = ("UH9002", "internal/icon-source-coverage"); diff --git a/crates/uhura-base/src/lib.rs b/crates/uhura-base/src/lib.rs index be18405..2607cfa 100644 --- a/crates/uhura-base/src/lib.rs +++ b/crates/uhura-base/src/lib.rs @@ -12,7 +12,10 @@ mod envelope; mod span; mod value; -pub use canonical::{hash_json, sha256_hex, to_canonical_json}; +pub use canonical::{ + CanonicalJsonError, hash_json, sha256_hex, to_canonical_json, try_hash_json, + try_to_canonical_json, +}; pub use diagnostic::{Diagnostic, Edit, Fix, Label, Severity, has_errors}; pub use envelope::{render_text, to_envelope}; pub use span::{FileId, LineCol, SourceMap, Span}; diff --git a/crates/uhura-check/Cargo.toml b/crates/uhura-check/Cargo.toml index d817db6..6de2766 100644 --- a/crates/uhura-check/Cargo.toml +++ b/crates/uhura-check/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [dependencies] uhura-base = { workspace = true } uhura-syntax = { workspace = true } -uhura-port = { workspace = true, features = ["toml"] } +uhura-port = { workspace = true } uhura-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/uhura-check/src/assets.rs b/crates/uhura-check/src/assets.rs new file mode 100644 index 0000000..b04f1ea --- /dev/null +++ b/crates/uhura-check/src/assets.rs @@ -0,0 +1,359 @@ +//! Checked local asset registries. +//! +//! The parser owns the existing `[assets.]` manifest shape. The loader is +//! pure over host-supplied bytes so Editor and Play can share one captured +//! project revision without filesystem access in semantic layers. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use uhura_base::{Ident, sha256_hex}; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AssetManifest { + pub assets: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetConfig { + /// Path relative to the asset manifest. + pub file: String, + pub alt: String, + /// Optional presentation-byte pin. Materialized sourced assets require it. + pub sha256: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetInput { + pub file: String, + pub bytes: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CheckedAsset { + pub file: String, + pub bytes: Arc<[u8]>, + pub media_type: String, + pub alt: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CheckedAssets { + pub assets: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetIssue { + pub path: String, + pub message: String, +} + +/// Parse the existing local asset registry. +/// +/// `[sources.*]` is provenance-only and intentionally does not enter runtime +/// resources. The accepted asset fields preserve the materializer's current +/// source/hash and motif/seed forms while exposing only file, alt, and hash to +/// the checked runtime resource model. +pub fn load_asset_manifest(text: &str) -> Result> { + let table: toml::Table = match text.parse() { + Ok(table) => table, + Err(error) => { + return Err(vec![AssetIssue { + path: String::new(), + message: format!("invalid TOML: {error}"), + }]); + } + }; + let mut issues = Vec::new(); + for key in table.keys() { + if !["assets", "sources"].contains(&key.as_str()) { + issue(&mut issues, key, format!("unknown key `{key}`")); + } + } + let Some(assets) = table.get("assets").and_then(toml::Value::as_table) else { + issue(&mut issues, "assets", "missing required `[assets]` table"); + return Err(issues); + }; + + let mut declarations = BTreeMap::new(); + for (id, value) in assets { + let path = format!("assets.{id}"); + let id = match Ident::new(id) { + Ok(id) => Some(id), + Err(_) => { + issue( + &mut issues, + &path, + format!("`{id}` is not a lowercase kebab-case identifier"), + ); + None + } + }; + let Some(entry) = value.as_table() else { + issue(&mut issues, &path, "expected an asset table"); + continue; + }; + for key in entry.keys() { + if !["file", "alt", "sha256", "size", "source", "motif", "seed"].contains(&key.as_str()) + { + issue( + &mut issues, + format!("{path}.{key}"), + format!("unknown key `{key}`"), + ); + } + } + + let file = required_string(entry.get("file"), &format!("{path}.file"), &mut issues) + .and_then(|file| { + if safe_asset_reference(&file) { + Some(file) + } else { + issue( + &mut issues, + format!("{path}.file"), + "expected a safe manifest-relative path", + ); + None + } + }); + let alt = required_string(entry.get("alt"), &format!("{path}.alt"), &mut issues).and_then( + |alt| { + if alt.trim().is_empty() { + issue( + &mut issues, + format!("{path}.alt"), + "alternative text must not be empty", + ); + None + } else { + Some(alt) + } + }, + ); + let sha256 = optional_sha256(entry.get("sha256"), &format!("{path}.sha256"), &mut issues); + + let source = optional_string(entry.get("source"), &format!("{path}.source"), &mut issues); + let motif = optional_string(entry.get("motif"), &format!("{path}.motif"), &mut issues); + let seed = match entry.get("seed") { + None => None, + Some(toml::Value::Integer(value)) => Some(*value), + Some(_) => { + issue(&mut issues, format!("{path}.seed"), "expected an integer"); + None + } + }; + if source.is_some() && sha256.is_none() { + issue( + &mut issues, + format!("{path}.sha256"), + "a sourced asset requires a SHA-256 pin", + ); + } + if motif.is_some() != seed.is_some() { + issue( + &mut issues, + &path, + "a generated asset requires both `motif` and `seed`", + ); + } + if source.is_some() && motif.is_some() { + issue( + &mut issues, + &path, + "an asset cannot declare both `source` and `motif`", + ); + } + if let Some(value) = entry.get("size") + && !matches!(value, toml::Value::Integer(size) if *size > 0) + { + issue( + &mut issues, + format!("{path}.size"), + "expected a positive integer", + ); + } + + if let (Some(id), Some(file), Some(alt)) = (id, file, alt) { + declarations.insert(id, AssetConfig { file, alt, sha256 }); + } + } + + if issues.is_empty() { + Ok(AssetManifest { + assets: declarations, + }) + } else { + Err(issues) + } +} + +/// Validate the exact captured bytes associated with each declaration. +pub fn load_assets( + manifest: &AssetManifest, + inputs: &BTreeMap, +) -> Result> { + let mut issues = Vec::new(); + let mut assets = BTreeMap::new(); + for (id, declaration) in &manifest.assets { + let path = format!("assets.{id}"); + let Some(input) = inputs.get(id) else { + issue( + &mut issues, + &path, + "declared asset has no supplied file input", + ); + continue; + }; + if input.file != declaration.file { + issue( + &mut issues, + format!("{path}.file"), + format!( + "host supplied `{}` for manifest path `{}`", + input.file, declaration.file + ), + ); + } + let Some(bytes) = input.bytes.as_ref() else { + issue( + &mut issues, + &declaration.file, + "asset file is missing or unreadable", + ); + continue; + }; + let actual_hash = sha256_hex(bytes); + if let Some(expected_hash) = &declaration.sha256 + && expected_hash != &actual_hash + { + issue( + &mut issues, + format!("{path}.sha256"), + format!("asset hash mismatch: expected `{expected_hash}`, got `{actual_hash}`"), + ); + continue; + } + assets.insert( + id.clone(), + CheckedAsset { + file: declaration.file.clone(), + bytes: Arc::clone(bytes), + media_type: asset_media_type(&declaration.file).to_string(), + alt: declaration.alt.clone(), + sha256: actual_hash, + }, + ); + } + for id in inputs.keys() { + if !manifest.assets.contains_key(id) { + issue( + &mut issues, + format!("assets.{id}"), + "host supplied an undeclared asset", + ); + } + } + + if issues.is_empty() { + Ok(CheckedAssets { assets }) + } else { + Err(issues) + } +} + +fn required_string( + value: Option<&toml::Value>, + path: &str, + issues: &mut Vec, +) -> Option { + match value.and_then(toml::Value::as_str) { + Some(value) => Some(value.to_string()), + None => { + issue(issues, path, "missing required string"); + None + } + } +} + +fn optional_string( + value: Option<&toml::Value>, + path: &str, + issues: &mut Vec, +) -> Option { + match value { + None => None, + Some(value) => match value.as_str() { + Some(value) => Some(value.to_string()), + None => { + issue(issues, path, "expected a string"); + None + } + }, + } +} + +fn optional_sha256( + value: Option<&toml::Value>, + path: &str, + issues: &mut Vec, +) -> Option { + let value = optional_string(value, path, issues)?; + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Some(value) + } else { + issue( + issues, + path, + "expected a lowercase 64-character SHA-256 digest", + ); + None + } +} + +fn safe_asset_reference(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains('\0') + && !path.contains("://") + && !matches!( + path.as_bytes(), + [drive, b':', ..] if drive.is_ascii_alphabetic() + ) + && path + .split('/') + .all(|segment| !segment.is_empty() && !matches!(segment, "." | "..")) +} + +fn asset_media_type(file: &str) -> &'static str { + match file + .rsplit_once('.') + .map(|(_, extension)| extension) + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "avif" => "image/avif", + "svg" => "image/svg+xml", + "mp4" => "video/mp4", + "webm" => "video/webm", + _ => "application/octet-stream", + } +} + +fn issue(issues: &mut Vec, path: impl Into, message: impl Into) { + issues.push(AssetIssue { + path: path.into(), + message: message.into(), + }); +} diff --git a/crates/uhura-check/src/catalog.rs b/crates/uhura-check/src/catalog.rs deleted file mode 100644 index 4e009a7..0000000 --- a/crates/uhura-check/src/catalog.rs +++ /dev/null @@ -1,804 +0,0 @@ -//! The semantic element catalog as data (design §10): the model, the TOML -//! loader, the meta-schema (input events only on interactive elements; -//! observation events only on viewports), and the canonical-form hash the -//! IR pins. - -use std::collections::{BTreeMap, BTreeSet}; - -use uhura_base::{Ident, hash_json}; - -/// One loaded, meta-schema-validated element catalog. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Catalog { - pub name: Ident, - pub version: String, - pub elements: BTreeMap, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ElementClass { - Layout, - Content, - Interactive, -} - -impl ElementClass { - pub fn as_str(self) -> &'static str { - match self { - ElementClass::Layout => "layout", - ElementClass::Content => "content", - ElementClass::Interactive => "interactive", - } - } -} - -/// What an element accepts as children (§10 children models). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChildrenModel { - /// No children ever (`img`, `icon`, `textfield`). - None, - /// Any markup (`view`, `scroll`). - Any, - /// Content-class elements only (`button` — icon/text/img). - Content, - /// Exactly one child element (`region`). - One, - /// Children come from exactly one keyed `{#each}` (`pager`). - KeyedEach, - /// Literal text and `{expr}` interpolation only (`text`). - Text, -} - -impl ChildrenModel { - pub fn as_str(self) -> &'static str { - match self { - ChildrenModel::None => "none", - ChildrenModel::Any => "any", - ChildrenModel::Content => "content", - ChildrenModel::One => "one", - ChildrenModel::KeyedEach => "keyed-each", - ChildrenModel::Text => "text", - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PropType { - Text, - Bool, - Int, - /// A closed token set; values typecheck as enum values (§4.3). - Enum(BTreeSet), - /// An asset reference (`img src`). - Asset, - /// A name from the selected icon family's glyph registry. - Icon, - /// A statically selected icon family alias. - IconFamily, -} - -impl PropType { - pub fn describe(&self) -> String { - match self { - PropType::Text => "text".into(), - PropType::Bool => "bool".into(), - PropType::Int => "int".into(), - PropType::Enum(values) => { - let list: Vec<&str> = values.iter().map(Ident::as_str).collect(); - format!("one of {}", list.join(" | ")) - } - PropType::Asset => "an asset reference".into(), - PropType::Icon => "an icon name".into(), - PropType::IconFamily => "an icon family name".into(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PropDecl { - pub ty: PropType, - pub required: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EventKind { - /// User input; interactive elements only. - Input, - /// A semantic observation; viewport elements only. - Observe, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct EventDecl { - pub kind: EventKind, - /// Renderer-carried payload fields (`change { value: text }`, §4.2). - /// Carried fields are `text`/`bool`/`int` only. - pub carries: BTreeMap, - /// `near-end`: integer percentage of one viewport extent (§8.2). - pub threshold_percent: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ElementDecl { - pub class: ElementClass, - /// Layout elements that own scrollable extent (`scroll`, `pager`). - pub viewport: bool, - pub children: ChildrenModel, - pub props: BTreeMap, - pub events: BTreeMap, - /// Prop groups where exactly one member must be bound (`alt` xor - /// `decorative`). - pub exactly_one_of: Vec>, - /// Controlled promotion: binding `prop` obligates handling `event`. - pub controlled: Option<(Ident, Ident)>, -} - -/// A catalog-level problem, located by TOML key path. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CatalogIssue { - pub path: String, - pub message: String, -} - -impl Catalog { - /// The canonical JSON form — the byte form whose SHA-256 the checked IR - /// pins (§10 "versioned + hash-pinned"). - pub fn to_canonical_json(&self) -> serde_json::Value { - use serde_json::{Map, Value as J, json}; - let elements: Map = self - .elements - .iter() - .map(|(name, el)| { - let props: Map = el - .props - .iter() - .map(|(p, decl)| { - ( - p.to_string(), - json!({ - "type": prop_type_json(&decl.ty), - "required": decl.required, - }), - ) - }) - .collect(); - let events: Map = el - .events - .iter() - .map(|(e, decl)| { - ( - e.to_string(), - json!({ - "kind": match decl.kind { - EventKind::Input => "input", - EventKind::Observe => "observe", - }, - "carries": decl - .carries - .iter() - .map(|(f, ty)| (f.to_string(), prop_type_json(ty))) - .collect::>(), - "threshold-percent": decl.threshold_percent, - }), - ) - }) - .collect(); - ( - name.to_string(), - json!({ - "class": el.class.as_str(), - "viewport": el.viewport, - "children": el.children.as_str(), - "props": props, - "events": events, - "exactly-one-of": el - .exactly_one_of - .iter() - .map(|group| group.iter().map(ToString::to_string).collect::>()) - .collect::>(), - "controlled": el - .controlled - .as_ref() - .map(|(p, e)| json!({ "prop": p.to_string(), "event": e.to_string() })), - }), - ) - }) - .collect(); - json!({ - "catalog": "uhura-catalog/0", - "name": self.name.to_string(), - "version": self.version, - "elements": elements, - }) - } - - pub fn canonical_hash(&self) -> String { - hash_json(&self.to_canonical_json()) - } - - /// The meta-schema (§10): event-kind eligibility by class, controlled - /// and exactly-one-of references, carried-field types, icon-prop - /// backing. - pub fn meta_schema(&self) -> Vec { - let mut issues = Vec::new(); - let mut push = |path: String, message: String| issues.push(CatalogIssue { path, message }); - - for (name, el) in &self.elements { - let path = format!("elements.{name}"); - if el.viewport && el.class != ElementClass::Layout { - push( - path.clone(), - "only layout elements can be viewports".to_string(), - ); - } - for (event, decl) in &el.events { - let epath = format!("{path}.events.{event}"); - match decl.kind { - EventKind::Input if el.class != ElementClass::Interactive => push( - epath.clone(), - "input events are declarable only on interactive elements".to_string(), - ), - EventKind::Observe if !el.viewport => push( - epath.clone(), - "observation events are declarable only on viewports".to_string(), - ), - _ => {} - } - for (field, ty) in &decl.carries { - if !matches!(ty, PropType::Text | PropType::Bool | PropType::Int) { - push( - format!("{epath}.carries.{field}"), - "carried fields are text | bool | int".to_string(), - ); - } - } - if decl - .threshold_percent - .is_some_and(|t| !(1..=200).contains(&t)) - { - push( - epath, - "`threshold-percent` must be an integer in 1..=200".to_string(), - ); - } - } - for group in &el.exactly_one_of { - for prop in group { - if !el.props.contains_key(prop) { - push( - format!("{path}.exactly-one-of"), - format!("`{prop}` is not a prop of `{name}`"), - ); - } - } - if group.len() < 2 { - push( - format!("{path}.exactly-one-of"), - "a group needs at least two props".to_string(), - ); - } - } - if let Some((prop, event)) = &el.controlled { - if !el.props.contains_key(prop) { - push( - format!("{path}.controlled"), - format!("`{prop}` is not a prop of `{name}`"), - ); - } - match el.events.get(event) { - None => push( - format!("{path}.controlled"), - format!("`{event}` is not an event of `{name}`"), - ), - Some(decl) if decl.kind != EventKind::Input => push( - format!("{path}.controlled"), - "the controlling event must be an input event".to_string(), - ), - Some(_) => {} - } - } - for (prop, prop_decl) in &el.props { - if prop.as_str() == "class" { - push( - format!("{path}.props.class"), - "`class` is universal and may not be redeclared".to_string(), - ); - } - if matches!(prop_decl.ty, PropType::Icon) - && !(name.as_str() == "icon" && prop.as_str() == "name") - { - push( - format!("{path}.props.{prop}"), - "the `icon` type is reserved for ``'s `name` prop".to_string(), - ); - } - if matches!(prop_decl.ty, PropType::IconFamily) - && !(name.as_str() == "icon" && prop.as_str() == "family") - { - push( - format!("{path}.props.{prop}"), - "the `icon-family` type is reserved for ``'s `family` prop" - .to_string(), - ); - } - } - if name.as_str() == "icon" { - if el.class != ElementClass::Content { - push( - path.clone(), - "`` must be a content element".to_string(), - ); - } - if el.children != ChildrenModel::None { - push(path.clone(), "`` cannot accept children".to_string()); - } - if !el.events.is_empty() { - push(path.clone(), "`` cannot declare events".to_string()); - } - match el.props.iter().find(|(prop, _)| prop.as_str() == "name") { - Some((_, decl)) if matches!(decl.ty, PropType::Icon) && decl.required => {} - _ => push( - format!("{path}.props.name"), - "`` requires a `name` prop of type `icon`".to_string(), - ), - } - match el.props.iter().find(|(prop, _)| prop.as_str() == "family") { - Some((_, decl)) - if matches!(decl.ty, PropType::IconFamily) && !decl.required => {} - _ => push( - format!("{path}.props.family"), - "`` requires an optional `family` prop of type `icon-family`" - .to_string(), - ), - } - } - } - issues - } -} - -fn prop_type_json(ty: &PropType) -> serde_json::Value { - use serde_json::json; - match ty { - PropType::Text => json!("text"), - PropType::Bool => json!("bool"), - PropType::Int => json!("int"), - PropType::Asset => json!("asset"), - PropType::Icon => json!("icon"), - PropType::IconFamily => json!("icon-family"), - PropType::Enum(values) => json!({ - "enum": values.iter().map(ToString::to_string).collect::>(), - }), - } -} - -/// Loads and validates a catalog. `Err` carries every issue (structural -/// and meta-schema); `Ok` catalogs are clean. -pub fn load_catalog(text: &str) -> Result> { - let table: toml::Table = match text.parse() { - Ok(t) => t, - Err(e) => { - return Err(vec![CatalogIssue { - path: String::new(), - message: format!("invalid TOML: {e}"), - }]); - } - }; - let mut issues = Vec::new(); - let catalog = walk_catalog(&table, &mut issues); - match catalog { - Some(c) if issues.is_empty() => { - let meta = c.meta_schema(); - if meta.is_empty() { Ok(c) } else { Err(meta) } - } - _ => Err(issues), - } -} - -fn walk_catalog(table: &toml::Table, issues: &mut Vec) -> Option { - let mut push = |path: &str, message: String| { - issues.push(CatalogIssue { - path: path.to_string(), - message, - }); - }; - - for key in table.keys() { - if !["catalog", "elements"].contains(&key.as_str()) { - push(key, format!("unknown key `{key}`")); - } - } - - let head = match table.get("catalog").and_then(toml::Value::as_table) { - Some(t) => t, - None => { - push("catalog", "missing `[catalog]` section".to_string()); - return None; - } - }; - for key in head.keys() { - if !["name", "version"].contains(&key.as_str()) { - push(&format!("catalog.{key}"), format!("unknown key `{key}`")); - } - } - let name = ident_at("catalog.name", head.get("name"), issues)?; - let version = match head.get("version").and_then(toml::Value::as_str) { - Some(v) => v.to_string(), - None => { - issues.push(CatalogIssue { - path: "catalog.version".into(), - message: "missing required string".into(), - }); - return None; - } - }; - let mut elements = BTreeMap::new(); - if let Some(section) = table.get("elements") { - let Some(section) = section.as_table() else { - issues.push(CatalogIssue { - path: "elements".into(), - message: "expected a table".into(), - }); - return None; - }; - for (el_name, decl) in section { - let path = format!("elements.{el_name}"); - let Some(el_ident) = ident_key(&path, el_name, issues) else { - continue; - }; - let Some(decl) = decl.as_table() else { - issues.push(CatalogIssue { - path, - message: "expected a table".into(), - }); - continue; - }; - if let Some(el) = walk_element(&path, decl, issues) { - elements.insert(el_ident, el); - } - } - } - - Some(Catalog { - name, - version, - elements, - }) -} - -fn walk_element( - path: &str, - table: &toml::Table, - issues: &mut Vec, -) -> Option { - for key in table.keys() { - if ![ - "class", - "viewport", - "children", - "props", - "events", - "exactly-one-of", - "controlled", - ] - .contains(&key.as_str()) - { - issues.push(CatalogIssue { - path: format!("{path}.{key}"), - message: format!("unknown key `{key}`"), - }); - } - } - - let class = match table.get("class").and_then(toml::Value::as_str) { - Some("layout") => ElementClass::Layout, - Some("content") => ElementClass::Content, - Some("interactive") => ElementClass::Interactive, - Some(other) => { - issues.push(CatalogIssue { - path: format!("{path}.class"), - message: format!("`{other}` is not a class (layout | content | interactive)"), - }); - return None; - } - None => { - issues.push(CatalogIssue { - path: format!("{path}.class"), - message: "missing required `class`".into(), - }); - return None; - } - }; - - let viewport = table - .get("viewport") - .and_then(toml::Value::as_bool) - .unwrap_or(false); - - let children = match table.get("children").and_then(toml::Value::as_str) { - Some("none") => ChildrenModel::None, - Some("any") => ChildrenModel::Any, - Some("content") => ChildrenModel::Content, - Some("one") => ChildrenModel::One, - Some("keyed-each") => ChildrenModel::KeyedEach, - Some("text") => ChildrenModel::Text, - Some(other) => { - issues.push(CatalogIssue { - path: format!("{path}.children"), - message: format!( - "`{other}` is not a children model \ - (none | any | content | one | keyed-each | text)" - ), - }); - return None; - } - None => { - issues.push(CatalogIssue { - path: format!("{path}.children"), - message: "missing required `children`".into(), - }); - return None; - } - }; - - let mut props = BTreeMap::new(); - if let Some(toml::Value::Table(section)) = table.get("props") { - for (prop_name, decl) in section { - let ppath = format!("{path}.props.{prop_name}"); - let Some(prop_ident) = ident_key(&ppath, prop_name, issues) else { - continue; - }; - let Some(decl) = decl.as_table() else { - issues.push(CatalogIssue { - path: ppath, - message: "expected a table".into(), - }); - continue; - }; - if let Some(prop) = walk_prop(&ppath, decl, issues) { - props.insert(prop_ident, prop); - } - } - } - - let mut events = BTreeMap::new(); - if let Some(toml::Value::Table(section)) = table.get("events") { - for (event_name, decl) in section { - let epath = format!("{path}.events.{event_name}"); - let Some(event_ident) = ident_key(&epath, event_name, issues) else { - continue; - }; - let Some(decl) = decl.as_table() else { - issues.push(CatalogIssue { - path: epath, - message: "expected a table".into(), - }); - continue; - }; - if let Some(event) = walk_event(&epath, decl, issues) { - events.insert(event_ident, event); - } - } - } - - let mut exactly_one_of = Vec::new(); - if let Some(toml::Value::Array(groups)) = table.get("exactly-one-of") { - for (i, group) in groups.iter().enumerate() { - let gpath = format!("{path}.exactly-one-of[{i}]"); - let Some(members) = group.as_array() else { - issues.push(CatalogIssue { - path: gpath, - message: "expected an array of prop names".into(), - }); - continue; - }; - let mut names = Vec::new(); - for member in members { - match member.as_str().map(Ident::new) { - Some(Ok(name)) => names.push(name), - _ => issues.push(CatalogIssue { - path: gpath.clone(), - message: "expected a prop name".into(), - }), - } - } - exactly_one_of.push(names); - } - } - - let controlled = match table.get("controlled") { - None => None, - Some(toml::Value::Table(t)) => { - let prop = ident_at(&format!("{path}.controlled.prop"), t.get("prop"), issues); - let event = ident_at(&format!("{path}.controlled.event"), t.get("event"), issues); - match (prop, event) { - (Some(p), Some(e)) => Some((p, e)), - _ => None, - } - } - Some(_) => { - issues.push(CatalogIssue { - path: format!("{path}.controlled"), - message: "expected `{ prop = …, event = … }`".into(), - }); - None - } - }; - - Some(ElementDecl { - class, - viewport, - children, - props, - events, - exactly_one_of, - controlled, - }) -} - -fn walk_prop(path: &str, table: &toml::Table, issues: &mut Vec) -> Option { - for key in table.keys() { - if !["type", "values", "required"].contains(&key.as_str()) { - issues.push(CatalogIssue { - path: format!("{path}.{key}"), - message: format!("unknown key `{key}`"), - }); - } - } - let required = table - .get("required") - .and_then(toml::Value::as_bool) - .unwrap_or(false); - let ty = match table.get("type").and_then(toml::Value::as_str) { - Some("text") => PropType::Text, - Some("bool") => PropType::Bool, - Some("int") => PropType::Int, - Some("asset") => PropType::Asset, - Some("icon") => PropType::Icon, - Some("icon-family") => PropType::IconFamily, - Some("enum") => { - let mut values = BTreeSet::new(); - match table.get("values") { - Some(toml::Value::Array(items)) => { - for item in items { - match item.as_str().map(Ident::new) { - Some(Ok(v)) => { - values.insert(v); - } - _ => issues.push(CatalogIssue { - path: format!("{path}.values"), - message: "enum values are kebab-case strings".into(), - }), - } - } - } - _ => issues.push(CatalogIssue { - path: format!("{path}.values"), - message: "an enum prop needs a `values` array".into(), - }), - } - if values.is_empty() { - return None; - } - PropType::Enum(values) - } - Some(other) => { - issues.push(CatalogIssue { - path: format!("{path}.type"), - message: format!( - "`{other}` is not a prop type (text | bool | int | enum | asset | icon | icon-family)" - ), - }); - return None; - } - None => { - issues.push(CatalogIssue { - path: format!("{path}.type"), - message: "missing required `type`".into(), - }); - return None; - } - }; - Some(PropDecl { ty, required }) -} - -fn walk_event( - path: &str, - table: &toml::Table, - issues: &mut Vec, -) -> Option { - for key in table.keys() { - if !["kind", "carries", "threshold-percent"].contains(&key.as_str()) { - issues.push(CatalogIssue { - path: format!("{path}.{key}"), - message: format!("unknown key `{key}`"), - }); - } - } - let kind = match table.get("kind").and_then(toml::Value::as_str) { - Some("input") => EventKind::Input, - Some("observe") => EventKind::Observe, - Some(other) => { - issues.push(CatalogIssue { - path: format!("{path}.kind"), - message: format!("`{other}` is not an event kind (input | observe)"), - }); - return None; - } - None => { - issues.push(CatalogIssue { - path: format!("{path}.kind"), - message: "missing required `kind`".into(), - }); - return None; - } - }; - let mut carries = BTreeMap::new(); - if let Some(toml::Value::Table(section)) = table.get("carries") { - for (field, ty) in section { - let fpath = format!("{path}.carries.{field}"); - let Some(field_ident) = ident_key(&fpath, field, issues) else { - continue; - }; - let ty = match ty.as_str() { - Some("text") => PropType::Text, - Some("bool") => PropType::Bool, - Some("int") => PropType::Int, - _ => { - issues.push(CatalogIssue { - path: fpath, - message: "carried fields are text | bool | int".into(), - }); - continue; - } - }; - carries.insert(field_ident, ty); - } - } - let threshold_percent = match table.get("threshold-percent") { - None => None, - Some(toml::Value::Integer(n)) => Some(*n), - Some(_) => { - issues.push(CatalogIssue { - path: format!("{path}.threshold-percent"), - message: "`threshold-percent` must be an integer".into(), - }); - None - } - }; - Some(EventDecl { - kind, - carries, - threshold_percent, - }) -} - -fn ident_at( - path: &str, - value: Option<&toml::Value>, - issues: &mut Vec, -) -> Option { - match value.and_then(toml::Value::as_str) { - Some(s) => ident_key(path, s, issues), - None => { - issues.push(CatalogIssue { - path: path.to_string(), - message: "missing required string".into(), - }); - None - } - } -} - -fn ident_key(path: &str, s: &str, issues: &mut Vec) -> Option { - match Ident::new(s) { - Ok(i) => Some(i), - Err(e) => { - issues.push(CatalogIssue { - path: path.to_string(), - message: e.to_string(), - }); - None - } - } -} diff --git a/crates/uhura-check/src/checker.rs b/crates/uhura-check/src/checker.rs new file mode 100644 index 0000000..999daf6 --- /dev/null +++ b/crates/uhura-check/src/checker.rs @@ -0,0 +1,10021 @@ +#![allow(clippy::only_used_in_recursion, clippy::too_many_arguments)] + +use std::collections::{BTreeMap, BTreeSet}; + +use super::diagnostic::{codes, error}; +use super::types::{ConstructorInfo, Ty, TypeInfo, TypeRegistry, TypeShape, compatible, join}; +use super::ui_catalog::{ + self, AttributeKind as UiAttributeKind, Availability as UiElementAvailability, + Constraint as UiConstraint, ContentModel as UiContentModel, ElementContext as UiElementContext, + EventContract as UiEventContract, EventPayload as UiEventPayload, +}; +use crate::checker_ir as ast; +use uhura_base::{Diagnostic, has_errors}; +use uhura_core::ir::{ + CommandDef, EvidenceRef as IrEvidenceRef, EvidenceStep as IrEvidenceStep, Handler as IrHandler, + Machine as IrMachine, ObservationField, OutcomeDef, OutcomePolicy as IrOutcomePolicy, PortDef, + Presentation, Scenario as IrScenario, ScenarioOrigin as IrScenarioOrigin, SourceRef, + StateField, Statement, StatementMatchArm, Transition as IrTransition, + UiAttribute as IrUiAttribute, UiAttributeValue as IrUiAttributeValue, UiCase as IrUiCase, + UiNode as IrUiNode, +}; +use uhura_core::{ + BinaryOp as IrBinaryOp, BoundaryNumber, ConstructorDef, Decimal, EvidenceExampleMetadata, + EvidencePresentationKind, Expr as IrExpr, Function as IrFunction, + INLINE_UPDATE_JOIN_LOCAL_PREFIX, INLINE_UPDATE_LOOP_EXIT_LOCAL_PREFIX, MatchArm as IrMatchArm, + PURE_CONTINUATION_LOCAL_PREFIX, Pattern as IrPattern, Program, TypeDef, TypeRef, + UnaryOp as IrUnaryOp, Value, +}; + +#[derive(Clone, Debug)] +pub(crate) struct DeferredPresentation { + pub(crate) module: String, + pub(crate) declaration: ast::UiDecl, + pub(crate) span: ast::SourceSpan, +} + +#[derive(Clone, Debug)] +pub(crate) struct DeferredEvidence { + pub(crate) module: String, + pub(crate) declaration: ast::Declaration, +} + +#[derive(Debug)] +pub struct CheckOutput { + pub program: Option, + pub diagnostics: Vec, + /// Source-layout-sensitive semantic-node occurrences. Callers without + /// source-layout metadata leave this empty. + pub provenance: Option, +} + +#[derive(Clone, Debug)] +enum Export { + Type(TypeRef), + Const { + id: String, + ty: TypeRef, + }, + Function { + id: String, + params: Vec, + result: TypeRef, + }, + Machine { + id: String, + }, + Presentation { + id: String, + }, + PortContract, + PureHelper, + UiElement, +} + +#[derive(Clone, Debug)] +struct ModuleEnv<'a> { + module: &'a ast::Module, + id: String, + physical_source_paths: BTreeMap, + semantic_paths: BTreeMap<(u32, u32), String>, + exports: BTreeMap, + imports: BTreeMap, + features: BTreeSet, +} + +impl ModuleEnv<'_> { + fn lookup(&self, name: &str) -> Option<&Export> { + self.exports.get(name).or_else(|| self.imports.get(name)) + } +} + +#[derive(Clone, Debug)] +struct Binding { + lowered: String, + ty: Ty, +} + +#[derive(Clone)] +struct DeferredPureContinuation { + parameters: Vec, + body: ast::Expr, + definition_scope: Scope, + parameter_types: Option>, + result: TypeRef, +} + +#[derive(Clone)] +struct CompiledPureContinuation { + lambda: IrExpr, + parameters: Vec, + result: Ty, +} + +#[derive(Clone, Copy, Debug, Default)] +struct NumericBounds { + min: Option, + max: Option, +} + +#[derive(Clone, Debug, Default)] +struct Scope { + values: BTreeMap, + types: BTreeMap, + constructors: BTreeMap>, + functions: BTreeMap, TypeRef)>, + transitions: BTreeMap, String)>, + state_fields: BTreeMap, + config_fields: BTreeMap, + port_receive: BTreeMap, + port_send: BTreeMap, + outcome_type: Option, + command_type: Option, + input_type: Option, + numeric_bounds: BTreeMap, + less_equal: BTreeSet<(String, String)>, + less_than: BTreeSet<(String, String)>, +} + +impl Scope { + fn child(&self) -> Self { + self.clone() + } + + fn bind(&mut self, source: impl Into, lowered: impl Into, ty: Ty) { + let source = source.into(); + let lowered = lowered.into(); + let bounds = match ty.as_value() { + Some(TypeRef::Nat) => Some(NumericBounds { + min: Some(0), + max: None, + }), + Some(TypeRef::PositiveInt) => Some(NumericBounds { + min: Some(1), + max: None, + }), + Some(TypeRef::Ratio) => Some(NumericBounds { + min: Some(0), + max: Some(1), + }), + _ => None, + }; + self.values.insert( + source, + Binding { + lowered: lowered.clone(), + ty, + }, + ); + self.numeric_bounds.remove(&lowered); + if let Some(bounds) = bounds { + self.numeric_bounds.insert(lowered, bounds); + } + } + + fn invalidate_path(&mut self, path: &str) { + let mentions = |candidate: &str| { + candidate == path + || candidate + .strip_prefix(path) + .is_some_and(|suffix| suffix.starts_with('.')) + }; + self.numeric_bounds + .retain(|candidate, _| !mentions(candidate)); + self.less_equal + .retain(|(left, right)| !mentions(left) && !mentions(right)); + self.less_than + .retain(|(left, right)| !mentions(left) && !mentions(right)); + } +} + +pub(crate) type ImportAliases = BTreeMap<(String, String, String), String>; +pub(crate) type PhysicalSourcePaths = BTreeMap; + +pub(crate) fn check_project_with_import_aliases( + project: &ast::Project, + import_aliases: &ImportAliases, + physical_source_paths: &PhysicalSourcePaths, +) -> CheckOutput { + let mut checker = Checker::new(project, import_aliases, physical_source_paths); + checker.run() +} + +struct Checker<'a> { + project: &'a ast::Project, + import_aliases: &'a ImportAliases, + physical_source_paths: PhysicalSourcePaths, + diagnostics: Vec, + registry: TypeRegistry, + modules: BTreeMap>, + program: Program, + presentations: Vec, + evidence: Vec, + pure_continuations: BTreeMap, + lower_expr_depth: usize, + draining_pure_continuations: bool, +} + +impl<'a> Checker<'a> { + fn new( + project: &'a ast::Project, + import_aliases: &'a ImportAliases, + physical_source_paths: &PhysicalSourcePaths, + ) -> Self { + Self { + project, + import_aliases, + physical_source_paths: physical_source_paths.clone(), + diagnostics: Vec::new(), + registry: TypeRegistry::default(), + modules: BTreeMap::new(), + program: Program::new(), + presentations: Vec::new(), + evidence: Vec::new(), + pure_continuations: BTreeMap::new(), + lower_expr_depth: 0, + draining_pure_continuations: false, + } + } + + fn run(&mut self) -> CheckOutput { + self.collect_modules(); + self.collect_imports(); + self.check_import_cycles(); + self.collect_type_shapes(); + self.collect_value_signatures(); + self.lower_declarations(); + self.lower_presentations(); + self.lower_evidence(); + self.check_recursion(); + + self.diagnostics.sort_by_key(|diagnostic| { + ( + diagnostic.span.file.0, + diagnostic.span.start, + diagnostic.span.end, + diagnostic.code, + ) + }); + self.diagnostics.dedup_by(|left, right| { + left.code == right.code && left.span == right.span && left.message == right.message + }); + let program = if has_errors(&self.diagnostics) { + None + } else { + // The current frontend still has to attach canonical provenance + // and authored fault-site identities. It freezes the complete + // executable artifact after that finalization step. + Some(std::mem::take(&mut self.program)) + }; + CheckOutput { + program, + diagnostics: std::mem::take(&mut self.diagnostics), + provenance: None, + } + } + + fn collect_modules(&mut self) { + for module in &self.project.modules { + if module.language.name.value != "uhura" || module.language.version != "0.4" { + self.diagnostics.push(error( + codes::HEADER, + "uhura/header", + format!( + "expected internal Uhura kernel version `0.4`, found `language {} {}`", + module.language.name.value, module.language.version + ), + module.language.span, + )); + } + let logical = module.identity.logical_name(); + let id = format!("{}@{}", logical, module.identity.major); + if self.modules.contains_key(&id) { + self.diagnostics.push(error( + codes::MODULE, + "uhura/duplicate-module", + format!("module `{id}` occurs more than once"), + module.identity.span, + )); + continue; + } + let mut features = BTreeSet::new(); + for feature in &module.uses { + if !matches!(feature.feature.value.as_str(), "ui" | "evidence") { + self.diagnostics.push(error( + codes::FEATURE, + "uhura/unknown-feature", + format!("unknown opt-in feature `{}`", feature.feature.value), + feature.feature.span, + )); + } else if !features.insert(feature.feature.value.clone()) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/duplicate-feature", + format!("feature `{}` is enabled twice", feature.feature.value), + feature.feature.span, + )); + } + } + self.program.machine_program.modules.push(id.clone()); + self.modules.insert( + id.clone(), + ModuleEnv { + module, + id, + physical_source_paths: self.physical_source_paths.clone(), + semantic_paths: semantic_path_index(module), + exports: BTreeMap::new(), + imports: BTreeMap::new(), + features, + }, + ); + } + + let ids = self.modules.keys().cloned().collect::>(); + for id in ids { + let declarations = self.modules[&id].module.declarations.clone(); + let mut exports = BTreeMap::new(); + for declaration in declarations { + let (name, export) = match &declaration.value { + ast::DeclarationKind::Key(value) => ( + value.name.value.clone(), + Export::Type(TypeRef::Named { + id: qualify(&id, &value.name.value), + }), + ), + ast::DeclarationKind::Type(value) => ( + value.name.value.clone(), + Export::Type(TypeRef::Named { + id: qualify(&id, &value.name.value), + }), + ), + ast::DeclarationKind::Const(value) => ( + value.name.value.clone(), + Export::Const { + id: qualify(&id, &value.name.value), + ty: TypeRef::Never, + }, + ), + ast::DeclarationKind::Function(value) => ( + value.name.value.clone(), + Export::Function { + id: qualify(&id, &value.name.value), + params: Vec::new(), + result: TypeRef::Never, + }, + ), + ast::DeclarationKind::Machine(value) => ( + value.name.value.clone(), + Export::Machine { + id: qualify(&id, &value.name.value), + }, + ), + ast::DeclarationKind::Ui(value) => ( + value.name.value.clone(), + Export::Presentation { + id: qualify(&id, &value.name.value), + }, + ), + ast::DeclarationKind::Scenario(_) + | ast::DeclarationKind::Example(_) + | ast::DeclarationKind::Checkpoint(_) => continue, + }; + if exports.insert(name.clone(), export).is_some() { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/duplicate-declaration", + format!("`{name}` is declared more than once in module `{id}`"), + declaration.span, + )); + } + } + self.modules.get_mut(&id).expect("module").exports = exports; + } + } + + fn collect_imports(&mut self) { + let module_ids = self.modules.keys().cloned().collect::>(); + for module_id in module_ids { + let imports = self.modules[&module_id].module.imports.clone(); + let mut resolved = BTreeMap::new(); + for import in imports { + for name in import.names { + let local_name = self + .import_aliases + .get(&(module_id.clone(), import.target.clone(), name.value.clone())) + .cloned() + .unwrap_or_else(|| name.value.clone()); + let export = if let Some(module) = self.modules.get(&import.target) { + module.exports.get(&name.value).cloned() + } else { + standard_export(&import.target, &name.value) + }; + match export { + Some(export) => { + if self.modules[&module_id].exports.contains_key(&local_name) + || resolved.insert(local_name.clone(), export).is_some() + { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/import-collision", + format!( + "imported name `{}` collides in this module", + local_name + ), + name.span, + )); + } + } + None => self.diagnostics.push(error( + codes::IMPORT, + "uhura/unresolved-import", + format!( + "`{}` is not exported by module `{}`", + name.value, import.target + ), + name.span, + )), + } + } + } + self.modules.get_mut(&module_id).expect("module").imports = resolved; + } + } + + fn check_import_cycles(&mut self) { + fn visit( + id: &str, + modules: &BTreeMap>, + active: &mut Vec, + complete: &mut BTreeSet, + cycles: &mut Vec<(String, ast::SourceSpan)>, + ) { + if complete.contains(id) { + return; + } + active.push(id.to_string()); + if let Some(module) = modules.get(id) { + for import in &module.module.imports { + if !modules.contains_key(&import.target) { + continue; + } + if active.iter().any(|active_id| active_id == &import.target) { + cycles.push(( + format!( + "import cycle closes from `{id}` back to `{}`", + import.target + ), + import.span, + )); + } else { + visit(&import.target, modules, active, complete, cycles); + } + } + } + active.pop(); + complete.insert(id.to_string()); + } + + let mut active = Vec::new(); + let mut complete = BTreeSet::new(); + let mut cycles = Vec::new(); + for id in self.modules.keys() { + visit(id, &self.modules, &mut active, &mut complete, &mut cycles); + } + cycles.sort_by_key(|(_, span)| (span.file, span.start, span.end)); + cycles.dedup_by_key(|(_, span)| (span.file, span.start, span.end)); + for (message, span) in cycles { + self.diagnostics.push(error( + codes::DEPENDENCY_CYCLE, + "uhura/import-cycle", + message, + span, + )); + } + } + + fn collect_type_shapes(&mut self) { + let module_ids = self.modules.keys().cloned().collect::>(); + for module_id in module_ids { + let module = self.modules[&module_id].clone(); + let scope = self.module_scope(&module); + for declaration in &module.module.declarations { + match &declaration.value { + ast::DeclarationKind::Key(key) => { + let underlying = self.resolve_type(&module, &scope, &key.over); + self.reject_persisted_finite_view( + &underlying, + key.over.span, + &format!("key `{}`", key.name.value), + ); + let id = qualify(&module_id, &key.name.value); + self.registry.insert(TypeInfo { + id: id.clone(), + shape: TypeShape::Key(underlying.clone()), + }); + self.program + .machine_program + .types + .insert(id.clone(), TypeDef::Key { id, underlying }); + } + ast::DeclarationKind::Type(ty) => { + if !ty.parameters.is_empty() { + self.diagnostics.push(error( + codes::UNSUPPORTED, + "uhura/user-generic", + "user-declared generic types are reserved but are not supported", + declaration.span, + )); + continue; + } + let id = qualify(&module_id, &ty.name.value); + self.install_type_body(&module, &scope, &id, &ty.body, declaration.span); + } + _ => {} + } + } + } + } + + fn collect_value_signatures(&mut self) { + let module_ids = self.modules.keys().cloned().collect::>(); + for module_id in module_ids { + let module = self.modules[&module_id].clone(); + let scope = self.module_scope(&module); + let mut updates = Vec::new(); + for declaration in &module.module.declarations { + match &declaration.value { + ast::DeclarationKind::Const(value) => { + let ty = self.resolve_type(&module, &scope, &value.ty); + updates.push(( + value.name.value.clone(), + Export::Const { + id: qualify(&module_id, &value.name.value), + ty, + }, + )); + } + ast::DeclarationKind::Function(value) => { + let params = value + .parameters + .iter() + .map(|parameter| self.resolve_type(&module, &scope, ¶meter.ty)) + .collect(); + let result = self.resolve_type(&module, &scope, &value.result); + updates.push(( + value.name.value.clone(), + Export::Function { + id: qualify(&module_id, &value.name.value), + params, + result, + }, + )); + } + _ => {} + } + } + for (name, export) in updates { + self.modules + .get_mut(&module_id) + .expect("module") + .exports + .insert(name, export); + } + } + // Imports carry value signatures, so refresh them after declaration + // signatures have replaced their collection placeholders. + self.collect_imports_refresh(); + } + + fn collect_imports_refresh(&mut self) { + let module_ids = self.modules.keys().cloned().collect::>(); + for module_id in module_ids { + let imports = self.modules[&module_id].module.imports.clone(); + let mut resolved = BTreeMap::new(); + for import in imports { + for name in import.names { + let local_name = self + .import_aliases + .get(&(module_id.clone(), import.target.clone(), name.value.clone())) + .cloned() + .unwrap_or_else(|| name.value.clone()); + if let Some(export) = self + .modules + .get(&import.target) + .and_then(|module| module.exports.get(&name.value)) + .cloned() + .or_else(|| standard_export(&import.target, &name.value)) + { + resolved.insert(local_name, export); + } + } + } + self.modules.get_mut(&module_id).expect("module").imports = resolved; + } + } + + fn lower_declarations(&mut self) { + let roots = self + .project + .modules + .iter() + .map(|module| { + format!( + "{}@{}", + module.identity.logical_name(), + module.identity.major + ) + }) + .collect::>(); + let module_ids = dependency_order(&self.modules, &roots); + + // Global constants are declarative values, not source-order + // statements. Probe their already typed expressions to recover exact + // resolved constant references, reject real cycles, then evaluate in + // dependency order. The probe is diagnostic-free: authoritative + // lowering runs once after all of a constant's dependencies exist. + let mut constants = BTreeMap::new(); + for module_id in &module_ids { + let module = self.modules[module_id].clone(); + for declaration in &module.module.declarations { + if let ast::DeclarationKind::Const(value) = &declaration.value { + constants.insert( + qualify(&module.id, &value.name.value), + (module.id.clone(), value.clone(), declaration.span), + ); + } + } + } + let constant_ids = constants.keys().cloned().collect::>(); + let mut constant_graph = BTreeMap::new(); + for (id, (module_id, declaration, _)) in &constants { + let module = self.modules[module_id].clone(); + let scope = self.module_scope(&module); + let expected = self.resolve_type(&module, &scope, &declaration.ty); + let checkpoint = self.diagnostics.len(); + let (expression, _) = self.lower_expr( + &module, + &scope, + &declaration.value, + Some(&expected), + ExprMode::Pure, + ); + self.diagnostics.truncate(checkpoint); + let mut dependencies = BTreeSet::new(); + collect_names(&expression, &mut dependencies); + dependencies.retain(|name| constant_ids.contains(name)); + constant_graph.insert(id.clone(), dependencies); + } + let cyclic_constants = cyclic_nodes(&constant_graph); + for id in &cyclic_constants { + let (_, declaration, declaration_span) = + constants.get(id).expect("cyclic constant is declared"); + self.diagnostics.push(error( + codes::DEPENDENCY_CYCLE, + "uhura/recursive-constant", + format!( + "constant `{}` participates in a compile-time dependency cycle", + declaration.name.value + ), + *declaration_span, + )); + } + for id in graph_dependency_order(&constant_graph, &cyclic_constants) { + let (module_id, declaration, declaration_span) = + constants.get(&id).expect("ordered constant is declared"); + let module = self.modules[module_id].clone(); + self.lower_global_const(&module, declaration, *declaration_span); + } + + for module_id in module_ids { + let module = self.modules[&module_id].clone(); + for declaration in &module.module.declarations { + match &declaration.value { + ast::DeclarationKind::Const(_) => {} + ast::DeclarationKind::Function(value) => { + self.lower_global_function(&module, value, declaration.span) + } + ast::DeclarationKind::Machine(value) => { + self.lower_machine(&module, value, declaration.span) + } + ast::DeclarationKind::Ui(value) => { + self.presentations.push(DeferredPresentation { + module: module.id.clone(), + declaration: value.clone(), + span: declaration.span, + }) + } + ast::DeclarationKind::Scenario(_) + | ast::DeclarationKind::Example(_) + | ast::DeclarationKind::Checkpoint(_) => self.evidence.push(DeferredEvidence { + module: module.id.clone(), + declaration: declaration.clone(), + }), + ast::DeclarationKind::Key(_) | ast::DeclarationKind::Type(_) => {} + } + } + } + } + + fn check_recursion(&mut self) { + let functions = self.program.machine_program.functions.clone(); + let global_ids = functions.keys().cloned().collect::>(); + let mut global_graph = BTreeMap::new(); + for (id, function) in &functions { + let mut calls = BTreeSet::new(); + collect_calls(&function.body, &mut calls); + calls.retain(|call| global_ids.contains(call)); + global_graph.insert(id.clone(), calls); + } + for id in cyclic_nodes(&global_graph) { + let function = &functions[&id]; + self.diagnostics.push(error( + codes::DEPENDENCY_CYCLE, + "uhura/recursive-function", + format!( + "pure function `{id}` participates in a call cycle; Uhura functions must terminate" + ), + self.physical_span(&function.source), + )); + } + + let machines = self.program.machine_program.machines.clone(); + for (machine_id, machine) in machines { + let function_ids = machine.functions.keys().cloned().collect::>(); + let mut function_graph = BTreeMap::new(); + for (name, function) in &machine.functions { + let mut calls = BTreeSet::new(); + collect_calls(&function.body, &mut calls); + calls.retain(|call| function_ids.contains(call)); + function_graph.insert(name.clone(), calls); + } + for name in cyclic_nodes(&function_graph) { + let function = &machine.functions[&name]; + self.diagnostics.push(error( + codes::DEPENDENCY_CYCLE, + "uhura/recursive-machine-function", + format!("machine function `{machine_id}.{name}` participates in a call cycle"), + self.physical_span(&function.source), + )); + } + + let derive_ids = machine + .derives + .iter() + .map(|(name, _, _, _)| name.clone()) + .collect::>(); + let mut derive_graph = BTreeMap::new(); + for (name, _, expression, _) in &machine.derives { + let mut names = BTreeSet::new(); + collect_names(expression, &mut names); + names.retain(|candidate| derive_ids.contains(candidate)); + derive_graph.insert(name.clone(), names); + } + for name in cyclic_nodes(&derive_graph) { + let source = &machine + .derives + .iter() + .find(|(candidate, _, _, _)| candidate == &name) + .expect("cyclic derive exists") + .3; + self.diagnostics.push(error( + codes::DEPENDENCY_CYCLE, + "uhura/recursive-derive", + format!("derive `{machine_id}.{name}` participates in a dependency cycle"), + self.physical_span(source), + )); + } + } + } + + fn physical_span(&self, source: &SourceRef) -> ast::SourceSpan { + let file = self + .modules + .values() + .find(|module| module.module.source_id.path == source.path) + .map(|module| module.module.source_id.file) + .unwrap_or(0); + ast::SourceSpan::new(file, source.start, source.end) + } + + fn module_scope(&self, module: &ModuleEnv<'_>) -> Scope { + let mut scope = Scope::default(); + for (name, export) in module.exports.iter().chain(module.imports.iter()) { + match export { + Export::Type(ty) => { + scope.types.insert(name.clone(), ty.clone()); + } + Export::Const { id, ty } => { + scope.bind(name, id, Ty::value(ty.clone())); + } + Export::Function { id, params, result } => { + scope + .functions + .insert(name.clone(), (id.clone(), params.clone(), result.clone())); + } + _ => {} + } + } + scope + } + + fn install_type_body( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + id: &str, + body: &ast::TypeBody, + span: ast::SourceSpan, + ) { + match body { + ast::TypeBody::Alias(alias) => { + let resolved = self.resolve_type(module, scope, alias); + let shape = match &resolved { + TypeRef::Record { fields } => TypeShape::Record(fields.clone()), + _ => TypeShape::Alias(resolved.clone()), + }; + self.registry.insert(TypeInfo { + id: id.into(), + shape, + }); + if let TypeRef::Record { fields } = resolved { + self.program.machine_program.types.insert( + id.into(), + TypeDef::Record { + id: id.into(), + fields, + }, + ); + } + } + ast::TypeBody::Sum(sum) => { + let constructors = sum + .variants + .iter() + .map(|variant| self.lower_constructor_def(module, scope, variant)) + .collect::>(); + self.registry.insert(TypeInfo { + id: id.into(), + shape: TypeShape::Sum(constructors.clone()), + }); + self.program.machine_program.types.insert( + id.into(), + TypeDef::Sum { + id: id.into(), + constructors, + }, + ); + } + } + if self.program.machine_program.types.contains_key(id) + && !self.registry.types.contains_key(id) + { + self.diagnostics.push(error( + codes::UNKNOWN_TYPE, + "uhura/type-shape", + format!("could not establish a type shape for `{id}`"), + span, + )); + } + } + + fn lower_constructor_def( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + variant: &ast::Variant, + ) -> ConstructorDef { + let fields = match &variant.payload { + ast::VariantPayload::Unit => Vec::new(), + ast::VariantPayload::Positional(values) => values + .iter() + .map(|ty| (None, self.resolve_type(module, scope, ty))) + .collect(), + ast::VariantPayload::Named(values) => values + .iter() + .map(|field| { + ( + Some(field.name.value.clone()), + self.resolve_type(module, scope, &field.ty), + ) + }) + .collect(), + }; + ConstructorDef { + name: variant.name.value.clone(), + fields, + } + } + + fn resolve_type( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + ty: &ast::TypeExpr, + ) -> TypeRef { + match &ty.value { + ast::TypeExprKind::Record(fields) => TypeRef::Record { + fields: fields + .iter() + .map(|field| { + ( + field.name.value.clone(), + self.resolve_type(module, scope, &field.ty), + ) + }) + .collect(), + }, + ast::TypeExprKind::Tuple(values) => TypeRef::Tuple { + values: values + .iter() + .map(|value| self.resolve_type(module, scope, value)) + .collect(), + }, + ast::TypeExprKind::Named { path, arguments } => { + let name = path + .iter() + .map(|part| part.value.as_str()) + .collect::>() + .join("."); + let args = arguments + .iter() + .map(|argument| self.resolve_type(module, scope, argument)) + .collect::>(); + if let Some(value) = builtin_type(&name, &args) { + if let TypeRef::Table { key, .. } = &value + && self.registry.finite_constructors(key).is_none() + { + self.diagnostics.push(error( + codes::NOT_TOTAL, + "uhura/table-key-not-finite", + format!( + "`Table` key `{}` must be a closed finite constructor type", + key.canonical_name() + ), + ty.span, + )); + } + return value; + } + if path.len() == 1 { + if let Some(value) = scope.types.get(&name) { + if !args.is_empty() { + self.diagnostics.push(error( + codes::ARITY, + "uhura/type-arity", + format!("type `{name}` does not accept type arguments"), + ty.span, + )); + } + return value.clone(); + } + if matches!(name.as_str(), "Token" | "Routes") && args.len() == 1 { + return TypeRef::Named { + id: format!("{name}<{}>", args[0].canonical_name()), + }; + } + } + self.diagnostics.push(error( + codes::UNKNOWN_TYPE, + "uhura/unknown-type", + format!("unknown type `{name}` in module `{}`", module.id), + ty.span, + )); + TypeRef::Never + } + } + } + + fn reject_persisted_finite_view( + &mut self, + ty: &TypeRef, + span: ast::SourceSpan, + boundary: &str, + ) { + let Some(path) = finite_view_path(ty, &self.registry, &mut BTreeSet::new()) else { + return; + }; + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura-0.4/ephemeral-finite-view", + format!( + "`FiniteView` is an ephemeral evaluator view and cannot appear in {boundary}; nested path: `{}`", + path.join(" -> ") + ), + span, + )); + } + + fn reject_persisted_constructor_finite_views( + &mut self, + constructor: &ConstructorDef, + variant: &ast::Variant, + boundary: &str, + ) { + let spans = match &variant.payload { + ast::VariantPayload::Unit => Vec::new(), + ast::VariantPayload::Positional(values) => { + values.iter().map(|value| value.span).collect() + } + ast::VariantPayload::Named(fields) => { + fields.iter().map(|field| field.ty.span).collect() + } + }; + for (index, ((name, ty), span)) in constructor.fields.iter().zip(spans).enumerate() { + let field = name.as_ref().map_or_else( + || format!("positional field #{}", index + 1), + |name| format!("field `{name}`"), + ); + self.reject_persisted_finite_view( + ty, + span, + &format!("{boundary} constructor `{}` {field}", constructor.name), + ); + } + } + + // Remaining lowering methods are kept below the expression engine so the + // semantic context is explicit at each call site. +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExprMode { + Pure, + Projection, + Reaction, + Ui, + Evidence, +} + +impl Checker<'_> { + fn try_compile_routes( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + declaration: &ast::ConstDecl, + expected: &TypeRef, + ) -> bool { + let TypeRef::Named { id: routes_id } = expected else { + return false; + }; + if !routes_id.starts_with("Routes<") { + return false; + } + let ast::TypeExprKind::Named { path, arguments } = &declaration.ty.value else { + return false; + }; + if path.last().map(|name| name.value.as_str()) != Some("Routes") || arguments.len() != 1 { + return false; + } + let location = self.resolve_type(module, scope, &arguments[0]); + let ast::ExprKind::Call { + callee, + arguments: route_args, + } = &declaration.value.value + else { + self.diagnostics.push(error( + codes::PORT, + "uhura/routes-value", + "`Routes` constants must be initialized by `routes({...})`", + declaration.value.span, + )); + return true; + }; + if !matches!(&callee.value, ast::ExprKind::Name(name) if name.value == "routes") + || route_args.len() != 1 + { + self.diagnostics.push(error( + codes::PORT, + "uhura/routes-value", + "route table initialization must call `routes` with one record", + declaration.value.span, + )); + return true; + } + let ast::ExprKind::Record(entries) = &route_args[0].value else { + self.diagnostics.push(error( + codes::PORT, + "uhura/routes-value", + "`routes` requires a constructor-to-pattern record", + route_args[0].span, + )); + return true; + }; + let constructors = self + .registry + .constructors_for(&location) + .into_iter() + .map(|constructor| { + let fields = constructor + .fields + .iter() + .enumerate() + .map(|(index, (name, ty))| { + let name = name.clone().unwrap_or_else(|| format!("_{index}")); + self.route_field(module, &name, ty, declaration.value.span) + }) + .collect(); + uhura_port::RouteConstructorDecl::new(constructor.name, fields) + }) + .collect::>(); + let patterns = entries + .iter() + .filter_map(|entry| { + let constructor = record_key(&entry.key)?; + let ast::ExprKind::Text(pattern) = &entry.value.value else { + self.diagnostics.push(error( + codes::PORT, + "uhura/route-pattern-literal", + "route patterns must be text literals", + entry.value.span, + )); + return None; + }; + Some(uhura_port::RoutePatternDecl::new( + constructor, + pattern.clone(), + )) + }) + .collect::>(); + match uhura_port::RouteTable::compile(port_ty(&location), constructors, patterns) { + Ok(routes) => { + let id = qualify(&module.id, &declaration.name.value); + let canonical = uhura_base::to_canonical_json( + &serde_json::to_value(&routes) + .expect("a checked Uhura route table is serializable"), + ); + // `Routes` is a checked host configuration, not an + // ordinary Uhura collection. Keep its executable structure in + // `route_tables`, while exposing the canonical immutable value + // through constants so port configuration and evidence fixture + // expressions evaluate through the same ordinary name lookup. + self.program + .machine_program + .constants + .insert(id.clone(), Value::Text(canonical)); + self.program + .machine_program + .constant_types + .insert(id.clone(), expected.clone()); + self.program.route_tables.insert(id, routes); + } + Err(route_error) => self.diagnostics.push(error( + codes::PORT, + "uhura/invalid-route-table", + route_error.to_string(), + declaration.value.span, + )), + } + true + } + + fn route_field( + &mut self, + _module: &ModuleEnv<'_>, + name: &str, + ty: &TypeRef, + span: ast::SourceSpan, + ) -> uhura_port::RouteFieldDecl { + use uhura_port::RouteFieldKind; + let kind = match ty { + TypeRef::Text => RouteFieldKind::Text, + TypeRef::Named { .. } if self.key_is_text(ty) => RouteFieldKind::TextKey { + type_name: port_ty(ty), + }, + TypeRef::Option { value } if matches!(value.as_ref(), TypeRef::Text) => { + RouteFieldKind::OptionalText + } + TypeRef::Option { value } if self.key_is_text(value) => { + RouteFieldKind::OptionalTextKey { + type_name: port_ty(value), + } + } + _ => { + self.diagnostics.push(error( + codes::PORT, + "uhura/route-field-type", + format!( + "route field `{name}` must be Text, a Text key, or an optional form; found `{}`", + ty.canonical_name() + ), + span, + )); + RouteFieldKind::Text + } + }; + uhura_port::RouteFieldDecl::new(name, kind) + } + + fn key_is_text(&self, ty: &TypeRef) -> bool { + match self.registry.shape(ty) { + Some(TypeShape::Key(TypeRef::Text)) => true, + Some(TypeShape::Alias(alias)) => self.key_is_text(alias), + _ => false, + } + } + + fn lower_global_const( + &mut self, + module: &ModuleEnv<'_>, + declaration: &ast::ConstDecl, + span: ast::SourceSpan, + ) { + let scope = self.module_scope(module); + let expected = self.resolve_type(module, &scope, &declaration.ty); + self.reject_persisted_finite_view( + &expected, + declaration.ty.span, + &format!("constant `{}`", declaration.name.value), + ); + if self.try_compile_routes(module, &scope, declaration, &expected) { + return; + } + let (expression, actual) = self.lower_expr( + module, + &scope, + &declaration.value, + Some(&expected), + ExprMode::Pure, + ); + self.expect_type(&actual, &expected, declaration.value.span); + match const_eval(&expression, &self.program) { + Ok(value) => { + let id = qualify(&module.id, &declaration.name.value); + self.program + .machine_program + .constants + .insert(id.clone(), value); + self.program + .machine_program + .constant_types + .insert(id, expected); + } + Err(message) => self.diagnostics.push(error( + codes::EFFECT, + "uhura/non-constant-expression", + format!( + "constant `{}` is not compile-time total: {message}", + declaration.name.value + ), + span, + )), + } + } + + fn lower_global_function( + &mut self, + module: &ModuleEnv<'_>, + declaration: &ast::FunctionDecl, + span: ast::SourceSpan, + ) { + let mut scope = self.module_scope(module); + let params = declaration + .parameters + .iter() + .map(|parameter| { + let ty = self.resolve_type(module, &scope, ¶meter.ty); + scope.bind( + ¶meter.name.value, + ¶meter.name.value, + Ty::value(ty.clone()), + ); + (parameter.name.value.clone(), ty) + }) + .collect::>(); + let result = self.resolve_type(module, &scope, &declaration.result); + if reaction_control(&declaration.body) { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/effect-in-pure-function", + format!( + "pure function `{}` contains reaction control (`finish`, `unreachable`, or a reaction block)", + declaration.name.value + ), + declaration.body.span, + )); + return; + } + let (body, actual) = self.lower_expr( + module, + &scope, + &declaration.body, + Some(&result), + ExprMode::Pure, + ); + self.expect_type(&actual, &result, declaration.body.span); + let id = qualify(&module.id, &declaration.name.value); + self.program.machine_program.functions.insert( + id.clone(), + IrFunction { + id, + params, + result, + body, + source: source(module, span), + }, + ); + } + + fn lower_expr( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + expression: &ast::Expr, + expected: Option<&TypeRef>, + mode: ExprMode, + ) -> (IrExpr, Ty) { + let root = self.lower_expr_depth == 0 && !self.draining_pure_continuations; + self.lower_expr_depth += 1; + let (mut value, ty) = self.lower_expr_inner(module, scope, expression, expected, mode); + self.lower_expr_depth -= 1; + if root { + value = self.drain_pure_continuations(module, mode, value); + } + (value, ty) + } + + fn lower_expr_inner( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + expression: &ast::Expr, + expected: Option<&TypeRef>, + mode: ExprMode, + ) -> (IrExpr, Ty) { + let (value, ty) = match &expression.value { + ast::ExprKind::Integer(text) => { + let target = expected.filter(|value| { + matches!( + value, + TypeRef::Int + | TypeRef::Nat + | TypeRef::PositiveInt + | TypeRef::Decimal + | TypeRef::Ratio + | TypeRef::BoundaryNumber + ) + }); + let ty = target.cloned().unwrap_or(TypeRef::Int); + match exact_number_value(text, &ty) { + Ok(value) => (IrExpr::Literal { value }, Ty::value(ty)), + Err(message) => { + self.diagnostics.push(error( + codes::INVALID_REFINEMENT, + "uhura/number-refinement", + message, + expression.span, + )); + ( + IrExpr::Literal { + value: exact_integer("0", "Int").expect("zero"), + }, + Ty::Unknown, + ) + } + } + } + ast::ExprKind::Decimal(text) => { + let ty = expected + .filter(|value| { + matches!( + value, + TypeRef::Decimal | TypeRef::Ratio | TypeRef::BoundaryNumber + ) + }) + .cloned() + .unwrap_or(TypeRef::Decimal); + match exact_number_value(text, &ty) { + Ok(value) => (IrExpr::Literal { value }, Ty::value(ty)), + Err(message) => { + self.diagnostics.push(error( + codes::INVALID_REFINEMENT, + "uhura/number-refinement", + message, + expression.span, + )); + ( + IrExpr::Literal { + value: exact_decimal("0").expect("zero"), + }, + Ty::Unknown, + ) + } + } + } + ast::ExprKind::Text(value) => ( + IrExpr::Literal { + value: Value::Text(value.clone()), + }, + Ty::value(TypeRef::Text), + ), + ast::ExprKind::Bool(value) => ( + IrExpr::Literal { + value: Value::Bool(*value), + }, + Ty::value(TypeRef::Bool), + ), + ast::ExprKind::Name(name) => self.lower_name(module, scope, name, expected), + ast::ExprKind::Tuple(values) => { + let expected_values = match expected { + Some(TypeRef::Tuple { values }) => Some(values.as_slice()), + _ => None, + }; + let lowered = values + .iter() + .enumerate() + .map(|(index, value)| { + self.lower_expr( + module, + scope, + value, + expected_values.and_then(|values| values.get(index)), + mode, + ) + }) + .collect::>(); + ( + IrExpr::Tuple { + values: lowered.iter().map(|(value, _)| value.clone()).collect(), + }, + Ty::value(TypeRef::Tuple { + values: lowered + .into_iter() + .map(|(_, ty)| ty.into_value().unwrap_or(TypeRef::Never)) + .collect(), + }), + ) + } + ast::ExprKind::Sequence(values) => { + let item_expected = match expected { + Some(TypeRef::Seq { value }) + | Some(TypeRef::NonEmpty { value }) + | Some(TypeRef::Set { value }) => Some(value.as_ref()), + _ => None, + }; + let lowered = values + .iter() + .map(|value| self.lower_expr(module, scope, value, item_expected, mode)) + .collect::>(); + let item_ty = lowered + .iter() + .map(|(_, ty)| ty.clone()) + .reduce(|left, right| join(&left, &right)) + .and_then(Ty::into_value) + .or_else(|| item_expected.cloned()) + .unwrap_or(TypeRef::Never); + ( + IrExpr::Seq { + values: lowered.into_iter().map(|(value, _)| value).collect(), + }, + Ty::value(TypeRef::Seq { + value: Box::new(item_ty), + }), + ) + } + ast::ExprKind::Record(entries) => { + self.lower_record(module, scope, entries, expected, mode, expression.span) + } + ast::ExprKind::Unary { op, operand } => { + let (value, ty) = self.lower_expr(module, scope, operand, None, mode); + let op = match op.value { + ast::UnaryOp::Not => IrUnaryOp::Not, + ast::UnaryOp::Negate => IrUnaryOp::Negate, + }; + let result = match (op, ty.as_value()) { + (IrUnaryOp::Not, Some(TypeRef::Bool)) => Ty::value(TypeRef::Bool), + ( + IrUnaryOp::Negate, + Some(TypeRef::Int | TypeRef::Nat | TypeRef::PositiveInt), + ) => Ty::value(TypeRef::Int), + (IrUnaryOp::Negate, Some(TypeRef::Decimal)) => Ty::value(TypeRef::Decimal), + (IrUnaryOp::Negate, Some(TypeRef::BoundaryNumber)) => { + Ty::value(TypeRef::BoundaryNumber) + } + (IrUnaryOp::Negate, Some(TypeRef::Ratio)) => { + self.diagnostics.push(error( + codes::INVALID_REFINEMENT, + "uhura/ratio-negation", + "negating a `Ratio` escapes [0,1]", + expression.span, + )); + Ty::value(TypeRef::Ratio) + } + _ => { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/invalid-unary-operand", + format!("invalid operand `{}` for unary operator", ty.display()), + operand.span, + )); + Ty::Unknown + } + }; + ( + IrExpr::Unary { + op, + value: Box::new(value), + }, + result, + ) + } + ast::ExprKind::Binary { left, op, right } => { + let op = lower_binary(op.value); + if matches!(op, IrBinaryOp::And | IrBinaryOp::Or) { + let left_source = left.as_ref(); + let (left_ir, refined) = self.lower_condition(module, scope, left_source, mode); + let false_scope; + let right_scope = if op == IrBinaryOp::And { + &refined + } else { + false_scope = + refined_numeric_scope(scope, left_source, false, &self.registry); + &false_scope + }; + let right_span = right.span; + let (right, right_ty) = + self.lower_expr(module, right_scope, right, Some(&TypeRef::Bool), mode); + self.expect_type(&right_ty, &TypeRef::Bool, right_span); + ( + IrExpr::Binary { + op, + left: Box::new(left_ir), + right: Box::new(right), + }, + Ty::value(TypeRef::Bool), + ) + } else { + // A contextual result refinement applies after the binary + // operation, not independently to each operand. In + // particular, `let serial: PositiveInt = counter + 1` + // must add while `counter` is still `Nat`; refining the + // zero-valued counter before addition would make a total + // expression fail spuriously. + let (left_ir, left_ty) = self.lower_expr(module, scope, left, None, mode); + let right_expected = left_ty.as_value().filter(|ty| { + !matches!(ty, TypeRef::Int | TypeRef::Nat | TypeRef::PositiveInt) + }); + let (right_ir, right_ty) = + self.lower_expr(module, scope, right, right_expected, mode); + if !compatible(&left_ty, &right_ty) { + self.type_mismatch(&right_ty, &left_ty, right.span); + } + let result = if matches!( + op, + IrBinaryOp::Equal + | IrBinaryOp::NotEqual + | IrBinaryOp::Less + | IrBinaryOp::LessEqual + | IrBinaryOp::Greater + | IrBinaryOp::GreaterEqual + ) { + Ty::value(TypeRef::Bool) + } else { + self.arithmetic_result_type( + scope, + op, + &left_ir, + &left_ty, + &right_ir, + &right_ty, + expression.span, + ) + }; + ( + IrExpr::Binary { + op, + left: Box::new(left_ir), + right: Box::new(right_ir), + }, + result, + ) + } + } + ast::ExprKind::Is { value, pattern } => { + let (value_ir, value_ty) = self.lower_expr(module, scope, value, None, mode); + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + pattern, + value_ty.as_value(), + PatternUse::Condition, + ); + ( + IrExpr::Is { + value: Box::new(value_ir), + pattern, + }, + Ty::value(TypeRef::Bool), + ) + } + ast::ExprKind::Call { callee, arguments } => self.lower_call( + module, + scope, + callee, + arguments, + expected, + mode, + expression.span, + ), + ast::ExprKind::Member { receiver, member } => { + self.lower_member(module, scope, receiver, member, mode) + } + ast::ExprKind::Index { receiver, index } => { + let (value, receiver_ty) = self.lower_expr(module, scope, receiver, None, mode); + let (key_ty, value_ty) = match receiver_ty.as_value() { + Some(TypeRef::Table { key, value }) => { + (key.as_ref().clone(), value.as_ref().clone()) + } + Some(TypeRef::Map { key, value }) => { + let (code, rule, message) = if mode == ExprMode::Projection { + ( + codes::PROJECTION_NOT_TOTAL, + "uhura/projection-partial-index", + "invariants, derives, and observations must be total and fault-free; use `Map.get` and handle the returned `Option` explicitly", + ) + } else { + ( + codes::PARTIAL_OPERATION, + "uhura/partial-index", + "only finite `Table` supports `value[key]`; use `Map.get` for maps", + ) + }; + self.diagnostics + .push(error(code, rule, message, receiver.span)); + (key.as_ref().clone(), value.as_ref().clone()) + } + _ => { + self.diagnostics.push(error( + codes::PARTIAL_OPERATION, + "uhura/partial-index", + "only finite `Table` supports `value[key]`; use `Map.get` for maps", + receiver.span, + )); + (TypeRef::Never, TypeRef::Never) + } + }; + let (key, actual_key) = self.lower_expr(module, scope, index, Some(&key_ty), mode); + self.expect_type(&actual_key, &key_ty, index.span); + ( + IrExpr::Index { + value: Box::new(value), + key: Box::new(key), + }, + Ty::value(value_ty), + ) + } + ast::ExprKind::Update { base, fields } => { + let (base_ir, base_ty) = self.lower_expr(module, scope, base, expected, mode); + let record_fields = base_ty + .as_value() + .and_then(|ty| self.registry.fields(ty)) + .unwrap_or_default(); + let mut lowered = Vec::new(); + for field in fields { + let Some(name) = record_key(&field.key) else { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/record-field-name", + "record update fields must be identifiers", + field.key.span, + )); + continue; + }; + let expected = record_fields + .iter() + .find(|(field, _)| field == &name) + .map(|(_, ty)| ty); + if expected.is_none() { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-record-field", + format!("record update names unknown field `{name}`"), + field.key.span, + )); + } + let (value, actual) = + self.lower_expr(module, scope, &field.value, expected, mode); + if let Some(expected) = expected { + self.expect_type(&actual, expected, field.value.span); + } + lowered.push((name, value)); + } + ( + IrExpr::Update { + value: Box::new(base_ir), + fields: lowered, + }, + base_ty, + ) + } + ast::ExprKind::Lambda { parameters, body } => { + // Lambda argument types are supplied by a total collection + // method. A free-standing lambda has no stable first-order IR + // type and is rejected by the caller if it escapes. + let mut child = scope.child(); + let mut params = Vec::new(); + for pattern in parameters { + params.extend(self.lower_lambda_pattern(&mut child, pattern, None)); + } + let (body, result) = self.lower_expr(module, &child, body, None, mode); + ( + IrExpr::Lambda { + params, + body: Box::new(body), + }, + Ty::Function(Vec::new(), Box::new(result)), + ) + } + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } => { + if reaction_control(expression) && mode == ExprMode::Reaction { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/reaction-value-control", + "reaction control must be lowered in statement position", + expression.span, + )); + } + let (condition_ir, refined) = self.lower_condition(module, scope, condition, mode); + let else_scope = refined_numeric_scope(scope, condition, false, &self.registry); + let (then_value, then_ty) = + self.lower_expr(module, &refined, then_branch, expected, mode); + let (else_value, else_ty) = if let Some(else_branch) = else_branch { + self.lower_expr(module, &else_scope, else_branch, expected, mode) + } else { + ( + IrExpr::Literal { value: Value::Unit }, + Ty::value(TypeRef::Unit), + ) + }; + if !compatible(&then_ty, &else_ty) { + self.type_mismatch(&else_ty, &then_ty, expression.span); + } + ( + IrExpr::If { + condition: Box::new(condition_ir), + then_value: Box::new(then_value), + else_value: Box::new(else_value), + }, + join(&then_ty, &else_ty), + ) + } + ast::ExprKind::Match { subject, arms } => self.lower_value_match( + module, + scope, + subject, + arms, + expected, + mode, + expression.span, + ), + ast::ExprKind::Collect(clauses) => { + let expected_item = match expected { + Some(TypeRef::Seq { value }) => Some(value.as_ref()), + _ => None, + }; + let lowered = clauses + .iter() + .map(|clause| { + let (condition, refined) = + self.lower_condition(module, scope, &clause.condition, mode); + let (value, actual) = + self.lower_expr(module, &refined, &clause.value, expected_item, mode); + if let Some(expected) = expected_item { + self.expect_type(&actual, expected, clause.value.span); + } + (condition, value, actual) + }) + .collect::>(); + let item = lowered + .iter() + .map(|(_, _, ty)| ty.clone()) + .reduce(|left, right| join(&left, &right)) + .and_then(Ty::into_value) + .or_else(|| expected_item.cloned()) + .unwrap_or(TypeRef::Never); + ( + IrExpr::Collect { + clauses: lowered + .into_iter() + .map(|(condition, value, _)| (condition, value)) + .collect(), + }, + Ty::value(TypeRef::Seq { + value: Box::new(item), + }), + ) + } + ast::ExprKind::SetComprehension { + binding, + source: collection, + filters, + value, + } => { + let (source_ir, source_ty) = self.lower_expr(module, scope, collection, None, mode); + let item_ty = collection_item_type(source_ty.as_value()).unwrap_or(TypeRef::Never); + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + binding, + Some(&item_ty), + PatternUse::Binding, + ); + let mut conditions = Vec::new(); + for filter in filters { + let (condition, refined) = self.lower_condition(module, &child, filter, mode); + child = refined; + conditions.push(condition); + } + let expected_item = match expected { + Some(TypeRef::Set { value }) => Some(value.as_ref()), + _ => None, + }; + let (value, result) = self.lower_expr(module, &child, value, expected_item, mode); + let result_type = TypeRef::Set { + value: Box::new( + result + .into_value() + .or_else(|| expected_item.cloned()) + .unwrap_or(TypeRef::Never), + ), + }; + ( + IrExpr::SetComprehension { + pattern, + source: Box::new(source_ir), + conditions, + value: Box::new(value), + result_type: result_type.clone(), + }, + Ty::value(result_type), + ) + } + ast::ExprKind::Block(block) if mode != ExprMode::Reaction => { + self.lower_pure_block(module, scope, block, expected, mode) + } + ast::ExprKind::Block(_) | ast::ExprKind::Finish(_) | ast::ExprKind::Unreachable => { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/reaction-control-in-value", + "reaction block/terminal control is only valid in a handler, transition, or `before commit` body", + expression.span, + )); + (IrExpr::Literal { value: Value::Unit }, Ty::Never) + } + ast::ExprKind::Error => { + self.diagnostics.push(error( + codes::UNSUPPORTED, + "uhura/error-expression", + "cannot lower a recovered parser error expression", + expression.span, + )); + (IrExpr::Literal { value: Value::Unit }, Ty::Unknown) + } + }; + if let Some(expected) = expected { + self.coerce(scope, value, ty, expected, expression.span) + } else { + (value, ty) + } + } + + fn lower_name( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + name: &ast::Name, + expected: Option<&TypeRef>, + ) -> (IrExpr, Ty) { + if let Some(binding) = scope.values.get(&name.value) { + return ( + IrExpr::Name { + name: binding.lowered.clone(), + }, + binding.ty.clone(), + ); + } + if let Ok(constructor) = self.resolve_constructor(scope, &name.value, expected) + && constructor.fields.is_empty() + { + return ( + IrExpr::Constructor { + type_id: constructor.type_id.clone(), + constructor: constructor.name, + fields: Vec::new(), + }, + Ty::value(TypeRef::Named { + id: constructor.type_id, + }), + ); + } + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-name", + format!( + "unknown value or nullary constructor `{}` in `{}`", + name.value, module.id + ), + name.span, + )); + (IrExpr::Literal { value: Value::Unit }, Ty::Unknown) + } + + fn lower_pure_block( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + block: &ast::Block, + expected: Option<&TypeRef>, + mode: ExprMode, + ) -> (IrExpr, Ty) { + self.lower_pure_block_at( + module, + scope, + &block.statements, + 0, + expected, + mode, + block.span, + ) + } + + fn lower_pure_block_at( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + statements: &[ast::Statement], + index: usize, + expected: Option<&TypeRef>, + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + let Some(statement) = statements.get(index) else { + return ( + IrExpr::Literal { value: Value::Unit }, + Ty::value(TypeRef::Unit), + ); + }; + match &statement.value { + ast::StatementKind::Let { + name, + ty: None, + value: + ast::Spanned { + value: ast::ExprKind::Lambda { parameters, body }, + .. + }, + } if name.value.starts_with(PURE_CONTINUATION_LOCAL_PREFIX) => { + self.pure_continuations.insert( + name.value.clone(), + DeferredPureContinuation { + parameters: parameters.clone(), + body: body.as_ref().clone(), + definition_scope: scope.clone(), + parameter_types: None, + result: expected.cloned().unwrap_or(TypeRef::Never), + }, + ); + let result_type = expected.cloned().unwrap_or(TypeRef::Never); + let mut child = scope.child(); + child.bind( + &name.value, + &name.value, + Ty::Function( + vec![Ty::Unknown; parameters.len()], + Box::new(Ty::value(result_type)), + ), + ); + let (rest, result) = self.lower_pure_block_at( + module, + &child, + statements, + index + 1, + expected, + mode, + span, + ); + let lambda = IrExpr::Lambda { + params: parameters + .iter() + .filter_map(|parameter| match ¶meter.value { + ast::PatternKind::Name(name) => Some(name.value.clone()), + _ => None, + }) + .collect(), + body: Box::new(IrExpr::Literal { value: Value::Unit }), + }; + ( + IrExpr::Let { + bindings: vec![(name.value.clone(), lambda)], + value: Box::new(rest), + }, + result, + ) + } + ast::StatementKind::Let { name, ty, value } => { + let annotation = ty.as_ref().map(|ty| self.resolve_type(module, scope, ty)); + let (value_ir, actual) = + self.lower_expr(module, scope, value, annotation.as_ref(), mode); + if let Some(annotation) = &annotation { + self.expect_type(&actual, annotation, value.span); + } + let binding_ty = annotation + .or_else(|| actual.into_value()) + .unwrap_or(TypeRef::Never); + let mut child = scope.child(); + child.bind(&name.value, &name.value, Ty::value(binding_ty)); + let (rest, result) = self.lower_pure_block_at( + module, + &child, + statements, + index + 1, + expected, + mode, + span, + ); + ( + IrExpr::Let { + bindings: vec![(name.value.clone(), value_ir)], + value: Box::new(rest), + }, + result, + ) + } + ast::StatementKind::Expr(value) if index + 1 == statements.len() => { + self.lower_expr(module, scope, value, expected, mode) + } + ast::StatementKind::Expr(value) => { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/discarded-pure-expression", + "only the final expression of a pure block may be unbound", + value.span, + )); + self.lower_pure_block_at(module, scope, statements, index + 1, expected, mode, span) + } + ast::StatementKind::Set { .. } + | ast::StatementKind::Emit(_) + | ast::StatementKind::While { .. } => { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/effect-in-pure-block", + "state writes, commands, and loops are not valid in a pure value block", + statement.span, + )); + (IrExpr::Literal { value: Value::Unit }, Ty::Unknown) + } + } + } + + fn lower_record( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + entries: &[ast::RecordEntry], + expected: Option<&TypeRef>, + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + if let Some(TypeRef::Map { key, value }) = expected { + let mut lowered = Vec::new(); + let mut canonical_keys = BTreeSet::new(); + for entry in entries { + let (key_ir, actual_key) = + self.lower_expr(module, scope, &entry.key, Some(key), mode); + self.expect_type(&actual_key, key, entry.key.span); + match const_eval(&key_ir, &self.program) { + Ok(value) => { + let canonical = value.canonical_bytes(); + if !canonical_keys.insert(canonical) { + self.diagnostics.push(error( + codes::NOT_TOTAL, + "uhura/duplicate-map-key", + "map literal keys must be canonically distinct", + entry.key.span, + )); + } + } + Err(_) => self.diagnostics.push(error( + codes::NOT_TOTAL, + "uhura/dynamic-map-key", + "map literal keys must be compile-time constants; use `Map.from_unique` for dynamic keys", + entry.key.span, + )), + } + let (value_ir, actual_value) = + self.lower_expr(module, scope, &entry.value, Some(value), mode); + self.expect_type(&actual_value, value, entry.value.span); + lowered.push((key_ir, value_ir)); + } + return ( + IrExpr::Map { + entries: lowered, + result_type: expected.cloned().expect("map expected"), + }, + Ty::value(expected.cloned().expect("map expected")), + ); + } + + if let Some(TypeRef::Table { key, value }) = expected { + let mut lowered = Vec::new(); + let mut seen = BTreeSet::new(); + for entry in entries { + let Some(name) = record_key(&entry.key) else { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/table-key", + "Table literal keys must be closed nullary constructors or key spellings", + entry.key.span, + )); + continue; + }; + if !seen.insert(name.clone()) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/duplicate-table-key", + format!("Table key `{name}` is repeated"), + entry.key.span, + )); + } + let (value_ir, actual) = + self.lower_expr(module, scope, &entry.value, Some(value), mode); + self.expect_type(&actual, value, entry.value.span); + lowered.push((name, value_ir)); + } + if let Some(constructors) = self.registry.finite_constructors(key) { + let actual = seen; + if constructors != actual { + self.diagnostics.push(error( + codes::NOT_TOTAL, + "uhura/incomplete-table", + format!( + "Table literal must cover exactly [{}]; found [{}]", + constructors.into_iter().collect::>().join(", "), + actual.into_iter().collect::>().join(", ") + ), + span, + )); + } + } + return ( + IrExpr::Table { + key_type: key.canonical_name(), + entries: lowered, + }, + Ty::value(expected.cloned().expect("table expected")), + ); + } + + let expected_fields = expected.and_then(|ty| self.registry.fields(ty)); + let mut fields = Vec::new(); + let mut types = Vec::new(); + let mut seen = BTreeSet::new(); + for entry in entries { + let Some(name) = record_key(&entry.key) else { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/record-field-name", + "record fields must use identifier keys", + entry.key.span, + )); + continue; + }; + if !seen.insert(name.clone()) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/duplicate-record-field", + format!("record field `{name}` is repeated"), + entry.key.span, + )); + } + let field_expected = expected_fields + .as_ref() + .and_then(|fields| fields.iter().find(|(field, _)| field == &name)) + .map(|(_, ty)| ty); + if expected_fields.is_some() && field_expected.is_none() { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-record-field", + format!("unknown field `{name}` for expected record"), + entry.key.span, + )); + } + let (value, actual) = + self.lower_expr(module, scope, &entry.value, field_expected, mode); + if let Some(expected) = field_expected { + self.expect_type(&actual, expected, entry.value.span); + } + fields.push((name.clone(), value)); + types.push((name, actual.into_value().unwrap_or(TypeRef::Never))); + } + if let Some(expected_fields) = expected_fields { + let missing = expected_fields + .iter() + .map(|(name, _)| name) + .filter(|name| !seen.contains(*name)) + .cloned() + .collect::>(); + if !missing.is_empty() { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/missing-record-field", + format!("record is missing field(s): {}", missing.join(", ")), + span, + )); + } + } + ( + IrExpr::Record { fields }, + Ty::value( + expected + .cloned() + .unwrap_or(TypeRef::Record { fields: types }), + ), + ) + } + + fn lower_condition( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + expression: &ast::Expr, + mode: ExprMode, + ) -> (IrExpr, Scope) { + match &expression.value { + ast::ExprKind::Is { value, pattern } => { + let (value_ir, value_ty) = self.lower_expr(module, scope, value, None, mode); + let mut refined = scope.child(); + let pattern = self.lower_pattern( + module, + &mut refined, + pattern, + value_ty.as_value(), + PatternUse::Condition, + ); + ( + IrExpr::Is { + value: Box::new(value_ir), + pattern, + }, + refined, + ) + } + ast::ExprKind::Binary { left, op, right } if op.value == ast::BinaryOp::And => { + let (left, mut refined) = self.lower_condition(module, scope, left, mode); + let (right, right_refined) = self.lower_condition(module, &refined, right, mode); + refined = right_refined; + ( + IrExpr::Binary { + op: IrBinaryOp::And, + left: Box::new(left), + right: Box::new(right), + }, + refined, + ) + } + _ => { + let (value, ty) = + self.lower_expr(module, scope, expression, Some(&TypeRef::Bool), mode); + self.expect_type(&ty, &TypeRef::Bool, expression.span); + ( + value, + refined_numeric_scope(scope, expression, true, &self.registry), + ) + } + } + } + + fn lower_value_match( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + subject: &ast::Expr, + arms: &[ast::MatchArm], + expected: Option<&TypeRef>, + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + let (value, subject_ty) = self.lower_expr(module, scope, subject, None, mode); + let inferred_expected = expected + .cloned() + .or_else(|| self.probe_match_result(module, scope, arms, subject_ty.as_value(), mode)); + let expected = inferred_expected.as_ref(); + let mut lowered = Vec::new(); + let mut result = Ty::Never; + let mut covered = BTreeSet::new(); + let mut wildcard = false; + for arm in arms { + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + &arm.pattern, + subject_ty.as_value(), + PatternUse::Match, + ); + self.record_pattern_coverage(&pattern, &mut covered, &mut wildcard, arm.pattern.span); + let (body, ty) = self.lower_expr(module, &child, &arm.body, expected, mode); + result = join(&result, &ty); + lowered.push(IrMatchArm { + pattern, + value: body, + }); + } + self.check_exhaustive(subject_ty.as_value(), &covered, wildcard, span); + ( + IrExpr::Match { + value: Box::new(value), + arms: lowered, + }, + result, + ) + } + + fn probe_match_result( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + arms: &[ast::MatchArm], + subject_ty: Option<&TypeRef>, + mode: ExprMode, + ) -> Option { + for arm in arms { + if reaction_control(&arm.body) { + continue; + } + let checkpoint = self.diagnostics.len(); + let mut child = scope.child(); + let _ = self.lower_pattern( + module, + &mut child, + &arm.pattern, + subject_ty, + PatternUse::Match, + ); + let (_, ty) = self.lower_expr(module, &child, &arm.body, None, mode); + self.diagnostics.truncate(checkpoint); + if let Some(ty) = ty.into_value() + && ty != TypeRef::Never + { + return Some(ty); + } + } + None + } + + fn lower_member( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + receiver: &ast::Expr, + member: &ast::Name, + mode: ExprMode, + ) -> (IrExpr, Ty) { + if let Some(mut path) = ast_member_path(receiver) { + path.push(member.value.clone()); + let qualified = path[1..].join("."); + if path.len() > 2 + && let Some(binding) = scope.values.get(&path[0]) + && let Some(TypeRef::Record { fields }) = binding.ty.as_value() + && let Some((_, field_ty)) = fields.iter().find(|(name, _)| name == &qualified) + { + return ( + IrExpr::Field { + value: Box::new(IrExpr::Name { + name: binding.lowered.clone(), + }), + field: qualified, + }, + Ty::value(field_ty.clone()), + ); + } + let constructor_name = path.join("."); + if let Ok(constructor) = self.resolve_constructor(scope, &constructor_name, None) + && constructor.fields.is_empty() + { + return ( + IrExpr::Constructor { + type_id: constructor.type_id.clone(), + constructor: constructor.name, + fields: Vec::new(), + }, + Ty::value(TypeRef::Named { + id: constructor.type_id, + }), + ); + } + } + if let ast::ExprKind::Name(type_name) = &receiver.value + && let Some(ty @ TypeRef::Named { .. }) = scope.types.get(&type_name.value) + && let Ok(constructor) = self.resolve_constructor(scope, &member.value, Some(ty)) + && constructor.fields.is_empty() + { + return ( + IrExpr::Constructor { + type_id: constructor.type_id.clone(), + constructor: constructor.name, + fields: Vec::new(), + }, + Ty::value(TypeRef::Named { + id: constructor.type_id, + }), + ); + } + if let ast::ExprKind::Name(type_name) = &receiver.value + && matches!(type_name.value.as_str(), "Map" | "Set") + && member.value == "empty" + { + return ( + if type_name.value == "Map" { + IrExpr::Map { + entries: Vec::new(), + result_type: TypeRef::Never, + } + } else { + IrExpr::SetComprehension { + pattern: IrPattern::Ignore, + source: Box::new(IrExpr::Seq { values: Vec::new() }), + conditions: Vec::new(), + value: Box::new(IrExpr::Literal { value: Value::Unit }), + result_type: TypeRef::Never, + } + }, + Ty::Unknown, + ); + } + let (value, ty) = self.lower_expr(module, scope, receiver, None, mode); + let result = match ty.as_value() { + Some(TypeRef::Named { .. }) => match self.registry.shape(ty.as_value().expect("value")) + { + Some(TypeShape::Key(underlying)) if member.value == "value" => { + Some(underlying.clone()) + } + _ => self + .registry + .fields(ty.as_value().expect("value")) + .and_then(|fields| { + fields + .into_iter() + .find(|(name, _)| name == &member.value) + .map(|(_, ty)| ty) + }), + }, + Some(TypeRef::Record { fields }) => fields + .iter() + .find(|(name, _)| name == &member.value) + .map(|(_, ty)| ty.clone()), + Some(TypeRef::Seq { value }) => match member.value.as_str() { + "size" => Some(TypeRef::Nat), + "is_empty" | "unique" => Some(TypeRef::Bool), + "uncons" => Some(TypeRef::Option { + value: Box::new(TypeRef::Record { + fields: vec![ + ("head".into(), value.as_ref().clone()), + ( + "tail".into(), + TypeRef::Seq { + value: value.clone(), + }, + ), + ], + }), + }), + _ => None, + }, + Some(TypeRef::Map { key, value }) => match member.value.as_str() { + "size" => Some(TypeRef::Nat), + "is_empty" => Some(TypeRef::Bool), + "entries" => Some(TypeRef::FiniteView { + value: Box::new(TypeRef::Record { + fields: vec![ + ("key".into(), key.as_ref().clone()), + ("value".into(), value.as_ref().clone()), + ], + }), + }), + "entries_by_key" => Some(TypeRef::Seq { + value: Box::new(TypeRef::Tuple { + values: vec![key.as_ref().clone(), value.as_ref().clone()], + }), + }), + "values" => Some(TypeRef::FiniteView { + value: value.clone(), + }), + _ => None, + }, + Some(TypeRef::Table { value, .. }) if member.value == "values" => Some(TypeRef::Seq { + value: value.clone(), + }), + Some(TypeRef::Set { .. }) if member.value == "size" => Some(TypeRef::Nat), + Some(TypeRef::Set { .. }) if member.value == "is_empty" => Some(TypeRef::Bool), + Some(TypeRef::Text) if member.value == "is_empty" => Some(TypeRef::Bool), + _ => None, + }; + if let Some(result) = result { + let ir = if matches!( + member.value.as_str(), + "entries" | "entries_by_key" | "values" | "uncons" | "unique" + ) { + IrExpr::Method { + value: Box::new(value), + method: member.value.clone(), + args: Vec::new(), + result_type: result.clone(), + } + } else { + IrExpr::Field { + value: Box::new(value), + field: member.value.clone(), + } + }; + (ir, Ty::value(result)) + } else { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-member", + format!( + "type `{}` has no total member `{}`", + ty.display(), + member.value + ), + member.span, + )); + ( + IrExpr::Field { + value: Box::new(value), + field: member.value.clone(), + }, + Ty::Unknown, + ) + } + } + + fn lower_call( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + callee: &ast::Expr, + arguments: &[ast::Expr], + expected: Option<&TypeRef>, + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + if let ast::ExprKind::Name(name) = &callee.value + && name.value.starts_with(PURE_CONTINUATION_LOCAL_PREFIX) + && self.pure_continuations.contains_key(&name.value) + { + return self.lower_pure_continuation_call( + module, + scope, + &name.value, + arguments, + mode, + span, + ); + } + if let ast::ExprKind::Name(name) = &callee.value { + if name.value == "finite" { + if arguments.len() != 1 { + self.arity("finite", 1, arguments.len(), span); + } + let (argument, actual) = arguments.first().map_or( + (IrExpr::Literal { value: Value::Unit }, Ty::Unknown), + |argument| { + self.lower_expr(module, scope, argument, Some(&TypeRef::Decimal), mode) + }, + ); + self.expect_type(&actual, &TypeRef::Decimal, span); + return ( + IrExpr::Constructor { + type_id: TypeRef::BoundaryNumber.canonical_name(), + constructor: "finite".into(), + fields: vec![(Some("value".into()), argument)], + }, + Ty::value(TypeRef::BoundaryNumber), + ); + } + if name.value == "some" && expected.is_none() && arguments.len() == 1 { + let (argument, ty) = self.lower_expr(module, scope, &arguments[0], None, mode); + if let Some(inner) = ty.into_value() { + let option = TypeRef::Option { + value: Box::new(inner.clone()), + }; + return ( + IrExpr::Constructor { + type_id: option.canonical_name(), + constructor: "some".into(), + fields: vec![(Some("value".into()), argument)], + }, + Ty::value(option), + ); + } + } + if let Some((id, params, result)) = scope.functions.get(&name.value) { + return self + .lower_resolved_call(module, scope, id, params, result, arguments, mode, span); + } + if matches!(name.value.as_str(), "min" | "max") { + let first_expected = expected; + let lowered = arguments + .iter() + .map(|argument| self.lower_expr(module, scope, argument, first_expected, mode)) + .collect::>(); + if lowered.len() != 2 { + self.arity(name.value.as_str(), 2, lowered.len(), span); + } + let ty = lowered + .first() + .map(|(_, ty)| ty.clone()) + .unwrap_or(Ty::Unknown); + return ( + IrExpr::Call { + function: name.value.clone(), + args: lowered.into_iter().map(|(value, _)| value).collect(), + result_type: ty.as_value().cloned().unwrap_or(TypeRef::Never), + }, + ty, + ); + } + if let Some(TypeRef::Named { id }) = scope.types.get(&name.value) { + let underlying = match self.registry.shape(&TypeRef::Named { id: id.clone() }) { + Some(TypeShape::Key(underlying)) => Some(underlying.clone()), + _ => None, + }; + if let Some(underlying) = underlying { + if arguments.len() != 1 { + self.arity(&name.value, 1, arguments.len(), span); + } + let argument = arguments + .first() + .map(|argument| { + self.lower_expr(module, scope, argument, Some(&underlying), mode) + .0 + }) + .unwrap_or(IrExpr::Literal { value: Value::Unit }); + return ( + IrExpr::Key { + type_id: id.clone(), + value: Box::new(argument), + }, + Ty::value(TypeRef::Named { id: id.clone() }), + ); + } + } + if let Ok(constructor) = self.resolve_constructor(scope, &name.value, expected) { + return self.lower_constructor_call( + module, + scope, + constructor, + arguments, + mode, + span, + ); + } + if name.value == "routes" { + // The route declaration itself is compiled by the const pass. + let args = arguments + .iter() + .map(|argument| self.lower_expr(module, scope, argument, None, mode).0) + .collect(); + return ( + IrExpr::Call { + function: "routes".into(), + args, + result_type: expected.cloned().unwrap_or(TypeRef::Never), + }, + Ty::value(expected.cloned().unwrap_or(TypeRef::Never)), + ); + } + } + + if let ast::ExprKind::Member { receiver, member } = &callee.value { + if let Some(path) = ast_member_path(callee) { + let qualified = path.join("."); + if let Ok(constructor) = self.resolve_constructor(scope, &qualified, expected) { + return self.lower_constructor_call( + module, + scope, + constructor, + arguments, + mode, + span, + ); + } + } + if let ast::ExprKind::Name(type_name) = &receiver.value { + if let Some(ty @ TypeRef::Named { .. }) = scope.types.get(&type_name.value) + && let Ok(constructor) = + self.resolve_constructor(scope, &member.value, Some(ty)) + { + return self.lower_constructor_call( + module, + scope, + constructor, + arguments, + mode, + span, + ); + } + let function = format!("{}.{}", type_name.value, member.value); + if member.value == "fixture" && mode == ExprMode::Evidence { + let args = arguments + .iter() + .map(|argument| self.lower_expr(module, scope, argument, None, mode).0) + .collect(); + return ( + IrExpr::Call { + function, + args, + result_type: TypeRef::Never, + }, + Ty::Unknown, + ); + } + if matches!( + function.as_str(), + "Int.from" + | "Ratio.from" + | "NonEmpty.from" + | "Map.from_unique" + | "Set.from_unique" + ) { + let arg_expected = match function.as_str() { + "Int.from" | "Ratio.from" => Some(TypeRef::BoundaryNumber), + _ => None, + }; + let lowered = arguments + .iter() + .map(|argument| { + self.lower_expr(module, scope, argument, arg_expected.as_ref(), mode) + }) + .collect::>(); + let inferred = match function.as_str() { + "Int.from" => TypeRef::Option { + value: Box::new(TypeRef::Int), + }, + "Ratio.from" => TypeRef::Option { + value: Box::new(TypeRef::Ratio), + }, + "NonEmpty.from" => { + match lowered.first().and_then(|(_, ty)| ty.as_value()) { + Some(TypeRef::Seq { value }) => TypeRef::Option { + value: Box::new(TypeRef::NonEmpty { + value: value.clone(), + }), + }, + _ => TypeRef::Never, + } + } + "Map.from_unique" => { + match lowered.first().and_then(|(_, ty)| ty.as_value()) { + Some(TypeRef::Seq { value }) => match value.as_ref() { + TypeRef::Tuple { values } if values.len() == 2 => { + TypeRef::Option { + value: Box::new(TypeRef::Map { + key: Box::new(values[0].clone()), + value: Box::new(values[1].clone()), + }), + } + } + _ => TypeRef::Never, + }, + _ => TypeRef::Never, + } + } + "Set.from_unique" => { + match lowered.first().and_then(|(_, ty)| ty.as_value()) { + Some(TypeRef::Seq { value }) => TypeRef::Option { + value: Box::new(TypeRef::Set { + value: value.clone(), + }), + }, + _ => TypeRef::Never, + } + } + _ => TypeRef::Never, + }; + let result = expected.cloned().unwrap_or(inferred); + return ( + IrExpr::Call { + function, + args: lowered.into_iter().map(|(value, _)| value).collect(), + result_type: result.clone(), + }, + Ty::value(result), + ); + } + } + if let ast::ExprKind::Name(port) = &receiver.value { + let qualified = format!("{}.{}", port.value, member.value); + if let Some(constructor) = scope.port_send.get(&qualified).or_else(|| { + (mode == ExprMode::Evidence) + .then(|| scope.port_receive.get(&qualified)) + .flatten() + }) { + return self.lower_constructor_call( + module, + scope, + constructor.clone(), + arguments, + mode, + span, + ); + } + } + let receiver_expected = (member.value == "from_options") + .then_some(expected) + .flatten() + .and_then(|expected| match expected { + TypeRef::Seq { value } => Some(TypeRef::Seq { + value: Box::new(TypeRef::Option { + value: value.clone(), + }), + }), + _ => None, + }); + let (receiver_ir, receiver_ty) = + self.lower_expr(module, scope, receiver, receiver_expected.as_ref(), mode); + return self.lower_method_call( + module, + scope, + receiver_ir, + receiver_ty, + &member.value, + arguments, + expected, + mode, + span, + ); + } + + let (function, function_ty) = self.lower_expr(module, scope, callee, None, mode); + let args = arguments + .iter() + .map(|argument| self.lower_expr(module, scope, argument, None, mode).0) + .collect::>(); + match function_ty { + Ty::Function(_, result) => ( + IrExpr::Invoke { + function: Box::new(function), + args, + }, + *result, + ), + _ => { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/not-callable", + format!("`{}` is not callable", function_ty.display()), + callee.span, + )); + ( + IrExpr::Invoke { + function: Box::new(function), + args, + }, + Ty::Unknown, + ) + } + } + } + + fn lower_pure_continuation_call( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + name: &str, + arguments: &[ast::Expr], + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + let signature = self + .pure_continuations + .get(name) + .map(|continuation| { + ( + continuation.parameters.len(), + continuation.parameter_types.clone(), + continuation.result.clone(), + ) + }) + .expect("generated pure continuation is registered before invocation"); + if signature.0 != arguments.len() { + self.arity(name, signature.0, arguments.len(), span); + } + let lowered = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + let expected = signature + .1 + .as_ref() + .and_then(|parameters| parameters.get(index)) + .and_then(Ty::as_value); + let (value, actual) = self.lower_expr(module, scope, argument, expected, mode); + if let Some(expected) = expected { + self.expect_type(&actual, expected, argument.span); + } + (value, actual) + }) + .collect::>(); + let actual_types = lowered.iter().map(|(_, ty)| ty.clone()).collect::>(); + let continuation = self + .pure_continuations + .get_mut(name) + .expect("generated pure continuation remains registered"); + continuation.parameter_types = Some(match continuation.parameter_types.take() { + Some(previous) => previous + .into_iter() + .zip(actual_types) + .map(|(left, right)| join(&left, &right)) + .collect(), + None => actual_types, + }); + ( + IrExpr::Invoke { + function: Box::new(IrExpr::Name { name: name.into() }), + args: lowered.into_iter().map(|(value, _)| value).collect(), + }, + Ty::value(signature.2), + ) + } + + fn drain_pure_continuations( + &mut self, + module: &ModuleEnv<'_>, + mode: ExprMode, + expression: IrExpr, + ) -> IrExpr { + if self.pure_continuations.is_empty() { + return expression; + } + + self.draining_pure_continuations = true; + let mut compiled = BTreeMap::new(); + loop { + let Some(name) = self + .pure_continuations + .keys() + .find(|name| !compiled.contains_key(*name)) + .cloned() + else { + break; + }; + let deferred = self.pure_continuations[&name].clone(); + let parameter_types = deferred + .parameter_types + .clone() + .unwrap_or_else(|| vec![Ty::Unknown; deferred.parameters.len()]); + let mut continuation_scope = deferred.definition_scope.clone(); + let mut parameter_names = Vec::new(); + for (index, parameter) in deferred.parameters.iter().enumerate() { + let actual = parameter_types.get(index).cloned().unwrap_or(Ty::Unknown); + match ¶meter.value { + ast::PatternKind::Name(parameter) => { + continuation_scope.bind(¶meter.value, ¶meter.value, actual); + parameter_names.push(parameter.value.clone()); + } + _ => { + self.diagnostics.push(error( + codes::UNSUPPORTED, + "uhura/internal-pure-continuation-pattern", + "compiler-generated pure continuations require named parameters", + parameter.span, + )); + parameter_names.push(format!("_continuation_{}", parameter.span.start)); + } + } + } + let (body, actual) = self.lower_expr( + module, + &continuation_scope, + &deferred.body, + Some(&deferred.result), + mode, + ); + self.expect_type(&actual, &deferred.result, deferred.body.span); + compiled.insert( + name, + CompiledPureContinuation { + lambda: IrExpr::Lambda { + params: parameter_names, + body: Box::new(body), + }, + parameters: parameter_types, + result: Ty::value(deferred.result), + }, + ); + } + self.pure_continuations.clear(); + self.draining_pure_continuations = false; + + let compiled = compiled + .into_iter() + .map(|(name, continuation)| { + debug_assert_eq!(continuation.parameters.len(), 1); + debug_assert!(matches!(continuation.result, Ty::Value(_))); + (name, continuation.lambda) + }) + .collect(); + materialize_pure_continuation_bindings(expression, &compiled, &mut BTreeMap::new()) + } + + fn lower_resolved_call( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + id: &str, + params: &[TypeRef], + result: &TypeRef, + arguments: &[ast::Expr], + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + if params.len() != arguments.len() { + self.arity(id, params.len(), arguments.len(), span); + } + let args = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + let expected = params.get(index); + let (value, actual) = self.lower_expr(module, scope, argument, expected, mode); + if let Some(expected) = expected { + self.expect_type(&actual, expected, argument.span); + } + value + }) + .collect(); + ( + IrExpr::Call { + function: id.into(), + args, + result_type: result.clone(), + }, + Ty::value(result.clone()), + ) + } + + fn lower_constructor_call( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + constructor: ConstructorInfo, + arguments: &[ast::Expr], + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + if constructor.fields.len() != arguments.len() { + self.arity( + &constructor.name, + constructor.fields.len(), + arguments.len(), + span, + ); + } + let fields = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + let expected = constructor.fields.get(index).map(|(_, ty)| ty); + let (value, actual) = self.lower_expr(module, scope, argument, expected, mode); + if let Some(expected) = expected { + self.expect_type(&actual, expected, argument.span); + } + ( + constructor + .fields + .get(index) + .and_then(|(name, _)| name.clone()), + value, + ) + }) + .collect(); + ( + IrExpr::Constructor { + type_id: constructor.type_id.clone(), + constructor: constructor.name, + fields, + }, + Ty::value(TypeRef::Named { + id: constructor.type_id, + }), + ) + } + + fn lower_method_call( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + receiver: IrExpr, + receiver_ty: Ty, + method: &str, + arguments: &[ast::Expr], + expected: Option<&TypeRef>, + mode: ExprMode, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + let receiver_value = receiver_ty.as_value().cloned(); + let (params, fixed_result, lambda_item) = match (receiver_value.as_ref(), method) { + (Some(TypeRef::Seq { value }), "from_options") + if matches!(value.as_ref(), TypeRef::Option { .. }) => + { + let TypeRef::Option { value } = value.as_ref() else { + unreachable!("guard proves Option item") + }; + ( + Vec::new(), + TypeRef::Seq { + value: value.clone(), + }, + None, + ) + } + (Some(TypeRef::Seq { value }), "append" | "without" | "contains") => ( + vec![value.as_ref().clone()], + if method == "contains" { + TypeRef::Bool + } else { + receiver_value.clone().unwrap() + }, + None, + ), + (Some(TypeRef::Map { key, value }), "get") => ( + vec![key.as_ref().clone()], + TypeRef::Option { + value: value.clone(), + }, + None, + ), + (Some(TypeRef::Map { key, value }), "put") => ( + vec![key.as_ref().clone(), value.as_ref().clone()], + receiver_value.clone().unwrap(), + None, + ), + (Some(TypeRef::Map { key, .. }), "remove") => ( + vec![key.as_ref().clone()], + receiver_value.clone().unwrap(), + None, + ), + (Some(TypeRef::Set { value }), "add" | "remove" | "contains") => ( + vec![value.as_ref().clone()], + if method == "contains" { + TypeRef::Bool + } else { + receiver_value.clone().unwrap() + }, + None, + ), + (Some(TypeRef::Table { key, value }), "set") => ( + vec![key.as_ref().clone(), value.as_ref().clone()], + receiver_value.clone().unwrap(), + None, + ), + ( + Some(ty @ (TypeRef::Seq { .. } | TypeRef::NonEmpty { .. })), + "all" | "any" | "count" | "map" | "filter" | "try_map", + ) + | ( + Some( + ty @ (TypeRef::Seq { .. } + | TypeRef::NonEmpty { .. } + | TypeRef::Set { .. } + | TypeRef::FiniteView { .. }), + ), + "filter_map", + ) + | (Some(ty @ TypeRef::FiniteView { .. }), "all" | "any" | "count") + | (Some(ty @ TypeRef::Map { .. }), "try_map_values") => { + let item = collection_item_type(Some(ty)).unwrap_or(TypeRef::Never); + let result = match method { + "all" | "any" => TypeRef::Bool, + "count" => TypeRef::Nat, + _ => TypeRef::Never, + }; + (Vec::new(), result, Some(item)) + } + _ => { + self.diagnostics.push(error( + codes::PARTIAL_OPERATION, + "uhura/unknown-total-method", + format!( + "`{}` has no supported total method `{method}`", + receiver_ty.display() + ), + span, + )); + (Vec::new(), TypeRef::Never, None) + } + }; + let (args, result) = if let Some(item) = lambda_item { + if arguments.len() != 1 { + self.arity(method, 1, arguments.len(), span); + } + let expected_lambda = expected + .and_then(|expected| match (method, expected) { + ("map", TypeRef::Seq { value }) => Some(value.as_ref().clone()), + ("try_map", TypeRef::Option { value }) => match value.as_ref() { + TypeRef::Seq { value } => Some(TypeRef::Option { + value: value.clone(), + }), + _ => None, + }, + ("try_map_values", TypeRef::Option { value }) => match value.as_ref() { + TypeRef::Map { value, .. } => Some(TypeRef::Option { + value: value.clone(), + }), + _ => None, + }, + ("filter_map", TypeRef::Set { value }) => Some(TypeRef::Option { + value: value.clone(), + }), + _ => None, + }) + .or_else(|| { + matches!(method, "all" | "any" | "count" | "filter").then_some(TypeRef::Bool) + }); + let (lambda, lambda_ty) = arguments.first().map_or( + ( + IrExpr::Lambda { + params: Vec::new(), + body: Box::new(IrExpr::Literal { value: Value::Unit }), + }, + Ty::Unknown, + ), + |argument| { + self.lower_typed_lambda( + module, + scope, + argument, + &item, + expected_lambda.as_ref(), + mode, + ) + }, + ); + let lambda_value = lambda_ty.into_value().unwrap_or(TypeRef::Never); + if method == "filter_map" && !matches!(&lambda_value, TypeRef::Option { .. }) { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/filter-map-option", + "`filter_map` binder must return `Option`", + arguments.first().map_or(span, |argument| argument.span), + )); + } + let inferred = match method { + "all" | "any" | "count" => fixed_result.clone(), + "map" => TypeRef::Seq { + value: Box::new(lambda_value), + }, + "filter" => TypeRef::Seq { + value: Box::new(item.clone()), + }, + "try_map" => { + let inner = match lambda_value { + TypeRef::Option { value } => value, + _ => Box::new(TypeRef::Never), + }; + TypeRef::Option { + value: Box::new(TypeRef::Seq { value: inner }), + } + } + "try_map_values" => { + let inner = match lambda_value { + TypeRef::Option { value } => value, + _ => Box::new(TypeRef::Never), + }; + match receiver_value.as_ref() { + Some(TypeRef::Map { key, .. }) => TypeRef::Option { + value: Box::new(TypeRef::Map { + key: key.clone(), + value: inner, + }), + }, + _ => TypeRef::Never, + } + } + "filter_map" => { + let inner = match lambda_value { + TypeRef::Option { value } => value, + _ => Box::new(TypeRef::Never), + }; + TypeRef::Set { value: inner } + } + _ => TypeRef::Never, + }; + (vec![lambda], expected.cloned().unwrap_or(inferred)) + } else { + if params.len() != arguments.len() { + self.arity(method, params.len(), arguments.len(), span); + } + let args = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + let expected = params.get(index); + let (value, actual) = self.lower_expr(module, scope, argument, expected, mode); + if let Some(expected) = expected { + self.expect_type(&actual, expected, argument.span); + } + value + }) + .collect(); + (args, fixed_result) + }; + ( + IrExpr::Method { + value: Box::new(receiver), + method: method.into(), + args, + result_type: result.clone(), + }, + Ty::value(result), + ) + } + + fn lower_typed_lambda( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + expression: &ast::Expr, + item: &TypeRef, + expected_result: Option<&TypeRef>, + mode: ExprMode, + ) -> (IrExpr, Ty) { + let ast::ExprKind::Lambda { parameters, body } = &expression.value else { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/expected-lambda", + "total collection operations require an inline pure lambda", + expression.span, + )); + return ( + IrExpr::Lambda { + params: Vec::new(), + body: Box::new(IrExpr::Literal { value: Value::Unit }), + }, + Ty::Unknown, + ); + }; + let param_types = match item { + TypeRef::Tuple { values } if parameters.len() == values.len() => values.clone(), + _ => vec![item.clone()], + }; + if parameters.len() != param_types.len() { + self.arity( + "lambda", + param_types.len(), + parameters.len(), + expression.span, + ); + } + let mut child = scope.child(); + let mut names = Vec::new(); + for (index, pattern) in parameters.iter().enumerate() { + names.extend(self.lower_lambda_pattern(&mut child, pattern, param_types.get(index))); + } + let (body, result) = self.lower_expr(module, &child, body, expected_result, mode); + ( + IrExpr::Lambda { + params: names, + body: Box::new(body), + }, + result, + ) + } + + fn lower_lambda_pattern( + &mut self, + scope: &mut Scope, + pattern: &ast::Pattern, + expected: Option<&TypeRef>, + ) -> Vec { + match &pattern.value { + ast::PatternKind::Name(name) => { + scope.bind( + &name.value, + &name.value, + Ty::value(expected.cloned().unwrap_or(TypeRef::Never)), + ); + vec![name.value.clone()] + } + ast::PatternKind::Wildcard => vec![format!("_lambda_{}", pattern.span.start)], + _ => { + self.diagnostics.push(error( + codes::UNSUPPORTED, + "uhura/lambda-pattern", + "Uhura lambda parameters must be names or `_`; tuple collection entries use multiple named parameters", + pattern.span, + )); + vec![format!("_lambda_{}", pattern.span.start)] + } + } + } + + fn resolve_constructor( + &self, + scope: &Scope, + name: &str, + expected: Option<&TypeRef>, + ) -> Result> { + if let Some(expected) = expected { + let local = scope + .constructors + .get(name) + .into_iter() + .flatten() + .filter(|constructor| { + constructor.type_id == expected.canonical_name() + || matches!(expected, TypeRef::Named { id } if id == &constructor.type_id) + }) + .cloned() + .collect::>(); + if local.len() == 1 { + return Ok(local[0].clone()); + } + if let Ok(value) = self.registry.constructor(name, Some(expected)) { + return Ok(value); + } + return Err(local); + } + let local = scope.constructors.get(name).cloned().unwrap_or_default(); + if local.len() == 1 { + Ok(local[0].clone()) + } else if local.is_empty() { + self.registry.constructor(name, expected) + } else { + Err(local) + } + } + + fn arithmetic_result_type( + &mut self, + scope: &Scope, + op: IrBinaryOp, + left_expression: &IrExpr, + left: &Ty, + right_expression: &IrExpr, + right: &Ty, + span: ast::SourceSpan, + ) -> Ty { + let (Some(left), Some(right)) = (left.as_value(), right.as_value()) else { + return Ty::Unknown; + }; + let result = match (op, left, right) { + ( + IrBinaryOp::Add | IrBinaryOp::Subtract | IrBinaryOp::Multiply, + TypeRef::Int | TypeRef::Nat | TypeRef::PositiveInt, + TypeRef::Int | TypeRef::Nat | TypeRef::PositiveInt, + ) => Some(TypeRef::Int), + ( + IrBinaryOp::Add | IrBinaryOp::Subtract | IrBinaryOp::Multiply, + TypeRef::Decimal, + TypeRef::Decimal, + ) => Some(TypeRef::Decimal), + ( + IrBinaryOp::Add | IrBinaryOp::Subtract | IrBinaryOp::Multiply, + TypeRef::Ratio, + TypeRef::Ratio, + ) => { + if !ratio_arithmetic_proven(scope, op, left_expression, right_expression) { + self.diagnostics.push(error( + codes::INVALID_REFINEMENT, + "uhura/ratio-arithmetic", + match op { + IrBinaryOp::Add => { + "cannot prove that `Ratio` addition remains at most 1 from active path facts" + } + IrBinaryOp::Subtract => { + "cannot prove that `Ratio` subtraction remains non-negative from active path facts" + } + IrBinaryOp::Multiply => { + unreachable!("Ratio multiplication is closed over [0,1]") + } + _ => unreachable!("match admits only Ratio arithmetic"), + }, + span, + )); + } + Some(TypeRef::Ratio) + } + _ => None, + }; + result.map_or_else( + || { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/invalid-arithmetic-operands", + format!( + "operator requires compatible exact numeric operands; found `{}` and `{}`", + left.canonical_name(), + right.canonical_name() + ), + span, + )); + Ty::Unknown + }, + Ty::value, + ) + } + + fn coerce( + &mut self, + scope: &Scope, + mut expression: IrExpr, + actual: Ty, + expected: &TypeRef, + span: ast::SourceSpan, + ) -> (IrExpr, Ty) { + match &mut expression { + IrExpr::Map { result_type, .. } + if *result_type == TypeRef::Never && matches!(expected, TypeRef::Map { .. }) => + { + *result_type = expected.clone(); + } + IrExpr::SetComprehension { result_type, .. } + if *result_type == TypeRef::Never && matches!(expected, TypeRef::Set { .. }) => + { + *result_type = expected.clone(); + } + _ => {} + } + if self.types_compatible(&actual, &Ty::value(expected.clone())) { + if let Some(actual_ref) = actual.as_value() { + if actual_ref == expected { + return (expression, Ty::value(expected.clone())); + } + let (function, needs_proof) = match (actual_ref, expected) { + (TypeRef::PositiveInt, TypeRef::Nat) => (Some("__coerce_nat"), false), + (TypeRef::PositiveInt | TypeRef::Nat, TypeRef::Int) => { + (Some("__coerce_int"), false) + } + (TypeRef::Int, TypeRef::Nat) => (Some("__coerce_nat"), true), + (TypeRef::Int | TypeRef::Nat, TypeRef::PositiveInt) => { + (Some("__coerce_positive"), true) + } + _ => (None, false), + }; + if let Some(function) = function { + if needs_proof && !self.integer_refinement_proven(scope, &expression, expected) + { + self.diagnostics.push(error( + codes::INVALID_REFINEMENT, + "uhura/unproved-integer-refinement", + format!( + "cannot prove that `{}` satisfies `{}` from its type, active path facts, and invariants", + actual_ref.canonical_name(), + expected.canonical_name() + ), + span, + )); + return (expression, actual); + } + return ( + IrExpr::Call { + function: function.into(), + args: vec![expression], + result_type: expected.clone(), + }, + Ty::value(expected.clone()), + ); + } + } + (expression, Ty::value(expected.clone())) + } else { + self.type_mismatch(&actual, &Ty::value(expected.clone()), span); + (expression, actual) + } + } + + fn integer_refinement_proven( + &self, + scope: &Scope, + expression: &IrExpr, + expected: &TypeRef, + ) -> bool { + let minimum = self.proved_integer_lower_bound(scope, expression); + match expected { + TypeRef::Nat => { + minimum.is_some_and(|value| value >= 0) + || integer_difference_non_negative(expression, scope) + } + TypeRef::PositiveInt => minimum.is_some_and(|value| value >= 1), + _ => true, + } + } + + fn proved_integer_lower_bound(&self, scope: &Scope, expression: &IrExpr) -> Option { + integer_lower_bound(expression, scope) + .or_else(|| { + ir_numeric_path(expression) + .and_then(|path| static_integer_minimum_for_path(&self.registry, scope, &path)) + }) + .or_else(|| match expression { + IrExpr::Binary { + op: IrBinaryOp::Add, + left, + right, + } => self + .proved_integer_lower_bound(scope, left)? + .checked_add(self.proved_integer_lower_bound(scope, right)?), + IrExpr::Binary { + op: IrBinaryOp::Multiply, + left, + right, + } => { + let left = self.proved_integer_lower_bound(scope, left)?; + let right = self.proved_integer_lower_bound(scope, right)?; + (left >= 0 && right >= 0).then(|| left.saturating_mul(right)) + } + _ => None, + }) + } + + fn expect_type(&mut self, actual: &Ty, expected: &TypeRef, span: ast::SourceSpan) { + if !self.types_compatible(actual, &Ty::value(expected.clone())) { + self.type_mismatch(actual, &Ty::value(expected.clone()), span); + } + } + + fn types_compatible(&self, actual: &Ty, expected: &Ty) -> bool { + match (actual, expected) { + (Ty::Value(actual), Ty::Value(expected)) => { + self.value_types_compatible(actual, expected) + } + _ => compatible(actual, expected), + } + } + + fn value_types_compatible(&self, actual: &TypeRef, expected: &TypeRef) -> bool { + if super::types::value_compatible(actual, expected) { + return true; + } + let actual_shape = match actual { + TypeRef::Named { .. } => self.registry.shape(actual), + _ => None, + }; + let expected_shape = match expected { + TypeRef::Named { .. } => self.registry.shape(expected), + _ => None, + }; + match (actual, expected) { + (TypeRef::Record { fields: actual }, TypeRef::Named { .. }) => { + matches!(expected_shape, Some(TypeShape::Record(expected)) if self.record_types_compatible(actual, expected)) + } + (TypeRef::Named { .. }, TypeRef::Record { fields: expected }) => { + matches!(actual_shape, Some(TypeShape::Record(actual)) if self.record_types_compatible(actual, expected)) + } + (TypeRef::Named { .. }, TypeRef::Named { .. }) => { + match (actual_shape, expected_shape) { + (Some(TypeShape::Alias(actual)), _) => { + self.value_types_compatible(actual, expected) + } + (_, Some(TypeShape::Alias(expected))) => { + self.value_types_compatible(actual, expected) + } + (Some(TypeShape::Record(actual)), Some(TypeShape::Record(expected))) => { + self.record_types_compatible(actual, expected) + } + _ => false, + } + } + (TypeRef::Option { value: actual }, TypeRef::Option { value: expected }) + | (TypeRef::Seq { value: actual }, TypeRef::Seq { value: expected }) + | (TypeRef::NonEmpty { value: actual }, TypeRef::NonEmpty { value: expected }) + | (TypeRef::Set { value: actual }, TypeRef::Set { value: expected }) + | (TypeRef::FiniteView { value: actual }, TypeRef::FiniteView { value: expected }) => { + self.value_types_compatible(actual, expected) + } + ( + TypeRef::Map { + key: actual_key, + value: actual_value, + }, + TypeRef::Map { + key: expected_key, + value: expected_value, + }, + ) + | ( + TypeRef::Table { + key: actual_key, + value: actual_value, + }, + TypeRef::Table { + key: expected_key, + value: expected_value, + }, + ) => { + self.value_types_compatible(actual_key, expected_key) + && self.value_types_compatible(actual_value, expected_value) + } + (TypeRef::Tuple { values: actual }, TypeRef::Tuple { values: expected }) => { + actual.len() == expected.len() + && actual + .iter() + .zip(expected) + .all(|(actual, expected)| self.value_types_compatible(actual, expected)) + } + _ => false, + } + } + + fn record_types_compatible( + &self, + actual: &[(String, TypeRef)], + expected: &[(String, TypeRef)], + ) -> bool { + actual.len() == expected.len() + && actual.iter().zip(expected).all( + |((actual_name, actual), (expected_name, expected))| { + actual_name == expected_name && self.value_types_compatible(actual, expected) + }, + ) + } + + fn type_mismatch(&mut self, actual: &Ty, expected: &Ty, span: ast::SourceSpan) { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/type-mismatch", + format!( + "expected `{}`, found `{}`", + expected.display(), + actual.display() + ), + span, + )); + } + + fn arity(&mut self, name: &str, expected: usize, actual: usize, span: ast::SourceSpan) { + self.diagnostics.push(error( + codes::ARITY, + "uhura/arity", + format!("`{name}` expects {expected} argument(s), found {actual}"), + span, + )); + } + + fn check_exhaustive( + &mut self, + subject: Option<&TypeRef>, + covered: &BTreeSet, + wildcard: bool, + span: ast::SourceSpan, + ) { + if wildcard { + return; + } + if matches!(subject, Some(TypeRef::Never)) { + return; + } + let patterns = covered + .iter() + .filter_map(|item| item.strip_prefix("pattern:")) + .filter_map(|json| serde_json::from_str::(json).ok()) + .collect::>(); + let exhaustive = subject.is_some_and(|ty| self.patterns_cover_type(ty, &patterns)); + if !exhaustive { + self.diagnostics.push(error( + codes::NOT_EXHAUSTIVE, + "uhura/non-exhaustive-match", + "match is not exhaustive; cover every closed case or add a final wildcard arm", + span, + )); + } + } + + fn patterns_cover_type(&self, ty: &TypeRef, patterns: &[IrPattern]) -> bool { + if patterns + .iter() + .any(|pattern| self.pattern_covers_type(ty, pattern)) + { + return true; + } + match ty { + TypeRef::Bool => { + let mut seen = BTreeSet::new(); + for pattern in flatten_alternatives(patterns) { + if let IrPattern::Literal { + value: Value::Bool(value), + } = pattern + { + seen.insert(*value); + } + } + seen.len() == 2 + } + TypeRef::Option { .. } | TypeRef::Named { .. } => { + let constructors = self.registry.constructors_for(ty); + !constructors.is_empty() + && constructors.iter().all(|constructor| { + let rows = flatten_alternatives(patterns) + .into_iter() + .filter_map(|pattern| match pattern { + IrPattern::Constructor { + type_id, + constructor: name, + fields, + } if type_id == &constructor.type_id + && name == &constructor.name => + { + Some(fields.as_slice()) + } + _ => None, + }) + .collect::>(); + self.pattern_rows_cover_product( + &constructor + .fields + .iter() + .map(|(_, ty)| ty.clone()) + .collect::>(), + &rows, + ) + }) + } + _ => false, + } + } + + fn pattern_rows_cover_product(&self, types: &[TypeRef], rows: &[&[IrPattern]]) -> bool { + if rows.is_empty() { + return false; + } + if types.is_empty() { + return true; + } + if rows + .iter() + .any(|row| row.len() == types.len() && row.iter().all(pattern_irrefutable)) + { + return true; + } + (0..types.len()).any(|varying| { + rows.iter().all(|row| { + row.len() == types.len() + && row + .iter() + .enumerate() + .all(|(index, pattern)| index == varying || pattern_irrefutable(pattern)) + }) && self.patterns_cover_type( + &types[varying], + &rows + .iter() + .map(|row| row[varying].clone()) + .collect::>(), + ) + }) + } + + fn pattern_covers_type(&self, ty: &TypeRef, pattern: &IrPattern) -> bool { + match pattern { + IrPattern::Ignore | IrPattern::Bind { .. } => true, + IrPattern::Alternative { patterns } => self.patterns_cover_type(ty, patterns), + IrPattern::Tuple { values } => match ty { + TypeRef::Tuple { values: types } if types.len() == values.len() => types + .iter() + .zip(values) + .all(|(ty, pattern)| self.pattern_covers_type(ty, pattern)), + _ => false, + }, + IrPattern::Record { fields, rest } => { + let Some(expected) = self.registry.fields(ty).or_else(|| match ty { + TypeRef::Record { fields } => Some(fields.clone()), + _ => None, + }) else { + return false; + }; + expected.iter().all(|(name, ty)| { + fields + .iter() + .find(|(field, _)| field == name) + .is_some_and(|(_, pattern)| self.pattern_covers_type(ty, pattern)) + || *rest + }) + } + IrPattern::Literal { .. } | IrPattern::Constructor { .. } => false, + } + } + + fn record_pattern_coverage( + &mut self, + pattern: &IrPattern, + covered: &mut BTreeSet, + wildcard: &mut bool, + span: ast::SourceSpan, + ) { + if *wildcard { + self.diagnostics.push(error( + codes::NOT_EXHAUSTIVE, + "uhura/arm-after-wildcard", + "a wildcard match arm is residual and must be final", + span, + )); + return; + } + let mut atoms = BTreeSet::new(); + let mut arm_wildcard = false; + pattern_coverage(pattern, &mut atoms, &mut arm_wildcard); + let overlaps_prior = !arm_wildcard + && covered.iter().any(|item| { + item.strip_prefix("pattern:") + .and_then(|json| serde_json::from_str::(json).ok()) + .is_some_and(|prior| patterns_overlap(&prior, pattern)) + }); + if overlaps_prior { + self.diagnostics.push(error( + codes::NOT_EXHAUSTIVE, + "uhura/overlapping-match-arm", + "match arm overlaps an earlier explicit arm", + span, + )); + } + covered.extend(atoms); + if let Ok(json) = serde_json::to_string(pattern) { + covered.insert(format!("pattern:{json}")); + } + *wildcard = arm_wildcard; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PatternUse { + Binding, + Condition, + Match, + Handler, + Evidence, +} + +impl Checker<'_> { + fn lower_pattern( + &mut self, + module: &ModuleEnv<'_>, + scope: &mut Scope, + pattern: &ast::Pattern, + expected: Option<&TypeRef>, + usage: PatternUse, + ) -> IrPattern { + match &pattern.value { + ast::PatternKind::Wildcard | ast::PatternKind::Rest => IrPattern::Ignore, + ast::PatternKind::Integer(value) => IrPattern::Literal { + value: exact_number_value(value, expected.unwrap_or(&TypeRef::Int)) + .unwrap_or_else(|_| exact_integer("0", "Int").expect("zero")), + }, + ast::PatternKind::Decimal(value) => IrPattern::Literal { + value: exact_number_value(value, expected.unwrap_or(&TypeRef::Decimal)) + .unwrap_or_else(|_| exact_decimal("0").expect("zero")), + }, + ast::PatternKind::Text(value) => IrPattern::Literal { + value: Value::Text(value.clone()), + }, + ast::PatternKind::Bool(value) => IrPattern::Literal { + value: Value::Bool(*value), + }, + ast::PatternKind::Name(name) => { + if let Ok(constructor) = self.resolve_constructor(scope, &name.value, expected) + && constructor.fields.is_empty() + { + return IrPattern::Constructor { + type_id: constructor.type_id, + constructor: constructor.name, + fields: Vec::new(), + }; + } + if is_binding_reserved_builtin(&name.value) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/reserved-pattern-binding", + format!( + "`{}` is a binding-reserved Uhura builtin, not a pattern variable", + name.value + ), + name.span, + )); + } + if usage == PatternUse::Handler { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-handler-input", + format!( + "handler input `{}` is not a declared input constructor", + name.value + ), + name.span, + )); + } + let lowered = if scope.values.contains_key(&name.value) { + // Pattern variables are lexically scoped. CPS lowering of + // a controlled match may place the continuation inside an + // arm, so an arm-local name must never alias an outer + // state/config/local binding in the flat runtime locals + // map after its source scope has ended. + format!( + "__uhura_bind_{}_{}_{}", + pattern.span.file, pattern.span.start, name.value + ) + } else { + name.value.clone() + }; + scope.bind( + &name.value, + &lowered, + Ty::value(expected.cloned().unwrap_or(TypeRef::Never)), + ); + IrPattern::Bind { name: lowered } + } + ast::PatternKind::Constructor { path, arguments } => { + let qualified = path + .iter() + .map(|part| part.value.as_str()) + .collect::>() + .join("."); + if path.len() == 1 + && let Some(TypeRef::Named { id }) = scope.types.get(&qualified) + && let Some(TypeShape::Key(underlying)) = + self.registry.shape(&TypeRef::Named { id: id.clone() }) + && arguments.len() == 1 + { + let underlying = underlying.clone(); + let scalar = match &arguments[0].value { + ast::PatternKind::Integer(value) => exact_number_value(value, &underlying), + ast::PatternKind::Decimal(value) => exact_number_value(value, &underlying), + ast::PatternKind::Text(value) if underlying == TypeRef::Text => { + Ok(Value::Text(value.clone())) + } + _ => Err("key patterns require one exact literal payload".into()), + }; + if let Ok(value) = scalar { + return IrPattern::Literal { + value: Value::Key { + type_id: id.clone(), + value: Box::new(value), + }, + }; + } + } + let nominal_constructor = (path.len() == 2) + .then(|| scope.types.get(&path[0].value)) + .flatten() + .and_then(|ty| { + self.resolve_constructor(scope, &path[1].value, Some(ty)) + .ok() + }); + let constructor = nominal_constructor.or_else(|| { + scope + .port_receive + .get(&qualified) + .or_else(|| scope.port_send.get(&qualified)) + .cloned() + .or_else(|| { + path.last().and_then(|name| { + self.resolve_constructor(scope, &name.value, expected).ok() + }) + }) + }); + let Some(constructor) = constructor else { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-constructor", + format!("unknown or ambiguous constructor `{qualified}`"), + pattern.span, + )); + return IrPattern::Ignore; + }; + if constructor.fields.len() != arguments.len() { + self.arity( + &qualified, + constructor.fields.len(), + arguments.len(), + pattern.span, + ); + } + let fields = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + let child_usage = if usage == PatternUse::Handler { + PatternUse::Binding + } else { + usage + }; + self.lower_pattern( + module, + scope, + argument, + constructor.fields.get(index).map(|(_, ty)| ty), + child_usage, + ) + }) + .collect(); + IrPattern::Constructor { + type_id: constructor.type_id, + constructor: constructor.name, + fields, + } + } + ast::PatternKind::Tuple(values) => { + let expected_values = match expected { + Some(TypeRef::Tuple { values }) => Some(values.as_slice()), + _ => None, + }; + IrPattern::Tuple { + values: values + .iter() + .enumerate() + .map(|(index, value)| { + self.lower_pattern( + module, + scope, + value, + expected_values.and_then(|values| values.get(index)), + usage, + ) + }) + .collect(), + } + } + ast::PatternKind::Record { fields, open } => { + let expected_fields = expected.and_then(|ty| self.registry.fields(ty)); + IrPattern::Record { + fields: fields + .iter() + .map(|field| { + let expected = expected_fields + .as_ref() + .and_then(|values| { + values.iter().find(|(name, _)| name == &field.name.value) + }) + .map(|(_, ty)| ty); + ( + field.name.value.clone(), + self.lower_pattern(module, scope, &field.pattern, expected, usage), + ) + }) + .collect(), + rest: *open, + } + } + ast::PatternKind::Alternative(values) => { + let original = scope.clone(); + let mut alternatives = Vec::new(); + let mut first_bindings: Option> = None; + for value in values { + let mut child = original.clone(); + alternatives + .push(self.lower_pattern(module, &mut child, value, expected, usage)); + let new = child + .values + .into_iter() + .filter(|(name, _)| !original.values.contains_key(name)) + .collect::>(); + if let Some(first) = &first_bindings { + if first.keys().collect::>() != new.keys().collect::>() { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/alternative-bindings", + "every alternative pattern must bind the same names", + pattern.span, + )); + } + } else { + first_bindings = Some(new); + } + } + if let Some(bindings) = first_bindings { + scope.values.extend(bindings); + } + IrPattern::Alternative { + patterns: alternatives, + } + } + ast::PatternKind::Error => { + self.diagnostics.push(error( + codes::UNSUPPORTED, + "uhura/error-pattern", + "cannot lower a recovered parser error pattern", + pattern.span, + )); + IrPattern::Ignore + } + } + } +} + +impl Checker<'_> { + fn lower_machine( + &mut self, + module: &ModuleEnv<'_>, + declaration: &ast::MachineDecl, + span: ast::SourceSpan, + ) { + self.validate_machine_members(declaration, span); + let machine_id = qualify(&module.id, &declaration.name.value); + let mut scope = self.module_scope(module); + + // Machine-local type identities are predeclared so mutually referring + // records and sums resolve independently of source order. + for member in &declaration.members { + match &member.value { + ast::MachineMemberKind::Key(key) => { + scope.types.insert( + key.name.value.clone(), + TypeRef::Named { + id: machine_qualify( + &module.id, + &declaration.name.value, + &key.name.value, + ), + }, + ); + } + ast::MachineMemberKind::Type(ty) => { + scope.types.insert( + ty.name.value.clone(), + TypeRef::Named { + id: machine_qualify( + &module.id, + &declaration.name.value, + &ty.name.value, + ), + }, + ); + } + _ => {} + } + } + for member in &declaration.members { + match &member.value { + ast::MachineMemberKind::Key(key) => { + let id = machine_qualify(&module.id, &declaration.name.value, &key.name.value); + let underlying = self.resolve_type(module, &scope, &key.over); + self.reject_persisted_finite_view( + &underlying, + key.over.span, + &format!("key `{}`", key.name.value), + ); + self.registry.insert(TypeInfo { + id: id.clone(), + shape: TypeShape::Key(underlying.clone()), + }); + self.program + .machine_program + .types + .insert(id.clone(), TypeDef::Key { id, underlying }); + } + ast::MachineMemberKind::Type(ty) => { + let id = machine_qualify(&module.id, &declaration.name.value, &ty.name.value); + self.install_type_body(module, &scope, &id, &ty.body, member.span); + } + _ => {} + } + } + self.populate_scope_constructors(&mut scope); + + // Predeclare local constants, functions, and transitions. + for member in &declaration.members { + match &member.value { + ast::MachineMemberKind::Const(value) => { + let ty = self.resolve_type(module, &scope, &value.ty); + let id = + machine_qualify(&module.id, &declaration.name.value, &value.name.value); + scope.bind(&value.name.value, &id, Ty::value(ty)); + } + ast::MachineMemberKind::Function(value) => { + let params = value + .parameters + .iter() + .map(|parameter| self.resolve_type(module, &scope, ¶meter.ty)) + .collect(); + let result = self.resolve_type(module, &scope, &value.result); + scope.functions.insert( + value.name.value.clone(), + (value.name.value.clone(), params, result), + ); + } + ast::MachineMemberKind::Transition(value) => { + let params = value + .parameters + .iter() + .map(|parameter| self.resolve_type(module, &scope, ¶meter.ty)) + .collect(); + scope + .transitions + .insert(value.name.value.clone(), (params, value.name.value.clone())); + } + _ => {} + } + } + + let config_member = unique_member(&declaration.members, |member| match member { + ast::MachineMemberKind::Config(value) => Some(value), + _ => None, + }); + let config = if let Some(config) = config_member { + let fields = config + .fields + .iter() + .map(|field| { + let ty = self.resolve_type(module, &scope, &field.ty); + self.reject_persisted_finite_view( + &ty, + field.ty.span, + &format!("machine configuration field `{}`", field.name.value), + ); + scope + .config_fields + .insert(field.name.value.clone(), ty.clone()); + scope.bind(&field.name.value, &field.name.value, Ty::value(ty.clone())); + (field.name.value.clone(), ty) + }) + .collect(); + TypeRef::Record { fields } + } else { + TypeRef::Unit + }; + + let (input_def, input_constructors) = self.machine_sum_domain( + module, + &scope, + &machine_id, + "Input", + unique_member(&declaration.members, |member| match member { + ast::MachineMemberKind::Input(value) => Some(value), + _ => None, + }), + span, + ); + let input_ty = TypeRef::Named { + id: input_def.id().to_string(), + }; + scope.input_type = Some(input_ty.clone()); + self.registry.insert(TypeInfo { + id: input_def.id().to_string(), + shape: TypeShape::Sum(input_constructors.clone()), + }); + self.program + .machine_program + .types + .insert(input_def.id().to_string(), input_def.clone()); + for constructor in input_constructors { + scope + .constructors + .entry(constructor.name.clone()) + .or_default() + .push(ConstructorInfo { + type_id: input_def.id().into(), + name: constructor.name, + fields: constructor.fields, + }); + } + + let (command_def, command_constructors) = self.machine_sum_domain( + module, + &scope, + &machine_id, + "Command", + unique_member(&declaration.members, |member| match member { + ast::MachineMemberKind::Command(value) => Some(value), + _ => None, + }), + span, + ); + let command_ty = TypeRef::Named { + id: command_def.id().to_string(), + }; + scope.command_type = Some(command_ty.clone()); + self.registry.insert(TypeInfo { + id: command_def.id().to_string(), + shape: TypeShape::Sum(command_constructors.clone()), + }); + self.program + .machine_program + .types + .insert(command_def.id().to_string(), command_def.clone()); + for constructor in &command_constructors { + scope + .constructors + .entry(constructor.name.clone()) + .or_default() + .push(ConstructorInfo { + type_id: command_def.id().into(), + name: constructor.name.clone(), + fields: constructor.fields.clone(), + }); + } + + let (outcomes, outcome_constructors, outcome_ty) = + self.machine_outcomes(module, &scope, &machine_id, declaration, span); + scope.outcome_type = Some(outcome_ty.clone()); + for constructor in &outcome_constructors { + scope + .constructors + .entry(constructor.name.clone()) + .or_default() + .push(ConstructorInfo { + type_id: outcome_ty.canonical_name(), + name: constructor.name.clone(), + fields: constructor.fields.clone(), + }); + } + + let ports = declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Port(port) => { + Some(self.lower_port(module, &machine_id, &mut scope, port, member.span)) + } + _ => None, + }) + .collect::>(); + + // State initialization is deliberately non-sequential. It may use + // configuration, module/machine constants, constructors, and builtin + // pure values, but no state field, function, transition, or derive. + let mut initializer_scope = scope.child(); + initializer_scope.functions.clear(); + initializer_scope.transitions.clear(); + initializer_scope.state_fields.clear(); + let initializer_lookup = initializer_scope.child(); + for requirement in declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Require(value) => Some(value), + _ => None, + }) + { + install_numeric_condition( + &mut initializer_scope, + &initializer_lookup, + requirement, + true, + &self.registry, + ); + } + + let mut state = Vec::new(); + for state_decl in declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::State(value) => Some(value), + _ => None, + }) + .take(1) + { + for field in &state_decl.fields { + let ty = self.resolve_type(module, &scope, &field.ty); + self.reject_persisted_finite_view( + &ty, + field.ty.span, + &format!("state field `{}`", field.name.value), + ); + scope + .state_fields + .insert(field.name.value.clone(), ty.clone()); + scope.bind(&field.name.value, &field.name.value, Ty::value(ty.clone())); + state.push(StateField { + name: field.name.value.clone(), + ty, + initial: IrExpr::Literal { value: Value::Unit }, + source: source(module, field.span), + }); + } + } + let mut state_index = 0usize; + for state_decl in declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::State(value) => Some(value), + _ => None, + }) + .take(1) + { + for field in &state_decl.fields { + let expected = state[state_index].ty.clone(); + let (initial, actual) = self.lower_expr( + module, + &initializer_scope, + &field.value, + Some(&expected), + ExprMode::Pure, + ); + self.expect_type(&actual, &expected, field.value.span); + state[state_index].initial = initial; + state_index += 1; + } + } + + let mut functions = BTreeMap::new(); + for member in &declaration.members { + if let ast::MachineMemberKind::Function(function) = &member.value { + let mut fn_scope = scope.child(); + let params = function + .parameters + .iter() + .map(|parameter| { + let ty = self.resolve_type(module, &fn_scope, ¶meter.ty); + fn_scope.bind( + ¶meter.name.value, + ¶meter.name.value, + Ty::value(ty.clone()), + ); + (parameter.name.value.clone(), ty) + }) + .collect::>(); + let result = self.resolve_type(module, &fn_scope, &function.result); + if reaction_control(&function.body) { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/effect-in-pure-function", + format!( + "machine function `{}` contains reaction control", + function.name.value + ), + function.body.span, + )); + continue; + } + let (body, actual) = self.lower_expr( + module, + &fn_scope, + &function.body, + Some(&result), + ExprMode::Pure, + ); + self.expect_type(&actual, &result, function.body.span); + functions.insert( + function.name.value.clone(), + IrFunction { + id: function.name.value.clone(), + params, + result, + body, + source: source(module, member.span), + }, + ); + } + } + + // Machine-local constants are immutable program constants with a + // machine-qualified identity, so runtime lookup remains collision-free. + for member in &declaration.members { + if let ast::MachineMemberKind::Const(value) = &member.value { + let expected = self.resolve_type(module, &scope, &value.ty); + self.reject_persisted_finite_view( + &expected, + value.ty.span, + &format!("machine constant `{}`", value.name.value), + ); + let (expression, actual) = self.lower_expr( + module, + &scope, + &value.value, + Some(&expected), + ExprMode::Pure, + ); + self.expect_type(&actual, &expected, value.value.span); + match const_eval(&expression, &self.program) { + Ok(constant) => { + let id = + machine_qualify(&module.id, &declaration.name.value, &value.name.value); + self.program + .machine_program + .constants + .insert(id.clone(), constant); + self.program + .machine_program + .constant_types + .insert(id, expected); + } + Err(message) => self.diagnostics.push(error( + codes::EFFECT, + "uhura/non-constant-expression", + format!( + "machine constant `{}` is not total: {message}", + value.name.value + ), + member.span, + )), + } + } + } + + let mut derives = Vec::new(); + let derive_names = declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Derive(value) => Some(value.name.value.clone()), + _ => None, + }) + .collect::>(); + let inferred_names = declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Derive(value) if value.ty.is_none() => { + Some(value.name.value.clone()) + } + _ => None, + }) + .collect::>(); + + // Explicit signatures are visible independent of declaration order. + for member in &declaration.members { + if let ast::MachineMemberKind::Derive(value) = &member.value + && let Some(annotation) = &value.ty + { + let ty = self.resolve_type(module, &scope, annotation); + scope.bind(&value.name.value, &value.name.value, Ty::value(ty)); + } + } + + // Unannotated derives are inferred in dependency order. The probe + // lowering is diagnostic-free because safety facts from invariants are + // installed only after every derive has a type; the authoritative + // lowering below runs once with the complete scope. + let mut inferred_types = BTreeMap::new(); + let mut pending = inferred_names.clone(); + while !pending.is_empty() { + let ready = declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Derive(value) + if pending.contains(&value.name.value) => + { + let mut dependencies = BTreeSet::new(); + collect_source_names(&value.value, &mut BTreeSet::new(), &mut dependencies); + dependencies.retain(|name| derive_names.contains(name)); + (!dependencies.iter().any(|name| pending.contains(name))) + .then_some(value.name.value.clone()) + } + _ => None, + }) + .collect::>(); + if ready.is_empty() { + for name in &pending { + let member = declaration + .members + .iter() + .find(|member| { + matches!( + &member.value, + ast::MachineMemberKind::Derive(value) + if &value.name.value == name + ) + }) + .expect("pending derive is declared"); + self.diagnostics.push(error( + codes::DEPENDENCY_CYCLE, + "uhura/recursive-derive-inference", + format!( + "computed value `{name}` needs an explicit type because its inferred-type dependencies are cyclic" + ), + member.span, + )); + scope.bind(name, name, Ty::value(TypeRef::Never)); + inferred_types.insert(name.clone(), TypeRef::Never); + } + break; + } + for name in ready { + let value = declaration + .members + .iter() + .find_map(|member| match &member.value { + ast::MachineMemberKind::Derive(value) if value.name.value == name => { + Some(value) + } + _ => None, + }) + .expect("ready derive is declared"); + let diagnostic_count = self.diagnostics.len(); + let (_, actual) = + self.lower_expr(module, &scope, &value.value, None, ExprMode::Projection); + self.diagnostics.truncate(diagnostic_count); + let ty = actual.into_value().unwrap_or(TypeRef::Never); + if !inferred_type_is_complete(&ty) { + self.diagnostics.push(error( + codes::TYPE_MISMATCH, + "uhura/derive-type-inference", + format!( + "computed value `{name}` does not have one complete inferable type; add an explicit type annotation" + ), + value.value.span, + )); + } + scope.bind(&name, &name, Ty::value(ty.clone())); + inferred_types.insert(name.clone(), ty); + pending.remove(&name); + } + } + let invariant_lookup = scope.child(); + for expression in declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Invariant(value) => Some(&value.expressions), + _ => None, + }) + .flatten() + { + install_numeric_condition( + &mut scope, + &invariant_lookup, + expression, + true, + &self.registry, + ); + } + for member in &declaration.members { + if let ast::MachineMemberKind::Derive(value) = &member.value { + let ty = value + .ty + .as_ref() + .map(|annotation| self.resolve_type(module, &scope, annotation)) + .or_else(|| inferred_types.get(&value.name.value).cloned()) + .unwrap_or(TypeRef::Never); + let (expression, actual) = self.lower_expr( + module, + &scope, + &value.value, + Some(&ty), + ExprMode::Projection, + ); + self.expect_type(&actual, &ty, value.value.span); + derives.push(( + value.name.value.clone(), + ty, + expression, + source(module, member.span), + )); + } + } + + let requires = declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Require(value) => Some((value, member.span)), + _ => None, + }) + .map(|(value, span)| { + let (value, ty) = + self.lower_expr(module, &scope, value, Some(&TypeRef::Bool), ExprMode::Pure); + self.expect_type(&ty, &TypeRef::Bool, span); + (value, source(module, span)) + }) + .collect::>(); + + let mut invariants = Vec::new(); + for (value, span) in declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Invariant(value) => Some((value, member.span)), + _ => None, + }) + { + for expression in &value.expressions { + let (expression_ir, ty) = self.lower_expr( + module, + &scope, + expression, + Some(&TypeRef::Bool), + ExprMode::Projection, + ); + self.expect_type(&ty, &TypeRef::Bool, expression.span); + invariants.push((expression_ir, source(module, span))); + } + } + + let mut observation = Vec::new(); + for observe in declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::Observe(value) => Some(value), + _ => None, + }) + .take(1) + { + for field in &observe.fields { + let declared = field + .ty + .as_ref() + .map(|ty| self.resolve_type(module, &scope, ty)); + let (expression, actual) = self.lower_expr( + module, + &scope, + &field.value, + declared.as_ref(), + ExprMode::Projection, + ); + let ty = declared + .or_else(|| actual.into_value()) + .unwrap_or(TypeRef::Never); + self.reject_persisted_finite_view( + &ty, + field + .ty + .as_ref() + .map_or(field.value.span, |declared| declared.span), + &format!("observation field `{}`", field.name.value), + ); + observation.push(ObservationField { + name: field.name.value.clone(), + ty, + expression, + source: source(module, field.span), + }); + } + } + + let mut transitions = BTreeMap::new(); + for member in &declaration.members { + if let ast::MachineMemberKind::Transition(value) = &member.value { + let mut transition_scope = scope.child(); + let params = value + .parameters + .iter() + .map(|parameter| { + let ty = self.resolve_type(module, &transition_scope, ¶meter.ty); + transition_scope.bind( + ¶meter.name.value, + ¶meter.name.value, + Ty::value(ty.clone()), + ); + (parameter.name.value.clone(), ty) + }) + .collect::>(); + let body = self.lower_reaction_block( + module, + &transition_scope, + &value.body, + &outcome_ty, + Vec::new(), + ); + if !statements_terminal(&body) { + self.diagnostics.push(error( + codes::TRANSITION_SHAPE, + "uhura/transition-fallthrough", + format!( + "transition `{}` may fall through without `finish`", + value.name.value + ), + member.span, + )); + } + transitions.insert( + value.name.value.clone(), + IrTransition { + name: value.name.value.clone(), + params, + body, + source: source(module, member.span), + }, + ); + } + } + + let mut handlers = BTreeMap::new(); + for member in &declaration.members { + if let ast::MachineMemberKind::Handler(handler) = &member.value { + let input_name = handler_input_name(&handler.input); + if handlers.contains_key(&input_name) { + self.diagnostics.push(error( + codes::INPUT_COVERAGE, + "uhura/duplicate-handler", + format!("input `{input_name}` has more than one handler"), + handler.input.span, + )); + continue; + } + let mut handler_scope = scope.child(); + let expected = scope + .port_receive + .get(&input_name) + .map(|constructor| TypeRef::Named { + id: constructor.type_id.clone(), + }) + .or_else(|| Some(input_ty.clone())); + let pattern = self.lower_pattern( + module, + &mut handler_scope, + &handler.input, + expected.as_ref(), + PatternUse::Handler, + ); + let body = match &handler.body { + ast::HandlerBody::Block(block) => self.lower_reaction_block( + module, + &handler_scope, + block, + &outcome_ty, + Vec::new(), + ), + ast::HandlerBody::Delegate(expression) => { + self.lower_delegate(module, &handler_scope, expression, member.span) + } + }; + if !statements_terminal(&body) { + self.diagnostics.push(error( + codes::TRANSITION_SHAPE, + "uhura/handler-fallthrough", + format!("handler `{input_name}` may fall through without `finish`"), + member.span, + )); + } + handlers.insert( + input_name.clone(), + IrHandler { + input: input_name, + pattern, + body, + source: source(module, member.span), + }, + ); + } + } + let expected_handlers = match &input_def { + TypeDef::Sum { constructors, .. } => constructors + .iter() + .map(|constructor| constructor.name.clone()) + .chain(ports.iter().flat_map(|port| { + port.receive + .iter() + .map(|constructor| format!("{}.{}", port.name, constructor.name)) + })) + .collect::>(), + _ => BTreeSet::new(), + }; + let actual_handlers = handlers.keys().cloned().collect::>(); + for input in expected_handlers.difference(&actual_handlers) { + self.diagnostics.push(error( + codes::INPUT_COVERAGE, + "uhura/missing-handler", + format!("input `{input}` has no handler"), + span, + )); + } + for input in actual_handlers.difference(&expected_handlers) { + self.diagnostics.push(error( + codes::INPUT_COVERAGE, + "uhura/extra-handler", + format!("handler `{input}` does not belong to the machine input domain"), + handlers + .get(input) + .map(|handler| self.physical_span(&handler.source)) + .unwrap_or(span), + )); + } + + let before_commit = declaration + .members + .iter() + .filter_map(|member| match &member.value { + ast::MachineMemberKind::BeforeCommit(value) => Some((value, member.span)), + _ => None, + }) + .next() + .map(|(block, span)| { + if block_contains_finish_control(block) { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/terminal-in-before-commit", + "`before commit` may reconcile the draft or fault, but cannot finish or replace the selected outcome", + span, + )); + } + self.lower_reaction_block(module, &scope, block, &outcome_ty, Vec::new()) + }) + .unwrap_or_default(); + + let local_commands = command_constructors + .into_iter() + .map(|constructor| CommandDef { + constructor, + source: source(module, span), + }) + .collect(); + + self.program.machine_program.machines.insert( + machine_id.clone(), + IrMachine { + id: machine_id, + config, + requires, + ports, + local_input: input_def, + local_commands, + outcomes, + state, + functions, + derives, + invariants, + observation, + transitions, + handlers, + before_commit, + source: source(module, span), + }, + ); + } + + fn validate_machine_members( + &mut self, + declaration: &ast::MachineDecl, + machine_span: ast::SourceSpan, + ) { + let mut singleton_spans: BTreeMap<&'static str, Vec> = BTreeMap::new(); + let mut require_spans = Vec::new(); + for member in &declaration.members { + let name = match &member.value { + ast::MachineMemberKind::Config(_) => Some("config"), + ast::MachineMemberKind::Input(_) => Some("input"), + ast::MachineMemberKind::Command(_) => Some("command"), + ast::MachineMemberKind::Outcome(_) => Some("outcome"), + ast::MachineMemberKind::State(_) => Some("state"), + ast::MachineMemberKind::Observe(_) => Some("observe"), + ast::MachineMemberKind::BeforeCommit(_) => Some("before commit"), + ast::MachineMemberKind::Require(_) => { + require_spans.push(member.span); + None + } + _ => None, + }; + if let Some(name) = name { + singleton_spans.entry(name).or_default().push(member.span); + } + } + + for required in ["input", "command", "outcome", "state", "observe"] { + if singleton_spans.get(required).is_none_or(Vec::is_empty) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/missing-machine-member", + format!("machine must declare exactly one `{required}` member"), + machine_span, + )); + } + } + for (name, spans) in singleton_spans { + for span in spans.into_iter().skip(1) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/duplicate-machine-member", + format!("machine may not declare `{name}` more than once"), + span, + )); + } + } + if !require_spans.is_empty() + && !declaration + .members + .iter() + .any(|member| matches!(member.value, ast::MachineMemberKind::Config(_))) + { + for span in require_spans { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/require-without-config", + "`require` is valid only for a machine with `config`", + span, + )); + } + } + } + + fn populate_scope_constructors(&self, scope: &mut Scope) { + for ty in scope.types.values() { + for constructor in self.registry.constructors_for(ty) { + scope + .constructors + .entry(constructor.name.clone()) + .or_default() + .push(constructor); + } + } + } + + fn machine_sum_domain( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + machine_id: &str, + name: &str, + domain: Option<&ast::SumDomain>, + span: ast::SourceSpan, + ) -> (TypeDef, Vec) { + let id = format!("{machine_id}.{name}"); + let constructors = match domain { + Some(ast::SumDomain::Never(_)) | None => Vec::new(), + Some(ast::SumDomain::Sum(sum)) => sum + .variants + .iter() + .map(|variant| { + let constructor = self.lower_constructor_def(module, scope, variant); + self.reject_persisted_constructor_finite_views( + &constructor, + variant, + &name.to_ascii_lowercase(), + ); + constructor + }) + .collect(), + }; + let _ = span; + ( + TypeDef::Sum { + id, + constructors: constructors.clone(), + }, + constructors, + ) + } + + fn machine_outcomes( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + machine_id: &str, + declaration: &ast::MachineDecl, + _span: ast::SourceSpan, + ) -> (Vec, Vec, TypeRef) { + let id = format!("{machine_id}.Outcome"); + let Some(outcome) = unique_member(&declaration.members, |member| match member { + ast::MachineMemberKind::Outcome(value) => Some(value), + _ => None, + }) else { + return (Vec::new(), Vec::new(), TypeRef::Named { id }); + }; + let mut definitions = Vec::new(); + let mut constructors = Vec::new(); + for value in &outcome.variants { + let constructor = self.lower_constructor_def(module, scope, &value.variant); + self.reject_persisted_constructor_finite_views(&constructor, &value.variant, "outcome"); + definitions.push(OutcomeDef { + constructor: constructor.clone(), + policy: match value.policy.value { + ast::OutcomePolicy::Commit => IrOutcomePolicy::Commit, + ast::OutcomePolicy::Abort => IrOutcomePolicy::Abort, + }, + source: source(module, value.span), + }); + constructors.push(constructor); + } + self.registry.insert(TypeInfo { + id: id.clone(), + shape: TypeShape::Sum(constructors.clone()), + }); + self.program.machine_program.types.insert( + id.clone(), + TypeDef::Sum { + id: id.clone(), + constructors: constructors.clone(), + }, + ); + (definitions, constructors, TypeRef::Named { id }) + } + + fn lower_port( + &mut self, + module: &ModuleEnv<'_>, + machine_id: &str, + scope: &mut Scope, + port: &ast::PortDecl, + span: ast::SourceSpan, + ) -> PortDef { + let ast::TypeExprKind::Named { path, arguments } = &port.contract.value else { + self.diagnostics.push(error( + codes::PORT, + "uhura/port-contract", + "port contracts must be named generic standard contracts", + port.contract.span, + )); + return PortDef { + name: port.name.value.clone(), + contract: "".into(), + contract_instance: None, + type_arguments: Vec::new(), + configuration: None, + receive: Vec::new(), + send: Vec::new(), + contract_hash: String::new(), + source: source(module, span), + }; + }; + let contract = path.last().map(|name| name.value.as_str()).unwrap_or(""); + let type_arguments = arguments + .iter() + .map(|argument| self.resolve_type(module, scope, argument)) + .collect::>(); + for (index, (argument, source)) in type_arguments.iter().zip(arguments).enumerate() { + self.reject_persisted_finite_view( + argument, + source.span, + &format!( + "port `{}` contract type argument #{}", + port.name.value, + index + 1 + ), + ); + } + let configuration = port.configuration.first().map(|value| { + let (expression, ty) = self.lower_expr(module, scope, value, None, ExprMode::Pure); + if let Some(ty) = ty.as_value() { + self.reject_persisted_finite_view( + ty, + value.span, + &format!("port `{}` configuration", port.name.value), + ); + } + expression + }); + let (receive, send, contract_instance, expected_arity) = match contract { + "Observation" => { + let value = type_arguments.first().cloned().unwrap_or(TypeRef::Never); + let instance = uhura_port::observation_instance(port_ty(&value)); + ( + vec![ConstructorDef { + name: "observed".into(), + fields: vec![(Some("value".into()), value)], + }], + Vec::new(), + Some(instance), + 1, + ) + } + "RequestPort" => { + let id = type_arguments.first().cloned().unwrap_or(TypeRef::Never); + let payload = type_arguments.get(1).cloned().unwrap_or(TypeRef::Never); + let settlement = type_arguments.get(2).cloned().unwrap_or(TypeRef::Never); + let instance = uhura_port::request_port_instance( + port_ty(&id), + port_ty(&payload), + port_ty(&settlement), + ); + ( + vec![ConstructorDef { + name: "settled".into(), + fields: vec![ + (Some("id".into()), id.clone()), + (Some("result".into()), settlement), + ], + }], + vec![ConstructorDef { + name: "request".into(), + fields: vec![(Some("id".into()), id), (Some("payload".into()), payload)], + }], + Some(instance), + 3, + ) + } + "SinkPort" => { + let value = type_arguments.first().cloned().unwrap_or(TypeRef::Never); + let instance = uhura_port::sink_port_instance(port_ty(&value)); + ( + Vec::new(), + vec![ConstructorDef { + name: "send".into(), + fields: vec![(Some("value".into()), value)], + }], + Some(instance), + 1, + ) + } + "Router" => { + let location = type_arguments.first().cloned().unwrap_or(TypeRef::Never); + let route_id = port + .configuration + .first() + .and_then(|value| match &value.value { + ast::ExprKind::Name(name) => scope.values.get(&name.value), + _ => None, + }) + .map(|binding| binding.lowered.clone()); + let instance = route_id + .as_ref() + .and_then(|id| self.program.route_tables.get(id)) + .map(|routes| uhura_port::router_instance(port_ty(&location), routes)); + ( + vec![ConstructorDef { + name: "changed".into(), + fields: vec![(Some("location".into()), location.clone())], + }], + vec![ + ConstructorDef { + name: "push".into(), + fields: vec![(Some("location".into()), location.clone())], + }, + ConstructorDef { + name: "replace".into(), + fields: vec![(Some("location".into()), location)], + }, + ConstructorDef { + name: "back".into(), + fields: Vec::new(), + }, + ], + instance, + 1, + ) + } + other => { + self.diagnostics.push(error( + codes::PORT, + "uhura/unknown-port-contract", + format!("unsupported or unresolved port contract `{other}`"), + port.contract.span, + )); + (Vec::new(), Vec::new(), None, type_arguments.len()) + } + }; + if type_arguments.len() != expected_arity { + self.arity( + contract, + expected_arity, + type_arguments.len(), + port.contract.span, + ); + } + let contract_instance = contract_instance.and_then(|instance| match instance { + Ok(instance) => Some(instance), + Err(model_error) => { + self.diagnostics.push(error( + codes::PORT, + "uhura/port-contract-instance", + model_error.to_string(), + port.contract.span, + )); + None + } + }); + let contract_identity = contract_instance + .as_ref() + .map(|instance| instance.identity.to_string()) + .unwrap_or_else(|| contract.to_string()); + let contract_hash = contract_instance + .as_ref() + .map(|instance| instance.content_hash.clone()) + .unwrap_or_default(); + for constructor in &receive { + let qualified = format!("{}.{}", port.name.value, constructor.name); + scope.port_receive.insert( + qualified.clone(), + ConstructorInfo { + type_id: format!("{machine_id}::port.{}.Receive", port.name.value), + name: qualified, + fields: constructor.fields.clone(), + }, + ); + } + for constructor in &send { + let qualified = format!("{}.{}", port.name.value, constructor.name); + scope.port_send.insert( + qualified.clone(), + ConstructorInfo { + type_id: format!("{machine_id}::port.{}.Send", port.name.value), + name: qualified, + fields: constructor.fields.clone(), + }, + ); + } + PortDef { + name: port.name.value.clone(), + contract: contract_identity, + contract_instance, + type_arguments, + configuration, + receive, + send, + contract_hash, + source: source(module, span), + } + } + + fn lower_delegate( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + expression: &ast::Expr, + span: ast::SourceSpan, + ) -> Vec { + let ast::ExprKind::Call { callee, arguments } = &expression.value else { + self.diagnostics.push(error( + codes::TRANSITION_SHAPE, + "uhura/delegate-shape", + "expression-bodied handlers must call one named transition", + expression.span, + )); + return Vec::new(); + }; + let ast::ExprKind::Name(name) = &callee.value else { + self.diagnostics.push(error( + codes::TRANSITION_SHAPE, + "uhura/delegate-shape", + "handler delegate target must be a transition name", + callee.span, + )); + return Vec::new(); + }; + let Some((params, lowered)) = scope.transitions.get(&name.value) else { + self.diagnostics.push(error( + codes::TRANSITION_SHAPE, + "uhura/unknown-transition", + format!("unknown transition `{}`", name.value), + name.span, + )); + return Vec::new(); + }; + if params.len() != arguments.len() { + self.arity(&name.value, params.len(), arguments.len(), expression.span); + } + let args = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + self.lower_expr( + module, + scope, + argument, + params.get(index), + ExprMode::Reaction, + ) + .0 + }) + .collect(); + vec![Statement::Delegate { + transition: lowered.clone(), + args, + source: source(module, span), + }] + } +} + +impl Checker<'_> { + fn lower_presentations(&mut self) { + let deferred = self.presentations.clone(); + for value in deferred { + let Some(module) = self.modules.get(&value.module).cloned() else { + continue; + }; + if !module.features.contains("ui") { + self.diagnostics.push(error( + codes::UI_NOT_ENABLED, + "uhura/ui-without-use", + "UI declarations require `use ui`", + value.span, + )); + } + let Some(machine_id) = self.resolve_machine(&module, &value.declaration.machine) else { + continue; + }; + let Some(machine) = self + .program + .machine_program + .machines + .get(&machine_id) + .cloned() + else { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/ui-machine-unavailable", + format!("machine `{machine_id}` was not checked before this presentation"), + value.declaration.machine.span, + )); + continue; + }; + let mut scope = self.module_scope(&module); + self.populate_scope_constructors(&mut scope); + let observation_ty = TypeRef::Record { + fields: machine + .observation + .iter() + .map(|field| (field.name.clone(), field.ty.clone())) + .collect(), + }; + scope.bind( + &value.declaration.binding.value, + &value.declaration.binding.value, + Ty::value(observation_ty), + ); + self.install_machine_io_scope(&machine, &mut scope); + let nodes = self.lower_ui_nodes(&module, &scope, &value.declaration.nodes, &machine); + let id = qualify(&module.id, &value.declaration.name.value); + self.program.presentations.insert( + id.clone(), + Presentation { + id, + machine: machine_id, + binding: value.declaration.binding.value, + nodes, + source: source(&module, value.span), + }, + ); + } + } + + fn resolve_machine(&mut self, module: &ModuleEnv<'_>, name: &ast::Name) -> Option { + match module.lookup(&name.value) { + Some(Export::Machine { id }) => Some(id.clone()), + _ => { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-machine", + format!("`{}` does not resolve to a machine", name.value), + name.span, + )); + None + } + } + } + + fn resolve_presentation(&mut self, module: &ModuleEnv<'_>, name: &ast::Name) -> Option { + match module.lookup(&name.value) { + Some(Export::Presentation { id }) => Some(id.clone()), + _ => { + self.diagnostics.push(error( + codes::UNKNOWN_NAME, + "uhura/unknown-presentation", + format!("`{}` does not resolve to a UI presentation", name.value), + name.span, + )); + None + } + } + } + + fn install_machine_io_scope(&self, machine: &IrMachine, scope: &mut Scope) { + if let TypeDef::Sum { id, constructors } = &machine.local_input { + let ty = TypeRef::Named { id: id.clone() }; + scope.input_type = Some(ty); + for constructor in constructors { + scope + .constructors + .entry(constructor.name.clone()) + .or_default() + .push(ConstructorInfo { + type_id: id.clone(), + name: constructor.name.clone(), + fields: constructor.fields.clone(), + }); + } + } + let outcome_id = format!("{}.Outcome", machine.id); + scope.outcome_type = Some(TypeRef::Named { + id: outcome_id.clone(), + }); + for outcome in &machine.outcomes { + scope + .constructors + .entry(outcome.constructor.name.clone()) + .or_default() + .push(ConstructorInfo { + type_id: outcome_id.clone(), + name: outcome.constructor.name.clone(), + fields: outcome.constructor.fields.clone(), + }); + } + let command_id = format!("{}.Command", machine.id); + scope.command_type = Some(TypeRef::Named { + id: command_id.clone(), + }); + for command in &machine.local_commands { + scope + .constructors + .entry(command.constructor.name.clone()) + .or_default() + .push(ConstructorInfo { + type_id: command_id.clone(), + name: command.constructor.name.clone(), + fields: command.constructor.fields.clone(), + }); + } + for port in &machine.ports { + for constructor in &port.receive { + let name = format!("{}.{}", port.name, constructor.name); + scope.port_receive.insert( + name.clone(), + ConstructorInfo { + type_id: format!("{}::port.{}.Receive", machine.id, port.name), + name, + fields: constructor.fields.clone(), + }, + ); + } + for constructor in &port.send { + let name = format!("{}.{}", port.name, constructor.name); + scope.port_send.insert( + name.clone(), + ConstructorInfo { + type_id: format!("{}::port.{}.Send", machine.id, port.name), + name, + fields: constructor.fields.clone(), + }, + ); + } + } + } + + fn lower_ui_nodes( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + nodes: &[ast::UiNode], + machine: &IrMachine, + ) -> Vec { + nodes + .iter() + .map(|node| match &node.value { + ast::UiNodeKind::Text(value) => IrUiNode::Text { + value: value.clone(), + source: source(module, node.span), + }, + ast::UiNodeKind::Interpolation(value) => { + let (value, ty) = + self.lower_expr(module, scope, value, None, ExprMode::Ui); + if !ty + .as_value() + .is_some_and(|ty| self.ui_scalar_type(ty)) + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-interpolation-type", + "UI interpolation requires Text, Bool, an exact numeric scalar, or a nominal scalar key", + node.span, + )); + } + IrUiNode::Interpolation { + value, + source: source(module, node.span), + } + } + ast::UiNodeKind::Element(element) => { + self.check_ui_element_shape(module, element, node.span); + let attributes = element + .attributes + .iter() + .map(|attribute| { + let value = match &attribute.value { + ast::UiAttributeValue::Text(value) => IrUiAttributeValue::Text { + value: value.clone(), + }, + ast::UiAttributeValue::Expression(value) => { + let expected = + self.ui_attribute_expected_type(element, &attribute.name); + let (value, ty) = self.lower_expr( + module, + scope, + value, + expected.as_ref(), + ExprMode::Ui, + ); + if let Some(expected) = expected { + self.expect_type(&ty, &expected, attribute.span); + } + self.check_ui_attribute_type( + element, + &attribute.name, + &ty, + attribute.span, + ); + IrUiAttributeValue::Expression { value } + } + ast::UiAttributeValue::Event { event, input } => { + let event_payload = self.check_ui_event( + module, + element, + &event.value, + event.span, + ); + let mut event_scope = scope.child(); + if let Some(event_payload) = event_payload { + event_scope.bind( + "event", + "event", + Ty::value(event_payload), + ); + } + let (input, actual) = self.lower_expr( + module, + &event_scope, + input, + scope.input_type.as_ref(), + ExprMode::Ui, + ); + if let Some(expected) = &scope.input_type { + self.expect_type(&actual, expected, attribute.span); + } + IrUiAttributeValue::Event { + event: event.value.clone(), + input, + } + } + }; + IrUiAttribute { + name: attribute.name.clone(), + value, + source: source(module, attribute.span), + } + }) + .collect(); + IrUiNode::Element { + name: element.name.value.clone(), + attributes, + children: self.lower_ui_nodes(module, scope, &element.children, machine), + source: source(module, node.span), + } + } + ast::UiNodeKind::If { + condition, + children, + } => { + let (condition, refined) = + self.lower_condition(module, scope, condition, ExprMode::Ui); + IrUiNode::If { + condition, + children: self.lower_ui_nodes(module, &refined, children, machine), + source: source(module, node.span), + } + } + ast::UiNodeKind::Match { subject, cases } => { + let (value, ty) = self.lower_expr(module, scope, subject, None, ExprMode::Ui); + let mut covered = BTreeSet::new(); + let mut wildcard = false; + let cases = cases + .iter() + .map(|case| { + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + &case.pattern, + ty.as_value(), + PatternUse::Match, + ); + self.record_pattern_coverage( + &pattern, + &mut covered, + &mut wildcard, + case.pattern.span, + ); + IrUiCase { + pattern, + children: self.lower_ui_nodes( + module, + &child, + &case.children, + machine, + ), + source: source(module, case.span), + } + }) + .collect(); + self.check_exhaustive(ty.as_value(), &covered, wildcard, node.span); + IrUiNode::Match { + value, + cases, + source: source(module, node.span), + } + } + ast::UiNodeKind::Each { + source: collection, + pattern, + key, + children, + } => { + let (value, ty) = + self.lower_expr(module, scope, collection, None, ExprMode::Ui); + if !matches!(ty.as_value(), Some(TypeRef::Seq { .. })) { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-each-source", + "UI `each` accepts only a semantically ordered `Seq`", + collection.span, + )); + } + let item = collection_item_type(ty.as_value()).unwrap_or(TypeRef::Never); + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + pattern, + Some(&item), + PatternUse::Binding, + ); + let (key, key_ty) = + self.lower_expr(module, &child, key, None, ExprMode::Ui); + if !key_ty + .as_value() + .is_some_and(|ty| self.ui_key_type(ty)) + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-each-key-type", + "UI repetition keys must be scalar or nominal scalar-key values", + node.span, + )); + } + IrUiNode::Each { + value, + pattern, + key: Box::new(key), + children: self.lower_ui_nodes(module, &child, children, machine), + source: source(module, node.span), + } + } + }) + .collect() + } + + fn check_ui_event( + &mut self, + module: &ModuleEnv<'_>, + element: &ast::UiElement, + event: &str, + span: ast::SourceSpan, + ) -> Option { + let name = element.name.value.as_str(); + let catalog = self.ui_catalog(); + let Some(spec) = catalog.element(name) else { + // A presentation-shaped tag is rejected once at the element + // boundary. Recover with Unit here so an event edge does not + // misleadingly imply that presentation invocation exists. + if matches!(module.lookup(name), Some(Export::Presentation { .. })) { + return Some(TypeRef::Unit); + } + self.diagnostics.push(error( + codes::UI, + "uhura/ui-event", + format!("`<{name}>` does not declare a checked `{event}` event"), + span, + )); + return None; + }; + match catalog.event(spec, event, ui_element_context(element)) { + UiEventContract::Admitted(payload) => Some(match payload { + UiEventPayload::Unit => TypeRef::Unit, + UiEventPayload::TextField(field) => TypeRef::Record { + fields: vec![(field.into(), TypeRef::Text)], + }, + UiEventPayload::BoundaryNumberField(field) => TypeRef::Record { + fields: vec![(field.into(), TypeRef::BoundaryNumber)], + }, + }), + UiEventContract::RequiresTextInput => { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-event", + "`on input` is admitted by text-shaped ``; use checked `on change` for ``", + span, + )); + None + } + UiEventContract::RequiresNumberInput => { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-event", + "`on change` is admitted only by ``", + span, + )); + None + } + UiEventContract::Unknown => { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-event", + format!("`<{name}>` does not declare a checked `{event}` event"), + span, + )); + None + } + } + } + + fn check_ui_element_shape( + &mut self, + module: &ModuleEnv<'_>, + element: &ast::UiElement, + span: ast::SourceSpan, + ) { + let name = element.name.value.as_str(); + let catalog = self.ui_catalog(); + let spec = catalog.element(name); + let imported_ui_element = matches!(module.lookup(name), Some(Export::UiElement)); + let imported_presentation = + matches!(module.lookup(name), Some(Export::Presentation { .. })); + let admitted = spec.is_some_and(|spec| match spec.availability { + UiElementAvailability::Native => true, + UiElementAvailability::StandardImport => imported_ui_element, + }); + if imported_presentation { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-presentation-invocation-unavailable", + format!( + "`<{name}>` resolves to a UI presentation, but presentation invocation is not part of Uhura 0.4; inline its markup or use a checked element" + ), + element.name.span, + )); + } else if !admitted { + self.diagnostics.push(error( + codes::UI, + "uhura/unknown-ui-element", + format!("`<{name}>` is not a native or imported checked UI element"), + element.name.span, + )); + } + if admitted + && spec.is_some_and(|spec| spec.content == UiContentModel::Void) + && !element.children.is_empty() + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-void-children", + format!("`<{name}>` is void and cannot have children"), + span, + )); + } + let mut seen = BTreeSet::new(); + for attribute in &element.attributes { + let identity = match &attribute.value { + ast::UiAttributeValue::Event { event, .. } => { + format!("on {}", event.value) + } + _ => attribute.name.clone(), + }; + if !seen.insert(identity.clone()) { + self.diagnostics.push(error( + codes::UI, + "uhura/duplicate-ui-attribute", + format!("UI attribute `{identity}` is repeated"), + attribute.span, + )); + } + let valid = match &attribute.value { + ast::UiAttributeValue::Event { .. } => admitted, + _ => { + admitted + && spec.is_some_and(|spec| { + catalog + .attribute(spec, &attribute.name, ui_element_context(element)) + .is_some() + }) + } + }; + if !valid { + self.diagnostics.push(error( + codes::UI, + "uhura/invalid-ui-attribute", + format!("`{}` is not valid on `<{name}>`", attribute.name), + attribute.span, + )); + } + match &attribute.value { + ast::UiAttributeValue::Text(value) => { + if self + .ui_attribute_kind(element, &attribute.name) + .is_some_and(UiAttributeKind::requires_expression) + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-attribute-type", + format!( + "attribute `{}` on `<{name}>` requires a checked expression", + attribute.name + ), + attribute.span, + )); + } + if let Some(UiAttributeKind::StaticToken(values)) = + self.ui_attribute_kind(element, &attribute.name) + && !values.contains(&value.as_str()) + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-attribute-value", + format!( + "attribute `{}` on `<{name}>` must be one of {}", + attribute.name, + values.join(", ") + ), + attribute.span, + )); + } + } + ast::UiAttributeValue::Expression(_) => { + if matches!( + self.ui_attribute_kind(element, &attribute.name), + Some(UiAttributeKind::StaticToken(_)) + ) { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-attribute-value", + format!( + "attribute `{}` on `<{name}>` requires a quoted checked token", + attribute.name + ), + attribute.span, + )); + } + } + ast::UiAttributeValue::Event { .. } => {} + } + } + if admitted { + let spec = spec.expect("admitted UI elements have a catalogue entry"); + for required in spec.required_attributes { + if !seen.contains(*required) { + self.diagnostics.push(error( + codes::UI, + "uhura/missing-ui-attribute", + format!("`<{name}>` requires `{required}`"), + span, + )); + } + } + for constraint in spec.constraints { + match constraint { + UiConstraint::ExactlyOneAttribute(attributes) => { + let alternatives = attributes + .iter() + .filter(|attribute| seen.contains(**attribute)) + .count(); + if alternatives != 1 { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-attribute-alternative", + format!( + "`<{name}>` requires exactly one of {}", + attributes + .iter() + .map(|attribute| format!("`{attribute}`")) + .collect::>() + .join(" or ") + ), + span, + )); + } + } + UiConstraint::Controlled { attribute, event } + if seen.contains(*attribute) && !has_ui_event(element, event) => + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-controlled-field", + format!("`<{name} {attribute}={{...}}>` must handle `{event}`"), + span, + )); + } + UiConstraint::Controlled { .. } => {} + UiConstraint::AccessibleName { attributes } + if !attributes.iter().any(|attribute| seen.contains(*attribute)) + && !ui_nodes_have_accessible_text(&element.children) => + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-accessible-name", + format!( + "`<{name}>` requires visible text{}", + if attributes.is_empty() { + String::new() + } else { + format!( + " or one of {}", + attributes + .iter() + .map(|attribute| format!("`{attribute}`")) + .collect::>() + .join(", ") + ) + } + ), + span, + )); + } + UiConstraint::AccessibleName { .. } => {} + UiConstraint::NoInteractiveDescendants + if ui_nodes_contain_interactive_element(&element.children, catalog) => + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-nested-interactive", + format!("`<{name}>` cannot contain another interactive element"), + span, + )); + } + UiConstraint::NoInteractiveDescendants => {} + UiConstraint::AtLeastOneEvent(events) + if !events.iter().any(|event| has_ui_event(element, event)) => + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-missing-event", + format!( + "`<{name}>` requires one of {}", + events + .iter() + .map(|event| format!("`on {event}`")) + .collect::>() + .join(" or ") + ), + span, + )); + } + UiConstraint::AtLeastOneEvent(_) => {} + UiConstraint::NeutralListItems { + element: item_element, + } if ui_element_has_text_attribute(element, "role", "list") + && !ui_nodes_are_neutral_list_items(&element.children, item_element) => + { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-list-item-boundary", + "`` requires each rendered direct child to be an unroled ``; nest buttons, regions, and other semantics inside that boundary", + span, + )); + } + UiConstraint::NeutralListItems { .. } => {} + } + } + } + } + + fn check_ui_attribute_type( + &mut self, + element: &ast::UiElement, + attribute: &str, + ty: &Ty, + span: ast::SourceSpan, + ) { + let valid = match self.ui_attribute_kind(element, attribute) { + Some(UiAttributeKind::Text | UiAttributeKind::StaticToken(_)) => { + matches!(ty.as_value(), Some(TypeRef::Text)) + } + Some(UiAttributeKind::Bool) => { + matches!(ty.as_value(), Some(TypeRef::Bool)) + } + Some(UiAttributeKind::ExactNumeric) => { + ty.as_value().is_some_and(|ty| self.exact_numeric_type(ty)) + } + Some(UiAttributeKind::Ratio) => { + matches!(ty.as_value(), Some(TypeRef::Ratio)) + } + Some(UiAttributeKind::Key) => ty.as_value().is_some_and(|ty| self.ui_key_type(ty)), + Some(UiAttributeKind::CheckedExpression) => true, + None => true, + }; + if !valid { + self.diagnostics.push(error( + codes::UI, + "uhura/ui-attribute-type", + format!( + "attribute `{attribute}` has invalid type `{}`", + ty.display() + ), + span, + )); + } + } + + fn ui_catalog(&self) -> ui_catalog::Catalog { + ui_catalog::current() + } + + fn ui_attribute_kind( + &self, + element: &ast::UiElement, + attribute: &str, + ) -> Option { + let catalog = self.ui_catalog(); + let spec = catalog.element(&element.name.value)?; + catalog.attribute(spec, attribute, ui_element_context(element)) + } + + fn ui_attribute_expected_type( + &self, + element: &ast::UiElement, + attribute: &str, + ) -> Option { + match self.ui_attribute_kind(element, attribute) { + Some(UiAttributeKind::Text | UiAttributeKind::StaticToken(_)) => Some(TypeRef::Text), + Some(UiAttributeKind::Bool) => Some(TypeRef::Bool), + Some(UiAttributeKind::Ratio) => Some(TypeRef::Ratio), + Some( + UiAttributeKind::ExactNumeric + | UiAttributeKind::CheckedExpression + | UiAttributeKind::Key, + ) + | None => None, + } + } + + fn exact_numeric_type(&self, ty: &TypeRef) -> bool { + matches!( + ty, + TypeRef::Int | TypeRef::Nat | TypeRef::PositiveInt | TypeRef::Decimal | TypeRef::Ratio + ) + } + + fn ui_scalar_type(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Text | TypeRef::Bool) + || self.exact_numeric_type(ty) + || matches!( + self.registry.shape(ty), + Some(TypeShape::Key( + TypeRef::Text + | TypeRef::Bool + | TypeRef::Int + | TypeRef::Nat + | TypeRef::PositiveInt + | TypeRef::Decimal + | TypeRef::Ratio + )) + ) + } + + fn ui_key_type(&self, ty: &TypeRef) -> bool { + self.ui_scalar_type(ty) + || matches!( + self.registry.shape(ty), + Some(TypeShape::Sum(constructors)) + if constructors.iter().all(|constructor| constructor.fields.is_empty()) + ) + } +} + +fn has_ui_event(element: &ast::UiElement, event_name: &str) -> bool { + element.attributes.iter().any(|attribute| { + matches!( + &attribute.value, + ast::UiAttributeValue::Event { event, .. } if event.value == event_name + ) + }) +} + +fn ui_element_has_text_attribute( + element: &ast::UiElement, + attribute_name: &str, + expected: &str, +) -> bool { + element.attributes.iter().any(|attribute| { + attribute.name == attribute_name + && matches!( + &attribute.value, + ast::UiAttributeValue::Text(value) if value == expected + ) + }) +} + +fn ui_nodes_are_neutral_list_items(nodes: &[ast::UiNode], item_element: &str) -> bool { + nodes.iter().all(|node| match &node.value { + ast::UiNodeKind::Text(value) => value.trim().is_empty(), + ast::UiNodeKind::Element(element) => { + element.name.value == item_element + && !element + .attributes + .iter() + .any(|attribute| attribute.name == "role") + } + ast::UiNodeKind::If { children, .. } | ast::UiNodeKind::Each { children, .. } => { + ui_nodes_are_neutral_list_items(children, item_element) + } + ast::UiNodeKind::Match { cases, .. } => cases + .iter() + .all(|case| ui_nodes_are_neutral_list_items(&case.children, item_element)), + ast::UiNodeKind::Interpolation(_) => false, + }) +} + +fn ui_nodes_have_accessible_text(nodes: &[ast::UiNode]) -> bool { + nodes.iter().any(|node| match &node.value { + ast::UiNodeKind::Text(value) => !value.trim().is_empty(), + ast::UiNodeKind::Interpolation(_) => true, + ast::UiNodeKind::Element(element) => ui_nodes_have_accessible_text(&element.children), + ast::UiNodeKind::If { children, .. } | ast::UiNodeKind::Each { children, .. } => { + ui_nodes_have_accessible_text(children) + } + ast::UiNodeKind::Match { cases, .. } => cases + .iter() + .any(|case| ui_nodes_have_accessible_text(&case.children)), + }) +} + +fn ui_nodes_contain_interactive_element( + nodes: &[ast::UiNode], + catalog: ui_catalog::Catalog, +) -> bool { + nodes.iter().any(|node| match &node.value { + ast::UiNodeKind::Element(element) => { + catalog.is_interactive(element.name.value.as_str()) + || ui_nodes_contain_interactive_element(&element.children, catalog) + } + ast::UiNodeKind::If { children, .. } | ast::UiNodeKind::Each { children, .. } => { + ui_nodes_contain_interactive_element(children, catalog) + } + ast::UiNodeKind::Match { cases, .. } => cases + .iter() + .any(|case| ui_nodes_contain_interactive_element(&case.children, catalog)), + ast::UiNodeKind::Text(_) | ast::UiNodeKind::Interpolation(_) => false, + }) +} + +fn ui_element_context(element: &ast::UiElement) -> UiElementContext { + UiElementContext { + static_number_input: input_is_static_number(element), + } +} + +fn input_is_static_number(element: &ast::UiElement) -> bool { + element.attributes.iter().any(|attribute| { + attribute.name == "type" + && matches!( + &attribute.value, + ast::UiAttributeValue::Text(value) if value == "number" + ) + }) +} + +impl Checker<'_> { + fn lower_evidence(&mut self) { + let deferred = self.evidence.clone(); + // Alias targets are installed first so a replay scenario can originate + // from a checkpoint declared later in source order. + for value in &deferred { + let Some(module) = self.modules.get(&value.module).cloned() else { + continue; + }; + match &value.declaration.value { + ast::DeclarationKind::Example(alias) => { + if !module.features.contains("evidence") { + self.evidence_feature_error(&module, value.declaration.span); + } + if let Some(reference) = self.lower_evidence_ref(&module, &alias.target) { + let id = qualify(&module.id, &alias.name.value); + let presentation = alias + .presentation + .as_ref() + .and_then(|name| self.resolve_presentation(&module, name)); + let kind = alias.kind.map(|kind| match kind { + ast::EvidencePresentationKind::Page => EvidencePresentationKind::Page, + ast::EvidencePresentationKind::Component => { + EvidencePresentationKind::Component + } + ast::EvidencePresentationKind::Surface => { + EvidencePresentationKind::Surface + } + }); + if alias.is_default + && presentation.as_ref().is_some_and(|presentation| { + self.program + .evidence + .example_metadata + .values() + .any(|metadata| { + metadata.is_default + && metadata.presentation.as_ref() == Some(presentation) + }) + }) + { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/duplicate-default-example", + format!( + "presentation `{}` has more than one default example", + presentation.as_deref().unwrap_or("") + ), + value.declaration.span, + )); + } + self.program.evidence.examples.insert(id.clone(), reference); + self.program.evidence.example_metadata.insert( + id.clone(), + EvidenceExampleMetadata { + presentation, + kind, + is_default: alias.is_default, + note: alias.note.clone(), + }, + ); + self.program + .evidence + .example_sources + .insert(id, source(&module, value.declaration.span)); + } + } + ast::DeclarationKind::Checkpoint(alias) => { + if !module.features.contains("evidence") { + self.evidence_feature_error(&module, value.declaration.span); + } + if let Some(reference) = self.lower_evidence_ref(&module, &alias.target) { + let id = qualify(&module.id, &alias.name.value); + self.program + .evidence + .checkpoints + .insert(id.clone(), reference); + self.program + .evidence + .checkpoint_sources + .insert(id, source(&module, value.declaration.span)); + } + } + _ => {} + } + } + + for value in deferred { + let ast::DeclarationKind::Scenario(scenario) = &value.declaration.value else { + continue; + }; + let Some(module) = self.modules.get(&value.module).cloned() else { + continue; + }; + if !module.features.contains("evidence") { + self.evidence_feature_error(&module, value.declaration.span); + } + let (machine_id, snapshot_reference) = match &scenario.origin { + ast::ScenarioOrigin::Machine { machine, .. } => { + let Some(machine) = self.resolve_machine(&module, machine) else { + continue; + }; + (machine, None) + } + ast::ScenarioOrigin::Snapshot(reference) => { + let Some(reference) = self.lower_evidence_ref(&module, reference) else { + continue; + }; + let reference = self.expand_checkpoint_reference(&module, reference); + let machine = self + .scenario_machine(&reference.scenario) + .unwrap_or_else(|| "".into()); + (machine, Some(reference)) + } + }; + let Some(machine) = self + .program + .machine_program + .machines + .get(&machine_id) + .cloned() + else { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/evidence-machine", + format!("scenario machine `{machine_id}` is unavailable"), + value.declaration.span, + )); + continue; + }; + let mut scope = self.module_scope(&module); + self.populate_scope_constructors(&mut scope); + let origin = match (&scenario.origin, snapshot_reference) { + ( + ast::ScenarioOrigin::Machine { + machine: machine_name, + configuration, + }, + None, + ) => { + let configuration = match configuration { + None if machine.config == TypeRef::Unit => Value::Unit, + None => { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/missing-scenario-configuration", + format!( + "scenario for `{}` requires a compile-time configuration of type `{}`; write `for {}(...)`", + machine.id, + machine.config.canonical_name(), + machine_name.value, + ), + machine_name.span, + )); + Value::Unit + } + Some(configuration) => { + let diagnostics_before = self.diagnostics.len(); + let (expression, actual) = self.lower_expr( + &module, + &scope, + configuration, + Some(&machine.config), + ExprMode::Pure, + ); + self.expect_type(&actual, &machine.config, configuration.span); + if self.diagnostics.len() != diagnostics_before { + Value::Unit + } else { + match const_eval(&expression, &self.program) { + Ok(value) => match self + .program + .machine_program + .canonicalize_value(&machine.config, &value) + { + Ok(value) => value, + Err(value_error) => { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/invalid-scenario-configuration", + format!( + "scenario configuration for `{}` is invalid: {value_error}", + machine.id + ), + configuration.span, + )); + Value::Unit + } + }, + Err(message) => { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/non-constant-scenario-configuration", + format!( + "scenario configuration for `{}` is not compile-time total: {message}", + machine.id + ), + configuration.span, + )); + Value::Unit + } + } + } + } + }; + IrScenarioOrigin::Machine { + machine: machine_id.clone(), + configuration, + } + } + (ast::ScenarioOrigin::Snapshot(_), Some(reference)) => { + IrScenarioOrigin::Snapshot { reference } + } + _ => unreachable!("scenario origin lowering preserves its source form"), + }; + self.install_machine_io_scope(&machine, &mut scope); + let observation = TypeRef::Record { + fields: machine + .observation + .iter() + .map(|field| (field.name.clone(), field.ty.clone())) + .collect(), + }; + let inspection = TypeRef::Record { + fields: machine + .state + .iter() + .map(|field| (field.name.clone(), field.ty.clone())) + .chain( + machine + .derives + .iter() + .map(|(name, ty, _, _)| (name.clone(), ty.clone())), + ) + .collect(), + }; + let mut pins = BTreeSet::new(); + let steps = scenario + .steps + .iter() + .map(|step| match &step.value { + ast::EvidenceStepKind::Bind { port, fixture } => { + if !machine.ports.iter().any(|value| value.name == port.value) { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/evidence-port", + format!("machine `{}` has no port `{}`", machine.id, port.value), + port.span, + )); + } + let (fixture, _) = + self.lower_expr(&module, &scope, fixture, None, ExprMode::Evidence); + IrEvidenceStep::Bind { + port: port.value.clone(), + fixture, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::Start => IrEvidenceStep::Start { + source: source(&module, step.span), + }, + ast::EvidenceStepKind::Send(input) => { + let (input, actual) = self.lower_expr( + &module, + &scope, + input, + scope.input_type.as_ref(), + ExprMode::Evidence, + ); + if let Some(expected) = &scope.input_type { + self.expect_type(&actual, expected, step.span); + } + IrEvidenceStep::Send { + input, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::Deliver(input) => { + let (input, _) = + self.lower_expr(&module, &scope, input, None, ExprMode::Evidence); + IrEvidenceStep::Deliver { + input, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::ExpectReaction { outcome, commands } => { + let mut pattern_scope = scope.child(); + let outcome = self.lower_pattern( + &module, + &mut pattern_scope, + outcome, + scope.outcome_type.as_ref(), + PatternUse::Evidence, + ); + let commands = commands + .iter() + .map(|command| { + self.lower_expr(&module, &scope, command, None, ExprMode::Evidence) + .0 + }) + .collect(); + IrEvidenceStep::ExpectReaction { + outcome, + commands, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::ExpectObservationPattern(pattern) => { + let mut pattern_scope = scope.child(); + let pattern = self.lower_pattern( + &module, + &mut pattern_scope, + pattern, + Some(&observation), + PatternUse::Evidence, + ); + IrEvidenceStep::ExpectObservationPattern { + pattern, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::ExpectInspectionPattern(pattern) => { + let mut pattern_scope = scope.child(); + let pattern = self.lower_pattern( + &module, + &mut pattern_scope, + pattern, + Some(&inspection), + PatternUse::Evidence, + ); + IrEvidenceStep::ExpectInspectionPattern { + pattern, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::ExpectObservationWhere(condition) => { + let mut observation_scope = scope.child(); + for field in &machine.observation { + observation_scope.bind( + &field.name, + &field.name, + Ty::value(field.ty.clone()), + ); + } + let (condition, _) = self.lower_condition( + &module, + &observation_scope, + condition, + ExprMode::Evidence, + ); + IrEvidenceStep::ExpectObservationWhere { + condition, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::ExpectRestore { commands } => { + let commands = commands + .iter() + .map(|command| { + self.lower_expr(&module, &scope, command, None, ExprMode::Evidence) + .0 + }) + .collect(); + IrEvidenceStep::ExpectRestore { + commands, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::ExpectSnapshot { target } => { + let reference = + self.lower_evidence_ref(&module, target) + .unwrap_or(IrEvidenceRef { + scenario: "".into(), + pin: "".into(), + }); + IrEvidenceStep::ExpectSnapshot { + reference, + source: source(&module, step.span), + } + } + ast::EvidenceStepKind::Pin(name) => { + if !pins.insert(name.value.clone()) { + self.diagnostics.push(error( + codes::DUPLICATE, + "uhura/duplicate-pin", + format!("scenario pin `{}` is repeated", name.value), + name.span, + )); + } + IrEvidenceStep::Pin { + name: name.value.clone(), + source: source(&module, step.span), + } + } + }) + .collect(); + let id = qualify(&module.id, &scenario.name.value); + self.program.evidence.scenarios.insert( + id.clone(), + IrScenario { + id, + origin, + steps, + source: source(&module, value.declaration.span), + }, + ); + } + + // An editor example is evidence for one machine snapshot and, when it + // names a presentation, that presentation must consume the same + // machine. Keeping this invariant in the checked program prevents the + // host from guessing or producing a presentation × example product. + for value in &self.evidence.clone() { + let ast::DeclarationKind::Example(alias) = &value.declaration.value else { + continue; + }; + let Some(module) = self.modules.get(&value.module).cloned() else { + continue; + }; + let example_id = qualify(&module.id, &alias.name.value); + let Some(metadata) = self + .program + .evidence + .example_metadata + .get(&example_id) + .cloned() + else { + continue; + }; + let Some(presentation_id) = metadata.presentation else { + continue; + }; + let Some(reference) = self.program.evidence.examples.get(&example_id) else { + continue; + }; + let Some(machine_id) = self.scenario_machine(&reference.scenario) else { + continue; + }; + let Some(presentation) = self.program.presentations.get(&presentation_id) else { + continue; + }; + if presentation.machine != machine_id { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/example-presentation-machine", + format!( + "example `{example_id}` snapshots machine `{machine_id}`, but presentation `{presentation_id}` targets `{}`", + presentation.machine + ), + value.declaration.span, + )); + } + } + } + + fn evidence_feature_error(&mut self, module: &ModuleEnv<'_>, span: ast::SourceSpan) { + self.diagnostics.push(error( + codes::EVIDENCE_NOT_ENABLED, + "uhura/evidence-without-use", + format!( + "evidence declarations in `{}` require `use evidence`", + module.id + ), + span, + )); + } + + fn lower_evidence_ref( + &mut self, + module: &ModuleEnv<'_>, + reference: &ast::EvidenceRef, + ) -> Option { + let parts = reference + .path + .iter() + .map(|part| part.value.clone()) + .collect::>(); + match parts.as_slice() { + [scenario, pin] => Some(IrEvidenceRef { + scenario: qualify(&module.id, scenario), + pin: pin.clone(), + }), + [checkpoint] => { + let id = qualify(&module.id, checkpoint); + self.program + .evidence + .checkpoints + .get(&id) + .cloned() + .or_else(|| { + Some(IrEvidenceRef { + scenario: id, + pin: checkpoint.clone(), + }) + }) + } + _ => { + self.diagnostics.push(error( + codes::EVIDENCE, + "uhura/evidence-reference", + "evidence references must be `scenario::pin` or a checkpoint name", + reference.span, + )); + None + } + } + } + + fn expand_checkpoint_reference( + &self, + module: &ModuleEnv<'_>, + reference: IrEvidenceRef, + ) -> IrEvidenceRef { + self.program + .evidence + .checkpoints + .get(&reference.scenario) + .cloned() + .or_else(|| { + self.program + .evidence + .checkpoints + .get(&qualify(&module.id, &reference.pin)) + .cloned() + }) + .unwrap_or(reference) + } + + fn scenario_machine(&self, scenario: &str) -> Option { + let value = self.program.evidence.scenarios.get(scenario)?; + match &value.origin { + IrScenarioOrigin::Machine { machine, .. } => Some(machine.clone()), + IrScenarioOrigin::Snapshot { reference } => self.scenario_machine(&reference.scenario), + } + } +} + +impl Checker<'_> { + fn lower_reaction_block( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + block: &ast::Block, + outcome: &TypeRef, + continuation: Vec, + ) -> Vec { + self.lower_reaction_sequence(module, scope, &block.statements, 0, outcome, continuation) + } + + fn lower_reaction_sequence( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + statements: &[ast::Statement], + index: usize, + outcome: &TypeRef, + continuation: Vec, + ) -> Vec { + let Some(statement) = statements.get(index) else { + return continuation; + }; + match &statement.value { + ast::StatementKind::Let { name, ty, value } => { + let expected = ty.as_ref().map(|ty| self.resolve_type(module, scope, ty)); + self.lower_bind_control_tail( + module, + scope, + &name.value, + expected.as_ref(), + value, + outcome, + statements, + index + 1, + continuation, + statement.span, + ) + } + ast::StatementKind::Set { target, value } => { + let expected = scope.state_fields.get(&target.value).cloned(); + if expected.is_none() { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/set-non-state", + format!("`set` target `{}` is not a state field", target.value), + target.span, + )); + } + if reaction_control(value) { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/control-in-set", + "terminal control cannot appear inside a state assignment value", + value.span, + )); + } + let (value, actual) = + self.lower_expr(module, scope, value, expected.as_ref(), ExprMode::Reaction); + if let Some(expected) = &expected { + self.expect_type(&actual, expected, statement.span); + } + let mut rest_scope = scope.child(); + rest_scope.invalidate_path(&target.value); + let type_minimum = match expected.as_ref() { + Some(TypeRef::Nat) => Some(0), + Some(TypeRef::PositiveInt) => Some(1), + _ => None, + }; + let assigned_minimum = integer_lower_bound(&value, scope).or(type_minimum); + if assigned_minimum.is_some() { + rest_scope.numeric_bounds.insert( + target.value.clone(), + NumericBounds { + min: assigned_minimum, + max: None, + }, + ); + } + let rest = self.lower_reaction_sequence( + module, + &rest_scope, + statements, + index + 1, + outcome, + continuation, + ); + let mut output = vec![Statement::Set { + field: target.value.clone(), + value, + source: source(module, statement.span), + }]; + output.extend(rest); + output + } + ast::StatementKind::Emit(value) => { + let expected = scope.command_type.as_ref(); + let expression_expected = if is_qualified_call(value) { + None + } else { + expected + }; + let (value, actual) = self.lower_expr( + module, + scope, + value, + expression_expected, + ExprMode::Reaction, + ); + if let Some(expected) = expected { + // Qualified port sends have their own nominal identity and + // are admitted alongside the local command sum. + let is_port = matches!(&value, IrExpr::Constructor { constructor, .. } if constructor.contains('.')); + if !is_port { + self.expect_type(&actual, expected, statement.span); + } + } + let rest = self.lower_reaction_sequence( + module, + scope, + statements, + index + 1, + outcome, + continuation, + ); + let mut output = vec![Statement::Emit { + value, + source: source(module, statement.span), + }]; + output.extend(rest); + output + } + ast::StatementKind::While { + condition, + decreases, + body, + } => { + let break_local = inline_update_loop_exit_local(body); + if !loop_decrease_proven(condition, decreases, body) { + self.diagnostics.push(error( + codes::TERMINATION, + "uhura/unproved-loop-decrease", + "`while` measure is not proved to decrease strictly on every back edge", + statement.span, + )); + } + let (condition, loop_scope) = + self.lower_condition(module, scope, condition, ExprMode::Reaction); + let (_, ty) = self.lower_expr( + module, + &loop_scope, + decreases, + Some(&TypeRef::Nat), + ExprMode::Reaction, + ); + if !matches!( + ty.as_value(), + Some(TypeRef::Int | TypeRef::Nat | TypeRef::PositiveInt) + ) { + self.diagnostics.push(error( + codes::TERMINATION, + "uhura/loop-measure", + "`decreases` must be an exact non-negative integer expression", + decreases.span, + )); + } + let body = + self.lower_reaction_block(module, &loop_scope, body, outcome, Vec::new()); + let rest = self.lower_reaction_sequence( + module, + scope, + statements, + index + 1, + outcome, + continuation, + ); + let mut output = vec![Statement::While { + condition, + body, + break_local, + source: source(module, statement.span), + }]; + output.extend(rest); + output + } + ast::StatementKind::Expr(expression) + if !guaranteed_update_joins(expression).is_empty() => + { + let joins = guaranteed_update_joins(expression); + let mut joined_scope = scope.child(); + for (name, ty) in joins { + let ty = self.resolve_type(module, scope, &ty); + joined_scope.bind(&name, &name, Ty::value(ty)); + } + let mut output = + self.lower_reaction_expression(module, scope, expression, outcome, Vec::new()); + output.extend(self.lower_reaction_sequence( + module, + &joined_scope, + statements, + index + 1, + outcome, + continuation, + )); + output + } + ast::StatementKind::Expr( + expression @ ast::Spanned { + value: + ast::ExprKind::If { + condition, + then_branch, + else_branch, + }, + .. + }, + ) if reaction_control(expression) => { + let (condition_ir, then_scope) = + self.lower_condition(module, scope, condition, ExprMode::Reaction); + let else_scope = refined_numeric_scope(scope, condition, false, &self.registry); + let then_continuation = if source_expr_terminal(then_branch) { + Vec::new() + } else { + self.lower_reaction_sequence( + module, + &then_scope, + statements, + index + 1, + outcome, + continuation.clone(), + ) + }; + let else_continuation = self.lower_reaction_sequence( + module, + &else_scope, + statements, + index + 1, + outcome, + continuation, + ); + let then_body = self.lower_reaction_expression( + module, + &then_scope, + then_branch, + outcome, + then_continuation, + ); + let else_body = else_branch + .as_ref() + .map_or(else_continuation.clone(), |branch| { + self.lower_reaction_expression( + module, + &else_scope, + branch, + outcome, + else_continuation, + ) + }); + vec![Statement::If { + condition: condition_ir, + then_body, + else_body, + source: source(module, expression.span), + }] + } + ast::StatementKind::Expr(expression) => { + let rest = self.lower_reaction_sequence( + module, + scope, + statements, + index + 1, + outcome, + continuation, + ); + self.lower_reaction_expression(module, scope, expression, outcome, rest) + } + } + } + + fn lower_bind_control_tail( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + name: &str, + annotation: Option<&TypeRef>, + expression: &ast::Expr, + outcome: &TypeRef, + statements: &[ast::Statement], + next_index: usize, + continuation: Vec, + statement_span: ast::SourceSpan, + ) -> Vec { + match &expression.value { + ast::ExprKind::Match { subject, arms } if reaction_control(expression) => { + let (value, subject_ty) = + self.lower_expr(module, scope, subject, None, ExprMode::Reaction); + let inferred_annotation = annotation.cloned().or_else(|| { + self.probe_match_result( + module, + scope, + arms, + subject_ty.as_value(), + ExprMode::Reaction, + ) + }); + let annotation = inferred_annotation.as_ref(); + let mut covered = BTreeSet::new(); + let mut wildcard = false; + let arms = arms + .iter() + .map(|arm| { + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + &arm.pattern, + subject_ty.as_value(), + PatternUse::Match, + ); + self.record_pattern_coverage( + &pattern, + &mut covered, + &mut wildcard, + arm.pattern.span, + ); + let body = self.lower_bind_branch_tail( + module, + &child, + scope, + name, + annotation, + &arm.body, + outcome, + statements, + next_index, + continuation.clone(), + statement_span, + ); + StatementMatchArm { pattern, body } + }) + .collect(); + self.check_exhaustive(subject_ty.as_value(), &covered, wildcard, expression.span); + vec![Statement::Match { + value, + arms, + source: source(module, expression.span), + }] + } + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } if reaction_control(expression) => { + let (condition, refined) = + self.lower_condition(module, scope, condition, ExprMode::Reaction); + let then_body = self.lower_bind_branch_tail( + module, + &refined, + scope, + name, + annotation, + then_branch, + outcome, + statements, + next_index, + continuation.clone(), + statement_span, + ); + let else_body = else_branch.as_ref().map_or_else( + || continuation.clone(), + |branch| { + self.lower_bind_branch_tail( + module, + scope, + scope, + name, + annotation, + branch, + outcome, + statements, + next_index, + continuation.clone(), + statement_span, + ) + }, + ); + vec![Statement::If { + condition, + then_body, + else_body, + source: source(module, expression.span), + }] + } + _ => { + let (value, actual) = + self.lower_expr(module, scope, expression, annotation, ExprMode::Reaction); + let ty = annotation + .cloned() + .or_else(|| actual.clone().into_value()) + .unwrap_or(TypeRef::Never); + let mut child = scope.child(); + child.bind(name, name, Ty::value(ty)); + let rest = self.lower_reaction_sequence( + module, + &child, + statements, + next_index, + outcome, + continuation, + ); + let mut output = vec![Statement::Let { + name: name.into(), + value, + source: source(module, statement_span), + }]; + output.extend(rest); + output + } + } + } + + fn lower_bind_branch_tail( + &mut self, + module: &ModuleEnv<'_>, + value_scope: &Scope, + tail_scope: &Scope, + name: &str, + annotation: Option<&TypeRef>, + expression: &ast::Expr, + outcome: &TypeRef, + statements: &[ast::Statement], + next_index: usize, + continuation: Vec, + statement_span: ast::SourceSpan, + ) -> Vec { + if reaction_control(expression) { + let tail = if source_expr_terminal(expression) { + Vec::new() + } else { + self.lower_reaction_sequence( + module, + tail_scope, + statements, + next_index, + outcome, + continuation, + ) + }; + return self.lower_reaction_expression(module, value_scope, expression, outcome, tail); + } + let (value, actual) = self.lower_expr( + module, + value_scope, + expression, + annotation, + ExprMode::Reaction, + ); + if let Some(annotation) = annotation { + self.expect_type(&actual, annotation, expression.span); + } + let ty = annotation + .cloned() + .or_else(|| actual.into_value()) + .unwrap_or(TypeRef::Never); + let mut child = tail_scope.child(); + child.bind(name, name, Ty::value(ty)); + let rest = self.lower_reaction_sequence( + module, + &child, + statements, + next_index, + outcome, + continuation, + ); + let mut output = vec![Statement::Let { + name: name.into(), + value, + source: source(module, statement_span), + }]; + output.extend(rest); + output + } + + fn lower_reaction_expression( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + expression: &ast::Expr, + outcome: &TypeRef, + continuation: Vec, + ) -> Vec { + match &expression.value { + ast::ExprKind::Finish(value) => { + self.lower_finish_value(module, scope, value, outcome, expression.span) + } + ast::ExprKind::Unreachable => vec![Statement::Unreachable { + source: source(module, expression.span), + }], + ast::ExprKind::Block(block) => { + self.lower_reaction_block(module, scope, block, outcome, continuation) + } + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } => { + let (condition, refined) = + self.lower_condition(module, scope, condition, ExprMode::Reaction); + let then_body = self.lower_reaction_expression( + module, + &refined, + then_branch, + outcome, + continuation.clone(), + ); + let else_body = else_branch.as_ref().map_or_else( + || continuation.clone(), + |branch| { + self.lower_reaction_expression( + module, + scope, + branch, + outcome, + continuation.clone(), + ) + }, + ); + vec![Statement::If { + condition, + then_body, + else_body, + source: source(module, expression.span), + }] + } + ast::ExprKind::Match { subject, arms } => { + let (value, subject_ty) = + self.lower_expr(module, scope, subject, None, ExprMode::Reaction); + let mut covered = BTreeSet::new(); + let mut wildcard = false; + let arms = arms + .iter() + .map(|arm| { + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + &arm.pattern, + subject_ty.as_value(), + PatternUse::Match, + ); + self.record_pattern_coverage( + &pattern, + &mut covered, + &mut wildcard, + arm.pattern.span, + ); + let body = self.lower_reaction_expression( + module, + &child, + &arm.body, + outcome, + continuation.clone(), + ); + StatementMatchArm { pattern, body } + }) + .collect(); + self.check_exhaustive(subject_ty.as_value(), &covered, wildcard, expression.span); + vec![Statement::Match { + value, + arms, + source: source(module, expression.span), + }] + } + _ => { + self.diagnostics.push(error( + codes::EFFECT, + "uhura/discarded-pure-expression", + "a pure value cannot be used as a reaction statement; bind it or finish with an outcome", + expression.span, + )); + continuation + } + } + } + + fn lower_finish_value( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + value: &ast::Expr, + outcome: &TypeRef, + finish_span: ast::SourceSpan, + ) -> Vec { + if let ast::ExprKind::Call { callee, arguments } = &value.value + && let ast::ExprKind::Name(name) = &callee.value + && let Some((index, controlled)) = arguments + .iter() + .enumerate() + .find(|(_, argument)| reaction_control(argument)) + && let Ok(constructor) = self.resolve_constructor(scope, &name.value, Some(outcome)) + { + return self.lower_finish_constructor_control( + module, + scope, + constructor, + arguments, + index, + controlled, + outcome, + finish_span, + ); + } + let value_span = value.span; + let (value, actual) = + self.lower_expr(module, scope, value, Some(outcome), ExprMode::Reaction); + self.expect_type(&actual, outcome, value_span); + vec![Statement::Finish { + outcome: value, + source: source(module, finish_span), + }] + } + + #[allow(clippy::too_many_arguments)] + fn lower_finish_constructor_control( + &mut self, + module: &ModuleEnv<'_>, + scope: &Scope, + constructor: ConstructorInfo, + arguments: &[ast::Expr], + controlled_index: usize, + controlled: &ast::Expr, + outcome: &TypeRef, + finish_span: ast::SourceSpan, + ) -> Vec { + match &controlled.value { + ast::ExprKind::Match { subject, arms } => { + let (subject, subject_ty) = + self.lower_expr(module, scope, subject, None, ExprMode::Reaction); + let mut covered = BTreeSet::new(); + let mut wildcard = false; + let arms = arms + .iter() + .map(|arm| { + let mut child = scope.child(); + let pattern = self.lower_pattern( + module, + &mut child, + &arm.pattern, + subject_ty.as_value(), + PatternUse::Match, + ); + self.record_pattern_coverage( + &pattern, + &mut covered, + &mut wildcard, + arm.pattern.span, + ); + let body = if matches!(arm.body.value, ast::ExprKind::Unreachable) { + vec![Statement::Unreachable { + source: source(module, arm.body.span), + }] + } else { + let fields = arguments + .iter() + .enumerate() + .map(|(index, argument)| { + let argument = if index == controlled_index { + &arm.body + } else { + argument + }; + let expected = constructor.fields.get(index).map(|(_, ty)| ty); + let (value, actual) = self.lower_expr( + module, + &child, + argument, + expected, + ExprMode::Reaction, + ); + if let Some(expected) = expected { + self.expect_type(&actual, expected, argument.span); + } + ( + constructor + .fields + .get(index) + .and_then(|(name, _)| name.clone()), + value, + ) + }) + .collect(); + vec![Statement::Finish { + outcome: IrExpr::Constructor { + type_id: constructor.type_id.clone(), + constructor: constructor.name.clone(), + fields, + }, + source: source(module, finish_span), + }] + }; + StatementMatchArm { pattern, body } + }) + .collect(); + self.check_exhaustive(subject_ty.as_value(), &covered, wildcard, controlled.span); + vec![Statement::Match { + value: subject, + arms, + source: source(module, controlled.span), + }] + } + ast::ExprKind::Unreachable => vec![Statement::Unreachable { + source: source(module, controlled.span), + }], + _ => { + self.diagnostics.push(error( + codes::UNSUPPORTED, + "uhura/nested-terminal-control", + "nested terminal control is supported only through a finite match", + controlled.span, + )); + let _ = outcome; + Vec::new() + } + } + } +} + +fn materialize_pure_continuation_bindings( + expression: IrExpr, + compiled: &BTreeMap, + expanded: &mut BTreeMap, +) -> IrExpr { + fn continuation( + name: &str, + compiled: &BTreeMap, + expanded: &mut BTreeMap, + ) -> IrExpr { + if let Some(expression) = expanded.get(name) { + return expression.clone(); + } + let expression = compiled + .get(name) + .cloned() + .expect("every generated continuation binding is compiled"); + let expression = materialize_pure_continuation_bindings(expression, compiled, expanded); + expanded.insert(name.into(), expression.clone()); + expression + } + + match expression { + IrExpr::Constructor { + type_id, + constructor: constructor_name, + fields, + } => IrExpr::Constructor { + type_id, + constructor: constructor_name, + fields: fields + .into_iter() + .map(|(name, value)| { + ( + name, + materialize_pure_continuation_bindings(value, compiled, expanded), + ) + }) + .collect(), + }, + IrExpr::Key { type_id, value } => IrExpr::Key { + type_id, + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + }, + IrExpr::Tuple { values } => IrExpr::Tuple { + values: values + .into_iter() + .map(|value| materialize_pure_continuation_bindings(value, compiled, expanded)) + .collect(), + }, + IrExpr::Record { fields } => IrExpr::Record { + fields: fields + .into_iter() + .map(|(name, value)| { + ( + name, + materialize_pure_continuation_bindings(value, compiled, expanded), + ) + }) + .collect(), + }, + IrExpr::Seq { values } => IrExpr::Seq { + values: values + .into_iter() + .map(|value| materialize_pure_continuation_bindings(value, compiled, expanded)) + .collect(), + }, + IrExpr::Map { + entries, + result_type, + } => IrExpr::Map { + entries: entries + .into_iter() + .map(|(key, value)| { + ( + materialize_pure_continuation_bindings(key, compiled, expanded), + materialize_pure_continuation_bindings(value, compiled, expanded), + ) + }) + .collect(), + result_type, + }, + IrExpr::Table { key_type, entries } => IrExpr::Table { + key_type, + entries: entries + .into_iter() + .map(|(name, value)| { + ( + name, + materialize_pure_continuation_bindings(value, compiled, expanded), + ) + }) + .collect(), + }, + IrExpr::Unary { op, value } => IrExpr::Unary { + op, + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + }, + IrExpr::Binary { op, left, right } => IrExpr::Binary { + op, + left: Box::new(materialize_pure_continuation_bindings( + *left, compiled, expanded, + )), + right: Box::new(materialize_pure_continuation_bindings( + *right, compiled, expanded, + )), + }, + IrExpr::Call { + function, + args, + result_type, + } => IrExpr::Call { + function, + args: args + .into_iter() + .map(|value| materialize_pure_continuation_bindings(value, compiled, expanded)) + .collect(), + result_type, + }, + IrExpr::Invoke { function, args } => IrExpr::Invoke { + function: Box::new(materialize_pure_continuation_bindings( + *function, compiled, expanded, + )), + args: args + .into_iter() + .map(|value| materialize_pure_continuation_bindings(value, compiled, expanded)) + .collect(), + }, + IrExpr::Field { value, field } => IrExpr::Field { + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + field, + }, + IrExpr::Index { value, key } => IrExpr::Index { + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + key: Box::new(materialize_pure_continuation_bindings( + *key, compiled, expanded, + )), + }, + IrExpr::Method { + value, + method, + args, + result_type, + } => IrExpr::Method { + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + method, + args: args + .into_iter() + .map(|value| materialize_pure_continuation_bindings(value, compiled, expanded)) + .collect(), + result_type, + }, + IrExpr::If { + condition, + then_value, + else_value, + } => IrExpr::If { + condition: Box::new(materialize_pure_continuation_bindings( + *condition, compiled, expanded, + )), + then_value: Box::new(materialize_pure_continuation_bindings( + *then_value, + compiled, + expanded, + )), + else_value: Box::new(materialize_pure_continuation_bindings( + *else_value, + compiled, + expanded, + )), + }, + IrExpr::Match { value, arms } => IrExpr::Match { + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + arms: arms + .into_iter() + .map(|arm| IrMatchArm { + pattern: arm.pattern, + value: materialize_pure_continuation_bindings(arm.value, compiled, expanded), + }) + .collect(), + }, + IrExpr::Is { value, pattern } => IrExpr::Is { + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + pattern, + }, + IrExpr::Update { value, fields } => IrExpr::Update { + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + fields: fields + .into_iter() + .map(|(name, value)| { + ( + name, + materialize_pure_continuation_bindings(value, compiled, expanded), + ) + }) + .collect(), + }, + IrExpr::Let { bindings, value } => IrExpr::Let { + bindings: bindings + .into_iter() + .map(|(name, value)| { + let value = if name.starts_with(PURE_CONTINUATION_LOCAL_PREFIX) { + continuation(&name, compiled, expanded) + } else { + materialize_pure_continuation_bindings(value, compiled, expanded) + }; + (name, value) + }) + .collect(), + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + }, + IrExpr::Lambda { params, body } => IrExpr::Lambda { + params, + body: Box::new(materialize_pure_continuation_bindings( + *body, compiled, expanded, + )), + }, + IrExpr::Collect { clauses } => IrExpr::Collect { + clauses: clauses + .into_iter() + .map(|(condition, value)| { + ( + materialize_pure_continuation_bindings(condition, compiled, expanded), + materialize_pure_continuation_bindings(value, compiled, expanded), + ) + }) + .collect(), + }, + IrExpr::SetComprehension { + pattern, + source, + conditions, + value, + result_type, + } => IrExpr::SetComprehension { + pattern, + source: Box::new(materialize_pure_continuation_bindings( + *source, compiled, expanded, + )), + conditions: conditions + .into_iter() + .map(|condition| { + materialize_pure_continuation_bindings(condition, compiled, expanded) + }) + .collect(), + value: Box::new(materialize_pure_continuation_bindings( + *value, compiled, expanded, + )), + result_type, + }, + expression @ (IrExpr::Literal { .. } | IrExpr::Name { .. }) => expression, + } +} + +fn unique_member<'a, T>( + members: &'a [ast::MachineMember], + mut select: impl FnMut(&'a ast::MachineMemberKind) -> Option<&'a T>, +) -> Option<&'a T> { + members.iter().find_map(|member| select(&member.value)) +} + +fn handler_input_name(pattern: &ast::Pattern) -> String { + match &pattern.value { + ast::PatternKind::Name(name) => name.value.clone(), + ast::PatternKind::Constructor { path, .. } => path + .iter() + .map(|part| part.value.as_str()) + .collect::>() + .join("."), + _ => format!("", pattern.span.start), + } +} + +fn statements_terminal(statements: &[Statement]) -> bool { + statements.last().is_some_and(|statement| match statement { + Statement::Finish { .. } | Statement::Unreachable { .. } | Statement::Delegate { .. } => { + true + } + Statement::If { + then_body, + else_body, + .. + } => statements_terminal(then_body) && statements_terminal(else_body), + Statement::Match { arms, .. } => { + !arms.is_empty() && arms.iter().all(|arm| statements_terminal(&arm.body)) + } + Statement::While { .. } + | Statement::Let { .. } + | Statement::Set { .. } + | Statement::Emit { .. } => false, + }) +} + +fn port_ty(ty: &TypeRef) -> uhura_port::TypeRef { + uhura_port::TypeRef::new(ty.canonical_name()).expect("checker type spelling is canonical") +} + +fn standard_export(module: &str, name: &str) -> Option { + match (module, name) { + ("uhura.boundary@1", "Token") => Some(Export::Type(TypeRef::Named { id: "Token".into() })), + ("uhura.observation@1", "Observation") + | ("uhura.ports@1", "RequestPort" | "SinkPort") + | ("uhura.web_router@1", "Router") => Some(Export::PortContract), + ("uhura.web_router@1", "Routes") => Some(Export::Type(TypeRef::Named { + id: "Routes".into(), + })), + ("uhura.web_router@1", "routes") => Some(Export::PureHelper), + ("uhura.web_router@1", "Link") | ("uhura.ui_surface@1", "Surface") => { + Some(Export::UiElement) + } + _ => None, + } +} + +fn is_binding_reserved_builtin(value: &str) -> bool { + matches!( + value, + "Bool" + | "Unit" + | "Never" + | "Int" + | "Nat" + | "PositiveInt" + | "Decimal" + | "BoundaryNumber" + | "Ratio" + | "Text" + | "Option" + | "Seq" + | "NonEmpty" + | "Set" + | "Map" + | "Table" + | "FiniteView" + | "min" + | "max" + ) +} + +fn finite_view_path( + ty: &TypeRef, + registry: &TypeRegistry, + visited: &mut BTreeSet, +) -> Option> { + fn nested(segment: impl Into, path: Option>) -> Option> { + path.map(|mut path| { + path.insert(0, segment.into()); + path + }) + } + + match ty { + TypeRef::FiniteView { .. } => Some(vec!["FiniteView".into()]), + TypeRef::Option { value } => { + nested("Option.value", finite_view_path(value, registry, visited)) + } + TypeRef::Seq { value } => nested("Seq.item", finite_view_path(value, registry, visited)), + TypeRef::NonEmpty { value } => { + nested("NonEmpty.item", finite_view_path(value, registry, visited)) + } + TypeRef::Set { value } => nested("Set.item", finite_view_path(value, registry, visited)), + TypeRef::Map { key, value } => nested("Map.key", finite_view_path(key, registry, visited)) + .or_else(|| nested("Map.value", finite_view_path(value, registry, visited))), + TypeRef::Table { key, value } => { + nested("Table.key", finite_view_path(key, registry, visited)) + .or_else(|| nested("Table.value", finite_view_path(value, registry, visited))) + } + TypeRef::Tuple { values } => values.iter().enumerate().find_map(|(index, value)| { + nested( + format!("Tuple[{}]", index + 1), + finite_view_path(value, registry, visited), + ) + }), + TypeRef::Record { fields } => fields.iter().find_map(|(name, value)| { + nested( + format!("Record.{name}"), + finite_view_path(value, registry, visited), + ) + }), + TypeRef::Named { id } => { + if id.contains("FiniteView<") { + return Some(vec![id.clone()]); + } + if !visited.insert(id.clone()) { + return None; + } + let name = id.rsplit("::").next().unwrap_or(id); + match registry.types.get(id).map(|info| &info.shape) { + Some(TypeShape::Alias(value)) | Some(TypeShape::Key(value)) => { + nested(name, finite_view_path(value, registry, visited)) + } + Some(TypeShape::Record(fields)) => fields.iter().find_map(|(field, value)| { + nested( + format!("{name}.{field}"), + finite_view_path(value, registry, visited), + ) + }), + Some(TypeShape::Sum(constructors)) => constructors.iter().find_map(|constructor| { + constructor + .fields + .iter() + .enumerate() + .find_map(|(index, (field, value))| { + let field = field + .as_deref() + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("#{}", index + 1)); + nested( + format!("{name}.{}.{field}", constructor.name), + finite_view_path(value, registry, visited), + ) + }) + }), + None => None, + } + } + TypeRef::Bool + | TypeRef::Unit + | TypeRef::Never + | TypeRef::Int + | TypeRef::Nat + | TypeRef::PositiveInt + | TypeRef::Decimal + | TypeRef::BoundaryNumber + | TypeRef::Ratio + | TypeRef::Text => None, + } +} + +fn builtin_type(name: &str, args: &[TypeRef]) -> Option { + let scalar = match name { + "Bool" => Some(TypeRef::Bool), + "Unit" => Some(TypeRef::Unit), + "Never" => Some(TypeRef::Never), + "Int" => Some(TypeRef::Int), + "Nat" => Some(TypeRef::Nat), + "PositiveInt" => Some(TypeRef::PositiveInt), + "Decimal" => Some(TypeRef::Decimal), + "BoundaryNumber" => Some(TypeRef::BoundaryNumber), + "Ratio" => Some(TypeRef::Ratio), + "Text" => Some(TypeRef::Text), + _ => None, + }; + if scalar.is_some() { + return scalar.filter(|_| args.is_empty()); + } + match (name, args) { + ("Option", [value]) => Some(TypeRef::Option { + value: Box::new(value.clone()), + }), + ("Seq", [value]) => Some(TypeRef::Seq { + value: Box::new(value.clone()), + }), + ("NonEmpty", [value]) => Some(TypeRef::NonEmpty { + value: Box::new(value.clone()), + }), + ("Set", [value]) => Some(TypeRef::Set { + value: Box::new(value.clone()), + }), + ("Map", [key, value]) => Some(TypeRef::Map { + key: Box::new(key.clone()), + value: Box::new(value.clone()), + }), + ("Table", [key, value]) => Some(TypeRef::Table { + key: Box::new(key.clone()), + value: Box::new(value.clone()), + }), + ("FiniteView", [value]) => Some(TypeRef::FiniteView { + value: Box::new(value.clone()), + }), + ("Token" | "Routes", [value]) => Some(TypeRef::Named { + id: format!("{name}<{}>", value.canonical_name()), + }), + _ => None, + } +} + +fn qualify(module: &str, name: &str) -> String { + format!("{module}::{name}") +} + +fn dependency_order(modules: &BTreeMap>, roots: &[String]) -> Vec { + fn visit( + id: &str, + modules: &BTreeMap>, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + output: &mut Vec, + ) { + if visited.contains(id) || !modules.contains_key(id) { + return; + } + if !visiting.insert(id.to_string()) { + // Type-only import cycles are legal. The stable root/source order + // breaks value-lowering ties; an actual constant cycle is still + // rejected by constant evaluation. + return; + } + let mut dependencies = modules[id] + .module + .imports + .iter() + .map(|import| import.target.clone()) + .filter(|target| modules.contains_key(target)) + .collect::>(); + dependencies.sort(); + dependencies.dedup(); + for dependency in dependencies { + visit(&dependency, modules, visiting, visited, output); + } + visiting.remove(id); + if visited.insert(id.to_string()) { + output.push(id.to_string()); + } + } + + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut output = Vec::new(); + for id in roots { + visit(id, modules, &mut visiting, &mut visited, &mut output); + } + for id in modules.keys() { + visit(id, modules, &mut visiting, &mut visited, &mut output); + } + output +} + +fn machine_qualify(module: &str, machine: &str, name: &str) -> String { + format!("{module}::{machine}.{name}") +} + +fn source(module: &ModuleEnv<'_>, span: ast::SourceSpan) -> SourceRef { + let semantic_path = module + .semantic_paths + .get(&(span.start, span.end)) + .cloned() + .unwrap_or_else(|| "unknown".into()); + SourceRef { + // Runtime faults and evidence pins carry a semantic source identity. + // Physical paths and byte offsets remain useful editor coordinates, + // but must not perturb a program hash or fault identity when a file is + // moved or whitespace is reformatted. + id: format!("{}#{semantic_path}", module.id), + path: module + .physical_source_paths + .get(&span.file) + .cloned() + .unwrap_or_else(|| module.module.source_id.path.clone()), + start: span.start, + end: span.end, + } +} + +fn semantic_path_index(module: &ast::Module) -> BTreeMap<(u32, u32), String> { + let mut output = BTreeMap::new(); + if let Ok(value) = serde_json::to_value(module) { + collect_semantic_paths(&value, String::new(), &mut output); + } + output +} + +fn collect_semantic_paths( + value: &serde_json::Value, + path: String, + output: &mut BTreeMap<(u32, u32), String>, +) { + match value { + serde_json::Value::Object(fields) => { + if let Some(span) = fields.get("span").and_then(json_span_range) { + output.entry(span).or_insert_with(|| { + if path.is_empty() { + "module".into() + } else { + path.clone() + } + }); + } + for (name, child) in fields { + if matches!(name.as_str(), "source" | "source_id") { + continue; + } + let child_path = if path.is_empty() { + name.clone() + } else { + format!("{path}.{name}") + }; + collect_semantic_paths(child, child_path, output); + } + } + serde_json::Value::Array(values) => { + for (index, child) in values.iter().enumerate() { + collect_semantic_paths(child, format!("{path}[{index}]"), output); + } + } + _ => {} + } +} + +fn json_span_range(value: &serde_json::Value) -> Option<(u32, u32)> { + let fields = value.as_object()?; + Some(( + fields.get("start")?.as_u64()?.try_into().ok()?, + fields.get("end")?.as_u64()?.try_into().ok()?, + )) +} + +fn collect_calls(expression: &IrExpr, calls: &mut BTreeSet) { + match expression { + IrExpr::Call { function, args, .. } => { + calls.insert(function.clone()); + for value in args { + collect_calls(value, calls); + } + } + IrExpr::Invoke { function, args } => { + collect_calls(function, calls); + for value in args { + collect_calls(value, calls); + } + } + IrExpr::Constructor { fields, .. } => { + for (_, value) in fields { + collect_calls(value, calls); + } + } + IrExpr::Key { value, .. } | IrExpr::Unary { value, .. } | IrExpr::Field { value, .. } => { + collect_calls(value, calls) + } + IrExpr::Tuple { values } | IrExpr::Seq { values } => { + for value in values { + collect_calls(value, calls); + } + } + IrExpr::Record { fields } => { + for (_, value) in fields { + collect_calls(value, calls); + } + } + IrExpr::Map { entries, .. } => { + for (key, value) in entries { + collect_calls(key, calls); + collect_calls(value, calls); + } + } + IrExpr::Table { entries, .. } => { + for (_, value) in entries { + collect_calls(value, calls); + } + } + IrExpr::Binary { left, right, .. } => { + collect_calls(left, calls); + collect_calls(right, calls); + } + IrExpr::Index { value, key } => { + collect_calls(value, calls); + collect_calls(key, calls); + } + IrExpr::Method { value, args, .. } => { + collect_calls(value, calls); + for arg in args { + collect_calls(arg, calls); + } + } + IrExpr::If { + condition, + then_value, + else_value, + } => { + collect_calls(condition, calls); + collect_calls(then_value, calls); + collect_calls(else_value, calls); + } + IrExpr::Match { value, arms } => { + collect_calls(value, calls); + for arm in arms { + collect_calls(&arm.value, calls); + } + } + IrExpr::Is { value, .. } => collect_calls(value, calls), + IrExpr::Update { value, fields } => { + collect_calls(value, calls); + for (_, value) in fields { + collect_calls(value, calls); + } + } + IrExpr::Let { bindings, value } => { + for (_, value) in bindings { + collect_calls(value, calls); + } + collect_calls(value, calls); + } + IrExpr::Lambda { body, .. } => collect_calls(body, calls), + IrExpr::Collect { clauses } => { + for (condition, value) in clauses { + collect_calls(condition, calls); + collect_calls(value, calls); + } + } + IrExpr::SetComprehension { + source, + conditions, + value, + .. + } => { + collect_calls(source, calls); + for condition in conditions { + collect_calls(condition, calls); + } + collect_calls(value, calls); + } + IrExpr::Literal { .. } | IrExpr::Name { .. } => {} + } +} + +fn collect_names(expression: &IrExpr, names: &mut BTreeSet) { + fn walk(value: &serde_json::Value, names: &mut BTreeSet) { + match value { + serde_json::Value::Object(fields) => { + if fields.get("kind").and_then(serde_json::Value::as_str) == Some("name") + && let Some(name) = fields.get("name").and_then(serde_json::Value::as_str) + { + names.insert(name.to_string()); + } + for child in fields.values() { + walk(child, names); + } + } + serde_json::Value::Array(values) => { + for child in values { + walk(child, names); + } + } + _ => {} + } + } + if let Ok(value) = serde_json::to_value(expression) { + walk(&value, names); + } +} + +fn inferred_type_is_complete(ty: &TypeRef) -> bool { + match ty { + TypeRef::Never => false, + TypeRef::Option { value } + | TypeRef::Seq { value } + | TypeRef::NonEmpty { value } + | TypeRef::Set { value } + | TypeRef::FiniteView { value } => inferred_type_is_complete(value), + TypeRef::Map { key, value } | TypeRef::Table { key, value } => { + inferred_type_is_complete(key) && inferred_type_is_complete(value) + } + TypeRef::Tuple { values } => values.iter().all(inferred_type_is_complete), + TypeRef::Record { fields } => fields + .iter() + .all(|(_, field)| inferred_type_is_complete(field)), + TypeRef::Bool + | TypeRef::Unit + | TypeRef::Int + | TypeRef::Nat + | TypeRef::PositiveInt + | TypeRef::Decimal + | TypeRef::BoundaryNumber + | TypeRef::Ratio + | TypeRef::Text + | TypeRef::Named { .. } => true, + } +} + +fn collect_source_names( + expression: &ast::Expr, + bound: &mut BTreeSet, + names: &mut BTreeSet, +) { + match &expression.value { + ast::ExprKind::Name(name) => { + if !bound.contains(&name.value) { + names.insert(name.value.clone()); + } + } + ast::ExprKind::Tuple(values) | ast::ExprKind::Sequence(values) => { + for value in values { + collect_source_names(value, bound, names); + } + } + ast::ExprKind::Record(fields) => { + for field in fields { + collect_source_names(&field.value, bound, names); + } + } + ast::ExprKind::Block(block) => collect_source_block_names(block, bound, names), + ast::ExprKind::Unary { operand, .. } + | ast::ExprKind::Is { value: operand, .. } + | ast::ExprKind::Finish(operand) => collect_source_names(operand, bound, names), + ast::ExprKind::Binary { left, right, .. } + | ast::ExprKind::Index { + receiver: left, + index: right, + } => { + collect_source_names(left, bound, names); + collect_source_names(right, bound, names); + } + ast::ExprKind::Call { callee, arguments } => { + collect_source_names(callee, bound, names); + for argument in arguments { + collect_source_names(argument, bound, names); + } + } + ast::ExprKind::Member { receiver, .. } => { + collect_source_names(receiver, bound, names); + } + ast::ExprKind::Update { base, fields } => { + collect_source_names(base, bound, names); + for field in fields { + collect_source_names(&field.value, bound, names); + } + } + ast::ExprKind::Lambda { parameters, body } => { + let mut child = bound.clone(); + for parameter in parameters { + collect_pattern_bindings(parameter, &mut child); + } + collect_source_names(body, &mut child, names); + } + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } => { + collect_source_names(condition, bound, names); + collect_source_names(then_branch, &mut bound.clone(), names); + if let Some(else_branch) = else_branch { + collect_source_names(else_branch, &mut bound.clone(), names); + } + } + ast::ExprKind::Match { subject, arms } => { + collect_source_names(subject, bound, names); + for arm in arms { + let mut child = bound.clone(); + collect_pattern_bindings(&arm.pattern, &mut child); + collect_source_names(&arm.body, &mut child, names); + } + } + ast::ExprKind::Collect(clauses) => { + for clause in clauses { + collect_source_names(&clause.condition, bound, names); + collect_source_names(&clause.value, bound, names); + } + } + ast::ExprKind::SetComprehension { + binding, + source, + filters, + value, + } => { + collect_source_names(source, bound, names); + let mut child = bound.clone(); + collect_pattern_bindings(binding, &mut child); + for filter in filters { + collect_source_names(filter, &mut child, names); + } + collect_source_names(value, &mut child, names); + } + ast::ExprKind::Integer(_) + | ast::ExprKind::Decimal(_) + | ast::ExprKind::Text(_) + | ast::ExprKind::Bool(_) + | ast::ExprKind::Unreachable + | ast::ExprKind::Error => {} + } +} + +fn collect_source_block_names( + block: &ast::Block, + bound: &mut BTreeSet, + names: &mut BTreeSet, +) { + let mut child = bound.clone(); + for statement in &block.statements { + match &statement.value { + ast::StatementKind::Let { name, value, .. } => { + collect_source_names(value, &mut child, names); + child.insert(name.value.clone()); + } + ast::StatementKind::Set { value, .. } + | ast::StatementKind::Emit(value) + | ast::StatementKind::Expr(value) => { + collect_source_names(value, &mut child, names); + } + ast::StatementKind::While { + condition, + decreases, + body, + } => { + collect_source_names(condition, &mut child, names); + collect_source_names(decreases, &mut child, names); + collect_source_block_names(body, &mut child, names); + } + } + } +} + +fn collect_pattern_bindings(pattern: &ast::Pattern, bound: &mut BTreeSet) { + match &pattern.value { + ast::PatternKind::Name(name) => { + bound.insert(name.value.clone()); + } + ast::PatternKind::Constructor { arguments, .. } + | ast::PatternKind::Tuple(arguments) + | ast::PatternKind::Alternative(arguments) => { + for argument in arguments { + collect_pattern_bindings(argument, bound); + } + } + ast::PatternKind::Record { fields, .. } => { + for field in fields { + collect_pattern_bindings(&field.pattern, bound); + } + } + ast::PatternKind::Wildcard + | ast::PatternKind::Rest + | ast::PatternKind::Integer(_) + | ast::PatternKind::Decimal(_) + | ast::PatternKind::Text(_) + | ast::PatternKind::Bool(_) + | ast::PatternKind::Error => {} + } +} + +fn cyclic_nodes(graph: &BTreeMap>) -> BTreeSet { + fn reaches( + origin: &str, + current: &str, + graph: &BTreeMap>, + visited: &mut BTreeSet, + ) -> bool { + if !visited.insert(current.to_string()) { + return false; + } + graph.get(current).is_some_and(|next| { + next.iter() + .any(|candidate| candidate == origin || reaches(origin, candidate, graph, visited)) + }) + } + + graph + .keys() + .filter(|origin| reaches(origin, origin, graph, &mut BTreeSet::new())) + .cloned() + .collect() +} + +fn graph_dependency_order( + graph: &BTreeMap>, + excluded: &BTreeSet, +) -> Vec { + fn visit( + node: &str, + graph: &BTreeMap>, + excluded: &BTreeSet, + visited: &mut BTreeSet, + output: &mut Vec, + ) { + if excluded.contains(node) || !visited.insert(node.to_string()) { + return; + } + if let Some(dependencies) = graph.get(node) { + for dependency in dependencies { + visit(dependency, graph, excluded, visited, output); + } + } + output.push(node.to_string()); + } + + let mut visited = BTreeSet::new(); + let mut output = Vec::new(); + for node in graph.keys() { + visit(node, graph, excluded, &mut visited, &mut output); + } + output +} + +fn lower_binary(value: ast::BinaryOp) -> IrBinaryOp { + match value { + ast::BinaryOp::Or => IrBinaryOp::Or, + ast::BinaryOp::And => IrBinaryOp::And, + ast::BinaryOp::Equal => IrBinaryOp::Equal, + ast::BinaryOp::NotEqual => IrBinaryOp::NotEqual, + ast::BinaryOp::Less => IrBinaryOp::Less, + ast::BinaryOp::LessEqual => IrBinaryOp::LessEqual, + ast::BinaryOp::Greater => IrBinaryOp::Greater, + ast::BinaryOp::GreaterEqual => IrBinaryOp::GreaterEqual, + ast::BinaryOp::Add => IrBinaryOp::Add, + ast::BinaryOp::Subtract => IrBinaryOp::Subtract, + ast::BinaryOp::Multiply => IrBinaryOp::Multiply, + } +} + +fn ir_numeric_path(expression: &IrExpr) -> Option { + match expression { + IrExpr::Name { name } => Some(name.clone()), + IrExpr::Field { value, field } => { + ir_numeric_path(value).map(|path| format!("{path}.{field}")) + } + _ => None, + } +} + +fn ast_member_path(expression: &ast::Expr) -> Option> { + match &expression.value { + ast::ExprKind::Name(name) => Some(vec![name.value.clone()]), + ast::ExprKind::Member { receiver, member } => { + let mut path = ast_member_path(receiver)?; + path.push(member.value.clone()); + Some(path) + } + _ => None, + } +} + +fn ast_numeric_path(scope: &Scope, expression: &ast::Expr) -> Option { + match &expression.value { + ast::ExprKind::Name(name) => Some( + scope + .values + .get(&name.value) + .map(|binding| binding.lowered.clone()) + .unwrap_or_else(|| name.value.clone()), + ), + ast::ExprKind::Member { receiver, member } => { + ast_numeric_path(scope, receiver).map(|path| format!("{path}.{}", member.value)) + } + _ => None, + } +} + +fn static_integer_minimum_for_path( + registry: &TypeRegistry, + scope: &Scope, + path: &str, +) -> Option { + let (base, binding) = scope + .values + .values() + .filter(|binding| { + path == binding.lowered + || path + .strip_prefix(&binding.lowered) + .is_some_and(|suffix| suffix.starts_with('.')) + }) + .max_by_key(|binding| binding.lowered.len()) + .map(|binding| (binding.lowered.as_str(), binding))?; + let mut ty = binding.ty.as_value()?.clone(); + if let Some(suffix) = path.strip_prefix(base) { + for field in suffix.trim_start_matches('.').split('.') { + if field.is_empty() { + continue; + } + ty = registry + .fields(&ty)? + .into_iter() + .find(|(name, _)| name == field)? + .1; + } + } + match ty { + TypeRef::Nat => Some(0), + TypeRef::PositiveInt => Some(1), + _ => None, + } +} + +fn refined_numeric_scope( + scope: &Scope, + expression: &ast::Expr, + truth: bool, + registry: &TypeRegistry, +) -> Scope { + let mut refined = scope.child(); + install_numeric_condition(&mut refined, scope, expression, truth, registry); + refined +} + +fn install_numeric_condition( + refined: &mut Scope, + lookup: &Scope, + expression: &ast::Expr, + truth: bool, + registry: &TypeRegistry, +) { + match &expression.value { + ast::ExprKind::Unary { op, operand } if op.value == ast::UnaryOp::Not => { + install_numeric_condition(refined, lookup, operand, !truth, registry); + } + ast::ExprKind::Binary { left, op, right } + if (op.value == ast::BinaryOp::And && truth) + || (op.value == ast::BinaryOp::Or && !truth) => + { + install_numeric_condition(refined, lookup, left, truth, registry); + install_numeric_condition(refined, lookup, right, truth, registry); + } + ast::ExprKind::Binary { left, op, right } => { + install_numeric_comparison(refined, lookup, left, op.value, right, truth, registry); + } + _ => {} + } +} + +fn install_numeric_comparison( + refined: &mut Scope, + lookup: &Scope, + left: &ast::Expr, + op: ast::BinaryOp, + right: &ast::Expr, + truth: bool, + registry: &TypeRegistry, +) { + let effective = if truth { op } else { negate_comparison(op) }; + if let (Some(path), Some(value)) = (ast_numeric_path(lookup, left), integer_literal(right)) { + apply_path_bound(refined, path, effective, value); + return; + } + if let (Some(value), Some(path)) = (integer_literal(left), ast_numeric_path(lookup, right)) { + apply_path_bound(refined, path, reverse_comparison(effective), value); + return; + } + let (Some(left), Some(right)) = ( + ast_numeric_path(lookup, left), + ast_numeric_path(lookup, right), + ) else { + return; + }; + match effective { + ast::BinaryOp::Less => { + refined.less_than.insert((left.clone(), right.clone())); + if let Some(maximum) = lookup + .numeric_bounds + .get(&right) + .and_then(|bounds| bounds.max) + { + refined.numeric_bounds.entry(left).or_default().max = + Some(maximum.saturating_sub(1)); + } + } + ast::BinaryOp::LessEqual | ast::BinaryOp::Equal => { + refined.less_equal.insert((left.clone(), right.clone())); + if effective == ast::BinaryOp::Equal { + refined.less_equal.insert((right, left)); + } + } + ast::BinaryOp::Greater => { + refined.less_than.insert((right.clone(), left.clone())); + if let Some(minimum) = lookup + .numeric_bounds + .get(&right) + .and_then(|bounds| bounds.min) + .or_else(|| static_integer_minimum_for_path(registry, lookup, &right)) + { + let bounds = refined.numeric_bounds.entry(left).or_default(); + let minimum = minimum.saturating_add(1); + bounds.min = Some(bounds.min.map_or(minimum, |old| old.max(minimum))); + } + } + ast::BinaryOp::GreaterEqual => { + refined.less_equal.insert((right.clone(), left.clone())); + if let Some(minimum) = lookup + .numeric_bounds + .get(&right) + .and_then(|bounds| bounds.min) + .or_else(|| static_integer_minimum_for_path(registry, lookup, &right)) + { + let bounds = refined.numeric_bounds.entry(left).or_default(); + bounds.min = Some(bounds.min.map_or(minimum, |old| old.max(minimum))); + } + } + _ => {} + } +} + +fn integer_literal(expression: &ast::Expr) -> Option { + match &expression.value { + ast::ExprKind::Integer(value) => value.parse().ok(), + ast::ExprKind::Decimal(value) => { + let value = value.parse::().ok()?; + value + .is_integral() + .then(|| value.canonical_text().parse().ok()) + .flatten() + } + ast::ExprKind::Unary { op, operand } if op.value == ast::UnaryOp::Negate => { + integer_literal(operand)?.checked_neg() + } + _ => None, + } +} + +fn apply_path_bound(scope: &mut Scope, path: String, op: ast::BinaryOp, value: i64) { + let bounds = scope.numeric_bounds.entry(path).or_default(); + match op { + ast::BinaryOp::Less => { + bounds.max = Some(bounds.max.map_or(value - 1, |old| old.min(value - 1))); + } + ast::BinaryOp::LessEqual => { + bounds.max = Some(bounds.max.map_or(value, |old| old.min(value))); + } + ast::BinaryOp::Greater => { + bounds.min = Some(bounds.min.map_or(value + 1, |old| old.max(value + 1))); + } + ast::BinaryOp::GreaterEqual => { + bounds.min = Some(bounds.min.map_or(value, |old| old.max(value))); + } + ast::BinaryOp::Equal => { + bounds.min = Some(value); + bounds.max = Some(value); + } + ast::BinaryOp::NotEqual => { + if bounds.min == Some(value) { + bounds.min = Some(value.saturating_add(1)); + } + if bounds.max == Some(value) { + bounds.max = Some(value.saturating_sub(1)); + } + } + _ => {} + } +} + +fn negate_comparison(op: ast::BinaryOp) -> ast::BinaryOp { + match op { + ast::BinaryOp::Equal => ast::BinaryOp::NotEqual, + ast::BinaryOp::NotEqual => ast::BinaryOp::Equal, + ast::BinaryOp::Less => ast::BinaryOp::GreaterEqual, + ast::BinaryOp::LessEqual => ast::BinaryOp::Greater, + ast::BinaryOp::Greater => ast::BinaryOp::LessEqual, + ast::BinaryOp::GreaterEqual => ast::BinaryOp::Less, + other => other, + } +} + +fn reverse_comparison(op: ast::BinaryOp) -> ast::BinaryOp { + match op { + ast::BinaryOp::Less => ast::BinaryOp::Greater, + ast::BinaryOp::LessEqual => ast::BinaryOp::GreaterEqual, + ast::BinaryOp::Greater => ast::BinaryOp::Less, + ast::BinaryOp::GreaterEqual => ast::BinaryOp::LessEqual, + other => other, + } +} + +fn integer_lower_bound(expression: &IrExpr, scope: &Scope) -> Option { + match expression { + IrExpr::Literal { + value: Value::Integer { value, .. }, + } => value.to_string().parse().ok(), + IrExpr::Name { .. } | IrExpr::Field { .. } => { + ir_numeric_path(expression).and_then(|path| { + scope + .numeric_bounds + .get(&path) + .and_then(|bounds| bounds.min) + }) + } + IrExpr::Binary { + op: IrBinaryOp::Add, + left, + right, + } => integer_lower_bound(left, scope)?.checked_add(integer_lower_bound(right, scope)?), + IrExpr::Binary { + op: IrBinaryOp::Multiply, + left, + right, + } => { + let left = integer_lower_bound(left, scope)?; + let right = integer_lower_bound(right, scope)?; + (left >= 0 && right >= 0).then(|| left.saturating_mul(right)) + } + IrExpr::Call { function, args, .. } + if matches!( + function.as_str(), + "__coerce_int" | "__coerce_nat" | "__coerce_positive" + ) => + { + let value = args + .first() + .and_then(|value| integer_lower_bound(value, scope)); + match function.as_str() { + "__coerce_nat" => Some(value.unwrap_or(0).max(0)), + "__coerce_positive" => Some(value.unwrap_or(1).max(1)), + _ => value, + } + } + _ => None, + } +} + +fn integer_difference_non_negative(expression: &IrExpr, scope: &Scope) -> bool { + let IrExpr::Binary { + op: IrBinaryOp::Subtract, + left, + right, + } = expression + else { + return false; + }; + let Some(left) = ir_numeric_path(left) else { + return false; + }; + let Some(right) = ir_numeric_path(right) else { + return false; + }; + scope.less_equal.contains(&(right, left)) +} + +fn ratio_arithmetic_proven(scope: &Scope, op: IrBinaryOp, left: &IrExpr, right: &IrExpr) -> bool { + match op { + IrBinaryOp::Multiply => true, + IrBinaryOp::Add => { + let Some(left_maximum) = ratio_bound(scope, left, false) else { + return false; + }; + let Some(right_maximum) = ratio_bound(scope, right, false) else { + return false; + }; + left_maximum.add(&right_maximum) <= Decimal::one() + } + IrBinaryOp::Subtract => { + if left == right { + return true; + } + if let (Some(left), Some(right)) = (ir_numeric_path(left), ir_numeric_path(right)) + && (scope.less_equal.contains(&(right.clone(), left.clone())) + || scope.less_than.contains(&(right, left))) + { + return true; + } + let Some(left_minimum) = ratio_bound(scope, left, true) else { + return false; + }; + let Some(right_maximum) = ratio_bound(scope, right, false) else { + return false; + }; + left_minimum >= right_maximum + } + _ => false, + } +} + +fn ratio_bound(scope: &Scope, expression: &IrExpr, minimum: bool) -> Option { + match expression { + IrExpr::Literal { + value: Value::Ratio(value), + } => Some(value.clone()), + IrExpr::Name { .. } | IrExpr::Field { .. } => { + let path = ir_numeric_path(expression)?; + let bounds = scope.numeric_bounds.get(&path); + let value = if minimum { + bounds.and_then(|bounds| bounds.min).unwrap_or(0) + } else { + bounds.and_then(|bounds| bounds.max).unwrap_or(1) + }; + value.to_string().parse().ok() + } + _ => None, + } +} + +fn exact_integer(text: &str, kind: &str) -> Result { + Value::from_wire_json(&serde_json::json!({"$": kind, "value": text})) + .map_err(|error| error.to_string()) +} + +fn exact_decimal(text: &str) -> Result { + Value::from_wire_json(&serde_json::json!({"$": "Decimal", "value": text})) + .map_err(|error| error.to_string()) +} + +fn exact_number_value(text: &str, ty: &TypeRef) -> Result { + match ty { + TypeRef::Int => exact_integer(text, "Int"), + TypeRef::Nat => exact_integer(text, "Nat"), + TypeRef::PositiveInt => exact_integer(text, "PositiveInt"), + TypeRef::Decimal => exact_decimal(text), + TypeRef::Ratio => Value::from_wire_json(&serde_json::json!({ + "$": "Ratio", + "value": text, + })) + .map_err(|error| error.to_string()), + TypeRef::BoundaryNumber => Value::from_wire_json(&serde_json::json!({ + "$": "BoundaryNumber", + "case": "finite", + "value": text, + })) + .map_err(|error| error.to_string()), + _ => Err(format!( + "`{text}` is not a literal for `{}`", + ty.canonical_name() + )), + } +} + +fn record_key(expression: &ast::Expr) -> Option { + match &expression.value { + ast::ExprKind::Name(name) => Some(name.value.clone()), + ast::ExprKind::Text(value) => Some(value.clone()), + _ => None, + } +} + +fn is_qualified_call(expression: &ast::Expr) -> bool { + matches!( + &expression.value, + ast::ExprKind::Call { callee, .. } + if matches!(callee.value, ast::ExprKind::Member { .. }) + ) +} + +fn collection_item_type(ty: Option<&TypeRef>) -> Option { + match ty? { + TypeRef::Seq { value } + | TypeRef::NonEmpty { value } + | TypeRef::Set { value } + | TypeRef::FiniteView { value } => Some(value.as_ref().clone()), + TypeRef::Map { key, value } => Some(TypeRef::Record { + fields: vec![ + ("key".into(), key.as_ref().clone()), + ("value".into(), value.as_ref().clone()), + ], + }), + TypeRef::Table { key, value } => Some(TypeRef::Tuple { + values: vec![key.as_ref().clone(), value.as_ref().clone()], + }), + _ => None, + } +} + +fn pattern_coverage(pattern: &IrPattern, covered: &mut BTreeSet, wildcard: &mut bool) { + match pattern { + IrPattern::Ignore | IrPattern::Bind { .. } => *wildcard = true, + IrPattern::Constructor { + constructor, + fields, + .. + } => { + if fields.iter().all(pattern_irrefutable) { + covered.insert(format!("constructor:{constructor}")); + } + } + IrPattern::Alternative { patterns } => { + for pattern in patterns { + pattern_coverage(pattern, covered, wildcard); + } + } + IrPattern::Literal { value } => { + let atom = match value { + Value::Bool(value) => format!("literal:{value}"), + _ => format!( + "literal:{}", + uhura_core::codec::hex(&value.canonical_bytes()) + ), + }; + covered.insert(atom); + } + IrPattern::Tuple { .. } | IrPattern::Record { .. } => {} + } +} + +fn pattern_irrefutable(pattern: &IrPattern) -> bool { + match pattern { + IrPattern::Ignore | IrPattern::Bind { .. } => true, + IrPattern::Tuple { values } => values.iter().all(pattern_irrefutable), + IrPattern::Record { fields, .. } => fields + .iter() + .all(|(_, pattern)| pattern_irrefutable(pattern)), + IrPattern::Alternative { patterns } => patterns.iter().any(pattern_irrefutable), + IrPattern::Literal { .. } | IrPattern::Constructor { .. } => false, + } +} + +fn flatten_alternatives(patterns: &[IrPattern]) -> Vec<&IrPattern> { + fn push<'a>(pattern: &'a IrPattern, output: &mut Vec<&'a IrPattern>) { + if let IrPattern::Alternative { patterns } = pattern { + for pattern in patterns { + push(pattern, output); + } + } else { + output.push(pattern); + } + } + let mut output = Vec::new(); + for pattern in patterns { + push(pattern, &mut output); + } + output +} + +fn patterns_overlap(left: &IrPattern, right: &IrPattern) -> bool { + match (left, right) { + (IrPattern::Ignore | IrPattern::Bind { .. }, _) + | (_, IrPattern::Ignore | IrPattern::Bind { .. }) => true, + (IrPattern::Literal { value: left }, IrPattern::Literal { value: right }) => left == right, + ( + IrPattern::Constructor { + type_id: left_type, + constructor: left_constructor, + fields: left_fields, + }, + IrPattern::Constructor { + type_id: right_type, + constructor: right_constructor, + fields: right_fields, + }, + ) => { + left_type == right_type + && left_constructor == right_constructor + && left_fields.len() == right_fields.len() + && left_fields + .iter() + .zip(right_fields) + .all(|(left, right)| patterns_overlap(left, right)) + } + (IrPattern::Tuple { values: left }, IrPattern::Tuple { values: right }) => { + left.len() == right.len() + && left + .iter() + .zip(right) + .all(|(left, right)| patterns_overlap(left, right)) + } + ( + IrPattern::Record { + fields: left, + rest: left_rest, + }, + IrPattern::Record { + fields: right, + rest: right_rest, + }, + ) => { + let shared_are_compatible = left.iter().all(|(name, left_pattern)| { + right + .iter() + .find(|(right_name, _)| right_name == name) + .is_none_or(|(_, right_pattern)| patterns_overlap(left_pattern, right_pattern)) + }); + shared_are_compatible + && (*left_rest + || *right_rest + || left + .iter() + .map(|(name, _)| name) + .eq(right.iter().map(|(name, _)| name))) + } + (IrPattern::Alternative { patterns }, other) + | (other, IrPattern::Alternative { patterns }) => patterns + .iter() + .any(|pattern| patterns_overlap(pattern, other)), + _ => false, + } +} + +fn loop_decrease_proven(condition: &ast::Expr, decreases: &ast::Expr, body: &ast::Block) -> bool { + // `v04_updates::lower_project` runs before the checker-neutral bridge and + // transitively inlines every non-terminal update. Consequently this body + // contains the complete write effect of every update call that can reach a + // loop back edge: `assignments_to` sees a called update's writes exactly as + // it sees authored assignments. Outcome-valued transitions are terminal + // control and therefore do not contribute a back edge. + if let Some(sequence) = sequence_size_measure(decreases) { + let tails = uncons_tail_bindings(condition, &sequence); + if tails.is_empty() { + return false; + } + let assignments = assignments_to(body, &sequence); + return assignments.iter().all(|assignment| { + matches!( + &assignment.value, + ast::ExprKind::Name(name) if tails.contains(&name.value) + ) + }) && loop_fallthrough_paths_assign(body, &sequence); + } + if let ast::ExprKind::Name(measure) = &decreases.value { + let assignments = assignments_to(body, &measure.value); + return assignments + .iter() + .all(|assignment| numeric_decrement_of(assignment, &measure.value)) + && loop_fallthrough_paths_assign(body, &measure.value); + } + false +} + +fn numeric_decrement_of(expression: &ast::Expr, measure: &str) -> bool { + matches!( + &expression.value, + ast::ExprKind::Binary { left, op, right } + if op.value == ast::BinaryOp::Subtract + && matches!(&left.value, ast::ExprKind::Name(name) if name.value == measure) + && integer_literal(right).is_some_and(|value| value > 0) + ) +} + +fn loop_fallthrough_paths_assign(block: &ast::Block, target: &str) -> bool { + fn sequence<'a>( + expressions: impl IntoIterator, + mut paths: BTreeSet, + target: &str, + ) -> BTreeSet { + for expression in expressions { + paths = flow_expression(expression, paths, target); + if paths.is_empty() { + break; + } + } + paths + } + + fn generated_loop_exit(value: &ast::Expr) -> bool { + matches!( + &value.value, + ast::ExprKind::Call { callee, arguments } + if arguments.len() == 1 + && matches!( + &callee.value, + ast::ExprKind::Name(name) if name.value == "some" + ) + ) + } + + fn flow_expression( + expression: &ast::Expr, + paths: BTreeSet, + target: &str, + ) -> BTreeSet { + match &expression.value { + ast::ExprKind::Block(block) => flow_block(block, paths, target), + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } => { + let paths = flow_expression(condition, paths, target); + let mut joined = BTreeSet::new(); + joined.extend(flow_expression(then_branch, paths.clone(), target)); + if let Some(else_branch) = else_branch { + joined.extend(flow_expression(else_branch, paths, target)); + } else { + joined.extend(paths); + } + joined + } + ast::ExprKind::Match { subject, arms } => { + let paths = flow_expression(subject, paths, target); + let mut joined = BTreeSet::new(); + for arm in arms { + joined.extend(flow_expression(&arm.body, paths.clone(), target)); + } + joined + } + ast::ExprKind::Finish(_) | ast::ExprKind::Unreachable => BTreeSet::new(), + ast::ExprKind::Tuple(values) | ast::ExprKind::Sequence(values) => { + sequence(values, paths, target) + } + ast::ExprKind::Record(fields) => { + sequence(fields.iter().map(|field| &field.value), paths, target) + } + ast::ExprKind::Unary { operand, .. } + | ast::ExprKind::Is { value: operand, .. } + | ast::ExprKind::Member { + receiver: operand, .. + } => flow_expression(operand, paths, target), + ast::ExprKind::Lambda { .. } => paths, + ast::ExprKind::Binary { left, right, .. } + | ast::ExprKind::Index { + receiver: left, + index: right, + } => { + let paths = flow_expression(left, paths, target); + flow_expression(right, paths, target) + } + ast::ExprKind::Call { callee, arguments } => { + let paths = flow_expression(callee, paths, target); + sequence(arguments, paths, target) + } + ast::ExprKind::Update { base, fields } => { + let paths = flow_expression(base, paths, target); + sequence(fields.iter().map(|field| &field.value), paths, target) + } + ast::ExprKind::Collect(clauses) => { + let mut paths = paths; + for clause in clauses { + paths = flow_expression(&clause.condition, paths, target); + paths = flow_expression(&clause.value, paths, target); + } + paths + } + ast::ExprKind::SetComprehension { + source, + filters, + value, + .. + } => { + let paths = flow_expression(source, paths, target); + let paths = sequence(filters, paths, target); + flow_expression(value, paths, target) + } + ast::ExprKind::Integer(_) + | ast::ExprKind::Decimal(_) + | ast::ExprKind::Text(_) + | ast::ExprKind::Bool(_) + | ast::ExprKind::Name(_) + | ast::ExprKind::Error => paths, + } + } + + fn flow_block(block: &ast::Block, mut paths: BTreeSet, target: &str) -> BTreeSet { + for statement in &block.statements { + let mut next = BTreeSet::new(); + for assigned in paths { + let singleton = BTreeSet::from([assigned]); + match &statement.value { + ast::StatementKind::Let { name, value, .. } => { + let evaluated = flow_expression(value, singleton, target); + if !name.value.starts_with(INLINE_UPDATE_LOOP_EXIT_LOCAL_PREFIX) + || !generated_loop_exit(value) + { + next.extend(evaluated); + } + } + ast::StatementKind::Set { + target: assigned_target, + value, + } => { + let evaluated = flow_expression(value, singleton, target); + if assigned_target.value == target { + next.extend(evaluated.into_iter().map(|_| true)); + } else { + next.extend(evaluated); + } + } + ast::StatementKind::Emit(value) | ast::StatementKind::Expr(value) => { + next.extend(flow_expression(value, singleton, target)); + } + // A nested loop may execute zero times, so it cannot by + // itself establish the enclosing loop's decrease. Any + // propagated lexical return is represented by its + // following generated match. + ast::StatementKind::While { .. } => { + next.insert(assigned); + } + } + } + paths = next; + if paths.is_empty() { + break; + } + } + paths + } + + flow_block(block, BTreeSet::from([false]), target) + .into_iter() + .all(|assigned| assigned) +} + +fn sequence_size_measure(expression: &ast::Expr) -> Option { + let ast::ExprKind::Member { receiver, member } = &expression.value else { + return None; + }; + if member.value != "size" { + return None; + } + match &receiver.value { + ast::ExprKind::Name(name) => Some(name.value.clone()), + _ => None, + } +} + +fn uncons_tail_bindings(condition: &ast::Expr, sequence: &str) -> BTreeSet { + fn visit(expression: &ast::Expr, sequence: &str, tails: &mut BTreeSet) { + match &expression.value { + ast::ExprKind::Binary { left, right, .. } => { + visit(left, sequence, tails); + visit(right, sequence, tails); + } + ast::ExprKind::Unary { operand, .. } => visit(operand, sequence, tails), + ast::ExprKind::Is { value, pattern } + if matches!( + &value.value, + ast::ExprKind::Member { receiver, member } + if member.value == "uncons" + && matches!(&receiver.value, ast::ExprKind::Name(name) if name.value == sequence) + ) => + { + if let ast::PatternKind::Constructor { path, arguments } = &pattern.value + && path.last().is_some_and(|name| name.value == "some") + && let Some(ast::Spanned { + value: ast::PatternKind::Record { fields, .. }, + .. + }) = arguments.first() + && let Some(field) = fields.iter().find(|field| field.name.value == "tail") + && let ast::PatternKind::Name(name) = &field.pattern.value + { + tails.insert(name.value.clone()); + } + } + _ => {} + } + } + let mut tails = BTreeSet::new(); + visit(condition, sequence, &mut tails); + tails +} + +fn assignments_to<'a>(block: &'a ast::Block, target: &str) -> Vec<&'a ast::Expr> { + fn expressions_in<'a>( + expression: &'a ast::Expr, + target: &str, + output: &mut Vec<&'a ast::Expr>, + ) { + match &expression.value { + ast::ExprKind::Block(block) => statements_in(block, target, output), + ast::ExprKind::If { + then_branch, + else_branch, + .. + } => { + expressions_in(then_branch, target, output); + if let Some(else_branch) = else_branch { + expressions_in(else_branch, target, output); + } + } + ast::ExprKind::Match { arms, .. } => { + for arm in arms { + expressions_in(&arm.body, target, output); + } + } + _ => {} + } + } + fn statements_in<'a>(block: &'a ast::Block, target: &str, output: &mut Vec<&'a ast::Expr>) { + for statement in &block.statements { + match &statement.value { + ast::StatementKind::Set { + target: assigned, + value, + } if assigned.value == target => output.push(value), + ast::StatementKind::While { body, .. } => statements_in(body, target, output), + ast::StatementKind::Let { value, .. } + | ast::StatementKind::Emit(value) + | ast::StatementKind::Expr(value) + | ast::StatementKind::Set { value, .. } => { + expressions_in(value, target, output); + } + } + } + } + let mut output = Vec::new(); + statements_in(block, target, &mut output); + output +} + +fn block_contains_finish_control(block: &ast::Block) -> bool { + block + .statements + .iter() + .any(|statement| match &statement.value { + ast::StatementKind::Let { value, .. } + | ast::StatementKind::Set { value, .. } + | ast::StatementKind::Emit(value) + | ast::StatementKind::Expr(value) => expression_contains_finish_control(value), + ast::StatementKind::While { + condition, + decreases, + body, + } => { + expression_contains_finish_control(condition) + || expression_contains_finish_control(decreases) + || block_contains_finish_control(body) + } + }) +} + +fn expression_contains_finish_control(expression: &ast::Expr) -> bool { + match &expression.value { + ast::ExprKind::Finish(_) => true, + ast::ExprKind::Unreachable => false, + ast::ExprKind::Block(block) => block_contains_finish_control(block), + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } => { + expression_contains_finish_control(condition) + || expression_contains_finish_control(then_branch) + || else_branch + .as_deref() + .is_some_and(expression_contains_finish_control) + } + ast::ExprKind::Match { subject, arms } => { + expression_contains_finish_control(subject) + || arms + .iter() + .any(|arm| expression_contains_finish_control(&arm.body)) + } + ast::ExprKind::Tuple(values) | ast::ExprKind::Sequence(values) => { + values.iter().any(expression_contains_finish_control) + } + ast::ExprKind::Record(fields) => fields + .iter() + .any(|field| expression_contains_finish_control(&field.value)), + ast::ExprKind::Unary { operand, .. } + | ast::ExprKind::Is { value: operand, .. } + | ast::ExprKind::Member { + receiver: operand, .. + } + | ast::ExprKind::Lambda { body: operand, .. } => { + expression_contains_finish_control(operand) + } + ast::ExprKind::Binary { left, right, .. } + | ast::ExprKind::Index { + receiver: left, + index: right, + } => expression_contains_finish_control(left) || expression_contains_finish_control(right), + ast::ExprKind::Call { callee, arguments } => { + expression_contains_finish_control(callee) + || arguments.iter().any(expression_contains_finish_control) + } + ast::ExprKind::Update { base, fields } => { + expression_contains_finish_control(base) + || fields + .iter() + .any(|field| expression_contains_finish_control(&field.value)) + } + ast::ExprKind::Collect(clauses) => clauses.iter().any(|clause| { + expression_contains_finish_control(&clause.condition) + || expression_contains_finish_control(&clause.value) + }), + ast::ExprKind::SetComprehension { + source, + filters, + value, + .. + } => { + expression_contains_finish_control(source) + || filters.iter().any(expression_contains_finish_control) + || expression_contains_finish_control(value) + } + ast::ExprKind::Integer(_) + | ast::ExprKind::Decimal(_) + | ast::ExprKind::Text(_) + | ast::ExprKind::Bool(_) + | ast::ExprKind::Name(_) + | ast::ExprKind::Error => false, + } +} + +fn reaction_control(expression: &ast::Expr) -> bool { + let mut pending = vec![expression]; + while let Some(expression) = pending.pop() { + match &expression.value { + ast::ExprKind::Finish(_) | ast::ExprKind::Unreachable => return true, + ast::ExprKind::Block(block) => { + for statement in &block.statements { + match &statement.value { + ast::StatementKind::Set { .. } + | ast::StatementKind::Emit(_) + | ast::StatementKind::While { .. } => return true, + ast::StatementKind::Let { value, .. } | ast::StatementKind::Expr(value) => { + pending.push(value) + } + } + } + } + ast::ExprKind::If { + then_branch, + else_branch, + .. + } => { + pending.push(then_branch); + if let Some(else_branch) = else_branch { + pending.push(else_branch); + } + } + ast::ExprKind::Match { arms, .. } => { + pending.extend(arms.iter().map(|arm| &arm.body)); + } + ast::ExprKind::Tuple(values) | ast::ExprKind::Sequence(values) => { + pending.extend(values); + } + ast::ExprKind::Record(fields) => { + pending.extend(fields.iter().map(|field| &field.value)); + } + ast::ExprKind::Unary { operand, .. } + | ast::ExprKind::Is { value: operand, .. } + | ast::ExprKind::Member { + receiver: operand, .. + } + | ast::ExprKind::Lambda { body: operand, .. } => pending.push(operand), + ast::ExprKind::Binary { left, right, .. } + | ast::ExprKind::Index { + receiver: left, + index: right, + } => { + pending.push(left); + pending.push(right); + } + ast::ExprKind::Call { callee, arguments } => { + pending.push(callee); + pending.extend(arguments); + } + ast::ExprKind::Update { base, fields } => { + pending.push(base); + pending.extend(fields.iter().map(|field| &field.value)); + } + ast::ExprKind::Collect(clauses) => { + for clause in clauses { + pending.push(&clause.condition); + pending.push(&clause.value); + } + } + ast::ExprKind::SetComprehension { + source, + filters, + value, + .. + } => { + pending.push(source); + pending.extend(filters); + pending.push(value); + } + ast::ExprKind::Integer(_) + | ast::ExprKind::Decimal(_) + | ast::ExprKind::Text(_) + | ast::ExprKind::Bool(_) + | ast::ExprKind::Name(_) + | ast::ExprKind::Error => {} + } + } + false +} + +fn inline_update_loop_exit_local(block: &ast::Block) -> Option { + fn expression(expr: &ast::Expr, names: &mut BTreeSet) { + match &expr.value { + ast::ExprKind::Block(block) => statements(block, names), + ast::ExprKind::If { + condition, + then_branch, + else_branch, + } => { + expression(condition, names); + expression(then_branch, names); + if let Some(else_branch) = else_branch { + expression(else_branch, names); + } + } + ast::ExprKind::Match { subject, arms } => { + expression(subject, names); + for arm in arms { + expression(&arm.body, names); + } + } + ast::ExprKind::Tuple(values) | ast::ExprKind::Sequence(values) => { + for value in values { + expression(value, names); + } + } + ast::ExprKind::Record(fields) => { + for field in fields { + expression(&field.value, names); + } + } + ast::ExprKind::Unary { operand, .. } + | ast::ExprKind::Is { value: operand, .. } + | ast::ExprKind::Member { + receiver: operand, .. + } + | ast::ExprKind::Lambda { body: operand, .. } + | ast::ExprKind::Finish(operand) => expression(operand, names), + ast::ExprKind::Binary { left, right, .. } + | ast::ExprKind::Index { + receiver: left, + index: right, + } => { + expression(left, names); + expression(right, names); + } + ast::ExprKind::Call { callee, arguments } => { + expression(callee, names); + for argument in arguments { + expression(argument, names); + } + } + ast::ExprKind::Update { base, fields } => { + expression(base, names); + for field in fields { + expression(&field.value, names); + } + } + ast::ExprKind::Collect(clauses) => { + for clause in clauses { + expression(&clause.condition, names); + expression(&clause.value, names); + } + } + ast::ExprKind::SetComprehension { + source, + filters, + value, + .. + } => { + expression(source, names); + for filter in filters { + expression(filter, names); + } + expression(value, names); + } + ast::ExprKind::Integer(_) + | ast::ExprKind::Decimal(_) + | ast::ExprKind::Text(_) + | ast::ExprKind::Bool(_) + | ast::ExprKind::Name(_) + | ast::ExprKind::Unreachable + | ast::ExprKind::Error => {} + } + } + + fn statements(block: &ast::Block, names: &mut BTreeSet) { + for statement in &block.statements { + match &statement.value { + ast::StatementKind::Let { name, value, .. } => { + if name.value.starts_with(INLINE_UPDATE_LOOP_EXIT_LOCAL_PREFIX) + && matches!( + &value.value, + ast::ExprKind::Call { callee, arguments } + if arguments.len() == 1 + && matches!( + &callee.value, + ast::ExprKind::Name(callee) if callee.value == "some" + ) + ) + { + names.insert(name.value.clone()); + } + expression(value, names); + } + ast::StatementKind::Set { value, .. } + | ast::StatementKind::Emit(value) + | ast::StatementKind::Expr(value) => expression(value, names), + // A nested loop owns its own exact break local. Its generated + // post-loop match remains a sibling statement and propagates + // any lexical return into this loop's local. + ast::StatementKind::While { .. } => {} + } + } + } + + let mut names = BTreeSet::new(); + statements(block, &mut names); + debug_assert!( + names.len() <= 1, + "one source loop owns one lexical exit local" + ); + names.into_iter().next() +} + +fn guaranteed_update_joins(expression: &ast::Expr) -> BTreeMap { + match &expression.value { + ast::ExprKind::Block(block) => { + let mut joins = BTreeMap::new(); + for statement in &block.statements { + match &statement.value { + ast::StatementKind::Let { + name, ty: Some(ty), .. + } if name.value.starts_with(INLINE_UPDATE_JOIN_LOCAL_PREFIX) => { + joins.insert(name.value.clone(), ty.clone()); + } + ast::StatementKind::Let { value, .. } | ast::StatementKind::Expr(value) => { + joins.extend(guaranteed_update_joins(value)); + } + ast::StatementKind::Set { .. } + | ast::StatementKind::Emit(_) + | ast::StatementKind::While { .. } => {} + } + } + joins + } + ast::ExprKind::If { + then_branch, + else_branch: Some(else_branch), + .. + } => intersect_update_joins( + guaranteed_update_joins(then_branch), + guaranteed_update_joins(else_branch), + ), + ast::ExprKind::Match { arms, .. } => { + let mut arms = arms.iter(); + let Some(first) = arms.next() else { + return BTreeMap::new(); + }; + arms.fold(guaranteed_update_joins(&first.body), |joins, arm| { + intersect_update_joins(joins, guaranteed_update_joins(&arm.body)) + }) + } + _ => BTreeMap::new(), + } +} + +fn intersect_update_joins( + mut left: BTreeMap, + right: BTreeMap, +) -> BTreeMap { + left.retain(|name, ty| right.get(name) == Some(ty)); + left +} + +fn source_expr_terminal(expression: &ast::Expr) -> bool { + match &expression.value { + ast::ExprKind::Finish(_) | ast::ExprKind::Unreachable => true, + ast::ExprKind::Block(block) => source_block_terminal(block), + ast::ExprKind::If { + then_branch, + else_branch: Some(else_branch), + .. + } => source_expr_terminal(then_branch) && source_expr_terminal(else_branch), + ast::ExprKind::Match { arms, .. } => { + !arms.is_empty() && arms.iter().all(|arm| source_expr_terminal(&arm.body)) + } + _ => false, + } +} + +fn source_block_terminal(block: &ast::Block) -> bool { + block + .statements + .last() + .is_some_and(|statement| match &statement.value { + ast::StatementKind::Expr(expression) => source_expr_terminal(expression), + _ => false, + }) +} + +fn const_eval(expression: &IrExpr, program: &Program) -> Result { + match expression { + IrExpr::Literal { value } => Ok(value.clone()), + IrExpr::Name { name } => program + .machine_program + .constants + .get(name) + .cloned() + .ok_or_else(|| format!("unknown constant `{name}`")), + IrExpr::Constructor { + type_id, + constructor, + fields, + } => { + let fields = fields + .iter() + .map(|(name, value)| const_eval(value, program).map(|value| (name.clone(), value))) + .collect::, _>>()?; + if type_id == "BoundaryNumber" && constructor == "finite" { + return match fields.as_slice() { + [(_, Value::Decimal(value))] => { + Ok(Value::Boundary(BoundaryNumber::Finite(value.clone()))) + } + [(_, _)] => Err("BoundaryNumber.finite needs an exact Decimal".into()), + _ => Err("BoundaryNumber.finite needs one argument".into()), + }; + } + Ok(Value::variant(type_id, constructor, fields)) + } + IrExpr::Key { type_id, value } => Ok(Value::Key { + type_id: type_id.clone(), + value: Box::new(const_eval(value, program)?), + }), + IrExpr::Tuple { values } => Ok(Value::Tuple( + values + .iter() + .map(|value| const_eval(value, program)) + .collect::>()?, + )), + IrExpr::Record { fields } => Value::record( + fields + .iter() + .map(|(name, value)| const_eval(value, program).map(|value| (name.clone(), value))) + .collect::, _>>()?, + ) + .map_err(|error| error.to_string()), + IrExpr::Seq { values } => Ok(Value::Seq( + values + .iter() + .map(|value| const_eval(value, program)) + .collect::>()?, + )), + IrExpr::Map { + entries, + result_type, + } => { + let value = Value::Map( + entries + .iter() + .map(|(key, value)| { + Ok((const_eval(key, program)?, const_eval(value, program)?)) + }) + .collect::, String>>()?, + ); + program + .machine_program + .canonicalize_value(result_type, &value) + .map_err(|error| error.to_string()) + } + IrExpr::Table { key_type, entries } => Ok(Value::Table { + key_type: key_type.clone(), + entries: entries + .iter() + .map(|(name, value)| const_eval(value, program).map(|value| (name.clone(), value))) + .collect::>()?, + }), + IrExpr::SetComprehension { + source, + result_type, + .. + } if matches!(source.as_ref(), IrExpr::Seq { values } if values.is_empty()) => program + .machine_program + .canonicalize_value(result_type, &Value::Set(Vec::new())) + .map_err(|error| error.to_string()), + _ => Err("expression uses runtime evaluation".into()), + } +} + +#[cfg(test)] +mod tests { + use super::collect_semantic_paths; + use std::collections::BTreeMap; + + fn semantic_paths(value: serde_json::Value) -> BTreeMap<(u32, u32), String> { + let mut output = BTreeMap::new(); + collect_semantic_paths(&value, String::new(), &mut output); + output + } + + #[test] + fn semantic_path_index_records_nested_object_and_array_paths() { + let paths = semantic_paths(serde_json::json!({ + "declarations": [{ + "body": { + "span": { "file": 7, "start": 10, "end": 20 } + } + }] + })); + + assert_eq!( + paths.get(&(10, 20)).map(String::as_str), + Some("declarations[0].body") + ); + } + + #[test] + fn semantic_path_index_excludes_embedded_source_identity_subtrees() { + let paths = semantic_paths(serde_json::json!({ + "declaration": { + "span": { "file": 7, "start": 1, "end": 2 }, + "source": { + "span": { "file": 7, "start": 3, "end": 4 } + }, + "source_id": { + "span": { "file": 7, "start": 5, "end": 6 } + } + } + })); + + assert_eq!(paths.get(&(1, 2)).map(String::as_str), Some("declaration")); + assert!(!paths.contains_key(&(3, 4))); + assert!(!paths.contains_key(&(5, 6))); + } + + #[test] + fn semantic_path_index_keeps_the_first_depth_first_duplicate_span() { + let paths = semantic_paths(serde_json::json!({ + "alpha": { + "span": { "file": 7, "start": 10, "end": 20 } + }, + "beta": { + "span": { "file": 9, "start": 10, "end": 20 } + } + })); + + assert_eq!(paths.len(), 1); + assert_eq!(paths.get(&(10, 20)).map(String::as_str), Some("alpha")); + } +} diff --git a/crates/uhura-check/src/checker_ir.rs b/crates/uhura-check/src/checker_ir.rs new file mode 100644 index 0000000..20e7217 --- /dev/null +++ b/crates/uhura-check/src/checker_ir.rs @@ -0,0 +1,676 @@ +//! Private source-spanned IR consumed by the checker kernel. +//! +//! This is not an authored language or a public syntax API. The current Uhura +//! frontend resolves and lowers its AST into this stable substrate so the +//! checker kernel can remain independent of source spelling. The tree stops +//! before name and type checking, and every semantically observable order +//! remains source ordered. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use uhura_base::{FileId, Span}; + +/// A serialization-friendly checker source identity. +/// +/// `FileId` is process-local and deliberately tiny. The lowering substrate +/// also carries the logical path for diagnostics and deterministic identity. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SourceId { + pub file: u32, + pub path: String, +} + +/// A UTF-8 byte range retained through frontend lowering. +#[derive( + Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, +)] +pub struct SourceSpan { + pub file: u32, + pub start: u32, + pub end: u32, +} + +impl SourceSpan { + pub const fn new(file: u32, start: u32, end: u32) -> Self { + Self { file, start, end } + } + + pub const fn empty(file: u32, at: u32) -> Self { + Self::new(file, at, at) + } + + pub fn to(self, other: Self) -> Self { + debug_assert_eq!(self.file, other.file); + Self::new( + self.file, + self.start.min(other.start), + self.end.max(other.end), + ) + } + + pub fn as_base(self) -> Span { + Span::new(FileId(self.file), self.start, self.end) + } +} + +impl From for SourceSpan { + fn from(value: Span) -> Self { + Self::new(value.file.0, value.start, value.end) + } +} + +/// A checker-IR value plus its exact authored source extent. +/// +/// Structural equality intentionally ignores locations while retaining spans +/// for diagnostics, editor selection, and lowering. +#[derive(Serialize, Deserialize, Clone)] +pub struct Spanned { + pub value: T, + pub span: SourceSpan, +} + +impl Spanned { + pub const fn new(value: T, span: SourceSpan) -> Self { + Self { value, span } + } +} + +impl fmt::Debug for Spanned { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Spanned") + .field("value", &self.value) + .field("span", &self.span) + .finish() + } +} + +impl PartialEq for Spanned { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl Eq for Spanned {} + +pub type Name = Spanned; +pub type TypeExpr = Spanned; +pub type Pattern = Spanned; +pub type Expr = Spanned; +pub type Statement = Spanned; +pub type Declaration = Spanned; +pub type MachineMember = Spanned; +pub type UiNode = Spanned; +pub type EvidenceStep = Spanned; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Module { + pub source_id: SourceId, + pub span: SourceSpan, + pub language: LanguageHeader, + pub identity: ModuleIdentity, + pub uses: Vec, + pub imports: Vec, + pub declarations: Vec, +} + +impl PartialEq for Module { + fn eq(&self, other: &Self) -> bool { + self.language == other.language + && self.identity == other.identity + && self.uses == other.uses + && self.imports == other.imports + && self.declarations == other.declarations + } +} + +impl Eq for Module {} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct LanguageHeader { + pub name: Name, + /// Internal checker-kernel version. Authored source is headerless. + pub version: String, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ModuleIdentity { + pub path: Vec, + pub major: String, + pub span: SourceSpan, +} + +impl ModuleIdentity { + pub fn logical_name(&self) -> String { + self.path + .iter() + .map(|part| part.value.as_str()) + .collect::>() + .join(".") + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UseDecl { + pub feature: Name, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ImportDecl { + pub names: Vec, + /// The quoted logical identity, retained exactly as decoded text. + pub target: String, + /// Parsed exact logical identity. Kept alongside the decoded spelling so + /// resolvers never need to reinterpret a string. + pub identity: ModuleIdentity, + pub target_span: SourceSpan, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum DeclarationKind { + Const(ConstDecl), + Key(KeyDecl), + Type(TypeDecl), + Function(FunctionDecl), + Machine(MachineDecl), + Ui(UiDecl), + Scenario(ScenarioDecl), + Example(EvidenceAliasDecl), + Checkpoint(EvidenceAliasDecl), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ConstDecl { + pub name: Name, + pub ty: TypeExpr, + pub value: Expr, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct KeyDecl { + pub name: Name, + pub over: TypeExpr, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TypeDecl { + pub name: Name, + pub parameters: Vec, + pub body: TypeBody, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum TypeBody { + Alias(TypeExpr), + Sum(ClosedSum), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum TypeExprKind { + Named { + path: Vec, + arguments: Vec, + }, + Record(Vec), + Tuple(Vec), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TypeField { + pub name: Name, + pub ty: TypeExpr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ClosedSum { + pub variants: Vec, + pub leading_pipe: bool, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Variant { + pub name: Name, + pub payload: VariantPayload, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum VariantPayload { + Unit, + Positional(Vec), + Named(Vec), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct FunctionDecl { + pub name: Name, + pub parameters: Vec, + pub result: TypeExpr, + pub body: Expr, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Parameter { + pub name: Name, + pub ty: TypeExpr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct MachineDecl { + pub name: Name, + /// Kept in source order. Ordering and cardinality are checker concerns. + pub members: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum MachineMemberKind { + Const(ConstDecl), + Key(KeyDecl), + Type(TypeDecl), + Port(PortDecl), + Config(FieldBlock), + Require(Expr), + Input(SumDomain), + Command(SumDomain), + Outcome(OutcomeDecl), + State(StateDecl), + Function(FunctionDecl), + Derive(DeriveDecl), + Invariant(InvariantDecl), + Observe(ObserveDecl), + Transition(TransitionDecl), + Handler(HandlerDecl), + BeforeCommit(Block), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct PortDecl { + pub name: Name, + pub contract: TypeExpr, + pub configuration: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct FieldBlock { + pub fields: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum SumDomain { + Never(Name), + Sum(ClosedSum), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct OutcomeDecl { + pub variants: Vec, + pub leading_pipe: bool, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum OutcomePolicy { + Commit, + Abort, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct OutcomeVariant { + pub variant: Variant, + pub policy: Spanned, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct StateDecl { + pub fields: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct InitializedField { + pub name: Name, + pub ty: TypeExpr, + pub value: Expr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct DeriveDecl { + pub name: Name, + pub ty: Option, + pub value: Expr, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct InvariantDecl { + pub expressions: Vec, + pub braced: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ObserveDecl { + pub fields: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ObserveField { + pub name: Name, + pub ty: Option, + pub value: Expr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TransitionDecl { + pub name: Name, + pub parameters: Vec, + pub body: Block, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct HandlerDecl { + pub input: Pattern, + pub body: HandlerBody, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum HandlerBody { + Block(Block), + Delegate(Expr), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Block { + pub statements: Vec, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum StatementKind { + Let { + name: Name, + ty: Option, + value: Expr, + }, + Set { + target: Name, + value: Expr, + }, + Emit(Expr), + While { + condition: Expr, + decreases: Expr, + body: Block, + }, + Expr(Expr), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum ExprKind { + Integer(String), + Decimal(String), + Text(String), + Bool(bool), + Name(Name), + Tuple(Vec), + Sequence(Vec), + Record(Vec), + Block(Block), + Unary { + op: Spanned, + operand: Box, + }, + Binary { + left: Box, + op: Spanned, + right: Box, + }, + Is { + value: Box, + pattern: Pattern, + }, + Call { + callee: Box, + arguments: Vec, + }, + Member { + receiver: Box, + member: Name, + }, + Index { + receiver: Box, + index: Box, + }, + Update { + base: Box, + fields: Vec, + }, + Lambda { + parameters: Vec, + body: Box, + }, + If { + condition: Box, + then_branch: Box, + else_branch: Option>, + }, + Match { + subject: Box, + arms: Vec, + }, + Collect(Vec), + SetComprehension { + binding: Pattern, + source: Box, + filters: Vec, + value: Box, + }, + Finish(Box), + Unreachable, + Error, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnaryOp { + Not, + Negate, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum BinaryOp { + Or, + And, + Equal, + NotEqual, + Less, + LessEqual, + Greater, + GreaterEqual, + Add, + Subtract, + Multiply, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct RecordEntry { + pub key: Expr, + pub value: Expr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct MatchArm { + pub pattern: Pattern, + pub body: Expr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct CollectClause { + pub condition: Expr, + pub value: Expr, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum PatternKind { + Wildcard, + Rest, + Integer(String), + Decimal(String), + Text(String), + Bool(bool), + Name(Name), + Constructor { + path: Vec, + arguments: Vec, + }, + Tuple(Vec), + Record { + fields: Vec, + open: bool, + }, + Alternative(Vec), + Error, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct RecordPatternField { + pub name: Name, + pub pattern: Pattern, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UiDecl { + pub name: Name, + pub machine: Name, + pub binding: Name, + pub nodes: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum UiNodeKind { + Text(String), + Interpolation(Expr), + Element(UiElement), + If { + condition: Expr, + children: Vec, + }, + Match { + subject: Expr, + cases: Vec, + }, + Each { + source: Expr, + pattern: Pattern, + key: Expr, + children: Vec, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UiElement { + pub name: Name, + pub attributes: Vec, + pub children: Vec, + pub self_closing: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UiAttribute { + pub name: String, + pub value: UiAttributeValue, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum UiAttributeValue { + Text(String), + Expression(Expr), + Event { event: Name, input: Expr }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UiCase { + pub pattern: Pattern, + pub children: Vec, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ScenarioDecl { + pub name: Name, + pub origin: ScenarioOrigin, + pub steps: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum ScenarioOrigin { + Machine { + machine: Name, + configuration: Option, + }, + Snapshot(EvidenceRef), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EvidenceRef { + pub path: Vec, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EvidenceAliasDecl { + pub name: Name, + pub presentation: Option, + pub kind: Option, + pub is_default: bool, + pub note: Option, + pub target: EvidenceRef, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EvidencePresentationKind { + Page, + Component, + Surface, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum EvidenceStepKind { + Bind { + port: Name, + fixture: Expr, + }, + Start, + Send(Expr), + Deliver(Expr), + ExpectReaction { + outcome: Pattern, + commands: Vec, + }, + ExpectObservationPattern(Pattern), + ExpectInspectionPattern(Pattern), + ExpectObservationWhere(Expr), + ExpectRestore { + commands: Vec, + }, + ExpectSnapshot { + target: EvidenceRef, + }, + Pin(Name), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Project { + pub modules: Vec, +} diff --git a/crates/uhura-check/src/diagnostic.rs b/crates/uhura-check/src/diagnostic.rs new file mode 100644 index 0000000..5f909a7 --- /dev/null +++ b/crates/uhura-check/src/diagnostic.rs @@ -0,0 +1,29 @@ +use crate::checker_ir::SourceSpan; +use uhura_base::{Diagnostic, Severity, Span}; + +/// Compatibility-preserving machine diagnostic families, centralized in +/// `uhura-base` so syntax, checker, CLI, and host do not grow parallel +/// registries. +pub use uhura_base::codes::machine as codes; + +pub fn error( + code: &'static str, + rule: &'static str, + message: impl Into, + span: SourceSpan, +) -> Diagnostic { + Diagnostic::new(code, rule, Severity::Error, message, as_span(span)) +} + +pub fn warning( + code: &'static str, + rule: &'static str, + message: impl Into, + span: SourceSpan, +) -> Diagnostic { + Diagnostic::new(code, rule, Severity::Warning, message, as_span(span)) +} + +pub(crate) fn as_span(span: SourceSpan) -> Span { + Span::new(uhura_base::FileId(span.file), span.start, span.end) +} diff --git a/crates/uhura-check/src/examples.rs b/crates/uhura-check/src/examples.rs deleted file mode 100644 index 90abc38..0000000 --- a/crates/uhura-check/src/examples.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! Examples clause legality (§6.1 — the checker-enforced matrix). -//! Resolution to frozen snapshots is M3; derived replay is M4. This pass -//! guarantees the *shape*: clause sets by subject kind, `from` chains -//! (earlier-only, so no cycles), default uniqueness, pin targets, and the -//! static-value discipline of pinned expressions. - -use std::collections::BTreeSet; - -use uhura_base::{Diagnostic, Ident, Severity, Span, codes}; -use uhura_syntax::ast; - -use crate::manifest::Manifest; -use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean}; - -pub fn check_examples( - file: &ast::ExamplesFile, - subject: &DefEnv, - resolved: &Resolved, - manifest: &Manifest, - file_span: Span, - diags: &mut Vec, -) { - // ── imports: fixtures only, and known ones ───────────────────────── - for use_decl in &file.uses { - match use_decl { - ast::Use::Fixture { name, span, .. } => { - if Ident::new(name).is_ok_and(|n| !manifest.fixtures.contains_key(&n)) { - diags.push(Diagnostic::error( - codes::UNKNOWN_FIXTURE.0, - codes::UNKNOWN_FIXTURE.1, - format!("no fixture `{name}` in the manifest"), - *span, - )); - } - } - ast::Use::Component { span, .. } - | ast::Use::Surface { span, .. } - | ast::Use::Port { span, .. } => { - diags.push(Diagnostic::error( - codes::ILLEGAL_CLAUSE.0, - codes::ILLEGAL_CLAUSE.1, - "examples files import fixtures only; the subject's imports are its own \ - (§6.1)" - .to_string(), - *span, - )); - } - } - } - - let is_page = matches!(subject.kind, SubjectKind::Page { .. }); - let is_component = matches!(subject.kind, SubjectKind::Component { .. }); - let is_surface = matches!(subject.kind, SubjectKind::Surface { .. }); - - let mut declared: BTreeSet<&str> = BTreeSet::new(); - let mut default_span: Option = None; - - for example in &file.examples { - if !declared.insert(&example.name) { - diags.push(Diagnostic::error( - codes::BAD_FROM.0, - codes::BAD_FROM.1, - format!("example `{}` is declared twice", example.name), - example.span, - )); - } - if example.is_default { - match default_span { - None => default_span = Some(example.span), - Some(first) => diags.push( - Diagnostic::error( - codes::MULTIPLE_DEFAULTS.0, - codes::MULTIPLE_DEFAULTS.1, - "at most one example is `default` (§6.1)".to_string(), - example.span, - ) - .with_label(first, "already defaulted here"), - ), - } - } - - for clause in &example.clauses { - let (legal, what, span) = match clause { - ast::ExampleClause::Error { .. } => continue, - ast::ExampleClause::Note { span, .. } => (true, "note", *span), - ast::ExampleClause::From { name, span } => { - // Earlier-declared only — forward refs and cycles are - // unrepresentable (§6.2). - if !declared.contains(name.as_str()) || name == &example.name { - diags.push(Diagnostic::error( - codes::BAD_FROM.0, - codes::BAD_FROM.1, - format!( - "`from {name}` must name an example declared earlier in this \ - file (§6.2: no cycles, no forward refs)" - ), - *span, - )); - } - (true, "from", *span) - } - ast::ExampleClause::Params { entries, span } => { - check_params(entries, subject, diags); - (is_page, "params", *span) - } - ast::ExampleClause::Props { entries, span } => { - check_props(entries, subject, diags); - (is_component || is_surface, "props", *span) - } - ast::ExampleClause::State { entries, span } => { - check_state(entries, subject, diags); - (is_page || is_surface, "state", *span) - } - ast::ExampleClause::Projection(pin) => { - check_projection_pin(pin, resolved, diags); - (is_page || is_surface, "projection", pin.span) - } - ast::ExampleClause::Events { entries, span } => { - for event in entries { - check_event(event, subject, resolved, diags); - } - (is_page || is_surface, "events", *span) - } - }; - if !legal { - diags.push(Diagnostic::error( - codes::ILLEGAL_CLAUSE.0, - codes::ILLEGAL_CLAUSE.1, - format!( - "`{what}` is not a {} clause (§6.1: components take props/from/note; \ - pages add params/projection/state/events; surfaces take both sets)", - subject.kind.describe() - ), - span, - )); - } - } - } - - if default_span.is_none() && !file.examples.is_empty() { - diags.push(Diagnostic::new( - codes::NO_DEFAULT.0, - codes::NO_DEFAULT.1, - Severity::Info, - "no `default` example — the canvas cover falls back to the first declared (§6.1)", - file_span, - )); - } -} - -fn check_params(entries: &[(String, ast::Expr)], subject: &DefEnv, diags: &mut Vec) { - for (name, value) in entries { - let known = Ident::new(name).is_ok_and(|n| subject.params.contains_key(&n)); - if !known { - diags.push(Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - format!("the page declares no param `{name}`"), - value.span, - )); - } - require_static(value, diags); - } -} - -fn check_props(entries: &[(String, ast::Expr)], subject: &DefEnv, diags: &mut Vec) { - for (name, value) in entries { - let known = Ident::new(name).is_ok_and(|n| subject.props.contains_key(&n)); - if !known { - let mut d = Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - format!("the subject declares no prop `{name}`"), - value.span, - ); - if let Ok(ident) = Ident::new(name) - && let Some(s) = did_you_mean(&ident, subject.props.keys()) - { - d = d.with_note(format!("did you mean `{s}`?")); - } - diags.push(d); - } - require_static(value, diags); - } -} - -fn check_state(entries: &[(String, ast::Expr)], subject: &DefEnv, diags: &mut Vec) { - for (name, value) in entries { - let known = Ident::new(name).is_ok_and(|n| subject.state.contains_key(&n)); - if !known { - diags.push(Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - format!("the subject declares no state field `{name}`"), - value.span, - )); - } - require_static(value, diags); - } -} - -fn check_projection_pin( - pin: &ast::ProjectionPin, - resolved: &Resolved, - diags: &mut Vec, -) { - let Ok(port_name) = Ident::new(&pin.port) else { - return; - }; - let Some((contract, _)) = resolved.ports.get(&port_name) else { - diags.push(Diagnostic::error( - codes::UNKNOWN_PIN_TARGET.0, - codes::UNKNOWN_PIN_TARGET.1, - format!("no port `{}` in the manifest", pin.port), - pin.span, - )); - return; - }; - let Ok(proj_name) = Ident::new(&pin.projection) else { - return; - }; - let Some(decl) = contract.projections.get(&proj_name) else { - diags.push(Diagnostic::error( - codes::UNKNOWN_PIN_TARGET.0, - codes::UNKNOWN_PIN_TARGET.1, - format!("port `{port_name}` declares no projection `{proj_name}`"), - pin.span, - )); - return; - }; - match (&decl.key, &pin.key) { - (Some(_), None) => diags.push(Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - format!("`{proj_name}` is keyed — pin an instance: `{port_name}.{proj_name}()`"), - pin.span, - )), - (None, Some(_)) => diags.push(Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - format!("`{proj_name}` is not keyed — drop the key"), - pin.span, - )), - _ => {} - } - if let Some(key) = &pin.key { - require_static(key, diags); - } - // `failed("")` pins the failure state (micro-decision — mirrors - // `projection-failed`, §9.3); anything else must be static data. - match &pin.value.kind { - ast::ExprKind::Call { name, args } if name == "failed" => { - if !matches!(args.as_slice(), [one] if matches!(one.kind, ast::ExprKind::Str(_))) { - diags.push(Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - "`failed(…)` takes one reason string".to_string(), - pin.value.span, - )); - } - } - _ => require_static(&pin.value, diags), - } -} - -fn check_event( - event: &ast::ExampleEvent, - subject: &DefEnv, - resolved: &Resolved, - diags: &mut Vec, -) { - match event { - ast::ExampleEvent::Projection(pin) => check_projection_pin(pin, resolved, diags), - ast::ExampleEvent::Semantic { name, args, span } => { - let known = Ident::new(name).is_ok_and(|n| subject.events.contains_key(&n)); - if !known { - diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - format!("the subject has no handler for `{name}` — replay would drop it"), - *span, - )); - } - for arg in args { - require_static(&arg.value, diags); - } - } - ast::ExampleEvent::Outcome { - command, - which, - args, - span, - } => { - let Ok(cmd) = Ident::new(command) else { - return; - }; - let Some(info) = subject.commands.get(&cmd) else { - diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - format!( - "the subject imports no command `{command}` — no send could be \ - outstanding for this outcome" - ), - *span, - )); - return; - }; - match which { - ast::OutcomeKind::Ok => { - if !args.is_empty() { - diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - "ok payloads are empty for every spike command (§9.1)".to_string(), - *span, - )); - } - } - ast::OutcomeKind::Err => { - // Mirrors OutcomeResult (§9.3): a refused outcome names - // a declared refusal; an unavailable outcome carries a - // reason text (micro-decision — the design's §6.1 - // excerpt shows only the refusal form). - match args.as_slice() { - [one] if one.name == "refusal" => { - let declared = declared_refusals(&cmd, subject, resolved); - match &one.value.kind { - ast::ExprKind::Ident(n) - if Ident::new(n).is_ok_and(|n| declared.contains(&n)) - || n == "unavailable" => {} - ast::ExprKind::Ident(n) => diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - format!( - "`{n}` is not a declared refusal of `{command}` \ - (or `unavailable`)" - ), - one.value.span, - )), - _ => diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - "the refusal is a bare name, not a string".to_string(), - one.value.span, - )), - } - } - [one] if one.name == "reason" => { - if !matches!(one.value.kind, ast::ExprKind::Str(_)) { - diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - "an unavailable outcome's `reason` is a text string (§9.3)" - .to_string(), - one.value.span, - )); - } - } - _ => diags.push(Diagnostic::error( - codes::BAD_EXAMPLE_EVENT.0, - codes::BAD_EXAMPLE_EVENT.1, - "`.err` takes exactly `(refusal: )` or `(reason: \"\")`" - .to_string(), - *span, - )), - } - let _ = info; - } - } - } - } -} - -fn declared_refusals(cmd: &Ident, subject: &DefEnv, resolved: &Resolved) -> BTreeSet { - let Some(info) = subject.commands.get(cmd) else { - return BTreeSet::new(); - }; - resolved - .ports - .get(&info.port) - .and_then(|(contract, _)| contract.commands.get(cmd)) - .map(|c| c.refusals.clone()) - .unwrap_or_default() -} - -/// Pinned values are static: literals, records of static values, and -/// fixture slice references (`fixture..` field chains). No state, -/// props, operators, or reads — an example is data, not a program (§6.2). -fn require_static(expr: &ast::Expr, diags: &mut Vec) { - match &expr.kind { - ast::ExprKind::Int(_) - | ast::ExprKind::Str(_) - | ast::ExprKind::Bool(_) - | ast::ExprKind::None - | ast::ExprKind::Error => {} - ast::ExprKind::Record(entries) => { - for (_, value) in entries { - require_static(value, diags); - } - } - ast::ExprKind::Field { .. } if fixture_rooted(expr) => {} - _ => diags.push(Diagnostic::error( - codes::BAD_PIN.0, - codes::BAD_PIN.1, - "pins are static: literals, records of literals, or `fixture.…` slice references \ - (§6.2)" - .to_string(), - expr.span, - )), - } -} - -fn fixture_rooted(expr: &ast::Expr) -> bool { - match &expr.kind { - ast::ExprKind::Ident(name) => name == "fixture", - ast::ExprKind::Field { base, .. } => fixture_rooted(base), - _ => false, - } -} diff --git a/crates/uhura-check/src/fixture.rs b/crates/uhura-check/src/fixture.rs deleted file mode 100644 index dc76d9d..0000000 --- a/crates/uhura-check/src/fixture.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Fixture data (design §9.5): named slices in `fixtures/standard.toml`, -//! loaded as raw JSON values and typed at every binding site against the -//! expected structural type (L8 — an ill-typed fixture is a link error at -//! the site that binds it). -//! -//! Slices may reference each other with `"@."` strings (spike -//! micro-decision — keeps `feed.page-1` from duplicating every post); -//! references resolve at load with cycle detection. - -use std::collections::{BTreeMap, BTreeSet}; - -use uhura_base::{Ident, Value}; - -use crate::types::{MapKey, Ty}; - -/// Namespace → slice name → raw JSON value (references resolved). -#[derive(Clone, Debug, Default)] -pub struct FixtureData { - pub slices: BTreeMap>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FixtureIssue { - /// `.` (or a TOML path for structural problems). - pub path: String, - pub message: String, -} - -impl FixtureData { - pub fn get(&self, ns: &str, name: &str) -> Option<&serde_json::Value> { - self.slices.get(ns).and_then(|m| m.get(name)) - } -} - -pub fn load_fixture(text: &str) -> Result> { - let table: toml::Table = match text.parse() { - Ok(t) => t, - Err(e) => { - return Err(vec![FixtureIssue { - path: String::new(), - message: format!("invalid TOML: {e}"), - }]); - } - }; - - let mut issues = Vec::new(); - let mut raw: BTreeMap> = BTreeMap::new(); - for (ns, section) in &table { - let Some(section) = section.as_table() else { - issues.push(FixtureIssue { - path: ns.clone(), - message: "a fixture namespace is a table of slices".into(), - }); - continue; - }; - let mut slices = BTreeMap::new(); - for (name, value) in section { - match toml_to_json(value) { - Ok(json) => { - slices.insert(name.clone(), json); - } - Err(message) => issues.push(FixtureIssue { - path: format!("{ns}.{name}"), - message, - }), - } - } - raw.insert(ns.clone(), slices); - } - if !issues.is_empty() { - return Err(issues); - } - - // Resolve `@ns.name` references (deep), with cycle detection. - let mut resolved: BTreeMap> = BTreeMap::new(); - let keys: Vec<(String, String)> = raw - .iter() - .flat_map(|(ns, m)| m.keys().map(move |n| (ns.clone(), n.clone()))) - .collect(); - for (ns, name) in keys { - let mut in_flight = BTreeSet::new(); - match resolve_slice(&raw, &ns, &name, &mut in_flight) { - Ok(json) => { - resolved.entry(ns).or_default().insert(name, json); - } - Err(message) => issues.push(FixtureIssue { - path: format!("{ns}.{name}"), - message, - }), - } - } - if issues.is_empty() { - Ok(FixtureData { slices: resolved }) - } else { - Err(issues) - } -} - -fn resolve_slice( - raw: &BTreeMap>, - ns: &str, - name: &str, - in_flight: &mut BTreeSet, -) -> Result { - let key = format!("{ns}.{name}"); - if !in_flight.insert(key.clone()) { - return Err(format!("slice reference cycle through `@{key}`")); - } - let value = raw - .get(ns) - .and_then(|m| m.get(name)) - .ok_or_else(|| format!("no slice `{key}`"))?; - let resolved = resolve_refs(raw, value, in_flight)?; - in_flight.remove(&key); - Ok(resolved) -} - -fn resolve_refs( - raw: &BTreeMap>, - value: &serde_json::Value, - in_flight: &mut BTreeSet, -) -> Result { - use serde_json::Value as J; - match value { - J::String(s) => { - if let Some(reference) = s.strip_prefix('@') { - // Driver substitution markers (§9.5) pass through verbatim: - // scripts resolve them at delivery time (`fresh-id`, - // `payload.` — the only two). Slices carrying them - // are script-reply material and never legal as pins. - if reference == "fresh-id" || reference.starts_with("payload.") { - return Ok(value.clone()); - } - let Some((ns, name)) = reference.split_once('.') else { - return Err(format!("`@{reference}` is not a `@.` reference")); - }; - resolve_slice(raw, ns, name, in_flight) - } else { - Ok(value.clone()) - } - } - J::Array(items) => items - .iter() - .map(|item| resolve_refs(raw, item, in_flight)) - .collect::, _>>() - .map(J::Array), - J::Object(map) => map - .iter() - .map(|(k, v)| Ok((k.clone(), resolve_refs(raw, v, in_flight)?))) - .collect::, String>>() - .map(J::Object), - _ => Ok(value.clone()), - } -} - -/// TOML → JSON, refusing everything the value model refuses (floats, -/// datetimes — §7.5 determinism by type shape). -fn toml_to_json(value: &toml::Value) -> Result { - use serde_json::Value as J; - match value { - toml::Value::String(s) => Ok(J::String(s.clone())), - toml::Value::Integer(i) => Ok(J::Number((*i).into())), - toml::Value::Boolean(b) => Ok(J::Bool(*b)), - toml::Value::Float(_) => Err("floats do not exist in fixture data (§7.5)".into()), - toml::Value::Datetime(_) => { - Err("no clocks: time labels are provider-formatted text (§9.1)".into()) - } - toml::Value::Array(items) => items - .iter() - .map(toml_to_json) - .collect::, _>>() - .map(J::Array), - toml::Value::Table(table) => table - .iter() - .map(|(k, v)| Ok((k.clone(), toml_to_json(v)?))) - .collect::, String>>() - .map(J::Object), - } -} - -/// Decodes raw fixture JSON against a structural type — the use-site half -/// of L8. Strict on records (no unknown fields; absent optionals become -/// `none`); unions are single-variant objects; optionals decode bare. -pub fn decode_against_ty(json: &serde_json::Value, ty: &Ty) -> Result { - use serde_json::Value as J; - match ty { - Ty::Error => Err("cannot decode against an erroneous type".into()), - Ty::Bool => match json { - J::Bool(b) => Ok(Value::Bool(*b)), - other => Err(mismatch("bool", other)), - }, - Ty::Int => match json { - J::Number(n) => n - .as_i64() - .map(Value::Int) - .ok_or_else(|| format!("`{n}` is not an i64")), - other => Err(mismatch("int", other)), - }, - Ty::Text => match json { - J::String(s) => Ok(Value::Text(s.clone())), - other => Err(mismatch("text", other)), - }, - Ty::Id | Ty::Asset | Ty::Nominal { .. } => match json { - J::String(s) => Ok(Value::Id(s.clone())), - other => Err(mismatch(&ty.describe(), other)), - }, - Ty::Tag => Err("tags are core-minted; fixtures never carry them".into()), - Ty::NoneLit => match json { - J::Null => Ok(Value::None), - other => Err(mismatch("none", other)), - }, - Ty::Enum(values) => match json { - J::String(s) if values.iter().any(|v| v.as_str() == s) => Ok(Value::Text(s.clone())), - J::String(s) => Err(format!("`{s}` is not one of the enum's values")), - other => Err(mismatch("an enum value", other)), - }, - Ty::Option(inner) => match json { - J::Null => Ok(Value::None), - present => decode_against_ty(present, inner), - }, - Ty::List(inner) => match json { - J::Array(items) => items - .iter() - .map(|item| decode_against_ty(item, inner)) - .collect::, _>>() - .map(Value::List), - other => Err(mismatch(&ty.describe(), other)), - }, - Ty::Map(key_kind, inner) => match json { - J::Object(map) => { - let mut entries = BTreeMap::new(); - for (k, v) in map { - if *key_kind == MapKey::Tag { - return Err( - "tag-keyed maps are core state; fixtures never carry them".to_string() - ); - } - // Map keys are canonical key strings, not identifiers: - // external ids (UUIDs) are valid keys (in lock-step - // with uhura-core's wire decoder). - entries.insert(k.clone(), decode_against_ty(v, inner)?); - } - Ok(Value::Map(entries)) - } - other => Err(mismatch(&ty.describe(), other)), - }, - Ty::Record(fields) => { - let J::Object(map) = json else { - return Err(mismatch(&ty.describe(), json)); - }; - for k in map.keys() { - if Ident::new(k) - .map(|k| !fields.contains_key(&k)) - .unwrap_or(true) - { - return Err(format!("`{k}` is not a field of {}", ty.describe())); - } - } - let mut record = BTreeMap::new(); - for (field, field_ty) in fields { - match map.get(field.as_str()) { - Some(v) => { - let decoded = decode_against_ty(v, field_ty) - .map_err(|e| format!("in `{field}`: {e}"))?; - record.insert(field.clone(), decoded); - } - None if matches!(field_ty, Ty::Option(_)) => { - record.insert(field.clone(), Value::None); - } - None => { - return Err(format!("missing required field `{field}`")); - } - } - } - Ok(Value::Record(record)) - } - Ty::Union(variants) => { - let J::Object(map) = json else { - return Err(mismatch("a single-variant union object", json)); - }; - if map.len() != 1 { - return Err(format!( - "a union value has exactly one variant, got {}", - map.len() - )); - } - let (variant, body) = map.iter().next().expect("len checked"); - let variant_ident = Ident::new(variant).map_err(|e| e.to_string())?; - let Some(fields) = variants.get(&variant_ident) else { - return Err(format!("`{variant}` is not a variant of the union")); - }; - let payload = decode_against_ty(body, &Ty::Record(fields.clone())) - .map_err(|e| format!("in `{variant}`: {e}"))?; - let mut record = BTreeMap::new(); - record.insert(variant_ident, payload); - Ok(Value::Record(record)) - } - } -} - -fn mismatch(expected: &str, got: &serde_json::Value) -> String { - let shape = match got { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "a bool", - serde_json::Value::Number(_) => "a number", - serde_json::Value::String(_) => "a string", - serde_json::Value::Array(_) => "a list", - serde_json::Value::Object(_) => "an object", - }; - format!("expected {expected}, got {shape}") -} diff --git a/crates/uhura-check/src/icon_fonts.rs b/crates/uhura-check/src/icon_fonts.rs index 1058b63..5012642 100644 --- a/crates/uhura-check/src/icon_fonts.rs +++ b/crates/uhura-check/src/icon_fonts.rs @@ -1,7 +1,7 @@ //! Checked icon-family registries. The checker owns names and codepoints; //! renderers receive only validated, content-addressed font resources. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fmt; use std::io::{self, Read}; @@ -10,10 +10,12 @@ use std::sync::{Arc, OnceLock}; use serde::de::{MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; use ttf_parser::{Face, Tag}; -use uhura_base::{Ident, hash_json, sha256_hex}; +use uhura_base::{Diagnostic, FileId, Ident, Severity, Span, codes, hash_json, sha256_hex}; +use uhura_core::ir::{Expr, SourceRef, UiAttribute, UiAttributeValue, UiNode}; +use uhura_core::{Program, Value}; use wuff::decompress_woff2_with_custom_brotli; -use crate::manifest::IconsConfig; +use crate::resource_manifest::IconsConfig; const LUCIDE_FONT: &[u8] = include_bytes!("../../../resources/icon-fonts/lucide/lucide.woff2"); const LUCIDE_GLYPHS: &str = include_str!("../../../resources/icon-fonts/lucide/glyphs.json"); @@ -55,6 +57,238 @@ pub struct IconFontIssue { pub message: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IconTokenIssue { + pub code: &'static str, + pub rule: &'static str, + pub source: SourceRef, + pub message: String, +} + +/// Check every logical icon token against the exact project registry before +/// any renderer receives the program. +/// +/// `family` is deliberately literal before v1. `name` may be a literal or a +/// finite expression composed from literals, constants, `if`, and `match`. +pub fn check_program_icon_tokens( + program: &Program, + fonts: &CheckedIconFonts, +) -> Vec { + let mut issues = Vec::new(); + for presentation in program.presentations.values() { + check_icon_nodes(program, fonts, &presentation.nodes, &mut issues); + } + issues +} + +fn check_icon_nodes( + program: &Program, + fonts: &CheckedIconFonts, + nodes: &[UiNode], + issues: &mut Vec, +) { + for node in nodes { + match node { + UiNode::Element { + name, + attributes, + children, + source, + } => { + if name == "icon" { + check_icon_element(program, fonts, attributes, source, issues); + } + check_icon_nodes(program, fonts, children, issues); + } + UiNode::If { children, .. } | UiNode::Each { children, .. } => { + check_icon_nodes(program, fonts, children, issues); + } + UiNode::Match { cases, .. } => { + for case in cases { + check_icon_nodes(program, fonts, &case.children, issues); + } + } + UiNode::Text { .. } | UiNode::Interpolation { .. } => {} + } + } +} + +fn check_icon_element( + program: &Program, + fonts: &CheckedIconFonts, + attributes: &[UiAttribute], + element_source: &SourceRef, + issues: &mut Vec, +) { + let family_attribute = attributes + .iter() + .find(|attribute| attribute.name == "family"); + let family = match family_attribute.map(|attribute| &attribute.value) { + None => fonts.default.as_str(), + Some(UiAttributeValue::Text { value }) => value, + Some(UiAttributeValue::Expression { .. } | UiAttributeValue::Event { .. }) => { + issues.push(IconTokenIssue { + code: codes::UNKNOWN_ICON_FAMILY.0, + rule: "uhura/dynamic-icon-family", + source: family_attribute.map_or_else( + || element_source.clone(), + |attribute| attribute.source.clone(), + ), + message: "icon `family` must be a quoted project icon-family name".into(), + }); + return; + } + }; + + let Some((_, checked_family)) = fonts + .families + .iter() + .find(|(name, _)| name.as_str() == family) + else { + issues.push(IconTokenIssue { + code: codes::UNKNOWN_ICON_FAMILY.0, + rule: "uhura/unknown-icon-family", + source: family_attribute.map_or_else( + || element_source.clone(), + |attribute| attribute.source.clone(), + ), + message: format!("unknown checked icon family `{family}`"), + }); + return; + }; + + let Some(name_attribute) = attributes.iter().find(|attribute| attribute.name == "name") else { + // The UI catalogue reports the more direct missing-required-attribute + // error. Avoid a second resource diagnostic during recovery. + return; + }; + let names = match &name_attribute.value { + UiAttributeValue::Text { value } => BTreeSet::from([value.clone()]), + UiAttributeValue::Expression { value } => { + let mut names = BTreeSet::new(); + if !finite_icon_names(program, value, &mut names) || names.is_empty() { + issues.push(IconTokenIssue { + code: codes::UNKNOWN_ICON.0, + rule: "uhura/unbounded-icon-name", + source: name_attribute.source.clone(), + message: "icon `name` must be a literal or a finite expression of checked glyph names" + .into(), + }); + return; + } + names + } + UiAttributeValue::Event { .. } => return, + }; + for name in names { + if !checked_family + .glyphs + .keys() + .any(|glyph| glyph.as_str() == name) + { + issues.push(IconTokenIssue { + code: codes::UNKNOWN_ICON.0, + rule: "uhura/unknown-icon", + source: name_attribute.source.clone(), + message: format!("unknown icon glyph `{name}` in family `{family}`"), + }); + } + } +} + +/// Convert icon-registry findings into ordinary source diagnostics. +/// +/// Hosts provide the same admitted path-to-file mapping used to compile the +/// program. A missing source is an internal coverage failure rather than a +/// user-authored unknown-icon error. +pub fn icon_token_diagnostics<'a>( + program: &Program, + fonts: &CheckedIconFonts, + sources: impl IntoIterator, +) -> Vec { + let files = sources + .into_iter() + .map(|(file, path)| (path, file)) + .collect::>(); + let mut diagnostics = check_program_icon_tokens(program, fonts) + .into_iter() + .map(|issue| { + let Some(file) = files.get(issue.source.path.as_str()).copied() else { + return Diagnostic::new( + codes::ICON_SOURCE_COVERAGE.0, + codes::ICON_SOURCE_COVERAGE.1, + Severity::Error, + format!( + "checked icon source `{}` is absent from the admitted source inventory", + issue.source.path + ), + Span::new(FileId(0), 0, 0), + ); + }; + Diagnostic::new( + issue.code, + issue.rule, + Severity::Error, + issue.message, + Span::new(file, issue.source.start, issue.source.end), + ) + }) + .collect::>(); + diagnostics.sort_by(|left, right| { + ( + left.span.file, + left.span.start, + left.span.end, + left.code, + left.rule, + left.message.as_str(), + ) + .cmp(&( + right.span.file, + right.span.start, + right.span.end, + right.code, + right.rule, + right.message.as_str(), + )) + }); + diagnostics +} + +fn finite_icon_names(program: &Program, expression: &Expr, names: &mut BTreeSet) -> bool { + if names.len() > 256 { + return false; + } + match expression { + Expr::Literal { + value: Value::Text(value), + } => { + names.insert(value.clone()); + true + } + Expr::Name { name } => match program.machine_program.constants.get(name) { + Some(Value::Text(value)) => { + names.insert(value.clone()); + true + } + _ => false, + }, + Expr::If { + then_value, + else_value, + .. + } => { + finite_icon_names(program, then_value, names) + && finite_icon_names(program, else_value, names) + } + Expr::Match { arms, .. } => arms + .iter() + .all(|arm| finite_icon_names(program, &arm.value, names)), + Expr::Let { value, .. } => finite_icon_names(program, value, names), + _ => false, + } +} + /// Validate the built-in Lucide family and every app-local family. The /// registry is all-or-nothing so invalid font inputs cannot reach lowering. pub fn load_icon_fonts( diff --git a/crates/uhura-check/src/infer.rs b/crates/uhura-check/src/infer.rs deleted file mode 100644 index 778bf43..0000000 --- a/crates/uhura-check/src/infer.rs +++ /dev/null @@ -1,863 +0,0 @@ -//! Expression typing (§4.3), statement legality (§4.2), and handler -//! discipline — multi-handler signatures, outcome shapes, guard order. - -use std::collections::BTreeMap; - -use uhura_base::{Diagnostic, Ident, Span, codes}; -use uhura_syntax::ast; - -use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean}; -use crate::types::{MapKey, Ty, comparable, compatible}; - -pub struct Typer<'a> { - pub env: &'a DefEnv, - pub resolved: &'a Resolved, - pub diags: &'a mut Vec, - /// Innermost-last binding stack: handler params, `as` tags, each items, - /// match bindings. - pub locals: Vec<(Ident, Ty)>, - /// View position: non-boot projection reads must sit inside `{#match}` - /// availability (§9.2); guards/bodies read bare (transactional - /// backstop, §4.2). - pub in_view: bool, -} - -impl<'a> Typer<'a> { - pub fn new(env: &'a DefEnv, resolved: &'a Resolved, diags: &'a mut Vec) -> Self { - Typer { - env, - resolved, - diags, - locals: Vec::new(), - in_view: false, - } - } - - fn error(&mut self, code: (&'static str, &'static str), message: String, span: Span) -> Ty { - self.diags - .push(Diagnostic::error(code.0, code.1, message, span)); - Ty::Error - } - - /// Pushes a local binding; local shadowing is forbidden like every - /// other kind (§3). - pub fn push_local(&mut self, name: &str, ty: Ty, span: Span) -> usize { - let Ok(ident) = Ident::new(name) else { - return self.locals.len(); - }; - let already = self.locals.iter().any(|(n, _)| *n == ident) - || self.env.state.contains_key(&ident) - || self.env.props.contains_key(&ident) - || self.env.params.contains_key(&ident) - || self.env.projections.contains_key(&ident); - if already { - self.diags.push(Diagnostic::error( - codes::SHADOWED_NAME.0, - codes::SHADOWED_NAME.1, - format!("binding `{ident}` shadows an existing name"), - span, - )); - } - self.locals.push((ident, ty)); - self.locals.len() - 1 - } - - pub fn truncate_locals(&mut self, len: usize) { - self.locals.truncate(len); - } - - fn name_type(&mut self, name: &str, span: Span) -> Ty { - let Ok(ident) = Ident::new(name) else { - return Ty::Error; - }; - if let Some((_, ty)) = self.locals.iter().rev().find(|(n, _)| *n == ident) { - return ty.clone(); - } - if let Some(ty) = self.env.state.get(&ident) { - return ty.clone(); - } - if let Some(ty) = self.env.props.get(&ident) { - return ty.clone(); - } - if let Some(ty) = self.env.params.get(&ident) { - return ty.clone(); - } - if let Some(proj) = self.env.projections.get(&ident) { - if proj.key.is_some() { - return self.error( - codes::WRONG_ARGS, - format!("projection `{ident}` is keyed — read it as `{ident}()`"), - span, - ); - } - if self.in_view && !proj.boot { - return self.error( - codes::UNGUARDED_PROJECTION_READ, - format!( - "`{ident}` is absent until delivered — in markup, read it through \ - `{{#match {ident}}}` availability arms (§9.2)" - ), - span, - ); - } - return proj.ty.clone(); - } - let candidates = self - .locals - .iter() - .map(|(n, _)| n) - .chain(self.env.state.keys()) - .chain(self.env.props.keys()) - .chain(self.env.params.keys()) - .chain(self.env.projections.keys()); - let suggestion = did_you_mean(&ident, candidates).cloned(); - let mut d = Diagnostic::error( - codes::UNRESOLVED_NAME.0, - codes::UNRESOLVED_NAME.1, - format!("nothing named `{ident}` is in scope"), - span, - ); - if let Some(s) = suggestion { - d = d.with_note(format!("did you mean `{s}`?")); - } - self.diags.push(d); - Ty::Error - } - - pub fn infer(&mut self, e: &ast::Expr) -> Ty { - match &e.kind { - ast::ExprKind::Error => Ty::Error, - ast::ExprKind::Int(_) => Ty::Int, - ast::ExprKind::Str(_) => Ty::Text, - ast::ExprKind::Bool(_) => Ty::Bool, - ast::ExprKind::None => Ty::NoneLit, - ast::ExprKind::Ident(name) => self.name_type(name, e.span), - ast::ExprKind::Field { base, name } => { - let base_ty = self.infer(base); - let Ok(field) = Ident::new(name) else { - return Ty::Error; - }; - match base_ty { - Ty::Error => Ty::Error, - Ty::Record(fields) => match fields.get(&field) { - Some(t) => t.clone(), - None => { - let suggestion = did_you_mean(&field, fields.keys()).cloned(); - let mut d = Diagnostic::error( - codes::UNKNOWN_FIELD.0, - codes::UNKNOWN_FIELD.1, - format!( - "no field `{field}` on {}", - Ty::Record(fields.clone()).describe() - ), - e.span, - ); - if let Some(s) = suggestion { - d = d.with_note(format!("did you mean `{s}`?")); - } - self.diags.push(d); - Ty::Error - } - }, - Ty::Option(_) => self.error( - codes::UNKNOWN_FIELD, - format!("`.{field}` on an optional — settle it first with `??`"), - e.span, - ), - other => self.error( - codes::UNKNOWN_FIELD, - format!("{} has no fields", other.describe()), - e.span, - ), - } - } - ast::ExprKind::Index { base, key } => { - let base_ty = self.infer(base); - match base_ty { - Ty::Error => { - self.infer(key); - Ty::Error - } - Ty::Map(k, v) => { - let key_ty = match k { - MapKey::Id => Ty::Id, - MapKey::Tag => Ty::Tag, - }; - self.check(key, &key_ty); - Ty::Option(v) - } - Ty::List(t) => { - self.check(key, &Ty::Int); - Ty::Option(t) - } - other => self.error( - codes::BAD_INDEX, - format!( - "{} is not indexable (§4.3: maps and lists)", - other.describe() - ), - e.span, - ), - } - } - ast::ExprKind::Call { name, args } => self.infer_call(name, args, e.span), - ast::ExprKind::Unary { op, expr } => match op { - ast::UnaryOp::Not => { - self.check(expr, &Ty::Bool); - Ty::Bool - } - ast::UnaryOp::Neg => { - self.check(expr, &Ty::Int); - Ty::Int - } - }, - ast::ExprKind::Binary { op, lhs, rhs } => self.infer_binary(*op, lhs, rhs, e.span), - ast::ExprKind::If { cond, then, els } => { - self.check(cond, &Ty::Bool); - let t = self.infer(then); - let f = self.infer(els); - self.unify_branches(t, f, e.span) - } - ast::ExprKind::Record(entries) => { - let mut fields = BTreeMap::new(); - for (name, value) in entries { - let ty = self.infer(value); - if let Ok(name) = Ident::new(name) { - fields.insert(name, ty); - } - } - Ty::Record(fields) - } - } - } - - fn infer_call(&mut self, name: &str, args: &[ast::Expr], span: Span) -> Ty { - match name { - "to-text" => { - if args.len() != 1 { - return self.error( - codes::BAD_BUILTIN_CALL, - "`to-text` takes exactly one argument".to_string(), - span, - ); - } - let ty = self.infer(&args[0]); - if !matches!(ty, Ty::Int | Ty::Text | Ty::Bool | Ty::Id | Ty::Error) { - return self.error( - codes::BAD_BUILTIN_CALL, - format!("`to-text` renders int/text/bool/id, not {}", ty.describe()), - span, - ); - } - Ty::Text - } - "count" => { - if args.len() != 1 { - return self.error( - codes::BAD_BUILTIN_CALL, - "`count` takes exactly one argument".to_string(), - span, - ); - } - let ty = self.infer(&args[0]); - if !matches!(ty, Ty::List(_) | Ty::Map(..) | Ty::Error) { - return self.error( - codes::BAD_BUILTIN_CALL, - format!("`count` counts lists and maps, not {}", ty.describe()), - span, - ); - } - Ty::Int - } - other => { - let Ok(ident) = Ident::new(other) else { - return Ty::Error; - }; - if let Some(proj) = self.env.projections.get(&ident) { - let Some(key_ty) = proj.key.clone() else { - return self.error( - codes::WRONG_ARGS, - format!("projection `{ident}` is not keyed — read it bare"), - span, - ); - }; - if args.len() != 1 { - return self.error( - codes::WRONG_ARGS, - format!("keyed read `{ident}()` takes exactly one key"), - span, - ); - } - self.check(&args[0], &key_ty); - if self.in_view { - return self.error( - codes::UNGUARDED_PROJECTION_READ, - format!( - "`{ident}(…)` is absent until delivered — in markup, read it \ - through `{{#match {ident}(…)}}` availability arms (§9.2)" - ), - span, - ); - } - return proj.ty.clone(); - } - self.error( - codes::UNRESOLVED_NAME, - format!( - "`{other}` is not a builtin (`to-text`, `count`) or a keyed projection" - ), - span, - ) - } - } - } - - fn infer_binary( - &mut self, - op: ast::BinaryOp, - lhs: &ast::Expr, - rhs: &ast::Expr, - span: Span, - ) -> Ty { - use ast::BinaryOp as B; - match op { - B::Add | B::Sub => { - self.check(lhs, &Ty::Int); - self.check(rhs, &Ty::Int); - Ty::Int - } - B::Concat => { - self.check(lhs, &Ty::Text); - self.check(rhs, &Ty::Text); - Ty::Text - } - B::And | B::Or => { - self.check(lhs, &Ty::Bool); - self.check(rhs, &Ty::Bool); - Ty::Bool - } - B::Lt | B::Le | B::Gt | B::Ge => { - self.check(lhs, &Ty::Int); - self.check(rhs, &Ty::Int); - Ty::Bool - } - B::Eq | B::NotEq => { - let l = self.infer(lhs); - let r = self.infer(rhs); - if !comparable(&l, &r) { - self.error( - codes::BAD_OPERAND, - format!("cannot compare {} with {}", l.describe(), r.describe()), - span, - ); - } - Ty::Bool - } - B::Coalesce => { - let l = self.infer(lhs); - match l { - Ty::Error => { - self.infer(rhs); - Ty::Error - } - Ty::Option(inner) => { - self.check(rhs, &inner); - *inner - } - other => { - self.infer(rhs); - self.error( - codes::BAD_OPERAND, - format!( - "`??` settles optionals; {} is not optional", - other.describe() - ), - span, - ) - } - } - } - } - } - - fn unify_branches(&mut self, t: Ty, f: Ty, span: Span) -> Ty { - if t.is_error() || f.is_error() { - return Ty::Error; - } - if t == f { - return t; - } - match (&t, &f) { - (Ty::NoneLit, Ty::Option(_)) => f, - (Ty::Option(_), Ty::NoneLit) => t, - (Ty::NoneLit, _) => Ty::Option(Box::new(f)), - (_, Ty::NoneLit) => Ty::Option(Box::new(t)), - _ if compatible(&t, &f) => t, - _ if compatible(&f, &t) => f, - _ => self.error( - codes::BAD_OPERAND, - format!( - "`if` branches disagree: {} vs {}", - t.describe(), - f.describe() - ), - span, - ), - } - } - - /// Expected-type-directed checking: string literals against enums, - /// record literals field-wise, `if` branch-wise; everything else infers - /// and tests compatibility. - pub fn check(&mut self, e: &ast::Expr, expected: &Ty) { - match (&e.kind, expected) { - (_, Ty::Error) | (ast::ExprKind::Error, _) => {} - (ast::ExprKind::Str(s), Ty::Enum(values)) => { - if !values.iter().any(|v| v.as_str() == s) { - let list: Vec<&str> = values.iter().map(Ident::as_str).collect(); - self.error( - codes::TYPE_MISMATCH, - format!("`\"{s}\"` is not one of {}", list.join(" | ")), - e.span, - ); - } - } - (ast::ExprKind::Record(entries), Ty::Record(fields)) => { - let mut bound: BTreeMap = 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 (name, args) = match target { - ast::NavTarget::Route { name, args } | ast::NavTarget::Replace { name, args } => { - (name, args) - } - ast::NavTarget::Back => 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/crates/uhura-check/src/lib.rs b/crates/uhura-check/src/lib.rs index f056541..e1d8d17 100644 --- a/crates/uhura-check/src/lib.rs +++ b/crates/uhura-check/src/lib.rs @@ -1,26 +1,46 @@ -//! 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. +//! Uhura's pure static semantics and lowering pass. +//! +//! The checker consumes a source-spanned project and returns the one canonical +//! machine program plus diagnostics. Filesystem and host policy stay outside +//! this crate. #![deny(clippy::float_arithmetic)] -pub mod catalog; -pub mod examples; -pub mod fixture; +pub mod assets; +mod checker; +mod checker_ir; +mod diagnostic; pub mod icon_fonts; -pub mod infer; -pub mod lower; -pub mod manifest; -pub mod markup; -pub mod metadata; -pub mod pipeline; -pub mod preview; -pub mod replay; -pub mod resolve; -pub mod style; -pub mod types; +pub mod project_lock; +pub mod project_manifest; +pub mod resource_manifest; +mod types; +pub mod ui_catalog; +pub mod v04; +mod v04_compile; +mod v04_evidence; +mod v04_parts; +pub mod v04_provenance; +mod v04_topology; +mod v04_updates; -pub use icon_fonts::{CheckedIconFamily, CheckedIconFonts, IconFontInput}; -pub use pipeline::{CheckInput, CheckOutput, LockStatus, SourceInput, check}; +pub use assets::{AssetInput, CheckedAsset, CheckedAssets}; +pub use checker::CheckOutput; +pub use diagnostic::{codes, error, warning}; +pub use icon_fonts::{ + CheckedIconFamily, CheckedIconFonts, IconFontInput, IconTokenIssue, check_program_icon_tokens, + icon_token_diagnostics, +}; +pub use v04::{ + CapturedPackageModules as V04CapturedPackageModules, + ResolutionMetadata as V04ResolutionMetadata, ResolvedBinding as V04ResolvedBinding, + ResolvedDeclaration as V04ResolvedDeclaration, ResolvedProject as ResolvedV04Project, + ResolvedSource as V04ResolvedSource, check_module as check_v04_module, + check_package_graph_with_evidence as check_v04_package_graph_with_evidence, + check_project_modules as check_v04_project_modules, + check_project_modules_with_evidence as check_v04_project_modules_with_evidence, + check_resolved_project as check_resolved_v04_project, + check_resolved_project_with_evidence as check_resolved_v04_project_with_evidence, + resolve_project_modules as resolve_v04_project_modules, +}; +pub use v04_compile::{V04ProjectSource, compile_v04_project}; +pub use v04_provenance::{V04ProvenanceBuildError, build_v04_provenance}; diff --git a/crates/uhura-check/src/lower.rs b/crates/uhura-check/src/lower.rs deleted file mode 100644 index 9ebad63..0000000 --- a/crates/uhura-check/src/lower.rs +++ /dev/null @@ -1,998 +0,0 @@ -//! 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_core::template::{DefinitionAddress, DefinitionKind, TemplateAddress, walk_template}; -use uhura_syntax::{Parsed, ast}; - -use crate::catalog::{Catalog, EventKind, PropType}; -use crate::icon_fonts::CheckedIconFonts; -use crate::infer::Typer; -use crate::manifest::Manifest; -use crate::markup::{ElementResolution, resolve_element}; -use crate::metadata::SourceTargetId; -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, - /// Tooling-only source origins for every structural template operation. - /// This map is never serialized into `uhura-ir/0`. - pub template_origins: BTreeMap, -} - -impl Lowered { - /// Installs the compiler-built provenance sidecar and refuses to produce a - /// lowered artifact unless every template operation has exactly one key. - /// Consuming `self` keeps a failed attachment from escaping partially - /// initialized. - pub fn with_template_origins( - mut self, - origins: BTreeMap, - ) -> Result { - if !self.template_origins.is_empty() { - return Err("template origins were already installed on the lowered artifact".into()); - } - self.template_origins = origins; - self.validate_template_origin_coverage()?; - Ok(self) - } - - pub fn validate_template_origin_coverage(&self) -> Result<(), String> { - let mut expected = std::collections::BTreeSet::new(); - for (kind, definitions) in [ - (DefinitionKind::Page, &self.program.pages), - (DefinitionKind::Component, &self.program.components), - (DefinitionKind::Surface, &self.program.surfaces), - ] { - for (name, definition) in definitions { - let address = DefinitionAddress::new(kind, name.clone()); - walk_template(&address, &definition.root, |template, _| { - expected.insert(template.clone()); - }); - } - } - let actual = self - .template_origins - .keys() - .cloned() - .collect::>(); - if expected == actual { - return Ok(()); - } - let missing = expected.difference(&actual).count(); - let extra = actual.difference(&expected).count(); - let first_missing = expected.difference(&actual).next(); - let first_extra = actual.difference(&expected).next(); - let mut message = - format!("template origin coverage mismatch ({missing} missing, {extra} extra)"); - if let Some(address) = first_missing { - message.push_str(&format!("; first missing address: {address:?}")); - } - if let Some(address) = first_extra { - message.push_str(&format!("; first extra address: {address:?}")); - } - Err(message) - } -} - -pub fn lower( - manifest: &Manifest, - resolved: &Resolved, - catalog: &Catalog, - icon_fonts: &CheckedIconFonts, - 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 | PropType::IconFamily => { - 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 path = format!("{prefix}.{name}"); - let def = lower_def( - env, - src, - resolved, - catalog, - &icon_fonts.default, - &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, - template_origins: BTreeMap::new(), - } -} - -/// 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, - src: &ParsedSource, - resolved: &Resolved, - catalog: &Catalog, - icon_default: &Ident, - path: &str, - spans: &mut BTreeMap, -) -> Option { - let Parsed::Module(ast) = &src.parsed else { - return None; - }; - 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, catalog, icon_default, handler) { - handlers.push(h); - } - } - } - - let mut ctx = LowerCtx { - env, - resolved, - catalog, - icon_default, - 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(); - - Some(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, - catalog: &Catalog, - icon_default: &Ident, - 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, - catalog, - icon_default, - 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, - catalog: &'a Catalog, - icon_default: &'a Ident, - /// 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), - }), - ast::NavTarget::Replace { name, args } => Some(ir::StmtIr::NavigateReplace { - route: Ident::new(name).ok()?, - args: self.lower_args(args), - }), - }, - } - } - - fn lower_nodes(&mut self, nodes: &ast::MarkupList) -> 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()?; - match resolve_element( - &name, - self.env.component_imports.contains_key(&name), - self.resolved, - Some(self.catalog), - ) { - ElementResolution::CatalogElement => Some(self.lower_element(el, name)), - ElementResolution::ImportedComponent => { - Some(self.lower_component_call(el, name)) - } - ElementResolution::UnimportedComponent - | ElementResolution::Ambiguous - | ElementResolution::Unknown => None, - } - } - } - } - - /// 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, - }); - } - } - if element.as_str() == "icon" && !props.iter().any(|prop| prop.name.as_str() == "family") { - props.push(ir::ArgIr { - name: Ident::new("family").expect("kebab"), - value: ir::ExprIr::Text(self.icon_default.to_string()), - }); - } - 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/crates/uhura-check/src/manifest.rs b/crates/uhura-check/src/manifest.rs deleted file mode 100644 index d40852f..0000000 --- a/crates/uhura-check/src/manifest.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! 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, - /// Icon registry selection plus any app-local font families. `lucide` is - /// always available as the built-in good default. - pub icons: IconsConfig, - /// 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 test double plus an optional - /// browser-only provider module for `uhura dev`. - pub play: BTreeMap, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct IconsConfig { - pub default: Ident, - pub families: BTreeMap, -} - -impl Default for IconsConfig { - fn default() -> Self { - Self { - default: Ident::new("lucide").expect("built-in icon family is a valid identifier"), - families: BTreeMap::new(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct IconFamilyConfig { - /// Corpus-relative WOFF2 file. - pub font: String, - /// Corpus-relative JSON map from icon name to decimal Unicode codepoint. - pub glyphs: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PlayProfile { - pub fixture: Ident, - pub script: Ident, - /// Whether the deterministic fixture driver is selectable in the browser - /// Play shell. It remains available to checks, previews, and traces even - /// when this is false. - pub allow_fixture: bool, - /// A live provider used only by the play shell. The fixture and script - /// remain required because checks, previews, and traces keep using them. - pub provider: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PlayProvider { - /// Corpus-relative ES module path. `uhura play` reads the bytes into its - /// last-good build and exposes them at a content-addressed - /// `/api/play/provider.js` URL. - pub module: String, - /// Opaque string settings published with the provider selection in - /// `/api/play/config.json`. - pub config: BTreeMap, -} - -#[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", "icons", "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 icons = parse_icons(&table, &mut issues); - - 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 = …, provider = …? }`".into(), - ); - continue; - }; - for key in profile.keys() { - if !["fixture", "script", "allow_fixture", "provider"].contains(&key.as_str()) { - push( - &mut issues, - &format!("{path}.{key}"), - format!("unknown key `{key}`"), - ); - } - } - let fixture = ident_at( - &mut issues, - &format!("{path}.fixture"), - profile.get("fixture"), - ); - let script = ident_at( - &mut issues, - &format!("{path}.script"), - profile.get("script"), - ); - let allow_fixture = match profile.get("allow_fixture") { - None => true, - Some(toml::Value::Boolean(value)) => *value, - Some(_) => { - push( - &mut issues, - &format!("{path}.allow_fixture"), - "expected a boolean".into(), - ); - true - } - }; - let provider = parse_play_provider(&mut issues, &path, profile.get("provider")); - if !allow_fixture && profile.get("provider").is_none() { - push( - &mut issues, - &format!("{path}.allow_fixture"), - "cannot be false without a live provider".into(), - ); - } - if let (Some(fixture), Some(script)) = (fixture, script) { - play.insert( - profile_name, - PlayProfile { - fixture, - script, - allow_fixture, - provider, - }, - ); - } - } - } - - 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, - icons, - ports, - fixtures, - assets_manifest, - play, - }), - _ => Err(issues), - } -} - -fn parse_icons(table: &toml::Table, issues: &mut Vec) -> IconsConfig { - let mut out = IconsConfig::default(); - let Some(value) = table.get("icons") else { - return out; - }; - let Some(icons) = value.as_table() else { - issues.push(ManifestIssue { - path: "icons".into(), - message: "expected an `[icons]` table".into(), - }); - return out; - }; - if let Some(value) = icons.get("default") { - match value.as_str().map(Ident::new) { - Some(Ok(default)) => out.default = default, - Some(Err(error)) => issues.push(ManifestIssue { - path: "icons.default".into(), - message: error.to_string(), - }), - None => issues.push(ManifestIssue { - path: "icons.default".into(), - message: "expected an icon family name string".into(), - }), - } - } - - for (name, value) in icons.iter().filter(|(name, _)| name.as_str() != "default") { - let path = format!("icons.{name}"); - let Ok(name) = Ident::new(name) else { - issues.push(ManifestIssue { - path, - message: format!("`{name}` is not a lowercase kebab-case identifier"), - }); - continue; - }; - if name.as_str() == "lucide" { - issues.push(ManifestIssue { - path, - message: "`lucide` is built in and cannot be replaced locally".into(), - }); - continue; - } - let Some(family) = value.as_table() else { - issues.push(ManifestIssue { - path, - message: "expected `{ font = ..., glyphs = ... }`".into(), - }); - continue; - }; - for key in family.keys() { - if !["font", "glyphs"].contains(&key.as_str()) { - issues.push(ManifestIssue { - path: format!("{path}.{key}"), - message: format!("unknown key `{key}`"), - }); - } - } - let font = local_icon_path(family.get("font"), &format!("{path}.font"), issues); - let glyphs = local_icon_path(family.get("glyphs"), &format!("{path}.glyphs"), issues); - if let (Some(font), Some(glyphs)) = (font, glyphs) { - out.families.insert(name, IconFamilyConfig { font, glyphs }); - } - } - - if out.default.as_str() != "lucide" && !out.families.contains_key(&out.default) { - issues.push(ManifestIssue { - path: "icons.default".into(), - message: format!( - "`{}` is neither the built-in `lucide` family nor a declared local family", - out.default - ), - }); - } - out -} - -fn local_icon_path( - value: Option<&toml::Value>, - path: &str, - issues: &mut Vec, -) -> Option { - match value.and_then(toml::Value::as_str) { - Some(value) if safe_corpus_path(value) => Some(value.to_string()), - Some(_) => { - issues.push(ManifestIssue { - path: path.into(), - message: "expected a safe corpus-relative path".into(), - }); - None - } - None => { - issues.push(ManifestIssue { - path: path.into(), - message: "missing required path string".into(), - }); - None - } - } -} - -fn parse_play_provider( - issues: &mut Vec, - profile_path: &str, - value: Option<&toml::Value>, -) -> Option { - let value = value?; - let path = format!("{profile_path}.provider"); - let Some(table) = value.as_table() else { - issues.push(ManifestIssue { - path, - message: "expected a `{ module = …, config = { … } }` table".into(), - }); - return None; - }; - for key in table.keys() { - if !["module", "config"].contains(&key.as_str()) { - issues.push(ManifestIssue { - path: format!("{path}.{key}"), - message: format!("unknown key `{key}`"), - }); - } - } - - let module = match table.get("module").and_then(toml::Value::as_str) { - Some(module) if safe_corpus_path(module) => Some(module.to_string()), - Some(_) => { - issues.push(ManifestIssue { - path: format!("{path}.module"), - message: "expected a safe corpus-relative module path".into(), - }); - None - } - None => { - issues.push(ManifestIssue { - path: format!("{path}.module"), - message: "missing required string".into(), - }); - None - } - }; - - let mut config = BTreeMap::new(); - match table.get("config") { - None => {} - Some(toml::Value::Table(entries)) => { - for (key, value) in entries { - match value.as_str() { - Some(value) => { - config.insert(key.clone(), value.to_string()); - } - None => issues.push(ManifestIssue { - path: format!("{path}.config.{key}"), - message: "provider config values must be strings".into(), - }), - } - } - } - Some(_) => issues.push(ManifestIssue { - path: format!("{path}.config"), - message: "expected a table of string values".into(), - }), - } - - module.map(|module| PlayProvider { module, config }) -} - -fn safe_corpus_path(path: &str) -> bool { - !path.is_empty() - && !path.starts_with('/') - && !path.contains('\\') - && path - .split('/') - .all(|segment| !segment.is_empty() && !matches!(segment, "." | "..")) -} diff --git a/crates/uhura-check/src/markup.rs b/crates/uhura-check/src/markup.rs deleted file mode 100644 index ddff1a6..0000000 --- a/crates/uhura-check/src/markup.rs +++ /dev/null @@ -1,1497 +0,0 @@ -//! 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::icon_fonts::CheckedIconFonts; -use crate::infer::Typer; -use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean}; -use crate::types::{MapKey, Ty}; - -/// The single checker-wide classification for a markup element name. -/// -/// An explicit component import normally selects the component. If that name -/// is also owned by the catalog, the reference is ambiguous and checking must -/// reject it instead of letting later passes choose different meanings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ElementResolution { - CatalogElement, - ImportedComponent, - UnimportedComponent, - Ambiguous, - Unknown, -} - -pub(crate) fn resolve_element( - name: &Ident, - component_imported: bool, - resolved: &Resolved, - catalog: Option<&Catalog>, -) -> ElementResolution { - let is_component = resolved.components.contains_key(name); - let is_catalog = catalog.is_some_and(|catalog| catalog.elements.contains_key(name)); - match (is_component, component_imported, is_catalog) { - (true, true, true) => ElementResolution::Ambiguous, - (true, true, false) => ElementResolution::ImportedComponent, - (_, _, true) => ElementResolution::CatalogElement, - (true, false, false) => ElementResolution::UnimportedComponent, - (false, _, false) => ElementResolution::Unknown, - } -} - -/// Documented patterns for things that are deliberately not elements (§10); -/// surfaced as notes on `unknown-element`. -const UNKNOWN_ELEMENT_NOTES: &[(&str, &str)] = &[ - ( - "image", - "`` was renamed to ``; the old spelling is not a compatibility alias", - ), - ( - "text-field", - "`` was renamed to ``; the old spelling is not a compatibility alias", - ), - ( - "avatar", - "the avatar pattern is `` — see docs/widgets/patterns", - ), - ( - "card", - "the card pattern is `` — see docs/widgets/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, - pub icon_fonts: &'a CheckedIconFonts, - /// 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, - icon_fonts: &CheckedIconFonts, - interactive_memo: &BTreeMap, - markup: &ast::MarkupList, - diags: &mut Vec, -) -> MarkupFacts { - let file_span = Span::new(env.file, 0, 0); - let mut typer = Typer::new(env, resolved, diags); - typer.in_view = true; - let mut checker = MarkupChecker { - typer, - catalog, - icon_fonts, - 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::MarkupList, 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; - }; - match resolve_element( - &name, - self.typer.env.component_imports.contains_key(&name), - self.typer.resolved, - Some(self.catalog), - ) { - ElementResolution::CatalogElement => { - self.walk_element(el, &name, in_interactive); - } - ElementResolution::ImportedComponent - | ElementResolution::UnimportedComponent => { - self.walk_component_call(el, &name, in_interactive); - } - ElementResolution::Ambiguous => self.typer.diags.push( - Diagnostic::error( - codes::SHADOWED_NAME.0, - codes::SHADOWED_NAME.1, - format!( - "`<{name}>` is ambiguous: an imported component shadows a catalog element with the same name" - ), - el.span, - ) - .with_label( - self.typer.env.component_imports[&name], - "component imported here", - ), - ), - ElementResolution::Unknown => { - 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)) = - UNKNOWN_ELEMENT_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(); - let icon_family = if name.as_str() == "icon" { - self.resolve_icon_family(el) - } else { - None - }; - - 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()); - } - if name.as_str() == "icon" && attr_name.as_str() == "family" { - // Family selection was resolved before the attribute loop so - // `name` is checked correctly regardless of authoring order. - continue; - } - if name.as_str() == "icon" && attr_name.as_str() == "name" { - if let Some((family, glyphs)) = &icon_family { - self.check_icon_name(family, glyphs, &attr.value, attr.span); - } - continue; - } - 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, - ); - continue; - } - - // An exactly-one boolean branch is selected by its presence, so a - // false or dynamic value would contradict the structural choice. - // Treat it as a bare marker while keeping ordinary bool props - // expression-capable. - for prop_name in group { - if !bound.contains_key(prop_name) - || !matches!(decl.props[prop_name].ty, PropType::Bool) - { - continue; - } - let Some(attr) = el.attrs.iter().find(|attr| attr.name == prop_name.as_str()) - else { - continue; - }; - if !matches!(attr.value, ast::AttrValue::Bare) { - self.error( - codes::A11Y_ALT, - format!( - "`<{name}>`'s `{prop_name}` alternative is a presence marker — write bare `{prop_name}`" - ), - attr.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 / img / video / 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 | PropType::IconFamily => Ty::Text, - }; - 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::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 | PropType::Icon | PropType::IconFamily => {} - 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), - } - } - - fn resolve_icon_family(&mut self, el: &ast::Element) -> Option<(Ident, BTreeSet)> { - let family_attr = el.attrs.iter().find(|attr| attr.name == "family"); - let family = match family_attr.map(|attr| (&attr.value, attr.span)) { - None => self.icon_fonts.default.clone(), - Some((ast::AttrValue::Literal(value), span)) => match Ident::new(value) { - Ok(family) => family, - Err(_) => { - self.error( - codes::UNKNOWN_ICON_FAMILY, - format!("`{value}` is not an icon family name"), - span, - ); - return None; - } - }, - Some((_, span)) => { - self.error( - codes::TYPE_MISMATCH, - "`` family must be a quoted, statically selected family name".to_string(), - span, - ); - return None; - } - }; - - let Some(checked) = self.icon_fonts.families.get(&family) else { - let span = family_attr.map_or(el.span, |attr| attr.span); - let mut diagnostic = Diagnostic::error( - codes::UNKNOWN_ICON_FAMILY.0, - codes::UNKNOWN_ICON_FAMILY.1, - format!("unknown icon family `{family}`"), - span, - ); - if let Some(near) = did_you_mean(&family, self.icon_fonts.families.keys()) { - diagnostic = diagnostic.with_note(format!("did you mean `{near}`?")); - } - self.typer.diags.push(diagnostic); - return None; - }; - Some((family, checked.glyphs.keys().cloned().collect())) - } - - fn check_icon_name( - &mut self, - family: &Ident, - glyphs: &BTreeSet, - value: &ast::AttrValue, - span: Span, - ) { - match value { - ast::AttrValue::Bare => self.error( - codes::TYPE_MISMATCH, - "bare `name` means `true`; ``'s `name` is an icon name".to_string(), - span, - ), - ast::AttrValue::Literal(value) => self.check_icon_literal(family, glyphs, value, span), - ast::AttrValue::Expr(expr) => self.check_icon_expr(family, glyphs, expr), - } - } - - fn check_icon_expr(&mut self, family: &Ident, glyphs: &BTreeSet, expr: &ast::Expr) { - // Registry membership is a domain constraint, not a 1,995-member - // language enum. Keep diagnostics family-specific and accept enum - // expressions whose possible values are a valid subset. - match &expr.kind { - ast::ExprKind::Error => {} - ast::ExprKind::Str(value) => self.check_icon_literal(family, glyphs, value, expr.span), - ast::ExprKind::If { cond, then, els } => { - self.typer.check(cond, &Ty::Bool); - self.check_icon_expr(family, glyphs, then); - self.check_icon_expr(family, glyphs, els); - } - _ => match self.typer.infer(expr) { - Ty::Error => {} - Ty::Enum(values) => { - if let Some(icon) = values.iter().find(|icon| !glyphs.contains(*icon)) { - self.unknown_icon(family, glyphs, icon, expr.span); - } - } - actual => self.error( - codes::TYPE_MISMATCH, - format!( - "expected an icon name in family `{family}`, got {}", - actual.describe() - ), - expr.span, - ), - }, - } - } - - fn check_icon_literal( - &mut self, - family: &Ident, - glyphs: &BTreeSet, - value: &str, - span: Span, - ) { - match Ident::new(value) { - Ok(icon) if glyphs.contains(&icon) => {} - Ok(icon) => self.unknown_icon(family, glyphs, &icon, span), - Err(_) => self.error( - codes::UNKNOWN_ICON, - format!("`{value}` is not an icon name"), - span, - ), - } - } - - fn unknown_icon(&mut self, family: &Ident, glyphs: &BTreeSet, icon: &Ident, span: Span) { - let mut diagnostic = Diagnostic::error( - codes::UNKNOWN_ICON.0, - codes::UNKNOWN_ICON.1, - format!("`{icon}` is not in icon family `{family}`"), - span, - ); - if let Some(near) = did_you_mean(icon, glyphs.iter()) { - diagnostic = diagnostic.with_note(format!("did you mean `{near}`?")); - } - self.typer.diags.push(diagnostic); - } - - /// `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::MarkupList, - env: &DefEnv, - 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; - }; - match resolve_element( - &name, - env.component_imports.contains_key(&name), - resolved, - Some(catalog), - ) { - ElementResolution::CatalogElement => { - catalog.elements[&name].class == ElementClass::Interactive - || nodes_interactive( - &el.children, - env, - catalog, - resolved, - sources, - memo, - ) - } - ElementResolution::ImportedComponent => { - component_interactive(&name, catalog, resolved, sources, memo) - } - ElementResolution::Ambiguous => { - catalog.elements[&name].class == ElementClass::Interactive - || component_interactive(&name, catalog, resolved, sources, memo) - } - ElementResolution::UnimportedComponent | ElementResolution::Unknown => false, - } - } - ast::Node::If { then, els, .. } => { - nodes_interactive(then, env, catalog, resolved, sources, memo) - || els.as_ref().is_some_and(|e| { - nodes_interactive(e, env, catalog, resolved, sources, memo) - }) - } - ast::Node::Each { body, .. } => { - nodes_interactive(body, env, catalog, resolved, sources, memo) - } - ast::Node::Match { arms, .. } => arms - .iter() - .any(|arm| nodes_interactive(&arm.body, env, 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, env, 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/crates/uhura-check/src/metadata.rs b/crates/uhura-check/src/metadata.rs deleted file mode 100644 index e272cfd..0000000 --- a/crates/uhura-check/src/metadata.rs +++ /dev/null @@ -1,1292 +0,0 @@ -//! Checked authoring metadata (RFC 0003 §9). -//! -//! This projection is compiler-owned and deliberately separate from runtime -//! IR. Its identifiers describe one checked source revision; they are not a -//! promise of durable identity across arbitrary source edits. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -use serde::Serialize; -use serde_json::json; -use uhura_base::{Diagnostic, Ident, SourceMap, Span, codes, hash_json}; -use uhura_core::template::{DefinitionAddress, DefinitionKind, TemplateAddress, TemplateSegment}; -use uhura_syntax::{Parsed, ast}; - -use crate::catalog::Catalog; -use crate::markup::{ElementResolution, resolve_element}; -use crate::resolve::{ParsedSource, Resolved, SubjectKind}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum MetadataClass { - Doc, - Annotation, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum SourceTargetClass { - SourceModule, - ComponentDeclaration, - PageDeclaration, - SurfaceDeclaration, - PropDeclaration, - EmittedEventDeclaration, - EmittedEventParameter, - RouteParameter, - StoreScope, - StateField, - EventHandler, - OutcomeHandler, - HandlerParameter, - ExampleDeclaration, - CatalogElement, - ComponentInvocation, - IfBlock, - EachBlock, - MatchBlock, -} - -impl SourceTargetClass { - pub fn is_annotatable(self) -> bool { - matches!( - self, - Self::CatalogElement - | Self::ComponentInvocation - | Self::IfBlock - | Self::EachBlock - | Self::MatchBlock - ) - } - - pub fn is_documentable(self) -> bool { - !self.is_annotatable() - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum SourceOwnerKind { - Module, - Examples, - Component, - Page, - Surface, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SourceOwner { - pub kind: SourceOwnerKind, - pub name: String, -} - -/// A structural address in syntax, used as the stable input to a target ID. -/// It intentionally contains no byte offsets or metadata prose. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(transparent)] -pub struct SourceSyntaxAddress(pub Vec); - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum SourceSyntaxSegment { - Module, - Definition, - Props, - Emits, - RouteParameters, - Store, - State, - Handlers, - Parameters, - Examples, - Markup, - Item(u32), - Children, - IfThen, - IfElse, - EachBody, - MatchArms, - Arm(u32), -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct SourceTargetId(String); - -impl SourceTargetId { - pub fn from_parts(file: &str, class: SourceTargetClass, address: &SourceSyntaxAddress) -> Self { - Self(hash_json(&json!({ - "file": file, - "class": class, - "address": address, - }))) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for SourceTargetId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct SourceMetadataId(String); - -impl SourceMetadataId { - pub fn from_parts(target: &SourceTargetId, class: MetadataClass, order: u32) -> Self { - Self(hash_json(&json!({ - "target": target.as_str(), - "class": class, - "order": order, - }))) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for SourceMetadataId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SourceTarget { - pub id: SourceTargetId, - pub class: SourceTargetClass, - pub file: String, - pub span: Span, - pub address: SourceSyntaxAddress, - pub owner: SourceOwner, - pub label: String, -} - -impl SourceTarget { - pub fn new( - class: SourceTargetClass, - file: String, - span: Span, - address: SourceSyntaxAddress, - owner: SourceOwner, - label: String, - ) -> Self { - let id = SourceTargetId::from_parts(&file, class, &address); - Self { - id, - class, - file, - span, - address, - owner, - label, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SourceMetadataEntry { - pub id: SourceMetadataId, - pub class: MetadataClass, - pub kind: String, - pub text: String, - pub metadata_span: Span, - pub target_id: SourceTargetId, - pub order: u32, -} - -impl SourceMetadataEntry { - pub fn new( - class: MetadataClass, - kind: String, - text: String, - metadata_span: Span, - target_id: SourceTargetId, - order: u32, - ) -> Self { - let id = SourceMetadataId::from_parts(&target_id, class, order); - Self { - id, - class, - kind, - text, - metadata_span, - target_id, - order, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct AuthoringProjection { - pub targets: Vec, - pub entries: Vec, -} - -/// Checker-internal indexes built alongside the public logical projection. -/// They connect syntax to lowering and previews without adding source data to -/// runtime IR or asking consumers to reverse-engineer target labels/spans. -#[derive(Clone, Debug, Default)] -pub struct AuthoringCollection { - pub projection: AuthoringProjection, - pub template_origins: BTreeMap, - pub definition_targets: BTreeMap, - pub example_targets: BTreeMap<(String, String), SourceTargetId>, - pub(crate) template_origin_errors: Vec, -} - -impl AuthoringProjection { - /// Checks the closed metadata/target contract before it crosses a wire - /// boundary. Parsing and checking still own user-facing diagnostics. - pub fn validate(&self) -> Result<(), String> { - let mut targets = BTreeMap::new(); - for target in &self.targets { - let expected = SourceTargetId::from_parts(&target.file, target.class, &target.address); - if target.id != expected { - return Err(format!("target `{}` has a non-canonical id", target.id)); - } - if targets.insert(target.id.clone(), target).is_some() { - return Err(format!("duplicate source target `{}`", target.id)); - } - } - - let mut ids = BTreeSet::new(); - let mut next_orders: BTreeMap<(&SourceTargetId, MetadataClass), u32> = BTreeMap::new(); - for entry in &self.entries { - if !ids.insert(entry.id.clone()) { - return Err(format!("duplicate metadata entry `{}`", entry.id)); - } - let Some(target) = targets.get(&entry.target_id) else { - return Err(format!( - "metadata entry `{}` references an unknown target `{}`", - entry.id, entry.target_id - )); - }; - let expected = SourceMetadataId::from_parts(&entry.target_id, entry.class, entry.order); - if entry.id != expected { - return Err(format!( - "metadata entry `{}` has a non-canonical id", - entry.id - )); - } - match entry.class { - MetadataClass::Doc => { - if entry.kind != "doc" || entry.order != 0 || !target.class.is_documentable() { - return Err(format!( - "metadata entry `{}` is not a valid declaration doc", - entry.id - )); - } - } - MetadataClass::Annotation => { - if !valid_annotation_kind(&entry.kind) || !target.class.is_annotatable() { - return Err(format!( - "metadata entry `{}` is not a valid markup annotation", - entry.id - )); - } - } - } - let next = next_orders - .entry((&entry.target_id, entry.class)) - .or_default(); - if entry.order != *next { - return Err(format!( - "metadata entries for target `{}` are not contiguous from zero", - entry.target_id - )); - } - *next += 1; - } - Ok(()) - } -} - -fn valid_annotation_kind(kind: &str) -> bool { - if kind.is_empty() || kind.len() > 64 || !kind.is_ascii() { - return false; - } - let bytes = kind.as_bytes(); - if !bytes[0].is_ascii_lowercase() || bytes.last() == Some(&b'-') { - return false; - } - let mut previous_dash = false; - for byte in bytes { - if *byte == b'-' { - if previous_dash { - return false; - } - previous_dash = true; - } else if byte.is_ascii_lowercase() || byte.is_ascii_digit() { - previous_dash = false; - } else { - return false; - } - } - true -} - -impl AuthoringCollection { - pub fn doc_for_target(&self, target: &SourceTargetId) -> Option { - self.projection - .entries - .iter() - .find(|entry| entry.class == MetadataClass::Doc && entry.target_id == *target) - .map(|entry| entry.id.clone()) - } - - pub(crate) fn template_origin_error(&self) -> Option { - (!self.template_origin_errors.is_empty()).then(|| self.template_origin_errors.join("; ")) - } -} - -/// Builds RFC 0003's authoring projection even when unrelated checking has -/// failed. Invalid/recovery constructs are omitted; markup targets are added -/// only when catalog/component resolution can classify them independently. -pub fn collect_authoring( - sources: &[ParsedSource], - resolved: &Resolved, - catalog: Option<&Catalog>, - source_map: &SourceMap, - diagnostics: &mut Vec, -) -> AuthoringCollection { - let env_by_source: BTreeMap = resolved - .pages - .values() - .chain(resolved.components.values()) - .chain(resolved.surfaces.values()) - .map(|env| (env.source, env)) - .collect(); - - let mut out = AuthoringCollection::default(); - for (source_index, source) in sources.iter().enumerate() { - match &source.parsed { - Parsed::Module(file) => { - collect_module( - source, - file, - env_by_source.get(&source_index).copied(), - resolved, - catalog, - source_map, - diagnostics, - &mut out, - ); - } - Parsed::Examples(file) => collect_examples(source, file, source_map, &mut out), - } - } - - out.projection.targets.sort_by(|a, b| { - (&a.file, a.span.start, a.class, &a.id).cmp(&(&b.file, b.span.start, b.class, &b.id)) - }); - out.projection.entries.sort_by(|a, b| { - let a_file = out - .projection - .targets - .iter() - .find(|target| target.id == a.target_id) - .map(|target| target.file.as_str()) - .unwrap_or_default(); - let b_file = out - .projection - .targets - .iter() - .find(|target| target.id == b.target_id) - .map(|target| target.file.as_str()) - .unwrap_or_default(); - (a_file, a.metadata_span.start, a.order, &a.id).cmp(&( - b_file, - b.metadata_span.start, - b.order, - &b.id, - )) - }); - debug_assert!(out.projection.validate().is_ok()); - out -} - -#[allow(clippy::too_many_arguments)] -fn collect_module( - source: &ParsedSource, - file: &ast::File, - env: Option<&crate::resolve::DefEnv>, - resolved: &Resolved, - catalog: Option<&Catalog>, - source_map: &SourceMap, - diagnostics: &mut Vec, - out: &mut AuthoringCollection, -) { - let source_span = Span::new( - source.file, - 0, - u32::try_from(source_map.text(source.file).len()).expect("source text fits in u32"), - ); - let module_owner = SourceOwner { - kind: SourceOwnerKind::Module, - name: source.rel_path.clone(), - }; - let module_target = add_target( - out, - SourceTargetClass::SourceModule, - source, - source_span, - SourceSyntaxAddress(vec![SourceSyntaxSegment::Module]), - module_owner, - source.rel_path.clone(), - first_doc(&file.preamble, ast::DocForm::Inner), - ); - let _ = module_target; - - let Some(identity) = module_identity(source, file, env) else { - return; - }; - let owner = identity.owner; - - let declaration_target = add_target( - out, - identity.declaration_class, - source, - identity.declaration_span, - SourceSyntaxAddress(vec![SourceSyntaxSegment::Definition]), - owner.clone(), - owner.name.clone(), - first_doc(&file.preamble, ast::DocForm::Outer), - ); - if let Some(definition) = &identity.definition { - out.definition_targets - .insert(definition.clone(), declaration_target); - } - - for (index, prop) in file.props.iter().enumerate() { - add_target( - out, - SourceTargetClass::PropDeclaration, - source, - prop.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Props, - item(index), - ]), - owner.clone(), - prop.name.clone(), - first_doc(&prop.leading, ast::DocForm::Outer), - ); - } - for (emit_index, emit) in file.emits.iter().enumerate() { - add_target( - out, - SourceTargetClass::EmittedEventDeclaration, - source, - emit.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Emits, - item(emit_index), - ]), - owner.clone(), - emit.name.clone(), - first_doc(&emit.leading, ast::DocForm::Outer), - ); - for (param_index, param) in emit.params.iter().enumerate() { - add_target( - out, - SourceTargetClass::EmittedEventParameter, - source, - param.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Emits, - item(emit_index), - SourceSyntaxSegment::Parameters, - item(param_index), - ]), - owner.clone(), - param.name.clone(), - first_doc(¶m.leading, ast::DocForm::Outer), - ); - } - } - for (index, param) in file.params.iter().enumerate() { - add_target( - out, - SourceTargetClass::RouteParameter, - source, - param.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::RouteParameters, - item(index), - ]), - owner.clone(), - param.name.clone(), - first_doc(¶m.leading, ast::DocForm::Outer), - ); - } - if let Some(store) = &file.store { - add_target( - out, - SourceTargetClass::StoreScope, - source, - store.span, - address([SourceSyntaxSegment::Definition, SourceSyntaxSegment::Store]), - owner.clone(), - "store".into(), - first_doc(&store.leading, ast::DocForm::Outer), - ); - for (index, field) in store.state.iter().enumerate() { - add_target( - out, - SourceTargetClass::StateField, - source, - field.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Store, - SourceSyntaxSegment::State, - item(index), - ]), - owner.clone(), - field.name.clone(), - first_doc(&field.leading, ast::DocForm::Outer), - ); - } - for (handler_index, handler) in store.handlers.iter().enumerate() { - let (class, label) = match &handler.event { - ast::EventRef::Semantic { name, .. } => { - (SourceTargetClass::EventHandler, name.clone()) - } - ast::EventRef::Outcome { command, which, .. } => ( - SourceTargetClass::OutcomeHandler, - format!( - "{}.{}", - command, - match which { - ast::OutcomeKind::Ok => "ok", - ast::OutcomeKind::Err => "err", - } - ), - ), - }; - add_target( - out, - class, - source, - handler.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Store, - SourceSyntaxSegment::Handlers, - item(handler_index), - ]), - owner.clone(), - label, - first_doc(&handler.leading, ast::DocForm::Outer), - ); - for (param_index, param) in handler.params.iter().enumerate() { - add_target( - out, - SourceTargetClass::HandlerParameter, - source, - param.span, - address([ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Store, - SourceSyntaxSegment::Handlers, - item(handler_index), - SourceSyntaxSegment::Parameters, - item(param_index), - ]), - owner.clone(), - param.name.clone(), - first_doc(¶m.leading, ast::DocForm::Outer), - ); - } - } - } - - let component_imports = file - .uses - .iter() - .filter_map(|use_decl| match use_decl { - ast::Use::Component { name, .. } => Ident::new(name).ok(), - _ => None, - }) - .collect::>(); - let source_prefix = vec![SourceSyntaxSegment::Definition, SourceSyntaxSegment::Markup]; - collect_markup_list( - source, - &file.markup, - &source_prefix, - identity.definition.as_ref(), - &owner, - &component_imports, - resolved, - catalog, - diagnostics, - out, - ); -} - -fn collect_examples( - source: &ParsedSource, - file: &ast::ExamplesFile, - source_map: &SourceMap, - out: &mut AuthoringCollection, -) { - let owner = SourceOwner { - kind: SourceOwnerKind::Examples, - name: source.rel_path.clone(), - }; - add_target( - out, - SourceTargetClass::SourceModule, - source, - Span::new( - source.file, - 0, - u32::try_from(source_map.text(source.file).len()).expect("source text fits in u32"), - ), - address([SourceSyntaxSegment::Module]), - owner.clone(), - source.rel_path.clone(), - first_doc(&file.preamble, ast::DocForm::Inner), - ); - for (index, example) in file.examples.iter().enumerate() { - let id = add_target( - out, - SourceTargetClass::ExampleDeclaration, - source, - example.span, - address([SourceSyntaxSegment::Examples, item(index)]), - owner.clone(), - example.name.clone(), - first_doc(&example.leading, ast::DocForm::Outer), - ); - out.example_targets - .insert((source.rel_path.clone(), example.name.clone()), id); - } -} - -struct ModuleIdentity { - owner: SourceOwner, - definition: Option, - declaration_class: SourceTargetClass, - declaration_span: Span, -} - -fn module_identity( - source: &ParsedSource, - file: &ast::File, - env: Option<&crate::resolve::DefEnv>, -) -> Option { - let (kind, fallback_name, declaration_class, declaration_span) = match &file.kind { - ast::DefKind::Component { name, span } => ( - SourceOwnerKind::Component, - name.clone(), - SourceTargetClass::ComponentDeclaration, - *span, - ), - ast::DefKind::Surface { name, span, .. } => ( - SourceOwnerKind::Surface, - name.clone(), - SourceTargetClass::SurfaceDeclaration, - *span, - ), - ast::DefKind::Page { span } => ( - SourceOwnerKind::Page, - source.rel_path.clone(), - SourceTargetClass::PageDeclaration, - *span, - ), - ast::DefKind::Error { .. } => return None, - }; - let definition = env.map(|env| definition_for_subject(&env.kind)); - let owner_name = env - .map(|env| env.kind.name().to_string()) - .unwrap_or(fallback_name); - let owner = SourceOwner { - kind, - name: owner_name, - }; - Some(ModuleIdentity { - owner, - definition, - declaration_class, - declaration_span, - }) -} - -fn definition_for_subject(subject: &SubjectKind) -> DefinitionAddress { - let (kind, name) = match subject { - SubjectKind::Page { route } => (DefinitionKind::Page, route.clone()), - SubjectKind::Component { name } => (DefinitionKind::Component, name.clone()), - SubjectKind::Surface { name, .. } => (DefinitionKind::Surface, name.clone()), - }; - DefinitionAddress::new(kind, name) -} - -#[allow(clippy::too_many_arguments)] -fn collect_markup_list( - source: &ParsedSource, - list: &ast::MarkupList, - source_prefix: &[SourceSyntaxSegment], - definition: Option<&DefinitionAddress>, - owner: &SourceOwner, - component_imports: &BTreeSet, - resolved: &Resolved, - catalog: Option<&Catalog>, - diagnostics: &mut Vec, - out: &mut AuthoringCollection, -) { - let semantic_nodes = list - .nodes - .iter() - .filter(|node| !matches!(node, ast::Node::Text { .. } | ast::Node::Error { .. })) - .count(); - let root_template = (semantic_nodes == 1) - .then(|| definition.map(|definition| TemplateAddress::root(definition.clone()))) - .flatten(); - for (source_index, node) in list.nodes.iter().enumerate() { - if matches!(node, ast::Node::Text { .. } | ast::Node::Error { .. }) { - continue; - } - let mut source_address = source_prefix.to_vec(); - source_address.push(item(source_index)); - collect_markup_node( - source, - node, - source_address, - root_template.clone(), - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - } -} - -#[allow(clippy::too_many_arguments)] -fn collect_markup_node( - source: &ParsedSource, - node: &ast::Node, - source_address: Vec, - template: Option, - owner: &SourceOwner, - component_imports: &BTreeSet, - resolved: &Resolved, - catalog: Option<&Catalog>, - diagnostics: &mut Vec, - out: &mut AuthoringCollection, -) { - match node { - ast::Node::Element(element) => { - let name = Ident::new(&element.name).ok(); - let resolution = name.as_ref().map_or(ElementResolution::Unknown, |name| { - resolve_element(name, component_imports.contains(name), resolved, catalog) - }); - let class = match resolution { - ElementResolution::CatalogElement => Some(SourceTargetClass::CatalogElement), - ElementResolution::ImportedComponent => { - Some(SourceTargetClass::ComponentInvocation) - } - ElementResolution::UnimportedComponent - | ElementResolution::Ambiguous - | ElementResolution::Unknown => None, - }; - if let Some(class) = class { - let id = add_target_with_annotations( - out, - class, - source, - element.span, - SourceSyntaxAddress(source_address.clone()), - owner.clone(), - element.name.clone(), - &element.annotations, - ); - record_template_origin(out, template.as_ref(), id); - } else if catalog.is_some() - || name.is_none() - || resolution == ElementResolution::Ambiguous - { - let reason = if resolution == ElementResolution::Ambiguous { - "ambiguous" - } else { - "unresolved" - }; - for annotation in &element.annotations { - diagnostics.push( - Diagnostic::error( - codes::INCOMPATIBLE_METADATA_TARGET.0, - codes::INCOMPATIBLE_METADATA_TARGET.1, - format!( - "markup annotation cannot target {reason} element `<{}>`", - element.name - ), - annotation.span, - ) - .with_label(element.span, "incompatible target"), - ); - } - } - if resolution == ElementResolution::CatalogElement { - let mut prefix = source_address; - prefix.push(SourceSyntaxSegment::Children); - collect_nested_list( - source, - &element.children, - &prefix, - template.as_ref(), - NestedList::ElementChildren, - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - } else if catalog.is_none() - && name.is_some() - && resolution != ElementResolution::ImportedComponent - { - // The unavailable catalog may ultimately classify this as a - // catalog element. Keep walking for independently classifiable - // descendant blocks/components, but do not invent provenance. - let mut prefix = source_address; - prefix.push(SourceSyntaxSegment::Children); - collect_nested_list( - source, - &element.children, - &prefix, - None, - NestedList::ElementChildren, - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - } - } - ast::Node::If { - annotations, - then, - els, - span, - .. - } => { - let id = add_target_with_annotations( - out, - SourceTargetClass::IfBlock, - source, - *span, - SourceSyntaxAddress(source_address.clone()), - owner.clone(), - "if".into(), - annotations, - ); - record_template_origin(out, template.as_ref(), id); - let mut then_prefix = source_address.clone(); - then_prefix.push(SourceSyntaxSegment::IfThen); - collect_nested_list( - source, - then, - &then_prefix, - template.as_ref(), - NestedList::IfThen, - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - if let Some(els) = els { - let mut else_prefix = source_address; - else_prefix.push(SourceSyntaxSegment::IfElse); - collect_nested_list( - source, - els, - &else_prefix, - template.as_ref(), - NestedList::IfElse, - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - } - } - ast::Node::Each { - annotations, - body, - span, - .. - } => { - let id = add_target_with_annotations( - out, - SourceTargetClass::EachBlock, - source, - *span, - SourceSyntaxAddress(source_address.clone()), - owner.clone(), - "each".into(), - annotations, - ); - record_template_origin(out, template.as_ref(), id); - let mut prefix = source_address; - prefix.push(SourceSyntaxSegment::EachBody); - collect_nested_list( - source, - body, - &prefix, - template.as_ref(), - NestedList::EachBody, - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - } - ast::Node::Match { - annotations, - arms, - span, - .. - } => { - let id = add_target_with_annotations( - out, - SourceTargetClass::MatchBlock, - source, - *span, - SourceSyntaxAddress(source_address.clone()), - owner.clone(), - "match".into(), - annotations, - ); - record_template_origin(out, template.as_ref(), id); - for (arm_index, arm) in arms.iter().enumerate() { - let mut prefix = source_address.clone(); - prefix.extend([ - SourceSyntaxSegment::MatchArms, - SourceSyntaxSegment::Arm(index_u32(arm_index)), - ]); - collect_nested_list( - source, - &arm.body, - &prefix, - template.as_ref(), - NestedList::MatchArm(arm_index), - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - } - } - ast::Node::Text { .. } | ast::Node::Error { .. } => {} - } -} - -#[derive(Clone, Copy)] -enum NestedList { - ElementChildren, - IfThen, - IfElse, - EachBody, - MatchArm(usize), -} - -#[allow(clippy::too_many_arguments)] -fn collect_nested_list( - source: &ParsedSource, - list: &ast::MarkupList, - source_prefix: &[SourceSyntaxSegment], - parent_template: Option<&TemplateAddress>, - kind: NestedList, - owner: &SourceOwner, - component_imports: &BTreeSet, - resolved: &Resolved, - catalog: Option<&Catalog>, - diagnostics: &mut Vec, - out: &mut AuthoringCollection, -) { - let mut semantic_index = 0usize; - for (source_index, node) in list.nodes.iter().enumerate() { - if matches!(node, ast::Node::Text { .. } | ast::Node::Error { .. }) { - continue; - } - let template = parent_template.map(|parent| { - parent.child(match kind { - NestedList::ElementChildren => TemplateSegment::ElementChild { - index: semantic_index, - }, - NestedList::IfThen => TemplateSegment::IfThen { - index: semantic_index, - }, - NestedList::IfElse => TemplateSegment::IfElse { - index: semantic_index, - }, - NestedList::EachBody => TemplateSegment::EachBody { - index: semantic_index, - }, - NestedList::MatchArm(arm) => TemplateSegment::MatchArm { - arm, - child: semantic_index, - }, - }) - }); - let mut address = source_prefix.to_vec(); - address.push(item(source_index)); - collect_markup_node( - source, - node, - address, - template, - owner, - component_imports, - resolved, - catalog, - diagnostics, - out, - ); - semantic_index += 1; - } -} - -fn record_template_origin( - out: &mut AuthoringCollection, - template: Option<&TemplateAddress>, - target: SourceTargetId, -) { - if let Some(template) = template { - match out.template_origins.entry(template.clone()) { - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(target); - } - std::collections::btree_map::Entry::Occupied(entry) => { - out.template_origin_errors.push(format!( - "duplicate source origins for template address {template:?}: targets `{}` and `{target}`", - entry.get() - )); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -fn add_target( - out: &mut AuthoringCollection, - class: SourceTargetClass, - source: &ParsedSource, - span: Span, - address: SourceSyntaxAddress, - owner: SourceOwner, - label: String, - doc: Option<&ast::DocComment>, -) -> SourceTargetId { - let target = SourceTarget::new(class, source.rel_path.clone(), span, address, owner, label); - let id = target.id.clone(); - out.projection.targets.push(target); - if let Some(doc) = doc { - out.projection.entries.push(SourceMetadataEntry::new( - MetadataClass::Doc, - "doc".into(), - doc.text.clone(), - doc.span, - id.clone(), - 0, - )); - } - id -} - -#[allow(clippy::too_many_arguments)] -fn add_target_with_annotations( - out: &mut AuthoringCollection, - class: SourceTargetClass, - source: &ParsedSource, - span: Span, - address: SourceSyntaxAddress, - owner: SourceOwner, - label: String, - annotations: &[ast::MarkupAnnotation], -) -> SourceTargetId { - let target = SourceTarget::new(class, source.rel_path.clone(), span, address, owner, label); - let id = target.id.clone(); - out.projection.targets.push(target); - for (order, annotation) in annotations.iter().enumerate() { - out.projection.entries.push(SourceMetadataEntry::new( - MetadataClass::Annotation, - annotation.kind.clone(), - annotation.text.clone(), - annotation.span, - id.clone(), - index_u32(order), - )); - } - id -} - -fn first_doc(trivia: &ast::DslTrivia, form: ast::DocForm) -> Option<&ast::DocComment> { - trivia.docs.iter().find(|doc| doc.form == form) -} - -fn address(segments: [SourceSyntaxSegment; N]) -> SourceSyntaxAddress { - SourceSyntaxAddress(Vec::from(segments)) -} - -fn item(index: usize) -> SourceSyntaxSegment { - SourceSyntaxSegment::Item(index_u32(index)) -} - -fn index_u32(index: usize) -> u32 { - u32::try_from(index).expect("source syntax lists fit in u32") -} - -#[cfg(test)] -mod tests { - use super::*; - use uhura_base::FileId; - - fn target(span: Span) -> SourceTarget { - SourceTarget::new( - SourceTargetClass::CatalogElement, - "components/card.uhura".into(), - span, - SourceSyntaxAddress(vec![ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Markup, - SourceSyntaxSegment::Item(0), - ]), - SourceOwner { - kind: SourceOwnerKind::Component, - name: "card".into(), - }, - "button".into(), - ) - } - - #[test] - fn target_id_ignores_source_span() { - let a = target(Span::new(FileId(0), 10, 20)); - let b = target(Span::new(FileId(0), 110, 120)); - assert_eq!(a.id, b.id); - } - - #[test] - fn metadata_id_ignores_prose_and_metadata_span() { - let target = target(Span::new(FileId(0), 10, 20)); - let a = SourceMetadataEntry::new( - MetadataClass::Annotation, - "review-note".into(), - "first".into(), - Span::new(FileId(0), 0, 5), - target.id.clone(), - 0, - ); - let b = SourceMetadataEntry::new( - MetadataClass::Annotation, - "rationale".into(), - "changed".into(), - Span::new(FileId(0), 50, 90), - target.id, - 0, - ); - assert_eq!(a.id, b.id); - } - - #[test] - fn doc_cannot_target_markup_occurrence() { - let target = target(Span::new(FileId(0), 10, 20)); - let entry = SourceMetadataEntry::new( - MetadataClass::Doc, - "doc".into(), - "not declaration documentation".into(), - Span::new(FileId(0), 0, 5), - target.id.clone(), - 0, - ); - let projection = AuthoringProjection { - targets: vec![target], - entries: vec![entry], - }; - assert!(projection.validate().is_err()); - } - - #[test] - fn annotation_kind_must_match_the_source_grammar() { - let target = target(Span::new(FileId(0), 10, 20)); - let invalid = ["Review", "review_note", "review--note", "review-", "é"] - .into_iter() - .map(str::to_string) - .chain(std::iter::once("a".repeat(65))); - for kind in invalid { - let entry = SourceMetadataEntry::new( - MetadataClass::Annotation, - kind.clone(), - "prose".into(), - Span::new(FileId(0), 0, 5), - target.id.clone(), - 0, - ); - let projection = AuthoringProjection { - targets: vec![target.clone()], - entries: vec![entry], - }; - assert!(projection.validate().is_err(), "accepted `{kind}`"); - } - } - - #[test] - fn duplicate_template_origin_is_recorded_without_overwriting_the_first() { - let first = target(Span::new(FileId(0), 10, 20)); - let second = SourceTarget::new( - SourceTargetClass::CatalogElement, - "components/card.uhura".into(), - Span::new(FileId(0), 30, 40), - SourceSyntaxAddress(vec![ - SourceSyntaxSegment::Definition, - SourceSyntaxSegment::Markup, - SourceSyntaxSegment::Item(1), - ]), - SourceOwner { - kind: SourceOwnerKind::Component, - name: "card".into(), - }, - "button".into(), - ); - let template = TemplateAddress::root(DefinitionAddress::new( - DefinitionKind::Component, - Ident::new("card").unwrap(), - )); - let mut collection = AuthoringCollection::default(); - - record_template_origin(&mut collection, Some(&template), first.id.clone()); - record_template_origin(&mut collection, Some(&template), second.id.clone()); - - assert_eq!(collection.template_origins.get(&template), Some(&first.id)); - let error = collection - .template_origin_error() - .expect("duplicate is a release-visible invariant error"); - assert!(error.contains(first.id.as_str())); - assert!(error.contains(second.id.as_str())); - } -} diff --git a/crates/uhura-check/src/pipeline.rs b/crates/uhura-check/src/pipeline.rs deleted file mode 100644 index cf9432a..0000000 --- a/crates/uhura-check/src/pipeline.rs +++ /dev/null @@ -1,483 +0,0 @@ -//! 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::icon_fonts::{CheckedIconFonts, IconFontInput, load_icon_fonts}; -use crate::infer::check_store; -use crate::lower::{Lowered, lower}; -use crate::manifest::Manifest; -use crate::markup::{check_markup, interactive_content_memo}; -use crate::metadata::{AuthoringProjection, collect_authoring}; -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), - /// Local family alias → manifest-associated WOFF2 bytes and glyph JSON. - /// Built-in families are compiled into `uhura-check` and are omitted. - pub icon_font_files: BTreeMap, - /// 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, - /// Validated built-in and app-local icon resources. Available even when - /// unrelated diagnostics gate runtime artifacts. - pub icon_fonts: Option, - /// Resolved example previews (empty unless the check came up clean). - pub previews: Vec, - /// theme.css + `` inner text, verbatim. - pub raw: String, - pub span: Span, -} - -#[derive(Debug)] -pub struct StyleRule { - /// Selector text, verbatim (normalized whitespace). - pub selector: String, - /// Class names referenced by the selector, for rooting/existence checks. - pub classes: Vec, - /// Declaration block, verbatim, without the outer braces. - pub decls: String, - pub span: Span, -} - -// ── examples files (design §6.1) ──────────────────────────────────────────── - -#[derive(Debug)] -pub struct ExamplesFile { - pub preamble: DslTrivia, - pub uses: Vec, - pub examples: Vec, - pub trailing: DslTrivia, -} - -#[derive(Debug)] -pub struct ExampleDecl { - pub name: String, - pub is_default: bool, - pub clauses: Vec, - /// Parallel to `clauses`; keeps ordinary comments and rejected docs at - /// the legal clause boundary without making them semantic clauses. - pub clause_leading: Vec, - pub trailing: DslTrivia, - pub span: Span, - pub leading: DslTrivia, -} - -#[derive(Debug)] -pub enum ExampleClause { - From { - name: String, - span: Span, - }, - Note { - text: String, - span: Span, - }, - /// `params { user = "…" }` (pages with dynamic segments). - Params { - entries: Vec<(String, Expr)>, - span: Span, - }, - /// `props { post = fixture.posts.x, … }` (components/surfaces). - Props { - entries: Vec<(String, Expr)>, - span: Span, - }, - /// `state { field = expr }` — literal state pin. - State { - entries: Vec<(String, Expr)>, - span: Span, - }, - /// `projection feed.feed-page = fixture.feed.page-1` - /// `projection comments.for-post("post-1") = fixture.comments.x` - Projection(ProjectionPin), - /// `events [ … ]` — the derivation timeline. - Events { - entries: Vec, - span: Span, - }, - Error { - span: Span, - }, -} - -#[derive(Debug)] -pub struct ProjectionPin { - pub port: String, - pub projection: String, - pub key: Option, - pub value: Expr, - pub span: Span, -} - -#[derive(Debug)] -pub enum ExampleEvent { - /// `like-toggled(post: "post-1", now-liked: true)` - Semantic { - name: String, - args: Vec, - span: Span, - }, - /// `outcome like-post.err(refusal: rate-limited)` - Outcome { - command: String, - which: OutcomeKind, - args: Vec, - span: Span, - }, - /// `projection feed.feed-page = fixture.feed.pages-1-2` - Projection(ProjectionPin), -} diff --git a/crates/uhura-syntax/src/css.rs b/crates/uhura-syntax/src/css.rs deleted file mode 100644 index 11253e7..0000000 --- a/crates/uhura-syntax/src/css.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! CSS handling (design §4.5): a selector tokenizer plus verbatim -//! balanced-brace declaration capture. The checker's whole CSS surface is -//! selector shape — declarations pass through untouched. Also used by -//! uhura-check on `styles/theme.css`. - -use uhura_base::{FileId, Span}; - -use crate::ast::StyleRule; - -/// Parses stylesheet text into rules. `base` is the byte offset of `text` -/// within the containing file (0 for standalone .css files) so spans line up. -pub fn parse_stylesheet(file: FileId, base: u32, text: &str) -> Vec { - let mut rules = Vec::new(); - let bytes = text.as_bytes(); - let mut i = 0usize; - - while i < bytes.len() { - // Skip whitespace and /* … */ comments. - if bytes[i].is_ascii_whitespace() { - i += 1; - continue; - } - if text[i..].starts_with("/*") { - i = text[i..] - .find("*/") - .map(|j| i + j + 2) - .unwrap_or(bytes.len()); - continue; - } - // Selector runs to the next `{` (or EOF for garbage). - let sel_start = i; - let Some(rel_brace) = text[i..].find('{') else { - break; - }; - let sel_end = i + rel_brace; - let selector_raw = text[sel_start..sel_end].trim(); - // Declaration block: balanced braces (handles @media nesting by - // capturing the whole inner block verbatim). - let mut depth = 0usize; - let mut j = sel_end; - let decl_start = sel_end + 1; - let mut decl_end = bytes.len(); - while j < bytes.len() { - match bytes[j] { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - decl_end = j; - break; - } - } - _ => {} - } - j += 1; - } - let decls = text[decl_start..decl_end.min(bytes.len())].trim(); - let selector = normalize_ws(selector_raw); - // For @-rules the class references live in the nested inner rules, - // which are captured verbatim inside `decls`. - let classes = if selector.starts_with('@') { - extract_classes(decls) - } else { - extract_classes(&selector) - }; - rules.push(StyleRule { - selector, - classes, - decls: decls.to_string(), - span: Span::new( - file, - base + sel_start as u32, - base + decl_end.min(bytes.len()) as u32, - ), - }); - i = decl_end.saturating_add(1); - } - rules -} - -fn normalize_ws(s: &str) -> String { - s.split_whitespace().collect::>().join(" ") -} - -/// Class names referenced anywhere in a selector (`.post-card` → `post-card`). -pub fn extract_classes(selector: &str) -> Vec { - let mut out = Vec::new(); - let bytes = selector.as_bytes(); - let mut i = 0usize; - while i < bytes.len() { - if bytes[i] == b'.' { - let start = i + 1; - let mut end = start; - while end < bytes.len() - && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'-' || bytes[end] == b'_') - { - end += 1; - } - if end > start { - let name = &selector[start..end]; - if !out.iter().any(|c| c == name) { - out.push(name.to_string()); - } - } - i = end; - } else { - i += 1; - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rules_and_classes() { - let css = " -/* tokens */ -.post-card { display: flex; } -.post-card .avatar, .muted { color: var(--x); } -@media (min-width: 600px) { .post-card { gap: 8px; } } -"; - let rules = parse_stylesheet(FileId(0), 0, css); - assert_eq!(rules.len(), 3); - assert_eq!(rules[0].selector, ".post-card"); - assert_eq!(rules[0].classes, vec!["post-card"]); - assert_eq!(rules[1].classes, vec!["post-card", "avatar", "muted"]); - assert!(rules[2].selector.starts_with("@media")); - assert_eq!(rules[2].classes, vec!["post-card"]); - assert_eq!(rules[1].decls, "color: var(--x);"); - } -} diff --git a/crates/uhura-syntax/src/cursor.rs b/crates/uhura-syntax/src/cursor.rs deleted file mode 100644 index f3c166b..0000000 --- a/crates/uhura-syntax/src/cursor.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! The character cursor shared by every surface parser, plus the DSL -//! tokenizer. Mode ownership is structural: parsers call the tokenizer -//! function for the surface they are in (design §4, plan risk #1), so a -//! token can never be lexed in the wrong mode. - -use uhura_base::{Diagnostic, FileId, Span, codes}; - -use crate::token::{Comment, CommentKind, Token, TokenKind}; - -pub struct Cursor<'src> { - pub file: FileId, - text: &'src str, - pos: u32, - pub diagnostics: Vec, -} - -impl<'src> Cursor<'src> { - pub fn new(file: FileId, text: &'src str) -> Self { - Cursor { - file, - text, - pos: 0, - diagnostics: Vec::new(), - } - } - - pub fn pos(&self) -> u32 { - self.pos - } - - /// Rewind/seek — used by parsers to resync after speculative reads. - pub fn set_pos(&mut self, pos: u32) { - debug_assert!(pos as usize <= self.text.len()); - self.pos = pos; - } - - pub fn is_eof(&self) -> bool { - self.pos as usize >= self.text.len() - } - - pub fn rest(&self) -> &'src str { - &self.text[self.pos as usize..] - } - - pub fn peek(&self) -> Option { - self.rest().chars().next() - } - - pub fn peek2(&self) -> Option { - let mut it = self.rest().chars(); - it.next(); - it.next() - } - - pub fn bump(&mut self) -> Option { - let c = self.peek()?; - self.pos += c.len_utf8() as u32; - Some(c) - } - - pub fn eat(&mut self, c: char) -> bool { - if self.peek() == Some(c) { - self.bump(); - true - } else { - false - } - } - - pub fn eat_str(&mut self, s: &str) -> bool { - if self.rest().starts_with(s) { - self.pos += s.len() as u32; - true - } else { - false - } - } - - pub fn span_from(&self, start: u32) -> Span { - Span::new(self.file, start, self.pos) - } - - /// The text consumed since `start`, as an owned string. - pub fn rest_from(&self, start: u32) -> String { - self.text[start as usize..self.pos as usize].to_string() - } - - pub fn error(&mut self, code: codes::Code, message: impl Into, span: Span) { - self.diagnostics - .push(Diagnostic::error(code.0, code.1, message, span)); - } - - /// Skips whitespace and `//` comments, returning the comments in order. - pub fn skip_trivia(&mut self) -> Vec { - let mut comments = Vec::new(); - loop { - match self.peek() { - Some(c) if c.is_whitespace() => { - self.bump(); - } - Some('/') if self.peek2() == Some('/') => { - let start = self.pos; - self.bump(); - self.bump(); - let kind = if self.peek() == Some('!') { - self.bump(); - CommentKind::InnerDoc - } else if self.peek() == Some('/') { - self.bump(); - if self.peek() == Some('/') { - // Four or more slashes are ordinary. Put the - // third slash back into the body logically. - self.pos -= 1; - CommentKind::Ordinary - } else { - CommentKind::OuterDoc - } - } else { - CommentKind::Ordinary - }; - let text_start = self.pos as usize; - while let Some(c) = self.peek() { - if c == '\n' || c == '\r' { - break; - } - self.bump(); - } - comments.push(Comment { - span: self.span_from(start), - kind, - text: self.text[text_start..self.pos as usize].to_string(), - }); - } - _ => break, - } - } - comments - } - - // ── DSL tokenizer ────────────────────────────────────────────────────── - - /// Lexes one DSL token (header / store / expression surfaces). - pub fn dsl_token(&mut self) -> Token { - self.dsl_token_mode(false) - } - - /// Module-level DSL lexing stops before an XML-shaped markup comment so - /// the file driver can perform the DSL-to-markup transition first. - pub(crate) fn module_dsl_token(&mut self) -> Token { - self.dsl_token_mode(true) - } - - fn dsl_token_mode(&mut self, allow_markup_transition: bool) -> Token { - let leading = self.skip_trivia(); - let start = self.pos; - let kind = if allow_markup_transition && self.rest().starts_with("") else { - let recovery = self.rest().find('}').unwrap_or(self.rest().len()); - self.set_pos(body_start + recovery as u32); - self.error( - codes::MALFORMED_MARKUP_COMMENT, - "unterminated markup comment", - self.span_from(start), - ); - return TokenKind::Error; - }; - let body = self.rest()[..close].to_string(); - self.set_pos(body_start + close as u32); - self.eat_str("-->"); - let normalized = body.replace("\r\n", "\n").replace('\r', "\n"); - let malformed_xml = body.contains("--") || body.ends_with('-'); - let malformed_marker = malformed_annotation_marker(&normalized); - if malformed_xml || malformed_marker { - self.error( - codes::MALFORMED_MARKUP_COMMENT, - "malformed XML-shaped comment or annotation marker", - self.span_from(start), - ); - } else { - self.error( - codes::UNEXPECTED_TOKEN, - "XML-shaped comments are only legal at markup sibling positions", - self.span_from(start), - ); - } - TokenKind::Error - } - - fn lex_string(&mut self, start: u32) -> TokenKind { - let mut out = String::new(); - loop { - match self.peek() { - None | Some('\n') => { - self.error( - codes::UNTERMINATED_STRING, - "unterminated string literal (no raw newlines in strings)", - self.span_from(start), - ); - return TokenKind::Str(out); - } - Some('"') => { - self.bump(); - return TokenKind::Str(out); - } - Some('\\') => { - self.bump(); - match self.bump() { - Some('"') => out.push('"'), - Some('\\') => out.push('\\'), - Some('n') => out.push('\n'), - Some('t') => out.push('\t'), - Some('u') => { - if self.eat('{') { - let hex_start = self.pos as usize; - while matches!(self.peek(), Some(c) if c.is_ascii_hexdigit()) { - self.bump(); - } - let hex = &self.text[hex_start..self.pos as usize]; - let ok = self.eat('}'); - match ( - ok, - u32::from_str_radix(hex, 16).ok().and_then(char::from_u32), - ) { - (true, Some(c)) => out.push(c), - _ => self.error( - codes::UNTERMINATED_STRING, - "invalid `\\u{…}` escape", - self.span_from(start), - ), - } - } else { - self.error( - codes::UNTERMINATED_STRING, - "`\\u` escape requires `{hex}`", - self.span_from(start), - ); - } - } - other => { - self.error( - codes::UNTERMINATED_STRING, - format!( - "unknown escape `\\{}`", - other.map(String::from).unwrap_or_default() - ), - self.span_from(start), - ); - } - } - } - Some(_) => { - out.push(self.bump().unwrap()); - } - } - } - } -} - -fn malformed_annotation_marker(body: &str) -> bool { - let body = body.trim_start_matches([' ', '\t', '\n']); - let Some(marker) = body.strip_prefix('@') else { - return false; - }; - let Some((kind_end, separator)) = marker - .char_indices() - .find(|(_, ch)| matches!(ch, ' ' | '\t' | '\n')) - else { - return true; - }; - let kind = &marker[..kind_end]; - let payload = &marker[kind_end + separator.len_utf8()..]; - !valid_annotation_kind(kind) || payload.trim_matches([' ', '\t', '\n']).is_empty() -} - -fn valid_annotation_kind(kind: &str) -> bool { - if kind.is_empty() || kind.len() > 64 || !kind.is_ascii() { - return false; - } - let bytes = kind.as_bytes(); - if !bytes[0].is_ascii_lowercase() || bytes.last() == Some(&b'-') { - return false; - } - let mut previous_dash = false; - for byte in bytes { - if *byte == b'-' { - if previous_dash { - return false; - } - previous_dash = true; - } else if byte.is_ascii_lowercase() || byte.is_ascii_digit() { - previous_dash = false; - } else { - return false; - } - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token::TokenKind as T; - - fn lex_all(src: &str) -> Vec { - let mut c = Cursor::new(FileId(0), src); - let mut out = Vec::new(); - loop { - let t = c.dsl_token(); - let eof = t.kind == T::Eof; - out.push(t.kind); - if eof { - break; - } - } - out.pop(); // drop Eof - out - } - - #[test] - fn kebab_vs_minus() { - assert_eq!( - lex_all("like-pending"), - vec![T::Ident("like-pending".into())] - ); - assert_eq!(lex_all("0 - 1"), vec![T::Int(0), T::Minus, T::Int(1)]); - // `a -b` is subtraction: `-` starts a fresh token after whitespace. - assert_eq!( - lex_all("a -b"), - vec![T::Ident("a".into()), T::Minus, T::Ident("b".into())] - ); - // `a- b`: the dash is not followed by an ident char, so it detaches. - assert_eq!( - lex_all("a- b"), - vec![T::Ident("a".into()), T::Minus, T::Ident("b".into())] - ); - } - - #[test] - fn operators() { - assert_eq!( - lex_all("a ?? b != c ++ \"x\" && !d"), - vec![ - T::Ident("a".into()), - T::Coalesce, - T::Ident("b".into()), - T::NotEq, - T::Ident("c".into()), - T::PlusPlus, - T::Str("x".into()), - T::AndAnd, - T::Bang, - T::Ident("d".into()), - ] - ); - } - - #[test] - fn string_escapes() { - assert_eq!( - lex_all(r#""a\n\"b\" \u{e9}""#), - vec![T::Str("a\n\"b\" é".into())] - ); - } - - #[test] - fn comments_are_leading_trivia() { - let mut c = Cursor::new(FileId(0), "// hi\n// there\nset"); - let t = c.dsl_token(); - assert_eq!(t.kind, T::Ident("set".into())); - assert_eq!(t.leading.len(), 2); - assert_eq!(t.leading[0].text, " hi"); - } - - #[test] - fn unterminated_string_diagnoses() { - let mut c = Cursor::new(FileId(0), "\"abc\nx"); - let t = c.dsl_token(); - assert!(matches!(t.kind, T::Str(_))); - assert_eq!(c.diagnostics.len(), 1); - assert_eq!(c.diagnostics[0].code, "UH0002"); - } -} diff --git a/crates/uhura-syntax/src/format.rs b/crates/uhura-syntax/src/format.rs deleted file mode 100644 index 49f3f3e..0000000 --- a/crates/uhura-syntax/src/format.rs +++ /dev/null @@ -1,834 +0,0 @@ -//! The one canonical formatter — zero options (design §4). Layout is fully -//! deterministic and width-independent: attributes, guards, and expressions -//! render on one line; children/bodies indent by two spaces. Comments attach -//! before their item. CSS declarations pass through verbatim (§4.5). - -use crate::ast::*; -use crate::token::CommentKind; - -const INDENT: &str = " "; - -pub fn format_module(f: &File) -> String { - let mut out = String::new(); - - // ── header ────────────────────────────────────────────────────────── - fmt_comments(&f.preamble, 0, &mut out); - match &f.kind { - DefKind::Component { name, .. } => out.push_str(&format!("component {name}\n")), - DefKind::Page { .. } => out.push_str("page\n"), - DefKind::Surface { name, modality, .. } => match modality { - Some(m) => out.push_str(&format!("surface {name} modality {m}\n")), - None => out.push_str(&format!("surface {name}\n")), - }, - DefKind::Error { .. } => {} - } - - if !f.uses.is_empty() { - out.push('\n'); - for u in &f.uses { - fmt_use(u, &mut out); - } - } - - if f.props_present { - out.push('\n'); - fmt_comments(&f.props_leading, 0, &mut out); - out.push_str("props {\n"); - for p in &f.props { - fmt_comments(&p.leading, 1, &mut out); - out.push_str(&format!("{INDENT}{}: {}\n", p.name, type_str(&p.ty))); - } - fmt_comments(&f.props_trailing, 1, &mut out); - out.push_str("}\n"); - } - - if f.emits_present { - out.push('\n'); - fmt_comments(&f.emits_leading, 0, &mut out); - out.push_str("emits {\n"); - for e in &f.emits { - fmt_comments(&e.leading, 1, &mut out); - if params_are_multiline( - e.params.iter().map(|param| ¶m.leading), - &e.params_trailing, - ) { - out.push_str(&format!("{INDENT}{}(\n", e.name)); - for (index, param) in e.params.iter().enumerate() { - fmt_comments(¶m.leading, 2, &mut out); - let comma = if index + 1 < e.params.len() { "," } else { "" }; - out.push_str(&format!( - "{INDENT}{INDENT}{}: {}{comma}\n", - param.name, - type_str(¶m.ty) - )); - } - fmt_comments(&e.params_trailing, 2, &mut out); - out.push_str(&format!("{INDENT})\n")); - } else { - let params = e - .params - .iter() - .map(|param| format!("{}: {}", param.name, type_str(¶m.ty))) - .collect::>() - .join(", "); - out.push_str(&format!("{INDENT}{}({params})\n", e.name)); - } - } - fmt_comments(&f.emits_trailing, 1, &mut out); - out.push_str("}\n"); - } - - for p in &f.params { - out.push('\n'); - fmt_comments(&p.leading, 0, &mut out); - out.push_str(&format!("param {}: {}\n", p.name, type_str(&p.ty))); - } - - if let Some(store) = &f.store { - out.push('\n'); - fmt_comments(&store.leading, 0, &mut out); - out.push_str("store {\n"); - if store.state_present { - fmt_comments(&store.state_leading, 1, &mut out); - out.push_str(&format!("{INDENT}state {{\n")); - for sf in &store.state { - fmt_comments(&sf.leading, 2, &mut out); - out.push_str(&format!( - "{INDENT}{INDENT}{}: {} = {}\n", - sf.name, - type_str(&sf.ty), - literal_str(&sf.init) - )); - } - fmt_comments(&store.state_trailing, 2, &mut out); - out.push_str(&format!("{INDENT}}}\n")); - } - for h in &store.handlers { - out.push('\n'); - fmt_handler(h, &mut out); - } - fmt_comments(&store.trailing, 1, &mut out); - out.push_str("}\n"); - } - - if f.trailing_dsl.has_formattable_content() - || !f.markup.is_empty() - || !f.markup.comments.is_empty() - || f.style.is_some() - { - out.push('\n'); - fmt_comments(&f.trailing_dsl, 0, &mut out); - fmt_markup_list(&f.markup, 0, &mut out); - } - - if let Some(style) = &f.style { - if !f.markup.is_empty() { - out.push('\n'); - } - out.push_str("\n"); - } - - out -} - -pub fn format_examples(f: &ExamplesFile) -> String { - let mut out = String::new(); - for u in &f.uses { - fmt_use(u, &mut out); - } - for e in &f.examples { - out.push('\n'); - fmt_comments(&e.leading, 0, &mut out); - let default = if e.is_default { " default" } else { "" }; - out.push_str(&format!("example {}{default} {{\n", e.name)); - for (index, c) in e.clauses.iter().enumerate() { - if let Some(trivia) = e.clause_leading.get(index) { - fmt_comments(trivia, 1, &mut out); - } - fmt_example_clause(c, &mut out); - } - fmt_comments(&e.trailing, 1, &mut out); - out.push_str("}\n"); - } - fmt_comments(&f.trailing, 0, &mut out); - out -} - -// ── pieces ────────────────────────────────────────────────────────────────── - -fn fmt_comments(trivia: &DslTrivia, depth: usize, out: &mut String) { - let mut rendered_docs: Vec> = vec![None; trivia.pieces.len()]; - let mut cursor = 0; - while cursor < trivia.pieces.len() { - let form = match trivia.pieces[cursor].kind { - CommentKind::Ordinary => { - cursor += 1; - continue; - } - CommentKind::OuterDoc => CommentKind::OuterDoc, - CommentKind::InnerDoc => CommentKind::InnerDoc, - }; - let mut end = cursor; - let mut doc_indices = Vec::new(); - let mut lines = Vec::new(); - while end < trivia.pieces.len() { - let kind = trivia.pieces[end].kind; - if kind != CommentKind::Ordinary && kind != form { - break; - } - if kind == form { - doc_indices.push(end); - lines.push(trivia.pieces[end].normalized_doc_line()); - } - end += 1; - } - while lines.last().is_some_and(String::is_empty) { - lines.pop(); - doc_indices.pop(); - } - for (index, line) in doc_indices.into_iter().zip(lines) { - rendered_docs[index] = Some(line); - } - cursor = end; - } - - for (index, c) in trivia.pieces.iter().enumerate() { - let line = match c.kind { - CommentKind::Ordinary => Some(format!("//{}", c.text.trim_end_matches([' ', '\t']))), - CommentKind::OuterDoc => rendered_docs[index] - .as_ref() - .map(|text| format!("///{}", doc_body(text))), - CommentKind::InnerDoc => rendered_docs[index] - .as_ref() - .map(|text| format!("//!{}", doc_body(text))), - }; - let Some(line) = line else { continue }; - out.push_str(&INDENT.repeat(depth)); - out.push_str(&line); - out.push('\n'); - } -} - -fn doc_body(text: &str) -> String { - if text.is_empty() { - String::new() - } else { - format!(" {text}") - } -} - -fn params_are_multiline<'a>( - mut leading: impl Iterator, - trailing: &DslTrivia, -) -> bool { - trailing.has_formattable_content() || leading.any(DslTrivia::has_formattable_content) -} - -fn fmt_use(u: &Use, out: &mut String) { - match u { - Use::Component { name, leading, .. } => { - fmt_comments(leading, 0, out); - out.push_str(&format!("use component {name}\n")); - } - Use::Surface { name, leading, .. } => { - fmt_comments(leading, 0, out); - out.push_str(&format!("use surface {name}\n")); - } - Use::Fixture { name, leading, .. } => { - fmt_comments(leading, 0, out); - out.push_str(&format!("use fixture {name}\n")); - } - Use::Port { - name, - items, - leading, - .. - } => { - fmt_comments(leading, 0, out); - // ≤ 3 items inline; otherwise one per line (deterministic by - // count, not width). - let rendered: Vec = items - .iter() - .map(|i| { - let kind = match i.kind { - PortItemKind::Projection => "projection", - PortItemKind::Command => "command", - PortItemKind::Type => "type", - }; - format!("{kind} {}", i.name) - }) - .collect(); - if rendered.len() <= 3 { - out.push_str(&format!("use port {name} {{ {} }}\n", rendered.join(", "))); - } else { - out.push_str(&format!("use port {name} {{\n")); - for r in rendered { - out.push_str(&format!("{INDENT}{r}\n")); - } - out.push_str("}\n"); - } - } - } -} - -fn fmt_handler(h: &Handler, out: &mut String) { - fmt_comments(&h.leading, 1, out); - let event = match &h.event { - EventRef::Semantic { name, .. } => name.clone(), - EventRef::Outcome { command, which, .. } => format!( - "{command}.{}", - if *which == OutcomeKind::Ok { - "ok" - } else { - "err" - } - ), - }; - let guard = match &h.guard { - Some(g) => format!(" when {}", expr_str(g)), - None => String::new(), - }; - if params_are_multiline( - h.params.iter().map(|param| ¶m.leading), - &h.params_trailing, - ) { - out.push_str(&format!("{INDENT}on {event}(\n")); - for (index, param) in h.params.iter().enumerate() { - fmt_comments(¶m.leading, 2, out); - let rendered = match ¶m.ty { - Some(ty) => format!("{}: {}", param.name, type_str(ty)), - None => param.name.clone(), - }; - let comma = if index + 1 < h.params.len() { "," } else { "" }; - out.push_str(&format!("{INDENT}{INDENT}{rendered}{comma}\n")); - } - fmt_comments(&h.params_trailing, 2, out); - out.push_str(&format!("{INDENT}){guard} {{\n")); - } else { - let params = h - .params - .iter() - .map(|p| match &p.ty { - Some(t) => format!("{}: {}", p.name, type_str(t)), - None => p.name.clone(), - }) - .collect::>() - .join(", "); - out.push_str(&format!("{INDENT}on {event}({params}){guard} {{\n")); - } - for st in &h.body { - fmt_stmt(st, out); - } - fmt_comments(&h.body_trailing, 2, out); - out.push_str(&format!("{INDENT}}}\n")); -} - -fn fmt_stmt(st: &Stmt, out: &mut String) { - let pad = INDENT.repeat(2); - match st { - Stmt::Set { - path, - value, - leading, - .. - } => { - fmt_comments(leading, 2, out); - let key = match &path.key { - Some(k) => format!("[{}]", expr_str(k)), - None => String::new(), - }; - out.push_str(&format!( - "{pad}set {}{key} = {}\n", - path.field, - expr_str(value) - )); - } - Stmt::Send { - command, - args, - bind, - leading, - .. - } => { - fmt_comments(leading, 2, out); - let bind = match bind { - Some(b) => format!(" as {b}"), - None => String::new(), - }; - out.push_str(&format!("{pad}send {command}({}){bind}\n", args_str(args))); - } - Stmt::OpenSurface { - name, - args, - leading, - .. - } => { - fmt_comments(leading, 2, out); - out.push_str(&format!("{pad}open-surface {name}({})\n", args_str(args))); - } - Stmt::Dismiss { leading, .. } => { - fmt_comments(leading, 2, out); - out.push_str(&format!("{pad}dismiss\n")); - } - Stmt::Navigate { - target, leading, .. - } => { - fmt_comments(leading, 2, out); - match target { - NavTarget::Back => out.push_str(&format!("{pad}navigate back\n")), - NavTarget::Route { name, args } => { - if args.is_empty() { - out.push_str(&format!("{pad}navigate {name}()\n")); - } else { - out.push_str(&format!("{pad}navigate {name}({})\n", args_str(args))); - } - } - NavTarget::Replace { name, args } => { - if args.is_empty() { - out.push_str(&format!("{pad}navigate replace {name}()\n")); - } else { - out.push_str(&format!( - "{pad}navigate replace {name}({})\n", - args_str(args) - )); - } - } - } - } - Stmt::Error { .. } => {} - } -} - -fn fmt_node(n: &Node, depth: usize, out: &mut String) { - let pad = INDENT.repeat(depth); - match n { - Node::Element(e) => { - let mut head = format!("<{}", e.name); - for a in &e.attrs { - match &a.value { - AttrValue::Bare => head.push_str(&format!(" {}", a.name)), - AttrValue::Literal(v) => head.push_str(&format!(" {}=\"{v}\"", a.name)), - AttrValue::Expr(x) => { - head.push_str(&format!(" {}={{{}}}", a.name, expr_str(x))) - } - } - } - for ev in &e.events { - match &ev.binding { - EventBinding::Forward => head.push_str(&format!(" on:{}", ev.event)), - EventBinding::Emit { name, args } => head.push_str(&format!( - " on:{}={{emit {name}({})}}", - ev.event, - args_str(args) - )), - } - } - if e.self_closing || (e.children.is_empty() && e.children.comments.is_empty()) { - out.push_str(&format!("{pad}{head} />\n")); - } else if is_inline_text_only(e) { - // `{expr} literal` stays on one line. - let mut line = format!("{pad}{head}>"); - if let Node::Text { runs, .. } = &e.children[0] { - line.push_str(&text_runs_str(runs)); - } - line.push_str(&format!("\n", e.name)); - out.push_str(&line); - } else { - out.push_str(&format!("{pad}{head}>\n")); - fmt_markup_list(&e.children, depth + 1, out); - out.push_str(&format!("{pad}\n", e.name)); - } - } - Node::Text { runs, .. } => { - out.push_str(&format!("{pad}{}\n", text_runs_str(runs))); - } - Node::If { - cond, then, els, .. - } => { - out.push_str(&format!("{pad}{{#if {}}}\n", expr_str(cond))); - fmt_markup_list(then, depth + 1, out); - if let Some(els) = els { - out.push_str(&format!("{pad}{{:else}}\n")); - fmt_markup_list(els, depth + 1, out); - } - out.push_str(&format!("{pad}{{/if}}\n")); - } - Node::Each { - item, - seq, - key, - body, - .. - } => { - out.push_str(&format!( - "{pad}{{#each {} as {item} ({})}}\n", - expr_str(seq), - expr_str(key) - )); - fmt_markup_list(body, depth + 1, out); - out.push_str(&format!("{pad}{{/each}}\n")); - } - Node::Match { - scrutinee, - before_arms, - arms, - .. - } => { - out.push_str(&format!("{pad}{{#match {}}}\n", expr_str(scrutinee))); - fmt_markup_list(before_arms, depth + 1, out); - for a in arms { - match &a.pattern { - MatchPattern::Variant(v) => match &a.binding { - Some(b) => out.push_str(&format!("{pad}{INDENT}{{:when {v} {b}}}\n")), - None => out.push_str(&format!("{pad}{INDENT}{{:when {v}}}\n")), - }, - MatchPattern::Else => out.push_str(&format!("{pad}{INDENT}{{:else}}\n")), - } - fmt_markup_list(&a.body, depth + 2, out); - } - out.push_str(&format!("{pad}{{/match}}\n")); - } - Node::Error { .. } => {} - } -} - -fn is_inline_text_only(e: &Element) -> bool { - e.children.comments.is_empty() - && e.children.len() == 1 - && matches!(&e.children[0], Node::Text { .. }) -} - -fn fmt_markup_list(list: &MarkupList, depth: usize, out: &mut String) { - let mut comments = list.comments.iter().peekable(); - for index in 0..=list.nodes.len() { - while comments.peek().is_some_and(|placed| placed.before == index) { - let placed = comments.next().expect("peeked comment"); - fmt_markup_comment(&placed.comment, depth, out); - } - if let Some(node) = list.nodes.get(index) { - fmt_node(node, depth, out); - } - } -} - -fn fmt_markup_comment(comment: &MarkupComment, depth: usize, out: &mut String) { - let pad = INDENT.repeat(depth); - match &comment.kind { - MarkupCommentKind::Malformed { terminated } => { - // Error formatting must preserve the lexical failure. In - // particular, adding canonical padding around a trailing `-`, or - // inventing a missing close, can turn recovery text into valid - // metadata on the next parse. - out.push_str(&pad); - out.push_str(""); - } - out.push('\n'); - return; - } - MarkupCommentKind::RejectedAnnotation { kind } => { - // `:` is outside annotation-kind, yielding a stable UH0016 - // carrier while keeping the author's visible kind and prose. - out.push_str(&format!("{pad}\n"); - return; - } - MarkupCommentKind::Ordinary | MarkupCommentKind::Annotation { .. } => {} - } - let marker = match &comment.kind { - MarkupCommentKind::Ordinary => None, - MarkupCommentKind::Annotation { kind } => Some(kind.as_str()), - MarkupCommentKind::Malformed { .. } | MarkupCommentKind::RejectedAnnotation { .. } => { - unreachable!("recovery comments return above") - } - }; - if !comment.text.contains('\n') { - match marker { - Some(kind) => out.push_str(&format!("{pad}\n", comment.text)), - None if comment.text.is_empty() => out.push_str(&format!("{pad}\n")), - None => out.push_str(&format!("{pad}\n", comment.text)), - } - return; - } - - match marker { - Some(kind) => out.push_str(&format!("{pad}\n")); -} - -fn text_runs_str(runs: &[TextRun]) -> String { - let mut out = String::new(); - for r in runs { - match r { - TextRun::Literal(t) => out.push_str(&normalize_text(t)), - TextRun::Interp(x) => out.push_str(&format!("{{{}}}", expr_str(x))), - } - } - out -} - -/// Collapses internal whitespace runs; preserves single spaces. -fn normalize_text(t: &str) -> String { - let has_lead = t.starts_with(char::is_whitespace); - let has_trail = t.ends_with(char::is_whitespace); - let core = t.split_whitespace().collect::>().join(" "); - format!( - "{}{core}{}", - if has_lead && !core.is_empty() { - " " - } else { - "" - }, - if has_trail && !core.is_empty() { - " " - } else { - "" - } - ) -} - -fn fmt_example_clause(c: &ExampleClause, out: &mut String) { - match c { - ExampleClause::From { name, .. } => out.push_str(&format!("{INDENT}from {name}\n")), - ExampleClause::Note { text, .. } => { - out.push_str(&format!("{INDENT}note {}\n", quote(text))); - } - ExampleClause::Params { entries, .. } => fmt_assign_block("params", entries, out), - ExampleClause::Props { entries, .. } => fmt_assign_block("props", entries, out), - ExampleClause::State { entries, .. } => fmt_assign_block("state", entries, out), - ExampleClause::Projection(p) => { - out.push_str(&format!("{INDENT}{}\n", projection_pin_str(p))); - } - ExampleClause::Events { entries, .. } => { - if entries.len() == 1 { - out.push_str(&format!( - "{INDENT}events [ {} ]\n", - example_event_str(&entries[0]) - )); - } else { - out.push_str(&format!("{INDENT}events [\n")); - for e in entries { - out.push_str(&format!("{INDENT}{INDENT}{}\n", example_event_str(e))); - } - out.push_str(&format!("{INDENT}]\n")); - } - } - ExampleClause::Error { .. } => {} - } -} - -fn fmt_assign_block(kw: &str, entries: &[(String, Expr)], out: &mut String) { - if entries.len() == 1 { - out.push_str(&format!( - "{INDENT}{kw} {{ {} = {} }}\n", - entries[0].0, - expr_str(&entries[0].1) - )); - return; - } - out.push_str(&format!("{INDENT}{kw} {{\n")); - for (n, v) in entries { - out.push_str(&format!("{INDENT}{INDENT}{n} = {}\n", expr_str(v))); - } - out.push_str(&format!("{INDENT}}}\n")); -} - -fn projection_pin_str(p: &ProjectionPin) -> String { - let key = match &p.key { - Some(k) => format!("({})", expr_str(k)), - None => String::new(), - }; - format!( - "projection {}.{}{key} = {}", - p.port, - p.projection, - expr_str(&p.value) - ) -} - -fn example_event_str(e: &ExampleEvent) -> String { - match e { - ExampleEvent::Semantic { name, args, .. } => format!("{name}({})", args_str(args)), - ExampleEvent::Outcome { - command, - which, - args, - .. - } => format!( - "outcome {command}.{}({})", - if *which == OutcomeKind::Ok { - "ok" - } else { - "err" - }, - args_str(args) - ), - ExampleEvent::Projection(p) => projection_pin_str(p), - } -} - -// ── leaf renderers ────────────────────────────────────────────────────────── - -pub fn type_str(t: &TypeExpr) -> String { - match &t.kind { - TypeKind::Name(n) => n.clone(), - TypeKind::List(inner) => format!("list[{}]", type_str(inner)), - TypeKind::Map(k, v) => format!("map[{k}]{}", type_str(v)), - TypeKind::Option(inner) => format!("{}?", type_str(inner)), - TypeKind::Error => "".to_string(), - } -} - -fn literal_str(l: &Literal) -> String { - match l { - Literal::Int(i) => i.to_string(), - Literal::Str(s) => quote(s), - Literal::Bool(b) => b.to_string(), - Literal::None => "none".to_string(), - Literal::EmptyMap => "{}".to_string(), - Literal::Error => "".to_string(), - } -} - -fn quote(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - out.push('"'); - out -} - -fn args_str(args: &[Arg]) -> String { - args.iter() - .map(|a| format!("{}: {}", a.name, expr_str(&a.value))) - .collect::>() - .join(", ") -} - -/// Renders an expression with minimal parentheses (by precedence). -pub fn expr_str(e: &Expr) -> String { - render_expr(e, 0) -} - -/// Precedence levels, loosest → tightest (must mirror the parser). -fn level(e: &Expr) -> u8 { - match &e.kind { - ExprKind::If { .. } => 0, - ExprKind::Binary { op, .. } => match op { - BinaryOp::Or => 1, - BinaryOp::And => 2, - BinaryOp::Eq - | BinaryOp::NotEq - | BinaryOp::Lt - | BinaryOp::Le - | BinaryOp::Gt - | BinaryOp::Ge => 3, - BinaryOp::Coalesce => 4, - BinaryOp::Add | BinaryOp::Sub | BinaryOp::Concat => 5, - }, - ExprKind::Unary { .. } => 6, - _ => 7, - } -} - -fn render_expr(e: &Expr, min_level: u8) -> String { - let mine = level(e); - let body = match &e.kind { - ExprKind::Ident(n) => n.clone(), - ExprKind::Int(i) => i.to_string(), - ExprKind::Str(s) => quote(s), - ExprKind::Bool(b) => b.to_string(), - ExprKind::None => "none".to_string(), - ExprKind::Field { base, name } => format!("{}.{name}", render_expr(base, 7)), - ExprKind::Index { base, key } => { - format!("{}[{}]", render_expr(base, 7), render_expr(key, 0)) - } - ExprKind::Call { name, args } => format!( - "{name}({})", - args.iter() - .map(|a| render_expr(a, 0)) - .collect::>() - .join(", ") - ), - ExprKind::Unary { op, expr } => { - let sym = match op { - UnaryOp::Not => "!", - UnaryOp::Neg => "-", - }; - format!("{sym}{}", render_expr(expr, 6)) - } - ExprKind::Binary { op, lhs, rhs } => { - let sym = match op { - BinaryOp::Add => "+", - BinaryOp::Sub => "-", - BinaryOp::Concat => "++", - BinaryOp::Eq => "==", - BinaryOp::NotEq => "!=", - BinaryOp::Lt => "<", - BinaryOp::Le => "<=", - BinaryOp::Gt => ">", - BinaryOp::Ge => ">=", - BinaryOp::And => "&&", - BinaryOp::Or => "||", - BinaryOp::Coalesce => "??", - }; - // Left-associative: rhs needs one level tighter. - format!( - "{} {sym} {}", - render_expr(lhs, mine), - render_expr(rhs, mine + 1) - ) - } - ExprKind::If { cond, then, els } => format!( - "if {} then {} else {}", - render_expr(cond, 1), - render_expr(then, 0), - render_expr(els, 0) - ), - ExprKind::Record(fields) => format!( - "{{ {} }}", - fields - .iter() - .map(|(n, v)| format!("{n}: {}", render_expr(v, 0))) - .collect::>() - .join(", ") - ), - ExprKind::Error => "".to_string(), - }; - if mine < min_level { - format!("({body})") - } else { - body - } -} diff --git a/crates/uhura-syntax/src/lib.rs b/crates/uhura-syntax/src/lib.rs index f843a50..39cb615 100644 --- a/crates/uhura-syntax/src/lib.rs +++ b/crates/uhura-syntax/src/lib.rs @@ -1,15 +1,6 @@ -//! uhura-syntax: mode-switching lexer (Dsl / Markup / Expr / Style / -//! Examples), recursive-descent parsers with recovery, AST, and the one -//! canonical trivia-preserving formatter (design §4, §12.2). +//! The canonical Uhura source frontend. +//! +//! Uhura exposes one current parser, source-spanned AST, checked UI parser, +//! and deterministic formatter. -pub mod ast; -pub mod css; -mod cursor; -mod format; -mod parser; -mod token; - -pub use cursor::Cursor; -pub use format::{expr_str, format_examples, format_module, type_str}; -pub use parser::{ParseOutput, Parsed, SourceKind, parse}; -pub use token::{Comment, CommentKind, Token, TokenKind}; +pub mod v04; diff --git a/crates/uhura-syntax/src/parser/dsl.rs b/crates/uhura-syntax/src/parser/dsl.rs deleted file mode 100644 index 66870c6..0000000 --- a/crates/uhura-syntax/src/parser/dsl.rs +++ /dev/null @@ -1,784 +0,0 @@ -//! Header and store parsing (design §4.1–§4.2). These surfaces are pure -//! DSL; the file-level driver (`mod.rs`) decides when markup begins. - -use uhura_base::{Span, codes}; - -use crate::ast::*; -use crate::token::TokenKind as T; - -use super::expr::{parse_args, parse_expr, parse_type}; -use super::stream::DslStream; - -/// Sync set for header/store recovery: skip until one of these idents (at -/// nesting depth 0) or EOF. -fn sync_to(s: &mut DslStream, targets: &[&str]) { - let mut depth = 0i32; - loop { - match s.peek() { - T::Eof => return, - T::LBrace => { - depth += 1; - s.bump(); - } - T::RBrace => { - if depth == 0 { - return; - } - depth -= 1; - s.bump(); - } - T::Ident(name) if depth == 0 && targets.iter().any(|t| t == name) => return, - _ => { - s.bump(); - } - } - } -} - -// ── header declarations ───────────────────────────────────────────────────── - -pub fn parse_use(s: &mut DslStream, file_preamble: bool) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - if !s.eat_ident("use") { - return None; - } - let Some((kind, _)) = s.expect_ident("after `use` (component | surface | port | fixture)") - else { - sync_to(s, &["use", "props", "emits", "param", "store", "example"]); - return None; - }; - let parsed = match kind.as_str() { - "component" => { - let (name, nspan) = s.expect_ident("as the component name")?; - Some(Use::Component { - name, - span: start.to(nspan), - leading, - }) - } - "surface" => { - let (name, nspan) = s.expect_ident("as the surface name")?; - Some(Use::Surface { - name, - span: start.to(nspan), - leading, - }) - } - "fixture" => { - let (name, nspan) = s.expect_ident("as the fixture name")?; - Some(Use::Fixture { - name, - span: start.to(nspan), - leading, - }) - } - "port" => { - let (name, _) = s.expect_ident("as the port name")?; - s.expect(&T::LBrace, "to open the port import list"); - let mut items = Vec::new(); - loop { - match s.peek().clone() { - T::RBrace => { - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - T::Ident(k) if matches!(k.as_str(), "projection" | "command" | "type") => { - let kspan = s.peek_span(); - s.bump(); - let kind = match k.as_str() { - "projection" => PortItemKind::Projection, - "command" => PortItemKind::Command, - _ => PortItemKind::Type, - }; - if let Some((iname, ispan)) = s.expect_ident("as the imported item name") { - items.push(PortItem { - kind, - name: iname, - span: kspan.to(ispan), - }); - } - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!( - "expected `projection`, `command`, or `type` in the port \ - import list, found {desc}" - ), - span, - ); - s.bump(); - } - } - } - let end = s.peek_span(); - s.expect(&T::RBrace, "to close the port import list"); - Some(Use::Port { - name, - items, - span: start.to(end), - leading, - }) - } - other => { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("`use {other}` is not an import kind (component | surface | port)"), - span, - ); - sync_to(s, &["use", "props", "emits", "param", "store"]); - None - } - }; - if let Some(use_decl) = &parsed { - let (span, leading) = match use_decl { - Use::Component { span, leading, .. } - | Use::Surface { span, leading, .. } - | Use::Port { span, leading, .. } - | Use::Fixture { span, leading, .. } => (*span, leading), - }; - if file_preamble { - s.accept_file_docs_only(leading, span); - } else { - s.reject_docs(leading, span); - } - } - parsed -} - -/// `props { name: type, … }` — brace block of typed names. -pub fn parse_props_block(s: &mut DslStream) -> (Vec, DslTrivia) { - let (items, trailing) = parse_typed_block(s, "props"); - ( - items - .into_iter() - .map(|(name, ty, span, leading)| PropDecl { - name, - ty, - span, - leading, - }) - .collect(), - trailing, - ) -} - -fn parse_typed_block( - s: &mut DslStream, - what: &str, -) -> (Vec<(String, TypeExpr, Span, DslTrivia)>, DslTrivia) { - let mut out = Vec::new(); - let mut trailing = DslTrivia::default(); - s.expect(&T::LBrace, &format!("to open the `{what}` block")); - loop { - match s.peek() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let leading = s.take_leading(); - let start = s.peek_span(); - let Some((name, _)) = s.expect_ident(&format!("as a {what} name")) else { - sync_to(s, &[]); - break; - }; - s.expect(&T::Colon, "before the type"); - let ty = parse_type(s); - let span = start.to(ty.span); - s.accept_outer_docs(&leading, span); - out.push((name, ty, span, leading)); - } - } - } - (out, trailing) -} - -/// `emits { name(field: type, …), … }` -pub fn parse_emits_block(s: &mut DslStream) -> (Vec, DslTrivia) { - let mut out = Vec::new(); - let mut trailing = DslTrivia::default(); - s.expect(&T::LBrace, "to open the `emits` block"); - loop { - match s.peek() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let leading = s.take_leading(); - let start = s.peek_span(); - let Some((name, mut end)) = s.expect_ident("as an emit name") else { - sync_to(s, &[]); - break; - }; - let mut params = Vec::new(); - let mut params_trailing = DslTrivia::default(); - if *s.peek() == T::LParen { - s.bump(); - if *s.peek() != T::RParen { - loop { - let param_leading = s.take_leading(); - if *s.peek() == T::RParen { - params_trailing = param_leading; - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - break; - } - let pstart = s.peek_span(); - let Some((pname, _)) = s.expect_ident("as a payload field name") else { - break; - }; - s.expect(&T::Colon, "before the field type"); - let ty = parse_type(s); - let pspan = pstart.to(ty.span); - s.accept_outer_docs(¶m_leading, pspan); - params.push(EmitParam { - name: pname, - ty, - span: pspan, - leading: param_leading, - }); - if !s.eat(&T::Comma) { - break; - } - } - } - if params_trailing.is_empty() { - params_trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - } - end = s.peek_span(); - s.expect(&T::RParen, "to close the emit payload"); - } - let span = start.to(end); - s.accept_outer_docs(&leading, span); - out.push(EmitDecl { - name, - params, - params_trailing, - span, - leading, - }); - } - } - } - (out, trailing) -} - -/// `param user: id` -pub fn parse_param(s: &mut DslStream) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - if !s.eat_ident("param") { - return None; - } - let (name, _) = s.expect_ident("as the route parameter name")?; - s.expect(&T::Colon, "before the parameter type"); - let ty = parse_type(s); - let span = start.to(ty.span); - s.accept_outer_docs(&leading, span); - Some(ParamDecl { - name, - ty, - span, - leading, - }) -} - -// ── store ─────────────────────────────────────────────────────────────────── - -pub fn parse_store(s: &mut DslStream) -> Store { - let leading = s.take_leading(); - let start = s.peek_span(); - s.eat_ident("store"); - s.expect(&T::LBrace, "to open the store block"); - let mut state = Vec::new(); - let mut state_present = false; - let mut handlers = Vec::new(); - let mut state_leading = DslTrivia::default(); - let mut state_trailing = DslTrivia::default(); - let mut trailing = DslTrivia::default(); - let mut end = start; - loop { - match s.peek().clone() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - end = s.bump().span; - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed `store { … }`", span); - break; - } - T::Ident(k) if k == "state" => { - state_present = true; - state_leading = s.take_leading(); - let target = s.peek_span(); - s.reject_docs(&state_leading, target); - s.bump(); - state_trailing = parse_state_block(s, &mut state); - } - T::Ident(k) if k == "on" => { - if let Some(h) = parse_handler(s) { - handlers.push(h); - } - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected `state` or `on` in the store, found {desc}"), - span, - ); - sync_to(s, &["state", "on"]); - } - } - } - let span = start.to(end); - s.accept_outer_docs(&leading, span); - Store { - state_present, - state, - handlers, - state_leading, - state_trailing, - trailing, - span, - leading, - } -} - -fn parse_state_block(s: &mut DslStream, out: &mut Vec) -> DslTrivia { - let mut trailing = DslTrivia::default(); - s.expect(&T::LBrace, "to open the state block"); - loop { - match s.peek() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let leading = s.take_leading(); - let start = s.peek_span(); - let Some((name, _)) = s.expect_ident("as a state field name") else { - sync_to(s, &["on"]); - break; - }; - s.expect(&T::Colon, "before the field type"); - let ty = parse_type(s); - s.expect( - &T::Eq, - "before the initial value (state initializers are literals)", - ); - let (init, end) = parse_literal(s); - let span = start.to(end); - s.accept_outer_docs(&leading, span); - out.push(StateField { - name, - ty, - init, - span, - leading, - }); - } - } - } - trailing -} - -fn parse_literal(s: &mut DslStream) -> (Literal, Span) { - let span = s.peek_span(); - let lit = match s.peek().clone() { - T::Int(i) => { - s.bump(); - Literal::Int(i) - } - T::Str(v) => { - s.bump(); - Literal::Str(v) - } - T::Ident(name) => match name.as_str() { - "true" => { - s.bump(); - Literal::Bool(true) - } - "false" => { - s.bump(); - Literal::Bool(false) - } - "none" => { - s.bump(); - Literal::None - } - _ => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("state initializers are literals only (§4.3), found `{name}`"), - span, - ); - s.bump(); - Literal::Error - } - }, - T::LBrace => { - s.bump(); - let end = s.peek_span(); - if s.expect( - &T::RBrace, - "— `{}` (the empty map) is the only brace literal here", - ) - .is_some() - { - return (Literal::EmptyMap, span.to(end)); - } - Literal::Error - } - T::Minus => { - // Negative integer literal. - s.bump(); - if let T::Int(i) = s.peek().clone() { - let end = s.peek_span(); - s.bump(); - return (Literal::Int(-i), span.to(end)); - } - s.cur.error( - codes::UNEXPECTED_TOKEN, - "expected an integer after `-`", - span, - ); - Literal::Error - } - other => { - let desc = other.describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a literal initializer, found {desc}"), - span, - ); - s.bump(); - Literal::Error - } - }; - (lit, span) -} - -fn parse_handler(s: &mut DslStream) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - s.eat_ident("on"); - let (first, fspan) = s.expect_ident("as the event name")?; - - // `on .ok(…)` / `.err(…)` — outcome handlers. - let event = if *s.peek() == T::Dot { - s.bump(); - let (which, wspan) = s.expect_ident("(`ok` or `err`) after `.`")?; - let kind = match which.as_str() { - "ok" => OutcomeKind::Ok, - "err" => OutcomeKind::Err, - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("outcome handlers are `.ok` or `.err`, found `.{other}`"), - wspan, - ); - OutcomeKind::Err - } - }; - EventRef::Outcome { - command: first, - which: kind, - span: fspan.to(wspan), - } - } else { - EventRef::Semantic { - name: first, - span: fspan, - } - }; - - // Parameter list: UI events declare `name: type`; outcome handlers are - // name-only. - let mut params = Vec::new(); - let mut params_trailing = DslTrivia::default(); - if *s.peek() == T::LParen { - s.bump(); - if *s.peek() != T::RParen { - loop { - let param_leading = s.take_leading(); - if *s.peek() == T::RParen { - params_trailing = param_leading; - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - break; - } - let pstart = s.peek_span(); - let Some((pname, pspan)) = s.expect_ident("as a handler parameter") else { - break; - }; - let ty = if s.eat(&T::Colon) { - Some(parse_type(s)) - } else { - None - }; - let span = ty.as_ref().map_or(pspan, |t| pstart.to(t.span)); - s.accept_outer_docs(¶m_leading, span); - params.push(HandlerParam { - name: pname, - ty, - span, - leading: param_leading, - }); - if !s.eat(&T::Comma) { - break; - } - } - } - if params_trailing.is_empty() { - params_trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - } - s.expect(&T::RParen, "to close the handler parameters"); - } - - let guard = if s.eat_ident("when") { - Some(parse_expr(s)) - } else { - None - }; - - s.expect(&T::LBrace, "to open the handler body"); - let mut body = Vec::new(); - let mut body_trailing = DslTrivia::default(); - let mut end = start; - loop { - match s.peek().clone() { - T::RBrace => { - body_trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&body_trailing, boundary); - end = s.bump().span; - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed handler body", span); - break; - } - _ => match parse_stmt(s) { - Some(st) => body.push(st), - None => { - sync_to( - s, - &["set", "send", "open-surface", "dismiss", "navigate", "on"], - ); - if s.peek().is_ident("on") { - // Missing `}` — let the store loop pick the next handler. - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "handler body not closed", span); - break; - } - } - }, - } - } - let span = start.to(end); - s.accept_outer_docs(&leading, span); - Some(Handler { - event, - params, - params_trailing, - guard, - body, - body_trailing, - span, - leading, - }) -} - -/// The five statements (design §4.2). -fn parse_stmt(s: &mut DslStream) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - let T::Ident(kw) = s.peek().clone() else { - let desc = s.peek().describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a statement (set | send | open-surface | dismiss | navigate), found {desc}"), - start, - ); - return None; - }; - let parsed = match kw.as_str() { - "set" => { - s.bump(); - let (field, fspan) = s.expect_ident("as the state field")?; - let key = if s.eat(&T::LBracket) { - let k = parse_expr(s); - s.expect(&T::RBracket, "to close the map key"); - Some(k) - } else { - None - }; - let path_span = fspan; - s.expect(&T::Eq, "in `set = `"); - let value = parse_expr(s); - let span = start.to(value.span); - Some(Stmt::Set { - path: SetPath { - field, - key, - span: path_span, - }, - value, - span, - leading, - }) - } - "send" => { - s.bump(); - let (command, _) = s.expect_ident("as the command name")?; - let args = parse_args(s); - let bind = if s.eat_ident("as") { - s.expect_ident("as the tag binding name").map(|(n, _)| n) - } else { - None - }; - let span = start.to(s.peek_span()); - Some(Stmt::Send { - command, - args, - bind, - span, - leading, - }) - } - "open-surface" => { - s.bump(); - let (name, _) = s.expect_ident("as the surface name")?; - let args = parse_args(s); - let span = start.to(s.peek_span()); - Some(Stmt::OpenSurface { - name, - args, - span, - leading, - }) - } - "dismiss" => { - s.bump(); - Some(Stmt::Dismiss { - span: start, - leading, - }) - } - "navigate" => { - s.bump(); - let (mut target_name, tspan) = - s.expect_ident("as a route name, `replace`, or `back`")?; - if target_name == "back" { - Some(Stmt::Navigate { - target: NavTarget::Back, - span: start.to(tspan), - leading, - }) - } else { - let replace = target_name == "replace"; - if replace { - (target_name, _) = s.expect_ident("as the route name after `replace`")?; - } - let args = if *s.peek() == T::LParen { - parse_args(s) - } else { - Vec::new() - }; - let span = start.to(s.peek_span()); - Some(Stmt::Navigate { - target: if replace { - NavTarget::Replace { - name: target_name, - args, - } - } else { - NavTarget::Route { - name: target_name, - args, - } - }, - span, - leading, - }) - } - } - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!( - "`{other}` is not a statement — the closed set is \ - set | send | open-surface | dismiss | navigate (§4.2)" - ), - start, - ); - None - } - }; - if let Some(stmt) = &parsed { - let span = match stmt { - Stmt::Set { span, .. } - | Stmt::Send { span, .. } - | Stmt::OpenSurface { span, .. } - | Stmt::Dismiss { span, .. } - | Stmt::Navigate { span, .. } - | Stmt::Error { span } => *span, - }; - let leading = match stmt { - Stmt::Set { leading, .. } - | Stmt::Send { leading, .. } - | Stmt::OpenSurface { leading, .. } - | Stmt::Dismiss { leading, .. } - | Stmt::Navigate { leading, .. } => Some(leading), - Stmt::Error { .. } => None, - }; - if let Some(leading) = leading { - s.reject_docs(leading, span); - } - } - parsed -} diff --git a/crates/uhura-syntax/src/parser/examples.rs b/crates/uhura-syntax/src/parser/examples.rs deleted file mode 100644 index 0bab62e..0000000 --- a/crates/uhura-syntax/src/parser/examples.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! `.examples.uhura` files (design §6.1): `use fixture …` imports plus -//! `example [default] { clauses }` declarations. Pure DSL surface. - -use uhura_base::codes; - -use crate::ast::*; -use crate::token::TokenKind as T; - -use super::expr::{parse_args, parse_expr}; -use super::stream::DslStream; - -pub fn parse_examples(s: &mut DslStream) -> ExamplesFile { - let mut preamble = DslTrivia::default(); - let mut uses = Vec::new(); - let mut examples = Vec::new(); - let trailing; - let mut first_item = true; - loop { - match s.peek().clone() { - T::Eof => { - trailing = s.take_leading(); - let eof = s.peek_span(); - if first_item { - s.accept_file_docs_at_eof(&trailing, eof); - preamble = trailing.clone(); - } else { - s.reject_boundary_docs(&trailing, eof); - } - break; - } - T::Ident(k) if k == "use" => { - if let Some(u) = super::dsl::parse_use(s, first_item) { - if first_item { - preamble = use_leading(&u).clone(); - } - uses.push(u); - } - first_item = false; - } - T::Ident(k) if k == "example" => { - if let Some(e) = parse_example(s, first_item) { - if first_item { - preamble = e.leading.clone(); - } - examples.push(e); - } - first_item = false; - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected `use fixture …` or `example …`, found {desc}"), - span, - ); - s.bump(); - } - } - } - ExamplesFile { - preamble, - uses, - examples, - trailing, - } -} - -fn use_leading(use_decl: &Use) -> &DslTrivia { - match use_decl { - Use::Component { leading, .. } - | Use::Surface { leading, .. } - | Use::Port { leading, .. } - | Use::Fixture { leading, .. } => leading, - } -} - -fn parse_example(s: &mut DslStream, file_preamble: bool) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - s.eat_ident("example"); - let (name, _) = s.expect_ident("as the example name")?; - let is_default = s.eat_ident("default"); - s.expect(&T::LBrace, "to open the example body"); - - let mut clauses = Vec::new(); - let mut clause_leading = Vec::new(); - let mut trailing = DslTrivia::default(); - let mut end = start; - loop { - match s.peek().clone() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - end = s.bump().span; - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed example body", span); - break; - } - T::Ident(k) => { - let clause_trivia = s.take_leading(); - let cstart = s.peek_span(); - match k.as_str() { - "from" => { - s.bump(); - if let Some((from, fspan)) = s.expect_ident("as the parent example") { - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::From { - name: from, - span: cstart.to(fspan), - }, - ); - } - } - "note" => { - s.bump(); - if let T::Str(text) = s.peek().clone() { - let tspan = s.peek_span(); - s.bump(); - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::Note { - text, - span: cstart.to(tspan), - }, - ); - } else { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - "`note` takes a string literal", - span, - ); - } - } - "params" | "props" | "state" => { - s.bump(); - let entries = parse_assign_block(s, &k); - let span = cstart.to(s.peek_span()); - let clause = match k.as_str() { - "params" => ExampleClause::Params { entries, span }, - "props" => ExampleClause::Props { entries, span }, - _ => ExampleClause::State { entries, span }, - }; - push_clause(s, &mut clauses, &mut clause_leading, clause_trivia, clause); - } - "projection" => { - s.bump(); - if let Some(pin) = parse_projection_pin(s) { - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::Projection(pin), - ); - } - } - "events" => { - s.bump(); - let entries = parse_events_list(s); - let span = cstart.to(s.peek_span()); - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::Events { entries, span }, - ); - } - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!( - "unknown example clause `{other}` — clauses are from | note | \ - params | props | state | projection | events" - ), - cstart, - ); - s.reject_docs(&clause_trivia, cstart); - s.bump(); - } - } - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected an example clause, found {desc}"), - span, - ); - s.bump(); - } - } - } - let span = start.to(end); - if file_preamble { - s.accept_preamble_docs(&leading, span); - } else { - s.accept_outer_docs(&leading, span); - } - Some(ExampleDecl { - name, - is_default, - clauses, - clause_leading, - trailing, - span, - leading, - }) -} - -fn push_clause( - s: &mut DslStream, - clauses: &mut Vec, - leading: &mut Vec, - trivia: DslTrivia, - clause: ExampleClause, -) { - let span = clause_span(&clause); - s.reject_docs(&trivia, span); - leading.push(trivia); - clauses.push(clause); -} - -fn clause_span(clause: &ExampleClause) -> uhura_base::Span { - match clause { - ExampleClause::From { span, .. } - | ExampleClause::Note { span, .. } - | ExampleClause::Params { span, .. } - | ExampleClause::Props { span, .. } - | ExampleClause::State { span, .. } - | ExampleClause::Events { span, .. } - | ExampleClause::Error { span } => *span, - ExampleClause::Projection(pin) => pin.span, - } -} - -/// `{ name = expr, … }` for params / props / state clauses. -fn parse_assign_block(s: &mut DslStream, what: &str) -> Vec<(String, Expr)> { - let mut out = Vec::new(); - if s.expect(&T::LBrace, &format!("to open the `{what}` clause")) - .is_none() - { - return out; - } - loop { - match s.peek() { - T::RBrace => { - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let Some((name, _)) = s.expect_ident(&format!("as a {what} entry name")) else { - break; - }; - s.expect(&T::Eq, "before the value"); - out.push((name, parse_expr(s))); - } - } - } - out -} - -/// `feed.feed-page = expr` or `comments.for-post("post-1") = expr` -/// (the leading `projection` keyword is already consumed). -fn parse_projection_pin(s: &mut DslStream) -> Option { - let start = s.peek_span(); - let (port, _) = s.expect_ident("as the port name")?; - s.expect(&T::Dot, "between port and projection"); - let (projection, _) = s.expect_ident("as the projection name")?; - let key = if *s.peek() == T::LParen { - s.bump(); - let k = parse_expr(s); - s.expect(&T::RParen, "to close the projection key"); - Some(k) - } else { - None - }; - s.expect(&T::Eq, "before the pinned value"); - let value = parse_expr(s); - let span = start.to(value.span); - Some(ProjectionPin { - port, - projection, - key, - value, - span, - }) -} - -/// `[ entry … ]` — the derivation timeline (design §6.2). -fn parse_events_list(s: &mut DslStream) -> Vec { - let mut out = Vec::new(); - if s.expect(&T::LBracket, "to open the events timeline") - .is_none() - { - return out; - } - loop { - match s.peek().clone() { - T::RBracket => { - s.bump(); - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed events timeline", span); - break; - } - T::Comma => { - s.bump(); - } - T::Ident(k) if k == "outcome" => { - let start = s.peek_span(); - s.bump(); - let Some((command, _)) = s.expect_ident("as the command name") else { - continue; - }; - s.expect(&T::Dot, "before `ok` or `err`"); - let which = match s.expect_ident("(`ok` or `err`)") { - Some((w, wspan)) => match w.as_str() { - "ok" => OutcomeKind::Ok, - "err" => OutcomeKind::Err, - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected `ok` or `err`, found `{other}`"), - wspan, - ); - OutcomeKind::Err - } - }, - None => OutcomeKind::Err, - }; - let args = if *s.peek() == T::LParen { - parse_args(s) - } else { - Vec::new() - }; - let span = start.to(s.peek_span()); - out.push(ExampleEvent::Outcome { - command, - which, - args, - span, - }); - } - T::Ident(k) if k == "projection" => { - s.bump(); - if let Some(pin) = parse_projection_pin(s) { - out.push(ExampleEvent::Projection(pin)); - } - } - T::Ident(_) => { - let start = s.peek_span(); - let (name, _) = s.expect_ident("as the event name").unwrap(); - let args = if *s.peek() == T::LParen { - parse_args(s) - } else { - Vec::new() - }; - let span = start.to(s.peek_span()); - out.push(ExampleEvent::Semantic { name, args, span }); - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a timeline entry, found {desc}"), - span, - ); - s.bump(); - } - } - } - out -} diff --git a/crates/uhura-syntax/src/parser/expr.rs b/crates/uhura-syntax/src/parser/expr.rs deleted file mode 100644 index be64d01..0000000 --- a/crates/uhura-syntax/src/parser/expr.rs +++ /dev/null @@ -1,436 +0,0 @@ -//! The total, tiny, closed expression language (design §4.3) and type -//! expressions. Precedence, loosest → tightest (micro-decision #4): -//! -//! `if-then-else` < `||` < `&&` < comparison (non-assoc) < `??` -//! < `+ - ++` < unary `! -` < postfix `.field` `[k]` `(call)` - -use uhura_base::codes; - -use crate::ast::{Arg, BinaryOp, Expr, ExprKind, TypeExpr, TypeKind, UnaryOp}; -use crate::token::TokenKind as T; - -use super::stream::DslStream; - -pub fn parse_expr(s: &mut DslStream) -> Expr { - parse_if_expr(s) -} - -fn parse_if_expr(s: &mut DslStream) -> Expr { - let start = s.peek_span(); - if s.peek().is_ident("if") { - s.bump(); - let cond = parse_or(s); - s.expect(&T::Ident("then".into()), "in `if … then … else …`"); - let then = parse_if_expr(s); - let els = if s.eat_ident("else") { - parse_if_expr(s) - } else { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - "`if` expressions require an `else` branch (§4.3: both branches, same type)", - span, - ); - Expr { - kind: ExprKind::Error, - span, - } - }; - let span = start.to(els.span); - return Expr { - kind: ExprKind::If { - cond: Box::new(cond), - then: Box::new(then), - els: Box::new(els), - }, - span, - }; - } - parse_or(s) -} - -fn parse_or(s: &mut DslStream) -> Expr { - let mut lhs = parse_and(s); - while *s.peek() == T::OrOr { - s.bump(); - let rhs = parse_and(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op: BinaryOp::Or, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn parse_and(s: &mut DslStream) -> Expr { - let mut lhs = parse_cmp(s); - while *s.peek() == T::AndAnd { - s.bump(); - let rhs = parse_cmp(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op: BinaryOp::And, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn cmp_op(t: &T) -> Option { - match t { - T::EqEq => Some(BinaryOp::Eq), - T::NotEq => Some(BinaryOp::NotEq), - T::Lt => Some(BinaryOp::Lt), - T::Le => Some(BinaryOp::Le), - T::Gt => Some(BinaryOp::Gt), - T::Ge => Some(BinaryOp::Ge), - _ => None, - } -} - -fn parse_cmp(s: &mut DslStream) -> Expr { - let lhs = parse_coalesce(s); - let Some(op) = cmp_op(s.peek()) else { - return lhs; - }; - s.bump(); - let rhs = parse_coalesce(s); - let span = lhs.span.to(rhs.span); - let out = Expr { - kind: ExprKind::Binary { - op, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - // Comparison is non-associative: a second comparison operator here is - // a hard parse error (design §4.3). - if cmp_op(s.peek()).is_some() { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - "comparison operators do not chain — parenthesize explicitly", - span, - ); - s.bump(); - let _ = parse_coalesce(s); - } - out -} - -fn parse_coalesce(s: &mut DslStream) -> Expr { - let mut lhs = parse_additive(s); - while *s.peek() == T::Coalesce { - s.bump(); - let rhs = parse_additive(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op: BinaryOp::Coalesce, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn parse_additive(s: &mut DslStream) -> Expr { - let mut lhs = parse_unary(s); - loop { - let op = match s.peek() { - T::Plus => BinaryOp::Add, - T::Minus => BinaryOp::Sub, - T::PlusPlus => BinaryOp::Concat, - _ => break, - }; - s.bump(); - let rhs = parse_unary(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn parse_unary(s: &mut DslStream) -> Expr { - let start = s.peek_span(); - let op = match s.peek() { - T::Bang => Some(UnaryOp::Not), - T::Minus => Some(UnaryOp::Neg), - _ => None, - }; - if let Some(op) = op { - s.bump(); - let expr = parse_unary(s); - let span = start.to(expr.span); - return Expr { - kind: ExprKind::Unary { - op, - expr: Box::new(expr), - }, - span, - }; - } - parse_postfix(s) -} - -fn parse_postfix(s: &mut DslStream) -> Expr { - let mut expr = parse_primary(s); - loop { - match s.peek() { - T::Dot => { - s.bump(); - if let Some((name, nspan)) = s.expect_ident("after `.`") { - let span = expr.span.to(nspan); - expr = Expr { - kind: ExprKind::Field { - base: Box::new(expr), - name, - }, - span, - }; - } else { - break; - } - } - T::LBracket => { - s.bump(); - let key = parse_expr(s); - let end = s.peek_span(); - s.expect(&T::RBracket, "to close the index"); - let span = expr.span.to(end); - expr = Expr { - kind: ExprKind::Index { - base: Box::new(expr), - key: Box::new(key), - }, - span, - }; - } - _ => break, - } - } - expr -} - -fn parse_primary(s: &mut DslStream) -> Expr { - let t = s.peek_token(); - let span = t.span; - match s.peek().clone() { - T::Int(i) => { - s.bump(); - Expr { - kind: ExprKind::Int(i), - span, - } - } - T::Str(v) => { - s.bump(); - Expr { - kind: ExprKind::Str(v), - span, - } - } - T::Ident(name) => { - match name.as_str() { - "true" => { - s.bump(); - return Expr { - kind: ExprKind::Bool(true), - span, - }; - } - "false" => { - s.bump(); - return Expr { - kind: ExprKind::Bool(false), - span, - }; - } - "none" => { - s.bump(); - return Expr { - kind: ExprKind::None, - span, - }; - } - _ => {} - } - s.bump(); - // Call form: `name(expr, …)` — builtins and keyed projections. - if *s.peek() == T::LParen { - s.bump(); - let mut args = Vec::new(); - if *s.peek() != T::RParen { - loop { - args.push(parse_expr(s)); - if !s.eat(&T::Comma) { - break; - } - } - } - let end = s.peek_span(); - s.expect(&T::RParen, "to close the call"); - return Expr { - kind: ExprKind::Call { name, args }, - span: span.to(end), - }; - } - Expr { - kind: ExprKind::Ident(name), - span, - } - } - T::LParen => { - s.bump(); - let inner = parse_expr(s); - s.expect(&T::RParen, "to close the group"); - inner - } - T::LBrace => { - // Record literal `{ field: expr, … }` (set-rhs and example pins). - s.bump(); - let mut fields = Vec::new(); - if *s.peek() != T::RBrace { - loop { - let Some((name, _)) = s.expect_ident("as a record field name") else { - break; - }; - s.expect(&T::Colon, "after the field name"); - fields.push((name, parse_expr(s))); - if !s.eat(&T::Comma) { - break; - } - } - } - let end = s.peek_span(); - s.expect(&T::RBrace, "to close the record literal"); - Expr { - kind: ExprKind::Record(fields), - span: span.to(end), - } - } - other => { - let desc = other.describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected an expression, found {desc}"), - span, - ); - s.bump(); - Expr { - kind: ExprKind::Error, - span, - } - } - } -} - -/// Named argument list: `(name: expr, …)` — the opening paren is expected -/// by the caller's context description. -pub fn parse_args(s: &mut DslStream) -> Vec { - let mut args = Vec::new(); - if s.expect(&T::LParen, "to open the argument list").is_none() { - return args; - } - if *s.peek() != T::RParen { - loop { - let start = s.peek_span(); - let Some((name, _)) = s.expect_ident("as an argument name") else { - break; - }; - s.expect( - &T::Colon, - "after the argument name (all arguments are named)", - ); - let value = parse_expr(s); - let span = start.to(value.span); - args.push(Arg { name, value, span }); - if !s.eat(&T::Comma) { - break; - } - } - } - s.expect(&T::RParen, "to close the argument list"); - args -} - -/// Type expressions: `name`, `list[T]`, `map[K]V`, suffix `?`. -pub fn parse_type(s: &mut DslStream) -> TypeExpr { - let start = s.peek_span(); - let base = match s.peek().clone() { - T::Ident(name) => { - s.bump(); - match name.as_str() { - "list" if *s.peek() == T::LBracket => { - s.bump(); - let inner = parse_type(s); - let end = s.peek_span(); - s.expect(&T::RBracket, "to close `list[…]`"); - TypeExpr { - kind: TypeKind::List(Box::new(inner)), - span: start.to(end), - } - } - "map" if *s.peek() == T::LBracket => { - s.bump(); - let key = match s.expect_ident("as the map key type") { - Some((k, _)) => k, - None => "id".to_string(), - }; - s.expect(&T::RBracket, "to close the map key"); - let value = parse_type(s); - let span = start.to(value.span); - TypeExpr { - kind: TypeKind::Map(key, Box::new(value)), - span, - } - } - _ => TypeExpr { - kind: TypeKind::Name(name), - span: start, - }, - } - } - other => { - let desc = other.describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a type, found {desc}"), - start, - ); - TypeExpr { - kind: TypeKind::Error, - span: start, - } - } - }; - if *s.peek() == T::Question { - let end = s.peek_span(); - s.bump(); - let span = base.span.to(end); - return TypeExpr { - kind: TypeKind::Option(Box::new(base)), - span, - }; - } - base -} diff --git a/crates/uhura-syntax/src/parser/markup.rs b/crates/uhura-syntax/src/parser/markup.rs deleted file mode 100644 index a710755..0000000 --- a/crates/uhura-syntax/src/parser/markup.rs +++ /dev/null @@ -1,1155 +0,0 @@ -//! The markup surface (design §4.4): elements, `{#if}` / `{#each}` / -//! `{#match}` blocks, `{expr}` interpolation, `on:` event bindings. Parsed -//! char-wise off the shared cursor; every `{…}` expression region drops into -//! the DSL parser and resyncs on its closing brace. - -use uhura_base::{Diagnostic, Span, codes}; - -use crate::ast::*; -use crate::cursor::Cursor; -use crate::token::TokenKind as T; - -use super::expr::{parse_args, parse_expr}; -use super::stream::DslStream; - -/// Why `parse_nodes` stopped. -#[derive(Debug, PartialEq, Eq)] -pub enum Stop { - /// `` — left unconsumed for the caller to match. - CloseTag, - /// `{:…}` — an arm marker; left unconsumed. - ArmMarker, - /// `{/…}` — a block close; left unconsumed. - BlockClose, - /// ` other than whitespace is misplaced. - let tail_start = cur.pos(); - let tail = cur.rest().trim(); - if !tail.is_empty() { - cur.error( - codes::MISPLACED_SECTION, - "content after `` — the style block ends the file", - Span::new(cur.file, tail_start, tail_start + 1), - ); - } - } - - File { - preamble, - kind, - uses, - props_present, - props_leading, - props, - props_trailing, - emits_present, - emits_leading, - emits, - emits_trailing, - params, - store, - trailing_dsl, - markup, - style, - } -} - -fn def_kind_span(kind: &DefKind) -> Span { - match kind { - DefKind::Component { span, .. } - | DefKind::Page { span } - | DefKind::Surface { span, .. } - | DefKind::Error { span } => *span, - } -} - -fn parse_def_kind(s: &mut DslStream) -> DefKind { - let start = s.peek_span(); - let T::Ident(kw) = s.peek().clone() else { - let span = s.peek_span(); - s.cur.error( - codes::MISPLACED_SECTION, - "a .uhura file starts with `component `, `page`, or `surface `", - span, - ); - return DefKind::Error { span: start }; - }; - match kw.as_str() { - "component" => { - s.bump(); - match s.expect_ident("as the component name") { - Some((name, nspan)) => DefKind::Component { - name, - span: start.to(nspan), - }, - None => DefKind::Error { span: start }, - } - } - "page" => { - s.bump(); - DefKind::Page { span: start } - } - "surface" => { - s.bump(); - let Some((name, mut end)) = s.expect_ident("as the surface name") else { - return DefKind::Error { span: start }; - }; - let modality = if s.eat_ident("modality") { - match s.expect_ident("as the modality (`sheet`)") { - Some((m, mspan)) => { - end = mspan; - Some(m) - } - None => None, - } - } else { - None - }; - DefKind::Surface { - name, - modality, - span: start.to(end), - } - } - other => { - s.cur.error( - codes::MISPLACED_SECTION, - format!( - "`{other}` is not a definition kind — a .uhura file starts with \ - `component `, `page`, or `surface `" - ), - start, - ); - DefKind::Error { span: start } - } - } -} - -fn parse_style_section(cur: &mut Cursor) -> Option { - let start = cur.pos(); - debug_assert!(markup::starts_style_section(cur.rest())); - if !cur.eat_str("`. - while matches!(cur.peek(), Some(c) if c.is_whitespace()) { - cur.bump(); - } - if !cur.eat('>') { - cur.error( - codes::INVALID_STYLE_BLOCK, - "`") { - Some(i) => (i, true), - None => (rest.len(), false), - }; - let raw = rest[..inner_len].to_string(); - cur.set_pos(inner_start + inner_len as u32); - if closed { - cur.eat_str(""); - } else { - cur.error( - codes::INVALID_STYLE_BLOCK, - "`\n"; - let (file, diagnostics) = module(source); - assert!( - diagnostics - .iter() - .all(|diagnostic| !matches!(diagnostic.code, "UH0016" | "UH0017" | "UH0019")), - "{diagnostics:#?}" - ); - assert!(file.markup.comments.is_empty()); -} - -#[test] -fn annotations_do_not_cross_text_or_scope_boundaries() { - let (_, incompatible) = - module("page\nliteral - - - - - {post.caption} - - - -"#; - -const FEED_STORE: &str = r#"page - -use component post-card -use surface comments-sheet -use port feed { - projection feed-page, projection viewer, - command like-post, command unlike-post, - command load-next-page, command reload -} - -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - load-pending: bool = false - notice: text? = none - } - - // like / unlike: guard-ordered multi-handler dispatch - on like-toggled(post: id, now-liked: bool) - when now-liked && !(like-pending[post] ?? false) { - set like-overlay[post] = true - set like-pending[post] = true - send like-post(post: post) - } - on like-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - } - on like-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set notice = "Couldn't like this post. Try again." - } - on feed-near-end() - when !load-pending && feed-page.has-more && feed-page.cursor != none { - set load-pending = true - send load-next-page(cursor: feed-page.cursor) - } - on comments-requested(post: id) { open-surface comments-sheet(post: post) } - on author-tapped(user: id) { navigate profile(user: user) } - on submit-requested() when draft != "" { - send add-comment(post: post, body: draft) as t - set pending-appends[t] = draft - set draft = "" - } - on dismiss-requested() { dismiss } - on back-tapped() { navigate back } -} - - - {#if notice != none} - - {/if} - {#match feed-page} - {:when loading} - Loading your feed… - {:when ready f} - - - {#each f.posts as p (p.id)} - - {/each} - - {#if !f.has-more} - You're all caught up. - {/if} - - {/match} - - -"#; - -const FEED_EXAMPLES: &str = r#"use fixture standard - -example loading { - note "cold start — nothing delivered yet" -} - -example first-page default { - projection feed.viewer = fixture.users.mira - projection feed.feed-page = fixture.feed.page-1 -} - -example like-pending { - from first-page - events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] - note "optimistic heart + count while like-post is in flight" -} - -example comments-open { - from first-page - projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze - events [ comments-requested(post: "post-lena-glaze") ] -} - -example appended { - from first-page - events [ - feed-near-end() - projection feed.feed-page = fixture.feed.pages-1-2 - outcome load-next-page.ok() - ] -} -"#; diff --git a/crates/uhura-syntax/tests/fixtures/v04-feed-ui.uhura b/crates/uhura-syntax/tests/fixtures/v04-feed-ui.uhura new file mode 100644 index 0000000..5e92fcf --- /dev/null +++ b/crates/uhura-syntax/tests/fixtures/v04-feed-ui.uhura @@ -0,0 +1,28 @@ +use uhura::ui; +use crate::feed::{Feed, Post, PostCard}; + +pub ui FeedWeb for Feed(view) { +

+
+

{view.title}

+ {#if view.loading} + + {:else} + + {/if} +
+ + {#each view.posts as Post { id, .. } (id)} + + ToggleLike(id) + featured + /> + {/each} + +

안녕하세요 {view.viewer_name}

+
+} diff --git a/crates/uhura-syntax/tests/fmt_roundtrip.rs b/crates/uhura-syntax/tests/fmt_roundtrip.rs deleted file mode 100644 index 1aaf976..0000000 --- a/crates/uhura-syntax/tests/fmt_roundtrip.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Formatter contract (design §12.5): formatting is idempotent, and the -//! formatted corpus reparses without diagnostics. Fixpoint stability implies -//! reparse-equality for everything the formatter renders. - -use uhura_base::FileId; -use uhura_syntax::{Parsed, SourceKind, format_examples, format_module, parse}; - -fn fmt(src: &str, kind: SourceKind) -> String { - let out = parse(FileId(0), src, kind); - assert!( - out.diagnostics.is_empty(), - "input must parse clean: {:?}", - out.diagnostics - ); - match out.parsed { - Parsed::Module(f) => format_module(&f), - Parsed::Examples(e) => format_examples(&e), - } -} - -fn assert_fixpoint(src: &str, kind: SourceKind) { - let once = fmt(src, kind); - let twice = fmt(&once, kind); - assert_eq!(once, twice, "formatter is not idempotent"); -} - -// Reuse the normative sources by including the sibling test file's constants -// via a tiny duplication-free include. -include!("common/normative_sources.rs"); - -#[test] -fn post_card_roundtrips() { - assert_fixpoint(POST_CARD, SourceKind::Module); -} - -#[test] -fn feed_page_roundtrips() { - assert_fixpoint(FEED_STORE, SourceKind::Module); -} - -#[test] -fn examples_roundtrip() { - assert_fixpoint(FEED_EXAMPLES, SourceKind::Examples); -} - -#[test] -fn comment_attachment_survives() { - let src = "page\n\nstore {\n state {\n // the overlay\n x: bool = false\n }\n\n // fires on tap\n on tapped() {\n // write it\n set x = true\n }\n}\n\n\n"; - let once = fmt(src, SourceKind::Module); - assert!(once.contains("// the overlay"), "{once}"); - assert!(once.contains("// fires on tap"), "{once}"); - assert!(once.contains("// write it"), "{once}"); - assert_fixpoint(src, SourceKind::Module); -} diff --git a/crates/uhura-syntax/tests/navigation.rs b/crates/uhura-syntax/tests/navigation.rs deleted file mode 100644 index daabb8c..0000000 --- a/crates/uhura-syntax/tests/navigation.rs +++ /dev/null @@ -1,49 +0,0 @@ -use uhura_base::FileId; -use uhura_syntax::ast::{NavTarget, Stmt}; -use uhura_syntax::{Parsed, SourceKind, format_module, parse}; - -const SOURCE: &str = r#"page - -store { - on reset(target: id) { - navigate replace profile(user: target) - } -} - - -"#; - -#[test] -fn navigate_replace_parses_and_formats_as_a_distinct_target() { - let parsed = parse(FileId(0), SOURCE, SourceKind::Module); - assert!( - parsed.diagnostics.is_empty(), - "unexpected diagnostics: {:?}", - parsed.diagnostics - ); - let Parsed::Module(file) = parsed.parsed else { - panic!("expected module") - }; - let store = file.store.as_ref().expect("store"); - let Stmt::Navigate { target, .. } = &store.handlers[0].body[0] else { - panic!("expected navigate statement") - }; - let NavTarget::Replace { name, args } = target else { - panic!("expected replace target") - }; - assert_eq!(name, "profile"); - assert_eq!(args.len(), 1); - assert_eq!(args[0].name, "user"); - - let formatted = format_module(&file); - assert!( - formatted.contains("navigate replace profile(user: target)"), - "{formatted}" - ); - let reparsed = parse(FileId(1), &formatted, SourceKind::Module); - assert!( - reparsed.diagnostics.is_empty(), - "formatted source must reparse: {:?}", - reparsed.diagnostics - ); -} diff --git a/crates/uhura-syntax/tests/parse_normative.rs b/crates/uhura-syntax/tests/parse_normative.rs deleted file mode 100644 index 02e18cc..0000000 --- a/crates/uhura-syntax/tests/parse_normative.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! The design doc's normative sources (§4.6, §4.7, §6.1) must parse with -//! zero diagnostics — the grammar is validated against the doc's own text -//! (plan risk #1 mitigation). - -use uhura_base::FileId; -use uhura_syntax::ast::*; -use uhura_syntax::{Parsed, SourceKind, parse}; - -include!("common/normative_sources.rs"); - -fn assert_clean(diags: &[uhura_base::Diagnostic]) { - assert!( - diags.is_empty(), - "expected zero diagnostics, got:\n{}", - diags - .iter() - .map(|d| format!( - " [{}] {} @{}..{}", - d.code, d.message, d.span.start, d.span.end - )) - .collect::>() - .join("\n") - ); -} - -#[test] -fn post_card_parses_clean() { - let out = parse(FileId(0), POST_CARD, SourceKind::Module); - assert_clean(&out.diagnostics); - let Parsed::Module(f) = out.parsed else { - panic!() - }; - - let DefKind::Component { name, .. } = &f.kind else { - panic!("expected component") - }; - assert_eq!(name, "post-card"); - assert_eq!(f.props.len(), 3); - assert_eq!(f.emits.len(), 3); - assert_eq!(f.uses.len(), 1); - assert!(f.store.is_none()); - assert_eq!(f.markup.len(), 1, "component has exactly one root"); - - let Node::Element(root) = &f.markup[0] else { - panic!() - }; - assert_eq!(root.name, "view"); - // match block with three arms sits among the children - let match_node = root - .children - .iter() - .find_map(|n| match n { - Node::Match { arms, .. } => Some(arms), - _ => None, - }) - .expect("media match"); - assert_eq!(match_node.len(), 3); - assert!(matches!(&match_node[0].pattern, MatchPattern::Variant(v) if v == "image")); - assert_eq!(match_node[1].binding.as_deref(), Some("c")); - - let style = f.style.expect("style block"); - assert_eq!(style.rules.len(), 2); - assert_eq!(style.rules[0].classes, vec!["post-card"]); -} - -#[test] -fn feed_page_parses_clean() { - let out = parse(FileId(0), FEED_STORE, SourceKind::Module); - assert_clean(&out.diagnostics); - let Parsed::Module(f) = out.parsed else { - panic!() - }; - - assert!(matches!(f.kind, DefKind::Page { .. })); - let store = f.store.expect("store"); - assert_eq!(store.state.len(), 4); - assert_eq!(store.handlers.len(), 9); - - // Multi-handler + guard + outcome signatures. - let h0 = &store.handlers[0]; - assert!(matches!(&h0.event, EventRef::Semantic { name, .. } if name == "like-toggled")); - assert!(h0.guard.is_some()); - assert_eq!(h0.body.len(), 3); - let h1 = &store.handlers[1]; - assert!(matches!( - &h1.event, - EventRef::Outcome { command, which: OutcomeKind::Ok, .. } if command == "like-post" - )); - // Outcome params are name-only. - assert!(h1.params.iter().all(|p| p.ty.is_none())); - - // `send … as t` binding. - let submit = &store.handlers[6]; - assert!(matches!( - &submit.body[0], - Stmt::Send { bind: Some(b), .. } if b == "t" - )); - // `navigate back`. - let back = &store.handlers[8]; - assert!(matches!( - &back.body[0], - Stmt::Navigate { - target: NavTarget::Back, - .. - } - )); - - // Markup: forwarding event attrs on the component call. - let Node::Element(root) = &f.markup[0] else { - panic!() - }; - fn find_element<'a>(nodes: &'a [Node], name: &str) -> Option<&'a Element> { - for n in nodes { - let kids: &[Node] = match n { - Node::Element(e) => { - if e.name == name { - return Some(e); - } - &e.children.nodes - } - Node::If { then, .. } => &then.nodes, - Node::Each { body, .. } => &body.nodes, - Node::Match { arms, .. } => { - for a in arms { - if let Some(e) = find_element(&a.body.nodes, name) { - return Some(e); - } - } - &[] - } - _ => &[], - }; - if let Some(e) = find_element(kids, name) { - return Some(e); - } - } - None - } - let card = find_element(&root.children.nodes, "post-card").expect("post-card call"); - assert_eq!(card.events.len(), 3); - assert!( - card.events - .iter() - .all(|e| matches!(e.binding, EventBinding::Forward)) - ); - assert!(card.self_closing); -} - -#[test] -fn examples_file_parses_clean() { - let out = parse(FileId(0), FEED_EXAMPLES, SourceKind::Examples); - assert_clean(&out.diagnostics); - let Parsed::Examples(ex) = out.parsed else { - panic!() - }; - - assert_eq!(ex.examples.len(), 5); - assert!(ex.examples[1].is_default); - - // Keyed projection pin. - let comments_open = &ex.examples[3]; - assert!(comments_open.clauses.iter().any(|c| matches!( - c, - ExampleClause::Projection(p) if p.projection == "for-post" && p.key.is_some() - ))); - - // Timeline with all three entry kinds. - let appended = &ex.examples[4]; - let events = appended - .clauses - .iter() - .find_map(|c| match c { - ExampleClause::Events { entries, .. } => Some(entries), - _ => None, - }) - .expect("events clause"); - assert_eq!(events.len(), 3); - assert!(matches!(&events[0], ExampleEvent::Semantic { name, .. } if name == "feed-near-end")); - assert!(matches!(&events[1], ExampleEvent::Projection(_))); - assert!(matches!( - &events[2], - ExampleEvent::Outcome { - which: OutcomeKind::Ok, - .. - } - )); -} - -#[test] -fn planted_errors_diagnose() { - // Unkeyed each is a parse error (§4.4). - let src = "component x\n{#each xs as x}{x}{/each}\n"; - let out = parse(FileId(0), src, SourceKind::Module); - assert!( - out.diagnostics.iter().any(|d| d.code == "UH0003"), - "{:?}", - out.diagnostics - ); - - // Unknown statement keyword. - let src = "page\nstore { on x() { mutate y = 1 } }\n\n"; - let out = parse(FileId(0), src, SourceKind::Module); - assert!(out.diagnostics.iter().any(|d| d.code == "UH0001")); - - // Mismatched close tag. - let src = "component x\nhello\n"; - let out = parse(FileId(0), src, SourceKind::Module); - assert!(out.diagnostics.iter().any(|d| d.code == "UH0004")); -} diff --git a/crates/uhura-syntax/tests/v04_format.rs b/crates/uhura-syntax/tests/v04_format.rs new file mode 100644 index 0000000..87ef0ba --- /dev/null +++ b/crates/uhura-syntax/tests/v04_format.rs @@ -0,0 +1,299 @@ +use uhura_syntax::v04::{ + FormatError, SourceIdentity, TriviaKind, UnsupportedComment, format, parse, +}; + +const PROGRAMS: &str = include_str!("../../../examples/programs/answers/uhura-0.4/programs.uhura"); + +fn identity(path: &str) -> SourceIdentity { + SourceIdentity::new(19, "examples.programs@1", "programs", path) +} + +fn parse_clean(path: &str, source: &str) -> uhura_syntax::v04::Parse { + let parsed = parse(identity(path), source); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics for {path}:\n{:#?}", + parsed.diagnostics + ); + parsed +} + +fn assert_round_trip(path: &str, source: &str) -> String { + let parsed = parse_clean(path, source); + let formatted = format(&parsed.module).expect("comment-free source must format"); + let reparsed = parse_clean(path, &formatted); + let reformatted = format(&reparsed.module).expect("formatted source must format again"); + + // The formatter is a complete structural projection of the AST: parsing + // its output and projecting again must retain every represented choice. + assert_eq!(reformatted, formatted, "formatter must be idempotent"); + assert_eq!( + reparsed.module.uses.len(), + parsed.module.uses.len(), + "imports must survive formatting" + ); + assert_eq!( + reparsed.module.declarations.len(), + parsed.module.declarations.len(), + "declarations must survive formatting" + ); + formatted +} + +#[test] +fn formats_the_complete_l0_l1_l2_fixture_and_reparses_it() { + let formatted = assert_round_trip("programs.uhura", PROGRAMS); + assert!(formatted.starts_with("pub machine BoundedCounter {\n config {\n")); + assert!(formatted.contains("\n before commit {\n")); + assert!(formatted.ends_with("\n")); + assert!(!formatted.ends_with("\n\n")); +} + +#[test] +fn formats_every_core_declaration_member_expression_and_pattern_form() { + let source = r#"use crate::shared::{Notice, Helper as LocalHelper}; +use crate::other::Thing as LocalThing; +pub use vendor::api::PublicType; + +pub struct Item { + value: Text, + pair: (Nat, Text), + nested: Outer::Inner, +} + +enum Choice { + Empty, + Value { value: Text }, +} + +pub key ItemId(Text); +pub const DEFAULT_VALUE: Text = "line\n\"quoted\""; + +pub fn expressions(value: Item, other: Item) -> Text { + let unit: () = (); + let sequence = [true, false, 0, 1, 1.5, "text"]; + let tuple: (Item, Item) = (value, other); + let grouped = (value); + let record = Item { + value: other.value, + pair: (1, "one"), + nested: other.nested, + ..value + }; + let empty = Choice::Empty {}; + let block = { + let inside = other; + inside + }; + let call = collect(value.member[0], |item| item + 1); + let operators = !false || -1 * 2 + 3 - 4 == 5 && 6 != 7; + let comparisons = 1 < 2 && 2 <= 3 && 3 > 2 && 3 >= 3; + let tested = value is Item { value: name, .. }; + let selected = if true { + value + } else if false { + other + } else { + record + }; + if false { + return; + } + let matched = match selected { + true => "bool", + false => "bool", + -1 => "integer", + -1.5 => "decimal", + "text" => "text", + () => "unit", + (left, right) => "tuple", + (single) => "group", + Some(inner) => "some", + None => "none", + Choice::Empty => "constructor", + Item { value, pair: renamed, .. } => "record", + Choice::Empty | Choice::Value { .. } => "alternative", + _ => "wildcard", + }; + if true {} else {} + match value { + _ => (), + } + value; + return matched +} + +pub part Worker(seed: Text) { + require seed != ""; + + requires outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + const LIMIT: Nat = 2; + + fn normalize(value: Text) -> Text { + value + } + + events { + Start(value: Text), + } + + commands { + Logged(value: Text), + } + + port clock = ClockPort { zone: seed }; + + state { + current: Option = None, + } + + pub computed visible: Bool = current is Some(_); + + invariant true; + + observe { + current, + ready: true, + } + + on Start(value) { + current = Some(value); + emit Logged(value); + emit clock.Logged(value); + Accepted + } + + on clock.Tick(now) { + let ignored = now; + Accepted + } + + pub update clear() -> Outcome { + current = None; + Accepted + } +} + +pub machine Application { + config { + label: Text, + } + + require label != ""; + + const ZERO: Nat = 0; + + fn identity(value: Text) -> Text { + return value; + } + + part worker = Worker(label); + + events { + Started, + } + + commands { + Ready, + } + + port router = Router {}; + + outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + state { + count: Nat = ZERO, + } + + computed doubled: Int = count * 2; + + computed unlabeled = count; + + invariant { + count >= 0, + doubled >= 0, + } + + observe { + count, + } + + on Started { + while count > 0 decreases(count) { + count = count - 1; + unreachable; + } + emit Ready; + Accepted + } + + update clear() { + count = 0; + } + + before commit { + worker.clear(); + } +} +"#; + + let formatted = assert_round_trip("all-core.uhura", source); + assert!(formatted.contains("pub part Worker(seed: Text) {")); + assert!(formatted.contains("Choice::Empty | Choice::Value {")); + assert!(formatted.contains("while count > 0 decreases(count) {")); + assert!(formatted.contains("port router = Router {};")); +} + +#[test] +fn inserts_only_the_parentheses_required_by_the_ast() { + let source = r#"const VALUE: Bool = (a || b) && c || d && e; + +fn arithmetic(a: Int, b: Int, c: Int) -> Int { + a - (b - c) + a * (b + c) +} +"#; + let formatted = assert_round_trip("precedence.uhura", source); + assert!(formatted.contains("const VALUE: Bool = (a || b) && c || d && e;")); + assert!(formatted.contains("a - (b - c) + a * (b + c)")); +} + +#[test] +fn refuses_to_silently_delete_comments_until_attachment_is_modeled() { + let source = r#"//! Module documentation. +// ordinary module note +/// Declaration documentation. +pub struct Item { + value: Text, +} +"#; + let parsed = parse_clean("comments.uhura", source); + let error = format(&parsed.module).expect_err("comments must be refused explicitly"); + let FormatError::UnsupportedComments { comments } = error; + assert_eq!( + comments, + vec![ + UnsupportedComment { + kind: TriviaKind::InnerDoc, + text: "//! Module documentation.".into(), + span: comments[0].span, + }, + UnsupportedComment { + kind: TriviaKind::OrdinaryComment, + text: "// ordinary module note".into(), + span: comments[1].span, + }, + UnsupportedComment { + kind: TriviaKind::OuterDoc, + text: "/// Declaration documentation.".into(), + span: comments[2].span, + }, + ] + ); +} diff --git a/crates/uhura-syntax/tests/v04_instagram.rs b/crates/uhura-syntax/tests/v04_instagram.rs new file mode 100644 index 0000000..0270104 --- /dev/null +++ b/crates/uhura-syntax/tests/v04_instagram.rs @@ -0,0 +1,34 @@ +use uhura_syntax::v04::{SourceIdentity, format, parse}; + +const MACHINE: &str = include_str!("../../../examples/instagram/client/machine.uhura"); + +#[test] +fn parses_and_formats_the_complete_instagram_machine_losslessly() { + let parsed = parse( + SourceIdentity::new(41, "app.instagram@1", "machine", "client/machine.uhura"), + MACHINE, + ); + + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics:\n{:#?}", + parsed.diagnostics + ); + assert_eq!(parsed.source_from_tokens(), MACHINE); + + let formatted = format(&parsed.module).expect("comment-free source must format"); + let reparsed = parse( + SourceIdentity::new(42, "app.instagram@1", "machine", "client/machine.uhura"), + &formatted, + ); + assert!( + reparsed.diagnostics.is_empty(), + "formatted source must reparse:\n{:#?}", + reparsed.diagnostics + ); + assert_eq!( + format(&reparsed.module).expect("formatted source must format again"), + formatted, + "formatter must be idempotent" + ); +} diff --git a/crates/uhura-syntax/tests/v04_lexer.rs b/crates/uhura-syntax/tests/v04_lexer.rs new file mode 100644 index 0000000..1cd6b8a --- /dev/null +++ b/crates/uhura-syntax/tests/v04_lexer.rs @@ -0,0 +1,72 @@ +use uhura_syntax::v04::{LexDiagnosticKind, SourceIdentity, TriviaKind, lex}; + +fn identity() -> SourceIdentity { + SourceIdentity::new(3, "test@1", "test", "test.uhura") +} + +#[test] +fn classifies_comments_and_decodes_json_text() { + let source = + "//! file\n/// outer\n//// ordinary\nconst TEXT: Text = \"A\\uD83D\\uDE80\\n\"; // tail\n"; + let output = lex(&identity(), source); + assert!(output.diagnostics.is_empty(), "{:#?}", output.diagnostics); + let kinds = output + .tokens + .iter() + .flat_map(|token| token.leading.iter().map(|trivia| trivia.kind)) + .collect::>(); + assert!(kinds.contains(&TriviaKind::InnerDoc)); + assert!(kinds.contains(&TriviaKind::OuterDoc)); + assert!(kinds.contains(&TriviaKind::OrdinaryComment)); + assert!(output.tokens.iter().any(|token| { + matches!(&token.kind, uhura_syntax::v04::TokenKind::Text(value) if value == "A🚀\n") + })); +} + +#[test] +fn rejects_non_core_lexical_spellings_deterministically() { + for (source, expected) in [ + ("\u{feff}const X: Int = 0;", LexDiagnosticKind::InitialBom), + ( + "const CAFÉ: Int = 0;", + LexDiagnosticKind::NonAsciiIdentifier, + ), + ("const X: Int = 01;", LexDiagnosticKind::InvalidNumber), + ("const X: Decimal = .5;", LexDiagnosticKind::InvalidNumber), + ("const X: Text = \"\\x\";", LexDiagnosticKind::InvalidEscape), + ( + "const X: Text = \"\\uD800\";", + LexDiagnosticKind::InvalidSurrogatePair, + ), + ( + "const X: Text = \"line\n\";", + LexDiagnosticKind::InvalidEscape, + ), + ( + "const X:\u{00a0}Int = 0;", + LexDiagnosticKind::InvalidWhitespace, + ), + ] { + let output = lex(&identity(), source); + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == expected), + "expected {expected:?} for {source:?}, got {:#?}", + output.diagnostics + ); + let reconstructed = output + .tokens + .iter() + .flat_map(|token| { + token + .leading + .iter() + .map(|trivia| trivia.text.as_str()) + .chain(std::iter::once(token.lexeme.as_str())) + }) + .collect::(); + assert_eq!(reconstructed, source); + } +} diff --git a/crates/uhura-syntax/tests/v04_parser.rs b/crates/uhura-syntax/tests/v04_parser.rs new file mode 100644 index 0000000..0d0ca94 --- /dev/null +++ b/crates/uhura-syntax/tests/v04_parser.rs @@ -0,0 +1,248 @@ +use uhura_syntax::v04::ast::{ + BinaryOperator, DeclarationKind, ExpressionKind, MachineMemberKind, StatementKind, +}; +use uhura_syntax::v04::{ParseDiagnosticKind, ParseFix, SourceIdentity, Span, parse}; + +fn parse_source(source: &str) -> uhura_syntax::v04::Parse { + parse( + SourceIdentity::new(11, "test@1", "precedence", "precedence.uhura"), + source, + ) +} + +#[test] +fn applies_the_frozen_operator_precedence() { + let parsed = parse_source("const VALUE: Bool = a || b && c == d + e * f;"); + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + let DeclarationKind::Const(value) = &parsed.module.declarations[0].kind else { + panic!("expected const"); + }; + let ExpressionKind::Binary { + operator: BinaryOperator::Or, + right, + .. + } = &value.value.kind + else { + panic!("expected top-level logical or: {:#?}", value.value); + }; + assert!(matches!( + right.kind, + ExpressionKind::Binary { + operator: BinaryOperator::And, + .. + } + )); +} + +#[test] +fn rejects_comparison_chains() { + let parsed = parse_source("const VALUE: Bool = a < b < c;"); + assert!( + parsed + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == ParseDiagnosticKind::ComparisonChain) + ); +} + +#[test] +fn preserves_block_tail_and_every_authored_semicolon() { + let source = r#"machine Example { + events { Run, } + outcomes { commit Accepted, } + state { count: Int = 0, } + on Run { + let next = count + 1; + count = next; + if next > 2 { + count = 2; + } + Accepted + } +} +"#; + let parsed = parse_source(source); + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + let DeclarationKind::Machine(machine) = &parsed.module.declarations[0].kind else { + panic!("expected machine"); + }; + let handler = machine + .members + .iter() + .find_map(|member| match &member.kind { + MachineMemberKind::Handler(handler) => Some(handler), + _ => None, + }) + .expect("handler"); + assert_eq!(handler.body.statements.len(), 3); + assert!(handler.body.tail.is_some()); + assert!(matches!( + handler.body.statements[0].kind, + StatementKind::Let { .. } + )); + assert!(matches!( + handler.body.statements[1].kind, + StatementKind::Assign { .. } + )); + assert!(matches!( + handler.body.statements[2].kind, + StatementKind::BlockExpression(_) + )); +} + +#[test] +fn diagnoses_missing_semicolons_and_recovers_to_later_statements() { + let source = r#"machine Example { + events { Run, } + outcomes { commit Accepted, } + state { count: Int = 0, } + on Run { + let next = count + 1 + count = next + Accepted + } +} +"#; + let parsed = parse_source(source); + let missing = parsed + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.kind == ParseDiagnosticKind::MissingToken) + .collect::>(); + assert_eq!(missing.len(), 2, "{:#?}", parsed.diagnostics); + let assignment = source.find("count = next").unwrap() as u32; + let accepted = source.find("Accepted\n").unwrap() as u32; + assert_eq!( + missing[0], + &uhura_syntax::v04::ParseDiagnostic { + kind: ParseDiagnosticKind::MissingToken, + message: "expected `;` in let statement".into(), + span: Span::new(11, assignment, assignment + "count".len() as u32), + fix: Some(ParseFix { + title: "Insert `;`".into(), + span: Span::empty(11, assignment), + insert: ";".into(), + }), + } + ); + assert_eq!( + missing[1], + &uhura_syntax::v04::ParseDiagnostic { + kind: ParseDiagnosticKind::MissingToken, + message: "expected `;` in state assignment".into(), + span: Span::new(11, accepted, accepted + "Accepted".len() as u32), + fix: Some(ParseFix { + title: "Insert `;`".into(), + span: Span::empty(11, accepted), + insert: ";".into(), + }), + } + ); +} + +#[test] +fn declaration_typo_has_one_stable_kind_and_safe_replacement() { + let source = "pub mashine Counter {}\n"; + let parsed = parse_source(source); + assert_eq!( + parsed.diagnostics, + vec![uhura_syntax::v04::ParseDiagnostic { + kind: ParseDiagnosticKind::InvalidDeclaration, + message: "unknown module declaration `mashine`; expected `machine`, `part`, `ui`, `scenario`, `example`, `checkpoint`, `struct`, `enum`, `key`, `const`, or `fn`".into(), + span: Span::new(11, 4, 11), + fix: Some(ParseFix { + title: "Replace `mashine` with `machine`".into(), + span: Span::new(11, 4, 11), + insert: "machine".into(), + }), + }] + ); +} + +#[test] +fn missing_expression_preserves_the_enclosing_delimiter_without_a_cascade() { + let source = "pub const INITIAL: Int = ;\n"; + let parsed = parse_source(source); + let semicolon = source.find(';').unwrap() as u32; + assert_eq!( + parsed.diagnostics, + vec![uhura_syntax::v04::ParseDiagnostic { + kind: ParseDiagnosticKind::InvalidExpression, + message: "expected expression, found `;`".into(), + span: Span::new(11, semicolon, semicolon + 1), + fix: None, + }] + ); + let diagnostic = parsed + .diagnostics + .into_iter() + .next() + .unwrap() + .into_public_diagnostic(); + assert_eq!(diagnostic.code, "R1001"); + assert_eq!(diagnostic.rule, "uhura-0.4/parse/invalid-expression"); + assert_eq!(diagnostic.span.start, semicolon); + assert_eq!(diagnostic.span.end, semicolon + 1); +} + +#[test] +fn admits_keywords_only_as_contextual_member_and_explicit_record_labels() { + let source = r#"struct Entry { + value: Text, +} + +fn project(entry: Entry) -> Text { + let copied = Entry { + key: entry.key, + match: entry.match, + }; + match copied { + Entry { key: id, match: value } => value, + } +} +"#; + let parsed = parse_source(source); + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + + let shorthand = parse_source("const VALUE: Entry = Entry { key };"); + assert!(shorthand.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == ParseDiagnosticKind::InvalidName + && diagnostic.message.contains("keyword shorthand") + })); +} + +#[test] +fn evidence_patterns_admit_nominal_key_constructors_without_relaxing_core_patterns() { + let evidence = parse_source( + r#"scenario pending for Counter { + start + expect inspection { + request: { + id: RequestId(1), + .. + }, + .. + } +} +"#, + ); + assert!( + evidence.diagnostics.is_empty(), + "{:#?}", + evidence.diagnostics + ); + + let core = parse_source( + r#"fn is_first(value: RequestId) -> Bool { + match value { + RequestId(1) => true, + _ => false, + } +} +"#, + ); + assert!(core.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == ParseDiagnosticKind::InvalidPattern + && diagnostic.message.contains("only the prelude") + })); +} diff --git a/crates/uhura-syntax/tests/v04_programs.rs b/crates/uhura-syntax/tests/v04_programs.rs new file mode 100644 index 0000000..a86c9fa --- /dev/null +++ b/crates/uhura-syntax/tests/v04_programs.rs @@ -0,0 +1,199 @@ +use uhura_syntax::v04::ast::{DeclarationKind, Module, SourceIdentity}; +use uhura_syntax::v04::parse; + +const PROGRAMS: &str = include_str!("../../../examples/programs/answers/uhura-0.4/programs.uhura"); + +fn identity(path: &str) -> SourceIdentity { + SourceIdentity::new(7, "examples.programs@1", "programs", path) +} + +#[test] +fn parses_complete_l0_l1_l2_programs_losslessly() { + let parsed = parse(identity("programs.uhura"), PROGRAMS); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics:\n{:#?}", + parsed.diagnostics + ); + assert_eq!(parsed.source_from_tokens(), PROGRAMS); + assert_eq!(parsed.module.source, PROGRAMS); + assert_eq!(parsed.module.identity.package, "examples.programs@1"); + assert_eq!(parsed.module.identity.module, "programs"); + assert_eq!( + parsed + .module + .declarations + .iter() + .filter(|declaration| matches!(declaration.kind, DeclarationKind::Machine(_))) + .count(), + 3 + ); + assert!(parsed.tokens.iter().any(|token| !token.leading.is_empty())); +} + +#[test] +fn parses_every_core_declaration_and_member_shape() { + let source = r#"//! Module documentation. +use crate::shared::{Notice, Helper as LocalHelper}; +pub use vendor::api::PublicType; + +pub struct Message { + text: Text, + priority: Nat, +} + +enum Delivery { + Pending, + Sent { at: Nat }, +} + +pub key MessageId(Text); +pub const DEFAULT_PRIORITY: Nat = 1; + +pub fn choose(left: Text, right: Text) -> Text { + if left == "" { right } else { left } +} + +pub part Notice(seed: Text) { + require seed != ""; + + requires outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + const LIMIT: Nat = 2; + + fn normalize(value: Text) -> Text { + value + } + + events { + Show(value: Text), + Hide, + } + + commands { + Logged(value: Text), + } + + port clock = ClockPort { zone: seed, }; + + state { + message: Option = None, + remaining: Nat = LIMIT, + } + + pub computed current: Option = message; + invariant remaining <= LIMIT; + + observe { + message, + visible: message is Some(_), + } + + on Show(value) { + message = Some(normalize(value)); + emit Logged(value); + Accepted + } + + on clock.Tick(now) { + let pair: (Nat, Text) = (now, seed); + if now == 0 { + return Refused("early"); + } + Accepted + } + + pub update dismiss() { + message = None; + } +} + +pub machine Application { + config { + label: Text, + } + + require label != ""; + const ZERO: Nat = 0; + + fn identity(value: Text) -> Text { + return value; + } + + part notice = Notice(label); + + events { + Started, + } + + commands { + Ready, + } + + port router = Router { initial: label, }; + + outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + state { + count: Nat = ZERO, + } + + computed doubled: Int = count * 2; + + invariant { + count >= 0, + doubled >= 0, + } + + observe { + count, + } + + on Started { + notice.dismiss(); + emit Ready; + Accepted + } + + on router.Changed(next) { + let selected = match next { + Some(value) => value, + None => identity(label), + }; + count = selected.len(); + Accepted + } + + update clear() { + count = 0; + } + + before commit { + while count > 0 decreases(count) { + count = count - 1; + } + } +} +"#; + let parsed = parse(identity("all-core.uhura"), source); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics:\n{:#?}", + parsed.diagnostics + ); + assert_eq!(parsed.module.uses.len(), 2); + assert_eq!(parsed.module.declarations.len(), 7); + assert_eq!(parsed.source_from_tokens(), source); +} + +#[test] +fn v04_module_is_serde_ready() { + fn assert_wire serde::Deserialize<'de>>() {} + assert_wire::(); +} diff --git a/crates/uhura-syntax/tests/v04_ui.rs b/crates/uhura-syntax/tests/v04_ui.rs new file mode 100644 index 0000000..13a3b63 --- /dev/null +++ b/crates/uhura-syntax/tests/v04_ui.rs @@ -0,0 +1,358 @@ +use uhura_syntax::v04::ast::{DeclarationKind, UiAttribute, UiNameKind, UiNodeKind}; +use uhura_syntax::v04::{ + FormatError, ParseDiagnosticKind, SourceIdentity, TokenKind, format, parse, +}; + +const FEED: &str = include_str!("fixtures/v04-feed-ui.uhura"); + +fn identity(path: &str) -> SourceIdentity { + SourceIdentity::new(31, "examples.feed@1", "feed", path) +} + +fn parse_clean(path: &str, source: &str) -> uhura_syntax::v04::Parse { + let parsed = parse(identity(path), source); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics for {path}:\n{:#?}", + parsed.diagnostics + ); + parsed +} + +#[test] +fn parses_the_complete_ui_profile_losslessly_with_exact_spans() { + let parsed = parse_clean("feed.uhura", FEED); + assert_eq!(parsed.source_from_tokens(), FEED); + assert_eq!( + parsed + .tokens + .iter() + .filter(|token| token.kind == TokenKind::UiBody) + .count(), + 1 + ); + + let declaration = &parsed.module.declarations[0]; + let DeclarationKind::Ui(ui) = &declaration.kind else { + panic!("expected contextual UI declaration"); + }; + assert_eq!(ui.name.text, "FeedWeb"); + assert_eq!(ui.machine.segments[0].name.text, "Feed"); + assert_eq!(ui.observation.text, "view"); + let body_start = FEED.find("Feed(view) {").unwrap() + "Feed(view) {".len(); + assert_eq!( + &FEED[ui.body.span.start as usize..ui.body.span.end as usize], + &FEED[body_start..FEED.rfind('}').unwrap()] + ); + + let UiNodeKind::Element(main) = &ui.body.nodes[0].kind else { + panic!("expected root element"); + }; + assert_eq!(main.name.text, "main"); + assert_eq!(main.name.kind, UiNameKind::Native); + let root_source = + &FEED[ui.body.nodes[0].span.start as usize..ui.body.nodes[0].span.end as usize]; + assert!(root_source.starts_with("")); + assert!(matches!(main.attributes[0], UiAttribute::StaticText { .. })); + + let each = main + .children + .iter() + .find_map(|node| match &node.kind { + UiNodeKind::Each(value) => Some(value), + _ => None, + }) + .expect("keyed each"); + let component = each + .children + .iter() + .find_map(|node| match &node.kind { + UiNodeKind::Element(value) => Some(value), + _ => None, + }) + .expect("component element"); + assert_eq!(component.name.kind, UiNameKind::Component); + assert!(component + .attributes + .iter() + .any(|attribute| matches!(attribute, UiAttribute::Boolean { name, .. } if name.text == "featured"))); + assert!(component.attributes.iter().any( + |attribute| matches!(attribute, UiAttribute::Event { event, .. } if event.text == "like") + )); +} + +#[test] +fn canonical_ui_format_is_parseable_and_idempotent() { + let parsed = parse_clean("feed.uhura", FEED); + let formatted = format(&parsed.module).expect("comment-free core expressions format"); + assert!(formatted.contains("pub ui FeedWeb for Feed(view) {")); + assert!(formatted.contains("{#if view.loading}")); + assert!(formatted.contains("{:else}")); + assert!(formatted.contains("{#each view.posts as Post {")); + assert!(formatted.contains("on like -> ToggleLike(id)")); + assert!(formatted.contains("featured")); + assert!(formatted.contains("")); + assert!(formatted.contains(">Refresh")); + assert!(formatted.contains("

안녕하세요 {view.viewer_name}

")); + + let reparsed = parse_clean("feed.formatted.uhura", &formatted); + let reformatted = format(&reparsed.module).expect("formatted UI must format again"); + assert_eq!(reformatted, formatted); +} + +#[test] +fn literal_right_brace_in_element_text_survives_parse_and_format() { + let source = r#"use uhura::ui; + +ui AppWeb for App(view) { +

Use } to close a block.

+} +"#; + let parsed = parse_clean("literal-right-brace.uhura", source); + let DeclarationKind::Ui(ui) = &parsed.module.declarations[0].kind else { + panic!("expected UI declaration"); + }; + let UiNodeKind::Element(paragraph) = &ui.body.nodes[0].kind else { + panic!("expected paragraph"); + }; + assert!(matches!( + ¶graph.children[0].kind, + UiNodeKind::Text(text) if text.raw == "Use } to close a block." + )); + + let formatted = format(&parsed.module).expect("literal text formats"); + assert!(formatted.contains("

Use } to close a block.

")); + let reparsed = parse_clean("literal-right-brace.formatted.uhura", &formatted); + assert_eq!( + format(&reparsed.module).expect("formatted literal text remains formatable"), + formatted, + ); +} + +#[test] +fn root_right_brace_requires_an_interpolation_escape() { + let ambiguous = r#"use uhura::ui; + +ui AppWeb for App(view) { + literal } text +

After the brace

+} +"#; + let rejected = parse(identity("root-right-brace.uhura"), ambiguous); + assert_eq!(rejected.source_from_tokens(), ambiguous); + assert!( + rejected.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == ParseDiagnosticKind::InvalidUi + && diagnostic.message.contains("render a literal right brace") + && diagnostic.message.contains("as `{\"}\"}`") + }), + "{:#?}", + rejected.diagnostics, + ); + + let escaped = r#"use uhura::ui; + +ui AppWeb for App(view) { + literal {"}"} text +

After the brace

+} +"#; + let parsed = parse_clean("escaped-root-right-brace.uhura", escaped); + let formatted = format(&parsed.module).expect("escaped root text formats"); + assert!(formatted.contains("{\"}\"}")); + let reparsed = parse_clean("escaped-root-right-brace.formatted.uhura", &formatted); + assert_eq!( + format(&reparsed.module).expect("escaped root text remains formatable"), + formatted, + ); +} + +#[test] +fn event_comparisons_do_not_close_the_surrounding_ui_tag() { + let source = r#"use uhura::ui; + +ui AppWeb for App(view) { + - - {viewer.avatar.alt} - -
-
- {#if notice != none} - - {/if} - {#match feed-page} - {:when loading} - - Loading your feed… - - {:when failed reason} - - Your feed didn't load. - - - {:when ready f} - - - - {#if count(f.posts) == 0} - - Nothing new yet - Posts from people you follow will appear here. - - {:else} - - {#each f.posts as p (p.id)} - - {/each} - - {#if load-pending} - Loading more… - {/if} - {#if load-failed} - - Couldn't load more. - - - {/if} - {#if !f.has-more} - You're all caught up. - {/if} - {/if} - - {/match} - -
- - diff --git a/examples/instagram/client/app/post/[id]/page.examples.uhura b/examples/instagram/client/app/post/[id]/page.examples.uhura deleted file mode 100644 index 7b823f1..0000000 --- a/examples/instagram/client/app/post/[id]/page.examples.uhura +++ /dev/null @@ -1,35 +0,0 @@ -use fixture standard - -example loading { - params { id = "post-lena-glaze" } - projection feed.viewer = fixture.users.mira -} - -example lena default { - params { id = "post-lena-glaze" } - projection feed.viewer = fixture.users.mira - projection feed.post-by-id("post-lena-glaze") = fixture.posts.lena-glaze -} - -example profile-history { - params { id = "post-lena-bowls" } - projection feed.viewer = fixture.users.mira - projection feed.post-by-id("post-lena-bowls") = fixture.posts.lena-bowls - note "a profile-grid tile opens a genuine post, not a decorative thumbnail" -} - -example like-pending { - from lena - events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] -} - -example save-pending { - from lena - events [ save-toggled(post: "post-lena-glaze", now-saved: true) ] -} - -example comments-open { - from lena - projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze - events [ comments-requested(post: "post-lena-glaze") ] -} diff --git a/examples/instagram/client/app/post/[id]/page.uhura b/examples/instagram/client/app/post/[id]/page.uhura deleted file mode 100644 index a536227..0000000 --- a/examples/instagram/client/app/post/[id]/page.uhura +++ /dev/null @@ -1,170 +0,0 @@ -page - -use component bottom-nav -use component notice-bar -use component post-card -use surface comments-sheet -use port feed { - projection post-by-id - projection viewer - command like-post - command unlike-post - command save-post - command unsave-post -} - -param id: id - -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - save-overlay: map[id]bool = {} - save-pending: map[id]bool = {} - notice: text? = none - } - - on like-toggled(post: id, now-liked: bool) when now-liked && !(like-pending[post] ?? false) { - set like-overlay[post] = true - set like-pending[post] = true - send like-post(post: post) - } - - on like-toggled(post: id, now-liked: bool) when !now-liked && !(like-pending[post] ?? false) { - set like-overlay[post] = false - set like-pending[post] = true - send unlike-post(post: post) - } - - on like-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - } - - on like-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - set notice = "Couldn't update this like. Try again." - } - - on unlike-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - } - - on unlike-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - set notice = "Couldn't update this like. Try again." - } - - on save-toggled(post: id, now-saved: bool) when now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = true - set save-pending[post] = true - send save-post(post: post) - } - - on save-toggled(post: id, now-saved: bool) when !now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = false - set save-pending[post] = true - send unsave-post(post: post) - } - - on save-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on save-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't save this post." - } - - on unsave-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on unsave-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't remove this post from saved." - } - - on comments-requested(post: id) { - open-surface comments-sheet(post: post) - } - - on author-tapped(user: id) when user == viewer.id { - navigate replace profile(user: user) - } - - on author-tapped(user: id) when user != viewer.id { - navigate profile(user: user) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on back-tapped() { - navigate back - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - - Post - - {#if notice != none} - - {/if} - {#match post-by-id(id)} - {:when loading} - - Loading post… - - {:when failed reason} - - This post isn't available. - - {:when ready p} - - - - {/match} - - - - diff --git a/examples/instagram/client/app/profile/[user]/followers/page.examples.uhura b/examples/instagram/client/app/profile/[user]/followers/page.examples.uhura deleted file mode 100644 index 849cc97..0000000 --- a/examples/instagram/client/app/profile/[user]/followers/page.examples.uhura +++ /dev/null @@ -1,23 +0,0 @@ -use fixture standard - -example loading { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira -} - -example lena default { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira - projection profile.followers("user-lena") = fixture.people.lena-followers -} - -example follow-pending { - from lena - events [ follow-toggled(target: "user-nils", now-following: true) ] -} - -example mira { - params { user = "user-mira" } - projection feed.viewer = fixture.users.mira - projection profile.followers("user-mira") = fixture.people.mira-followers -} diff --git a/examples/instagram/client/app/profile/[user]/followers/page.uhura b/examples/instagram/client/app/profile/[user]/followers/page.uhura deleted file mode 100644 index 24169e5..0000000 --- a/examples/instagram/client/app/profile/[user]/followers/page.uhura +++ /dev/null @@ -1,122 +0,0 @@ -page - -use component bottom-nav -use component connection-row -use component notice-bar -use port feed { projection viewer } -use port profile { projection followers, command follow-user, command unfollow-user } - -param user: id - -store { - state { - follow-pending: map[id]bool = {} - notice: text? = none - } - - on follow-toggled(target: id, now-following: bool) when now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on follow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on unfollow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't unfollow this person." - } - - on profile-tapped(target: id) when target == viewer.id { - navigate replace profile(user: target) - } - - on profile-tapped(target: id) when target != viewer.id { - navigate profile(user: target) - } - - on back-tapped() { - navigate back - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - - Followers - - {#if notice != none} - - {/if} - {#match followers(user)} - {:when loading} - - Loading followers… - - {:when failed reason} - - Followers aren't available. - - {:when ready list} - {#if count(list.people) == 0} - - No followers yet. - - {:else} - - - {#each list.people as person (person.user.id)} - - {/each} - - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/profile/[user]/following/page.examples.uhura b/examples/instagram/client/app/profile/[user]/following/page.examples.uhura deleted file mode 100644 index 77a201c..0000000 --- a/examples/instagram/client/app/profile/[user]/following/page.examples.uhura +++ /dev/null @@ -1,18 +0,0 @@ -use fixture standard - -example loading { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira -} - -example lena default { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira - projection profile.following("user-lena") = fixture.people.lena-following -} - -example mira { - params { user = "user-mira" } - projection feed.viewer = fixture.users.mira - projection profile.following("user-mira") = fixture.people.mira-following -} diff --git a/examples/instagram/client/app/profile/[user]/following/page.uhura b/examples/instagram/client/app/profile/[user]/following/page.uhura deleted file mode 100644 index f3edb87..0000000 --- a/examples/instagram/client/app/profile/[user]/following/page.uhura +++ /dev/null @@ -1,122 +0,0 @@ -page - -use component bottom-nav -use component connection-row -use component notice-bar -use port feed { projection viewer } -use port profile { projection following, command follow-user, command unfollow-user } - -param user: id - -store { - state { - follow-pending: map[id]bool = {} - notice: text? = none - } - - on follow-toggled(target: id, now-following: bool) when now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on follow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on unfollow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't unfollow this person." - } - - on profile-tapped(target: id) when target == viewer.id { - navigate replace profile(user: target) - } - - on profile-tapped(target: id) when target != viewer.id { - navigate profile(user: target) - } - - on back-tapped() { - navigate back - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - - Following - - {#if notice != none} - - {/if} - {#match following(user)} - {:when loading} - - Loading following… - - {:when failed reason} - - Following isn't available. - - {:when ready list} - {#if count(list.people) == 0} - - Not following anyone yet. - - {:else} - - - {#each list.people as person (person.user.id)} - - {/each} - - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/profile/[user]/page.examples.uhura b/examples/instagram/client/app/profile/[user]/page.examples.uhura deleted file mode 100644 index 23ab039..0000000 --- a/examples/instagram/client/app/profile/[user]/page.examples.uhura +++ /dev/null @@ -1,58 +0,0 @@ -use fixture standard - -example loading { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira -} - -example lena-posts default { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira - projection profile.profile("user-lena") = fixture.profiles.lena -} - -example lena-tagged { - from lena-posts - events [ profile-tab-selected(tab: "tagged") ] - note "the tile is Priya's real sourdough post from the seeded tag edge" -} - -example self { - params { user = "user-mira" } - projection feed.viewer = fixture.users.mira - projection profile.profile("user-mira") = fixture.profiles.mira -} - -example self-tagged { - from self - events [ profile-tab-selected(tab: "tagged") ] - note "Mira's tagged grid carries Marco's real Baja post id and opens shared post detail" -} - -example self-reels { - from self - events [ profile-tab-selected(tab: "reels") ] - note "Reels is a filtered view of Mira's genuine video posts" -} - -example self-saved { - from self - events [ profile-tab-selected(tab: "saved") ] - note "Saved is private to Mira and mirrors her two seeded save edges" -} - -example nils-posts { - params { user = "user-nils" } - projection feed.viewer = fixture.users.mira - projection profile.profile("user-nils") = fixture.profiles.nils -} - -example nils-reels { - from nils-posts - events [ profile-tab-selected(tab: "reels") ] -} - -example nils-tagged-empty { - from nils-posts - events [ profile-tab-selected(tab: "tagged") ] -} diff --git a/examples/instagram/client/app/profile/[user]/page.uhura b/examples/instagram/client/app/profile/[user]/page.uhura deleted file mode 100644 index 831d338..0000000 --- a/examples/instagram/client/app/profile/[user]/page.uhura +++ /dev/null @@ -1,255 +0,0 @@ -page - -use component bottom-nav -use component notice-bar -use component profile-header -use port feed { projection viewer } -use port profile { projection profile, command follow-user, command unfollow-user } - -param user: id - -store { - state { - active-tab: text = "posts" - relationship-pending: bool = false - notice: text? = none - } - - on profile-tab-selected(tab: text) { - set active-tab = tab - } - - on posts-tapped() { - set active-tab = "posts" - } - - on followers-tapped(target: id) { - navigate profile-followers(user: target) - } - - on following-tapped(target: id) { - navigate profile-following(user: target) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on follow-toggled(target: id, now-following: bool) when now-following && !relationship-pending { - set relationship-pending = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !relationship-pending { - set relationship-pending = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set relationship-pending = false - } - - on follow-user.err(tag, cmd, refusal) { - set relationship-pending = false - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set relationship-pending = false - } - - on unfollow-user.err(tag, cmd, refusal) { - set relationship-pending = false - set notice = "Couldn't unfollow this person." - } - - on create-tapped() { - navigate create() - } - - on notice-dismissed() { - set notice = none - } - - on back-tapped() { - navigate back - } - - on feed-tapped() { - navigate replace feed() - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" && user == viewer.id { - set active-tab = "posts" - } - - on tab-selected(section: text) when section == "profile" && user != viewer.id { - navigate replace profile(user: viewer.id) - } -} - - - {#if notice != none} - - {/if} - {#match profile(user)} - {:when loading} - - {#if user != viewer.id} - - {/if} - Profile - - - Loading profile… - - {:when failed reason} - - {#if user != viewer.id} - - {/if} - Profile - - - This profile didn't load. - - - {:when ready pr} - - {#if !pr.is-self} - - {/if} - {pr.user.username} - - - - - - - {#if pr.is-self} - - {/if} - - - {#if active-tab == "posts"} - {#if count(pr.posts) == 0} - - No posts yet. - - {:else} - - {#each pr.posts as th (th.id)} - - - {th.alt} - - - {/each} - - {/if} - {:else} - {#if active-tab == "reels"} - {#if count(pr.reels) == 0} - - - No reels yet. - - {:else} - - {#each pr.reels as th (th.id)} - - - {th.alt} - - - {/each} - - {/if} - {:else} - {#if active-tab == "saved"} - {#if count(pr.saved) == 0} - - - Posts you save will appear here. - - {:else} - - {#each pr.saved as th (th.id)} - - - {th.alt} - - - {/each} - - {/if} - {:else} - {#if count(pr.tagged) == 0} - - No tagged posts yet. - - {:else} - - {#each pr.tagged as th (th.id)} - - - {th.alt} - - - {/each} - - {/if} - {/if} - {/if} - {/if} - - {/match} - - - - diff --git a/examples/instagram/client/app/reels/page.examples.uhura b/examples/instagram/client/app/reels/page.examples.uhura deleted file mode 100644 index ca80e29..0000000 --- a/examples/instagram/client/app/reels/page.examples.uhura +++ /dev/null @@ -1,26 +0,0 @@ -use fixture standard - -example loading { - projection feed.viewer = fixture.users.mira -} - -example videos default { - projection feed.viewer = fixture.users.mira - projection feed.reels = fixture.reels.page - note "three real video posts backed by local fixture MP4s" -} - -example like-pending { - from videos - events [ like-toggled(post: "post-nils-aurora", now-liked: true) ] -} - -example save-pending { - from videos - events [ save-toggled(post: "post-theo-court", now-saved: true) ] -} - -example unsave-pending { - from videos - events [ save-toggled(post: "post-nils-aurora", now-saved: false) ] -} diff --git a/examples/instagram/client/app/reels/page.uhura b/examples/instagram/client/app/reels/page.uhura deleted file mode 100644 index 3ab7a6d..0000000 --- a/examples/instagram/client/app/reels/page.uhura +++ /dev/null @@ -1,174 +0,0 @@ -page - -use component bottom-nav -use component notice-bar -use component reel-card -use surface comments-sheet -use port feed { - projection reels - projection viewer - command like-post - command unlike-post - command save-post - command unsave-post -} - -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - save-overlay: map[id]bool = {} - save-pending: map[id]bool = {} - notice: text? = none - } - - on like-toggled(post: id, now-liked: bool) when now-liked && !(like-pending[post] ?? false) { - set like-overlay[post] = true - set like-pending[post] = true - send like-post(post: post) - } - - on like-toggled(post: id, now-liked: bool) when !now-liked && !(like-pending[post] ?? false) { - set like-overlay[post] = false - set like-pending[post] = true - send unlike-post(post: post) - } - - on like-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - } - - on like-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - set notice = "Couldn't update this like." - } - - on unlike-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - } - - on unlike-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - set notice = "Couldn't update this like." - } - - on save-toggled(post: id, now-saved: bool) when now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = true - set save-pending[post] = true - send save-post(post: post) - } - - on save-toggled(post: id, now-saved: bool) when !now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = false - set save-pending[post] = true - send unsave-post(post: post) - } - - on save-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on save-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't save this reel." - } - - on unsave-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on unsave-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't remove this reel from saved." - } - - on comments-requested(post: id) { - open-surface comments-sheet(post: post) - } - - on author-tapped(user: id) when user == viewer.id { - navigate replace profile(user: user) - } - - on author-tapped(user: id) when user != viewer.id { - navigate profile(user: user) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - Reels - - {#if notice != none} - - {/if} - {#match reels} - {:when loading} - - Loading reels… - - {:when failed reason} - - Reels aren't available. - - {:when ready r} - {#if count(r.posts) == 0} - - - No reels yet. - - {:else} - - - {#each r.posts as p (p.id)} - - {/each} - - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/search/page.examples.uhura b/examples/instagram/client/app/search/page.examples.uhura deleted file mode 100644 index b6b6661..0000000 --- a/examples/instagram/client/app/search/page.examples.uhura +++ /dev/null @@ -1,44 +0,0 @@ -use fixture standard - -example loading { - projection feed.viewer = fixture.users.mira -} - -example explore default { - projection feed.viewer = fixture.users.mira - projection profile.search-results = fixture.people.search-all - note "Explore combines real people and clickable post thumbnails" -} - -example searching { - from explore - events [ - query-changed(value: "nils") - search-submitted() - ] -} - -example nils-results { - from explore - events [ - query-changed(value: "nils") - search-submitted() - projection profile.search-results = fixture.people.search-nils - outcome search-people.ok() - ] -} - -example no-results { - from explore - events [ - query-changed(value: "no-such-person") - search-submitted() - projection profile.search-results = fixture.people.search-empty - outcome search-people.ok() - ] -} - -example empty-explore { - projection feed.viewer = fixture.users.mira - projection profile.search-results = fixture.people.search-empty -} diff --git a/examples/instagram/client/app/search/page.uhura b/examples/instagram/client/app/search/page.uhura deleted file mode 100644 index cc18bb5..0000000 --- a/examples/instagram/client/app/search/page.uhura +++ /dev/null @@ -1,178 +0,0 @@ -page - -use component bottom-nav -use component connection-row -use component notice-bar -use port feed { projection viewer } -use port profile { - projection search-results - command search-people - command follow-user - command unfollow-user -} - -store { - state { - query: text = "" - applied-query: text = "" - search-pending: bool = false - follow-pending: map[id]bool = {} - notice: text? = none - } - - on query-changed(value: text) { - set query = value - } - - on search-submitted() when !search-pending { - set search-pending = true - send search-people(query: query) - } - - on search-people.ok(tag, cmd) { - set search-pending = false - set applied-query = cmd.query - } - - on search-people.err(tag, cmd, refusal) { - set search-pending = false - set notice = "Search isn't available right now." - } - - on follow-toggled(target: id, now-following: bool) when now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on follow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on unfollow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't unfollow this person." - } - - on profile-tapped(target: id) { - navigate profile(user: target) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on tab-selected(section: text) when section == "search" && !search-pending { - set search-pending = true - send search-people(query: query) - } - - on notice-dismissed() { - set notice = none - } -} - - - - Explore - - - - - - {#if notice != none} - - {/if} - {#match search-results} - {:when loading} - - Finding people… - - {:when failed reason} - - Search isn't available. - - {:when ready results} - {#if count(results.people) == 0 && count(results.posts) == 0} - - No results found. - Try an account name or a word from a caption. - - {:else} - - {#if count(results.people) > 0} - - {if applied-query == "" then "Suggested accounts" else "Accounts"} - - {#each results.people as person (person.user.id)} - - {/each} - - - {/if} - {#if count(results.posts) > 0} - - {if applied-query == "" then "Explore posts" else "Posts"} - - {#each results.posts as post (post.id)} - - - {post.alt} - - - {/each} - - - {/if} - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/story/[id]/page.examples.uhura b/examples/instagram/client/app/story/[id]/page.examples.uhura deleted file mode 100644 index 339c155..0000000 --- a/examples/instagram/client/app/story/[id]/page.examples.uhura +++ /dev/null @@ -1,46 +0,0 @@ -use fixture standard - -example loading { - params { id = "ring-lena" } - projection feed.viewer = fixture.users.mira -} - -example unseen default { - params { id = "ring-lena" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-lena") = fixture.story-details.lena - note "first of three: no previous target, next target present, all segments unseen" -} - -example lena-middle { - params { id = "ring-lena-glazes" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-lena-glazes") = fixture.story-details.lena-glazes - note "middle frame exposes both previous and next hit zones" -} - -example lena-last { - params { id = "ring-lena-studio" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-lena-studio") = fixture.story-details.lena-studio - note "last frame replaces Next with the close affordance" -} - -example self-middle-seen { - params { id = "ring-mira-tram" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-mira-tram") = fixture.story-details.mira-tram - note "Mira's own three-frame sequence is fully viewed" -} - -example seen { - params { id = "ring-priya" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-priya") = fixture.story-details.priya - note "a single-frame seen story has one progress segment and closes on advance" -} - -example marking-seen { - from unseen - events [ story-selected(story: "ring-lena") ] -} diff --git a/examples/instagram/client/app/story/[id]/page.uhura b/examples/instagram/client/app/story/[id]/page.uhura deleted file mode 100644 index 62b4472..0000000 --- a/examples/instagram/client/app/story/[id]/page.uhura +++ /dev/null @@ -1,128 +0,0 @@ -page - -use port feed { projection story-by-id, projection viewer, command mark-story-seen } - -param id: id - -store { - state { - mark-pending: bool = false - } - - on story-selected(story: id) when !mark-pending { - set mark-pending = true - send mark-story-seen(story: story) - } - - on mark-story-seen.ok(tag, cmd) { - set mark-pending = false - navigate replace story(id: cmd.story) - } - - on mark-story-seen.err(tag, cmd, refusal) { - set mark-pending = false - // Viewing is still useful when the read is available but its seen edge - // could not settle. The destination will honestly show failed if it was - // actually removed. - navigate replace story(id: cmd.story) - } - - on author-tapped(user: id) when user == viewer.id { - navigate replace profile(user: user) - } - - on author-tapped(user: id) when user != viewer.id { - navigate profile(user: user) - } - - on back-tapped() { - navigate back - } -} - - - {#match story-by-id(id)} - {:when loading} - - Loading story… - - {:when failed reason} - - This story is no longer available. - - - {:when ready s} - - {s.image.alt} - - - - - {#each s.progress as segment (segment.id)} - - {/each} - - - - - - - - - - {s.caption} - - - - {#if s.previous != none} - - {:else} - - {/if} - {#if s.next != none} - - {:else} - - {/if} - - - {/match} - - - diff --git a/examples/instagram/client/catalog/base.toml b/examples/instagram/client/catalog/base.toml deleted file mode 100644 index 820a2df..0000000 --- a/examples/instagram/client/catalog/base.toml +++ /dev/null @@ -1,184 +0,0 @@ -# The base semantic element catalog (design §10 — normative). Ten -# elements, three classes; layout and aesthetics belong to CSS. The catalog -# is DATA: source cannot invent an element, prop, or event by naming it, -# and the checker validates this file against a meta-schema (input events -# only on interactive elements; observation events only on viewports). -# -# Every element additionally takes `class` (opaque, CSS-owned) — it is -# universal and deliberately not declared per element. - -[catalog] -name = "base" -version = "0.3.0" - -[elements.view] -class = "layout" -children = "any" - -[elements.view.props.role] -type = "enum" -values = ["none", "list", "navigation", "tablist"] - -[elements.scroll] -class = "layout" -viewport = true -children = "any" - -[elements.scroll.props.direction] -type = "enum" -values = ["vertical", "horizontal"] - -[elements.scroll.events.near-end] -kind = "observe" -# Physical proximity: remaining extent below 100% of one viewport extent — -# integer percentage, stated once here (§8.2). -threshold-percent = 100 - -[elements.pager] -class = "layout" -viewport = true -# Children come from exactly one keyed each (§10); uncontrolled in the spike. -children = "keyed-each" - -[elements.pager.props.indicator] -type = "enum" -values = ["none", "dots"] - -[elements.pager.props.label] -type = "text" -required = true - -# Declared for controlled use; the spike never binds it (§10). -[elements.pager.events.page-change] -kind = "observe" - -[elements.text] -class = "content" -# Literal text and {expr} interpolation — only here (§4.4). -children = "text" - -[elements.img] -class = "content" -children = "none" -# a11y completeness: exactly one of alt / decorative (§10). -exactly-one-of = [["alt", "decorative"]] - -[elements.img.props.src] -type = "asset" -required = true - -[elements.img.props.alt] -type = "text" - -[elements.img.props.decorative] -type = "bool" - -# First-class time-based media. The semantic props are deliberately small: -# source/poster are provider-resolved assets, label is the accessible name, -# and playback policy remains explicit instead of renderer magic. -[elements.video] -class = "content" -children = "none" - -[elements.video.props.src] -type = "asset" -required = true - -[elements.video.props.poster] -type = "asset" - -[elements.video.props.label] -type = "text" -required = true - -[elements.video.props.autoplay] -type = "bool" - -[elements.video.props.muted] -type = "bool" - -[elements.video.props.loop] -type = "bool" - -[elements.video.props.controls] -type = "bool" - -[elements.video.props.playsinline] -type = "bool" - -[elements.icon] -class = "content" -children = "none" - -[elements.icon.props.name] -type = "icon" -required = true - -[elements.icon.props.family] -type = "icon-family" - -[elements.button] -class = "interactive" -children = "content" - -[elements.button.props.label] -type = "text" -required = true - -[elements.button.props.disabled] -type = "bool" - -[elements.button.props.busy] -type = "bool" - -[elements.button.props.pressed] -type = "bool" - -[elements.button.props.current] -type = "bool" - -[elements.button.events.press] -kind = "input" - -[elements.textfield] -class = "interactive" -children = "none" -# Binding `value` obligates handling `change` (controlled promotion, §10). -controlled = { prop = "value", event = "change" } - -[elements.textfield.props.value] -type = "text" - -[elements.textfield.props.placeholder] -type = "text" - -[elements.textfield.props.label] -type = "text" -required = true - -[elements.textfield.props.disabled] -type = "bool" - -[elements.textfield.events.change] -kind = "input" -carries = { value = "text" } - -[elements.textfield.events.submit] -kind = "input" - -[elements.region] -class = "interactive" -children = "one" - -[elements.region.props.label] -type = "text" -required = true - -[elements.region.props.supplementary] -type = "bool" - -[elements.region.events.activate] -kind = "input" - -[elements.region.events.activate-double] -kind = "input" diff --git a/examples/instagram/client/components/bottom-nav.examples.uhura b/examples/instagram/client/components/bottom-nav.examples.uhura deleted file mode 100644 index ad03880..0000000 --- a/examples/instagram/client/components/bottom-nav.examples.uhura +++ /dev/null @@ -1,21 +0,0 @@ -use fixture standard - -example feed-active default { - props { current = "feed" } -} - -example profile-active { - props { current = "profile" } -} - -example create-active { - props { current = "create" } -} - -example search-active { - props { current = "search" } -} - -example reels-active { - props { current = "reels" } -} diff --git a/examples/instagram/client/components/bottom-nav.uhura b/examples/instagram/client/components/bottom-nav.uhura deleted file mode 100644 index 8d34ff7..0000000 --- a/examples/instagram/client/components/bottom-nav.uhura +++ /dev/null @@ -1,45 +0,0 @@ -component bottom-nav - -props { - current: text -} - -emits { - tab-selected(section: text) -} - - - Instagram - - - - - - - - - - diff --git a/examples/instagram/client/components/comment-row.examples.uhura b/examples/instagram/client/components/comment-row.examples.uhura deleted file mode 100644 index c29ba30..0000000 --- a/examples/instagram/client/components/comment-row.examples.uhura +++ /dev/null @@ -1,21 +0,0 @@ -use fixture standard - -example settled default { - props { - avatar = fixture.avatars.kenji - username = "kenji.rides" - body = "That copper red is unreal. What cone are you firing to?" - time-label = "1h" - pending = false - } -} - -example pending { - props { - avatar = fixture.avatars.mira - username = "mira.santos" - body = "Saving this palette for my kitchen reno — stunning work!" - time-label = "Posting…" - pending = true - } -} diff --git a/examples/instagram/client/components/comment-row.uhura b/examples/instagram/client/components/comment-row.uhura deleted file mode 100644 index 5294ea7..0000000 --- a/examples/instagram/client/components/comment-row.uhura +++ /dev/null @@ -1,28 +0,0 @@ -component comment-row - -use port comments { type image-ref } - -props { - avatar: image-ref - username: text - body: text - time-label: text - pending: bool -} - - - {avatar.alt} - - {username ++ " · " ++ time-label} - {body} - - - - diff --git a/examples/instagram/client/components/connection-row.examples.uhura b/examples/instagram/client/components/connection-row.examples.uhura deleted file mode 100644 index d6c6e27..0000000 --- a/examples/instagram/client/components/connection-row.examples.uhura +++ /dev/null @@ -1,25 +0,0 @@ -use fixture standard - -example following default { - props { - person = fixture.connections.lena - viewer = "user-mira" - pending = false - } -} - -example follow-action { - props { - person = fixture.connections.nils - viewer = "user-mira" - pending = false - } -} - -example pending { - props { - person = fixture.connections.nils - viewer = "user-mira" - pending = true - } -} diff --git a/examples/instagram/client/components/connection-row.uhura b/examples/instagram/client/components/connection-row.uhura deleted file mode 100644 index 2c01694..0000000 --- a/examples/instagram/client/components/connection-row.uhura +++ /dev/null @@ -1,39 +0,0 @@ -component connection-row - -use port profile { type connection } - -props { - person: connection - viewer: id - pending: bool -} - -emits { - profile-tapped(target: id) - follow-toggled(target: id, now-following: bool) -} - - - - - {person.user.avatar.alt} - - {person.user.username} - {person.user.display-name} - - - - {#if person.user.id != viewer} - - {/if} - - - diff --git a/examples/instagram/client/components/notice-bar.examples.uhura b/examples/instagram/client/components/notice-bar.examples.uhura deleted file mode 100644 index 682b0d1..0000000 --- a/examples/instagram/client/components/notice-bar.examples.uhura +++ /dev/null @@ -1,5 +0,0 @@ -use fixture standard - -example refusal default { - props { text = "Couldn't like this post. Try again." } -} diff --git a/examples/instagram/client/components/notice-bar.uhura b/examples/instagram/client/components/notice-bar.uhura deleted file mode 100644 index ca29deb..0000000 --- a/examples/instagram/client/components/notice-bar.uhura +++ /dev/null @@ -1,21 +0,0 @@ -component notice-bar - -props { - text: text -} - -emits { - dismissed() -} - - - {text} - - - - diff --git a/examples/instagram/client/components/post-card.examples.uhura b/examples/instagram/client/components/post-card.examples.uhura deleted file mode 100644 index 25e1043..0000000 --- a/examples/instagram/client/components/post-card.examples.uhura +++ /dev/null @@ -1,61 +0,0 @@ -//! Design examples for the post-card component. -use fixture standard - -/// The canonical image-post example. -example image-post default { - props { - post = fixture.posts.lena-glaze - liked = false - like-pending = false - saved = false - save-pending = false - show-open = true - } -} - -example carousel-liked { - props { - post = fixture.posts.marco-baja - liked = true - like-pending = false - saved = true - save-pending = false - show-open = true - } -} - -example video-post { - props { - post = fixture.posts.nils-aurora - liked = false - like-pending = false - saved = true - save-pending = false - show-open = true - } - note "native video with a fixture MP4, poster, accessible label, and controls" -} - -example like-pending { - props { - post = fixture.posts.lena-glaze - liked = true - like-pending = true - saved = false - save-pending = false - show-open = true - } - note "busy heart during the optimistic window" -} - -example save-pending { - props { - post = fixture.posts.lena-glaze - liked = false - like-pending = false - saved = true - save-pending = true - show-open = true - } - note "optimistic bookmark while the private saved-library edge settles" -} diff --git a/examples/instagram/client/components/post-card.uhura b/examples/instagram/client/components/post-card.uhura deleted file mode 100644 index eb8a1a0..0000000 --- a/examples/instagram/client/components/post-card.uhura +++ /dev/null @@ -1,110 +0,0 @@ -//! Shared post presentation for the Instagram example. -/// Presents one post and its primary interactions. -component post-card - -use port feed { type post-summary } - -props { - /// The post projection rendered by this card. - post: post-summary - liked: bool - like-pending: bool - saved: bool - save-pending: bool - show-open: bool -} - -emits { - like-toggled(post: id, now-liked: bool) - save-toggled(post: id, now-saved: bool) - comments-requested(post: id) - author-tapped(user: id) - post-tapped(post: id) -} - - - - - - {post.author.avatar.alt} - {post.author.username} - - - - {#match post.media} - {:when image m} - - {m.image.alt} - - {:when carousel c} - - - {#each c.slides as s (s.id)} - {s.alt} - {/each} - - - {:when video v} - - - diff --git a/examples/instagram/client/components/profile-header.examples.uhura b/examples/instagram/client/components/profile-header.examples.uhura deleted file mode 100644 index b31fc7e..0000000 --- a/examples/instagram/client/components/profile-header.examples.uhura +++ /dev/null @@ -1,41 +0,0 @@ -use fixture standard - -example lena default { - props { - user = fixture.users.lena - bio = "Ceramics and slow mornings. Small-batch studio work from Portland." - is-self = false - viewer-follows = true - relationship-pending = false - post-count = 10 - follower-count = 8 - following-count = 5 - } -} - -example self { - props { - user = fixture.users.mira - bio = "Food and travel photographer in Lisbon. Usually awake before the trams." - is-self = true - viewer-follows = false - relationship-pending = false - post-count = 6 - follower-count = 4 - following-count = 6 - } - note "the current actor gets a New post action, never a self-follow action" -} - -example follow-pending { - props { - user = fixture.users.nils - bio = "Night skies and northern water, filmed around Tromsø." - is-self = false - viewer-follows = true - relationship-pending = true - post-count = 1 - follower-count = 2 - following-count = 3 - } -} diff --git a/examples/instagram/client/components/profile-header.uhura b/examples/instagram/client/components/profile-header.uhura deleted file mode 100644 index 3498156..0000000 --- a/examples/instagram/client/components/profile-header.uhura +++ /dev/null @@ -1,72 +0,0 @@ -component profile-header - -use port profile { type user-ref } - -props { - user: user-ref - bio: text - is-self: bool - viewer-follows: bool - relationship-pending: bool - post-count: int - follower-count: int - following-count: int -} - -emits { - posts-tapped() - followers-tapped(target: id) - following-tapped(target: id) - follow-toggled(target: id, now-following: bool) - create-tapped() -} - - - - {user.avatar.alt} - - - - - - - {user.display-name} - {"@" ++ user.username} - {bio} - - - {#if is-self} - - {:else} - - {/if} - - - - diff --git a/examples/instagram/client/components/reel-card.examples.uhura b/examples/instagram/client/components/reel-card.examples.uhura deleted file mode 100644 index a6a3b3c..0000000 --- a/examples/instagram/client/components/reel-card.examples.uhura +++ /dev/null @@ -1,32 +0,0 @@ -use fixture standard - -example aurora default { - props { - post = fixture.posts.nils-aurora - liked = false - like-pending = false - saved = true - save-pending = false - } - note "a real vertical player surface backed by the seeded aurora MP4" -} - -example court { - props { - post = fixture.posts.theo-court - liked = false - like-pending = false - saved = false - save-pending = false - } -} - -example save-pending { - props { - post = fixture.posts.theo-court - liked = false - like-pending = false - saved = true - save-pending = true - } -} diff --git a/examples/instagram/client/components/reel-card.uhura b/examples/instagram/client/components/reel-card.uhura deleted file mode 100644 index fbd15f0..0000000 --- a/examples/instagram/client/components/reel-card.uhura +++ /dev/null @@ -1,84 +0,0 @@ -component reel-card - -use port feed { type post-summary } - -props { - post: post-summary - liked: bool - like-pending: bool - saved: bool - save-pending: bool -} - -emits { - like-toggled(post: id, now-liked: bool) - save-toggled(post: id, now-saved: bool) - comments-requested(post: id) - author-tapped(user: id) - post-tapped(post: id) -} - - - - {#match post.media} - {:when video v} - - - - - - - {post.author.avatar.alt} - {post.author.username} - - - {post.caption} - - - - - - - - - - - diff --git a/examples/instagram/client/components/stories-tray.examples.uhura b/examples/instagram/client/components/stories-tray.examples.uhura deleted file mode 100644 index efc188d..0000000 --- a/examples/instagram/client/components/stories-tray.examples.uhura +++ /dev/null @@ -1,5 +0,0 @@ -use fixture standard - -example tray default { - props { stories = fixture.feed.stories } -} diff --git a/examples/instagram/client/components/stories-tray.uhura b/examples/instagram/client/components/stories-tray.uhura deleted file mode 100644 index 2d2c4d7..0000000 --- a/examples/instagram/client/components/stories-tray.uhura +++ /dev/null @@ -1,37 +0,0 @@ -component stories-tray - -use port feed { type story-ring } - -props { - stories: list[story-ring] -} - -emits { - story-tapped(story: id) -} - - - - {#each stories as story (story.id)} - - - - {story.user.avatar.alt} - {if story.is-self then "Your story" else story.user.username} - - - - {/each} - - - - diff --git a/examples/instagram/client/evidence.uhura b/examples/instagram/client/evidence.uhura new file mode 100644 index 0000000..53a900f --- /dev/null +++ b/examples/instagram/client/evidence.uhura @@ -0,0 +1,1352 @@ +use crate::instagram::{ + Authority, + DEMO_APPENDED, + DEMO_EMPTY, + DEMO_EMPTY_EXPLORE, + DEMO_EXHAUSTED, + DEMO_STANDARD, + INSTAGRAM_ROUTES, + Instagram, + Location, + Mutation, + POST_AYLA_FERRY, + POST_LENA_BOWLS, + POST_LENA_GLAZE, + POST_MARCO_BAJA, + POST_NILS_AURORA, + POST_THEO_COURT, + Post, + Profile, + ProfileTab, + RequestId, + Settlement, + STORY_LENA, + STORY_LENA_GLAZES, + STORY_LENA_STUDIO, + STORY_MIRA_TRAM, + STORY_PRIYA, + USER_LENA, + USER_MIRA, + USER_NILS, +}; +use crate::ui::{ + BottomNav, + CommentRow, + CommentsSheet, + ConnectionRow, + CreatePage, + FeedPage, + FollowersPage, + FollowingPage, + NoticeBar, + PostCard, + PostPage, + ProfileHeader, + ProfilePage, + ReelCard, + ReelsPage, + SearchPage, + StoriesTray, + StoryPage, +}; +use uhura::observation::Observation; +use uhura::ports::RequestPort; +use uhura::web_router::Router; + + +scenario bootstrap_loading for Instagram { + bind router = Router.fixture(INSTAGRAM_ROUTES) + bind authority = Observation.fixture() + bind mutations = RequestPort.fixture() + + start + deliver router.changed(Feed) + expect Accepted commands [] + pin frame +} + +checkpoint loading_base = bootstrap_loading::frame; + +scenario bootstrap_ready from loading_base { + expect restore commands [] + deliver authority.observed(Ready(DEMO_STANDARD)) + expect Accepted commands [] + pin frame +} + +checkpoint ready_base = bootstrap_ready::frame; + + +// Pages: Create + +scenario create_loading_scenario from loading_base { + deliver router.changed(Create) + expect Accepted commands [] + pin frame +} + +scenario create_empty_scenario from ready_base { + deliver router.changed(Create) + expect Accepted commands [] + pin frame +} + +scenario create_choosing_scenario + from create_empty_scenario::frame +{ + send ChooseImage + expect Accepted commands [ + mutations.request(RequestId(1), ChooseImage), + ] + pin frame +} + +scenario create_uploaded_scenario from ready_base { + deliver router.changed(Create) + expect Accepted commands [] + send ChooseImage + expect Accepted commands [ + mutations.request(RequestId(1), ChooseImage), + ] + deliver mutations.settled( + RequestId(1), + ImageReady( + "object-mira-draft", + "media-ayla-ferry", + "lisbon-ferry.webp", + ), + ) + expect Accepted commands [] + pin frame +} + +scenario create_composed_scenario + from create_uploaded_scenario::frame +{ + send CaptionChanged("Last light over the Tagus.") + expect Accepted commands [] + send AltChanged( + "Ferry wake glowing orange beneath the Lisbon skyline", + ) + expect Accepted commands [] + pin frame +} + +scenario create_publishing_scenario + from create_composed_scenario::frame +{ + send PublishImage + expect Accepted commands [ + mutations.request( + RequestId(2), + PublishImage( + "object-mira-draft", + "Last light over the Tagus.", + "Ferry wake glowing orange beneath the Lisbon skyline", + ), + ), + ] + pin frame +} + +scenario create_publish_refused_scenario + from create_publishing_scenario::frame +{ + deliver mutations.settled( + RequestId(2), + Refused("image-not-ready"), + ) + expect Accepted commands [] + pin frame +} + + +// Pages: Feed + +scenario feed_loading_scenario from loading_base { + pin frame +} + +scenario feed_first_page_scenario from ready_base { + pin frame +} + +scenario feed_like_pending_scenario + from feed_first_page_scenario::frame +{ + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario feed_like_refused_scenario + from feed_like_pending_scenario::frame +{ + deliver mutations.settled( + RequestId(1), + Refused("network unavailable"), + ) + expect Accepted commands [] + pin frame +} + +scenario feed_save_pending_scenario + from feed_first_page_scenario::frame +{ + send ToggleSave(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario feed_unsave_pending_scenario + from feed_first_page_scenario::frame +{ + send ToggleSave(POST_MARCO_BAJA, false) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_MARCO_BAJA, false), + ), + ] + pin frame +} + +scenario feed_comments_open_scenario + from feed_first_page_scenario::frame +{ + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + pin frame +} + +scenario feed_load_pending_scenario + from feed_first_page_scenario::frame +{ + send FeedNearEnd + expect Accepted commands [ + mutations.request(RequestId(1), LoadMore), + ] + pin frame +} + +scenario feed_load_failed_scenario + from feed_load_pending_scenario::frame +{ + deliver mutations.settled( + RequestId(1), + Refused("unreachable"), + ) + expect Accepted commands [] + pin frame +} + +scenario feed_appended_scenario + from feed_first_page_scenario::frame +{ + send FeedNearEnd + expect Accepted commands [ + mutations.request(RequestId(1), LoadMore), + ] + deliver authority.observed(Ready(DEMO_APPENDED)) + expect Accepted commands [] + deliver mutations.settled(RequestId(1), Accepted) + expect Accepted commands [] + pin frame +} + +scenario feed_exhausted_scenario from ready_base { + deliver authority.observed(Ready(DEMO_EXHAUSTED)) + expect Accepted commands [] + pin frame +} + +scenario feed_empty_scenario from ready_base { + deliver authority.observed(Ready(DEMO_EMPTY)) + expect Accepted commands [] + pin frame +} + +scenario feed_failed_scenario from ready_base { + deliver authority.observed(Failed("unreachable")) + expect Accepted commands [] + pin frame +} + + +// Pages: Post + +scenario post_loading_scenario from loading_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + pin frame +} + +scenario post_lena_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + pin frame +} + +scenario post_profile_history_scenario from ready_base { + deliver router.changed(Post(POST_LENA_BOWLS)) + expect Accepted commands [] + pin frame +} + +scenario post_like_pending_scenario + from post_lena_scenario::frame +{ + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario post_save_pending_scenario + from post_lena_scenario::frame +{ + send ToggleSave(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario post_comments_open_scenario + from post_lena_scenario::frame +{ + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + pin frame +} + + +// Pages: Followers + +scenario followers_loading_scenario from loading_base { + deliver router.changed(Followers(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario followers_lena_scenario from ready_base { + deliver router.changed(Followers(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario followers_follow_pending_scenario + from followers_lena_scenario::frame +{ + send ToggleFollow(USER_NILS, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetFollow(USER_NILS, true), + ), + ] + pin frame +} + +scenario followers_mira_scenario from ready_base { + deliver router.changed(Followers(USER_MIRA)) + expect Accepted commands [] + pin frame +} + + +// Pages: Following + +scenario following_loading_scenario from loading_base { + deliver router.changed(Following(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario following_lena_scenario from ready_base { + deliver router.changed(Following(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario following_mira_scenario from ready_base { + deliver router.changed(Following(USER_MIRA)) + expect Accepted commands [] + pin frame +} + + +// Pages: Profile + +scenario profile_loading_scenario from loading_base { + deliver router.changed(Profile(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario profile_lena_posts_scenario from ready_base { + deliver router.changed(Profile(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario profile_lena_tagged_scenario + from profile_lena_posts_scenario::frame +{ + send SelectProfileTab(Tagged) + expect Accepted commands [] + pin frame +} + +scenario profile_self_scenario from ready_base { + deliver router.changed(Profile(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario profile_self_tagged_scenario + from profile_self_scenario::frame +{ + send SelectProfileTab(Tagged) + expect Accepted commands [] + pin frame +} + +scenario profile_self_reels_scenario + from profile_self_scenario::frame +{ + send SelectProfileTab(Reels) + expect Accepted commands [] + pin frame +} + +scenario profile_self_saved_scenario + from profile_self_scenario::frame +{ + send SelectProfileTab(Saved) + expect Accepted commands [] + pin frame +} + +scenario profile_nils_posts_scenario from ready_base { + deliver router.changed(Profile(USER_NILS)) + expect Accepted commands [] + pin frame +} + +scenario profile_nils_reels_scenario + from profile_nils_posts_scenario::frame +{ + send SelectProfileTab(Reels) + expect Accepted commands [] + pin frame +} + +scenario profile_nils_tagged_empty_scenario + from profile_nils_posts_scenario::frame +{ + send SelectProfileTab(Tagged) + expect Accepted commands [] + pin frame +} + + +// Pages: Reels + +scenario reels_loading_scenario from loading_base { + deliver router.changed(Reels) + expect Accepted commands [] + pin frame +} + +scenario reels_videos_scenario from ready_base { + deliver router.changed(Reels) + expect Accepted commands [] + pin frame +} + +scenario reels_like_pending_scenario + from reels_videos_scenario::frame +{ + send ToggleLike(POST_NILS_AURORA, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_NILS_AURORA, true), + ), + ] + pin frame +} + +scenario reels_save_pending_scenario + from reels_videos_scenario::frame +{ + send ToggleSave(POST_THEO_COURT, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_THEO_COURT, true), + ), + ] + pin frame +} + +scenario reels_unsave_pending_scenario + from reels_videos_scenario::frame +{ + send ToggleSave(POST_NILS_AURORA, false) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_NILS_AURORA, false), + ), + ] + pin frame +} + + +// Pages: Search + +scenario search_loading_scenario from loading_base { + deliver router.changed(Search) + expect Accepted commands [] + pin frame +} + +scenario search_explore_scenario from ready_base { + deliver router.changed(Search) + expect Accepted commands [] + pin frame +} + +scenario search_searching_scenario + from search_explore_scenario::frame +{ + send SearchChanged("nils") + expect Accepted commands [] + send SubmitSearch + expect Accepted commands [ + mutations.request( + RequestId(1), + SearchPeople("nils"), + ), + ] + pin frame +} + +scenario search_nils_results_scenario + from search_explore_scenario::frame +{ + send SearchChanged("nils") + expect Accepted commands [] + send SubmitSearch + expect Accepted commands [ + mutations.request( + RequestId(1), + SearchPeople("nils"), + ), + ] + deliver mutations.settled(RequestId(1), Accepted) + expect Accepted commands [] + pin frame +} + +scenario search_no_results_scenario + from search_explore_scenario::frame +{ + send SearchChanged("no-such-person") + expect Accepted commands [] + send SubmitSearch + expect Accepted commands [ + mutations.request( + RequestId(1), + SearchPeople("no-such-person"), + ), + ] + deliver mutations.settled(RequestId(1), Accepted) + expect Accepted commands [] + pin frame +} + +scenario search_empty_explore_scenario from ready_base { + deliver router.changed(Search) + expect Accepted commands [] + deliver authority.observed(Ready(DEMO_EMPTY_EXPLORE)) + expect Accepted commands [] + pin frame +} + + +// Pages: Story + +scenario story_loading_scenario from loading_base { + deliver router.changed(Story(STORY_LENA)) + expect Accepted commands [] + pin frame +} + +scenario story_unseen_scenario from ready_base { + deliver router.changed(Story(STORY_LENA)) + expect Accepted commands [] + pin frame +} + +scenario story_lena_middle_scenario from ready_base { + deliver router.changed(Story(STORY_LENA_GLAZES)) + expect Accepted commands [] + pin frame +} + +scenario story_lena_last_scenario from ready_base { + deliver router.changed(Story(STORY_LENA_STUDIO)) + expect Accepted commands [] + pin frame +} + +scenario story_self_middle_seen_scenario from ready_base { + deliver router.changed(Story(STORY_MIRA_TRAM)) + expect Accepted commands [] + pin frame +} + +scenario story_seen_scenario from ready_base { + deliver router.changed(Story(STORY_PRIYA)) + expect Accepted commands [] + pin frame +} + +scenario story_marking_seen_scenario + from story_unseen_scenario::frame +{ + send MarkStorySeen(STORY_LENA) + expect Accepted commands [ + mutations.request( + RequestId(1), + MarkStory(STORY_LENA), + ), + ] + pin frame +} + + +// Components: bottom navigation + +scenario bottom_nav_feed_scenario from ready_base { + pin frame +} + +scenario bottom_nav_profile_scenario from ready_base { + deliver router.changed(Profile(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario bottom_nav_create_scenario from ready_base { + deliver router.changed(Create) + expect Accepted commands [] + pin frame +} + +scenario bottom_nav_search_scenario from ready_base { + deliver router.changed(Search) + expect Accepted commands [] + pin frame +} + +scenario bottom_nav_reels_scenario from ready_base { + deliver router.changed(Reels) + expect Accepted commands [] + pin frame +} + + +// Components: comment row + +scenario comment_row_settled_scenario from ready_base { + pin frame +} + +scenario comment_row_pending_scenario from ready_base { + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + send CommentChanged( + "Saving this palette for my kitchen reno — stunning work!", + ) + expect Accepted commands [] + send SubmitComment + expect Accepted commands [ + mutations.request( + RequestId(1), + AddComment( + POST_LENA_GLAZE, + "Saving this palette for my kitchen reno — stunning work!", + ), + ), + ] + pin frame +} + + +// Components: connection row + +scenario connection_following_scenario from ready_base { + deliver router.changed(Following(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario connection_follow_action_scenario from ready_base { + deliver router.changed(Followers(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario connection_pending_scenario from ready_base { + deliver router.changed(Followers(USER_MIRA)) + expect Accepted commands [] + send ToggleFollow(USER_NILS, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetFollow(USER_NILS, true), + ), + ] + pin frame +} + + +// Components: notice bar + +scenario notice_refusal_scenario from ready_base { + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + deliver mutations.settled( + RequestId(1), + Refused("network unavailable"), + ) + expect Accepted commands [] + pin frame +} + + +// Components: Post card + +scenario post_card_image_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + pin frame +} + +scenario post_card_carousel_scenario from ready_base { + deliver router.changed(Post(POST_MARCO_BAJA)) + expect Accepted commands [] + pin frame +} + +scenario post_card_video_scenario from ready_base { + deliver router.changed(Post(POST_NILS_AURORA)) + expect Accepted commands [] + pin frame +} + +scenario post_card_like_pending_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario post_card_save_pending_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + send ToggleSave(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + + +// Components: Profile header + +scenario profile_header_lena_scenario from ready_base { + deliver router.changed(Profile(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario profile_header_self_scenario from ready_base { + deliver router.changed(Profile(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario profile_header_follow_pending_scenario from ready_base { + deliver router.changed(Profile(USER_NILS)) + expect Accepted commands [] + send ToggleFollow(USER_NILS, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetFollow(USER_NILS, true), + ), + ] + pin frame +} + + +// Components: reel card + +scenario reel_card_aurora_scenario from ready_base { + deliver router.changed(Post(POST_NILS_AURORA)) + expect Accepted commands [] + pin frame +} + +scenario reel_card_court_scenario from ready_base { + deliver router.changed(Post(POST_THEO_COURT)) + expect Accepted commands [] + pin frame +} + +scenario reel_card_save_pending_scenario from ready_base { + deliver router.changed(Post(POST_THEO_COURT)) + expect Accepted commands [] + send ToggleSave(POST_THEO_COURT, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_THEO_COURT, true), + ), + ] + pin frame +} + + +// Components: stories tray + +scenario stories_tray_scenario from ready_base { + pin frame +} + + +// Surface: comments sheet + +scenario comments_populated_scenario from ready_base { + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + pin frame +} + +scenario comments_composing_scenario + from comments_populated_scenario::frame +{ + send CommentChanged( + "Saving this palette for my kitchen reno", + ) + expect Accepted commands [] + pin frame +} + +scenario comments_pending_append_scenario + from comments_composing_scenario::frame +{ + send SubmitComment + expect Accepted commands [ + mutations.request( + RequestId(1), + AddComment( + POST_LENA_GLAZE, + "Saving this palette for my kitchen reno", + ), + ), + ] + pin frame +} + +scenario comments_empty_scenario from ready_base { + send OpenComments(POST_AYLA_FERRY) + expect Accepted commands [] + pin frame +} + +scenario comments_empty_composing_scenario + from comments_empty_scenario::frame +{ + send CommentChanged("First comment") + expect Accepted commands [] + pin frame +} + +scenario comments_empty_pending_scenario + from comments_empty_composing_scenario::frame +{ + send SubmitComment + expect Accepted commands [ + mutations.request( + RequestId(1), + AddComment(POST_AYLA_FERRY, "First comment"), + ), + ] + pin frame +} + +scenario comments_rejected_scenario + from comments_empty_pending_scenario::frame +{ + deliver mutations.settled( + RequestId(1), + Refused("comment_body_invalid"), + ) + expect Accepted commands [] + pin frame +} + + +// The catalog is presentation-targeted. Every alias names one checked +// snapshot; `default` is unique within its presentation. + +example create_loading + for CreatePage as page + = create_loading_scenario::frame; + +example create_empty + for CreatePage as page default + = create_empty_scenario::frame; + +example create_choosing + for CreatePage as page + note "the platform picker/upload is in flight; no bytes enter Core" + = create_choosing_scenario::frame; + +example create_uploaded + for CreatePage as page + = create_uploaded_scenario::frame; + +example create_composed + for CreatePage as page + = create_composed_scenario::frame; + +example create_publishing + for CreatePage as page + note "publish carries only the storage object id and authored text" + = create_publishing_scenario::frame; + +example create_publish_refused + for CreatePage as page + = create_publish_refused_scenario::frame; + + +example feed_loading + for FeedPage as page + note "cold start — nothing delivered yet" + = feed_loading_scenario::frame; + +example feed_first_page + for FeedPage as page default + = feed_first_page_scenario::frame; + +example feed_like_pending + for FeedPage as page + note "optimistic heart + count while the like request is in flight" + = feed_like_pending_scenario::frame; + +example feed_like_refused + for FeedPage as page + note "transport unavailable — rollback, notice explains" + = feed_like_refused_scenario::frame; + +example feed_save_pending + for FeedPage as page + note "optimistic bookmark while the save request is in flight" + = feed_save_pending_scenario::frame; + +example feed_unsave_pending + for FeedPage as page + note "Marco's post starts saved because the seed contains Mira's save edge" + = feed_unsave_pending_scenario::frame; + +example feed_comments_open + for FeedPage as page + note "the sheet mounts because the machine owns its semantic lifetime" + = feed_comments_open_scenario::frame; + +example feed_load_pending + for FeedPage as page + note "footer spinner; the machine suppresses duplicate pagination" + = feed_load_pending_scenario::frame; + +example feed_load_failed + for FeedPage as page + = feed_load_failed_scenario::frame; + +example feed_appended + for FeedPage as page + note "all six followed-author posts are loaded, so has-more is false" + = feed_appended_scenario::frame; + +example feed_exhausted + for FeedPage as page + note "has-more false — end cap; pinned state" + = feed_exhausted_scenario::frame; + +example feed_empty + for FeedPage as page + = feed_empty_scenario::frame; + +example feed_failed + for FeedPage as page + note "provider reported an authoritative source failure" + = feed_failed_scenario::frame; + + +example post_loading + for PostPage as page + = post_loading_scenario::frame; + +example post_lena + for PostPage as page default + = post_lena_scenario::frame; + +example post_profile_history + for PostPage as page + note "a profile-grid tile opens a genuine post, not a decorative thumbnail" + = post_profile_history_scenario::frame; + +example post_like_pending + for PostPage as page + = post_like_pending_scenario::frame; + +example post_save_pending + for PostPage as page + = post_save_pending_scenario::frame; + +example post_comments_open + for PostPage as page + = post_comments_open_scenario::frame; + + +example followers_loading + for FollowersPage as page + = followers_loading_scenario::frame; + +example followers_lena + for FollowersPage as page default + = followers_lena_scenario::frame; + +example followers_follow_pending + for FollowersPage as page + = followers_follow_pending_scenario::frame; + +example followers_mira + for FollowersPage as page + = followers_mira_scenario::frame; + + +example following_loading + for FollowingPage as page + = following_loading_scenario::frame; + +example following_lena + for FollowingPage as page default + = following_lena_scenario::frame; + +example following_mira + for FollowingPage as page + = following_mira_scenario::frame; + + +example profile_loading + for ProfilePage as page + = profile_loading_scenario::frame; + +example profile_lena_posts + for ProfilePage as page default + = profile_lena_posts_scenario::frame; + +example profile_lena_tagged + for ProfilePage as page + note "the tile is Priya's real sourdough post from the seeded tag edge" + = profile_lena_tagged_scenario::frame; + +example profile_self + for ProfilePage as page + = profile_self_scenario::frame; + +example profile_self_tagged + for ProfilePage as page + note "Mira's tagged grid carries Marco's real Baja post id" + = profile_self_tagged_scenario::frame; + +example profile_self_reels + for ProfilePage as page + note "Reels is a filtered view of Mira's genuine video posts" + = profile_self_reels_scenario::frame; + +example profile_self_saved + for ProfilePage as page + note "Saved is private to Mira and mirrors her two seeded save edges" + = profile_self_saved_scenario::frame; + +example profile_nils_posts + for ProfilePage as page + = profile_nils_posts_scenario::frame; + +example profile_nils_reels + for ProfilePage as page + = profile_nils_reels_scenario::frame; + +example profile_nils_tagged_empty + for ProfilePage as page + = profile_nils_tagged_empty_scenario::frame; + + +example reels_loading + for ReelsPage as page + = reels_loading_scenario::frame; + +example reels_videos + for ReelsPage as page default + note "three real video posts backed by local fixture MP4s" + = reels_videos_scenario::frame; + +example reels_like_pending + for ReelsPage as page + = reels_like_pending_scenario::frame; + +example reels_save_pending + for ReelsPage as page + = reels_save_pending_scenario::frame; + +example reels_unsave_pending + for ReelsPage as page + = reels_unsave_pending_scenario::frame; + + +example search_loading + for SearchPage as page + = search_loading_scenario::frame; + +example search_explore + for SearchPage as page default + note "Explore combines real people and clickable post thumbnails" + = search_explore_scenario::frame; + +example search_searching + for SearchPage as page + = search_searching_scenario::frame; + +example search_nils_results + for SearchPage as page + = search_nils_results_scenario::frame; + +example search_no_results + for SearchPage as page + = search_no_results_scenario::frame; + +example search_empty_explore + for SearchPage as page + = search_empty_explore_scenario::frame; + + +example story_loading + for StoryPage as page + = story_loading_scenario::frame; + +example story_unseen + for StoryPage as page default + note "first of three: no previous target, next target present, all segments unseen" + = story_unseen_scenario::frame; + +example story_lena_middle + for StoryPage as page + note "middle frame exposes both previous and next hit zones" + = story_lena_middle_scenario::frame; + +example story_lena_last + for StoryPage as page + note "last frame replaces Next with the close affordance" + = story_lena_last_scenario::frame; + +example story_self_middle_seen + for StoryPage as page + note "Mira's own three-frame sequence is fully viewed" + = story_self_middle_seen_scenario::frame; + +example story_seen + for StoryPage as page + note "a single-frame seen story has one progress segment and closes on advance" + = story_seen_scenario::frame; + +example story_marking_seen + for StoryPage as page + = story_marking_seen_scenario::frame; + + +example bottom_nav_feed_active + for BottomNav as component default + = bottom_nav_feed_scenario::frame; + +example bottom_nav_profile_active + for BottomNav as component + = bottom_nav_profile_scenario::frame; + +example bottom_nav_create_active + for BottomNav as component + = bottom_nav_create_scenario::frame; + +example bottom_nav_search_active + for BottomNav as component + = bottom_nav_search_scenario::frame; + +example bottom_nav_reels_active + for BottomNav as component + = bottom_nav_reels_scenario::frame; + + +example comment_row_settled + for CommentRow as component default + = comment_row_settled_scenario::frame; + +example comment_row_pending + for CommentRow as component + = comment_row_pending_scenario::frame; + + +example connection_row_following + for ConnectionRow as component default + = connection_following_scenario::frame; + +example connection_row_follow_action + for ConnectionRow as component + = connection_follow_action_scenario::frame; + +example connection_row_pending + for ConnectionRow as component + = connection_pending_scenario::frame; + + +example notice_bar_refusal + for NoticeBar as component default + = notice_refusal_scenario::frame; + + +example post_card_image + for PostCard as component default + = post_card_image_scenario::frame; + +example post_card_carousel_liked + for PostCard as component + = post_card_carousel_scenario::frame; + +example post_card_video + for PostCard as component + note "native video with a fixture MP4, poster, accessible label, and controls" + = post_card_video_scenario::frame; + +example post_card_like_pending + for PostCard as component + note "busy heart during the optimistic window" + = post_card_like_pending_scenario::frame; + +example post_card_save_pending + for PostCard as component + note "optimistic bookmark while the private saved-library edge settles" + = post_card_save_pending_scenario::frame; + + +example profile_header_lena + for ProfileHeader as component default + = profile_header_lena_scenario::frame; + +example profile_header_self + for ProfileHeader as component + note "the current actor gets a New post action, never a self-follow action" + = profile_header_self_scenario::frame; + +example profile_header_follow_pending + for ProfileHeader as component + = profile_header_follow_pending_scenario::frame; + + +example reel_card_aurora + for ReelCard as component default + note "a real vertical player surface backed by the seeded aurora MP4" + = reel_card_aurora_scenario::frame; + +example reel_card_court + for ReelCard as component + = reel_card_court_scenario::frame; + +example reel_card_save_pending + for ReelCard as component + = reel_card_save_pending_scenario::frame; + + +example stories_tray + for StoriesTray as component default + = stories_tray_scenario::frame; + + +example comments_populated + for CommentsSheet as surface default + = comments_populated_scenario::frame; + +example comments_composing + for CommentsSheet as surface + note "composer mid-draft; post enables once non-empty" + = comments_composing_scenario::frame; + +example comments_pending_append + for CommentsSheet as surface + note "optimistic dimmed row until the outcome settles" + = comments_pending_append_scenario::frame; + +example comments_empty + for CommentsSheet as surface + = comments_empty_scenario::frame; + +example comments_empty_composing + for CommentsSheet as surface + = comments_empty_composing_scenario::frame; + +example comments_empty_pending + for CommentsSheet as surface + note "the optimistic row replaces the empty state and serializes submission" + = comments_empty_pending_scenario::frame; + +example comments_rejected + for CommentsSheet as surface + note "a refusal restores the submitted body for correction" + = comments_rejected_scenario::frame; diff --git a/examples/instagram/client/fixtures/assets/manifest.toml b/examples/instagram/client/fixtures/assets/manifest.toml index 7ce38e5..ca05887 100644 --- a/examples/instagram/client/fixtures/assets/manifest.toml +++ b/examples/instagram/client/fixtures/assets/manifest.toml @@ -7,7 +7,7 @@ # https://grida.co/library/license # # `cargo run -p uhura-cli --bin gen-assets -- examples/instagram/client` -# validates and preserves sourced files; it only renders legacy motif entries. +# validates and preserves sourced files; it only renders declared motif entries. [assets.avatar-mira] file = "avatar-mira.webp" diff --git a/examples/instagram/client/fixtures/scripts/comment-ok.toml b/examples/instagram/client/fixtures/scripts/comment-ok.toml deleted file mode 100644 index 72eb3b6..0000000 --- a/examples/instagram/client/fixtures/scripts/comment-ok.toml +++ /dev/null @@ -1,46 +0,0 @@ -# §11.4 steps 5–7: open comments, type Mira's comment, Post — a dimmed -# optimistic row swaps atomically for the authoritative comment (the reply -# echoes the typed body and mints the id, §9.5); closing the sheet -# restores focus to the comment button. CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.page-1" - -[[deliver]] -after-ticks = 3 -port = "comments" -projection = "for-post" -key = "post-lena-glaze" -slice = "comments.lena-glaze" - -[[reply]] -on = { command = "add-comment", where = { post = "post-lena-glaze" } } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "comments" -projection = "for-post" -key = { from = "payload.post" } -slice = "comments.lena-glaze-plus-mira" - -[[ui]] -at-tick = 2 -emit = "comments-requested" -where = { post = "post-lena-glaze" } - -[[ui]] -at-tick = 4 -emit = "composer-changed" -data = { value = "Saving this palette for my kitchen reno — stunning work!" } - -[[ui]] -at-tick = 5 -emit = "submit-requested" - -[[ui]] -at-tick = 7 -emit = "dismiss-requested" diff --git a/examples/instagram/client/fixtures/scripts/demo.toml b/examples/instagram/client/fixtures/scripts/demo.toml deleted file mode 100644 index a5b47f7..0000000 --- a/examples/instagram/client/fixtures/scripts/demo.toml +++ /dev/null @@ -1,150 +0,0 @@ -# The §11.4 walkthrough as one play-mode script (not goldened; the M4 gate -# smoke-runs it): settle the feed, like Lena's post, add Mira's comment, -# paginate, visit Lena's profile, come back. The pagination reply carries -# the liked page-1 — authority truth persists across appends. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.page-1" - -[[deliver]] -after-ticks = 1 -port = "create" -projection = "draft" -slice = "create.empty" - -[[deliver]] -after-ticks = 5 -port = "comments" -projection = "for-post" -key = "post-lena-glaze" -slice = "comments.lena-glaze" - -[[deliver]] -after-ticks = 13 -port = "profile" -projection = "profile" -key = "user-lena" -slice = "profiles.lena" - -[[reply]] -on = { command = "like-post", where = { post = "post-lena-glaze" } } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.page-1-liked" - -[[reply]] -on = { command = "add-comment", where = { post = "post-lena-glaze" } } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "comments" -projection = "for-post" -key = { from = "payload.post" } -slice = "comments.lena-glaze-plus-mira" - -# The feed carries comment-count — every carrier settles together (§9.4). -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.page-1-liked-commented" - -[[reply]] -on = { command = "load-next-page", where = { cursor = "cursor-page-2" } } -after-ticks = 2 -outcome = "ok" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.pages-1-2-liked-commented" - -# Hand-play create flow. File selection/upload remains a platform/provider -# concern in live play; this deterministic driver settles the same contract. -[[reply]] -on = { command = "choose-image" } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "create" -projection = "draft" -slice = "create.uploaded" - -[[reply]] -on = { command = "publish-image", where = { image = "object-mira-draft" } } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "create" -projection = "draft" -slice = "create.empty" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.page-1-created" - -[[ui]] -at-tick = 2 -emit = "like-toggled" -where = { post = "post-lena-glaze", now-liked = true } - -[[ui]] -at-tick = 4 -emit = "comments-requested" -where = { post = "post-lena-glaze" } - -[[ui]] -at-tick = 6 -emit = "composer-changed" -data = { value = "Saving this palette for my kitchen reno — stunning work!" } - -[[ui]] -at-tick = 7 -emit = "submit-requested" - -[[ui]] -at-tick = 9 -emit = "dismiss-requested" - -[[ui]] -at-tick = 10 -emit = "feed-near-end" - -[[ui]] -at-tick = 12 -emit = "author-tapped" -where = { user = "user-lena" } - -[[ui]] -at-tick = 14 -emit = "back-tapped" - -# ── hand-play additions (M5 live gate) ────────────────────────────────── -# The [[ui]] walkthrough above never triggers these; a human at the play -# shell does. One-shot, file-order — the closed world stays closed. - -# Walkthrough step 3: Marco's like survives one optimistic beat, then the -# provider is unavailable — rollback + notice bar. -[[reply]] -on = { command = "like-post", where = { post = "post-marco-baja" } } -after-ticks = 2 -outcome = "unavailable" -reason = "network unavailable" - -# Walkthrough step 11: the bottom tab visits the viewer's own profile. -[[deliver]] -after-ticks = 1 -port = "profile" -projection = "profile" -key = "user-mira" -slice = "profiles.mira" diff --git a/examples/instagram/client/fixtures/scripts/feed-empty.toml b/examples/instagram/client/fixtures/scripts/feed-empty.toml deleted file mode 100644 index 021ab52..0000000 --- a/examples/instagram/client/fixtures/scripts/feed-empty.toml +++ /dev/null @@ -1,13 +0,0 @@ -# A followed-nobody feed: the empty state renders. The current scroll owns a -# near-end observer even when empty, so the scripted observation proves the -# projection-truth guard emits no pagination command. CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.empty" - -[[ui]] -at-tick = 2 -emit = "feed-near-end" diff --git a/examples/instagram/client/fixtures/scripts/feed-failed.toml b/examples/instagram/client/fixtures/scripts/feed-failed.toml deleted file mode 100644 index 9a8b842..0000000 --- a/examples/instagram/client/fixtures/scripts/feed-failed.toml +++ /dev/null @@ -1,22 +0,0 @@ -# The provider reports the feed projection failed; retry reloads and the -# authority recovers. CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -failed = "unreachable" - -[[reply]] -on = { command = "reload" } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.page-1" - -[[ui]] -at-tick = 2 -emit = "retry-reload-tapped" diff --git a/examples/instagram/client/fixtures/scripts/like-ok.toml b/examples/instagram/client/fixtures/scripts/like-ok.toml deleted file mode 100644 index 417d03c..0000000 --- a/examples/instagram/client/fixtures/scripts/like-ok.toml +++ /dev/null @@ -1,23 +0,0 @@ -# §11.4 step 2: like Lena's post — optimistic beat, then the authority -# settles via a piggybacked update (flicker-free, §9.4). CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.page-1" - -[[reply]] -on = { command = "like-post", where = { post = "post-lena-glaze" } } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.page-1-liked" - -[[ui]] -at-tick = 2 -emit = "like-toggled" -where = { post = "post-lena-glaze", now-liked = true } diff --git a/examples/instagram/client/fixtures/scripts/like-refused.toml b/examples/instagram/client/fixtures/scripts/like-refused.toml deleted file mode 100644 index e0ae5ed..0000000 --- a/examples/instagram/client/fixtures/scripts/like-refused.toml +++ /dev/null @@ -1,24 +0,0 @@ -# §11.4 step 3: the authority is unavailable — heart and count roll back, the -# notice explains; after dismissing it the feed subtree is byte-identical -# to pre-like (the scoped invariant). CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.page-1" - -[[reply]] -on = { command = "like-post", where = { post = "post-lena-glaze" } } -after-ticks = 1 -outcome = "unavailable" -reason = "network unavailable" - -[[ui]] -at-tick = 2 -emit = "like-toggled" -where = { post = "post-lena-glaze", now-liked = true } - -[[ui]] -at-tick = 4 -emit = "notice-dismissed" diff --git a/examples/instagram/client/fixtures/scripts/paginate.toml b/examples/instagram/client/fixtures/scripts/paginate.toml deleted file mode 100644 index e62174f..0000000 --- a/examples/instagram/client/fixtures/scripts/paginate.toml +++ /dev/null @@ -1,27 +0,0 @@ -# §11.4 step 8: scroll to the bottom — exactly one load-next-page; the -# wiggle re-observation is guard-dropped (the guard IS the dedupe); the two -# remaining followed-author posts append with keys preserved. CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.page-1" - -[[reply]] -on = { command = "load-next-page", where = { cursor = "cursor-page-2" } } -after-ticks = 2 -outcome = "ok" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.pages-1-2" - -[[ui]] -at-tick = 2 -emit = "feed-near-end" - -[[ui]] -at-tick = 3 -emit = "feed-near-end" diff --git a/examples/instagram/client/fixtures/standard.toml b/examples/instagram/client/fixtures/standard.toml deleted file mode 100644 index 7aa140f..0000000 --- a/examples/instagram/client/fixtures/standard.toml +++ /dev/null @@ -1,1140 +0,0 @@ -# The standard fixture — the §11.2 cast as named, typed data slices. -# Slices are raw values typed at every binding site (L8 at use — an -# ill-typed slice is a link error where it binds). `"@."` strings -# splice another slice (resolved at load, cycles rejected), so a post is -# authored exactly once. No lorem ipsum. Relative-time labels mirror the -# provider's clock formatting; every integer count matches the concrete -# post, like, comment, or follow rows in Spock's seed. - -# ── boot (auto-bound by the examples resolver, §6.1) ──────────────────── - -[boot] -viewer = "@users.mira" - -# ── users (user-ref) ──────────────────────────────────────────────────── - -[users.mira] -id = "user-mira" -username = "mira.santos" -display-name = "Mira Santos" -avatar = "@avatars.mira" - -[users.lena] -id = "user-lena" -username = "lena.holt" -display-name = "Lena Holt" -avatar = "@avatars.lena" - -[users.marco] -id = "user-marco" -username = "marco.reyes" -display-name = "Marco Reyes" -avatar = "@avatars.marco" - -[users.nils] -id = "user-nils" -username = "nils.bergman" -display-name = "Nils Bergman" -avatar = "@avatars.nils" - -[users.priya] -id = "user-priya" -username = "priya.raman" -display-name = "Priya Raman" -avatar = "@avatars.priya" - -[users.ayla] -id = "user-ayla" -username = "ayla.demir" -display-name = "Ayla Demir" -avatar = "@avatars.ayla" - -[users.june] -id = "user-june" -username = "june.park" -display-name = "June Park" -avatar = "@avatars.june" - -[users.theo] -id = "user-theo" -username = "theo.okafor" -display-name = "Theo Okafor" -avatar = "@avatars.theo" - -[users.kenji] -id = "user-kenji" -username = "kenji.rides" -display-name = "Kenji Tanaka" -avatar = "@avatars.kenji" - -# ── avatars (image-ref) ───────────────────────────────────────────────── - -[avatars.mira] -src = "avatar-mira" -alt = "Mira Santos" - -[avatars.lena] -src = "avatar-lena" -alt = "Lena Holt" - -[avatars.marco] -src = "avatar-marco" -alt = "Marco Reyes" - -[avatars.nils] -src = "avatar-nils" -alt = "Nils Bergman" - -[avatars.priya] -src = "avatar-priya" -alt = "Priya Raman" - -[avatars.ayla] -src = "avatar-ayla" -alt = "Ayla Demir" - -[avatars.june] -src = "avatar-june" -alt = "June Park" - -[avatars.theo] -src = "avatar-theo" -alt = "Theo Okafor" - -[avatars.kenji] -src = "avatar-kenji" -alt = "Kenji Tanaka" - -# ── stories (story-ring) ──────────────────────────────────────────────── - -[stories.ring-mira] -id = "ring-mira" -user = "@users.mira" -has-unseen = false -is-self = true - -[stories.ring-lena] -id = "ring-lena" -user = "@users.lena" -has-unseen = true -is-self = false - -[stories.ring-marco] -id = "ring-marco" -user = "@users.marco" -has-unseen = true -is-self = false - -[stories.ring-priya] -id = "ring-priya" -user = "@users.priya" -has-unseen = false -is-self = false - -[stories.ring-june] -id = "ring-june" -user = "@users.june" -has-unseen = true -is-self = false - -[stories.ring-kenji] -id = "ring-kenji" -user = "@users.kenji" -has-unseen = false -is-self = false - -# Story details share the ring ids. Mira, Lena, and Marco each have the same -# three-frame sequence as the Spock seed; previous/next stay within an -# author's sequence, and progress reflects Mira's concrete story-view rows. -[story-details.mira] -id = "ring-mira" -author = "@users.mira" -image = { src = "thumb-mira-1", alt = "Pastéis de nata cooling on a marble counter" } -caption = "Breakfast before the first tram" -posted-label = "20m" -viewer-has-viewed = true -next = "ring-mira-tram" -progress = [ - { id = "ring-mira", is-current = true, is-viewed = true }, - { id = "ring-mira-tram", is-current = false, is-viewed = true }, - { id = "ring-mira-market", is-current = false, is-viewed = true }, -] - -[story-details.mira-tram] -id = "ring-mira-tram" -author = "@users.mira" -image = { src = "thumb-mira-2", alt = "Tram rails catching the first light in Lisbon" } -caption = "Then the city wakes" -posted-label = "8m" -viewer-has-viewed = true -previous = "ring-mira" -next = "ring-mira-market" -progress = [ - { id = "ring-mira", is-current = false, is-viewed = true }, - { id = "ring-mira-tram", is-current = true, is-viewed = true }, - { id = "ring-mira-market", is-current = false, is-viewed = true }, -] - -[story-details.mira-market] -id = "ring-mira-market" -author = "@users.mira" -image = { src = "thumb-mira-3", alt = "Crates of citrus stacked at the morning market" } -caption = "Saturday palette" -posted-label = "now" -viewer-has-viewed = true -previous = "ring-mira-tram" -progress = [ - { id = "ring-mira", is-current = false, is-viewed = true }, - { id = "ring-mira-tram", is-current = false, is-viewed = true }, - { id = "ring-mira-market", is-current = true, is-viewed = true }, -] - -[story-details.lena] -id = "ring-lena" -author = "@users.lena" -image = { src = "thumb-lena-7", alt = "Lena throwing a tall clay cylinder" } -caption = "One pull, no edits" -posted-label = "35m" -viewer-has-viewed = false -next = "ring-lena-glazes" -progress = [ - { id = "ring-lena", is-current = true, is-viewed = false }, - { id = "ring-lena-glazes", is-current = false, is-viewed = false }, - { id = "ring-lena-studio", is-current = false, is-viewed = false }, -] - -[story-details.lena-glazes] -id = "ring-lena-glazes" -author = "@users.lena" -image = { src = "thumb-lena-8", alt = "Rows of glaze buckets labelled by firing cone" } -caption = "The unglamorous half of studio day" -posted-label = "22m" -viewer-has-viewed = false -previous = "ring-lena" -next = "ring-lena-studio" -progress = [ - { id = "ring-lena", is-current = false, is-viewed = false }, - { id = "ring-lena-glazes", is-current = true, is-viewed = false }, - { id = "ring-lena-studio", is-current = false, is-viewed = false }, -] - -[story-details.lena-studio] -id = "ring-lena-studio" -author = "@users.lena" -image = { src = "thumb-lena-9", alt = "Morning light crossing a clean ceramics workbench" } -caption = "Reset for tomorrow" -posted-label = "6m" -viewer-has-viewed = false -previous = "ring-lena-glazes" -progress = [ - { id = "ring-lena", is-current = false, is-viewed = false }, - { id = "ring-lena-glazes", is-current = false, is-viewed = false }, - { id = "ring-lena-studio", is-current = true, is-viewed = false }, -] - -[story-details.marco] -id = "ring-marco" -author = "@users.marco" -image = { src = "media-marco-baja-2", alt = "Campfire on a bluff above the break" } -caption = "Last night at camp" -posted-label = "1h" -viewer-has-viewed = false -next = "ring-marco-swell" -progress = [ - { id = "ring-marco", is-current = true, is-viewed = false }, - { id = "ring-marco-swell", is-current = false, is-viewed = false }, - { id = "ring-marco-dawn", is-current = false, is-viewed = false }, -] - -[story-details.marco-swell] -id = "ring-marco-swell" -author = "@users.marco" -image = { src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" } -caption = "It finally arrived" -posted-label = "48m" -viewer-has-viewed = false -previous = "ring-marco" -next = "ring-marco-dawn" -progress = [ - { id = "ring-marco", is-current = false, is-viewed = false }, - { id = "ring-marco-swell", is-current = true, is-viewed = false }, - { id = "ring-marco-dawn", is-current = false, is-viewed = false }, -] - -[story-details.marco-dawn] -id = "ring-marco-dawn" -author = "@users.marco" -image = { src = "media-marco-baja-3", alt = "Surfboard fins silhouetted against a Baja sunrise" } -caption = "Pack up before the wind" -posted-label = "36m" -viewer-has-viewed = false -previous = "ring-marco-swell" -progress = [ - { id = "ring-marco", is-current = false, is-viewed = false }, - { id = "ring-marco-swell", is-current = false, is-viewed = false }, - { id = "ring-marco-dawn", is-current = true, is-viewed = false }, -] - -[story-details.priya] -id = "ring-priya" -author = "@users.priya" -image = { src = "media-priya-starter", alt = "Freshly sliced sourdough loaf" } -caption = "Still warm" -posted-label = "2h" -viewer-has-viewed = true -progress = [{ id = "ring-priya", is-current = true, is-viewed = true }] - -[story-details.june] -id = "ring-june" -author = "@users.june" -image = { src = "media-june-lookbook", alt = "Linen garments arranged by shade" } -caption = "Fitting day" -posted-label = "2h" -viewer-has-viewed = false -progress = [{ id = "ring-june", is-current = true, is-viewed = false }] - -[story-details.kenji] -id = "ring-kenji" -author = "@users.kenji" -image = { src = "media-kenji-copper", alt = "Road bike at Copper Pass" } -caption = "Worth the climb" -posted-label = "3h" -viewer-has-viewed = true -progress = [{ id = "ring-kenji", is-current = true, is-viewed = true }] - -# ── posts (post-summary) ──────────────────────────────────────────────── - -[posts.lena-glaze] -id = "post-lena-glaze" -author = "@users.lena" -caption = "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved." -like-count = 7 -comment-count = 4 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "2h" - -[posts.lena-glaze.media.image.image] -src = "media-lena-glaze" -alt = "Grid of copper-red glaze test tiles on a maple bench" - -# The authority's view after like-post settles: same post, count 8, -# viewer-has-liked — the like-ok script's piggybacked update (§9.4). -[posts.lena-glaze-liked] -id = "post-lena-glaze" -author = "@users.lena" -caption = "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved." -like-count = 8 -comment-count = 4 -viewer-has-liked = true -viewer-has-saved = false -posted-label = "2h" - -[posts.lena-glaze-liked.media.image.image] -src = "media-lena-glaze" -alt = "Grid of copper-red glaze test tiles on a maple bench" - -# After Mira's comment settles: the feed truth carries the new count -# (§11.4 step 6 — "post-card meta shows 5 comments"; the provider updates -# every carrier of the fact on settle). -[posts.lena-glaze-liked-commented] -id = "post-lena-glaze" -author = "@users.lena" -caption = "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved." -like-count = 8 -comment-count = 5 -viewer-has-liked = true -viewer-has-saved = false -posted-label = "2h" - -[posts.lena-glaze-liked-commented.media.image.image] -src = "media-lena-glaze" -alt = "Grid of copper-red glaze test tiles on a maple bench" - -[posts.marco-baja] -id = "post-marco-baja" -author = "@users.marco" -caption = "Three days down the Baja coast. Swell arrived on the last morning, as it always does." -like-count = 5 -comment-count = 2 -viewer-has-liked = false -viewer-has-saved = true -posted-label = "5h" - -[[posts.marco-baja.media.carousel.slides]] -id = "slide-marco-baja-1" -src = "media-marco-baja-1" -alt = "Long left-hand wave peeling along a desert point" - -[[posts.marco-baja.media.carousel.slides]] -id = "slide-marco-baja-2" -src = "media-marco-baja-2" -alt = "Campfire on the bluff above the break at dusk" - -[[posts.marco-baja.media.carousel.slides]] -id = "slide-marco-baja-3" -src = "media-marco-baja-3" -alt = "Board fins silhouetted against the sunrise" - -[posts.nils-aurora] -id = "post-nils-aurora" -author = "@users.nils" -caption = "Aurora over the fjord last night — the whole sky was breathing." -like-count = 5 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = true -posted-label = "9h" - -[posts.nils-aurora.media.video] -src = "media-nils-aurora" - -[posts.nils-aurora.media.video.poster] -src = "media-nils-aurora-poster" -alt = "Green aurora curtains over a dark fjord" - -[posts.priya-starter] -id = "post-priya-starter" -author = "@users.priya" -caption = "Day 400 of the starter. She's earned a name: Clint Yeastwood." -like-count = 7 -comment-count = 2 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "12h" - -[posts.priya-starter.media.image.image] -src = "media-priya-starter" -alt = "Open crumb of a sourdough loaf, sliced on a flour-dusted board" - -[posts.ayla-ferry] -id = "post-ayla-ferry" -author = "@users.ayla" -caption = "Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon." -like-count = 4 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "1d" - -[posts.ayla-ferry.media.image.image] -src = "media-ayla-ferry" -alt = "Ferry deck railing over blue water, city skyline behind" - -[posts.june-lookbook] -id = "post-june-lookbook" -author = "@users.june" -caption = "Studio lookbook, page one. Linen in every weight we could mill." -like-count = 4 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "1d" - -[posts.june-lookbook.media.image.image] -src = "media-june-lookbook" -alt = "Folded linen garments stacked by shade on a workbench" - -[posts.theo-court] -id = "post-theo-court" -author = "@users.theo" -caption = "Finished the mural at the 9th street court. Paint holds up better than my jumper." -like-count = 4 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "2d" - -[posts.theo-court.media.video] -src = "media-theo-court" - -[posts.theo-court.media.video.poster] -src = "media-theo-court" -alt = "Basketball court painted with bold geometric shapes" - -[posts.kenji-copper] -id = "post-kenji-copper" -author = "@users.kenji" -caption = "120km of switchbacks and one very smug goat. Copper Pass, you were worth it." -like-count = 8 -comment-count = 1 -viewer-has-liked = true -viewer-has-saved = false -posted-label = "2d" - -[posts.kenji-copper.media.image.image] -src = "media-kenji-copper" -alt = "Road bike leaning on a stone wall at a mountain pass" - -# Lena's and Mira's old decorative grid images are genuine post summaries. -# Their ids are the ids carried by profile tiles and post-detail projections. -[posts.lena-bowls] -id = "post-lena-bowls" -author = "@users.lena" -caption = "Copper glaze in close-up, before the wax cooled." -like-count = 2 -comment-count = 0 -viewer-has-liked = true -viewer-has-saved = false -posted-label = "4d" - -[posts.lena-bowls.media.image.image] -src = "thumb-lena-1" -alt = "Copper-red glaze tiles" - -[posts.lena-greenware] -id = "post-lena-greenware" -author = "@users.lena" -caption = "A quiet stack of bowls waiting for bisque firing." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "6d" - -[posts.lena-greenware.media.image.image] -src = "thumb-lena-2" -alt = "Stack of unglazed bowls" - -[posts.lena-kiln] -id = "post-lena-kiln" -author = "@users.lena" -caption = "Kiln Tetris, level thirty-seven." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "9d" - -[posts.lena-kiln.media.image.image] -src = "thumb-lena-3" -alt = "Kiln shelf mid-load" - -[posts.lena-celadon] -id = "post-lena-celadon" -author = "@users.lena" -caption = "Celadon tests after a slower cool-down." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "12d" - -[posts.lena-celadon.media.image.image] -src = "thumb-lena-4" -alt = "Celadon test cups" - -[posts.lena-clay] -id = "post-lena-clay" -author = "@users.lena" -caption = "Fresh reclaim, wedged and ready for tomorrow." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "16d" - -[posts.lena-clay.media.image.image] -src = "thumb-lena-5" -alt = "Wedging table with fresh clay" - -[posts.lena-plates] -id = "post-lena-plates" -author = "@users.lena" -caption = "Dinner plates with just enough iron speckle." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "20d" - -[posts.lena-plates.media.image.image] -src = "thumb-lena-6" -alt = "Iron-speckled dinner plates" - -[posts.lena-throwing] -id = "post-lena-throwing" -author = "@users.lena" -caption = "Pulling one tall cylinder before lunch." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "25d" - -[posts.lena-throwing.media.image.image] -src = "thumb-lena-7" -alt = "Throwing a tall cylinder" - -[posts.lena-buckets] -id = "post-lena-buckets" -author = "@users.lena" -caption = "Labelling day. Future me will be grateful." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "32d" - -[posts.lena-buckets.media.image.image] -src = "thumb-lena-8" -alt = "Glaze buckets labelled by cone" - -[posts.lena-morning] -id = "post-lena-morning" -author = "@users.lena" -caption = "Seven o'clock light across the clean bench." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "41d" - -[posts.lena-morning.media.image.image] -src = "thumb-lena-9" -alt = "Morning light across the studio bench" - -[posts.mira-pasteis] -id = "post-mira-pasteis" -author = "@users.mira" -caption = "The batch that vanished before I finished the coffee." -like-count = 2 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "5d" - -[posts.mira-pasteis.media.image.image] -src = "thumb-mira-1" -alt = "Pastéis de nata on a marble counter" - -[posts.mira-tram] -id = "post-mira-tram" -author = "@users.mira" -caption = "Rails holding the first light on Rua da Conceição." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "10d" - -[posts.mira-tram.media.image.image] -src = "thumb-mira-2" -alt = "Tram rails catching dawn light" - -[posts.mira-citrus] -id = "post-mira-citrus" -author = "@users.mira" -caption = "Saturday citrus, arranged better than any still life." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "15d" - -[posts.mira-citrus.media.image.image] -src = "thumb-mira-3" -alt = "Market citrus stacked in crates" - -[posts.mira-tiles] -id = "post-mira-tiles" -author = "@users.mira" -caption = "Blue after blue after blue." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "23d" - -[posts.mira-tiles.media.image.image] -src = "thumb-mira-4" -alt = "Tiled facade in alternating blues" - -[posts.mira-sardines] -id = "post-mira-sardines" -author = "@users.mira" -caption = "Sardines, smoke, lemon. Nothing else needed." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "31d" - -[posts.mira-sardines.media.image.image] -src = "thumb-mira-5" -alt = "Grilled sardines over coals" - -[posts.mira-ferry] -id = "post-mira-ferry" -author = "@users.mira" -caption = "The last ferry left a gold line all the way home." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "43d" - -[posts.mira-ferry.media.video] -src = "media-mira-ferry" - -[posts.mira-ferry.media.video.poster] -src = "thumb-mira-6" -alt = "Ferry wake at golden hour" - -# Reels reuse the real video posts and their playable fixture media rather -# than maintaining a separate decorative dataset. -[reels.page] -posts = ["@posts.nils-aurora", "@posts.theo-court", "@posts.mira-ferry"] - -# ── feed (feed-page) ──────────────────────────────────────────────────── - -[feed] -stories = [ - "@stories.ring-mira", - "@stories.ring-lena", - "@stories.ring-marco", - "@stories.ring-priya", - "@stories.ring-june", - "@stories.ring-kenji", -] - -[feed.page-1] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", -] -cursor = "cursor-page-2" -has-more = true - -# Page 1 with Lena's post settled at 8 — the like-ok reply value. -[feed.page-1-liked] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze-liked", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", -] -cursor = "cursor-page-2" -has-more = true - -# …and with Mira's comment counted — the demo's add-comment piggyback. -[feed.page-1-liked-commented] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze-liked-commented", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", -] -cursor = "cursor-page-2" -has-more = true - -[feed.page-2] -stories = "@feed.stories" -posts = [ - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -[feed.pages-1-2] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -# Both pages with Lena's like settled — what the demo's pagination reply -# delivers after like-post already settled (whole slices, §9.5). -[feed.pages-1-2-liked] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze-liked", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -# Both pages, like AND comment settled, feed EXHAUSTED — the demo's -# pagination reply (authored truth never regresses mid-walkthrough; the -# end cap renders from `!has-more`, and a further near-end fires into an -# unsatisfied guard — the observation descriptor itself is markup-authored -# and stays in V; the acceptance battery pins the guard rejection). -[feed.pages-1-2-liked-commented] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze-liked-commented", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -[feed.final] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -[feed.empty] -stories = ["@stories.ring-mira"] -posts = [] -has-more = false - -# ── create draft (create-draft) ───────────────────────────────────────── - -[create.empty.empty] - -[create.uploaded.uploaded] -object = "object-mira-draft" -preview = "thumb-mira-6" -name = "tagus-last-light.jpg" - -# The fixture authority's post after publish-image settles. The upload -# object id stays in the command; only authored caption/alt cross back into -# the projection through the scripted authority response. -[posts.mira-created] -id = "@fresh-id" -author = "@users.mira" -caption = "@payload.caption" -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "now" - -[posts.mira-created.media.image.image] -src = "thumb-mira-6" -alt = "@payload.alt" - -[feed.page-1-created] -stories = "@feed.stories" -posts = [ - "@posts.mira-created", - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", -] -cursor = "cursor-page-2" -has-more = true - -# ── comments (comment-thread) ─────────────────────────────────────────── - -[comments.lena-1] -id = "comment-lena-glaze-1" -author = "@users.kenji" -body = "That copper red is unreal. What cone are you firing to?" -posted-label = "1h" - -[comments.lena-2] -id = "comment-lena-glaze-2" -author = "@users.priya" -body = "The third tile down — that speckle! Saving this for glaze inspiration." -posted-label = "1h" - -[comments.lena-3] -id = "comment-lena-glaze-3" -author = "@users.june" -body = "Would buy the whole batch honestly. Seconds sale when?" -posted-label = "45m" - -[comments.lena-4] -id = "comment-lena-glaze-4" -author = "@users.theo" -body = "These would look wild as a court-side mosaic. Collab?" -posted-label = "20m" - -[comments.lena-glaze] -comments = ["@comments.lena-1", "@comments.lena-2", "@comments.lena-3", "@comments.lena-4"] - -# The thread after add-comment settles: the four authored comments plus -# Mira's — the comment-ok script's piggybacked update (§9.4). The last -# entry carries the driver's only two substitutions (§9.5). -[comments.lena-glaze-plus-mira] -comments = [ - "@comments.lena-1", - "@comments.lena-2", - "@comments.lena-3", - "@comments.lena-4", - "@comments.mira-reply", -] - -[comments.empty] -comments = [] - -# Mira's demo comment (§11.2) as the driver's substitution template: -# `@fresh-id` mints the authority's comment id; `@payload.body` echoes the -# typed text — the optimistic row swaps for this atomically (§9.4/§9.5). -[comments.mira-reply] -id = "@fresh-id" -author = "@users.mira" -body = "@payload.body" -posted-label = "now" - -# ── profiles (profile-view) ───────────────────────────────────────────── - -[profiles.lena] -user = "@users.lena" -bio = "Ceramics and slow mornings. Small-batch studio work from Portland." -is-self = false -viewer-follows = true -post-count = 10 -follower-count = 8 -following-count = 5 -posts = [ - { id = "post-lena-glaze", src = "media-lena-glaze", alt = "Grid of copper-red glaze test tiles" }, - { id = "post-lena-bowls", src = "thumb-lena-1", alt = "Copper-red glaze tiles" }, - { id = "post-lena-greenware", src = "thumb-lena-2", alt = "Stack of unglazed bowls" }, - { id = "post-lena-kiln", src = "thumb-lena-3", alt = "Kiln shelf mid-load" }, - { id = "post-lena-celadon", src = "thumb-lena-4", alt = "Celadon test cups" }, - { id = "post-lena-clay", src = "thumb-lena-5", alt = "Wedging table with fresh clay" }, - { id = "post-lena-plates", src = "thumb-lena-6", alt = "Iron-speckled dinner plates" }, - { id = "post-lena-throwing", src = "thumb-lena-7", alt = "Throwing a tall cylinder" }, - { id = "post-lena-buckets", src = "thumb-lena-8", alt = "Glaze buckets labelled by cone" }, - { id = "post-lena-morning", src = "thumb-lena-9", alt = "Morning light across the studio bench" }, -] -reels = [] -saved = [] -tagged = [ - { id = "post-priya-starter", src = "media-priya-starter", alt = "Fresh sourdough loaf" }, -] - -[profiles.mira] -user = "@users.mira" -bio = "Food and travel photographer in Lisbon. Usually awake before the trams." -is-self = true -viewer-follows = false -post-count = 6 -follower-count = 4 -following-count = 6 -posts = [ - { id = "post-mira-pasteis", src = "thumb-mira-1", alt = "Pastéis de nata on a marble counter" }, - { id = "post-mira-tram", src = "thumb-mira-2", alt = "Tram rails catching dawn light" }, - { id = "post-mira-citrus", src = "thumb-mira-3", alt = "Market citrus stacked in crates" }, - { id = "post-mira-tiles", src = "thumb-mira-4", alt = "Tiled facade in alternating blues" }, - { id = "post-mira-sardines", src = "thumb-mira-5", alt = "Grilled sardines over coals" }, - { id = "post-mira-ferry", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, -] -reels = [ - { id = "post-mira-ferry", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, -] -saved = [ - { id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" }, - { id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora over a fjord" }, -] -tagged = [ - { id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" }, -] - -[profiles.marco] -user = "@users.marco" -bio = "Surf photographer, road-trip cook, and reluctant morning person." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 4 -following-count = 4 -posts = [{ id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long wave peeling along a desert point" }] -reels = [] -saved = [] -tagged = [{ id = "post-kenji-copper", src = "media-kenji-copper", alt = "Road bike at Copper Pass" }] - -[profiles.nils] -user = "@users.nils" -bio = "Night skies and northern water, filmed around Tromsø." -is-self = false -viewer-follows = false -post-count = 1 -follower-count = 2 -following-count = 3 -posts = [{ id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora over a fjord" }] -reels = [{ id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora over a fjord" }] -saved = [] -tagged = [] - -[profiles.priya] -user = "@users.priya" -bio = "Bread notebook, tiny kitchen, stubborn sourdough starter." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 4 -following-count = 4 -posts = [{ id = "post-priya-starter", src = "media-priya-starter", alt = "Fresh sourdough loaf" }] -reels = [] -saved = [] -tagged = [] - -[profiles.ayla] -user = "@users.ayla" -bio = "Istanbul by ferry. Architecture, tea, and ordinary light." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 4 -following-count = 3 -posts = [{ id = "post-ayla-ferry", src = "media-ayla-ferry", alt = "Morning ferry across the Bosphorus" }] -reels = [] -saved = [] -tagged = [{ id = "post-june-lookbook", src = "media-june-lookbook", alt = "Linen garments arranged by shade" }] - -[profiles.june] -user = "@users.june" -bio = "Natural-fiber clothes made in a very crowded studio." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 5 -following-count = 4 -posts = [{ id = "post-june-lookbook", src = "media-june-lookbook", alt = "Linen garments arranged by shade" }] -reels = [] -saved = [] -tagged = [{ id = "post-theo-court", src = "media-theo-court", alt = "Geometric basketball court mural" }] - -[profiles.theo] -user = "@users.theo" -bio = "Murals, community courts, and an unreliable jump shot." -is-self = false -viewer-follows = false -post-count = 1 -follower-count = 3 -following-count = 3 -posts = [{ id = "post-theo-court", src = "media-theo-court", alt = "Geometric basketball court mural" }] -reels = [{ id = "post-theo-court", src = "media-theo-court", alt = "Geometric basketball court mural" }] -saved = [] -tagged = [{ id = "post-lena-glaze", src = "media-lena-glaze", alt = "Grid of copper-red glaze test tiles" }] - -[profiles.kenji] -user = "@users.kenji" -bio = "Long climbs, quiet roads, and coffee at the turnaround." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 2 -following-count = 4 -posts = [{ id = "post-kenji-copper", src = "media-kenji-copper", alt = "Road bike at Copper Pass" }] -reels = [] -saved = [] -tagged = [] - -# ── relationships (connection / connection-list) ──────────────────────── - -[connections.mira] -user = "@users.mira" -viewer-follows = false - -[connections.lena] -user = "@users.lena" -viewer-follows = true - -[connections.marco] -user = "@users.marco" -viewer-follows = true - -[connections.nils] -user = "@users.nils" -viewer-follows = false - -[connections.priya] -user = "@users.priya" -viewer-follows = true - -[connections.ayla] -user = "@users.ayla" -viewer-follows = true - -[connections.june] -user = "@users.june" -viewer-follows = true - -[connections.theo] -user = "@users.theo" -viewer-follows = false - -[connections.kenji] -user = "@users.kenji" -viewer-follows = true - -[people.search-all] -people = [ - "@connections.ayla", "@connections.june", "@connections.kenji", - "@connections.lena", "@connections.marco", "@connections.nils", - "@connections.priya", "@connections.theo", -] -posts = [ - { id = "post-lena-glaze", src = "media-lena-glaze", alt = "Grid of copper-red glaze test tiles on a maple bench" }, - { id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" }, - { id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora curtains over a dark fjord" }, - { id = "post-priya-starter", src = "media-priya-starter", alt = "Open crumb of a sourdough loaf, sliced on a flour-dusted board" }, - { id = "post-ayla-ferry", src = "media-ayla-ferry", alt = "Ferry deck railing over blue water, city skyline behind" }, - { id = "post-june-lookbook", src = "media-june-lookbook", alt = "Folded linen garments stacked by shade on a workbench" }, - { id = "post-theo-court", src = "media-theo-court", alt = "Basketball court painted with bold geometric shapes" }, - { id = "post-kenji-copper", src = "media-kenji-copper", alt = "Road bike leaning on a stone wall at a mountain pass" }, - { id = "post-lena-bowls", src = "thumb-lena-1", alt = "Copper-red glaze tiles" }, - { id = "post-mira-pasteis", src = "thumb-mira-1", alt = "Pastéis de nata on a marble counter" }, - { id = "post-lena-greenware", src = "thumb-lena-2", alt = "Stack of unglazed bowls" }, - { id = "post-lena-kiln", src = "thumb-lena-3", alt = "Kiln shelf mid-load" }, - { id = "post-mira-tram", src = "thumb-mira-2", alt = "Tram rails catching dawn light" }, - { id = "post-lena-celadon", src = "thumb-lena-4", alt = "Celadon test cups" }, - { id = "post-mira-citrus", src = "thumb-mira-3", alt = "Market citrus stacked in crates" }, - { id = "post-lena-clay", src = "thumb-lena-5", alt = "Wedging table with fresh clay" }, - { id = "post-lena-plates", src = "thumb-lena-6", alt = "Iron-speckled dinner plates" }, - { id = "post-mira-tiles", src = "thumb-mira-4", alt = "Tiled facade in alternating blues" }, - { id = "post-lena-throwing", src = "thumb-lena-7", alt = "Throwing a tall cylinder" }, - { id = "post-mira-sardines", src = "thumb-mira-5", alt = "Grilled sardines over coals" }, - { id = "post-lena-buckets", src = "thumb-lena-8", alt = "Glaze buckets labelled by cone" }, - { id = "post-lena-morning", src = "thumb-lena-9", alt = "Morning light across the studio bench" }, - { id = "post-mira-ferry", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, -] - -[people.search-empty] -people = [] -posts = [] - -[people.search-nils] -people = ["@connections.nils"] -posts = [ - { id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora curtains over a dark fjord" }, -] - -[people.mira-followers] -people = ["@connections.lena", "@connections.priya", "@connections.june", "@connections.theo"] - -[people.mira-following] -people = [ - "@connections.lena", "@connections.marco", "@connections.priya", - "@connections.ayla", "@connections.june", "@connections.kenji", -] - -[people.lena-followers] -people = [ - "@connections.mira", "@connections.marco", "@connections.nils", - "@connections.priya", "@connections.ayla", "@connections.june", - "@connections.theo", "@connections.kenji", -] - -[people.lena-following] -people = [ - "@connections.mira", "@connections.marco", "@connections.priya", - "@connections.june", "@connections.theo", -] diff --git a/examples/instagram/client/host.toml b/examples/instagram/client/host.toml new file mode 100644 index 0000000..8082230 --- /dev/null +++ b/examples/instagram/client/host.toml @@ -0,0 +1,19 @@ +[entry.instagram] +machine = "crate::Instagram" +presentation = "crate::FeedPage" +lifetime = "application-session" +stylesheet = "styles/theme.css" + +[entry.instagram.ports] +router = "web.history" +authority = "app.provider" +mutations = "app.provider" + +[entry.instagram.provider] +module = "providers/dist/spock.js" + +[entry.instagram.provider.config] +graphql_url = "http://127.0.0.1:4000/graphql/v1" +rpc_url = "http://127.0.0.1:4000/rest/v1/rpc" +storage_url = "http://127.0.0.1:4000/storage/v1" +actor = "10000000-0000-4000-8000-000000000001" diff --git a/examples/instagram/client/machine.uhura b/examples/instagram/client/machine.uhura new file mode 100644 index 0000000..2c6dbcc --- /dev/null +++ b/examples/instagram/client/machine.uhura @@ -0,0 +1,1834 @@ +use uhura::observation::Observation; +use uhura::ports::RequestPort; +use uhura::web_router::{Router, Routes}; +use crate::parts::{Notice, NoticeControls}; + +pub key UserId(Text); + +pub key PostId(Text); + +pub key StoryId(Text); + +pub key RequestId(PositiveInt); + +pub const USER_MIRA: UserId = UserId("user-mira"); + +pub const USER_LENA: UserId = UserId("user-lena"); + +pub const USER_MARCO: UserId = UserId("user-marco"); + +pub const USER_NILS: UserId = UserId("user-nils"); + +pub const USER_PRIYA: UserId = UserId("user-priya"); + +pub const USER_AYLA: UserId = UserId("user-ayla"); + +pub const USER_JUNE: UserId = UserId("user-june"); + +pub const USER_THEO: UserId = UserId("user-theo"); + +pub const USER_KENJI: UserId = UserId("user-kenji"); + +pub const POST_LENA_GLAZE: PostId = PostId("post-lena-glaze"); + +pub const POST_LENA_BOWLS: PostId = PostId("post-lena-bowls"); + +pub const POST_MARCO_BAJA: PostId = PostId("post-marco-baja"); + +pub const POST_NILS_AURORA: PostId = PostId("post-nils-aurora"); + +pub const POST_PRIYA_STARTER: PostId = PostId("post-priya-starter"); + +pub const POST_AYLA_FERRY: PostId = PostId("post-ayla-ferry"); + +pub const POST_JUNE_LOOKBOOK: PostId = PostId("post-june-lookbook"); + +pub const POST_THEO_COURT: PostId = PostId("post-theo-court"); + +pub const POST_KENJI_COPPER: PostId = PostId("post-kenji-copper"); + +pub const POST_MIRA_FERRY: PostId = PostId("post-mira-ferry"); + +pub const STORY_MIRA: StoryId = StoryId("ring-mira"); + +pub const STORY_MIRA_TRAM: StoryId = StoryId("ring-mira-tram"); + +pub const STORY_LENA: StoryId = StoryId("ring-lena"); + +pub const STORY_LENA_GLAZES: StoryId = StoryId("ring-lena-glazes"); + +pub const STORY_LENA_STUDIO: StoryId = StoryId("ring-lena-studio"); + +pub const STORY_PRIYA: StoryId = StoryId("ring-priya"); + +pub enum Section { + Feed, + Search, + Create, + Reels, + Profile, +} + +pub enum ProfileTab { + Posts, + Reels, + Tagged, + Saved, +} + +pub enum FeedStatus { + Idle, + Loading, + Failed, + Exhausted, +} + +pub enum SearchStatus { + Explore, + Searching, + Results, + NoResults, +} + +pub enum Location { + Feed, + Search, + Create, + Reels, + Post { + id: PostId, + }, + Profile { + user: UserId, + }, + Followers { + user: UserId, + }, + Following { + user: UserId, + }, + Story { + id: StoryId, + }, +} + +pub const INSTAGRAM_ROUTES: Routes = Routes::from([("Feed", "/"), ("Search", "/search"), ("Create", "/create"), ("Reels", "/reels"), ("Post", "/p/{id}"), ("Profile", "/profile/{user}"), ("Followers", "/profile/{user}/followers"), ("Following", "/profile/{user}/following"), ("Story", "/stories/{id}")]); + +pub enum Page { + None, + Feed, + Search, + Create, + Reels, + Post { + id: PostId, + }, + Profile { + user: UserId, + }, + Followers { + user: UserId, + }, + Following { + user: UserId, + }, + Story { + id: StoryId, + }, +} + +pub struct ImageRef { + src: Text, + alt: Text, +} + +pub struct User { + id: UserId, + username: Text, + display_name: Text, + avatar: ImageRef, +} + +pub enum Media { + Image { + image: ImageRef, + }, + Carousel { + images: Seq, + }, + Video { + src: Text, + poster: ImageRef, + }, +} + +pub struct Post { + id: PostId, + author: User, + caption: Text, + media: Media, + like_count: Nat, + comment_count: Nat, + viewer_liked: Bool, + viewer_saved: Bool, + posted_label: Text, +} + +pub struct StoryRing { + id: StoryId, + user: User, + unseen: Bool, + is_self: Bool, +} + +pub struct StorySegment { + id: StoryId, + current: Bool, + viewed: Bool, +} + +pub struct StoryDetail { + id: StoryId, + author: User, + image: ImageRef, + caption: Text, + posted_label: Text, + viewed: Bool, + previous: Option, + next: Option, + progress: Seq, +} + +pub struct Tile { + post: PostId, + image: ImageRef, +} + +pub struct Profile { + user: User, + bio: Text, + post_count: Nat, + follower_count: Nat, + following_count: Nat, + viewer_follows: Bool, + posts: Seq, + reels: Seq, + tagged: Seq, + saved: Seq, +} + +pub struct Connection { + user: User, + follows_viewer: Bool, + viewer_follows: Bool, +} + +pub struct Comment { + id: Text, + author: User, + body: Text, + posted_label: Text, +} + +pub struct AppData { + viewer: User, + posts: Map, + feed_posts: Seq, + feed_has_more: Bool, + reels: Seq, + stories: Seq, + story_details: Map, + profiles: Map, + followers: Map>, + following: Map>, + comments: Map>, + search_people: Seq, + explore_tiles: Seq, +} + +pub enum Authority { + Loading, + Failed { + reason: Text, + }, + Ready { + data: AppData, + }, +} + +pub enum Upload { + Empty, + Choosing, + Uploaded { + object: Text, + preview: Text, + name: Text, + }, + Publishing { + object: Text, + preview: Text, + name: Text, + }, +} + +pub enum Mutation { + SetLike { + post: PostId, + liked: Bool, + }, + SetSave { + post: PostId, + saved: Bool, + }, + LoadMore, + ReloadFeed, + SetFollow { + user: UserId, + following: Bool, + }, + AddComment { + post: PostId, + body: Text, + }, + SearchPeople { + query: Text, + }, + ChooseImage, + PublishImage { + object: Text, + caption: Text, + alt: Text, + }, + MarkStory { + story: StoryId, + }, +} + +pub enum Settlement { + Accepted, + Refused { + reason: Text, + }, + ImageReady { + object: Text, + preview: Text, + name: Text, + }, +} + +pub const MIRA: User = User { + id: USER_MIRA, + username: "mira.santos", + display_name: "Mira Santos", + avatar: ImageRef { + src: "avatar-mira", + alt: "Mira Santos", + }, +}; + +pub const LENA: User = User { + id: USER_LENA, + username: "lena.holt", + display_name: "Lena Holt", + avatar: ImageRef { + src: "avatar-lena", + alt: "Lena Holt", + }, +}; + +pub const MARCO: User = User { + id: USER_MARCO, + username: "marco.reyes", + display_name: "Marco Reyes", + avatar: ImageRef { + src: "avatar-marco", + alt: "Marco Reyes", + }, +}; + +pub const NILS: User = User { + id: USER_NILS, + username: "nils.bergman", + display_name: "Nils Bergman", + avatar: ImageRef { + src: "avatar-nils", + alt: "Nils Bergman", + }, +}; + +pub const PRIYA: User = User { + id: USER_PRIYA, + username: "priya.raman", + display_name: "Priya Raman", + avatar: ImageRef { + src: "avatar-priya", + alt: "Priya Raman", + }, +}; + +pub const AYLA: User = User { + id: USER_AYLA, + username: "ayla.demir", + display_name: "Ayla Demir", + avatar: ImageRef { + src: "avatar-ayla", + alt: "Ayla Demir", + }, +}; + +pub const JUNE: User = User { + id: USER_JUNE, + username: "june.park", + display_name: "June Park", + avatar: ImageRef { + src: "avatar-june", + alt: "June Park", + }, +}; + +pub const THEO: User = User { + id: USER_THEO, + username: "theo.okafor", + display_name: "Theo Okafor", + avatar: ImageRef { + src: "avatar-theo", + alt: "Theo Okafor", + }, +}; + +pub const KENJI: User = User { + id: USER_KENJI, + username: "kenji.rides", + display_name: "Kenji Tanaka", + avatar: ImageRef { + src: "avatar-kenji", + alt: "Kenji Tanaka", + }, +}; + +pub const LENA_GLAZE: Post = Post { + id: POST_LENA_GLAZE, + author: LENA, + caption: "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.", + media: Media::Image { + image: ImageRef { + src: "media-lena-glaze", + alt: "Decorative ceramic tile panels leaning in an artisan studio", + }, + }, + like_count: 7, + comment_count: 4, + viewer_liked: false, + viewer_saved: false, + posted_label: "2h", +}; + +pub const LENA_BOWLS: Post = Post { + id: POST_LENA_BOWLS, + author: LENA, + caption: "Copper glaze in close-up, before the wax cooled.", + media: Media::Image { + image: ImageRef { + src: "thumb-lena-2", + alt: "Celadon ceramic vessel with a sculpted wave rim", + }, + }, + like_count: 5, + comment_count: 1, + viewer_liked: false, + viewer_saved: false, + posted_label: "1d", +}; + +pub const MARCO_BAJA: Post = Post { + id: POST_MARCO_BAJA, + author: MARCO, + caption: "Three days down the Baja coast. Swell arrived on the last morning, as it always does.", + media: Media::Carousel { + images: [ImageRef { + src: "media-marco-baja-1", + alt: "Ocean wave exploding into white spray against deep blue water", + }, ImageRef { + src: "media-marco-baja-2", + alt: "Glowing campfire beside a tent under a desert night sky", + }, ImageRef { + src: "media-marco-baja-3", + alt: "Palm-lined ocean glowing orange and violet at sunset", + }], + }, + like_count: 12, + comment_count: 2, + viewer_liked: true, + viewer_saved: true, + posted_label: "4h", +}; + +pub const NILS_AURORA: Post = Post { + id: POST_NILS_AURORA, + author: NILS, + caption: "Aurora over the fjord last night — the whole sky was breathing.", + media: Media::Video { + src: "video-nils-aurora", + poster: ImageRef { + src: "media-nils-aurora-poster", + alt: "Soft bands of blue, violet, and green light across a dark sky", + }, + }, + like_count: 21, + comment_count: 3, + viewer_liked: false, + viewer_saved: true, + posted_label: "6h", +}; + +pub const PRIYA_STARTER: Post = Post { + id: POST_PRIYA_STARTER, + author: PRIYA, + caption: "Day 400 of the starter. She's earned a name: Clint Yeastwood.", + media: Media::Image { + image: ImageRef { + src: "media-priya-starter", + alt: "Black-and-white cross-section of a rustic bread loaf", + }, + }, + like_count: 18, + comment_count: 2, + viewer_liked: false, + viewer_saved: false, + posted_label: "8h", +}; + +pub const AYLA_FERRY: Post = Post { + id: POST_AYLA_FERRY, + author: AYLA, + caption: "Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon.", + media: Media::Image { + image: ImageRef { + src: "media-ayla-ferry", + alt: "Small boat crossing blue water toward the Jaffa skyline", + }, + }, + like_count: 9, + comment_count: 0, + viewer_liked: false, + viewer_saved: false, + posted_label: "10h", +}; + +pub const JUNE_LOOKBOOK: Post = Post { + id: POST_JUNE_LOOKBOOK, + author: JUNE, + caption: "Studio lookbook, page one. Linen in every weight we could mill.", + media: Media::Image { + image: ImageRef { + src: "media-june-lookbook", + alt: "Navy mosaic printed across natural linen fabric", + }, + }, + like_count: 14, + comment_count: 1, + viewer_liked: false, + viewer_saved: false, + posted_label: "12h", +}; + +pub const THEO_COURT: Post = Post { + id: POST_THEO_COURT, + author: THEO, + caption: "Finished the mural at the 9th street court. Paint holds up better than my jumper.", + media: Media::Video { + src: "video-theo-court", + poster: ImageRef { + src: "media-theo-court", + alt: "Colorful patterned staircase framed by saturated yellow walls", + }, + }, + like_count: 32, + comment_count: 5, + viewer_liked: false, + viewer_saved: false, + posted_label: "14h", +}; + +pub const KENJI_COPPER: Post = Post { + id: POST_KENJI_COPPER, + author: KENJI, + caption: "120km of switchbacks and one very smug goat. Copper Pass, you were worth it.", + media: Media::Image { + image: ImageRef { + src: "media-kenji-copper", + alt: "Cyclists riding through a crowded market square", + }, + }, + like_count: 11, + comment_count: 4, + viewer_liked: false, + viewer_saved: false, + posted_label: "1d", +}; + +pub const MIRA_FERRY: Post = Post { + id: POST_MIRA_FERRY, + author: MIRA, + caption: "The last ferry left a gold line all the way home.", + media: Media::Video { + src: "video-mira-ferry", + poster: ImageRef { + src: "thumb-mira-6", + alt: "Ocean spray breaking over rocks in golden light", + }, + }, + like_count: 24, + comment_count: 6, + viewer_liked: true, + viewer_saved: false, + posted_label: "2d", +}; + +pub const ALL_POSTS: Map = Map::from([(POST_LENA_GLAZE, LENA_GLAZE), (POST_LENA_BOWLS, LENA_BOWLS), (POST_MARCO_BAJA, MARCO_BAJA), (POST_NILS_AURORA, NILS_AURORA), (POST_PRIYA_STARTER, PRIYA_STARTER), (POST_AYLA_FERRY, AYLA_FERRY), (POST_JUNE_LOOKBOOK, JUNE_LOOKBOOK), (POST_THEO_COURT, THEO_COURT), (POST_KENJI_COPPER, KENJI_COPPER), (POST_MIRA_FERRY, MIRA_FERRY)]); + +pub const FEED_PAGE_ONE: Seq = [LENA_GLAZE, MARCO_BAJA, NILS_AURORA]; + +pub const FEED_ALL: Seq = [LENA_GLAZE, MARCO_BAJA, NILS_AURORA, PRIYA_STARTER, AYLA_FERRY, JUNE_LOOKBOOK]; + +pub const ALL_REELS: Seq = [NILS_AURORA, THEO_COURT, MIRA_FERRY]; + +pub const ALL_STORIES: Seq = [StoryRing { + id: STORY_MIRA, + user: MIRA, + unseen: false, + is_self: true, +}, StoryRing { + id: STORY_LENA, + user: LENA, + unseen: true, + is_self: false, +}, StoryRing { + id: StoryId("ring-marco"), + user: MARCO, + unseen: true, + is_self: false, +}, StoryRing { + id: STORY_PRIYA, + user: PRIYA, + unseen: false, + is_self: false, +}, StoryRing { + id: StoryId("ring-june"), + user: JUNE, + unseen: true, + is_self: false, +}, StoryRing { + id: StoryId("ring-kenji"), + user: KENJI, + unseen: false, + is_self: false, +}]; + +pub const LENA_STORY: StoryDetail = StoryDetail { + id: STORY_LENA, + author: LENA, + image: ImageRef { + src: "thumb-lena-7", + alt: "Lena throwing a tall clay cylinder", + }, + caption: "One pull, no edits", + posted_label: "35m", + viewed: false, + previous: None, + next: Some(STORY_LENA_GLAZES), + progress: [StorySegment { + id: STORY_LENA, + current: true, + viewed: false, + }, StorySegment { + id: STORY_LENA_GLAZES, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_STUDIO, + current: false, + viewed: false, + }], +}; + +pub const LENA_GLAZES_STORY: StoryDetail = StoryDetail { + id: STORY_LENA_GLAZES, + author: LENA, + image: ImageRef { + src: "thumb-lena-8", + alt: "Rows of glaze buckets labelled by firing cone", + }, + caption: "The unglamorous half of studio day", + posted_label: "22m", + viewed: false, + previous: Some(STORY_LENA), + next: Some(STORY_LENA_STUDIO), + progress: [StorySegment { + id: STORY_LENA, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_GLAZES, + current: true, + viewed: false, + }, StorySegment { + id: STORY_LENA_STUDIO, + current: false, + viewed: false, + }], +}; + +pub const LENA_STUDIO_STORY: StoryDetail = StoryDetail { + id: STORY_LENA_STUDIO, + author: LENA, + image: ImageRef { + src: "thumb-lena-9", + alt: "Morning light crossing a clean ceramics workbench", + }, + caption: "Reset for tomorrow", + posted_label: "6m", + viewed: false, + previous: Some(STORY_LENA_GLAZES), + next: None, + progress: [StorySegment { + id: STORY_LENA, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_GLAZES, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_STUDIO, + current: true, + viewed: false, + }], +}; + +pub const MIRA_TRAM_STORY: StoryDetail = StoryDetail { + id: STORY_MIRA_TRAM, + author: MIRA, + image: ImageRef { + src: "thumb-mira-2", + alt: "Tram rails catching the first light in Lisbon", + }, + caption: "Then the city wakes", + posted_label: "8m", + viewed: true, + previous: Some(STORY_MIRA), + next: Some(StoryId("ring-mira-market")), + progress: [StorySegment { + id: STORY_MIRA, + current: false, + viewed: true, + }, StorySegment { + id: STORY_MIRA_TRAM, + current: true, + viewed: true, + }, StorySegment { + id: StoryId("ring-mira-market"), + current: false, + viewed: true, + }], +}; + +pub const PRIYA_STORY: StoryDetail = StoryDetail { + id: STORY_PRIYA, + author: PRIYA, + image: ImageRef { + src: "media-priya-starter", + alt: "Freshly sliced sourdough loaf", + }, + caption: "Still warm", + posted_label: "45m", + viewed: true, + previous: None, + next: None, + progress: [StorySegment { + id: STORY_PRIYA, + current: true, + viewed: true, + }], +}; + +pub const STORY_DETAILS: Map = Map::from([(STORY_LENA, LENA_STORY), (STORY_LENA_GLAZES, LENA_GLAZES_STORY), (STORY_LENA_STUDIO, LENA_STUDIO_STORY), (STORY_MIRA_TRAM, MIRA_TRAM_STORY), (STORY_PRIYA, PRIYA_STORY)]); + +pub const LENA_PROFILE: Profile = Profile { + user: LENA, + bio: "Ceramics and slow mornings. Small-batch studio work from Portland.", + post_count: 10, + follower_count: 8, + following_count: 5, + viewer_follows: true, + posts: [Tile { + post: POST_LENA_GLAZE, + image: ImageRef { + src: "thumb-lena-1", + alt: "Decorative ceramic tile panels in warm glaze colors", + }, + }, Tile { + post: POST_LENA_BOWLS, + image: ImageRef { + src: "thumb-lena-2", + alt: "Celadon ceramic vessel with a sculpted wave rim", + }, + }, Tile { + post: PostId("post-lena-greenware"), + image: ImageRef { + src: "thumb-lena-3", + alt: "Editorial catalog of handmade stoneware ceramics", + }, + }, Tile { + post: PostId("post-lena-kiln"), + image: ImageRef { + src: "thumb-lena-4", + alt: "White ceramic vessel with a flowing sculptural form", + }, + }, Tile { + post: PostId("post-lena-clay"), + image: ImageRef { + src: "thumb-lena-5", + alt: "Miniature artist studio with shelves and a workbench", + }, + }, Tile { + post: PostId("post-lena-plates"), + image: ImageRef { + src: "thumb-lena-6", + alt: "Blush ceramic sculpture with a looping organic form", + }, + }], + reels: [], + tagged: [Tile { + post: POST_PRIYA_STARTER, + image: ImageRef { + src: "media-priya-starter", + alt: "Fresh sourdough loaf", + }, + }], + saved: [], +}; + +pub const MIRA_PROFILE: Profile = Profile { + user: MIRA, + bio: "Food and travel photographer in Lisbon. Usually awake before the trams.", + post_count: 6, + follower_count: 4, + following_count: 6, + viewer_follows: false, + posts: [Tile { + post: PostId("post-mira-pasteis"), + image: ImageRef { + src: "thumb-mira-1", + alt: "Translucent citrus and radish slices on white", + }, + }, Tile { + post: PostId("post-mira-tram"), + image: ImageRef { + src: "thumb-mira-2", + alt: "Colorful geometric Mediterranean hillside village", + }, + }, Tile { + post: PostId("post-mira-citrus"), + image: ImageRef { + src: "thumb-mira-3", + alt: "Grid of cross-sectioned fruits and vegetables", + }, + }, Tile { + post: PostId("post-mira-tiles"), + image: ImageRef { + src: "thumb-mira-4", + alt: "Colorful patterned staircase framed by yellow walls", + }, + }, Tile { + post: PostId("post-mira-sardines"), + image: ImageRef { + src: "thumb-mira-5", + alt: "Bold illustrated food flavors in a four-panel grid", + }, + }, Tile { + post: POST_MIRA_FERRY, + image: ImageRef { + src: "thumb-mira-6", + alt: "Ocean spray breaking over rocks in golden light", + }, + }], + reels: [Tile { + post: POST_MIRA_FERRY, + image: ImageRef { + src: "thumb-mira-6", + alt: "Ocean spray breaking over rocks in golden light", + }, + }], + tagged: [Tile { + post: POST_MARCO_BAJA, + image: ImageRef { + src: "media-marco-baja-1", + alt: "Long left-hand wave peeling along a desert point", + }, + }], + saved: [Tile { + post: POST_MARCO_BAJA, + image: ImageRef { + src: "media-marco-baja-1", + alt: "Long left-hand wave peeling along a desert point", + }, + }, Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora over a fjord", + }, + }], +}; + +pub const NILS_PROFILE: Profile = Profile { + user: NILS, + bio: "Night skies and northern water, filmed around Tromsø.", + post_count: 1, + follower_count: 2, + following_count: 3, + viewer_follows: false, + posts: [Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora over a fjord", + }, + }], + reels: [Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora over a fjord", + }, + }], + tagged: [], + saved: [], +}; + +pub const PROFILES: Map = Map::from([(USER_LENA, LENA_PROFILE), (USER_MIRA, MIRA_PROFILE), (USER_NILS, NILS_PROFILE)]); + +pub const LENA_CONNECTIONS: Seq = [Connection { + user: MIRA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: NILS, + follows_viewer: false, + viewer_follows: false, +}, Connection { + user: PRIYA, + follows_viewer: true, + viewer_follows: true, +}]; + +pub const MIRA_FOLLOWERS: Seq = [Connection { + user: LENA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: NILS, + follows_viewer: true, + viewer_follows: false, +}]; + +pub const MIRA_FOLLOWING: Seq = [Connection { + user: LENA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: MARCO, + follows_viewer: false, + viewer_follows: true, +}, Connection { + user: PRIYA, + follows_viewer: true, + viewer_follows: true, +}]; + +pub const FOLLOWER_LISTS: Map> = Map::from([(USER_LENA, LENA_CONNECTIONS), (USER_MIRA, MIRA_FOLLOWERS), (USER_NILS, [])]); + +pub const FOLLOWING_LISTS: Map> = Map::from([(USER_LENA, LENA_CONNECTIONS), (USER_MIRA, MIRA_FOLLOWING), (USER_NILS, [])]); + +pub const LENA_COMMENTS: Seq = [Comment { + id: "comment-1", + author: KENJI, + body: "That copper red is unreal. What cone are you firing to?", + posted_label: "1h", +}, Comment { + id: "comment-2", + author: PRIYA, + body: "The speckle is perfect.", + posted_label: "48m", +}, Comment { + id: "comment-3", + author: MARCO, + body: "Saving this palette.", + posted_label: "22m", +}]; + +pub const COMMENT_LISTS: Map> = Map::from([(POST_LENA_GLAZE, LENA_COMMENTS), (POST_AYLA_FERRY, [])]); + +pub const SEARCH_CONNECTIONS: Seq = [Connection { + user: LENA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: MARCO, + follows_viewer: false, + viewer_follows: true, +}, Connection { + user: NILS, + follows_viewer: false, + viewer_follows: false, +}, Connection { + user: PRIYA, + follows_viewer: true, + viewer_follows: true, +}]; + +pub const EXPLORE_TILES: Seq = [Tile { + post: POST_LENA_GLAZE, + image: ImageRef { + src: "media-lena-glaze", + alt: "Grid of copper-red glaze test tiles on a maple bench", + }, +}, Tile { + post: POST_MARCO_BAJA, + image: ImageRef { + src: "media-marco-baja-1", + alt: "Long left-hand wave peeling along a desert point", + }, +}, Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora curtains over a dark fjord", + }, +}, Tile { + post: POST_PRIYA_STARTER, + image: ImageRef { + src: "media-priya-starter", + alt: "Open crumb of a sourdough loaf", + }, +}, Tile { + post: POST_AYLA_FERRY, + image: ImageRef { + src: "media-ayla-ferry", + alt: "Ferry deck railing over blue water", + }, +}, Tile { + post: POST_JUNE_LOOKBOOK, + image: ImageRef { + src: "media-june-lookbook", + alt: "Folded linen garments stacked by shade", + }, +}]; + +pub const DEMO_STANDARD: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_PAGE_ONE, + feed_has_more: true, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_APPENDED: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_ALL, + feed_has_more: false, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_EXHAUSTED: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_PAGE_ONE, + feed_has_more: false, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_EMPTY: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: [], + feed_has_more: false, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_EMPTY_EXPLORE: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_PAGE_ONE, + feed_has_more: true, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: [], + explore_tiles: [], +}; + +pub machine Instagram { + port router = Router { + routes: INSTAGRAM_ROUTES, + }; + + port authority = Observation {}; + + port mutations = RequestPort {}; + + events { + SelectTab(section: Section), + OpenPost(id: PostId), + OpenProfile(user: UserId), + OpenFollowers(user: UserId), + OpenFollowing(user: UserId), + OpenStory(id: StoryId), + MarkStorySeen(id: StoryId), + GoBack, + ToggleLike(post: PostId, liked: Bool), + ToggleSave(post: PostId, saved: Bool), + FeedNearEnd, + RetryFeed, + OpenComments(post: PostId), + DismissComments, + CommentChanged(value: Text), + SubmitComment, + ToggleFollow(user: UserId, following: Bool), + SelectProfileTab(tab: ProfileTab), + SearchChanged(value: Text), + SubmitSearch, + ChooseImage, + CaptionChanged(value: Text), + AltChanged(value: Text), + PublishImage, + } + + outcomes { + commit Accepted, + abort Blocked(reason: Text), + abort Duplicate, + abort Stale, + abort Invalid(reason: Text), + } + + state { + location: Option = None, + authority_state: Authority = Authority::Loading, + profile_tab: ProfileTab = ProfileTab::Posts, + feed_status: FeedStatus = FeedStatus::Idle, + search_query: Text = "", + search_status: SearchStatus = SearchStatus::Explore, + comments_post: Option = None, + comment_draft: Text = "", + pending_comment: Option<(RequestId, Text)> = None, + upload: Upload = Upload::Empty, + caption: Text = "", + alt: Text = "", + like_overlay: Map = Map::empty(), + save_overlay: Map = Map::empty(), + follow_overlay: Map = Map::empty(), + like_pending: Set = Set::empty(), + save_pending: Set = Set::empty(), + follow_pending: Set = Set::empty(), + story_pending: Set = Set::empty(), + pending: Map = Map::empty(), + settled: Set = Set::empty(), + next_request: Nat = 0, + } + + fn page_of(value: Option) -> Page { + match value { + None => Page::None, + Some(Location::Feed) => Page::Feed, + Some(Location::Search) => Page::Search, + Some(Location::Create) => Page::Create, + Some(Location::Reels) => Page::Reels, + Some(Location::Post { + id, + }) => Page::Post { + id, + }, + Some(Location::Profile { + user, + }) => Page::Profile { + user, + }, + Some(Location::Followers { + user, + }) => Page::Followers { + user, + }, + Some(Location::Following { + user, + }) => Page::Following { + user, + }, + Some(Location::Story { + id, + }) => Page::Story { + id, + }, + } + } + + fn section_location(section: Section) -> Location { + match section { + Section::Feed => Location::Feed, + Section::Search => Location::Search, + Section::Create => Location::Create, + Section::Reels => Location::Reels, + Section::Profile => Location::Profile { + user: USER_MIRA, + }, + } + } + + fn can_load(value: Authority) -> Bool { + match value { + Authority::Loading => false, + Authority::Failed { + .., + } => false, + Authority::Ready { + data, + } => data.feed_has_more, + } + } + + computed page: Page = page_of(location); + + invariant { + next_request >= pending.len(), + settled.len() <= next_request, + } + + part notice = Notice(); + + part notice_controls = NoticeControls(notice.reads, notice.updates); + + observe { + page, + location, + authority: authority_state, + profile_tab, + feed_status, + search_query, + search_status, + comments_post, + comment_draft, + pending_comment, + upload, + caption, + alt, + notice: notice.reads.current, + like_overlay, + save_overlay, + follow_overlay, + like_pending, + save_pending, + follow_pending, + story_pending, + } + + on router.Changed(next) { + location = Some(next); + Accepted + } + + on authority.Observed(next) { + authority_state = next; + Accepted + } + + on SelectTab(section) { + let target = section_location(section); + match location { + Some(current) => { + if current == target { + if section == Section::Feed { + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + feed_status = FeedStatus::Loading; + pending = pending.put(request, Mutation::ReloadFeed); + emit mutations.Request(request, Mutation::ReloadFeed); + return Accepted; + } + return Duplicate; + } + }, + None => {}, + } + emit router.Replace(target); + Accepted + } + + on OpenPost(id) { + emit router.Push(Location::Post { + id, + }); + Accepted + } + + on OpenProfile(user) { + emit router.Push(Location::Profile { + user, + }); + Accepted + } + + on OpenFollowers(user) { + emit router.Push(Location::Followers { + user, + }); + Accepted + } + + on OpenFollowing(user) { + emit router.Push(Location::Following { + user, + }); + Accepted + } + + on OpenStory(id) { + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + story_pending = story_pending.add(id); + pending = pending.put(request, Mutation::MarkStory { + story: id, + }); + emit mutations.Request(request, Mutation::MarkStory { + story: id, + }); + emit router.Push(Location::Story { + id, + }); + Accepted + } + + on MarkStorySeen(id) { + if story_pending.contains(id) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + story_pending = story_pending.add(id); + pending = pending.put(request, Mutation::MarkStory { + story: id, + }); + emit mutations.Request(request, Mutation::MarkStory { + story: id, + }); + Accepted + } + + on GoBack { + emit router.Replace(Location::Feed); + Accepted + } + + on ToggleLike(post, liked) { + if like_pending.contains(post) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + like_overlay = like_overlay.put(post, liked); + like_pending = like_pending.add(post); + pending = pending.put(request, Mutation::SetLike { + post, + liked, + }); + emit mutations.Request(request, Mutation::SetLike { + post, + liked, + }); + Accepted + } + + on ToggleSave(post, saved) { + if save_pending.contains(post) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + save_overlay = save_overlay.put(post, saved); + save_pending = save_pending.add(post); + pending = pending.put(request, Mutation::SetSave { + post, + saved, + }); + emit mutations.Request(request, Mutation::SetSave { + post, + saved, + }); + Accepted + } + + on FeedNearEnd { + if feed_status == FeedStatus::Loading { + return Duplicate; + } + if !can_load(authority_state) { + return Blocked("feed is exhausted"); + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + feed_status = FeedStatus::Loading; + pending = pending.put(request, Mutation::LoadMore); + emit mutations.Request(request, Mutation::LoadMore); + Accepted + } + + on RetryFeed { + if feed_status == FeedStatus::Loading { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + feed_status = FeedStatus::Loading; + pending = pending.put(request, Mutation::ReloadFeed); + emit mutations.Request(request, Mutation::ReloadFeed); + Accepted + } + + on OpenComments(post) { + if comments_post == Some(post) { + return Duplicate; + } + comments_post = Some(post); + comment_draft = ""; + pending_comment = None; + Accepted + } + + on DismissComments { + if comments_post == None { + return Duplicate; + } + comments_post = None; + comment_draft = ""; + pending_comment = None; + Accepted + } + + on CommentChanged(value) { + if comment_draft == value { + return Duplicate; + } + comment_draft = value; + Accepted + } + + on SubmitComment { + if comment_draft == "" { + return Invalid("comment body is empty"); + } + if pending_comment is Some(_) { + return Duplicate; + } + let post = match comments_post { + None => return Blocked("comments are closed"), + Some(post) => post, + }; + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + let body = comment_draft; + next_request = serial; + pending_comment = Some((request, body)); + comment_draft = ""; + pending = pending.put(request, Mutation::AddComment { + post, + body, + }); + emit mutations.Request(request, Mutation::AddComment { + post, + body, + }); + Accepted + } + + on ToggleFollow(user, following) { + if user == USER_MIRA { + return Invalid("the viewer cannot follow itself"); + } + if follow_pending.contains(user) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + follow_overlay = follow_overlay.put(user, following); + follow_pending = follow_pending.add(user); + pending = pending.put(request, Mutation::SetFollow { + user, + following, + }); + emit mutations.Request(request, Mutation::SetFollow { + user, + following, + }); + Accepted + } + + on SelectProfileTab(tab) { + if profile_tab == tab { + return Duplicate; + } + profile_tab = tab; + Accepted + } + + on SearchChanged(value) { + if search_query == value { + return Duplicate; + } + search_query = value; + if value == "" { + search_status = SearchStatus::Explore; + } + Accepted + } + + on SubmitSearch { + if search_query == "" { + return Invalid("search query is empty"); + } + if search_status == SearchStatus::Searching { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + search_status = SearchStatus::Searching; + pending = pending.put(request, Mutation::SearchPeople { + query: search_query, + }); + emit mutations.Request(request, Mutation::SearchPeople { + query: search_query, + }); + Accepted + } + + on ChooseImage { + match upload { + Upload::Choosing => return Duplicate, + Upload::Publishing { + .., + } => return Blocked("publish is in flight"), + _ => {}, + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + upload = Upload::Choosing; + pending = pending.put(request, Mutation::ChooseImage); + emit mutations.Request(request, Mutation::ChooseImage); + Accepted + } + + on CaptionChanged(value) { + if caption == value { + return Duplicate; + } + caption = value; + Accepted + } + + on AltChanged(value) { + if alt == value { + return Duplicate; + } + alt = value; + Accepted + } + + on PublishImage { + match upload { + Upload::Uploaded { + object, + preview, + name, + } => { + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + upload = Upload::Publishing { + object, + preview, + name, + }; + pending = pending.put(request, Mutation::PublishImage { + object, + caption, + alt, + }); + emit mutations.Request(request, Mutation::PublishImage { + object, + caption, + alt, + }); + return Accepted; + }, + Upload::Publishing { + .., + } => return Duplicate, + _ => return Blocked("no uploaded image"), + } + } + + on mutations.Settled(request, result) { + if settled.contains(request) { + return Stale; + } + let mutation = match pending.get(request) { + None => return Stale, + Some(mutation) => mutation, + }; + pending = pending.remove(request); + settled = settled.add(request); + match mutation { + Mutation::SetLike { + post, + liked: _, + } => { + like_pending = like_pending.remove(post); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + like_overlay = like_overlay.remove(post); + notice.updates.show("Couldn't update this like. Try again."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::SetSave { + post, + saved: _, + } => { + save_pending = save_pending.remove(post); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + save_overlay = save_overlay.remove(post); + notice.updates.show("Couldn't update this saved post."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::LoadMore => { + match result { + Settlement::Accepted => { + feed_status = FeedStatus::Idle; + }, + Settlement::Refused { + .., + } => { + feed_status = FeedStatus::Failed; + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::ReloadFeed => { + match result { + Settlement::Accepted => { + feed_status = FeedStatus::Idle; + }, + Settlement::Refused { + .., + } => { + feed_status = FeedStatus::Failed; + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::SetFollow { + user, + following: _, + } => { + follow_pending = follow_pending.remove(user); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + follow_overlay = follow_overlay.remove(user); + notice.updates.show("Couldn't update this relationship."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::AddComment { + post: _, + body, + } => { + pending_comment = None; + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + comment_draft = body; + notice.updates.show("Couldn't post your comment. Try again."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::SearchPeople { + query, + } => { + match result { + Settlement::Accepted => { + if query == "nils" { + search_status = SearchStatus::Results; + } else { + search_status = SearchStatus::NoResults; + } + }, + Settlement::Refused { + .., + } => { + search_status = SearchStatus::NoResults; + notice.updates.show("Search isn't available."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::ChooseImage => { + match result { + Settlement::ImageReady { + object, + preview, + name, + } => { + upload = Upload::Uploaded { + object, + preview, + name, + }; + }, + Settlement::Refused { + .., + } => { + upload = Upload::Empty; + notice.updates.show("Choose a JPEG, PNG, or WebP image."); + }, + Settlement::Accepted => return Invalid("image selection omitted its object"), + } + }, + Mutation::PublishImage { + .., + } => { + match result { + Settlement::Accepted => { + upload = Upload::Empty; + caption = ""; + alt = ""; + emit router.Replace(Location::Feed); + }, + Settlement::Refused { + .., + } => { + match upload { + Upload::Publishing { + object, + preview, + name, + } => { + upload = Upload::Uploaded { + object, + preview, + name, + }; + }, + _ => {}, + } + notice.updates.show("Couldn't publish this post. Try again."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::MarkStory { + story, + } => { + story_pending = story_pending.remove(story); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => {}, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + } + Accepted + } +} diff --git a/examples/instagram/client/parts.uhura b/examples/instagram/client/parts.uhura new file mode 100644 index 0000000..e7444e4 --- /dev/null +++ b/examples/instagram/client/parts.uhura @@ -0,0 +1,43 @@ +pub part Notice { + state { + message: Option = None, + } + + pub computed current: Option = message; + + observe {} + + pub update show(next: Text) { + message = Some(next); + } + + pub update dismiss() { + message = None; + } +} + +pub part NoticeControls( + notice: Notice::Reads, + notice_updates: Notice::Updates, +) { + requires outcomes { + commit Accepted, + abort Duplicate, + } + + events { + DismissNotice, + } + + state {} + + observe {} + + on DismissNotice { + if notice.current == None { + return Duplicate; + } + notice_updates.dismiss(); + Accepted + } +} diff --git a/examples/instagram/client/ports/comments.port.toml b/examples/instagram/client/ports/comments.port.toml deleted file mode 100644 index 53a624b..0000000 --- a/examples/instagram/client/ports/comments.port.toml +++ /dev/null @@ -1,52 +0,0 @@ -# The per-post comment thread (design §9.1: keyed projection `for-post` + -# `add-comment`). Ports are separate namespaces: `image-ref`/`user-ref` here -# are this contract's own declarations; cross-port compatibility is -# structural (micro-decision — canonical shapes compare equal). - -[port] -name = "comments" -version = "0.1.0" - -[types.image-ref] -kind = "record" - -[types.image-ref.fields] -src = "asset" -alt = "text" - -[types.user-ref] -kind = "record" - -[types.user-ref.fields] -id = "id" -username = "text" -display-name = "text" -avatar = "image-ref" - -[types.comment] -kind = "record" - -[types.comment.fields] -id = "id" -author = "user-ref" -body = "text" -posted-label = "text" - -[types.comment-thread] -kind = "record" - -[types.comment-thread.fields] -comments = "list" - -# Keyed by post id: `for-post(post)` (§9.2). -[projections.for-post] -type = "comment-thread" -key = "id" - -[refusals.not-authorized] -[refusals.comment-body-invalid] -[refusals.not-found] - -[commands.add-comment] -payload = { post = "id", body = "text" } -refusals = ["not-authorized", "comment-body-invalid", "not-found"] diff --git a/examples/instagram/client/ports/create.port.toml b/examples/instagram/client/ports/create.port.toml deleted file mode 100644 index 5473154..0000000 --- a/examples/instagram/client/ports/create.port.toml +++ /dev/null @@ -1,32 +0,0 @@ -# The create-post seam. Picking and uploading a file are provider/platform -# concerns; Core sees only a storage-object id and serializable preview/name -# metadata, never the browser File or its bytes. - -[port] -name = "create" -version = "0.1.0" - -[types.create-draft] -kind = "union" - -[types.create-draft.variants.empty] - -[types.create-draft.variants.uploaded] -object = "id" -preview = "asset" -name = "text" - -[projections.draft] -type = "create-draft" - -[refusals.not-authorized] -[refusals.image-not-ready] -[refusals.unsupported-media-type] - -[commands.choose-image] -payload = {} -refusals = ["unsupported-media-type"] - -[commands.publish-image] -payload = { image = "id", caption = "text", alt = "text" } -refusals = ["not-authorized", "image-not-ready", "unsupported-media-type"] diff --git a/examples/instagram/client/ports/feed.port.toml b/examples/instagram/client/ports/feed.port.toml deleted file mode 100644 index 6cad524..0000000 --- a/examples/instagram/client/ports/feed.port.toml +++ /dev/null @@ -1,157 +0,0 @@ -# The feed read/write surface (design §9.1 — normative). The canonical-form -# hash of this contract is pinned in uhura.lock; drift is a link error. - -[port] -name = "feed" -version = "0.1.0" - -[types.image-ref] -kind = "record" - -[types.image-ref.fields] -src = "asset" -alt = "text" - -[types.user-ref] -kind = "record" - -[types.user-ref.fields] -id = "id" -username = "text" -display-name = "text" -avatar = "image-ref" - -[types.slide] -kind = "record" - -[types.slide.fields] -id = "id" -src = "asset" -alt = "text" - -[types.story-ring] -kind = "record" - -[types.story-ring.fields] -id = "id" -user = "user-ref" -has-unseen = "bool" -is-self = "bool" - -[types.story-detail] -kind = "record" - -[types.story-detail.fields] -id = "id" -author = "user-ref" -image = "image-ref" -caption = "text" -posted-label = "text" -viewer-has-viewed = "bool" -previous = "option" -next = "option" -progress = "list" - -[types.story-progress] -kind = "record" - -[types.story-progress.fields] -id = "id" -is-current = "bool" -is-viewed = "bool" - -[types.media] -kind = "union" - -[types.media.variants.image] -image = "image-ref" - -[types.media.variants.carousel] -slides = "list" - -[types.media.variants.video] -src = "asset" -poster = "image-ref" - -[types.post-summary] -kind = "record" - -[types.post-summary.fields] -id = "id" -author = "user-ref" -media = "media" -caption = "text" -like-count = "int" -comment-count = "int" -viewer-has-liked = "bool" -viewer-has-saved = "bool" -# provider-formatted; core has no clock (§9.1) -posted-label = "text" - -[types.feed-cursor] -kind = "opaque" - -[types.feed-page] -kind = "record" - -[types.feed-page.fields] -stories = "list" -posts = "list" -cursor = "option" -has-more = "bool" - -[types.reels-page] -kind = "record" - -[types.reels-page.fields] -posts = "list" - -# Delivered before Init; bare reads are legal (§9.2). -[projections.viewer] -type = "user-ref" -boot = true - -[projections.feed-page] -type = "feed-page" - -# Keyed detail carriers make profile-grid navigation and story viewing use -# the same authority-owned records as the feed. -[projections.post-by-id] -type = "post-summary" -key = "id" - -[projections.story-by-id] -type = "story-detail" -key = "id" - -[projections.reels] -type = "reels-page" - -[refusals.not-authorized] -[refusals.not-found] - -[commands.like-post] -payload = { post = "id" } -refusals = ["not-authorized", "not-found"] - -[commands.unlike-post] -payload = { post = "id" } -refusals = ["not-authorized"] - -[commands.save-post] -payload = { post = "id" } -refusals = ["not-authorized", "not-found"] - -[commands.unsave-post] -payload = { post = "id" } -refusals = ["not-authorized"] - -[commands.load-next-page] -payload = { cursor = "option" } - -[commands.reload] -payload = {} - -[commands.mark-story-seen] -payload = { story = "id" } -refusals = ["not-authorized", "not-found"] diff --git a/examples/instagram/client/ports/profile.port.toml b/examples/instagram/client/ports/profile.port.toml deleted file mode 100644 index ad0e3b7..0000000 --- a/examples/instagram/client/ports/profile.port.toml +++ /dev/null @@ -1,101 +0,0 @@ -# Profiles and their relationship lists. Counts are integers derived from -# authority rows; the client owns formatting, never pre-seeded labels. - -[port] -name = "profile" -version = "0.1.0" - -[types.image-ref] -kind = "record" - -[types.image-ref.fields] -src = "asset" -alt = "text" - -[types.user-ref] -kind = "record" - -[types.user-ref.fields] -id = "id" -username = "text" -display-name = "text" -avatar = "image-ref" - -[types.thumb] -kind = "record" - -[types.thumb.fields] -# The real post id, so a grid tile can navigate to feed.post(id). -id = "id" -src = "asset" -alt = "text" - -[types.connection] -kind = "record" - -[types.connection.fields] -user = "user-ref" -viewer-follows = "bool" - -[types.connection-list] -kind = "record" - -[types.connection-list.fields] -people = "list" - -[types.profile-view] -kind = "record" - -[types.profile-view.fields] -user = "user-ref" -bio = "text" -is-self = "bool" -viewer-follows = "bool" -post-count = "int" -follower-count = "int" -following-count = "int" -posts = "list" -reels = "list" -saved = "list" -tagged = "list" - -[types.search-view] -kind = "record" - -[types.search-view.fields] -people = "list" -posts = "list" - -# Keyed by user id: `profile(user)` (§9.2). -[projections.profile] -type = "profile-view" -key = "id" - -[projections.followers] -type = "connection-list" -key = "id" - -[projections.following] -type = "connection-list" -key = "id" - -# Initially all other people; search-people replaces this slice with the -# provider-filtered result. Search remains useful in Play and deterministic -# in read-only Editor previews without leaking a database query primitive into Core. -[projections.search-results] -type = "search-view" - -[refusals.not-authorized] -[refusals.not-found] -[refusals.cannot-follow-self] - -[commands.follow-user] -payload = { user = "id" } -refusals = ["not-authorized", "not-found", "cannot-follow-self"] - -[commands.unfollow-user] -payload = { user = "id" } -refusals = ["not-authorized"] - -[commands.search-people] -payload = { query = "text" } diff --git a/examples/instagram/client/providers/spock.test.ts b/examples/instagram/client/providers/spock.test.ts index c5a4f6b..5899689 100644 --- a/examples/instagram/client/providers/spock.test.ts +++ b/examples/instagram/client/providers/spock.test.ts @@ -1,1123 +1,537 @@ import assert from "node:assert/strict"; -import { test, vi } from "vitest"; +import { test } from "vitest"; -import { - createDriver, - type ProviderHost, - type SpockDriver, -} from "./spock.js"; +import { createUhuraAdapters } from "./spock.js"; -interface Decoded { - [key: string]: unknown; - kind?: string; - port?: string; - projection?: string; - key?: unknown; - value?: Decoded; - outcome?: unknown; - author: Decoded; - user: Decoded; - updates: Decoded[]; - posts: Decoded[]; - stories: Decoded[]; - people: Decoded[]; - saved: Decoded[]; - reels: Decoded[]; - progress: Decoded[]; +interface WireValue { + readonly $: string; + readonly [field: string]: unknown; } -interface RpcCall { - url: string; - init: RequestInit; -} - -interface PublishedPayload { - image: string; - caption: string; - alt: string; -} type TestFetch = ( input: RequestInfo | URL, init: RequestInit, ) => Promise; +const MODULE = "app.instagram@1"; +const MACHINE = `${MODULE}::Instagram`; +const POST_ID = `${MODULE}::PostId`; +const REQUEST_ID = `${MODULE}::RequestId`; +const MUTATION = `${MODULE}::Mutation`; +const MUTATIONS_SEND = `${MACHINE}::port.mutations.Send`; + const MIRA = "user-mira"; const LENA = "user-lena"; -const THEO = "user-theo"; - -const USERS = [ - { - id: LENA, - username: "lena.holt", - display_name: "Lena Holt", - avatar: { id: "avatar-lena" }, - avatar_alt: "Lena Holt", - bio: "Clay and slow mornings", - }, - { - id: MIRA, - username: "mira.santos", - display_name: "Mira Santos", - avatar: { id: "avatar-mira" }, - avatar_alt: "Mira Santos", - bio: "Designer", - }, - { - id: THEO, - username: "theo.okafor", - display_name: "Theo Okafor", - avatar: { id: "avatar-theo" }, - avatar_alt: "Theo Okafor", - bio: "Courts and murals", - }, -]; - -const BASE_SNAPSHOT = { - users: USERS, - stories: [ - { - id: "story-mira-1", - author: { id: MIRA }, - position: 1, - media_file: { id: "story-media-mira" }, - media_alt: "Breakfast on a marble counter", - caption: "Breakfast", - published_at: "2026-07-13T15:30:00Z", - }, - { - id: "story-lena-1", - author: { id: LENA }, - position: 1, - media_file: { id: "story-media-lena-1" }, - media_alt: "Clay on a wheel", - caption: "Centering", - published_at: "2026-07-13T15:00:00Z", - }, - { - id: "story-lena-2", - author: { id: LENA }, - position: 2, - media_file: { id: "story-media-lena-2" }, - media_alt: "A tall clay cylinder", - caption: "One pull", - published_at: "2026-07-13T15:10:00Z", - }, - { - id: "story-lena-3", - author: { id: LENA }, - position: 3, - media_file: { id: "story-media-lena-3" }, - media_alt: "A clean ceramics bench", - caption: "Reset", - published_at: "2026-07-13T15:20:00Z", - }, - { - id: "story-theo-1", - author: { id: THEO }, - position: 1, - media_file: { id: "story-media-theo" }, - media_alt: "A newly painted court", - caption: "Finished", - published_at: "2026-07-13T15:25:00Z", - }, - ], - storyViews: [ - { viewer: { id: MIRA }, story: { id: "story-lena-1" }, at: "2026-07-13T16:00:00Z" }, - ], - posts: [ - { - id: "post-theo-image", - author: { id: THEO }, - caption: "Court mural in cobalt and orange", - published_at: "2026-07-13T14:00:00Z", - show_in_feed: true, - media_kind: "image", - media_file: { id: "media-theo" }, - video_file: null, - media_alt: "A geometric basketball court mural", - }, - { - id: "post-lena-video", - author: { id: LENA }, - caption: "Kiln notes from Lena", - published_at: "2026-07-13T13:00:00Z", - show_in_feed: true, - media_kind: "video", - media_file: { id: "poster-lena" }, - video_file: { id: "video-lena" }, - media_alt: "Copper glaze moving through kiln light", - }, - { - id: "post-mira-image", - author: { id: MIRA }, - caption: "First tram", - published_at: "2026-07-13T12:00:00Z", - show_in_feed: true, - media_kind: "image", - media_file: { id: "media-mira" }, - video_file: null, - media_alt: "Tram rails at sunrise", - }, - { - id: "post-lena-archive", - author: { id: LENA }, - caption: "Shelf of celadon tests", - published_at: "2026-07-10T12:00:00Z", - show_in_feed: false, - media_kind: "image", - media_file: { id: "media-lena-archive" }, - video_file: null, - media_alt: "Celadon test cups on a shelf", - }, - { - id: "post-mira-video", - author: { id: MIRA }, - caption: "Last ferry home", - published_at: "2026-07-09T12:00:00Z", - show_in_feed: false, - media_kind: "video", - media_file: { id: "poster-mira" }, - video_file: { id: "video-mira" }, - media_alt: "Ferry wake at golden hour", - }, - ], - slides: [], - comments: [ - { - id: "comment-1", - post: { id: "post-lena-video" }, - author: { id: MIRA }, - body: "That light is perfect.", - created_at: "2026-07-13T13:30:00Z", - }, - ], - likes: [ - { user: { id: MIRA }, post: { id: "post-lena-video" }, at: "2026-07-13T13:20:00Z" }, - { user: { id: THEO }, post: { id: "post-lena-video" }, at: "2026-07-13T13:21:00Z" }, - ], - saves: [ - { user: { id: MIRA }, post: { id: "post-theo-image" }, at: "2026-07-13T14:10:00Z" }, - { user: { id: LENA }, post: { id: "post-mira-image" }, at: "2026-07-13T14:11:00Z" }, - ], - follows: [ - { follower: { id: MIRA }, followed: { id: LENA }, at: "2026-07-01T00:00:00Z" }, - { follower: { id: THEO }, followed: { id: MIRA }, at: "2026-07-02T00:00:00Z" }, - ], - postTags: [ - { post: { id: "post-theo-image" }, person: { id: LENA } }, - ], -}; - -function snapshot(): typeof BASE_SNAPSHOT { - return structuredClone(BASE_SNAPSHOT); -} -function driver( - actor = "mira.santos", - host: ProviderHost = { - signal: new AbortController().signal, - pickFile: async () => null, - }, -): SpockDriver { - return createDriver( - { - graphql_url: "http://spock.test/graphql/v1", - rpc_url: "http://spock.test/rest/v1/rpc", - storage_url: "http://spock.test/storage/v1", - actor, - }, - host, - ); -} - -function graphql(data: unknown): Response { - return new Response(JSON.stringify({ data })); +function snapshot() { + return { + users: [ + { + id: MIRA, + username: "mira.santos", + display_name: "Mira Santos", + avatar: { id: "avatar-mira" }, + avatar_alt: "Mira Santos", + bio: "Designer", + }, + { + id: LENA, + username: "lena.holt", + display_name: "Lena Holt", + avatar: { id: "avatar-lena" }, + avatar_alt: "Lena Holt", + bio: "Clay and slow mornings", + }, + ], + stories: [ + { + id: "story-lena-1", + author: { id: LENA }, + position: 1, + media_file: { id: "story-media-lena" }, + media_alt: "Clay on a wheel", + caption: "Centering", + published_at: "2026-07-13T15:00:00Z", + }, + ], + storyViews: [], + posts: [ + { + id: "post-lena-image", + author: { id: LENA }, + caption: "Kiln notes from Lena", + published_at: "2026-07-13T13:00:00Z", + show_in_feed: true, + media_kind: "image", + media_file: { id: "media-lena" }, + video_file: null, + media_alt: "Copper glaze moving through kiln light", + }, + ], + slides: [], + comments: [], + likes: [], + saves: [], + follows: [ + { + follower: { id: MIRA }, + followed: { id: LENA }, + at: "2026-07-01T00:00:00Z", + }, + ], + postTags: [], + }; } -function frameworkEnvironment( - authority: Record = { - graphql_path: "/framework/graphql", - rpc_path: "/framework/rpc", - storage_path: "/framework/storage", - }, -): Record { +function frameworkEnvironment(): Record { return { protocol: "spock-host-environment/1", mode: "dev", project_generation_id: 7, backend_generation_id: 3, - authority, + authority: { + graphql_path: "/framework/graphql", + rpc_path: "/framework/rpc", + storage_path: "/framework/storage", + }, }; } function whoami(init: RequestInit): Response { - const headers = new Headers(init?.headers); - const actor = headers.get("x-spock-actor"); + const actor = new Headers(init.headers).get("x-spock-actor"); return new Response( JSON.stringify({ actor, known: true, anonymous: actor === null }), ); } -function requestBody(init: RequestInit): string { - if (typeof init.body !== "string") { - throw new Error("expected a JSON request body"); - } - return init.body; -} - async function withFetch( fetcher: TestFetch, run: () => Promise, ): Promise { - const originalFetch = globalThis.fetch; + const original = globalThis.fetch; globalThis.fetch = (input, init) => fetcher(input, init ?? {}); try { return await run(); } finally { - globalThis.fetch = originalFetch; + globalThis.fetch = original; } } -function bootMessages(remote: SpockDriver): Decoded[] { - return remote.tick().map((message) => JSON.parse(message) as Decoded); +function variant( + type: string, + caseName: string, + fields: ReadonlyArray = [], +): WireValue { + return { + $: "variant", + type, + case: caseName, + fields: fields.map(([name, value]) => ({ name, value })), + }; } -function projection( - messages: Decoded[], - port: string, - name: string, - key: unknown = null, -): Decoded { - const found = messages.find( - (message) => - message.kind === "projection" && - message.port === port && - message.projection === name && - message.key === key, - ); - assert.ok(found, `missing ${port}.${name}(${JSON.stringify(key)})`); - return found.value as Decoded; +function text(value: string): WireValue { + return { $: "Text", value }; } -function update( - outcome: Decoded, - port: string, - name: string, - key: unknown = null, -): Decoded { - const found = outcome.updates.find( - (candidate) => - candidate.port === port && - candidate.projection === name && - candidate.key === key, - ); - assert.ok(found, `missing update ${port}.${name}(${JSON.stringify(key)})`); - return found.value as Decoded; +function bool(value: boolean): WireValue { + return { $: "bool", value }; } -function command( - port: string, - name: string, - payload: Record, - correlation = `${port}-${name}`, -): string { - return JSON.stringify({ - kind: "command", - port, - command: name, - correlation, - payload, - }); +function key(type: string, value: WireValue): WireValue { + return { $: "key", type, value }; } -async function settle(remote: SpockDriver): Promise { - const messages: Decoded[] = []; - for (let attempt = 0; attempt < 100; attempt += 1) { - messages.push(...remote.tick().map((message) => JSON.parse(message))); - if (remote.idle()) return messages; - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error("provider command did not settle"); +function textKeyMapKeys(value: WireValue): string[] { + assert.equal(value.$, "map"); + assert.ok(Array.isArray(value.entries)); + return (value.entries as WireValue[][]).map(([entryKey]) => { + assert.ok(entryKey); + assert.equal(entryKey.$, "key"); + assert.equal(typeof entryKey.value, "object"); + assert.notEqual(entryKey.value, null); + const underlying = entryKey.value as WireValue; + assert.equal(underlying.$, "Text"); + assert.equal(typeof underlying.value, "string"); + return underlying.value as string; + }); } -function onlyOutcome(messages: Decoded[]): Decoded { - const outcomes = messages.filter((message) => message.kind === "outcome"); - assert.equal(outcomes.length, 1); - const [outcome] = outcomes; - assert.ok(outcome); - assert.deepEqual(outcome.outcome, { ok: {} }); - return outcome; +function request( + id: number, + mutation: string, + fields: ReadonlyArray = [], +): WireValue { + return variant(MUTATIONS_SEND, "request", [ + [ + "id", + key(REQUEST_ID, { $: "PositiveInt", value: String(id) }), + ], + ["payload", variant(MUTATION, mutation, fields)], + ]); } -test("prefers one strictly typed framework environment before authority work", async () => { - const data = snapshot(); - const calls: string[] = []; - await withFetch(async (input, init) => { - const url = String(input); - calls.push(url); - if (url === "/~project/environment") { - assert.equal(init.method, "GET"); - assert.equal(new Headers(init.headers).get("accept"), "application/json"); - return new Response(JSON.stringify(frameworkEnvironment())); - } - if (url === "/framework/graphql") return graphql(data); - if (url === "/~whoami") return whoami(init); - if (url === "/framework/storage/object/sign/media-theo") { - return new Response( - JSON.stringify({ - url: "/framework/storage/object/media-theo?exp=9999999999&sig=test", - }), - ); - } - if (url === "/framework/rpc/unlike_post") { - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); - } - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - assert.equal( - await remote.resolveAsset("media-theo"), - "/framework/storage/object/media-theo?exp=9999999999&sig=test", - ); - remote.deliver( - command("feed", "unlike-post", { post: "post-lena-video" }), - ); - onlyOutcome(await settle(remote)); - remote.dispose(); - }); +function caseOf(value: unknown): string { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + assert.equal((value as Record).$, "variant"); + const caseName = (value as Record).case; + assert.equal(typeof caseName, "string"); + return caseName as string; +} - assert.equal(calls[0], "/~project/environment"); - assert.equal( - calls.filter((url) => url === "/~project/environment").length, - 1, +function namedField(value: unknown, name: string): WireValue { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + const fields = (value as Record).fields; + assert.ok(Array.isArray(fields)); + const found = fields.find( + (field) => + typeof field === "object" + && field !== null + && (field as Record).name === name, ); - assert.ok(calls.includes("/framework/graphql")); - assert.ok(calls.includes("/framework/rpc/unlike_post")); - assert.ok(calls.includes("/framework/storage/object/sign/media-theo")); - assert.equal(calls.some((url) => url.startsWith("http://spock.test")), false); -}); + assert.ok(found, `missing wire field ${name}`); + return (found as Record).value as WireValue; +} -test("falls back for unavailable or invalid framework metadata", async () => { - const cases: Array<{ name: string; response: () => Response }> = [ - { - name: "unavailable", - response: () => new Response(null, { status: 404 }), +function onlyField(value: unknown): WireValue { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + const fields = (value as Record).fields; + assert.ok(Array.isArray(fields)); + assert.equal(fields.length, 1); + return (fields[0] as Record).value as WireValue; +} + +function makeHarness( + pickFile: () => Promise = async () => null, +) { + const abort = new AbortController(); + const requirements = { + authority: { + port: "authority", + adapter: "app.provider", + contractHash: "authority-contract", + contractInstanceHash: "authority-instance", }, - { - name: "wrong protocol", - response: () => - new Response( - JSON.stringify({ - ...frameworkEnvironment(), - protocol: "spock-host-environment/0", - }), - ), + mutations: { + port: "mutations", + adapter: "app.provider", + contractHash: "mutations-contract", + contractInstanceHash: "mutations-instance", }, + } as const; + const provider = createUhuraAdapters( { - name: "extra top-level provider data", - response: () => - new Response( - JSON.stringify({ ...frameworkEnvironment(), provider: { actor: THEO } }), - ), + graphql_url: "http://standalone.test/graphql/v1", + rpc_url: "http://standalone.test/rest/v1/rpc", + storage_url: "http://standalone.test/storage/v1", + actor: "mira.santos", }, { - name: "absolute authority URL", - response: () => - new Response( - JSON.stringify( - frameworkEnvironment({ - graphql_path: "https://other.test/graphql", - rpc_path: "/framework/rpc", - storage_path: "/framework/storage", - }), - ), - ), + signal: abort.signal, + pickFile: async () => pickFile(), + port(name: string) { + if (name !== "authority" && name !== "mutations") { + throw new Error(`unexpected port ${name}`); + } + return requirements[name]; + }, }, - { - name: "invalid generation", - response: () => - new Response( - JSON.stringify({ - ...frameworkEnvironment(), - backend_generation_id: 0, - }), - ), + ); + const authority = provider.adapters.find( + (adapter) => adapter.port === "authority", + ); + const mutations = provider.adapters.find( + (adapter) => adapter.port === "mutations", + ); + assert.ok(authority); + assert.ok(mutations); + assert.equal(authority.adapter, "app.provider"); + assert.equal(mutations.adapter, "app.provider"); + const authorityValues: WireValue[] = []; + const mutationValues: WireValue[] = []; + const authorityContext = { + signal: abort.signal, + deliver(value: WireValue): void { + authorityValues.push(value); }, - ]; - - for (const candidate of cases) { - const calls: string[] = []; - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - calls.push(url); - if (url === "/~project/environment") return candidate.response(); - if (url === "http://spock.test/graphql/v1") return graphql(data); - if (url === "http://spock.test/~whoami") return whoami(init); - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - remote.dispose(); - }); - assert.deepEqual( - calls.slice(0, 2), - ["/~project/environment", "http://spock.test/graphql/v1"], - candidate.name, - ); - } -}); + }; + const mutationsContext = { + signal: abort.signal, + deliver(value: WireValue): void { + mutationValues.push(value); + }, + }; + return { + abort, + provider, + authority, + mutations, + authorityContext, + mutationsContext, + authorityValues, + mutationValues, + }; +} -test("bounds framework discovery before using standalone fallback endpoints", async () => { - const calls: string[] = []; +test("boots through admitted authority and mutation port identities", async () => { const data = snapshot(); - vi.useFakeTimers(); - try { - await withFetch(async (input, init) => { - const url = String(input); - calls.push(url); - if (url === "/~project/environment") { - return await new Promise((_resolve, reject) => { - const signal = init.signal; - const abort = (): void => - reject( - new DOMException("environment discovery timed out", "AbortError"), - ); - if (signal?.aborted) abort(); - else signal?.addEventListener("abort", abort, { once: true }); - }); - } - if (url === "http://spock.test/graphql/v1") return graphql(data); - if (url === "http://spock.test/~whoami") return whoami(init); - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - const boot = remote.assembleBoot(); - await vi.advanceTimersByTimeAsync(2_001); - await boot; - remote.dispose(); - }); - } finally { - vi.useRealTimers(); - } - - assert.deepEqual(calls.slice(0, 2), [ - "/~project/environment", - "http://spock.test/graphql/v1", - ]); -}); - -test("treats nullable integrated GraphQL as capability absence, not fallback", async () => { + data.stories.push({ + id: "s", + author: { id: LENA }, + position: 2, + media_file: { id: "story-media-lena-2" }, + media_alt: "Glaze buckets beside the kiln", + caption: "Firing day", + published_at: "2026-07-13T15:30:00Z", + }); const calls: string[] = []; - await withFetch(async (input) => { + await withFetch(async (input, init) => { const url = String(input); calls.push(url); if (url === "/~project/environment") { - return new Response( - JSON.stringify( - frameworkEnvironment({ - graphql_path: null, - rpc_path: "/framework/rpc", - storage_path: "/framework/storage", - }), - ), - ); + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } + if (url === "/~whoami") return whoami(init); throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver(); - await assert.rejects( - remote.assembleBoot(), - /integrated Spock host does not advertise a GraphQL capability/, + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + + assert.equal(harness.authority.contractHash, "authority-contract"); + assert.equal(harness.authority.contractInstanceHash, "authority-instance"); + assert.equal(harness.mutations.contractHash, "mutations-contract"); + assert.equal(harness.mutations.contractInstanceHash, "mutations-instance"); + assert.equal(harness.authorityValues.length, 1); + const observed = harness.authorityValues[0]; + assert.equal(caseOf(observed), "authority.observed"); + const authority = namedField(observed, "value"); + assert.equal(caseOf(authority), "Ready"); + const authorityData = namedField(authority, "data"); + const storyDetails = namedField(authorityData, "story_details"); + assert.equal(storyDetails.$, "map"); + assert.ok(Array.isArray(storyDetails.entries)); + assert.equal(storyDetails.entries.length, 2); + assert.deepEqual(textKeyMapKeys(storyDetails), ["s", "story-lena-1"]); + const storyDetailValues = (storyDetails.entries as WireValue[][]).map( + (entry) => entry[1], ); - remote.dispose(); - }); - - assert.deepEqual(calls, ["/~project/environment"]); - assert.equal(calls.some((url) => url.startsWith("http://spock.test")), false); -}); - -test("disposing during environment discovery aborts without authority fallback", async () => { - const controller = new AbortController(); - let markStarted!: () => void; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - let authorityCalls = 0; - - await withFetch(async (input, init) => { - if (String(input) !== "/~project/environment") { - authorityCalls += 1; - throw new Error(`unexpected authority fetch ${String(input)}`); + const previousOptions = storyDetailValues.map((detail) => + namedField(detail, "previous") + ); + const nextOptions = storyDetailValues.map((detail) => + namedField(detail, "next") + ); + const presentPrevious = previousOptions.filter( + (option) => caseOf(option) === "some", + ); + const presentNext = nextOptions.filter( + (option) => caseOf(option) === "some", + ); + assert.equal(presentPrevious.length, 1); + assert.equal(presentNext.length, 1); + for (const option of [...presentPrevious, ...presentNext]) { + assert.equal(namedField(option, "value").$, "key"); } - markStarted(); - return await new Promise((_resolve, reject) => { - init.signal?.addEventListener( - "abort", - () => reject(new DOMException("disposed", "AbortError")), - { once: true }, - ); - }); - }, async () => { - const remote = driver("mira.santos", { - signal: controller.signal, - pickFile: async () => null, - }); - const boot = remote.assembleBoot(); - await started; - controller.abort(); - await assert.rejects( - boot, - (error: unknown) => - error instanceof DOMException && error.name === "AbortError", + assert.deepEqual( + textKeyMapKeys(namedField(authorityData, "profiles")), + [LENA, MIRA], ); - }); - - assert.equal(authorityCalls, 0); -}); - -test("normalizes a configured username and exposes authority-owned actors", async () => { - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - return graphql(data); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - assert.deepEqual(remote.systemInfo(), { + assert.deepEqual(harness.provider.systemInfo(), { actor: MIRA, actors: [ { id: LENA, username: "lena.holt", label: "Lena Holt" }, { id: MIRA, username: "mira.santos", label: "Mira Santos" }, - { id: THEO, username: "theo.okafor", label: "Theo Okafor" }, - ], - }); - }); -}); - -test("retains the actor catalog when the configured actor is invalid", async () => { - const data = snapshot(); - await withFetch(async () => graphql(data), async () => { - const remote = driver("typo"); - await assert.rejects(remote.assembleBoot(), /actor `typo` is not a seeded user/); - assert.deepEqual(remote.systemInfo(), { - actor: "typo", - actors: [ - { id: LENA, username: "lena.holt", label: "Lena Holt" }, - { id: MIRA, username: "mira.santos", label: "Mira Santos" }, - { id: THEO, username: "theo.okafor", label: "Theo Okafor" }, ], }); + harness.provider.dispose(); }); -}); - -test("boot projects actor-filtered home, playable video, sequences, profiles, and explore", async () => { - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - return graphql(data); - }, async () => { - const remote = driver(); - const boot = JSON.parse(await remote.assembleBoot()); - assert.equal(boot.updates[0].value.id, MIRA); - const messages = bootMessages(remote); - - const home = projection(messages, "feed", "feed-page"); - assert.deepEqual(home.posts.map((post) => post.id), [ - "post-lena-video", - "post-mira-image", - ]); - assert.deepEqual(home.stories.map((ring) => ring.id), [ - "story-mira-1", - "story-lena-2", - ]); - const [selfStory] = home.stories; - const [firstPost] = home.posts; - assert.ok(selfStory); - assert.ok(firstPost); - assert.equal(selfStory["is-self"], true); - assert.equal(selfStory["has-unseen"], false); - assert.deepEqual(firstPost.media, { - video: { - src: "video-lena", - poster: { - src: "poster-lena", - alt: "Copper glaze moving through kiln light", - }, - }, - }); - assert.equal(firstPost["viewer-has-liked"], true); - assert.equal(firstPost["viewer-has-saved"], false); - - const middle = projection(messages, "feed", "story-by-id", "story-lena-2"); - assert.equal(middle.previous, "story-lena-1"); - assert.equal(middle.next, "story-lena-3"); - assert.deepEqual(middle.progress, [ - { id: "story-lena-1", "is-current": false, "is-viewed": true }, - { id: "story-lena-2", "is-current": true, "is-viewed": false }, - { id: "story-lena-3", "is-current": false, "is-viewed": false }, - ]); - const self = projection(messages, "profile", "profile", MIRA); - assert.equal(self["is-self"], true); - assert.equal(self["viewer-follows"], false); - assert.deepEqual(self.reels.map((post) => post.id), ["post-mira-video"]); - assert.deepEqual(self.saved.map((post) => post.id), ["post-theo-image"]); - - const lena = projection(messages, "profile", "profile", LENA); - assert.equal(lena["is-self"], false); - assert.equal(lena["viewer-follows"], true); - assert.deepEqual(lena.reels.map((post) => post.id), ["post-lena-video"]); - assert.deepEqual(lena.saved, []); - - const explore = projection(messages, "profile", "search-results"); - assert.deepEqual(explore.people.map((person) => person.user.id), [LENA, THEO]); - assert.deepEqual(explore.posts.map((post) => post.id), [ - "post-theo-image", - "post-lena-video", - "post-mira-image", - "post-lena-archive", - "post-mira-video", - ]); - }); + assert.deepEqual(calls.slice(0, 3), [ + "/~project/environment", + "/framework/graphql", + "/~whoami", + ]); }); -test("follow and unfollow refresh the relationship-filtered feed and story tray", async () => { +test("settles a machine mutation and publishes refreshed authority", async () => { const data = snapshot(); - const rpcCalls: RpcCall[] = []; + const rpcBodies: unknown[] = []; await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/follow_user")) { - rpcCalls.push({ url, init }); - data.follows.push({ - follower: { id: MIRA }, - followed: { id: THEO }, - at: "2026-07-13T17:00:00Z", - }); - return new Response(JSON.stringify({ follower: MIRA, followed: THEO })); - } - if (url.endsWith("/unfollow_user")) { - rpcCalls.push({ url, init }); - data.follows = data.follows.filter( - (edge) => !(edge.follower.id === MIRA && edge.followed.id === THEO), - ); - return new Response(JSON.stringify({ follower: MIRA, followed: THEO })); + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); } - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver(command("profile", "follow-user", { user: THEO })); - const followed = onlyOutcome(await settle(remote)); - const followedFeed = update(followed, "feed", "feed-page"); - assert.deepEqual(followedFeed.posts.map((post) => post.id), [ - "post-theo-image", - "post-lena-video", - "post-mira-image", - ]); - assert.deepEqual(followedFeed.stories.map((ring) => ring.user.id), [ - MIRA, - THEO, - LENA, - ]); - assert.equal( - update(followed, "profile", "profile", THEO)["viewer-follows"], - true, - ); - assert.equal( - update(followed, "profile", "search-results").people.find( - (person) => person.user.id === THEO, - )!["viewer-follows"], - true, - ); - - remote.deliver(command("profile", "unfollow-user", { user: THEO })); - const unfollowed = onlyOutcome(await settle(remote)); - const unfollowedFeed = update(unfollowed, "feed", "feed-page"); - assert.equal( - unfollowedFeed.posts.some((post) => post.author.id === THEO), - false, - ); - assert.equal( - unfollowedFeed.stories.some((ring) => ring.user.id === THEO), - false, - ); - - assert.equal(rpcCalls.length, 2); - for (const call of rpcCalls) { - assert.equal(new Headers(call.init.headers).get("x-spock-actor"), MIRA); - assert.deepEqual(JSON.parse(requestBody(call.init)), { target: THEO }); + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } - }); -}); - -test("save and unsave settle every viewer-specific post surface and private grid", async () => { - const data = snapshot(); - const rpcCalls: RpcCall[] = []; - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/save_post")) { - rpcCalls.push({ url, init }); - data.saves.push({ + if (url === "/~whoami") return whoami(init); + if (url === "/framework/rpc/like_post") { + assert.equal(init.method, "POST"); + assert.equal(new Headers(init.headers).get("x-spock-actor"), MIRA); + assert.equal(typeof init.body, "string"); + rpcBodies.push(JSON.parse(init.body as string)); + (data.likes as Array<{ + user: { id: string }; + post: { id: string }; + at: string; + }>).push({ user: { id: MIRA }, - post: { id: "post-lena-video" }, - at: "2026-07-13T17:00:00Z", + post: { id: "post-lena-image" }, + at: "2026-07-13T16:00:00Z", }); - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); - } - if (url.endsWith("/unsave_post")) { - rpcCalls.push({ url, init }); - data.saves = data.saves.filter( - (save) => !(save.user.id === MIRA && save.post.id === "post-lena-video"), - ); - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); + return new Response(JSON.stringify({ user: MIRA, post: "post-lena-image" })); } throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver( - command("feed", "save-post", { post: "post-lena-video" }), - ); - const saved = onlyOutcome(await settle(remote)); - assert.equal( - update(saved, "feed", "post-by-id", "post-lena-video")["viewer-has-saved"], - true, - ); - assert.equal( - update(saved, "feed", "feed-page").posts.find( - (post) => post.id === "post-lena-video", - )!["viewer-has-saved"], - true, - ); - assert.equal( - update(saved, "feed", "reels").posts.find( - (post) => post.id === "post-lena-video", - )!["viewer-has-saved"], - true, - ); - assert.deepEqual( - update(saved, "profile", "profile", MIRA).saved.map((post) => post.id), - ["post-theo-image", "post-lena-video"], + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(1, "SetLike", [ + ["post", key(POST_ID, text("post-lena-image"))], + ["liked", bool(true)], + ]), + harness.mutationsContext, ); - remote.deliver( - command("feed", "unsave-post", { post: "post-lena-video" }), - ); - const unsaved = onlyOutcome(await settle(remote)); - assert.equal( - update(unsaved, "feed", "post-by-id", "post-lena-video")["viewer-has-saved"], - false, - ); - assert.deepEqual( - update(unsaved, "profile", "profile", MIRA).saved.map((post) => post.id), - ["post-theo-image"], - ); - - assert.equal(rpcCalls.length, 2); - for (const call of rpcCalls) { - assert.equal(new Headers(call.init.headers).get("x-spock-actor"), MIRA); - assert.deepEqual(JSON.parse(requestBody(call.init)), { - post: "post-lena-video", - }); - } + assert.equal(harness.mutationValues.length, 1); + const settled = harness.mutationValues[0]; + assert.equal(caseOf(settled), "mutations.settled"); + const result = namedField(settled, "result"); + assert.equal(caseOf(result), "Accepted", JSON.stringify(result)); + assert.deepEqual(rpcBodies, [{ post: "post-lena-image" }]); + assert.equal(harness.authorityValues.length, 2); + harness.provider.dispose(); }); }); -test("viewing one frame advances the ring and refreshes sequence progress", async () => { +test("accepts the browser-unqualified request case for search", async () => { const data = snapshot(); await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/mark_story_viewed")) { - const { story } = JSON.parse(requestBody(init)) as { story: string }; - data.storyViews.push({ - viewer: { id: MIRA }, - story: { id: story }, - at: "2026-07-13T17:00:00Z", - }); - return new Response(JSON.stringify({ viewer: MIRA, story })); + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } + if (url === "/~whoami") return whoami(init); throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver( - command("feed", "mark-story-seen", { story: "story-lena-2" }), - ); - const viewed = onlyOutcome(await settle(remote)); - assert.equal( - update(viewed, "feed", "feed-page").stories.find( - (ring) => ring.user.id === LENA, - )!.id, - "story-lena-3", + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(1, "SearchPeople", [["query", text("nils")]]), + harness.mutationsContext, ); - assert.deepEqual( - update(viewed, "feed", "story-by-id", "story-lena-3").progress, - [ - { id: "story-lena-1", "is-current": false, "is-viewed": true }, - { id: "story-lena-2", "is-current": false, "is-viewed": true }, - { id: "story-lena-3", "is-current": true, "is-viewed": false }, - ], - ); - }); -}); -test("search returns both matching people and authority post thumbnails", async () => { - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - return graphql(data); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - remote.deliver( - command("profile", "search-people", { query: "lena" }), - ); - const searched = onlyOutcome(await settle(remote)); - const results = update(searched, "profile", "search-results"); - assert.deepEqual(results.people.map((person) => person.user.id), [LENA]); - assert.deepEqual(results.posts.map((post) => post.id), [ - "post-lena-video", - "post-lena-archive", - ]); + assert.equal(harness.mutationValues.length, 1); + const settled = harness.mutationValues[0]; + assert.equal(caseOf(settled), "mutations.settled"); + assert.equal(caseOf(namedField(settled, "result")), "Accepted"); + assert.equal(harness.authorityValues.length, 2); + harness.provider.dispose(); }); }); -test("empty create metadata publishes and uses a provenance-only fallback alt", async () => { +test("returns ImageReady directly from the current mutation contract", async () => { const data = snapshot(); const selected = new File(["jpeg bytes"], "sunrise.jpg", { type: "image/jpeg", }); - let publishedPayload: PublishedPayload | null = null; await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/object/upload/sign")) { + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); + } + if (url === "/~whoami") return whoami(init); + if (url === "/framework/storage/object/upload/sign") { return new Response( JSON.stringify({ id: "upload-1", - url: "/storage/v1/object/upload-1?exp=9999999999&sig=test", + url: "/framework/storage/object/upload-1?exp=9999999999&sig=test", }), ); } - if (url.includes("/object/upload-1?") && init.method === "PUT") { - assert.equal(new Headers(init.headers).get("content-type"), "image/jpeg"); + if ( + url === "/framework/storage/object/upload-1?exp=9999999999&sig=test" + && init.method === "PUT" + ) { assert.equal(init.body, selected); return new Response(null, { status: 204 }); } - if (url.endsWith("/create_image_post")) { - const payload = JSON.parse(requestBody(init)) as PublishedPayload; - publishedPayload = payload; - data.posts.push({ - id: "post-upload", - author: { id: MIRA }, - caption: payload.caption, - published_at: "2026-07-13T18:00:00Z", - show_in_feed: true, - media_kind: "image", - media_file: { id: payload.image }, - video_file: null, - media_alt: payload.alt, - }); - return new Response(JSON.stringify({ id: "post-upload" })); - } throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver("mira.santos", { - signal: new AbortController().signal, - pickFile: async () => selected, - }); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver(command("create", "choose-image", {})); - const chosen = onlyOutcome(await settle(remote)); - assert.deepEqual(update(chosen, "create", "draft"), { - uploaded: { - object: "upload-1", - preview: "upload-1", - name: "sunrise.jpg", - }, - }); - - remote.deliver( - command("create", "publish-image", { - image: "upload-1", - caption: "", - alt: "", - }), + const harness = makeHarness(async () => selected); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(2, "ChooseImage"), + harness.mutationsContext, ); - const published = onlyOutcome(await settle(remote)); - assert.deepEqual(publishedPayload, { - image: "upload-1", - caption: "", - alt: "Uploaded image “sunrise.jpg” by Mira Santos", - }); - const post = update(published, "feed", "post-by-id", "post-upload"); - assert.equal(post.caption, ""); - assert.deepEqual(post.media, { - image: { - image: { - src: "upload-1", - alt: "Uploaded image “sunrise.jpg” by Mira Santos", - }, - }, - }); - assert.equal( - update(published, "profile", "profile", MIRA).posts[0]!.id, - "post-upload", - ); - assert.equal( - update(published, "profile", "search-results").posts[0]!.id, - "post-upload", + + assert.equal(harness.authorityValues.length, 1); + const settled = harness.mutationValues[0]; + const result = namedField(settled, "result"); + assert.equal(caseOf(result), "ImageReady"); + assert.deepEqual( + [ + namedField(result, "object").value, + namedField(result, "preview").value, + namedField(result, "name").value, + ], + ["upload-1", "upload-1", "sunrise.jpg"], ); - assert.deepEqual(update(published, "create", "draft"), { empty: {} }); + harness.provider.dispose(); }); }); -test("a remounted driver waits for an accepted retired mutation before boot", async () => { - const data = snapshot(); - let graphqlCalls = 0; - let abortedRetiredReads = 0; - let markMutationStarted!: () => void; - let finishMutation!: () => void; - const mutationStarted = new Promise((resolve) => { - markMutationStarted = resolve; - }); - const mutationFinished = new Promise((resolve) => { - finishMutation = resolve; - }); - - await withFetch(async (input, init) => { - const url = String(input); - if (init.signal?.aborted) { - abortedRetiredReads += 1; - throw new DOMException("retired", "AbortError"); - } - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) { - graphqlCalls += 1; - return graphql(data); - } - if (url.endsWith("/unlike_post")) { - markMutationStarted(); - await mutationFinished; - data.likes = data.likes.filter( - (like) => !(like.user.id === MIRA && like.post.id === "post-lena-video"), - ); - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); - } - throw new Error(`unexpected fetch ${url}`); +test("resolves checked local assets without contacting Spock storage", async () => { + let fetched = false; + await withFetch(async () => { + fetched = true; + throw new Error("local fixture asset must not use fetch"); }, async () => { - const retired = driver(); - await retired.assembleBoot(); - bootMessages(retired); - retired.deliver( - command("feed", "unlike-post", { post: "post-lena-video" }), - ); - await mutationStarted; - retired.dispose(); - - const fresh = driver(); - const freshBoot = fresh.assembleBoot(); - await Promise.resolve(); - assert.equal(graphqlCalls, 1, "fresh boot must wait behind accepted authority work"); - - finishMutation(); - await freshBoot; - assert.equal(graphqlCalls, 2); - assert.equal(abortedRetiredReads, 1); - assert.deepEqual(retired.tick(), []); + const harness = makeHarness(); assert.equal( - projection( - bootMessages(fresh), - "feed", - "post-by-id", - "post-lena-video", - )["viewer-has-liked"], - false, + await harness.provider.resolveAsset("video-nils-aurora"), + "/api/play/assets/media-nils-aurora.mp4", ); - fresh.dispose(); + harness.provider.dispose(); }); + assert.equal(fetched, false); }); -test("a retired hung upload cannot block the replacement driver boot", async () => { +test("cancelling the picker settles as an explicit refusal", async () => { const data = snapshot(); - const controller = new AbortController(); - const selected = new File(["jpeg bytes"], "never-finishes.jpg", { - type: "image/jpeg", - }); - let markUploadStarted!: () => void; - const uploadStarted = new Promise((resolve) => { - markUploadStarted = resolve; - }); - let uploadSignal: AbortSignal | null = null; - let graphqlCalls = 0; - await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) { - graphqlCalls += 1; - return graphql(data); + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); } - if (url.endsWith("/object/upload/sign")) { - uploadSignal = init.signal ?? null; - markUploadStarted(); - // Model a transport that fails to settle even after cancellation. A - // draft upload is not domain authority work, so it must not gate boot. - return await new Promise(() => {}); + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } + if (url === "/~whoami") return whoami(init); throw new Error(`unexpected fetch ${url}`); }, async () => { - const retired = driver("mira.santos", { - signal: controller.signal, - pickFile: async () => selected, - }); - await retired.assembleBoot(); - bootMessages(retired); - retired.deliver(command("create", "choose-image", {})); - await uploadStarted; - retired.dispose(); - assert.equal(uploadSignal?.aborted, true); - - const fresh = driver(); - let timeout: ReturnType | undefined; - try { - await Promise.race([ - fresh.assembleBoot(), - new Promise((_resolve, reject) => { - timeout = setTimeout( - () => reject(new Error("replacement provider boot stayed blocked")), - 250, - ); - }), - ]); - } finally { - if (timeout !== undefined) clearTimeout(timeout); - } - assert.equal(graphqlCalls, 2); - fresh.dispose(); + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(3, "ChooseImage"), + harness.mutationsContext, + ); + const result = namedField(harness.mutationValues[0], "result"); + assert.equal(caseOf(result), "Refused"); + assert.equal(onlyField(result).value, "selection-cancelled"); + harness.provider.dispose(); }); }); diff --git a/examples/instagram/client/providers/spock.ts b/examples/instagram/client/providers/spock.ts index 09bf02f..55d3c35 100644 --- a/examples/instagram/client/providers/spock.ts +++ b/examples/instagram/client/providers/spock.ts @@ -1,7 +1,6 @@ -// Instagram's app-local Spock provider. It speaks the same -// `uhura-provider/0` envelopes as FixtureDriver, but reads one coherent -// authority snapshot through Spock GraphQL and sends commands through the -// deliberate REST RPC surface. +// Instagram's app-local adapter provider. Uhura owns the deterministic +// machine; this module observes Spock authority and performs requested +// mutations at the two explicitly admitted application ports. const PAGE_SIZE = 4; const SUPPORTED_IMAGE_TYPES = new Set([ @@ -10,7 +9,49 @@ const SUPPORTED_IMAGE_TYPES = new Set([ "image/webp", ]); -// Spock v0 caps one collection read at 200 rows. The current demo fits inside +// Evidence fixtures use stable logical names while Play serves exact captured +// files. Live Spock storage ids deliberately fall through to signed URLs. +const LOCAL_PLAY_ASSETS: Readonly> = { + "avatar-mira": "avatar-mira.webp", + "avatar-lena": "avatar-lena.webp", + "avatar-marco": "avatar-marco.webp", + "avatar-nils": "avatar-nils.webp", + "avatar-priya": "avatar-priya.webp", + "avatar-ayla": "avatar-ayla.webp", + "avatar-june": "avatar-june.webp", + "avatar-theo": "avatar-theo.webp", + "avatar-kenji": "avatar-kenji.webp", + "media-lena-glaze": "media-lena-glaze.webp", + "media-marco-baja-1": "media-marco-baja-1.webp", + "media-marco-baja-2": "media-marco-baja-2.webp", + "media-marco-baja-3": "media-marco-baja-3.webp", + "media-nils-aurora-poster": "media-nils-aurora-poster.webp", + "media-priya-starter": "media-priya-starter.webp", + "media-ayla-ferry": "media-ayla-ferry.webp", + "media-june-lookbook": "media-june-lookbook.webp", + "media-theo-court": "media-theo-court.webp", + "media-kenji-copper": "media-kenji-copper.webp", + "thumb-lena-1": "thumb-lena-1.webp", + "thumb-lena-2": "thumb-lena-2.webp", + "thumb-lena-3": "thumb-lena-3.webp", + "thumb-lena-4": "thumb-lena-4.webp", + "thumb-lena-5": "thumb-lena-5.webp", + "thumb-lena-6": "thumb-lena-6.webp", + "thumb-lena-7": "thumb-lena-7.webp", + "thumb-lena-8": "thumb-lena-8.webp", + "thumb-lena-9": "thumb-lena-9.webp", + "thumb-mira-1": "thumb-mira-1.webp", + "thumb-mira-2": "thumb-mira-2.webp", + "thumb-mira-3": "thumb-mira-3.webp", + "thumb-mira-4": "thumb-mira-4.webp", + "thumb-mira-5": "thumb-mira-5.webp", + "thumb-mira-6": "thumb-mira-6.webp", + "video-nils-aurora": "media-nils-aurora.mp4", + "video-theo-court": "media-theo-court.mp4", + "video-mira-ferry": "media-mira-ferry.mp4", +}; + +// The current Spock authority caps one collection read at 200 rows. This demo fits inside // that ceiling per table; snapshot-consistent pagination is deferred dogfood // rather than pretending a clamped response is complete. const SNAPSHOT_QUERY = ` @@ -104,7 +145,7 @@ const COMMAND_REFUSALS: Readonly> = { ], }; -export interface SpockDriverConfig { +export interface SpockProviderConfig { /** Standalone fallback for the full Spock `/graphql/v1` endpoint. */ graphql_url: string; /** Standalone fallback for the Spock `/rest/v1/rpc` prefix. */ @@ -125,42 +166,179 @@ export interface ProviderHost { pickFile(options: { accept: string }): Promise; } +interface PortRequirement { + readonly port: string; + readonly adapter: "app.provider"; + readonly contractHash: string; + readonly contractInstanceHash: string; +} + +interface PortAdapterContext { + readonly signal: AbortSignal; + deliver(value: WireValue): void; +} + +interface AdapterProviderHost extends ProviderHost { + port(name: string): PortRequirement; +} + +interface PortAdapter extends PortRequirement { + start?(context: PortAdapterContext): void | Promise; + accept(command: WireValue, context: PortAdapterContext): void | Promise; + dispose?(): void; +} + +interface WireValue { + readonly $: string; + readonly [field: string]: unknown; +} + export interface RemoteSystemInfo { actor: string | null; actors: Array<{ id: string; username: string; label: string }>; } -export interface SpockDriver { +interface SpockBackend { dispose(): void; - assembleBoot(): Promise; - deliver(commandJson: string): void; - tick(): string[]; - idle(): boolean; + load(): Promise; + execute(operation: BackendOperation): Promise; + authorityValue(): WireValue; resolveAsset(asset: string): Promise; systemInfo(): RemoteSystemInfo; } +const INSTAGRAM_MODULE = "app.instagram@1"; +const INSTAGRAM_MACHINE = `${INSTAGRAM_MODULE}::Instagram`; +const USER_ID_TYPE = `${INSTAGRAM_MODULE}::UserId`; +const POST_ID_TYPE = `${INSTAGRAM_MODULE}::PostId`; +const STORY_ID_TYPE = `${INSTAGRAM_MODULE}::StoryId`; +const REQUEST_ID_TYPE = `${INSTAGRAM_MODULE}::RequestId`; +const AUTHORITY_TYPE = `${INSTAGRAM_MODULE}::Authority`; +const MEDIA_TYPE = `${INSTAGRAM_MODULE}::Media`; +const MUTATION_TYPE = `${INSTAGRAM_MODULE}::Mutation`; +const SETTLEMENT_TYPE = `${INSTAGRAM_MODULE}::Settlement`; +const AUTHORITY_RECEIVE_TYPE = + `${INSTAGRAM_MACHINE}::port.authority.Receive`; +const MUTATIONS_SEND_TYPE = + `${INSTAGRAM_MACHINE}::port.mutations.Send`; +const MUTATIONS_RECEIVE_TYPE = + `${INSTAGRAM_MACHINE}::port.mutations.Receive`; + +const wireText = (value: string): WireValue => ({ $: "Text", value }); +const wireBool = (value: boolean): WireValue => ({ $: "bool", value }); +const wireNat = (value: number): WireValue => ({ + $: "Nat", + value: String(value), +}); +const wireKey = ( + type: string, + value: WireValue, +): WireValue => ({ $: "key", type, value }); +const wireRecord = ( + fields: ReadonlyArray, +): WireValue => ({ + $: "record", + fields: fields.map(([name, value]) => ({ name, value })), +}); +const wireVariant = ( + type: string, + caseName: string, + fields: ReadonlyArray = [], +): WireValue => ({ + $: "variant", + type, + case: caseName, + fields: fields.map(([name, value]) => ({ name, value })), +}); +const wireSeq = (items: readonly WireValue[]): WireValue => ({ + $: "seq", + items, +}); +const wireMap = ( + entries: ReadonlyArray, +): WireValue => ({ $: "map", entries }); +const textEncoder = new TextEncoder(); +const lengthPrefix = (value: number): number[] => { + const bytes: number[] = []; + do { + let byte = value % 128; + value = Math.floor(value / 128); + if (value !== 0) byte += 128; + bytes.push(byte); + } while (value !== 0); + return bytes; +}; +const canonicalTextKeyBytes = (value: string): Uint8Array => { + const body = textEncoder.encode(value); + return Uint8Array.from([...lengthPrefix(body.length), ...body]); +}; +const compareBytes = (left: Uint8Array, right: Uint8Array): number => { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const order = (left[index] ?? 0) - (right[index] ?? 0); + if (order !== 0) return order; + } + return left.length - right.length; +}; +/** + * Every map currently emitted by this adapter has a nominal Text-backed key. + * Uhura orders a map by the complete canonical key bytes. The nominal type is + * constant within one map, so its shared prefix cancels and the order reduces + * exactly to the length-framed UTF-8 Text body below. + */ +const wireTextKeyMap = ( + type: string, + entries: ReadonlyArray, +): WireValue => { + const ordered = entries + .map(([key, value]) => ({ + key, + value, + canonical: canonicalTextKeyBytes(key), + })) + .sort((left, right) => compareBytes(left.canonical, right.canonical)); + for (let index = 1; index < ordered.length; index += 1) { + if ( + compareBytes( + ordered[index - 1]?.canonical ?? new Uint8Array(), + ordered[index]?.canonical ?? new Uint8Array(), + ) === 0 + ) { + throw new Error(`duplicate canonical map key \`${ordered[index]?.key}\``); + } + } + return wireMap(ordered.map(({ key, value }) => [ + wireKey(type, wireText(key)), + value, + ])); +}; +const wireOption = ( + type: string, + value: WireValue | null, +): WireValue => wireVariant( + `Option<${type}>`, + value === null ? "none" : "some", + value === null ? [] : [["value", value]], +); + /** * A picker result is observed immediately so a rejection cannot become * unhandled while an earlier provider command finishes. */ type PickedFile = Promise<{ file: File | null } | { error: unknown }>; -// A retired driver can have a mutation already accepted by Spock. New driver -// boot waits for that work before reading its authority snapshot, so a route +// A retired backend instance can have a mutation already accepted by Spock. +// A replacement waits for that work before reading its authority snapshot, so a route // remount cannot strand a just-accepted mutation behind stale boot data. let authorityTail: Promise = Promise.resolve(); -const AUTHORITY_COMMANDS = new Set([ - "feed/like-post", - "feed/unlike-post", - "feed/save-post", - "feed/unsave-post", - "comments/add-comment", - "feed/mark-story-seen", - "profile/follow-user", - "profile/unfollow-user", - "create/publish-image", +const AUTHORITY_OPERATIONS = new Set([ + "set_like", + "set_save", + "add_comment", + "mark_story", + "set_follow", + "publish_image_request", ]); const AUTHORITY_REQUEST_TIMEOUT_MS = 15_000; @@ -272,9 +450,12 @@ function resolveFromEndpoint(reference: string, endpoint: string): string { } } -function enqueueAuthorityWork(work: () => Promise): Promise { +function enqueueAuthorityWork(work: () => Promise): Promise { const queued = authorityTail.then(work, work); - authorityTail = queued.catch(() => {}); + authorityTail = queued.then( + () => {}, + () => {}, + ); return queued; } @@ -433,26 +614,32 @@ type RpcReply = | { ok: true; result: unknown } | { ok: false; error: SpockError }; -interface ProviderCommand { - kind: "command"; - port: string; - command: string; - correlation: string; - payload: Record; -} - -interface ProjectionUpdate { - port: string; - projection: string; - key: unknown; - revision: number; - value: unknown; -} - -type CommandOutcome = - | { ok: Record } - | { refused: { refusal: string } } - | { unavailable: { reason: string } }; +type BackendOperation = + | { kind: "set_like"; post: string; liked: boolean } + | { kind: "set_save"; post: string; saved: boolean } + | { kind: "load_more" } + | { kind: "reload_feed" } + | { kind: "set_follow"; user: string; following: boolean } + | { kind: "add_comment"; post: string; body: string } + | { kind: "search_people"; query: string } + | { kind: "choose_image_request" } + | { + kind: "publish_image_request"; + object: string; + caption: string; + alt: string; + } + | { kind: "mark_story"; story: string }; + +type BackendSettlement = + | { kind: "accepted" } + | { kind: "refused"; reason: string } + | { + kind: "image_ready"; + object: string; + preview: string; + name: string; + }; interface Database { users: Map; @@ -568,21 +755,14 @@ function toRefusalName(code: string): string { } /** - * Create the Instagram demo's live Spock-backed provider. - * - * Delivery is eager: boot queues every keyed post, comment thread, story, - * profile, and relationship list plus the feed, reels, people search, and - * create draft. Commands settle by re-reading one authority snapshot and - * carrying whole-slice updates in their outcome envelope. - * - * @param {SpockDriverConfig} config - * @param {ProviderHost} host - * @returns {SpockDriver} + * Create the app-local Spock authority bridge used by the admitted Uhura + * ports. It exposes domain operations and typed authority values directly; + * there is no second provider protocol or projection/outcome envelope. */ -export function createDriver( - { graphql_url, rpc_url, storage_url, actor }: SpockDriverConfig, +function createSpockBackend( + { graphql_url, rpc_url, storage_url, actor }: SpockProviderConfig, host: ProviderHost, -): SpockDriver { +): SpockBackend { const graphqlUrl = graphql_url.replace(/\/+$/, ""); const rpcUrl = rpc_url.replace(/\/+$/, ""); const storageUrl = storage_url.replace(/\/+$/, ""); @@ -600,13 +780,10 @@ export function createDriver( whoamiUrl: new URL("/~whoami", graphqlUrl).toString(), }; - const outbox: string[] = []; - let inflight = 0; - let commandTail: Promise = Promise.resolve(); - const signedAssets = new Map(); const signingAssets = new Map>(); const uploadedFileNames = new Map(); + let operationTail: Promise = Promise.resolve(); const cancellable = new AbortController(); let disposed = host.signal.aborted; let authorityResolution: Promise | undefined; @@ -616,7 +793,6 @@ export function createDriver( disposed = true; host.signal.removeEventListener("abort", dispose); cancellable.abort(); - outbox.length = 0; signedAssets.clear(); signingAssets.clear(); uploadedFileNames.clear(); @@ -666,70 +842,6 @@ export function createDriver( return authorityResolution; } - const revisions = new Map(); - - /** - * @param {string} port - * @param {string} projection - * @param {unknown} key - * @returns {number} - */ - function mintRevision( - port: string, - projection: string, - key: unknown, - ): number { - const slot = `${port}|${projection}|${encode(key ?? null)}`; - const next = (revisions.get(slot) ?? 1) + 1; - revisions.set(slot, next); - return next; - } - - /** - * @param {string} port - * @param {string} projection - * @param {unknown} key - * @param {unknown} value - * @returns {string} - */ - function projectionMsg( - port: string, - projection: string, - key: unknown, - value: unknown, - ): string { - return encode({ - kind: "projection", - port, - projection, - key: key ?? null, - revision: mintRevision(port, projection, key), - value, - }); - } - - /** - * @param {string} port - * @param {string} projection - * @param {unknown} key - * @param {unknown} value - * @returns {ProjectionUpdate} - */ - function projectionUpdate( - port: string, - projection: string, - key: unknown, - value: unknown, - ): ProjectionUpdate { - return { - port, - projection, - key: key ?? null, - revision: mintRevision(port, projection, key), - value, - }; - } - /** * @returns {Promise} */ @@ -816,7 +928,7 @@ export function createDriver( try { // Once sent, a domain mutation may already be accepted by Spock. Do not // abort it merely because its route retired; the module-level authority - // barrier makes the next driver wait for settlement. The finite timeout + // barrier makes the replacement backend wait for settlement. The finite timeout // prevents a broken connection from blocking every future boot forever. response = await fetch(`${rpcUrl}/${fn}`, { method: "POST", @@ -853,6 +965,11 @@ export function createDriver( * @returns {Promise} */ async function resolveAsset(asset: string): Promise { + if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(asset)) return asset; + const local = LOCAL_PLAY_ASSETS[asset]; + if (local) { + return `/api/play/assets/${encodeURIComponent(local)}`; + } assertLive(); const cached = signedAssets.get(asset); if (cached && Date.now() < cached.refreshAt) return cached.url; @@ -1064,8 +1181,8 @@ export function createDriver( const resolved = data.users.find( (user) => user.id === actor || user.username === actor, ); - // Keep the authority-owned user catalog available even when a stale - // tab-local actor selection cannot resolve. assembleBoot still refuses + // Keep the authority-owned user directory available even when a stale + // tab-local actor selection cannot resolve. `load` still refuses // that identity, but the system chrome can offer a valid actor and recover // by replacing the stored selection. viewerRow = resolved ?? null; @@ -1091,87 +1208,6 @@ export function createDriver( return user; } - /** - * @param {UserRow} user - * @returns {Record} - */ - function userRef(user: UserRow) { - return { - id: user.id, - username: user.username, - "display-name": user.display_name, - avatar: { src: user.avatar.id, alt: user.avatar_alt }, - }; - } - - /** - * @param {PostRow} post - * @returns {Record} - */ - function media(post: PostRow) { - if (post.media_kind === "carousel") { - const slides = db.slidesByPost.get(post.id) ?? []; - return { - carousel: { - slides: slides.map((slide) => ({ - id: slide.id, - src: slide.file, - alt: slide.alt, - })), - }, - }; - } - if (post.media_file === null || post.media_alt === null) { - throw new Error(`post \`${post.id}\` has incomplete ${post.media_kind} media`); - } - const ref = { src: post.media_file, alt: post.media_alt }; - if (post.media_kind === "video") { - if (post.video_file === null) { - throw new Error(`video post \`${post.id}\` has no playable video_file`); - } - return { video: { src: post.video_file, poster: ref } }; - } - return { image: { image: ref } }; - } - - /** - * @param {PostRow} post - * @returns {Record} - */ - function postSummary(post: PostRow) { - return { - id: post.id, - author: userRef(requireUser(post.author)), - media: media(post), - caption: post.caption, - "like-count": db.likeCounts.get(post.id) ?? 0, - "comment-count": (db.commentsByPost.get(post.id) ?? []).length, - "viewer-has-liked": db.liked.has(post.id), - "viewer-has-saved": db.saved.has(post.id), - "posted-label": ageLabel(post.published_at), - }; - } - - /** - * @param {string} id - * @returns {PostRow} - */ - function requirePost(id: string): PostRow { - const post = db.posts.find((candidate) => candidate.id === id); - if (!post) throw new Error(`Spock snapshot has no post \`${id}\``); - return post; - } - - /** - * @param {string} id - * @returns {StoryRow} - */ - function requireStory(id: string): StoryRow { - const story = db.stories.find((candidate) => candidate.id === id); - if (!story) throw new Error(`Spock snapshot has no story \`${id}\``); - return story; - } - /** * @param {PostRow} post * @returns {{ id: string, src: string, alt: string }} @@ -1204,301 +1240,323 @@ export function createDriver( return db.posts.filter((post) => post.show_in_feed && isHomeAuthor(post.author)); } - /** - * One tray entry represents one author's current story sequence. Its id is - * the next unseen frame (or the first frame after the sequence is exhausted), - * so opening a ring always addresses a real keyed story projection. - * @returns {Record[]} - */ - function storyRingsValue() { + function userWire(id: string): WireValue { + const user = requireUser(id); + return wireRecord([ + ["id", wireKey(USER_ID_TYPE, wireText(user.id))], + ["username", wireText(user.username)], + ["display_name", wireText(user.display_name)], + [ + "avatar", + wireRecord([ + ["src", wireText(user.avatar.id)], + ["alt", wireText(user.avatar_alt)], + ]), + ], + ]); + } + + function imageWire(src: string, alt: string): WireValue { + return wireRecord([ + ["src", wireText(src)], + ["alt", wireText(alt)], + ]); + } + + function mediaWire(post: PostRow): WireValue { + if (post.media_kind === "carousel") { + const slides = db.slidesByPost.get(post.id) ?? []; + return wireVariant(MEDIA_TYPE, "Carousel", [[ + "images", + wireSeq(slides.map((slide) => imageWire(slide.file, slide.alt))), + ]]); + } + if (post.media_file === null || post.media_alt === null) { + throw new Error( + `post \`${post.id}\` has incomplete ${post.media_kind} media`, + ); + } + const poster = imageWire(post.media_file, post.media_alt); + if (post.media_kind === "video") { + if (post.video_file === null) { + throw new Error(`video post \`${post.id}\` has no playable video_file`); + } + return wireVariant(MEDIA_TYPE, "Video", [ + ["src", wireText(post.video_file)], + ["poster", poster], + ]); + } + return wireVariant(MEDIA_TYPE, "Image", [["image", poster]]); + } + + function postWire(post: PostRow): WireValue { + return wireRecord([ + ["id", wireKey(POST_ID_TYPE, wireText(post.id))], + ["author", userWire(post.author)], + ["caption", wireText(post.caption)], + ["media", mediaWire(post)], + ["like_count", wireNat(db.likeCounts.get(post.id) ?? 0)], + [ + "comment_count", + wireNat((db.commentsByPost.get(post.id) ?? []).length), + ], + ["viewer_liked", wireBool(db.liked.has(post.id))], + ["viewer_saved", wireBool(db.saved.has(post.id))], + ["posted_label", wireText(ageLabel(post.published_at))], + ]); + } + + function tileWire(post: PostRow): WireValue { + const thumb = postThumb(post); + return wireRecord([ + ["post", wireKey(POST_ID_TYPE, wireText(post.id))], + ["image", imageWire(thumb.src, thumb.alt)], + ]); + } + + function connectionWire(id: string): WireValue { + return wireRecord([ + ["user", userWire(id)], + ["follows_viewer", wireBool(db.follows.has(edgeKey(id, viewerId())))], + [ + "viewer_follows", + wireBool(db.follows.has(edgeKey(viewerId(), id))), + ], + ]); + } + + function connectionSequence(ids: readonly string[]): WireValue { + const unique = [...new Set(ids)]; + unique.sort((left, right) => + requireUser(left).username.localeCompare(requireUser(right).username) + ); + return wireSeq(unique.map(connectionWire)); + } + + function commentWire(comment: CommentRow): WireValue { + return wireRecord([ + ["id", wireText(comment.id)], + ["author", userWire(comment.author)], + ["body", wireText(comment.body)], + ["posted_label", wireText(ageLabel(comment.created_at))], + ]); + } + + function storyDetailWire(story: StoryRow): WireValue { + const sequence = db.stories + .filter((candidate) => candidate.author === story.author) + .sort( + (left, right) => + left.position - right.position || left.id.localeCompare(right.id), + ); + const index = sequence.findIndex((candidate) => candidate.id === story.id); + if (index < 0) { + throw new Error(`story sequence lost frame \`${story.id}\``); + } + const previous = index > 0 ? sequence[index - 1]?.id ?? null : null; + const next = index + 1 < sequence.length + ? sequence[index + 1]?.id ?? null + : null; + return wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(story.id))], + ["author", userWire(story.author)], + ["image", imageWire(story.media_file, story.media_alt)], + ["caption", wireText(story.caption ?? "")], + ["posted_label", wireText(ageLabel(story.published_at))], + [ + "viewed", + wireBool(db.storyViews.has(edgeKey(viewerId(), story.id))), + ], + [ + "previous", + wireOption( + STORY_ID_TYPE, + previous === null + ? null + : wireKey(STORY_ID_TYPE, wireText(previous)), + ), + ], + [ + "next", + wireOption( + STORY_ID_TYPE, + next === null ? null : wireKey(STORY_ID_TYPE, wireText(next)), + ), + ], + [ + "progress", + wireSeq(sequence.map((frame) => + wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(frame.id))], + ["current", wireBool(frame.id === story.id)], + [ + "viewed", + wireBool(db.storyViews.has(edgeKey(viewerId(), frame.id))), + ], + ]) + )), + ], + ]); + } + + function storyRingWires(): WireValue[] { const grouped = groupBy( db.stories.filter((story) => isHomeAuthor(story.author)), (story) => story.author, ); const rings = [...grouped.entries()].map(([author, stories]) => { stories.sort( - (left, right) => left.position - right.position || left.id.localeCompare(right.id), + (left, right) => + left.position - right.position || left.id.localeCompare(right.id), ); const unseen = stories.filter( (story) => !db.storyViews.has(edgeKey(viewerId(), story.id)), ); - const isSelf = author === viewerId(); - const selected = isSelf ? stories[0] : unseen[0] ?? stories[0]; + const self = author === viewerId(); + const selected = self ? stories[0] : unseen[0] ?? stories[0]; if (!selected) throw new Error(`story author \`${author}\` has no frames`); const newest = stories.reduce((latest, story) => - newestFirst(latest.published_at, story.published_at) <= 0 ? latest : story, + newestFirst(latest.published_at, story.published_at) <= 0 + ? latest + : story ); return { author, selected, newest, - hasUnseen: !isSelf && unseen.length > 0, + unseen: !self && unseen.length > 0, + self, }; }); - rings.sort((left, right) => { - const leftSelf = left.author === viewerId() ? 1 : 0; - const rightSelf = right.author === viewerId() ? 1 : 0; - return ( - rightSelf - leftSelf || - newestFirst(left.newest.published_at, right.newest.published_at) || - requireUser(left.author).username.localeCompare(requireUser(right.author).username) - ); - }); - return rings.map((ring) => ({ - id: ring.selected.id, - user: userRef(requireUser(ring.author)), - "has-unseen": ring.hasUnseen, - "is-self": ring.author === viewerId(), - })); - } - - /** @returns {Record} */ - function feedPageValue() { - const posts = feedPosts(); - const shown = posts.slice(0, feedCount); - const hasMore = feedCount < posts.length; - return { - stories: storyRingsValue(), - posts: shown.map((post) => postSummary(post)), - cursor: hasMore ? `offset:${feedCount}` : null, - "has-more": hasMore, - }; - } - - /** - * @param {string} postId - * @returns {Record} - */ - function threadValue(postId: string) { - const rows = db.commentsByPost.get(postId) ?? []; - return { - comments: rows.map((comment) => ({ - id: comment.id, - author: userRef(requireUser(comment.author)), - body: comment.body, - "posted-label": ageLabel(comment.created_at), - })), - }; - } - - /** - * @param {string} storyId - * @returns {Record} - */ - function storyValue(storyId: string) { - const story = requireStory(storyId); - const sequence = db.stories - .filter((candidate) => candidate.author === story.author) - .sort( - (left, right) => - left.position - right.position || left.id.localeCompare(right.id), - ); - const index = sequence.findIndex((candidate) => candidate.id === story.id); - if (index < 0) throw new Error(`story sequence lost frame \`${story.id}\``); - return { - id: story.id, - author: userRef(requireUser(story.author)), - image: { src: story.media_file, alt: story.media_alt }, - caption: story.caption ?? "", - "posted-label": ageLabel(story.published_at), - "viewer-has-viewed": db.storyViews.has(edgeKey(viewerId(), story.id)), - previous: index > 0 ? sequence[index - 1]?.id ?? null : null, - next: - index + 1 < sequence.length ? sequence[index + 1]?.id ?? null : null, - progress: sequence.map((frame) => ({ - id: frame.id, - "is-current": frame.id === story.id, - "is-viewed": db.storyViews.has(edgeKey(viewerId(), frame.id)), - })), - }; - } - - /** @returns {Record} */ - function reelsValue() { - return { - posts: db.posts - .filter((post) => post.media_kind === "video") - .map((post) => postSummary(post)), - }; - } - - /** - * @param {string} userId - * @returns {Record} - */ - function profileValue(userId: string) { - const user = requireUser(userId); - const posts = db.posts.filter((post) => post.author === userId); - const reels = posts.filter((post) => post.media_kind === "video"); - const saved = - userId === viewerId() - ? db.posts.filter((post) => db.saved.has(post.id)) - : []; - const taggedIds = new Set(db.taggedPostsByUser.get(userId) ?? []); - const tagged = db.posts.filter((post) => taggedIds.has(post.id)); - return { - user: userRef(user), - bio: user.bio ?? "", - "is-self": userId === viewerId(), - "viewer-follows": db.follows.has(edgeKey(viewerId(), userId)), - "post-count": posts.length, - "follower-count": (db.followersByUser.get(userId) ?? []).length, - "following-count": (db.followingByUser.get(userId) ?? []).length, - posts: posts.map((post) => postThumb(post)), - reels: reels.map((post) => postThumb(post)), - saved: saved.map((post) => postThumb(post)), - tagged: tagged.map((post) => postThumb(post)), - }; - } - - /** - * @param {string[]} userIds - * @returns {Record} - */ - function connectionsValue(userIds: string[]) { - return { - people: userIds - .map((id) => requireUser(id)) - .sort((left, right) => left.username.localeCompare(right.username)) - .map((user) => ({ - user: userRef(user), - "viewer-follows": db.follows.has(edgeKey(viewerId(), user.id)), - })), - }; - } - - /** - * @param {string} userId - * @returns {Record} - */ - function followersValue(userId: string) { - requireUser(userId); - return connectionsValue(db.followersByUser.get(userId) ?? []); + rings.sort((left, right) => + Number(right.self) - Number(left.self) + || newestFirst(left.newest.published_at, right.newest.published_at) + || requireUser(left.author).username.localeCompare( + requireUser(right.author).username, + ) + ); + return rings.map((ring) => + wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(ring.selected.id))], + ["user", userWire(ring.author)], + ["unseen", wireBool(ring.unseen)], + ["is_self", wireBool(ring.self)], + ]) + ); } - /** - * @param {string} userId - * @returns {Record} - */ - function followingValue(userId: string) { - requireUser(userId); - return connectionsValue(db.followingByUser.get(userId) ?? []); + function profileWire(id: string): WireValue { + const user = requireUser(id); + const posts = db.posts.filter((post) => post.author === id); + const tagged = new Set(db.taggedPostsByUser.get(id) ?? []); + return wireRecord([ + ["user", userWire(id)], + ["bio", wireText(user.bio ?? "")], + ["post_count", wireNat(posts.length)], + [ + "follower_count", + wireNat((db.followersByUser.get(id) ?? []).length), + ], + [ + "following_count", + wireNat((db.followingByUser.get(id) ?? []).length), + ], + [ + "viewer_follows", + wireBool(db.follows.has(edgeKey(viewerId(), id))), + ], + ["posts", wireSeq(posts.map(tileWire))], + [ + "reels", + wireSeq(posts.filter((post) => post.media_kind === "video").map(tileWire)), + ], + [ + "tagged", + wireSeq(db.posts.filter((post) => tagged.has(post.id)).map(tileWire)), + ], + [ + "saved", + wireSeq( + id === viewerId() + ? db.posts.filter((post) => db.saved.has(post.id)).map(tileWire) + : [], + ), + ], + ]); } - /** - * @param {string} query - * @returns {Record} - */ - function searchValue(query: string) { - const needle = query.trim().toLocaleLowerCase(); - const people = [...db.users.values()] + function authorityValue(): WireValue { + const home = feedPosts(); + const visible = home.slice(0, feedCount); + const needle = searchQuery.trim().toLocaleLowerCase(); + const searchPeople = [...db.users.values()] .filter((user) => user.id !== viewerId()) - .filter( - (user) => - needle.length === 0 || - user.username.toLocaleLowerCase().includes(needle) || - user.display_name.toLocaleLowerCase().includes(needle), + .filter((user) => + needle.length === 0 + || user.username.toLocaleLowerCase().includes(needle) + || user.display_name.toLocaleLowerCase().includes(needle) ) .map((user) => user.id); - const posts = db.posts.filter((post) => { - if (needle.length === 0) return true; - const author = requireUser(post.author); - return ( - post.caption.toLocaleLowerCase().includes(needle) || - author.username.toLocaleLowerCase().includes(needle) || - author.display_name.toLocaleLowerCase().includes(needle) - ); - }); - return { - people: connectionsValue(people).people, - posts: posts.map((post) => postThumb(post)), - }; - } - - /** - * @param {string} postId - * @param {boolean} includeThread - * @returns {ProjectionUpdate[]} - */ - function postSettlementUpdates( - postId: string, - includeThread: boolean, - ): ProjectionUpdate[] { - const updates = [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - projectionUpdate( - "feed", - "post-by-id", - postId, - postSummary(requirePost(postId)), - ), - projectionUpdate("feed", "reels", null, reelsValue()), - ]; - if (includeThread) { - updates.push( - projectionUpdate("comments", "for-post", postId, threadValue(postId)), - ); - } - return updates; - } - - /** - * Saving changes every viewer-specific rendering of a post plus the private - * Saved grid on the actor's own profile. - * @param {string} postId - * @returns {ProjectionUpdate[]} - */ - function saveSettlementUpdates(postId: string): ProjectionUpdate[] { - return [ - ...postSettlementUpdates(postId, false), - projectionUpdate( - "profile", - "profile", - viewerId(), - profileValue(viewerId()), - ), - ]; - } - - /** - * A viewed edge changes the ring and every frame's progress strip in that - * author's sequence, so settle them as one authority snapshot. - * @param {string} storyId - * @returns {ProjectionUpdate[]} - */ - function storySettlementUpdates(storyId: string): ProjectionUpdate[] { - const author = requireStory(storyId).author; - return [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ...db.stories - .filter((story) => story.author === author) - .map((story) => - projectionUpdate( - "feed", - "story-by-id", - story.id, - storyValue(story.id), + const users = [...db.users.keys()]; + return wireVariant(AUTHORITY_TYPE, "Ready", [[ + "data", + wireRecord([ + ["viewer", userWire(viewerId())], + [ + "posts", + wireTextKeyMap(POST_ID_TYPE, db.posts.map((post) => [ + post.id, + postWire(post), + ])), + ], + ["feed_posts", wireSeq(visible.map(postWire))], + ["feed_has_more", wireBool(feedCount < home.length)], + [ + "reels", + wireSeq( + db.posts.filter((post) => post.media_kind === "video").map(postWire), ), - ), - ]; - } - - /** @returns {ProjectionUpdate[]} */ - function allSocialUpdates(): ProjectionUpdate[] { - const updates: ProjectionUpdate[] = [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ]; - for (const userId of db.users.keys()) { - updates.push( - projectionUpdate("profile", "profile", userId, profileValue(userId)), - projectionUpdate("profile", "followers", userId, followersValue(userId)), - projectionUpdate("profile", "following", userId, followingValue(userId)), - ); - } - updates.push( - projectionUpdate("profile", "search-results", null, searchValue(searchQuery)), - ); - return updates; + ], + ["stories", wireSeq(storyRingWires())], + [ + "story_details", + wireTextKeyMap(STORY_ID_TYPE, db.stories.map((story) => [ + story.id, + storyDetailWire(story), + ])), + ], + [ + "profiles", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + profileWire(id), + ])), + ], + [ + "followers", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + connectionSequence(db.followersByUser.get(id) ?? []), + ])), + ], + [ + "following", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + connectionSequence(db.followingByUser.get(id) ?? []), + ])), + ], + [ + "comments", + wireTextKeyMap(POST_ID_TYPE, db.posts.map((post) => [ + post.id, + wireSeq((db.commentsByPost.get(post.id) ?? []).map(commentWire)), + ])), + ], + ["search_people", connectionSequence(searchPeople)], + ["explore_tiles", wireSeq(db.posts.map(tileWire))], + ]), + ]]); } /** @@ -1515,202 +1573,139 @@ export function createDriver( : `Image uploaded by ${author.display_name}`; } - /** - * @param {ProviderCommand} command - * @param {string} field - * @returns {string} - */ - function payloadString(command: ProviderCommand, field: string): string { - const value = command.payload[field]; - if (typeof value !== "string") { - throw new Error(`command \`${command.port}/${command.command}\` needs string \`${field}\``); - } - return value; - } - - /** - * @param {string} route - * @param {SpockError} error - * @returns {CommandOutcome} - */ - function refuseOrUnavailable( + function refusal( route: string, error: SpockError, - ): CommandOutcome { - const refusal = toRefusalName(error.code ?? ""); - if ((COMMAND_REFUSALS[route] ?? []).includes(refusal)) { - return { refused: { refusal } }; + ): BackendSettlement { + const reason = toRefusalName(error.code ?? ""); + if ((COMMAND_REFUSALS[route] ?? []).includes(reason)) { + return { kind: "refused", reason }; } return { - unavailable: { reason: error.message ?? error.code ?? "provider error" }, + kind: "refused", + reason: error.message ?? error.code ?? "provider-error", }; } - /** - * @param {ProviderCommand} command - * @param {CommandOutcome} result - * @param {ProjectionUpdate[]} [updates] - * @returns {void} - */ - function outcome( - command: ProviderCommand, - result: CommandOutcome, - updates: ProjectionUpdate[] = [], - ): void { - if (disposed) return; - outbox.push( - encode({ - kind: "outcome", - correlation: command.correlation, - outcome: result, - updates, - }), - ); - } - - /** - * @param {ProviderCommand} command - * @param {PickedFile | undefined} pickedFile - * @returns {Promise} - */ async function handle( - command: ProviderCommand, + operation: BackendOperation, pickedFile: PickedFile | undefined, - ): Promise { - const route = `${command.port}/${command.command}`; + ): Promise { try { - switch (route) { - case "feed/like-post": - case "feed/unlike-post": { - const post = payloadString(command, "post"); - const fn = command.command === "like-post" ? "like_post" : "unlike_post"; - const reply = await rpc(fn, { post }); + switch (operation.kind) { + case "set_like": { + const route = operation.liked + ? "feed/like-post" + : "feed/unlike-post"; + const reply = await rpc( + operation.liked ? "like_post" : "unlike_post", + { post: operation.post }, + ); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, postSettlementUpdates(post, false)); - return; + return { kind: "accepted" }; } - case "feed/save-post": - case "feed/unsave-post": { - const post = payloadString(command, "post"); - const fn = command.command === "save-post" ? "save_post" : "unsave_post"; - const reply = await rpc(fn, { post }); + case "set_save": { + const route = operation.saved + ? "feed/save-post" + : "feed/unsave-post"; + const reply = await rpc( + operation.saved ? "save_post" : "unsave_post", + { post: operation.post }, + ); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, saveSettlementUpdates(post)); - return; + return { kind: "accepted" }; } - case "comments/add-comment": { - const post = payloadString(command, "post"); - const body = payloadString(command, "body"); - const reply = await rpc("add_comment", { post, body }); + case "add_comment": { + const route = "comments/add-comment"; + const reply = await rpc("add_comment", { + post: operation.post, + body: operation.body, + }); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, postSettlementUpdates(post, true)); - return; + return { kind: "accepted" }; } - case "feed/load-next-page": { + case "load_more": { await loadAll(); feedCount = Math.min(feedCount + PAGE_SIZE, feedPosts().length); - outcome(command, { ok: {} }, [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ]); - return; + return { kind: "accepted" }; } - case "feed/reload": { + case "reload_feed": { feedCount = PAGE_SIZE; await loadAll(); - outcome(command, { ok: {} }, [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ]); - return; + return { kind: "accepted" }; } - case "feed/mark-story-seen": { - const story = payloadString(command, "story"); - const reply = await rpc("mark_story_viewed", { story }); + case "mark_story": { + const route = "feed/mark-story-seen"; + const reply = await rpc("mark_story_viewed", { + story: operation.story, + }); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, storySettlementUpdates(story)); - return; + return { kind: "accepted" }; } - case "profile/follow-user": - case "profile/unfollow-user": { - const user = payloadString(command, "user"); - const fn = command.command === "follow-user" ? "follow_user" : "unfollow_user"; - const reply = await rpc(fn, { target: user }); + case "set_follow": { + const route = operation.following + ? "profile/follow-user" + : "profile/unfollow-user"; + const reply = await rpc( + operation.following ? "follow_user" : "unfollow_user", + { target: operation.user }, + ); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, allSocialUpdates()); - return; + return { kind: "accepted" }; } - case "profile/search-people": { - searchQuery = payloadString(command, "query"); + case "search_people": { + searchQuery = operation.query; await loadAll(); - outcome(command, { ok: {} }, [ - projectionUpdate( - "profile", - "search-results", - null, - searchValue(searchQuery), - ), - ]); - return; + return { kind: "accepted" }; } - case "create/choose-image": { + case "choose_image_request": { if (!pickedFile) { throw new Error("this play host cannot choose local files"); } const picked = await pickedFile; if ("error" in picked) throw picked.error; if (picked.file === null) { - outcome(command, { ok: {} }); - return; + return { kind: "refused", reason: "selection-cancelled" }; } if (!SUPPORTED_IMAGE_TYPES.has(picked.file.type.trim().toLowerCase())) { - outcome(command, { - refused: { refusal: "unsupported-media-type" }, - }); - return; + return { kind: "refused", reason: "unsupported-media-type" }; } const object = await uploadFile(picked.file); uploadedFileNames.set(object, picked.file.name); - outcome(command, { ok: {} }, [ - projectionUpdate("create", "draft", null, { - uploaded: { - object, - preview: object, - name: picked.file.name, - }, - }), - ]); - return; + return { + kind: "image_ready", + object, + preview: object, + name: picked.file.name, + }; } - case "create/publish-image": { - const image = payloadString(command, "image"); - const caption = payloadString(command, "caption"); - const requestedAlt = payloadString(command, "alt"); - const alt = requestedAlt.trim().length > 0 - ? requestedAlt - : fallbackUploadAlt(image); - const reply = await rpc("create_image_post", { image, caption, alt }); + case "publish_image_request": { + const route = "create/publish-image"; + const alt = operation.alt.trim().length > 0 + ? operation.alt + : fallbackUploadAlt(operation.object); + const reply = await rpc("create_image_post", { + image: operation.object, + caption: operation.caption, + alt, + }); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } if ( typeof reply.result !== "object" || @@ -1720,36 +1715,13 @@ export function createDriver( ) { throw new Error("create_image_post returned no post id"); } - const post = reply.result.id; await loadAll(); - uploadedFileNames.delete(image); - outcome(command, { ok: {} }, [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - projectionUpdate( - "feed", - "post-by-id", - post, - postSummary(requirePost(post)), - ), - projectionUpdate("comments", "for-post", post, threadValue(post)), - projectionUpdate("profile", "profile", viewerId(), profileValue(viewerId())), - projectionUpdate( - "profile", - "search-results", - null, - searchValue(searchQuery), - ), - projectionUpdate("create", "draft", null, { empty: {} }), - ]); - return; + uploadedFileNames.delete(operation.object); + return { kind: "accepted" }; } - default: - outcome(command, { - unavailable: { reason: `no binding for command \`${route}\`` }, - }); } } catch (error) { - outcome(command, { unavailable: { reason: errorMessage(error) } }); + return { kind: "refused", reason: errorMessage(error) }; } } @@ -1769,7 +1741,7 @@ export function createDriver( }; }, - async assembleBoot() { + async load() { await authorityTail; assertLive(); await loadAll(); @@ -1777,63 +1749,15 @@ export function createDriver( const viewer = viewerRow; if (!viewer) throw new Error(`actor \`${actor}\` is not a seeded user`); await verifyViewer(); - - outbox.push(projectionMsg("feed", "feed-page", null, feedPageValue())); - for (const post of db.posts) { - outbox.push( - projectionMsg("comments", "for-post", post.id, threadValue(post.id)), - projectionMsg("feed", "post-by-id", post.id, postSummary(post)), - ); - } - for (const story of db.stories) { - outbox.push( - projectionMsg( - "feed", - "story-by-id", - story.id, - storyValue(story.id), - ), - ); - } - outbox.push(projectionMsg("feed", "reels", null, reelsValue())); - for (const userId of db.users.keys()) { - outbox.push( - projectionMsg("profile", "profile", userId, profileValue(userId)), - projectionMsg("profile", "followers", userId, followersValue(userId)), - projectionMsg("profile", "following", userId, followingValue(userId)), - ); - } - outbox.push( - projectionMsg( - "profile", - "search-results", - null, - searchValue(searchQuery), - ), - ); - outbox.push(projectionMsg("create", "draft", null, { empty: {} })); - - return encode({ - updates: [ - { - port: "feed", - projection: "viewer", - key: null, - revision: 1, - value: userRef(viewer), - }, - ], - }); }, - deliver(commandJson: string) { - if (disposed) return; - const command = JSON.parse(commandJson) as ProviderCommand; + execute(operation: BackendOperation): Promise { + assertLive(); let pickedFile: PickedFile | undefined; - if (`${command.port}/${command.command}` === "create/choose-image") { + if (operation.kind === "choose_image_request") { try { // This must happen in the click's synchronous call stack. Deferring - // it behind commandTail would lose browser user activation. + // it behind the operation queue would lose browser user activation. pickedFile = host.pickFile({ accept: "image/jpeg,image/png,image/webp" }) .then( (file) => ({ file }), @@ -1843,34 +1767,323 @@ export function createDriver( pickedFile = Promise.resolve({ error }); } } - inflight += 1; - // Preserve delivery order inside this driver. Only domain mutations - // enter the cross-driver authority barrier: a picker, upload draft, or - // ordinary read must never strand a later Play boot. - const route = `${command.port}/${command.command}`; - const predecessor = commandTail; - commandTail = predecessor - .then(() => { - if (disposed) return; - const work = () => handle(command, pickedFile); - return AUTHORITY_COMMANDS.has(route) - ? enqueueAuthorityWork(work) - : work(); - }) - .finally(() => { - inflight -= 1; - }); + const work = operationTail.then(() => { + assertLive(); + const run = () => handle(operation, pickedFile); + return AUTHORITY_OPERATIONS.has(operation.kind) + ? enqueueAuthorityWork(run) + : run(); + }); + operationTail = work.then( + () => {}, + () => {}, + ); + return work; }, - tick() { - if (disposed) return []; - return outbox.splice(0, outbox.length); + authorityValue, + resolveAsset, + }; +} + +function wireObject(value: unknown, context: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${context} must be an object`); + } + return value as Record; +} + +function variantFields( + value: WireValue, + type: string, + caseName?: string, +): Map { + if (value.$ !== "variant" || value.type !== type) { + throw new TypeError(`expected Uhura variant ${type}`); + } + if (caseName !== undefined && value.case !== caseName) { + throw new TypeError(`expected Uhura variant ${type}.${caseName}`); + } + if (!Array.isArray(value.fields)) { + throw new TypeError(`Uhura variant ${type} has no fields`); + } + const fields = new Map(); + for (const raw of value.fields) { + const field = wireObject(raw, `${type} field`); + const name = field.name; + if (name !== null && typeof name !== "string") { + throw new TypeError(`${type} field name must be text or null`); + } + const child = wireObject(field.value, `${type} field value`) as WireValue; + if (fields.has(name)) throw new TypeError(`${type} repeats field ${String(name)}`); + fields.set(name, child); + } + return fields; +} + +function requiredField( + fields: ReadonlyMap, + name: string, +): WireValue { + const value = fields.get(name); + if (!value) throw new TypeError(`Uhura value has no field \`${name}\``); + return value; +} + +function keyText(value: WireValue, type: string): string { + if (value.$ !== "key" || value.type !== type) { + throw new TypeError(`expected Uhura key ${type}`); + } + const body = wireObject(value.value, `${type} body`); + if (body.$ !== "Text" || typeof body.value !== "string") { + throw new TypeError(`${type} must wrap Text`); + } + return body.value; +} + +function requestText(value: WireValue): string { + if (value.$ !== "key" || value.type !== REQUEST_ID_TYPE) { + throw new TypeError(`expected Uhura key ${REQUEST_ID_TYPE}`); + } + const body = wireObject(value.value, `${REQUEST_ID_TYPE} body`); + if ( + body.$ !== "PositiveInt" + || typeof body.value !== "string" + || !/^[1-9]\d*$/u.test(body.value) + ) { + throw new TypeError(`${REQUEST_ID_TYPE} must wrap PositiveInt`); + } + return body.value; +} + +function textValue(value: WireValue): string { + if (value.$ !== "Text" || typeof value.value !== "string") { + throw new TypeError("expected Uhura Text"); + } + return value.value; +} + +function boolValue(value: WireValue): boolean { + if (value.$ !== "bool" || typeof value.value !== "boolean") { + throw new TypeError("expected Uhura Bool"); + } + return value.value; +} + +interface AdaptedRequest { + readonly request: WireValue; + readonly operation: BackendOperation; +} + +function adaptRequest(command: WireValue): AdaptedRequest { + const requestFields = variantFields( + command, + MUTATIONS_SEND_TYPE, + "request", + ); + const request = requiredField(requestFields, "id"); + requestText(request); + const payload = requiredField(requestFields, "payload"); + const fields = variantFields(payload, MUTATION_TYPE); + const mutation = String(payload.case); + + switch (mutation) { + case "SetLike": + return { + request, + operation: { + kind: "set_like", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + liked: boolValue(requiredField(fields, "liked")), + }, + }; + case "SetSave": + return { + request, + operation: { + kind: "set_save", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + saved: boolValue(requiredField(fields, "saved")), + }, + }; + case "LoadMore": + return { request, operation: { kind: "load_more" } }; + case "ReloadFeed": + return { request, operation: { kind: "reload_feed" } }; + case "SetFollow": + return { + request, + operation: { + kind: "set_follow", + user: keyText(requiredField(fields, "user"), USER_ID_TYPE), + following: boolValue(requiredField(fields, "following")), + }, + }; + case "AddComment": + return { + request, + operation: { + kind: "add_comment", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + body: textValue(requiredField(fields, "body")), + }, + }; + case "SearchPeople": + return { + request, + operation: { + kind: "search_people", + query: textValue(requiredField(fields, "query")), + }, + }; + case "ChooseImage": + return { request, operation: { kind: "choose_image_request" } }; + case "PublishImage": + return { + request, + operation: { + kind: "publish_image_request", + object: textValue(requiredField(fields, "object")), + caption: textValue(requiredField(fields, "caption")), + alt: textValue(requiredField(fields, "alt")), + }, + }; + case "MarkStory": + return { + request, + operation: { + kind: "mark_story", + story: keyText(requiredField(fields, "story"), STORY_ID_TYPE), + }, + }; + default: + throw new TypeError(`unsupported Instagram mutation \`${mutation}\``); + } +} + +function observed(value: WireValue): WireValue { + return wireVariant( + AUTHORITY_RECEIVE_TYPE, + "authority.observed", + [["value", value]], + ); +} + +function refused(reason: string): WireValue { + return wireVariant(SETTLEMENT_TYPE, "Refused", [[ + "reason", + wireText(reason), + ]]); +} + +function settlementValue(result: BackendSettlement): WireValue { + switch (result.kind) { + case "accepted": + return wireVariant(SETTLEMENT_TYPE, "Accepted"); + case "refused": + return refused(result.reason); + case "image_ready": + return wireVariant(SETTLEMENT_TYPE, "ImageReady", [ + ["object", wireText(result.object)], + ["preview", wireText(result.preview)], + ["name", wireText(result.name)], + ]); + } +} + +function settled(request: WireValue, result: WireValue): WireValue { + return wireVariant( + MUTATIONS_RECEIVE_TYPE, + "mutations.settled", + [ + ["id", request], + ["result", result], + ], + ); +} + +function providerConfig( + config: Readonly>, +): SpockProviderConfig { + const value = (name: keyof SpockProviderConfig): string => { + const entry = config[name]; + if (typeof entry !== "string" || entry.trim().length === 0) { + throw new TypeError(`Instagram provider needs nonempty \`${name}\``); + } + return entry; + }; + return { + graphql_url: value("graphql_url"), + rpc_url: value("rpc_url"), + storage_url: value("storage_url"), + actor: value("actor"), + }; +} + +/** + * Current Uhura adapter entry point. Contract identities come from the + * admitted Play deployment; the app provider never calculates or hardcodes + * compiler-owned hashes. + */ +export function createUhuraAdapters( + config: Readonly>, + host: AdapterProviderHost, +): { + readonly adapters: readonly PortAdapter[]; + resolveAsset(asset: string): Promise; + systemInfo(): RemoteSystemInfo; + dispose(): void; +} { + const backend = createSpockBackend(providerConfig(config), host); + const authorityRequirement = host.port("authority"); + const mutationsRequirement = host.port("mutations"); + let authorityContext: PortAdapterContext | null = null; + + const authority: PortAdapter = { + ...authorityRequirement, + async start(context): Promise { + authorityContext = context; + try { + await backend.load(); + context.deliver(observed(backend.authorityValue())); + } catch (error) { + context.deliver( + observed( + wireVariant(AUTHORITY_TYPE, "Failed", [[ + "reason", + wireText(errorMessage(error)), + ]]), + ), + ); + } + }, + accept(): never { + throw new Error("Observation does not accept commands"); }, + }; - idle() { - return inflight === 0 && outbox.length === 0; + const mutations: PortAdapter = { + ...mutationsRequirement, + accept(command, context): Promise { + const adapted = adaptRequest(command); + const work = backend.execute(adapted.operation).then((settlement) => { + const result = settlementValue(settlement); + if ( + result.case === "Accepted" + && adapted.operation.kind !== "choose_image_request" + ) { + authorityContext?.deliver(observed(backend.authorityValue())); + } + context.deliver(settled(adapted.request, result)); + }); + return work; }, + }; - resolveAsset, + return { + adapters: [authority, mutations], + resolveAsset: (asset) => backend.resolveAsset(asset), + systemInfo: () => backend.systemInfo(), + dispose: () => backend.dispose(), }; } diff --git a/examples/instagram/client/styles/theme.css b/examples/instagram/client/styles/theme.css index 23510d7..99f8cf8 100644 --- a/examples/instagram/client/styles/theme.css +++ b/examples/instagram/client/styles/theme.css @@ -85,7 +85,7 @@ body { } /* shared utilities the slice names in class= */ -.screen { display: flex; flex-direction: column; block-size: 100%; } +.screen { display: flex; flex-direction: column; min-block-size: 0; block-size: 100%; } .fill-center { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .row-center { display: flex; align-items: center; justify-content: center; padding: var(--space-3); } .stack-sm { gap: var(--space-2); } @@ -98,7 +98,7 @@ body { /* The desktop prototype is an application layout, not a stretched phone. Navigation becomes a persistent Instagram-style rail; content retains readable widths. Story intentionally stays full-bleed. */ -#uh-frame[data-frame="desktop"] .screen:not(.story-page) { +#uh-frame[data-frame="desktop"] .instagram-app:has(> .bottom-nav) { position: relative; padding-inline-start: var(--nav-rail-width); } @@ -161,19 +161,27 @@ body { font-size: 25px; } -#uh-frame[data-frame="desktop"] .screen:not(.story-page):not(.reels-page) > :not(.bottom-nav) { +#uh-frame[data-frame="desktop"] .reels-page + .bottom-nav .nav-items .uh-button { + color: var(--color-ink-subtle); +} + +#uh-frame[data-frame="desktop"] .reels-page + .bottom-nav .nav-items .uh-button[aria-current="true"] { + color: var(--color-ink); +} + +#uh-frame[data-frame="desktop"] .instagram-app > .screen:not(.story-page):not(.reels-page) > * { inline-size: 100%; max-inline-size: var(--content-wide); margin-inline: auto; } -#uh-frame[data-frame="desktop"] .feed-page > :not(.bottom-nav), -#uh-frame[data-frame="desktop"] .post-page > :not(.bottom-nav), -#uh-frame[data-frame="desktop"] .people-page > :not(.bottom-nav) { +#uh-frame[data-frame="desktop"] .instagram-app > .feed-page > *, +#uh-frame[data-frame="desktop"] .instagram-app > .post-page > *, +#uh-frame[data-frame="desktop"] .instagram-app > .connections-page > * { max-inline-size: var(--content-narrow); } -#uh-frame[data-frame="desktop"] .create-page > :not(.bottom-nav) { +#uh-frame[data-frame="desktop"] .instagram-app > .create-page > * { max-inline-size: 720px; } @@ -187,14 +195,14 @@ body { /* Reels remains an immersive black surface, but a desktop-sized video should not stretch across the full workspace beside the rail. */ -#uh-frame[data-frame="desktop"] .reels-page > :not(.bottom-nav):not(.reels-head) { +#uh-frame[data-frame="desktop"] .instagram-app > .reels-page > :not(.reels-head) { inline-size: 100%; max-inline-size: 520px; margin-inline: auto; } #uh-frame[data-frame="desktop"] .reels-page .reels-head { - inset-inline: var(--nav-rail-width) 0; + inset-inline: 0; } #uh-frame[data-frame="desktop"] .reels-page .reels-head .title { @@ -204,7 +212,7 @@ body { } #uh-frame[data-frame="desktop"] .reels-page > .notice-bar { - inset-inline-start: var(--nav-rail-width); + inset-inline-start: 0; } #uh-frame[data-frame="desktop"] .reel-card .reel-media { @@ -214,3 +222,720 @@ body { #uh-frame[data-frame="desktop"] .reel-card .reel-overlay { padding-block-end: 58px; } + +/* Instagram application projections. These rules used to be distributed + across v0 page/component files; the current project has one checked UI + module and one deployment-owned stylesheet. */ +.feed-page, +.create-page, +.profile-page, +.search-page, +.post-page, +.connections-page, +.reels-page, +.story-page { + display: flex; + flex-direction: column; + block-size: 100%; +} + +.instagram-app { + position: relative; + min-block-size: 0; + overflow: hidden; +} + +.instagram-app > .screen { + flex: 1; + min-block-size: 0; + block-size: auto; +} + +.feed-head, +.create-head, +.detail-bar, +.search-head, +.top-bar { + flex: none; + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: 52px; + padding: var(--space-2) var(--space-4); + border-block-end: 1px solid var(--color-line); + background: var(--color-surface); +} + +.feed-head { + justify-content: space-between; +} + +.feed-head .wordmark { + font-family: "Snell Roundhand", "Segoe Script", cursive; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.04em; +} + +.feed-head-actions, +.action-row, +.action-primary, +.create-actions, +.profile-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.feed-head-actions .uh-button, +.icon-action { + min-inline-size: 40px; + min-block-size: 40px; + justify-content: center; +} + +.feed-head-actions > .uh-region { + display: grid; + place-items: center; + min-inline-size: 44px; + min-block-size: 44px; +} + +.viewer-avatar, +.avatar { + inline-size: 32px; + block-size: 32px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.feed-scroll, +.post-scroll, +.profile-scroll, +.search-scroll, +.connection-list, +.reels-scroll, +.comment-list, +.create-form { + flex: 1; + min-block-size: 0; + overscroll-behavior-y: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.feed-scroll::-webkit-scrollbar, +.post-scroll::-webkit-scrollbar, +.profile-scroll::-webkit-scrollbar, +.search-scroll::-webkit-scrollbar, +.connection-list::-webkit-scrollbar, +.reels-scroll::-webkit-scrollbar, +.comment-list::-webkit-scrollbar, +.create-form::-webkit-scrollbar, +.stories-tray::-webkit-scrollbar { + display: none; +} + +.feed-empty { + min-block-size: 360px; + text-align: center; +} + +.post-list { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.notice, +.notice-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + background: var(--color-surface-sunken); + border-block-end: 1px solid var(--color-line); +} + +.stories-tray { + flex: none; + overscroll-behavior-inline: contain; + scrollbar-width: none; + border-block-end: 1px solid var(--color-line); + -webkit-overflow-scrolling: touch; +} + +.ring-row { + display: flex; + min-inline-size: 100%; + inline-size: max-content; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); +} + +.ring-row > [role="listitem"] { + flex: none; +} + +.ring-item { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} + +.ring { + inline-size: 56px; + block-size: 56px; + padding: 2px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.ring.unseen { border: 2px solid var(--color-accent); } +.ring.seen { border: 2px solid var(--color-line); } + +.ring-name { + max-inline-size: 60px; + overflow: hidden; + color: var(--color-ink-subtle); + font-size: var(--type-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.post-card { + display: flex; + flex-direction: column; + gap: var(--space-2); + background: var(--color-surface); +} + +.post-card .author-row { + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: 48px; + padding: var(--space-2) var(--space-4); +} + +.post-card .username, +.comment-row .username, +.connection-row .username, +.reel-card .username { + font-weight: 600; +} + +.post-card .media, +.post-card .slide { + inline-size: 100%; + aspect-ratio: 1; + object-fit: cover; +} + +.post-card .action-row { + justify-content: space-between; + padding-inline: var(--space-2); +} + +.icon-action[aria-pressed="true"]:not(.save-action) { + color: var(--color-accent); +} + +.icon-action[aria-pressed="true"] .uh-icon { + transform: scale(1.06); +} + +.save-action { margin-inline-start: auto; } + +.post-card .likes { + padding-inline: var(--space-4); + font-weight: 600; +} + +.caption-row { + display: block; + padding-inline: var(--space-4); +} + +.caption-author { + display: inline; + margin-inline-end: var(--space-2); + font-weight: 600; + white-space: nowrap; +} + +.caption { display: inline; } + +.comments-link, +.post-meta-link { + display: inline-flex; + align-items: center; + inline-size: fit-content; + min-block-size: 32px; + margin-inline: var(--space-4); +} + +.comment-link { + color: var(--color-ink-subtle); + font-size: var(--type-sm); +} + +.posted-label { + color: var(--color-ink-subtle); + font-size: var(--type-xs); +} + +.bottom-nav { + z-index: 6; + flex: none; + display: flex; + align-items: center; + min-block-size: 54px; + padding: var(--space-1) var(--space-2); + border-block-start: 1px solid var(--color-line); + background: var(--color-surface); +} + +.bottom-nav .nav-brand, +.bottom-nav .nav-label { + display: none; +} + +.bottom-nav .nav-items { + flex: 1; + display: flex; + align-items: center; + justify-content: space-around; + min-inline-size: 0; +} + +.bottom-nav .nav-items .uh-button { + flex: 1; + justify-content: center; + min-inline-size: 44px; + min-block-size: 44px; + padding: var(--space-2); + color: var(--color-ink-subtle); +} + +.bottom-nav .nav-items .uh-button[aria-current="true"] { + color: var(--color-ink); +} + +.bottom-nav .nav-items .uh-button[aria-current="true"] .uh-icon { + transform: scale(1.08); +} + +.reels-page + .bottom-nav { + position: absolute; + inset: auto 0 0; + color: var(--color-on-media); + background: linear-gradient(to top, rgb(0 0 0 / 72%), transparent); + border-block-start: 0; +} + +.reels-page + .bottom-nav .nav-items .uh-button { + color: rgb(255 255 255 / 78%); +} + +.reels-page + .bottom-nav .nav-items .uh-button[aria-current="true"] { + color: var(--color-on-media); +} + +.create-page .upload-mark { + display: grid; + place-items: center; + inline-size: 72px; + block-size: 72px; + border: 1px dashed var(--color-ink-faint); + border-radius: var(--radius-full); +} + +.create-page .upload-mark .uh-icon { font-size: 30px; } +.create-empty-title { font-size: var(--type-lg); font-weight: 600; } + +.create-preview { + inline-size: 100%; + aspect-ratio: 1; + object-fit: cover; + background: var(--color-surface-sunken); +} + +.create-form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.create-form > :not(.create-preview) { + margin-inline: var(--space-4); +} + +.create-actions { + justify-content: space-between; +} + +.create-actions .uh-button { + flex: 1; + justify-content: center; +} + +.technical-id { + overflow-wrap: anywhere; + color: var(--color-ink-faint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--type-xs); +} + +.profile-header { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); +} + +.profile-header .head-row { + display: flex; + align-items: center; + gap: var(--space-6); +} + +.profile-avatar { + inline-size: 80px; + block-size: 80px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.profile-header .stats { + flex: 1; + display: flex; + justify-content: space-around; +} + +.profile-header .stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + padding: 0; +} + +.stat-num, +.display-name { font-weight: 600; } +.stat-name, +.profile-handle { color: var(--color-ink-subtle); font-size: var(--type-xs); } +.bio { font-size: var(--type-sm); } + +.profile-action { + flex: 1; + justify-content: center; + min-block-size: 38px; +} + +.profile-tabs { + position: sticky; + inset-block-start: 0; + z-index: 2; + display: flex; + justify-content: space-around; + border-block: 1px solid var(--color-line); + background: var(--color-surface); +} + +.profile-tabs .uh-button { + flex: 1; + justify-content: center; + min-block-size: 44px; + border-radius: 0; +} + +.profile-tabs .uh-button[aria-current="true"], +.profile-tabs .uh-button[aria-pressed="true"] { + box-shadow: inset 0 -2px 0 var(--color-ink); +} + +.profile-grid, +.explore-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2px; +} + +.profile-empty { + min-block-size: 240px; + text-align: center; +} + +.profile-grid .grid-tile, +.explore-grid .grid-tile { + inline-size: 100%; + aspect-ratio: 1; + object-fit: cover; +} + +.search-head { + flex-direction: column; + align-items: stretch; + gap: var(--space-3); +} + +.search-title { + font-size: var(--type-xl); + font-weight: 700; +} + +.search-controls { + display: flex; + align-items: flex-end; + gap: var(--space-2); +} + +.search-controls .uh-textfield { + flex: 1; +} + +.search-controls .uh-button { + flex: none; + min-inline-size: 44px; + justify-content: center; +} + +.connection-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-block-end: 1px solid var(--color-line); +} + +.connection-person, +.connection-copy { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.connection-copy { + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); +} + +.connection-row .avatar { + inline-size: 48px; + block-size: 48px; +} + +.reels-page { + position: relative; + color: var(--color-on-media); + background: #050505; +} + +.reels-head { + pointer-events: none; + position: absolute; + inset: 0 0 auto; + z-index: 4; + padding: var(--space-4); + color: var(--color-on-media); + background: linear-gradient(to bottom, rgb(0 0 0 / 55%), transparent); +} + +.reels-page .muted { color: rgb(255 255 255 / 80%); } + +.reels-scroll { + scroll-behavior: smooth; + scroll-snap-type: y mandatory; + overscroll-behavior-y: contain; + scrollbar-width: none; +} + +.reel-card { + position: relative; + display: grid; + min-block-size: 100%; + overflow: hidden; + color: var(--color-on-media); + background: #050505; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.reels-scroll > .reel-card { + block-size: 100%; +} + +.reel-card > * { grid-area: 1 / 1; } + +.reel-media { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.reel-overlay { + pointer-events: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-6) var(--space-3) 118px var(--space-4); + background: linear-gradient(to bottom, transparent 38%, rgb(0 0 0 / 72%)); +} + +.reel-copy, +.reel-actions { + pointer-events: auto; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.reel-copy { + max-inline-size: calc(100% - 76px); + text-shadow: 0 1px 2px rgb(0 0 0 / 65%); +} + +.reel-author { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.reel-actions { align-items: center; } + +.reel-action[aria-pressed="true"]:not(.reel-save-action) { + color: var(--color-accent); +} + +.reel-action[aria-pressed="true"] .uh-icon { + transform: scale(1.06); +} + +.story-page { + color: var(--color-on-media); + background: var(--color-ink); +} + +.story-page .muted { color: rgb(255 255 255 / 80%); } + +.story-stage { + flex: 1; + display: grid; + min-block-size: 0; +} + +.story-stage > * { grid-area: 1 / 1; } + +.story-image { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.story-progress { + z-index: 2; + display: flex; + gap: 3px; + block-size: fit-content; + padding: var(--space-3); +} + +.story-segment { + flex: 1; + block-size: 2px; + border-radius: var(--radius-full); + background: rgb(255 255 255 / 36%); +} + +.story-segment.current, +.story-segment.viewed { background: #fff; } + +.story-head { + z-index: 2; + display: flex; + align-items: center; + gap: var(--space-2); + block-size: fit-content; + margin-block-start: var(--space-6); + padding: var(--space-3); + text-shadow: 0 1px 2px rgb(0 0 0 / 70%); +} + +.story-head .uh-button { margin-inline-start: auto; color: #fff; } + +.story-caption { + z-index: 2; + align-self: end; + max-inline-size: 80%; + margin-block-end: var(--space-8); + padding: var(--space-3); + text-shadow: 0 1px 2px rgb(0 0 0 / 80%); +} + +.story-hit-zones { + z-index: 1; + display: grid; + grid-template-columns: 1fr 1fr; +} + +.story-hit-zones .uh-button { + min-block-size: 100%; + color: transparent; + border-radius: 0; +} + +.comments-sheet { + display: flex; + flex-direction: column; + block-size: 100%; + background: var(--color-surface); +} + +.comments-sheet .sheet-head, +.comments-sheet .composer { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); +} + +.comments-sheet .sheet-head { + justify-content: space-between; + border-block-end: 1px solid var(--color-line); +} + +.comments-sheet .composer { + border-block-start: 1px solid var(--color-line); +} + +.comments-sheet .composer .uh-textfield { flex: 1; } +.sheet-title, +.empty-title { font-weight: 600; } + +.comment-row { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); +} + +.comment-row .avatar, +.composer .avatar { + inline-size: 28px; + block-size: 28px; +} + +.comment-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pending { opacity: 0.55; } diff --git a/examples/instagram/client/surfaces/comments-sheet.examples.uhura b/examples/instagram/client/surfaces/comments-sheet.examples.uhura deleted file mode 100644 index b5e1c6e..0000000 --- a/examples/instagram/client/surfaces/comments-sheet.examples.uhura +++ /dev/null @@ -1,42 +0,0 @@ -use fixture standard - -example populated default { - props { post = "post-lena-glaze" } - projection feed.viewer = fixture.users.mira - projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze -} - -example composing { - from populated - events [ composer-changed(value: "Saving this palette for my kitchen reno") ] - note "composer mid-draft; Post enables once non-empty" -} - -example pending-append { - from composing - events [ submit-requested() ] - note "optimistic dimmed row until the outcome settles" -} - -example empty { - props { post = "post-ayla-ferry" } - projection feed.viewer = fixture.users.mira - projection comments.for-post("post-ayla-ferry") = fixture.comments.empty -} - -example empty-composing { - from empty - events [ composer-changed(value: "First comment") ] -} - -example empty-pending { - from empty-composing - events [ submit-requested() ] - note "the optimistic row replaces the empty state and serializes submission" -} - -example rejected { - from empty-pending - events [ outcome add-comment.err(reason: "comment_body_invalid") ] - note "a refusal restores the submitted body for correction" -} diff --git a/examples/instagram/client/surfaces/comments-sheet.uhura b/examples/instagram/client/surfaces/comments-sheet.uhura deleted file mode 100644 index 5aead7a..0000000 --- a/examples/instagram/client/surfaces/comments-sheet.uhura +++ /dev/null @@ -1,115 +0,0 @@ -surface comments-sheet modality sheet - -use component comment-row -use port comments { projection for-post, command add-comment } -use port feed { projection viewer } - -props { - post: id -} - -store { - state { - draft: text = "" - pending-appends: map[tag]text = {} - comment-pending: bool = false - notice: text? = none - } - - on composer-changed(value: text) { - set draft = value - } - - on submit-requested() when draft != "" && !comment-pending { - send add-comment(post: post, body: draft) as t - set pending-appends[t] = draft - set comment-pending = true - set draft = "" - } - - on add-comment.ok(tag, cmd) { - set pending-appends[tag] = none - set comment-pending = false - } - - on add-comment.err(tag, cmd, refusal) { - set pending-appends[tag] = none - set comment-pending = false - set draft = cmd.body - set notice = "Couldn't post your comment. Try again." - } - - on dismiss-requested() { - dismiss - } - - on notice-dismissed() { - set notice = none - } -} - - - - Comments - - - {#if notice != none} - - {notice ?? ""} - - - {/if} - {#match for-post(post)} - {:when loading} - - - - {:when failed reason} - - Comments couldn't load. - - {:when ready t} - - - {#if count(t.comments) == 0 && count(pending-appends) == 0} - - No comments yet - Start the conversation. - - {:else} - - {#each t.comments as c (c.id)} - - {/each} - - {/if} - - {#each pending-appends as pending-tag (pending-tag)} - - {/each} - - - {/match} - - {viewer.avatar.alt} - - - - - - diff --git a/examples/instagram/client/uhura.lock b/examples/instagram/client/uhura.lock deleted file mode 100644 index b43a527..0000000 --- a/examples/instagram/client/uhura.lock +++ /dev/null @@ -1,9 +0,0 @@ -# uhura.lock — canonical contract pins (§9.1). `uhura check` writes this -# file when absent and errors on drift; delete it to re-pin intentionally. -catalog base 0.3.0 sha256:5a8957419d5b25051a93834888385117bb1f1a03424f9f5115875e28ceaac8ec -icon-glyphs lucide sha256:4b8c4c4d25a12009c031d2d3db86e978a8f0624f1c92fe672650daee9aac3643 -icon-font lucide sha256:ac8e910a948c000ad075c8ebc7c429f066f68b87a4fbf6bce2d911588102c403 -port comments 0.1.0 sha256:3ab0bd261953917213e4932eb3ca62731f25452183b713a7776f61ccc2ffadc8 -port create 0.1.0 sha256:ff8666517391bcd8d87efcbfb2d1862af25ec9757f2fb280918fd852e5eb0731 -port feed 0.1.0 sha256:8a34a68363cc0492734a233c04bd0bbfd6d73cf4589ede56658a1743361e600d -port profile 0.1.0 sha256:73e13ebb6b0cbb11b5331523430ca2f635f3d335456cfb1baa6f3307c12ae814 diff --git a/examples/instagram/client/uhura.toml b/examples/instagram/client/uhura.toml index 74808e3..3782dce 100644 --- a/examples/instagram/client/uhura.toml +++ b/examples/instagram/client/uhura.toml @@ -1,41 +1,20 @@ -# App manifest (design §3): entry route, catalog pin, port bindings, -# fixtures, and play profiles. Paths are corpus-relative. +[project] +name = "app.instagram" +version = 1 +language = "0.4" -[app] -name = "instagram" -entry = "feed" +[modules] +instagram = "machine.uhura" +parts = "parts.uhura" +ui = "ui.uhura" -[catalog] -path = "catalog/base.toml" - -[ports] -feed = "ports/feed.port.toml" -comments = "ports/comments.port.toml" -profile = "ports/profile.port.toml" -create = "ports/create.port.toml" - -[fixtures] -standard = "fixtures/standard.toml" +[evidence.modules] +previews = "evidence.uhura" +# Live instance identity, presentation, lifetime, and port bindings belong to +# `host.toml`. [assets] manifest = "fixtures/assets/manifest.toml" -# `uhura play`/`uhura trace` profiles: which fixture data + script to drive. -[play.default] -fixture = "standard" -script = "demo" -# This strict script is a deterministic preview/trace walkthrough, not a -# complete interactive backend. Browser Play is therefore Spock-only. -allow_fixture = false - -# The browser play shell uses only the live provider. The fixture and -# script above remain the deterministic source for checks, canvas examples, -# and `uhura trace`. -[play.default.provider] -module = "providers/dist/spock.js" - -[play.default.provider.config] -graphql_url = "http://127.0.0.1:4000/graphql/v1" -rpc_url = "http://127.0.0.1:4000/rest/v1/rpc" -storage_url = "http://127.0.0.1:4000/storage/v1" -actor = "10000000-0000-4000-8000-000000000001" +[icons] +default = "lucide" diff --git a/examples/instagram/client/ui.uhura b/examples/instagram/client/ui.uhura new file mode 100644 index 0000000..cbf0ecd --- /dev/null +++ b/examples/instagram/client/ui.uhura @@ -0,0 +1,2652 @@ +use uhura::ui; +use crate::instagram::{AppData, Authority, Comment, Connection, Instagram, Media, Page, Post, PostId, Profile, ProfileTab, Section, SearchStatus, FeedStatus, StoryDetail, StoryId, Tile, Upload, UserId, KENJI, LENA, LENA_GLAZE, LENA_PROFILE, LENA_STORY, MIRA, NILS, POST_LENA_GLAZE, USER_LENA, USER_MIRA, USER_NILS}; +use uhura::ui_surface::Surface; + +pub fn post_for(data: AppData, id: PostId) -> Post { + match data.posts.get(id) { + Some(post) => post, + None => LENA_GLAZE, + } +} + +pub fn profile_for(data: AppData, user: UserId) -> Profile { + match data.profiles.get(user) { + Some(profile) => profile, + None => LENA_PROFILE, + } +} + +pub fn comments_for(data: AppData, post: PostId) -> Seq { + match data.comments.get(post) { + Some(comments) => comments, + None => [], + } +} + +pub fn followers_for(data: AppData, user: UserId) -> Seq { + match data.followers.get(user) { + Some(connections) => connections, + None => [], + } +} + +pub fn following_for(data: AppData, user: UserId) -> Seq { + match data.following.get(user) { + Some(connections) => connections, + None => [], + } +} + +pub fn story_for(data: AppData, story: StoryId) -> StoryDetail { + match data.story_details.get(story) { + Some(detail) => detail, + None => LENA_STORY, + } +} + +pub fn effective_post_flag(source: Bool, overlay: Map, post: PostId) -> Bool { + match overlay.get(post) { + Some(value) => value, + None => source, + } +} + +pub fn effective_follow(source: Bool, overlay: Map, user: UserId) -> Bool { + match overlay.get(user) { + Some(value) => value, + None => source, + } +} + +pub fn post_for_page(data: AppData, page: Page) -> Post { + match page { + Page::Post { + id, + } => post_for(data, id), + _ => LENA_GLAZE, + } +} + +pub fn profile_for_page(data: AppData, page: Page) -> Profile { + match page { + Page::Profile { + user, + } => profile_for(data, user), + Page::Followers { + user, + } => profile_for(data, user), + Page::Following { + user, + } => profile_for(data, user), + _ => LENA_PROFILE, + } +} + +pub fn story_for_page(data: AppData, page: Page) -> StoryDetail { + match page { + Page::Story { + id, + } => story_for(data, id), + _ => LENA_STORY, + } +} + +pub fn tiles_for(profile: Profile, tab: ProfileTab) -> Seq { + match tab { + ProfileTab::Posts => profile.posts, + ProfileTab::Reels => profile.reels, + ProfileTab::Tagged => profile.tagged, + ProfileTab::Saved => profile.saved, + } +} + +pub fn show_navigation(page: Page) -> Bool { + match page { + Page::None => false, + Page::Story { + .., + } => false, + _ => true, + } +} + +pub fn feed_current(page: Page) -> Bool { + match page { + Page::Feed => true, + _ => false, + } +} + +pub fn search_current(page: Page) -> Bool { + match page { + Page::Search => true, + _ => false, + } +} + +pub fn create_current(page: Page) -> Bool { + match page { + Page::Create => true, + _ => false, + } +} + +pub fn reels_current(page: Page) -> Bool { + match page { + Page::Reels => true, + _ => false, + } +} + +pub fn profile_current(page: Page) -> Bool { + match page { + Page::Profile { + .., + } | Page::Followers { + .., + } | Page::Following { + .., + } => true, + _ => false, + } +} + +pub fn like_label(liked: Bool) -> Text { + if liked { + "Unlike" + } else { + "Like" + } +} + +pub fn save_label(saved: Bool) -> Text { + if saved { + "Remove from saved" + } else { + "Save post" + } +} + +pub fn follow_label(following: Bool) -> Text { + if following { + "Unfollow" + } else { + "Follow" + } +} + +pub fn profile_tabs_label(tab: ProfileTab) -> Text { + match tab { + ProfileTab::Posts => "Posts", + ProfileTab::Reels => "Reels", + ProfileTab::Tagged => "Tagged", + ProfileTab::Saved => "Saved", + } +} + +pub fn profile_empty_label(tab: ProfileTab) -> Text { + match tab { + ProfileTab::Posts => "No posts yet.", + ProfileTab::Reels => "No reels yet.", + ProfileTab::Tagged => "No tagged posts yet.", + ProfileTab::Saved => "Posts you save will appear here.", + } +} + +pub ui FeedPage for Instagram(view) { + + {#if view.page is Page::None} + + Opening Instagram… + + {:else} + {#if view.page is Page::Feed} + + + Instagram + + + {#if view.authority is Authority::Ready { + data: data, + }} + OpenProfile(data.viewer.id) + > + {data.viewer.avatar.alt} + + {:else} + + {/if} + + + {#if view.notice is Some(message)} + + {message} + + + {:else} + {#if view.notice is None} + + {/if} + {/if} + {#if view.authority is Authority::Loading} + + Loading your feed… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Your feed didn't load. + + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + FeedNearEnd + > + + + {#each data.stories as story (story.id)} + + OpenStory(story.id) + > + + {story.user.avatar.alt} + {story.user.username} + + + + {/each} + + + {#if data.feed_posts.size == 0} + + Nothing new yet + Posts from people you follow will appear here. + + {/if} + + {#each data.feed_posts as post (post.id)} + + OpenProfile(post.author.id) + > + + {post.author.avatar.alt} + {post.author.username} + + + {#if post.media is Media::Image { + image: media, + }} + ToggleLike(post.id, true) + > + {media.alt} + + {:else} + {#if post.media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post.media is Media::Video { + src: src, + poster: poster, + }} + + {/each} + + {#if view.feed_status is FeedStatus::Loading} + Loading more… + {:else} + {#if view.feed_status is FeedStatus::Failed} + + Couldn't load more. + + + {:else} + + {/if} + {/if} + {#if !data.feed_has_more} + You're all caught up. + {/if} + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Search} + + + Explore + + SearchChanged(event.text) + /> + + + + {#if view.authority is Authority::Loading} + + Loading Explore… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Search isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#if view.search_status is SearchStatus::Explore} + + {#each data.explore_tiles as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + {:else} + {#if view.search_status is SearchStatus::Searching} + + Searching… + + {:else} + {#if view.search_status is SearchStatus::Results} + + {#each data.search_people as person (person.user.id)} + + OpenProfile(person.user.id) + > + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + + {/each} + + {:else} + {#if view.search_status is SearchStatus::NoResults} + + No results + Try another username. + + {/if} + {/if} + {/if} + {/if} + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Create} + + + + New post + + {#if view.upload is Upload::Empty} + + + + + Share a photo + Choose a JPEG, PNG, or WebP image from this device. + + + {:else} + {#if view.upload is Upload::Choosing} + + Choosing a photo… + + {:else} + {#if view.upload is Upload::Uploaded { + object: object, + preview: preview, + name: name, + }} + + {name} + CaptionChanged(event.text) + /> + AltChanged(event.text) + /> + + {object} + + {:else} + {#if view.upload is Upload::Publishing { + preview: preview, + name: name, + .., + }} + + {name} + Publishing… + + {/if} + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Reels} + + + Reels + + {#if view.authority is Authority::Loading} + + Loading Reels… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Reels aren't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each data.reels as post (post.id)} + + {#if post.media is Media::Video { + src: src, + poster: poster, + }} + + {/each} + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Post { + id: id, + }} + + + + Post + + {#if view.authority is Authority::Loading} + + Loading post… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This post isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {post_for(data, + {post_for(data, id).author.username} + + {#if post_for(data, id).media is Media::Image { + image: media, + }} + {media.alt} + {:else} + {#if post_for(data, id).media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post_for(data, id).media is Media::Video { + src: src, + poster: poster, + }} + + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Profile { + user: user, + }} + + + {#if user != USER_MIRA} + + {/if} + {#if view.authority is Authority::Ready { + data: data, + }} + {profile_for(data, user).user.username} + {:else} + Profile + {/if} + + {#if view.authority is Authority::Loading} + + Loading profile… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This profile isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {profile_for(data, + + + {profile_for(data, user).post_count} + Posts + + + + + + {profile_for(data, user).user.display_name} + {profile_for(data, user).user.username} + {profile_for(data, user).bio} + + {#if user != data.viewer.id} + + {/if} + {#if user == data.viewer.id} + + {/if} + + + + + + + {#if user == data.viewer.id} + + {/if} + + {#if tiles_for(profile_for(data, user), view.profile_tab).size == 0} + + {profile_empty_label(view.profile_tab)} + + {/if} + + {#each tiles_for(profile_for(data, user), view.profile_tab) as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Followers { + user: user, + }} + + + + Followers + + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each followers_for(data, user) as person (person.user.id)} + + OpenProfile(person.user.id) + > + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {#if person.user.id != data.viewer.id} + + {/if} + + {/each} + + {:else} + + Loading followers… + + {/if} + + {:else} + {#if view.page is Page::Following { + user: user, + }} + + + + Following + + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each following_for(data, user) as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + {#if person.user.id != data.viewer.id} + + {/if} + + {/each} + + {:else} + + Loading following… + + {/if} + + {:else} + {#if view.page is Page::Story { + id: id, + }} + + {#if view.authority is Authority::Ready { + data: data, + }} + + {story_for(data, + + {#each story_for(data, id).progress as segment (segment.id)} + + {/each} + + + {story_for(data, + {story_for(data, id).author.username} + {story_for(data, id).posted_label} + + + {story_for(data, id).caption} + + {#if story_for(data, id).previous is Some(previous)} + + {:else} + {#if story_for(data, id).previous is None} + + {/if} + {/if} + {#if story_for(data, id).next is Some(next)} + + {:else} + {#if story_for(data, id).next is None} + + {/if} + {/if} + + + {:else} + + Loading story… + + {/if} + + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {#if show_navigation(view.page)} + + Instagram + + + + + + + + + {/if} + {#if view.comments_post is Some(post)} + + + + Comments + + + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each comments_for(data, post) as comment (comment.id)} + + {comment.author.avatar.alt} + + {comment.author.username} + {comment.body} + {comment.posted_label} + + + {/each} + {#if view.pending_comment is Some((_, body))} + + {data.viewer.avatar.alt} + + {data.viewer.username} + {body} + Posting… + + + {:else} + {#if view.pending_comment is None} + + {/if} + {/if} + + + {data.viewer.avatar.alt} + CommentChanged(event.text) + /> + + + {:else} + + Loading comments… + + {/if} + + + {:else} + {#if view.comments_post is None} + + {/if} + {/if} + +} + +pub ui CreatePage for Instagram(view) { + + + + New post + + {#if view.authority is Authority::Loading} + + Preparing the uploader… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This uploader isn't available. + + {:else} + {#if view.authority is Authority::Ready { + .., + }} + {#if view.upload is Upload::Empty} + + + + + Share a photo + Choose a JPEG, PNG, or WebP image from this device. + + + {:else} + {#if view.upload is Upload::Choosing} + + Choosing a photo… + + {:else} + {#if view.upload is Upload::Uploaded { + object: object, + preview: preview, + name: name, + }} + + {name} + {name} + CaptionChanged(event.text) + /> + AltChanged(event.text) + /> + + + + + {object} + + {:else} + {#if view.upload is Upload::Publishing { + preview: preview, + name: name, + .., + }} + + {name} + Publishing… + + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + +} + +pub ui PostPage for Instagram(view) { + + + + Post + + {#if view.authority is Authority::Loading} + + Loading post… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This post isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {post_for_page(data, + {post_for_page(data, view.page).author.username} + + {#if post_for_page(data, view.page).media is Media::Image { + image: media, + }} + {media.alt} + {:else} + {#if post_for_page(data, view.page).media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post_for_page(data, view.page).media is Media::Video { + src: src, + poster: poster, + }} + + + {/if} + {/if} + {/if} + +} + +pub ui ProfilePage for Instagram(view) { + + + {#if view.page is Page::Profile { + user: user, + }} + {#if user != USER_MIRA} + + {/if} + {/if} + {#if view.authority is Authority::Ready { + data: data, + }} + {profile_for_page(data, view.page).user.username} + {:else} + Profile + {/if} + + {#if view.authority is Authority::Loading} + + Loading profile… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This profile isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {profile_for_page(data, + + + {profile_for_page(data, view.page).post_count} + Posts + + + {profile_for_page(data, view.page).follower_count} + Followers + + + {profile_for_page(data, view.page).following_count} + Following + + + + {profile_for_page(data, view.page).user.display_name} + {profile_for_page(data, view.page).user.username} + {profile_for_page(data, view.page).bio} + + + + + + + + {#if tiles_for(profile_for_page(data, view.page), view.profile_tab).size == 0} + + {profile_empty_label(view.profile_tab)} + + {/if} + + {#each tiles_for(profile_for_page(data, view.page), view.profile_tab) as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + + {/if} + {/if} + {/if} + +} + +pub ui FollowersPage for Instagram(view) { + + + + Followers + + {#if view.authority is Authority::Ready { + data: data, + }} + {#if view.page is Page::Followers { + user: user, + }} + + {#each followers_for(data, user) as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {/each} + + {:else} + + Loading followers… + + {/if} + {:else} + + Loading followers… + + {/if} + +} + +pub ui FollowingPage for Instagram(view) { + + + + Following + + {#if view.authority is Authority::Ready { + data: data, + }} + {#if view.page is Page::Following { + user: user, + }} + + {#each following_for(data, user) as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {/each} + + {:else} + + Loading following… + + {/if} + {:else} + + Loading following… + + {/if} + +} + +pub ui ReelsPage for Instagram(view) { + + + Reels + + {#if view.authority is Authority::Loading} + + Loading Reels… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Reels aren't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each data.reels as post (post.id)} + + {#if post.media is Media::Video { + src: src, + poster: poster, + }} + + {/each} + + {/if} + {/if} + {/if} + +} + +pub ui SearchPage for Instagram(view) { + + + Explore + + SearchChanged(event.text) + /> + + + + {#if view.authority is Authority::Loading} + + Loading Explore… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Search isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#if view.search_status is SearchStatus::Explore} + {#if data.explore_tiles.size == 0} + + Explore is empty. + + {/if} + + {#each data.explore_tiles as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + {:else} + {#if view.search_status is SearchStatus::Searching} + + Searching… + + {:else} + {#if view.search_status is SearchStatus::Results} + + {#each data.search_people as person (person.user.id)} + + OpenProfile(person.user.id) + > + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + + {/each} + + {:else} + {#if view.search_status is SearchStatus::NoResults} + + No results + Try another username. + + {/if} + {/if} + {/if} + {/if} + + {/if} + {/if} + {/if} + +} + +pub ui StoryPage for Instagram(view) { + + {#if view.authority is Authority::Loading} + + Loading story… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This story isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {story_for_page(data, + + {#each story_for_page(data, view.page).progress as segment (segment.id)} + + {/each} + + + {story_for_page(data, + {story_for_page(data, view.page).author.username} + {story_for_page(data, view.page).posted_label} + + + {story_for_page(data, view.page).caption} + + {#if story_for_page(data, view.page).previous is Some(previous)} + + {:else} + {#if story_for_page(data, view.page).previous is None} + + {/if} + {/if} + {#if story_for_page(data, view.page).next is Some(next)} + + {:else} + {#if story_for_page(data, view.page).next is None} + + {/if} + {/if} + + + {/if} + {/if} + {/if} + +} + +pub ui BottomNav for Instagram(view) { + + Instagram + + + + + + + + +} + +pub ui CommentRow for Instagram(view) { + + {#if view.pending_comment is Some((_, body))} + {MIRA.avatar.alt} + + {MIRA.username} + {body} + Posting… + + {:else} + {#if view.pending_comment is None} + {KENJI.avatar.alt} + + {KENJI.username} + That copper red is unreal. What cone are you firing to? + 1h + + {/if} + {/if} + +} + +pub ui ConnectionRow for Instagram(view) { + + {#if view.page is Page::Followers { + user: user, + }} + {#if user == USER_MIRA} + {NILS.avatar.alt} + + {NILS.username} + {NILS.display_name} + + + {/if} + {#if user != USER_MIRA} + {LENA.avatar.alt} + + {LENA.username} + {LENA.display_name} + + + {/if} + {:else} + {LENA.avatar.alt} + + {LENA.username} + {LENA.display_name} + + + {/if} + +} + +pub ui NoticeBar for Instagram(view) { + + {#if view.notice is Some(message)} + {message} + {:else} + {#if view.notice is None} + Couldn't like this post. Try again. + {/if} + {/if} + + +} + +pub ui PostCard for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + + {post_for_page(data, + {post_for_page(data, view.page).author.username} + + {#if post_for_page(data, view.page).media is Media::Image { + image: media, + }} + ToggleLike(post_for_page(data, view.page).id, true) + > + {media.alt} + + {:else} + {#if post_for_page(data, view.page).media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post_for_page(data, view.page).media is Media::Video { + src: src, + poster: poster, + }} + + {:else} + + Loading post… + + {/if} +} + +pub ui ProfileHeader for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + + {profile_for_page(data, + + + {profile_for_page(data, view.page).post_count} + Posts + + + {profile_for_page(data, view.page).follower_count} + Followers + + + {profile_for_page(data, view.page).following_count} + Following + + + + {profile_for_page(data, view.page).user.display_name} + {profile_for_page(data, view.page).user.username} + {profile_for_page(data, view.page).bio} + + {#if profile_for_page(data, view.page).user.id == data.viewer.id} + + {/if} + {#if profile_for_page(data, view.page).user.id != data.viewer.id} + + {/if} + + + {:else} + + Loading profile… + + {/if} +} + +pub ui ReelCard for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + {#if post_for_page(data, view.page).media is Media::Video { + src: src, + poster: poster, + }} + + {:else} + + Loading reel… + + {/if} +} + +pub ui StoriesTray for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + + {#each data.stories as story (story.id)} + + OpenStory(story.id) + > + + {story.user.avatar.alt} + {story.user.username} + + + + {/each} + + + {:else} + + Loading stories… + + {/if} +} + +pub ui CommentsSheet for Instagram(view) { + + + + Comments + + + {#if view.notice is Some(message)} + + {message} + + {:else} + {#if view.notice is None} + + {/if} + {/if} + {#if view.authority is Authority::Ready { + data: data, + }} + {#if view.comments_post is Some(post)} + + {#if comments_for(data, post).size == 0 && view.pending_comment == None} + + No comments yet + Start the conversation. + + {/if} + {#each comments_for(data, post) as comment (comment.id)} + + {comment.author.avatar.alt} + + {comment.author.username} + {comment.body} + {comment.posted_label} + + + {/each} + {#if view.pending_comment is Some((_, body))} + + {data.viewer.avatar.alt} + + {data.viewer.username} + {body} + Posting… + + + {:else} + {#if view.pending_comment is None} + + {/if} + {/if} + + + {data.viewer.avatar.alt} + CommentChanged(event.text) + /> + + + {:else} + {#if view.comments_post is None} + + Comments are closed. + + {/if} + {/if} + {:else} + + Loading comments… + + {/if} + + +} diff --git a/examples/programs/README.md b/examples/programs/README.md index 09af810..da26121 100644 --- a/examples/programs/README.md +++ b/examples/programs/README.md @@ -3,7 +3,8 @@ - **Status:** Non-normative language-design corpus - **Current form:** Language-neutral problem specifications plus non-authoritative comparison answers -- **Implementation status:** No accepted Uhura solutions or runtime fixtures +- **Implementation status:** Executable Uhura 0.4 incubation candidate; no + accepted stable language version - **Scope:** The experience-machine language, not widgets or presentation Program harnesses are small, pure, standalone problems used to design and @@ -45,6 +46,21 @@ A baseline is an answer sheet, not an authority and not a proposed Uhura implementation. It must preserve the same frozen behavior before its readability or size is compared. +## Uhura answer sheets + +| Language | Answer | Status | +| --- | --- | --- | +| Uhura 0.4 | [L0–L2 source](answers/uhura-0.4/) | Executable incubation candidate; the 0.4 frontend passes the same frozen semantic traces | + +The 0.4 source is specified in the +[active incubation candidate](../../docs/spec/drafts/0.4/) and checks, +executes, checkpoints, and replays against the frozen problems through the +canonical engine. + +Answer sheets remain subordinate to the three problem statements. Their +presence records an executable language claim; the versioned specification +remains the authority for supported syntax and semantics. + For apples-to-apples source measurements, count the complete authoring source and every helper it relies on. Exclude tests, comments, documentation, build configuration, generated files, boundary decoders, and compiler/runtime diff --git a/examples/programs/answers/uhura-0.4/README.md b/examples/programs/answers/uhura-0.4/README.md new file mode 100644 index 0000000..842eb69 --- /dev/null +++ b/examples/programs/answers/uhura-0.4/README.md @@ -0,0 +1,22 @@ +# Uhura 0.4 answer to L0–L2 + +- **Status:** Executable incubation-candidate answer +- **Language:** Uhura 0.4 incubation candidate +- **Problem authority:** [L0–L2 program harnesses](../../) +- **Specification:** [Uhura 0.4](../../../../docs/spec/drafts/0.4/) + +[programs.uhura](programs.uhura) is the complete executable source fixture +against which the 0.4 grammar, formatter, checker, lowering, and runtime +behavior are tested. It answers: + +- L0 Bounded Counter; +- L1 River Crossing; and +- L2 Keyed Task Supervisor. + +The 0.4 frontend parses, formats, checks, lowers, executes, checkpoints, and +replays this file against the frozen harness traces. It remains subordinate to +the language-neutral problems. + +The answer deliberately contains no UI, framework feature, host adapter, or +widget. It tests the standalone machine core. Its project identity and +single-file logical-module map are fixed by [uhura.toml](uhura.toml). diff --git a/examples/programs/answers/uhura-0.4/programs.uhura b/examples/programs/answers/uhura-0.4/programs.uhura new file mode 100644 index 0000000..e51a79c --- /dev/null +++ b/examples/programs/answers/uhura-0.4/programs.uhura @@ -0,0 +1,492 @@ +pub machine BoundedCounter { + config { + minimum: Int, + maximum: Int, + initial: Int, + } + + require minimum <= initial && initial <= maximum; + + events { + Increment, + Decrement, + Reset, + } + + outcomes { + commit Accepted, + } + + state { + count: Int = initial, + } + + invariant minimum <= count && count <= maximum; + + observe { + count, + at_minimum: count == minimum, + at_maximum: count == maximum, + } + + on Increment { + count = min(count + 1, maximum); + Accepted + } + + on Decrement { + count = max(count - 1, minimum); + Accepted + } + + on Reset { + count = initial; + Accepted + } +} + +enum Side { + Left, + Right, +} + +enum Entity { + Farmer, + Wolf, + Goat, + Cabbage, +} + +enum Cargo { + Wolf, + Goat, + Cabbage, +} + +enum Violation { + WolfWithGoat, + GoatWithCabbage, +} + +enum RiverStatus { + InProgress, + Solved, +} + +struct Crossing { + passenger: Option, + departure: Side, + arrival: Side, +} + +enum Refusal { + PassengerNotWithFarmer { + passenger: Cargo, + }, + Unsafe { + violations: NonEmpty, + }, +} + +const INITIAL_POSITIONS: Table = Table::from([ + (Entity::Farmer, Side::Left), + (Entity::Wolf, Side::Left), + (Entity::Goat, Side::Left), + (Entity::Cabbage, Side::Left), +]); + +fn entity(cargo: Cargo) -> Entity { + match cargo { + Cargo::Wolf => Entity::Wolf, + Cargo::Goat => Entity::Goat, + Cargo::Cabbage => Entity::Cabbage, + } +} + +fn opposite(side: Side) -> Side { + match side { + Side::Left => Side::Right, + Side::Right => Side::Left, + } +} + +fn violations(at: Table) -> Seq { + Seq::from_options([ + if at[Entity::Wolf] == at[Entity::Goat] + && at[Entity::Farmer] != at[Entity::Wolf] + { + Some(Violation::WolfWithGoat) + } else { + None + }, + if at[Entity::Goat] == at[Entity::Cabbage] + && at[Entity::Farmer] != at[Entity::Goat] + { + Some(Violation::GoatWithCabbage) + } else { + None + }, + ]) +} + +pub machine RiverCrossing { + events { + Cross(passenger: Option), + } + + outcomes { + commit Accepted(crossing: Crossing), + abort Refused(reason: Refusal), + } + + state { + positions: Table = INITIAL_POSITIONS, + } + + invariant violations(positions).is_empty(); + + observe { + positions, + status: if positions.values().all(|side| side == Side::Right) { + RiverStatus::Solved + } else { + RiverStatus::InProgress + }, + } + + on Cross(passenger) { + let departure = positions[Entity::Farmer]; + + if passenger is Some(cargo) + && positions[entity(cargo)] != departure + { + return Refused(Refusal::PassengerNotWithFarmer { + passenger: cargo, + }); + } + + let arrival = opposite(departure); + let farmer_moved = positions.set(Entity::Farmer, arrival); + let candidate = match passenger { + None => farmer_moved, + Some(cargo) => farmer_moved.set(entity(cargo), arrival), + }; + + match NonEmpty::checked_from(violations(candidate)) { + Some(harms) => Refused(Refusal::Unsafe { + violations: harms, + }), + None => { + positions = candidate; + Accepted(Crossing { + passenger, + departure, + arrival, + }) + }, + } + } +} + +pub key TaskId(Text); + +enum Terminal { + Success, + Failure, +} + +enum Phase { + Queued, + Running { + attempt: PositiveInt, + progress: Ratio, + }, + Succeeded, + Failed, + Cancelled, +} + +struct Task { + phase: Phase, + started: Nat, +} + +struct Running { + task: TaskId, + attempt: PositiveInt, + progress: Ratio, +} + +pub machine KeyedTaskSupervisor { + const LIMIT: Nat = 2; + + events { + Submit(task: TaskId), + Cancel(task: TaskId), + Retry(task: TaskId), + Progress(task: TaskId, attempt: Int, value: BoundaryNumber), + Succeed(task: TaskId, attempt: Int), + Fail(task: TaskId, attempt: Int), + } + + commands { + Start(task: TaskId, attempt: PositiveInt), + Cancel(task: TaskId, attempt: PositiveInt), + } + + outcomes { + commit Accepted, + abort Duplicate, + abort Stale, + abort Invalid, + } + + state { + tasks: Map = Map::empty(), + queue: Seq = [], + } + + computed running_count: Nat = + tasks.values().count(|task| task.phase is Phase::Running { .. }); + + computed running: Set = + Set::filter_map(tasks.entries(), |entry| + match entry.value.phase { + Phase::Running { attempt, progress } => Some(Running { + task: entry.key, + attempt, + progress, + }), + _ => None, + } + ); + + computed available_capacity: Nat = LIMIT - running_count; + + invariant { + running_count <= LIMIT, + queue.is_unique(), + queue.all(|id| tasks.get(id) is Some(Task { + phase: Phase::Queued, + .. + })), + tasks.entries().all(|entry| + (entry.value.phase is Phase::Queued) == queue.contains(entry.key) + ), + queue.is_empty() || running_count == LIMIT, + tasks.values().all(|task| + match task.phase { + Phase::Running { attempt, .. } => task.started == attempt, + _ => true, + } + ), + } + + observe { + tasks, + queue, + running, + available_capacity, + } + + update resolve_terminal( + id: TaskId, + attempt: Int, + terminal: Terminal, + ) -> Outcome { + if attempt <= 0 { + return Invalid; + } + + let task = match tasks.get(id) { + None => return Invalid, + Some(task) => task, + }; + + if attempt > task.started { + return Invalid; + } + + if attempt < task.started { + return Stale; + } + + match task.phase { + Phase::Running { + attempt: current_attempt, + .. + } => { + if current_attempt != attempt { + unreachable; + } + + let phase = match terminal { + Terminal::Success => Phase::Succeeded, + Terminal::Failure => Phase::Failed, + }; + + tasks = tasks.put(id, Task { + phase, + ..task + }); + Accepted + }, + Phase::Succeeded => { + if terminal == Terminal::Success { + return Duplicate; + } + Stale + }, + Phase::Failed => { + if terminal == Terminal::Failure { + return Duplicate; + } + Stale + }, + Phase::Queued | Phase::Cancelled => Stale, + } + } + + on Submit(id) { + match tasks.get(id) { + Some(_) => Invalid, + None => { + tasks = tasks.put(id, Task { + phase: Phase::Queued, + started: 0, + }); + queue = queue.append(id); + Accepted + }, + } + } + + on Cancel(id) { + match tasks.get(id) { + None => Invalid, + Some(task) => match task.phase { + Phase::Queued => { + queue = queue.without(id); + tasks = tasks.put(id, Task { + phase: Phase::Cancelled, + ..task + }); + Accepted + }, + Phase::Running { attempt, .. } => { + tasks = tasks.put(id, Task { + phase: Phase::Cancelled, + ..task + }); + emit Cancel(id, attempt); + Accepted + }, + Phase::Cancelled => Duplicate, + Phase::Succeeded | Phase::Failed => Invalid, + }, + } + } + + on Retry(id) { + match tasks.get(id) { + None => Invalid, + Some(task) => match task.phase { + Phase::Failed | Phase::Cancelled => { + tasks = tasks.put(id, Task { + phase: Phase::Queued, + ..task + }); + queue = queue.append(id); + Accepted + }, + Phase::Queued | Phase::Running { .. } | Phase::Succeeded => Invalid, + }, + } + } + + on Progress(id, attempt, value) { + if attempt <= 0 { + return Invalid; + } + + let next = match Ratio::checked_from(value) { + None => return Invalid, + Some(progress) => progress, + }; + + let task = match tasks.get(id) { + None => return Invalid, + Some(task) => task, + }; + + if attempt > task.started { + return Invalid; + } + + if attempt < task.started { + return Stale; + } + + match task.phase { + Phase::Running { + attempt: current_attempt, + progress: current, + } => { + if current_attempt != attempt { + unreachable; + } + if next < current { + return Stale; + } + if next == current { + return Duplicate; + } + + tasks = tasks.put(id, Task { + phase: Phase::Running { + attempt, + progress: next, + }, + ..task + }); + Accepted + }, + Phase::Queued + | Phase::Succeeded + | Phase::Failed + | Phase::Cancelled => Stale, + } + } + + on Succeed(id, attempt) { + resolve_terminal(id, attempt, Terminal::Success) + } + + on Fail(id, attempt) { + resolve_terminal(id, attempt, Terminal::Failure) + } + + before commit { + while running_count < LIMIT + && queue.uncons() is Some(Uncons { head: id, tail: rest }) + decreases(queue.len()) { + let task = match tasks.get(id) { + None => { + unreachable; + }, + Some(task) => task, + }; + let attempt: PositiveInt = task.started + 1; + + queue = rest; + tasks = tasks.put(id, Task { + phase: Phase::Running { + attempt, + progress: 0.0, + }, + started: attempt, + }); + emit Start(id, attempt); + } + } +} diff --git a/examples/programs/answers/uhura-0.4/uhura.toml b/examples/programs/answers/uhura-0.4/uhura.toml new file mode 100644 index 0000000..9f97951 --- /dev/null +++ b/examples/programs/answers/uhura-0.4/uhura.toml @@ -0,0 +1,7 @@ +[project] +name = "examples.programs" +version = 1 +language = "0.4" + +[modules] +programs = "programs.uhura" diff --git a/examples/programs/l0-counter/README.md b/examples/programs/l0-counter/README.md index ca0e6b7..058f69b 100644 --- a/examples/programs/l0-counter/README.md +++ b/examples/programs/l0-counter/README.md @@ -2,7 +2,8 @@ - **Status:** Language-neutral program specification - **Level:** L0 — one local transition system -- **Implementation:** None +- **Implementation:** None inside this language-neutral problem; executable + answers are indexed by the parent harness - **Authority:** The problem contract is authoritative for candidate comparison; no Uhura behavior is accepted here diff --git a/examples/programs/l1-river-crossing/README.md b/examples/programs/l1-river-crossing/README.md index 30a9837..a0330be 100644 --- a/examples/programs/l1-river-crossing/README.md +++ b/examples/programs/l1-river-crossing/README.md @@ -5,7 +5,8 @@ - **Class:** Pure standalone program harness - **Subject:** Deterministic state transition, refusal, invariant preservation, and trace replay -- **Implementation:** None +- **Implementation:** None inside this language-neutral problem; executable + answers are indexed by the parent harness - **Authority:** The problem contract is authoritative for candidate comparison; no Uhura behavior is accepted here diff --git a/examples/programs/l2-task-supervisor/README.md b/examples/programs/l2-task-supervisor/README.md index 271aaa9..5b01a03 100644 --- a/examples/programs/l2-task-supervisor/README.md +++ b/examples/programs/l2-task-supervisor/README.md @@ -2,7 +2,8 @@ - **Status:** Language-neutral program specification - **Level:** L2 — one open, keyed machine system -- **Implementation:** None +- **Implementation:** None inside this language-neutral problem; executable + answers are indexed by the parent harness - **Authority:** The problem contract is authoritative for candidate comparison; no Uhura behavior is accepted here diff --git a/resources/ui-catalog/0.4.json b/resources/ui-catalog/0.4.json new file mode 100644 index 0000000..3959308 --- /dev/null +++ b/resources/ui-catalog/0.4.json @@ -0,0 +1,16 @@ +{ + "protocol": "uhura-ui-catalog/0", + "language": "0.4", + "primitiveAdapters": [ + "button", + "icon", + "img", + "pager", + "region", + "scroll", + "text", + "textfield", + "video", + "view" + ] +} diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 74dc9dc..e9bfb2f 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Builds the uhura-wasm bundle for both targets (design §12.3): +# Builds the canonical uhura-wasm runtime for both browser and Node.js hosts: # crates/uhura-wasm/pkg/web/ — ES module for the play shell -# crates/uhura-wasm/pkg/node/ — CommonJS for scripts/parity.mjs +# crates/uhura-wasm/pkg/node/ — CommonJS for conformance and host tooling # # wasm-bindgen-cli MUST match the workspace's wasm-bindgen pin exactly # (Cargo.lock) — the CLI and the crate write two halves of one ABI. diff --git a/scripts/parity.mjs b/scripts/parity.mjs deleted file mode 100644 index 5ff4575..0000000 --- a/scripts/parity.mjs +++ /dev/null @@ -1,223 +0,0 @@ -// Native ↔ wasm parity (design §12.5, §13): replays a script through the -// REAL wasm32 binary (pkg/node, built by scripts/build-wasm.sh) with the -// same JSON-only pump the play shell and the native ABI-contract test -// use, and diffs the per-step trace lines byte-for-byte against the -// native harness's output. -// -// Inputs (a directory of prepared artifacts): -// ir.json — canonical uhura-ir/0 (uhura check --emit-ir) -// fixture.json — resolved slices (what `uhura play` serves) -// script.json — the script as JSON -// boot.json — {"updates": […]} boot deliveries -// native.jsonl — `uhura trace --script=` output -// -// Usage: node scripts/parity.mjs -// M6 automates artifact preparation per canonical script; until then the -// quickest source is a running `uhura play` (curl `/api/play/ir.json`, -// `/api/play/fixture.json`, `/api/play/script.json`, and `/api/play/boot.json`) -// plus `uhura trace` for native.jsonl. - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; - -const here = fileURLToPath(new URL(".", import.meta.url)); -const require = createRequire(import.meta.url); -const { Session, FixtureDriver, protocols } = require( - join(here, "../crates/uhura-wasm/pkg/node/uhura_wasm.js"), -); - -const dir = process.argv[2]; -if (!dir) { - console.error("usage: node scripts/parity.mjs "); - process.exit(2); -} -const read = (name) => readFileSync(join(dir, name), "utf8"); - -const spoken = JSON.parse(protocols()); -if ( - spoken.inspect !== "uhura-inspect/0" || - spoken.ir !== "uhura-ir/0" || - spoken.view !== "uhura-view/0" || - spoken.provider !== "uhura-provider/0" -) { - console.error(`protocol mismatch: ${JSON.stringify(spoken)}`); - process.exit(1); -} - -const irText = read("ir.json"); -const script = JSON.parse(read("script.json")); -const native = read("native.jsonl").trim().split("\n"); - -// ── the same pump as web/src/play/main.ts and abi_contract.rs ────────── -const session = new Session(irText); -session.boot(read("boot.json")); -const driver = new FixtureDriver(read("fixture.json"), JSON.stringify(script)); - -const stimuli = (script.ui ?? []).map((entry) => ({ - atTick: entry["at-tick"], - emit: entry.emit, - where: entry.where ?? {}, - data: entry.data ?? {}, -})); - -const lines = []; -function dispatch(event) { - const raw = session.dispatch(JSON.stringify(event)); - // The compared artifacts are extracted from the RAW canonical envelope - // bytes — never round-tripped through JS numbers, so the byte-parity - // verdict is faithful for the whole i64 domain. - lines.push(extractTop(raw, "t")); - for (const c of arrayElements(extractTop(raw, "c"))) driver.deliver(c); - return JSON.parse(raw).v; -} - -/** - * The raw value substring of a top-level key in one canonical JSON - * object (canonical ⇒ object/array/string/number/bool/null, no floats). - */ -function extractTop(raw, key) { - const needle = `"${key}":`; - let depth = 0; - let inStr = false; - let esc = false; - for (let i = 0; i < raw.length; i += 1) { - const ch = raw[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - continue; - } - if (ch === '"') { - if (depth === 1 && raw.startsWith(needle, i)) { - return sliceValue(raw, i + needle.length); - } - inStr = true; - } else if (ch === "{" || ch === "[") depth += 1; - else if (ch === "}" || ch === "]") depth -= 1; - } - throw new Error(`no top-level "${key}" in the step result`); -} - -function sliceValue(raw, start) { - let depth = 0; - let inStr = false; - let esc = false; - for (let i = start; i < raw.length; i += 1) { - const ch = raw[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - } else if (ch === '"') inStr = true; - else if (ch === "{" || ch === "[") depth += 1; - else if (ch === "}" || ch === "]") { - depth -= 1; - if (depth === 0) return raw.slice(start, i + 1); - } else if (depth === 0 && (ch === "," || ch === "}")) { - return raw.slice(start, i); // bare scalar value - } - } - throw new Error("unbalanced JSON value"); -} - -/** Splits a raw canonical JSON array into raw element substrings. */ -function arrayElements(rawArray) { - const out = []; - let depth = 0; - let inStr = false; - let esc = false; - let start = -1; - for (let i = 0; i < rawArray.length; i += 1) { - const ch = rawArray[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - continue; - } - if (ch === '"') inStr = true; - else if (ch === "{" || ch === "[") { - depth += 1; - if (depth === 2) start = i; - } else if (ch === "}" || ch === "]") { - if (depth === 2 && start >= 0) { - out.push(rawArray.slice(start, i + 1)); - start = -1; - } - depth -= 1; - } - } - return out; -} - -const matches = (d, stim) => - d.emit === stim.emit && - Object.entries(stim.where).every(([k, v]) => JSON.stringify(d.payload?.[k]) === JSON.stringify(v)); - -function findDescriptor(view, stim) { - const found = []; - const walk = (node) => { - for (const d of node.on ?? []) if (matches(d, stim)) found.push(d); - for (const child of node.children ?? []) walk(child); - }; - walk(view.page.root); - for (const surface of view.surfaces) { - walk(surface.root); - if (matches(surface.dismiss, stim)) found.push(surface.dismiss); - } - const distinct = found.filter( - (d, i) => - found.findIndex( - (o) => o.emit === d.emit && o.scope === d.scope && canonical(o.payload) === canonical(d.payload), - ) === i, - ); - if (distinct.length !== 1) { - throw new Error(`stimulus \`${stim.emit}\` matched ${distinct.length} descriptors`); - } - return distinct[0]; -} - -let view = dispatch({ kind: "init", route: JSON.parse(irText).entry, params: {} }); -let tick = 0; -let next = 0; -while (!(driver.idle() && next >= stimuli.length)) { - tick += 1; - if (tick > 10_000) throw new Error("the script did not quiesce"); - for (const msgJson of driver.tick()) { - const msg = JSON.parse(msgJson); - view = dispatch(msg.kind === "projection" ? { kind: "projection", updates: [msg] } : msg); - } - while (next < stimuli.length && stimuli[next].atTick === tick) { - const stim = stimuli[next++]; - const event = { kind: "ui", descriptor: findDescriptor(view, stim), "view-rev": view.revision }; - if (Object.keys(stim.data).length > 0) event.data = stim.data; - view = dispatch(event); - } -} - -// ── canonical JSON (stimulus matching only — trace lines never pass -// through here) ───────────────────────────────────────────────────────── -function canonical(value) { - if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; - if (value !== null && typeof value === "object") { - const keys = Object.keys(value).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`; - } - return JSON.stringify(value); -} - -// ── diff ───────────────────────────────────────────────────────────────── -if (lines.length !== native.length) { - console.error(`step count diverged: wasm ${lines.length}, native ${native.length}`); - process.exit(1); -} -for (let i = 0; i < lines.length; i += 1) { - if (lines[i] !== native[i]) { - console.error(`step ${i} diverged:\n wasm: ${lines[i]}\n native: ${native[i]}`); - process.exit(1); - } -} -console.log(`parity: ${lines.length} steps byte-identical (native ↔ wasm32)`); diff --git a/web/README.md b/web/README.md index 914a630..b05af0f 100644 --- a/web/README.md +++ b/web/README.md @@ -5,10 +5,12 @@ framework-free TypeScript application. Both render Uhura semantic nodes through the shared renderer in `src/renderer/`; policy keeps Editor previews inert and enables runtime delivery and browser effects only in Play. -The native host remains authoritative for source observation, checking, -evaluation, Editor-state publication, Play artifacts, providers, and runtime -events. Browser code owns routing and presentation. It never parses Uhura -source or reconstructs language semantics. +The native host remains authoritative for coherent source observation, +checking, evidence execution, Editor-state publication, deployment admission, +and Play artifacts. In Play, the Wasm `Session` executes the same canonical +Uhura engine used natively. Browser code owns routing mechanics, presentation, +and the admitted adapter bridge; it never parses Uhura source or reconstructs +language semantics. ## Install and check @@ -24,10 +26,12 @@ production builds, and browser-unit tests. ## Development loop -Build Wasm and start the native host from the Uhura repository root: +Build Wasm and the browser application, then start the native host from the +Uhura repository root: ```sh ./scripts/build-wasm.sh +corepack pnpm@10.11.0 -C web build cargo run --locked -p uhura-cli -- editor examples/instagram/client --port 8787 ``` @@ -63,7 +67,13 @@ The Wasm package remains external and is served below `/api/play/wasm/` rather than bundled by Vite. The native host serves the compiled application unchanged and provides SPA -fallback for `/` and `/play`. Node and Vite are build-time dependencies only. +fallback for `/` and `/play`. Play admits the complete browser-owned and +application-owned adapter set against the exact port contract and instance +hashes before any command leaves the machine. `web.history` is a built-in +browser adapter; a configured provider module supplies typed application +adapters through `createUhuraAdapters(config, host)`. Deliveries return through +a deferred FIFO bridge and cannot synchronously re-enter a reaction. Node and +Vite are build-time dependencies only. `../scripts/package.sh` builds the application, provider, Wasm, and release binary, then places the runtime web and Wasm assets beside the executable under `dist/uhura/` (or a supplied output directory). diff --git a/web/src/app/location.ts b/web/src/app/location.ts new file mode 100644 index 0000000..e712bdb --- /dev/null +++ b/web/src/app/location.ts @@ -0,0 +1,28 @@ +import type { LocationChange } from "../app/router.js"; + +export type LocationConsumer = (change: LocationChange) => void; + +const consumers = new Set(); +let latest: LocationChange | null = null; + +/** Publishes the browser router's committed location to the mounted Play runtime. */ +export const publishLocation = (change: LocationChange): void => { + latest = change; + for (const consumer of [...consumers]) consumer(change); +}; + +/** Subscribes one app-owned route adapter until its provider is disposed. */ +export const installLocationConsumer = ( + next: LocationConsumer, +): (() => void) => { + consumers.add(next); + try { + if (latest !== null) next(latest); + } catch (error) { + consumers.delete(next); + throw error; + } + return () => { + consumers.delete(next); + }; +}; diff --git a/web/src/app/main.ts b/web/src/app/main.ts index 9637b03..416607d 100644 --- a/web/src/app/main.ts +++ b/web/src/app/main.ts @@ -1,5 +1,6 @@ import type { SurfaceLoader } from "./router.js"; import { createRouter } from "./router.js"; +import { publishLocation } from "./location.js"; const root = document.getElementById("uhura-root"); if (!root) throw new Error("Uhura application entry lost #uhura-root"); @@ -14,4 +15,9 @@ const loadPlay: SurfaceLoader = async () => { return mountPlay; }; -createRouter({ root, loadEditor, loadPlay }).start(); +createRouter({ + root, + loadEditor, + loadPlay, + locationChanged: publishLocation, +}).start(); diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index 8270dab..639fb71 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import type { SurfaceLoader, SurfaceMount } from "./router.js"; -import { createRouteRenderer } from "./router.js"; +import { + createRouteRenderer, + EDITOR_PATH, + routeFor, +} from "./router.js"; const deferred = (): { load: SurfaceLoader; @@ -49,3 +53,51 @@ test("only a committed route owns disposal", async () => { await renderer.render("/play"); assert.equal(editorDisposals, 1); }); + +test("only reserved editor entry points select Editor", () => { + assert.equal(routeFor("/").surface, "editor"); + assert.equal(routeFor(EDITOR_PATH).surface, "editor"); + assert.equal(routeFor(`${EDITOR_PATH}/`).surface, "editor"); + + assert.equal(routeFor("/play").surface, "play"); + assert.equal(routeFor("/play/").surface, "play"); + assert.equal(routeFor("/returns/return-100").surface, "play"); + assert.equal(routeFor("/_uhura/editor/preferences").surface, "play"); +}); + +test("real application locations keep one running Play surface", async () => { + let playLoads = 0; + let playMounts = 0; + let playDisposals = 0; + const commits: string[] = []; + const renderer = createRouteRenderer({ + root: { replaceChildren() {} } as unknown as HTMLElement, + loadEditor: async () => () => undefined, + loadPlay: async () => { + playLoads += 1; + return () => { + playMounts += 1; + return () => { playDisposals += 1; }; + }; + }, + committed(route) { + commits.push(route.pathname); + }, + }); + + await renderer.render("/play"); + await renderer.render("/returns"); + await renderer.render("/returns/return-100"); + + assert.equal(playLoads, 1); + assert.equal(playMounts, 1); + assert.equal(playDisposals, 0); + assert.deepEqual(commits, [ + "/play", + "/returns", + "/returns/return-100", + ]); + + await renderer.render(EDITOR_PATH); + assert.equal(playDisposals, 1); +}); diff --git a/web/src/app/router.ts b/web/src/app/router.ts index 4bd12d4..01a2f43 100644 --- a/web/src/app/router.ts +++ b/web/src/app/router.ts @@ -4,55 +4,116 @@ export type SurfaceMount = ( ) => void | SurfaceDispose; export type SurfaceLoader = () => Promise; -interface RouterOptions { +export type AppSurface = "editor" | "play"; +export type NavigationCause = "start" | "push" | "replace" | "pop"; + +export interface AppRoute { + pathname: string; + surface: AppSurface; +} + +export interface BrowserLocation { + pathname: string; + search: string; + hash: string; +} + +export interface LocationChange { + cause: NavigationCause; + location: BrowserLocation; + route: AppRoute; +} + +export interface RouterOptions { root: HTMLElement; loadEditor: SurfaceLoader; loadPlay: SurfaceLoader; + /** + * Runs only after the matching surface owns the route. Play can use this + * seam to deliver a real pathname/query change to its router port without + * remounting the running machine. + */ + locationChanged?(change: LocationChange): void; } export interface AppRouter { start(): void; - navigate(path: "/" | "/play", replace?: boolean): Promise; + navigate(destination: string | URL, replace?: boolean): Promise; } interface RouteRendererOptions extends RouterOptions { - committed?(path: "/" | "/play"): void; + committed?(route: AppRoute): void; } export interface RouteRenderer { - render(path: "/" | "/play"): Promise; + /** + * Returns false when a newer route superseded this asynchronous render. + * A route within the already-mounted surface commits without remounting it. + */ + render(pathname: string): Promise; } -const routeFor = (pathname: string): "/" | "/play" => - pathname === "/play" || pathname === "/play/" ? "/play" : "/"; +export const EDITOR_PATH = "/_uhura/editor"; + +const editorPath = (pathname: string): boolean => + pathname === "/" + || pathname === EDITOR_PATH + || pathname === `${EDITOR_PATH}/`; + +/** + * `/` remains the friendly Editor entry. The explicit reserved route makes + * Editor addressable after an Uhura application owns ordinary web paths. + * `/play` is the compatibility Play entry; every other pathname is an actual + * application location and therefore also belongs to Play. + */ +export const routeFor = (pathname: string): AppRoute => ({ + pathname, + surface: editorPath(pathname) ? "editor" : "play", +}); -const routedAnchor = (target: EventTarget | null): HTMLAnchorElement | null => { +interface RoutedAnchor { + url: URL; +} + +const routedAnchor = (target: EventTarget | null): RoutedAnchor | null => { if (!(target instanceof Element)) return null; const anchor = target.closest("a[href]"); if (!(anchor instanceof HTMLAnchorElement)) return null; if (anchor.target && anchor.target !== "_self") return null; + if (anchor.hasAttribute("download")) return null; const url = new URL(anchor.href, location.href); - if (url.origin !== location.origin || (url.pathname !== "/" && url.pathname !== "/play")) { - return null; - } - return anchor; + if (url.origin !== location.origin) return null; + return { url }; }; /** Loads first and mounts only after ownership is rechecked. */ export function createRouteRenderer(options: RouteRendererOptions): RouteRenderer { let dispose: SurfaceDispose | undefined; let transition = 0; + let activeSurface: AppSurface | null = null; - const render = async (path: "/" | "/play"): Promise => { + const render = async (pathname: string): Promise => { const token = ++transition; - const mount = await (path === "/play" ? options.loadPlay() : options.loadEditor()); - if (token !== transition) return; + const route = routeFor(pathname); + if (route.surface === activeSurface) { + options.committed?.(route); + return true; + } + + const mount = await ( + route.surface === "play" + ? options.loadPlay() + : options.loadEditor() + ); + if (token !== transition) return false; dispose?.(); dispose = undefined; options.root.replaceChildren(); - options.committed?.(path); const mounted = mount(options.root); + activeSurface = route.surface; + options.committed?.(route); if (typeof mounted === "function") dispose = mounted; + return true; }; return { render }; @@ -61,23 +122,56 @@ export function createRouteRenderer(options: RouteRendererOptions): RouteRendere export function createRouter(options: RouterOptions): AppRouter { const renderer = createRouteRenderer({ ...options, - committed(path) { - document.documentElement.dataset["uhuraRoute"] = path === "/play" ? "play" : "editor"; - document.title = path === "/play" ? "Uhura Play" : "Uhura Editor"; + committed(route) { + document.documentElement.dataset["uhuraRoute"] = route.surface; + document.title = route.surface === "play" ? "Uhura Play" : "Uhura Editor"; }, }); - const navigate = async (path: "/" | "/play", replace = false): Promise => { - const normalized = routeFor(path); - if (replace) history.replaceState(null, "", normalized); - else if (routeFor(location.pathname) !== normalized) history.pushState(null, "", normalized); - await renderer.render(normalized); + let locationSequence = 0; + + const browserLocation = (url: URL): BrowserLocation => ({ + pathname: url.pathname, + search: url.search, + hash: url.hash, + }); + + const renderLocation = async ( + url: URL, + cause: NavigationCause, + ): Promise => { + const sequence = ++locationSequence; + const committed = await renderer.render(url.pathname); + if (!committed || sequence !== locationSequence) return; + options.locationChanged?.({ + cause, + location: browserLocation(url), + route: routeFor(url.pathname), + }); + }; + + const navigate = async ( + destination: string | URL, + replace = false, + ): Promise => { + const url = new URL(destination, location.href); + if (url.origin !== location.origin) { + throw new Error(`cannot route a different origin: ${url.origin}`); + } + const href = `${url.pathname}${url.search}${url.hash}`; + const current = `${location.pathname}${location.search}${location.hash}`; + if (replace) { + history.replaceState(null, "", href); + } else if (current !== href) { + history.pushState(null, "", href); + } + await renderLocation(url, replace ? "replace" : "push"); }; return { start(): void { window.addEventListener("popstate", () => { - void renderer.render(routeFor(location.pathname)); + void renderLocation(new URL(location.href), "pop"); }); document.addEventListener("click", (event) => { if ( @@ -90,13 +184,12 @@ export function createRouter(options: RouterOptions): AppRouter { ) { return; } - const anchor = routedAnchor(event.target); - if (!anchor) return; + const routed = routedAnchor(event.target); + if (!routed) return; event.preventDefault(); - const path = new URL(anchor.href, location.href).pathname as "/" | "/play"; - void navigate(path); + void navigate(routed.url); }); - void renderer.render(routeFor(location.pathname)); + void renderLocation(new URL(location.href), "start"); }, navigate, }; diff --git a/web/src/editor/annotation-overlay.ts b/web/src/editor/annotation-overlay.ts index 4ac6115..5ea8203 100644 --- a/web/src/editor/annotation-overlay.ts +++ b/web/src/editor/annotation-overlay.ts @@ -193,8 +193,8 @@ const sourceTargetAction = ( const button = element(document, "button", "source-target-select", "Show"); button.type = "button"; button.setAttribute("data-source-target-id", target.id); - button.setAttribute("aria-label", `Show ${target.label} annotation on canvas`); - button.title = "Show annotation on canvas"; + button.setAttribute("aria-label", `Show ${target.label} on canvas`); + button.title = "Show rendered source on canvas"; button.disabled = !selectTarget || !occurrences.some((item) => item.occurrence.anchors.length > 0); button.addEventListener("click", () => selectTarget?.(target.id)); @@ -277,7 +277,7 @@ export const renderSourcePanel = ( const heading = element(document, "div", "source-entry-heading"); const actions = element(document, "div", "source-entry-actions"); const annotations = entries.filter((entry) => entry.class === "annotation"); - if (annotations.length > 0 && selectTarget) { + if (occurrences.length > 0 && selectTarget) { actions.append(sourceTargetAction(document, target, occurrences, selectTarget)); } actions.append(sourceAction(document, target, stale)); @@ -302,7 +302,12 @@ export const renderSourcePanel = ( sections.push(groupSection); } if (sections.length === 0) { - sections.push(element(document, "p", "inspector-muted", "No authored documentation or annotations.")); + sections.push(element( + document, + "p", + "inspector-muted", + "No authored documentation, annotations, or rendered source targets.", + )); } container.replaceChildren(...sections); container.classList.toggle("is-stale", stale); @@ -498,7 +503,29 @@ export class AnnotationOverlay { /** Selects a Source target and reveals its selected-preview or first realization. */ selectSourceTarget(targetId: string): boolean { const record = this.#records.find((candidate) => candidate.annotation.target.id === targetId); - if (!record || record.markers.length === 0) return false; + if (!record || record.markers.length === 0) { + const occurrences = this.#install.authoring.occurrencesByTarget.get(targetId) ?? []; + const rendered = occurrences.filter((occurrence) => + occurrence.occurrence.anchors.length > 0 + ); + const occurrence = rendered.find((candidate) => + candidate.previewId === this.#activePreviewId + ) ?? rendered[0]; + if (!occurrence) return false; + this.#activeMarkerId = null; + this.#revealedTargetId = null; + this.#pendingFocusTargetId = null; + for (const candidate of this.#records) { + candidate.card.hidden = true; + candidate.card.classList.toggle("is-revealed", false); + for (const marker of candidate.markers) marker.line.style.display = "none"; + } + this.#syncStateClasses(); + this.#focusSourceTarget?.(targetId); + this.#focusPreviewOccurrence(occurrence); + this.invalidate(); + return true; + } this.setCanvasVisible(true); const selected = record.markers.filter((marker) => marker.occurrence.previewId === this.#activePreviewId @@ -803,12 +830,16 @@ export class AnnotationOverlay { } #focusOccurrence(record: OverlayMarkerRecord): void { - const resources = this.#install.resourcesByPreviewId.get(record.occurrence.previewId); - const anchors = record.occurrence.occurrence.anchors.flatMap((anchor) => { + this.#focusPreviewOccurrence(record.occurrence); + } + + #focusPreviewOccurrence(occurrence: PreviewOccurrence): void { + const resources = this.#install.resourcesByPreviewId.get(occurrence.previewId); + const anchors = occurrence.occurrence.anchors.flatMap((anchor) => { const realized = resources?.resolve(anchor); return realized ? [realized] : []; }); - this.#focusPreview(record.occurrence.previewId, anchors); + this.#focusPreview(occurrence.previewId, anchors); } #pinToViewport(): void { diff --git a/web/src/editor/display-labels.ts b/web/src/editor/display-labels.ts new file mode 100644 index 0000000..95d10ba --- /dev/null +++ b/web/src/editor/display-labels.ts @@ -0,0 +1,72 @@ +import type { PreviewIdentity } from "./editor-state.js"; + +export interface PreviewDisplayLabels { + readonly subject: string; + readonly example: string; + readonly combined: string; +} + +type SubjectIdentity = Pick; + +const qualifiedTail = (value: string): string => { + const separator = value.lastIndexOf("::"); + return separator < 0 ? value : value.slice(separator + 2); +}; + +/** + * Converts one authored/public identifier into the Editor's compact label + * vocabulary. This is presentation only: callers retain the original identity + * for every semantic join and protocol operation. + */ +export const editorIdentifierLabel = (value: string): string => { + const tail = qualifiedTail(value) + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + return tail || value; +}; + +/** A page's `Page` suffix describes its UI kind, so the Editor need not repeat it. */ +export const editorSubjectLabel = (identity: SubjectIdentity): string => { + const label = editorIdentifierLabel(identity.subject); + if (identity.kind !== "page") return label; + const withoutPage = label.replace(/-?page$/, ""); + return withoutPage || label; +}; + +const fullExampleLabel = (identity: PreviewIdentity): string => + editorIdentifierLabel(identity.example); + +const shortenedExampleLabel = (identity: PreviewIdentity): string => { + const full = fullExampleLabel(identity); + const subject = editorSubjectLabel(identity); + const prefix = `${subject}-`; + return subject && full.startsWith(prefix) && full.length > prefix.length + ? full.slice(prefix.length) + : full; +}; + +const sameSubject = (left: PreviewIdentity, right: PreviewIdentity): boolean => + left.kind === right.kind && left.subject === right.subject; + +/** + * Produces friendly labels for one preview. A presentation-derived subject + * prefix is removed from its example only when the resulting label is unique + * among the subject's peer examples. + */ +export const editorPreviewLabels = ( + identity: PreviewIdentity, + peers: readonly PreviewIdentity[] = [identity], +): PreviewDisplayLabels => { + const subject = editorSubjectLabel(identity); + const fullExample = fullExampleLabel(identity); + const candidate = shortenedExampleLabel(identity); + const collisions = peers.filter((peer) => + sameSubject(identity, peer) && shortenedExampleLabel(peer) === candidate); + const example = candidate !== fullExample && collisions.length === 1 + ? candidate + : fullExample; + return { subject, example, combined: `${subject} / ${example}` }; +}; diff --git a/web/src/editor/editor-authoring.ts b/web/src/editor/editor-authoring.ts index bf42db4..9337db8 100644 --- a/web/src/editor/editor-authoring.ts +++ b/web/src/editor/editor-authoring.ts @@ -149,6 +149,7 @@ export const presentedSourceTargets = ( const targetIds = new Set([ ...authoring.documentedTargets.map((target) => target.id), ...authoring.annotationTargets.map((annotation) => annotation.target.id), + ...authoring.occurrencesByTarget.keys(), ]); return [...targetIds] .flatMap((targetId) => { diff --git a/web/src/editor/editor-board.ts b/web/src/editor/editor-board.ts index a66b8ce..569b7c9 100644 --- a/web/src/editor/editor-board.ts +++ b/web/src/editor/editor-board.ts @@ -1,6 +1,6 @@ -import { createEditorRenderer } from "../renderer/editor.js"; +import { createEditorAssets } from "../renderer/assets.js"; import type { IconFontRegistry } from "../renderer/icons.js"; -import type { Snapshot, VNode } from "../protocol/types.js"; +import { createProjectionRenderer } from "../renderer/projection.js"; import { type EditorPreview, type EditorRender, @@ -32,6 +32,12 @@ import { structureConnectorDescription, } from "./structure-connectors.js"; import { structureConnectorLabelSegments } from "./structure-presentation.js"; +import { + editorIdentifierLabel, + editorPreviewLabels, + editorSubjectLabel, + type PreviewDisplayLabels, +} from "./display-labels.js"; export interface PreparedWorkflowConnector extends WorkflowConnector { element: SVGGElement; @@ -93,9 +99,9 @@ const prepareWorkflowConnector = ( group.dataset.lane = String(connector.lane); group.dataset.sourcePort = `${connector.sourcePort.slot + 1}/${connector.sourcePort.count}`; group.dataset.targetPort = `${connector.targetPort.slot + 1}/${connector.targetPort.count}`; - if (connector.openedSurfaces.length > 0) { + if (connector.introducedSurfaces.length > 0) { group.classList.add("opens-surface"); - group.dataset.openedSurfaces = connector.openedSurfaces + group.dataset.introducedSurfaces = connector.introducedSurfaces .map((surface) => surface.definition) .join(" "); } @@ -107,7 +113,7 @@ const prepareWorkflowConnector = ( const origin = svgElement(document, "circle", "workflow-connector-origin"); origin.setAttribute("r", "3"); const label = svgElement(document, "text", "workflow-connector-label"); - label.textContent = workflowConnectorLabel(connector.steps, connector.openedSurfaces); + label.textContent = workflowConnectorLabel(connector.steps, connector.introducedSurfaces); group.append(title, path, arrow, origin, label); return { ...connector, element: group }; }; @@ -174,9 +180,6 @@ const prepareStructureConnector = ( return { ...connector, element: group }; }; -const isSnapshot = (content: Snapshot | VNode): content is Snapshot => - "protocol" in content && content.protocol === "uhura-view/0"; - const provenance = (preview: EditorPreview): string => { if (preview.pinned) return "Pinned example"; if (preview.derived) return "Replay-derived"; @@ -190,60 +193,26 @@ const realizePreview = ( stylesheet: CSSStyleSheet, host: HTMLElement, resources: RealizationResources, - icons: IconFontRegistry, + icons: IconFontRegistry | undefined, ): void => { const shadow = host.attachShadow({ mode: "open" }); shadow.adoptedStyleSheets = [stylesheet]; const application = element(document, "div", "preview-application"); application.id = "uh-app"; - const wrapper = element(document, "div", isSnapshot(preview.content) + const wrapper = element(document, "div", preview.identity.kind === "page" ? "screen-root" - : "fragment-root"); + : "preview-root"); wrapper.inert = true; - const renderer = createEditorRenderer({ - document, - assets: render.assets, + const renderer = createProjectionRenderer({ + root: wrapper, + dispatch: () => undefined, + mode: "editor", + assets: createEditorAssets(render.assets), icons, + modalSurfaces: false, + observeElement: (key, realized) => resources.registerKey(key, realized), }); - if (isSnapshot(preview.content)) { - renderer.realizeRoot(wrapper, preview.content.page.root, { - root: { kind: "page" }, - scope: `${preview.id}:page`, - parentIsList: false, - observe: (realization) => resources.register(realization), - }); - for (const [index, surface] of preview.content.surfaces.entries()) { - const overlay = element(document, "div", "uh-surface-overlay"); - overlay.dataset.surfaceDefinition = surface.definition; - overlay.dataset.surfaceModality = surface.modality; - overlay.dataset.surfaceStackIndex = String(index); - overlay.style.zIndex = String(index + 1); - const scrim = element(document, "div", "uh-scrim"); - const surfaceHost = element( - document, - "div", - `uh-surface uh-modality-${surface.modality}`, - ); - surfaceHost.setAttribute("role", "dialog"); - surfaceHost.setAttribute("aria-modal", "true"); - surfaceHost.dataset.surfaceDefinition = surface.definition; - renderer.realizeRoot(surfaceHost, surface.root, { - root: { kind: "surface", key: surface.key }, - scope: `${preview.id}:surface:${surface.key}`, - parentIsList: false, - observe: (realization) => resources.register(realization), - }); - overlay.append(scrim, surfaceHost); - wrapper.append(overlay); - } - } else { - renderer.realizeRoot(wrapper, preview.content, { - root: { kind: "fragment" }, - scope: `${preview.id}:fragment`, - parentIsList: false, - observe: (realization) => resources.register(realization), - }); - } + renderer.render(preview.content.value.document); application.append(wrapper); shadow.append(application); }; @@ -262,10 +231,11 @@ interface PreparedFrame { const frame = ( document: Document, preview: EditorPreview, + labels: PreviewDisplayLabels, render: EditorRender, stylesheet: CSSStyleSheet, resources: RealizationResources, - icons: IconFontRegistry, + icons: IconFontRegistry | undefined, realize: boolean, ): PreparedFrame => { const figure = element(document, "figure", "editor-frame"); @@ -295,7 +265,7 @@ const frame = ( document, "span", "caption-title", - `${preview.identity.subject} / ${preview.identity.example}`, + labels.combined, )); if (preview.default) caption.append(badge(document, "badge-default", "default")); if (preview.pinned) caption.append(badge(document, "badge-pinned", "pinned")); @@ -309,13 +279,13 @@ const frame = ( const surfaceBadge = badge( document, "badge-surface", - `${surface.modality} ${surface.definition}`, + `${surface.modality} ${editorIdentifierLabel(surface.definition)}`, ); surfaceBadge.dataset.relation = surface.relation; surfaceBadge.title = { - direct: "Child surface opened by this replay edge", - inherited: "Child surface inherited from replay ancestry", - mounted: "Child surface mounted in this snapshot", + introduced: "Present in this projection but absent from its evidence parent", + retained: "Present in this projection and its evidence parent", + present: "Present in this standalone projection", }[surface.relation]; caption.append(surfaceBadge); } @@ -331,22 +301,29 @@ const navigatorGroup = ( group: EditorRender["groups"][number], previews: EditorPreview[], ): HTMLElement => { + const peerIdentities = previews.map((preview) => preview.identity); + const subjectLabel = editorSubjectLabel(group); const section = element(document, "section", "navigator-group"); section.dataset.navigatorGroup = ""; - section.dataset.search = `${group.kind} ${group.subject}`.toLocaleLowerCase(); + section.dataset.search = [ + group.kind, + group.subject, + subjectLabel, + ].join(" ").toLocaleLowerCase(); const row = element(document, "button", "navigator-row"); row.type = "button"; row.dataset.groupId = group.id; row.append( element(document, "span", "navigator-kind"), - element(document, "span", "navigator-row-title", group.subject), + element(document, "span", "navigator-row-title", subjectLabel), element(document, "span", "navigator-count", String(previews.length)), ); (row.firstElementChild as HTMLElement).dataset.kind = group.kind; const list = element(document, "div", "navigator-frames"); for (const preview of previews) { + const labels = editorPreviewLabels(preview.identity, peerIdentities); const button = element(document, "button", "navigator-frame"); button.type = "button"; button.dataset.previewId = preview.id; @@ -354,11 +331,13 @@ const navigatorGroup = ( preview.identity.kind, preview.identity.subject, preview.identity.example, + labels.subject, + labels.example, ].join(" ").toLocaleLowerCase(); button.setAttribute("aria-pressed", "false"); button.append( element(document, "span", "navigator-frame-icon"), - element(document, "span", "navigator-frame-title", preview.identity.example), + element(document, "span", "navigator-frame-title", labels.example), ); if (preview.derived) { const marker = element(document, "span", "navigator-derived", "D"); @@ -437,12 +416,11 @@ export const prepareEditorModel = ( }; } - if (!icons) throw new Error("A renderable Editor model requires icon fonts"); - const stylesheet = previous?.render?.stylesheet === render.stylesheet ? previous.stylesheet ?? preparePreviewStylesheet(document, render.stylesheet) : preparePreviewStylesheet(document, render.stylesheet); - const resourcesMatch = previous?.iconFingerprint === icons.fingerprint; + const iconFingerprint = icons?.fingerprint ?? null; + const resourcesMatch = previous?.iconFingerprint === iconFingerprint; const reusableRealizationIds = new Set(resourcesMatch ? [...reusablePreviewIds(previous?.render ?? null, render)].filter((id) => previous?.frameById.has(id) ?? false) @@ -475,7 +453,7 @@ export const prepareEditorModel = ( document, "h2", "row-title", - `${group.kind} ${group.subject}`, + `${group.kind} ${editorSubjectLabel(group)}`, )); const frames = element(document, "div", "row-frames"); const laneCount = groupConnectors.reduce( @@ -485,6 +463,7 @@ export const prepareEditorModel = ( if (laneCount > 0) { frames.style.setProperty("--workflow-rail-height", `${workflowRailHeight(laneCount)}px`); } + const peerIdentities = typedPreviews.map((preview) => preview.identity); for (const preview of typedPreviews) { const resources = new RealizationResources(); resources.claim(resourceOwner); @@ -492,6 +471,7 @@ export const prepareEditorModel = ( const prepared = frame( document, preview, + editorPreviewLabels(preview.identity, peerIdentities), render, stylesheet, resources, @@ -533,7 +513,7 @@ export const prepareEditorModel = ( connectors, structureConnectors, render, - iconFingerprint: icons.fingerprint, + iconFingerprint, stylesheet, reusableRealizationIds, reusableFrameIds, diff --git a/web/src/editor/editor-realization.ts b/web/src/editor/editor-realization.ts index 307f0d7..9b9ce4d 100644 --- a/web/src/editor/editor-realization.ts +++ b/web/src/editor/editor-realization.ts @@ -1,19 +1,6 @@ -import type { - EditorNodeRealization, - EditorRenderNodeRef, - EditorRenderRoot, -} from "../renderer/editor.js"; - export type RealizationOwner = object; -const rootKey = (root: EditorRenderRoot): string => { - if (root.kind === "page") return "page"; - if (root.kind === "fragment") return "fragment"; - return `surface:${root.key}`; -}; - -export const realizationKey = (reference: EditorRenderNodeRef): string => - `${rootKey(reference.root)}|${reference.path.join(".")}`; +export const realizationKey = (key: string): string => `key|${key}`; /** * Direct semantic-node handles and their geometry subscriptions for one @@ -47,18 +34,18 @@ export class RealizationResources { this.#owner = to; } - register(realization: EditorNodeRealization): void { + registerKey(key: string, element: HTMLElement): void { if (this.#disposed) throw new Error("cannot register into disposed realization resources"); - const key = realizationKey(realization); - if (this.#elements.has(key)) { - throw new Error(`duplicate semantic realization ${key}`); + const realization = realizationKey(key); + if (this.#elements.has(realization)) { + throw new Error(`duplicate semantic realization ${realization}`); } - this.#elements.set(key, realization.element); + this.#elements.set(realization, element); } - resolve(reference: EditorRenderNodeRef): HTMLElement | null { + resolve(key: string): HTMLElement | null { if (this.#disposed) return null; - return this.#elements.get(realizationKey(reference)) ?? null; + return this.#elements.get(realizationKey(key)) ?? null; } realizedElements(): readonly HTMLElement[] { diff --git a/web/src/editor/editor-state.ts b/web/src/editor/editor-state.ts index 2c49ad3..4383a48 100644 --- a/web/src/editor/editor-state.ts +++ b/web/src/editor/editor-state.ts @@ -1,13 +1,22 @@ -import type { - Descriptor, - InteractionGraph, - Snapshot, - SurfaceView, - VNode, - VValue, -} from "../protocol/types.js"; - -export const EDITOR_STATE_PROTOCOL = "uhura-editor-state/2" as const; +import type { InteractionGraph } from "../protocol/types.js"; +import { decodeInteractionGraphArtifacts } from "../protocol/interaction-graph.js"; +import { + decodeSemanticProvenance, + type SemanticProvenance, +} from "../protocol/provenance.js"; +import { + decodeEvidenceSummary, + type EvidenceSummary, +} from "../protocol/evidence-summary.js"; +import { + decodeProjectionSources, + decodeRenderDocument, + type ProjectionSources, + type RenderDocument, + type RenderNode, +} from "../renderer/projection.js"; + +export const EDITOR_STATE_PROTOCOL = "uhura-editor-state/5" as const; export const EDITOR_EVENT_PROTOCOL = "uhura-editor-event/0" as const; export const INTERACTION_GRAPH_PROTOCOL = "uhura-interaction-graph/0" as const; @@ -96,7 +105,7 @@ export type SourceTargetClass = | "outcome-handler" | "handler-parameter" | "example-declaration" - | "catalog-element" + | "ui-element" | "component-invocation" | "if-block" | "each-block" @@ -145,26 +154,27 @@ export interface PreviewDocumentation { exampleDocId: string | null; } -export type RenderRoot = - | { kind: "page" } - | { kind: "fragment" } - | { kind: "surface"; key: string }; - -export interface RenderNodeRef { - root: RenderRoot; - path: number[]; -} - export interface TargetOccurrence { id: string; targetId: string; - anchors: RenderNodeRef[]; + /** Opaque semantic node keys from this preview's `uhura-view/1` document. */ + anchors: string[]; } export interface PreviewProvenance { occurrences: TargetOccurrence[]; } +export interface PreviewEvidence { + scenario: string; + pin: string; + sourceId: string; + sources: { + registration: JsonValue; + pin: JsonValue; + }; +} + export interface ReplayGuard { handler: number; result: "satisfied" | "unsatisfied" | "not-ready"; @@ -211,7 +221,16 @@ export interface EditorPreview { interactions: PreviewInteraction[]; documentation: PreviewDocumentation; provenance: PreviewProvenance; - content: Snapshot | VNode; + evidence: PreviewEvidence | null; + content: PreviewContent; +} + +export interface PreviewContent { + kind: "projection"; + value: { + document: RenderDocument; + sources: ProjectionSources; + }; } export interface EditorAsset { @@ -219,6 +238,18 @@ export interface EditorAsset { alt: string; } +export interface EditorMachine { + protocol: "uhura-machine-inspection/1"; + identityProtocol: string; + deployment: JsonValue; + sources: JsonValue; + provenance: SemanticProvenance; + interactionGraph: JsonValue; + graphSources: JsonValue; + checkpoints: JsonValue; + evidence: EvidenceSummary; +} + export interface EditorRender { revision: number; freshness: PreviewFreshness; @@ -229,6 +260,7 @@ export interface EditorRender { stylesheet: string; assets: Record; interactionGraph: InteractionGraph; + machine: EditorMachine | null; } export interface EditorState { @@ -369,7 +401,7 @@ const sourceTargetClasses = [ "outcome-handler", "handler-parameter", "example-declaration", - "catalog-element", + "ui-element", "component-invocation", "if-block", "each-block", @@ -377,7 +409,7 @@ const sourceTargetClasses = [ ] as const satisfies readonly SourceTargetClass[]; const annotatableTargetClasses = new Set([ - "catalog-element", + "ui-element", "component-invocation", "if-block", "each-block", @@ -485,27 +517,6 @@ const previewDocumentation = (value: unknown, path: string): PreviewDocumentatio }; }; -const renderRoot = (value: unknown, path: string): RenderRoot => { - const object = record(value, path); - const kind = oneOf(object["kind"], `${path}.kind`, ["page", "fragment", "surface"]); - if (kind === "surface") { - exact(object, path, ["kind", "key"]); - return { kind, key: string(object["key"], `${path}.key`) }; - } - exact(object, path, ["kind"]); - return { kind }; -}; - -const renderNodeRef = (value: unknown, path: string): RenderNodeRef => { - const object = record(value, path); - exact(object, path, ["root", "path"]); - return { - root: renderRoot(object["root"], `${path}.root`), - path: array(object["path"], `${path}.path`).map((item, index) => - nonNegativeInteger(item, `${path}.path[${index}]`)), - }; -}; - const targetOccurrence = (value: unknown, path: string): TargetOccurrence => { const object = record(value, path); exact(object, path, ["id", "targetId", "anchors"]); @@ -513,7 +524,7 @@ const targetOccurrence = (value: unknown, path: string): TargetOccurrence => { id: string(object["id"], `${path}.id`), targetId: string(object["targetId"], `${path}.targetId`), anchors: array(object["anchors"], `${path}.anchors`).map((item, index) => - renderNodeRef(item, `${path}.anchors[${index}]`)), + string(item, `${path}.anchors[${index}]`)), }; }; @@ -526,114 +537,56 @@ const previewProvenance = (value: unknown, path: string): PreviewProvenance => { }; }; -const descriptor = (value: unknown, path: string): Descriptor => { +const content = (value: unknown, path: string): PreviewContent => { const object = record(value, path); - exact(object, path, ["kind", "event", "emit", "scope", "payload", "carries"]); - const carriesValue = object["carries"]; - let carries: Record | undefined; - if (carriesValue !== undefined) { - carries = Object.fromEntries(Object.entries(record(carriesValue, `${path}.carries`)).map( - ([key, item]) => [key, oneOf(item, `${path}.carries.${key}`, ["text", "bool", "int"])], - )); - } - return { - kind: oneOf(object["kind"], `${path}.kind`, ["input", "observe"]), - event: string(object["event"], `${path}.event`), - emit: string(object["emit"], `${path}.emit`), - scope: string(object["scope"], `${path}.scope`), - payload: jsonValue(object["payload"], `${path}.payload`), - ...(carries === undefined ? {} : { carries }), - }; -}; - -const vValue = (value: unknown, path: string): VValue => { - if (typeof value === "boolean" || typeof value === "string") return value; - if (typeof value === "number") return finiteNumber(value, path); - const object = record(value, path); - const tag = object["t"]; - if (tag === "plain") { - exact(object, path, ["t", "v"]); - return { t: "plain", v: string(object["v"], `${path}.v`, true) }; - } - if (tag === "image") { - exact(object, path, ["t", "asset"]); - return { t: "image", asset: string(object["asset"], `${path}.asset`) }; + const kind = oneOf(object["kind"], `${path}.kind`, ["projection"]); + exact(object, path, ["kind", "value"]); + const projection = record(object["value"], `${path}.value`); + exact(projection, `${path}.value`, ["document", "sources"]); + try { + const document = decodeRenderDocument( + projection["document"], + `${path}.value.document`, + ); + return { + kind, + value: { + document, + sources: decodeProjectionSources( + projection["sources"], + document, + `${path}.value.sources`, + ), + }, + }; + } catch (error) { + throw new EditorContractError( + `${path}.value`, + error instanceof Error ? error.message : "an Uhura machine render document", + ); } - throw new EditorContractError(path, "a valid Uhura property value"); -}; - -const vnode = (value: unknown, path: string): VNode => { - const object = record(value, path); - exact(object, path, ["key", "element", "class", "props", "children", "on"]); - const props = Object.fromEntries(Object.entries(record(object["props"], `${path}.props`)).map( - ([key, item]) => [key, vValue(item, `${path}.props.${key}`)], - )); - const childrenValue = object["children"]; - const onValue = object["on"]; - const children = childrenValue === undefined - ? undefined - : array(childrenValue, `${path}.children`).map((item, index) => - vnode(item, `${path}.children[${index}]`)); - const on = onValue === undefined - ? undefined - : array(onValue, `${path}.on`).map((item, index) => - descriptor(item, `${path}.on[${index}]`)); - return { - key: string(object["key"], `${path}.key`), - element: string(object["element"], `${path}.element`), - props, - ...(object["class"] === undefined - ? {} - : { class: string(object["class"], `${path}.class`, true) }), - ...(children === undefined ? {} : { children }), - ...(on === undefined ? {} : { on }), - }; }; -const surface = (value: unknown, path: string): SurfaceView => { +const previewEvidence = (value: unknown, path: string): PreviewEvidence | null => { + if (value === null) return null; const object = record(value, path); - exact(object, path, ["key", "definition", "modality", "restore-focus", "dismiss", "root"]); - return { - key: string(object["key"], `${path}.key`), - definition: string(object["definition"], `${path}.definition`), - modality: string(object["modality"], `${path}.modality`), - ...(object["restore-focus"] === undefined - ? {} - : { "restore-focus": string(object["restore-focus"], `${path}.restore-focus`) }), - dismiss: descriptor(object["dismiss"], `${path}.dismiss`), - root: vnode(object["root"], `${path}.root`), - }; -}; - -const snapshot = (value: UnknownRecord, path: string): Snapshot => { - exact(value, path, ["protocol", "revision", "page", "surfaces"]); - if (value["protocol"] !== "uhura-view/0") { - throw new EditorContractError(`${path}.protocol`, JSON.stringify("uhura-view/0")); - } - const page = record(value["page"], `${path}.page`); - exact(page, `${path}.page`, ["route", "root"]); + exact(object, path, ["scenario", "pin", "sourceId", "sources"]); + const sources = record(object["sources"], `${path}.sources`); + exact(sources, `${path}.sources`, ["registration", "pin"]); return { - protocol: "uhura-view/0", - revision: nonNegativeInteger(value["revision"], `${path}.revision`), - page: { - route: string(page["route"], `${path}.page.route`, true), - root: vnode(page["root"], `${path}.page.root`), + scenario: string(object["scenario"], `${path}.scenario`), + pin: string(object["pin"], `${path}.pin`), + sourceId: string(object["sourceId"], `${path}.sourceId`), + sources: { + registration: jsonValue( + sources["registration"], + `${path}.sources.registration`, + ), + pin: jsonValue(sources["pin"], `${path}.sources.pin`), }, - surfaces: array(value["surfaces"], `${path}.surfaces`).map((item, index) => - surface(item, `${path}.surfaces[${index}]`)), }; }; -const content = (value: unknown, path: string): Snapshot | VNode => { - const object = record(value, path); - return object["protocol"] === "uhura-view/0" - ? snapshot(object, path) - : vnode(object, path); -}; - -const isSnapshotContent = (value: Snapshot | VNode): value is Snapshot => - "protocol" in value && value.protocol === "uhura-view/0"; - const dataSource = (value: unknown, path: string): PreviewDataSource | null => { if (value === null) return null; const object = record(value, path); @@ -768,16 +721,11 @@ const preview = (value: unknown, path: string): EditorPreview => { const object = record(value, path); exact(object, path, [ "id", "identity", "sourceFile", "default", "pinned", "derived", "inFlight", "from", "note", - "replaySteps", "replay", "data", "interactions", "documentation", "provenance", "content", + "replaySteps", "replay", "data", "interactions", "documentation", "provenance", "evidence", + "content", ]); const previewIdentity = identity(object["identity"], `${path}.identity`); const previewContent = content(object["content"], `${path}.content`); - if ((previewIdentity.kind === "page") !== isSnapshotContent(previewContent)) { - throw new EditorContractError( - `${path}.content`, - previewIdentity.kind === "page" ? "an uhura-view/0 snapshot" : "a fragment VNode", - ); - } const replaySteps = array(object["replaySteps"], `${path}.replaySteps`).map((item, index) => string(item, `${path}.replaySteps[${index}]`)); const replay = array(object["replay"], `${path}.replay`).map((item, index) => @@ -804,6 +752,7 @@ const preview = (value: unknown, path: string): EditorPreview => { interaction(item, `${path}.interactions[${index}]`)), documentation: previewDocumentation(object["documentation"], `${path}.documentation`), provenance: previewProvenance(object["provenance"], `${path}.provenance`), + evidence: previewEvidence(object["evidence"], `${path}.evidence`), content: previewContent, }; }; @@ -846,7 +795,11 @@ const validateAuthoring = ( const targets = new Map(authoring.targets.map((target) => [target.id, target])); const entries = new Map(authoring.entries.map((entry) => [entry.id, entry])); const orders = new Map(); - const annotationTargets = new Set(); + const projectionSourceTargets = new Set( + previews.flatMap((preview) => + preview.provenance.occurrences.map((occurrence) => occurrence.targetId) + ), + ); for (const [index, entry] of authoring.entries.entries()) { const entryPath = `$.render.authoring.entries[${index}]`; @@ -867,7 +820,6 @@ const validateAuthoring = ( ) { throw new EditorContractError(entryPath, "annotation metadata on an annotatable target"); } - annotationTargets.add(entry.targetId); } const targetOrders = orders.get(entry.targetId) ?? []; targetOrders.push(entry.order); @@ -884,7 +836,9 @@ const validateAuthoring = ( } }); } - const unusedTarget = authoring.targets.find((target) => !orders.has(target.id)); + const unusedTarget = authoring.targets.find((target) => + !orders.has(target.id) && !projectionSourceTargets.has(target.id) + ); if (unusedTarget) { throw new EditorContractError( "$.render.authoring.targets", @@ -944,22 +898,18 @@ const validateAuthoring = ( if ( !target || !annotatableTargetClasses.has(target.class) - || !annotationTargets.has(occurrence.targetId) ) { throw new EditorContractError( `${occurrencePath}.targetId`, - "an annotation-bearing annotatable source target id", + "an annotatable source target id", ); } - unique( - occurrence.anchors.map((anchor) => JSON.stringify(anchor)), - `${occurrencePath}.anchors`, - ); + unique(occurrence.anchors, `${occurrencePath}.anchors`); for (const [anchorIndex, anchor] of occurrence.anchors.entries()) { if (!anchorResolves(preview.content, anchor)) { throw new EditorContractError( `${occurrencePath}.anchors[${anchorIndex}]`, - "a semantic node path in this preview", + "a semantic node key in this preview", ); } } @@ -967,25 +917,16 @@ const validateAuthoring = ( } }; -const anchorResolves = (contentValue: Snapshot | VNode, anchor: RenderNodeRef): boolean => { - let node: VNode | undefined; - if (isSnapshotContent(contentValue)) { - if (anchor.root.kind === "page") node = contentValue.page.root; - else if (anchor.root.kind === "surface") { - const key = anchor.root.key; - const matching = contentValue.surfaces.filter((surfaceValue) => surfaceValue.key === key); - if (matching.length === 1) node = matching[0]?.root; - } - } else if (anchor.root.kind === "fragment") { - node = contentValue; - } - if (!node) return false; - for (const index of anchor.path) { - node = node.children?.[index]; - if (!node) return false; - } - return true; -}; +const projectionNodeHasKey = ( + nodes: readonly RenderNode[], + key: string, +): boolean => nodes.some((node) => + node.key === key + || (node.kind === "element" && projectionNodeHasKey(node.children, key)) +); + +const anchorResolves = (contentValue: PreviewContent, anchor: string): boolean => + projectionNodeHasKey(contentValue.value.document.nodes, anchor); const validateReferences = (groups: PreviewGroup[], previews: EditorPreview[]): void => { unique(groups.map((item) => item.id), "$.render.groups[].id"); @@ -1088,12 +1029,89 @@ const interactionGraph = (value: unknown, path: string): InteractionGraph => { }; }; +const editorMachine = (value: unknown, path: string): EditorMachine | null => { + if (value === null) return null; + const object = record(value, path); + exact(object, path, [ + "protocol", + "identityProtocol", + "deployment", + "sources", + "provenance", + "interactionGraph", + "graphSources", + "checkpoints", + "evidence", + ]); + if (object["protocol"] !== "uhura-machine-inspection/1") { + throw new EditorContractError( + `${path}.protocol`, + JSON.stringify("uhura-machine-inspection/1"), + ); + } + const sources = jsonValue(object["sources"], `${path}.sources`); + decodeInteractionGraphArtifacts( + object["interactionGraph"], + object["graphSources"], + ); + const provenance = decodeSemanticProvenance( + object["provenance"], + `${path}.provenance`, + ); + const inventory = new Map( + array(object["sources"], `${path}.sources`).map((value, index) => { + const sourcePath = `${path}.sources[${index}]`; + const source = record(value, sourcePath); + return [ + string(source["path"], `${sourcePath}.path`), + { + sha256: string(source["sha256"], `${sourcePath}.sha256`), + bytes: nonNegativeInteger(source["bytes"], `${sourcePath}.bytes`), + }, + ] as const; + }), + ); + for (const semanticSource of provenance.sources) { + const physical = inventory.get(semanticSource.path); + if ( + physical === undefined + || physical.sha256 !== semanticSource.sha256 + || physical.bytes !== semanticSource.bytes + ) { + throw new EditorContractError( + `${path}.provenance.sources`, + "entries matching the accepted source inventory", + ); + } + } + return { + protocol: "uhura-machine-inspection/1", + identityProtocol: string(object["identityProtocol"], `${path}.identityProtocol`), + deployment: jsonValue(object["deployment"], `${path}.deployment`), + sources, + provenance, + interactionGraph: jsonValue(object["interactionGraph"], `${path}.interactionGraph`), + graphSources: jsonValue(object["graphSources"], `${path}.graphSources`), + checkpoints: jsonValue(object["checkpoints"], `${path}.checkpoints`), + evidence: (() => { + try { + return decodeEvidenceSummary(object["evidence"], `${path}.evidence`); + } catch (error) { + throw new EditorContractError( + `${path}.evidence`, + error instanceof Error ? error.message : "a bounded Uhura evidence summary", + ); + } + })(), + }; +}; + const render = (value: unknown, path: string, sourceRevision: number): EditorRender | null => { if (value === null) return null; const object = record(value, path); exact(object, path, [ "revision", "freshness", "application", "authoring", "groups", "previews", "stylesheet", - "assets", "interactionGraph", + "assets", "interactionGraph", "machine", ]); const revision = positiveRevision(object["revision"], `${path}.revision`); const freshness = oneOf(object["freshness"], `${path}.freshness`, ["current", "stale"]); @@ -1125,6 +1143,7 @@ const render = (value: unknown, path: string, sourceRevision: number): EditorRen stylesheet: string(object["stylesheet"], `${path}.stylesheet`, true), assets, interactionGraph: interactionGraph(object["interactionGraph"], `${path}.interactionGraph`), + machine: editorMachine(object["machine"], `${path}.machine`), }; }; diff --git a/web/src/editor/editor-styles.ts b/web/src/editor/editor-styles.ts index 15cc786..9f82725 100644 --- a/web/src/editor/editor-styles.ts +++ b/web/src/editor/editor-styles.ts @@ -1,3 +1,5 @@ +import PRIMITIVE_BASE_STYLES from "../renderer/primitives/base.css?inline"; + export const EDITOR_STYLES = ` .uhura-editor { --navigator-width: 240px; @@ -474,6 +476,22 @@ export const EDITOR_STYLES = ` .inspector-grid > div { padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: #fafbfc; } .inspector-grid dt { color: var(--faint); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; } .inspector-grid dd { margin: 2px 0 0; font-size: 16px; font-weight: 680; } + .inspector-block.overview-machine-block { margin: 0 0 16px; padding: 11px; border: 1px solid #dbe5ec; border-radius: 8px; background: #f8fbfd; } + .machine-block-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 8px; } + .overview-machine-block .machine-block-heading h3 { margin: 0; } + .machine-evidence-status { flex: none; padding: 2px 6px; border-radius: 999px; color: #596a79; background: #e9eff4; font-size: 8px; font-weight: 700; } + .machine-evidence-status[data-tone="passed"] { color: #24623c; background: #dff4e7; } + .machine-evidence-status[data-tone="failed"] { color: #7b4651; background: #fbe5e9; } + .machine-property-list { margin: 0; } + .machine-property-list > div { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 8px; padding-block: 6px; border-block-end: 1px solid #e4eaee; } + .machine-property-list > div:first-child { border-block-start: 1px solid #e4eaee; } + .machine-property-list dt { color: var(--faint); font-size: 9px; } + .machine-property-list dd { min-inline-size: 0; margin: 0; color: #34404c; font: 9px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } + .machine-metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin: 9px 0 0; } + .machine-metric-grid > div { padding: 7px 8px; border-radius: 6px; background: #edf3f7; } + .machine-metric-grid dt { color: #70808d; font-size: 8px; text-transform: uppercase; letter-spacing: .04em; } + .machine-metric-grid dd { margin: 1px 0 0; color: #2f3c48; font-size: 13px; font-weight: 680; } + .machine-topology-heading { margin: 11px 0 5px; color: #5f6e7a; font-size: 9px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; } .inspector-callout { padding: 12px; border-radius: 8px; color: #425466; background: #f3f7fa; } .inspector-callout strong { font-size: 11px; } .inspector-callout p { margin: 4px 0 0; color: #657181; font-size: 11px; } @@ -622,31 +640,18 @@ export const EDITOR_STYLES = ` export const PREVIEW_BASE_STYLES = ` :host, #uh-app { display: block; inline-size: 100%; block-size: 100%; color: #16181c; } *, *::before, *::after { box-sizing: border-box; } - .screen-root, .fragment-root { position: relative; inline-size: 100%; block-size: 100%; overflow: hidden; } - .screen-root { isolation: isolate; } + .screen-root, .preview-root { position: relative; inline-size: 100%; block-size: 100%; overflow: hidden; isolation: isolate; } .screen-root > * { block-size: 100%; } - .fragment-root > * { min-inline-size: 0; } - .uh-view { display: block; min-inline-size: 0; } - .uh-scroll { overflow-y: auto; overflow-x: hidden; min-block-size: 0; } - .uh-scroll[data-direction="horizontal"] { overflow-x: auto; overflow-y: hidden; } - .uh-text { margin: 0; overflow-wrap: anywhere; } - .uh-img { display: block; background-color: #d9d9de; } - .uh-video { display: block; inline-size: 100%; background: #111 center / cover no-repeat; object-fit: cover; } - .uh-icon { display: inline-flex; align-items: center; justify-content: center; inline-size: 1em; block-size: 1em; font-size: 24px; } - button.uh-button { appearance: none; display: inline-flex; align-items: center; gap: 6px; padding: 6px; border: 0; border-radius: 8px; color: inherit; background: none; font: inherit; } - button.uh-button[disabled] { opacity: .35; } - button.uh-button[aria-busy="true"] { opacity: .6; } - .uh-textfield input { inline-size: 100%; padding: 8px 14px; border: 1px solid #d5d5da; border-radius: 999px; color: #222; background: #fff; font: inherit; } - .uh-region { display: block; } - .uh-pager { position: relative; } - .uh-pager .uh-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; } - .uh-pager .uh-track > * { flex: 0 0 100%; scroll-snap-align: center; } - .uh-dots { position: absolute; inset-block-end: 10px; inset-inline: 0; display: flex; justify-content: center; gap: 5px; } - .uh-dot { inline-size: 6px; block-size: 6px; border-radius: 999px; background: rgb(255 255 255 / 55%); } - .uh-dot.on { background: #fff; } - .uh-surface-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; isolation: isolate; } - .uh-scrim { position: absolute; inset: 0; z-index: 0; background: rgb(0 0 0 / 40%); } - .uh-surface { position: relative; z-index: 1; block-size: 72%; max-block-size: 72%; overflow: hidden; border-radius: 16px 16px 0 0; background: #fff; box-shadow: 0 -8px 32px rgb(0 0 0 / 35%); } + .preview-root > * { min-inline-size: 0; } + ${PRIMITIVE_BASE_STYLES} + .screen-root:has(.uhura-surface[open])::before, + .preview-root:has(.uhura-surface[open])::before { + content: ""; + position: absolute; + inset: 0; + z-index: 9; + background: rgb(0 0 0 / 40%); + } `; /** diff --git a/web/src/editor/editor.ts b/web/src/editor/editor.ts index 9619889..a2b30ae 100644 --- a/web/src/editor/editor.ts +++ b/web/src/editor/editor.ts @@ -46,6 +46,7 @@ import { import { incomingLeftLabelShift, layoutStructureConnectors, + logicalRoutePreviewNode, routeStructureConnector, structureDefinitionNode, visibleStructureConnectors, @@ -67,6 +68,18 @@ import { loadIconFontRegistry, type IconFontRegistry, } from "../renderer/icons.js"; +import { projectionUsesPrimitiveCapability } from "../renderer/primitives/registry.js"; +import type { RenderNode } from "../renderer/projection.js"; +import { + inspectMachine, + machineMetricRows, + previewEvidenceRows, + renderInspectionRows, +} from "./machine-inspection.js"; +import { + editorIdentifierLabel, + editorPreviewLabels, +} from "./display-labels.js"; const EDITOR_STATE_PATH = "/api/editor/state"; const EDITOR_ICON_FONTS_PATH = "/api/editor/icon-fonts.json"; @@ -97,6 +110,10 @@ interface Rect extends Point { height: number; } +export const projectionNeedsIconFonts = ( + nodes: readonly RenderNode[], +): boolean => projectionUsesPrimitiveCapability(nodes, "icon-fonts"); + interface PanState { pointerId: number; pointerX: number; @@ -149,6 +166,13 @@ interface EditorShell { overviewApplication: HTMLElement; overviewFreshness: HTMLElement; overviewStats: HTMLElement; + overviewMachineBlock: HTMLElement; + overviewMachineIdentity: HTMLElement; + overviewMachineStatus: HTMLElement; + overviewMachineMetrics: HTMLElement; + overviewMachineOwnership: HTMLElement; + overviewMachineOutcomes: HTMLElement; + overviewMachineDependencies: HTMLElement; overviewCallout: HTMLElement; clearSelectionButton: HTMLButtonElement; selectionKind: HTMLElement; @@ -168,6 +192,8 @@ interface EditorShell { selectionWorkflowBlock: HTMLElement; selectionWorkflow: HTMLOListElement; selectionStatus: HTMLElement; + selectionEvidenceBlock: HTMLElement; + selectionEvidence: HTMLElement; selectionData: HTMLElement; selectionNoData: HTMLElement; selectionNoteBlock: HTMLElement; @@ -234,6 +260,17 @@ const SHELL_HTML = `
UhuraLoading preview model
+
Read-only projection

Save a .uhura file to rebuild these previews automatically.

@@ -325,6 +367,13 @@ const buildShell = (root: HTMLElement): EditorShell => { overviewApplication: required(shell, "[data-overview-application]"), overviewFreshness: required(shell, "[data-overview-freshness]"), overviewStats: required(shell, "[data-overview-stats]"), + overviewMachineBlock: required(shell, ".overview-machine-block"), + overviewMachineIdentity: required(shell, ".overview-machine-identity"), + overviewMachineStatus: required(shell, ".machine-evidence-status"), + overviewMachineMetrics: required(shell, ".overview-machine-metrics"), + overviewMachineOwnership: required(shell, ".overview-machine-ownership"), + overviewMachineOutcomes: required(shell, ".overview-machine-outcomes"), + overviewMachineDependencies: required(shell, ".overview-machine-dependencies"), overviewCallout: required(shell, "[data-overview-callout]"), clearSelectionButton: required(shell, ".clear-selection"), selectionKind: required(shell, ".selection-kind"), @@ -344,6 +393,8 @@ const buildShell = (root: HTMLElement): EditorShell => { selectionWorkflowBlock: required(shell, ".selection-workflow-block"), selectionWorkflow: required(shell, ".selection-workflow"), selectionStatus: required(shell, ".selection-status"), + selectionEvidenceBlock: required(shell, ".selection-evidence-block"), + selectionEvidence: required(shell, ".selection-evidence"), selectionData: required(shell, ".selection-data"), selectionNoData: required(shell, ".selection-no-data"), selectionNoteBlock: required(shell, ".selection-note-block"), @@ -970,9 +1021,13 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { observeFocusedFrame(null); return; } + const labels = editorPreviewLabels( + focusedPreview.identity, + model.render?.previews.map((preview) => preview.identity), + ); shell.focusBreadcrumbKind.textContent = focusedPreview.identity.kind; - shell.focusBreadcrumbSubject.textContent = focusedPreview.identity.subject; - shell.focusBreadcrumbExample.textContent = focusedPreview.identity.example; + shell.focusBreadcrumbSubject.textContent = labels.subject; + shell.focusBreadcrumbExample.textContent = labels.example; frame.classList.add("is-focus-target"); frame.closest(".preview-row")?.classList.add("is-focus-row"); const navigatorButton = Array.from( @@ -1186,12 +1241,13 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { child.dataset.surfaceKey = node.surface.key; if (node.opener !== null) child.dataset.opener = node.opener; const label = document.createElement("strong"); - label.textContent = `${node.surface.modality} ${node.surface.definition}`; + label.textContent = + `${node.surface.modality} ${editorIdentifierLabel(node.surface.definition)}`; const relation = document.createElement("span"); relation.textContent = { - direct: "opened by this replay", - inherited: "inherited from replay ancestry", - mounted: "mounted in this snapshot", + introduced: "introduced since its evidence parent", + retained: "retained from its evidence parent", + present: "present in this projection", }[node.surface.relation]; child.append(label, relation); if (node.children.length > 0) { @@ -1203,7 +1259,7 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { }; const root = document.createElement("li"); root.className = "surface-hierarchy-root"; - root.textContent = `page ${hierarchy.page}`; + root.textContent = `presentation ${editorIdentifierLabel(hierarchy.presentation)}`; const children = document.createElement("ul"); children.append(...hierarchy.roots.map(renderNode)); root.append(children); @@ -1213,6 +1269,8 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { const renderInspector = (preview: EditorPreview): void => { const focused = focusedPreviewId() === preview.id; + const peerIdentities = model.render?.previews.map((candidate) => candidate.identity); + const labels = editorPreviewLabels(preview.identity, peerIdentities); shell.inspectorOverview.hidden = true; shell.inspectorSelection.hidden = false; shell.focusSelectionButton.disabled = false; @@ -1220,9 +1278,9 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { shell.selectionKind.textContent = focused ? `Focused ${preview.identity.kind}` : preview.identity.kind; - shell.selectionName.textContent = `${preview.identity.subject} / ${preview.identity.example}`; - shell.selectionSubject.textContent = preview.identity.subject; - shell.selectionExample.textContent = preview.identity.example; + shell.selectionName.textContent = labels.combined; + shell.selectionSubject.textContent = labels.subject; + shell.selectionExample.textContent = labels.example; shell.selectionSize.textContent = shellSize(preview); shell.selectionOrigin.textContent = origin(preview); shell.selectionSourceRow.hidden = preview.identity.kind !== "page"; @@ -1234,7 +1292,12 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { ? "Copy is disabled while the preview is stale" : "Copy page source path"; shell.selectionFromRow.hidden = preview.from === null || preview.from === ""; - shell.selectionFrom.textContent = preview.from ?? ""; + shell.selectionFrom.textContent = preview.from + ? editorPreviewLabels( + { ...preview.identity, example: preview.from }, + peerIdentities, + ).example + : ""; shell.selectionReplayRow.hidden = preview.replaySteps.length === 0; shell.selectionReplay.textContent = preview.replaySteps.join(" → "); renderSurfaceHierarchy(preview); @@ -1242,6 +1305,17 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { const status = preview.default ? ["Default"] : []; status.push(preview.inFlight > 0 ? `${preview.inFlight} in flight` : "Settled"); shell.selectionStatus.textContent = status.join(" · "); + if (preview.evidence) { + renderInspectionRows( + document, + shell.selectionEvidence, + previewEvidenceRows(preview.evidence), + ); + shell.selectionEvidenceBlock.hidden = false; + } else { + shell.selectionEvidence.replaceChildren(); + shell.selectionEvidenceBlock.hidden = true; + } renderData(preview); shell.selectionNoteBlock.hidden = !preview.note; shell.selectionNote.textContent = preview.note ?? ""; @@ -1266,8 +1340,8 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { })); shell.selectionNoInteractions.hidden = preview.interactions.length > 0; shell.selectionAnnouncement.textContent = focused - ? `${preview.identity.subject} / ${preview.identity.example} focused; details updated.` - : `${preview.identity.subject} / ${preview.identity.example} selected; details updated.`; + ? `${labels.combined} focused; details updated.` + : `${labels.combined} selected; details updated.`; }; const clearSelection = (): void => { @@ -1295,6 +1369,8 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { shell.selectionHierarchyBlock.hidden = true; shell.selectionWorkflow.replaceChildren(); shell.selectionWorkflowBlock.hidden = true; + shell.selectionEvidence.replaceChildren(); + shell.selectionEvidenceBlock.hidden = true; shell.selectionAnnouncement.textContent = ""; }; @@ -1323,8 +1399,15 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { // right edge, incoming arrives muted at the left edge, and presents // leave the bottom edge (or arrive at a selected surface's top edge). activeStructureConnectors = layoutStructureConnectors( - visibleStructureConnectors(model.structureConnectors, preview.identity), - { node: structureDefinitionNode(preview.identity), previewId }, + visibleStructureConnectors(model.structureConnectors, { + ...preview.identity, + previewId, + }), + { + node: logicalRoutePreviewNode(previewId), + aliases: [structureDefinitionNode(preview.identity)], + previewId, + }, ); // Active structural arrows lift the whole connector layer above the // preview rows so edge label pills and arrowheads never clip behind a @@ -1452,6 +1535,59 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { stat(document, "Flows", previews.filter((preview) => preview.from !== null).length), stat(document, "Assets", Object.keys(render?.assets ?? {}).length), ); + if (render?.machine) { + const inspection = inspectMachine(render.machine); + renderInspectionRows(document, shell.overviewMachineIdentity, inspection.identity); + shell.overviewMachineIdentity.hidden = inspection.identity.length === 0; + renderInspectionRows( + document, + shell.overviewMachineMetrics, + machineMetricRows(inspection), + ); + renderInspectionRows( + document, + shell.overviewMachineOwnership, + inspection.ownership, + ); + shell.overviewMachineOwnership.hidden = inspection.ownership.length === 0; + const ownershipHeading = shell.overviewMachineOwnership + .previousElementSibling as HTMLElement | null; + if (ownershipHeading) ownershipHeading.hidden = inspection.ownership.length === 0; + renderInspectionRows( + document, + shell.overviewMachineOutcomes, + inspection.outcomes, + ); + shell.overviewMachineOutcomes.hidden = inspection.outcomes.length === 0; + const outcomeHeading = shell.overviewMachineOutcomes + .previousElementSibling as HTMLElement | null; + if (outcomeHeading) outcomeHeading.hidden = inspection.outcomes.length === 0; + renderInspectionRows( + document, + shell.overviewMachineDependencies, + inspection.dependencies, + ); + shell.overviewMachineDependencies.hidden = inspection.dependencies.length === 0; + const dependencyHeading = shell.overviewMachineDependencies + .previousElementSibling as HTMLElement | null; + if (dependencyHeading) dependencyHeading.hidden = inspection.dependencies.length === 0; + shell.overviewMachineStatus.dataset.tone = inspection.status; + shell.overviewMachineStatus.textContent = { + passed: "Evidence passed", + failed: "Evidence failed", + unknown: "Evidence unavailable", + }[inspection.status]; + shell.overviewMachineBlock.hidden = false; + } else { + shell.overviewMachineIdentity.replaceChildren(); + shell.overviewMachineMetrics.replaceChildren(); + shell.overviewMachineOwnership.replaceChildren(); + shell.overviewMachineOutcomes.replaceChildren(); + shell.overviewMachineDependencies.replaceChildren(); + shell.overviewMachineStatus.textContent = ""; + delete shell.overviewMachineStatus.dataset.tone; + shell.overviewMachineBlock.hidden = true; + } const callout = shell.overviewCallout.querySelector("p"); if (callout) { callout.textContent = render?.freshness === "stale" @@ -1626,7 +1762,10 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { return; } let icons: IconFontRegistry | undefined; - if (decision.state.render) { + const needsIcons = decision.state.render?.previews.some((preview) => + projectionNeedsIconFonts(preview.content.value.document.nodes) + ) ?? false; + if (decision.state.render && needsIcons) { const iconResponse = await window.fetch(EDITOR_ICON_FONTS_PATH, { headers: { Accept: "application/json" }, cache: "no-store", diff --git a/web/src/editor/machine-inspection.ts b/web/src/editor/machine-inspection.ts new file mode 100644 index 0000000..f75332b --- /dev/null +++ b/web/src/editor/machine-inspection.ts @@ -0,0 +1,264 @@ +import type { + EditorMachine, + JsonValue, + PreviewEvidence, +} from "./editor-state.js"; + +export interface InspectionRow { + label: string; + value: string; +} + +export interface MachineInspection { + identity: InspectionRow[]; + status: "passed" | "failed" | "unknown"; + passes: number; + failures: number; + checkpoints: number; + sources: number; + ownership: InspectionRow[]; + outcomes: InspectionRow[]; + dependencies: InspectionRow[]; +} + +type JsonRecord = Record; + +interface InspectionGraphNode { + id: string; + kind: string; + machine: string; + label: string; +} + +interface InspectionGraphEdge { + from: string; + to: string; + kind: string; +} + +const jsonRecord = (value: JsonValue | undefined): JsonRecord | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : null; + +const nonEmptyString = (value: JsonValue | undefined): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +const collectionSize = (value: JsonValue): number => { + if (Array.isArray(value)) return value.length; + const object = jsonRecord(value); + return object ? Object.keys(object).length : 0; +}; + +const graphNodes = (value: JsonValue): InspectionGraphNode[] => { + const nodes = jsonRecord(value)?.["nodes"]; + if (!Array.isArray(nodes)) return []; + return nodes.flatMap((value) => { + const node = jsonRecord(value); + const id = nonEmptyString(node?.["id"]); + const kind = nonEmptyString(node?.["kind"]); + const machine = nonEmptyString(node?.["machine"]); + const label = nonEmptyString(node?.["label"]); + return id && kind && machine && label + ? [{ id, kind, machine, label }] + : []; + }); +}; + +const graphEdges = (value: JsonValue): InspectionGraphEdge[] => { + const edges = jsonRecord(value)?.["edges"]; + if (!Array.isArray(edges)) return []; + return edges.flatMap((value) => { + const edge = jsonRecord(value); + const from = nonEmptyString(edge?.["from"]); + const to = nonEmptyString(edge?.["to"]); + const kind = nonEmptyString(edge?.["kind"]); + return from && to && kind ? [{ from, to, kind }] : []; + }); +}; + +const memberSummary = ( + ids: ReadonlySet, + nodes: ReadonlyMap, +): string => { + const counts = new Map(); + for (const id of ids) { + const kind = nodes.get(id)?.kind; + if ( + kind === "state" + || kind === "computed" + || kind === "invariant" + || kind === "update" + || kind === "observation" + ) { + counts.set(kind, (counts.get(kind) ?? 0) + 1); + } + } + return ["state", "computed", "invariant", "update", "observation"] + .flatMap((kind) => { + const count = counts.get(kind) ?? 0; + return count > 0 ? [`${count} ${kind}${count === 1 ? "" : "s"}`] : []; + }) + .join(" · "); +}; + +const ownershipRows = ( + graph: JsonValue, + machine: string | null, +): InspectionRow[] => { + const allNodes = graphNodes(graph); + const nodes = allNodes.filter((node) => machine === null || node.machine === machine); + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const edges = graphEdges(graph).filter((edge) => + nodeById.has(edge.from) && nodeById.has(edge.to) + ); + const modules = nodes + .filter((node) => node.kind === "module") + .map((node) => node.label) + .filter((label, index, labels) => labels.indexOf(label) === index) + .sort(); + const parts = nodes.filter((node) => node.kind === "part") + .sort((left, right) => left.label.localeCompare(right.label)); + const partOwned = new Set( + edges + .filter((edge) => + edge.kind === "owns" && nodeById.get(edge.from)?.kind === "part" + ) + .map((edge) => edge.to), + ); + const machineOwned = new Set( + nodes + .filter((node) => + ["state", "computed", "invariant", "update", "observation"].includes(node.kind) + && !partOwned.has(node.id) + ) + .map((node) => node.id), + ); + + return [ + ...modules.map((value) => ({ label: "Module", value })), + ...(machineOwned.size > 0 + ? [{ label: "Machine-owned", value: memberSummary(machineOwned, nodeById) }] + : []), + ...parts.map((part) => { + const owned = new Set( + edges + .filter((edge) => edge.kind === "owns" && edge.from === part.id) + .map((edge) => edge.to), + ); + return { + label: `Part ${part.label}`, + value: memberSummary(owned, nodeById) || "No stateful members", + }; + }), + ]; +}; + +const outcomeRows = ( + graph: JsonValue, + machine: string | null, +): InspectionRow[] => { + const policies = jsonRecord(jsonRecord(graph)?.["outcome_policies"]); + if (policies === null) return []; + return graphNodes(graph) + .filter((node) => + node.kind === "outcome" + && (machine === null || node.machine === machine) + ) + .flatMap((node) => { + const policy = nonEmptyString(policies[node.id]); + return policy === "commit" || policy === "abort" + ? [{ label: `Outcome ${node.label}`, value: policy }] + : []; + }) + .sort((left, right) => left.label.localeCompare(right.label)); +}; + +const dependencyRows = ( + graph: JsonValue, + machine: string | null, +): InspectionRow[] => { + const nodes = graphNodes(graph) + .filter((node) => machine === null || node.machine === machine); + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const edges = graphEdges(graph) + .filter((edge) => + ["reads", "calls", "observes"].includes(edge.kind) + && nodeById.has(edge.from) + && nodeById.has(edge.to) + ); + return [ + { kind: "reads", label: "Reads" }, + { kind: "calls", label: "Calls" }, + { kind: "observes", label: "Observes" }, + ].flatMap(({ kind, label }) => { + const matches = edges.filter((edge) => edge.kind === kind); + if (matches.length === 0) return []; + const examples = matches.slice(0, 2).map((edge) => + `${nodeById.get(edge.from)!.label} → ${nodeById.get(edge.to)!.label}` + ); + return [{ + label, + value: `${matches.length} · ${examples.join(", ")}${ + matches.length > examples.length ? ", …" : "" + }`, + }]; + }); +}; + +export const inspectMachine = (machine: EditorMachine): MachineInspection => { + const deployment = jsonRecord(machine.deployment); + const entry = nonEmptyString(deployment?.["entry"]); + const machineName = nonEmptyString(deployment?.["machine"]); + const presentation = nonEmptyString(deployment?.["presentation"]); + const evidence = machine.evidence; + + return { + identity: [ + entry ? { label: "Deployment", value: entry } : null, + machineName ? { label: "Machine", value: machineName } : null, + presentation ? { label: "Presentation", value: presentation } : null, + ].filter((row): row is InspectionRow => row !== null), + status: evidence.passed ? "passed" : "failed", + passes: evidence.scenarios.passed, + failures: evidence.failureCount, + checkpoints: evidence.artifacts.checkpoints, + sources: collectionSize(machine.sources), + ownership: ownershipRows(machine.interactionGraph, machineName), + outcomes: outcomeRows(machine.interactionGraph, machineName), + dependencies: dependencyRows(machine.interactionGraph, machineName), + }; +}; + +export const machineMetricRows = ( + inspection: MachineInspection, +): InspectionRow[] => [ + { label: "Passes", value: String(inspection.passes) }, + { label: "Failures", value: String(inspection.failures) }, + { label: "Checkpoints", value: String(inspection.checkpoints) }, + { label: "Sources", value: String(inspection.sources) }, +]; + +export const previewEvidenceRows = ( + evidence: PreviewEvidence, +): InspectionRow[] => [ + { label: "Scenario", value: evidence.scenario }, + { label: "Pin", value: evidence.pin }, + { label: "Source", value: evidence.sourceId }, +]; + +export const renderInspectionRows = ( + document: Document, + root: HTMLElement, + rows: readonly InspectionRow[], +): void => { + root.replaceChildren(...rows.map((row) => { + const group = document.createElement("div"); + const term = document.createElement("dt"); + term.textContent = row.label; + const description = document.createElement("dd"); + description.textContent = row.value; + group.append(term, description); + return group; + })); +}; diff --git a/web/src/editor/structure-connectors.ts b/web/src/editor/structure-connectors.ts index 1bff3ed..43d51db 100644 --- a/web/src/editor/structure-connectors.ts +++ b/web/src/editor/structure-connectors.ts @@ -12,12 +12,14 @@ export type StructureConnectorKind = "navigate" | "present"; export interface StructureDefinition { kind: string; subject: string; + /** Exact preview selected when the graph models a logical route state. */ + previewId?: string; } /** One deduplicated structural edge between two board frames. */ export interface StructureConnector { kind: StructureConnectorKind; - /** The `page:`/`surface:` graph node behind each endpoint. */ + /** Definition or `preview:` graph identity behind each endpoint. */ sourceNode: string; targetNode: string; sourceId: string; @@ -29,9 +31,9 @@ export interface StructureConnector { } /** - * Maps `page:`/`surface:` graph nodes to the first board frame - * that previews the same definition. Command and dynamic nodes, and - * definitions without previews, have no frame and draw nothing. + * Maps definition nodes to their first board frame and preview-backed logical + * route nodes to their exact frame. Command and dynamic nodes, and graph + * identities without previews, have no frame and draw nothing. */ const frameIdByGraphNode = ( previews: readonly EditorPreview[], @@ -42,10 +44,15 @@ const frameIdByGraphNode = ( if (kind !== "page" && kind !== "surface") continue; const nodeId = `${kind}:${preview.identity.subject}`; if (!frames.has(nodeId)) frames.set(nodeId, preview.id); + frames.set(logicalRoutePreviewNode(preview.id), preview.id); } return frames; }; +/** Graph identity for one preview-backed logical route state. */ +export const logicalRoutePreviewNode = (previewId: string): string => + `preview:${previewId}`; + const compareStrings = (left: readonly string[], right: readonly string[]): number => { for (let index = 0; index < left.length; index += 1) { if (left[index]! < right[index]!) return -1; @@ -107,19 +114,25 @@ export const buildStructureConnectors = ( export const structureDefinitionNode = (definition: StructureDefinition): string => `${definition.kind}:${definition.subject}`; +const structureSelectionNodes = (definition: StructureDefinition): Set => + new Set([ + structureDefinitionNode(definition), + ...(definition.previewId ? [logicalRoutePreviewNode(definition.previewId)] : []), + ]); + /** * Figma-style selection scoping: with no selection nothing structural draws; - * with a selected preview only the connectors entering or leaving that - * preview's definition (kind + subject) remain. + * with a selected preview only the connectors entering or leaving either its + * definition (kind + subject) or its exact preview-backed route state remain. */ export const visibleStructureConnectors = ( connectors: readonly T[], selected: StructureDefinition | null, ): T[] => { if (!selected) return []; - const node = structureDefinitionNode(selected); + const nodes = structureSelectionNodes(selected); return connectors.filter((connector) => - connector.sourceNode === node || connector.targetNode === node); + nodes.has(connector.sourceNode) || nodes.has(connector.targetNode)); }; export type StructureConnectorDirection = "outgoing" | "incoming"; @@ -127,9 +140,11 @@ export type StructureConnectorDirection = "outgoing" | "incoming"; /** The selected frame's edge a connector fans out on. */ export type StructureEdgeSide = "right" | "left" | "bottom" | "top"; -/** The board frame the user actually clicked, plus its definition node. */ +/** The board frame the user actually clicked, plus its graph identities. */ export interface StructureSelection { node: string; + /** Equivalent definition/logical-state nodes owned by this selection. */ + aliases?: readonly string[]; previewId: string; } @@ -171,10 +186,11 @@ export const layoutStructureConnectors = ( connectors: readonly T[], selected: StructureSelection, ): PlacedStructureConnector[] => { + const selectedNodes = new Set([selected.node, ...(selected.aliases ?? [])]); const classified = connectors .map((connector) => { const direction: StructureConnectorDirection = - connector.sourceNode === selected.node ? "outgoing" : "incoming"; + selectedNodes.has(connector.sourceNode) ? "outgoing" : "incoming"; const farId = direction === "outgoing" ? connector.targetId : connector.sourceId; const farNode = direction === "outgoing" ? connector.targetNode : connector.sourceNode; return { diff --git a/web/src/editor/surface-hierarchy.ts b/web/src/editor/surface-hierarchy.ts index e4b1cbd..10440ab 100644 --- a/web/src/editor/surface-hierarchy.ts +++ b/web/src/editor/surface-hierarchy.ts @@ -1,145 +1,128 @@ -import type { SurfaceView } from "../protocol/types.js"; -import type { EditorPreview, JsonValue } from "./editor-state.js"; +import type { RenderNode } from "../renderer/projection.js"; +import type { EditorPreview } from "./editor-state.js"; export interface MountedSurface { + /** Stable semantic identity from the canonical projection. */ key: string; + /** Best available authored label, falling back to the semantic key. */ definition: string; + /** Honest rendered modality; canonical Surface currently projects to dialog. */ modality: string; + /** Deterministic pre-order among every mounted surface in this projection. */ stackIndex: number; - relation: "direct" | "inherited" | "mounted"; + /** Comparison with the direct evidence parent, when one exists. */ + relation: "introduced" | "retained" | "present"; } export interface SurfaceHierarchyNode { surface: MountedSurface; + /** Nearest containing surface key, not an inferred runtime opener. */ opener: string | null; children: SurfaceHierarchyNode[]; } export interface SurfaceHierarchy { - page: string; - /** Bottom-to-top mounted surface order. */ + presentation: string; + /** Deterministic pre-order over canonical `surface: true` nodes. */ surfaces: MountedSurface[]; - /** Opener-derived parent/child forest rooted at the current page. */ + /** Render-tree containment forest. */ roots: SurfaceHierarchyNode[]; } -const stringField = (value: JsonValue, field: string): string | null => { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - const candidate = value[field]; - return typeof candidate === "string" ? candidate : null; +const textAttribute = ( + node: Extract, + names: readonly string[], +): string | null => { + for (const name of names) { + const value = node.attributes.find((attribute) => attribute.name === name)?.value; + if (typeof value === "string" && value.length > 0) return value; + } + return null; }; -interface SurfaceOpen { - surface: string; - opener: string | null; -} - -const openedSurfaces = (preview: EditorPreview): SurfaceOpen[] => - preview.replay.flatMap((step) => step.effects.structural.flatMap((effect) => { - if (stringField(effect, "op") !== "open-surface") return []; - const surface = stringField(effect, "surface"); - const opener = stringField(effect, "opener"); - return surface === null ? [] : [{ surface, opener }]; - })); - -const previewLineage = ( - preview: EditorPreview, - relatedPreviews: readonly EditorPreview[], -): EditorPreview[] => { - const byExample = new Map( - relatedPreviews - .filter((candidate) => - candidate.identity.kind === preview.identity.kind - && candidate.identity.subject === preview.identity.subject) - .map((candidate) => [candidate.identity.example, candidate] as const), - ); - byExample.set(preview.identity.example, preview); - - const lineage: EditorPreview[] = []; - const visited = new Set(); - let current: EditorPreview | undefined = preview; - while (current && !visited.has(current.id)) { - lineage.unshift(current); - visited.add(current.id); - current = current.from === null ? undefined : byExample.get(current.from); - } - return lineage; +const surfaceKeys = (nodes: readonly RenderNode[]): Set => { + const keys = new Set(); + const visit = (node: RenderNode): void => { + if (node.kind !== "element") return; + if (node.surface) keys.add(node.key); + node.children.forEach(visit); + }; + nodes.forEach(visit); + return keys; }; -const inheritedOpeners = ( +const parentPreview = ( preview: EditorPreview, relatedPreviews: readonly EditorPreview[], -): Map => { - const openerBySurface = new Map(); - for (const ancestor of previewLineage(preview, relatedPreviews)) { - for (const step of ancestor.replay) { - for (const effect of step.effects.structural) { - const op = stringField(effect, "op"); - const surface = stringField(effect, "surface"); - if (op === "open-surface") { - const opener = stringField(effect, "opener"); - if (surface !== null && opener !== null) openerBySurface.set(surface, opener); - } else if ((op === "dismiss" || op === "force-close") && surface !== null) { - openerBySurface.delete(surface); - } - } - } - } - return openerBySurface; +): EditorPreview | null => { + if (preview.from === null) return null; + return relatedPreviews.find((candidate) => + candidate.identity.kind === preview.identity.kind + && candidate.identity.subject === preview.identity.subject + && candidate.identity.example === preview.from + ) ?? null; }; -const mountedSurface = ( - surface: SurfaceView, - stackIndex: number, - directlyOpened: ReadonlySet, - hasReplayParent: boolean, -): MountedSurface => ({ - key: surface.key, - definition: surface.definition, - modality: surface.modality, - stackIndex, - relation: directlyOpened.has(surface.key) - ? "direct" - : hasReplayParent - ? "inherited" - : "mounted", -}); - export const surfaceHierarchy = ( preview: EditorPreview, relatedPreviews: readonly EditorPreview[] = [preview], ): SurfaceHierarchy | null => { - if (!("protocol" in preview.content) || preview.content.protocol !== "uhura-view/0") return null; - const directlyOpened = new Set(openedSurfaces(preview).map(({ surface }) => surface)); - const surfaces = preview.content.surfaces.map((surface, index) => - mountedSurface(surface, index, directlyOpened, preview.from !== null)); - const nodeByKey = new Map(); - const nodeByScope = new Map(); - for (const [index, surface] of preview.content.surfaces.entries()) { - const node: SurfaceHierarchyNode = { - surface: surfaces[index]!, - opener: null, - children: [], - }; - nodeByKey.set(surface.key, node); - nodeByScope.set(surface.dismiss.scope, node); - } - - const openerBySurface = inheritedOpeners(preview, relatedPreviews); + const document = preview.content.value.document; + const parent = parentPreview(preview, relatedPreviews); + const retainedKeys = parent === null + ? null + : surfaceKeys(parent.content.value.document.nodes); + const surfaces: MountedSurface[] = []; const roots: SurfaceHierarchyNode[] = []; - for (const surface of surfaces) { - const node = nodeByKey.get(surface.key)!; - node.opener = openerBySurface.get(surface.key) ?? null; - const parent = node.opener === null ? undefined : nodeByScope.get(node.opener); - if (parent && parent.surface.stackIndex < surface.stackIndex) parent.children.push(node); - else roots.push(node); - } + + const visit = ( + nodes: readonly RenderNode[], + parent: SurfaceHierarchyNode | null, + ): void => { + for (const node of nodes) { + if (node.kind !== "element") continue; + let childParent = parent; + if (node.surface) { + const surface: MountedSurface = { + key: node.key, + definition: textAttribute(node, ["aria-label", "title", "name", "id"]) + ?? `Surface ${surfaces.length + 1}`, + modality: textAttribute(node, ["data-modality", "role"]) ?? node.element, + stackIndex: surfaces.length, + relation: retainedKeys === null + ? "present" + : retainedKeys.has(node.key) + ? "retained" + : "introduced", + }; + const hierarchyNode: SurfaceHierarchyNode = { + surface, + opener: parent?.surface.key ?? null, + children: [], + }; + surfaces.push(surface); + if (parent === null) roots.push(hierarchyNode); + else parent.children.push(hierarchyNode); + childParent = hierarchyNode; + } + visit(node.children, childParent); + } + }; + + visit(document.nodes, null); + if (surfaces.length === 0) return null; return { - page: preview.content.page.route, + presentation: document.presentation, surfaces, roots, }; }; -export const directlyOpenedSurfaces = (preview: EditorPreview): MountedSurface[] => - surfaceHierarchy(preview)?.surfaces.filter((surface) => surface.relation === "direct") ?? []; +/** Surfaces present in a derived projection but absent from its direct parent. */ +export const introducedSurfaces = ( + preview: EditorPreview, + relatedPreviews: readonly EditorPreview[] = [preview], +): MountedSurface[] => + surfaceHierarchy(preview, relatedPreviews)?.surfaces.filter( + (surface) => surface.relation === "introduced", + ) ?? []; diff --git a/web/src/editor/tests/annotation-overlay.test.ts b/web/src/editor/tests/annotation-overlay.test.ts index 6fd8930..380de1e 100644 --- a/web/src/editor/tests/annotation-overlay.test.ts +++ b/web/src/editor/tests/annotation-overlay.test.ts @@ -5,18 +5,18 @@ import type { PreparedAuthoring, PreviewOccurrence } from "../editor-authoring.j import { AnnotationOverlay, composedParent, + renderSourcePanel, validateAnnotationRealizations, } from "../annotation-overlay.js"; import { RealizationResources } from "../editor-realization.js"; import type { - RenderNodeRef, SourceMetadataEntry, SourceTarget, } from "../editor-state.js"; const sourceTarget: SourceTarget = { id: "target", - class: "catalog-element", + class: "ui-element", file: "example.uhura", span: { offset: 0, @@ -28,7 +28,7 @@ const sourceTarget: SourceTarget = { owner: { kind: "component", name: "card" }, }; -const anchor: RenderNodeRef = { root: { kind: "fragment" }, path: [0, 2] }; +const anchor = "primary-action"; const occurrence: PreviewOccurrence = { previewId: "preview", occurrence: { id: "occurrence", targetId: sourceTarget.id, anchors: [anchor] }, @@ -142,6 +142,14 @@ class OverlayTestElement { this.#listeners.set(type, current); } + click(): void { + const event = { type: "click", stopPropagation: () => {} } as Event; + for (const listener of this.#listeners.get("click") ?? []) { + if (typeof listener === "function") listener.call(this, event); + else listener.handleEvent(event); + } + } + removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { const current = this.#listeners.get(type); if (!current) return; @@ -214,7 +222,7 @@ const overlayElements = ( test("validates every protocol anchor against its direct realization registry", () => { const resources = new RealizationResources(); resources.claim({}); - resources.register({ root: anchor.root, path: anchor.path, element: {} as HTMLElement }); + resources.registerKey(anchor, {} as HTMLElement); assert.doesNotThrow(() => validateAnnotationRealizations({ render: null, authoring, @@ -229,7 +237,7 @@ test("validates every protocol anchor against its direct realization registry", authoring, resourcesByPreviewId: new Map([[occurrence.previewId, incomplete]]), }), - /internal error.*target.*occurrence.*preview.*fragment\|0\.2.*did not register/s, + /internal error.*target.*occurrence.*preview.*key\|primary-action.*did not register/s, ); assert.throws( () => validateAnnotationRealizations({ @@ -241,6 +249,76 @@ test("validates every protocol anchor against its direct realization registry", ); }); +test("presents entryless projection provenance as navigation without annotation UI", () => { + const document = new OverlayTestDocument(); + const viewport = document.createElement("div"); + const overlayRoot = document.createElement("div"); + const panel = document.createElement("div"); + const realized = document.createElement("h1"); + const projectionAnchor = "heading"; + const projectionOccurrence: PreviewOccurrence = { + previewId: "page/ready", + occurrence: { + id: "occurrence/heading", + targetId: sourceTarget.id, + anchors: [projectionAnchor], + }, + }; + const projectionAuthoring: PreparedAuthoring = { + targetsById: new Map([[sourceTarget.id, sourceTarget]]), + entriesById: new Map(), + entriesByTarget: new Map(), + occurrencesByTarget: new Map([[sourceTarget.id, [projectionOccurrence]]]), + annotationTargets: [], + documentedTargets: [], + }; + const resources = new RealizationResources(); + resources.claim({}); + resources.registerKey(projectionAnchor, realized as unknown as HTMLElement); + let focusedPreview: string | null = null; + let focusedAnchors: readonly HTMLElement[] | undefined; + const focusedSources: string[] = []; + const overlay = new AnnotationOverlay({ + viewport: viewport as unknown as HTMLElement, + root: overlayRoot as unknown as HTMLElement, + focusPreview: (previewId, anchors) => { + focusedPreview = previewId; + focusedAnchors = anchors; + }, + focusSourceTarget: (targetId) => focusedSources.push(targetId), + }); + overlay.install({ + render: null, + authoring: projectionAuthoring, + resourcesByPreviewId: new Map([[projectionOccurrence.previewId, resources]]), + }); + + renderSourcePanel( + panel as unknown as HTMLElement, + projectionAuthoring, + false, + (targetId) => { + assert.equal(overlay.selectSourceTarget(targetId), true); + }, + ); + const entries = overlayElements(panel, "source-entry"); + const actions = overlayElements(panel, "source-target-select"); + assert.equal(entries.length, 1, "occurrence-backed targets appear in Source without metadata"); + assert.equal(actions.length, 1); + assert.equal(actions[0]?.textContent, "Show"); + assert.equal(actions[0]?.getAttribute("aria-label"), `Show ${sourceTarget.label} on canvas`); + assert.equal(overlayElements(panel, "annotation-entry").length, 0); + assert.equal(overlayElements(overlayRoot, "annotation-marker").length, 0); + assert.equal(overlayElements(overlayRoot, "annotation-card").length, 0); + + actions[0]?.click(); + assert.equal(focusedPreview, projectionOccurrence.previewId); + assert.deepEqual(focusedAnchors, [realized]); + assert.deepEqual(focusedSources, [sourceTarget.id]); + + overlay.dispose(); +}); + test("composed parent traversal reaches a ShadowRoot host", () => { const host = { nodeType: 1, parentNode: null } as unknown as Node; const shadow = { nodeType: 11, parentNode: null, host } as unknown as Node; @@ -367,18 +445,10 @@ test("focused preview filters presentation without becoming annotation selection }; const firstResources = new RealizationResources(); firstResources.claim({}); - firstResources.register({ - root: anchor.root, - path: anchor.path, - element: firstTarget as unknown as HTMLElement, - }); + firstResources.registerKey(anchor, firstTarget as unknown as HTMLElement); const secondResources = new RealizationResources(); secondResources.claim({}); - secondResources.register({ - root: anchor.root, - path: anchor.path, - element: secondTarget as unknown as HTMLElement, - }); + secondResources.registerKey(anchor, secondTarget as unknown as HTMLElement); const overlay = new AnnotationOverlay({ viewport: viewport as unknown as HTMLElement, root: root as unknown as HTMLElement, @@ -490,7 +560,7 @@ test("focused preview expands one collision-aware card per annotated target", () id: "target/secondary", label: "secondary button", }; - const secondAnchor: RenderNodeRef = { root: { kind: "fragment" }, path: [0, 4] }; + const secondAnchor = "secondary-action"; const entries: SourceMetadataEntry[] = [ { id: "annotation/first", @@ -554,16 +624,8 @@ test("focused preview expands one collision-aware card per annotated target", () }; const resources = new RealizationResources(); resources.claim({}); - resources.register({ - root: anchor.root, - path: anchor.path, - element: firstElement as unknown as HTMLElement, - }); - resources.register({ - root: secondAnchor.root, - path: secondAnchor.path, - element: secondElement as unknown as HTMLElement, - }); + resources.registerKey(anchor, firstElement as unknown as HTMLElement); + resources.registerKey(secondAnchor, secondElement as unknown as HTMLElement); const overlay = new AnnotationOverlay({ viewport: viewport as unknown as HTMLElement, root: root as unknown as HTMLElement, diff --git a/web/src/editor/tests/display-labels.test.ts b/web/src/editor/tests/display-labels.test.ts new file mode 100644 index 0000000..d7d9940 --- /dev/null +++ b/web/src/editor/tests/display-labels.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + editorIdentifierLabel, + editorPreviewLabels, + editorSubjectLabel, +} from "../display-labels.js"; +import type { PreviewIdentity } from "../editor-state.js"; + +const identity = ( + kind: PreviewIdentity["kind"], + subject: string, + example: string, +): PreviewIdentity => ({ kind, subject, example }); + +test("qualified 0.4 page and evidence identities become compact Editor labels", () => { + const preview = identity( + "page", + "app.instagram@1::FeedPage", + "app.instagram.evidence@1::feed_first_page", + ); + + assert.deepEqual(editorPreviewLabels(preview), { + subject: "feed", + example: "first-page", + combined: "feed / first-page", + }); + assert.deepEqual(preview, { + kind: "page", + subject: "app.instagram@1::FeedPage", + example: "app.instagram.evidence@1::feed_first_page", + }, "display derivation never mutates semantic identity"); +}); + +test("identifier labels humanize PascalCase, acronyms, and snake case", () => { + assert.equal(editorIdentifierLabel("app.example@2::HTTPStatusCard"), "http-status-card"); + assert.equal(editorIdentifierLabel("app.example@2::save_in_flight"), "save-in-flight"); + assert.equal( + editorSubjectLabel({ kind: "surface", subject: "app.example@2::CommentsSheet" }), + "comments-sheet", + ); + assert.equal( + editorSubjectLabel({ kind: "component", subject: "app.example@2::LandingPage" }), + "landing-page", + "Page is semantic for non-page subjects", + ); +}); + +test("page suffix removal is total for a subject named only Page", () => { + assert.equal( + editorSubjectLabel({ kind: "page", subject: "app.example@2::Page" }), + "page", + ); +}); + +test("example prefixes are retained when shortening would collide", () => { + const prefixed = identity( + "page", + "app.example@1::FeedPage", + "app.example.evidence@1::feed_first_page", + ); + const alreadyShort = identity( + "page", + "app.example@1::FeedPage", + "app.example.evidence@1::first_page", + ); + const peers = [prefixed, alreadyShort]; + + assert.equal(editorPreviewLabels(prefixed, peers).example, "feed-first-page"); + assert.equal(editorPreviewLabels(alreadyShort, peers).example, "first-page"); +}); + +test("only an exact subject-token prefix is removed", () => { + const preview = identity( + "page", + "app.example@1::FeedPage", + "app.example.evidence@1::feedback_default", + ); + assert.equal(editorPreviewLabels(preview).example, "feedback-default"); +}); diff --git a/web/src/editor/tests/editor-authoring.test.ts b/web/src/editor/tests/editor-authoring.test.ts index 6c55499..2776c62 100644 --- a/web/src/editor/tests/editor-authoring.test.ts +++ b/web/src/editor/tests/editor-authoring.test.ts @@ -18,6 +18,7 @@ import { renderedOccurrences, sourceActionsEnabled, } from "../editor-authoring.js"; +import { projectionContent } from "./fixtures/projection.js"; const span = (offset: number): EditorSourceSpan => ({ offset, @@ -75,15 +76,16 @@ const preview = (entries: { id: string; targetId: string; anchored: boolean }[]) occurrences: entries.map((item) => ({ id: item.id, targetId: item.targetId, - anchors: item.anchored ? [{ root: { kind: "fragment" }, path: [] }] : [], + anchors: item.anchored ? ["root"] : [], })), }, - content: { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }); const render = (): EditorRender => { const targets = [ - target("annotation", "z.uhura", 5, "catalog-element"), + target("annotation", "z.uhura", 5, "ui-element"), target( "declaration", "a.uhura", @@ -113,6 +115,7 @@ const render = (): EditorRender => { stylesheet: "", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }; }; diff --git a/web/src/editor/tests/editor-board.test.ts b/web/src/editor/tests/editor-board.test.ts index 0341d77..5f95f0b 100644 --- a/web/src/editor/tests/editor-board.test.ts +++ b/web/src/editor/tests/editor-board.test.ts @@ -17,11 +17,11 @@ import { import type { EditorRender, PreviewFreshness, - RenderNodeRef, SourceMetadataEntry, SourceTarget, } from "../editor-state.js"; import type { IconFontRegistry } from "../../renderer/icons.js"; +import { elementNode, projectionContent, textNode } from "./fixtures/projection.js"; const TEST_ICONS: IconFontRegistry = { defaultFamily: "lucide", @@ -170,6 +170,16 @@ class FakeContainer extends FakeNode { } } +class FakeText extends FakeNode { + data: string; + __uhuraKey?: string; + + constructor(ownerDocument: FakeDocument, data: string) { + super(ownerDocument, 3); + this.data = data; + } +} + class FakeShadowRoot extends FakeContainer { adoptedStyleSheets: FakeStyleSheet[] = []; readonly host: FakeElement; @@ -190,6 +200,7 @@ class FakeElement extends FakeContainer { readonly dataset: Record = {}; readonly style = new FakeStyle(); readonly tagName: string; + readonly localName: string; readonly #listeners = new Map(); shadowRoot: FakeShadowRoot | null = null; className = ""; @@ -207,6 +218,7 @@ class FakeElement extends FakeContainer { constructor(ownerDocument: FakeDocument, tagName: string) { super(ownerDocument, 1); this.tagName = tagName.toUpperCase(); + this.localName = tagName.toLowerCase(); this.classList = new FakeClassList(this); } @@ -290,6 +302,10 @@ class FakeDocument { return this.createElement(tagName); } + createTextNode(data: string): FakeText { + return new FakeText(this, data); + } + createDocumentFragment(): FakeDocumentFragment { return new FakeDocumentFragment(this); } @@ -298,7 +314,7 @@ class FakeDocument { const asDocument = (document: FakeDocument): Document => document as unknown as Document; const asElement = (element: FakeElement): HTMLElement => element as unknown as HTMLElement; -const anchor: RenderNodeRef = { root: { kind: "fragment" }, path: [] }; +const anchor = "root"; const span = { offset: 24, @@ -309,7 +325,7 @@ const span = { const target: SourceTarget = { id: "target:button", - class: "catalog-element", + class: "ui-element", file: "components/card.uhura", span, label: "button", @@ -363,15 +379,15 @@ const render = ( anchors: [anchor], }], }, - content: { - key: "root", - element: "text", - props: { content: { t: "plain", v: "Stable semantic content" } }, - }, + evidence: null, + content: projectionContent([ + elementNode("root", [textNode("content", "Stable semantic content")]), + ]), }], stylesheet: ":root { --accent: blue; } body { color: black; }", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }); const annotationText = (model: PreparedEditorModel): string | undefined => @@ -567,6 +583,64 @@ test("caption chrome can replace while its semantic ShadowRoot stays exact", () disposePreparedEditorModel(captionUpdate); }); +test("0.4 public identities keep semantic joins but render friendly board labels", () => { + const document = new FakeDocument(); + const qualified = render(1, "current", "Friendly labels"); + const group = qualified.groups[0]!; + const preview = qualified.previews[0]!; + group.id = "page:app.instagram@1::FeedPage"; + group.kind = "page"; + group.subject = "app.instagram@1::FeedPage"; + group.previews = ["feed/first-page"]; + preview.id = "feed/first-page"; + preview.identity = { + kind: "page", + subject: "app.instagram@1::FeedPage", + example: "app.instagram.evidence@1::feed_first_page", + }; + preview.sourceFile = "ui.uhura"; + + const model = prepareEditorModel(asDocument(document), qualified, null, TEST_ICONS); + const board = model.board as unknown as FakeElement; + const navigator = model.navigator as unknown as FakeDocumentFragment; + + assert.deepEqual( + classElements(board, "row-title").map((node) => node.textContent), + ["page feed"], + ); + assert.deepEqual( + classElements(board, "caption-title").map((node) => node.textContent), + ["feed / first-page"], + ); + assert.deepEqual( + navigator.descendants() + .filter((node) => node.classList.contains("navigator-row-title")) + .map((node) => node.textContent), + ["feed"], + ); + assert.deepEqual( + navigator.descendants() + .filter((node) => node.classList.contains("navigator-frame-title")) + .map((node) => node.textContent), + ["first-page"], + ); + const search = navigator.descendants() + .find((node) => node.classList.contains("navigator-frame")) + ?.dataset.search ?? ""; + assert.match(search, /app\.instagram@1::feedpage/); + assert.match(search, /app\.instagram\.evidence@1::feed_first_page/); + assert.equal( + model.previewIdByIdentity.get(JSON.stringify([ + "page", + "app.instagram@1::FeedPage", + "app.instagram.evidence@1::feed_first_page", + ])), + "feed/first-page", + ); + + disposePreparedEditorModel(model); +}); + test("all rendered occurrences keep one badge while preview selection only decorates them", () => { const document = new FakeDocument(); const root = document.createElement("main"); diff --git a/web/src/editor/tests/editor-focus.test.ts b/web/src/editor/tests/editor-focus.test.ts index 118f9d5..18990f4 100644 --- a/web/src/editor/tests/editor-focus.test.ts +++ b/web/src/editor/tests/editor-focus.test.ts @@ -14,6 +14,7 @@ import type { EditorState, PreviewIdentity, } from "../editor-state.js"; +import { projectionContent } from "./fixtures/projection.js"; const identity = ( subject: string, @@ -36,7 +37,8 @@ const preview = (id: string, previewIdentity: PreviewIdentity): EditorPreview => interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }); const render = (previews: EditorPreview[]): EditorRender => ({ @@ -49,10 +51,11 @@ const render = (previews: EditorPreview[]): EditorRender => ({ stylesheet: "", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }); const state = (value: EditorRender | null): EditorState => ({ - protocol: "uhura-editor-state/2", + protocol: "uhura-editor-state/5", sourceRevision: 1, diagnostics: null, render: value, diff --git a/web/src/editor/tests/editor-icons.test.ts b/web/src/editor/tests/editor-icons.test.ts new file mode 100644 index 0000000..c28016a --- /dev/null +++ b/web/src/editor/tests/editor-icons.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { projectionNeedsIconFonts } from "../editor.js"; +import { + elementNode, + textNode, +} from "./fixtures/projection.js"; + +describe("Editor projection icon resources", () => { + it("loads icon fonts only when a canonical projection contains an icon", () => { + expect(projectionNeedsIconFonts([ + elementNode("root", [ + textNode("copy", "No icon"), + ]), + ])).toBe(false); + + expect(projectionNeedsIconFonts([ + elementNode("root", [ + elementNode("nested", [ + elementNode("heart", [], { element: "icon" }), + ]), + ]), + ])).toBe(true); + }); +}); diff --git a/web/src/editor/tests/editor-realization.test.ts b/web/src/editor/tests/editor-realization.test.ts index f5b86df..b472ba2 100644 --- a/web/src/editor/tests/editor-realization.test.ts +++ b/web/src/editor/tests/editor-realization.test.ts @@ -3,25 +3,33 @@ import { test } from "vitest"; import { RealizationResources } from "../editor-realization.js"; -test("registry resolves direct semantic references and transfers one live owner", () => { +test("registry resolves semantic keys and transfers one live owner", () => { const resources = new RealizationResources(); const firstOwner = {}; const nextOwner = {}; const element = {} as HTMLElement; resources.claim(firstOwner); - resources.register({ root: { kind: "surface", key: "sheet:2" }, path: [1, 0], element }); + resources.registerKey("sheet:2/action", element); - assert.equal( - resources.resolve({ root: { kind: "surface", key: "sheet:2" }, path: [1, 0] }), - element, - ); - assert.equal(resources.resolve({ root: { kind: "surface", key: "sheet:2" }, path: [0] }), null); + assert.equal(resources.resolve("sheet:2/action"), element); + assert.equal(resources.resolve("sheet:2/missing"), null); resources.transfer(firstOwner, nextOwner); resources.release(firstOwner); assert.equal(resources.disposed, false, "the old model cannot dispose a transplanted registry"); resources.release(nextOwner); assert.equal(resources.disposed, true); - assert.equal(resources.resolve({ root: { kind: "surface", key: "sheet:2" }, path: [1, 0] }), null); + assert.equal(resources.resolve("sheet:2/action"), null); +}); + +test("registry resolves semantic keys without ShadowRoot queries", () => { + const resources = new RealizationResources(); + const owner = {}; + const element = {} as HTMLElement; + resources.claim(owner); + resources.registerKey("main/action", element); + + assert.equal(resources.resolve("main/action"), element); + assert.equal(resources.resolve("main/missing"), null); }); test("unused candidate resources dispose independently", () => { @@ -62,7 +70,7 @@ test("watchers move with ownership and release scroll/resize resources", () => { const nextOwner = {}; const resources = new RealizationResources(); resources.claim(firstOwner); - resources.register({ root: { kind: "fragment" }, path: [], element: realized }); + resources.registerKey("root", realized); let firstInvalidations = 0; resources.watch(firstOwner, frame, window, () => { firstInvalidations += 1; }); root.dispatchEvent(new Event("scroll")); diff --git a/web/src/editor/tests/editor-state.test.ts b/web/src/editor/tests/editor-state.test.ts index 732cf9e..d522c86 100644 --- a/web/src/editor/tests/editor-state.test.ts +++ b/web/src/editor/tests/editor-state.test.ts @@ -1,21 +1,15 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { test } from "vitest"; import { decodeEditorRevisionEvent, decodeEditorState, + EDITOR_STATE_PROTOCOL, EditorContractError, type EditorState, } from "../editor-state.js"; -const node = { - key: "root", - element: "text", - props: { content: { t: "plain", v: "Hello" } }, -}; - const span = (offset: number, len: number, line: number, col: number) => ({ offset, len, @@ -35,8 +29,13 @@ const diagnostics = (message: string): Record => ({ }], }); -const stateFixture = (): unknown => ({ - protocol: "uhura-editor-state/2", +const stateFixture = (): { + protocol: string; + sourceRevision: number; + diagnostics: unknown; + render: Record | null; +} => ({ + protocol: "uhura-editor-state/5", sourceRevision: 3, diagnostics: null, render: { @@ -47,37 +46,37 @@ const stateFixture = (): unknown => ({ targets: [{ id: "target:feed", class: "page-declaration", - file: "pages/feed.uhura", + file: "web.uhura", span: span(20, 9, 2, 1), - label: "page feed", - owner: { kind: "page", name: "feed" }, + label: "Feed", + owner: { kind: "page", name: "example@1::Feed" }, }, { id: "target:primary-action", - class: "catalog-element", - file: "pages/feed.uhura", + class: "ui-element", + file: "web.uhura", span: span(80, 10, 6, 1), label: "button", - owner: { kind: "page", name: "feed" }, + owner: { kind: "page", name: "example@1::Feed" }, }, { id: "target:feed-example", class: "example-declaration", - file: "pages/feed.examples.uhura", + file: "evidence.uhura", span: span(20, 15, 2, 1), label: "default", - owner: { kind: "examples", name: "pages/feed.examples.uhura" }, + owner: { kind: "examples", name: "evidence.uhura" }, }], entries: [{ id: "doc:feed", class: "doc", kind: "doc", - text: "The feed page.", + text: "The feed presentation.", span: span(0, 18, 1, 1), targetId: "target:feed", order: 0, }, { id: "annotation:primary-action:0", class: "annotation", - kind: "doc", + kind: "review-note", text: "The primary action.", span: span(50, 28, 5, 1), targetId: "target:primary-action", @@ -86,7 +85,7 @@ const stateFixture = (): unknown => ({ id: "doc:feed-example", class: "doc", kind: "doc", - text: "The default feed example.", + text: "The default evidence pin.", span: span(0, 18, 1, 1), targetId: "target:feed-example", order: 0, @@ -95,15 +94,15 @@ const stateFixture = (): unknown => ({ groups: [{ id: "page-feed", kind: "page", - subject: "feed", + subject: "example@1::Feed", previews: ["page-feed-default"], }], previews: [{ id: "page-feed-default", - identity: { kind: "page", subject: "feed", example: "default" }, - sourceFile: "app/feed/page.uhura", + identity: { kind: "page", subject: "example@1::Feed", example: "default" }, + sourceFile: "web.uhura", default: true, - pinned: false, + pinned: true, derived: false, inFlight: 0, from: null, @@ -113,8 +112,8 @@ const stateFixture = (): unknown => ({ kind: "semantic", payload: { id: "post-1" }, dispatch: { - scope: "page:1", - definition: "feed", + scope: "entry/example", + definition: "example@1::App", on: "opened", guards: [ { handler: 0, result: "unsatisfied" }, @@ -139,11 +138,9 @@ const stateFixture = (): unknown => ({ status: "ready", value: "Feed", source: { - kind: "fixture", - declaredIn: "pages/feed.uhura", + kind: "inline", + declaredIn: "web.uhura", timeline: false, - fixture: "feed-default", - path: ["viewer", "feed"], }, }], interactions: [{ @@ -152,7 +149,7 @@ const stateFixture = (): unknown => ({ kind: "input", event: "press", emit: "opened", - scope: "page:1", + scope: "entry/example", payload: { id: "post-1" }, carries: { query: "text" }, }], @@ -164,14 +161,46 @@ const stateFixture = (): unknown => ({ occurrences: [{ id: "occurrence:primary-action:0", targetId: "target:primary-action", - anchors: [{ root: { kind: "page" }, path: [] }], + anchors: ["root"], }], }, + evidence: { + scenario: "ready", + pin: "default", + sourceId: "evidence/default", + sources: { + registration: { path: "evidence.uhura", start: 0, end: 8 }, + pin: { path: "evidence.uhura", start: 9, end: 12 }, + }, + }, content: { - protocol: "uhura-view/0", - revision: 0, - page: { route: "feed", root: node }, - surfaces: [], + kind: "projection", + value: { + document: { + protocol: "uhura-view/1", + presentation: "example@1::Feed", + machine: "example@1::App", + instance: "entry/example", + sequence: "0", + nodes: [{ + kind: "element", + key: "root", + element: "main", + attributes: [], + events: [], + children: [{ kind: "text", key: "label", text: "Ready" }], + surface: false, + }], + }, + sources: { + protocol: "uhura-projection-sources/0", + presentation: "example@1::Feed", + nodes: { + root: { id: "ui/root", path: "web.uhura", start: 0, end: 4 }, + label: { id: "ui/label", path: "web.uhura", start: 5, end: 10 }, + }, + }, + }, }, }], stylesheet: ":root { --accent: blue; }", @@ -184,386 +213,180 @@ const stateFixture = (): unknown => ({ entry: "page:feed", nodes: [ { id: "page:feed", kind: "page", label: "feed" }, - { id: "surface:comments-sheet", kind: "surface", label: "comments-sheet", modality: "sheet" }, + { id: "surface:comments", kind: "surface", label: "comments", modality: "dialog" }, ], edges: [{ - id: "pages.feed/handler/0/stmt/0", + id: "edge/0", kind: "present", from: "page:feed", - to: "surface:comments-sheet", + to: "surface:comments", event: "comments-requested", - guard: { t: "bool", v: true }, }], }, + machine: { + protocol: "uhura-machine-inspection/1", + identityProtocol: "uhura-machine-program/0", + deployment: { machine: "example@1::App" }, + sources: [], + provenance: { + protocol: "uhura-provenance/0", + sources: [], + occurrences: [], + topology: { + protocol: "uhura-authored-interaction-topology/0", + nodes: [], + edges: [], + }, + }, + interactionGraph: { + protocol: "uhura-interaction-graph/0", + identity_protocol: "uhura-machine-program/0", + machine_program_hashes: {}, + presentation_hashes: {}, + outcome_policies: {}, + nodes: [], + edges: [], + }, + graphSources: { + protocol: "uhura-interaction-graph-provenance/0", + nodes: [], + edges: [], + }, + checkpoints: {}, + evidence: { + protocol: "uhura-evidence-summary/0", + passed: true, + scenarios: { total: 1, passed: 1, failed: 0 }, + artifacts: { pins: 1, examples: 1, checkpoints: 0 }, + failureCount: 0, + }, + }, }, }); -test("decodes the complete fixed EditorState contract", () => { +test("decodes the canonical projection-only EditorState/5 contract", () => { const state = decodeEditorState(stateFixture()); - - assert.equal(state.protocol, "uhura-editor-state/2"); - assert.equal(state.sourceRevision, 3); - assert.equal(state.render?.previews[0]?.data[0]?.source?.kind, "fixture"); - assert.equal(state.render?.previews[0]?.sourceFile, "app/feed/page.uhura"); - assert.deepEqual(state.render?.previews[0]?.interactions[0]?.payload, { id: "post-1" }); - assert.equal(state.render?.previews[0]?.replay[0]?.dispatch?.selected, 1); - assert.deepEqual(state.render?.previews[0]?.replay[0]?.effects.writes[0], { - field: "selected", - value: "post-1", - }); - assert.equal(state.render?.authoring.entries[1]?.class, "annotation"); - assert.deepEqual(state.render?.previews[0]?.provenance.occurrences[0]?.anchors[0], { - root: { kind: "page" }, - path: [], - }); const preview = state.render?.previews[0]; - const declarationDoc = state.render?.authoring.entries.find((entry) => - entry.id === preview?.documentation.declarationDocId); - const declarationTarget = state.render?.authoring.targets.find((target) => - target.id === declarationDoc?.targetId); - assert.equal(declarationDoc?.class, "doc"); - assert.equal(declarationTarget?.class, "page-declaration"); - assert.equal(declarationTarget?.owner.name, preview?.identity.subject); - - const exampleDoc = state.render?.authoring.entries.find((entry) => - entry.id === preview?.documentation.exampleDocId); - const exampleTarget = state.render?.authoring.targets.find((target) => - target.id === exampleDoc?.targetId); - assert.equal(exampleDoc?.class, "doc"); - assert.equal(exampleTarget?.class, "example-declaration"); - assert.equal(exampleTarget?.label, preview?.identity.example); - assert.equal(state.render?.interactionGraph.protocol, "uhura-interaction-graph/0"); - assert.equal(state.render?.interactionGraph.nodes[1]?.kind, "surface"); + assert.equal(state.protocol, EDITOR_STATE_PROTOCOL); + assert.equal(preview?.content.kind, "projection"); + assert.equal(preview?.content.value.document.protocol, "uhura-view/1"); + assert.deepEqual(preview?.provenance.occurrences[0]?.anchors, ["root"]); + assert.equal(preview?.evidence?.scenario, "ready"); + assert.equal(state.render?.machine?.identityProtocol, "uhura-machine-program/0"); + assert.equal(state.render?.machine?.evidence.scenarios.passed, 1); assert.deepEqual(state.render?.interactionGraph.edges[0], { kind: "present", from: "page:feed", - to: "surface:comments-sheet", + to: "surface:comments", event: "comments-requested", - }, "the decoder keeps only the drawn fields of a graph edge"); + }); }); -test("decodes the native model's canonical contract fixture", () => { - const fixture = JSON.parse(readFileSync(new URL( - "../../../../crates/uhura-editor-model/tests/fixtures/editor-state.json", - import.meta.url, - ), "utf8")) as unknown; - - const state = decodeEditorState(fixture); - const render = state.render; - assert.ok(render); - assert.equal(render.previews.length, 3); - assert.equal(render.previews[1]?.identity.kind, "surface"); - assert.equal(render.authoring.targets.length, 3); - assert.equal(render.authoring.entries.length, 3); - assert.equal(render.interactionGraph.protocol, "uhura-interaction-graph/0"); - assert.equal(render.interactionGraph.nodes.length, 4); - assert.equal(render.interactionGraph.edges.length, 0); - - const page = render.previews.find((preview) => preview.id === "page/home/default"); - assert.ok(page); - const declarationDoc = render.authoring.entries.find((entry) => - entry.id === page.documentation.declarationDocId); - const exampleDoc = render.authoring.entries.find((entry) => - entry.id === page.documentation.exampleDocId); - assert.equal(declarationDoc?.class, "doc"); - assert.equal(exampleDoc?.class, "doc"); - assert.equal( - render.authoring.targets.find((target) => target.id === declarationDoc?.targetId)?.class, - "page-declaration", - ); - assert.equal( - render.authoring.targets.find((target) => target.id === exampleDoc?.targetId)?.class, - "example-declaration", +test("strictly decodes the same machine graph artifact consumed by Play", () => { + const invalid = stateFixture(); + invalid.render!.machine.interactionGraph = {}; + assert.throws( + () => decodeEditorState(invalid), + /interaction graph has the wrong fields/u, ); - - const annotation = render.authoring.entries.find((entry) => entry.class === "annotation"); - const occurrence = page.provenance.occurrences[0]; - assert.ok(annotation); - assert.ok(occurrence); - assert.equal(occurrence.targetId, annotation.targetId); - assert.deepEqual(occurrence.anchors, [{ root: { kind: "page" }, path: [] }]); }); -test("accepts explicit cold-invalid and stale render states", () => { - const cold = stateFixture() as Record; - cold["sourceRevision"] = 4; - cold["diagnostics"] = diagnostics("broken source"); - cold["render"] = null; - assert.equal(decodeEditorState(cold).render, null); - - const stale = stateFixture() as { - sourceRevision: number; - render: { revision: number; freshness: string }; - }; - stale.sourceRevision = 4; - stale.render.revision = 3; - stale.render.freshness = "stale"; - const decodedStale = decodeEditorState(stale); - assert.equal(decodedStale.render?.freshness, "stale"); - assert.equal(decodedStale.render?.authoring.entries.length, 3); - assert.equal( - decodedStale.render?.previews[0]?.provenance.occurrences.length, - 1, - "stale metadata and provenance stay owned by the retained render", - ); -}); +test("rejects every retired Editor view and structural anchor encoding", () => { + const oldProtocol = stateFixture(); + oldProtocol.protocol = "uhura-editor-state/4"; + assert.throws(() => decodeEditorState(oldProtocol), /uhura-editor-state\/5/); -test("rejects malformed or internally inconsistent diagnostics envelopes", () => { - const missingVersion = stateFixture() as Record; - missingVersion["diagnostics"] = { diagnostics: [] }; - assert.throws(() => decodeEditorState(missingVersion), /no unknown property|format/); + for (const kind of ["snapshot", "fragment"]) { + const oldContent = stateFixture(); + oldContent.render!.previews[0].content = { kind, value: {} }; + assert.throws(() => decodeEditorState(oldContent), /"projection"/); + } - const wrongCounts = stateFixture() as Record; - wrongCounts["diagnostics"] = diagnostics("broken source"); - (wrongCounts["diagnostics"] as { summary: { errors: number } }).summary.errors = 0; - assert.throws(() => decodeEditorState(wrongCounts), /counts matching diagnostics/); + const pathAnchor = stateFixture(); + pathAnchor.render!.previews[0].provenance.occurrences[0].anchors = [{ + kind: "path", + root: { kind: "page" }, + path: [], + }]; + assert.throws(() => decodeEditorState(pathAnchor), /non-empty string/); }); -test("enforces current and stale revision invariants", () => { - const current = stateFixture() as { - sourceRevision: number; - render: { revision: number; freshness: string }; +test("rejects unbounded machine and preview evidence from the retired transport", () => { + const rawMachineEvidence = stateFixture(); + rawMachineEvidence.render!.machine.evidence = { + passed: true, + scenarios: [], + failures: [], }; - current.sourceRevision = 4; - assert.throws(() => decodeEditorState(current), EditorContractError); - - const stale = stateFixture() as { - sourceRevision: number; - render: { revision: number; freshness: string }; - }; - stale.render.freshness = "stale"; - assert.throws(() => decodeEditorState(stale), /less than sourceRevision/); -}); - -test("rejects unknown properties, malformed data variants, and content-kind drift", () => { - const unknown = stateFixture() as { render: { previews: Array> } }; - unknown.render.previews[0]!["html"] = "

not semantic

"; - assert.throws(() => decodeEditorState(unknown), /no unknown property/); - - const legacyIcons = stateFixture() as { render: Record }; - legacyIcons.render["icons"] = { heart: { viewBox: [0, 0, 24, 24], commands: [] } }; assert.throws( - () => decodeEditorState(legacyIcons), - /no unknown property/, - "EditorState/2 rejects engine-delivered glyph geometry", + () => decodeEditorState(rawMachineEvidence), + /evidence has the wrong fields/u, ); - const waitingWithValue = stateFixture() as { - render: { previews: Array<{ data: Array> }> }; - }; - waitingWithValue.render.previews[0]!.data[0]!["status"] = "waiting"; - assert.throws(() => decodeEditorState(waitingWithValue), /no value unless status is ready/); - - const fragmentPage = stateFixture() as { - render: { previews: Array> }; - }; - fragmentPage.render.previews[0]!["content"] = node; - assert.throws(() => decodeEditorState(fragmentPage), /uhura-view\/0 snapshot/); - - const invalidGuard = stateFixture() as { - render: { previews: Array<{ replay: Array<{ dispatch: { guards: Array<{ result: string }> } }> }> }; - }; - invalidGuard.render.previews[0]!.replay[0]!.dispatch.guards[0]!.result = "maybe"; - assert.throws(() => decodeEditorState(invalidGuard), /"satisfied" or "unsatisfied" or "not-ready"/); - - const mismatchedReplay = stateFixture() as { - render: { previews: Array<{ replaySteps: string[] }> }; - }; - mismatchedReplay.render.previews[0]!.replaySteps[0] = "other-event"; - assert.throws(() => decodeEditorState(mismatchedReplay), /details matching replaySteps in order/); -}); - -test("enforces group references, identity matching, and unique IDs", () => { - const missing = stateFixture() as { - render: { groups: Array<{ previews: string[] }> }; - }; - missing.render.groups[0]!.previews = ["unknown"]; - assert.throws(() => decodeEditorState(missing), /existing preview id/); - - const duplicate = stateFixture() as { - render: { previews: unknown[]; groups: Array<{ previews: string[] }> }; - }; - duplicate.render.previews.push(structuredClone(duplicate.render.previews[0])); - duplicate.render.groups[0]!.previews.push("page-feed-default"); - assert.throws(() => decodeEditorState(duplicate), /unique values/); - - const missingParent = stateFixture() as { - render: { previews: Array<{ from: string | null }> }; - }; - missingParent.render.previews[0]!.from = "missing"; - assert.throws(() => decodeEditorState(missingParent), /existing example in the same subject/); -}); - -test("strictly validates authoring classes, ranges, kinds, and references", () => { - const malformedKind = stateFixture() as { - render: { authoring: { entries: Array> } }; - }; - malformedKind.render.authoring.entries[1]!["kind"] = "Review_Note"; - assert.throws(() => decodeEditorState(malformedKind), /annotation metadata/); - - const missingTarget = stateFixture() as { - render: { authoring: { entries: Array> } }; - }; - missingTarget.render.authoring.entries[0]!["targetId"] = "missing"; - assert.throws(() => decodeEditorState(missingTarget), /existing source target id/); - - const invalidRange = stateFixture() as { - render: { authoring: { targets: Array<{ span: { start: { line: number } } }> } }; - }; - invalidRange.render.authoring.targets[0]!.span.start.line = 0; - assert.throws(() => decodeEditorState(invalidRange), /positive integer/); - - const annotationOnDocTarget = stateFixture() as { - render: { authoring: { entries: Array> } }; - }; - annotationOnDocTarget.render.authoring.entries[1]!["targetId"] = "target:feed"; - assert.throws(() => decodeEditorState(annotationOnDocTarget), /annotation metadata/); - - const unusedTarget = stateFixture() as { - render: { authoring: { targets: Array> } }; - }; - const extra = structuredClone(unusedTarget.render.authoring.targets[0]!); - extra["id"] = "target:unused"; - unusedTarget.render.authoring.targets.push(extra); - assert.throws(() => decodeEditorState(unusedTarget), /only metadata-referenced targets/); -}); - -test("annotation kinds use the full ASCII lower-kebab grammar", () => { - for (const kind of ["a", "a0", "a-0", "review-note", "a".repeat(64)]) { - const fixture = stateFixture() as { - render: { authoring: { entries: Array<{ kind: string }> } }; - }; - fixture.render.authoring.entries[1]!.kind = kind; - assert.equal(decodeEditorState(fixture).render?.authoring.entries[1]?.kind, kind); - } - for (const kind of [ - "", - "0note", - "Review", - "review_note", - "-note", - "note-", - "note--later", - "nöté", - "a".repeat(65), - ]) { - const fixture = stateFixture() as { - render: { authoring: { entries: Array<{ kind: string }> } }; - }; - fixture.render.authoring.entries[1]!.kind = kind; + for (const field of ["observation", "snapshot", "scenarioReceiptLog"]) { + const rawPreviewEvidence = stateFixture(); + rawPreviewEvidence.render!.previews[0].evidence[field] = {}; assert.throws( - () => decodeEditorState(fixture), - /annotation metadata|non-empty string/, - kind, + () => decodeEditorState(rawPreviewEvidence), + /no unknown property/u, ); } }); -test("validates documentation and semantic provenance while allowing zero anchors", () => { - const wrongSourceFile = stateFixture() as { - render: { previews: Array<{ sourceFile: string }> }; - }; - wrongSourceFile.render.previews[0]!.sourceFile = "../feed.uhura"; - assert.throws(() => decodeEditorState(wrongSourceFile), /canonical project-relative source path/); +test("validates projection source coverage and semantic anchor keys", () => { + const missingSource = stateFixture(); + delete missingSource.render!.previews[0].content.value.sources.nodes.label; + assert.throws(() => decodeEditorState(missingSource), /must address every rendered key exactly/); - const zeroAnchors = stateFixture() as { - render: { previews: Array<{ provenance: { occurrences: Array<{ anchors: unknown[] }> } }> }; - }; - zeroAnchors.render.previews[0]!.provenance.occurrences[0]!.anchors = []; - assert.equal( - decodeEditorState(zeroAnchors).render?.previews[0]?.provenance.occurrences[0]?.anchors.length, - 0, - ); + const unknownAnchor = stateFixture(); + unknownAnchor.render!.previews[0].provenance.occurrences[0].anchors = ["missing"]; + assert.throws(() => decodeEditorState(unknownAnchor), /semantic node key/); - const wrongDoc = stateFixture() as { - render: { previews: Array<{ documentation: { declarationDocId: string } }> }; - }; - wrongDoc.render.previews[0]!.documentation.declarationDocId = "annotation:primary-action:0"; - assert.throws(() => decodeEditorState(wrongDoc), /doc entry for page-declaration/); + const duplicateAnchor = stateFixture(); + duplicateAnchor.render!.previews[0].provenance.occurrences[0].anchors = ["root", "root"]; + assert.throws(() => decodeEditorState(duplicateAnchor), /unique values/); +}); - const wrongDeclarationOwner = stateFixture() as { - render: { authoring: { targets: Array<{ owner: { name: string } }> } }; - }; - wrongDeclarationOwner.render.authoring.targets[0]!.owner.name = "another-page"; - assert.throws(() => decodeEditorState(wrongDeclarationOwner), /doc entry for page-declaration/); +test("accepts cold-invalid and stale render states with strict revisions", () => { + const cold = stateFixture(); + cold.sourceRevision = 4; + cold.diagnostics = diagnostics("broken source"); + cold.render = null; + assert.equal(decodeEditorState(cold).render, null); - const wrongExample = stateFixture() as { - render: { authoring: { targets: Array<{ label: string }> } }; - }; - wrongExample.render.authoring.targets[2]!.label = "another-example"; - assert.throws(() => decodeEditorState(wrongExample), /doc entry for example-declaration/); + const stale = stateFixture(); + stale.sourceRevision = 4; + stale.render!.revision = 3; + stale.render!.freshness = "stale"; + assert.equal(decodeEditorState(stale).render?.freshness, "stale"); - const wrongRoot = stateFixture() as { - render: { previews: Array<{ provenance: { occurrences: Array<{ anchors: unknown[] }> } }> }; - }; - wrongRoot.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "fragment" }, - path: [], - }]; - assert.throws(() => decodeEditorState(wrongRoot), /semantic node path/); + const invalidCurrent = stateFixture(); + invalidCurrent.sourceRevision = 4; + assert.throws(() => decodeEditorState(invalidCurrent), /sourceRevision 4/); - const wrongPath = stateFixture() as { - render: { previews: Array<{ provenance: { occurrences: Array<{ anchors: unknown[] }> } }> }; - }; - wrongPath.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "page" }, - path: [9], - }]; - assert.throws(() => decodeEditorState(wrongPath), /semantic node path/); + const invalidStale = stateFixture(); + invalidStale.render!.freshness = "stale"; + assert.throws(() => decodeEditorState(invalidStale), /less than sourceRevision/); }); -test("resolves surface roots by semantic key and rejects malformed root variants", () => { - const withSurface = stateFixture() as { - render: { - previews: Array<{ - content: { surfaces: unknown[] }; - provenance: { occurrences: Array<{ anchors: unknown[] }> }; - }>; - }; - }; - withSurface.render.previews[0]!.content.surfaces.push({ - key: "sheet:1", - definition: "sheet", - modality: "sheet", - dismiss: { - kind: "input", - event: "dismiss", - emit: "dismissed", - scope: "surface:1", - payload: {}, - }, - root: { key: "surface-root", element: "view", props: {} }, - }); - withSurface.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "surface", key: "sheet:1" }, - path: [], - }]; - assert.equal( - decodeEditorState(withSurface).render?.previews[0] - ?.provenance.occurrences[0]?.anchors[0]?.root.kind, - "surface", - ); +test("strictly validates diagnostics, authoring, replay, and group references", () => { + const wrongCounts = stateFixture(); + wrongCounts.diagnostics = diagnostics("broken source"); + (wrongCounts.diagnostics as { summary: { errors: number } }).summary.errors = 0; + assert.throws(() => decodeEditorState(wrongCounts), /counts matching diagnostics/); - const missingSurface = structuredClone(withSurface); - missingSurface.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "surface", key: "missing" }, - path: [], - }]; - assert.throws(() => decodeEditorState(missingSurface), /semantic node path/); + const unknownTarget = stateFixture(); + unknownTarget.render!.previews[0].provenance.occurrences[0].targetId = "missing"; + assert.throws(() => decodeEditorState(unknownTarget), /annotatable source target/); - const duplicateSurface = structuredClone(withSurface); - duplicateSurface.render.previews[0]!.content.surfaces.push(structuredClone( - duplicateSurface.render.previews[0]!.content.surfaces[0], - )); - assert.throws(() => decodeEditorState(duplicateSurface), /semantic node path/); + const mismatchedReplay = stateFixture(); + mismatchedReplay.render!.previews[0].replaySteps[0] = "other"; + assert.throws(() => decodeEditorState(mismatchedReplay), /matching replaySteps/); - const pageWithKey = structuredClone(withSurface); - pageWithKey.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "page", key: "illegal" }, - path: [], - }]; - assert.throws(() => decodeEditorState(pageWithKey), /no unknown property/); + const missingPreview = stateFixture(); + missingPreview.render!.groups[0].previews = ["missing"]; + assert.throws(() => decodeEditorState(missingPreview), /existing preview id/); }); test("decodes only the versioned revision event", () => { diff --git a/web/src/editor/tests/editor-updates.test.ts b/web/src/editor/tests/editor-updates.test.ts index d3f45be..78fb71e 100644 --- a/web/src/editor/tests/editor-updates.test.ts +++ b/web/src/editor/tests/editor-updates.test.ts @@ -14,9 +14,10 @@ import { reusablePreviewFrameIds, reusablePreviewIds, } from "../editor-updates.js"; +import { elementNode, projectionContent, textNode } from "./fixtures/projection.js"; const state = (sourceRevision: number): EditorState => ({ - protocol: "uhura-editor-state/2", + protocol: "uhura-editor-state/5", sourceRevision, diagnostics: null, render: null, @@ -43,11 +44,10 @@ const preview = (id: string, content = id): EditorPreview => ({ interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { - key: "root", - element: "text", - props: { content: { t: "plain", v: content } }, - }, + evidence: null, + content: projectionContent([ + elementNode("root", [textNode("content", content)]), + ]), }); const render = ( @@ -70,6 +70,7 @@ const render = ( photo: { dataUri: "data:image/png;base64,AA==", alt: "Photo" }, }, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }); test("every connection open fetches, including equal counters after a restart", () => { @@ -170,11 +171,13 @@ test("semantic selection survives replacement and disappears with its preview", interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }], stylesheet: "", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }, }; @@ -204,7 +207,7 @@ test("authoring-only changes reuse semantic DOM", () => { const next = structuredClone(render(4)); next.authoring.targets.push({ id: "target", - class: "catalog-element", + class: "ui-element", file: "card.uhura", span: { offset: 10, @@ -218,7 +221,7 @@ test("authoring-only changes reuse semantic DOM", () => { next.previews[0]!.provenance.occurrences.push({ id: "occurrence", targetId: "target", - anchors: [{ root: { kind: "fragment" }, path: [] }], + anchors: ["root"], }); assert.deepEqual([...reusablePreviewIds(previous, next)], ["alpha", "beta"]); diff --git a/web/src/editor/tests/fixtures/projection.ts b/web/src/editor/tests/fixtures/projection.ts new file mode 100644 index 0000000..30ee20e --- /dev/null +++ b/web/src/editor/tests/fixtures/projection.ts @@ -0,0 +1,58 @@ +import { natural } from "../../../protocol/machine.js"; +import type { RenderNode } from "../../../renderer/projection.js"; +import type { PreviewContent } from "../../editor-state.js"; + +export const elementNode = ( + key: string, + children: readonly RenderNode[] = [], + options: { + element?: string; + surface?: boolean; + attributes?: readonly { name: string; value: boolean | string }[]; + } = {}, +): RenderNode => ({ + kind: "element", + key, + element: options.element ?? "div", + attributes: options.attributes ?? [], + events: [], + children, + surface: options.surface ?? false, +}); + +export const textNode = (key: string, text: string): RenderNode => ({ + kind: "text", + key, + text, +}); + +const keys = (nodes: readonly RenderNode[]): string[] => + nodes.flatMap((node) => [ + node.key, + ...(node.kind === "element" ? keys(node.children) : []), + ]); + +export const projectionContent = ( + nodes: readonly RenderNode[] = [elementNode("root")], + presentation = "test@1::Web", +): PreviewContent => ({ + kind: "projection", + value: { + document: { + protocol: "uhura-view/1", + presentation, + machine: "test@1::Machine", + instance: "editor/test", + sequence: natural("0"), + nodes, + }, + sources: { + protocol: "uhura-projection-sources/0", + presentation, + nodes: Object.fromEntries(keys(nodes).map((key) => [ + key, + { id: `ui/${key}`, path: "web.uhura", start: 0, end: 1 }, + ])), + }, + }, +}); diff --git a/web/src/editor/tests/machine-inspection.test.ts b/web/src/editor/tests/machine-inspection.test.ts new file mode 100644 index 0000000..6e20daa --- /dev/null +++ b/web/src/editor/tests/machine-inspection.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; + +import { test } from "vitest"; + +import type { + EditorMachine, + PreviewEvidence, +} from "../editor-state.js"; +import { + inspectMachine, + machineMetricRows, + previewEvidenceRows, + renderInspectionRows, +} from "../machine-inspection.js"; + +const machine = (overrides: Partial = {}): EditorMachine => ({ + protocol: "uhura-machine-inspection/1", + identityProtocol: "uhura-machine-identity/0", + deployment: { + entry: "return-desk", + machine: "app.return_desk.machine@1::ReturnDesk", + presentation: "app.return_desk.web@1::ReturnDeskWeb", + deploymentHash: "sha256:deployment", + }, + sources: [{ path: "machine.uhura" }, { path: "web.uhura" }], + provenance: { + protocol: "uhura-provenance/0", + sources: [], + occurrences: [], + topology: { + protocol: "uhura-authored-interaction-topology/0", + nodes: [], + edges: [], + }, + }, + interactionGraph: {}, + graphSources: {}, + checkpoints: { + empty: { protocol: "uhura-checkpoint/0" }, + reviewed: { protocol: "uhura-checkpoint/0" }, + }, + evidence: { + protocol: "uhura-evidence-summary/0", + passed: false, + scenarios: { total: 2, passed: 1, failed: 1 }, + artifacts: { pins: 2, examples: 2, checkpoints: 2 }, + failureCount: 1, + }, + ...overrides, +}); + +test("summarizes deployment identity and bounded machine evidence counts", () => { + const summary = inspectMachine(machine()); + + assert.deepEqual(summary.identity, [ + { label: "Deployment", value: "return-desk" }, + { label: "Machine", value: "app.return_desk.machine@1::ReturnDesk" }, + { label: "Presentation", value: "app.return_desk.web@1::ReturnDeskWeb" }, + ]); + assert.equal(summary.status, "failed"); + assert.deepEqual(machineMetricRows(summary), [ + { label: "Passes", value: "1" }, + { label: "Failures", value: "1" }, + { label: "Checkpoints", value: "2" }, + { label: "Sources", value: "2" }, + ]); +}); + +test("keeps absent deployment and an empty evidence summary honest", () => { + const summary = inspectMachine(machine({ + deployment: null, + sources: [], + checkpoints: {}, + evidence: { + protocol: "uhura-evidence-summary/0", + passed: true, + scenarios: { total: 0, passed: 0, failed: 0 }, + artifacts: { pins: 0, examples: 0, checkpoints: 0 }, + failureCount: 0, + }, + })); + + assert.deepEqual(summary.identity, []); + assert.equal(summary.status, "passed"); + assert.equal(summary.passes, 0); + assert.equal(summary.failures, 0); + assert.equal(summary.checkpoints, 0); + assert.equal(summary.sources, 0); + assert.deepEqual(summary.ownership, []); + assert.deepEqual(summary.outcomes, []); + assert.deepEqual(summary.dependencies, []); +}); + +test("projects authored module, part ownership, and dependencies without replacing evidence UX", () => { + const machineId = "app.return_desk.machine@1::ReturnDesk"; + const summary = inspectMachine(machine({ + interactionGraph: { + protocol: "uhura-interaction-graph/0", + outcome_policies: { + accepted: "commit", + refused: "abort", + }, + nodes: [ + { id: "module:app", kind: "module", machine: machineId, label: "app" }, + { id: "module:parts", kind: "module", machine: machineId, label: "parts" }, + { id: "machine", kind: "machine", machine: machineId, label: machineId }, + { id: "producer", kind: "part", machine: machineId, label: "producer" }, + { id: "consumer", kind: "part", machine: machineId, label: "consumer" }, + { id: "value", kind: "state", machine: machineId, label: "producer.value" }, + { id: "current", kind: "computed", machine: machineId, label: "producer.current" }, + { id: "set", kind: "update", machine: machineId, label: "producer.set" }, + { id: "producer-invariant", kind: "invariant", machine: machineId, label: "producer.invariant 1" }, + { id: "input", kind: "input", machine: machineId, label: "consumer.Apply" }, + { id: "observed", kind: "observation", machine: machineId, label: "consumer.current" }, + { id: "root-invariant", kind: "invariant", machine: machineId, label: "invariant 1" }, + { id: "accepted", kind: "outcome", machine: machineId, label: "Accepted" }, + { id: "refused", kind: "outcome", machine: machineId, label: "Refused" }, + ], + edges: [ + { from: "module:app", to: "machine", kind: "owns" }, + { from: "module:parts", to: "producer", kind: "owns" }, + { from: "module:parts", to: "consumer", kind: "owns" }, + { from: "machine", to: "producer", kind: "composes" }, + { from: "machine", to: "consumer", kind: "composes" }, + { from: "producer", to: "value", kind: "owns" }, + { from: "producer", to: "current", kind: "owns" }, + { from: "producer", to: "set", kind: "owns" }, + { from: "producer", to: "producer-invariant", kind: "owns" }, + { from: "consumer", to: "input", kind: "owns" }, + { from: "consumer", to: "observed", kind: "owns" }, + { from: "machine", to: "root-invariant", kind: "owns" }, + { from: "machine", to: "accepted", kind: "owns" }, + { from: "machine", to: "refused", kind: "owns" }, + { from: "current", to: "value", kind: "reads" }, + { from: "input", to: "set", kind: "calls" }, + { from: "observed", to: "current", kind: "observes" }, + ], + }, + })); + + assert.deepEqual(summary.ownership, [ + { label: "Module", value: "app" }, + { label: "Module", value: "parts" }, + { + label: "Machine-owned", + value: "1 invariant", + }, + { + label: "Part consumer", + value: "1 observation", + }, + { + label: "Part producer", + value: "1 state · 1 computed · 1 invariant · 1 update", + }, + ]); + assert.deepEqual(summary.outcomes, [ + { label: "Outcome Accepted", value: "commit" }, + { label: "Outcome Refused", value: "abort" }, + ]); + assert.deepEqual(summary.dependencies, [ + { label: "Reads", value: "1 · producer.current → producer.value" }, + { label: "Calls", value: "1 · consumer.Apply → producer.set" }, + { label: "Observes", value: "1 · consumer.current → producer.current" }, + ]); +}); + +test("exposes only the selected preview evidence identity", () => { + const evidence: PreviewEvidence = { + scenario: "return-approved", + pin: "completed", + sourceId: "conformance.uhura:44:3", + sources: { + registration: { path: "conformance.uhura" }, + pin: { path: "conformance.uhura" }, + }, + }; + + assert.deepEqual(previewEvidenceRows(evidence), [ + { label: "Scenario", value: "return-approved" }, + { label: "Pin", value: "completed" }, + { label: "Source", value: "conformance.uhura:44:3" }, + ]); +}); + +class TestElement { + readonly children: TestElement[] = []; + readonly tagName: string; + textContent = ""; + + constructor(tagName: string) { + this.tagName = tagName; + } + + append(...children: TestElement[]): void { + this.children.push(...children); + } + + replaceChildren(...children: TestElement[]): void { + this.children.splice(0, this.children.length, ...children); + } +} + +test("renders semantic definition-list rows without serializing raw evidence", () => { + const document = { + createElement: (tagName: string) => new TestElement(tagName), + } as unknown as Document; + const root = new TestElement("dl"); + + renderInspectionRows( + document, + root as unknown as HTMLElement, + previewEvidenceRows({ + scenario: "ready", + pin: "loaded", + sourceId: "evidence/ready/loaded", + sources: { registration: {}, pin: {} }, + }), + ); + + assert.deepEqual( + root.children.map((group) => ({ + tags: group.children.map((child) => child.tagName), + text: group.children.map((child) => child.textContent), + })), + [ + { tags: ["dt", "dd"], text: ["Scenario", "ready"] }, + { tags: ["dt", "dd"], text: ["Pin", "loaded"] }, + { tags: ["dt", "dd"], text: ["Source", "evidence/ready/loaded"] }, + ], + ); +}); diff --git a/web/src/editor/tests/structure-connectors.test.ts b/web/src/editor/tests/structure-connectors.test.ts index ed6c711..9fdca59 100644 --- a/web/src/editor/tests/structure-connectors.test.ts +++ b/web/src/editor/tests/structure-connectors.test.ts @@ -8,6 +8,7 @@ import { buildStructureConnectors, incomingLeftLabelShift, layoutStructureConnectors, + logicalRoutePreviewNode, routeStructureConnector, structureConnectorDescription, structureConnectorLabel, @@ -15,6 +16,7 @@ import { type StructureConnectorPlacement, type StructureRect, } from "../structure-connectors.js"; +import { projectionContent } from "./fixtures/projection.js"; const preview = ( kind: PreviewKind, @@ -36,17 +38,8 @@ const preview = ( interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: kind === "page" - ? { - protocol: "uhura-view/0", - revision: 0, - page: { - route: subject, - root: { key: "root", element: "view", props: {} }, - }, - surfaces: [], - } - : { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }); const graph = (edges: InteractionGraphEdge[]): InteractionGraph => ({ @@ -227,6 +220,54 @@ test("selection scoping matches kind and subject, never subject alone", () => { ); }); +test("preview-backed route nodes map and select the exact application state", () => { + const feedBase = "page/feed/base"; + const feedExtra = "page/feed/extra"; + const profile = "page/profile/default"; + const connectors = buildStructureConnectors(graph([ + { + kind: "navigate", + from: logicalRoutePreviewNode(feedBase), + to: logicalRoutePreviewNode(profile), + event: "base-profile", + }, + { + kind: "navigate", + from: logicalRoutePreviewNode(feedExtra), + to: logicalRoutePreviewNode(profile), + event: "extra-profile", + }, + ]), boardPreviews); + + assert.deepEqual( + connectors.map(({ sourceId, targetId, event }) => [sourceId, targetId, event]), + [ + [feedBase, profile, "base-profile"], + [feedExtra, profile, "extra-profile"], + ], + ); + + const visible = visibleStructureConnectors(connectors, { + kind: "page", + subject: "feed", + previewId: feedExtra, + }); + assert.deepEqual( + visible.map(({ sourceId, event }) => [sourceId, event]), + [[feedExtra, "extra-profile"]], + "selecting one logical state must not adopt a sibling preview's route", + ); + + const laid = layoutStructureConnectors(visible, { + node: logicalRoutePreviewNode(feedExtra), + aliases: ["page:feed"], + previewId: feedExtra, + }); + assert.equal(laid[0]?.placement.direction, "outgoing"); + assert.equal(laid[0]?.placement.selectedId, feedExtra); + assert.equal(laid[0]?.placement.farId, profile); +}); + test("empty or unrelated selection hides every structural connector", () => { const all = buildStructureConnectors(fullGraph, boardPreviews); assert.deepEqual(visibleStructureConnectors(all, null), []); diff --git a/web/src/editor/tests/surface-hierarchy.test.ts b/web/src/editor/tests/surface-hierarchy.test.ts index fc78465..828822f 100644 --- a/web/src/editor/tests/surface-hierarchy.test.ts +++ b/web/src/editor/tests/surface-hierarchy.test.ts @@ -2,139 +2,110 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import type { EditorPreview, ReplayStep } from "../editor-state.js"; -import { directlyOpenedSurfaces, surfaceHierarchy } from "../surface-hierarchy.js"; +import type { RenderNode } from "../../renderer/projection.js"; +import type { EditorPreview } from "../editor-state.js"; +import { introducedSurfaces, surfaceHierarchy } from "../surface-hierarchy.js"; +import { elementNode, projectionContent } from "./fixtures/projection.js"; -const replay = (structural: ReplayStep["effects"]["structural"]): ReplayStep => ({ - label: "comments-requested", - kind: "semantic", - payload: { post: "post-1" }, - dispatch: null, - effects: { - writes: [], - commands: [], - intents: [], - structural, - projections: [], - }, -}); - -const page = (steps: ReplayStep[], from: string | null = "first-page"): EditorPreview => ({ - id: "page/feed/comments-open", - identity: { kind: "page", subject: "feed", example: "comments-open" }, - sourceFile: "pages/feed.uhura", - default: false, +const preview = ( + example: string, + nodes: readonly RenderNode[], + from: string | null = null, +): EditorPreview => ({ + id: `page/feed/${example}`, + identity: { kind: "page", subject: "feed", example }, + sourceFile: "web.uhura", + default: from === null, pinned: false, - derived: true, + derived: from !== null, inFlight: 0, from, - replaySteps: steps.map((step) => step.label), - replay: steps, + replaySteps: [], + replay: [], note: null, data: [], interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { - protocol: "uhura-view/0", - revision: 2, - page: { route: "feed", root: { key: "root", element: "view", props: {} } }, - surfaces: [{ - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", - dismiss: { - kind: "input", - event: "dismiss", - emit: "dismiss", - scope: "surface:1", - payload: {}, - }, - root: { key: "surface", element: "view", props: {} }, - }], - }, + evidence: null, + content: projectionContent(nodes, "instagram@1::Feed"), }); -test("matches a direct open-surface effect to the mounted child by instance key", () => { - const preview = page([replay([{ - op: "open-surface", - opener: "page:1", - surface: "comments-sheet:1", - }])]); +const comments = (children: readonly RenderNode[] = []): RenderNode => + elementNode("comments-sheet", children, { + element: "dialog", + surface: true, + attributes: [{ name: "aria-label", value: "Comments" }], + }); + +test("derives mounted surfaces and readable labels from canonical projection nodes", () => { + const current = preview("comments", [ + elementNode("root", [comments()]), + ]); - assert.deepEqual(surfaceHierarchy(preview), { - page: "feed", + assert.deepEqual(surfaceHierarchy(current), { + presentation: "instagram@1::Feed", surfaces: [{ - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", + key: "comments-sheet", + definition: "Comments", + modality: "dialog", stackIndex: 0, - relation: "direct", + relation: "present", }], roots: [{ surface: { - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", + key: "comments-sheet", + definition: "Comments", + modality: "dialog", stackIndex: 0, - relation: "direct", + relation: "present", }, - opener: "page:1", + opener: null, children: [], }], }); - assert.equal(directlyOpenedSurfaces(preview)[0]?.definition, "comments-sheet"); }); -test("does not infer direct parentage from a matching definition alone", () => { - const preview = page([replay([{ - op: "open-surface", - opener: "page:1", - surface: "comments-sheet:2", - }])]); - assert.equal(surfaceHierarchy(preview)?.surfaces[0]?.relation, "inherited"); - assert.deepEqual(directlyOpenedSurfaces(preview), []); -}); +test("keeps nested hierarchy and compares exact keys with the evidence parent", () => { + const parent = preview("comments", [elementNode("root", [comments()])]); + const report = elementNode("report-dialog", [], { + element: "dialog", + surface: true, + attributes: [{ name: "data-modality", value: "alert dialog" }], + }); + const child = preview( + "report", + [elementNode("root", [comments([report])])], + "comments", + ); -test("keeps parentless snapshot surfaces distinct from inherited replay children", () => { - const preview = page([], null); - assert.equal(surfaceHierarchy(preview)?.surfaces[0]?.relation, "mounted"); - assert.deepEqual(directlyOpenedSurfaces(preview), []); + const hierarchy = surfaceHierarchy(child, [parent, child]); + assert.deepEqual( + hierarchy?.surfaces.map(({ key, definition, modality, relation }) => ({ + key, + definition, + modality, + relation, + })), + [{ + key: "comments-sheet", + definition: "Comments", + modality: "dialog", + relation: "retained", + }, { + key: "report-dialog", + definition: "Surface 2", + modality: "alert dialog", + relation: "introduced", + }], + ); + assert.equal(hierarchy?.roots[0]?.children[0]?.opener, "comments-sheet"); + assert.deepEqual( + introducedSurfaces(child, [parent, child]).map((surface) => surface.key), + ["report-dialog"], + ); }); -test("reconstructs nested surface ancestry from direct replay instance keys", () => { - const sheet = page([replay([{ - op: "open-surface", - opener: "page:1", - surface: "comments-sheet:1", - }])]); - const dialog = structuredClone(sheet); - dialog.id = "page/feed/report-open"; - dialog.identity.example = "report-open"; - dialog.from = "comments-open"; - dialog.replaySteps = ["report-requested"]; - dialog.replay = [replay([{ - op: "open-surface", - opener: "surface:1", - surface: "report-dialog:2", - }])]; - if (!("protocol" in dialog.content)) throw new Error("page fixture"); - dialog.content.surfaces.push({ - key: "report-dialog:2", - definition: "report-dialog", - modality: "dialog", - dismiss: { - kind: "input", event: "dismiss", emit: "dismiss", scope: "surface:2", payload: {}, - }, - root: { key: "dialog", element: "view", props: {} }, - }); - - const hierarchy = surfaceHierarchy(dialog, [sheet, dialog]); - assert.equal(hierarchy?.roots.length, 1); - assert.equal(hierarchy?.roots[0]?.surface.definition, "comments-sheet"); - assert.equal(hierarchy?.roots[0]?.surface.relation, "inherited"); - assert.equal(hierarchy?.roots[0]?.opener, "page:1"); - assert.equal(hierarchy?.roots[0]?.children[0]?.surface.definition, "report-dialog"); - assert.equal(hierarchy?.roots[0]?.children[0]?.surface.relation, "direct"); - assert.equal(hierarchy?.roots[0]?.children[0]?.opener, "surface:1"); +test("returns no hierarchy when a projection has no semantic surfaces", () => { + assert.equal(surfaceHierarchy(preview("plain", [elementNode("root")])), null); }); diff --git a/web/src/editor/tests/workflow-connectors.test.ts b/web/src/editor/tests/workflow-connectors.test.ts index ca5c044..5f3fda1 100644 --- a/web/src/editor/tests/workflow-connectors.test.ts +++ b/web/src/editor/tests/workflow-connectors.test.ts @@ -10,6 +10,7 @@ import { workflowConnectorDescription, workflowConnectorLabel, } from "../workflow-connectors.js"; +import { elementNode, projectionContent } from "./fixtures/projection.js"; const preview = ( example: string, @@ -31,15 +32,8 @@ const preview = ( interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { - protocol: "uhura-view/0", - revision: 0, - page: { - route: "feed", - root: { key: "root", element: "view", props: {} }, - }, - surfaces: [], - }, + evidence: null, + content: projectionContent(), }); test("builds direct checked provenance without repeating ancestor steps", () => { @@ -54,7 +48,7 @@ test("builds direct checked provenance without repeating ancestor steps", () => sourceId: "page/feed/base", targetId: "page/feed/pending", steps: ["like-toggled"], - openedSurfaces: [], + introducedSurfaces: [], lane: 0, sourcePort: { slot: 0, count: 1 }, targetPort: { slot: 0, count: 1 }, @@ -63,7 +57,7 @@ test("builds direct checked provenance without repeating ancestor steps", () => sourceId: "page/feed/pending", targetId: "page/feed/refused", steps: ["like-post.err"], - openedSurfaces: [], + introducedSurfaces: [], lane: 1, sourcePort: { slot: 0, count: 1 }, targetPort: { slot: 0, count: 1 }, @@ -138,13 +132,13 @@ test("skips unresolved parents and summarizes labels without hiding full order", assert.equal( workflowConnectorDescription({ steps: ["near-end", "projection feed.page", "load.ok"], - openedSurfaces: [], + introducedSurfaces: [], }), "near-end → projection feed.page → load.ok", ); }); -test("classifies a checked edge that opens a mounted child surface", () => { +test("classifies a checked edge whose projection introduces a surface", () => { const child = preview("comments-open", "base", ["comments-requested"]); child.replay = [{ label: "comments-requested", @@ -156,27 +150,26 @@ test("classifies a checked edge that opens a mounted child surface", () => { structural: [{ op: "open-surface", surface: "comments-sheet:1" }], }, }]; - if (!("protocol" in child.content)) throw new Error("page fixture"); - child.content.surfaces = [{ - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", - dismiss: { - kind: "input", event: "dismiss", emit: "dismiss", scope: "surface:1", payload: {}, - }, - root: { key: "surface", element: "view", props: {} }, - }]; + child.content = projectionContent([ + elementNode("root", [ + elementNode("comments-sheet:1", [], { + element: "dialog", + surface: true, + attributes: [{ name: "aria-label", value: "comments-sheet" }], + }), + ]), + ]); const connector = buildWorkflowConnectors("page/feed", [preview("base"), child])[0]!; - assert.deepEqual(connector.openedSurfaces.map(({ definition, modality }) => ({ + assert.deepEqual(connector.introducedSurfaces.map(({ definition, modality }) => ({ definition, modality, - })), [{ definition: "comments-sheet", modality: "sheet" }]); + })), [{ definition: "comments-sheet", modality: "dialog" }]); assert.equal( - workflowConnectorLabel(connector.steps, connector.openedSurfaces), - "comments-requested · opens comments-sheet", + workflowConnectorLabel(connector.steps, connector.introducedSurfaces), + "comments-requested · introduces comments-sheet", ); assert.equal( workflowConnectorDescription(connector), - "comments-requested; opens child sheet comments-sheet", + "comments-requested; projection introduces dialog comments-sheet", ); }); diff --git a/web/src/editor/workflow-connectors.ts b/web/src/editor/workflow-connectors.ts index 1faaa39..0e12760 100644 --- a/web/src/editor/workflow-connectors.ts +++ b/web/src/editor/workflow-connectors.ts @@ -1,12 +1,12 @@ import type { EditorPreview } from "./editor-state.js"; -import { directlyOpenedSurfaces, type MountedSurface } from "./surface-hierarchy.js"; +import { introducedSurfaces, type MountedSurface } from "./surface-hierarchy.js"; export interface WorkflowConnector { groupId: string; sourceId: string; targetId: string; steps: string[]; - openedSurfaces: MountedSurface[]; + introducedSurfaces: MountedSurface[]; lane: number; sourcePort: ConnectorPort; targetPort: ConnectorPort; @@ -145,7 +145,7 @@ export const buildWorkflowConnectors = ( sourceId, targetId: preview.id, steps: [...preview.replaySteps], - openedSurfaces: directlyOpenedSurfaces(preview), + introducedSurfaces: introducedSurfaces(preview, previews), lane: 0, sourcePort: { slot: 0, count: 1 }, targetPort: { slot: 0, count: 1 }, @@ -210,26 +210,26 @@ export const routeWorkflowConnector = ( export const workflowConnectorLabel = ( steps: readonly string[], - openedSurfaces: readonly Pick[] = [], + surfaces: readonly Pick[] = [], ): string => { const replay = steps.length === 0 ? "derived" : steps.length === 1 ? steps[0]! : `${steps[0]} +${steps.length - 1}`; - if (openedSurfaces.length === 0) return replay; - return `${replay} · opens ${openedSurfaces.map((surface) => surface.definition).join(", ")}`; + if (surfaces.length === 0) return replay; + return `${replay} · introduces ${surfaces.map((surface) => surface.definition).join(", ")}`; }; export const workflowConnectorDescription = ( - connector: Pick, + connector: Pick, ): string => { const replay = connector.steps.length === 0 ? "derived example" : connector.steps.join(" → "); - if (connector.openedSurfaces.length === 0) return replay; - const children = connector.openedSurfaces + if (connector.introducedSurfaces.length === 0) return replay; + const children = connector.introducedSurfaces .map((surface) => `${surface.modality} ${surface.definition}`) .join(", "); - return `${replay}; opens child ${children}`; + return `${replay}; projection introduces ${children}`; }; diff --git a/web/src/play/TODO.md b/web/src/play/TODO.md index 58cf301..20c826a 100644 --- a/web/src/play/TODO.md +++ b/web/src/play/TODO.md @@ -1,11 +1,11 @@ # Play debugger TODO -This tracker starts at the current Play debugger spike: the inspection protocol, -bounded step history, focused behavior graph, live highlighting, definition -pinning, a resizable responsive shell, graph zoom/pan, route-scoped page-scale -and history-swipe locking, and keyboard navigation already exist. Items below -describe what is still required to turn that demonstration into a useful -debugger. +This tracker starts at the current canonical Play debugger: an admitted host +inspection artifact, bounded correlated receipt/inspection history, a focused +machine graph, conservative live highlighting, machine pinning, responsive +debug chrome, graph zoom/pan, route-scoped page-scale and history-swipe +locking, and keyboard navigation already exist. Items below describe what is +still required to turn that foundation into a useful debugger. Ownership stays local. Engine and CLI prerequisites are tracked beside their implementations rather than being hidden in this browser backlog: @@ -25,10 +25,10 @@ implementations rather than being hidden in this browser backlog: - **Difficulty L** — substantial UI architecture, performance, or cross-host integration. - **Difficulty XL** — runtime-semantics or protocol-lifecycle project. -- **Engine work: No** — the current inspection artifact and step records are - sufficient. -- **Engine work: Conditional** — the browser-owned version is possible now, - but a stronger form requires the engine tracker. +- **Engine work: No** — admitted host inspection plus correlated machine + receipts and inspections are sufficient. +- **Engine work: Conditional** — a conservative browser-owned version is + possible now, but a stronger claim requires the core tracker. - **Engine work: Yes** — blocked on an item in the core inspection tracker. ## P0 — make recorded execution useful @@ -37,85 +37,95 @@ implementations rather than being hidden in this browser backlog: inspection mode.** - **Owner:** `inspection-store.ts`, `debug-controller.ts`, and `debug-surface.ts`. - - Present the already-retained bounded history; do not add another history - buffer in the visualization. + - Present the store's already-retained receipt/inspection pairs; do not add a + second history buffer in the visualization. - Provide Live/Pause, previous/next step, direct step selection, and a clear indication when the graph is showing history rather than the running tip. - - Keep historical inspection observational. Selecting an old record must - never mutate, pause, or restore the runtime. + - Keep historical inspection observational. Selecting an old publication + must never mutate, pause, or restore the Wasm `Session`. - Returning to Live must resume from the newest publication without losing - steps received while the viewer was paused. - -- [ ] **[P0][M][Engine work: No] Isolate and narrate the causal path for one - recorded step.** - - **Owner:** `debug-model.ts`, `debug-layout.ts`, and `debug-surface.ts`. - - Derive an explicit sequence from facts already present in the trace: - event, consulted guards, selected handler, writes, sends, structural - effects, outcomes, and projection application. - - Add a “taken path only” view that dims or hides unrelated topology without - changing the underlying graph identity. - - Show before/after values from adjacent retained snapshots for state that - changed in the selected step. - - Never invent expression values that the trace does not record. Richer - evaluated facts belong in the core tracker. + receipts received while the viewer was paused. + +- [ ] **[P0][M][Engine work: Conditional] Narrate the defensible causal path + for one receipt.** + - **Owner:** `session.ts`, `adapter-host.ts`, `debug-model.ts`, + `debug-layout.ts`, and `debug-surface.ts`. + - Start from facts the canonical boundary actually publishes: resolved local + or port input, reaction disposition or fault, ordered commands, state + differences between adjacent inspections, and the post-observation. + - Correlate those facts only with nodes and edges in the admitted interaction + graph. A “taken path” view may dim unrelated topology, but must label + conservative context separately from proven activity. + - Preserve exact port identity for adapter-delivered inputs and port-bound + commands; the browser must not reconstruct provider or router semantics. + - Evaluated guard values, internal transition paths, and expression + provenance require an explicit core inspection addition. Never infer them + from the rendered UI. ## P1 — product-quality browser debugger -- [ ] **[P1][L][Engine work: No] Make large definitions navigable.** +- [ ] **[P1][L][Engine work: No] Make large machine graphs navigable.** - **Owner:** `debug-model.ts`, `debug-layout.ts`, `debug-surface.ts`, and `shell.css`. - Build on the existing zoom/pan camera with search, semantic filters, fit-to-selection, and a compact overview or minimap. - Allow unrelated branches to collapse while preserving a stable route back - to the full definition. - - Keep lane labels and current-step context visible while the canvas scrolls. + to the full admitted machine graph. + - Keep lane labels and current-receipt context visible while the canvas + scrolls. - Preserve the existing roving-tab-stop and arrow-navigation contract. - - Validate against the current 77-node/115-edge feed definition and at least - one larger synthetic fixture. + - Validate against the Instagram machine graph and at least one larger + generated graph fixture; record node/edge counts in the test rather than in + this backlog. - [ ] **[P1][L][Engine work: No] Replace whole-graph rerenders with incremental - runtime decoration.** + receipt decoration.** - **Owner:** `debug-model.ts`, `debug-layout.ts`, and `debug-surface.ts`. - - Cache static topology and geometry by `(programHash, focusDefinitionId)`. - - Rebuild the graph only when the checked program or focused definition - changes; otherwise patch node classes, status text, edge activity, summary, - and selection details by stable ID. - - Avoid layout reads after graph writes on ordinary runtime steps. + - Cache static topology and geometry by + `(machineProgramHash, focusDefinitionId)`. + - Rebuild only when the admitted deployment or focused machine changes; + otherwise patch node classes, status text, edge activity, summary, and + selection details by stable ID. + - Avoid layout reads after graph writes on ordinary receipt publications. - Add a repeatable performance fixture and define budgets for update time, allocations, and dropped frames before optimizing further. - Keep the accessible SVG-edge/HTML-node renderer unless profiling isolates edge paint as the bottleneck. A Canvas edge layer is a measured fallback, - not an out-of-the-box renderer switch. + not an automatic renderer switch. -- [ ] **[P1][S][Engine work: No] Split ambiguous Follow-live behavior into an - explicit policy.** +- [ ] **[P1][S][Engine work: No] Make live-machine following explicit.** - **Owner:** `debug-model.ts` and `debug-surface.ts`. - - Decide whether the default follows the latest dispatch origin, the topmost - mounted definition, or exposes both as separate modes. - - Preserve the transition source long enough to explain navigation without - leaving the debugger apparently stuck on the prior page. - - Cover navigation, replace, back, surface open/dismiss, and provider outcome - delivery in model tests. + - The runtime machine from the admitted deployment is the sole live target. + Imported machines may be pinned for static inspection, but must never be + decorated as if they were the running instance. + - Rename UI copy if “Follow live” can be mistaken for route, page, component, + or DOM focus. + - Cover switching between the running machine and a pinned imported machine, + then returning to the newest receipt without changing the session. - [ ] **[P1][M][Engine work: No] Add checked-in real-browser and accessibility regressions.** - **Owner:** `tests/` plus the repository's browser-test harness. - - Exercise the actual Play route: open/close, lazy subscription, live - transition highlights, definition pinning, Follow live, historical mode, + - Exercise the actual Play route: open/close, lazy subscription, genesis and + reaction highlights, machine pinning, Follow live, historical mode, keyboard graph navigation, and disposal. + - Include one adapter-delivered port input and one emitted port command so the + test crosses the real `adapter-host.ts` boundary. - Cover wide right-dock, narrow bottom-dock, and compact takeover layouts, including bounds and overflow assertions. - - Add an automated accessibility audit and a short manual assistive- - technology checklist; unit DOM contracts are not a substitute for either. + - Add an automated accessibility audit and a short manual + assistive-technology checklist; unit DOM contracts are not a substitute for + either. - Add targeted visual snapshots only for layout states whose geometry is part of the contract. -- [ ] **[P1][M][Engine work: No] Turn source spans into source navigation.** +- [ ] **[P1][M][Engine work: No] Turn admitted source spans into source + navigation.** - **Owner:** `debug-surface.ts` for the interaction; the safe source contract is owned by the [CLI Play-host TODO](../../../crates/uhura-cli/src/cmd/TODO.md). - Show a small source excerpt and provide an Open-in-Editor action. - - Bind every excerpt to the source revision/hash that produced the inspected - program; never display current bytes against a stale span. + - Bind every excerpt to the machine program/deployment identity that produced + the inspected graph; never display current bytes against a stale span. - Treat UTF-8 byte offsets as bytes throughout the host boundary. ## P2 — measured hardening and optional capabilities @@ -123,35 +133,35 @@ implementations rather than being hidden in this browser backlog: - [ ] **[P2][M][Engine work: No] Replace count-only browser retention with a measured byte budget.** - **Owner:** `inspection-store.ts` and the timeline UI. - - Keep the existing hard step-count ceiling as a safety backstop, but evict - by measured payload size so large state snapshots cannot dominate memory. - - Make truncation visible in the timeline and preserve the newest coherent - step boundary. + - Keep the existing hard publication-count ceiling as a safety backstop, but + evict by measured payload size so large inspections cannot dominate memory. + - Make truncation visible in the timeline and preserve complete correlated + receipt/inspection pairs. - Measure representative projects before choosing defaults. - [ ] **[P2][M][Engine work: Conditional] Export and reopen observational inspection sessions.** - **Owner:** `inspection-store.ts`, protocol types, and a small Play UI seam. - - Export versioned program metadata plus retained records, with explicit - truncation and redaction metadata. - - Reopening an export is an offline viewer, not deterministic runtime replay. - - Exact replay or runtime restoration is blocked on the core runtime-control - item; do not imply that an observational export can reproduce provider - effects. - -- [ ] **[P2][M][Engine work: Yes] Represent component runtime instances without - misleading static values.** - - **Owner:** browser presentation after the core inspection contract decides - whether components have inspectable runtime identity. - - Until that contract exists, label component graphs as static topology when - instance-specific state is unavailable. - - See the component-instance item in the - [Core inspection TODO](../../../crates/uhura-core/src/TODO.md). + - Export versioned host deployment identity plus retained correlated + publications, with explicit truncation and redaction metadata. + - Reopening an export is an offline viewer, not deterministic session replay. + - Exact replay or runtime restoration needs an explicit session/checkpoint + design; do not imply that observational records reproduce foreign effects. + +- [ ] **[P2][L][Engine work: Yes] Visualize richer transition internals only + after the machine protocol can prove them.** + - **Owner:** browser presentation after the core inspection contract defines + any evaluated guard, transition, or expression-provenance facts. + - Until then, keep receipt decoration conservative and explain its limits in + the details pane. + - Do not revive a browser-side evaluator or a second trace schema. ## Non-goals for the browser backlog - Reimplementing the Uhura evaluator in TypeScript. -- Rewinding the running session by assigning old browser snapshots. -- Inferring guard values or provider effects that the engine did not record. +- Rewinding the running `Session` by assigning old browser inspections. +- Inferring guards, internal transition paths, or foreign effects that receipts + do not record. +- Letting an adapter provider bypass `adapter-host.ts` contract admission. - Making inspection data safe for an untrusted or public Play deployment solely by hiding fields in the DOM. diff --git a/web/src/play/adapter-host.test.ts b/web/src/play/adapter-host.test.ts new file mode 100644 index 0000000..cfab90c --- /dev/null +++ b/web/src/play/adapter-host.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import type { + ResolvedInput, + Value, +} from "../protocol/machine.js"; +import type { DeliveryQueue } from "./adapter-host.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + createAdapterHost, + createDeliveryQueue, + WEB_HISTORY_ADAPTER, +} from "./adapter-host.js"; +import { hash } from "../protocol/machine.js"; + +const text = (value: string): Value => ({ $: "Text", value }); + +const textOf = (input: ResolvedInput): string => { + assert.equal(input.value.$, "Text"); + return input.value.value; +}; + +test("adapter deliveries are deferred, FIFO, and drained from snapshots", () => { + const tasks: (() => void)[] = []; + const delivered: string[] = []; + let queue!: DeliveryQueue; + queue = createDeliveryQueue( + (input) => { + const value = textOf(input); + delivered.push(value); + if (value === "first") { + queue.enqueue({ source: "port", port: "router", value: text("later") }); + } + }, + (task) => { tasks.push(task); }, + ); + + queue.enqueue({ source: "port", port: "router", value: text("first") }); + queue.enqueue({ source: "port", port: "router", value: text("second") }); + assert.deepEqual(delivered, []); + assert.equal(tasks.length, 1); + + tasks.shift()?.(); + assert.deepEqual(delivered, ["first", "second"]); + assert.equal(tasks.length, 1); + + tasks.shift()?.(); + assert.deepEqual(delivered, ["first", "second", "later"]); +}); + +test("an admitted adapter receives commands in order and reports later inputs", () => { + const contractHash = hash("0".repeat(64)); + const contractInstanceHash = hash("1".repeat(64)); + const tasks: (() => void)[] = []; + const accepted: string[] = []; + const delivered: ResolvedInput[] = []; + const host = createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash, + contractInstanceHash, + }], + adapters: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash, + contractInstanceHash, + accept(command, context): void { + assert.equal(command.$, "Text"); + accepted.push(command.value); + context.deliver(text(`changed:${command.value}`)); + }, + }], + deliver(input) { + delivered.push(input); + }, + schedule(task) { + tasks.push(task); + }, + }); + + host.publish([ + { target: "port", port: "router", value: text("/orders") }, + { target: "port", port: "router", value: text("/returns") }, + ]); + + assert.deepEqual(accepted, ["/orders", "/returns"]); + assert.deepEqual(delivered, []); + assert.equal(tasks.length, 1); + + tasks.shift()?.(); + assert.deepEqual(delivered.map(textOf), [ + "changed:/orders", + "changed:/returns", + ]); + host.dispose(); +}); + +test("adapter admission is complete and contract checked", () => { + const expected = hash("1".repeat(64)); + const incompatible = hash("2".repeat(64)); + const instance = hash("3".repeat(64)); + const incompatibleInstance = hash("4".repeat(64)); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [], + deliver() {}, + }), + /missing Uhura adapter/u, + ); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: incompatible, + contractInstanceHash: instance, + accept() {}, + }], + deliver() {}, + }), + /incompatible admitted identity/u, + ); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: incompatibleInstance, + accept() {}, + }], + deliver() {}, + }), + /incompatible admitted identity/u, + ); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [{ + port: "router", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + accept() {}, + }], + deliver() {}, + }), + /incompatible admitted identity/u, + ); + + const compatible = { + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + accept() {}, + } as const; + assert.throws( + () => createAdapterHost({ + requirements: [], + adapters: [compatible], + deliver() {}, + }), + /undeclared Uhura adapter/u, + ); + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [compatible, compatible], + deliver() {}, + }), + /duplicate Uhura adapter/u, + ); +}); diff --git a/web/src/play/adapter-host.ts b/web/src/play/adapter-host.ts new file mode 100644 index 0000000..255c5a0 --- /dev/null +++ b/web/src/play/adapter-host.ts @@ -0,0 +1,325 @@ +import type { + Hash, + Inspection, + ReactionStep, + ResolvedCommand, + ResolvedInput, + Value, +} from "../protocol/machine.js"; + +export interface RuntimeSession { + /** + * Runs exactly one admitted reaction. The native/Wasm implementation owns + * FIFO admission, transactional publication, and receipt construction. + */ + submit(input: ResolvedInput): ReactionStep; + inspect(): Inspection; +} + +export const WEB_HISTORY_ADAPTER = "web.history" as const; +export const APPLICATION_PROVIDER_ADAPTER = "app.provider" as const; +export const WEB_ROUTER_CONTRACT = "uhura.web_router@1::Router" as const; + +export type AdapterIdentity = + | typeof WEB_HISTORY_ADAPTER + | typeof APPLICATION_PROVIDER_ADAPTER; + +export interface PortRequirement { + readonly port: string; + readonly adapter: AdapterIdentity; + readonly contractHash: Hash; + readonly contractInstanceHash: Hash; +} + +export interface AdmittedPortRequirement extends PortRequirement { + readonly contract: string; +} + +/** The browser-visible mirror of the host's sealed adapter table. */ +export const assertSupportedAdapterBinding = ( + requirement: AdmittedPortRequirement, +): void => { + const adapter: string = requirement.adapter; + switch (adapter) { + case WEB_HISTORY_ADAPTER: + if (requirement.contract !== WEB_ROUTER_CONTRACT) { + throw new TypeError( + `Uhura adapter ${JSON.stringify(WEB_HISTORY_ADAPTER)} cannot implement ${JSON.stringify(requirement.contract)}`, + ); + } + return; + case APPLICATION_PROVIDER_ADAPTER: + return; + default: + throw new TypeError( + `unknown sealed Uhura adapter ${JSON.stringify(adapter)}`, + ); + } +}; + +export interface AdapterRequirementPartition { + readonly browser: readonly AdmittedPortRequirement[]; + readonly provider: readonly AdmittedPortRequirement[]; +} + +/** + * Partitions admitted ownership without guessing from a contract family. + * `web.history` is deliberately singular in the current sealed table. + */ +export const partitionAdapterRequirements = ( + requirements: readonly AdmittedPortRequirement[], +): AdapterRequirementPartition => { + const browser: AdmittedPortRequirement[] = []; + const provider: AdmittedPortRequirement[] = []; + for (const requirement of requirements) { + assertSupportedAdapterBinding(requirement); + if (requirement.adapter === WEB_HISTORY_ADAPTER) browser.push(requirement); + else provider.push(requirement); + } + if (browser.length > 1) { + throw new TypeError( + `Uhura adapter ${JSON.stringify(WEB_HISTORY_ADAPTER)} may own at most one port`, + ); + } + return { browser, provider }; +}; + +export interface PortAdapterContext { + readonly signal: AbortSignal; + /** + * Reports one later port input. The bridge always schedules delivery; this + * callback can never synchronously reenter a machine reaction. + */ + deliver(value: Value): void; +} + +export interface PortAdapter { + readonly port: string; + readonly adapter: AdapterIdentity; + readonly contractHash: Hash; + readonly contractInstanceHash: Hash; + /** + * Starts an observation or browser-capability adapter after the complete + * admitted set exists. Deliveries are always deferred by the host queue. + */ + start?(context: PortAdapterContext): void | Promise; + accept( + command: Value, + context: PortAdapterContext, + ): void | Promise; + dispose?(): void; +} + +export interface DeliveryQueue { + enqueue(input: ResolvedInput): void; + close(): void; +} + +export type Schedule = (task: () => void) => void; + +const defaultSchedule: Schedule = (task) => { + queueMicrotask(task); +}; + +/** + * A small host-boundary queue. Each drain uses a snapshot, so inputs reported + * while a reaction publishes new commands are deferred to a later turn. + */ +export function createDeliveryQueue( + deliver: (input: ResolvedInput) => void, + schedule: Schedule = defaultSchedule, +): DeliveryQueue { + let pending: ResolvedInput[] = []; + let scheduled = false; + let closed = false; + + const requestDrain = (): void => { + if (scheduled || closed) return; + scheduled = true; + schedule(() => { + scheduled = false; + if (closed) return; + const batch = pending; + pending = []; + for (const input of batch) deliver(input); + if (pending.length > 0) requestDrain(); + }); + }; + + return { + enqueue(input): void { + if (closed) { + throw new Error("cannot deliver to a disposed Uhura adapter host"); + } + pending.push(input); + requestDrain(); + }, + close(): void { + closed = true; + pending = []; + }, + }; +} + +export interface AdapterHostOptions { + readonly requirements: readonly PortRequirement[]; + readonly adapters: readonly PortAdapter[]; + readonly deliver: (input: ResolvedInput) => void; + readonly localCommand?: (command: Value) => void; + readonly adapterError?: ( + error: unknown, + port: string, + command?: ResolvedCommand, + ) => void; + readonly schedule?: Schedule; +} + +export interface AdapterHost { + /** Starts every admitted adapter exactly once. */ + start(): void; + /** + * Offers committed commands in semantic order. Adapters may complete in any + * order; promises are observed only for operational error reporting. + */ + publish(commands: readonly ResolvedCommand[]): void; + dispose(): void; +} + +const portTable = ( + adapters: readonly PortAdapter[], +): ReadonlyMap => { + const table = new Map(); + for (const adapter of adapters) { + if (table.has(adapter.port)) { + throw new Error(`duplicate Uhura adapter for port \`${adapter.port}\``); + } + table.set(adapter.port, adapter); + } + return table; +}; + +const admitAdapters = ( + requirements: readonly PortRequirement[], + adapters: ReadonlyMap, +): void => { + const required = new Set(); + for (const requirement of requirements) { + if (required.has(requirement.port)) { + throw new Error(`duplicate Uhura port requirement \`${requirement.port}\``); + } + required.add(requirement.port); + const adapter = adapters.get(requirement.port); + if (!adapter) { + throw new Error(`missing Uhura adapter for port \`${requirement.port}\``); + } + if ( + adapter.adapter !== requirement.adapter + || adapter.contractHash !== requirement.contractHash + || adapter.contractInstanceHash !== requirement.contractInstanceHash + ) { + throw new Error( + `Uhura adapter for \`${requirement.port}\` has an incompatible admitted identity`, + ); + } + } + for (const port of adapters.keys()) { + if (!required.has(port)) { + throw new Error(`undeclared Uhura adapter for port \`${port}\``); + } + } +}; + +/** + * Admits a complete adapter set and creates the only bridge from committed + * commands to foreign work. This object owns no machine semantics. + */ +export function createAdapterHost( + options: AdapterHostOptions, +): AdapterHost { + const adapters = portTable(options.adapters); + admitAdapters(options.requirements, adapters); + const abort = new AbortController(); + const deliveries = createDeliveryQueue( + options.deliver, + options.schedule, + ); + let disposed = false; + let started = false; + + const reportError = ( + error: unknown, + port: string, + command?: ResolvedCommand, + ): void => { + options.adapterError?.(error, port, command); + }; + + const contextFor = (port: string): PortAdapterContext => ({ + signal: abort.signal, + deliver(value): void { + deliveries.enqueue({ + source: "port", + port, + value, + }); + }, + }); + + return { + start(): void { + if (disposed) { + throw new Error("cannot start a disposed Uhura adapter host"); + } + if (started) return; + started = true; + for (const adapter of adapters.values()) { + if (!adapter.start) continue; + try { + const result = adapter.start(contextFor(adapter.port)); + if (result) { + void Promise.resolve(result).catch((error: unknown) => { + reportError(error, adapter.port); + }); + } + } catch (error) { + reportError(error, adapter.port); + } + } + }, + publish(commands): void { + if (disposed) { + throw new Error("cannot publish through a disposed Uhura adapter host"); + } + for (const command of commands) { + if (command.target === "local") { + options.localCommand?.(command.value); + continue; + } + const adapter = adapters.get(command.port); + if (!adapter) { + throw new Error( + `admitted Uhura adapter for \`${command.port}\` disappeared`, + ); + } + const context = contextFor(command.port); + try { + const accepted = adapter.accept(command.value, context); + if (accepted) { + void Promise.resolve(accepted).catch((error: unknown) => { + reportError(error, command.port, command); + }); + } + } catch (error) { + reportError(error, command.port, command); + } + } + }, + dispose(): void { + if (disposed) return; + disposed = true; + abort.abort(); + deliveries.close(); + for (const adapter of adapters.values()) adapter.dispose?.(); + }, + }; +} diff --git a/web/src/play/application-location.test.ts b/web/src/play/application-location.test.ts new file mode 100644 index 0000000..5d71bd0 --- /dev/null +++ b/web/src/play/application-location.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { + applicationPathForBrowser, + browserUrlForApplication, +} from "./application-location.js"; + +describe("Play compatibility location", () => { + it("presents /play as the application's root route", () => { + expect(applicationPathForBrowser({ + pathname: "/play", + search: "?tab=home", + hash: "#top", + })).toBe("/?tab=home#top"); + expect(applicationPathForBrowser({ + pathname: "/search", + search: "?q=uhura", + hash: "", + })).toBe("/search?q=uhura"); + }); + + it("keeps the application's root inside the mounted Play surface", () => { + expect( + browserUrlForApplication("/", "http://localhost/search").pathname, + ).toBe("/play"); + expect( + browserUrlForApplication( + "/profile/mira?tab=posts", + "http://localhost/play", + ).pathname, + ).toBe("/profile/mira"); + }); +}); diff --git a/web/src/play/application-location.ts b/web/src/play/application-location.ts new file mode 100644 index 0000000..f8fa347 --- /dev/null +++ b/web/src/play/application-location.ts @@ -0,0 +1,32 @@ +import type { BrowserLocation } from "../app/router.js"; + +export const PLAY_COMPATIBILITY_PATH = "/play" as const; + +/** + * The host keeps `/` as the friendly Editor entry, while an application's + * checked route table is still allowed to own `/`. `/play` is therefore a + * browser-shell alias for the application's root location, never a second + * route in the machine. + */ +export const applicationPathForBrowser = ( + location: BrowserLocation, +): string => { + const pathname = + location.pathname === PLAY_COMPATIBILITY_PATH + || location.pathname === `${PLAY_COMPATIBILITY_PATH}/` + ? "/" + : location.pathname; + return `${pathname}${location.search}${location.hash}`; +}; + +/** Maps a checked application URL back into the host-owned browser topology. */ +export const browserUrlForApplication = ( + applicationUrl: string, + baseUrl: string, +): URL => { + const destination = new URL(applicationUrl, baseUrl); + if (destination.pathname === "/") { + destination.pathname = PLAY_COMPATIBILITY_PATH; + } + return destination; +}; diff --git a/web/src/play/browser-adapters.test.ts b/web/src/play/browser-adapters.test.ts new file mode 100644 index 0000000..289f180 --- /dev/null +++ b/web/src/play/browser-adapters.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; + +import { hash, type Value } from "../protocol/machine.js"; +import type { PortAdapterContext } from "./adapter-host.js"; +import { + type AdmittedPortRequirement, + APPLICATION_PROVIDER_ADAPTER, + WEB_HISTORY_ADAPTER, +} from "./adapter-host.js"; +import { + createBrowserPortAdapters, + createWebHistoryAdapter, + WEB_ROUTER_CONTRACT, +} from "./browser-adapters.js"; +import type { UhuraProviderHost } from "./provider.js"; + +const requirement: AdmittedPortRequirement = { + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contract: WEB_ROUTER_CONTRACT, + contractHash: hash("1".repeat(64)), + contractInstanceHash: hash("2".repeat(64)), +}; + +const location: Value = { + $: "variant", + type: "example.app@1::Location", + case: "orders", + fields: [], +}; + +const changed: Value = { + $: "variant", + type: "uhura.web_router@1::RouterReceive", + case: "changed", + fields: [{ name: "location", value: location }], +}; + +const command = (kind: "push" | "replace"): Value => ({ + $: "variant", + type: "uhura.web_router@1::RouterSend", + case: kind, + fields: [{ name: "location", value: location }], +}); + +const setup = () => { + let locationListener: ((url: string) => void) | null = null; + const host = { + signal: new AbortController().signal, + pickFile: vi.fn(), + port: vi.fn(() => requirement), + decodeRoute: vi.fn(() => ({ + source: "port" as const, + port: "router", + value: changed, + })), + encodeRoute: vi.fn(() => "/orders"), + onLocation: vi.fn((listener) => { + locationListener = listener; + return vi.fn<() => void>(); + }), + navigate: vi.fn(), + back: vi.fn(), + } satisfies UhuraProviderHost; + const deliver = vi.fn(); + const context = { + signal: new AbortController().signal, + deliver, + } satisfies PortAdapterContext; + return { + host, + context, + emitLocation: (url: string) => { + if (locationListener === null) throw new Error("adapter was not started"); + locationListener(url); + }, + }; +}; + +describe("built-in browser adapters", () => { + it("constructs web history only for a Router assigned to web.history", () => { + const { host } = setup(); + expect(createBrowserPortAdapters([requirement], host)).toHaveLength(1); + expect(createBrowserPortAdapters([{ + ...requirement, + adapter: APPLICATION_PROVIDER_ADAPTER, + }], host)).toEqual([]); + }); + + it("rejects unsupported adapter and contract pairs", () => { + const { host } = setup(); + expect(() => createBrowserPortAdapters([{ + ...requirement, + contract: "uhura.ports@1::RequestPort", + }], host)).toThrow(/cannot implement/u); + expect(() => createBrowserPortAdapters([{ + ...requirement, + adapter: "unknown.adapter", + } as never], host)).toThrow(/unknown sealed Uhura adapter/u); + expect(() => createBrowserPortAdapters([ + requirement, + { ...requirement, port: "router_backup" }, + ], host)).toThrow(/at most one port/u); + }); + + it("decodes committed browser locations through Wasm", () => { + const { host, context, emitLocation } = setup(); + const adapter = createWebHistoryAdapter(requirement, host); + adapter.start?.(context); + emitLocation("/orders?state=open"); + expect(host.decodeRoute).toHaveBeenCalledWith( + "router", + "/orders?state=open", + ); + expect(context.deliver).toHaveBeenCalledWith(changed); + }); + + it("encodes push/replace and delegates back without inventing values", () => { + const { host, context } = setup(); + const adapter = createWebHistoryAdapter(requirement, host); + adapter.accept(command("push"), context); + adapter.accept(command("replace"), context); + adapter.accept({ + $: "variant", + type: "uhura.web_router@1::RouterSend", + case: "back", + fields: [], + }, context); + expect(host.encodeRoute).toHaveBeenCalledTimes(2); + expect(host.navigate).toHaveBeenNthCalledWith(1, "push", "/orders"); + expect(host.navigate).toHaveBeenNthCalledWith(2, "replace", "/orders"); + expect(host.back).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/play/browser-adapters.ts b/web/src/play/browser-adapters.ts new file mode 100644 index 0000000..6a62c9c --- /dev/null +++ b/web/src/play/browser-adapters.ts @@ -0,0 +1,100 @@ +import type { Value } from "../protocol/machine.js"; +import type { + AdmittedPortRequirement, + PortAdapter, + PortAdapterContext, +} from "./adapter-host.js"; +import { + partitionAdapterRequirements, + WEB_HISTORY_ADAPTER, + WEB_ROUTER_CONTRACT, +} from "./adapter-host.js"; +import type { UhuraProviderHost } from "./provider.js"; +export { WEB_ROUTER_CONTRACT } from "./adapter-host.js"; + +const locationField = (command: Value): Value => { + if (command.$ !== "variant") { + throw new TypeError("Uhura web-history command must be a variant"); + } + if (command.fields.length !== 1 || command.fields[0]?.name !== "location") { + throw new TypeError( + `Uhura web-history \`${command.case}\` command must contain exactly one named \`location\` field`, + ); + } + return command.fields[0].value; +}; + +/** + * Implements the sealed browser-history capability for one checked Router + * port. Route encoding and decoding stay in Wasm, so this adapter owns only + * browser effects and never reconstructs an Uhura value or route table. + */ +export const createWebHistoryAdapter = ( + requirement: AdmittedPortRequirement, + host: UhuraProviderHost, +): PortAdapter => { + if (requirement.adapter !== WEB_HISTORY_ADAPTER) { + throw new TypeError( + `Uhura web history cannot take ownership of ${JSON.stringify(requirement.adapter)}`, + ); + } + if (requirement.contract !== WEB_ROUTER_CONTRACT) { + throw new TypeError( + `Uhura web history cannot implement ${JSON.stringify(requirement.contract)}`, + ); + } + let stop: (() => void) | null = null; + return { + port: requirement.port, + adapter: requirement.adapter, + contractHash: requirement.contractHash, + contractInstanceHash: requirement.contractInstanceHash, + start(context: PortAdapterContext): void { + stop = host.onLocation((url) => { + context.deliver(host.decodeRoute(requirement.port, url).value); + }); + }, + accept(command): void { + if (command.$ !== "variant") { + throw new TypeError("Uhura web-history command must be a variant"); + } + switch (command.case) { + case "push": + case "replace": { + const url = host.encodeRoute( + requirement.port, + locationField(command), + ); + host.navigate(command.case, url); + return; + } + case "back": + if (command.fields.length !== 0) { + throw new TypeError( + "Uhura web-history `back` command cannot contain fields", + ); + } + host.back(); + return; + default: + throw new TypeError( + `unknown Uhura web-history command \`${command.case}\``, + ); + } + }, + dispose(): void { + stop?.(); + stop = null; + }, + }; +}; + +/** Creates every host-owned browser adapter required by the checked machine. */ +export const createBrowserPortAdapters = ( + requirements: readonly AdmittedPortRequirement[], + host: UhuraProviderHost, +): PortAdapter[] => { + const { browser } = partitionAdapterRequirements(requirements); + return browser + .map((requirement) => createWebHistoryAdapter(requirement, host)); +}; diff --git a/web/src/play/chrome.ts b/web/src/play/chrome.ts index 54f6f57..2f42bb1 100644 --- a/web/src/play/chrome.ts +++ b/web/src/play/chrome.ts @@ -1,4 +1,4 @@ -// Route-owned Play controls. Frame size, provider, actor, and restart remain +// Route-owned Play controls. Frame size, application actor, and restart remain // host state rather than Uhura application state. import type { SystemState } from "../protocol/types.js"; @@ -160,28 +160,16 @@ export function mountPlayChrome( function renderSystem(system: SystemState): void { if (disposed) return; - renderStatus(system.status, system.error); + const boundary = system.hasProvider + ? "Application adapters admitted" + : "Built-in adapters only"; + renderStatus(system.status, system.error ?? boundary); shell.restart.disabled = system.status === "starting"; - shell.providerControl.hidden = system.providers.length < 2; - - const priorProvider = shell.providerSelect.value; - clearOptions(shell.providerSelect); - for (const provider of system.providers) { - const option = shell.document.createElement("option"); - option.value = provider; - option.textContent = provider === "remote" ? "Remote" : "Fixture"; - shell.providerSelect.append(option); - } - if (system.provider) shell.providerSelect.value = system.provider; - else if (priorProvider) shell.providerSelect.value = priorProvider; - shell.providerSelect.disabled = - system.status === "starting" || system.providers.length < 2; clearOptions(shell.actorSelect); if (system.actors.length === 0) { const option = shell.document.createElement("option"); - option.textContent = - system.provider === "fixture" ? "Fixture identity" : "Unavailable"; + option.textContent = system.hasProvider ? "Not exposed" : "Local session"; shell.actorSelect.append(option); } else { const hasCurrent = system.actors.some((actor) => actor.id === system.actor); @@ -221,12 +209,6 @@ export function mountPlayChrome( renderSystem(detail as SystemState); } }; - const onProviderChange = (): void => { - const provider = shell.providerSelect.value; - if (provider === "remote" || provider === "fixture") { - view.__uhura?.setProvider(provider); - } - }; const onActorChange = (): void => { view.__uhura?.setActor(shell.actorSelect.value); }; @@ -258,7 +240,6 @@ export function mountPlayChrome( }; view.addEventListener("uhura:system-state", onSystemState); - shell.providerSelect.addEventListener("change", onProviderChange); shell.actorSelect.addEventListener("change", onActorChange); shell.fullscreen.addEventListener("click", onFullscreen); shell.document.addEventListener("fullscreenchange", renderFullscreen); @@ -294,7 +275,6 @@ export function mountPlayChrome( autoHideTimer = undefined; observer.disconnect(); view.removeEventListener("uhura:system-state", onSystemState); - shell.providerSelect.removeEventListener("change", onProviderChange); shell.actorSelect.removeEventListener("change", onActorChange); shell.fullscreen.removeEventListener("click", onFullscreen); shell.document.removeEventListener("fullscreenchange", renderFullscreen); diff --git a/web/src/play/debug-controller.ts b/web/src/play/debug-controller.ts index b7d6af8..623e7c8 100644 --- a/web/src/play/debug-controller.ts +++ b/web/src/play/debug-controller.ts @@ -3,14 +3,14 @@ // owns the inspection subscription and coalesces its publications to frames. import type { - InspectionHandle, - InspectionState, + RuntimeInspectionHandle, + RuntimeInspectionState, } from "../protocol/types.js"; export type DebugControllerUpdate = | { readonly kind: "inspection"; - readonly state: InspectionState; + readonly publication: RuntimeInspectionState; } | { readonly kind: "unavailable"; @@ -18,7 +18,7 @@ export type DebugControllerUpdate = export interface DebugControllerOptions { /** Resolved on each closed -> open transition, never while closed. */ - resolveInspection(): InspectionHandle | null | undefined; + resolveInspection(): RuntimeInspectionHandle | null | undefined; requestFrame(callback: () => void): number; cancelFrame(handle: number): void; render(update: DebugControllerUpdate): void; @@ -37,7 +37,7 @@ export interface DebugController { interface SubscriptionSlot { readonly generation: number; - handle: InspectionHandle | null; + handle: RuntimeInspectionHandle | null; stop: (() => void) | null; terminal: boolean; } @@ -141,7 +141,7 @@ export function createDebugController( open = true; const owner = ++generation; - let handle: InspectionHandle | null | undefined; + let handle: RuntimeInspectionHandle | null | undefined; try { handle = options.resolveInspection(); } catch { @@ -163,7 +163,7 @@ export function createDebugController( let stop: () => void; try { - stop = handle.subscribe((state) => { + stop = handle.subscribe((publication) => { if ( disposed || !open @@ -172,8 +172,11 @@ export function createDebugController( ) { return; } - queue(Object.freeze({ kind: "inspection", state }), slot.generation); - if (state.disposed) { + queue( + Object.freeze({ kind: "inspection", publication }), + slot.generation, + ); + if (publication.disposed) { slot.terminal = true; if (slot.stop !== null) release(slot); } @@ -185,7 +188,7 @@ export function createDebugController( } slot.stop = stop; - // InspectionHandle.subscribe replays synchronously. The replay, or a + // RuntimeInspectionHandle.subscribe replays synchronously. The replay, or a // custom handle around it, may close/dispose this controller before the // unsubscribe function is returned. Never retain that late function. if ( diff --git a/web/src/play/debug-layout.ts b/web/src/play/debug-layout.ts index c4642df..fb25431 100644 --- a/web/src/play/debug-layout.ts +++ b/web/src/play/debug-layout.ts @@ -81,12 +81,22 @@ const LANE_LABELS: Readonly> = { }; const KIND_ORDER: Readonly> = { - event: 0, - projection: 1, - state: 2, - handler: 3, - command: 4, - definition: 5, + module: -2, + part: -1, + port: 0, + "ui-event": 1, + input: 2, + transition: 3, + "commit-hook": 4, + computed: 4.5, + invariant: 4.55, + observation: 4.6, + update: 4.7, + state: 5, + command: 6, + outcome: 7, + presentation: 8, + machine: 9, }; function compareText(left: string, right: string): number { diff --git a/web/src/play/debug-model.ts b/web/src/play/debug-model.ts index aa61c50..30f3aca 100644 --- a/web/src/play/debug-model.ts +++ b/web/src/play/debug-model.ts @@ -1,22 +1,40 @@ -// Pure projection from the versioned inspection protocol into the small, -// focused behavior graph consumed by Play's developer UI. Runtime values only -// decorate static nodes: they never decide which nodes exist, so a focused -// graph keeps the same geometry while the machine advances. +// Pure projection from admitted machine topology and immutable runtime +// inspection into the focused behavior graph consumed by Play's developer UI. +// Receipts decorate the checked graph; they never invent topology or claim +// execution details that the machine boundary does not expose. import type { - DeepReadonly, - InspectProgramEdge, - InspectProgramNode, - InspectSourceSpan, - InspectionState, - StepTrace, - TraceGuardNote, + RuntimeInspectionState, } from "../protocol/types.js"; +import type { + GraphEdgeKind, + GraphNodeKind, + GraphSourceRef, + OutcomePolicy, +} from "../protocol/interaction-graph.js"; +import type { + ReactionReceipt, + Receipt, + ResolvedCommand, + ResolvedInput, + Value, +} from "../protocol/machine.js"; export type DebugLane = "input" | "handler" | "effect"; -export type DebugDefinitionKind = "page" | "surface" | "component"; -export type DebugProjectionApply = "applied" | "dropped-stale" | "failed"; +export type DebugDefinitionKind = "machine"; export type DebugEdgeActivity = "idle" | "context" | "taken"; +export type DebugGraphNodeKind = GraphNodeKind; +export type DebugGraphEdgeKind = GraphEdgeKind; + +export interface DebugSourceSpan { + /** Stable source inventory identity from the admitted inspection artifact. */ + readonly id: string | null; + readonly file: string; + /** Inclusive UTF-8 byte offset; this is not a JavaScript string index. */ + readonly start: number; + /** Exclusive UTF-8 byte offset; this is not a JavaScript string index. */ + readonly end: number; +} export interface DebugDefinitionOption { readonly id: string; @@ -24,59 +42,53 @@ export interface DebugDefinitionOption { readonly label: string; readonly entry: boolean; readonly active: boolean; - readonly top: boolean; readonly runtime: boolean; - readonly transitionTarget: boolean; } export interface DebugNodeRuntime { - /** The owning definition is mounted, or this definition target is mounted. */ + /** The node belongs to the admitted machine instance. */ readonly active: boolean; - /** The node participated in the latest retained step. */ + /** The node participated in the latest retained receipt. */ readonly current: boolean; readonly selected: boolean; - readonly consulted: TraceGuardNote["guard"] | null; readonly written: boolean; readonly sent: boolean; - readonly pending: number; - readonly projectionApply: DebugProjectionApply | null; - readonly projectionReady: number; - readonly projectionFailures: number; - readonly structural: boolean; } export interface DebugGraphNode { readonly id: string; - readonly kind: InspectProgramNode["kind"]; + readonly kind: DebugGraphNodeKind; readonly lane: DebugLane; - readonly definitionId: string | null; + readonly definitionId: string; readonly label: string; readonly detail: string | null; - /** Source-order hint. Handler nodes use their absolute handler index. */ + /** Stable source-order hint within a lane. */ readonly order: number; - readonly span: InspectSourceSpan | null; + readonly span: Omit | null; + readonly sourceSpans: readonly DebugSourceSpan[]; readonly runtime: DebugNodeRuntime; } export interface DebugGraphEdge { readonly id: string; - readonly kind: InspectProgramEdge["kind"]; + readonly kind: DebugGraphEdgeKind; readonly from: string; readonly to: string; readonly label: string; - readonly order: number | null; - readonly mode: "push" | "replace" | null; + readonly order: number; readonly activity: DebugEdgeActivity; + readonly sourceSpans: readonly DebugSourceSpan[]; } -export type DebugEmptyReason = "loading" | "disposed" | "no-definitions"; +export type DebugEmptyReason = "loading" | "disposed" | "no-machines"; export interface DebugGraphModel { readonly disposed: boolean; readonly emptyReason: DebugEmptyReason | null; readonly generation: number | null; readonly programHash: string | null; - readonly revision: number | null; + /** Exact machine sequence text. Never projected through a JavaScript number. */ + readonly exactSequence: string | null; readonly focusDefinitionId: string | null; readonly runtimeDefinitionId: string | null; readonly definitions: readonly DebugDefinitionOption[]; @@ -85,26 +97,27 @@ export interface DebugGraphModel { } export interface DeriveDebugGraphOptions { - /** A valid definition pins focus; absent/invalid focus follows the runtime. */ + /** A valid machine ID pins focus; absent/invalid focus follows the runtime. */ readonly focusDefinitionId?: string | null; } -type ProgramNode = DeepReadonly; -type ProgramEdge = DeepReadonly; - -const DEFINITION_KIND_ORDER: Readonly> = { - page: 0, - surface: 1, - component: 2, -}; - -const NODE_KIND_ORDER: Readonly> = { - event: 0, - projection: 1, - state: 2, - handler: 3, - command: 4, - definition: 5, +const NODE_KIND_ORDER: Readonly> = { + module: -2, + part: -1, + port: 0, + "ui-event": 1, + input: 2, + transition: 3, + "commit-hook": 4, + computed: 4.5, + invariant: 4.55, + observation: 4.6, + update: 4.7, + state: 5, + command: 6, + outcome: 7, + presentation: 8, + machine: 9, }; const LANE_ORDER: Readonly> = { @@ -117,39 +130,11 @@ function compareText(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function definitionIdForNode(node: ProgramNode): string | null { - switch (node.kind) { - case "definition": - return node.id; - case "event": - case "handler": - case "state": - return node.definition; - case "command": - case "projection": - return null; - } -} - -/** Maps a canonical dispatch record to the same definition namespace as IR. */ -export function runtimeDefinitionIdForTrace( - trace: DeepReadonly | null, -): string | null { - const dispatch = trace?.dispatch; - if (!dispatch) return null; - if (dispatch.scope.startsWith("page:")) return `pages.${dispatch.definition}`; - if (dispatch.scope.startsWith("surface:")) { - return `surfaces.${dispatch.definition}`; - } - return null; -} - function stableJson(value: unknown): string | undefined { if (value === undefined) return undefined; if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) { - const items = value.map((item) => stableJson(item) ?? "null"); - return `[${items.join(",")}]`; + return `[${value.map((item) => stableJson(item) ?? "null").join(",")}]`; } const record = value as Record; const fields = Object.keys(record) @@ -161,403 +146,232 @@ function stableJson(value: unknown): string | undefined { return `{${fields.join(",")}}`; } -/** Compact deterministic value text; layout never measures this string. */ -export function formatDebugValue(value: unknown, maxLength = 88): string { - const encoded = stableJson(value) ?? "unset"; - if (encoded.length <= maxLength) return encoded; - if (maxLength <= 1) return "…".slice(0, maxLength); - return `${encoded.slice(0, maxLength - 1)}…`; +function renderValue(value: Value): string { + switch (value.$) { + case "unit": + return "unit"; + case "bool": + return String(value.value); + case "Int": + case "Nat": + case "PositiveInt": + case "Decimal": + case "Ratio": + return value.value; + case "BoundaryNumber": + return value.case === "finite" ? value.value : value.case; + case "Text": + return JSON.stringify(value.value); + case "key": + return `${value.type}(${renderValue(value.value)})`; + case "tuple": + return `(${value.items.map(renderValue).join(", ")})`; + case "record": + return `{ ${ + value.fields + .map((field) => `${field.name}: ${renderValue(field.value)}`) + .join(", ") + } }`; + case "variant": { + const fields = value.fields.map((field) => { + const rendered = renderValue(field.value); + return field.name === null ? rendered : `${field.name}: ${rendered}`; + }); + return fields.length === 0 + ? value.case + : `${value.case}(${fields.join(", ")})`; + } + case "seq": + return `[${value.items.map(renderValue).join(", ")}]`; + case "nonempty": + return `NonEmpty[${value.items.map(renderValue).join(", ")}]`; + case "set": + return `Set{${value.items.map(renderValue).join(", ")}}`; + case "map": + return `Map{ ${ + value.entries + .map(([key, entry]) => + `${renderValue(key)}: ${renderValue(entry)}`) + .join(", ") + } }`; + case "table": + return `${value.keyType}{ ${ + value.entries + .map(([key, entry]) => + `${JSON.stringify(key)}: ${renderValue(entry)}`) + .join(", ") + } }`; + } } -function activeDefinitions( - state: InspectionState, -): { ids: Set; top: string | null } { - const snapshot = state.latest?.inspection; - if (!snapshot) return { ids: new Set(), top: null }; - const ids = new Set(); - for (const entry of snapshot.u.nav) ids.add(`pages.${entry.route}`); - for (const surface of snapshot.u.surfaces) { - ids.add(`surfaces.${surface.definition}`); +/** + * Human-facing Uhura value text. Exact numerics remain canonical text and are + * never translated through JavaScript's lossy number domain. + */ +export function formatDebugValue( + value: Value, + maxLength = 120, +): string { + const rendered = renderValue(value); + if ( + value.$ === "Int" + || value.$ === "Nat" + || value.$ === "PositiveInt" + || value.$ === "Decimal" + || value.$ === "Ratio" + || (value.$ === "BoundaryNumber" && value.case === "finite") + ) { + return rendered; } - const topSurface = snapshot.u.surfaces.at(-1); - if (topSurface) return { ids, top: `surfaces.${topSurface.definition}` }; - const topPage = snapshot.u.nav.at(-1); - return { ids, top: topPage ? `pages.${topPage.route}` : null }; + if (rendered.length <= maxLength) return rendered; + if (maxLength <= 1) return "…".slice(0, maxLength); + return `${rendered.slice(0, maxLength - 1)}…`; } -function structuralTargets(trace: DeepReadonly | null): Set { - const targets = new Set(); - const surfaceDefinition = (instance: string): string => - instance.replace(/:\d+$/, ""); - for (const operation of trace?.structural ?? []) { - switch (operation.op) { - case "init": - case "navigate": - case "replace": - targets.add(`pages.${operation.route}`); - break; - case "back": - if (operation.to !== null) targets.add(`pages.${operation.to}`); - break; - case "open-surface": - case "already-open": - case "force-close": - case "dismiss": - targets.add(`surfaces.${surfaceDefinition(operation.surface)}`); - break; - case "nav-underflow": - break; - } - } - return targets; +function constructor(value: Value): string | null { + return value.$ === "variant" ? value.case : null; } -interface DefinitionInstance { - readonly definitionId: string; - readonly scope: string; - readonly state: Readonly>; +function inputLabel(input: ResolvedInput): string | null { + const name = constructor(input.value); + if (name === null) return null; + return input.source === "port" ? `${input.port}.${name}` : name; } -function definitionInstance( - state: InspectionState, - definitionId: string, - exactScope?: string, -): DefinitionInstance | null { - const snapshot = state.latest?.inspection; - if (!snapshot) return null; - if (definitionId.startsWith("pages.")) { - const route = definitionId.slice("pages.".length); - const candidates = snapshot.u.nav.filter((item) => item.route === route); - const entry = exactScope === undefined - ? candidates.at(-1) - : candidates.find((item) => `page:${item.serial}` === exactScope); - return entry - ? { definitionId, scope: `page:${entry.serial}`, state: entry.state } - : null; - } - if (definitionId.startsWith("surfaces.")) { - const definition = definitionId.slice("surfaces.".length); - const candidates = snapshot.u.surfaces.filter( - (item) => item.definition === definition, - ); - const surface = exactScope === undefined - ? candidates.at(-1) - : candidates.find((item) => `surface:${item.serial}` === exactScope); - return surface - ? { - definitionId, - scope: `surface:${surface.serial}`, - state: surface.state, - } - : null; - } - return null; +function commandLabel(command: ResolvedCommand): string | null { + const name = constructor(command.value); + if (name === null) return null; + return command.target === "port" ? `${command.port}.${name}` : name; } -function staticEdgeOrder(edge: ProgramEdge): number | null { - switch (edge.kind) { - case "writes": - case "sends": - case "opens": - case "navigates": - return edge.order; - case "handles": - case "guard-reads": - case "body-reads": - case "settles": - return null; - } +function labelMatches(graphLabel: string, runtimeLabel: string | null): boolean { + return runtimeLabel !== null && graphLabel === runtimeLabel; } -function staticEdgeMode(edge: ProgramEdge): "push" | "replace" | null { - return edge.kind === "navigates" ? edge.mode : null; +function recordFields( + value: Value | null, +): ReadonlyMap { + if (value?.$ !== "record") return new Map(); + return new Map(value.fields.map((field) => [field.name, field.value])); } -function edgeSignature(edge: ProgramEdge): string { - return [ - edge.kind, - edge.from, - edge.to, - String(staticEdgeOrder(edge) ?? -1), - staticEdgeMode(edge) ?? "", - ].join("|"); +function valueEqual( + left: Value | undefined, + right: Value | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return stableJson(left) === stableJson(right); } -function edgeLabel(edge: ProgramEdge): string { - switch (edge.kind) { - case "handles": - return "handles"; - case "guard-reads": - return "guard"; - case "body-reads": - return "reads"; - case "writes": - return "writes"; - case "sends": - return "sends"; - case "opens": - return "opens"; - case "navigates": - return edge.mode; - case "settles": - return "settles"; - } +function sourceSpan(source: GraphSourceRef): DebugSourceSpan { + return { + id: source.id, + file: source.path, + start: source.start, + end: source.end, + }; } -function laneForNode( - node: ProgramNode, - writtenStateIds: ReadonlySet, -): DebugLane { - switch (node.kind) { - case "handler": - return "handler"; - case "event": - case "projection": +function lane(kind: GraphNodeKind): DebugLane { + switch (kind) { + case "module": + case "part": + case "computed": + case "invariant": + case "observation": + case "port": + case "presentation": + case "ui-event": return "input"; + case "input": + case "transition": + case "commit-hook": + case "update": + return "handler"; + case "machine": case "state": - return writtenStateIds.has(node.id) ? "effect" : "input"; case "command": - case "definition": + case "outcome": return "effect"; } } -function definitionDetail(node: Extract): string { - const kind = node["definition-kind"]; - const label = `${kind[0]?.toUpperCase() ?? ""}${kind.slice(1)}`; - return node.entry ? `${label} · entry` : label; +interface RuntimeFacts { + readonly reaction: ReactionReceipt | null; + readonly inputLabel: string | null; + readonly inputPort: string | null; + readonly commandLabels: readonly string[]; + readonly commandPorts: ReadonlySet; + readonly outcomeLabel: string | null; + readonly commit: boolean; + readonly stateFields: ReadonlyMap; + readonly writtenFields: ReadonlySet; } -function projectionDetail( - node: Extract, - state: InspectionState, -): string { - const snapshot = state.latest?.inspection; - if (!snapshot) return `Projection · ${node.port}`; - const ready = snapshot.x.snapshots.filter( - (item) => item.projection === node.name, - ); - const failed = snapshot.x.failed.filter( - (item) => item.projection === node.name, - ); - if (ready.length === 0 && failed.length === 0) return "Waiting"; - if (ready.length === 1 && failed.length === 0) { - return `Ready · ${formatDebugValue(ready[0]?.value)}`; - } - const parts: string[] = []; - if (ready.length > 0) parts.push(`${ready.length} ready`); - if (failed.length > 0) parts.push(`${failed.length} failed`); - return parts.join(" · "); -} - -function presentation( - node: ProgramNode, - state: InspectionState, - instance: DefinitionInstance | null, - pendingByCommand: ReadonlyMap, -): { label: string; detail: string | null; order: number } { - switch (node.kind) { - case "definition": - return { label: node.name, detail: definitionDetail(node), order: 0 }; - case "event": { - const detail = node["event-kind"] === "outcome" - ? `${node.command ?? "command"}.${node.outcome ?? "outcome"}` - : "Semantic event"; - return { label: node.name, detail, order: 0 }; - } - case "handler": - return { - label: `Handler ${node.index + 1}`, - detail: `on ${node.on}${node.guarded ? " · guarded" : ""}`, - order: node.index, - }; - case "state": { - const values = instance?.definitionId === node.definition - ? instance.state - : null; - const value = values && Object.hasOwn(values, node.name) - ? values[node.name] - : node.initial; - const prefix = values ? "" : "Initial · "; - return { - label: node.name, - detail: `${prefix}${formatDebugValue(value)}`, - order: 0, - }; +function runtimeFacts(state: RuntimeInspectionState): RuntimeFacts { + const latest = state.latest; + const receipt = latest?.receipt; + const reaction = receipt?.kind === "reaction" ? receipt : null; + const stateFields = recordFields(latest?.snapshot.state ?? null); + const prior = state.history.length > 1 + ? state.history.at(-2)?.snapshot.state ?? null + : null; + const priorFields = recordFields(prior); + const writtenFields = new Set(); + if (prior !== null) { + for (const [field, value] of stateFields) { + if (!valueEqual(priorFields.get(field), value)) { + writtenFields.add(field); + } } - case "projection": - return { label: node.name, detail: projectionDetail(node, state), order: 0 }; - case "command": { - const pending = pendingByCommand.get(node.name) ?? 0; - const detail = pending > 0 - ? `${pending} pending` - : node.port - ? `Command · ${node.port}` - : "Command"; - return { label: node.name, detail, order: 0 }; + for (const field of priorFields.keys()) { + if (!stateFields.has(field)) writtenFields.add(field); } } -} - -function programSpan( - spans: DeepReadonly>, - id: string, -): InspectSourceSpan | null { - const span = spans[id]; - return span ? { file: span.file, start: span.start, end: span.end } : null; -} - -function projectionRuntime( - node: ProgramNode, - state: InspectionState, - applies: ReadonlyMap, -): { - apply: DebugProjectionApply | null; - ready: number; - failures: number; -} { - if (node.kind !== "projection") return { apply: null, ready: 0, failures: 0 }; - const snapshot = state.latest?.inspection; - return { - apply: applies.get(node.name) ?? null, - ready: snapshot?.x.snapshots.filter( - (item) => item.projection === node.name, - ).length ?? 0, - failures: snapshot?.x.failed.filter( - (item) => item.projection === node.name, - ).length ?? 0, - }; -} - -interface EdgeActivityContext { - readonly currentEventId: string | null; - readonly selectedHandlerId: string | null; - readonly consultedHandlers: ReadonlyMap; - readonly runtimeWrittenIds: ReadonlySet; - readonly sentCommandIds: ReadonlySet; - readonly transitionTargets: ReadonlySet; -} - -interface DebugNodeContext extends EdgeActivityContext { - readonly state: InspectionState; - readonly spans: DeepReadonly>; - readonly writtenStateIds: ReadonlySet; - readonly activeIds: ReadonlySet; - readonly instance: DefinitionInstance | null; - readonly pendingByCommand: ReadonlyMap; - readonly applies: ReadonlyMap; -} - -function debugNode(node: ProgramNode, context: DebugNodeContext): DebugGraphNode { - const { - state, - spans, - writtenStateIds, - activeIds, - currentEventId, - selectedHandlerId, - consultedHandlers, - runtimeWrittenIds, - sentCommandIds, - instance, - pendingByCommand, - applies, - transitionTargets, - } = context; - const definitionId = definitionIdForNode(node); - const view = presentation(node, state, instance, pendingByCommand); - const consulted = consultedHandlers.get(node.id) ?? null; - const written = runtimeWrittenIds.has(node.id); - const sent = sentCommandIds.has(node.id); - const projection = projectionRuntime(node, state, applies); - const structural = transitionTargets.has(node.id); - const selected = node.id === selectedHandlerId; - const active = node.kind === "definition" - ? activeIds.has(node.id) - : definitionId !== null && activeIds.has(definitionId); - const current = node.id === currentEventId - || consulted !== null - || selected - || written - || sent - || projection.apply !== null - || structural; + const commandLabels = reaction?.orderedCommands + .map(commandLabel) + .filter((label): label is string => label !== null) ?? []; + const commandPorts = new Set( + reaction?.orderedCommands.flatMap((command) => + command.target === "port" ? [command.port] : []) ?? [], + ); + const completed = reaction?.resolution.kind === "completed" + ? reaction.resolution + : null; return { - id: node.id, - kind: node.kind, - lane: laneForNode(node, writtenStateIds), - definitionId, - label: view.label, - detail: view.detail, - order: view.order, - span: programSpan(spans, node.id), - runtime: { - active, - current, - selected, - consulted, - written, - sent, - pending: node.kind === "command" - ? pendingByCommand.get(node.name) ?? 0 - : 0, - projectionApply: projection.apply, - projectionReady: projection.ready, - projectionFailures: projection.failures, - structural, - }, + reaction, + inputLabel: reaction ? inputLabel(reaction.input) : null, + inputPort: reaction?.input.source === "port" + ? reaction.input.port + : null, + commandLabels, + commandPorts, + outcomeLabel: completed === null + ? null + : constructor(completed.outcome), + commit: completed?.disposition === "commit", + stateFields, + writtenFields, }; } -function edgeActivity( - edge: ProgramEdge, - context: EdgeActivityContext, -): DebugEdgeActivity { - const { - currentEventId, - selectedHandlerId, - consultedHandlers, - runtimeWrittenIds, - sentCommandIds, - transitionTargets, - } = context; - switch (edge.kind) { - case "handles": - if (edge.from === currentEventId && edge.to === selectedHandlerId) return "taken"; - if (edge.from === currentEventId && consultedHandlers.has(edge.to)) return "context"; - return "idle"; - case "guard-reads": - return consultedHandlers.has(edge.to) ? "context" : "idle"; - case "body-reads": - return edge.to === selectedHandlerId ? "context" : "idle"; - case "writes": - return edge.from === selectedHandlerId && runtimeWrittenIds.has(edge.to) - ? "taken" - : "idle"; - case "sends": - return edge.from === selectedHandlerId && sentCommandIds.has(edge.to) - ? "taken" - : "idle"; - case "opens": - case "navigates": - return edge.from === selectedHandlerId && transitionTargets.has(edge.to) - ? "taken" - : "idle"; - case "settles": - return edge.to === currentEventId ? "taken" : "idle"; - } -} +const receiptObservation = (receipt: Receipt | undefined): Value | null => { + if (receipt === undefined) return null; + return receipt.kind === "reaction" + ? receipt.postObservation + : receipt.initialObservation; +}; function emptyModel( - state: InspectionState, + state: RuntimeInspectionState, reason: DebugEmptyReason, ): DebugGraphModel { return { disposed: state.disposed, emptyReason: reason, - generation: null, - programHash: null, - revision: null, + generation: state.artifacts?.generation ?? null, + programHash: state.artifacts?.deployment.machineProgramHash ?? null, + exactSequence: state.latest?.snapshot.nextSequence ?? null, focusDefinitionId: null, runtimeDefinitionId: null, definitions: [], @@ -566,226 +380,297 @@ function emptyModel( }; } +function nodeDetail( + kind: GraphNodeKind, + label: string, + state: RuntimeInspectionState, + facts: RuntimeFacts, + runtimeMachine: boolean, + policy: OutcomePolicy | null, +): string | null { + const snapshot = state.latest?.snapshot; + const observation = receiptObservation(state.latest?.receipt); + switch (kind) { + case "module": + return "Source module"; + case "machine": + return runtimeMachine && snapshot + ? `Machine · ${snapshot.lifecycle}` + : "Machine"; + case "part": + return "Composed part"; + case "port": + return facts.inputPort === label + ? "Inbound port" + : facts.commandPorts.has(label) + ? "Outbound port" + : "Port"; + case "input": + return facts.reaction && labelMatches(label, facts.inputLabel) + ? `Input · ${formatDebugValue(facts.reaction.input.value)}` + : "Input handler"; + case "transition": + return "Named transition"; + case "commit-hook": + return "Atomic commit hook"; + case "state": { + const value = facts.stateFields.get(label); + return value === undefined ? "State" : formatDebugValue(value); + } + case "computed": + return "Computed read"; + case "invariant": + return "Invariant"; + case "update": + return "Callable update"; + case "observation": + return "Committed observation"; + case "command": { + const commands = facts.reaction?.orderedCommands.filter((command) => + labelMatches(label, commandLabel(command))) ?? []; + if (commands.length === 0) return "Command"; + return commands + .map((command) => formatDebugValue(command.value)) + .join(" · "); + } + case "outcome": { + const resolution = facts.reaction?.resolution; + return resolution?.kind === "completed" + && labelMatches(label, facts.outcomeLabel) + ? `${resolution.disposition} · ${ + formatDebugValue(resolution.outcome) + }` + : policy === null + ? "Outcome" + : `Outcome · ${policy}`; + } + case "presentation": + return runtimeMachine && observation + ? `Observation · ${formatDebugValue(observation)}` + : "Presentation"; + case "ui-event": + return "Checked UI event binding"; + } +} + +function edgeKey( + edge: { readonly kind: GraphEdgeKind; readonly from: string; readonly to: string }, +): string { + return `${edge.kind}\u0000${edge.from}\u0000${edge.to}`; +} + /** - * Produces one definition-sized behavior graph. The returned node and edge set - * depends only on `(program, focusDefinitionId)`; live state changes labels and - * runtime marks without moving or adding graph structure. + * Projects one inspection publication into a stable, machine-sized graph. + * Runtime receipts decorate admitted nodes and edges conservatively. */ export function deriveDebugGraph( - state: InspectionState, + state: RuntimeInspectionState, options: DeriveDebugGraphOptions = {}, ): DebugGraphModel { const artifacts = state.artifacts; - if (!artifacts) return emptyModel(state, state.disposed ? "disposed" : "loading"); - - const program = artifacts.program; - const nodesById = new Map(program.nodes.map((node) => [node.id, node])); - const definitionNodes = program.nodes.filter( - (node): node is Extract => - node.kind === "definition", - ); - if (definitionNodes.length === 0) { - return { - ...emptyModel(state, "no-definitions"), - generation: artifacts.generation, - programHash: program.ir.hash, - revision: state.latest?.inspection.revision ?? null, - }; + if (artifacts === null) { + return emptyModel(state, state.disposed ? "disposed" : "loading"); } - - const trace = state.latest?.trace ?? null; - const runtimeDefinitionId = runtimeDefinitionIdForTrace(trace); - const active = activeDefinitions(state); - const transitionTargets = structuralTargets(trace); - const validDefinitionIds = new Set(definitionNodes.map((node) => node.id)); + const deployment = artifacts.deployment; + const graph = deployment.interactionGraph; + const machineNodes = graph.nodes.filter((node) => node.kind === "machine"); + if (machineNodes.length === 0) { + return emptyModel(state, "no-machines"); + } + const deployedMachineNode = machineNodes.find( + (node) => node.machine === deployment.machine, + ) ?? null; + const validDefinitionIds = new Set(machineNodes.map((node) => node.id)); const requested = options.focusDefinitionId; - const entryId = `pages.${program.ir.entry}`; const focusDefinitionId = requested && validDefinitionIds.has(requested) ? requested - : runtimeDefinitionId && validDefinitionIds.has(runtimeDefinitionId) - ? runtimeDefinitionId - : active.top && validDefinitionIds.has(active.top) - ? active.top - : validDefinitionIds.has(entryId) - ? entryId - : definitionNodes - .map((node) => node.id) - .sort(compareText)[0] ?? null; - - const definitions = definitionNodes + : deployedMachineNode?.id + ?? [...validDefinitionIds].sort(compareText)[0] + ?? null; + const runtimeDefinitionId = state.latest === null + ? null + : deployedMachineNode?.id ?? null; + const definitions = machineNodes .map((node): DebugDefinitionOption => ({ id: node.id, - kind: node["definition-kind"], - label: node.name, - entry: node.entry === true, - active: active.ids.has(node.id), - top: active.top === node.id, - runtime: runtimeDefinitionId === node.id, - transitionTarget: transitionTargets.has(node.id), + kind: "machine", + label: node.label, + entry: node.machine === deployment.machine, + active: node.machine === deployment.machine + && state.latest?.snapshot.lifecycle !== "stopped", + runtime: node.id === runtimeDefinitionId, })) .sort((left, right) => - DEFINITION_KIND_ORDER[left.kind] - DEFINITION_KIND_ORDER[right.kind] - || compareText(left.label, right.label) - || compareText(left.id, right.id)); - + compareText(left.label, right.label) || compareText(left.id, right.id)); if (focusDefinitionId === null) { return { - disposed: false, - emptyReason: "no-definitions", - generation: artifacts.generation, - programHash: program.ir.hash, - revision: state.latest?.inspection.revision ?? null, - focusDefinitionId: null, - runtimeDefinitionId, + ...emptyModel(state, "no-machines"), definitions, - nodes: [], - edges: [], }; } - - const localNodeIds = new Set( - program.nodes - .filter((node) => - node.kind !== "definition" - && definitionIdForNode(node) === focusDefinitionId) - .map((node) => node.id), - ); - const localHandlerIds = new Set( - program.nodes - .filter( - (node) => node.kind === "handler" && node.definition === focusDefinitionId, - ) - .map((node) => node.id), - ); - - const focusedEdges = program.edges.filter((edge) => - localHandlerIds.has(edge.from) || localHandlerIds.has(edge.to)); - const includedNodeIds = new Set(localNodeIds); - for (const edge of focusedEdges) { - includedNodeIds.add(edge.from); - includedNodeIds.add(edge.to); - } - // Commands sent by this definition can settle into its outcome events. - const settleEdges = program.edges.filter((edge) => - edge.kind === "settles" - && includedNodeIds.has(edge.from) - && localNodeIds.has(edge.to)); - const includedEdges = [...focusedEdges, ...settleEdges] - .filter((edge, index, all) => all.indexOf(edge) === index); - - const writtenStateIds = new Set( - focusedEdges.filter((edge) => edge.kind === "writes").map((edge) => edge.to), - ); - const dispatch = trace?.dispatch; - const traceMatchesFocus = runtimeDefinitionId === focusDefinitionId; - // A dispatch identifies one concrete mounted instance. When there is no - // dispatch (for example a projection delivery or a user-pinned definition), - // the topmost mounted instance of that definition is the observable one. - // If the dispatched instance was structurally removed by this step, do not - // fall through to a different duplicate instance with the same definition. - const exactScope = traceMatchesFocus ? dispatch?.scope : undefined; - const instance = definitionInstance( - state, - focusDefinitionId, - exactScope, - ); - const focusScope = exactScope ?? instance?.scope ?? null; - const currentEventId = traceMatchesFocus && dispatch - ? `${focusDefinitionId}/event/${dispatch.on}` - : null; - const selectedHandlerId = traceMatchesFocus && dispatch?.selected !== null - && dispatch?.selected !== undefined - ? `${focusDefinitionId}/handler/${dispatch.selected}` - : null; - const consultedHandlers = new Map(); - if (traceMatchesFocus && dispatch) { - for (const guard of dispatch.guards) { - consultedHandlers.set( - `${focusDefinitionId}/handler/${guard.handler}`, - guard.guard, - ); - } - } - const runtimeWrittenIds = new Set(); - if (traceMatchesFocus && dispatch) { - for (const write of dispatch.writes ?? []) { - runtimeWrittenIds.add(`${focusDefinitionId}/state/${write.field}`); - } - } - const sentCommandIds = new Set(); - if (traceMatchesFocus) { - for (const message of trace?.c ?? []) { - if (message.kind === "command" && message.command) { - sentCommandIds.add(`commands.${message.command}`); - } - } - } - const pendingByCommand = new Map(); - for (const pending of Object.values(state.latest?.inspection.u.pending ?? {})) { - if (focusScope === null || pending.origin !== focusScope) continue; - pendingByCommand.set( - pending.command, - (pendingByCommand.get(pending.command) ?? 0) + 1, - ); + const focusedMachine = machineNodes.find( + (node) => node.id === focusDefinitionId, + )?.machine; + if (focusedMachine === undefined) { + return { + ...emptyModel(state, "no-machines"), + definitions, + }; } - const applies = new Map(); - for (const apply of trace?.applies ?? []) applies.set(apply.projection, apply.apply); - const focusedTransitionTargets: ReadonlySet = traceMatchesFocus - ? transitionTargets - : new Set(); - const debugContext: DebugNodeContext = { - state, - spans: program.spans, - writtenStateIds, - activeIds: active.ids, - currentEventId, - selectedHandlerId, - consultedHandlers, - runtimeWrittenIds, - sentCommandIds, - instance, - pendingByCommand, - applies, - transitionTargets: focusedTransitionTargets, - }; - const nodes = [...includedNodeIds] - .map((id) => nodesById.get(id)) - .filter((node): node is ProgramNode => node !== undefined) - .map((node) => debugNode(node, debugContext)) + const runtimeMachine = focusedMachine === deployment.machine; + const facts: RuntimeFacts = runtimeMachine + ? runtimeFacts(state) + : { + reaction: null, + inputLabel: null, + inputPort: null, + commandLabels: [], + commandPorts: new Set(), + outcomeLabel: null, + commit: false, + stateFields: new Map(), + writtenFields: new Set(), + }; + const nodeSources = new Map( + deployment.graphSources.nodes.map((entry) => [entry.node, entry.sources]), + ); + const included = graph.nodes.filter((node) => node.machine === focusedMachine); + const includedIds = new Set(included.map((node) => node.id)); + const activeMachine = focusedMachine === deployment.machine + && state.latest?.snapshot.lifecycle !== "stopped"; + const nodes = included + .map((node, order): DebugGraphNode => { + const inputCurrent = node.kind === "input" + && labelMatches(node.label, facts.inputLabel); + const commandCurrent = node.kind === "command" + && facts.commandLabels.some((label) => + labelMatches(node.label, label)); + const outcomeCurrent = node.kind === "outcome" + && labelMatches(node.label, facts.outcomeLabel); + const hookCurrent = node.kind === "commit-hook" && facts.commit; + const stateWritten = node.kind === "state" + && facts.writtenFields.has(node.label); + const portCurrent = node.kind === "port" + && (facts.inputPort === node.label || facts.commandPorts.has(node.label)); + const machineCurrent = runtimeMachine + && node.kind === "machine" + && state.latest !== null; + const presentationCurrent = node.kind === "presentation" + && runtimeMachine + && state.latest !== null + && node.label === deployment.presentation; + const current = inputCurrent + || commandCurrent + || outcomeCurrent + || hookCurrent + || stateWritten + || portCurrent + || machineCurrent + || presentationCurrent; + const sources = (nodeSources.get(node.id) ?? []).map(sourceSpan); + const first = sources[0]; + return { + id: node.id, + kind: node.kind, + lane: lane(node.kind), + definitionId: focusDefinitionId, + label: node.label, + detail: nodeDetail( + node.kind, + node.label, + state, + facts, + runtimeMachine, + graph.outcomePolicies[node.id] ?? null, + ), + order, + span: first + ? { file: first.file, start: first.start, end: first.end } + : null, + sourceSpans: sources, + runtime: { + active: activeMachine, + current, + selected: inputCurrent || hookCurrent, + written: stateWritten, + sent: commandCurrent, + }, + }; + }) .sort((left, right) => LANE_ORDER[left.lane] - LANE_ORDER[right.lane] || NODE_KIND_ORDER[left.kind] - NODE_KIND_ORDER[right.kind] || left.order - right.order || compareText(left.id, right.id)); - - const sortedProgramEdges = includedEdges - .map((edge, sourceIndex) => ({ edge, sourceIndex, signature: edgeSignature(edge) })) - .sort((left, right) => - compareText(left.signature, right.signature) - || left.sourceIndex - right.sourceIndex); - const duplicateCounts = new Map(); - const edges = sortedProgramEdges.map(({ edge, signature }): DebugGraphEdge => { - const duplicate = duplicateCounts.get(signature) ?? 0; - duplicateCounts.set(signature, duplicate + 1); - return { - id: `edge/${signature}/${duplicate}`, - kind: edge.kind, - from: edge.from, - to: edge.to, - label: edgeLabel(edge), - order: staticEdgeOrder(edge), - mode: staticEdgeMode(edge), - activity: edgeActivity(edge, debugContext), - }; - }); + const runtimeById = new Map(nodes.map((node) => [node.id, node.runtime])); + const edgeSources = new Map( + deployment.graphSources.edges.map((entry) => [ + edgeKey(entry.edge), + entry.sources, + ]), + ); + const edges = graph.edges + .filter((edge) => includedIds.has(edge.from) && includedIds.has(edge.to)) + .map((edge, order): DebugGraphEdge => { + const from = runtimeById.get(edge.from); + const to = runtimeById.get(edge.to); + let activity: DebugEdgeActivity = "idle"; + switch (edge.kind) { + case "delivers": + if (from?.current && to?.selected) activity = "taken"; + break; + case "writes": + if (from?.current && to?.written) activity = "taken"; + break; + case "emits": + if (from?.current && to?.sent) activity = "taken"; + break; + case "finishes": + case "triggers": + if (from?.current && to?.current) activity = "taken"; + break; + case "sends-via": + if (from?.sent && to?.current) activity = "taken"; + break; + case "dispatches": + if (to?.selected) activity = "context"; + break; + case "projects": + case "exposes": + if (from?.current || to?.current) activity = "context"; + break; + case "owns": + case "composes": + case "reads": + case "calls": + case "observes": + if (to?.current) activity = "context"; + break; + case "delegates": + // Receipts do not expose internal transition paths. + break; + } + const sources = (edgeSources.get(edgeKey(edge)) ?? []).map(sourceSpan); + return { + id: `edge/${edge.kind}/${edge.from}/${edge.to}`, + kind: edge.kind, + from: edge.from, + to: edge.to, + label: edge.kind, + order, + activity, + sourceSpans: sources, + }; + }); return { disposed: false, emptyReason: null, generation: artifacts.generation, - programHash: program.ir.hash, - revision: state.latest?.inspection.revision ?? null, + programHash: graph.machineProgramHashes[focusedMachine] + ?? deployment.machineProgramHash, + exactSequence: state.latest?.snapshot.nextSequence ?? null, focusDefinitionId, runtimeDefinitionId, definitions, diff --git a/web/src/play/debug-surface.ts b/web/src/play/debug-surface.ts index db7ac53..faa117e 100644 --- a/web/src/play/debug-surface.ts +++ b/web/src/play/debug-surface.ts @@ -3,8 +3,8 @@ // focused definition at a time as a deterministic behavior graph. import type { - InspectionHandle, - InspectionState, + RuntimeInspectionHandle, + RuntimeInspectionState, } from "../protocol/types.js"; import { createDebugController, @@ -12,6 +12,7 @@ import { } from "./debug-controller.js"; import { deriveDebugGraph, + formatDebugValue, type DebugDefinitionOption, type DebugGraphModel, type DebugGraphNode, @@ -57,46 +58,42 @@ function capitalized(value: string): string { function definitionText(definition: DebugDefinitionOption): string { const markers: string[] = []; - if (definition.top) markers.push("top"); - else if (definition.active) markers.push("mounted"); + if (definition.active) markers.push("mounted"); if (definition.runtime) markers.push("running"); - if (definition.transitionTarget) markers.push("transition"); if (definition.entry) markers.push("entry"); const suffix = markers.length === 0 ? "" : " · " + markers.join(", "); return capitalized(definition.kind) + " · " + definition.label + suffix; } -function traceEventLabel(state: InspectionState): string { - const trace = state.latest?.trace; - if (!trace) return "waiting for first step"; - if (trace.dispatch) return trace.dispatch.on; - const kind = trace.event["kind"]; - return typeof kind === "string" ? kind : "runtime event"; +function traceEventLabel( + publication: RuntimeInspectionState, +): string { + const receipt = publication.latest?.receipt; + if (!receipt) return "waiting for first step"; + if (receipt.kind === "genesis") return "genesis"; + const value = formatDebugValue(receipt.input.value); + return receipt.input.source === "port" + ? `${receipt.input.port} · ${value}` + : value; } -function traceDisposition(state: InspectionState): string { - const trace = state.latest?.trace; - if (!trace) return "idle"; - if (trace.dispatch?.aborted) { - return "aborted · " + trace.dispatch.aborted; - } - if (trace.drop) return "dropped · " + trace.drop; - if (trace.dispatch?.selected !== null && trace.dispatch?.selected !== undefined) { - return "handler " + String(trace.dispatch.selected + 1); - } - if (trace.reserved) return "reserved · " + trace.reserved.event; - return "state updated"; +function traceDisposition( + publication: RuntimeInspectionState, +): string { + const receipt = publication.latest?.receipt; + if (!receipt) return "idle"; + if (receipt.kind === "genesis") return "admitted"; + const resolution = receipt.resolution; + return resolution.kind === "fault" + ? `fault · ${resolution.fault.code}` + : `${resolution.disposition} · ${formatDebugValue(resolution.outcome)}`; } function nodeStatus(node: DebugGraphNode): string { const runtime = node.runtime; if (runtime.selected) return "selected"; - if (runtime.consulted) return runtime.consulted; if (runtime.written) return "written"; if (runtime.sent) return "sent"; - if (runtime.structural) return "transition"; - if (runtime.projectionApply) return runtime.projectionApply; - if (runtime.pending > 0) return String(runtime.pending) + " pending"; if (runtime.active) return "mounted"; return node.kind; } @@ -112,12 +109,6 @@ function nodeClasses(node: DebugGraphNode, selected: boolean): string { if (runtime.selected) classes.push("is-runtime-selected"); if (runtime.written) classes.push("is-written"); if (runtime.sent) classes.push("is-sent"); - if (runtime.structural) classes.push("is-structural"); - if (runtime.pending > 0) classes.push("is-pending"); - if (runtime.projectionFailures > 0 || runtime.projectionApply === "failed") { - classes.push("has-failure"); - } - if (runtime.consulted) classes.push("is-consulted-" + runtime.consulted); if (selected) classes.push("is-selected"); return classes.join(" "); } @@ -126,21 +117,9 @@ function nodeRuntimeText(node: DebugGraphNode): string { const runtime = node.runtime; const states: string[] = []; if (runtime.active) states.push("mounted"); - if (runtime.selected) states.push("selected handler"); - else if (runtime.consulted) states.push("guard " + runtime.consulted); + if (runtime.selected) states.push("selected this step"); if (runtime.written) states.push("written this step"); if (runtime.sent) states.push("sent this step"); - if (runtime.structural) states.push("structural target"); - if (runtime.pending > 0) states.push(String(runtime.pending) + " pending"); - if (runtime.projectionApply) { - states.push("projection " + runtime.projectionApply); - } - if (runtime.projectionReady > 0) { - states.push(String(runtime.projectionReady) + " projection snapshot"); - } - if (runtime.projectionFailures > 0) { - states.push(String(runtime.projectionFailures) + " projection failure"); - } return states.length === 0 ? "No activity in the latest step" : states.join(" · "); } @@ -148,8 +127,8 @@ function emptyMessage(reason: DebugGraphModel["emptyReason"]): string { switch (reason) { case "disposed": return "Runtime inspection is unavailable for this Play session."; - case "no-definitions": - return "The checked program contains no visualizable definitions."; + case "no-machines": + return "The checked program contains no visualizable machines."; case "loading": return "Waiting for the checked program and first runtime step."; case null: @@ -159,7 +138,7 @@ function emptyMessage(reason: DebugGraphModel["emptyReason"]): string { export function mountPlayDebugSurface( shell: PlayShell, - inspection: InspectionHandle, + inspection: RuntimeInspectionHandle, options: PlayDebugSurfaceOptions = {}, ): PlayDebugSurface { const view = options.window ?? shell.document.defaultView ?? window; @@ -185,12 +164,14 @@ export function mountPlayDebugSurface( let pinnedDefinitionId: string | null = null; let selectedNodeId: string | null = null; let userSelectedNode = false; - let lastState: InspectionState | null = null; + let lastPublication: RuntimeInspectionState | null = null; let currentModel: DebugGraphModel | null = null; let definitionSignature = ""; let lastRuntimeNodeId: string | null = null; + let disclosed = false; function setDisclosure(open: boolean): void { + disclosed = open; shell.debugPanel.hidden = !open; shell.debugToggle.setAttribute("aria-expanded", String(open)); const label = open ? "Close runtime debugger" : "Open runtime debugger"; @@ -237,17 +218,20 @@ export function mountPlayDebugSurface( ); } children.push(activity); - if (node.span) { + const sourceSpans = node.sourceSpans + ?? (node.span ? [{ id: null, ...node.span }] : []); + for (const span of sourceSpans) { children.push( element( shell.document, "p", "uh-debug-source", - node.span.file + span.file + ":" - + String(node.span.start) + + String(span.start) + "-" - + String(node.span.end) + + String(span.end) + + (span.id === null ? "" : ` · ${span.id}`) + " · UTF-8 bytes", ), ); @@ -434,7 +418,7 @@ export function mountPlayDebugSurface( selectedNodeId = runtimeNode.id; } else if (selectedNodeId === null) { selectedNodeId = runtimeNode?.id - ?? model.nodes.find((node) => node.kind === "handler")?.id + ?? model.nodes.find((node) => node.lane === "handler")?.id ?? model.nodes[0]?.id ?? null; } @@ -523,10 +507,12 @@ export function mountPlayDebugSurface( lastRuntimeNodeId = runtimeNodeId; } - function renderInspection(state: InspectionState): void { - lastState = state; + function renderInspection( + publication: RuntimeInspectionState, + ): void { + lastPublication = publication; const previousFocus = currentModel?.focusDefinitionId ?? null; - const model = deriveDebugGraph(state, { + const model = deriveDebugGraph(publication, { focusDefinitionId: followLive ? null : pinnedDefinitionId, }); if (followLive) pinnedDefinitionId = model.focusDefinitionId; @@ -540,26 +526,26 @@ export function mountPlayDebugSurface( if (model.disposed) { shell.debugSummary.textContent = "Debugger unavailable · inspection retired"; - } else if (model.revision === null) { - shell.debugSummary.textContent = model.generation === null - ? "Waiting for checked program…" - : "Program ready · waiting for first runtime step"; - } else { + } else if (model.exactSequence !== null) { shell.debugSummary.textContent = - (model.focusDefinitionId ?? "program") - + " · revision " - + String(model.revision) + (model.focusDefinitionId ?? "machine") + + " · next sequence " + + model.exactSequence + " · " - + traceEventLabel(state) + + traceEventLabel(publication) + " · " - + traceDisposition(state); + + traceDisposition(publication); + } else { + shell.debugSummary.textContent = model.generation === null + ? "Waiting for checked program…" + : "Program ready · waiting for first runtime step"; } renderGraph(model); } function renderUpdate(update: DebugControllerUpdate): void { if (update.kind === "unavailable") { - lastState = null; + lastPublication = null; currentModel = null; definitionSignature = ""; shell.debugDefinition.disabled = true; @@ -577,11 +563,11 @@ export function mountPlayDebugSurface( renderDetails(null); return; } - renderInspection(update.state); + renderInspection(update.publication); } function clearTransientView(): void { - lastState = null; + lastPublication = null; currentModel = null; definitionSignature = ""; lastRuntimeNodeId = null; @@ -612,45 +598,45 @@ export function mountPlayDebugSurface( }); function open(): void { - if (disposed || controller.isOpen) return; + if (disposed || disclosed) return; setDisclosure(true); controller.open(); shell.debugClose.focus(); } function close(restoreFocus: boolean): void { - if (disposed || !controller.isOpen) return; - controller.close(); + if (disposed || !disclosed) return; + if (controller.isOpen) controller.close(); clearTransientView(); setDisclosure(false); if (restoreFocus) shell.debugToggle.focus(); } const onToggle = (): void => { - if (controller.isOpen) close(false); + if (disclosed) close(false); else open(); }; const onClose = (): void => close(true); const onDefinition = (): void => { - if (!lastState || shell.debugDefinition.value.length === 0) return; + if (!lastPublication || shell.debugDefinition.value.length === 0) return; followLive = false; pinnedDefinitionId = shell.debugDefinition.value; selectedNodeId = null; userSelectedNode = false; shell.debugFollowLive.setAttribute("aria-pressed", "false"); - renderInspection(lastState); + renderInspection(lastPublication); }; const onFollowLive = (): void => { - if (!lastState) return; + if (!lastPublication) return; followLive = true; pinnedDefinitionId = null; selectedNodeId = null; userSelectedNode = false; shell.debugFollowLive.setAttribute("aria-pressed", "true"); - renderInspection(lastState); + renderInspection(lastPublication); }; const onPanelKeydown = (event: KeyboardEvent): void => { - if (event.key !== "Escape" || !controller.isOpen) return; + if (event.key !== "Escape" || !disclosed) return; event.preventDefault(); event.stopPropagation(); close(true); @@ -709,12 +695,13 @@ export function mountPlayDebugSurface( return Object.freeze({ get isOpen() { - return controller.isOpen; + return disclosed; }, dispose(): void { if (disposed) return; - const wasOpen = controller.isOpen; + const wasOpen = disclosed; disposed = true; + disclosed = false; controller.dispose(); resizeController.dispose(); viewportController.dispose(); @@ -731,7 +718,7 @@ export function mountPlayDebugSurface( delete shell.container.dataset["debugOpen"]; if (wasOpen) options.onOpenChange?.(false); shell.debugGraphContent.replaceChildren(); - lastState = null; + lastPublication = null; currentModel = null; selectedNodeId = null; }, diff --git a/web/src/play/focus.ts b/web/src/play/focus.ts deleted file mode 100644 index 88a1ed6..0000000 --- a/web/src/play/focus.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Focus mechanics (§8.4), scoped to one mounted Play route. The machine owns -// WHAT gets focus back; this controller owns HOW and cancels queued work when -// the route is disposed. - -import type { Intent } from "../protocol/types.js"; - -const FOCUSABLE = 'button, input, [tabindex="0"]'; - -export interface FocusController { - handleIntents(intents: Intent[]): void; - enterSurface(surfaceEl: HTMLElement): void; - dispose(): void; -} - -export function createFocusController(root: HTMLElement): FocusController { - let active = true; - - function restoreFocus(keyPath: string): void { - if (!active) return; - const escaped = CSS.escape(keyPath); - const element = root.querySelector(`[data-path="${escaped}"]`); - if (!(element instanceof HTMLElement)) return; - const target = element.matches(FOCUSABLE) - ? element - : element.querySelector(FOCUSABLE); - if (target instanceof HTMLElement) target.focus(); - } - - function handleIntents(intents: Intent[]): void { - for (const intent of intents) { - if (intent.intent !== "focus-restore") continue; - const path = intent["key-path"]; - queueMicrotask(() => restoreFocus(path)); - } - } - - function enterSurface(surfaceEl: HTMLElement): void { - if (!active) return; - const target = surfaceEl.querySelector(FOCUSABLE); - if (target instanceof HTMLElement) target.focus(); - else surfaceEl.focus(); - } - - function dispose(): void { - active = false; - } - - return { handleIntents, enterSurface, dispose }; -} diff --git a/web/src/play/inspection-store.ts b/web/src/play/inspection-store.ts index ab52066..d025e98 100644 --- a/web/src/play/inspection-store.ts +++ b/web/src/play/inspection-store.ts @@ -1,19 +1,18 @@ -// Framework-neutral, read-only publication of Uhura's inspection protocol. -// The store retains a bounded trace/state window and deliberately omits full -// view snapshots: Play already owns the current V, while Session.inspect() -// supplies the machine/projection state a behavior visualizer needs. - import type { - InspectSnapshot, - InspectedStep, - InspectionArtifacts, - InspectionHandle, - InspectionListener, - InspectionState, - StepResult, + RuntimeInspectedStep, + RuntimeInspectionArtifacts, + RuntimeInspectionHandle, + RuntimeInspectionListener, + RuntimeInspectionState, } from "../protocol/types.js"; +import type { + Receipt, + RuntimeSnapshot, +} from "../protocol/machine.js"; -export const DEFAULT_INSPECTION_HISTORY_LIMIT = 128; +export const UHURA_INSPECTION_STATE_PROTOCOL = + "uhura-runtime-inspection-state/1" as const; +export const DEFAULT_UHURA_INSPECTION_HISTORY_LIMIT = 128; export interface InspectionStoreOptions { historyLimit?: number; @@ -21,12 +20,9 @@ export interface InspectionStoreOptions { } export interface InspectionStore { - readonly handle: InspectionHandle; - /** Installs the one generation-coherent program artifact for this mount. */ - installArtifacts(artifacts: InspectionArtifacts): boolean; - /** Correlates and publishes one successful dispatch with committed U/X. */ - record(result: StepResult, inspection: InspectSnapshot): boolean; - /** Idempotently clears retained developer data and retires subscriptions. */ + readonly handle: RuntimeInspectionHandle; + installArtifacts(artifacts: RuntimeInspectionArtifacts): boolean; + record(snapshot: RuntimeSnapshot, receipt: Receipt): boolean; dispose(): void; } @@ -41,87 +37,94 @@ function deepFreeze(value: T, seen = new WeakSet()): T { } function frozenState( - state: Omit & { - history: readonly InspectedStep[]; + state: Omit & { + history: readonly RuntimeInspectedStep[]; }, -): InspectionState { - const history = Object.freeze([...state.history]); - return Object.freeze({ ...state, history }); -} - -function assertGeneration(generation: number): void { - if (!Number.isSafeInteger(generation) || generation < 0) { - throw new Error("inspection artifact generation must be a non-negative safe integer"); - } +): RuntimeInspectionState { + return Object.freeze({ + protocol: UHURA_INSPECTION_STATE_PROTOCOL, + ...state, + history: Object.freeze([...state.history]), + }); } -function assertProgram(artifacts: InspectionArtifacts): void { - assertGeneration(artifacts.generation); - const { program } = artifacts; - if (program.protocol !== "uhura-inspect/0" || program.kind !== "program") { - throw new Error("inspection artifact must be an uhura-inspect/0 program"); - } - if (program["span-offset-encoding"] !== "utf-8-bytes") { - throw new Error("inspection artifact spans must use UTF-8 byte offsets"); +function assertArtifacts(artifacts: RuntimeInspectionArtifacts): void { + if (!Number.isSafeInteger(artifacts.generation) || artifacts.generation < 0) { + throw new TypeError( + "Uhura machine inspection artifact generation must be a non-negative safe integer", + ); } - if (program.ir.protocol !== "uhura-ir/0" || program.ir.hash.length === 0) { - throw new Error("inspection artifact must identify a hashed uhura-ir/0 program"); + if (artifacts.deployment.protocol !== "uhura-inspection/1") { + throw new TypeError( + "Uhura machine inspection artifacts must contain uhura-inspection/1 deployment metadata", + ); } } function assertCorrelated( - artifacts: InspectionArtifacts, - previous: InspectedStep | null, - result: StepResult, - inspection: InspectSnapshot, + artifacts: RuntimeInspectionArtifacts, + previous: RuntimeInspectedStep | null, + snapshot: RuntimeSnapshot, + receipt: Receipt, ): void { - if (inspection.protocol !== "uhura-inspect/0" || inspection.kind !== "snapshot") { - throw new Error("inspection step must be an uhura-inspect/0 snapshot"); - } - if (inspection["ir-hash"] !== artifacts.program.ir.hash) { - throw new Error("inspection snapshot IR hash does not match the program artifact"); - } - if (!Number.isSafeInteger(inspection.revision) || inspection.revision < 1) { - throw new Error("inspection snapshot revision must be a positive safe integer"); - } + const deployment = artifacts.deployment; if ( - previous !== null - && inspection.revision <= previous.inspection.revision + snapshot.instance.length === 0 + || snapshot.machineProgramHash !== deployment.machineProgramHash + || snapshot.presentation !== deployment.presentation + || snapshot.presentationHash !== deployment.presentationHash ) { - throw new Error("inspection snapshot revisions must increase monotonically"); - } - if (inspection.u.rev !== inspection.revision) { - throw new Error("inspection U revision does not match its snapshot revision"); - } - if (inspection["u-hash"] !== result.t["u-hash"]) { - throw new Error("inspection U hash does not match the step trace"); + throw new TypeError( + "Uhura machine snapshot does not match the admitted deployment identity", + ); } - if (result.v.revision !== inspection.revision) { - throw new Error("inspection revision does not match the step view revision"); + if ( + receipt.instance !== snapshot.instance + || receipt.machineProgramHash !== snapshot.machineProgramHash + || receipt.configurationHash !== snapshot.configurationHash + ) { + throw new TypeError( + "Uhura machine receipt does not match its runtime snapshot identity", + ); } - if (inspection.view === null) { - throw new Error("a successful Play step inspection must include view metadata"); + const receiptStateHash = receipt.kind === "reaction" + ? receipt.postStateHash + : receipt.initialStateHash; + if (snapshot.stateHash !== receiptStateHash) { + throw new TypeError( + "Uhura machine snapshot state identity does not match its receipt", + ); } - if (inspection.view.revision !== result.v.revision) { - throw new Error("inspection view metadata revision does not match the step view"); + if (BigInt(snapshot.nextSequence) !== BigInt(receipt.sequence) + 1n) { + throw new TypeError( + "Uhura machine snapshot nextSequence must immediately follow its receipt", + ); } - if (inspection.view["v-hash"] !== result.t["v-hash"]) { - throw new Error("inspection view hash does not match the step trace"); + if ( + previous !== null + && BigInt(receipt.sequence) !== BigInt(previous.receipt.sequence) + 1n + ) { + throw new TypeError( + "Uhura machine inspection receipt sequences must increase contiguously", + ); } } export function createInspectionStore( options: InspectionStoreOptions = {}, ): InspectionStore { - const historyLimit = options.historyLimit ?? DEFAULT_INSPECTION_HISTORY_LIMIT; + const historyLimit = + options.historyLimit ?? DEFAULT_UHURA_INSPECTION_HISTORY_LIMIT; if (!Number.isSafeInteger(historyLimit) || historyLimit < 1) { - throw new RangeError("inspection history limit must be a positive safe integer"); + throw new RangeError( + "Uhura machine inspection history limit must be a positive safe integer", + ); } - const onListenerError = options.onListenerError - ?? ((error: unknown) => console.error("uhura inspection listener failed", error)); - const listeners = new Set(); + ?? ((error: unknown) => + console.error("Uhura machine inspection listener failed", error)); + const listeners = new Set(); let state = frozenState({ disposed: false, historyLimit, @@ -131,92 +134,93 @@ export function createInspectionStore( evictedSteps: 0, }); - function notifyOne(listener: InspectionListener, published: InspectionState): void { + function notify( + listener: RuntimeInspectionListener, + publication: RuntimeInspectionState, + ): void { try { - listener(published); + listener(publication); } catch (error) { try { onListenerError(error); } catch { - // Debug listeners and their reporters are observational: neither may - // interrupt Play or prevent the remaining subscribers from running. + // Inspection is observational; neither listeners nor reporters may + // interrupt the machine or prevent the remaining listeners. } } } - function publish(next: InspectionState): void { + function publish(next: RuntimeInspectionState): void { state = next; - for (const listener of [...listeners]) notifyOne(listener, next); + for (const listener of [...listeners]) notify(listener, next); } - function subscribe(listener: InspectionListener): () => void { - if (state.disposed) { - notifyOne(listener, state); - return () => {}; - } - listeners.add(listener); - notifyOne(listener, state); - let subscribed = true; - return () => { - if (!subscribed) return; - subscribed = false; - listeners.delete(listener); - }; - } - - const handle: InspectionHandle = Object.freeze({ + const handle: RuntimeInspectionHandle = Object.freeze({ get state() { return state; }, - subscribe, + subscribe(listener: RuntimeInspectionListener) { + if (state.disposed) { + notify(listener, state); + return () => {}; + } + listeners.add(listener); + notify(listener, state); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + listeners.delete(listener); + }; + }, }); - function installArtifacts(artifacts: InspectionArtifacts): boolean { + function installArtifacts(artifacts: RuntimeInspectionArtifacts): boolean { if (state.disposed) return false; if (state.artifacts !== null) { - throw new Error("inspection artifacts are already installed for this mount"); + throw new Error( + "Uhura machine inspection artifacts are already installed for this mount", + ); } - assertProgram(artifacts); - const installed = deepFreeze(artifacts); - publish(frozenState({ ...state, artifacts: installed })); + assertArtifacts(artifacts); + publish(frozenState({ ...state, artifacts: deepFreeze(artifacts) })); return true; } - function record(result: StepResult, inspection: InspectSnapshot): boolean { + function record( + snapshot: RuntimeSnapshot, + receipt: Receipt, + ): boolean { if (state.disposed) return false; - const { artifacts } = state; - if (artifacts === null) { - throw new Error("inspection artifacts must be installed before recording steps"); + if (state.artifacts === null) { + throw new Error( + "Uhura machine inspection artifacts must be installed before runtime records", + ); } - assertCorrelated(artifacts, state.latest, result, inspection); - - const step = deepFreeze({ trace: result.t, inspection }); + assertCorrelated(state.artifacts, state.latest, snapshot, receipt); + const step = deepFreeze({ snapshot, receipt }); const appended = [...state.history, step]; const evicted = Math.max(0, appended.length - historyLimit); const history = evicted === 0 ? appended : appended.slice(evicted); - publish( - frozenState({ - ...state, - latest: step, - history, - evictedSteps: state.evictedSteps + evicted, - }), - ); + publish(frozenState({ + ...state, + latest: step, + history, + evictedSteps: state.evictedSteps + evicted, + })); return true; } function dispose(): void { if (state.disposed) return; - publish( - frozenState({ - disposed: true, - historyLimit, - artifacts: null, - latest: null, - history: [], - evictedSteps: 0, - }), - ); + publish(frozenState({ + disposed: true, + historyLimit, + artifacts: null, + latest: null, + history: [], + evictedSteps: 0, + })); listeners.clear(); } diff --git a/web/src/play/main.test.ts b/web/src/play/main.test.ts new file mode 100644 index 0000000..30ed637 --- /dev/null +++ b/web/src/play/main.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { assertWasmProtocols } from "./main.js"; + +const protocols = { + browser: "uhura-browser/3", + checkpoint: "uhura-checkpoint/0", + genesisReceipt: "uhura-genesis-receipt/0", + ingressRecord: "uhura-ingress-record/0", + ir: "uhura-ir/1", + reactionReceipt: "uhura-reaction-receipt/0", + runtimeSnapshot: "uhura-runtime-snapshot/0", + view: "uhura-view/1", +} as const; + +describe("Uhura Wasm protocol admission", () => { + it("accepts the one complete protocol set", () => { + expect(() => assertWasmProtocols(protocols)).not.toThrow(); + }); + + it("rejects missing, extra, and drifted protocol declarations", () => { + const missing: Record = { ...protocols }; + delete missing["view"]; + expect(() => assertWasmProtocols(missing)).toThrow(/protocol set mismatch/u); + expect(() => + assertWasmProtocols({ ...protocols, experimental: "example/0" }) + ).toThrow(/protocol set mismatch/u); + expect(() => + assertWasmProtocols({ ...protocols, browser: "uhura-browser/1" }) + ).toThrow(/protocol mismatch/u); + }); +}); diff --git a/web/src/play/main.ts b/web/src/play/main.ts index 8e9a804..40a18b8 100644 --- a/web/src/play/main.ts +++ b/web/src/play/main.ts @@ -1,69 +1,119 @@ -// Mount-owned Uhura Play runtime. Boot remains asynchronous, but every timer, -// stream, global handle, focus task, surface listener, and browser capability -// belongs to one route lifetime and is retired by dispose(). +// Mount-owned Uhura Play runtime. The deterministic machine lives in Wasm; +// this browser layer owns artifacts, rendering, foreign adapters, developer +// inspection, and every effectful capability for one route lifetime. import type { - Descriptor, DevEvent, - Driver, - InspectProgram, - InspectSnapshot, - InspectionHandle, - PlayConfig, - ProviderMode, - ProviderModule, - RemoteDriver, - RemoteSystemInfo, RuntimeHandle, - Snapshot, - StepResult, + RuntimeInspectionHandle, + SystemInfo, } from "../protocol/types.js"; -import type { ResolveAsset } from "../renderer/play.js"; -import type { AssetAppliers } from "../renderer/play.js"; +import { + UHURA_BROWSER_PROTOCOL, + UHURA_RUNTIME_SNAPSHOT_PROTOCOL, + decodeResolvedInput, + type ResolvedInput, + type Value, +} from "../protocol/machine.js"; +import { + decodeHostInspection, + type HostInspection, +} from "../protocol/host-inspection.js"; import { createPlayAssets, - createPlayRenderer, - findScope, -} from "../renderer/play.js"; + type AssetAppliers, +} from "../renderer/assets.js"; import { decodeIconFontManifest, loadIconFontRegistry, + type IconFontRegistry, } from "../renderer/icons.js"; -import { createFocusController } from "./focus.js"; +import { + installLocationConsumer, + publishLocation, +} from "../app/location.js"; +import { routeFor } from "../app/router.js"; import { PlayGenerationGate } from "./generation.js"; import type { GenerationAction } from "./generation.js"; import { createInspectionStore } from "./inspection-store.js"; import { createOverlay } from "./overlay.js"; -import { selectPlayProvider } from "./play-provider-selection.js"; -import { createPump, providerMsgToEvent } from "./pump.js"; +import { + loadUhuraAdapterProvider, + type UhuraAdapterProvider, + type UhuraProviderHost, +} from "./provider.js"; +import { + partitionAdapterRequirements, + type PortRequirement, +} from "./adapter-host.js"; +import { + admitConfiguredPorts, + decodePortRequirements, + decodePlayConfig, + startPlay, + type PlayConfig, + type PlayController, +} from "./session.js"; +import { createBrowserPortAdapters } from "./browser-adapters.js"; +import { + applicationPathForBrowser, + browserUrlForApplication, +} from "./application-location.js"; import { createProviderHost } from "./provider-host.js"; import type { DisposableProviderHost } from "./provider-host.js"; -import { createScrolls } from "./scroll.js"; import type { PlayShell } from "./shell.js"; -import { createSurfaces } from "./surfaces.js"; -import type { SurfaceController } from "./surfaces.js"; import { SYSTEM_ACTOR_STORAGE_KEY, - SYSTEM_PROVIDER_STORAGE_KEY, createSystemControls, } from "./system-controls.js"; -import { createTextFields } from "./textfield.js"; -import { createTicks, DEFAULT_TICK_MS } from "./ticks.js"; const WASM_MODULE_URL = "/api/play/wasm/uhura_wasm.js"; type WasmModule = typeof import("/api/play/wasm/uhura_wasm.js"); +type WasmSession = InstanceType; export const PLAY_ARTIFACT_URLS = [ "/api/play/ir.json", "/api/play/inspect.json", - "/api/play/boot.json", - "/api/play/fixture.json", - "/api/play/script.json", "/api/play/config.json", "/api/play/icon-fonts.json", "/api/play/stylesheet.css", ] as const; +const EXPECTED_PROTOCOLS: Readonly> = { + browser: UHURA_BROWSER_PROTOCOL, + checkpoint: "uhura-checkpoint/0", + genesisReceipt: "uhura-genesis-receipt/0", + ingressRecord: "uhura-ingress-record/0", + ir: "uhura-ir/1", + reactionReceipt: "uhura-reaction-receipt/0", + runtimeSnapshot: UHURA_RUNTIME_SNAPSHOT_PROTOCOL, + view: "uhura-view/1", +}; + +export function assertWasmProtocols(value: unknown): void { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Uhura Wasm protocols must be an object"); + } + const spoken = value as Readonly>; + const expectedKeys = Object.keys(EXPECTED_PROTOCOLS).sort(); + const spokenKeys = Object.keys(spoken).sort(); + if ( + expectedKeys.length !== spokenKeys.length + || expectedKeys.some((key, index) => key !== spokenKeys[index]) + ) { + throw new Error( + `protocol set mismatch: this shell requires exactly [${expectedKeys.join(", ")}], the wasm build declares [${spokenKeys.join(", ")}] — rebuild with scripts/build-wasm.sh`, + ); + } + for (const [name, version] of Object.entries(EXPECTED_PROTOCOLS)) { + if (spoken[name] !== version) { + throw new Error( + `protocol mismatch: this shell speaks ${name} ${version}, the wasm build speaks ${String(spoken[name])} — rebuild with scripts/build-wasm.sh`, + ); + } + } +} + let wasmReady: Promise | undefined; async function loadWasm(): Promise { @@ -82,12 +132,32 @@ async function loadWasm(): Promise { } export interface PlayRuntime { - readonly inspection: InspectionHandle; + readonly inspection: RuntimeInspectionHandle; dispose(): void; } class PlayArtifactsUnavailableError extends Error {} +function assertDeploymentMatchesPlay( + deployment: HostInspection, + play: PlayConfig, +): void { + if ( + deployment.identityProtocol !== play.identityProtocol + || deployment.entry !== play.entry + || deployment.machine !== play.machine + || deployment.presentation !== play.presentation + || deployment.machineProgramHash !== play.machineProgramHash + || deployment.presentationHash !== play.presentationHash + || deployment.evidenceHash !== play.evidenceHash + || deployment.deploymentHash !== play.deploymentHash + ) { + throw new TypeError( + "Uhura host inspection identity differs from the admitted Play deployment", + ); + } +} + function isAbort(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } @@ -103,6 +173,47 @@ function release(value: unknown): void { } } +const parseJson = (source: string, context: string): unknown => { + try { + return JSON.parse(source) as unknown; + } catch (error) { + throw new TypeError(`${context} is not JSON: ${String(error)}`); + } +}; + +const providerSystemInfo = ( + provider: UhuraAdapterProvider | null, + hasProvider: boolean, +): SystemInfo => ({ + ...(provider?.systemInfo?.() ?? {}), + hasProvider, +}); + +async function loadOptionalIcons( + shell: PlayShell, + source: string, + generation: number, +): Promise { + const value = parseJson(source, "Uhura icon-font manifest"); + if ( + typeof value !== "object" + || value === null + || (value as Record)["protocol"] === undefined + ) { + return undefined; + } + const manifest = decodeIconFontManifest(value, "play"); + if (manifest.generation !== generation) { + throw new Error( + `Play icon fonts generation ${String(manifest.generation)} does not match artifact generation ${generation}`, + ); + } + return loadIconFontRegistry({ + document: shell.document, + manifest, + }); +} + /** Starts Play without waiting for its network/provider boot to finish. */ export function startPlayRuntime( shell: PlayShell, @@ -115,12 +226,12 @@ export function startPlayRuntime( const abort = new AbortController(); let disposed = false; let eventSource: EventSource | null = null; - let ticks: ReturnType | null = null; - let surfaces: SurfaceController | null = null; - let focus: ReturnType | null = null; - let scrolls: ReturnType | null = null; let providerHost: DisposableProviderHost | null = null; + let provider: UhuraAdapterProvider | null = null; + let hasProvider = false; let playAssets: AssetAppliers | null = null; + let play: PlayController | null = null; + let pendingSession: WasmSession | null = null; const systemControls = createSystemControls({ target: view, @@ -129,48 +240,19 @@ export function startPlayRuntime( }); const runtime: RuntimeHandle = { session: null, - driver: null, + provider: null, inspection: inspection.handle, get steps() { - return inspection.handle.state.history.map((step) => step.trace); + return inspection.handle.state.history.map((step) => step.receipt); }, - ticks: null, get system() { return systemControls.state; }, restart: () => systemControls.restart(), setActor: (actor: string) => systemControls.setActor(actor), - setProvider: (provider: ProviderMode) => systemControls.setProvider(provider), }; view.__uhura = runtime; - function currentRemoteSystemInfo(): RemoteSystemInfo | undefined { - const driver = runtime.driver as RemoteDriver | null; - if (!driver || typeof driver.systemInfo !== "function") return undefined; - try { - return driver.systemInfo(); - } catch (error) { - console.error("uhura provider system metadata failed", error); - return undefined; - } - } - - async function importProvider(module: string): Promise { - try { - const loaded = (await import(/* @vite-ignore */ module)) as ProviderModule; - view.sessionStorage.removeItem("uh-provider-retry"); - return loaded; - } catch (error) { - if (disposed) throw error; - if (view.sessionStorage.getItem("uh-provider-retry") === null) { - view.sessionStorage.setItem("uh-provider-retry", "1"); - view.location.reload(); - await new Promise(() => {}); - } - throw error; - } - } - async function fetchArtifacts( urls: T, ): Promise<{ texts: { [K in keyof T]: string }; generation: number }> { @@ -182,13 +264,15 @@ export function startPlayRuntime( if (!response.ok) { const message = `${urls[index] ?? "artifact"}: ${response.status}\n${texts[index] ?? ""}`; - if (response.status === 503) throw new PlayArtifactsUnavailableError(message); + if (response.status === 503) { + throw new PlayArtifactsUnavailableError(message); + } throw new Error(message); } }); const artifactGenerations = responses.map((response, index) => { const header = response.headers.get("x-uhura-generation"); - if (header === null || !/^\d+$/.test(header)) { + if (header === null || !/^\d+$/u.test(header)) { throw new Error( `${urls[index] ?? "artifact"}: missing or invalid x-uhura-generation`, ); @@ -199,8 +283,7 @@ export function startPlayRuntime( } return generation; }); - const distinctGenerations = new Set(artifactGenerations); - if (distinctGenerations.size > 1) { + if (new Set(artifactGenerations).size > 1) { if (view.sessionStorage.getItem("uh-gen-retry") === null) { view.sessionStorage.setItem("uh-gen-retry", "1"); view.location.reload(); @@ -210,8 +293,13 @@ export function startPlayRuntime( } view.sessionStorage.removeItem("uh-gen-retry"); const generation = artifactGenerations[0]; - if (generation === undefined) throw new Error("Play has no authoritative artifacts"); - return { texts: texts as { [K in keyof T]: string }, generation }; + if (generation === undefined) { + throw new Error("Play has no authoritative artifacts"); + } + return { + texts: texts as { [K in keyof T]: string }, + generation, + }; } function applyGenerationAction(action: GenerationAction): void { @@ -242,11 +330,120 @@ export function startPlayRuntime( }; events.onmessage = (message: MessageEvent) => { if (disposed) return; - const dev = JSON.parse(message.data) as DevEvent; - applyGenerationAction(generations.event(dev)); + applyGenerationAction(generations.event(JSON.parse(message.data) as DevEvent)); }; } + const providerConfig = ( + config: Readonly>, + ): Readonly> => { + const actor = + view.sessionStorage.getItem(SYSTEM_ACTOR_STORAGE_KEY)?.trim() || null; + return actor === null ? config : { ...config, actor }; + }; + + const adapterBoundary = ( + session: WasmSession, + host: DisposableProviderHost, + requirements: readonly PortRequirement[], + ): UhuraProviderHost => { + const ports = new Map( + requirements.map((requirement) => [requirement.port, requirement]), + ); + return { + signal: host.signal, + pickFile: (options) => host.pickFile(options), + port(name): PortRequirement { + const requirement = ports.get(name); + if (!requirement) { + throw new Error(`Uhura deployment has no admitted port \`${name}\``); + } + return requirement; + }, + decodeRoute(port, url): ResolvedInput { + if (!ports.has(port)) { + throw new Error( + `Uhura adapter boundary has no admitted port \`${port}\``, + ); + } + const input = decodeResolvedInput( + parseJson(session.decode_route(port, url), "Uhura route input"), + "Uhura route input", + ); + if (input.source !== "port" || input.port !== port) { + throw new TypeError( + `Uhura route decoder did not produce an input for \`${port}\``, + ); + } + return input; + }, + encodeRoute(port: string, location: Value): string { + if (!ports.has(port)) { + throw new Error( + `Uhura adapter boundary has no admitted port \`${port}\``, + ); + } + return session.encode_route(port, JSON.stringify(location)); + }, + onLocation(listener): () => void { + if (host.signal.aborted) return () => undefined; + const stop = installLocationConsumer((change) => { + if (change.route.surface !== "play") return; + listener(applicationPathForBrowser(change.location)); + }); + let active = true; + const dispose = (): void => { + if (!active) return; + active = false; + host.signal.removeEventListener("abort", dispose); + stop(); + }; + host.signal.addEventListener("abort", dispose, { once: true }); + return dispose; + }, + navigate(mode, url): void { + if (host.signal.aborted) { + throw new Error( + "cannot navigate through a disposed Uhura provider host", + ); + } + const destination = browserUrlForApplication(url, view.location.href); + if (destination.origin !== view.location.origin) { + throw new Error( + `Uhura web history cannot navigate a different origin: ${destination.origin}`, + ); + } + const route = routeFor(destination.pathname); + if (route.surface !== "play") { + throw new Error( + `Uhura application route ${JSON.stringify(destination.pathname)} is reserved by the host`, + ); + } + const href = + `${destination.pathname}${destination.search}${destination.hash}`; + if (mode === "replace") view.history.replaceState(null, "", href); + else view.history.pushState(null, "", href); + publishLocation({ + cause: mode, + location: { + pathname: destination.pathname, + search: destination.search, + hash: destination.hash, + }, + route, + }); + }, + back(): void { + if (host.signal.aborted) { + throw new Error( + "cannot navigate through a disposed Uhura provider host", + ); + } + view.history.back(); + }, + }; + }; + async function boot(): Promise { const artifacts = await fetchArtifacts(PLAY_ARTIFACT_URLS); const generationAction = generations.artifacts(artifacts.generation); @@ -255,229 +452,135 @@ export function startPlayRuntime( const [ irText, inspectText, - bootText, - fixtureText, - scriptText, playText, iconFontsText, styleText, ] = artifacts.texts; if (disposed) return; - const iconManifest = decodeIconFontManifest(JSON.parse(iconFontsText), "play"); - if (iconManifest.generation !== artifacts.generation) { - throw new Error( - `Play icon fonts generation ${String(iconManifest.generation)} does not match artifact generation ${artifacts.generation}`, - ); - } - const icons = await loadIconFontRegistry({ - document: shell.document, - manifest: iconManifest, - }); - if (disposed) return; - inspection.installArtifacts({ - generation: artifacts.generation, - program: JSON.parse(inspectText) as InspectProgram, - }); - applicationStyle.textContent = styleText; + + const config = decodePlayConfig(parseJson(playText, "Uhura Play config")); + const deployment = decodeHostInspection( + parseJson(inspectText, "Uhura host inspection"), + ); + assertDeploymentMatchesPlay(deployment, config); const wasm = await loadWasm(); if (disposed) return; - const { FixtureDriver, Session, protocols } = wasm; + const spoken = parseJson(wasm.protocols(), "Uhura Wasm protocols"); + assertWasmProtocols(spoken); - const spoken = JSON.parse(protocols()) as Record; - const expected: Record = { - inspect: "uhura-inspect/0", - ir: "uhura-ir/0", - view: "uhura-view/0", - provider: "uhura-provider/0", - }; - for (const [name, version] of Object.entries(expected)) { - if (spoken[name] !== version) { - throw new Error( - `protocol mismatch: this shell speaks ${name} ${version}, the wasm build speaks ${spoken[name]} — rebuild with scripts/build-wasm.sh`, - ); - } - } - - const play = JSON.parse(playText) as PlayConfig; - const storedProvider = view.sessionStorage.getItem(SYSTEM_PROVIDER_STORAGE_KEY); - const selection = selectPlayProvider(play, storedProvider); - if (selection.clearStoredProvider) { - view.sessionStorage.removeItem(SYSTEM_PROVIDER_STORAGE_KEY); - } - const inferredProvider = selection.provider; - const configuredActor = - play.provider.kind === "module" ? play.provider.config.actor ?? null : null; - const storedActor = - view.sessionStorage.getItem(SYSTEM_ACTOR_STORAGE_KEY)?.trim() || null; - const selectedActor = storedActor ?? configuredActor; + hasProvider = config.provider !== null; systemControls.starting({ - provider: inferredProvider, - providers: selection.providers, - actor: inferredProvider === "remote" ? selectedActor : null, + hasProvider, + actor: config.provider === null + ? null + : (view.sessionStorage.getItem(SYSTEM_ACTOR_STORAGE_KEY)?.trim() || null), actors: [], }); - const session = new Session(irText); + const session = new wasm.Session( + irText, + config.machine, + JSON.stringify(config.configuration), + config.instance, + config.presentation ?? undefined, + JSON.stringify({ + identityProtocol: config.identityProtocol, + machineProgramHash: config.machineProgramHash, + presentationHash: config.presentationHash, + }), + ); + pendingSession = session; runtime.session = session; - let driver: Driver; - let resolveAsset: ResolveAsset | undefined; - if (inferredProvider === "remote") { - if (play.provider.kind !== "module") { - throw new Error("remote play was selected without a provider module"); - } - const providerModule = await importProvider(play.provider.module); - if (disposed) return; - if (typeof providerModule.createDriver !== "function") { - throw new Error(`${play.provider.module} must export createDriver(config, host)`); - } - const config = { ...play.provider.config }; - if (selectedActor !== null) config.actor = selectedActor; - providerHost = createProviderHost(abort.signal); - const remote = providerModule.createDriver(config, providerHost); - runtime.driver = remote; - const remoteBoot = await remote.assembleBoot(); - if (disposed) return; - session.boot(remoteBoot); - driver = remote; - if (typeof remote.resolveAsset === "function") { - resolveAsset = remote.resolveAsset.bind(remote); - } - } else { - session.boot(bootText); - driver = new FixtureDriver(fixtureText, scriptText); - runtime.driver = driver; - } - - let currentRevision = 0; - let currentNavKey: string | null = null; - let pageElement: HTMLElement | null = null; - let nextNavToken = 1; - const navFrames: { params: string; token: number }[] = [ - { params: "{}", token: 0 }, - ]; - let pump: ReturnType; - - function emit( - descriptor: Descriptor, - data?: Record, - onApplied?: () => void, - ): void { - if (disposed) return; - const event: Record = { - kind: "ui", - descriptor, - "view-rev": currentRevision, - }; - if (data) event["data"] = data; - pump.enqueue(JSON.stringify(event), onApplied); - } - - const textFields = createTextFields({ emit }); - scrolls = createScrolls({ emit }); - const assets = createPlayAssets(resolveAsset); - playAssets = assets; - const renderer = createPlayRenderer({ - document: shell.document, - emit, - assets, - icons, - textFields, - scrolls, - }); - focus = createFocusController(shell.container); - surfaces = createSurfaces({ - host: shell.surfaceHost, - pageHost: shell.pageHost, - emit, - reconcileChildren: renderer.reconcileChildren, - disposeSubtree: renderer.disposeSubtree, - enterSurface: focus.enterSurface, - }); - - function renderPage(snapshot: Snapshot): void { - const scope = findScope(snapshot.page.root) ?? "page"; - const topFrame = navFrames.at(-1) ?? { params: "{}", token: -1 }; - const navKey = - `${snapshot.page.route}|${navFrames.length}|${topFrame.params}|${topFrame.token}`; - if (!pageElement || currentNavKey !== navKey) { - if (pageElement && currentNavKey !== null) { - scrolls?.savePositions(currentNavKey, pageElement); - } - if (pageElement) renderer.disposeSubtree(pageElement); - shell.pageHost.replaceChildren(); - pageElement = shell.document.createElement("div"); - pageElement.className = "uh-page-root"; - shell.pageHost.append(pageElement); - renderer.reconcileChildren(pageElement, [snapshot.page.root], scope, false); - scrolls?.restorePositions(navKey, pageElement); - currentNavKey = navKey; - } else { - renderer.reconcileChildren(pageElement, [snapshot.page.root], scope, false); - } - } - - function onStep(result: StepResult): void { - if (disposed) return; - currentRevision = result.v.revision; - for (const intent of result.i) { - if (intent.intent === "history-push") { - navFrames.push({ - params: JSON.stringify(intent.params ?? {}), - token: nextNavToken++, - }); - } else if (intent.intent === "history-replace") { - navFrames[navFrames.length - 1] = { - params: JSON.stringify(intent.params ?? {}), - token: nextNavToken++, - }; - } else if (intent.intent === "history-back" && navFrames.length > 1) { - navFrames.pop(); - } - } - renderPage(result.v); - surfaces?.render(result.v); - focus?.handleIntents(result.i); - for (const guard of result.g) { - console.warn(`uhura ${guard.code} ${guard.rule}: ${guard.message}`); - } + providerHost = createProviderHost(abort.signal); + const portRequirements = decodePortRequirements( + session.port_requirements(), + ); + const admittedRequirements = admitConfiguredPorts(portRequirements, config); + const requirements = partitionAdapterRequirements(admittedRequirements); + const browserBoundary = adapterBoundary( + session, + providerHost, + requirements.browser, + ); + const browserAdapters = createBrowserPortAdapters( + requirements.browser, + browserBoundary, + ); + if (config.provider !== null) { + const boundary = adapterBoundary( + session, + providerHost, + requirements.provider, + ); try { - const snapshot = JSON.parse(session.inspect()) as InspectSnapshot; - inspection.record(result, snapshot); + const loadedProvider = await loadUhuraAdapterProvider( + config.provider.module, + providerConfig(config.provider.config), + boundary, + requirements.provider, + ); + if (disposed) { + release(loadedProvider); + return; + } + provider = loadedProvider; + view.sessionStorage.removeItem("uh-provider-retry"); } catch (error) { - // Inspection is observational. A tooling failure must not interrupt - // the already-committed machine step or the renderer/provider pump. - console.error("uhura inspection failed", error); - inspection.dispose(); + if ( + !disposed + && view.sessionStorage.getItem("uh-provider-retry") === null + ) { + view.sessionStorage.setItem("uh-provider-retry", "1"); + view.location.reload(); + await new Promise(() => {}); + } + throw error; } - console.debug("uhura-step", JSON.stringify(result.t)); } + if (disposed) return; - pump = createPump({ - dispatch: (eventJson) => session.dispatch(eventJson), - deliver: (commandJson) => driver.deliver(commandJson), - onStep, - onError: (error, eventJson) => { + const icons = await loadOptionalIcons( + shell, + iconFontsText, + artifacts.generation, + ); + if (disposed) return; + applicationStyle.textContent = styleText; + const resolveAsset = provider?.resolveAsset?.bind(provider); + playAssets = createPlayAssets(resolveAsset); + inspection.installArtifacts({ + generation: artifacts.generation, + deployment, + }); + play = startPlay({ + shell, + session, + config, + adapters: [...browserAdapters, ...(provider?.adapters ?? [])], + assets: playAssets, + icons, + resolveLinkHref(href): string { + const destination = browserUrlForApplication(href, view.location.href); + return `${destination.pathname}${destination.search}${destination.hash}`; + }, + publishRuntimeStep(snapshot, receipt): void { + inspection.record(snapshot, receipt); + }, + onProjectionError(error): void { if (disposed) return; - console.error("uhura dispatch failed", error, eventJson); - overlay.showFatal(`dispatch failed: ${String(error)}\n\nevent: ${eventJson}`); + console.error("Uhura presentation projection failed; machine continues", error); + }, + onError(error): void { + if (disposed) return; + console.error("Uhura Play failed", error); + overlay.showFatal(String(error)); }, }); - - ticks = createTicks({ - tick: () => driver.tick(), - idle: () => driver.idle(), - enqueue: (eventJson) => pump.enqueue(eventJson), - toEvent: providerMsgToEvent, - intervalMs: DEFAULT_TICK_MS, - }); - - const entry = String((JSON.parse(irText) as { entry?: unknown }).entry); - pump.enqueue(JSON.stringify({ kind: "init", route: entry, params: {} })); + pendingSession = null; + runtime.provider = provider; if (disposed) return; - ticks.start(); - runtime.ticks = ticks; - systemControls.ready(currentRemoteSystemInfo()); + systemControls.ready(providerSystemInfo(provider, hasProvider)); } try { @@ -489,7 +592,10 @@ export function startPlayRuntime( void boot().catch((error: unknown) => { if (disposed || isAbort(error)) return; - systemControls.failed(error, currentRemoteSystemInfo()); + systemControls.failed( + error, + providerSystemInfo(provider, hasProvider), + ); if (error instanceof PlayArtifactsUnavailableError) { const action = generations.unavailable(); applyGenerationAction(action); @@ -510,24 +616,19 @@ export function startPlayRuntime( eventSource.close(); eventSource = null; } - ticks?.stop(); - ticks = null; - surfaces?.dispose(); - surfaces = null; - focus?.dispose(); - focus = null; - scrolls?.dispose(); - scrolls = null; + play?.dispose(); + play = null; + if (pendingSession) release(pendingSession); + pendingSession = null; playAssets?.dispose?.(); playAssets = null; - inspection.dispose(); - release(runtime.driver); + release(provider); + provider = null; providerHost?.dispose(); providerHost = null; - release(runtime.session); - runtime.driver = null; + inspection.dispose(); + runtime.provider = null; runtime.session = null; - runtime.ticks = null; applicationStyle.textContent = ""; overlay.hide(); if (view.__uhura === runtime) delete view.__uhura; diff --git a/web/src/play/play-provider-selection.ts b/web/src/play/play-provider-selection.ts deleted file mode 100644 index 04f437f..0000000 --- a/web/src/play/play-provider-selection.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Pure selection policy for the browser Play runtime. A live-provider profile -// may keep its fixture for Editor/check/trace without exposing that deliberately -// partial script as an interactive Play backend. - -import type { PlayConfig, ProviderMode } from "../protocol/types.js"; - -export function selectPlayProvider( - play: PlayConfig, - storedProvider: string | null, -): { - provider: ProviderMode; - providers: ProviderMode[]; - clearStoredProvider: boolean; -} { - const hasRemote = play.provider.kind === "module"; - const providers: ProviderMode[] = hasRemote - ? play.allow_fixture === false - ? ["remote"] - : ["remote", "fixture"] - : ["fixture"]; - const storedCandidate = - storedProvider === "remote" || storedProvider === "fixture" - ? storedProvider - : null; - const override = - storedCandidate !== null && providers.includes(storedCandidate) - ? storedCandidate - : null; - return { - provider: override ?? (hasRemote ? "remote" : "fixture"), - providers, - clearStoredProvider: - storedProvider !== null && - (storedCandidate === null || !providers.includes(storedCandidate)), - }; -} diff --git a/web/src/play/provider.test.ts b/web/src/play/provider.test.ts new file mode 100644 index 0000000..bc609f5 --- /dev/null +++ b/web/src/play/provider.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; + +import { hash } from "../protocol/machine.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + WEB_HISTORY_ADAPTER, + type PortAdapter, + type PortRequirement, +} from "./adapter-host.js"; +import { + admitProviderAdapterSet, + loadUhuraAdapterProvider, + type UhuraAdapterProvider, + type UhuraProviderHost, +} from "./provider.js"; + +const requirement: PortRequirement = { + port: "authority", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash("1".repeat(64)), + contractInstanceHash: hash("2".repeat(64)), +}; + +const adapter = ( + fields: Partial = {}, +): PortAdapter => ({ + ...requirement, + accept() {}, + ...fields, +}); + +describe("application adapter provider admission", () => { + it("admits exactly the configured app.provider set", () => { + expect(() => admitProviderAdapterSet( + { adapters: [adapter()] }, + [requirement], + "provider.js", + )).not.toThrow(); + }); + + it("rejects ownership substitution, missing, extra, and duplicate adapters", () => { + expect(() => admitProviderAdapterSet( + { adapters: [adapter({ adapter: WEB_HISTORY_ADAPTER })] }, + [requirement], + "provider.js", + )).toThrow(/only "app\.provider" adapters/u); + expect(() => admitProviderAdapterSet( + { adapters: [] }, + [requirement], + "provider.js", + )).toThrow(/omitted provider adapter/u); + expect(() => admitProviderAdapterSet( + { adapters: [adapter({ port: "extra" })] }, + [requirement], + "provider.js", + )).toThrow(/undeclared provider adapter/u); + expect(() => admitProviderAdapterSet( + { adapters: [adapter(), adapter()] }, + [requirement], + "provider.js", + )).toThrow(/duplicate adapter/u); + }); + + it("rejects contract or instance substitution", () => { + expect(() => admitProviderAdapterSet( + { adapters: [adapter({ contractHash: hash("3".repeat(64)) })] }, + [requirement], + "provider.js", + )).toThrow(/incompatible provider adapter/u); + expect(() => admitProviderAdapterSet( + { + adapters: [adapter({ + contractInstanceHash: hash("4".repeat(64)), + })], + }, + [requirement], + "provider.js", + )).toThrow(/incompatible provider adapter/u); + }); + + it("disposes a provider that resolves after its Play route is aborted", async () => { + let beginFactory = (): void => undefined; + const factoryStarted = new Promise((resolve) => { + beginFactory = resolve; + }); + let resolveProvider = (_provider: UhuraAdapterProvider): void => undefined; + const providerReady = new Promise((resolve) => { + resolveProvider = resolve; + }); + const globals = globalThis as typeof globalThis & { + __uhuraDeferredProvider?: () => Promise; + }; + globals.__uhuraDeferredProvider = () => { + beginFactory(); + return providerReady; + }; + const module = `data:text/javascript,${ + encodeURIComponent( + "export const createUhuraAdapters = () => globalThis.__uhuraDeferredProvider();", + ) + }`; + const abort = new AbortController(); + const disposed = vi.fn<() => void>(); + + try { + const pending = loadUhuraAdapterProvider( + module, + {}, + { signal: abort.signal } as UhuraProviderHost, + [], + ); + await factoryStarted; + abort.abort(); + resolveProvider({ adapters: [], dispose: disposed }); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(disposed).toHaveBeenCalledOnce(); + } finally { + delete globals.__uhuraDeferredProvider; + } + }); +}); diff --git a/web/src/play/provider.ts b/web/src/play/provider.ts new file mode 100644 index 0000000..2e4725c --- /dev/null +++ b/web/src/play/provider.ts @@ -0,0 +1,191 @@ +import type { + ProviderHost, + SystemInfo, +} from "../protocol/types.js"; +import type { + ResolvedInput, + Value, +} from "../protocol/machine.js"; +import type { ResolveAsset } from "../renderer/assets.js"; +import type { + PortAdapter, + PortRequirement, +} from "./adapter-host.js"; +import { APPLICATION_PROVIDER_ADAPTER } from "./adapter-host.js"; + +/** Browser module ABI implemented by application-owned adapter providers. */ +export const UHURA_ADAPTER_PROVIDER_PROTOCOL = + "uhura-adapter-provider/0" as const; + +export interface UhuraProviderHost extends ProviderHost { + /** Exact admitted identity for one machine port owned by this deployment. */ + port(name: string): PortRequirement; + /** + * Decodes one browser URL through the checked route contract attached to a + * machine port. The returned input retains the admitted port identity. + */ + decodeRoute(port: string, url: string): ResolvedInput; + /** Encodes a checked route-contract Location value for browser history. */ + encodeRoute(port: string, location: Value): string; + /** Subscribes to committed Play locations owned by the application router. */ + onLocation(listener: (url: string) => void): () => void; + /** Applies browser history without inventing a machine input. */ + navigate(mode: "push" | "replace", url: string): void; + /** Requests one browser-history back traversal. */ + back(): void; +} + +/** + * One app-owned foreign-capability boundary. The browser admits the complete + * adapter set against Wasm-issued contract hashes before any command leaves + * the deterministic machine. + */ +export interface UhuraAdapterProvider { + readonly adapters: readonly PortAdapter[]; + readonly resolveAsset?: ResolveAsset; + systemInfo?(): SystemInfo; + dispose?(): void; +} + +export interface UhuraAdapterProviderModule { + createUhuraAdapters( + config: Readonly>, + host: UhuraProviderHost, + ): UhuraAdapterProvider | Promise; +} + +const providerModule = ( + value: unknown, + module: string, +): UhuraAdapterProviderModule => { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${module} must export createUhuraAdapters(config, host)`); + } + const candidate = value as Partial; + if (typeof candidate.createUhuraAdapters !== "function") { + throw new TypeError(`${module} must export createUhuraAdapters(config, host)`); + } + return candidate as UhuraAdapterProviderModule; +}; + +const providerInstance = ( + value: unknown, + module: string, +): UhuraAdapterProvider => { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${module} createUhuraAdapters() must return an object`); + } + const candidate = value as Partial; + if (!Array.isArray(candidate.adapters)) { + throw new TypeError( + `${module} createUhuraAdapters() must return an adapters array`, + ); + } + return candidate as UhuraAdapterProvider; +}; + +const providerAbort = (): DOMException => + new DOMException("Uhura adapter provider loading was aborted", "AbortError"); + +const disposeProvider = (provider: UhuraAdapterProvider): void => { + try { + provider.dispose?.(); + } catch (error) { + console.error("uhura provider cleanup failed", error); + } +}; + +export const admitProviderAdapterSet = ( + provider: UhuraAdapterProvider, + requirements: readonly PortRequirement[], + module: string, +): void => { + const expected = new Map(); + for (const requirement of requirements) { + if (requirement.adapter !== APPLICATION_PROVIDER_ADAPTER) { + throw new TypeError( + `${module} was offered non-provider port \`${requirement.port}\``, + ); + } + if (expected.has(requirement.port)) { + throw new TypeError( + `${module} received duplicate port requirement \`${requirement.port}\``, + ); + } + expected.set(requirement.port, requirement); + } + + const supplied = new Set(); + for (const adapter of provider.adapters) { + if (typeof adapter !== "object" || adapter === null) { + throw new TypeError(`${module} returned a non-object Uhura adapter`); + } + if (adapter.adapter !== APPLICATION_PROVIDER_ADAPTER) { + throw new TypeError( + `${module} may return only ${JSON.stringify(APPLICATION_PROVIDER_ADAPTER)} adapters`, + ); + } + if (supplied.has(adapter.port)) { + throw new TypeError( + `${module} returned duplicate adapter for port \`${adapter.port}\``, + ); + } + supplied.add(adapter.port); + const requirement = expected.get(adapter.port); + if (!requirement) { + throw new TypeError( + `${module} returned undeclared provider adapter for port \`${adapter.port}\``, + ); + } + if ( + adapter.contractHash !== requirement.contractHash + || adapter.contractInstanceHash !== requirement.contractInstanceHash + ) { + throw new TypeError( + `${module} returned an incompatible provider adapter for port \`${adapter.port}\``, + ); + } + if (typeof adapter.accept !== "function") { + throw new TypeError( + `${module} adapter for \`${adapter.port}\` must implement accept()`, + ); + } + } + + for (const port of expected.keys()) { + if (!supplied.has(port)) { + throw new TypeError( + `${module} omitted provider adapter for port \`${port}\``, + ); + } + } +}; + +export async function loadUhuraAdapterProvider( + module: string, + config: Readonly>, + host: UhuraProviderHost, + requirements: readonly PortRequirement[], +): Promise { + if (host.signal.aborted) throw providerAbort(); + const loaded = providerModule( + await import(/* @vite-ignore */ module) as unknown, + module, + ); + if (host.signal.aborted) throw providerAbort(); + const provider = providerInstance( + await loaded.createUhuraAdapters(config, host), + module, + ); + if (host.signal.aborted) { + disposeProvider(provider); + throw providerAbort(); + } + try { + admitProviderAdapterSet(provider, requirements, module); + } catch (error) { + disposeProvider(provider); + throw error; + } + return provider; +} diff --git a/web/src/play/pump.ts b/web/src/play/pump.ts deleted file mode 100644 index 01894a0..0000000 --- a/web/src/play/pump.ts +++ /dev/null @@ -1,103 +0,0 @@ -// The event pump (§8.4, normative): renderer emissions always ENQUEUE; -// a `pumping` flag makes nested pumps no-ops — the wasm Session is -// single-borrow, so re-entering `dispatch` from inside `onStep` would -// panic. Post-drain observation checks run in a microtask. - -import type { ProviderMsg, StepResult } from "../protocol/types.js"; - -interface PumpWiring { - dispatch: (eventJson: string) => string; - deliver: (cmdJson: string) => void; - onStep: (result: StepResult) => void; - onError: (error: unknown, eventJson: string) => void; - onDrained?: () => void; -} - -interface QueuedEvent { - eventJson: string; - onApplied?: () => void; -} - -export function createPump({ dispatch, deliver, onStep, onError, onDrained }: PumpWiring) { - const queue: QueuedEvent[] = []; - let pumping = false; - - /** - * The ONLY entry point — everything (user input, driver ticks, Init) - * goes through the queue, so step order is arrival order. - * @param {string} eventJson - * @param {() => void} [onApplied] runs right after this event's step - * lands (the textfield in-flight accounting hangs off this) - */ - function enqueue(eventJson: string, onApplied?: () => void): void { - queue.push(onApplied ? { eventJson, onApplied } : { eventJson }); - pump(); - } - - function pump() { - if (pumping) return; - pumping = true; - try { - while (queue.length > 0) { - const item = queue.shift(); - if (!item) break; - let resultJson: string; - try { - resultJson = dispatch(item.eventJson); - } catch (error) { - item.onApplied?.(); - onError(error, item.eventJson); - continue; - } - const result = JSON.parse(resultJson) as StepResult; - // Emitted commands go to the provider as they appear (§7.2). A - // provider refusal to ACCEPT one (unscripted command, §9.5) is - // reported but must not skip the render below: the machine - // stepped — the DOM tracks the session, never the provider. - for (const c of result.c) { - const cmdJson = JSON.stringify(c); - try { - deliver(cmdJson); - } catch (error) { - onError(error, cmdJson); - } - } - // onStep reconciles the DOM; anything it provokes (focus events, - // observation flips) re-enters via enqueue and drains here. - onStep(result); - item.onApplied?.(); - } - } finally { - pumping = false; - } - if (queue.length > 0) { - pump(); // an emission slipped in during the finally window - } else if (onDrained) { - queueMicrotask(() => { - if (!pumping && queue.length === 0) onDrained(); - }); - } - } - - return { enqueue }; -} - -/** - * Maps one provider wire message to its external event (§7.2): a - * standalone projection update wraps into an `updates` list; `outcome` - * and `projection-failed` are shape-identical pass-throughs. - * @param {string} msgJson - * @returns {string} event JSON for `Session.dispatch` - */ -export function providerMsgToEvent(msgJson: string): string { - const msg = JSON.parse(msgJson) as ProviderMsg; - switch (msg.kind) { - case "projection": - return JSON.stringify({ kind: "projection", updates: [msg] }); - case "outcome": - case "projection-failed": - return msgJson; - default: - throw new Error(`the driver emitted a \`${msg.kind}\` message`); - } -} diff --git a/web/src/play/scroll.ts b/web/src/play/scroll.ts deleted file mode 100644 index ada0388..0000000 --- a/web/src/play/scroll.ts +++ /dev/null @@ -1,179 +0,0 @@ -// scroll mechanics (§8.4): near-end observation via a sentinel + -// IntersectionObserver (rootMargin 100% — the catalog's stated -// threshold), with an EDGE LATCH: one emission per entry into the -// near-end zone; re-arms only after the sentinel leaves. Wiggle-scroll -// at the bottom emits nothing (the machine's guard is the backstop, the -// latch keeps the trace clean). Plus the per-route scroll cache -// (micro-decision #17). - -import type { Descriptor } from "../protocol/types.js"; -import type { - NearEndState, - ScrollController, - ScrollHolder, -} from "../renderer/contracts.js"; - -interface ScrollPosition { - top: number; - left: number; -} - -/** Bounds stale page-instance positions minted by long-running navigation. */ -export const SCROLL_POSITION_CACHE_LIMIT = 64; - -interface ScrollWiring { - emit(descriptor: Descriptor): void; -} - -export interface PlayScrollController extends ScrollController { - dispose(): void; -} - -export function createScrolls({ emit }: ScrollWiring): PlayScrollController { - const routeCache = new Map>(); - const observed = new Map(); - - function disposeObservation(el: HTMLElement, holder: ScrollHolder): void { - const nearEnd = holder.nearEnd; - if (nearEnd) { - nearEnd.io.disconnect(); - nearEnd.sentinel.remove(); - holder.nearEnd = undefined; - } - observed.delete(el); - } - - /** - * Keeps one scroll element's near-end observation in sync with its - * CURRENT descriptors. `holder.on` rotates per step; descriptor - * absence (exhausted feed) tears the sentinel down — descriptor - * presence IS the subscription (§8.1). - */ - function sync(el: HTMLElement, holder: ScrollHolder): void { - const descriptor = holder.on["near-end"]; - if (!descriptor) { - disposeObservation(el, holder); - return; - } - if (!holder.nearEnd) { - const sentinel = document.createElement("div"); - sentinel.setAttribute("data-uh-mechanic", "sentinel"); - sentinel.style.cssText = "block-size:1px;flex:none;"; - el.append(sentinel); - const nearEnd: NearEndState = { - sentinel, - armed: true, - lastHeight: -1, - io: new IntersectionObserver( - (entries) => { - // A delivery already queued before disconnect must not outlive - // the renderer subtree that owned this observation. - if (holder.nearEnd !== nearEnd) return; - for (const entry of entries) { - if (entry.isIntersecting && nearEnd.armed) { - nearEnd.armed = false; - const d = holder.on["near-end"]; - if (d) emit(d); - } else if (!entry.isIntersecting) { - nearEnd.armed = true; // left the zone — re-arm the latch - } - } - }, - { root: el, rootMargin: "100%" }, - ), - }; - nearEnd.io.observe(sentinel); - holder.nearEnd = nearEnd; - observed.set(el, holder); - } - const nearEnd = holder.nearEnd; - if (nearEnd.sentinel !== el.lastElementChild) { - el.append(nearEnd.sentinel); // keep it after appended rows - } - if (el.scrollHeight !== nearEnd.lastHeight) { - // Content changed. The catalog's near-end threshold is a STATE - // (remaining extent below one viewport — §10), not an edge: re-arm - // and take a fresh observation, so a feed still inside the zone - // after a short append keeps paginating instead of deadlocking. - // The machine's guard is the backstop against spam. - nearEnd.lastHeight = el.scrollHeight; - nearEnd.armed = true; - nearEnd.io.unobserve(nearEnd.sentinel); - nearEnd.io.observe(nearEnd.sentinel); - } - } - - /** - * Saves every scroll position under the outgoing page instance before - * the page subtree remounts. The key is main.ts's nav key - * (route + depth + params — register #17), so two `profile/[user]` - * instances never share positions. - */ - function savePositions(navKey: string, pageEl: HTMLElement): void { - const positions = new Map(); - for (const candidate of pageEl.querySelectorAll(".uh-scroll")) { - if (!(candidate instanceof HTMLElement)) continue; - // Keyed by data-key, NOT data-path: node keys are stable source - // ordinals, while paths embed the page serial, which is freshly - // minted on every remount. - const key = candidate.getAttribute("data-key"); - if (key) { - positions.set(key, { - top: candidate.scrollTop, - left: candidate.scrollLeft, - }); - } - } - // Map insertion order gives a small LRU: refreshing a key moves it to - // the back, and the least-recent page instance is evicted first. - routeCache.delete(navKey); - routeCache.set(navKey, positions); - while (routeCache.size > SCROLL_POSITION_CACHE_LIMIT) { - const oldest = routeCache.keys().next().value; - if (oldest === undefined) break; - routeCache.delete(oldest); - } - } - - /** - * Restores cached positions after a page remount (back → the feed - * exactly where it was). Unknown keys stay at 0 — a freshly pushed - * instance starts at the top. - */ - function restorePositions(navKey: string, pageEl: HTMLElement): void { - const positions = routeCache.get(navKey); - if (!positions) return; - routeCache.delete(navKey); - routeCache.set(navKey, positions); - for (const candidate of pageEl.querySelectorAll(".uh-scroll")) { - if (!(candidate instanceof HTMLElement)) continue; - const key = candidate.getAttribute("data-key"); - const saved = key ? positions.get(key) : undefined; - if (saved) { - candidate.scrollTop = saved.top; - candidate.scrollLeft = saved.left; - } - } - } - - /** Disconnects observations before a renderer-owned subtree is detached. */ - function disposeSubtree(root: HTMLElement): void { - for (const [el, holder] of observed) { - if (el === root || root.contains(el)) disposeObservation(el, holder); - } - } - - function dispose(): void { - for (const [el, holder] of observed) disposeObservation(el, holder); - observed.clear(); - routeCache.clear(); - } - - return { sync, disposeSubtree, savePositions, restorePositions, dispose }; -} - -export type { - NearEndState, - ScrollController, - ScrollHolder, -} from "../renderer/contracts.js"; diff --git a/web/src/play/session.test.ts b/web/src/play/session.test.ts new file mode 100644 index 0000000..1f6952b --- /dev/null +++ b/web/src/play/session.test.ts @@ -0,0 +1,563 @@ +import { describe, expect, it } from "vitest"; + +import { + UHURA_MACHINE_PROGRAM_ID_PROTOCOL, + decodeValue, +} from "../protocol/machine.js"; +import { + admitConfiguredPorts, + decodePlayConfig, + decodePlayStep, + startPlay, +} from "./session.js"; +import { UHURA_ADAPTER_PROVIDER_PROTOCOL } from "./provider.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + WEB_HISTORY_ADAPTER, + WEB_ROUTER_CONTRACT, + type PortAdapter, +} from "./adapter-host.js"; + +const hash = "11".repeat(32); +const configurationHash = "22".repeat(32); +const stateHash = "33".repeat(32); +const nextStateHash = "44".repeat(32); + +const config = { + protocol: "uhura-play-config/1", + identityProtocol: UHURA_MACHINE_PROGRAM_ID_PROTOCOL, + entry: "app", + machine: "example.app@1::App", + presentation: "example.app@1::Web", + machineProgramHash: hash, + presentationHash: hash, + evidenceHash: null, + deploymentHash: hash, + lifetime: "application-session", + instance: "entry/app", + configuration: { $: "unit" }, + ports: [], +} as const; + +const observation = (count: string) => ({ + $: "record", + fields: [{ name: "count", value: { $: "Int", value: count } }], +}); + +const command = { + target: "local", + value: { + $: "variant", + type: "example.app@1::App.Command", + case: "reported", + fields: [], + }, +}; + +const reaction = { + protocol: "uhura-reaction-receipt/0", + kind: "reaction", + instance: config.instance, + machineProgramHash: hash, + configurationHash, + sequence: "1", + input: { + source: "local", + value: { + $: "variant", + type: "example.app@1::App.Input", + case: "increment", + fields: [], + }, + }, + resolution: { + kind: "completed", + outcome: { + $: "variant", + type: "example.app@1::App.Outcome", + case: "accepted", + fields: [], + }, + disposition: "commit", + }, + orderedCommands: [command], + postObservation: observation("1"), + preStateHash: stateHash, + postStateHash: nextStateHash, +}; + +const step = { + protocol: "uhura-browser/3", + receipt: reaction, + snapshot: { + protocol: "uhura-runtime-snapshot/0", + instance: config.instance, + machineProgramHash: hash, + presentation: config.presentation, + presentationHash: hash, + configurationHash, + state: observation("1"), + stateHash: nextStateHash, + lifecycle: "running", + nextSequence: "2", + tracePrefixHash: "66".repeat(32), + ingressPrefixHash: "77".repeat(32), + nextIngressOrdinal: "1", + }, + presentation: { + kind: "view", + projectionRevision: "1", + view: { + protocol: "uhura-view/1", + presentation: config.presentation, + machine: config.machine, + instance: config.instance, + sequence: "1", + nodes: [], + }, + }, +}; + +const clone = (value: T): T => structuredClone(value); + +describe("Uhura Play config", () => { + it("admits only the current language-owned identity protocol", () => { + expect(decodePlayConfig(config).identityProtocol) + .toBe(UHURA_MACHINE_PROGRAM_ID_PROTOCOL); + expect(() => + decodePlayConfig({ + ...config, + identityProtocol: "uhura-semantic-ir-hash/0", + }) + ).toThrow(/identityProtocol must be/u); + }); + + it("admits generic provider metadata and exact port identities", () => { + const decoded = decodePlayConfig({ + ...config, + ports: [{ + port: "authority", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + provider: { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: "/api/play/provider.js", + config: { actor: "demo" }, + }, + }); + expect(decoded.ports[0]?.contractInstanceHash).toBe(hash); + expect(decoded.ports[0]?.adapter).toBe(APPLICATION_PROVIDER_ADAPTER); + expect(decoded.provider?.protocol).toBe(UHURA_ADAPTER_PROVIDER_PROTOCOL); + expect(decoded.provider?.module).toBe("/api/play/provider.js"); + expect(decoded.provider?.config).toEqual({ actor: "demo" }); + }); + + it("rejects a provider module with an unknown adapter ABI", () => { + expect(() => + decodePlayConfig({ + ...config, + provider: { + protocol: "uhura-adapter-provider/9", + module: "/api/play/provider.js", + config: {}, + }, + }) + ).toThrow(/provider\.protocol/u); + }); + + it("has no runtime discriminator and rejects unsealed adapter names", () => { + expect(() => + decodePlayConfig({ + ...config, + runtime: "other", + }) + ).toThrow(/wrong fields/u); + expect(() => + decodePlayConfig({ + ...config, + ports: [{ + port: "orders", + adapter: "return-desk.orders", + contractHash: hash, + contractInstanceHash: hash, + }], + }) + ).toThrow(/sealed Uhura adapter table/u); + }); + + it("requires provider metadata exactly when app.provider owns a port", () => { + expect(() => decodePlayConfig({ + ...config, + ports: [{ + port: "authority", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + })).toThrow(/has no provider module/u); + expect(() => decodePlayConfig({ + ...config, + provider: { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: "/api/play/provider.js", + config: {}, + }, + })).toThrow(/binds no app\.provider ports/u); + }); + + it("merges core contracts with exact host adapter ownership", () => { + const play = decodePlayConfig({ + ...config, + ports: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + }); + const admitted = admitConfiguredPorts([{ + port: "router", + contract: WEB_ROUTER_CONTRACT, + contractHash: play.ports[0]!.contractHash, + contractInstanceHash: play.ports[0]!.contractInstanceHash, + }], play); + expect(admitted).toEqual([{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contract: WEB_ROUTER_CONTRACT, + contractHash: hash, + contractInstanceHash: hash, + }]); + }); + + it("requires a presentation and its identity together", () => { + expect(() => + decodePlayConfig({ + ...config, + presentationHash: null, + }) + ).toThrow(/must either both be null or both be present/u); + }); +}); + +describe("Uhura browser-step admission", () => { + const play = decodePlayConfig(config); + + it("correlates receipt, bounded snapshot, derived values, and view", () => { + const decoded = decodePlayStep( + JSON.stringify(step), + play, + ); + expect(decoded.receipt.sequence).toBe("1"); + expect(decoded.commands).toEqual(decoded.receipt.orderedCommands); + expect(decoded.observation).toEqual(decoded.receipt.postObservation); + expect(decoded.snapshot.nextSequence).toBe("2"); + expect(decoded.presentation.kind).toBe("view"); + if (decoded.presentation.kind !== "view") throw new Error("expected view"); + expect(decoded.presentation.projectionRevision).toBe("1"); + expect(decoded.presentation.view.sequence).toBe(decoded.receipt.sequence); + }); + + it("rejects a stale or unrelated view", () => { + const invalid = clone(step); + invalid.presentation.view.sequence = "0"; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow(/view identity or sequence/u); + }); + + it("rejects a live view without a projection revision before dispatch", () => { + const invalid = clone(step); + delete (invalid.presentation as { + projectionRevision?: string; + }).projectionRevision; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow(/wrong fields/u); + }); + + it("requires the live projection revision to be canonical natural text", () => { + for (const projectionRevision of [undefined, 1, "01", "-1"]) { + const invalid = clone(step) as { + presentation: { + projectionRevision: unknown; + }; + }; + invalid.presentation.projectionRevision = projectionRevision; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow( + projectionRevision === undefined + ? /wrong fields/u + : projectionRevision === 1 + ? /projectionRevision must be nonempty text/u + : /Uhura Nat must use canonical exact text/u, + ); + } + }); + + it("admits a correlated projection error without losing committed commands", () => { + const failed = clone(step) as Record; + failed["presentation"] = { + kind: "error", + error: { + code: "projection-failed", + message: "one projection contains duplicate Surface keys", + machine: config.machine, + presentation: config.presentation, + instance: config.instance, + sequence: reaction.sequence, + }, + }; + const decoded = decodePlayStep( + JSON.stringify(failed), + play, + ); + expect(decoded.presentation.kind).toBe("error"); + expect(decoded.commands).toEqual(decoded.receipt.orderedCommands); + }); + + it("rejects an uncorrelated projection error", () => { + const failed = clone(step) as Record; + failed["presentation"] = { + kind: "error", + error: { + code: "projection-failed", + message: "one projection contains duplicate Surface keys", + machine: config.machine, + presentation: config.presentation, + instance: config.instance, + sequence: "0", + }, + }; + expect(() => + decodePlayStep( + JSON.stringify(failed), + play, + ) + ).toThrow(/projection error identity or sequence/u); + }); + + it("rejects receipt protocol drift", () => { + const invalid = clone(step); + invalid.receipt.protocol = "uhura-reaction-receipt/9"; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow(/reaction-receipt\/0/u); + }); + + it("rejects duplicate command transport outside the committed receipt", () => { + const invalid = clone(step); + Object.assign(invalid, { commands: [] }); + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow(/wrong fields/u); + }); + + it("rejects a snapshot that does not follow the committed receipt", () => { + const invalid = clone(step); + invalid.snapshot.nextSequence = "1"; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow(/nextSequence/u); + }); + + it("rejects a snapshot with an unrelated committed state identity", () => { + const invalid = clone(step); + invalid.snapshot.stateHash = stateHash; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + play, + ) + ).toThrow(/state identity/u); + }); +}); + +describe("Uhura Play reaction inspection", () => { + it("does not request cumulative inspection on the reaction hot path", async () => { + const deliveryCount = 6; + const port = "events"; + const play = decodePlayConfig({ + ...config, + presentation: null, + presentationHash: null, + ports: [{ + port, + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + provider: { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: "/api/play/provider.js", + config: {}, + }, + }); + const genesis = { + protocol: "uhura-genesis-receipt/0", + kind: "genesis", + instance: play.instance, + machineProgramHash: hash, + configurationHash, + sequence: "0", + initialObservation: observation("0"), + initialStateHash: stateHash, + }; + const inspection = { + protocol: "uhura-browser/3", + identityProtocol: play.identityProtocol, + instance: play.instance, + machineProgramHash: hash, + presentation: null, + presentationHash: null, + configurationHash, + configuration: play.configuration, + state: observation("0"), + observation: observation("0"), + inbox: [], + lifecycle: "running", + nextSequence: "1", + tracePrefixHash: "66".repeat(32), + receipts: [genesis], + ingressPrefixHash: "77".repeat(32), + nextIngressOrdinal: "1", + ingressRecords: [], + }; + let inspectCalls = 0; + let submitCalls = 0; + let freed = false; + const session = { + inspect(): string { + inspectCalls += 1; + return JSON.stringify(inspection); + }, + port_requirements(): string { + return JSON.stringify([{ + port, + contract: "example.events@1::Events", + contractHash: hash, + contractInstanceHash: hash, + }]); + }, + presentation(): string { + return JSON.stringify({ kind: "none" }); + }, + submit(inputSource: string): string { + submitCalls += 1; + const sequence = String(submitCalls); + const input = JSON.parse(inputSource) as unknown; + const receipt = { + protocol: "uhura-reaction-receipt/0", + kind: "reaction", + instance: play.instance, + machineProgramHash: hash, + configurationHash, + sequence, + input, + resolution: { + kind: "completed", + outcome: { + $: "variant", + type: "example.app@1::App.Outcome", + case: "accepted", + fields: [], + }, + disposition: "commit", + }, + orderedCommands: [], + postObservation: observation(sequence), + preStateHash: nextStateHash, + postStateHash: nextStateHash, + }; + return JSON.stringify({ + protocol: "uhura-browser/3", + receipt, + snapshot: { + protocol: "uhura-runtime-snapshot/0", + instance: play.instance, + machineProgramHash: hash, + presentation: null, + presentationHash: null, + configurationHash, + state: observation(sequence), + stateHash: nextStateHash, + lifecycle: "running", + nextSequence: String(submitCalls + 1), + tracePrefixHash: "66".repeat(32), + ingressPrefixHash: "77".repeat(32), + nextIngressOrdinal: "1", + }, + presentation: { kind: "none" }, + }); + }, + free(): void { + freed = true; + }, + } as unknown as Parameters[0]["session"]; + const adapter = { + port, + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: play.ports[0]!.contractHash, + contractInstanceHash: play.ports[0]!.contractInstanceHash, + start(context): void { + for (let index = 1; index <= deliveryCount; index += 1) { + context.deliver(decodeValue({ + $: "variant", + type: "example.events@1::Events.Input", + case: "tick", + fields: [{ + name: "count", + value: { $: "Nat", value: String(index) }, + }], + }, "test deferred provider input")); + } + }, + accept(): void {}, + } satisfies PortAdapter; + const published: string[] = []; + const errors: unknown[] = []; + const controller = startPlay({ + shell: {} as Parameters[0]["shell"], + session, + config: play, + adapters: [adapter], + publishRuntimeStep(_snapshot, receipt): void { + published.push(receipt.sequence); + }, + onError(error): void { + errors.push(error); + }, + }); + + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(errors).toEqual([]); + expect(submitCalls).toBe(deliveryCount); + expect(published).toEqual(["0", "1", "2", "3", "4", "5", "6"]); + expect(inspectCalls).toBe(1); + + controller.dispose(); + expect(freed).toBe(true); + }); +}); diff --git a/web/src/play/session.ts b/web/src/play/session.ts new file mode 100644 index 0000000..849f5cd --- /dev/null +++ b/web/src/play/session.ts @@ -0,0 +1,840 @@ +import type { Session as WasmSession } from "/api/play/wasm/uhura_wasm.js"; + +import type { PlayShell } from "./shell.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + createAdapterHost, + partitionAdapterRequirements, + WEB_HISTORY_ADAPTER, + type AdapterIdentity, + type AdmittedPortRequirement, + type AdapterHost, + type PortAdapter, + type PortRequirement, +} from "./adapter-host.js"; +import { + UHURA_BROWSER_PROTOCOL, + UHURA_RUNTIME_SNAPSHOT_PROTOCOL, + decodeIdentityProtocol, + decodeInspection, + decodeReactionReceipt, + decodeRuntimeSnapshot, + decodeValue, + hash, + natural, + type Hash, + type Inspection, + type NaturalText, + type Observation, + type Receipt, + type ReactionReceipt, + type ResolvedCommand, + type ResolvedInput, + type RuntimeSnapshot, + type UhuraIdentityProtocol, + type Value, +} from "../protocol/machine.js"; +import type { AssetAppliers } from "../renderer/assets.js"; +import type { IconFontRegistry } from "../renderer/icons.js"; +import { UHURA_ADAPTER_PROVIDER_PROTOCOL } from "./provider.js"; +import { + createProjectionRenderer, + decodeRenderDocument, + type ProjectionRenderer, + type RenderDocument, +} from "../renderer/projection.js"; + +export const UHURA_PLAY_CONFIG_PROTOCOL = "uhura-play-config/1" as const; + +export interface PlayPortConfig extends PortRequirement {} + +export interface PlayProviderConfig { + readonly protocol: typeof UHURA_ADAPTER_PROVIDER_PROTOCOL; + readonly module: string; + readonly config: Readonly>; +} + +export interface PlayConfig { + readonly protocol: typeof UHURA_PLAY_CONFIG_PROTOCOL; + readonly identityProtocol: UhuraIdentityProtocol; + readonly entry: string; + readonly machine: string; + readonly presentation: string | null; + readonly machineProgramHash: Hash; + readonly presentationHash: Hash | null; + readonly evidenceHash: Hash | null; + readonly deploymentHash: Hash; + readonly lifetime: "application-session"; + readonly instance: string; + readonly configuration: Value; + readonly ports: readonly PlayPortConfig[]; + readonly provider: PlayProviderConfig | null; +} + +export interface PlayController { + readonly session: WasmSession; + dispose(): void; +} + +export interface StartPlayOptions { + readonly shell: PlayShell; + readonly session: WasmSession; + readonly config: PlayConfig; + readonly adapters: readonly PortAdapter[]; + readonly assets?: AssetAppliers; + readonly icons?: IconFontRegistry; + /** Maps checked application links into the browser host's mounted topology. */ + readonly resolveLinkHref?: (href: string) => string; + /** + * Publishes one correlated receipt and bounded current-state snapshot. + * Complete audit inspection remains explicit and never enters this hot path. + */ + readonly publishRuntimeStep: ( + snapshot: RuntimeSnapshot, + receipt: Receipt, + ) => void; + /** Reports a recoverable UI projection failure; the machine keeps running. */ + readonly onProjectionError?: (error: ProjectionFailure) => void; + readonly onError: (error: unknown) => void; +} + +export interface BrowserStep { + readonly receipt: ReactionReceipt; + readonly observation: Observation; + readonly commands: readonly ResolvedCommand[]; + readonly presentation: BrowserPresentation; + readonly snapshot: RuntimeSnapshot; +} + +export interface ProjectionFailure { + readonly code: "projection-failed"; + readonly message: string; + readonly machine: string; + readonly presentation: string; + readonly instance: string; + readonly sequence: NaturalText; +} + +export type BrowserPresentation = + | { readonly kind: "none" } + | { + readonly kind: "view"; + readonly projectionRevision: NaturalText; + readonly view: RenderDocument; + } + | { readonly kind: "error"; readonly error: ProjectionFailure }; + +const object = ( + value: unknown, + context: string, +): Readonly> => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${context} must be an object`); + } + return value as Readonly>; +}; + +const text = (value: unknown, context: string): string => { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${context} must be nonempty text`); + } + return value; +}; + +const adapterIdentity = ( + value: unknown, + context: string, +): AdapterIdentity => { + if ( + value !== WEB_HISTORY_ADAPTER + && value !== APPLICATION_PROVIDER_ADAPTER + ) { + throw new TypeError(`${context} is not in the sealed Uhura adapter table`); + } + return value; +}; + +const list = (value: unknown, context: string): readonly unknown[] => { + if (!Array.isArray(value)) { + throw new TypeError(`${context} must be a list`); + } + return value; +}; + +const exactKeys = ( + value: Readonly>, + required: readonly string[], + context: string, + optional: readonly string[] = [], +): void => { + const expected = new Set([...required, ...optional]); + const missing = required.filter((key) => !Object.hasOwn(value, key)); + const extra = Object.keys(value).filter((key) => !expected.has(key)); + if (missing.length > 0 || extra.length > 0) { + throw new TypeError( + `${context} has the wrong fields; missing [${missing.join(", ")}], extra [${extra.join(", ")}]`, + ); + } +}; + +const parseJson = (source: string, context: string): unknown => { + try { + return JSON.parse(source) as unknown; + } catch (error) { + throw new TypeError(`${context} is not JSON: ${String(error)}`); + } +}; + +const decodeProvider = (value: unknown): PlayProviderConfig | null => { + if (value === undefined || value === null) return null; + const provider = object(value, "Uhura Play config.provider"); + exactKeys( + provider, + ["protocol", "module", "config"], + "Uhura Play config.provider", + ); + if (provider["protocol"] !== UHURA_ADAPTER_PROVIDER_PROTOCOL) { + throw new TypeError( + `Uhura Play config.provider.protocol must be ${JSON.stringify(UHURA_ADAPTER_PROVIDER_PROTOCOL)}`, + ); + } + return { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: text(provider["module"], "Uhura Play config.provider.module"), + config: object(provider["config"], "Uhura Play config.provider.config"), + }; +}; + +export const decodePlayConfig = (value: unknown): PlayConfig => { + const config = object(value, "Uhura Play config"); + exactKeys( + config, + [ + "protocol", + "identityProtocol", + "entry", + "machine", + "presentation", + "machineProgramHash", + "presentationHash", + "evidenceHash", + "deploymentHash", + "lifetime", + "instance", + "configuration", + "ports", + ], + "Uhura Play config", + ["provider"], + ); + if (config["protocol"] !== UHURA_PLAY_CONFIG_PROTOCOL) { + throw new TypeError( + `Uhura Play config.protocol must be ${JSON.stringify(UHURA_PLAY_CONFIG_PROTOCOL)}`, + ); + } + const identityProtocol = decodeIdentityProtocol( + config["identityProtocol"], + "Uhura Play config.identityProtocol", + ); + if (config["lifetime"] !== "application-session") { + throw new TypeError( + "Uhura Play config.lifetime must be `application-session`", + ); + } + const presentation = config["presentation"]; + if (presentation !== null && typeof presentation !== "string") { + throw new TypeError("Uhura Play config.presentation must be text or null"); + } + if (presentation === "") { + throw new TypeError( + "Uhura Play config.presentation must be nonempty when present", + ); + } + const presentationHash = config["presentationHash"] === null + ? null + : hash( + text(config["presentationHash"], "Uhura Play config.presentationHash"), + ); + if ((presentation === null) !== (presentationHash === null)) { + throw new TypeError( + "Uhura Play config.presentation and presentationHash must either both be null or both be present", + ); + } + const evidenceHash = config["evidenceHash"] === null + ? null + : hash(text(config["evidenceHash"], "Uhura Play config.evidenceHash")); + const ports = list(config["ports"], "Uhura Play config.ports").map( + (value, index): PlayPortConfig => { + const context = `Uhura Play config.ports[${index}]`; + const port = object(value, context); + exactKeys( + port, + ["port", "adapter", "contractHash", "contractInstanceHash"], + context, + ); + return { + port: text(port["port"], `${context}.port`), + adapter: adapterIdentity(port["adapter"], `${context}.adapter`), + contractHash: hash(text(port["contractHash"], `${context}.contractHash`)), + contractInstanceHash: hash( + text(port["contractInstanceHash"], `${context}.contractInstanceHash`), + ), + }; + }, + ); + const names = new Set(); + for (const port of ports) { + if (names.has(port.port)) { + throw new TypeError(`Uhura Play config repeats port \`${port.port}\``); + } + names.add(port.port); + } + const provider = decodeProvider(config["provider"]); + const needsProvider = ports.some( + (port) => port.adapter === APPLICATION_PROVIDER_ADAPTER, + ); + if (needsProvider !== (provider !== null)) { + throw new TypeError( + needsProvider + ? "Uhura Play config binds app.provider ports but has no provider module" + : "Uhura Play config has a provider module but binds no app.provider ports", + ); + } + return { + protocol: UHURA_PLAY_CONFIG_PROTOCOL, + identityProtocol, + entry: text(config["entry"], "Uhura Play config.entry"), + machine: text(config["machine"], "Uhura Play config.machine"), + presentation: typeof presentation === "string" ? presentation : null, + machineProgramHash: hash( + text(config["machineProgramHash"], "Uhura Play config.machineProgramHash"), + ), + presentationHash, + evidenceHash, + deploymentHash: hash( + text(config["deploymentHash"], "Uhura Play config.deploymentHash"), + ), + lifetime: "application-session", + instance: text(config["instance"], "Uhura Play config.instance"), + configuration: decodeValue( + config["configuration"], + "Uhura Play config.configuration", + ), + ports, + provider, + }; +}; + +export interface WasmPortRequirement extends Omit { + readonly contract: string; +} + +export const decodePortRequirements = ( + source: string, +): WasmPortRequirement[] => { + const requirements = list( + parseJson(source, "Uhura port requirements"), + "Uhura port requirements", + ).map((value, index): WasmPortRequirement => { + const context = `Uhura port requirements[${index}]`; + const requirement = object(value, context); + exactKeys( + requirement, + ["port", "contract", "contractHash", "contractInstanceHash"], + context, + ); + return { + port: text(requirement["port"], `${context}.port`), + contract: text(requirement["contract"], `${context}.contract`), + contractHash: hash( + text(requirement["contractHash"], `${context}.contractHash`), + ), + contractInstanceHash: hash( + text( + requirement["contractInstanceHash"], + `${context}.contractInstanceHash`, + ), + ), + }; + }); + const names = new Set(); + for (const requirement of requirements) { + if (names.has(requirement.port)) { + throw new TypeError( + `Uhura machine runtime repeats port requirement \`${requirement.port}\``, + ); + } + names.add(requirement.port); + } + return requirements; +}; + +const sameWireValue = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const requireSameWireValue = ( + left: unknown, + right: unknown, + message: string, +): void => { + if (!sameWireValue(left, right)) throw new TypeError(message); +}; + +const validateInspectionIdentity = ( + inspection: Inspection, + config: PlayConfig, +): void => { + if (inspection.identityProtocol !== config.identityProtocol) { + throw new TypeError( + "Uhura machine runtime identity protocol differs from Play config", + ); + } + if (inspection.instance !== config.instance) { + throw new TypeError( + "Uhura machine runtime instance differs from Play config", + ); + } + if (inspection.machineProgramHash !== config.machineProgramHash) { + throw new TypeError( + "Uhura machine runtime machine identity differs from Play config", + ); + } + if (inspection.presentation !== config.presentation) { + throw new TypeError( + "Uhura machine runtime presentation differs from Play config", + ); + } + if (inspection.presentationHash !== config.presentationHash) { + throw new TypeError( + "Uhura machine runtime presentation identity differs from Play config", + ); + } + requireSameWireValue( + inspection.configuration, + config.configuration, + "Uhura machine runtime configuration differs from Play config", + ); +}; + +const validateRuntimeSnapshotIdentity = ( + snapshot: RuntimeSnapshot, + config: PlayConfig, +): void => { + if (snapshot.instance !== config.instance) { + throw new TypeError( + "Uhura runtime snapshot instance differs from Play config", + ); + } + if (snapshot.machineProgramHash !== config.machineProgramHash) { + throw new TypeError( + "Uhura runtime snapshot machine identity differs from Play config", + ); + } + if (snapshot.presentation !== config.presentation) { + throw new TypeError( + "Uhura runtime snapshot presentation differs from Play config", + ); + } + if (snapshot.presentationHash !== config.presentationHash) { + throw new TypeError( + "Uhura runtime snapshot presentation identity differs from Play config", + ); + } +}; + +const snapshotFromInspection = ( + inspection: Inspection, + receipt: Receipt, +): RuntimeSnapshot => ({ + protocol: UHURA_RUNTIME_SNAPSHOT_PROTOCOL, + instance: inspection.instance, + machineProgramHash: inspection.machineProgramHash, + presentation: inspection.presentation, + presentationHash: inspection.presentationHash, + configurationHash: inspection.configurationHash, + state: inspection.state, + stateHash: receipt.kind === "reaction" + ? receipt.postStateHash + : receipt.initialStateHash, + lifecycle: inspection.lifecycle, + nextSequence: inspection.nextSequence, + tracePrefixHash: inspection.tracePrefixHash, + ingressPrefixHash: inspection.ingressPrefixHash, + nextIngressOrdinal: inspection.nextIngressOrdinal, +}); + +const validateViewIdentity = ( + view: RenderDocument, + receipt: Receipt, + config: PlayConfig, +): void => { + if (config.presentation === null) { + throw new TypeError("headless Uhura Play received an undeclared view"); + } + if ( + view.instance !== config.instance + || view.machine !== config.machine + || view.presentation !== config.presentation + || view.sequence !== receipt.sequence + ) { + throw new TypeError( + "Uhura reaction view identity or sequence differs from its admitted receipt", + ); + } +}; + +const decodeProjectionFailure = ( + value: unknown, + receipt: Receipt, + config: PlayConfig, + context: string, +): ProjectionFailure => { + if (config.presentation === null) { + throw new TypeError("headless Uhura Play received a projection error"); + } + const failure = object(value, context); + exactKeys( + failure, + ["code", "message", "machine", "presentation", "instance", "sequence"], + context, + ); + if (failure["code"] !== "projection-failed") { + throw new TypeError(`${context}.code must be \`projection-failed\``); + } + const decoded: ProjectionFailure = { + code: "projection-failed", + message: text(failure["message"], `${context}.message`), + machine: text(failure["machine"], `${context}.machine`), + presentation: text( + failure["presentation"], + `${context}.presentation`, + ), + instance: text(failure["instance"], `${context}.instance`), + sequence: natural(text(failure["sequence"], `${context}.sequence`)), + }; + if ( + decoded.machine !== config.machine + || decoded.presentation !== config.presentation + || decoded.instance !== config.instance + || decoded.sequence !== receipt.sequence + ) { + throw new TypeError( + "Uhura projection error identity or sequence differs from its admitted receipt", + ); + } + return decoded; +}; + +const decodeBrowserPresentation = ( + value: unknown, + receipt: Receipt, + config: PlayConfig, + context: string, +): BrowserPresentation => { + const presentation = object(value, context); + const kind = text(presentation["kind"], `${context}.kind`); + switch (kind) { + case "none": + exactKeys(presentation, ["kind"], context); + if (config.presentation !== null) { + throw new TypeError( + "presented Uhura Play omitted both its view and projection error", + ); + } + return { kind: "none" }; + case "view": { + exactKeys( + presentation, + ["kind", "projectionRevision", "view"], + context, + ); + const projectionRevision = natural( + text( + presentation["projectionRevision"], + `${context}.projectionRevision`, + ), + ); + const view = decodeRenderDocument( + presentation["view"], + `${context}.view`, + ); + validateViewIdentity(view, receipt, config); + return { kind: "view", projectionRevision, view }; + } + case "error": + exactKeys(presentation, ["kind", "error"], context); + return { + kind: "error", + error: decodeProjectionFailure( + presentation["error"], + receipt, + config, + `${context}.error`, + ), + }; + default: + throw new TypeError(`${context}.kind is not supported`); + } +}; + +/** + * Validates one complete Wasm reaction boundary before Play mutates the DOM or + * publishes a command to an adapter. + */ +export const decodePlayStep = ( + source: string, + config: PlayConfig, +): BrowserStep => { + const step = object( + parseJson(source, "Uhura reaction step"), + "Uhura reaction step", + ); + exactKeys( + step, + ["protocol", "receipt", "snapshot", "presentation"], + "Uhura reaction step", + ); + if (step["protocol"] !== UHURA_BROWSER_PROTOCOL) { + throw new TypeError("Uhura reaction step has an unsupported protocol"); + } + const receipt = decodeReactionReceipt( + step["receipt"], + "Uhura reaction step.receipt", + ); + const snapshot = decodeRuntimeSnapshot( + step["snapshot"], + "Uhura reaction step.snapshot", + ); + validateRuntimeSnapshotIdentity(snapshot, config); + const presentation = decodeBrowserPresentation( + step["presentation"], + receipt, + config, + "Uhura reaction step.presentation", + ); + if ( + receipt.instance !== snapshot.instance + || receipt.machineProgramHash !== snapshot.machineProgramHash + || receipt.configurationHash !== snapshot.configurationHash + ) { + throw new TypeError( + "Uhura reaction receipt identity differs from its runtime snapshot", + ); + } + if (snapshot.stateHash !== receipt.postStateHash) { + throw new TypeError( + "Uhura runtime snapshot state identity differs from its reaction receipt", + ); + } + if (BigInt(snapshot.nextSequence) !== BigInt(receipt.sequence) + 1n) { + throw new TypeError( + "Uhura runtime snapshot nextSequence does not follow its reaction receipt", + ); + } + return { + receipt, + observation: receipt.postObservation, + commands: receipt.orderedCommands, + presentation, + snapshot, + }; +}; + +export const admitConfiguredPorts = ( + requirements: readonly WasmPortRequirement[], + config: PlayConfig, +): AdmittedPortRequirement[] => { + const configured = new Map(config.ports.map((port) => [port.port, port])); + const admitted: AdmittedPortRequirement[] = []; + for (const requirement of requirements) { + const port = configured.get(requirement.port); + if (!port) { + throw new Error( + `Uhura Play has no configured adapter for \`${requirement.port}\``, + ); + } + if ( + port.contractHash !== requirement.contractHash + || port.contractInstanceHash !== requirement.contractInstanceHash + ) { + throw new Error( + `Uhura Play contract identity does not match port \`${port.port}\``, + ); + } + admitted.push({ ...requirement, adapter: port.adapter }); + } + for (const port of configured.keys()) { + if (!requirements.some((requirement) => requirement.port === port)) { + throw new Error(`Uhura Play config binds undeclared port \`${port}\``); + } + } + partitionAdapterRequirements(admitted); + return admitted; +}; + +const showProjectionFailure = ( + root: HTMLElement, + failure: ProjectionFailure, +): void => { + const notice = root.ownerDocument.createElement("section"); + notice.className = "uh-projection-error"; + notice.setAttribute("role", "alert"); + notice.setAttribute("aria-live", "polite"); + const heading = root.ownerDocument.createElement("strong"); + heading.textContent = "Presentation unavailable"; + const message = root.ownerDocument.createElement("p"); + message.textContent = failure.message; + const context = root.ownerDocument.createElement("code"); + context.textContent = `${failure.presentation} at reaction ${failure.sequence}`; + notice.append(heading, message, context); + root.replaceChildren(notice); +}; + +export function startPlay( + options: StartPlayOptions, +): PlayController { + const { shell, session, config } = options; + const identityInspection = decodeInspection( + parseJson(session.inspect(), "Uhura identity inspection"), + "Uhura identity inspection", + ); + validateInspectionIdentity(identityInspection, config); + + const requirements = admitConfiguredPorts( + decodePortRequirements(session.port_requirements()), + config, + ); + const initialReceipt = identityInspection.receipts.at(-1); + if (!initialReceipt) { + throw new TypeError("Uhura initial inspection has no admitted receipt"); + } + const initialPresentation = decodeBrowserPresentation( + parseJson(session.presentation(), "Uhura initial presentation"), + initialReceipt, + config, + "Uhura initial presentation", + ); + + let disposed = false; + let renderer: ProjectionRenderer | null = null; + let adapters: AdapterHost | null = null; + let currentView: RenderDocument | null = null; + + function createRenderer(): ProjectionRenderer { + return createProjectionRenderer({ + root: shell.pageHost, + surfaceRoot: shell.surfaceHost, + mode: "play", + assets: options.assets, + icons: options.icons, + resolveLinkHref: options.resolveLinkHref, + dispatch(binding, projectionRevision, event): void { + if (disposed || currentView === null) return; + if (projectionRevision === undefined) { + options.onError( + new TypeError( + "Uhura Play cannot dispatch an event without a projection revision", + ), + ); + return; + } + try { + applyStep( + session.dispatch_ui( + binding, + projectionRevision, + JSON.stringify(event), + ), + ); + } catch (error) { + options.onError(error); + } + }, + }); + } + + function applyPresentation(presentation: BrowserPresentation): void { + switch (presentation.kind) { + case "none": + currentView = null; + renderer?.dispose(); + renderer = null; + return; + case "error": + currentView = null; + renderer?.dispose(); + renderer = null; + showProjectionFailure(shell.pageHost, presentation.error); + options.onProjectionError?.(presentation.error); + return; + case "view": + renderer ??= createRenderer(); + currentView = null; + renderer.render( + presentation.view, + presentation.projectionRevision, + ); + currentView = presentation.view; + return; + } + } + + function applyStep(source: string): void { + const step = decodePlayStep(source, config); + options.publishRuntimeStep(step.snapshot, step.receipt); + // Committed commands are never contingent on optional UI projection or + // DOM reconciliation. Adapter delivery therefore precedes presentation. + adapters?.publish(step.commands); + applyPresentation(step.presentation); + } + + const submit = (input: ResolvedInput): void => { + if (disposed) return; + try { + applyStep(session.submit(JSON.stringify(input))); + } catch (error) { + options.onError(error); + } + }; + + applyPresentation(initialPresentation); + + adapters = createAdapterHost({ + requirements, + adapters: options.adapters, + deliver: submit, + localCommand(command): void { + options.onError( + new Error( + `Uhura Play has no host target for local command ${JSON.stringify(command)}`, + ), + ); + }, + adapterError(error, port): void { + options.onError( + error instanceof Error + ? error + : new Error(`Uhura adapter ${port} failed: ${String(error)}`), + ); + }, + }); + + options.publishRuntimeStep( + snapshotFromInspection(identityInspection, initialReceipt), + initialReceipt, + ); + adapters.start(); + + return { + session, + dispose(): void { + if (disposed) return; + disposed = true; + adapters?.dispose(); + adapters = null; + renderer?.dispose(); + renderer = null; + session.free(); + }, + }; +} diff --git a/web/src/play/shell.css b/web/src/play/shell.css index cd50275..cbb305a 100644 --- a/web/src/play/shell.css +++ b/web/src/play/shell.css @@ -1,3 +1,5 @@ +@import "../renderer/primitives/base.css"; + /* Shell chrome + semantic element bases (§8.4). The uh-* rules provide the shared renderer's browser vocabulary; layout/aesthetics stay authored (§10), and the compiled app stylesheet loads after this file and wins. */ @@ -595,18 +597,8 @@ body.uh-play-shell { border-color: #7aa7ff; box-shadow: 0 0 0 2px rgba(122, 167, 255, 0.16), 0 5px 16px rgba(0, 0, 0, 0.3); } -#uh-debug-panel button.uh-debug-node.is-consulted-unsatisfied { - border-color: #b98343; - border-style: dashed; -} -#uh-debug-panel button.uh-debug-node.is-consulted-not-ready, -#uh-debug-panel button.uh-debug-node.has-failure { - border-color: #bd6262; - border-style: dashed; -} #uh-debug-panel button.uh-debug-node.is-written, -#uh-debug-panel button.uh-debug-node.is-sent, -#uh-debug-panel button.uh-debug-node.is-structural { +#uh-debug-panel button.uh-debug-node.is-sent { background: #1d2a27; border-color: #4f8877; } @@ -730,32 +722,42 @@ body.uh-play-shell { } #uh-page, .uh-page-root { block-size: 100%; min-block-size: 0; } .uh-page-root > * { block-size: 100%; } +.uh-projection-error { + block-size: 100%; + display: grid; + place-content: center; + gap: 8px; + padding: 32px; + color: #e4e4e7; + background: #111113; + text-align: center; +} +.uh-projection-error strong { font-size: 15px; } +.uh-projection-error p { max-inline-size: 54ch; color: #f0a6a6; } +.uh-projection-error code { color: #a1a1aa; font: 12px/1.5 ui-monospace, monospace; } -/* semantic element bases used by the shared browser renderer */ -.uh-view { display: block; min-inline-size: 0; } -.uh-scroll { overflow-y: auto; overflow-x: hidden; min-block-size: 0; } -.uh-scroll[data-direction="horizontal"] { overflow-x: auto; overflow-y: hidden; } -.uh-text { margin: 0; overflow-wrap: anywhere; } -.uh-img { display: block; background-color: #d9d9de; } -.uh-video { display: block; inline-size: 100%; background: #111 center / cover no-repeat; object-fit: cover; } -.uh-icon { display: inline-flex; align-items: center; justify-content: center; inline-size: 1em; block-size: 1em; font-size: 24px; } -button.uh-button { appearance: none; background: none; border: 0; padding: 6px; font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; border-radius: 8px; cursor: pointer; } -button.uh-button[disabled] { opacity: 0.35; cursor: default; } -button.uh-button[aria-busy="true"] { opacity: 0.6; } -.uh-textfield input { font: inherit; inline-size: 100%; border: 1px solid #d5d5da; border-radius: 999px; padding: 8px 14px; background: #fff; color: #222; } -.uh-region { display: block; cursor: pointer; } -.uh-pager .uh-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; } -.uh-pager .uh-track > * { flex: 0 0 100%; scroll-snap-align: center; } -.uh-pager { position: relative; } -.uh-dots { position: absolute; inset-block-end: 10px; inset-inline: 0; display: flex; justify-content: center; gap: 5px; } -.uh-dot { inline-size: 6px; block-size: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.55); } -.uh-dot.on { background: #fff; } +/* Play-only affordances layered over the shared primitive bases. */ +button.uh-button { cursor: pointer; } +button.uh-button[disabled] { cursor: default; } +.uh-region { cursor: pointer; } +.uh-pager .uh-track { scrollbar-width: none; } -/* interactive Play surface stack */ -.uh-surface-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; z-index: 10; } -.uh-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.4); } -.uh-surface { position: relative; background: #fff; border-radius: 16px 16px 0 0; max-block-size: 72%; block-size: 72%; box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35); outline: none; } -.uh-surface > * { block-size: 100%; } +/* Machine-owned surfaces stay inside the selected prototype frame. */ +#uh-surfaces { + position: absolute; + inset: 0; + z-index: 10; + pointer-events: none; +} +#uh-surfaces:empty { display: none; } +#uh-surfaces::before { + content: ""; + position: absolute; + inset: 0; + background: rgb(0 0 0 / 40%); + pointer-events: auto; +} +#uh-surfaces > .uhura-surface { pointer-events: auto; } /* keyboard affordance for non-native interactives */ .uh-region:focus-visible, button.uh-button:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; } diff --git a/web/src/play/shell.ts b/web/src/play/shell.ts index aa7e59e..7f4d4a9 100644 --- a/web/src/play/shell.ts +++ b/web/src/play/shell.ts @@ -27,10 +27,6 @@ export const PLAY_SHELL_MARKUP = ` Starting -