diff --git a/Cargo.lock b/Cargo.lock index 86e13e2..305976a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,7 @@ version = "0.1.0" dependencies = [ "cb-kernel", "games-ground", + "quick-js", "serde_json", ] @@ -104,6 +105,16 @@ dependencies = [ "games-ground", ] +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -162,6 +173,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "copy_dir" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3" +dependencies = [ + "walkdir", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -243,6 +263,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "games-ground" version = "0.1.0" @@ -331,6 +357,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libquickjs-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0b24e9bd171b75ae0295bd428fb8fe58410fb23156e5f34a4657a70c3cee96" +dependencies = [ + "cc", + "copy_dir", +] + [[package]] name = "memchr" version = "2.8.3" @@ -376,6 +412,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-js" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19cb4cefcb00f4ab9b332664d06005a74f582ac16aa959c6ad5912957bd83e5f" +dependencies = [ + "libquickjs-sys", + "once_cell", +] + [[package]] name = "quote" version = "1.0.47" @@ -512,6 +558,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "syn" version = "2.0.119" diff --git a/INTENT.md b/INTENT.md index ebf9939..8fb0fbd 100644 --- a/INTENT.md +++ b/INTENT.md @@ -64,6 +64,11 @@ game. and scenario tests, simple bots. No rendering, no physics. 1. **Inspectable 2D table** — card/token/hand/relationship-graph visualization, drag-to-propose, debug inspector, hot-seat play. + *Open on one human verification (CB-EV-0012 §4): the inspector, + drag-to-propose and hot-seat play are evidenced by executing code; + the visualization is evidenced only as correctly emitted, because no + browser is available to the loop. Run `cb-play --serve 0`, open the + printed URL, and confirm the table reads and a drag works.* 2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera and pointer controls, snap zones, asset importer. 3. **Networked sessions** — authoritative host, private projections, diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ff6ed48..adca1ae 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -100,3 +100,6 @@ | task | CB-WP-0013-T02 | done | — | workplans/CB-WP-0013-instrument-corrections.md | | task | CB-WP-0013-T03 | done | — | workplans/CB-WP-0013-instrument-corrections.md | | task | CB-WP-0013-T04 | done | — | workplans/CB-WP-0013-instrument-corrections.md | +| task | CB-WP-0014-T01 | done | — | workplans/CB-WP-0014-execute-the-javascript.md | +| task | CB-WP-0014-T02 | done | — | workplans/CB-WP-0014-execute-the-javascript.md | +| task | CB-WP-0014-T03 | done | — | workplans/CB-WP-0014-execute-the-javascript.md | diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml index 4435a33..204baaa 100644 --- a/crates/cb-render-html/Cargo.toml +++ b/crates/cb-render-html/Cargo.toml @@ -4,16 +4,28 @@ edition.workspace = true version.workspace = true license-file.workspace = true -# ADR-0007 Decision 1: the browser is the renderer, so this crate has -# **no third-party dependencies at all** beyond what the game already +[features] +# ADR-0009: the JS harness is exposed to cb-play's tests behind a feature, +# so the engine stays a dev cost there too and never a shipped one. +js-harness = ["dep:quick-js"] + +# ADR-0007 Decision 1: the browser is the renderer, so the rendering path +# has **no third-party dependencies at all** beyond what the game already # carries. Adding one here needs an argument against ADR-0007 §Decision 3. [dependencies] cb-kernel.workspace = true games-ground.workspace = true - -[lints] -workspace = true +# ADR-0009: an EMBEDDED engine, not `node` — CI runs on rust:1.97, which +# has no node, so requiring one would make our build acquire a runtime +# nobody audits while it scored zero on the only instrument that governs +# dependencies. Optional, and never in the shipped-runtime configuration: +# AM-4a measures 157,202 against 161,000 and has no room for it. +quick-js = { version = "0.4", optional = true } [dev-dependencies] # The coverage gate walks the serialized view; nothing else needs it. serde_json.workspace = true +quick-js = "0.4" + +[lints] +workspace = true diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs new file mode 100644 index 0000000..484462b --- /dev/null +++ b/crates/cb-render-html/src/jsrun.rs @@ -0,0 +1,290 @@ +//! Execute the emitted JavaScript for real (ADR-0009). +//! +//! ADR-0007 control 5 says the page **may not construct commands**: it +//! reports raw pointer facts and Rust decides what they mean. Until now +//! that contract was held up by a test that greps the emitted script for +//! game vocabulary — and grepping for the absence of words is a weak +//! proxy for *"this code cannot construct a command"*. +//! +//! This runs it. QuickJS, a DOM stub with exactly the surface `SCRIPT` +//! touches, and a Rust callback standing in for `fetch` so the test can +//! see precisely what would have gone on the wire. +//! +//! **The scripts are lifted from a real emitted document, not pasted +//! here.** Both `` body out of a document, in order. +pub fn scripts(html: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = html; + while let Some(i) = rest.find("") { + Some(j) => { + out.push(after[..j].to_string()); + rest = &after[j..]; + } + None => break, + } + } + out +} + +/// The DOM surface `SCRIPT` touches, and nothing more. +/// +/// Deliberately minimal: every additional stubbed API is a way for the +/// script to do something in the test that it could not do in a browser, +/// or vice versa. If `SCRIPT` ever needs more than this, that is a signal +/// about the script, not about the stub. +const DOM: &str = r#" +var __handlers = {}; +var __status = { textContent: "" }; +var document = { + addEventListener: function (type, fn) { __handlers[type] = fn; }, + getElementById: function (id) { return __status; } +}; +var window = { location: { reload: function () { __reloaded(); } } }; +function fetch(url, opts) { + __post(url, (opts && opts.body) || ""); + // The script chains .then(...).then(...); give it something to chain on + // without ever resolving, so response handling stays out of scope here. + var chainable = { then: function () { return chainable; } }; + return chainable; +} +function __down(id) { __handlers['pointerdown']({ target: { id: id } }); } +function __up(id) { __handlers['pointerup']({ target: { id: id } }); } +"#; + +/// Run the document's scripts, then a pointer gesture, and report what +/// the page tried to send. +/// +/// `gesture` is `(down_id, up_id)`. Errors are JS errors, and they are +/// returned rather than swallowed: a script that throws must not look +/// like a script that sent nothing. +pub fn gesture(html: &str, down: &str, up: &str) -> Result, String> { + let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?; + + // `Arc>` rather than `Rc>`: quick-js requires the + // callback to be unwind-safe, since a panic inside it would cross the + // C boundary. + let posted: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = posted.clone(); + ctx.add_callback("__post", move |url: String, body: String| { + sink.lock().expect("posted").push(Posted { url, body }); + 0i32 + }) + .map_err(|e| format!("register __post: {e}"))?; + ctx.add_callback("__reloaded", || 0i32) + .map_err(|e| format!("register __reloaded: {e}"))?; + + ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?; + + let found = scripts(html); + if found.is_empty() { + return Err("the document carries no ", "a", "b").unwrap_err(); + assert!(e.contains("no pointer handlers"), "{e}"); + } + + #[test] + fn both_script_blocks_are_extracted_in_order() { + let found = scripts(&page()); + assert_eq!( + found.len(), + 2, + "the page carries the endpoint and the script" + ); + assert!(found[0].contains("CB_ENDPOINT"), "{}", found[0]); + assert!(found[1].contains("pointerdown"), "{}", found[1]); + } +} diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index d910f7e..c39b300 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -43,6 +43,8 @@ pub mod doc; pub mod input; +#[cfg(any(test, feature = "js-harness"))] +pub mod jsrun; pub mod serve; pub use doc::{document, text_of}; diff --git a/decisions/ADR-0009-embed-the-js-engine.md b/decisions/ADR-0009-embed-the-js-engine.md new file mode 100644 index 0000000..f41f32b --- /dev/null +++ b/decisions/ADR-0009-embed-the-js-engine.md @@ -0,0 +1,121 @@ +# ADR-0009: embed the JS engine; do not put `node` in the toolchain + +status: accepted +date: 2026-08-02 +decided by: agent, under the standing loop authorization +tier: M (structural M — adds an external dependency to the toolchain, +InnerLoop v1.6; chaos d4=2 → no override). Tier M merges survey and +decision into one document, which this is. +references: [CB-WP-0014](../workplans/CB-WP-0014-execute-the-javascript.md), +[ADR-0007](ADR-0007-render-html-not-a-port.md) D3 (the acquisition rule) +and D5 (control 5), +[ADR-0008](ADR-0008-instrument-corrections.md) D2 (AM-4b left uncorrected), +[CB-EV-0010](../evidence/CB-EV-0010-render-port.md) §4 + +## Context + +ADR-0007 control 5 says the emitted page **may not construct commands** — +it reports raw pointer facts and Rust decides what they mean. That +contract is currently held up by a test that greps the emitted script for +game vocabulary. **Grepping for the absence of words is a weak proxy for +"this code cannot construct a command."** + +And the script has never been executed at all, which is the sole remaining +reason INTENT stage 1 is open. + +## The survey + +`node` v24.11.1 is on this machine, so the obvious move is to shell out to +it. Measured alternatives, same method as `dep-weight.py`, marginal against +the 29-crate dev-toolchain graph, under the positive control: + +| option | marginal Rust lines | notes | +|---|---:|---| +| `boa_engine` | 896,410 | 27× AM-4b's headroom | +| `rquickjs` | 69,985 | 2.1× headroom | +| **`quick-js`** | **11,434** | bindings + vendored QuickJS C | +| `node`, shelled out | **0** | and that zero is the problem | + +AM-4b headroom is **32,979** (317,021 of 350,000), so `quick-js` fits at +35% of it and `rquickjs` does not. + +## Decision — embed `quick-js`; `node` is refused + +**This is ADR-0007 Decision 3's acquisition rule biting its author, which +is the only real test of whether it was written honestly.** The rule: + +> AM-4 counts third-party code the project causes to be **acquired**. It +> does not count runtimes the user already has independently of us. It +> **does** count a library our build or install instructions cause to be +> fetched, pinned, or linked, whether or not its source is Rust. + +A browser is not counted because a developer has one regardless of us. But +**CI runs on `rust:1.97`, which has no `node`** — so adding this test +would make our CI fetch a JavaScript runtime. That is our build causing an +acquisition, of tens of millions of lines nobody here will audit, scoring +**zero** on the only instrument that governs dependencies. + +Taking `node` would mean using the rule to exempt a browser we do not +install while also exempting a runtime we do. `quick-js` costs 11,434 +lines that are vendored, pinned, auditable, and counted. + +The secondary reasons matter less but all point the same way: the test +runs anywhere `cargo test` runs, needs no CI change, cannot skip because a +binary is missing, and pins one engine version rather than whatever the +image happens to ship. + +**`python3` is not a precedent for `node`.** It is already a toolchain +dependency, and the honest reading is that it was never argued — it +predates the acquisition rule. Leaning on it would be using an unexamined +decision to license a second one. It is left alone here and noted as +owed. + +### What is bought, and what is not + +Executing the script proves the **input contract**: what the page puts on +the wire in response to a pointer gesture. It does **not** prove the SVG +renders legibly, that a drag feels like a drag, or that anyone can play a +game. QuickJS has no layout engine and this ADR claims no rendering +evidence. + +### Cost accepted — and a correction, measured after the fact + +The paragraph originally here read *"35% of AM-4b's remaining headroom, +for a test."* **That was wrong, and finding out how wrong is the more +important result of this pass.** + +After landing, `make dep-weight` reported AM-4b **unchanged at 317,021**. +`quick-js` is a dev-dependency of `cb-render-html`, and AM-4b measures +`cargo tree -p games-ground --edges normal` — one package, no dev edges. +It cannot see it. Measured: + +| | crates | lines | +|---|---:|---:| +| AM-4b as instrumented (`games-ground`, normal) | 29 | 317,021 | +| the whole workspace, including dev edges | 57 | 725,258 | +| **uncounted by AM-4b** | **28** | **408,237** | + +**The dev-toolchain budget is blind to more source than its entire +target** — `criterion`, `clap`, `ciborium`, and now `quick-js`. + +This is the same defect this ADR refuses `node` for: a real acquisition +scoring zero because the instrument does not look there. The difference is +that `quick-js` is *auditable and pinned* and `node` is neither, so the +decision stands. But it stands on the acquisition rule, **not** on an +affordability argument, because there is no affordability argument to be +had until AM-4b can see what it is buying. + +Recorded as owed, and it is now the third defect in the AM-4 family: the +shipped-runtime proc-macro count (fixed, ADR-0008 D2), AM-4b's own +proc-macro share (owed), and AM-4b's scope (this). + +## Consequences + +- `quick-js` is a **dev-dependency of `cb-render-html` only**. It must + never reach the shipped-runtime configuration; AM-4a would catch that, + and now measures 157,202 against 161,000 with no room for it. +- The grep test stays. It is cheap, and it fails faster and more legibly + than an execution test when someone adds a word to the script. +- If `quick-js` becomes unmaintained, the fallback is not `node` — it is + `rquickjs` plus an AM-4b decision, or dropping the execution test and + saying so. diff --git a/evidence/CB-EV-0012-execute-the-javascript.md b/evidence/CB-EV-0012-execute-the-javascript.md new file mode 100644 index 0000000..c180a64 --- /dev/null +++ b/evidence/CB-EV-0012-execute-the-javascript.md @@ -0,0 +1,150 @@ +# CB-EV-0012 — the loop is closed, and the budget is blind + +CB-WP-0014 T03. Measured 2026-08-02 at `55212d7`+. Pass kind `product`, +tier **M** (chaos d4=2, no override). Declaration 9 of 12. + +**Applying CB-EV-0011 §4's proposed rule, and hereby adopting it:** the +cost table below quotes the **previous** pass's final figure and marks +this pass's own as provisional. See §5. + +--- + +## 1. What was actually blocking stage 1, and what it cost to find out + +CB-EV-0010 §4 said the emitted JavaScript had never run. That sentence was +carried for a pass on an assumption nobody checked. **`node` v24.11.1 was +on the machine the whole time.** + +That is the fourth consecutive pass whose central finding was a claim +carried because nobody ran the command that settles it — after the 3,750 +headroom figure, the "empty" sub-100k dependency region, and SH-3's 0.0%. +The pattern is stable enough now to name: **this project's characteristic +error is not faulty reasoning, it is unexecuted verification.** Every one +was cheap to check and none had been checked. + +## 2. The loop, closed + +The real server serves the real page → QuickJS runs *that page's own two +`