feat: affinescript-tea (TEA in AffineScript), affinescript-router, affinescript-crdt - #778
Merged
Merged
Conversation
…eiver-first fns
Found while probing the Bun-ESM target for a TEA UI runtime:
- typecheck: `infer_kind` gave every non-builtin type constructor kind
`Type`, so naming a user generic enum applied to arguments in any
signature (`fn mk<M>(x: M) -> Box<M>`, `Html<Msg>`) failed with
"Too many arguments for kind". Record each parametric enum's arity, and
recover the arity of imported enums from the imported value schemes
(the only cross-module type information `check_program` receives).
Over-application is still rejected (planted negative test).
- bun-esm: a qualified payload constructor `Msg::SetName(s)` lowered to a
call on the nullary `({ tag })` object. It now lowers to the emitted
constructor binding.
- bun-esm: every receiver-first fn was folded into an async class method
and its calls rewritten to `(await recv.m(..))`. Struct literals are
plain objects, so chained calls failed and `await` leaked into sync
callers (e.g. a TEA `update`). Fns are now always emitted as plain sync
exports and called directly; the synthesised class remains as an extra
JS-facing surface (ref_fields/class_basic unchanged).
Tests: test/test_generic_enum_kinds.ml (6), tests/codegen-deno/tea_shape
(Bun-ESM harness; 33/33 harnesses pass; dune test main suite 550 OK).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…builtins - typecheck: a zero-parameter lambda `fn() => e` was typed as its bare body type in synth mode and checked against the *whole* `() -> T` arrow in check mode, so a thunk could never be passed to a `() -> T` parameter (inline or by name). It is now `Unit -> T` in both modes — the type an explicit `() -> T` already lowers to, and which the zero-argument call rule already consumes. Wrong body types still fail. - typecheck: `extern type Cell<T>` records its arity like a parametric enum, so host-backed generic types can be named in signatures. - bun-esm: lower the stdlib/math.affine header builtins (float, floor, ceil, round, trunc, sqrt, cbrt, pow_float, trig, exp/log family) to `Number`/`Math.*`; they compiled to calls of undefined JS globals. Tests: parametric extern type case in test_generic_enum_kinds; tests/codegen-deno/thunk_math (34/34 Bun-ESM harnesses); WASM corpus 38/38; dune test main suite 551 OK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Colon-separated directories searched after the current directory and the stdlib, so third-party AffineScript packages (affinescript-tea first) can be imported from outside the importing program's directory. Previously the loader's search_paths was always empty with no way to set it. Test: test/test_module_search_path.ml (absent without the path, found with it; empty entries ignored). dune test main suite 553 OK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Statements other than `let` and expression statements were skipped, so a name used only inside a `for`/`while` body (or an assignment) was never reported free. Import flattening then dropped the private helper it named: a public `create` calling `apply_attr` in a for-loop compiled to `ReferenceError: apply_attr is not defined` on Bun-ESM. The same walker drives closure conversion, which could likewise miss a capture. Also bind every variable of a destructuring `let` and of match-arm patterns (and walk guards), instead of only plain `PatVar` lets. dune test main suite 553 OK; Bun-ESM 34/34; WASM 38/38. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 41 minutes. View limit detailsLimit details: You’ve used the included review currently available. Review configuration: ⚙️ Run configuration
⛔ Files ignored due to path filters (2)
📒 Files selected for processing (46)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… lambdas in methods
Found porting nexia-list's UI to AffineScript (a multi-module TEA app):
- typecheck: `S #{ ..base, f: v }` ignored the spread when typing, so the
result was only the explicit fields and an update could never produce
the struct it started from. Now: a closed base keeps every field, each
explicit field must match the base's type (an update cannot change a
field's type) and new fields extend it; an open or not-yet-known base is
constrained to have the explicit fields and the result is its own type
(extending an open row there failed the row occurs check).
- typecheck/resolve: an imported struct was an opaque name in the
importer, because imports carry only value schemes, so its fields could
not be read. A module now records each monomorphic type definition under
a reserved `name_types` key (NUL-prefixed, disjoint from identifiers),
the import paths copy it for imported type symbols (aliases honoured),
and `check_program` installs it in the importer's type environment.
Local declarations still win.
- bun-esm: a lambda inside a synthesised (async) class method inherited
the async context, emitting `(x) => (await ...)`, which is a SyntaxError
in V8/browsers (Bun's parser accepts it, which hid the bug). Lambda bodies
are now emitted in a non-async context.
Tests: test/test_records_and_imports.ml (7, incl. wrong-field-type and
unknown-imported-field negatives); tests/codegen-deno/method_lambda
(fails with the SyntaxError when the lambda fix is reverted).
dune test main 560 OK; Bun-ESM 35/35; WASM 38/38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A browser TEA runtime for the Bun-ESM target, in AffineScript: - src/Tea.affine: typed virtual DOM (`Html<M>`, `Attr<M>` with attributes, properties, styles, keys and `Event -> Option<M>` decoders; HTML and SVG), a reconciler with keyed child moves (DOM nodes of surviving keys are reused and moved), `Cmd<M>` (none/batch/send/run_cmd) and `Sub<M>` (window events, animation frames, intervals) diffed by key after every update, and `run(selector, init, update, view, subscriptions)` with in-order message processing and frame-batched renders. - src/tea_host.js: the only JavaScript, a host carve-out implementing the primitive externs (DOM ops, handler slots, rAF/timers, cells, key index). - examples/todo: the reference app. - e2e: Playwright (pinned 1.62.1) drives the app in headless Chromium; 8 tests, including keyed reorder preserving node identity (verified to fail with keyed diffing disabled). Wired into ci.yml's build job. Depends on the compiler fixes in #777 (generic enum/extern kinds, thunks, qualified ctors, free-vars, $AFFINESCRIPT_PATH). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iewport size ev_local_x/ev_local_y (pointer relative to the handler's element, e.g. double-click-to-create on a canvas), ev_target_editable (global shortcuts skip keys typed into text fields) and tea_viewport_width/height (viewport culling). Host primitives only; e2e 8/8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Bun-ESM backend runs a module's `main` on load, so a page that also called `main()` mounted a second instance on the same root. The two runtimes then patched one DOM tree against different virtual trees (duplicated subtrees in nexia-list's port; hidden in the Todo example because both instances rendered identical trees). The e2e page and README now only import the app; a test asserts exactly one instance is mounted (9/9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hyperpolymath
force-pushed
the
feat/js-tea-ecosystem
branch
from
October 5, 2026 18:09
215effa to
e822cef
Compare
An enum's constructors reached an importer's flattened (non-Wasm) output
only if some carried *function* constructed them; patterns do not count.
So `use Geometry::{Up}` + `Some(Up)` compiled to a reference to an
undefined `Up` (a ReferenceError at the first keypress in nexia-list's
port). Public enums named in a `use M::{...}` list (by type or by any
constructor), and all public enums for `use M`/globs, are now carried;
enums made only of the preamble's Option/Result constructors never are.
The Bun-ESM corpus runner now puts the corpus on AFFINESCRIPT_PATH so
fixtures can be multi-module. Test: imported_ctor + DirLib (fails with
`ReferenceError: North is not defined` without this change).
dune main 560 OK; Bun-ESM 36/36; WASM 38/38; native Bun 1/1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ape' into feat/js-tea-ecosystem
…efore declaration - bun-esm (#478 follow-up): OCaml evaluates `^` operands right to left, so a `while`/`for` body's tail expression was generated before its statements. The int-tracking for `Int / Int` truncation then saw the tail's assignments (`lo = mid + 1`) before the `let mid = ...` they depend on, forgot `lo` was an Int, and emitted float division: a binary search computed `(lo + hi) / 2 = 2.5`. Block parts are now generated in source order. Fixture int_div_loop fails (1 vs 3) without this. - typecheck: the forward pass bound every function to a fresh, ungeneralised type variable, so a generic extern used before its declaration had its type fixed by the first use and a second instantiation failed (`TypeMismatch (Int, Bool)`). Extern signatures are complete, so their generalised schemes are now registered in the forward pass (after all types). Ordinary generic fns keep the old behaviour (their effects are inferred) — a documented follow-up. dune main 561 OK; Bun-ESM 37/37; WASM 38/38; native Bun 1/1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ape' into feat/js-tea-ecosystem
…rams check_fn_decl restored only parameter bindings after checking a body, so a block-local `let node = ...` overwrote the module-level `node` in name_types, and importers saw the local's type as the module's export (`Expected a function type, got Node` when affinescript-tea's `node` helper was imported after its keyed diff gained a local `node`). The name table is now snapshotted after the function's own recursive binding and restored wholesale after the body. Test: local binding does not shadow export (fails without the fix). dune main 562 OK; Bun-ESM 37/37; WASM 38/38. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ape' into feat/js-tea-ecosystem
Flattening carried an imported module's own declarations but not what that module imported, so a program importing Router.on_url_change (whose body calls Tea.subs) emitted an undefined `subs`. An imported module's imports are now flattened first (with a cycle guard), so the dependency closure reaches across modules. Fixture chain_import -> ChainLib -> DirLib fails with `ReferenceError: name is not defined` without this change. dune main 562 OK; Bun-ESM 38/38; WASM 38/38. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ape' into feat/js-tea-ecosystem
Router.affine (AffineScript): current_url, on_url_change (popstate + hashchange), navigate/replace/back commands that also deliver the new URL (the History API fires nothing for programmatic changes), match_route with :param / trailing * captures, query_param (form decoding), href, parse_url. router_host.js is the host carve-out (Location/History primitives). Tests: test/router.test.js (pure functions, 5); e2e/nav.test.js drives the Nav example in headless Chromium (6: push, replace, back/forward incl. the browser's, manual hash edit, deep link, no errors). Wired into ci.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crdt.affine (pure AffineScript): Lamport Clock/Stamp; Lww<T> register; LwwMap<V> (string keys, timestamped tombstones); OrSet (observed-remove, add wins over a concurrent remove); PnCounter. States are sorted plain values, so merges are linear merge-joins and serialise directly. Property tests (test/crdt.test.js, 7): three replicas x 300 random ops with random gossip converge in all merge orders (map, set, counter); map values equal the highest-stamped write; merge laws on random states; OR-set and PN-counter semantics; merge cost reported. Breaking map_merge's tie rule fails the convergence and law tests. Wired into ci.yml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…finescript-crdt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hyperpolymath
changed the base branch from
fix/bun-esm-generic-kinds-tea-shape
to
main
October 5, 2026 19:00
Owner
Author
|
Closing and reopening to trigger CI after retargeting the base to main (ci.yml runs only for PRs into main). |
The digest-verified Bun release zip installed by scripts/install-bun.sh ships only the `bun` binary, so `bunx` was not found (exit 127) in the TEA browser-test step. Also install the e2e packages with --ignore-scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stricter negative tests Review follow-up (CodeRabbit on #777): - Since qualified constructors lower to their binding (`Msg::Inc` -> `Inc`), a top-level const naming a variant of an enum declared later in the file read the binding in its temporal dead zone (ReferenceError). Enum bindings are now emitted before the source-order pass. Fixture const_before_enum fails with `Cannot access 'Fast' before initialization` without this. - test_records_and_imports: negative tests now require a *type* error (a parse/resolution failure no longer counts), the unknown-imported-field test checks the field is named, and the field-type-change test returns `String` from `r.a` so only validating the update can reject it. dune main 562 OK; Bun-ESM 39/39; WASM 38/38. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Hypatia js_eval (false positive on Playwright's $eval/$$eval in todo.test.js): use locator().evaluate/evaluateAll/count instead; no JS eval exists in the PR. - SonarCloud: modern DOM operations in tea_host.js (before/append/remove/ replaceWith), a lookup table instead of a nested ternary for wheel units, an optional chain; router tests now assert the page label explicitly after waiting for it. TEA e2e 10/10; router e2e 6/6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ape' into feat/js-tea-ecosystem
…ound Review follow-up (CodeRabbit on #777): a module shared by several import paths (a diamond) was re-flattened once per path, which grows exponentially with depth; completed flattened programs are now cached for the duration of one flatten_imports call (the cycle guard is unchanged). The imported-struct negative test now asserts the specific "Field 'z' not found" diagnostic. dune main 562 OK; Bun-ESM 39/39; WASM 38/38. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ape' into feat/js-tea-ecosystem
…s script SonarCloud S6505 read `playwright install` (a browser download) as a package install lacking --ignore-scripts; the download now runs as `bun run browsers` from affinescript-tea/e2e/package.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Contributor
|
Add Carrot credits or activate Agent usage billing to use Autopilot |
hyperpolymath
added a commit
that referenced
this pull request
Oct 5, 2026
…n-ESM (#777) Compiler fixes found while building a real browser application in AffineScript: `affinescript-tea` (#778) and the nexia-list UI port. Each fix has a regression test, and for each one I confirmed the test fails without the fix (a "planted negative"). ## Type checker - **Kinds of user generic enums and extern types.** `fn mk<M>(x: M) -> Box<M>`, `Html<Msg>` and `extern type Cell<T>` all failed with `Too many arguments for kind`. Arities are now recorded, and for imported types they are recovered from the imported schemes. Over-application is still rejected. - **Zero-parameter lambdas.** `fn() => e` is now `Unit -> T` (what `() -> T` lowers to), so thunks can be passed. - **Record update.** `S #{ ..base, f: v }` ignored the spread when typing. It now has update semantics: field types are preserved, new fields extend a closed record, and an open base is constrained instead of extended (which previously failed the row occurs check). - **Imported structs.** An imported struct was an opaque name, so its fields couldn't be read. Each module now records its type definitions under a reserved (NUL-prefixed) `name_types` key that imports carry across. - **Generic externs used before their declaration.** They now get their generalised scheme in the forward pass. Before, the first use fixed the type: `TypeMismatch (Int, Bool)`. - **Scoping.** A function body's `let` bindings leaked into the module's name table, so a local `let node = …` replaced the *exported* `node`. The table is now snapshotted and restored around each body. ## Bun-ESM backend - **Qualified payload constructors.** `Msg::SetName(s)` called a `{ tag }` object. - **Receiver-first functions.** They were turned into `async` class methods and their calls rewritten to `await recv.m(..)`, which broke chained calls on struct literals and leaked `await` into sync code. They're now always plain sync exports; the class remains as an extra JS surface. - **Lambdas inside synthesised methods.** They inherited the async context, producing `(x) => (await …)`. Browsers and V8 reject this; Bun accepts it, which hid the bug. - **Math builtins.** `float`, `floor`, `sqrt`, trig, `exp`/`log` and friends now lower to `Number`/`Math.*`. - **Integer division in loops (#478).** A loop body's tail expression was generated before its statements, because OCaml evaluates `^` operands right to left. The int-tracking then forgot `lo` was an `Int`, so `(lo + hi) / 2` became float division. ## Module loader / AST - **`$AFFINESCRIPT_PATH`.** A package search path (colon-separated), so third-party packages can be imported. - **`find_free_vars`.** It now walks `for`/`while`/assignment statements and pattern binders. Before, flattening dropped private helpers called inside loops (`ReferenceError`), and closure conversion could miss captures. - **Transitive flattening.** An imported module's own imports are now flattened first, with a cycle guard. Before, importing `Router.on_url_change` (whose body calls `Tea.subs`) emitted an undefined `subs`. Fixture: `chain_import` → `ChainLib` → `DirLib`. - **Flattening carries directly imported enums.** `use M::{Up}` followed by `Some(Up)` produced an undefined `Up`. The Bun-ESM corpus runner now puts the corpus directory on the module path, so fixtures can span several modules. ## Tests - New unit test modules: `test_generic_enum_kinds`, `test_module_search_path`, `test_records_and_imports`. - New Bun-ESM fixtures: `tea_shape`, `thunk_math`, `method_lambda`, `imported_ctor` + `DirLib`, `int_div_loop`, `chain_import` + `ChainLib`. - `dune test` main suite: **562 OK**. Bun-ESM corpus: **38/38**. WASM corpus: **38/38**. Native Bun: **1/1**. - `res-to-affine-walker` fails locally only because its tree-sitter grammar isn't built (`parser.c is absent`); that's an environment issue, not this change. ## Known follow-ups (not in this PR) - Generic *structs* still lower their fields without binding the type parameters. - Ordinary (non-extern) generic functions used before their declaration are still monomorphic at those call sites, because their effects are inferred. - A zero-parameter function *declaration* is typed as its bare result while its JS value is a function. - A qualified pattern on an imported enum needs the constructor imported explicitly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Depends on: #777
A browser TEA runtime written in AffineScript, plus a router and a CRDT library built on the same toolchain. The TEA runtime is, compiled with the Bun-ESM backend. It's the runtime the nexia-list UI is now built on (hyperpolymath/nexia-list#112), and it's usable by any AffineScript web app.
affinescript-tea/src/Tea.affine(all logic in AffineScript)Html<M>:Text,Element(tag, ns, attrs, kids)for HTML and SVG, andLazy(key, memo, view), which skips building and diffing an unchanged subtree, like Elm'sHtml.Lazy.Attr<M>: attributes, DOM properties, styles, keys, andOn(event, Event -> Option<M>)decoders.map_html,map_attrandmap_cmd.Cmd<M>(none/batch/send/run_cmd) andSub<M>(on_window/on_frame/every/subs), diffed by key after every update.run.run(selector, init, update, view, subscriptions): in-order message processing, re-entrant dispatches queued, renders batched to the next animation frame.src/tea_host.jsThe only JavaScript: a host carve-out implementing the primitive externs (DOM operations, handler slots, rAF and timers, cells, key index, array helpers). It makes no decisions.
Tests (
e2e/, Playwright 1.62.1, headless Chromium, inci.yml): 10/10 locallyMeasured in nexia-list (2,793 visible cards of 10k)
lazycards, the LIS diff and canvas level-of-detail.Notes
main(). The Bun-ESM backend runsmainon load, so a second call mounts a second instance. The README says so.ci.ymlstep locally (Playwright install). This PR's CI run is its first execution.Also in this PR: two more ecosystem packages
affinescript-router: URL routing for TEA apps.current_urlandon_url_change(popstate+hashchange).navigate/replace/backcommands that also deliver the new URL; the History API fires nothing for programmatic changes.match_routewith:paramand trailing*captures (decoded),query_param(form decoding), andhref.router_host.jsis the host carve-out.affinescript-crdt: state-based CRDTs in pure AffineScript.map_mergemakes them fail.docs/ECOSYSTEM.adoc, and both test suites run inci.yml.🤖 Generated with Claude Code