Skip to content

feat: affinescript-tea (TEA in AffineScript), affinescript-router, affinescript-crdt - #778

Merged
hyperpolymath merged 27 commits into
mainfrom
feat/js-tea-ecosystem
Oct 5, 2026
Merged

hyperpolymath merged 27 commits into
mainfrom
feat/js-tea-ecosystem

Conversation

@hyperpolymath

@hyperpolymath hyperpolymath commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

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)

  • Virtual DOM.
    • Html<M>: Text, Element(tag, ns, attrs, kids) for HTML and SVG, and Lazy(key, memo, view), which skips building and diffing an unchanged subtree, like Elm's Html.Lazy.
    • Attr<M>: attributes, DOM properties, styles, keys, and On(event, Event -> Option<M>) decoders.
    • Helpers, plus map_html, map_attr and map_cmd.
  • Reconciler.
    • Attributes are patched in place.
    • Handlers are re-pointed through per-node slots, with one DOM listener per event.
    • Keyed children use minimal moves: surviving nodes on a longest increasing subsequence of their old positions stay put, and the rest are inserted before their right-hand neighbour. The LIS is O(n log n) and written in AffineScript.
    • Duplicate keys are matched once.
    • Unkeyed children patch by position.
  • Commands and subscriptions. Cmd<M> (none/batch/send/run_cmd) and Sub<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.js

The 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, in ci.yml): 10/10 locally

  • Todo reference app: mount, init command, controlled input with Enter, async command, keyed reorder preserving DOM-node identity (with lazy rows), toggle, remove, a subscription that starts and stops, exactly one mounted instance, and no runtime errors.
  • A 200-round randomized keyed-diff property test against a real DOM: order and identity are always preserved, and rotating one item costs exactly one DOM move.
  • The keyed-reorder test fails when keyed diffing is disabled.

Measured in nexia-list (2,793 visible cards of 10k)

  • Pan work per frame went from p95 73 ms to 8.3 ms with lazy cards, the LIS diff and canvas level-of-detail.

Notes

  • Pages import the compiled module and never call main(). The Bun-ESM backend runs main on load, so a second call mounts a second instance. The README says so.
  • The commit "wip(affinescript-tea): lazy subtrees, LIS keyed diff (tests pending)" is complete; its tests (randomized diff, lazy rows) are in the same branch. I left the message as-is rather than rewrite pushed history.
  • I couldn't run the new ci.yml step 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_url and 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 and trailing * captures (decoded), query_param (form decoding), and href.
    • router_host.js is the host carve-out.
    • Tests: 5 unit tests and 6 browser tests (push, replace, Back/Forward including the browser's own, manual hash edit, deep link). Used by nexia-list for deep links.
  • affinescript-crdt: state-based CRDTs in pure AffineScript.
    • A Lamport clock and stamps, an LWW register, an LWW map with tombstones, an observed-remove set and a PN-counter.
    • Tests: 7 property tests. Three replicas run 300 random operations each with gossip and converge in all merge orders, and the merge laws hold on random states. Breaking map_merge makes them fail.
  • Both are registered in docs/ECOSYSTEM.adoc, and both test suites run in ci.yml.
  • The router depends on the transitive-flattening fix in fix(compiler): fixes found building a real multi-module TEA app on Bun-ESM #777.

🤖 Generated with Claude Code

hyperpolymath and others added 4 commits October 5, 2026 18:39
…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>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 10c24d72-f400-45c3-bbdf-b714c197d047
📥 Commits

Reviewing files that changed from the base of the PR and between b23819c and b8fbb48.

⛔ Files ignored due to path filters (2)
  • affinescript-router/e2e/bun.lock is excluded by !**/*.lock
  • affinescript-tea/e2e/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • .github/workflows/ci.yml
  • .gitignore
  • affinescript-crdt/README.adoc
  • affinescript-crdt/src/Crdt.affine
  • affinescript-crdt/test/crdt.test.js
  • affinescript-router/README.adoc
  • affinescript-router/e2e/nav.test.js
  • affinescript-router/e2e/package.json
  • affinescript-router/examples/nav/Nav.affine
  • affinescript-router/src/Router.affine
  • affinescript-router/src/router_host.js
  • affinescript-router/test/router.test.js
  • affinescript-tea/README.adoc
  • affinescript-tea/e2e/package.json
  • affinescript-tea/e2e/todo.test.js
  • affinescript-tea/examples/todo/Todo.affine
  • affinescript-tea/src/Tea.affine
  • affinescript-tea/src/tea_host.js
  • docs/ECOSYSTEM.adoc
  • lib/ast.ml
  • lib/codegen_deno.ml
  • lib/module_loader.ml
  • lib/resolve.ml
  • lib/typecheck.ml
  • test/test_deno_builtins_consistency.ml
  • test/test_generic_enum_kinds.ml
  • test/test_main.ml
  • test/test_module_search_path.ml
  • test/test_records_and_imports.ml
  • tests/codegen-deno/ChainLib.affine
  • tests/codegen-deno/DirLib.affine
  • tests/codegen-deno/chain_import.affine
  • tests/codegen-deno/chain_import.harness.mjs
  • tests/codegen-deno/const_before_enum.affine
  • tests/codegen-deno/const_before_enum.harness.mjs
  • tests/codegen-deno/imported_ctor.affine
  • tests/codegen-deno/imported_ctor.harness.mjs
  • tests/codegen-deno/int_div_loop.affine
  • tests/codegen-deno/int_div_loop.harness.mjs
  • tests/codegen-deno/method_lambda.affine
  • tests/codegen-deno/method_lambda.harness.mjs
  • tests/codegen-deno/tea_shape.affine
  • tests/codegen-deno/tea_shape.harness.mjs
  • tests/codegen-deno/thunk_math.affine
  • tests/codegen-deno/thunk_math.harness.mjs
  • tools/run_codegen_deno_tests.sh
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread affinescript-tea/e2e/todo.test.js Fixed
hyperpolymath and others added 4 commits October 5, 2026 19:09
… 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
hyperpolymath force-pushed the feat/js-tea-ecosystem branch from 215effa to e822cef Compare October 5, 2026 18:09
hyperpolymath and others added 7 commits October 5, 2026 19:14
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>
…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>
…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>
hyperpolymath and others added 5 commits October 5, 2026 19:46
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>
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 hyperpolymath changed the title feat(affinescript-tea): The Elm Architecture written in AffineScript feat: affinescript-tea (TEA in AffineScript), affinescript-router, affinescript-crdt Oct 5, 2026
@hyperpolymath
hyperpolymath changed the base branch from fix/bun-esm-generic-kinds-tea-shape to main October 5, 2026 19:00
@hyperpolymath

Copy link
Copy Markdown
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>
hyperpolymath and others added 6 commits October 5, 2026 20:15
… 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>
…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>
…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>
@sonarqubecloud

sonarqubecloud Bot commented Oct 5, 2026

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Add Carrot credits or activate Agent usage billing to use Autopilot

@hyperpolymath
hyperpolymath merged commit 35a6794 into main Oct 5, 2026
16 of 27 checks passed
@hyperpolymath
hyperpolymath deleted the feat/js-tea-ecosystem branch October 5, 2026 20:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants