fix(codegen): namespace-member access of a renamed class export (#1758) - #1812
Merged
Conversation
`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.
This was referenced May 25, 2026
This was referenced Jul 18, 2026
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>
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.
Summary
import * as M from "..."; M.FoowhereFoois a local renamed class export(
export { Foo$ as Foo }) resolvedclass_ids["Foo"](the export alias) — amiss, since the class is named
Foo$— soM.Foofell back to a JS global ofthat 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 viaexport { Number$ as Number };S.decodeUnknownSync(S.Number)readsS.Number.ast— butS.Numberresolved to the globalNumberconstructor(the alias collides with the global), so
.astwas undefined →Cannot read properties of undefined (reading '_tag')deep inParseResult.Localization
An env-gated diagnostic in
js_object_get_field_by_nameshowed theS.Number.astreceiver hadtop16=0x0(a POINTER to the globalNumber), notthe
0x7ffeclass-ref thatNumber$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 renamedExport::Namedinall_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 backfrom the export alias to the origin name via
import_function_origin_namesbefore giving up (otherwise it drops to the global / undefined).
Validation
S.decodeUnknownSync(S.Number)(42)→42,S.String→"hello"—byte-identical to node. effect Schema now decodes.
effect/Effectdeep import stays green (result: 42);import * as S from "effect/Schema"init still works.test_gap_renamed_class_export_namespace.ts(+
_helpers/renamed_class_export.ts) — byte-for-byte with node acrossrenamed (global-colliding
Number+ non-collidingWidget) and directexports, plus a guard that the global
Numberis unshadowed.PERRY_NO_AUTO_OPTIMIZE=1): 0 regressions — all 13 failures failidentically on origin/main (decorators / static-block / mixins /
node-error-format / pino / #503 guard refuses dynamic dispatch on require()-imported stdlib namespaces (compile-fail) #1723 / Auto-bind reactive template literals in Text/TextField labels to perry_ui_state_bind_text_template #764 / Native server SIGSEGV at
jwt.sign(...)(jsonwebtoken) after resumed async-step body — follow-up to #859 #915 / tui-hang).Remaining (reported, not chained)
effectbarrel (import { Effect } from "effect") hits a furthervalue is not a function(pulls in more than Schema).M.renamedConst) remain a separatepre-existing gap (no longer link-errors, but returns garbage).
Refs #1758, #1785, #1772, #1791.