ci: add wasm32 to the toolchain and gate the bundle crates (improvise-ywd)

The audit could not start because the Nix toolchain shipped no wasm32 std and
there is no rustup here. Adding targets = ["wasm32-unknown-unknown"] to the
flake's rustToolchain was the whole blocker: all four bundle crates —
improvise-core, improvise-formula, improvise-io, improvise-command — then
check clean on the first try with no feature flags and no source changes.

The root improvise crate does not build for wasm because crossterm does not.
That is correct: it is the terminal binary, not part of the browser bundle,
so the CI job names the four bundle crates rather than checking the workspace.

Adds .github/workflows/ci.yml with that wasm job plus a host job running fmt,
clippy -D warnings and the test suite — there was no test or lint CI before,
only cargo-dist's release workflow.

A check gate cannot catch what merely panics at runtime, and the audit's real
finding was that two of the ticket's six named hazards are real while four are
not. Instant::now() panics on wasm yet compiles, so improvise-command now uses
web-time, which re-exports std::time off-wasm. std::fs likewise compiles and
then fails in a browser; StartImportWizard and LoadModel still bypass the
Storage trait, filed as improvise-fuj because the fix needs a decision on how
a wasm build injects its backend.

Verified by compilation on both targets and by the native suite. Nothing here
has been run on a wasm host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
This commit is contained in:
Edward Langley
2026-08-30 09:51:41 -07:00
parent 6007f5f38c
commit 69e77d6823
7 changed files with 97 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
name: CI
on:
push:
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Test and lint (host)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
wasm:
name: wasm32 check (browser bundle crates)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
# Only the crates that go into the browser bundle. The root `improvise`
# crate is the terminal binary and depends on crossterm, which does not
# build for wasm — that is correct, not a gap (improvise-ywd).
- name: improvise-core
run: cargo check -p improvise-core --target wasm32-unknown-unknown
- name: improvise-formula
run: cargo check -p improvise-formula --target wasm32-unknown-unknown
- name: improvise-io
run: cargo check -p improvise-io --target wasm32-unknown-unknown
- name: improvise-command
run: cargo check -p improvise-command --target wasm32-unknown-unknown
Generated
+11
View File
@@ -798,6 +798,7 @@ dependencies = [
"serde_json",
"tempfile",
"unicode-width",
"web-time",
]
[[package]]
@@ -2115,6 +2116,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
+14
View File
@@ -249,6 +249,20 @@ docs/design-notes.md Product vision & non-goals
---
## Wasm
The browser bundle is `improvise-core`, `improvise-formula`, `improvise-io`
and `improvise-command`. All four `cargo check --target
wasm32-unknown-unknown` clean with no feature flags (improvise-ywd), gated by
the `wasm` job in `.github/workflows/ci.yml`. The root `improvise` crate is
the terminal binary and does **not** build for wasm — it depends on
crossterm; that is correct, not a gap.
`cargo check` passing is not the same as running. Two known runtime hazards
it cannot see: `Instant::now()` panics on wasm (hence `web-time` in
`improvise-command`), and any `std::fs` call compiles but fails in a browser
— see improvise-fuj for the two effects that still bypass `Storage`.
## CLI
```
+3
View File
@@ -13,6 +13,9 @@ improvise-io = { path = "../improvise-io" }
anyhow = "1"
serde_json = "1"
unicode-width = "^0.2"
# Instant::now() panics on wasm32-unknown-unknown; web_time re-exports
# std::time off-wasm and uses performance.now() on it (improvise-ywd).
web-time = "1"
[dev-dependencies]
tempfile = "3"
+4 -1
View File
@@ -3,7 +3,10 @@ use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
// Not std::time::Instant: that panics on wasm32-unknown-unknown, and this
// crate is part of the browser bundle (improvise-ywd).
use web_time::Instant;
use std::rc::Rc;
+4
View File
@@ -25,6 +25,10 @@
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "clippy" "rustfmt" "llvm-tools-preview"];
# The browser deployment targets wasm; without this, the wasm audit
# (improvise-ywd) and the OPFS backend (improvise-i34) cannot even be
# checked, let alone fixed.
targets = ["wasm32-unknown-unknown"];
};
cargoNix = import ./Cargo.nix {
+18
View File
@@ -188,3 +188,21 @@ invariant(prefix_is_an_address,'ViewState::transient_prefix stores the (KeyCode,
decision('improvise-jc8',flip_the_arc,'Arc<Keymap> moved out of ViewState and back into KeymapSet, which already owns every keymap. Motivated by a hard dependency cycle -- Keymap is in improvise-command, which will depend on improvise-protocol once reduce_full lives there -- not by serde.').
work_done('improvise-jc8',refactor,'ViewState no longer holds Arc<Keymap>; four characterization tests written before the change pinned gg/ZZ/prefix-consumption/records-inheritance, plus two new guards for flatness and parent resolution.').
location(prefix_resolution,'crates/improvise-command/src/command/keymap.rs','KeymapSet::prefix_keymap, SetTransientPrefix').
% ── Wasm compatibility (improvise-ywd, audited 2026-08-30) ──────────────────
wasm_bundle_crate('improvise-core').
wasm_bundle_crate('improvise-formula').
wasm_bundle_crate('improvise-io').
wasm_bundle_crate('improvise-command').
not_in_wasm_bundle('improvise', 'terminal binary; depends on crossterm').
wasm_checks_clean(all_bundle_crates, no_feature_flags_needed).
% Hazards a compile check cannot see.
wasm_runtime_hazard(instant_now, 'panics on wasm32-unknown-unknown', fixed_with(web_time)).
wasm_runtime_hazard(std_fs, 'compiles on wasm but fails in a browser', tracked_as('improvise-fuj')).
wasm_non_hazard(dirs, 'declared in root Cargo.toml but used nowhere').
wasm_non_hazard(chrono_local_now, 'never called').
wasm_non_hazard(std_env, 'only in a test').
wasm_non_hazard(flate2, 'compiles for wasm as-is').
invariant(compile_check_is_not_runtime_proof,'the wasm CI gate is cargo check, so it catches nothing that merely panics at runtime: Instant and std::fs both COMPILE for wasm32-unknown-unknown').
work_done('improvise-ywd',audit,'added wasm32-unknown-unknown to the flake toolchain, which was the real blocker; all four bundle crates then checked clean with zero source changes. Swapped Instant for web_time and added the CI wasm job.').
location(wasm_ci,'.github/workflows/ci.yml','wasm job').