Repository navigation
stdlib has never been AOT-compiled as a coherent unit (angle-applied types + flat-namespace collisions; string.affine is the first symptom) #128
Description
Summary
The AffineScript stdlib has never been compiled through the static
resolve → typecheck → codegen pipeline as a coherent unit. It is
interpreter-era code (dynamically typed, flat global namespace) that the
AOT path has never exercised end-to-end. stdlib/string.affine is the
first file that surfaced this (originally reported here), but the two
root causes are stdlib-wide, not string-specific.
This issue is rescoped from "stdlib/string.affine does not compile" to
the actual underlying defect.
Reproduce (original symptom)
affinescript compile stdlib/string.affine -o /tmp/x.deno.js --deno-esm
# => affinescript: Resolution error (fails at resolve, before typecheck/codegen)
Root causes (both stdlib-wide)
1. Angle-bracket applied types are the stdlib's surface, not a string.affine quirk
The static front-end accepts <...> for type parameters but
rejects it for applied types in value/return position
(fn char_at(...) -> Option<Char>). That asymmetry is the actual
defect. It is pervasive, not local:
| file | <…> usages |
|---|---|
option.affine |
36 |
result.affine |
24 |
collections.affine |
22 |
testing.affine |
18 |
prelude.affine |
15 |
io.affine |
10 |
string.affine |
5 |
Rewriting only string.affine to bracket form yields a file that still
cannot use option.affine / prelude.affine because those don't
resolve either — a single-file smoke check would pass while the stdlib
remains incoherent.
2. No module/import story; flat-namespace collisions
No stdlib file uses use / import / open. prelude.affine and
option.affine both define is_some / is_none / unwrap /
unwrap_or / map / filter / contains, with conflicting
signatures (prelude.map(arr, f) vs option.map(f, opt)). Commit
b895374 ("seed Some/None/Ok/Err builtins so it resolves standalone")
is a band-aid that entrenches the interpreter-era global namespace
rather than resolving it; the original "add prelude/option imports or
qualify Option::Some" suggestion assumes import machinery the stdlib
has never been run through under AOT.
Decision required (resolve at source)
Pick the applied-type surface once and apply it uniformly. Per-file
rewrites are the worst of both options.
- (Preferred) Accept
Option<Char>in applied position in the
resolver. Cheap, consistent with the<...>parameter syntax
already accepted, makes the entire existing stdlib compile
unchanged. - Bracket-only. Migrate all of
stdlib/*.affine+ docs + examples
in one sweep and remove the angle alias so it cannot rot back.
Scope of work
- Decide + implement the applied-type surface uniformly (above).
- Resolve the duplicate-definition / namespace question: does the
stdlib have modules, or stay flat-and-deduplicated? Remove the
conflictingpreludevsoptionoverlaps either way. - Add a stdlib-wide AOT compile-smoke gate: every
stdlib/*.affinethroughresolve → typecheck → codegen, plus at
least one test thatuses several stdlib modules together, so the
AOT path can't silently rot again. (Not a single-file check.)
Impact / scope
Non-blocking and separate from #122. The Deno-ESM consumer migration
(ubicity#30 → ubicity#31/#33) deliberately does not depend on the stdlib
(self-contained extern fn primitives; ubicity green 44/44). This is
the separately-tracked structural loose end noted during the #122 work,
now scoped to its true root cause.
Refs #122, #123, #125, #127. Supersedes the original
string.affine-only framing of this issue.
Tracking (sub-issues)
This issue is now the tracking epic. Work items, with dependencies:
- resolver: accept angle-bracket applied types (Option<Char>) in value/return position #131 — resolver: accept angle-bracket applied types in value/return position (core fix; unblocks stdlib: make every stdlib/*.affine compile through resolve→typecheck→codegen #135, cleanup: remove b895374 seeded Some/None/Ok/Err builtins band-aid #138)
- stdlib: decide & document the namespace/module model (modules vs flat-deduplicated) #132 — decide & document stdlib namespace/module model (decision; blocks stdlib: remove prelude/option/result duplicate & conflicting definitions #133, test: multi-module integration test using several stdlib modules together #137)
- stdlib: remove prelude/option/result duplicate & conflicting definitions #133 — remove prelude/option/result duplicate & conflicting defs (needs stdlib: decide & document the namespace/module model (modules vs flat-deduplicated) #132; blocks stdlib: make every stdlib/*.affine compile through resolve→typecheck→codegen #135)
- prelude: unwrap/unwrap_result fall through (return nothing) on None/Err #134 — fix prelude
unwrap/unwrap_resultfall-through (independent) - stdlib: make every stdlib/*.affine compile through resolve→typecheck→codegen #135 — every
stdlib/*.affinecompiles via resolve→typecheck→codegen (needs resolver: accept angle-bracket applied types (Option<Char>) in value/return position #131, stdlib: remove prelude/option/result duplicate & conflicting definitions #133; blocks ci: add stdlib-wide AOT compile-smoke gate #136) - ci: add stdlib-wide AOT compile-smoke gate #136 — stdlib-wide AOT compile-smoke gate in CI (needs stdlib: make every stdlib/*.affine compile through resolve→typecheck→codegen #135)
- test: multi-module integration test using several stdlib modules together #137 — multi-module integration test (needs stdlib: decide & document the namespace/module model (modules vs flat-deduplicated) #132, stdlib: remove prelude/option/result duplicate & conflicting definitions #133, ci: add stdlib-wide AOT compile-smoke gate #136)
- cleanup: remove b895374 seeded Some/None/Ok/Err builtins band-aid #138 — remove
b895374seeded-builtins band-aid (needs resolver: accept angle-bracket applied types (Option<Char>) in value/return position #131, stdlib: decide & document the namespace/module model (modules vs flat-deduplicated) #132, stdlib: remove prelude/option/result duplicate & conflicting definitions #133)
Suggested order: #131 → #132 → #133 → #135 → #136 → #137 → #138, with #134 doable anytime in parallel.
Downstream requirements (consumer-driven, from the ECHIDNA migration)
Folded in from the ReScript→AffineScript migration backlog. These are
requirements this epic must satisfy, not separate work streams — the
Client.res port (echidna [migration-roadmap.rescript-to-affinescript])
is blocked until the stdlib exposes these primitives. They extend the
stdlib surface the AOT pipeline (#135) must compile, beyond the existing
angle-bracket/namespace work:
| echidna issue | primitive needed | maps to stdlib |
|---|---|---|
| hyperpolymath/echidna#61 | Http/Fetch (GET/POST, RequestInit, Response ok/status/json) |
new stdlib/http.affine (no module today; effects.affine has no HTTP) |
| hyperpolymath/echidna#62 | Async[T]/Promise[T] (pure/bind/catch or await) |
concrete Async[T] over effects.affine (currently no documented type) |
| hyperpolymath/echidna#63 | Json (opaque type + Decode/Encode combinators, stringify) |
new stdlib/json.affine |
| hyperpolymath/echidna#64 | Dict/Map (empty/get/insert/set) |
collections.affine (listed TODO; depends on #133/#135) |
Implication for scope: #135's "every stdlib/*.affine compiles" must
include the new http.affine and json.affine plus a real
collections.affine, and #137's multi-module integration test should
exercise the Client.res-shaped path (Http + Async + Json + Dict together)
as the migration's validation target. The four echidna issues stay open as
the consumer-side trackers and close when the migration lands, not when this
epic does.
Refs hyperpolymath/echidna#61, hyperpolymath/echidna#62, hyperpolymath/echidna#63, hyperpolymath/echidna#64.
Activity
fix(stdlib): single-ownership dedup of prelude/option/result (#133) (#…
feat(parser): fn(params) => expr lambdas + #135 triage punch list (#153)
feat(parser): slice/range index e[a:b] via slice builtin (#135 slice …
feat(parser): bare effect decl + ADR-008 -> T / E effect row (#135 sl…
fix(stdlib): rename try->attempt, ref->make_ref (#135 slice 6) (#156)
docs(stdlib): refresh #135 triage after slices 1-3,6 (STDLIB-AOT-TRIA…
fix(stdlib): rename `as` param/var -> `xs` in collections (#135 slice…
feat(parser): trait method default bodies (left-factored) (#135 slice…
fix(typecheck): instantiate generic fn type params; prelude `mut` (#135…
fix(stdlib): module visibility + imports per ADR-011 (#135 slice 8) (#…
docs(stdlib): refresh #135 triage (r2) after slices 5,6b,7,8 (#165)
fix(resolve): two-pass — forward-declare top-level names (#135 slice …
fix(stdlib): rename `total` var -> `tot` (#135 slice 4) (#168)
fix(stdlib): Iterator::collect uses [] not nonexistent Vec (#135 slic…
docs: session handoff note for #135 stdlib AOT (next-Claude brief) (#171
hyperpolymath commentedon May 18, 2026
io.affine / math.affine resolution — root cause (from a parallel STAGE-A analysis)
Handing this off with the dig already done so it isn't repeated.
math.affine — only genuinely missing symbol is
trunc: it's a real runtime builtin (interp.ml:674, in thekernel_sublangbuiltin set) but absent from the resolver builtin-seed list inresolve.ml create_contextwhile its siblingsfloor/ceil/roundare present. One-line fixdef "trunc";. Already PR'd: #170 (verified: math clears the resolve stage; its remainingInt/FloatTypeMismatch is a separate pre-existing math typecheck bug).pow/to_float/fractare defined locally in math.affine — not builtins, not the problem.io.affine — deeper, and not a missing-declaration issue:
use module::{...}(not flat-namespace, not prelude-hub). Sosplit(used byread_lines) must bepubinstring.affineand io needsuse string::{ split };.import_specific_itemsalready enforcespub(VisibilityErrorotherwise).use string::{ split };(+pub fn split, +module string;/module io;since string/io currently have no module decl while every working importer e.g.collectionsdoes), io still fails — and the undefined symbol islen, notsplit.useimport is resolved via the module-resolution path (resolve_and_typecheck_module/resolve_imports_with_loaderinresolve.ml), and that path builds a resolution context without the builtin seeding thatcreate_contextapplies (def "len",def "split", …). So any stdlib file that gains an import loses visibility of all builtins →UndefinedVariable "len".Fix direction: ensure the module-resolution context is seeded with the same builtins as
create_context(factor thedef/defcseeding so both the top-level and theresolve_and_typecheck_modulecontexts share it). That single fix should unblock io and the general "stdlib file +use" case under ADR-011.Refs: ADR-011 module-model ruling (explicit
use); PR #170 (trunc); PR #167 (Slice 10 effects/Refkind, independent).fix(resolve): seed `trunc` builtin in resolver context (#135) (#170)
fix(typecheck): infer Ref<T> kind as arity-1 ctor (#135 slice 10) (#167)
fix: cross-module imports for stdlib (ADR-011) + module builtin seedi…
hyperpolymath commentedon May 18, 2026
Follow-up:
Typecheck.register_builtinshas drifted from the resolver builtin-seed listSurfaced while landing the ADR-011 cross-module fix (#172).
resolve.ml'sseed_builtins(thedef/defclist) andtypecheck.ml'sregister_builtins(thebind_var/bind_schemelist) are not in sync. The resolver seeds the full builtin family;register_builtinsis missing the file-I/O / process / env family that io.affine uses at typecheck:read_file,write_file,append_file,file_exists,is_directory,getenv,getcwd,exit,time_now— allUndefinedVariable-clean now butUnbound variable: …at typecheck.Also note
read_lineis currently bound asUnit -> Stringbut io.affine pattern-matches it asResult<String,String>(match read_line() { Ok.. Err.. }) — reconciling it risks regressing any file using it as plainString, so this needs a deliberate call, not a drive-by.Recommended fix: a single reconciliation pass giving each missing builtin a correct scheme (Result/Option/Bool/Never via the existing
res/opt/ty_bool/ty_neverhelpers), plus a decision onread_line's true type. This is what blocks io.affine from going fully green; resolution is already fixed (#172).fix(typecheck): reconcile register_builtins with resolver seed list (#…
hyperpolymath commentedon May 18, 2026
Next io layer: cross-module imported-type threading
After #172 (resolution) + #173 (register_builtins reconciliation), io.affine's only remaining failure is:
split(imported from string.affine viause string::{ split, join }) resolves fine but isUnbound variable: splitat io's typecheck stage. The imported symbols' resolution is threaded, but their type schemes are not propagated into the importer'scheck_program(?import_types/name_types).resolve_program_with_loaderreturns atype_ctxwhosename_typesshould carry the imported schemes (seelookup_source_schemenote re var_types vs name_types keying); the gap is threading those into the top-level typecheck of the importing file. Fixing this is the last step to io fully green and is the general fix for any ADR-011 cross-module value import at typecheck.fix(main): thread imported type schemes into check_program (#135) (#174)
hyperpolymath commentedon May 18, 2026
io.affine status after the import-path infra fixes (#172/#173/#174)
io.affine surfaced a 5-layer stack of systemic bugs, all now fixed + merged: (1) #172 imported-module resolve context lacked builtin seeding; (2) #172 imported-module typecheck path skipped register_builtins; (3) #172 eprint/eprintln unbound; (4) #173 register_builtins drift (file-I/O/env/time/float-math family + read_line→Result); (5) #174 imported type-schemes not threaded into check_program.
io now gets to a genuine type-level error:
Unify.TypeMismatch (String, Array[String])—splitreturns[String]; the mismatch is at the imported-scheme/usage level (likelyOk(split(content,"\n"))inread_linesvsResult<[String],String>, or the importedsplitscheme losing its[String]return). This is io's own remaining typecheck issue, no longer infrastructural. Tracking as part of slice-12 (deeper typecheck) alongside collections/result/testing/traits which now also fail deeper post-#174. Recommend addressing the tractable single-error files (math, etc.) before this.fix(stdlib): math.affine typecheck + borrow (#135 slice 12) (#184)
docs: session handoff (b) — #128/#135 stdlib AOT at 13/19 (#135) (#185)
fix(typecheck): zero-arg thunk calls, tuple-destructuring let, Runtim…
fix(typecheck): instantiate constructor schemes per use-site in patte…
fix(typecheck): generalize parametric enum ctor schemes via shared pa…
fix(stdlib): declare reassigned locals in io.affine as let mut (#128) (…
fix(typecheck): lenient deref so *ref-mode-param type-checks (#128) (#…
fix(stdlib): collections.affine prelude arg-order + let mut (#128) (#191
fix(stdlib): option.affine — last STAGE-A file; 19/19 (#128) (#192)
fix(resolve): remove b895374 Some/None/Ok/Err seed; resolve imports r…
test(stdlib): AOT smoke gate (#136) + multi-module integration (#137) (…
hyperpolymath commentedon May 18, 2026
STAGE A complete — stdlib AOT coherence achieved.
All 19
stdlib/*.affinecompile resolve→typecheck→borrow→Deno-ESM codegen, standalone and as a coherent multi-module set. Landed this session via PRs #186, #187, #188, #189, #190, #191, #192, #193, #194 (each 233→253/253, zero regression).Closers done at root cause (not patched):
Some/None/Ok/Errflat-seed band-aid; root fix:resolve_and_typecheck_modulenow recursively resolves an imported module's ownuseimports through the loader, and type-checks viacheck_program(forward-declaration) so imported modules behave identically to standalone.RuntimeErrorkept (genuine interpreter exception, no module home).test_stdlib_aot.ml: stdlib-wide AOT smoke gate, one case per file, wired intodune runtest(CI).Coherence gaps found & fixed along the way: parametric enum ctor generalization on the import path; lenient deref for
ref-mode params; prelude arg-order / name-conflict drift in collections & option;&mut→mutparam syntax; assignment-arm vs Unit-arm typing;option/collectionsexposed zeropubAPI.Leaving #128 open for joint close at your discretion. STAGE A unblocks STAGE B (effect system, #59→#103).
hyperpolymath commentedon May 18, 2026
Post-merge verification (independent re-check, 2026-05-18)
Confirming the STAGE-A completion comment above (04:42) now that the dust has settled:
MERGEDtomain: fix(typecheck): zero-arg thunk calls, tuple-destructuring let, RuntimeError catch (#128) #186 fix(typecheck): instantiate constructor schemes per use-site in patterns (#128) #187 fix(typecheck): generalize parametric enum ctor schemes (import path) (#128) #188 fix(stdlib): io.affine reassigned locals need let mut (#128) #189 fix(typecheck): lenient deref for ref-mode params (#128) #190 fix(stdlib): collections.affine prelude arg-order + let mut (#128) #191 fix(stdlib): option.affine — final STAGE-A file, stdlib 19/19 (#128) #192 fix(resolve): remove b895374 seed band-aid; recursive cross-module resolution (#138) #193 test(stdlib): AOT smoke gate (#136) + multi-module integration (#137) #194.test/test_stdlib_aot.ml, resolve→typecheck→borrow→Deno-ESM codegen, one case/file) runs indune runteston every push — STAGE-A coherence is now regression-protected onmain, not just a point-in-time pass.lib/resolve.ml), not patched.No remaining STAGE-A work. Refs #128 — leaving this open for your joint-close per the requirements-target protocol; it's ready whenever you want to close it. STAGE-B (effect system, #59→#103) is now unblocked and proceeding.
hyperpolymath commentedon May 18, 2026
Joint-close approved by @hyperpolymath (2026-05-18).
STAGE-A complete and regression-protected on
main(stdlib 19/19 AOT-coherent; 253/253; CI smoke-gate via #194). Closers #138/#136/#137 landed at root cause. Closing per the requirements-target joint-close protocol. STAGE-B (effect system, #59→#103) is underway.docs(history): mark STDLIB-AOT-TRIAGE closed — #128/#131-138 delivered (
hyperpolymath commentedon Jun 1, 2026
Status update — AS stdlib codegen pattern now established (2026-06-01) 📌
For ticket coordinators / anyone tracking this gap:
lib/codegen_deno.ml. Tests live intests/codegen-deno/*.What this means for the gap tracked in this ticket:
stdlib/*.affine→ wire indeno_builtinstable + prelude → smoke-test intests/codegen-deno/*.🤖 Cross-estate AS-status sweep on 2026-06-01.
refactor(descriptiles): migrate .machine_readable/6a2 → descriptiles …