diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcf570b1..db64be02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,35 @@ jobs: - name: Build run: opam exec -- dune build - name: Run tests - run: opam exec -- dune runtest + run: | + set -o pipefail + opam exec -- dune runtest 2>&1 | tee runtest.log + # ── TEMPORARY DIAGNOSTIC (removed before merge) ─────────────────────── + # Republishes the failure + a downstream probe as annotations, so the + # result is readable through the API rather than the Actions log UI. + - name: "[diag] surface runtest failure and probe downstream" + if: always() + run: bash tools/ci/diag-probe.sh + # ───────────────────────────────────────────────────────────────────── + - name: Consumer on-ramp example (issue #771) + # The consumer-facing contract this project owes its downstream + # readers: a host surface declared with `extern fn`, compiled to + # wasm, driven by a host that supplies the imports, errors carried as + # stable integer codes. docs/ON-RAMP.adoc is the prose; this is the + # executable half, and it is gated so the on-ramp cannot rot the way + # blocky-writer's "just tell us how to build" ask did. The example + # finds the compiler three ways (in-tree build / PATH / dune exec), + # so it exercises the same entry point a consumer has. + run: ./examples/consumers/extension-boundary/build.sh + - name: WASM harness instantiation-idiom gate + if: ${{ !cancelled() }} + # tests/**/*.mjs must not mix the two WebAssembly.instantiate + # overloads (Module -> Instance vs BufferSource -> { module, instance }). + # A mix-up yields `undefined` and, before the codegen WASM runner was + # made fail-late, aborted the harness loop at the first bad file and + # silently masked every harness after it (33 of them). + # See tools/check-wasm-harness-idioms.sh. + run: ./tools/check-wasm-harness-idioms.sh - name: Run codegen WASM tests run: opam exec -- ./tools/run_codegen_wasm_tests.sh - name: Run codegen Bun-ESM tests (historical codegen-deno corpus) diff --git a/.github/workflows/governance-baseline.yml b/.github/workflows/governance-baseline.yml index 6a74faae..8b960682 100644 --- a/.github/workflows/governance-baseline.yml +++ b/.github/workflows/governance-baseline.yml @@ -35,12 +35,28 @@ on: branches: [main, master] pull_request: workflow_dispatch: -# Caller-side concurrency only. The local reusable deliberately declares NO -# concurrency block: a reusable that declares concurrency on the same computed -# key as its caller is rejected at run-creation (the BP008 class). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# NO `concurrency:` block — deliberately, and this is the fix for a 30-for-30 +# `startup_failure` streak (every run this workflow has ever had, from its +# first push to 2026-10-03). +# +# The BP008 class is caller-plus-callee, not callee-only: a reusable-workflow +# caller that declares `concurrency` alongside a callee that also declares it +# is rejected at *run-creation* — no job, no `check_run`, and therefore no +# way for the pinned context `governance / Validate Hypatia baseline` to ever +# report. An earlier pass removed the block from the local reusable +# (`governance-baseline-impl.yml`, which still has none) and left the +# caller's in place, so the collision survived and the streak continued. +# +# The evidence for removing the caller's block instead is the sibling +# workflow that works: `spark-theatre-gate.yml` is the estate's other +# reusable caller, it carries the same "NO workflow-level concurrency" note +# for the same reason, and its context reports on every PR. `governance.yml` +# keeps its concurrency block precisely because it is a *normal* workflow, +# not a caller. +# +# Consequence, recorded rather than hidden: two runs of this bridge on the +# same ref are no longer auto-cancelled. The bridge is a ~5 s `jq` check, so +# that is an acceptable price for a context that actually reports. permissions: contents: read jobs: diff --git a/.github/workflows/zz-probe-a.yml b/.github/workflows/zz-probe-a.yml new file mode 100644 index 00000000..9ceef5c5 --- /dev/null +++ b/.github/workflows/zz-probe-a.yml @@ -0,0 +1,18 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# +# TEMPORARY PROBE (deleted before merge) — hypothesis A: +# the local reusable call fails at run-creation because the CALLER JOB does +# not grant permissions explicitly (the only reusable caller in this repo that +# works, spark-theatre-gate.yml, does grant them at job level). Same shape as +# governance-baseline.yml, plus job-level permissions. +name: ZZ Probe A +on: + pull_request: +permissions: + contents: read +jobs: + governance: + uses: ./.github/workflows/governance-baseline-impl.yml + permissions: + contents: read diff --git a/.github/workflows/zz-probe-b.yml b/.github/workflows/zz-probe-b.yml new file mode 100644 index 00000000..25da43fa --- /dev/null +++ b/.github/workflows/zz-probe-b.yml @@ -0,0 +1,23 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# +# TEMPORARY PROBE (deleted before merge) — hypothesis B: no reusable workflow +# at all, and the pinned context reproduced by naming the job literally. +# A normal (non-caller) workflow always starts, so this is what the bridge +# becomes if the reusable mechanism itself is what the platform refuses. +name: ZZ Probe B +on: + pull_request: +permissions: + contents: read +jobs: + governance: + name: Validate Hypatia baseline + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + - name: Validate .hypatia-baseline.json (if present) + run: | + echo "probe B: plain job, no reusable workflow" diff --git a/README.adoc b/README.adoc index d7896df8..c35162f2 100644 --- a/README.adoc +++ b/README.adoc @@ -86,20 +86,34 @@ write(buf, "world") # error: 'buf' was already used up by close() == Quick start -// TODO: replace this block with the real toolchain commands. +Build the compiler from a checkout (the only route that works today — see +link:docs/ON-RAMP.adoc[docs/ON-RAMP.adoc] for why the release-binary and +JSR-shim routes do not yet): + [source,console] ---- -# install -$ +$ git clone https://github.com/hyperpolymath/affinescript && cd affinescript +$ opam install . --deps-only --with-test --yes +$ dune build + +# lex / parse / type-check / evaluate a program +$ dune exec affinescript -- check examples/hello.affine -# compile a face program to WebAssembly -$ hello.rattle -o hello.wasm +# compile it to WebAssembly +$ dune exec affinescript -- compile examples/hello.affine -o hello.wasm -# run it -$ hello.wasm +# or to an ES module for a JavaScript host +$ dune exec affinescript -- compile examples/hello.affine -o hello.bun.js --bun-esm ---- -A runnable example lives in `examples/` once the toolchain lands. +If you are *consuming* AffineScript — a project outside this repository that +wants a compiler, a target, and a host boundary — read +link:docs/ON-RAMP.adoc[docs/ON-RAMP.adoc] instead of the rest of this file. +It states what exists and what does not. + +A complete, CI-gated consumer lives in +`examples/consumers/extension-boundary/`; `just on-ramp-example` builds and +runs it. == How it works diff --git a/docs/NAVIGATION.adoc b/docs/NAVIGATION.adoc index 3dd4bfb1..d9a5b1f3 100644 --- a/docs/NAVIGATION.adoc +++ b/docs/NAVIGATION.adoc @@ -80,6 +80,7 @@ This guide helps you navigate the AffineScript repository structure. **Guides:** +* link:ON-RAMP.adoc[Consumer On-Ramp] - **Start here if you are consuming AffineScript from another project** — getting a compiler, targets, declaring a host surface with `extern fn`, carrying errors across the wasm boundary (#771) * link:CAPABILITY-MATRIX.adoc[Capability Matrix] - **Live** per-component readiness (authoritative for feature readiness) * link:SOUNDNESS.adoc[Soundness Ledger] - **Live** test-anchored soundness-hole status (authoritative for "is it sound?"; the matrix links here). *Start here for soundness questions.* * link:PROOF-NEEDS.adoc[Proof-Needs Inventory] - what must be *proven* (mostly unmechanised); distinct from the soundness ledger above diff --git a/docs/ON-RAMP.adoc b/docs/ON-RAMP.adoc new file mode 100644 index 00000000..9d261b2a --- /dev/null +++ b/docs/ON-RAMP.adoc @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Consumer on-ramp: wiring AffineScript into another project +:toc: macro +:toclevels: 3 +:icons: font + +This is the page a project *outside this repository* needs: how to get a +compiler, which target to pick, how to declare the host functions your +program calls, and how to carry errors across that boundary. + +It exists because the first real consumer asked for exactly this and could +not find it — `hyperpolymath/blocky-writer#73` filed +`hyperpolymath/affinescript#771`, "a real consumer exists, and it is blocked +on the on-ramp". Every claim below is either runnable in this repository or +marked as not-yet-true. Where something is missing, it says so rather than +leaving you to discover it. + +[IMPORTANT] +==== +Feature readiness is *not* stated here. The authoritative per-feature status +is link:CAPABILITY-MATRIX.adoc[CAPABILITY-MATRIX.adoc]; soundness-hole status +is link:SOUNDNESS.adoc[SOUNDNESS.adoc]. This page states only what a +*consumer* has to do, and it is CI-gated by +`examples/consumers/extension-boundary` (see <>). +==== + +toc::[] + +== The whole on-ramp in one screen + +. Get a compiler (<>). +. Write `.affine` in the canonical face — see <> if your + sources came from ReScript. +. Declare every host function you call with `extern fn` and give it a type + (<>). +. Compile to the target you want (<>). +. Supply those host functions when you load the artifact + (<>). + +There is no project manifest to write, no build-file schema to learn, and no +bundler configuration. The compiler takes a file and `-o`. If you want a +task runner, the repository's own `justfile` recipes are the model — +`just on-ramp-example` runs the worked example. + +== Getting a compiler + +Three routes exist. Only the first works today; the other two are recorded +with their exact state so you do not spend an afternoon on them. + +[cols="1,2,3",options="header"] +|=== +| Route | State (2026-10-03) | What to do + +| Build from a checkout | *Works.* +| `git clone https://github.com/hyperpolymath/affinescript && cd + affinescript && opam install . --deps-only --with-test --yes && dune + build`, then use `dune exec affinescript -- …` or + `_build/default/bin/main.exe`. This is what CI does on every pull request, + so it is the route with the most evidence behind it. Needs OCaml >= 4.14 + and opam. + +| Release binary | *Not available.* All three published Releases (`v0.1.0`, + `v0.1.1`, `v0.2.0`) carry **zero assets**. `release.yml` did not complete + on those tags (documented in link:PACKAGING.adoc[PACKAGING.adoc]); it has + since been repaired and now creates the release as a draft and publishes it + only after all four assets are attached, but *it has not run since that + repair*. +| Watch `hyperpolymath/affinescript` Releases for a `v*` tag whose assets + include `affinescript-linux-x64` / `affinescript-macos-{x64,arm64}` and + `SHA256SUMS`. Do not assume an asset exists because a tag does. + +| JSR shim `@hyperpolymath/affinescript` | *Cannot work yet.* The shim + downloads the host binary from the Release pinned in + `packages/affinescript-cli/pins.js` and verifies it against an embedded + SHA-256, failing closed. With no assets attached, every target refuses to + run — deliberately, rather than fetching something unverified. +| Nothing yet. This is the same blocker as the row above: it unblocks when a + `v*` tag publishes the three binaries. +|=== + +[NOTE] +==== +`tools/affine-pkg/` is a Rust prototype of a package manager with an +`affine.toml` manifest. It is *not* part of the consumer path today: nothing +in CI builds it, and the compiler does not read `affine.toml`. It is listed +in link:standards/ROADMAP.adoc[standards/ROADMAP.adoc] and nothing more. +==== + +== Targets + +One flag selects the output; the file extension also implies it. + +[cols="2,1,3",options="header"] +|=== +| Invocation | Output | Use it when + +| `compile FILE -o OUT.wasm` | WebAssembly (linear memory) +| You want to embed the program in a host that owns the outside world: a + browser extension, a Node/Bun/Deno process, wasmtime, a plugin host. The + artifact's imports are yours to satisfy (<>). + This is the target the worked example uses. + +| `compile FILE -o OUT.bun.js --bun-esm` | ES module +| You want JavaScript you can read, bundle, or hand to a JS toolchain, and + your host APIs are Node-shaped (`Bun`/`Node` synchronous file, JSON, Date). + Host operations the compiler knows about lower to those APIs directly; a + declared `extern fn` the compiler has no lowering for becomes a plain call + to the same name, which the host provides by putting it in scope. + +| `compile FILE -o OUT.cjs --vscode-extension` | Node CJS shim +| You are building a VS Code extension and want the `exports.activate` wiring + generated for you (`packages/affine-vscode` supplies the adapter). + +| `compile FILE -o OUT.wasm --wasm-gc` | WebAssembly GC +| Your runtime supports the GC proposal and you want struct/array types + instead of linear memory. V8 >= 119 / SpiderMonkey >= 120. +|=== + +Deno-ESM is *retired*: `--deno-esm` (or an `-o …deno.js` path) is an error by +design, not a missing feature. Use `--bun-esm`. + +== Faces: which surface syntax + +`--face` selects the surface syntax; the affine core is the same either way, +so the guarantees do not depend on your choice: + +* `canonical` (default) — `.affine`, the shape used in this repository. +* `python` / `rattle` — Python-shaped (`def`, indentation). +* `js` / `jaffa` — JavaScript-shaped (`const`, `function`, `=>`). +* `pseudocode`, `lucid`, `cafe` — the remaining documented faces. + +A file can declare its own face in a leading comment instead of relying on +the flag (`# face: rattlescript`). The recommended layout is `.affine` +everywhere plus that pragma; the old face-implying extensions (`.rattle`, +`.pyaff`, …) are deprecated. + +== Not ReScript + +A face is a *shape for AffineScript source*. It is not a ReScript parser. +`.res` written in ReScript is not a face, and renaming `Foo.res` to +`Foo.affine` does not make it one — it produces a file that fails to parse: + +[source,console] +---- +$ affinescript check src/popup.affine +src/popup.affine:16:8: parse error: Syntax error +---- + +ReScript constructs with no AffineScript counterpart: `@val` / `@module` / +`@scope` / `@react.component` attributes, `%raw`, `external … = "js.path"` +bindings, `Js.Dict.t` / `Js.Promise.t`, `~labelled` parameters, and +`'a`-style type variables. All eight `.affine` files in the consumer that +prompted this page fail to parse for exactly these reasons. + +If your sources are ReScript, the honest sequence is: + +. Decide what you are actually migrating. A React component tree is not a + mechanical translation: AffineScript's typed surfaces are + `stdlib/Dom.affine` (VNode builders) and the TEA runtime, not a React + binding. A pure data/algorithm module — a PDF byte protocol, a parser, a + storage layer — usually is close to mechanical. +. Convert, don't rename. `tools/res-to-affine/` exists for this: it reads + `.res` and emits an `.affine` skeleton with migration markers naming each + anti-pattern it found, or `--partial` for a partial port. + Usage: `dune exec tools/res-to-affine/main.exe -- path/to/Foo.res`. + See link:MIGRATION-ASSISTANT.adoc[MIGRATION-ASSISTANT.adoc] and + link:RESCRIPT-ELIMINATION.adoc[RESCRIPT-ELIMINATION.adoc]. +. Port the boundary first. Declare the host functions the module needs + (<>) before porting bodies; the boundary is + what fixes the types, and it is where the compiler's feedback is most + useful. + +== Declaring your host surface + +This is the whole foreign-function story for the wasm target: a *declaration* +with no body. The compiler emits one import per declaration, into the `env` +module, with `i32` parameters and an `i32` result. + +[source,affine] +---- +module boundary; + +// Implemented by the host — see host.mjs in the worked example. +pub extern fn bw_detect_blocks(pdf_len: Int) -> Int; +pub extern fn bw_fill_blocks(pdf_len: Int, field_count: Int) -> Int; + +// An opaque host-owned type, when you need one. +pub extern type HostBuffer; +---- + +Your host satisfies them in the import object: + +[source,javascript] +---- +const imports = { + env: { + bw_detect_blocks: (pdfLen) => { /* ... */ return 0; }, + bw_fill_blocks: (pdfLen, fieldCount) => { /* ... */ return 1042; }, + }, +}; +const { instance } = await WebAssembly.instantiate(bytes, imports); +const status = instance.exports.detect(4096); // your `pub fn`s are exports +---- + +Three things this example does not show, in increasing order of effort: + +* *Bytes in and out.* Parameters are `i32`, so a byte transfer is + `(pointer, length)` into the guest's exported `memory`, which the host reads + and writes with a `DataView`. Worked references in this repository: + `tests/codegen/test_net_send.mjs` (host writes into guest memory) and + `tests/codegen/test_file_roundtrip.mjs`. +* *Closures passed to the host.* A closure is a pointer to `[fnId @+0, + envPtr @+4]`; the host looks `fnId` up in the guest's exported + `__indirect_function_table` and calls it with `envPtr` first, zero-padding + to arity. A complete dispatcher is in + `tests/codegen/test_closure_indirect_dispatch.mjs`, and the same code + appears in `packages/affine-vscode/mod.js`. +* *The ESM target.* There, a declared `extern fn` the compiler has no + built-in lowering for becomes a plain call to that name — so the host must + put it in scope (a `globalThis. = …` before importing the generated + module). The pattern is in `tests/codegen-deno/pixi_smoke.harness.mjs`. + +== Errors across the boundary + +Do not send strings. Send a status code, and let the host own the prose. +This keeps the guest free of allocation and encoding questions, and gives you +a stable taxonomy your host can switch on: + +[source,affine] +---- +/// 0 is success; anything else is a stable code the host defines. +pub fn detect(pdf_len: Int) -> Int { + let status = bw_detect_blocks(pdf_len); + if is_ok(status) { 0 } else { bw_error_code() } +} +---- + +The example pairs this with `bw_error_code` / `bw_error_message_len` / +`bw_error_message_byte`, so the human-readable message is reachable +byte-by-byte *without* the guest ever holding a string. The host keeps the +`{code, message, context}` payload it already wants; the guest only ever +names codes. `examples/consumers/extension-boundary/host.mjs` asserts the +whole round-trip. + +== Statements and `;` + +Two grammar rules cost consumers time, so they are stated here: + +* `if`, `while` and `for` are self-terminating statements — the closing `}` + is enough. +* A `match` that is *not* a block's final expression is an ordinary + expression statement and needs its `;`: + +[source,affine] +---- +pub fn f(o: Opt) -> Int { + match o { // mid-block: statement, needs the `;` + SomeV(v) => { return v; } + NoneV => {} + }; + return 0; +} +---- + +Omitting that `;` fails at the `}` that closes the `match`, which reads as +"the match is broken" rather than "the statement is unterminated". Empty arm +bodies (`Pat => {}`) are fine, in any position. The rule is pinned by +`test_match_statement_requires_semicolon` in `test/test_e2e.ml`, and the +grammar question ("should a `}`-terminated `match` self-terminate, as `if` +does?") is open on `#644`. + +== The worked example + +`examples/consumers/extension-boundary/` is a complete consumer in one +directory, shaped after the first real consumer request: a two-function +boundary (`detect` / `fill`), a host-supplied error taxonomy, and a host +harness that asserts the contract. + +[source,console] +---- +$ just on-ramp-example +Compiling boundary.affine -> boundary.wasm +Compiled …/src/boundary.affine -> …/dist/boundary.wasm (WASM) +Running host.mjs +examples/consumers/extension-boundary: OK +---- + +Its `build.sh` locates the compiler the three ways a consumer can have one +(in-tree `dune build`, `affinescript` on `PATH`, `dune exec`), so the same +script works in a checkout and against a release binary. CI runs it in the +`build` job, which means the page you are reading cannot drift away from the +code it describes without the build going red. + +== What is not there yet + +Recorded so you can plan around it rather than discover it: + +* *No release binary.* See <>. Until a `v*` tag publishes + assets, building from a checkout is the only route. +* *No project manifest.* `affine.toml` is a prototype (`tools/affine-pkg/`); + a consumer's "build file" is a shell script or a `justfile` recipe. +* *The host surface is per-target, not declared per-project.* Adding a host + function needs no compiler change (the `extern fn` route above), but the + *ESM* target's built-in lowerings are a fixed table in + `lib/codegen_deno.ml`, so an ESM consumer cannot rename what the compiler + already knows. +* *`docs/reference/ABI-FFI.adoc` is an unfilled estate template.* It describes + an Idris2-plus-Zig ABI that is not how a consumer reaches this compiler (the + wasm import contract above is). Treat it as unmaintained until it is + rewritten or deleted. +* *Async host calls.* The ESM target has documented async-extern limits + (`#122` / `#103`); the wasm target's `extern fn` is synchronous. diff --git a/examples/consumers/extension-boundary/README.adoc b/examples/consumers/extension-boundary/README.adoc new file mode 100644 index 00000000..33c4390b --- /dev/null +++ b/examples/consumers/extension-boundary/README.adoc @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Consumer example: an extension-shaped host boundary +:toc: macro +:icons: font + +A complete, minimal consumer of AffineScript — the thing +link:../../../docs/ON-RAMP.adoc[docs/ON-RAMP.adoc] describes in prose. + +It is shaped after the first real consumer request +(`hyperpolymath/affinescript#771`, from `hyperpolymath/blocky-writer#73`): a +browser extension whose document engine lives behind a two-function boundary, +compiled to WebAssembly, with an error taxonomy that has to cross that +boundary intact. + +toc::[] + +== Run it + +[source,console] +---- +$ just on-ramp-example # from the repository root +# or +$ ./examples/consumers/extension-boundary/build.sh +---- + +Expected output: + +[source,console] +---- +Compiling boundary.affine -> boundary.wasm +Compiled …/src/boundary.affine -> …/dist/boundary.wasm (WASM) +Running host.mjs +examples/consumers/extension-boundary: OK +---- + +`build.sh` finds the compiler three ways, so it works in a checkout +(`_build/default/bin/main.exe`), against a release binary on `PATH` +(`affinescript`), or with `dune exec`. That is deliberate: a consumer's build +script should not care which of the three you have. + +== The four files, and why each exists + +[cols="1,3",options="header"] +|=== +| File | What it is + +| `src/boundary.affine` +| The consumer's own module. It declares the host functions it calls with + `extern fn` (the whole binding story — no compiler change, no IDL, no + adapter package), wraps them in typed helpers, and never touches a string + it did not receive from the host. + +| `host.mjs` +| The other half: an import object that satisfies those declarations, a + `{code, message, context}` payload the host owns, and assertions for the + contract (success path, failure path, message round-trip, exported + surface). + +| `build.sh` +| Compile, then run. The "smallest thing that turns `src/*.affine` into + something that compiles", in executable form. + +| `dist/` +| Build output (gitignored). `boundary.wasm` is what you would ship in an + extension bundle next to `host.mjs`'s import object. +|=== + +== The parts worth copying + +*Declare the boundary before porting bodies.* The `extern fn` block at the top +of `src/boundary.affine` fixes every type the guest can see. In a real port +this is the first thing to write, because the compiler's error messages are +most useful at the seam. + +*Codes, not strings.* `bw_detect_blocks` returns an `Int` status; the message +lives host-side and is reachable byte-by-byte via +`bw_error_message_len` / `bw_error_message_byte`. The guest never allocates, +never encodes, and cannot invent an error shape the host does not know. + +*Assert the contract in CI.* `host.mjs` is not a smoke test — it asserts that +`fill` propagates the exact `BW_*` code, that the message round-trips +byte-for-byte, and that the guest exports the names it advertised. The same +script runs in this repository's `build` job, so the on-ramp cannot rot. + +*Nothing here is browser-specific.* Node is used only to read the file and to +have an assertion library. `WebAssembly.instantiate(bytes, imports)` with the +same `imports` object runs unchanged in a Firefox/Chrome extension background +frame, in Bun, or in Deno. + +== What this example deliberately does not do + +* *No byte transfer through linear memory.* Parameters are `i32`, so real byte + work is a `(pointer, length)` pair into the guest's exported `memory`; see + `tests/codegen/test_net_send.mjs` for a worked host-side write, and + `tests/codegen/test_file_roundtrip.mjs` for a round-trip. Kept out here so + the boundary pattern is the only thing in view. +* *No closures.* Passing a callback to the host uses the + `[fnId @+0, envPtr @+4]` + `__indirect_function_table` ABI; a complete + dispatcher is in `tests/codegen/test_closure_indirect_dispatch.mjs` and in + `packages/affine-vscode/mod.js`. +* *No bundler, no manifest.* There is no `deno.json`, no `package.json`, no + `affine.toml`. A consumer's build file is this shell script, because the + compiler takes a source file and `-o`. diff --git a/examples/consumers/extension-boundary/build.sh b/examples/consumers/extension-boundary/build.sh new file mode 100755 index 00000000..92993c5b --- /dev/null +++ b/examples/consumers/extension-boundary/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Build + run the consumer on-ramp example (issue #771). +# +# This is the "smallest thing" the consumer asked for, in executable form: +# +# ./build.sh compile src/boundary.affine -> dist/boundary.wasm, +# then run the Node harness against it. +# +# The compiler is located the three ways a consumer legitimately has it: +# an in-tree dune build (a checkout of this repo), an `affinescript` on +# PATH (a release binary), or `dune exec` (a checkout without a prior +# build). Nothing else is required — no deno.json, no npm manifest, no +# bundler config. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" + +SRC="$HERE/src/boundary.affine" +OUT_DIR="$HERE/dist" +OUT="$OUT_DIR/boundary.wasm" + +if [ -x "$ROOT/_build/default/bin/main.exe" ]; then + COMPILE=("$ROOT/_build/default/bin/main.exe" compile) +elif command -v affinescript >/dev/null 2>&1; then + COMPILE=(affinescript compile) +else + COMPILE=(dune exec affinescript -- compile) +fi + +mkdir -p "$OUT_DIR" + +echo "Compiling $(basename "$SRC") -> $(basename "$OUT")" +"${COMPILE[@]}" "$SRC" -o "$OUT" + +echo "Running host.mjs" +node "$HERE/host.mjs" diff --git a/examples/consumers/extension-boundary/host.mjs b/examples/consumers/extension-boundary/host.mjs new file mode 100644 index 00000000..c85360a8 --- /dev/null +++ b/examples/consumers/extension-boundary/host.mjs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Node ESM harness for examples/consumers/extension-boundary. +// +// This file is the other half of the on-ramp: it shows exactly how a host +// satisfies the `extern fn` surface declared in src/boundary.affine, and +// what the assertable contract looks like in CI. Node is used because CI +// already has it; nothing here is Node-specific except `readFile`, so the +// same object literal works in Deno, Bun, or a browser extension +// (`WebAssembly.instantiate` with the same `imports`). +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// The error taxonomy a real consumer needs is stable and enumerated. These +// are the codes the host returns; the guest only ever sees integers. +const BW = { + OK: 0, + BW_PDF_ENCRYPTED: 1042, + BW_NO_BLOCKS_FOUND: 1051, +}; + +// The host owns the message text. Nothing crosses the boundary as a string +// — `bw_error_message_*` is a byte-wise accessor over this value. +const MESSAGES = { + [BW.BW_PDF_ENCRYPTED]: "document is encrypted; decrypt before filling", + [BW.BW_NO_BLOCKS_FOUND]: "no fillable blocks detected in document", +}; + +// `context` is the third leg of the {code, message, context} payload the +// consumer contract requires; it stays host-side too. +let lastFailure = null; + +function fail(code, context) { + lastFailure = { code, message: MESSAGES[code] ?? "", context }; + return code; +} + +const calls = { detect: 0, fill: 0 }; + +const imports = { + wasi_snapshot_preview1: { + // Nothing in this guest touches WASI, but the compiler links the + // preview-1 surface when the file mentions effects; a stub keeps the + // instantiation total rather than depending on what codegen emitted. + fd_write: () => 0, + }, + env: { + bw_detect_blocks: (pdfLen) => { + calls.detect += 1; + assert.ok(Number.isInteger(pdfLen) && pdfLen > 0, "host got the length"); + return BW.OK; + }, + bw_fill_blocks: (pdfLen, fieldCount) => { + calls.fill += 1; + assert.ok(Number.isInteger(pdfLen) && pdfLen > 0, "host got the length"); + assert.ok(fieldCount > 0, "host got the field count"); + // Second document is encrypted — the failure path, deliberately. + return fail(BW.BW_PDF_ENCRYPTED, { pdfLen, fieldCount }); + }, + bw_error_code: () => lastFailure?.code ?? BW.OK, + bw_error_message_len: () => lastFailure?.message.length ?? 0, + bw_error_message_byte: (offset) => + lastFailure?.message.charCodeAt(offset) ?? 0, + }, +}; + +const wasmPath = join(here, "dist", "boundary.wasm"); +const bytes = await readFile(wasmPath); +const { instance } = await WebAssembly.instantiate(bytes, imports); +const guest = instance.exports; + +// ── The contract, asserted ─────────────────────────────────────────────── + +// 1. Success path: the guest normalises "host said 0" to 0. +assert.equal(guest.detect(4096), 0, "detect returns 0 on success"); +assert.equal(guest.is_ok(0), 1, "is_ok(0) is true"); +assert.equal(guest.message_len(0), 0, "no message for a success status"); +assert.equal(calls.detect, 1, "host detected once"); + +// 2. Failure path: the guest propagates the stable code, not a string. +assert.equal( + guest.fill(4096, 3), + BW.BW_PDF_ENCRYPTED, + "fill propagates the host's BW_* code", +); +assert.equal(calls.fill, 1, "host filled once"); + +// 3. The message is host-owned and byte-addressable, exactly as declared. +const len = guest.message_len(BW.BW_PDF_ENCRYPTED); +assert.equal(len, MESSAGES[BW.BW_PDF_ENCRYPTED].length, "message length crosses"); +const rebuilt = Array.from({ length: len }, (_, i) => + String.fromCharCode(guest.message_byte(BW.BW_PDF_ENCRYPTED, i)), +).join(""); +assert.equal(rebuilt, MESSAGES[BW.BW_PDF_ENCRYPTED], "message round-trips"); +assert.equal(guest.message_len(0), 0, "success still has no message"); + +// 4. The consumer-facing payload shape: {code, message, context} — never a +// bare string, which is the taxonomy requirement this example exists for. +assert.deepEqual(lastFailure, { + code: BW.BW_PDF_ENCRYPTED, + message: MESSAGES[BW.BW_PDF_ENCRYPTED], + context: { pdfLen: 4096, fieldCount: 3 }, +}); + +// 5. The guest's public surface is what the consumer advertised — a +// two-function boundary plus the error accessors. +for (const name of [ + "detect", "fill", "is_ok", "message_len", "message_byte", +]) { + assert.equal(typeof guest[name], "function", `${name} is exported`); +} + +console.log("examples/consumers/extension-boundary: OK"); diff --git a/examples/consumers/extension-boundary/src/boundary.affine b/examples/consumers/extension-boundary/src/boundary.affine new file mode 100644 index 00000000..eff50451 --- /dev/null +++ b/examples/consumers/extension-boundary/src/boundary.affine @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Consumer on-ramp example (issue #771) — the "extension boundary" shape. +// +// This module is what a downstream consumer writes. It is deliberately +// shaped after the first real consumer request the project received: a +// browser extension with a WASM core behind a two-function boundary +// (`detect` / `fill`), whose error taxonomy has to cross that boundary as +// stable integer codes plus a message the host owns. +// +// Two things are being demonstrated: +// +// 1. A consumer owns its own host surface. The `extern fn` declarations +// below are the entire binding story: the WASM backend emits one +// `(import "env" "")` per declaration, and the host supplies +// them in the import object. No compiler change is needed to add a +// host function. +// +// 2. Errors do not cross as strings. The guest returns a status code +// (0 = ok, non-zero = a stable `BW_*` code) and asks the host for the +// message only when a human needs to read it. +// +// Compile: +// affinescript compile src/boundary.affine -o dist/boundary.wasm +// Run the harness: +// node host.mjs + +module boundary; + +// ── The host surface this consumer owns ────────────────────────────────── +// Implemented in the host's `env` import object (see host.mjs). Params and +// results are i32 across the WASM boundary. + +/// Scan a document for fillable blocks. PDF bytes stay host-side; the guest +/// passes only the length it was told about. Returns 0 or a `BW_*` code. +pub extern fn bw_detect_blocks(pdf_len: Int) -> Int; + +/// Write field values into the blocks found by `bw_detect_blocks`. +/// `field_count` is the number of entries the host handed over. Returns 0 +/// or a `BW_*` code. +pub extern fn bw_fill_blocks(pdf_len: Int, field_count: Int) -> Int; + +/// The `BW_*` code for the most recent failure. Always safe to call. +pub extern fn bw_error_code() -> Int; + +/// Byte length of the host-owned message for the most recent failure. +pub extern fn bw_error_message_len() -> Int; + +/// Byte `offset` of the host-owned message for the most recent failure. +pub extern fn bw_error_message_byte(offset: Int) -> Int; + +// ── Guest-side vocabulary ──────────────────────────────────────────────── + +/// Status values are the `BW_*` taxonomy, narrowed to the two the guest +/// reasons about: 0 is success, anything else is a failure whose detail +/// lives host-side. +pub fn is_ok(status: Int) -> Bool { + status == 0 +} + +/// Detect blocks, normalising the host's status to the documented +/// "0 = ok, non-zero = BW_* code" contract. +pub fn detect(pdf_len: Int) -> Int { + let status = bw_detect_blocks(pdf_len); + if is_ok(status) { 0 } else { bw_error_code() } +} + +/// Fill blocks. Same normalisation as `detect`. +pub fn fill(pdf_len: Int, field_count: Int) -> Int { + let status = bw_fill_blocks(pdf_len, field_count); + if is_ok(status) { 0 } else { bw_error_code() } +} + +/// Length of the human-readable message for `status`, or 0 when `status` +/// is success. The guest never materialises the message as a String: the +/// host owns it, and the guest asks for it only when someone will read it. +pub fn message_len(status: Int) -> Int { + if is_ok(status) { 0 } else { bw_error_message_len() } +} + +/// Byte of the human-readable message for `status`, or 0 when `status` is +/// success. Mirrors `message_len`. +pub fn message_byte(status: Int, offset: Int) -> Int { + if is_ok(status) { 0 } else { bw_error_message_byte(offset) } +} diff --git a/justfile b/justfile index 4d7e94e5..1863e263 100644 --- a/justfile +++ b/justfile @@ -247,6 +247,26 @@ golden-path: dune exec affinescript -- parse examples/ownership.affine 2>/dev/null || dune exec affinescript -- parse examples/ownership.affine 2>/dev/null || echo "(no ownership example — skip)" @echo "=== Golden Path Complete ===" +# ── Consumer on-ramp (issue #771) ───────────────────────────────────────────── +# The wiring a project *outside* this repo uses: declare a host surface with +# `extern fn`, compile to wasm, hand the imports to the host. Prose: +# docs/ON-RAMP.adoc. This recipe is the same thing CI gates, so a consumer +# can reproduce the guarantee locally before trusting it. + +# Build and run the consumer on-ramp example (compile -> wasm -> host harness) +on-ramp-example: + ./examples/consumers/extension-boundary/build.sh + +# Show the consumer-facing compiler surface without reading the CLI source +on-ramp-targets: + @echo "Targets a consumer can pick from (docs/ON-RAMP.adoc):" + @echo " compile FILE -o OUT.wasm wasm — host supplies imports (browser, wasmtime, node)" + @echo " compile FILE -o OUT.bun.js --bun-esm ES module for a JS host (Bun/Node APIs; Deno-ESM retired)" + @echo " compile FILE -o OUT.cjs --vscode-extension Node-CJS shim wired for a VS Code extension" + @echo " compile FILE -o OUT.wasm --wasm-gc WebAssembly GC proposal target" + @echo "" + @echo "Faces (--face): canonical (default) | python/rattle | js/jaffa | pseudocode | lucid | cafe" + # Run panic-attack security scan panic: panic-attack assail diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 4cd32d21..0b677d04 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -733,7 +733,7 @@ const __as_dbGroupBy = (h, sql, params) => String(globalThis.__as_sqlite.gro const __as_dbGroupCount = (h, table, keyCol) => String(globalThis.__as_sqlite.groupCount(h, table, keyCol)); // ---- Dom (#56 PR 4): Window / Document / utility ---- // Host is `globalThis.window` when present (browser, jsdom, idaptik -// WebView); otherwise `globalThis` so a Deno/Node harness can install +// WebView); otherwise `globalThis` so a Node/Bun harness can install // document/window mocks on the global. No consumer-side init required // beyond providing those web-platform objects. const __as_domWin = () => diff --git a/test/test_e2e.ml b/test/test_e2e.ml index 46802d6a..5e5fd562 100644 --- a/test/test_e2e.ml +++ b/test/test_e2e.ml @@ -1080,13 +1080,29 @@ let test_total_is_valid_binding_name () = | Ok _ -> () let test_empty_match_arm_block_parses () = + (* #644: an empty match-arm body (`Pat => {}`) must parse. + The issue's repro additionally put the `match` in NON-final statement + position *without* a `;`, and reported the resulting failure at the + `}` that closes the `match`. Measured with `affinescript parse` on the + repro and its variants (`tools/ci/diag-probe.sh`, 2026-10-03): + - empty arm, `match` FINAL in the block -> parses + - empty arm, `match` mid-block, no `;` -> fails AT the match's `}` + - empty arm, `match` mid-block, with `;` -> parses + - non-empty arm, `match` mid-block, no `;` -> fails at the same place + So the empty arm was never the cause: only `if`/`while`/`for` are + self-terminating statements (`lib/parser.mly`, `stmt:`), and a `match` + that is not a block's final expression is an ordinary expression + statement needing its `;`. This case keeps the empty arm *and* + terminates the statement, so it asserts #644's actual property rather + than the `;`-less shape the grammar has never accepted. The `;` rule + itself is pinned by test_match_statement_requires_semicolon below. *) let source = {|module EmptyArm; enum Opt { SomeV(Int), NoneV } pub fn f(o: Opt) -> Int { match o { SomeV(v) => { return v; } NoneV => {} - } + }; return 0; }|} in try @@ -1097,6 +1113,33 @@ let test_empty_match_arm_block_parses () = | Lexer.Lexer_error (msg, _) -> Alcotest.failf "empty match-arm block should lex: %s" msg +(* The statement-termination rule the #644 repro tripped over, pinned so it + stops being folklore. `if`/`while`/`for` end with a `}` and terminate + themselves; a `match` in non-final statement position is an expression + statement and needs an explicit `;`. If the language decides instead that + a `}`-terminated `match` should self-terminate (that is #644's open + question — see docs/ON-RAMP.adoc, "Statements and `;`"), then this test is + the one to delete, and the positive form belongs next to the #644 case + above. *) +let test_match_statement_requires_semicolon () = + let source = {|module NoSemi; + enum Opt { SomeV(Int), NoneV } + pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => { return v; } + NoneV => {} + } + return 0; + }|} in + match Parse_driver.parse_string ~file:"" source with + | exception Parse_driver.Parse_error _ -> () + | exception Lexer.Lexer_error _ -> () + | _ -> + Alcotest.fail + "a mid-block `match` with no trailing `;` parsed; if the grammar was \ + deliberately extended to make `match` self-terminating, delete this \ + test and record the decision on #644" + let error_tests = [ Alcotest.test_case "bad syntax" `Quick test_error_parse_bad_syntax; Alcotest.test_case "unclosed brace" `Quick test_error_parse_unclosed_brace; @@ -1105,6 +1148,8 @@ let error_tests = [ test_total_is_valid_binding_name; Alcotest.test_case "empty match arm block parses (#644)" `Quick test_empty_match_arm_block_parses; + Alcotest.test_case "mid-block `match` needs its `;` (#644 repro)" `Quick + test_match_statement_requires_semicolon; ] (* ============================================================================ diff --git a/tests/codegen-deno/deno_scripting.harness.mjs b/tests/codegen-deno/deno_scripting.harness.mjs index b12fb92d..4c3ae753 100644 --- a/tests/codegen-deno/deno_scripting.harness.mjs +++ b/tests/codegen-deno/deno_scripting.harness.mjs @@ -1,14 +1,22 @@ // SPDX-License-Identifier: MPL-2.0 -// issue #122 follow-up — Node-ESM harness for new Deno-scripting externs. +// issue #122 follow-up — Node-ESM harness for the Deno-era scripting externs, +// now exercised through the Bun-ESM backend. // -// Stubs only what the new surface needs: a tiny in-memory FS for -// `Deno.readDirSync` (so `walkRecursive` traverses it deterministically), -// `Deno.args` / `Deno.exit`, and captures `console.error`. +// Stubs only what the surface needs: a tiny in-memory FS (so `walkRecursive` +// traverses it deterministically), `args`, `exit`, and captures +// `console.error`. +// +// IMPORTANT: this corpus is compiled with `--bun-esm`, and the emitted Bun +// prelude resolves `node:fs` lazily via `process.getBuiltinModule()` and reads +// `process.argv` / `process.exit`. Stubbing a `globalThis.Deno` object is +// inert under that backend, so the host is stubbed at the seam the emission +// actually uses. import assert from "node:assert/strict"; // ── In-memory FS stub for walkRecursive ───────────────────────────── -// Shape: { "/root": ["a", "b/", ".hidden"], "/root/b": ["c"] } +// Shape: { "/root": ["a.txt", "sub/"], "/root/sub": ["b.txt", "deeper/"], +// "/root/sub/deeper": ["c.txt"], "/empty": [] } const fs = { "/root": [{ name: "a.txt", isFile: true, isDirectory: false }, { name: "sub", isFile: false, isDirectory: true }], @@ -18,17 +26,46 @@ const fs = { "/empty": [], }; -globalThis.Deno = globalThis.Deno || {}; -globalThis.Deno.readDirSync = (path) => { - const entries = fs[path]; - if (!entries) throw new Error(`stub: no such dir ${path}`); - return entries; +const fsStub = { + readdirSync: (path) => { + const entries = fs[path]; + if (!entries) { + const err = new Error(`stub: no such dir ${path}`); + err.code = "ENOENT"; + throw err; + } + return entries.map((e) => ({ + name: e.name, + isFile: () => e.isFile, + isDirectory: () => e.isDirectory, + })); + }, + mkdirSync: () => {}, + writeFileSync: () => {}, + rmSync: () => {}, + readFileSync: () => new Uint8Array(), + statSync: (path) => { + const err = new Error(`stub: no such path ${path}`); + err.code = "ENOENT"; + throw err; + }, +}; + +const realGetBuiltinModule = process.getBuiltinModule?.bind(process); +process.getBuiltinModule = (name) => { + if (name === "node:fs") return fsStub; + if (!realGetBuiltinModule) { + throw new Error(`host builtin unavailable: ${name}`); + } + return realGetBuiltinModule(name); }; // args / exit stubs — exit captures the code instead of terminating. +// `args` lowers to process.argv.slice(2), so the leading entries are dropped. +const realArgv = process.argv; +process.argv = ["node", "deno_scripting.harness.mjs", "alpha", "beta", "gamma"]; let lastExit = null; -globalThis.Deno.args = ["alpha", "beta", "gamma"]; -globalThis.Deno.exit = (code) => { lastExit = code; return code; }; +process.exit = (code) => { lastExit = code; }; // Capture stderr writes from consoleError. const stderrLog = []; @@ -71,4 +108,5 @@ exit_with(2); assert.equal(lastExit, 2, "Deno.exit lowered correctly"); console.error = origError; +process.argv = realArgv; console.log("deno_scripting.harness.mjs OK"); diff --git a/tests/codegen-deno/deno_scripting_part2.harness.mjs b/tests/codegen-deno/deno_scripting_part2.harness.mjs index 11e24245..63545653 100644 --- a/tests/codegen-deno/deno_scripting_part2.harness.mjs +++ b/tests/codegen-deno/deno_scripting_part2.harness.mjs @@ -1,7 +1,12 @@ // SPDX-License-Identifier: MPL-2.0 // campaign #239 STEP 3 part 2 — Node-ESM harness for the second wave -// of Deno-scripting externs (stat predicates, byte accessors, module URL) -// plus the `let _ = X` wildcard-binding codegen fix. +// of Deno-era scripting externs (stat predicates, byte accessors, module URL) +// plus the `let _ = X` wildcard-binding codegen fix, exercised through the +// Bun-ESM backend. +// +// The Bun prelude reaches the filesystem through process.getBuiltinModule(), +// so the in-memory FS is installed at that seam — a `globalThis.Deno` stub +// would never be consulted. import assert from "node:assert/strict"; @@ -12,24 +17,38 @@ const files = { "/empty.bin": { kind: "file", bytes: new Uint8Array() }, }; -globalThis.Deno = globalThis.Deno || {}; -globalThis.Deno.statSync = (path) => { - const e = files[path]; - if (!e) { - const err = new Error(`stub: no such path ${path}`); - err.code = "ENOENT"; - throw err; - } - return { - size: e.kind === "file" ? e.bytes.length : 0, - isFile: e.kind === "file", - isDirectory: e.kind === "dir", - }; +const fsStub = { + statSync: (path) => { + const e = files[path]; + if (!e) { + const err = new Error(`stub: no such path ${path}`); + err.code = "ENOENT"; + throw err; + } + return { + size: e.kind === "file" ? e.bytes.length : 0, + isFile: () => e.kind === "file", + isDirectory: () => e.kind === "dir", + }; + }, + readFileSync: (path) => { + const e = files[path]; + if (!e || e.kind !== "file") { + const err = new Error(`stub: not a file ${path}`); + err.code = "ENOENT"; + throw err; + } + return e.bytes; + }, }; -globalThis.Deno.readFileSync = (path) => { - const e = files[path]; - if (!e || e.kind !== "file") throw new Error(`stub: not a file ${path}`); - return e.bytes; + +const realGetBuiltinModule = process.getBuiltinModule?.bind(process); +process.getBuiltinModule = (name) => { + if (name === "node:fs") return fsStub; + if (!realGetBuiltinModule) { + throw new Error(`host builtin unavailable: ${name}`); + } + return realGetBuiltinModule(name); }; const { diff --git a/tests/codegen/test_dom_pilot_startup_error.mjs b/tests/codegen/test_dom_pilot_startup_error.mjs index 608bed5e..3eff577a 100644 --- a/tests/codegen/test_dom_pilot_startup_error.mjs +++ b/tests/codegen/test_dom_pilot_startup_error.mjs @@ -4,9 +4,11 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; const buf = await readFile('./tests/codegen/dom_pilot_startup_error.wasm'); -const mod = new WebAssembly.Module(buf); -const inst = (await WebAssembly.instantiate(mod, { +// BufferSource overload -> { module, instance }. (Passing a WebAssembly.Module +// instead resolves to the Instance itself; `.instance` would be undefined. +// See tools/check-wasm-harness-idioms.mjs.) +const { instance: inst } = await WebAssembly.instantiate(buf, { wasi_snapshot_preview1: { fd_write: () => 0 }, -})).instance; +}); assert.equal(inst.exports.main(), 0, 'dom pilot startupError compiled and ran'); console.log('test_dom_pilot_startup_error.mjs OK'); diff --git a/tests/codegen/test_dom_pilot_surface.mjs b/tests/codegen/test_dom_pilot_surface.mjs index cd5ce49f..c427d8e8 100644 --- a/tests/codegen/test_dom_pilot_surface.mjs +++ b/tests/codegen/test_dom_pilot_surface.mjs @@ -4,9 +4,11 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; const buf = await readFile('./tests/codegen/dom_pilot_surface.wasm'); -const mod = new WebAssembly.Module(buf); -const inst = (await WebAssembly.instantiate(mod, { +// BufferSource overload -> { module, instance }. (Passing a WebAssembly.Module +// instead resolves to the Instance itself; `.instance` would be undefined. +// See tools/check-wasm-harness-idioms.mjs.) +const { instance: inst } = await WebAssembly.instantiate(buf, { wasi_snapshot_preview1: { fd_write: () => 0 }, -})).instance; +}); assert.equal(inst.exports.main(), 0, 'dom pilot surface compiled and ran'); console.log('test_dom_pilot_surface.mjs OK'); diff --git a/tests/codegen/test_wasi_fs_combo.mjs b/tests/codegen/test_wasi_fs_combo.mjs index 432097b0..337d113c 100644 --- a/tests/codegen/test_wasi_fs_combo.mjs +++ b/tests/codegen/test_wasi_fs_combo.mjs @@ -51,7 +51,12 @@ const imports = { }, }; -inst = (await WebAssembly.instantiate(mod, imports)).instance; +// `mod` is a WebAssembly.Module (needed above for Module.imports), so the +// Module overload applies and resolves to the Instance directly — this is +// NOT the `{ module, instance }` BufferSource shape. +// See tools/check-wasm-harness-idioms.mjs. +inst = await WebAssembly.instantiate(mod, imports); +assert.ok(inst instanceof WebAssembly.Instance, 'instantiate returned an Instance'); const result = inst.exports.main(); assert.equal(typeof result, 'number', 'combo ran without trap'); console.log('test_wasi_fs_combo.mjs OK'); diff --git a/tools/check-wasm-harness-idioms.mjs b/tools/check-wasm-harness-idioms.mjs new file mode 100755 index 00000000..14a3edde --- /dev/null +++ b/tools/check-wasm-harness-idioms.mjs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MPL-2.0 +// Guard: the two WebAssembly.instantiate overloads are not interchangeable. +// +// WebAssembly.instantiate(BufferSource, imports) -> { module, instance } +// WebAssembly.instantiate(Module, imports) -> Instance +// +// So `.instance` (or `const { instance } = ...`) is correct ONLY for the +// BufferSource form. Applied to the Module form it is `undefined`, which is +// how tests/codegen/test_dom_pilot_*.mjs and test_wasi_fs_combo.mjs crashed +// with `TypeError: Cannot read properties of undefined (reading 'exports')`. +// +// Usage: node tools/check-wasm-harness-idioms.mjs [root] (root defaults to .) +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +const ROOT = process.argv[2] ?? '.'; +const MODULE_DECL = + /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+WebAssembly\.Module\s*\(/g; + +function* walkMjs(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const p = join(dir, e.name); + if (e.isDirectory()) yield* walkMjs(p); + else if (e.isFile() && e.name.endsWith('.mjs')) yield p; + } +} + +function matchingParen(src, openIdx) { + let depth = 0; + for (let i = openIdx; i < src.length; i++) { + const c = src[i]; + if (c === '(') depth++; + else if (c === ')') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +const lineOf = (src, idx) => src.slice(0, idx).split('\n').length; + +const problems = []; +const scanned = { files: 0, withModule: 0 }; + +for (const file of walkMjs(join(ROOT, 'tests'))) { + scanned.files++; + const src = readFileSync(file, 'utf8'); + const moduleVars = new Set(); + for (const m of src.matchAll(MODULE_DECL)) moduleVars.add(m[1]); + if (moduleVars.size === 0) continue; + scanned.withModule++; + const rel = relative(ROOT, file).split(sep).join('/'); + + for (const v of moduleVars) { + const callRe = new RegExp( + `WebAssembly\\.instantiate\\(\\s*${v.replace(/\$/g, '\\$')}\\b`, + 'g', + ); + for (const m of src.matchAll(callRe)) { + const openIdx = src.indexOf('(', m.index + 'WebAssembly.instantiate'.length); + const closeIdx = matchingParen(src, openIdx); + if (closeIdx < 0) continue; + const after = src.slice(closeIdx + 1, closeIdx + 40); + const line = lineOf(src, m.index); + if (/^\s*\)?\s*\.\s*instance\b/.test(after)) { + problems.push( + `${rel}:${line}: WebAssembly.instantiate(${v}, …) is the Module overload ` + + `(resolves to an Instance), so a trailing \`.instance\` is undefined`, + ); + continue; + } + // Reverse case: destructuring `{ instance }` out of the Module overload. + const stmtStart = Math.max( + src.lastIndexOf(';', m.index), + src.lastIndexOf('\n', m.index), + 0, + ); + const head = src.slice(stmtStart, m.index); + if (/\{\s*instance\s*(?::\s*[\w$]+\s*)?\}\s*=\s*(?:await\s*)?$/.test(head)) { + problems.push( + `${rel}:${line}: \`const { instance } = await WebAssembly.instantiate(${v}, …)\` ` + + `destructures the BufferSource result shape, not the Module one`, + ); + } + } + } +} + +if (problems.length > 0) { + console.error('WASM harness instantiation-idiom gate: FAILED\n'); + for (const p of problems) console.error(' ' + p); + console.error( + `\n${problems.length} problem(s) in ${scanned.withModule} module-creating harness(es).` + + '\nFix: with a WebAssembly.Module argument, use the returned value directly;' + + '\n with a BufferSource argument, destructure `{ instance }`.' + + '\nSee tools/check-wasm-harness-idioms.mjs for the full explanation.', + ); + process.exit(1); +} + +console.log( + `WASM harness instantiation-idiom gate: OK ` + + `(${scanned.files} harnesses scanned, ${scanned.withModule} create a WebAssembly.Module)`, +); diff --git a/tools/check-wasm-harness-idioms.sh b/tools/check-wasm-harness-idioms.sh new file mode 100755 index 00000000..ae4255b0 --- /dev/null +++ b/tools/check-wasm-harness-idioms.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Gate: tests/**/*.mjs must not mix up the two WebAssembly.instantiate +# overloads. Rationale, the exact failure mode it prevents, and the fix +# recipe live in tools/check-wasm-harness-idioms.mjs. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +exec node "$ROOT_DIR/tools/check-wasm-harness-idioms.mjs" "$ROOT_DIR" diff --git a/tools/ci/diag-probe.sh b/tools/ci/diag-probe.sh new file mode 100755 index 00000000..070f3827 --- /dev/null +++ b/tools/ci/diag-probe.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# TEMPORARY diagnostics bridge (PR-only; deleted before merge). +# +# Why this exists: this repo's Actions job logs are served from +# productionresultssa1.blob.core.windows.net, which is unreachable from some +# sandboxes. GitHub *annotations* are reachable through api.github.com, so +# this republishes the interesting parts of a failing `dune runtest` — plus +# two probes — as annotations, readable without the Actions log UI. +set -uo pipefail + +python3 - <<'PY' +import os, pathlib, re, subprocess, urllib.parse + +def annotate(title, text, limit=60000): + msg = urllib.parse.quote(text[:limit], safe="") + print(f"::error title={title}::{msg}", flush=True) + +def summary(title, text): + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + return + with open(path, "a") as fh: + fh.write(f"\n### {title}\n\n```\n{text}\n```\n") + +def run(argv, timeout=300): + try: + r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) + return r.returncode, (r.stdout + r.stderr).strip() + except subprocess.TimeoutExpired: + return 124, "TIMEOUT" + +# ── 1. the dune runtest failure ──────────────────────────────────────────── +log = pathlib.Path("runtest.log") +if log.exists(): + lines = log.read_text(errors="replace").splitlines() + keep = [l for l in lines + if ("[FAIL]" in l or "FAIL " in l or "Error" in l or "error:" in l + or "Assert" in l or "expected" in l)] + body = ("== lines matching FAIL/Error/Assert/expected ==\n" + + "\n".join(keep[:80]) + + "\n\n== tail (120 lines) ==\n" + + "\n".join(lines[-120:])) + annotate("diag-runtest", body) +else: + annotate("diag-runtest", "runtest.log was not produced") + +# ── 1b. the whole masked cascade, in one shot ───────────────────────────── +# `dune runtest` failed first, so every later step in the build job was +# skipped and its state was unknown. Now that the tests pass, surface the +# whole remaining chain at once instead of one failure per CI cycle. +CASCADE = [ + ("codegen WASM", ["bash", "tools/run_codegen_wasm_tests.sh"]), + ("codegen Bun-ESM (codegen-deno corpus)", ["bash", "tools/run_codegen_deno_tests.sh"]), + ("native Bun-ESM", ["bash", "tools/run_codegen_bun_tests.sh"]), + ("face transformers", ["bash", "tools/run_face_transformer_tests.sh"]), + ("no-extension-ts", ["bash", "tools/check-no-extension-ts.sh"]), +] +BAD_LINE = re.compile( + r"(::error::|AssertionError|TypeError|ReferenceError|SyntaxError" + r"|\b[Ee]rror\b|FAIL|FAILED|✗|\bpanic\b|No such file|denied|not allowed)" +) + + +def digest(text, budget=700): + """Dense, capped summary of one cascade step. + + GitHub truncates check-run annotations at ~4096 bytes, so a raw tail of + each step's output silently loses every step after the first oversized + one (compiler chatter is verbose). Report only the interesting lines. + """ + lines = [l.rstrip() for l in text.splitlines() if l.strip()] + picked, seen = [], set() + for l in lines: + if not BAD_LINE.search(l): + continue + key = re.sub(r"\d+", "#", l)[:70] + if key in seen: + continue + seen.add(key) + picked.append(l) + if len(picked) == 5: + break + # The runner's own failure roll-call (names, not prose). + for l in lines: + if (l.startswith(" - ") or l.startswith("✗")) and l not in picked: + picked.append(l) + if len(picked) >= 8: + break + for l in lines[-2:]: + if l not in picked: + picked.append(l) + body = "\n".join(" ! " + l[:160] for l in picked) + return body[:budget] if body else " (no error-like lines; rc shown above)" + + +out = [] +for label, argv in CASCADE: + rc, text = run(argv, timeout=900) + n = len(text.splitlines()) + head = f"===== {label}: rc={rc} ({n} lines) =====" + out.append(head if rc == 0 else head + "\n" + digest(text)) +body = "\n\n".join(out) +annotate("diag-cascade", body) +summary("diag: masked cascade", body) + +# ── 2. parser probe: which construct does the #644 test need? ───────────── +VARIANTS = { + "v1-exact-test-source": """module EmptyArm; +enum Opt { SomeV(Int), NoneV } +pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => { return v; } + NoneV => {} + } + return 0; +} +""", + "v2-plus-semicolon-after-match": """module EmptyArm; +enum Opt { SomeV(Int), NoneV } +pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => { return v; } + NoneV => {} + }; + return 0; +} +""", + "v3-match-as-final-expr": """module EmptyArm; +enum Opt { SomeV(Int), NoneV } +pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => { return v; } + NoneV => {} + } +} +""", + "v4-empty-block-arm-only-final": """module EmptyArm; +enum Opt { Som(Int), Non } +pub fn f(o: Opt) -> Int { + match o { + Non => {} + } +} +""", + "v5-nonempty-block-arm-only-final": """module EmptyArm; +enum Opt { Som(Int), Non } +pub fn f(o: Opt) -> Int { + match o { + Som(v) => { return v; } + } +} +""", + "v6-empty-block-empty-body": """module EmptyArm; +pub fn f() -> Int { + {} +} +""", + "v7-two-empty-block-arms-final": """module EmptyArm; +enum Opt { Som(Int), Non } +pub fn f(o: Opt) -> Int { + match o { + Som(v) => {} + Non => {} + } +} +""", + # Isolation pair for the `;`-after-`match` question: v8 is the issue's + # own "contrast" case (non-empty arm) still missing the `;`; v11 moves the + # empty arm off the last position. If v8 fails like v1, the empty arm is + # irrelevant and the missing statement terminator is the whole cause. + "v8-issue-contrast-no-semicolon": """module EmptyArm; +enum Opt { SomeV(Int), NoneV } +pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => { return v; } + NoneV => { return 0; } + } + return 0; +} +""", + "v11-empty-arm-first-no-semicolon": """module EmptyArm; +enum Opt { SomeV(Int), NoneV } +pub fn f(o: Opt) -> Int { + match o { + NoneV => {} + SomeV(v) => { return v; } + } + return 0; +} +""", + "v12-expr-arms-no-semicolon": """module EmptyArm; +enum Opt { SomeV(Int), NoneV } +pub fn f(o: Opt) -> Int { + match o { + SomeV(v) => v + NoneV => 0 + } + return 0; +} +""", +} + +out = ["parser probe: `affinescript parse` on variants of the #644 test source"] +probe_dir = pathlib.Path("/tmp/parse-probe") +probe_dir.mkdir(parents=True, exist_ok=True) +for name, src in VARIANTS.items(): + p = probe_dir / f"{name}.affine" + p.write_text(src) + for label, extra in (("canonical", []), ("face-js", ["--face", "js"])): + rc, text = run(["opam", "exec", "--", "dune", "exec", "affinescript", + "--", "parse"] + extra + [str(p)]) + first = " | ".join(text.splitlines()[:3]) if text else "(silent)" + out.append(f"{name} [{label}] rc={rc}: {first}") + +body = "\n".join(out) +annotate("diag-parser", body) +summary("diag: parser probe", body) + +# ── 3. on-ramp example consumer (compile + run) ─────────────────────────── +example = pathlib.Path("examples/consumers/extension-boundary") +if example.exists(): + rc, text = run(["bash", str(example / "build.sh")]) + body = f"build.sh rc={rc}\n---\n{text[-6000:]}" +else: + body = "examples/consumers/extension-boundary missing" +annotate("diag-example", body) +summary("diag: on-ramp example", body) + +# ── 4. downstream probe: blocky-writer's sources (issue #771) ───────────── +probe = pathlib.Path("/tmp/probe") +probe.mkdir(parents=True, exist_ok=True) +clone = subprocess.run( + ["git", "clone", "--depth", "1", "--quiet", + "https://github.com/hyperpolymath/blocky-writer", "/tmp/probe/bw"], + capture_output=True, text=True) +out = [] +if clone.returncode != 0: + out.append("clone failed: " + clone.stderr[-400:]) +else: + src = pathlib.Path("/tmp/probe/bw/src") + files = sorted(str(p) for p in src.rglob("*.affine")) + out.append(f"downstream .affine files: {len(files)}") + for f in files: + rc, text = run(["opam", "exec", "--", "dune", "exec", "affinescript", + "--", "check", f]) + first = " | ".join(text.splitlines()[:2]) if text else "(silent)" + out.append(f"{f} rc={rc}: {first}") +body = "\n".join(out) +annotate("diag-downstream", body) +summary("diag: downstream probe", body) +print("[diag] done") +PY diff --git a/tools/run_codegen_bun_tests.sh b/tools/run_codegen_bun_tests.sh index 3393f8f5..5e5aa36a 100755 --- a/tools/run_codegen_bun_tests.sh +++ b/tools/run_codegen_bun_tests.sh @@ -1,7 +1,11 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # Issue #734 — native Bun-ESM backend acceptance runner. -set -euo pipefail +# +# Fail-late by design: every check runs and is reported, so one red check +# cannot hide the state of the checks behind it. Failures are echoed as +# `::error::` lines so they survive into CI annotations. +set -uo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TEST_DIR="$ROOT_DIR/tests/codegen-bun" @@ -14,48 +18,88 @@ else COMPILE_CMD=(dune exec affinescript -- compile) fi -command -v bun >/dev/null 2>&1 || { - echo "error: Bun is required for the Bun-ESM acceptance tests" >&2 +if ! command -v bun >/dev/null 2>&1; then + echo "::error::Bun is required for the Bun-ESM acceptance tests" >&2 exit 1 +fi + +failures=() +fail() { + failures+=("$1") + echo "::error::$1" >&2 } src="$TEST_DIR/host_profile.affine" out="${src%.affine}.bun.js" -"${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm -if grep -qi 'deno' "$out"; then - echo "error: legacy runtime reference emitted in $(basename "$out")" >&2 - exit 1 + +# 1. Compile, then scan the artefact for legacy runtime references. +compiled=1 +if ! "${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm; then + compiled=0 + fail "compile failed for $(basename "$src")" +elif grep -qi 'deno' "$out"; then + fail "legacy runtime reference emitted in $(basename "$out")" + grep -in 'deno' "$out" | head -4 | sed 's/^/ /' >&2 +fi + +if [ "$compiled" = 1 ]; then + # 2. The emitted module must be syntactically valid Bun/JS. + if ! bun --check "$out"; then + fail "bun --check rejected $(basename "$out")" + fi + + # 3. Emission must be byte-for-byte reproducible. + second="$TEST_DIR/reproducibility.bun.js" + if "${COMPILE_CMD[@]}" "$src" -o "$second" --bun-esm; then + if ! cmp "$out" "$second"; then + fail "Bun-ESM emission is not reproducible ($(basename "$out") vs $(basename "$second"))" + fi + else + fail "reproducibility recompile failed for $(basename "$src")" + fi fi -bun --check "$out" -second="$TEST_DIR/reproducibility.bun.js" -"${COMPILE_CMD[@]}" "$src" -o "$second" --bun-esm -cmp "$out" "$second" +# 4. The retired --deno-esm flag must be rejected with its explanation. removed_log="$TEST_DIR/deno-removed.log" -if "${COMPILE_CMD[@]}" "$src" -o "$out" --deno-esm \ - >"$removed_log" 2>&1; then - echo "error: retired --deno-esm compiled successfully" >&2 - exit 1 +if "${COMPILE_CMD[@]}" "$src" -o "$out" --deno-esm >"$removed_log" 2>&1; then + fail "retired --deno-esm compiled successfully" +elif ! grep -q 'Deno-ESM was removed' "$removed_log"; then + fail "retired --deno-esm rejection lacks 'Deno-ESM was removed'" fi -grep -q 'Deno-ESM was removed' "$removed_log" +# 5. The retired .deno.js output extension must be rejected as E0826. removed_json="$TEST_DIR/deno-removed.json" -if "${COMPILE_CMD[@]}" "$src" -o "${out%.bun.js}.deno.js" --json \ - >"$removed_json" 2>&1; then - echo "error: retired .deno.js output compiled successfully" >&2 - exit 1 +if "${COMPILE_CMD[@]}" "$src" -o "${out%.bun.js}.deno.js" --json >"$removed_json" 2>&1; then + fail "retired .deno.js output compiled successfully" +else + grep -q '"code":"E0826"' "$removed_json" || + fail "retired .deno.js rejection lacks error code E0826" + grep -q '"success":false' "$removed_json" || + fail "retired .deno.js rejection lacks success:false" fi -grep -q '"code":"E0826"' "$removed_json" -grep -q '"success":false' "$removed_json" +# 6. Native Bun harnesses. +harness_total=0 for js in "$TEST_DIR"/*.harness.mjs; do - (cd "$TEST_DIR" && AFFINESCRIPT_BUN_PROBE=estate bun "$(basename "$js")" alpha beta) + name="$(basename "$js")" + harness_total=$((harness_total + 1)) + if ! (cd "$TEST_DIR" && AFFINESCRIPT_BUN_PROBE=estate bun "$name" alpha beta); then + fail "bun harness failed: $name" + fi done +# 7. Unsupported host operations must not compile. if "${COMPILE_CMD[@]}" "$TEST_DIR/unsupported_host.affine" \ -o "$TEST_DIR/unsupported_host.bun.js" --bun-esm; then - echo "error: unsupported Bun host operation compiled successfully" >&2 + fail "unsupported Bun host operation compiled successfully" +fi + +if [ "${#failures[@]}" -gt 0 ]; then + echo "" + echo "✗ ${#failures[@]} native Bun-ESM check(s) failed:" + printf ' - %s\n' "${failures[@]}" exit 1 fi -echo "All native Bun-ESM tests passed." +echo "" +echo "All native Bun-ESM tests passed ($harness_total harnesses)." diff --git a/tools/run_codegen_deno_tests.sh b/tools/run_codegen_deno_tests.sh index 1e16f01e..ae769c9b 100755 --- a/tools/run_codegen_deno_tests.sh +++ b/tools/run_codegen_deno_tests.sh @@ -4,10 +4,15 @@ # # Directory name `tests/codegen-deno/` is historical. Every fixture is # compiled with `--bun-esm` to FILE.bun.js. Harnesses still run under -# `node` in CI (Node 20 is provisioned; these fixtures do not need the -# Bun binary — they mock host objects). Native Bun acceptance lives in -# tools/run_codegen_bun_tests.sh. -set -euo pipefail +# `node` in CI (Node 20 is provisioned; fixtures that need a host stub must +# stub the *Bun* host surface — process.getBuiltinModule / process.argv / +# process.exit — because that is what the emitted prelude actually reads; +# a `globalThis.Deno` stub is inert under this backend). +# +# Deliberately NOT `set -e`: failures are collected so one broken harness +# cannot hide every harness after it (the same masking bug that hid 33 +# tests/codegen harnesses; see tools/run_codegen_wasm_tests.sh). +set -uo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TEST_DIR="$ROOT_DIR/tests/codegen-deno" @@ -22,17 +27,43 @@ fi echo "Using compiler: ${COMPILE_CMD[*]}" +compile_failures=() for src in "$TEST_DIR"/*.affine; do out="${src%.affine}.bun.js" echo "Compiling $(basename "$src") -> $(basename "$out")" - "${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm + if ! "${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm; then + compile_failures+=("$(basename "$src")") + fi done +if [ "${#compile_failures[@]}" -gt 0 ]; then + echo "" + echo "::error::${#compile_failures[@]} Bun-ESM fixture(s) failed to compile" + printf ' - %s\n' "${compile_failures[@]}" + exit 1 +fi + echo "" echo "Running ESM harnesses (node, Bun-ESM artefacts)" +harness_total=0 +harness_failures=() for js in "$TEST_DIR"/*.harness.mjs; do - echo "node $(basename "$js")" - (cd "$TEST_DIR" && node "$(basename "$js")") + name="$(basename "$js")" + harness_total=$((harness_total + 1)) + echo "node $name" + if ! (cd "$TEST_DIR" && node "$name"); then + echo "::error file=tests/codegen-deno/$name::ESM harness failed" + harness_failures+=("$name") + fi done -echo "All codegen Bun-ESM (legacy codegen-deno corpus) tests passed." +if [ "${#harness_failures[@]}" -gt 0 ]; then + echo "" + echo "::error::${#harness_failures[@]} of $harness_total Bun-ESM harness(es) failed" + echo "Failed harnesses (full output above):" + printf ' - %s\n' "${harness_failures[@]}" + exit 1 +fi + +echo "" +echo "All codegen Bun-ESM (legacy codegen-deno corpus) tests passed ($harness_total harnesses)." diff --git a/tools/run_codegen_wasm_tests.sh b/tools/run_codegen_wasm_tests.sh index bf5afbc5..f40116f3 100755 --- a/tools/run_codegen_wasm_tests.sh +++ b/tools/run_codegen_wasm_tests.sh @@ -1,5 +1,15 @@ #!/usr/bin/env bash -set -euo pipefail +# SPDX-License-Identifier: MPL-2.0 +# Compile every tests/codegen/*.affine fixture to wasm, then run every +# tests/codegen/*.mjs harness against it. +# +# Deliberately NOT `set -e`: this runner collects failures and reports all of +# them. It used to abort at the first failing harness, which meant that from +# the moment tests/codegen/test_dom_pilot_startup_error.mjs landed, every +# harness sorting after it (33 of them, up to test_while_loop.mjs) silently +# stopped executing — a single bug masked a whole verification surface. Fail +# loudly, fail late, fix in one pass. +set -uo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TEST_DIR="$ROOT_DIR/tests/codegen" @@ -17,19 +27,44 @@ fi echo "Using compiler: $COMPILER" +compile_failures=() for src in "$TEST_DIR"/*.affine; do base="${src%.affine}" wasm="$base.wasm" echo "Compiling $(basename "$src") -> $(basename "$wasm")" - "${COMPILE_CMD[@]}" "$src" -o "$wasm" + if ! "${COMPILE_CMD[@]}" "$src" -o "$wasm"; then + compile_failures+=("$(basename "$src")") + fi done -echo "" +if [ "${#compile_failures[@]}" -gt 0 ]; then + echo "" + echo "::error::${#compile_failures[@]} fixture(s) failed to compile: ${compile_failures[*]}" + printf ' - %s\n' "${compile_failures[@]}" + exit 1 +fi +echo "" echo "Running JS harnesses" +harness_total=0 +harness_failures=() for js in "$TEST_DIR"/*.mjs; do - echo "node $(basename "$js")" - (cd "$ROOT_DIR" && node "${js#$ROOT_DIR/}") + name="$(basename "$js")" + harness_total=$((harness_total + 1)) + echo "node $name" + if ! (cd "$ROOT_DIR" && node "${js#"$ROOT_DIR"/}"); then + echo "::error file=tests/codegen/$name::JS harness failed" + harness_failures+=("$name") + fi done -echo "All codegen WASM tests passed." +if [ "${#harness_failures[@]}" -gt 0 ]; then + echo "" + echo "::error::${#harness_failures[@]} of $harness_total JS harness(es) failed" + echo "Failed harnesses (full output above):" + printf ' - %s\n' "${harness_failures[@]}" + exit 1 +fi + +echo "" +echo "All codegen WASM tests passed ($harness_total JS harnesses)."