Skip to content

fix(codegen): namespace-member access of a renamed class export (#1758) - #1812

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-xpkg-cid
May 26, 2026
Merged

fix(codegen): namespace-member access of a renamed class export (#1758)#1812
proggeramlug merged 1 commit into
mainfrom
worktree-xpkg-cid

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

import * as M from "..."; M.Foo where Foo is a local renamed class export
(export { Foo$ as Foo }) resolved class_ids["Foo"] (the export alias) — a
miss, since the class is named Foo$ — so M.Foo fell back to a JS global of
that name (or undefined), and any static read (M.Foo.ast) returned undefined.

This unblocks effect Schema decode. effect's
class Number$ extends make(numberKeyword) {} is re-exported via
export { Number$ as Number }; S.decodeUnknownSync(S.Number) reads
S.Number.ast — but S.Number resolved to the global Number constructor
(the alias collides with the global), so .ast was undefined →
Cannot read properties of undefined (reading '_tag') deep in ParseResult.

Localization

An env-gated diagnostic in js_object_get_field_by_name showed the
S.Number.ast receiver had top16=0x0 (a POINTER to the global Number), not
the 0x7ffe class-ref that Number$ is in the defining module. Minimal repro
(compilePackages): export { X$ as X } + import * as M; M.X.ast → undefined;
direct exports and named imports both work — only the local rename via
namespace member access breaks.

Fix (2 edits)

  • compile.rs — record the origin (local) name for local renamed
    Export::Named in all_module_export_origin_names, scoped to classes
    (renamed var/func exports key their wrapper symbols on the export name;
    feeding the origin name there breaks linking — out of scope).
  • property_get.rs — when resolving a namespace-member class ref, fall back
    from the export alias to the origin name via import_function_origin_names
    before giving up (otherwise it drops to the global / undefined).

Validation

Remaining (reported, not chained)

  • The effect barrel (import { Effect } from "effect") hits a further
    value is not a function (pulls in more than Schema).
  • Renamed VAR/const namespace members (M.renamedConst) remain a separate
    pre-existing gap (no longer link-errors, but returns garbage).

Refs #1758, #1785, #1772, #1791.

`import * as M from "..."; M.Foo` where `Foo` is a LOCAL renamed class export
(`export { Foo$ as Foo }`) resolved `class_ids["Foo"]` (the export ALIAS) — a
miss, since the class is named `Foo$` — so `M.Foo` fell back to a JS global of
that name (or undefined), and any static read (`M.Foo.ast`) returned undefined.

This blocked effect Schema DECODE. effect's
`class Number$ extends make(numberKeyword) {}` is re-exported via
`export { Number$ as Number }`; `S.decodeUnknownSync(S.Number)` reads
`S.Number.ast`, but `S.Number` resolved to the **global `Number`** constructor
(the alias collides with the global), so `.ast` was undefined →
`Cannot read properties of undefined (reading '_tag')` in ParseResult.

Localized with an env-gated diagnostic in `js_object_get_field_by_name`: the
`S.Number.ast` receiver had top16=0x0 (a POINTER to the global Number), not the
0x7ffe class-ref `Number$` is in the defining module. Minimal repro
(compilePackages): `export { X$ as X }` + `import * as M; M.X.ast` → undefined;
direct exports and named imports both work — only the local *rename* breaks.

Fix (2 edits):
- `compile.rs`: record the origin (local) name for LOCAL renamed `Export::Named`
  in `all_module_export_origin_names`, scoped to CLASSES (renamed var/func
  exports key their wrapper symbols on the export name; feeding the origin name
  there breaks linking — out of scope here).
- `property_get.rs`: when resolving a namespace-member class ref, fall back from
  the export alias to the origin name via `import_function_origin_names` before
  giving up (otherwise it drops to the global/undefined).

With this, `S.decodeUnknownSync(S.Number)(42)` → 42 and `S.String` → "hello",
byte-identical to node. `effect/Effect` deep import stays green (42); Schema
init still works. New multi-file gap test
`test_gap_renamed_class_export_namespace.ts` (+ `_helpers/`), byte-identical to
node across renamed (global-colliding + not) and direct exports.

Cross-module / import / namespace / class regression sweep: 0 regressions (all
13 failures fail identically on origin/main).

NOTE: the `effect` barrel (`import { Effect } from "effect"`) hits a further
`value is not a function` (pulls in more than Schema) — reported separately.
Renamed VAR/const namespace members (`M.renamedConst`) remain a separate
pre-existing gap.

Refs #1758, #1785, #1772, #1791.
@proggeramlug
proggeramlug merged commit e3e0b2f into main May 26, 2026
10 checks passed
@proggeramlug
proggeramlug deleted the worktree-xpkg-cid branch May 26, 2026 03:33
proggeramlug added a commit that referenced this pull request Jul 18, 2026
…istry props (#6552) (#6556)

* fix(runtime): per-evaluation inherited static wins over last-wins registry props (#6552)

A subclass of a class-EXPRESSION value evaluated more than once
(`function make(a){return class{static ast=a}}`, then
`class Number$ extends make(x) {}` / `class Widget$ extends make(y) {}`)
records THIS evaluation's parent object as its static prototype
(`class_prototype_object`, #1788). But #6443's static-DATA-field inheritance
walk reads the parent's `CLASS_DYNAMIC_PROPS`, which are keyed by the
class-expression TEMPLATE id — shared, last-wins across every evaluation — and
it ran BEFORE the per-evaluation prototype-object walk. So every renamed
effect-Schema class read via `import * as M` (`M.Number.ast`/`M.Widget.ast`)
collapsed to the LAST `make(...)`'s static `ast` (the direct export's
`DirectKeyword`), re-breaking effect Schema decode
(test_gap_renamed_class_export_namespace, the #1758/#1812 guard).

Interleave the two walks: at each level of the class-object proto chain,
consult the class's pinned per-evaluation parent object BEFORE the parent's
registry props. The pinned object is authoritative (it carries this
evaluation's own edge); the registry read stays the fallback for a plain
declaration parent (#6443: Auth.js `SignInError.kind`), which has no pinned
object. Depth order is preserved, so a closer registry static still shadows a
deeper per-evaluation one.

test_gap_renamed_class_export_namespace is byte-identical to node again across
renamed (global-colliding + not) and direct exports; #6443 static-field
inheritance + deleted-key fallthrough, #6438 per-eval statics, effect factory
static dispatch, and the rest of the class-expr/static suite (18/18) stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): return a present `null` inherited static, don't fall through (#6552 review)

CodeRabbit review follow-up. The interleaved per-evaluation proto-object read
guarded on `!is_undefined() && !is_null()`, so a static explicitly set to `null`
on one evaluation (`class NullAst$ extends make(null) {}`) was treated as "absent"
and fell through to the parent's last-wins `CLASS_DYNAMIC_PROPS`, returning a
SIBLING evaluation's `ast` instead of `null` (verified: perry read `{_tag:'OBJ'}`
where node reads `null`).

`null` is a present, authoritative value — only `undefined` means "absent here".
Guard on `!is_undefined()` alone so a present `null` is returned and only a true
miss continues to the registry / a higher ancestor. Extends the gap test + helper
with a renamed null-static export (`M.NullAst.ast === null`), placed before the
last non-null `make(...)` so `null` is a genuine discriminator; byte-identical to
node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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.

1 participant