Skip to content

stdlib has never been AOT-compiled as a coherent unit (angle-applied types + flat-namespace collisions; string.affine is the first symptom) #128

Closed
8 of 8 issues completed

Description

@hyperpolymath

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

  1. Decide + implement the applied-type surface uniformly (above).
  2. Resolve the duplicate-definition / namespace question: does the
    stdlib have modules, or stay flat-and-deduplicated? Remove the
    conflicting prelude vs option overlaps either way.
  3. Add a stdlib-wide AOT compile-smoke gate: every
    stdlib/*.affine through resolve → typecheck → codegen, plus at
    least one test that uses 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:

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

  1. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    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 the kernel_sublang builtin set) but absent from the resolver builtin-seed list in resolve.ml create_context while its siblings floor/ceil/round are present. One-line fix def "trunc";. Already PR'd: #170 (verified: math clears the resolve stage; its remaining Int/Float TypeMismatch is a separate pre-existing math typecheck bug). pow/to_float/fract are defined locally in math.affine — not builtins, not the problem.

    io.affine — deeper, and not a missing-declaration issue:

    • Module model was ruled (owner decision): explicit use module::{...} (not flat-namespace, not prelude-hub). So split (used by read_lines) must be pub in string.affine and io needs use string::{ split };. import_specific_items already enforces pub (VisibilityError otherwise).
    • But after adding use string::{ split }; (+ pub fn split, + module string;/module io; since string/io currently have no module decl while every working importer e.g. collections does), io still fails — and the undefined symbol is len, not split.
    • Root cause: a file carrying a use import is resolved via the module-resolution path (resolve_and_typecheck_module / resolve_imports_with_loader in resolve.ml), and that path builds a resolution context without the builtin seeding that create_context applies (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 the def/defc seeding so both the top-level and the resolve_and_typecheck_module contexts 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/Ref kind, independent).

  2. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    Follow-up: Typecheck.register_builtins has drifted from the resolver builtin-seed list

    Surfaced while landing the ADR-011 cross-module fix (#172). resolve.ml's seed_builtins (the def/defc list) and typecheck.ml's register_builtins (the bind_var/bind_scheme list) are not in sync. The resolver seeds the full builtin family; register_builtins is 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 — all UndefinedVariable-clean now but Unbound variable: … at typecheck.

    Also note read_line is currently bound as Unit -> String but io.affine pattern-matches it as Result<String,String> (match read_line() { Ok.. Err.. }) — reconciling it risks regressing any file using it as plain String, 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_never helpers), plus a decision on read_line's true type. This is what blocks io.affine from going fully green; resolution is already fixed (#172).

  3. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    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 via use string::{ split, join }) resolves fine but is Unbound variable: split at io's typecheck stage. The imported symbols' resolution is threaded, but their type schemes are not propagated into the importer's check_program (?import_types / name_types). resolve_program_with_loader returns a type_ctx whose name_types should carry the imported schemes (see lookup_source_scheme note 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.

  4. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    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]) — split returns [String]; the mismatch is at the imported-scheme/usage level (likely Ok(split(content,"\n")) in read_lines vs Result<[String],String>, or the imported split scheme 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.

  5. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    STAGE A complete — stdlib AOT coherence achieved.

    All 19 stdlib/*.affine compile 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):

    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 → mut param syntax; assignment-arm vs Unit-arm typing; option/collections exposed zero pub API.

    Leaving #128 open for joint close at your discretion. STAGE A unblocks STAGE B (effect system, #59→#103).

  6. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    Post-merge verification (independent re-check, 2026-05-18)

    Confirming the STAGE-A completion comment above (04:42) now that the dust has settled:

    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.

  7. hyperpolymath commented on May 18, 2026

    @hyperpolymath
    OwnerAuthor

    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.

  8. added a commit that references this issue on May 18, 2026
  9. hyperpolymath commented on Jun 1, 2026

    @hyperpolymath
    OwnerAuthor

    Status update — AS stdlib codegen pattern now established (2026-06-01) 📌

    For ticket coordinators / anyone tracking this gap:

    What this means for the gap tracked in this ticket:

    • The codegen pattern is now a known repeatable shape: declare in stdlib/*.affine → wire in deno_builtins table + prelude → smoke-test in tests/codegen-deno/*.
    • This ticket remains open as a tracked gap; no new mass-port is being kicked off from this post.
    • Surfacing status so the broader picture stays accurate as more sub-tickets / dependent campaigns reference it.

    🤖 Cross-estate AS-status sweep on 2026-06-01.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaves incorrectlymajorMajor issue — significant scope, broader impact than a feature/bug

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions