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::