fix(es/resolver): Merge re-opened TypeScript namespace scopes - #11872
fix(es/resolver): Merge re-opened TypeScript namespace scopes#11872Onyeka Obi (MavenRain) wants to merge 12 commits into
Conversation
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b94ef6823a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
Merging this PR will not alter performance
Comparing Footnotes
|
b94ef68 to
cee5448
Compare
|
I updated 18 fixture files (16 crates/swc/tests/tsc-references/* files, plus swc_ecma_transforms_typescript's namespace_004 snapshot and namespace-and-enum/output.js) to reflect the merged-namespace resolver |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cee5448477
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
Address review feedback from @magic-akari / Codex on swc-project#11872: only `export …` declarations should be shared across sibling re-opens of a TypeScript namespace, not every declaration. Each re-open now gets a fresh per-body scope for its private bindings; only exported names live in the cache-backed `NamespaceExportScope`, which the body scope exposes via a `shared` link so reference lookups still see sibling-declared exports with a stable `SyntaxContext`. A per-body pre-scan of `export` declarations drives the routing in `modify`, making the decision order-independent: TypeScript's var/function same-body merge still holds (`export var a = 1; for (var a; …)`) while a non-exported re-open of a sibling-exported name stays isolated — fixing the `namespace_004` `MyEnum.A` `1`→`2` regression Codex flagged. Added tests/ts-resolver/namespace_reopen_private covering @magic-akari's example; updated fixture snapshots accordingly (re-opened non-exported classes / enums no longer trigger the "defined multiple times" diagnostic). Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
cee5448 to
d9ffafb
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9ffafbeba
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
…pens
Address Codex P1 on swc-project#11872: the cache key for `TsModuleDecl` previously
used `self.namespace_export.mark` whenever it was present, so a nested
namespace declared inside an outer namespace body was always keyed to
the outer's stable export mark. That made the two `Inner` declarations
in
namespace Outer { namespace Inner { export const a = 1 } }
namespace Outer { namespace Inner { export const b = a } }
collide on a single cache entry and merge, even though TypeScript keeps
non-exported members local to each declaration body.
The cache-parent mark now falls back to `self.current.mark` (the outer
re-open's per-body mark) unless the nested namespace name is in the
enclosing body's pre-scanned `namespace_export_names`. Two outer
re-opens hold distinct body marks, so their non-exported `Inner`
children land in disjoint cache entries and stay isolated; exported
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf4ec2a736
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
Address review feedback from @magic-akari / Codex on swc-project#11872: only `export …` declarations should be shared across sibling re-opens of a TypeScript namespace, not every declaration. Each re-open now gets a fresh per-body scope for its private bindings; only exported names live in the cache-backed `NamespaceExportScope`, which the body scope exposes via a `shared` link so reference lookups still see sibling-declared exports with a stable `SyntaxContext`. A per-body pre-scan of `export` declarations drives the routing in `modify`, making the decision order-independent: TypeScript's var/function same-body merge still holds (`export var a = 1; for (var a; …)`) while a non-exported re-open of a sibling-exported name stays isolated — fixing the `namespace_004` `MyEnum.A` `1`→`2` regression Codex flagged. Added tests/ts-resolver/namespace_reopen_private covering @magic-akari's example; updated fixture snapshots accordingly (re-opened non-exported classes / enums no longer trigger the "defined multiple times" diagnostic). Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
…pens
Address Codex P1 on swc-project#11872: the cache key for `TsModuleDecl` previously
used `self.namespace_export.mark` whenever it was present, so a nested
namespace declared inside an outer namespace body was always keyed to
the outer's stable export mark. That made the two `Inner` declarations
in
namespace Outer { namespace Inner { export const a = 1 } }
namespace Outer { namespace Inner { export const b = a } }
collide on a single cache entry and merge, even though TypeScript keeps
non-exported members local to each declaration body.
The cache-parent mark now falls back to `self.current.mark` (the outer
re-open's per-body mark) unless the nested namespace name is in the
enclosing body's pre-scanned `namespace_export_names`. Two outer
re-opens hold distinct body marks, so their non-exported `Inner`
children land in disjoint cache entries and stay isolated; exported
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
bf4ec2a to
450fd85
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 450fd85e81
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
78b7d08 to
a9c8700
Compare
Address review feedback from @magic-akari / Codex on swc-project#11872: only `export …` declarations should be shared across sibling re-opens of a TypeScript namespace, not every declaration. Each re-open now gets a fresh per-body scope for its private bindings; only exported names live in the cache-backed `NamespaceExportScope`, which the body scope exposes via a `shared` link so reference lookups still see sibling-declared exports with a stable `SyntaxContext`. A per-body pre-scan of `export` declarations drives the routing in `modify`, making the decision order-independent: TypeScript's var/function same-body merge still holds (`export var a = 1; for (var a; …)`) while a non-exported re-open of a sibling-exported name stays isolated — fixing the `namespace_004` `MyEnum.A` `1`→`2` regression Codex flagged. Added tests/ts-resolver/namespace_reopen_private covering @magic-akari's example; updated fixture snapshots accordingly (re-opened non-exported classes / enums no longer trigger the "defined multiple times" diagnostic). Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
…pens
Address Codex P1 on swc-project#11872: the cache key for `TsModuleDecl` previously
used `self.namespace_export.mark` whenever it was present, so a nested
namespace declared inside an outer namespace body was always keyed to
the outer's stable export mark. That made the two `Inner` declarations
in
namespace Outer { namespace Inner { export const a = 1 } }
namespace Outer { namespace Inner { export const b = a } }
collide on a single cache entry and merge, even though TypeScript keeps
non-exported members local to each declaration body.
The cache-parent mark now falls back to `self.current.mark` (the outer
re-open's per-body mark) unless the nested namespace name is in the
enclosing body's pre-scanned `namespace_export_names`. Two outer
re-opens hold distinct body marks, so their non-exported `Inner`
children land in disjoint cache entries and stay isolated; exported
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
…wc-project#11607) Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
…pe resolver output Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
Address review feedback from @magic-akari / Codex on swc-project#11872: only `export …` declarations should be shared across sibling re-opens of a TypeScript namespace, not every declaration. Each re-open now gets a fresh per-body scope for its private bindings; only exported names live in the cache-backed `NamespaceExportScope`, which the body scope exposes via a `shared` link so reference lookups still see sibling-declared exports with a stable `SyntaxContext`. A per-body pre-scan of `export` declarations drives the routing in `modify`, making the decision order-independent: TypeScript's var/function same-body merge still holds (`export var a = 1; for (var a; …)`) while a non-exported re-open of a sibling-exported name stays isolated — fixing the `namespace_004` `MyEnum.A` `1`→`2` regression Codex flagged. Added tests/ts-resolver/namespace_reopen_private covering @magic-akari's example; updated fixture snapshots accordingly (re-opened non-exported classes / enums no longer trigger the "defined multiple times" diagnostic). Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
…pens
Address Codex P1 on swc-project#11872: the cache key for `TsModuleDecl` previously
used `self.namespace_export.mark` whenever it was present, so a nested
namespace declared inside an outer namespace body was always keyed to
the outer's stable export mark. That made the two `Inner` declarations
in
namespace Outer { namespace Inner { export const a = 1 } }
namespace Outer { namespace Inner { export const b = a } }
collide on a single cache entry and merge, even though TypeScript keeps
non-exported members local to each declaration body.
The cache-parent mark now falls back to `self.current.mark` (the outer
re-open's per-body mark) unless the nested namespace name is in the
enclosing body's pre-scanned `namespace_export_names`. Two outer
re-opens hold distinct body marks, so their non-exported `Inner`
children land in disjoint cache entries and stay isolated; exported
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
nested namespaces still key on the outer's stable export mark and
merge across re-opens.
Added tests/ts-resolver/namespace_reopen_nested_private exercising the
Codex case (second `Inner`'s reference to `a` is unresolved,
confirming isolation).
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
`pre_scan_namespace_exports` only inspected `ModuleDecl::ExportDecl`, so
`export import A = ...` (a `TsImportEqualsDecl { is_export: true, .. }`)
was never added to `namespace_export_names`. The alias binding was then
routed to the body-local scope instead of the namespace's merged export
scope, and references from sibling re-opens of the same namespace fell
through to outer/unresolved bindings, violating TypeScript's
namespace-merge semantics.
Recognise the exported import-equals form in the pre-scan, adding the
alias to `values` (and to `types` when not `is_type_only`), mirroring
how `export namespace`/`export class`/`export enum` are tracked. The
existing `visit_mut_ts_import_equals_decl` `modify` call now routes the
binding to the shared export scope, and type-position lookups in
sibling bodies resolve through `mark_for_ref_inner`'s
`declared_types -> declared_symbols` fall-through.
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
a9c8700 to
7aa055b
Compare
|
magic-akari Can you take a look? |
|
After reviewing the test snapshots and generated outputs, no issues have been found so far. |
|
Codex (@codex) review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7aa055b10c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
| /// bindings, violating TypeScript's namespace-merge semantics. | ||
| fn pre_scan_namespace_exports(body: &TsNamespaceBody) -> NamespaceExportNames { | ||
| let mut scan = NamespaceExportNames::default(); | ||
| if let TsNamespaceBody::TsModuleBlock(block) = body { |
There was a problem hiding this comment.
Handle dotted namespace reopens
When the reopened namespace is written in dotted form (namespace Outer.Inner { ... }), the Inner body is represented as TsNamespaceBody::TsNamespaceDecl, so this branch never pre-scans its exports and the existing visit_mut_ts_namespace_decl path never installs a shared namespace export scope for Inner. A pair such as namespace Outer.Inner { export const a = 1 } followed by namespace Outer.Inner { export const b = a } still leaves a unresolved/outer-scoped instead of resolving to Outer.Inner.a, even though TypeScript emits Inner.a for that case.
Useful? React with 👍 / 👎.
| scan.values.insert(f.ident.sym.clone()); | ||
| } | ||
| Decl::Class(c) => { | ||
| scan.values.insert(c.ident.sym.clone()); |
There was a problem hiding this comment.
Qualify reopened class and function exports
When a later namespace reopen references an exported class or function from an earlier reopen, adding these names to the shared value scope gives the reference the exported declaration's context, but the TypeScript transform still does not add ExportDecl classes/functions to semantic.exported_binding (unlike vars/enums/modules/import aliases), so ExportQuery never rewrites the later-body reference to N.C/N.f. For namespace N { export class C {} } namespace N { new C() }, the generated code keeps a free C inside the second IIFE instead of reading N.C, causing a runtime ReferenceError.
Useful? React with 👍 / 👎.
| if exp.declared_symbols.contains_key(sym) { | ||
| return Some(exp.mark); |
There was a problem hiding this comment.
Predeclare later namespace exports before lookups
This lookup only sees names already accumulated in the shared scope, so references in an earlier namespace body cannot resolve to exports declared by a later reopen. Valid var cases like namespace Test { export var b = a + 1 } namespace Test { export var a = 1 } should emit the first initializer as Test.a + 1; with the current one-pass cache, a is unresolved/outer-scoped while visiting the first body, producing a free a instead of the namespace property.
Useful? React with 👍 / 👎.
magic-akari
left a comment
There was a problem hiding this comment.
I added additional test cases that should pass according to the expected behavior, but they are failing on my machine.
This suggests the current implementation may not fully match the expected behavior and may require a fix.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24c58ea1c4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
| if !import.is_type_only { | ||
| scan.values.insert(import.id.sym.clone()); | ||
| } | ||
| scan.types.insert(import.id.sym.clone()); |
There was a problem hiding this comment.
Populate type scope for exported import aliases
This records export import type A = ... as a namespace type export, but the declaration visitor for TsImportEqualsDecl only calls modify in value mode, so type-only aliases never get inserted into the shared declared_types scope. In a reopen such as namespace N { export import type A = Outer; } namespace N { export type T = A.I; }, A is not found through the merged namespace type scope and can fall through to an outer/unresolved type instead. Add a type-space declaration for exported import-equals aliases, and avoid creating a value binding when is_type_only is set.
Useful? React with 👍 / 👎.
Store one merged visibility table for each namespace identity. Each exported name keeps the mark of the re-open that declares it. A hoisting pre-pass seeds sibling exports into the table. Forward references across re-opens then resolve. Namespace ids bind body-locally and register in the table with a yield rule: a namespace id must not overwrite a slot that a merged class, function, enum, or interface owns. Exportedness for merge keying uses value exports only. Bind a type-only `export import` alias in type space only. Add a fixture for it. Add two fixtures for declaration merging: a class that merges with a namespace across re-opens, and a forward type reference to an interface that shares its name with a namespace. Update the tsxPreserveEmit1 snapshots. The reference to the exported class X now resolves through the namespace and emits `M.X`. This matches the comment in the fixture source. Reformat namespace_reopen_nested_type_only_export/output.ts. The codegen on main now prints empty interface bodies on two lines. All marks in this fixture are unchanged. Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9520ac537
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
| let exported = self | ||
| .namespace_export_names | ||
| .as_ref() | ||
| .is_some_and(|names| names.values.contains_key(name)); |
There was a problem hiding this comment.
Key nested namespace merges by namespace exports only
Here exported becomes true for any value export with the same name, but values also contains unrelated exports such as export import Inner = O, vars, classes, and enums. In a valid case like namespace Outer { export import Inner = O; namespace Inner { export const a = 1 } } const a = 2; namespace Outer { namespace Inner { export const b = a } }, the local Inner declarations must remain body-local and b should read the outer a; this check instead gives both local Inner bodies the exported merge table, so a resolves to the first private namespace and can be emitted as an out-of-scope Inner.a. Please base exported nested-namespace keying on the namespace declaration itself (for example namespace_ids), not every value export named the same.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Thank you. I checked this case against tsc 5.9.3. The example compiles clean, and swc
already produces the correct resolution and the correct emit:
- Resolver: the two private
Innerbodies stay body-local (Inner__4/Inner__7),
andb = aresolves to the outerconst a(a__2). - Emit:
Inner.b = a;with both private namespaces initialized body-locally, and
Outer.Inner = Ofor the alias. This matches the tsc output.
The reason: the merged-table keying is only reachable for observable breakage through
programs that mix local and exported declarations of one name, and TypeScript rejects
those (error 2395). I added the example as a regression fixture
(namespace_reopen_private_export_import_shadow) so the behavior stays pinned.
| *self | ||
| .namespace_instances | ||
| .borrow_mut() | ||
| .entry((keys.instance_scope, keys.name.clone())) |
There was a problem hiding this comment.
Share exported nested namespace aliases across reopens
Keying the instance mark by the enclosing body gives an exported nested namespace a different local alias in each outer reopen, but references imported from the merged table keep the declaration body's member context. In namespace N { export namespace I { export const a = 1 } } namespace N { export namespace I { export const b = a } }, the second body resolves a to the first body's I context; the TypeScript transform then qualifies it with that first local alias, which is not in scope inside the second IIFE, instead of using the current N.I alias. Exported nested namespace instance marks need to be stable across parent reopens while private body scopes remain isolated.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. The old code keyed the instance mark by the enclosing body's mark, so each
parent re-open minted a distinct instance for the nested namespace. The emitted JS for
the example was:
(function(I1) {
I1.b = I.a; // `I` is body 1's alias, not in scope here
})(N.I || (N.I = {}));I is unbound at runtime. Fixed by keying instance marks by (parent identity, name)
instead of (enclosing body mark, name). Top-level namespaces and private namespaces
derive the same key as before (their parent identity IS the enclosing scope's mark),
so only exported nested namespaces unify, which is the fix. The emit is now
I.b = I.a;, matching tsc.
On the transform side, a cross-body member reference is now qualified with the alias
of the body it appears in (a small stack of the namespace bodies under emit), not
with the declaring body's alias, and this works from nested bodies too.
NOTE (disclosure): this changes one token in the maintainer-provided fixture
namespace_reopen_nested_type_only_export: b__7 becomes b__5, i.e. b now
shares the nested instance mark with a (export const b__5 = a__5). The old pinned
resolution produced the out-of-scope emit above for the fixture's own
export const b = a line; the new resolution produces the tsc-equivalent emit. The
namespace declaration ids stay body-local (Inner__4 / Inner__6), as before.
| let mut table = table.borrow_mut(); | ||
| scan.values | ||
| .iter() | ||
| .filter(|(sym, _)| !scan.namespace_ids.contains(*sym)) |
There was a problem hiding this comment.
Seed later exported namespaces before lookups
This filter intentionally drops namespace declarations from the forward seed, so an earlier reopen cannot resolve an exported namespace that is declared only by a later reopen. For namespace N { export const x = C } namespace N { export namespace C { export const a = 1 } } const C = "outer", TypeScript emits N.x = N.C, but the first body never gets C in the merged table here and will bind to the outer C (or remain unresolved). Please seed exported namespace ids as namespace-property bindings too, not only non-namespace exports.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. The forward seed skipped namespace ids, so x = C bound the outer
const C (N.x = C at runtime, the string "outer"; tsc emits N.x = N.C). Fixed by
seeding exported namespace ids as namespace-property bindings with the enclosing
instance mark, and registering the id's value-space table slot with that same mark in
bind_namespace_id. The id itself still binds body-locally (namespace declarations
do not unify across re-opens); only the member-reference view goes through the
instance mark. The yield rule for class/function/enum/interface merges is unchanged.
One refinement on top: only instantiated namespaces take a value-space slot. A
namespace whose members are (transitively) only interfaces, type aliases, and other
non-instantiated namespaces is fully erased, and tsc resolves a value reference to
its name past it to the outer binding. The check mirrors IsConcrete in the
TypeScript transform's retain module, so the resolver and the emitter agree. Both
declaration orders are pinned (namespace_reopen_erased_namespace_no_shadow,
namespace-reopen/type-only-namespace-no-shadow).
…dy member references
Round 2 of review feedback for the namespace scope merge.
Resolver (swc_ecma_transforms_base):
- Key the instance mark by (parent identity, name). Every re-open of an
exported nested namespace now binds its exported value members with
one shared instance mark. Before, each parent re-open minted its own
mark, and the TypeScript transform emitted an alias that is not in
scope at the reference site (`I1.b = I.a`).
- Seed exported namespace ids into the value side of the merged table.
A reference in an earlier re-open then resolves a namespace that only
a later sibling re-open declares.
- Give no value-space slot to a namespace that is not instantiated
(transitively type-only; mirrors `IsConcrete` in the TypeScript
transform). TypeScript erases such a namespace, and a value reference
to its name must resolve to the outer binding.
TypeScript transform (swc_ecma_transforms_typescript):
- Record one shared member context per namespace body
(`SemanticInfo::member_ctxt`) and the exported member names that keep
local bindings in the emitted body (`SemanticInfo::local_member`,
keyed by body span because re-opened declarations share an id). A
post-pass shares the member context across re-opened bodies of one
namespace, so bodies whose exports are only functions, classes, or
namespaces also arm the rewrite.
- Qualify a cross-body member reference with the alias of the enclosing
body under emit (`ExportQuery::namespace_stack`), not with the
declaring body's alias. The stack makes this work from nested bodies.
- Keep a reference bare when the member keeps a local binding in the
matched body (an exported function or class): the local binding is in
scope, and function references rely on hoisting.
- Do not use the context-equality fast path for dotted bodies
(`namespace A.B`): their members bind with the enclosing scope's
mark, which every body-local binding shares.
New fixtures pin each behavior against the tsc 5.9.3 emit:
ts-resolver: namespace_reopen_private_export_import_shadow,
namespace_reopen_nested_reopen, namespace_reopen_late_namespace_export,
namespace_reopen_erased_namespace_no_shadow; strip:
namespace-reopen/{private-alias-shadow,nested-reopen,
late-namespace-export,dotted-body-locals,fn-class-members,
fn-class-only-bodies,type-only-namespace-no-shadow,nested-chain,
nested-fn-ref}.
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 471e14d553
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
| let frame = self | ||
| .semantic | ||
| .member_ctxt | ||
| .get(id) | ||
| .filter(|ctxt| **ctxt != id.1) |
There was a problem hiding this comment.
Qualify namespace-only parent exports
When a reopened namespace only exports nested namespaces, semantic.member_ctxt never gets an entry for the parent namespace, so this lookup returns None and no parent frame is pushed. In namespace N { export namespace C { export const a = 1 } } namespace N { export namespace D { export const x = C } } const C = "outer", the resolver gives the C reference the parent namespace member context, but the transform has only D's frame and leaves C bare; tsc emits D.x = N.C, while SWC would bind to the outer/free C instead.
Useful? React with 👍 / 👎.
| let scan = pre_scan_namespace_exports(body); | ||
| let mut table = table.borrow_mut(); | ||
| scan.values | ||
| .iter() | ||
| .filter(|(sym, _)| !scan.erased_namespace_ids.contains(*sym)) | ||
| .for_each(|(sym, kind)| { |
There was a problem hiding this comment.
Seed exports inside dotted namespace bodies
For dotted reopens, this seed only scans the outer synthetic body, so a later namespace A.B { export var y = 1 } contributes B to A but never predeclares y in B's merged table. An earlier namespace A.B { export var x = y } therefore resolves y to an outer binding or leaves it unresolved, even though tsc emits B.x = B.y for this valid var forward-reference across dotted namespace reopens.
Useful? React with 👍 / 👎.
| .semantic | ||
| .member_ctxt | ||
| .get(id) | ||
| .filter(|ctxt| **ctxt != id.1) |
There was a problem hiding this comment.
Qualify class members in dotted reopens
This filter prevents dotted namespace bodies from ever pushing a namespace frame, but exported classes/functions are deliberately absent from exported_binding, so cross-body refs to them have no fallback rewrite. In namespace Outer.Inner { export class C {} } namespace Outer.Inner { export const c = new C(); }, the resolver resolves C to the first dotted body's context, then the transform leaves new C() in the second IIFE instead of new Inner.C(), causing a runtime ReferenceError.
Useful? React with 👍 / 👎.
CI compiles swc_ecma_transforms with `--cfg swc_ast_unknown`. Under that cfg, `VarDeclKind` is non-exhaustive, and the `match` in `add_decl_export_names` did not compile (E0004). Use the existing `impl From<VarDeclKind> for DeclKind` in scope.rs instead. That impl holds the same mapping and already handles the cfg. This keeps one source of truth and does not change behavior for `Var`, `Let`, or `Const`. Regenerate the `tsxEmit3` baselines in `crates/swc/tests/tsc-references`. The old baselines held bare cross-body references (`Foo`, `Bar`) that throw a ReferenceError at run time. This branch now qualifies them (`M.Foo`, `S.Bar`, and `M.Foo` beside the hygiene-renamed local `M1`). This agrees with the intent comments in the fixture (`// Emit M.Foo`, `// Emit S.Bar`, `// Emit M_1.Foo`) and with the tsc emit for the same source. Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bcd1935d38
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
| || decl | ||
| .as_fn_decl() | ||
| .map(|function_decl| function_decl.function.body.is_none()) | ||
| .unwrap_or_default() |
There was a problem hiding this comment.
Treat ambient namespace functions as instantiating
When an exported nested namespace contains only ambient function members, e.g. namespace N { export const x = C } namespace N { export namespace C { export declare function f(): void } }, this predicate classifies the C body as non-instantiated because the function has no body. That puts C in erased_namespace_ids, so the forward seed skips the namespace value and the earlier C reference can bind to an outer/free C; TypeScript still creates the namespace object for ambient function members and emits the reference as N.C.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, fixed. I verified the claim against tsc 5.9.3 first: a
namespace whose only member is export declare function f(): void gets
its IIFE, and an earlier sibling reopen reference is emitted qualified
(N.x = N.C). The same holds for a bare declare function, for a
declare namespace member (qualified reference, no IIFE for the ambient
member itself), and transitively for deep nesting.
The fix removes the body check from decl_is_instantiating: every
function declaration now counts as instantiating, which matches tsc. To
keep the resolver and the emitter in agreement by construction,
ts_module_is_instantiated is now public and
TsModuleDecl::is_concrete in the strip pass delegates to it. Statement
retention of overload signatures is unchanged; only the namespace
instantiation aggregate changed.
Two existing tsc reference baselines churned, and I checked both against
the real tsc emit before regenerating: parserFunctionDeclaration7
(module M { function foo(); } now gets var M; plus its IIFE, which
is exactly what tsc emits) and neverReturningFunctions1 (the Debug
namespace, whose only member is export declare function fail, now gets
its IIFE; its Debug.fail references were already qualified in the old
baseline, so the old output was a ReferenceError at runtime).
New fixtures: namespace-reopen/ambient-fn-namespace (covers both the
export and the plain statement path), namespace-reopen/declare-namespace-member,
namespace-reopen/deep-ambient-fn (strip), and
namespace_reopen_ambient_fn_instantiates (resolver, a direct
differential against the existing erased-namespace fixture: the
interface-only namespace still lets the reference escape to the outer
binding, the ambient-fn namespace no longer does).
| scan.types.iter().for_each(|sym| { | ||
| table.types.entry(sym.clone()).or_insert(instance_mark); | ||
| }); |
There was a problem hiding this comment.
Seed namespace ids into the merged type scope
This only pre-seeds scan.types, but exported namespace declarations are intentionally kept out of that set, so an earlier reopen cannot resolve a type reference to a namespace that is declared only by a later reopen. For example namespace N { export type T = C.I } namespace N { export namespace C { export interface I {} } } is accepted by TypeScript, but while resolving the first body C is not in the merged type table yet and can remain unresolved or bind to an outer type. Please seed namespace_ids into the type table as well, using the namespace declaration's eventual type mark semantics.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, fixed. Verified against tsc 5.9.3 with a
positive/negative pair: a forward type reference C.I from an earlier
reopen resolves to the namespace C declared only by a later reopen
(compiles clean), and the negative control that would only compile if
C.I bound an outer C is rejected. So the earlier reopen must see the
later sibling, which the old code did not provide.
The fix seeds scan.namespace_ids into the merged type table in the
hoisting pre-pass, mirroring the existing value-side seed. Three
deliberate properties:
- The seed only fills vacant slots and records namespace ownership, so
the declaring body's own registration still overwrites it with the
body mark through the existing ownership mechanism (forward
references get the instance mark, backward references the body mark,
the same contract as the value side). - A slot already claimed by a non-namespace type declaration is left
alone, preserving the existing "explicit type declaration wins"
behavior. - The seed has no erased-namespace filter, on purpose: erasure removes
the value meaning but keeps the type meaning, and a type-only
namespace is exactly the case the comment's example exercises.
New fixture: namespace_reopen_forward_type_ref (resolver), covering
an erased namespace shadowing an import alias, an instantiated
namespace variant, and a backward reference (namespace declared in an
earlier reopen, type reference in a later one) that pins the
forward-gets-instance-mark / backward-gets-body-mark contract.
While landing this I ran an adversarial self-review over the seed and
found two follow-on defects, both fixed here:
- When a later reopen declares a real interface or type alias with a
seeded namespace name, the slot kept namespace ownership, so the
namespace registration clobbered the merged slot with its body mark
and references disagreed with the interface declaration. A
non-namespace type declaration seen by any reopen now revokes
namespace ownership, in either seeding order; the reference and the
merged interface agree on the instance mark
(namespace_reopen_ns_iface_type_mergepins it, tsc-verified). - The seed made a bare type reference resolve into a sibling
namespace. tsc gives a namespace-only symbol no type meaning
(TS2709) and resolves the reference past it to an outer type
binding, so bare type references now skip merged type slots that
only a namespace owns, while the left side of a qualified name
still resolves with namespace meaning and binds them
(namespace_reopen_bare_type_ref_outerpins both directions,
tsc-verified with a TS2709 negative control).
…espace ids into the merged type scope Address the two Codex P2 review comments (round 4), plus three defects that an adversarial self-review found in the same machinery. P2-1: tsc classifies every function declaration as instantiating, with or without a body. Remove the body check from decl_is_instantiating. A namespace whose only member is an ambient function declaration now counts as instantiated. The hoisting pre-pass seeds its value slot, so an earlier sibling reopen binds the namespace instead of an outer binding. Make ts_module_is_instantiated public and delegate TsModuleDecl::is_concrete in the strip pass to it. The resolver and the emitter now classify namespaces identically by construction. Statement retention of overload signatures does not change. P2-2: seed scan.namespace_ids into the merged type table during the hoisting pre-pass. The seed only fills vacant slots, uses the instance mark, and records namespace ownership. The declaring body later overwrites the slot with its body mark through the existing ownership mechanism. A type slot that a non-namespace type declaration already claimed stays untouched. The seed applies to erased namespaces too, because erasure removes the value meaning and keeps the type meaning. Self-review fixes in the same machinery: 1. Value seed: the erased-name filter kept a name erased when any occurrence of it was a non-instantiated namespace declaration, even when a function, class, enum, or an instantiated namespace occurrence in the same body gave the merged symbol value meaning. A forward value reference then fell through to an outer binding or to an unresolved identifier. The scan now tracks value meaning per name, in either declaration order, and a namespace declaration no longer overwrites the declaration kind that a merged function recorded. 2. Type seed ownership: when a later re-open declared an interface or a type alias with a seeded namespace name, the slot kept namespace ownership, so the namespace registration clobbered the merged slot with its body mark and references disagreed with the type declaration. A non-namespace type declaration seen by any re-open now revokes namespace ownership, in either seeding order, and every reference agrees with the merged declaration on the instance mark. 3. Bare type references: the type seed made a bare type reference resolve into a sibling namespace. tsc gives a namespace-only symbol no type meaning (TS2709) and resolves the reference past it to an outer type binding. Bare type references now skip merged type slots that only a namespace owns; the left side of a qualified name still resolves with namespace meaning and binds them. Verified against tsc 5.9.3 for all changes, with positive and negative controls. Two existing tsc reference baselines change and both now match the tsc emit: parserFunctionDeclaration7 (module M with only an overload signature gets its IIFE) and neverReturningFunctions1 (the Debug namespace gets its IIFE; its references were already qualified). New fixtures: ambient-fn-namespace, declare-namespace-member, deep-ambient-fn, and fn-ns-value-merge (strip); namespace_reopen_ambient_fn_instantiates, namespace_reopen_forward_type_ref, namespace_reopen_fn_ns_value_merge, namespace_reopen_ns_iface_type_merge, and namespace_reopen_bare_type_ref_outer (resolver). Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
|
One more fix in this push, from the same self-review of the merge namespace N {
export const x = foo;
}
namespace N {
export function foo() { return 1; }
export namespace foo { export interface Options {} }
}tsc emits |
Description
Closes #11607. Unblocks #11514.
In TypeScript, re-declaring a namespace with the same name merges it;
identifiers in any declaration's body see bindings declared in earlier
declarations. SWC's resolver was creating a fresh block scope (with
Mark::fresh) for every namespace X { ... } body, leaving sibling
re-declarations with disjoint scopes. Lookups in the second body walked their
own (empty) scope, then fell through to the outer scope and resolved to
whatever happened to be declared there.
Repro (also added as tests/ts-resolver/namespace_reopen)
Before: a resolved to outer const a (a__2).
After: a resolves to Test.a from the first body (a__3).
Fix
A Resolver now carries a Rc<RefCell<FxHashMap<(Mark, Atom), NamespaceBody>>>
cache keyed by (parent_scope.mark, namespace_name). On
visit_mut_ts_module_decl:
mark, declared symbols, and declared types before visiting the body. All
earlier-declaration bindings are visible to lookups, and the merged scope's
mark is preserved so identifiers across declarations share a SyntaxContext.
cache, so a third (or fourth) re-declaration sees the union.
Cache keying on the parent scope's mark makes nested namespace re-opens behave
correctly: namespace Outer { namespace Inner {} } namespace Outer { namespace
Inner {} } merges the outer pair, then the inner pair (whose parent mark is
now the outer's merged mark) also merges.
Test coverage
The new fixture under tests/ts-resolver/namespace_reopen exercises the issue's
exact repro. The full swc_ecma_transforms_base test suite (~7500 tests,
including the 5182-fixture ts_resolver corpus) passes unchanged.