diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ade6d..be4e07c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,8 @@ jobs: run: cargo test --locked --workspace --all-targets - name: check the canonical Uhura project run: | - cargo run --locked -p uhura-cli -- fmt --check examples/instagram/client + # Project-wide formatting remains gated on Uhura 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 diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 0af17f3..5066ab8 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -340,7 +340,7 @@ jobs: PACKAGE_VERSION: ${{ steps.resolve.outputs.version }} run: cd npm && npm version "$PACKAGE_VERSION" --no-git-tag-version --allow-same-version - - name: guard — packed package is complete and within 25 MiB + - name: guard — packed package is complete and within 26 MiB env: PACKAGE_VERSION: ${{ steps.resolve.outputs.version }} run: | @@ -398,7 +398,7 @@ jobs: throw new Error(`packed executable must have mode 0755: ${path} (mode ${mode})`); } } - const limit = 25 * 1024 * 1024; + const limit = 26 * 1024 * 1024; if (entry.size > limit) { throw new Error(`packed package is ${entry.size} bytes; limit is ${limit} bytes`); } @@ -424,7 +424,10 @@ jobs: echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}" exit 1 fi - npm publish "${packages[0]}" --dry-run --tag "$DIST_TAG" + # A branch may revalidate the Cargo version after that version is + # already on npm. npm otherwise rejects even --dry-run; --force is + # safe here only because this step is gated to dry_run == true. + npm publish "${packages[0]}" --dry-run --force --tag "$DIST_TAG" - name: npm publish if: steps.resolve.outputs.dry_run != 'true' @@ -554,7 +557,7 @@ jobs: fi curl -fsS http://127.0.0.1:4124/ | grep -qi "/dev/null curl -fsS http://127.0.0.1:4124/api/play/wasm/uhura_wasm_bg.wasm >/dev/null curl -fsS http://127.0.0.1:4124/~project/status | grep -q '"protocol":"spock-project-status/1"' @@ -608,7 +611,7 @@ jobs: throw "Editor or Play did not serve HTML" } $state = Invoke-RestMethod -Uri "http://127.0.0.1:4124/api/editor/state" - if ($state.protocol -ne "uhura-editor-state/2") { throw "wrong Editor protocol" } + if ($state.protocol -ne "uhura-editor-state/5") { throw "wrong Editor protocol" } Invoke-WebRequest -Uri "http://127.0.0.1:4124/api/play/wasm/uhura_wasm.js" | Out-Null Invoke-WebRequest -Uri "http://127.0.0.1:4124/api/play/wasm/uhura_wasm_bg.wasm" | Out-Null $status = Invoke-RestMethod -Uri "http://127.0.0.1:4124/~project/status" diff --git a/Cargo.lock b/Cargo.lock index d3905a1..5d6e789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1476,12 +1476,32 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[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-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[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-modular" version = "0.6.4" @@ -2648,8 +2668,12 @@ dependencies = [ name = "uhura-core" version = "0.0.0" dependencies = [ + "num-bigint", + "num-integer", + "num-traits", "serde", "serde_json", + "sha2 0.10.9", "uhura-base", "uhura-port", ] @@ -2661,21 +2685,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" @@ -2687,7 +2699,6 @@ dependencies = [ "uhura-check", "uhura-core", "uhura-editor-model", - "uhura-fixture", "uhura-port", "uhura-syntax", ] @@ -2706,7 +2717,9 @@ dependencies = [ name = "uhura-syntax" version = "0.0.0" dependencies = [ + "serde", "uhura-base", + "unicode-ident", ] [[package]] diff --git a/README.md b/README.md index f57e4d4..4d34d77 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,20 @@ other musl-based Linux distributions are not supported yet. Framework commands begin with `0.5.0`; registry releases through `0.4.0` expose only the standalone language commands. -With the `0.5.0` framework npm release: +Published `0.5.x` supports framework projects. The shortest version-stable +start is backend-only: ```sh -npx spock new demo +npx spock new demo --backend-only cd demo npx spock dev ``` +Published `spock@0.5.3` embeds the retired Uhura frontend. The strict Uhura 0.4 +client documented below is integrated in current source and requires the +[source build](#uhura-the-client-language) until a compatible npm release +ships. Do not mix a 0.4 client with the 0.5.3 sidecar. + Standalone programs keep the same entry points: ```sh @@ -50,10 +56,17 @@ npm i -g spock ``` ```sh -spock new demo # create backend + Uhura client + spock.toml -spock check # check the whole nearest project -spock dev # watch client; observe backend as restart-required -spock start # serve one fixed combined generation +# published 0.5.3: backend-only +spock new api --backend-only +spock check api +spock dev api + +# current checkout: strict Uhura 0.4 full stack, after the asset build below +cargo run --locked -p spock-cli -- new demo +cargo run --locked -p spock-cli -- check demo +SPOCK_UHURA_WEB_DIST="$PWD/uhura/web/dist" \ +SPOCK_UHURA_WASM_DIST="$PWD/uhura/crates/uhura-wasm/pkg/web" \ +cargo run --locked -p spock-cli -- dev demo # standalone language escape hatches stay available spock check app.spock # parse + fully load-check one program @@ -99,17 +112,26 @@ status, contract metadata, Editor, and Play can still run. The repository's canonical full-stack example uses this exact shape at [`uhura/examples/instagram`](https://github.com/gridaco/uhura/tree/main/examples/instagram). Build its app-owned -provider once, then one command checks and serves the authority and client: +provider and the source-only Editor/Play assets once, then the current Spock +source checks and serves the authority and strict Uhura 0.4 client: ```sh +git submodule update --init --recursive +corepack pnpm@10.11.0 -C crates/spock-runtime/studio install --frozen-lockfile +corepack pnpm@10.11.0 -C crates/spock-runtime/studio build corepack pnpm@10.11.0 -C uhura/web install --frozen-lockfile -corepack pnpm@10.11.0 -C uhura/web build:provider -spock start uhura/examples/instagram +corepack pnpm@10.11.0 -C uhura/web build +bash uhura/scripts/build-wasm.sh + +SPOCK_UHURA_WEB_DIST="$PWD/uhura/web/dist" \ +SPOCK_UHURA_WASM_DIST="$PWD/uhura/crates/uhura-wasm/pkg/web" \ +cargo run --locked -p spock-cli -- start uhura/examples/instagram ``` -The `0.5.2` CLI includes the fixture's proposed `error` declarations as an -experimental RFD 0024 implementation preview, so this remains a distribution -smoke through the published npm CLI. The same project remains independently +Published `spock@0.5.3` and earlier accept the backend's experimental RFD 0024 +`error` declarations but embed the retired Uhura frontend, so they cannot run +this strict 0.4 checkout. The next compatible npm release must package this +same checked runtime and asset generation. The project remains independently checkable as a Spock backend and an Uhura client. `spock dev` deliberately has asymmetric reload semantics today. Valid Uhura @@ -751,10 +773,11 @@ shim only selects and owns the matching native process. Releases through Spock's doctrine forbids Spock from building a client language — "generate types, never the client" (`docs/rfd/0010`), "borrow, don't build" (`docs/rfd/0009`). That slot is filled by -[Uhura](https://github.com/gridaco/uhura): a declarative UI language and -deterministic headless experience runtime that owns non-authoritative -UI-session state and experience behavior, with Spock as its canonical -provider. No fact may be authoritative in both languages. +[Uhura](https://github.com/gridaco/uhura): a deterministic machine language +with an explicit optional Web UI profile. It owns non-authoritative client +state and experience behavior, with Spock as its canonical provider; its UI +modules are pure projections of machine observations rather than a second +state model. No fact may be authoritative in both languages. Uhura is a subsystem of the Spock project: its canonical source lives in its own repository and is included here as a git submodule at `uhura/`. The root diff --git a/crates/spock-cli/tests/cli.rs b/crates/spock-cli/tests/cli.rs index f1a131e..19c7c72 100644 --- a/crates/spock-cli/tests/cli.rs +++ b/crates/spock-cli/tests/cli.rs @@ -303,7 +303,7 @@ fn project_check_deduplicates_shared_editor_and_play_diagnostics() { .assert() .success(); std::fs::write( - temporary.path().join("demo/client/app/home/page.uhura"), + temporary.path().join("demo/client/machine.uhura"), "not valid uhura\n", ) .unwrap(); @@ -314,9 +314,9 @@ fn project_check_deduplicates_shared_editor_and_play_diagnostics() { .assert() .failure(); let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap(); - let repeated = "`not` is not a definition kind"; + let repeated = "[R1001 uhura-0.4/parse/invalid-declaration]"; assert_eq!(stderr.matches(repeated).count(), 1, "{stderr}"); - assert!(stderr.contains("app/home/page.uhura:"), "{stderr}"); + assert!(stderr.contains("machine.uhura:"), "{stderr}"); } #[test] diff --git a/crates/spock-host/src/assets.rs b/crates/spock-host/src/assets.rs index d608e7f..33d9c52 100644 --- a/crates/spock-host/src/assets.rs +++ b/crates/spock-host/src/assets.rs @@ -22,12 +22,12 @@ const SIDECAR_PROTOCOL: &str = "spock-asset-sidecar/1"; const HOST_ENVIRONMENT_PROTOCOL: &str = "spock-host-environment/1"; const PROJECT_STATUS_PROTOCOL: &str = "spock-project-status/1"; const PROJECT_EVENT_PROTOCOL: &str = "spock-project-event/1"; -const EDITOR_STATE_PROTOCOL: &str = "uhura-editor-state/2"; +const EDITOR_STATE_PROTOCOL: &str = "uhura-editor-state/5"; const EDITOR_EVENT_PROTOCOL: &str = "uhura-editor-event/0"; -const IR_PROTOCOL: &str = "uhura-ir/0"; -const INSPECT_PROTOCOL: &str = "uhura-inspect/0"; -const VIEW_PROTOCOL: &str = "uhura-view/0"; -const PROVIDER_PROTOCOL: &str = "uhura-provider/0"; +const IR_PROTOCOL: &str = "uhura-ir/1"; +const INSPECT_PROTOCOL: &str = "uhura-inspection/1"; +const VIEW_PROTOCOL: &str = "uhura-view/1"; +const ADAPTER_PROVIDER_PROTOCOL: &str = "uhura-adapter-provider/0"; const SIDECAR_PROTOCOLS: [(&str, &str); 9] = [ ("environment", HOST_ENVIRONMENT_PROTOCOL), ("project_status", PROJECT_STATUS_PROTOCOL), @@ -37,7 +37,7 @@ const SIDECAR_PROTOCOLS: [(&str, &str); 9] = [ ("ir", IR_PROTOCOL), ("inspect", INSPECT_PROTOCOL), ("view", VIEW_PROTOCOL), - ("provider", PROVIDER_PROTOCOL), + ("adapter_provider", ADAPTER_PROVIDER_PROTOCOL), ]; const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024; const REQUIRED_FILES: [&str; 3] = [ @@ -762,12 +762,12 @@ mod tests { fixture.manifest["protocols"] .as_object_mut() .expect("protocol object") - .remove("provider"); + .remove("adapter_provider"); fixture.write_manifest(); let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) .expect_err("missing protocol must fail closed"); assert!( - error.contains("required protocol provider is missing"), + error.contains("required protocol adapter_provider is missing"), "{error}" ); @@ -1024,7 +1024,7 @@ mod tests { "ir": IR_PROTOCOL, "inspect": INSPECT_PROTOCOL, "view": VIEW_PROTOCOL, - "provider": PROVIDER_PROTOCOL + "adapter_provider": ADAPTER_PROVIDER_PROTOCOL }, "files": files, }); diff --git a/crates/spock-host/src/client.rs b/crates/spock-host/src/client.rs index 945c5e9..73f85f4 100644 --- a/crates/spock-host/src/client.rs +++ b/crates/spock-host/src/client.rs @@ -5,6 +5,7 @@ use uhura_host::{ WebAssets, }; +use crate::client_route_admission::apply_checked_client_route_admission; use crate::{Fingerprint, ObservedRevision}; /// Content identity of the exact Uhura snapshot consumed by a client build. @@ -38,7 +39,8 @@ impl PreparedClient { source_revision: u64, ) -> Self { let source_fingerprint = client_source_fingerprint(snapshot); - let candidate = build_candidate(snapshot, source_revision); + let mut candidate = build_candidate(snapshot, source_revision); + apply_checked_client_route_admission(&mut candidate); let summary = candidate.summary(); Self { observed_revision, @@ -313,6 +315,78 @@ mod tests { ) } + fn canonical_uhura_snapshot() -> ProjectSourceSnapshot { + capture_project_snapshot( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../uhura/examples/applications/a0-return-desk/answers/uhura-0.4"), + ) + } + + fn colliding_route_snapshot() -> (TempDirectory, ProjectSourceSnapshot) { + let root = TempDirectory::new("colliding-route"); + fs::write( + root.as_ref().join("uhura.toml"), + r#"[project] +name = "test.spock-collision" +version = 1 +language = "0.4" + +[modules] +app = "app.uhura" +"#, + ) + .unwrap(); + fs::write( + root.as_ref().join("app.uhura"), + r#"use uhura::web_router::{Router, Routes}; + +pub enum Location { + Graph, +} + +pub const ROUTES: Routes = Routes::from([ + ("Graph", "/graphql/v2"), +]); + +pub machine App { + port router = Router { routes: ROUTES }; + + outcomes { + commit Accepted, + } + + on router.Changed(location) { + Accepted + } +} +"#, + ) + .unwrap(); + fs::write( + root.as_ref().join("host.toml"), + r#"[entry.app] +machine = "crate::App" +lifetime = "application-session" + +[entry.app.ports] +router = "web.history" +"#, + ) + .unwrap(); + let snapshot = capture_project_snapshot(root.as_ref()); + (root, snapshot) + } + + fn response_json(response: RouteResponse) -> serde_json::Value { + assert_eq!(response.status, 200); + let RouteBody::Bytes(mut body) = response.body else { + panic!("expected a JSON byte response"); + }; + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut body, &mut bytes).expect("response bytes"); + serde_json::from_slice(&bytes).expect("response JSON") + } + fn coordinator() -> GenerationCoordinator { GenerationCoordinator::activated( HostMode::Dev, @@ -360,6 +434,61 @@ mod tests { ); } + #[test] + fn aggregate_client_host_preserves_the_uhura_editor_and_play_pipeline() { + let (_web_root, web) = web_assets(); + let snapshot = canonical_uhura_snapshot(); + let coordinator = coordinator(); + let (host, publication) = + ClientHost::activate(web, &snapshot, coordinator.observed_revision()).unwrap(); + + assert!(publication.report.editor_current); + assert!(publication.report.play_ok); + assert_eq!(publication.report.preview_count, Some(12)); + assert_eq!(publication.report.replay_derived_count, Some(11)); + + let editor = response_json(host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/editor/state", + })); + assert_eq!(editor["protocol"], "uhura-editor-state/5"); + assert_eq!( + editor["render"]["previews"].as_array().map(Vec::len), + Some(12) + ); + + let play = response_json(host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/config.json", + })); + assert_eq!(play["protocol"], "uhura-play-config/1"); + assert!( + play.get("runtime").is_none(), + "single-engine Play config must not expose a runtime selector" + ); + assert_eq!(play["entry"], "return-desk"); + assert_eq!(play["provider"]["protocol"], "uhura-adapter-provider/0"); + + let ir = response_json(host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/ir.json", + })); + assert_eq!(ir["protocol"], "uhura-ir/1"); + + let inspection = response_json(host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/inspect.json", + })); + assert_eq!(inspection["protocol"], "uhura-inspection/1"); + + let application = host.route(RouteRequest { + method: RequestMethod::Get, + url: "/orders/order-100/return?step=items", + }); + assert_eq!(application.status, 200); + assert!(matches!(application.body, RouteBody::Bytes(_))); + } + #[test] fn stale_result_is_rejected_before_it_can_advance_uhura_state() { let (_web_root, web) = web_assets(); @@ -423,4 +552,80 @@ mod tests { assert_eq!(response.status, 200); assert!(matches!(response.body, RouteBody::Bytes(_))); } + + #[test] + fn initial_dev_route_collision_publishes_editor_without_play() { + let (_web_root, web) = web_assets(); + let (_project_root, collision) = colliding_route_snapshot(); + let revision = coordinator().observed_revision(); + let (host, publication) = ClientHost::activate(web, &collision, revision).unwrap(); + + assert!(publication.report.editor_current); + assert!(!publication.report.play_ok); + assert!(!publication.report.has_good_play); + assert!(publication.active.is_none()); + let editor = response_json(host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/editor/state", + })); + assert!(editor["diagnostics"]["diagnostics"] + .as_array() + .is_some_and(|diagnostics| diagnostics + .iter() + .any(|diagnostic| { diagnostic["rule"] == "spock/reserved-client-route" }))); + assert_eq!( + host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/ir.json", + }) + .status, + 503 + ); + } + + #[test] + fn route_collision_update_advances_editor_and_retains_last_good_play() { + let (_web_root, web) = web_assets(); + let valid = canonical_uhura_snapshot(); + let mut coordinator = coordinator(); + let (mut host, initial) = + ClientHost::activate(web, &valid, coordinator.observed_revision()).unwrap(); + let active = initial.active.expect("valid example has Play artifacts"); + let (_project_root, collision) = colliding_route_snapshot(); + coordinator.observe(Observation { + topology: Fingerprint::new("topology-a"), + backend: Fingerprint::new("backend-a"), + client: Some(client_source_fingerprint(&collision)), + changed_backend_inputs: Vec::new(), + backend_diagnostics: Vec::new(), + }); + let revision = coordinator.observed_revision(); + let candidate = host.prepare(&collision, revision); + assert!(candidate.summary().editor_current); + assert!(!candidate.summary().play_ok); + + let publication = host.publish(candidate, revision).unwrap(); + assert!(publication.report.editor_current); + assert!(!publication.report.play_ok); + assert!(publication.report.has_good_play); + assert_eq!(publication.active.as_ref(), Some(&active)); + let editor = response_json(host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/editor/state", + })); + assert_eq!(editor["sourceRevision"], 2); + assert!(editor["diagnostics"]["diagnostics"] + .as_array() + .is_some_and(|diagnostics| diagnostics + .iter() + .any(|diagnostic| { diagnostic["rule"] == "spock/reserved-client-route" }))); + assert_eq!( + host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/ir.json", + }) + .status, + 200 + ); + } } diff --git a/crates/spock-host/src/client_route_admission.rs b/crates/spock-host/src/client_route_admission.rs new file mode 100644 index 0000000..e08159d --- /dev/null +++ b/crates/spock-host/src/client_route_admission.rs @@ -0,0 +1,227 @@ +use uhura_host::{ + CheckedRoutePattern, ClientCandidate, PlayAdmissionRejection, RoutePathClaim, RoutePathDecode, + RoutePathScope, +}; + +const ROUTE_RULE: &str = "spock/reserved-client-route"; +const ROUTE_CODE: &str = "SPK1001"; + +const SPOCK_ROUTE_CLAIMS: &[(RoutePathClaim<'static>, &str)] = &[ + ( + RoutePathClaim { + path: "/api", + scope: RoutePathScope::Namespace, + decode: RoutePathDecode::PercentDecodedOnce, + }, + "/api", + ), + ( + RoutePathClaim { + path: "/graphql", + scope: RoutePathScope::Namespace, + decode: RoutePathDecode::PercentDecodedOnce, + }, + "/graphql", + ), + ( + RoutePathClaim { + path: "/rest", + scope: RoutePathScope::Namespace, + decode: RoutePathDecode::PercentDecodedOnce, + }, + "/rest", + ), + ( + RoutePathClaim { + path: "/storage", + scope: RoutePathScope::Namespace, + decode: RoutePathDecode::PercentDecodedOnce, + }, + "/storage", + ), + ( + RoutePathClaim { + path: "/~", + scope: RoutePathScope::Prefix, + decode: RoutePathDecode::PercentDecodedOnce, + }, + "/~*", + ), +]; + +/// One semantically checked Uhura route claimed by the aggregate Spock host +/// before the application history adapter can see it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClientRouteCollision { + pub table: String, + pub constructor: String, + pub pattern: String, + pub namespace: &'static str, +} + +impl ClientRouteCollision { + pub(crate) const fn code(&self) -> &'static str { + ROUTE_CODE + } + + pub(crate) const fn rule(&self) -> &'static str { + ROUTE_RULE + } + + pub(crate) fn message(&self) -> String { + format!( + "checked route table `{}` maps constructor `{}` to pattern `{}`, which overlaps Spock-owned namespace `{}`; framework protocol routes are resolved before the Uhura application router", + self.table, self.constructor, self.pattern, self.namespace + ) + } +} + +/// Apply only Spock composition policy to Uhura's already-checked semantic +/// route view. Source parsing, aliases, constant lowering, and route validity +/// remain exclusively owned by Uhura. +pub(crate) fn checked_client_route_collisions( + routes: Option<&[CheckedRoutePattern]>, +) -> Vec { + let Some(routes) = routes else { + return Vec::new(); + }; + routes + .iter() + .filter_map(|route| { + let (_, namespace) = SPOCK_ROUTE_CLAIMS + .iter() + .find(|(claim, _)| route.overlaps(*claim))?; + Some(ClientRouteCollision { + table: route.table().to_string(), + constructor: route.constructor().to_string(), + pattern: route.display_pattern().to_string(), + namespace, + }) + }) + .collect() +} + +/// Apply Spock's aggregate-host ownership policy before an Uhura candidate is +/// published. The rejection remains part of that coherent candidate, allowing +/// Editor to advance while Uhura atomically retains last-good Play. +pub(crate) fn apply_checked_client_route_admission( + candidate: &mut ClientCandidate, +) -> Vec { + let collisions = checked_client_route_collisions(candidate.checked_route_patterns()); + for collision in &collisions { + candidate.reject_play_admission(PlayAdmissionRejection::new( + ROUTE_CODE, + ROUTE_RULE, + collision.message(), + )); + } + collisions +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use tempfile::tempdir; + use uhura_host::{build_candidate, capture_project_snapshot}; + + use super::*; + + fn write_project(root: &Path, pattern: &str) { + fs::write( + root.join("uhura.toml"), + r#"[project] +name = "test.routes" +version = 1 +language = "0.4" + +[modules] +app = "app.uhura" +"#, + ) + .unwrap(); + fs::write( + root.join("app.uhura"), + format!( + r#"use uhura::web_router::{{Router, Routes as WebRoutes}}; + +pub enum Location {{ + Page, +}} + +pub const ROUTES: WebRoutes = WebRoutes::from([ + ("Page", "{pattern}"), +]); + +pub machine App {{ + port router = Router {{ routes: ROUTES }}; + + outcomes {{ + commit Accepted, + }} + + on router.Changed(location) {{ + Accepted + }} +}} +"# + ), + ) + .unwrap(); + fs::write( + root.join("host.toml"), + r#"[entry.app] +machine = "crate::App" +lifetime = "application-session" + +[entry.app.ports] +router = "web.history" +"#, + ) + .unwrap(); + } + + #[test] + fn composition_policy_consumes_the_checked_candidate_route_view() { + for (pattern, namespace) in [ + ("/api", "/api"), + ("/api%2Fshadow", "/api"), + ("/graphql/v2", "/graphql"), + ("/graphql%2Fv2", "/graphql"), + ("/rest/v2/items", "/rest"), + ("/rest%2Fv2/items", "/rest"), + ("/storage/v2/object", "/storage"), + ("/storage%2Fv2/object", "/storage"), + ] { + let root = tempdir().unwrap(); + write_project(root.path(), pattern); + let candidate = build_candidate(&capture_project_snapshot(root.path()), 1); + assert!( + candidate.summary().play_ok, + "{pattern} diagnostics: {:#}", + candidate.diagnostics().play + ); + let collisions = checked_client_route_collisions(candidate.checked_route_patterns()); + assert_eq!(collisions.len(), 1, "{pattern}"); + assert_eq!(collisions[0].table, "test.routes@1::ROUTES"); + assert_eq!(collisions[0].constructor, "Page"); + assert_eq!(collisions[0].pattern, pattern); + assert_eq!(collisions[0].namespace, namespace); + } + } + + #[test] + fn similarly_prefixed_checked_routes_remain_application_owned() { + for pattern in ["/apiary", "/graphical", "/restroom", "/storage-unit"] { + let root = tempdir().unwrap(); + write_project(root.path(), pattern); + let candidate = build_candidate(&capture_project_snapshot(root.path()), 1); + assert!(candidate.summary().play_ok, "{pattern}"); + assert!( + checked_client_route_collisions(candidate.checked_route_patterns()).is_empty(), + "{pattern}" + ); + } + } +} diff --git a/crates/spock-host/src/lib.rs b/crates/spock-host/src/lib.rs index c191d2c..6149910 100644 --- a/crates/spock-host/src/lib.rs +++ b/crates/spock-host/src/lib.rs @@ -7,6 +7,7 @@ mod assets; mod backend_capture; mod client; +mod client_route_admission; mod events; mod generation; mod http; diff --git a/crates/spock-host/src/project.rs b/crates/spock-host/src/project.rs index d01e9b7..1576179 100644 --- a/crates/spock-host/src/project.rs +++ b/crates/spock-host/src/project.rs @@ -9,6 +9,9 @@ use spock_project::ProjectLayout; use spock_runtime::generation::{BackendGeneration, BackendGenerationError}; use uhura_host::{build_candidate, capture_project_snapshot, ProjectSourceSnapshot}; +use crate::client_route_admission::{ + apply_checked_client_route_admission, checked_client_route_collisions, ClientRouteCollision, +}; use crate::{ client_source_fingerprint, load_uhura_assets, observe_backend, AssetError, BackendDiagnostics, BackendObservation, ClientHost, ClientHostError, Fingerprint, FrameworkSession, @@ -208,11 +211,18 @@ pub fn check_project(layout: &ProjectLayout) -> Result, + failures: &mut ProjectCheckFailure, +) { + failures + .diagnostics + .extend(collisions.into_iter().map(|collision| { + let message = collision.message(); + ProjectCheckDiagnostic { + component: ProjectComponent::Client, + code: Some(collision.code().to_string()), + rule: Some(collision.rule().to_string()), + file: None, + span: None, + message, + } + })); +} + fn parse_diagnostic_span(value: &Value) -> Option { Some(ProjectDiagnosticSpan { offset: value.get("offset")?.as_u64()?, @@ -381,7 +410,8 @@ pub(crate) fn prepare_project( let (initial_client, client_diagnostics, web) = if let Some(client_layout) = &layout.client { let snapshot = capture_stable_client(client_layout.root.absolute()) .map_err(HostError::ClientCapture)?; - let candidate = build_candidate(&snapshot, 1); + let mut candidate = build_candidate(&snapshot, 1); + apply_checked_client_route_admission(&mut candidate); let summary = candidate.summary(); let diagnostic_messages = candidate_diagnostic_messages(&candidate); let diagnostic_text = candidate_diagnostic_text(&candidate); @@ -633,6 +663,119 @@ mod tests { load_project_from(root).unwrap() } + fn dummy_uhura_assets(root: &Path) -> UhuraAssetRoots { + let web = root.join("web"); + let wasm = root.join("wasm"); + fs::create_dir_all(web.join("assets")).unwrap(); + fs::create_dir_all(&wasm).unwrap(); + fs::write( + web.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(web.join("assets/app.js"), "export {};\n").unwrap(); + fs::write(wasm.join("uhura_wasm.js"), "export {};\n").unwrap(); + fs::write(wasm.join("uhura_wasm_bg.wasm"), b"wasm").unwrap(); + UhuraAssetRoots { web, wasm } + } + + fn write_client_routes(root: &Path, patterns: &[(&str, &str)]) { + let variants = patterns + .iter() + .map(|(constructor, _)| format!(" {constructor},")) + .collect::>() + .join("\n"); + let entries = patterns + .iter() + .map(|(constructor, pattern)| format!(" (\"{constructor}\", \"{pattern}\"),")) + .collect::>() + .join("\n"); + fs::write( + root.join("client/machine.uhura"), + format!( + r#"use uhura::web_router::{{Router, Routes as WebRoutes}}; + +pub enum Location {{ +{variants} +}} + +pub const ROUTES: WebRoutes = WebRoutes::from([ +{entries} +]); + +pub machine Starter {{ + port router = Router {{ routes: ROUTES }}; + + events {{ + Increment, + }} + + outcomes {{ + commit Accepted, + }} + + state {{ + count: Nat = 0, + }} + + observe {{ + count, + }} + + on Increment {{ + count = count + 1; + Accepted + }} + + on router.Changed(location) {{ + Accepted + }} +}} +"# + ), + ) + .unwrap(); + fs::write( + root.join("client/host.toml"), + r#"[entry.starter] +machine = "crate::Starter" +presentation = "crate::StarterWeb" +lifetime = "application-session" + +[entry.starter.ports] +router = "web.history" +"#, + ) + .unwrap(); + fs::write( + root.join("client/evidence.uhura"), + r#"use crate::starter::{ROUTES, Starter}; +use crate::ui::StarterWeb; +use uhura::web_router::Router; + +scenario walkthrough for Starter { + bind router = Router.fixture(ROUTES) + + start + pin welcome + + send Increment + expect Accepted commands [] + pin incremented +} + +example welcome + for StarterWeb as page default + = walkthrough::welcome; + +example incremented + for StarterWeb as page + = walkthrough::incremented; +"#, + ) + .unwrap(); + } + #[test] fn project_check_accepts_backend_only_and_empty_full_stack_projects() { let backend = tempdir().unwrap(); @@ -660,11 +803,7 @@ mod tests { fn project_check_aggregates_backend_and_client_failures() { let temp = tempdir().unwrap(); let layout = write_project(temp.path(), "table broken {", true); - fs::write( - temp.path().join("client/app/home/page.uhura"), - "not valid uhura", - ) - .unwrap(); + fs::write(temp.path().join("client/machine.uhura"), "not valid uhura").unwrap(); let failure = check_project(&layout).unwrap_err(); assert!(failure @@ -691,6 +830,115 @@ mod tests { assert!(rendered.contains('['), "{rendered}"); } + #[test] + fn project_check_rejects_every_spock_owned_client_route_namespace() { + for (constructor, pattern, namespace) in [ + ("Api", "/api", "/api"), + ("Meta", "/graphql", "/graphql"), + ("Graph", "/graphql/v2/query", "/graphql"), + ("Rest", "/rest/v2/items", "/rest"), + ("Storage", "/storage/v2/object", "/storage"), + ] { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "", true); + write_client_routes(temp.path(), &[(constructor, pattern)]); + + let failure = check_project(&layout).unwrap_err(); + let diagnostic = failure + .diagnostics() + .iter() + .find(|diagnostic| { + diagnostic.rule.as_deref() == Some("spock/reserved-client-route") + }) + .unwrap_or_else(|| panic!("{pattern} diagnostics: {:#?}", failure.diagnostics())); + assert_eq!(diagnostic.component, ProjectComponent::Client); + assert_eq!(diagnostic.code.as_deref(), Some("SPK1001")); + assert_eq!(diagnostic.file, None); + assert_eq!(diagnostic.span, None); + assert!(diagnostic.message.contains("spock.starter@1::ROUTES")); + assert!(diagnostic.message.contains(constructor)); + assert!(diagnostic.message.contains(pattern)); + assert!(diagnostic.message.contains(namespace)); + } + } + + #[test] + fn project_check_keeps_similarly_prefixed_application_routes() { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "", true); + write_client_routes( + temp.path(), + &[ + ("Graphical", "/graphical"), + ("Restroom", "/restroom"), + ("StorageUnit", "/storage-unit"), + ], + ); + + let report = check_project(&layout).unwrap(); + assert!(report.client.is_some()); + } + + #[test] + fn start_preparation_rejects_route_collisions_before_loading_web_assets() { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "", true); + write_client_routes(temp.path(), &[("Graph", "/graphql/v1")]); + + let error = match prepare_project(layout, HostMode::Start, None, None) { + Ok(_) => panic!("route collision must fail start preparation"), + Err(error) => error, + }; + assert!(matches!(error, HostError::ClientInvalid(_)), "{error:?}"); + assert!(error.to_string().contains("spock/reserved-client-route")); + assert!(error.to_string().contains("/graphql/v1")); + } + + #[test] + fn dev_preparation_publishes_colliding_editor_without_play() { + let temp = tempdir().unwrap(); + let assets = tempdir().unwrap(); + let layout = write_project(temp.path(), "", true); + write_client_routes(temp.path(), &[("Graph", "/graphql/v1")]); + + let prepared = prepare_project( + layout, + HostMode::Dev, + None, + Some(dummy_uhura_assets(assets.path())), + ) + .unwrap(); + let status = prepared.session.status(); + assert!(status.client.active.is_none()); + assert_eq!( + status.client.latest_attempt.unwrap().state, + crate::ClientAttemptState::Rejected + ); + let publication_state = prepared.session.publication(); + let publication = publication_state.read().unwrap(); + let client = publication.client.as_ref().unwrap(); + assert!(client.latest_publication().report.editor_current); + assert!(!client.latest_publication().report.play_ok); + assert_eq!( + client + .route(uhura_host::RouteRequest { + method: uhura_host::RequestMethod::Get, + url: "/api/editor/state", + }) + .status, + 200 + ); + assert_eq!( + client + .route(uhura_host::RouteRequest { + method: uhura_host::RequestMethod::Get, + url: "/api/play/ir.json", + }) + .status, + 503 + ); + } + #[test] fn topology_identity_changes_for_bytes_and_unsafe_entry_kinds() { let temp = tempdir().unwrap(); @@ -764,7 +1012,7 @@ mod tests { .expect("unchanged project should remain eligible"); fs::write( - temp.path().join("client/app/home/page.uhura"), + temp.path().join("client/machine.uhura"), "this source changed during preparation\n", ) .unwrap(); diff --git a/crates/spock-host/src/routing.rs b/crates/spock-host/src/routing.rs index 412ab25..26b4b49 100644 --- a/crates/spock-host/src/routing.rs +++ b/crates/spock-host/src/routing.rs @@ -8,6 +8,8 @@ pub enum RouteOwner { NotFound, } +const RESERVED_PROTOCOL_MOUNTS: [&str; 4] = ["/api", "/graphql", "/rest", "/storage"]; + /// Classify one URI path into the combined host's non-overlapping route map. /// /// Query strings are transport metadata and must be removed by the caller. @@ -116,10 +118,19 @@ fn client_path(path: &str) -> bool { } fn reserved_protocol_path(path: &str) -> bool { - path.starts_with("/~") - || ["/api", "/graphql", "/rest", "/storage"] - .iter() - .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/"))) + reserved_protocol_namespace(path).is_some() +} + +fn reserved_protocol_namespace(path: &str) -> Option<&'static str> { + if path.starts_with("/~") { + return Some("/~*"); + } + RESERVED_PROTOCOL_MOUNTS.into_iter().find(|prefix| { + path == *prefix + || path + .strip_prefix(*prefix) + .is_some_and(|suffix| suffix.starts_with('/')) + }) } #[cfg(test)] diff --git a/crates/spock-host/src/server.rs b/crates/spock-host/src/server.rs index e012a02..613d3ad 100644 --- a/crates/spock-host/src/server.rs +++ b/crates/spock-host/src/server.rs @@ -994,11 +994,11 @@ mod tests { .latest_publication() .clone(); - let page = project.path().join("client/app/home/page.uhura"); - let original = fs::read_to_string(&page).unwrap(); + let presentation = project.path().join("client/ui.uhura"); + let original = fs::read_to_string(&presentation).unwrap(); let edited = original.replace("Your app is running.", "A client build is in flight."); assert_ne!(edited, original); - fs::write(&page, edited).unwrap(); + fs::write(&presentation, edited).unwrap(); let changed_frame = capture_frame(&prepared.layout).unwrap(); let changed = apply_frame( &session, @@ -1352,7 +1352,7 @@ mod tests { let initial_play = ok_bytes(&client, address, "/api/play/ir.json").await; let initial_contract = ok_bytes(&client, address, "/~contract").await; - let client_source = project.path().join("client/app/home/page.uhura"); + let client_source = project.path().join("client/machine.uhura"); let original_client_source = fs::read(&client_source).unwrap(); fs::write(&client_source, "this is not valid uhura\n").unwrap(); let rejected = status_until( @@ -1457,11 +1457,11 @@ mod tests { fs::write(project.path().join("backend/app.spock"), "").unwrap(); write_client_template(&project.path().join("client-a")); write_client_template(&project.path().join("client-b")); - let retargeted_page = project.path().join("client-b/app/home/page.uhura"); - let retargeted_source = fs::read_to_string(&retargeted_page) + let retargeted_presentation = project.path().join("client-b/ui.uhura"); + let retargeted_source = fs::read_to_string(&retargeted_presentation) .unwrap() .replace("Your app is running.", "The retargeted app is running."); - fs::write(&retargeted_page, retargeted_source).unwrap(); + fs::write(&retargeted_presentation, retargeted_source).unwrap(); write_client_template(outside.path()); symlink("client-a", project.path().join("client")).unwrap(); fs::write( diff --git a/crates/spock-project/src/path.rs b/crates/spock-project/src/path.rs index 9a5d96c..38f9fb8 100644 --- a/crates/spock-project/src/path.rs +++ b/crates/spock-project/src/path.rs @@ -459,11 +459,10 @@ mod tests { } for valid in [ - "app/home/page.examples.uhura", - "app/home/page.uhura", - "catalog/base.toml", - "fixtures/empty.toml", - "fixtures/scripts/empty.toml", + "evidence.uhura", + "host.toml", + "machine.uhura", + "ui.uhura", "uhura.toml", ] { assert_eq!(NormalizedRelativePath::file(valid).unwrap().as_str(), valid); diff --git a/crates/spock-project/src/starter.rs b/crates/spock-project/src/starter.rs index 5fc8294..8fefb47 100644 --- a/crates/spock-project/src/starter.rs +++ b/crates/spock-project/src/starter.rs @@ -10,24 +10,20 @@ use crate::plan::{ClientTemplate, TemplateFile}; pub fn minimal_uhura_client_template() -> ClientTemplate { let files = [ ( - "app/home/page.examples.uhura", - include_bytes!("../templates/minimal-client/app/home/page.examples.uhura").as_slice(), + "evidence.uhura", + include_bytes!("../templates/minimal-client/evidence.uhura").as_slice(), ), ( - "app/home/page.uhura", - include_bytes!("../templates/minimal-client/app/home/page.uhura").as_slice(), + "host.toml", + include_bytes!("../templates/minimal-client/host.toml").as_slice(), ), ( - "catalog/base.toml", - include_bytes!("../templates/minimal-client/catalog/base.toml").as_slice(), + "machine.uhura", + include_bytes!("../templates/minimal-client/machine.uhura").as_slice(), ), ( - "fixtures/empty.toml", - include_bytes!("../templates/minimal-client/fixtures/empty.toml").as_slice(), - ), - ( - "fixtures/scripts/empty.toml", - include_bytes!("../templates/minimal-client/fixtures/scripts/empty.toml").as_slice(), + "ui.uhura", + include_bytes!("../templates/minimal-client/ui.uhura").as_slice(), ), ( "uhura.toml", @@ -51,13 +47,12 @@ mod tests { use super::*; use crate::plan::{scaffold_plan, DEFAULT_BACKEND_SOURCE}; - const TEMPLATE_PATHS: [&str; 6] = [ - "app/home/page.examples.uhura", - "app/home/page.uhura", - "catalog/base.toml", - "fixtures/empty.toml", - "fixtures/scripts/empty.toml", + const TEMPLATE_PATHS: [&str; 5] = [ + "evidence.uhura", + "host.toml", + "machine.uhura", "uhura.toml", + "ui.uhura", ]; #[test] @@ -75,6 +70,31 @@ mod tests { TEMPLATE_PATHS ); assert!(first.files().iter().all(|file| !file.contents().is_empty())); + + let contents = |path: &str| { + std::str::from_utf8( + first + .files() + .iter() + .find(|file| file.path().as_str() == path) + .expect("canonical starter file") + .contents(), + ) + .expect("canonical starter text is UTF-8") + }; + assert!(contents("machine.uhura").starts_with("pub machine Starter")); + assert!(contents("ui.uhura").starts_with("use uhura::ui;\n")); + assert!(contents("evidence.uhura").starts_with("use crate::starter::Starter;\n")); + assert!(contents("host.toml").contains("lifetime = \"application-session\"")); + let manifest = contents("uhura.toml"); + assert!(manifest.contains("language = \"0.4\"")); + assert!(manifest.contains("starter = \"machine.uhura\"")); + assert!(manifest.contains("ui = \"ui.uhura\"")); + assert!(manifest.contains("[evidence.modules]")); + assert!(manifest.contains("examples = \"evidence.uhura\"")); + assert!(!manifest.contains("sources = [")); + assert!(!manifest.contains("generation")); + assert!(!manifest.contains("runtime")); } #[test] diff --git a/crates/spock-project/templates/minimal-client/app/home/page.examples.uhura b/crates/spock-project/templates/minimal-client/app/home/page.examples.uhura deleted file mode 100644 index acc53b8..0000000 --- a/crates/spock-project/templates/minimal-client/app/home/page.examples.uhura +++ /dev/null @@ -1,5 +0,0 @@ -use fixture empty - -example welcome default { - note "The clean starting point generated by spock new." -} diff --git a/crates/spock-project/templates/minimal-client/app/home/page.uhura b/crates/spock-project/templates/minimal-client/app/home/page.uhura deleted file mode 100644 index 717d2aa..0000000 --- a/crates/spock-project/templates/minimal-client/app/home/page.uhura +++ /dev/null @@ -1,14 +0,0 @@ -page - - - SPOCK + UHURA - Your app is running. - Edit client/app/home/page.uhura to begin. - - - diff --git a/crates/spock-project/templates/minimal-client/catalog/base.toml b/crates/spock-project/templates/minimal-client/catalog/base.toml deleted file mode 100644 index 8d56476..0000000 --- a/crates/spock-project/templates/minimal-client/catalog/base.toml +++ /dev/null @@ -1,11 +0,0 @@ -[catalog] -name = "spock-starter" -version = "0.1.0" - -[elements.view] -class = "layout" -children = "any" - -[elements.text] -class = "content" -children = "text" diff --git a/crates/spock-project/templates/minimal-client/evidence.uhura b/crates/spock-project/templates/minimal-client/evidence.uhura new file mode 100644 index 0000000..a56cdaa --- /dev/null +++ b/crates/spock-project/templates/minimal-client/evidence.uhura @@ -0,0 +1,22 @@ +use crate::starter::Starter; +use crate::ui::StarterWeb; + +scenario walkthrough for Starter { + start + pin welcome + + send Increment + expect Accepted commands [] + pin incremented +} + + +example welcome + for StarterWeb as page default + note "The clean starting point generated by spock new." + = walkthrough::welcome; + +example incremented + for StarterWeb as page + note "The same program after one accepted input." + = walkthrough::incremented; diff --git a/crates/spock-project/templates/minimal-client/fixtures/empty.toml b/crates/spock-project/templates/minimal-client/fixtures/empty.toml deleted file mode 100644 index 06f0707..0000000 --- a/crates/spock-project/templates/minimal-client/fixtures/empty.toml +++ /dev/null @@ -1,2 +0,0 @@ -# Intentionally empty. Add deterministic named slices here as the client -# begins consuming backend projections. diff --git a/crates/spock-project/templates/minimal-client/fixtures/scripts/empty.toml b/crates/spock-project/templates/minimal-client/fixtures/scripts/empty.toml deleted file mode 100644 index 654bc00..0000000 --- a/crates/spock-project/templates/minimal-client/fixtures/scripts/empty.toml +++ /dev/null @@ -1,5 +0,0 @@ -# A deterministic no-op driver for the starter page. -on-unscripted = "error" -deliver = [] -reply = [] -ui = [] diff --git a/crates/spock-project/templates/minimal-client/host.toml b/crates/spock-project/templates/minimal-client/host.toml new file mode 100644 index 0000000..990c2a8 --- /dev/null +++ b/crates/spock-project/templates/minimal-client/host.toml @@ -0,0 +1,4 @@ +[entry.starter] +machine = "crate::Starter" +presentation = "crate::StarterWeb" +lifetime = "application-session" diff --git a/crates/spock-project/templates/minimal-client/machine.uhura b/crates/spock-project/templates/minimal-client/machine.uhura new file mode 100644 index 0000000..828a752 --- /dev/null +++ b/crates/spock-project/templates/minimal-client/machine.uhura @@ -0,0 +1,22 @@ +pub machine Starter { + events { + Increment, + } + + outcomes { + commit Accepted, + } + + state { + count: Nat = 0, + } + + observe { + count, + } + + on Increment { + count = count + 1; + Accepted + } +} diff --git a/crates/spock-project/templates/minimal-client/uhura.toml b/crates/spock-project/templates/minimal-client/uhura.toml index 8586dff..f87471c 100644 --- a/crates/spock-project/templates/minimal-client/uhura.toml +++ b/crates/spock-project/templates/minimal-client/uhura.toml @@ -1,14 +1,14 @@ -[app] -name = "spock-starter" -entry = "home" +[project] +name = "spock.starter" +version = 1 +language = "0.4" -[catalog] -path = "catalog/base.toml" +[modules] +starter = "machine.uhura" +ui = "ui.uhura" -[fixtures] -empty = "fixtures/empty.toml" +[evidence.modules] +examples = "evidence.uhura" -[play.default] -fixture = "empty" -script = "empty" -allow_fixture = true +# Live machine, presentation, lifetime, and port bindings belong to host.toml. +# Add [assets] or [icons] here when the application needs them. diff --git a/crates/spock-project/templates/minimal-client/ui.uhura b/crates/spock-project/templates/minimal-client/ui.uhura new file mode 100644 index 0000000..e159e8d --- /dev/null +++ b/crates/spock-project/templates/minimal-client/ui.uhura @@ -0,0 +1,13 @@ +use uhura::ui; +use crate::starter::Starter; + +pub ui StarterWeb for Starter(view) { +
+

SPOCK + UHURA

+

Your app is running.

+

Count: {view.count}

+ +
+} diff --git a/crates/spock-project/tests/project_flow.rs b/crates/spock-project/tests/project_flow.rs index 440344b..dd8fa06 100644 --- a/crates/spock-project/tests/project_flow.rs +++ b/crates/spock-project/tests/project_flow.rs @@ -25,7 +25,7 @@ fn scaffolded_full_stack_project_discovers_and_loads_from_a_descendant() { .unwrap(); apply_for_test(&plan); - let descendant = destination.join("client/app"); + let descendant = destination.join("client"); let target = resolve_target(None, &descendant).unwrap(); assert!(matches!(target, ResolvedTarget::Project(_))); let layout = load_project_from(&descendant).unwrap(); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d9e91c1..ac47b63 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -18,6 +18,12 @@ you name. makes the bare `spock` command available. Platform requirements and the distribution model live on the [install page](../start/install.md). +The version transcript below is the current published package. Its framework +commands are available, but its embedded Uhura frontend predates strict 0.4. +Sections that show the machine/UI/evidence scaffold describe current source +until a compatible npm release ships; use `--backend-only` with 0.5.3 rather +than mixing that sidecar with a 0.4 client. + ```sh $ spock --version spock 0.5.3 @@ -52,7 +58,7 @@ provable. ```text $ spock check -ok: project `demo` — 0 table(s), 0 record(s), 0 fn(s), 0 seed row(s), 1 preview(s), 0 replay-derived preview(s), 1 unchecked link(s), 1 warning(s) +ok: project `demo` — 0 table(s), 0 record(s), 0 fn(s), 0 seed row(s), 2 preview(s), 1 replay-derived preview(s), 1 unchecked link(s), 1 warning(s) warning: link: application-owned provider adapter code remains unchecked ``` @@ -72,10 +78,10 @@ demo/ │ └── app.spock └── client/ ├── uhura.toml - ├── app/home/page.uhura - ├── app/home/page.examples.uhura - ├── catalog/base.toml - └── fixtures/… + ├── host.toml + ├── machine.uhura + ├── ui.uhura + └── evidence.uhura ``` `new` is create-new-only: the destination must not exist, and a conflicting diff --git a/docs/reference/http.md b/docs/reference/http.md index 854c12e..b8e4d53 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -14,6 +14,19 @@ never collide with one, and future surfaces (an `/auth/v1`, say) claim mounts the same way. The one exception worth knowing: the `rpc` path segment under `/rest/v1` is protocol-owned too, so a table named `rpc` fails startup. +In a framework project, this ownership is also a client-admission rule. +Spock asks Uhura whether routes selected by the deployed machine's +`web.history` port overlap `/~*`, `/api`, `/graphql`, `/rest`, or `/storage` +and their descendants. It consumes Uhura's checked path semantics; it does not +parse source route strings. The composition check runs for `spock check`, +before `spock start` binds or loads browser assets, and for client generations +observed by `spock dev`. A rejected development edit still publishes the +current Editor graph and diagnostic while Play keeps serving the last good +client generation. Uhura remains framework-agnostic: these namespaces are +reserved by the Spock host, not by the Uhura language. Similar ordinary names +such as `/graphical`, `/restroom`, and `/storage-unit` remain application +routes. + The examples on this page assume this program, served with `spock run api.spock`: diff --git a/docs/rfd/0020-distribution.md b/docs/rfd/0020-distribution.md index 9b65514..b7e977d 100644 --- a/docs/rfd/0020-distribution.md +++ b/docs/rfd/0020-distribution.md @@ -19,7 +19,7 @@ genuinely open are in §10. Local framework acceptance assembles the exact 21-file package topology and sidecar inventory. Release CI remains authoritative for the four real platform -artifacts and rejects any package above 25 MiB. The `0.5.0` dry run installed +artifacts and rejects any package above 26 MiB. The `0.5.0` dry run installed and exercised that exact guarded tarball on macOS, Linux, and Windows after all four native targets built successfully; verification against the first real framework publish remains pending. @@ -85,9 +85,12 @@ forced by publishing. **npm Trusted Publishing (OIDC) is configured for the `spock` package only.** Each `@scope/spock-` package would need its own trusted-publisher config, which cannot be set until the package exists — a bootstrap that needs a one-time token. Bundling keeps v0 **tokenless** through -the one already-trusted package. The workflow's hard 25 MiB limit keeps the -all-platform trade-off explicit and makes package growth fail visibly. That -remains cheap enough for v0 that simplicity wins. +the one already-trusted package. The workflow's hard 26 MiB limit keeps the +all-platform trade-off explicit and makes package growth fail visibly. Uhura +0.4 moved the original limit by one MiB only after the exact 21-file dry-run +package measured 26,501,976 bytes; the increase is accounted for by four +statically linked copies of the language engine and the expanded Wasm sidecar, +not an accidental file. That remains cheap enough for v0 that simplicity wins. `optionalDependencies` remains the documented end-state (§7), for when the platform packages are worth bootstrapping. @@ -234,7 +237,7 @@ dispatch / tag vX.Y.Z │ ├─ assemble npm/binaries// (chmod 0755 on Unix) │ ├─ guard: all four platforms and the sidecar are present │ ├─ stamp the resolved version into package.json - │ ├─ npm pack: exact file set, exact 0755 executables, tarball ≤ 25 MiB + │ ├─ npm pack: exact file set, exact 0755 executables, tarball ≤ 26 MiB │ ├─ upload the guarded tarball as a one-day workflow artifact │ └─ npm publish the same guarded .tgz (OIDC, tokenless; --dry-run when requested) └─ verify (matrix: macos-14, ubuntu-22.04, windows-2025) @@ -263,7 +266,9 @@ is installed and exercised on the full macOS/Linux/Windows verification matrix without spending an npm version. Prereleases go out under a `next` dist-tag so `latest` only ever moves on a real cut. Both dry and real `npm publish` receive that same guarded `.tgz` as their package argument; neither branch silently -repacks the source directory. +repacks the source directory. The dry-run command alone uses `--force`, because +npm otherwise rejects `--dry-run` when the workspace version is already in the +registry; the real publishing branch never uses `--force`. **Why hand-rolled, not `dist`.** `dist`'s value is the matrix + C-toolchain provisioning + installers + Homebrew formula + GitHub Release, generated @@ -356,7 +361,7 @@ The committed shim is mode `0755`, and CI restores that exact mode on the Unix native binaries (`upload-artifact` drops native executable bits); npm preserves those modes in the tarball. Release CI rejects any missing or unexpected packed path, checks the shim plus all three Unix native binaries are exactly `0755`, -and measures the actual artifact against the framework's 25 MiB budget. +and measures the actual artifact against the framework's 26 MiB budget. **Version.** CI derives the version from `[workspace.package]`; a tag or an optional dispatch value is an exact assertion, never an independent source. It @@ -397,7 +402,7 @@ binary; the sidecar is runtime browser machinery, not a template dependency. | # | Decision | Recommendation | Trade-off | |---|---|---|---| | D1 | Orchestrator | **Hand-rolled `npm.yml`** for npm-only; adopt `dist` later when installers + brew are added | About 500 explicit lines including framework/package verification; `dist` re-enters when its currently unused outputs become wanted. | -| D2 | npm layout | **Single package, all binaries bundled** (not `optionalDependencies`) | Hard 25 MiB release gate; tokenless through the one trusted `spock` package. `optionalDependencies` is deferred until worth a bootstrap token (§7). | +| D2 | npm layout | **Single package, all binaries bundled** (not `optionalDependencies`) | Hard 26 MiB release gate; tokenless through the one trusted `spock` package. `optionalDependencies` is deferred until worth a bootstrap token (§7). | | D3 | Linux binary | **glibc for v0** (`ubuntu-22.04`); static-musl as the follow-up | glibc covers most hosts and isolates first-release CI variables; musl needs another target, linker toolchain, and runtime smoke. | | D4 | Studio prebuild | **plain `pnpm build` step per job + non-empty guard** | Rebuilds the SPA in each build job (cheap) and validates the mac/Windows build. | | D5 | Studio for crates.io | *(deferred)* `include = ["studio/dist/**"]` + check-only `build.rs` | Recorded so it isn't re-derived when crates.io is picked up. | @@ -446,7 +451,7 @@ and stays tokenless. - [x] Write `CHANGELOG.md`. - [x] Extend the package with one shared Uhura web/Wasm sidecar; build it once, record commits/protocols/hashes/sizes, bind its manifest SHA-256 into all - four binaries, and enforce the 25 MiB packed gate. + four binaries, and enforce the 26 MiB packed gate. - [x] Run the first framework release dry run: the exact guarded `0.5.0` tarball passed the four-target build and macOS/Linux/Windows installed-package verification, including framework routes and sidecar diff --git a/docs/rfd/0022-spock-framework.md b/docs/rfd/0022-spock-framework.md index 9a286aa..4ff047e 100644 --- a/docs/rfd/0022-spock-framework.md +++ b/docs/rfd/0022-spock-framework.md @@ -138,6 +138,13 @@ Projects add optional seed assets and richer Uhura directories such as components, surfaces, ports, providers, and styles only when the application needs them. The initial starter does not invent empty structure or a lockfile. +> **Implementation evolution.** This tree records the client model selected +> when RFD 0022 was accepted. Current source has since moved the incubating Uhura +> client to strict 0.4 machine, UI, evidence, and host modules. The generated +> topology documented by the current [quickstart](../start/quickstart.md) and +> [Uhura integration guide](../uhura.md) is authoritative for the toolchain; +> this retained tree is design history, not current scaffolding syntax. + `app.spock` is the conventional backend name, not `main.spock`: - it already appears in examples, command tests, and the composition proof; @@ -727,7 +734,7 @@ Initial release budgets on the recorded reference machine are: source backend startup baseline; valid client publication p95 within 1.5 seconds; idle observer CPU at or below 2%; a 250-revision soak with RSS growth at most 25 MiB and file-descriptor/thread growth at most three; shutdown and port -rebind within 2 seconds; and a packed all-platform npm artifact at most 25 MiB. +rebind within 2 seconds; and a packed all-platform npm artifact at most 26 MiB. Still deferred are backend world reuse/rebase/migration, Play state-preserving HMR, automatic provider TypeScript build supervision, multiple backends or @@ -791,7 +798,7 @@ watcher convention. ([Actions run 29379605382](https://github.com/gridaco/spock/actions/runs/29379605382)); that workflow remains the authoritative full-matrix integration proof. - The local package-topology smoke contains exactly 21 files and the release - workflow rejects a packed artifact above 25 MiB. Release CI remains + workflow rejects a packed artifact above 26 MiB. Release CI remains authoritative for the four real platform binaries. - `spock run app.spock` remains valid and shares the named-state safety lock. diff --git a/docs/start/install.md b/docs/start/install.md index 72e3359..255db2a 100644 --- a/docs/start/install.md +++ b/docs/start/install.md @@ -25,7 +25,7 @@ Play — and every command runs offline once the package is on disk. `npx` runs Spock without installing anything globally: ```sh -npx spock new demo +npx spock new demo --backend-only cd demo npx spock dev ``` @@ -69,6 +69,12 @@ later. The experimental error-declaration preview requires `0.5.2` or later; [project status](../status.md) records the standing of every surface. The [changelog](../../CHANGELOG.md) records each cut. +Published `spock@0.5.3` still embeds the retired Uhura frontend. Current source +has moved the client to strict Uhura 0.4, but that checker/runtime/asset set has +not shipped in npm yet. Use `--backend-only` for a version-stable 0.5.3 +quickstart, or use the repository source build for a current 0.4 full-stack +project. A 0.4 client must not be paired with the 0.5.3 sidecar. + ## Building from source The npm package is the no-checkout path, and for using Spock it is the only diff --git a/docs/start/quickstart.md b/docs/start/quickstart.md index e6ce48a..a098a69 100644 --- a/docs/start/quickstart.md +++ b/docs/start/quickstart.md @@ -5,15 +5,36 @@ order: 2 # Quickstart -In about ten minutes you will scaffold a project, declare a two-table -authority, serve it, read from it over REST, write to it over GraphQL, hit -your first derived error, and read the contract that predicted it. It assumes -the `spock` CLI from [Install](install.md) is on your path. +After the one-time source build below, in about ten minutes you will scaffold +a project, declare a two-table authority, serve it, read from it over REST, +write to it over GraphQL, hit your first derived error, and read the contract +that predicted it. + +> **Current-source client.** The full-stack scaffold shown here includes strict +> Uhura 0.4 and uses an explicitly built source-checkout binary below until a +> compatible npm release ships. Published `spock@0.5.3` users instead start +> with `spock new demo --backend-only` and use plain `spock` for later +> commands. Their backend behavior is the same, but the client files, preview +> counts, Editor, and Play shown on this page are absent. Do not pair the +> published retired sidecar with the 0.4 files below. ## Scaffold a project ```sh -spock new demo +# From the Spock source checkout. +git submodule update --init --recursive +corepack pnpm@10.11.0 -C crates/spock-runtime/studio install --frozen-lockfile +corepack pnpm@10.11.0 -C crates/spock-runtime/studio build +corepack pnpm@10.11.0 -C uhura/web install --frozen-lockfile +corepack pnpm@10.11.0 -C uhura/web build +bash uhura/scripts/build-wasm.sh +cargo build --locked -p spock-cli + +export SPOCK_SOURCE="$PWD/target/debug/spock" +export SPOCK_UHURA_WEB_DIST="$PWD/uhura/web/dist" +export SPOCK_UHURA_WASM_DIST="$PWD/uhura/crates/uhura-wasm/pkg/web" +cd .. +"$SPOCK_SOURCE" new demo cd demo ``` @@ -24,16 +45,10 @@ demo/ │ └── app.spock └── client/ ├── uhura.toml - ├── app/ - │ └── home/ - │ ├── page.uhura - │ └── page.examples.uhura - ├── catalog/ - │ └── base.toml - └── fixtures/ - ├── empty.toml - └── scripts/ - └── empty.toml + ├── host.toml + ├── machine.uhura + ├── ui.uhura + └── evidence.uhura ``` This is a framework project: a `spock.toml` manifest composing a Spock @@ -79,23 +94,25 @@ believable state. ## Check it ```sh -spock check +"$SPOCK_SOURCE" check ``` ```text -ok: project `demo` — 2 table(s), 0 record(s), 0 fn(s), 4 seed row(s), 1 preview(s), 0 replay-derived preview(s), 1 unchecked link(s), 1 warning(s) +ok: project `demo` — 2 table(s), 0 record(s), 0 fn(s), 4 seed row(s), 2 preview(s), 1 replay-derived preview(s), 1 unchecked link(s), 1 warning(s) warning: link: application-owned provider adapter code remains unchecked ``` `check` does more than parse: it materializes the schema in memory, validates every declaration, and replays the seed through the runtime write path. The counts after the seed rows — previews, links, the warning — describe the -Uhura client half of the project, which Spock observes but does not check. +Uhura client half of the project. Spock checks that client as part of the same +project: an invalid Uhura machine, Web UI, evidence corpus, host entry, or +provider contract makes the whole `spock check` fail. ## Serve it ```sh -spock dev +"$SPOCK_SOURCE" dev ``` ```text diff --git a/docs/status.md b/docs/status.md index 44dcb9a..a21ced2 100644 --- a/docs/status.md +++ b/docs/status.md @@ -14,8 +14,10 @@ contract: the compiled contract shape and the derived error vocabulary are frozen additively for v0.x, while source syntax may still change between minor versions. -That is the whole disclaimer. Every other page on this site describes shipped -behavior in plain terms and points here instead of hedging inline. +That is the whole disclaimer. Every page distinguishes shipped behavior from +any explicitly labeled current-source integration that is waiting for a +release; pages otherwise describe shipped behavior in plain terms and point +here instead of hedging inline. ## Legend @@ -72,7 +74,7 @@ behavior in plain terms and points here instead of hedging inline. | `spock new` / `init` / `check` / `start` / `dev` | Stable | | `spock dev` client live reload (last-known-good) | Stable | | `spock dev` backend reload (currently `restart_required`) | In progress | -| Uhura client language | Experimental — incubating, see [Uhura](uhura.md) | +| Uhura client language | Experimental — strict 0.4 is integrated in current source but not yet shipped in npm; published 0.5.3 embeds the retired frontend. See [Uhura](uhura.md) | ## Distribution diff --git a/docs/uhura.md b/docs/uhura.md index d6004c1..2c77ca4 100644 --- a/docs/uhura.md +++ b/docs/uhura.md @@ -7,11 +7,11 @@ badge: # Uhura -Uhura is a declarative UI language and deterministic experience runtime, and -the optional client half of a Spock framework project. An Uhura program -defines what an interface presents, the local UI state that drives it, and how -semantic events advance that state; a runtime evaluates it into a -renderer-neutral semantic view. +Uhura is a deterministic machine language with an explicit, optional Web UI +profile. It is the client half of a Spock framework project when that project +needs one. An Uhura machine defines state, typed inputs, observations, and +deterministic transitions. A `ui` module may then project an observation into +checked Web semantics without becoming a second state model. Uhura is developed in its own repository: [github.com/gridaco/uhura](https://github.com/gridaco/uhura). @@ -22,8 +22,9 @@ The two languages keep separate responsibilities: - **Spock** specifies authoritative backend state and guarded product behavior — the authority. -- **Uhura** specifies non-authoritative interface state and experience - behavior — the experience. Renderers own layout and presentation. +- **Uhura** specifies deterministic, non-authoritative client state and + behavior — the experience. Its optional Web UI profile specifies semantic + presentation; renderers own physical layout and device mechanics. Integration crosses versioned port and provider contracts, and one rule governs the seam: a fact should never be authoritative in both systems. Your @@ -33,11 +34,15 @@ the Uhura client; neither restates the other. ## Status: incubating Uhura is incubating. Its grammar, ABI, package structure, and compatibility -policy may change between releases — the 0.5.3 toolchain, for example, -renamed core widgets in a deliberately breaking pre-v1 update. There is no -accepted Uhura specification yet, which is why this site documents no Uhura -syntax: anything copied here would fossilize a draft that Uhura's own process -treats as disposable. For current material, use the +policy may change between releases. Current Spock source integrates the strict +Uhura 0.4 candidate: a machine-first rewrite with explicit UI, evidence, host, +and provider boundaries. Published `spock@0.5.3` embeds the retired frontend +and cannot run a strict 0.4 project. A compatible release must ship the +checker, runtime, Editor, Play assets, public guidance, and framework host +together. + +The 0.4 documents are candidate specifications, not a compatibility promise. +For the exact current language and its status, use the [Uhura repository](https://github.com/gridaco/uhura) directly. ## What ships with `spock` @@ -62,14 +67,16 @@ dependency-free Uhura client alongside the backend: ```text client/ ├── uhura.toml -├── app/home/page.uhura -├── app/home/page.examples.uhura -├── catalog/base.toml -└── fixtures/ +├── host.toml +├── machine.uhura +├── ui.uhura +└── evidence.uhura ``` -The scaffold is a welcome screen you can open in the Editor immediately; -`spock dev` republishes valid client saves live while you edit. +The scaffold is a complete Uhura 0.4 counter: a standalone machine, an +explicit web UI projection, its static evidence, and the host entry that binds +them. You can open it in the Editor immediately; `spock dev` republishes valid +client saves live while you edit. ## The canonical full-stack example @@ -77,6 +84,6 @@ The complete framework example — a Spock authority plus a full Uhura Instagram client served by one `spock start` — lives in the Uhura repository at [gridaco/uhura/examples/instagram](https://github.com/gridaco/uhura/tree/main/examples/instagram). -Its Play provider needs a one-time build the CLI does not perform (see that -example's README for the exact `pnpm` commands) before `spock start -examples/instagram` serves it. +It is strict Uhura 0.4, so run it from the companion Spock source checkout +until a compatible npm release exists. The example README records the +one-time web, Wasm, and provider build plus the paired source-asset overrides. diff --git a/npm/scripts/sidecar.mjs b/npm/scripts/sidecar.mjs index cdf5329..dc06302 100644 --- a/npm/scripts/sidecar.mjs +++ b/npm/scripts/sidecar.mjs @@ -20,18 +20,20 @@ const PROTOCOLS = Object.freeze({ environment: "spock-host-environment/1", project_status: "spock-project-status/1", project_event: "spock-project-event/1", - editor_state: "uhura-editor-state/2", + editor_state: "uhura-editor-state/5", editor_event: "uhura-editor-event/0", - ir: "uhura-ir/0", - inspect: "uhura-inspect/0", - view: "uhura-view/0", - provider: "uhura-provider/0", + ir: "uhura-ir/1", + inspect: "uhura-inspection/1", + view: "uhura-view/1", + adapter_provider: "uhura-adapter-provider/0", }); const REQUIRED_ROUTES = Object.freeze([ "/api/editor/state", "/api/editor/events", "/api/play/events", + "/api/play/config.json", "/api/play/ir.json", + "/api/play/inspect.json", "/api/play/wasm/uhura_wasm.js", ]); const REQUIRED_WASM = Object.freeze([ diff --git a/skills/README.md b/skills/README.md index d91cd14..25b76c0 100644 --- a/skills/README.md +++ b/skills/README.md @@ -22,5 +22,11 @@ The `spock-lang` skill helps coding agents create, inspect, validate, run, debug, and modify Spock programs. The `uhura-lang` skill helps coding agents create, inspect, validate, run, -debug, and modify Uhura clients inside npm-distributed Spock framework -projects. +debug, and modify strict machine-first Uhura 0.4 programs inside +compatible Spock framework projects. + +The current source targets Uhura 0.4. The published `spock@0.5.3` +package predates it. The skill therefore checks the installed CLI before +authoring source and stops on an incompatible distribution; it never falls +back to the retired v0 language. Building the repository toolchain is a +separate contributor workflow, not part of this public skill. diff --git a/skills/uhura-lang/SKILL.md b/skills/uhura-lang/SKILL.md index 695ca9a..f692652 100644 --- a/skills/uhura-lang/SKILL.md +++ b/skills/uhura-lang/SKILL.md @@ -1,38 +1,39 @@ --- name: uhura-lang -description: Create, inspect, validate, run, debug, and modify Uhura client experiences inside Spock framework projects using the published npm distribution. Use when translating product requirements into pages, components, surfaces, examples, typed ports, fixtures, provider-backed behavior, Canvas previews, or live Play interactions; repairing Uhura diagnostics; or verifying the boundary between disposable UI-session state and authoritative Spock behavior. +description: Create, inspect, validate, run, debug, and modify strict Uhura 0.4 machine-first programs and Web UI applications inside Spock framework projects. Use when translating requirements into machines, explicit UI projections, deterministic evidence, host bindings, or provider-backed behavior; repairing Uhura diagnostics; or verifying Editor, Play, and authority behavior with the npm-distributed Spock CLI. --- # Uhura Language -Treat the request as an executable experience-engineering task. Produce or -modify the client, validate the whole framework project, repair failures within -scope, and finish with concrete Editor, Play, and authority evidence. +Treat Uhura as a deterministic machine language first. Keep its Web UI profile, +evidence modules, and live host admission as explicit layers over the checked +machine. Produce or modify the program, validate the complete framework +project, repair failures within scope, and finish with concrete evidence. -Use only the published `spock` npm package and the files in the user's target -project. Do not require any additional implementation toolchain or examples. +Use the public npm workflow for user projects. Do not require a Rust checkout, +workspace build, or unpublished binary unless the user explicitly changes the +task to contributor development. -## Establish the environment +## Pass the compatibility gate first -1. Locate the target directory, `spock.toml`, configured client root, and its - `uhura.toml` and `.uhura` files. +1. Locate `spock.toml`, the configured client root, `uhura.toml`, `host.toml` + when present, and every mapped `.uhura` source file. 2. Require Node.js 18 or newer. Run `spock --version`; if unavailable, install - it once with `npm install --global spock@latest`, then use plain `spock` - commands and record the installed version. -3. Confirm that the target is a framework project with a configured client. - Use `spock new` to create a new backend-and-client project or `spock init` - to adopt an existing unambiguous project. -4. Preserve unrelated changes. Read the affected manifest, definitions, - examples, ports, fixtures, styles, and provider seams before editing. - -## Select the source of truth - -- Treat the installed npm CLI as the executable contract. -- Use the bundled references for implemented syntax, project contracts, - workflows, and limits; never substitute proposed syntax from memory. -- Run `spock check` instead of guessing whether a construct is accepted. -- If a reference and the installed version disagree, preserve the installed - version's behavior and report the version difference. + the requested public release once and then use plain `spock` commands. +3. For an existing project, read `[project].language` before editing and run + `spock check`. This skill documents strict language `"0.4"` only. +4. For a new project, prove compatibility before writing the requested target. + Stop immediately on published `spock@0.5.3`. For a later public release, + generate and check a disposable probe under the operating-system temporary + directory; require its manifest to select exact language `"0.4"`. + +The current repository uses Uhura 0.4, while the published `spock@0.5.3` +release predates that language. If the installed CLI is that release, generates +an older manifest in the disposable probe, or rejects +`[project] language = "0.4"`, stop and report a distribution mismatch. Never +use the requested target as the probe, rewrite the project in retired v0 +syntax, remove the language gate, or silently fall back to the installed +legacy grammar. Read references only as needed: @@ -41,58 +42,57 @@ Read references only as needed: - Read [references/source-language.md](references/source-language.md) before authoring or substantially changing `.uhura` source. - Read [references/project-and-providers.md](references/project-and-providers.md) - for manifests, ports, examples, fixtures, providers, and ownership. + for manifests, module maps, evidence, host entries, providers, and ownership. - Read [references/tooling-and-limits.md](references/tooling-and-limits.md) for - npm tooling, Editor, Canvas, Play, live reload, and current limitations. + the compatibility gate, npm tooling, Editor, Play, and current limitations. ## Start with ownership - Put durable records, authorization, accepted mutations, files, shared workflow, and cross-device truth in Spock or another authority. -- Put selected tabs, drafts, pending flags, optimistic overlays, notices, - logical navigation, and mounted surfaces in Uhura. +- Put deterministic state, events, outcomes, guards, drafts, pending flags, + optimistic overlays, notices, and logical navigation in an Uhura machine. +- Keep a `pub ui` declaration a pure projection of one machine observation. - Leave pixels, layout measurement, pointer mechanics, native media state, - clocks, network, files, and device I/O to renderers or declared provider - seams. + clocks, network, files, and device I/O to renderers or declared host adapters. - Never make the same fact authoritative in both Uhura and its provider. +## Preserve the layer boundaries + +- `uhura.toml` selects language 0.4 and maps logical core and evidence modules. +- Core `.uhura` modules define types, values, parts, ports, and `pub machine` + declarations. +- A UI module must explicitly `use uhura::ui;` before defining a `pub ui` + projection for a machine. +- Evidence modules define scenarios, checkpoints, and named examples over the + same checked machine; they do not define another runtime. +- `host.toml` selects one live machine, optional presentation, lifetime, + stylesheet, and exact adapter bindings after source checking. +- A provider module implements only ports assigned to its adapter identity. + It does not own machine state or UI semantics. + +Do not recreate retired path-based pages, component stores, surface stores, +TOML port contracts, fixture scripts, or catalog manifests. + ## Follow the execution loop -1. Convert the request into observable states and event paths: loading, ready, - empty, failed, pending, accepted, refused, retry, navigation, and surfaces. +1. Convert the request into state, input, outcome, command, observation, and + invariant contracts. 2. Run a healthy `spock check` baseline before changing an existing project. -3. Define or update typed projections and commands before consuming them in - the client. Keep authoritative operations in Spock. -4. Choose the correct source kind: page for a route, component for reusable - presentation, or surface for a mounted sheet, dialog, or popover. -5. Add only reconstructible UI-session state. Make guards, optimism, - settlement, rollback, dismissal, and navigation explicit. -6. Add pinned examples for meaningful static states and derived examples for - reachable interaction states. +3. Change the machine contract before its UI projection or host adapters. +4. Make loading, ready, empty, failed, pending, settlement, rollback, and + navigation states explicit where the product requires them. +5. Bind semantic UI events directly to checked machine inputs. Do not embed + JavaScript callbacks or mutate state from markup. +6. Add evidence that reaches and pins meaningful states through checked + inputs, deliveries, outcomes, and emitted commands. 7. Run `spock check` after each coherent change. Fix the earliest diagnostic first and repeat until the whole project checks. -8. Use `spock dev` while iterating on client source or `spock start` for one - fixed checked generation. Verify Editor, Play, Studio, actor selection, and - the affected provider boundary on the same origin. +8. Use `spock dev` while iterating or `spock start` for one fixed checked + generation. Verify Editor, Play, Studio, actor selection, and the affected + adapter boundary on the same origin. 9. Stop processes started for verification and report changed files, exact - commands, outcomes, provider/actor evidence, and material limitations. - -## Preserve deterministic semantics - -- Keep component behavior as typed emits over props. Put state machines in - pages and surfaces, not renderer callbacks. -- Use only implemented store statements: `set`, `send`, `open-surface`, - `dismiss`, and navigation variants. -- Dispatch one external event per core step. Do not invent timers, randomness, - ambient I/O, hidden queues, or host-language escapes. -- Guard duplicate commands. Keep pending and optimistic state explicit and - clear or roll it back on settlement. -- Handle command success and refusal/unavailable paths and projection - availability where required. -- Mount surfaces with `open-surface` and close them with `dismiss`; preserve - ownership, modality, and focus restoration. -- Use catalog semantics and authored CSS. Do not attach arbitrary DOM events - or hide product truth in styling. + commands, outcomes, provider evidence, and material limitations. ## Validate proportionally @@ -105,7 +105,7 @@ spock check path/to/project For behavior work, serve the project on an available port and verify `/`, `/play`, `/~studio`, `/~project/status`, and the exact affected read or command. -Canvas proves checked preview projection; Play plus the authority proves live +Editor proves checked evidence projection; Play plus the authority proves live behavior. Never treat a screenshot alone as behavioral proof. The npm CLI does not expose standalone Uhura format, trace, project, or editor diff --git a/skills/uhura-lang/agents/openai.yaml b/skills/uhura-lang/agents/openai.yaml index f85cd25..6af382a 100644 --- a/skills/uhura-lang/agents/openai.yaml +++ b/skills/uhura-lang/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Uhura Language" - short_description: "Build Uhura clients with npm Spock" - default_prompt: "Use $uhura-lang to create or modify this Uhura client with the npm-distributed Spock CLI, then verify Editor, Play, and authority behavior." + short_description: "Build strict Uhura 0.4 programs with Spock" + default_prompt: "Use $uhura-lang to create or modify this strict Uhura 0.4 program with the npm-distributed Spock CLI, stop on an incompatible legacy distribution, and verify Editor, Play, and authority behavior." diff --git a/skills/uhura-lang/references/project-and-providers.md b/skills/uhura-lang/references/project-and-providers.md index 5a57a82..97b75ca 100644 --- a/skills/uhura-lang/references/project-and-providers.md +++ b/skills/uhura-lang/references/project-and-providers.md @@ -1,6 +1,12 @@ -# Uhura Projects, Examples, and Providers +# Uhura 0.4 Projects, Evidence, and Providers -## Framework project layout +Keep the machine, presentation, evidence, and deployment boundaries visible. +They share checked types and one runtime kernel, but they are not one ambient +application language. + +## Framework project topology + +A small framework project normally has this shape: ```text spock.toml @@ -8,145 +14,174 @@ backend/ app.spock client/ uhura.toml - uhura.lock - app/**/page.uhura - components/*.uhura - surfaces/*.uhura - ports/*.port.toml - fixtures/standard.toml - fixtures/scripts/*.toml - fixtures/assets/manifest.toml - providers/ - catalog/base.toml + host.toml + machine.uhura + ui.uhura + evidence.uhura styles/theme.css + providers/ + app.js ``` -`spock.toml` composes the authority and client roots without merging their -languages or ownership. Inside the configured client, `uhura.toml` declares -the entry route, catalog, named ports, fixtures, assets, play profile, and -optional provider module/configuration. `uhura.lock` pins catalog and port -hashes. Treat contract drift as a project error; never hand-edit or silently -delete the lock to bypass it. - -## Port contracts - -Ports are the typed boundary to authoritative providers. Keep UI source provider-neutral. +The physical `.uhura` filenames are conventional, not semantic. `uhura.toml` +is the source of truth for logical modules: ```toml -[port] -name = "feed" -version = "0.1.0" +[project] +name = "spock.starter" +version = 1 +language = "0.4" -[types.user-ref] -kind = "record" +[modules] +starter = "machine.uhura" +ui = "ui.uhura" -[types.user-ref.fields] -id = "id" -username = "text" +[evidence.modules] +examples = "evidence.uhura" +``` -[types.media] -kind = "union" +Every owned `.uhura` file must appear exactly once in `[modules]` or +`[evidence.modules]`. Imports use the logical name: -[types.media.variants.image] -src = "asset" -alt = "text" +```uhura +use crate::starter::Starter; +``` -[projections.viewer] -type = "user-ref" -boot = true +Moving a physical file and updating only the module map must not change the +checked program identity. Public declaration identity follows package identity +and public name, not a directory route. -[projections.post-by-id] -type = "post-summary" -key = "id" +Optional `[assets]` and `[icons]` tables also belong in `uhura.toml`. Do not +invent a separate catalog manifest. -[refusals.not-authorized] +`uhura.lock` exists exactly when `[dependencies]` is non-empty. Never +hand-author, retain, or delete it to bypass resolution. A local project with no +dependencies must not carry a lock. -[commands.like-post] -payload = { post = "id" } -refusals = ["not-authorized"] -``` +## Deterministic evidence -Current type grammar includes `bool`, `int`, `text`, `option`, `list`, and declared types of kind `record`, `union`, `enum`, `id`, `opaque`, or `asset`. +Evidence modules use the same 0.4 lexer, imports, values, and machine contracts. +They define scenarios, checkpoints, and named UI examples without contributing +runtime declarations: -Use opaque values for provider-owned cursors that Uhura may echo but not -inspect. Use asset values for provider-resolved media references. Command -success payloads are empty in the current implementation; authority settlement -travels as projection updates, not a second payload carrier. +```uhura +use crate::starter::Starter; +use crate::ui::StarterWeb; -Projections are absent until delivered. A `boot = true` projection arrives before `Init`; other projections must be handled through availability. Keyed projections use a typed key. +scenario walkthrough for Starter { + start + pin welcome -## Examples and preview provenance + send Increment + expect Accepted commands [] + pin incremented +} -Place examples beside their definition: +example welcome + for StarterWeb as page default + note "The clean starting point." + = walkthrough::welcome; -```text -page.uhura -page.examples.uhura +example incremented + for StarterWeb as page + note "The same program after one accepted input." + = walkthrough::incremented; ``` -Pinned example: +Use evidence to prove reachability and exact machine behavior: -```uhura -use fixture standard +- `start` constructs the machine; +- `send` dispatches an external event; +- `deliver` supplies a typed port input; +- `expect` checks the outcome and ordered commands; +- `pin` captures a checked checkpoint; and +- `example` selects a UI projection over a checkpoint for Editor. -example first-page default { - projection feed.viewer = fixture.users.mira - projection feed.feed-page = fixture.feed.page-1 -} -``` +Bind deterministic standard port drivers in a scenario before `start` when the +machine requires them. Prefer a scenario chain over duplicating state literals. +Cover loading, ready, empty, failure, pending, accepted, refused, retry, and +navigation states that materially change the experience. -Derived example: +Evidence is not live provider proof. It executes the ordinary deterministic +machine with checked inputs and adapters; Play exercises the admitted host +deployment. -```uhura -example like-pending { - from first-page - events [ like-toggled(post: "post-1", now-liked: true) ] - note "optimistic state while the command is pending" -} -``` +## Live host admission -Use examples to expose meaningful loading, ready, empty, failure, pending, refusal, surface, and navigation states. A derived example must remain reachable through checked events from its source example. Preserve `from`, projections, events, and notes so Canvas can show honest provenance. +`host.toml` is read after the project resolves and checks. It selects one live +entry in 0.4: -Examples do not enter runtime IR. Static projection must not execute I/O or emit commands; replay-derived previews are checked build artifacts over deterministic core steps. +```toml +[entry.starter] +machine = "crate::Starter" +presentation = "crate::StarterWeb" +lifetime = "application-session" +stylesheet = "styles/theme.css" +``` -## Fixtures and scripts +Host selectors use public declarations, not logical module paths. The entry +selects: -Fixture data in `fixtures/*.toml` supplies named, typed projection slices. Keep slices aligned with port contracts and use meaningful product data rather than lorem ipsum or decorative counters. +- one public machine; +- an optional `pub ui` bound to that machine; +- exactly the `"application-session"` lifetime; +- required configuration when the machine config is not `Unit`; +- a stylesheet; +- exact port-adapter bindings; and +- an optional provider module and configuration. -Scripts in `fixtures/scripts/*.toml` provide deterministic provider delivery and UI events. A reply matches one pending command by command name and optional payload predicate. Use `after-ticks` to make pending states observable. Keep `on-unscripted = "error"` for strict scenarios unless the design explicitly requires another policy. +Core checking and evidence do not require `host.toml`. Play does. -Use focused scripts for one behavior and a canonical demo script for the full walkthrough. Trace both success and failure/refusal paths. +## Port and provider boundary -## Provider seam +Machines declare typed ports in `.uhura` source. The host maps each required +port locator to an adapter identity: -Core exchanges versioned JSON envelopes with a provider: +```toml +[entry.instagram.ports] +router = "web.history" +authority = "app.provider" +mutations = "app.provider" -```text -command -projection -projection-failed -outcome with optional atomic projection updates +[entry.instagram.provider] +module = "providers/dist/spock.js" + +[entry.instagram.provider.config] +endpoint = "http://127.0.0.1:4000" ``` -Rules: +`web.history` is a checked built-in adapter. `app.provider` names ports supplied +by the configured provider module. These sets must cover the deployment +requirements exactly. + +A custom module exports the provider adapter factory expected by the admitted +host, currently `createUhuraAdapters(config, host)`. Use the host-provided port, +adapter, contract hash, and contract-instance hash. Do not calculate or +hardcode compiler-owned identities. + +Provider rules: -- Echo core-minted correlation ids unchanged. -- Produce exactly one eventual outcome per command. -- Deliver one ordered, non-reentrant stream. -- Increase projection revisions strictly per `(projection, key)`. -- Apply an accepted command's projection consequence before or atomically with its outcome. -- Convert transport failure to `unavailable`; do not throw ambient exceptions into Core. -- Keep files and browser-native values outside the serializable Core envelope. +- implement only the ports assigned to the provider's adapter identity; +- preserve typed wire values and core-minted correlation ids; +- deliver one ordered, deferred stream so foreign code cannot synchronously + re-enter a machine reaction; +- produce exactly one eventual settlement for each request; +- convert transport failures into modeled deliveries rather than throwing them + into Core; and +- keep browser-native and authority-specific objects outside machine state. -The fixture driver is the deterministic CI provider. A live provider may use Spock, but no Spock object or source syntax belongs in Uhura Core or `.uhura` source. +The npm Spock host serves a generated JavaScript provider module but does not +compile app-specific TypeScript. Build provider source separately and point +`host.toml` at the generated JavaScript artifact. ## Authority boundary -Spock owns users, posts, relationships, permissions, transactions, files, accepted commands, and durable timestamps. Uhura owns local drafts, optimistic overlays, pending markers, notices, selected UI sections, surfaces, and logical navigation. +Spock or another authority owns users, records, permissions, transactions, +files, accepted mutations, and durable timestamps. Uhura owns deterministic +session state, drafts, optimistic overlays, pending markers, notices, and +logical navigation. -Derive displayed counts from authority source rows in the provider. Do not -store decorative counts in both systems. Return authority echoes and -projection updates after mutations. Treat local actor selection as prototype -impersonation, not production authentication. Verify authoritative -consequences through Studio or the affected endpoint, not by trusting client -state alone. +Do not duplicate one authoritative fact in machine and provider state. Verify +durable consequences through Studio or the affected endpoint, not by trusting +Play state alone. Actor selection in a prototype is impersonation, not +production authentication or authorization. diff --git a/skills/uhura-lang/references/source-language.md b/skills/uhura-lang/references/source-language.md index a5913ab..65eb503 100644 --- a/skills/uhura-lang/references/source-language.md +++ b/skills/uhura-lang/references/source-language.md @@ -1,182 +1,173 @@ -# Current Uhura Source Language +# Uhura 0.4 Source Language -Use `spock check` from the installed npm distribution as the acceptance test -for every source form. The language is incubating and not -compatibility-frozen; do not infer syntax that the installed version rejects. +Use `spock check` from a compatible npm distribution as the acceptance test. +Uhura 0.4 is strict, machine-first, and not backward-compatible with the +retired v0 page/store grammar. -## File placement and headers +## Rust-shaped core -Paths define identity: +Core source deliberately follows a bounded Rust shape: -```text -app/feed/page.uhura route feed -app/profile/[user]/page.uhura route profile(user) -components/post-card.uhura component post-card -surfaces/comments-sheet.uhura surface comments-sheet -``` +- `use`, `pub`, and `pub use` for resolution and visibility; +- `struct`, `enum`, constructors, patterns, and exhaustive `match`; +- immutable `let`, `const`, `fn`, lexical `return`, and block-tail values; +- semicolon-terminated statements and comma-separated fields; and +- snake_case values, SCREAMING_SNAKE_CASE constants, and UpperCamelCase types. -One definition lives in each file. Pages are route definitions and cannot be imported. Components and surfaces are imported explicitly. Use lowercase kebab-case names. +It does not borrow Rust ownership, traits, macros, async execution, or unsafe +foreign calls. Do not import JavaScript or treat expressions as JavaScript. -Headers: +Imports are explicit and inert: ```uhura -page - -component post-card - -surface comments-sheet modality sheet +use uhura::observation::Observation; +use crate::notice::Notice; ``` -Imports bring exact items into file scope: +`crate` names the current package. `uhura` names checked standard contracts. +Physical filenames do not infer modules; `uhura.toml` maps each logical module +to one source file. -```uhura -use component post-card -use surface comments-sheet -use port feed { - projection feed-page - projection viewer - command like-post - type post-summary -} -``` +## Machine contract -Declare route params, component/surface props, and component emits explicitly: +A minimal complete machine: ```uhura -param user: id - -props { - post: post-summary - liked: bool -} - -emits { - like-toggled(post: id, now-liked: bool) -} -``` - -## Store blocks - -Pages and surfaces own reconstructible UI-session state and handlers: +pub machine Starter { + events { + Increment, + } -```uhura -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - notice: text? = none + outcomes { + commit Accepted, } - 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) + state { + count: Nat = 0, } - on like-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none + observe { + count, } - on like-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - set notice = "Could not like this post." + on Increment { + count = count + 1; + Accepted } } ``` -Current value shapes include `bool`, `int`, `text`, `id`, `tag`, optionals, lists, records, and maps. Core has no floats, clock, randomness, environment, network, storage, URL, clipboard, renderer geometry, or unordered iteration. +The machine is the state-transition authority. Its named sections may include: -## Store statements +- `config` and `require` for admitted construction parameters; +- `events` for external inputs; +- `commands` and typed `port` declarations for output and later delivery; +- `outcomes`, with explicit `commit` or `abort` publication policy; +- `state`, `computed`, and `observe`; +- `invariant` checks; and +- one `on` reaction per admitted input pattern. -Only these statement forms are current: +Each reaction executes transactionally. A commit-policy outcome publishes its +new state and commands. An abort-policy outcome discards the draft and emits no +commands. Model pending work, optimism, settlement, and rollback as ordinary +typed state; do not hide them in a renderer or provider. -```text -set field = expression -set map[key] = expression -set map[key] = none -send command(args) -send command(args) as tag_binding -open-surface name(args) -dismiss -navigate route(named_args) -navigate replace route(named_args) -navigate back -``` - -`set` writes only the current scope. Handler execution is transactional. At most one guarded handler for an event runs. Structural statements apply at dispatch end. - -`send` emits a typed provider command and creates a pending correlation. An imported command provides `.ok` and `.err` events. Provider updates settle authority truth before the outcome handler clears an optimistic overlay. - -Use `as t` when local optimistic state needs the minted command tag as a stable key. - -## Navigation and surfaces - -Use plain `navigate` for hierarchical push navigation, `navigate replace` for peer/redirect replacement, and `navigate back` to reveal retained previous page state. +Use ports for typed host capabilities: ```uhura -on comments-requested(post: id) { - open-surface comments-sheet(post: post) -} - -on profile-tab-selected(user: id) { - navigate replace profile(user: user) +use uhura::observation::Observation; +use uhura::ports::RequestPort; +use uhura::web_router::{Router, Routes}; + +pub machine Application { + port router = Router { + routes: APP_ROUTES, + }; + port authority = Observation {}; + port mutations = RequestPort {}; + + // events, outcomes, state, observation, and reactions } ``` -Only a surface may `dismiss`. Dismissal pops that instance and emits focus restoration intent. Replacing or popping a page force-closes surfaces owned by the removed page. +Qualified port deliveries such as `mutations.Settled(request, result)` are +checked inputs. `emit mutations.Request(...)` publishes a typed command. A +provider is not called inline and cannot synchronously re-enter a reaction. -## Markup +## Explicit Web UI profile -Use catalog elements and semantic events: +Core is complete without presentation. A source module activates Web UI only +with the exact direct import: ```uhura - - - {post.caption} - +use uhura::ui; +use crate::starter::Starter; + +pub ui StarterWeb for Starter(view) { +
+

Count: {view.count}

+ +
+} ``` -The catalog, not HTML, defines legal elements, props, slots, and events. Elements represent semantics; the renderer decides concrete controls and pixels. +`pub ui Name for Machine(view)` is a named pure projection of that machine's +observation. It does not allocate an instance, own state, access private +machine fields, or grant browser authority. -Structural forms: +The 0.4 UI profile is Svelte-shaped where familiarity is useful: ```uhura -{#if condition} - ... +{#if view.loading} +

Loading…

{:else} - ... +
    + {#each view.items as item (item.id)} +
  • {item.label}
  • + {/each} +
{/if} - -{#each items as item (item.id)} - ... -{/each} - -{#match value} - {:when variant binding} - ... -{/match} ``` -Every repeated child needs a stable key. Match closed unions exhaustively. Projection availability uses `loading`, `failed reason`, and `ready value` arms. - -Expressions are deliberately small: literals, lexical names, field access, map lookup, option fallback `??`, boolean/comparison operators, integer arithmetic, text concatenation `++`, `if ... then ... else ...`, record literals, and accepted builtins such as `count` and `to-text`. Check the existing corpus before assuming another operation exists. - -Forward a declared component emit with `on:event-name`; produce a semantic event with `emit event-name(...)`. Do not invent DOM event names unless the catalog declares equivalent semantics. +Expressions inside braces are Uhura expressions, not JavaScript. UI bodies +cannot mutate state, emit commands, run callbacks, access the DOM, or execute +host code. -## CSS +Semantic event binding constructs exactly one machine input: -An optional final `