Skip to content

Commit 123462e

Browse files
committed
Merge branch 'main' into claude/stdlib-04d-io-tests
2 parents dbddeeb + 1938289 commit 123462e

27 files changed

Lines changed: 1118 additions & 152 deletions

.github/workflows/ci.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,22 @@ jobs:
183183
# ReScript source. Picks the existing res-to-affine test fixture
184184
# so any drift in the pinned commit's syntactic surface area
185185
# surfaces here rather than at walker-rule writing time.
186+
#
187+
# NOTE: tree-sitter-cli >= 0.25 repurposed `--paths` to mean "a
188+
# file listing input source paths", not "a directory containing
189+
# a grammar". Grammar lookup is now driven by the current
190+
# working directory (the CLI walks up looking for grammar.js /
191+
# src/parser.c), so we cd into the vendored grammar tree and
192+
# pass an absolute path to the fixture. Without this, the step
193+
# fails with `Failed to read paths file ... Is a directory`.
186194
run: |
187195
shopt -s nullglob
188196
fixtures=(tools/res-to-affine/test/fixtures/*.res)
189197
if [ ${#fixtures[@]} -eq 0 ]; then
190198
echo "no .res fixtures to smoke-parse; skipping"
191199
exit 0
192200
fi
193-
tree-sitter parse \
194-
--quiet \
195-
"${fixtures[0]}" \
196-
--paths tools/vendor/tree-sitter-rescript \
197-
> /dev/null
201+
fixture_abs="$(realpath "${fixtures[0]}")"
202+
( cd tools/vendor/tree-sitter-rescript \
203+
&& tree-sitter parse --quiet "${fixture_abs}" > /dev/null )
198204
echo "smoke-parsed: ${fixtures[0]}"

.machine_readable/6a2/META.a2ml

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -988,8 +988,20 @@ Staged plan (ledger INT-03; each row = one gated PR):
988988
- S4: native `wasi:clocks` + environment + argv via preview2 (wasmtime
989989
host-testable), replacing the preview1 shims behind the component path.
990990
- S5: `wasi:filesystem` (open/read/write/close) — unblocks INT-06.
991-
- S6: `wasi:sockets`; then flip the default wasm target to component
992-
and demote the preview1 stdout path to a named legacy target.
991+
- S6a (WIT export lifting, DONE): codegen emits a `_start : () -> ()`
992+
shim that calls `main` and drops its i32 result whenever a
993+
parameter-less `fn main()` is present (`lib/codegen.ml`);
994+
`tools/componentize.sh --command` wraps with the fetch-pinned
995+
preview1->preview2 *command* adapter (wasmtime v44.0.1, sha256
996+
8ff2ea78...) producing a component that exports `wasi:cli/run@0.2.x`
997+
per `wit/affinescript.wit`. End-to-end gate:
998+
`tests/componentize/command_smoke.sh` proves `wasmtime run` invokes
999+
the component (exit 0), the ownership section survives, and the
1000+
lift is asserted. Purely additive: reactor consumers + game-loop
1001+
hooks are byte-unchanged.
1002+
- S6b: `wasi:sockets`.
1003+
- S6c: flip the default wasm target to component and demote the
1004+
preview1 stdout path to a named legacy target.
9931005
"""
9941006
consequences = """
9951007
- End-state is one-way (the component model becomes the canonical wasm
@@ -1058,23 +1070,34 @@ Thread per-call-site effect rows from typecheck to codegen via a
10581070
`let`-RHS call's effect row ⊇ `Async`” via a table lookup, replacing
10591071
`is_async_prim_call`/`async_primitives`.
10601072
- *Fallback / safety.* If the table has no entry for a site (e.g. a
1061-
pre-typecheck embedder path, or a synthesised node), codegen falls
1062-
back to the structural recogniser. The hardcoded set is retired only
1063-
once the table path is proven (final slice); over-conservative
1064-
fallback is always sound (= today's behaviour).
1073+
pre-typecheck embedder path, or a synthesised node), or the consumer
1074+
detects a producer/consumer count-mismatch, [Effect_sites.is_async_call]
1075+
returns false ⇒ the CPS transform simply does not fire for that call.
1076+
The pre-S4 plan retained the structural recogniser as the fallback
1077+
*until the table path was proven*; S4 (PR #278) retired the
1078+
hardcoded `async_primitives` set, so the steady-state fallback is
1079+
"no transform" — over-conservative, always sound.
10651080

10661081
Staged (ledger #234; each a gated PR, full `dune test --force` +
10671082
wasm e2e):
1068-
- S1 (this): ADR-016 + plan. No code change.
1069-
- S2: `lib/effect_sites.ml` shared numbering + typecheck builds &
1070-
returns the side-table. NO codegen behaviour change (table built,
1071-
unused) — pure, gate-neutral.
1072-
- S3: pipeline threads the table; codegen boundary predicate switches
1073-
to the table with structural fallback; new e2e proving a
1074-
*user-defined* `Async` fn triggers the transform (the payoff). All
1075-
existing http_cps_* / http_response_reader stay green.
1076-
- S4: retire the hardcoded `async_primitives` set (fallback remains
1077-
for table-miss only); doc truthing.
1083+
- S1 (ADR-016 + plan, PR #270): DONE — no code change.
1084+
- S2a (`lib/effect_sites.ml` shared numbering, PR #275): DONE — pure,
1085+
gate-neutral.
1086+
- S2b (typecheck builds & returns the side-table, PR #276): DONE — no
1087+
codegen behaviour change yet.
1088+
- S3 (pipeline threads the table; codegen boundary predicate switches
1089+
to the table with structural fallback; new e2e
1090+
`tests/codegen/effect_async_boundary.affine` proving a *user-defined*
1091+
`Async` fn triggers the transform — PR #277): DONE. All existing
1092+
http_cps_* / http_response_reader stay green.
1093+
- S4 (retire the hardcoded `async_primitives` set; boundary is now
1094+
exactly `Effect_sites.is_async_call`; fallback remains for table-
1095+
empty / count-mismatch only — PR #278): DONE.
1096+
1097+
*Delivery status:* CLOSED 2026-05-19 end-to-end. Issue #234 closed
1098+
completed (`hyperpolymath/affinescript#234`). The structural name set
1099+
no longer exists; the boundary is single-sourced from the typecheck
1100+
effect side-table via the shared `Effect_sites` ordinal.
10781101
"""
10791102
consequences = """
10801103
- Generalises to user-defined `Async` functions; new async primitives

docs/ECOSYSTEM.adoc

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ made real; #228/ADR-014 module-qualified paths (estate port unblocker). |
8080
|*E* |typed-wasm convergence hardening (the transition runway): the
8181
AffineScript↔typed-wasm contract widened from L7+L10 toward full
8282
L1–6/L13–16 emitted-wasm enforcement; estate-wide re-validation (#235);
83-
effect-threaded async-boundary recogniser (#234); the #225/#160 convergence
83+
effect-threaded async-boundary recogniser (#234, DELIVERED 2026-05-19 —
84+
ADR-016, S1..S4 / PRs #270/#275/#276/#277/#278); the #225/#160 convergence
8485
ABI matured to "shared with Ephapax". |*planned* |Begins when D's substrate
8586
(INT-01..04, CORE-01) is closed; ends at a stable, multi-producer
8687
typed-wasm convergence ABI.
@@ -205,25 +206,44 @@ INT-01 ↔ INT-02. S1; unblocks INT-05/08/11. The `affinescript-dom-loader`
205206
satellite shell is downstream.
206207
|INT-03 |WASI preview2 / host I/O beyond stdout |#180 |S1, ADR-015
207208
ACCEPTED (owner-chosen full WASM Component-Model re-target). Staged
208-
S1..S6; legacy preview1 stdout path is the default until S6
209-
(reversible-in-progress). S2 toolchain provisioned (#251). **S3
209+
S1..S6; legacy preview1 stdout path remains the default until S6c
210+
(reversible-in-progress). S2 toolchain provisioned (#251). S3
210211
DONE: `tools/componentize.sh` wraps the emitted core module into a
211212
valid WASI-0.2 component via the fetch-pinned preview1→preview2
212213
*reactor* adapter (wasmtime v44.0.1, sha256-verified); the
213214
`affinescript.ownership` section is proven to SURVIVE the wrap;
214215
`tests/componentize/smoke.sh` gates it (SKIP-safe without the
215-
toolchain). Codegen UNCHANGED — core preview1 stays the default
216-
(reversible). S4a (clock) + S4b (env_count, arg_count) DONE:
216+
toolchain). S4a (clock) + S4b (env_count, arg_count) DONE:
217217
builtins lower to on-demand `wasi_snapshot_preview1.*` imports
218218
(Effect_sites pre-scan, canonical-order indexing through
219219
`ctx.wasi_func_indices`; zero impact on units that don't use them;
220220
verified with a multi-import combo regression). Component path
221+
bridges to `wasi:clocks`/`wasi:cli`. **S6a (WIT export lifting)
222+
DONE: `lib/codegen.ml` auto-emits a `_start : () -> ()` shim that
223+
calls `main` and drops its i32 result whenever a parameter-less
224+
`fn main()` is present. `tools/componentize.sh --command` wraps
225+
with the fetch-pinned preview1→preview2 *command* adapter
226+
(wasmtime v44.0.1, sha256 8ff2ea78…) — the resulting component
227+
exports `wasi:cli/run@0.2.x` per `wit/affinescript.wit`. End-to-end
228+
smoke `tests/componentize/command_smoke.sh` proves the lift, the
229+
ownership section survives, and `wasmtime run <component>` exits
230+
0 on real-host invoke. Purely additive: reactor consumers, the
231+
`__indirect_function_table` export, and game-loop hooks are
232+
byte-unchanged.** String accessors (env_at/arg_at) gated on a
233+
byte-level wasm-IR extension (I32Load8U/I32Store8 absent today)
234+
— tracked as the next slice before/with S5 filesystem. Remaining
235+
sub-slices: S5 (native clocks/env/argv via preview2), S6b
236+
(`wasi:sockets`), S6c (flip the default wasm target from preview1
237+
→ component). WIT world of record: `wit/affinescript.wit`
221238
bridges to `wasi:clocks`/`wasi:cli`. Real-host main-invoke deferred
222-
to S6 (WIT export-lifting / wasi:cli/run command shape). String
223-
accessors (env_at/arg_at) gated on a byte-level wasm-IR extension
224-
(I32Load8U/I32Store8 absent today) — tracked as the next slice
225-
before/with S5 filesystem.** WIT world of
226-
record: `wit/affinescript.wit`
239+
to S6 (WIT export-lifting / wasi:cli/run command shape).
240+
**S5 string accessors (env_at/arg_at) DONE: the wasm IR gained
241+
the byte-level load/store family (I32Load8U/I32Store8 + the full
242+
WebAssembly 1.0 §5.4.6 row, opcodes 0x2C..0x35 / 0x3A..0x3E);
243+
accessors lower to on-demand `environ_get`/`args_get` paired with
244+
the existing `*_sizes_get` import (dedup keeps each WASI import
245+
once even when both `*_count` and `*_at` are used).** WIT world
246+
of record: `wit/affinescript.wit`
227247
|INT-04 |Publish compiler + runtime to JSR (then npm) |#181 |runtime
228248
packaging READY (affine-js + affinescript-tea JSR dry-run green;
229249
manual-only `publish-jsr.yml`; docs/PACKAGING.adoc). INT-01 dep

docs/TECH-DEBT.adoc

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,20 @@ captured linears. |S1 |pt1 #240 + pt2 return-escape + `&mut` surface +
127127
pt3 Slice A NLL last-use DONE (Refs #177); residual = Slices B–D
128128
(flow-sensitive escape, origin variables, quantity integration) —
129129
issue #177
130-
|CORE-02 |Effect-handler dispatch on WasmGC (currently `UnsupportedFeature`;
131-
EH proposal or CPS). The #225 CPS line closes the async slice. |S2 |partial
132-
(#225 line CLOSED PR1..PR3d+PR4; #234 generalises the recogniser —
133-
ADR-016 ACCEPTED, side-table typecheck→codegen, staged S1..S4; S1 done)
130+
|CORE-02 |Effect-handler dispatch on WasmGC (was `UnsupportedFeature`;
131+
the chosen path is CPS over the EH proposal). The #225 CPS line closed
132+
the async slice; #234 generalised the boundary recogniser from a
133+
hardcoded name set to "any call whose effect row ⊇ `Async`". |S2
134+
|*CLOSED 2026-05-19* — #225 line CLOSED PR1..PR3d+PR4; #234 ADR-016
135+
delivered end-to-end (S1 #270 ADR; S2a #275 shared call-site
136+
numbering `lib/effect_sites.ml`; S2b #276 typecheck builds the
137+
ordinal→effect side-table; S3 #277 pipeline threads it + codegen
138+
boundary predicate switched + user-defined-`Async` e2e
139+
`tests/codegen/effect_async_boundary.affine`; S4 #278 hardcoded
140+
`async_primitives` set retired — boundary is now exactly
141+
`Effect_sites.is_async_call`). Issue #234 closed completed. Table-miss
142+
fallback (`Effect_sites.is_async_call` = false ⇒ no transform) remains
143+
as the sound table-empty / count-mismatch path.
134144
|CORE-03 |ADR-014: module-qualified type/effect path. Decision settled
135145
(both `.`/`::` accepted, `Pkg::Type` canonical, `.`→`::` for free via the
136146
`::`-fold). Was the estate's dominant parse blocker (525/1177 .affine).
@@ -183,6 +193,19 @@ round-trips + Deno codegen __cell-shape assertion) |S3 |DONE
183193
(`lib/interp.ml`, `lib/js_codegen.ml`, `lib/codegen_deno.ml`); sibling
184194
`panic` is wired and `error` should mirror it (divergent `T`) |S3 |open
185195
— issue #329
196+
|STDLIB-04c |`string_concat` extern — *REMOVED* (Refs #330): the
197+
canonical surface is the `++` operator (lowered in `Value.binop_string`
198+
and `__as_concat`); the extern was declared but never wired in any
199+
backend nor called from any stdlib/test/fixture — dead surface. The
200+
contract is now operator-only and one-source-of-truth. |S3 |DONE
201+
2026-05-24 (Refs #330)
202+
|STDLIB-04b |Throws extern `error<T>` — *LANDED* (Refs #329): mirrors
203+
`panic`'s divergent semantics with a polymorphic return (`<T>` unifies
204+
with the call-site expectation, unobservable at runtime). Wired in
205+
interp (`RuntimeError`), Deno codegen (`throw new Error`), resolve
206+
seed, and typecheck as a scheme (`poly1` so each call instantiates a
207+
fresh tyvar). 3 hermetic tests in "E2E STDLIB-04b error #329". |S3
208+
|DONE 2026-05-24 (Refs #329)
186209
|STDLIB-04c |`string_concat` extern — no direct wiring found; `++`
187210
operator independently lowered. Decide: remove (operator-only) or wire
188211
to mirror `++` |S3 |open — issue #330
@@ -195,9 +218,13 @@ prelude. `read_line` is interactive and intentionally out of scope (the
195218
TEA-bridge tests already exercise the redirected-stdin path). No impl
196219
change; was test-debt, not impl-debt. |S3 |DONE 2026-05-24 (Refs #331)
197220
|STDLIB-04e |Pure externs (`int_to_string`/`string_to_int`/
198-
`string_length`) — real + tested (`lib/interp.ml:615-633`,
199-
`lib/codegen_deno.ml:263-272`); bookkeeping to mark DONE |S3 |open —
200-
issue #332
221+
`string_length`) — *DONE* (Refs #332): `int_to_string` +
222+
`string_length` were already real-wired; `string_to_int` was unwired
223+
dead surface (re-audit caught the gap the initial sweep missed — same
224+
trap STDLIB-04c removed). Now the typed-alias of `parse_int` in interp
225+
+ Deno codegen + resolve/typecheck seeds. 4 hermetic tests in "E2E
226+
STDLIB-04e Pure #332" lock the round-trip semantics. |S3 |DONE
227+
2026-05-24 (Refs #332)
201228
|===
202229

203230
== Section D — INT (ecosystem integration)
@@ -230,16 +257,33 @@ follow-up
230257
multi-ns import object, ownership accessor); 14 unit tests via pinned
231258
`deno task test` + `tests/modules/loader-bridge/` e2e on real
232259
compiler-emitted xmod wasm (closes INT-01↔INT-02). Unblocks INT-05/08/11
233-
|INT-03 |WASI preview2 / host I/O |S1→S4b |#180 ADR-015 (full
260+
|INT-03 |WASI preview2 / host I/O |S1→S6a |#180 ADR-015 (full
234261
Component-Model re-target, S1..S6); S2 toolchain #251 closed;
235-
S3 componentize done; **S4a (clock) + S4b (env_count, arg_count)
262+
S3 componentize done; S4a (clock) + S4b (env_count, arg_count)
236263
DONE — on-demand preview1 imports via Effect_sites pre-scan,
237264
canonical-order indexing through `ctx.wasi_func_indices`; combo
238265
regression proves no collision. String accessors (env_at/arg_at)
239266
gated on byte-level wasm IR (I32Load8U/I32Store8 absent today) —
240-
tracked next slice. Real-host main-invoke = S6 (WIT export
241-
lifting). Next S5
242-
(native clocks/env/argv)**
267+
tracked next slice. **S6a (WIT export lifting) DONE:
268+
`lib/codegen.ml` auto-emits a `_start : () -> ()` shim whenever
269+
a parameter-less `fn main()` is present (additive, no regression
270+
on reactor consumers); `tools/componentize.sh --command` wraps
271+
with the fetch-pinned command adapter (sha256
272+
8ff2ea78… / wasmtime v44.0.1, provisioned alongside the reactor
273+
adapter); `tests/componentize/command_smoke.sh` gates the
274+
contract end-to-end — `wasi:cli/run` lifted, ownership section
275+
survives, `wasmtime run` exits 0. Real-host main-invoke now
276+
works via `wasmtime run <component>`.** Remaining S6
277+
sub-slices: S5 (native clocks/env/argv), S6b (`wasi:sockets`),
278+
S6c (flip the default wasm target from preview1 → component)
279+
regression proves no collision. **S5 (env_at/arg_at) DONE — wasm
280+
IR extended with the byte-level load/store family
281+
(I32Load8U/I32Store8 and siblings); accessors lower to on-demand
282+
`environ_get`/`args_get` imports paired with the existing
283+
`*_sizes_get` (dedup keeps each WASI import exactly once even when
284+
both `*_count` and `*_at` are used in the same unit); guest
285+
allocates a length-prefixed AS string and byte-copies from the
286+
WASI buffer.** Real-host main-invoke = S6 (WIT export lifting)
243287
|INT-04 |Publish to JSR/npm |S2 |#181 packaging READY (dry-run green,
244288
manual workflow); compiler-binary distribution decided = **ADR-019**
245289
(#260, Releases + thin Deno/JSR shim, staged S1..S4) — S1/S2/S3
@@ -292,7 +336,9 @@ Proven + locked (see INT-02)
292336
|CONV-01 |Estate-wide re-validation of the #199/#205 closure ABI (static →
293337
real wasm engine) |S2 |open #235
294338
|CONV-02 |Effect-threaded async-boundary detection (generalise the
295-
structural-conservative recogniser) |S2 |open #234
339+
structural-conservative recogniser) |S2 |*CLOSED 2026-05-19* — #234
340+
DELIVERED end-to-end (ADR-016 S1..S4; PRs #270/#275/#276/#277/#278).
341+
See CORE-02 above for the resolution narrative.
296342
|CONV-03 |#225/#160 convergence ABI matured to "shared with Ephapax" |S1
297343
|partial (PR3a/b/c merged)
298344
|CONV-04 |Widen emitted-wasm enforcement beyond L7+L10 toward L1–6/L13–16 |S2

docs/specs/SETTLED-DECISIONS.adoc

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,12 @@ change; *S2* toolchain provisioning (`wasm-tools`, `wasm-component-ld`,
302302
(codegen still emits core wasm; post-step wraps it via the standard
303303
preview1→preview2 adapter; ownership-section survival asserted; wasmtime
304304
component-run smoke); *S4* native `wasi:clocks`/environment/argv; *S5*
305-
`wasi:filesystem` (unblocks INT-06); *S6* `wasi:sockets`, then flip the
306-
default wasm target to component and demote preview1 to a legacy target.
305+
`wasi:filesystem` (unblocks INT-06); *S6a* (WIT export lifting) codegen
306+
auto-emits a `_start : () -> ()` shim around a parameter-less `fn main()`
307+
so `tools/componentize.sh --command` (preview1→preview2 *command*
308+
adapter) produces a real `wasi:cli/run`-exporting component that any
309+
WASI 0.2 host can invoke; *S6b* `wasi:sockets`; *S6c* flip the default
310+
wasm target to component and demote preview1 to a legacy target.
307311

308312
This decision is settled; do not reopen without amending the ADR. Full
309313
ADR in `.machine_readable/6a2/META.a2ml` (ADR-015); ledger INT-03 in
@@ -329,15 +333,23 @@ deterministic shared call-site numbering (a single traversal in a new
329333
keys cannot drift; no AST shape change). `Typecheck.check_program`
330334
populates `ordinal → effect_row` (declared and inferred) and returns
331335
it; `bin/main.ml` threads it into codegen (source-to-source backends
332-
ignore it); the CPS boundary predicate becomes a table lookup with the
333-
existing structural recogniser as the sound table-miss fallback.
334-
335-
Staged (ledger #234): *S1* this ADR + plan (no code); *S2*
336-
`effect_sites.ml` + typecheck builds/returns the table (built, unused —
337-
gate-neutral); *S3* pipeline threads it, codegen switches to the table
338-
with structural fallback, new e2e proving a user-defined `Async` fn
339-
triggers the transform; *S4* retire the hardcoded set (fallback kept
340-
for table-miss only).
336+
ignore it); the CPS boundary predicate becomes a table lookup. The
337+
pre-S4 plan retained the existing structural recogniser as the
338+
table-miss fallback; S4 (#278) retired the hardcoded set, so the
339+
steady-state miss path is "no transform" — over-conservative, always
340+
sound.
341+
342+
Staged (ledger #234): *S1* this ADR + plan (no code, PR #270); *S2a*
343+
`effect_sites.ml` shared call-site numbering (PR #275); *S2b* typecheck
344+
builds/returns the table (PR #276); *S3* pipeline threads it, codegen
345+
switches to the table with structural fallback, new e2e
346+
`tests/codegen/effect_async_boundary.affine` proving a user-defined
347+
`Async` fn triggers the transform (PR #277); *S4* retire the hardcoded
348+
`async_primitives` name set, the boundary is now exactly
349+
`Effect_sites.is_async_call` — table-empty / count-mismatch fallback
350+
([is_async_call] = false ⇒ no transform) is the sound miss path (PR
351+
#278). *Status:* DELIVERED 2026-05-19 end-to-end; issue #234 closed
352+
completed.
341353

342354
This decision is settled; do not reopen without amending the ADR. Full
343355
ADR in `.machine_readable/6a2/META.a2ml` (ADR-016); ledger #234 /

docs/specs/async-on-wasm-cps.adoc

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,12 @@ of* the verified Thenable-surface primitives.
157157
*DONE* — Deno-ESM (#226) + the typed-wasm CPS line PR1..PR3d all
158158
merged; this PR joint-closes #160 + #225. The ADR-013 delivery plan
159159
is complete; the transparent `fetch/get -> Response` surface is
160-
delivered on both targets. (Two follow-ups, *not* part of this
161-
slice, remain tracked: effect-threaded boundary generalisation
162-
#234; estate #199/#205 re-validation #235.)
160+
delivered on both targets. Follow-ups: the effect-threaded boundary
161+
generalisation (#234, ADR-016) has since landed (S1..S4, PRs
162+
#270/#275/#276/#277/#278, closed 2026-05-19 — the boundary is now
163+
`Effect_sites.is_async_call` from the typecheck side-table, the
164+
hardcoded `async_primitives` name set is retired); estate #199/#205
165+
re-validation #235 remains tracked.
163166

164167
== References
165168

0 commit comments

Comments
 (0)