From c476a74cd56c243209372e0e6a1f31acd2c02313 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 4 Jul 2026 12:59:12 -0700 Subject: [PATCH] fix(compile): initialize namespace fields in the multi-module entry point (#1245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `namespace` declaration combined with any top-level `import` (which turns the file into an ES module) crashed compiled programs at startup with a NullReferenceException in `$TSNamespace.Set`, called from a module's `$Initialize`. The interpreter ran the same program correctly. Namespace objects live in flat static fields (`$ns_*`) on `$Program`, shared across every module. A module's `$Initialize` body only POPULATES them (`EmitNamespace` emits `$ns_X.Set(...)`); it never creates them. The single-file entry point calls `InitializeNamespaceFields` at the top of `Main` before any top-level code runs, but the multi-module entry point (`EmitModulesEntryPoint`) omitted it — so the first `Set()` dereferenced a null namespace field. Fix: call `InitializeNamespaceFields(il)` in `EmitModulesEntryPoint` before the module `$Initialize` dispatch loop, mirroring the single-file path. Adds regression tests (both interpreter and compiled) covering namespace + import in the entry file (const/function members, import before/after), nested namespaces, and a namespace alongside a sibling-module import with class/enum members. --- Compilation/ILCompiler.Modules.cs | 8 ++ .../SharedTests/NamespaceInModuleTests.cs | 104 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 SharpTS.Tests/SharedTests/NamespaceInModuleTests.cs diff --git a/Compilation/ILCompiler.Modules.cs b/Compilation/ILCompiler.Modules.cs index 09b4c572..1e64e534 100644 --- a/Compilation/ILCompiler.Modules.cs +++ b/Compilation/ILCompiler.Modules.cs @@ -713,6 +713,14 @@ private void EmitModulesEntryPoint(List modules) } } + // Initialize namespace static fields before any module/script $Initialize body runs. + // Namespace objects live in flat static fields ($ns_*) on $Program shared across every + // module, but a module's $Initialize only POPULATES them (EmitNamespace does + // $ns_X.Set(...)); it never creates them. The single-file entry point calls this at the + // top of Main; the multi-module entry point must too, or the first Set() dereferences a + // null namespace field (#1245). + InitializeNamespaceFields(il); + // Call each module/script's $Initialize method in dependency order. // CommonJS modules are initialized lazily — only the entry CJS module is run eagerly, // and require() triggers the rest. This matches Node semantics for the visible execution diff --git a/SharpTS.Tests/SharedTests/NamespaceInModuleTests.cs b/SharpTS.Tests/SharedTests/NamespaceInModuleTests.cs new file mode 100644 index 00000000..ca8cd36f --- /dev/null +++ b/SharpTS.Tests/SharedTests/NamespaceInModuleTests.cs @@ -0,0 +1,104 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.SharedTests; + +/// +/// Regression tests for #1245: in compiled mode, a namespace declaration combined with a +/// top-level import (which turns the file into an ES module) crashed at startup with a +/// NullReferenceException in $TSNamespace.Set. The multi-module entry point never +/// called InitializeNamespaceFields, so the namespace's backing static field was still null +/// when the module's $Initialize body tried to populate it. A namespace in a plain script +/// (no import) was unaffected — becoming an ES module is what broke it. The interpreter always ran +/// these correctly; each test runs both modes so the compiled path is pinned against that oracle. +/// +public class NamespaceInModuleTests +{ + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Namespace_WithImportAfter_ExportedConst(ExecutionMode mode) + { + var files = new Dictionary + { + ["main.ts"] = """ + namespace NS { + export const x = 1; + } + console.log(NS.x); + import * as fs from "fs"; + console.log(typeof fs.statSync); + """ + }; + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Equal("1\nfunction\n", output); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Namespace_WithImportBefore_ExportedFunction(ExecutionMode mode) + { + var files = new Dictionary + { + ["main.ts"] = """ + import * as path from "path"; + namespace NS { + export function greet(): string { return "hi"; } + } + console.log(NS.greet()); + console.log(typeof path.join); + """ + }; + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Equal("hi\nfunction\n", output); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void NestedNamespace_WithImport(ExecutionMode mode) + { + var files = new Dictionary + { + ["main.ts"] = """ + import * as path from "path"; + namespace Outer { + export const a = 10; + export namespace Inner { + export const b = 42; + } + } + console.log(Outer.a); + console.log(Outer.Inner.b); + console.log(typeof path.join); + """ + }; + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Equal("10\n42\nfunction\n", output); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Namespace_WithSiblingModuleImport_ClassAndEnumMembers(ExecutionMode mode) + { + var files = new Dictionary + { + ["helper.ts"] = """ + export function add(a: number, b: number): number { return a + b; } + """, + ["main.ts"] = """ + import { add } from "./helper"; + namespace Calc { + export const base = 10; + export function compute(): number { return add(base, 5); } + export enum Op { Add, Sub } + export class Adder { run(): number { return add(1, 2); } } + } + console.log(Calc.base); + console.log(Calc.compute()); + console.log(Calc.Op.Sub); + console.log(new Calc.Adder().run()); + """ + }; + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Equal("10\n15\n1\n3\n", output); + } +}