Conversation
… target has none
First build for wasm32-unknown-unknown, straight from the tree:
error: the wasm*-unknown-unknown targets are not supported by default,
you may need to enable the "js" feature.
getrandom 0.2, reached through aoc-ornaments → rand 0.8 → rand_core, and
nothing in this day ever draws a random number. On every other target
getrandom has an OS to ask; wasm32-unknown-unknown is the target with no
OS by definition, so the crate refuses to compile rather than guess.
Two backends on offer. `js` asks the JavaScript host through
wasm-bindgen imports — which would hand the raw route a module with
imports to satisfy, and a dependency on the generator it exists to do
without. `custom` lets the crate register its own source: here, one
that returns an error, because this module has no entropy and should say
so if asked. The registration lives in a target-gated module rather than
behind a cargo feature, so a bare `cargo build --target
wasm32-unknown-unknown` builds the day with no flags — the shape the
wasm job asserts.
The dependency is target-scoped, so the host build, its lockfile
resolution on the 1.85 floor, and every other day are untouched.
…nker for it
Second finding from the first wasm build, after getrandom: the exercises
compiled for wasm32-unknown-unknown and then died at the link step with
error: linker `lld` not found
rustup's toolchains ship rust-lld inside the sysroot and the target spec
reaches for it; nixpkgs' rustc is built with the wasm32 targets in its
list but without a bundled lld (there is no rust-lld under
lib/rustlib/<host>/bin, only rust-objcopy), so the spec's fallback is a
bare `lld` on PATH. nixpkgs' lld package provides it — 13.2 MiB download,
40.0 MiB unpacked, measured with nix-build --dry-run on 2026-09-15 — and
with it on PATH the same build links to a 20 KB module.
Default list, not `full`: the wasm track is the one boundary that needs no
C library, and a linker the compiler cannot link without is part of the
toolchain, not a track's extra. Node and the bindgen tools stay out of
this shell — they are the track's own install, per the devcontainer
variant and `just setup-wasm`.
…unknown-unknown The first question the wasm work has to answer is whether the toolchains attendees actually have carry the target: rustup's stable, and the nixpkgs rustc the workshop shell hands out. nixpkgs' rustc.nix builds every compiler with wasm32-unknown-unknown in its target list, so the default shell should already have it — this job is the mechanical form of that claim, on both OSes, before any code depends on it. Two cells. `wasm` builds the day and the exercises for the target on rustup stable (three OSes — the target itself is what is under test, and Windows is where a missing std would surprise a manual-setup attendee). The nix cell adds one line to the existing ffi job: the same build, inside the DEFAULT shell, not the full one — the wasm track needs no C library, which is the whole practical argument for it.
…rates them The wasm track brings the first JavaScript into this tree: a package.json per consumer directory and, on the generated lap, a pkg/ that wasm-bindgen writes. In a jj repo an untracked file is committed by the next command, so the rules land first and the installs come after — the same order the Rust, Python, Swift, Kotlin and Dart sections above already keep. node_modules/ is global, like target/. The pkg/ rule is scoped to the two places wasm-bindgen is ever pointed at, for the reason the days' include/ rule is scoped: a directory called pkg anywhere else must stay tracked, and jj never warns about an ignored file.
…asm` feature Same two solvers the C API exports, this time through wasm-bindgen: the generator writes the JavaScript glue that the raw route makes you write by hand — the string copy into linear memory, the read-back, the error conversion. That is the trade the workshop's UniFFI slot was cut before it could show: generated bindings are the same C-shaped module plus a toolchain, and here the toolchain is a crate and a CLI that must agree to the patch version. Three exports, one more than the C API, on purpose. part1 cannot fail. part2 returns Result, and Santa never entering the basement crosses as a thrown JS Error carrying the miette message. part2_unchecked is the version a Rust-only codebase would have written — expect() on the Option — and crosses as a trap: no Error, a WebAssembly.RuntimeError, and an instance whose Rust state is now undefined. The consumer in wasm/ exists to put those two failures back in different channels; this module exists to produce them from one solver. Pinned =0.2.121 because that is the wasm-bindgen-cli in the nixpkgs channel shell.nix reads (<nixpkgs>, nixpkgs-unstable — checked 2026-09-15; the flake registry's nixpkgs is already at 0.2.127). The CLI refuses a module built with any other crate version, so the pin follows the channel and the recipe checks the two agree before running. Target- scoped and optional: the host build, the 1.85 floor and --all-features on the host never see the crate, and wasm.rs is the only file that does.
…in different channels
wasm-bindgen hands JavaScript one failure shape: something was thrown.
An Err from part2 and the trap from part2_unchecked both arrive that
way, and a try/catch cannot tell a puzzle input that never goes
underground from a broken invariant inside the module. Rust had two
channels for that — Result and panic — and the boundary flattened them.
Effect has exactly two as well, and they line up: a thrown Error becomes
a typed failure in the E channel, which the program can match and
recover from; a WebAssembly.RuntimeError becomes a defect, which catchAll
cannot see, the same way ? never catches a panic. callRust in
src/boundary.ts is the fifteen lines that route them. src/main.ts
mirrors main.rs — read inputs/2015-12-01.txt, print both parts — and
src/demo.ts runs the three outcomes on the statement examples so the
routing is something CI asserts, not something the README claims.
This is the first run of what the effect-playground prototype (07) only
described: it was never executed there. Verified here against the
channel's wasm-bindgen-cli 0.2.121 and Node 24: part2("(((") throws
Error with the miette message; part2_unchecked("(((") throws
RuntimeError: unreachable; and the instance goes on answering part1
afterwards — which is the honest version of 'its state is undefined'.
package-lock.json is committed, the rule days/*/dart/pubspec.lock set: a
worked example pins what it resolved. effect 3.22.2 is the version the
prototype typechecked against; typescript stays on the 5.9 line it was
verified with rather than the 7.x that npm now serves by default.
The C API takes a const char* and writes through an int*. Every caller so far — the C harness, Python, and the tracks the golden days carry — had an allocator on its own side of the boundary: it made the string, it owned the four bytes, it handed over addresses. A JavaScript caller of the raw wasm module has neither. The only memory the module can read is its own linear memory, and nothing in JavaScript can allocate inside it; there is no malloc on that side of the line. So the module has to lend one out. alloc(size) returns an offset into linear memory the caller may write; free(ptr, size) gives it back — with the size, because Rust's allocator wants the layout it handed out and a wasm module carries no malloc header to recover it from. That is the callee-allocates ownership contract the reference card describes and no exercise makes anyone write, arriving from the other direction: not a string the library returns and the caller must free, but a buffer the caller must borrow before the library can be called at all. Target-gated, not feature-gated, next to the getrandom backend: a bare build for wasm32-unknown-unknown is the raw route, and the raw route is unusable without these. cbindgen never sees them — it is aimed at c_api.rs — so the C header stays at two symbols, which is the decision taken for this branch: the wasm-only surface lives in the wasm-only module.
…ory, nothing generated The generated lap's glue, done by hand, against the C API rather than the wasm-bindgen exports: instantiate the .wasm cargo produced (one file, one name — no .so/.dylib/.dll dance, and no dynamic linker, WebAssembly .instantiate takes bytes), borrow len + 1 bytes of linear memory, write the UTF-8 and the NUL yourself, borrow four more for the int*, call, read the answer back through a DataView, give both buffers back. Two status codes, -1 and -2, become two typed failures; a trap becomes a defect, exactly as in boundary.ts. Nothing here is hidden, which is the point: this is the file Exercise 4 asks an attendee to write, and the generated lap next door is what a tool writes for them. Two details the generated lap never shows. memory.buffer is re-read on every access rather than cached, because a growing allocation detaches the old ArrayBuffer and a view captured before an alloc is dead after it. And alloc/free are paired through acquireUseRelease — Effect's spelling of Drop — so the free runs whether the call succeeded, failed, or trapped, which is the discipline the Dart track does with try/finally. Also the hostile-input contract, from the outside: NULL is the integer 0 here, and invalid UTF-8 is two bytes written straight into the buffer. Both come back as -1, the same treaty the C harness checks.
…rd.flf, against libcaca's output libcaca cannot come to wasm32-unknown-unknown: it is a C library found through pkg-config against a store path, and there is no libcaca for the target and no dynamic linker that could find one. What can cross is the font. fonts/standard.flf is data, and the same file libcaca's FIGfont engine reads in caca.rs is readable by a second, unrelated engine on the other side of the boundary — patorjk's figlet.js, whose parseFont takes raw .flf contents. Same font, same text, two implementations of the FIGfont spec written years and languages apart, byte-comparable output. That gives this variant an assertion none of the other five has. The fixture is what `cargo run --features caca` printed for the statement example "()())" — the banners for -1 and 5 — captured once in a shell with libcaca (nix-shell -p libcaca pkg-config, 2026-09-15) and committed as test/fixtures/caca-banner.txt with its trailing spaces intact. The test renders the same two answers through figlet.js and diffs. Not @effect-ts/figlet: that package is four years unpublished and pinned to the pre-v3 @effect-ts/core namespace, which does not interoperate with the `effect` package this directory uses. Wrapping the untyped, callback-era figlet.js in a typed Effect service is twenty lines and is the more honest exhibit anyway.
…r, two modules
Found by running the recipes back to back. `cargo build --features wasm`
and a bare `cargo build` both write target/wasm32-unknown-unknown/debug/
aoc_2015_12_01.wasm, and the two files are not the same module: the
feature build carries wasm-bindgen's import section
(__wbindgen_placeholder__ and friends — the glue satisfies those), the
bare build has none. raw.ts loads whichever is there, and after a
feature build it got
WebAssembly.instantiate(): Import #0 "__wbindgen_placeholder__":
module is not an object or function
which is true and useless. The module's import list is readable before
instantiation — WebAssembly.Module.imports is the header, again — so
raw.ts now checks it and fails with the fact: this module asks the host
for things, it was built with the wasm feature, rebuild without it. The
raw route's whole premise is a module that asks for nothing; a module
that asks is the generated lap's, and it needs the generated lap's glue.
The recipe that follows rebuilds the bare module after generating the
glue for the same reason: the two laps share a target directory, and
the last build wins.
…policies One verb per variant, like python-demo and dart-demo: build the day for wasm32-unknown-unknown with the wasm feature, generate the glue, run the Effect consumer against your input, then the raw route, then the banner test. What the two recipes differ in is who fetches the generator. wasm-demo uses the wasm-bindgen on PATH — the nix channel's, or the one `cargo install wasm-bindgen-cli --version <pin>` put there — and refuses before building if its version is not the crate's: the CLI rejects a module built with any other patch version, and the message it gives names neither the lockfile nor the fix. The recipe asks cargo pkgid for the pinned version and the CLI for its own, and prints both with the install command when they disagree. Everything is local; nothing is fetched. wasm-pack-demo lets wasm-pack do it: it reads the same lockfile, downloads a matching wasm-bindgen into ~/.cache/.wasm-pack on first use, and runs the same two steps. No pin to keep in sync, one network fetch inside a build. Run both once; the plan's expectation is that the first stays as the workshop's path and the second becomes the README's 'on a machine without a nix shell' paragraph, but that is a call to make after watching them, not before.
…ned CLI, and the three consumers The wasm job proved the target builds. Now it proves the track, and it does so by running the recipe: `just` is one more prebuilt binary from taiki-e/install-action (the Verify workflow already takes it that way), so the job runs `just days wasm-demo 2015-12-01` rather than a copy of its steps — the version guard, the feature build, the glue, the bare rebuild, and the three consumers, tested as attendees run them. A step added to the recipe is a step added here. `just` goes on every other job's runner too, for the next job that wants a recipe; those jobs keep their spelled-out commands because `just days verify` bundles three gates the jobs deliberately keep separate. What the job asserts: the Effect program's -1 and 5 through the generated glue, the same two numbers through linear memory plus the hostile-input contract from the outside, the banner test with its one-column verdict against libcaca's fixture — all inside the recipe — and then, outside it, the strict typecheck and the three-channel demo, which exits nonzero if Ok, Err or a trap lands in the wrong channel. Three OSes: node and npm ship on every stock image, install-action has manifests for just and wasm-bindgen on all three, and the consumers compute their own paths, so Windows costs nothing extra here — the first place in this repo that is true of a language track. The pin appears in one more place now (the install-action line); the recipe's check is what catches the three copies drifting apart.
The variant table grows two rows for one boundary, because the boundary has two laps and they teach different things. The raw route is the C API called from JavaScript through linear memory — the first row in the table whose direction is not the C ABI wearing a different hat — and the generated lap is wasm-bindgen's glue plus an Effect consumer that puts Err and panic back in the channels the boundary flattened. The learnings are the ones the build produced, in the order it produced them: the target with no OS refusing a dependency that wanted one; the linker nixpkgs' rustc does not carry; the allocator a caller must borrow because it has none; the header that is now inside the module; one target directory holding two different modules; a panic that leaves the instance answering; and the font that crossed as data to be rendered by a second engine that agrees with the first to within one column — with the reference figlet CLI as the tiebreaker that says which one is off.
…new target Exercise 4 is the wasm track, and its first lesson is a diff: the crate here is Ex 2's shape — a cdylib over your Ex 1 solver, the same ex_part1(const char*) -> i64 — built for wasm32-unknown-unknown instead of the host. The wrapper compiles for the new target with no edits, which is the point of TODO 0: paste yours in and watch it build. Ex 2 itself stays frozen; this is a sibling crate, not a flag on that one, so the morning's contract is byte-identical before and after. Then the one thing the new runtime needs that C never asked for. A JavaScript caller has no allocator inside the module's linear memory, so TODO 1 is two exports — ex_alloc and ex_free — before any string can cross at all. That is the callee-allocates ownership contract from the reference card, met as a precondition rather than as a returned string, and it is the reason this exercise exists as more than 'run cargo with a --target'. build.sh is Ex 2's four beats for this target: cargo build for wasm32-unknown-unknown, cbindgen for a header nobody will read (the module's export section is the header now — the script prints it), and the consumer. Step 0 is the same as Ex 2's: run it before implementing and watch the todo!() — here not an abort but a trap, RuntimeError: unreachable, and an instance that is still there afterwards.
…Int, and two channels for what goes wrong
The caller for Exercise 4, as a TODO ladder in TypeScript with Effect,
the same shape as the four Ex 3 tracks: each TODO is one thing the
runtime needs that the C header could not say, and the last one is the
hostile-input contract proved from this side. Worked reference for every
step: days/2015-12-01/wasm/src/raw.ts.
TODO 2 instantiate — one file, one name, no dynamic linker, and the
export section printed by build.sh is the header now
TODO 3 the string crosses — TextEncoder, ex_alloc(len + 1), a view
created after the alloc, the NUL written by you
TODO 4 call — and why the answer is a BigInt: i64 has no JavaScript
number, so 5 comes back as 5n and -1 as -1n
TODO 5 the contract — NULL is 0, garbage is two raw bytes, both -1n;
and what a trap looks like from here, and what is still alive
after it
The Effect part is not decoration. The sentinel is a typed failure and
the trap is a defect, so the compiler knows which of the two a caller may
recover from — which is the distinction the C ABI erased in Ex 2 and Ex
3 and this runtime hands back, if the caller is written to take it.
Each borrow of linear memory is an acquireUseRelease, so ex_free runs
however the call ends: Drop, spelled in TypeScript.
npm ci is step 0, against a committed lockfile; effect and the tooling
are pinned to the versions the day's consumer verified. The pkg/ rule in
the root .gitignore covers this directory too, for the bonus lap.
…ked for? The exercise's front page, in the shape of Ex 2's and Ex 3's: goal, route, tasks in order, the key concepts, the worked reference, and the debrief question. The route is the raw one — nothing generated — and the README says why, and where the generated lap lives for the bonus: days/2015-12-01/wasm, where wasm-bindgen writes what TODO 3 and 4 make you write, and the Effect consumer there shows what that glue still cannot do. Timing is a placeholder: this block has never been in front of a room, and the plan's own rule is that the deck transcribes from validated exercises. [??s] until it has been run.
…sysroot A fifth optional row, in the shape of the other four: one probe, one --track name for CI, one hint that names the recipe. Two things have to be true for the track and neither is a tool's mere existence. Node, because the caller is a script — 22 is the floor, being what CI and the wasm devcontainer run rather than a feature anyone needs. And the target's std, which is what `cargo build --target wasm32-unknown-unknown` actually needs: rustup installs it on request (`rustup target add`), the nix shell's rustc has it built in, and the probe asks the sysroot rather than either toolchain — `rustc --print sysroot` then the target's lib directory — so the answer is the same however rustc got there. The linker is not probed: the nix shell now carries lld and rustup bundles rust-lld, and a missing one fails at the first link with a message that names itself. just setup-wasm is the recipe the hint points at: `rustup target add` on the manual path (a no-op under nix, and the recipe says so), and the Node install pointer per OS, since Node is the track's own install and stays out of the shell.
The attendee scaffold ships with todo!() and TODOs, so nothing on main can run Ex 4 either; .github/ci/exercises gains the same two files solved once — src/lib.rs with the Ex 2 wrapper pasted in and the allocator pair filled, wasm/src/ex4.ts with the string written by hand and the BigInt checked — on the overlay's own day (2025 day 3, whose part-1 answer is 357 and whose part-2 answer is above 32 bits, which a BigInt carries without comment). The Verify workflow copies them over and runs `just exercises wasm`, the exact command the README gives. A wasm column in the track matrix, on all three OSes: the self-check's --track wasm probe (node, and the wasm32 std in the sysroot) is the cell's verdict, provisioned with setup-node and `rustup target add` the way the python cell provisions cffi. The end-to-end steps stay off Windows with the other exercises' — Ex 2 must be green first and its own script stops there — even though nothing in Ex 4 itself is Windows-shaped.
…iant The wasm track's own install, in the shape of the kotlin and flutter variants: a copy of git/devcontainer.json with a name and a WORKSHOP_HOME_NIX pointing at a home.nix that imports the shared one and adds what the track needs — nodejs_22 (the floor the self-check probes and CI runs), wasm-bindgen-cli at the channel's version (which is the version days/Cargo.toml pins, and the reason the pin is what it is), and wasm-pack for the other recipe. The target's std and the linker are not here: they come from shell.nix's rustc and lld, which every variant already gets. Node stays out of shell.nix on purpose — 45 MiB that no other track needs — so this variant and `just setup-wasm` are the two ways to get it, and the README row says which is which.
Where attendees meet it: the setup guide's track list gains `just setup-wasm` and the sentence that says what it is — Exercise 4's track, afternoon material, a runtime with no C in it — and the day menu's 2015-12-01 row says the day now carries the wasm variants, both laps. Nothing about the required toolchain changes: the five tools are the same five, the target's std comes with them, and the linker is in the shell.
…e, and derives the pin Found by review, reproduced with `just -n`: rust.yml's default working-directory is days/, so `just days wasm-demo` ran from inside the module directory, where the nearest justfile is days/justfile and there is no `days` recipe. The step failed before any build on every trigger; the version guard, the bare rebuild and the three consumers were never reached. From days/ the recipe is `just wasm-demo`, and the input lines above it were already written for that cwd. Two more things in the same job, from the same review. `just` is installed only where a job calls it — this one — rather than on every runner; the other four jobs spell their commands out and a download they never use is a step to keep in sync. And the wasm-bindgen pin is derived from the lockfile with the same `cargo pkgid` line the recipe's guard uses, rather than written into the workflow as a third copy; bumping the crate no longer trips the guard in the job that exists to prove it. Caching, while here, in both workflows: rust-cache covers the exercises workspace the wasm job also builds, and setup-node caches npm against the committed lockfiles — a fresh runner never has node_modules, so the recipes' own guard never helps in CI without it.
…ys true wasm-demo and wasm-pack-demo ended in the same six lines — the bare rebuild, the cd, npm ci, main, raw, test — and only wasm-demo runs in CI, so a consumer added to one would go missing from the other with nothing to notice. The tail is now one recipe, _wasm-consumers, that both name as a post-dependency (`&&`, so it runs after the generator, which the bare rebuild depends on), the same shape the C tracks use with `(bindgen day)`. The guard's hint said the wasm devcontainer 'ships' the pinned version. That is a snapshot: the container reads an unpinned channel, and the day nixpkgs-unstable moves past 0.2.121 the hint is false in exactly the environment that produced the error. It now reports what is on PATH and names the two real fixes — install the pinned CLI, or move the pin to what the channel ships — without claiming either environment agrees.
…one false claim Three review findings on the exercise, one commit because they are all 'what the scaffold promises versus what runs'. The typecheck script was never invoked: tsx strips types without checking them, so the strict tsconfig the README leans on — 'the compiler keeps the two channels apart' — gated nothing, and an attendee whose TODO 4 returned a number against a bigint met a runtime 5 !== 5n instead of a type error. `just exercises wasm` now typechecks before it runs, on the scaffold and on the solved overlay alike. build.sh wrote a cbindgen header into include/ for comparison only, and supporting that file cost a .gitkeep, an ignore rule, and a hard cbindgen dependency on a track whose pitch is 'no C in it'. cbindgen prints to stdout without --output; the header now appears in the terminal next to the export section it is there to be compared with, and the three files that existed to hold it are gone. ex4.ts's header said `just setup-wasm` covers npm ci. It does not — the recipe does, once, and the README already said so correctly.
…t from the generated lap Review caught what the file's own comment admitted: the raw route imported callRust from boundary.ts and then carried a RustError case in describe that it called unreachable, because the raw exports return integers and never throw a JS Error. Two things wrong with that. The route whose whole claim is 'nothing generated' depended on the generated lap's file; and this is the worked reference for Exercise 4, whose scaffold already has the eight-line die-only `call` — the reference and the exercise disagreed on the shape of the one function that routes a trap. raw.ts now has that same `call`: a trap is a defect, and there is no other channel because the ABI has no other failure.
tsconfig included src/ only, so the CI typecheck never saw test/banner.test.ts, and `npm test` runs it through tsx, which strips types without checking them — a mistyped test was silent everywhere. Measured by review with `tsc --listFilesOnly`: five src files, no test. One line: test/**/*.ts joins the include.
banner() ran loadStandardFont on every call — the doc comment said 'idempotent', which was true and beside the point: idempotent here meant the file was re-read and re-parsed into figlet.js's registry for every banner, four times per test run. Effect.cached at module scope makes the first run the only one; a consumer that banners N answers pays one read.
…er needs it Review found a latent contract violation: alloc handed out Layout::from_size_align(size, 1), and raw.ts passed one of those pointers as the int* the C API writes through. Nothing failed — std's dlmalloc returns eight-aligned blocks on wasm32 for every request — but the export promised one-byte alignment and the caller relied on eight, which is the kind of thing that stays true until the allocator changes. alloc and free now use alignment 4: enough for the int32_t the C API writes, harmless for the strings, and stated in the doc comment as part of what the caller is being lent. Exercise 4's scaffold keeps alignment 1; its wrapper takes strings only and returns the i64 by value.
…ays how to link node@22 Three review findings on one recipe. The rustup half was six lines duplicated verbatim across the [macos] and [linux] variants; it is one private recipe now, _setup-wasm-target, and the OS split covers only the Node line, which is the only part that differs — the shape the other per-OS pairs already have. On macOS, `command -v node && node --version || brew install` treated any node as done, so a v18 machine passed the recipe and then failed the self-check's floor with no path through; the recipe now reads the major version and installs only below 22. And brew's node@22 is a versioned formula Homebrew keeps unlinked (keg-only, as its versioned formulae are by policy — check `brew info node@22` on a Mac if this ever looks wrong), so after installing it the recipe prints the link command, the way setup-kotlin does for its keg-only JDK.
…es Git Bash The floor was a literal 22 in the probe, in both setup-wasm variants and in two workflows, where the Dart probe reads its floor from the exercise pubspec 'so the number lives in one place'. The wasm exercise's package.json now declares engines.node, the probe and the setup recipe read the major out of it, and CI's setup-node steps stay pinned to what they run — a bump is the package.json and the two workflow lines, and the probe cannot disagree with the package it probes for. Second, from the same review: on the Windows cell `rustc --print sysroot` returns a C:\\ path and the probe tests it with a POSIX [ -d ]. MSYS usually converts drive-letter paths, but the cell is otherwise unexercised and the probe's exit code is its whole verdict, so the path goes through cygpath when cygpath exists — the same tell fetch-jna.sh uses to notice Git Bash.
… lockfile Two review findings on wording. The day README said 'wasm/ has its own (npm test)', which reads as the wasm variants having tests; npm test is the banner test only, and the boundary code is exercised by npm run raw and demo, which are consumers. The line now says which is which. exercises/.gitignore explains why the Dart scaffold does not commit its pubspec.lock — the attendee resolves it, so the lock is their machine's output — and Exercise 4 commits a package-lock.json without a word about the opposite choice. The word: npm ci needs the lock to exist and refuses to drift it, so an attendee never resolves anything; committing it is what makes 'npm ci runs once' true on every machine the same way.
… for that machine
`just setup-wasm` on a box without Node printed the rustup line and died:
`recipe setup-wasm failed with exit code 127`. Both recipes run under
`set -euo pipefail` and then do
major="$(node --version 2>/dev/null | sed -nE '…')"
With no node the pipeline's status is 127, an assignment takes the status
of its command substitution, and `set -e` ends the script on that line —
before the `else` branch that says where to get Node 22, which is the only
reason the recipe runs on such a machine at all. Node present, it never
showed: the substitution succeeds and the floor check runs as written.
`|| true` inside the substitution, on both the linux and macOS recipes.
Not `command -v node` up front: the floor check must still run when node
is present, and this keeps one code path.
Reproduced and verified on a PATH holding only the tools the justfile
itself needs: the old recipe exits 127 after the rustup line; the patched
one prints the Node 22 pointer and exits 0; with node on PATH it prints
"node v24.19.0 meets the 22 floor" and exits 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…channel Reopening the repo in the wasm variant and running `just days wasm-demo 2015-12-01` stopped at the recipe's own guard: wasm-bindgen-cli 0.2.127 on PATH, days/Cargo.lock pinning the crate at 0.2.121, and the CLI refuses any other version. home.nix installed the bare `wasm-bindgen-cli`, which is whatever the profile's channel calls current — 0.2.121 on the channel the track was written against, 0.2.127 on nixpkgs-unstable the same week, which is what the devcontainer's nix feature leaves in place. The comment above the pin said as much: "a channel bump here is a pin bump there". The bump happened on its own. nixpkgs keeps every recent CLI release as its own attribute (wasm-bindgen-cli_0_2_93 through _0_2_127 today), hashes maintained upstream and built by Hydra. So home.nix now reads the pinned version out of days/Cargo.lock with builtins.fromTOML and takes the attribute that names it — the same store path as the default package when the channel agrees, the right older build from the binary cache when it does not, and a message naming both the pin and the two ways out if nixpkgs has no attribute for it at all. Three comments that described the old policy (Cargo.toml's pin, the recipe's guard, the variant table) now describe this one. Not taken: pinning the crate to whatever the channel has today, which the recipe's hint offers as an option — it moves the drift, it does not end it. Also not taken: the flake pin on develop. It pins shell.nix's nixpkgs, but the profile the devcontainer builds with home-manager reads the container's channel, not shell.nix's — so the pin alone would not have prevented this; the lockfile is the authority both sides can read. Verified by evaluating the variant's package list against both channels: 26.05 and nixpkgs-unstable each resolve to nodejs 22, wasm-bindgen-cli 0.2.121 and wasm-pack; on unstable the 0.2.121 build comes from cache.nixos.org and reports itself as 0.2.121; on 26.05 the versioned attribute is byte-identical to the default. Not verified by rebuilding a container — `just _rebuild` inside the variant is what applies it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The boundary chapter is the record of one C API crossed from four languages. Since then the same header has been carried into five more runtimes, and each asked for something C never did. This chapter is those five, one section each, answering the same three questions every time: what crosses and in what shape, the one lesson that runtime teaches that no other can, and the front door — what you install, what the recipe runs, where the runtime's version gets to matter. It is longest on wasm because wasm is Exercise 4 and the one boundary with no C in it: the export section as the header, the caller with no allocator, three failure channels where C had one, a trap that leaves the instance answering, the raw route beside the generated lap, and the toolchain clauses (std and linker as separate deliveries, a target with no OS refusing `getrandom`, a generator version-coupled to its CLI). Fortran, R and Godot each get the lesson their day README already earned: by-reference for free and the hidden length from both sides; `.C()` discarding the return value; a descriptor nothing validates and a panic that leaves the return slot unwritten. Written from the day READMEs and the exercise, not from memory; the material for Fortran, R and Godot lives on the develop line, so on this line the chapter is ahead of the tree it describes and reads correctly once the lines meet. `mdbook build` is clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a Windows checkout The wasm32 job's first run on windows-latest failed in `_wasm-consumers` (run 35016847250), and the step had captured the consumers' output into a variable that died with it, so the log said only that a recipe failed. The consumers themselves narrow it down: nothing pins line endings in this repo, Git for Windows checks text files out with CRLF by default, and banner.test.ts splits `test/fixtures/caca-banner.txt` on "\n" and compares each row byte for byte with figlet.js's. Reproduced here by converting the fixture to CRLF: the first assertion fails with `actual: '🦀:\r', expected: '🦀:'`. The font beside it survives the same treatment — figlet.js normalises while parsing — so it needs no rule. `text eol=lf` on the fixtures directory keeps the recorded bytes on every platform. Proved on a scratch repo cloned with core.autocrlf=true: a control file comes out CRLF, the ruled fixture stays LF. Not run on the Windows runner; the next push is what proves the job. Not taken: normalising "\r" in the test. The fixture is a byte-exact record of what libcaca printed, and the comparison is the point; teaching the test to forgive a transport artefact would hide the next one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The step ran `out="$(just wasm-demo 2015-12-01)"` and echoed it afterwards. With the shell's `set -e`, a failing recipe ends the step at the assignment, and everything the consumers printed to stdout dies with the variable; only stderr survives, and the consumers say nothing there. The first windows-latest run (35016847250) therefore logged exactly one line, `recipe _wasm-consumers failed with exit code 1`, for a fifteen-second failure. `tee` to a file keeps every line in the log whether the recipe passes or fails, `pipefail` keeps the recipe's exit status, and the assertions read the file. The same capture shape is in every env-check track cell; those have not failed yet, and this commit leaves them as they are. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shell, one lock wasm on top of nix/flake: the track that was built first and pushed last meets the three tracks, the octopus merge, and the flake pin that grew underneath it. Thirteen files conflicted, and all of them were the same shape again — two lines appending at the same anchor in the shared plumbing — resolved as the union in branch order (the pinned tree first, then wasm), with the lines a union cannot decide picked by hand: the matrix track list and self-check's usage and case arms gain `wasm`, the devcontainer sentence and the base variant's comment name it, the day README's count is eleven solves, and the days index row names both wasm routes. .gitignore did not conflict at all: the node_modules rule the gitignore extract had rolled back under the plan commit is byte-identical to the wasm branch's own, and diff3 knows an identical change when it sees one. The union collapsed two identical trailing lines again — probe_godot's closing brace against the wasm probe's opening comment, and the blank before the wasm paragraph in the day README — the same trap the tracks merge recorded; restored. Verified on the merged tree: self-check parses and lists all eight tracks; both justfiles parse; days/Cargo.lock holds under --locked; actionlint has nothing beyond its four standing shellcheck notes; default-feature test, clippy and fmt clean; `nix-shell --arg full true --run 'cargo test --workspace --all-features --locked'` and the clippy line after it green at the pin, which now carries lld; `just days wasm-demo 2015-12-01` runs the raw route, the generated lap and the banner tests to green with the pinned CLI; `mdbook build` clean with the new chapter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every end-to-end cell captured its consumer's output with `out="$(…)"` and
echoed it on the next line. Under the runner's `bash -e`, a consumer that
exits non-zero ends the step at the assignment, and the captured stdout —
the answers it printed, or the message it failed with — dies with the
variable. Only stderr reaches the log. The wasm32 job in rust.yml hit this
on its first Windows run and logged one line for a fifteen-second failure;
these twenty-four cells have the same shape and have simply not failed yet.
One clause on each plain capture: `|| { rc=$?; echo "$out"; exit $rc; }`.
The assignment still happens on failure, so the echo prints what was
captured; the `||` list keeps `set -e` from firing first; the step exits
with the consumer's own status. Proved under the runner's exact shell flags
(`bash -e -o pipefail`): a command that prints then exits 3 leaves both
lines in the log and the step exits 3; a successful capture is unchanged.
Left alone on purpose: the r cell's negative test, which already branches on
the capture's status and echoes on both arms. The wasm32 job keeps its
`tee`, a different shape because there the output was also the diagnosis.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
alycda
added this pull request to stack #5
September 15, 2026 20:49
alycda
commented
Sep 15, 2026
| # scratch the first time: the crate generates bindings for the whole | ||
| # engine API from a JSON file it ships, and that is minutes rather than | ||
| # seconds. An expression rather than raising the number for all nine, | ||
| # seconds. An expression rather than raising the number for all ten, |
Owner
Author
There was a problem hiding this comment.
Suggested change
| # seconds. An expression rather than raising the number for all ten, | |
| # seconds. An expression rather than raising the number for all tracks, |
alycda
commented
Sep 15, 2026
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "effect": "^3.22.2", |
alycda
commented
Sep 15, 2026
|
|
||
| # wasm32 only, and one day so far (2015-12-01): aoc-ornaments pulls rand, | ||
| # rand pulls getrandom, and getrandom refuses to compile for | ||
| # wasm32-unknown-unknown until told where entropy comes from — the target |
alycda
commented
Sep 15, 2026
Comment on lines
+9
to
+11
| Afternoon / bonus material: this block has not been in front of a room | ||
| yet, so it carries no timing. It slots after Ex 3, and it is the answer | ||
| to "try another language" when the other language has no C ABI at all. |
Owner
Author
There was a problem hiding this comment.
TODO: other bindgen or wasi-environment
alycda
pushed a commit
that referenced
this pull request
Sep 23, 2026
In the room, one integer stopped being enough twice. Ex 2's -1 error value collides with 2015-12-01's real answers, and 2022-12-01's menu row already says its array is the thing worth exposing. This plans an afternoon extension after Ex 4. It is a ladder of return shapes, each one fixing the previous one's pain and bringing its own: A caller buffer B callee allocates + free C repr(C) structs D opaque handle E bytes + schema (hand-rolled, then protobuf) Stacked on #4. Rung B is Ex 4's alloc/free pair run the other way. It also assumes #7's status table, because rung A extends -3 to report the length needed. That extension changes #7's "out untouched on error" rule, so the plan says the rule has to change explicitly. What was checked, not assumed: 2022-12-01's parser, run from a scratch crate against this tree. An empty input or a trailing blank line yields a 0-calorie phantom elf. It is invisible to max and to the top-3 sum, and becomes observable the moment the boundary returns the array. That is Hyrum's Law, and it is Part 5's opening demo. CRLF input is a hard parse error. Claims about other runtimes and libraries are marked [verify]. The plan also records a follow-up #4 needs regardless: raw.ts's MEANING table lists only -1/-2, so after #7 a -3 or -4 prints "unknown status". Nothing built. Five decisions are listed at the end for the owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6DLQXw4LR4JkRTXNtbuaX
This was referenced Sep 23, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #3. Merge that one first, with a merge commit — GitHub will retarget this PR to
developon its own, and its diff will shrink to the wasm work.The wasm track, built first and pushed last: Exercise 4 and the 2015-12-01 variant, the C API carried into the one runtime that has no C ABI at all. 35 commits on the line, then the merge that joins it to the three later tracks and the flake pin, then one fix to the Verify workflow that the merge made visible.
What the track is
cargo build --target wasm32-unknown-unknownexports the same twoextern "C"functions as wasm exports, and Node's built-inWebAssemblycalls them with nothing generated. The module lends the caller an allocator (alloc/free), because JavaScript cannot allocate in linear memory.#[wasm_bindgen]exports behind awasmfeature, with an Effect consumer that putsErrand panic back into separate channels.exercises/ex4-wasm): your Ex 2 wrapper, unchanged, on a new target; TODOs for the string, the allocator and the hostile-input contract..flf, tested row by row against libcaca's output.wasmdevcontainer variant,just setup-wasm, a self-check probe, a wasm32 CI job on all three OSes, and a book chapter covering the five newer tracks with wasm first.Fixes found on the way to origin
just setup-wasmdied with exit 127 on a machine with no Node — the machine it exists for.set -eand a command substitution in an assignment.wasm-bindgen-cli, which is whatever the profile's channel calls current; the crate pin was checked against a different nixpkgs.home.nixnow reads the pinned version fromdays/Cargo.lockand takes nixpkgs' versioned attribute, cached on every channel.autocrlfdefault put a\ron every row and the wasm32 job's first Windows run failed in that test.text eol=lfon the fixtures.tees now. Every Verify track cell had the same shape; they print on failure now.The merge
Thirteen shared files conflicted, all two lines appending at the same anchor, resolved as the union with the hand-picked lines named in the merge message.
.gitignoredid not conflict: the extractednode_modulesrule under the plan commit is byte-identical to the branch's own.Verified on the merged tree: self-check with all eight tracks; both justfiles;
days/Cargo.lockunder--locked; actionlint; default-feature test, clippy, fmt; the pinned full shell'scargo test --workspace --all-features --lockedand clippy, lld now in the closure;just days wasm-demo 2015-12-01end to end with the pinned CLI;mdbook build. CI on this head is running as this opens.🤖 Generated with Claude Code