CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind

ADR-0009: embed quick-js; node is refused. Measured marginal cost against
the dev-toolchain graph, under the positive control:

  boa_engine   896,410
  rquickjs      69,985
  quick-js      11,434
  node               0   <- and that zero is the problem

ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97,
which has no node, so the test would make our build fetch a JS runtime of
tens of millions of unaudited lines while scoring zero on the only
instrument that governs dependencies. A browser is exempt because a
developer has one regardless of us; a CI-installed runtime is not.

The loop is now closed: the real server serves the real page, QuickJS
runs that page's own scripts, the gesture goes over a real socket, and
the seat's Choice comes back. Before this, every link was tested and the
chain was not — a page whose JavaScript sent something else entirely
would have passed everything.

Three controls, each red for its stated reason: the JS posting a command
name instead of ids, the gesture not being delivered (EXPECT-VACUOUS),
and the token stripped from the endpoint.

A wrong assertion worth keeping: the first draft required the body not to
contain "attack". It legitimately does — action-attack is the id of an
element a finger landed on. An element may name an action; that is not
the page deciding. The real test is the shape: exactly two fields, down
and up, carrying two ids and nothing derived from them.

AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's
headroom; after landing AM-4b did not move at all. It measures
games-ground --edges normal — one package, no dev edges. Measured, the
workspace including dev edges is 725,258 lines against AM-4b's 317,021:
408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap,
ciborium, quick-js). The decision stands on the acquisition rule; the
affordability argument is withdrawn. Third defect in the AM-4 family.

Also fixed structurally rather than by raising a limit: `make status` had
grown past its 40-line readability gate as workplans accumulated. Closed
workplans now collapse to one line, so the report is fixed-size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-02 07:56:02 +02:00
parent 4c930fac32
commit 55212d7e0f
9 changed files with 599 additions and 8 deletions

52
Cargo.lock generated
View file

@ -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"

View file

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

View file

@ -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 `<script>` blocks are extracted and evaluated in order, so
//! the endpoint the JavaScript posts to is the one the page actually
//! carried — token included. A harness that supplied its own endpoint
//! would pass while the page shipped a broken one.
//!
//! What this does **not** prove: that the SVG renders legibly, that a drag
//! feels like a drag, or that anyone can play a game. QuickJS has no
//! layout engine (ADR-0009 §What is bought).
use std::sync::{Arc, Mutex};
/// One intercepted `fetch`, exactly as the page would have sent it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Posted {
pub url: String,
pub body: String,
}
/// Pull every `<script>…</script>` body out of a document, in order.
pub fn scripts(html: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = html;
while let Some(i) = rest.find("<script>") {
let after = &rest[i + "<script>".len()..];
match after.find("</script>") {
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<Vec<Posted>, String> {
let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?;
// `Arc<Mutex<_>>` rather than `Rc<RefCell<_>>`: quick-js requires the
// callback to be unwind-safe, since a panic inside it would cross the
// C boundary.
let posted: Arc<Mutex<Vec<Posted>>> = 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 <script> block".to_string());
}
for (i, s) in found.iter().enumerate() {
ctx.eval(s)
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
}
// If the page never registered handlers, the gesture below would be a
// no-op and the test would read as "sent nothing" rather than "the
// page is broken". Distinguish the two.
let armed: bool = ctx
.eval_as("!!(__handlers['pointerdown'] && __handlers['pointerup'])")
.map_err(|e| format!("handler check: {e}"))?;
if !armed {
return Err("the page registered no pointer handlers".to_string());
}
ctx.eval(&format!(
"__down({}); __up({});",
json_lit(down),
json_lit(up)
))
.map_err(|e| format!("gesture: {e}"))?;
let out = posted.lock().expect("posted").clone();
Ok(out)
}
fn json_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use cb_kernel::PlayerId;
use games_ground::{Action, GroundCommand};
fn page() -> String {
let legal = vec![
GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
},
GroundCommand::SelectAction {
action: Action::Attack,
target: Some(PlayerId(1)),
problem: None,
},
];
crate::doc::document(
&crate::testfix::view(Some(PlayerId(0))),
&legal,
"/command?t=deadbeef",
Some(PlayerId(0)),
false,
)
}
/// **The one this pass exists for.** The emitted JavaScript runs, and
/// what it puts on the wire is two element ids and nothing else.
#[test]
fn the_emitted_javascript_reports_element_ids_and_nothing_else() {
let posts = gesture(&page(), "action-attack", "seat-1").expect("the script runs");
assert_eq!(posts.len(), 1, "expected exactly one fetch, got {posts:?}");
let p = &posts[0];
// The endpoint comes from the page, token and all — not from the
// harness. A page that shipped a broken endpoint fails here.
assert_eq!(p.url, "/command?t=deadbeef", "the page's own endpoint");
assert_eq!(p.body, "down=action-attack&up=seat-1");
// Control 5, asserted rather than grepped.
//
// The naive check — "the body must not contain 'attack'" — is
// WRONG, and writing it was instructive: the body legitimately
// contains `action-attack`, because that is the id of an element
// a finger landed on. An element may name an action; that is not
// the page deciding anything.
//
// What actually distinguishes reporting from deciding is the
// *shape*: exactly two fields, named `down` and `up`, whose values
// are the two ids and nothing derived from them.
let fields: Vec<&str> = p
.body
.split('&')
.map(|kv| kv.split('=').next().unwrap())
.collect();
assert_eq!(
fields,
vec!["down", "up"],
"the page sent fields beyond the gesture"
);
for marker in ["SelectAction", "{", "}", "\"", "kind", "target", "problem"] {
assert!(
!p.body.contains(marker),
"the body carries {marker:?} — the page is constructing a command: {}",
p.body
);
}
}
/// And the ids it reports resolve, in Rust, to the command the
/// aggregate offered — closing the loop the grep test could not.
#[test]
fn what_the_page_sends_resolves_to_a_legal_command() {
let legal = vec![
GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
},
GroundCommand::SelectAction {
action: Action::Attack,
target: Some(PlayerId(1)),
problem: None,
},
];
let posts = gesture(&page(), "action-attack", "seat-1").expect("runs");
let fact = crate::PointerFact::parse(&posts[0].body).expect("a pointer fact");
assert_eq!(crate::resolve(&fact, &legal, PlayerId(0)), Ok(1));
}
/// A gesture that means nothing is still *reported* faithfully — the
/// page does not filter, because filtering would be deciding.
#[test]
fn the_page_reports_a_meaningless_gesture_rather_than_suppressing_it() {
let posts = gesture(&page(), "seat-1", "seat-2").expect("runs");
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].body, "down=seat-1&up=seat-2");
}
/// EXPECT-VACUOUS: a harness that ran the script but delivered no
/// events would report "sent nothing" and read as a pass. Prove the
/// gesture is what causes the send.
#[test]
fn without_a_gesture_the_page_sends_nothing() {
let html = page();
let ctx = quick_js::Context::new().expect("ctx");
let seen = std::sync::Arc::new(std::sync::Mutex::new(0usize));
let sink = seen.clone();
ctx.add_callback("__post", move |_u: String, _b: String| {
*sink.lock().expect("seen") += 1;
0i32
})
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
ctx.eval(DOM).expect("dom");
for s in scripts(&html) {
ctx.eval(&s).expect("script");
}
assert_eq!(
*seen.lock().expect("seen"),
0,
"the page sent something unprompted"
);
}
/// The harness must not report success when the page is broken.
#[test]
fn a_page_with_no_script_or_no_handlers_is_an_error_not_a_silence() {
assert!(gesture("<p>no script here</p>", "a", "b").is_err());
// A script that registers nothing must not look like one that
// registered handlers and chose to send nothing.
let e = gesture("<script>var x = 1;</script>", "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]);
}
}

View file

@ -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};

View file

@ -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.

View file

@ -14,5 +14,10 @@ serde_json.workspace = true
# Scenario args are YAML values; the prompt renders them (T02).
serde_yaml.workspace = true
[dev-dependencies]
# ADR-0009: the real page, driven by a real JS engine, into the real
# socket. Dev-only — cb-play ships without it.
cb-render-html = { workspace = true, features = ["js-harness"] }
[lints]
workspace = true

View file

@ -370,4 +370,60 @@ mod tests {
);
assert!(replies[1].contains("200 OK"));
}
/// **The loop, closed.** The real server serves the real page; a real
/// JavaScript engine runs the page's own script and produces a pointer
/// gesture; what it produces goes over a real socket; and the seat's
/// choice comes back.
///
/// Before ADR-0009 every link in that chain was tested and the chain
/// was not. The page was asserted against as a parsed document and the
/// socket was driven by synthetic HTTP that this test suite wrote
/// itself — so a page whose JavaScript sent something else entirely
/// would have passed everything.
#[test]
fn a_gesture_in_javascript_becomes_a_move_in_the_game() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let tok = token.clone();
let client = std::thread::spawn(move || {
let token = tok;
// 1. fetch the page the server actually serves
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&token).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
assert!(page.contains("200 OK"), "{page}");
// 2. run ITS script, in a real engine, with a real gesture
let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table")
.expect("the served page's script runs");
assert_eq!(posts.len(), 1, "{posts:?}");
// 3. send exactly what the JavaScript produced — not what this
// test thinks it should have produced
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&token, &posts[0].body).as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
(posts[0].clone(), reply)
});
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let (posted, reply) = client.join().expect("client thread");
assert_eq!(posted.body, "down=action-ground&up=table");
// The endpoint the JS used carries the server's own token, which
// the page cannot mint — so this also proves the token round-trips
// through the emitted document.
assert!(posted.url.contains(&token), "{}", posted.url);
assert!(reply.contains("200 OK"), "{reply}");
assert!(server.refusals().is_empty(), "{:?}", server.refusals());
}
}

View file

@ -214,14 +214,21 @@ def report():
print(f" branch {branch}"
+ (f" ({len(dirty)} uncommitted)" if dirty else " (clean)"))
# The list grows by one line per pass forever, and this report has a
# length limit it is meant to keep. Collapse the fully-closed ones to
# a single line rather than raising the limit — the precedent is
# LOOP-LINT's four loadability breaches, each fixed structurally.
print("\n workplans")
closed = []
for wid, title, status, tasks, _kind in plans:
done = sum(1 for _, s, _ in tasks if s == "done")
if status == "done" and done == len(tasks):
print(f" {wid} {status:<12} {done}/{len(tasks)}")
closed.append(wid.replace("CB-WP-", ""))
continue
short = (title or "")[:44]
print(f" {wid} {status:<12} {done}/{len(tasks)} done {short}")
if closed:
print(f" {len(closed)} closed and complete: " + " ".join(closed))
nxt = next_task(plans)
if nxt:

View file

@ -55,7 +55,7 @@ is the real check.
```task
id: CB-WP-0014-T01
status: todo
status: done
priority: high
```
@ -84,11 +84,32 @@ an automatic pass:
CB-WP-0013 explicitly declined to correct. Do not use its uncorrected
headroom as an argument.
**Done 2026-08-02.**
[ADR-0009](../decisions/ADR-0009-embed-the-js-engine.md) — **embed
`quick-js`; `node` is refused.** Measured, marginal, under the control:
`boa_engine` 896,410 · `rquickjs` 69,985 · **`quick-js` 11,434** · `node`
**0**, and that zero is the problem.
This is ADR-0007 D3's acquisition rule biting its author. CI runs on
`rust:1.97`, which has no `node` — so the test would make *our build*
fetch a JS runtime of tens of millions of unaudited lines, scoring zero on
the only instrument that governs dependencies. A browser is exempt because
a developer has one regardless of us; a CI-installed runtime is not.
**And the cost argument in the first draft was wrong.** It claimed 35% of
AM-4b's headroom. After landing, AM-4b did not move at all — it measures
`games-ground --edges normal`, one package, no dev edges. Measured: the
workspace including dev edges is **725,258** lines against AM-4b's
**317,021**, so **408,237 lines are uncounted — more than the target
itself**. The decision stands on the acquisition rule; the affordability
argument is withdrawn, because there is none to be had until the
instrument can see what it is buying. Filed as the third AM-4 defect.
## Task: run it, end to end, through a real socket
```task
id: CB-WP-0014-T02
status: todo
status: done
priority: high
```
@ -110,6 +131,31 @@ has closed that loop.
harness that runs a script and asserts nothing about what it did;
- the token is stripped from the endpoint the page was given.
**Done 2026-08-02.** `crates/cb-render-html/src/jsrun.rs` and
`hotseat::tests::a_gesture_in_javascript_becomes_a_move_in_the_game`.
**The loop is closed.** The real server serves the real page; QuickJS runs
*that page's own scripts*; the gesture it produces goes over a real socket;
the seat's `Choice` comes back. Both `<script>` blocks are lifted from the
served document, so the endpoint under test is the one the page actually
carried — token included.
Three controls, each red for its stated reason:
| mutation | result |
|---|---|
| the JS posts `command=SelectAction&target=…` | body assertion red — **control 5 asserted, not grepped** |
| the gesture is not delivered (EXPECT-VACUOUS) | `expected exactly one fetch, got []` |
| the token dropped from the endpoint | the end-to-end token assertion red |
**A wrong assertion, worth keeping.** The first draft asserted the body
must not contain `"attack"`. It legitimately does: the body is
`down=action-attack&up=seat-1`, and `action-attack` is *the id of an
element a finger landed on*. An element may name an action; that is not
the page deciding anything. The real test of reporting-versus-deciding is
the **shape** — exactly two fields, `down` and `up`, carrying two ids and
nothing derived from them. That is what it asserts now.
## Task: close stage 1, or say what still blocks it
```task