Compile namespace-style npm packages: guarded declarations and var exports

Two refusals kept pre-module JavaScript packages from compiling:

- `var ns = ns || {}` (and `?? {}`): the read in the variable's own
  initializer made the checker type the namespace `any`, and requirers
  then failed to reach the binding (SC1030). In a module, the read sees
  the hoisted `undefined` whenever nothing else writes the variable, so a
  new package-source normalization respells the declaration as its
  default (`var ns = ({})`) when no other declaration, assignment, update,
  function, class, parameter or local of that name exists in the file and
  no earlier top-level statement mentions it.
- `module.exports = { ns }` with a `var` binding was refused as exporting
  a mutable binding by reference. A `var` (or `let`) whose initialized
  declaration precedes the export statement at top level and that nothing
  reassigns exports exactly the value Node copies, so it now aliases like
  a const.

The namespace benchmark (results/design-shape-inference/nsbench) now
compiles and matches Node; the new npm case legacy-namespaces pins a
namespace library with guarded declarations, constructor functions with
prototype methods, enums, a revealing module and a handler registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-24 20:24:57 -07:00
co-authored by Claude Opus 5.5
parent 4a8a6567ca
commit 9d3365c99b
11 changed files with 368 additions and 2 deletions
@@ -7445,7 +7445,7 @@ function lowerCjsExportStatement(
const fenceMutable = (nameNode: ts.Identifier): void => {
const sym = lowerer.resolveValueSymbol(nameNode);
const g = sym ? lowerer.globalsBySymbol.get(sym) : undefined;
if (g?.mutable) {
if (g?.mutable && !(sym && settledBeforeExport(lowerer, sym, cjs.expr))) {
lowerer.unsupported(
"SC1090",
nameNode,
@@ -7605,7 +7605,7 @@ function lowerCjsSingleValueExport(
}
const g = lowerer.globalsBySymbol.get(sym);
if (g) {
if (g.mutable) {
if (g.mutable && !settledBeforeExport(lowerer, sym, rhs)) {
lowerer.unsupported(
"SC1090",
rhs,
@@ -12707,3 +12707,19 @@ function isAssertionInitializer(node: ts.Expression): boolean {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return ts.isAsExpression(e) || ts.isTypeAssertion(e);
}
/** A `var` (or `let`) whose initialized declaration precedes the export
* statement in the module's top-level statements and that nothing ever
* reassigns: exporting it by reference reads the value Node copies. The
* legacy `var ns = ns || {}; …; module.exports = { ns }` shape. */
function settledBeforeExport(lowerer: Lowerer, sym: ts.Symbol, exportNode: ts.Node): boolean {
const decl = sym.valueDeclaration;
if (!decl || !ts.isVariableDeclaration(decl) || decl.initializer === undefined) return false;
const stmt = decl.parent?.parent;
if (!stmt || !ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) return false;
let exportStmt: ts.Node = exportNode;
while (exportStmt.parent !== undefined && !ts.isSourceFile(exportStmt.parent))
exportStmt = exportStmt.parent;
if (exportStmt.parent !== stmt.parent || stmt.end > exportStmt.pos) return false;
return bindingNeverReassigned(lowerer, sym, decl);
}
@@ -0,0 +1,63 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, test } from "vitest";
import { nodeFrontendServices } from "./services-node.js";
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function rewrite(source: string): string | null {
return nodeFrontendServices().namespaceDefaults(source, "/pkg/index.js");
}
/** Node prints the same for the original and the rewritten module. */
function expectSameOutput(source: string): void {
const result = rewrite(source);
expect(typeof result).toBe("string");
const dir = mkdtempSync(join(tmpdir(), "scriptc-namespace-defaults-"));
dirs.push(dir);
const outputs = [source, result!].map((text, i) => {
const file = join(dir, `m${i}.cjs`);
writeFileSync(file, text);
const r = spawnSync(process.execPath, [file], { encoding: "utf8" });
expect(r.status).toBe(0);
return r.stdout;
});
expect(outputs[1]).toBe(outputs[0]);
}
test("a guarded namespace declaration becomes its default", () => {
const source = `var app = app || {};
app.util = app.util || {};
app.util.twice = function (x) { return 2 * x; };
var cfg = cfg ?? { depth: 1 };
console.log(app.util.twice(3), cfg.depth, Object.keys(app).join(","));
`;
expect(rewrite(source)).toBe(`var app = {};
app.util = app.util || {};
app.util.twice = function (x) { return 2 * x; };
var cfg = { depth: 1 };
console.log(app.util.twice(3), cfg.depth, Object.keys(app).join(","));
`);
expectSameOutput(source);
});
test("any other write, declaration or earlier mention keeps the text", () => {
// Redeclared: the second declaration reads the first one's object.
expect(rewrite("var ns = ns || {};\nvar ns = ns || {};\n")).toBeNull();
// Assigned elsewhere, updated, or declared as a function.
expect(rewrite("var ns = ns || {};\nfunction reset() { ns = {}; }\n")).toBeNull();
expect(rewrite("var n = n || 0;\nn++;\n")).toBeNull();
// A local of the same name somewhere in the file.
expect(rewrite("var ns = ns || {};\nfunction f(ns) { return ns; }\n")).toBeNull();
// Mentioned by an earlier statement.
expect(rewrite("console.log(typeof ns);\nvar ns = ns || {};\n")).toBeNull();
// let and const read their own binding in its temporal dead zone.
expect(rewrite("let ns = ns || {};\n")).toBeNull();
// Not the declared name on the left.
expect(rewrite("var ns = other || {};\n")).toBeNull();
});
@@ -0,0 +1,138 @@
/* The guarded namespace declaration of pre-module JavaScript:
*
* var ns = ns || {};
*
* In a module (CommonJS or ES), `var` is module-local and hoisted, so the
* read in its own initializer sees `undefined` unless something writes the
* variable first. When nothing else in the file ever writes it (no other
* declaration, assignment or update of that name anywhere, no function or
* class of that name) and no earlier top-level statement mentions it, the
* initializer is its right operand: `var ns = {};`. Inference then types
* the namespace from that literal and the members later statements add,
* instead of `any` (the self-reference makes the checker give up). `??`
* is the same. Anything else keeps the original text. */
import * as ts from "./ts7/syntax.js";
import { tokenStartOfNode } from "./ts7/node-functions.js";
import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js";
/** A cheap probe: some `var X = X ||` or `?? ` may be present. */
export function mayHoldNamespaceDefault(source: string): boolean {
return /\bvar\s+([A-Za-z_$][\w$]*)\s*=\s*\(?\s*\1\s*(\|\||\?\?)/.test(source);
}
/** The module text with each guarded namespace declaration respelled as a
* plain one, or null when there is none. */
export function rewriteNamespaceDefaults(sf: ts.SourceFile): string | null {
const stmts = sf.statements.nodes;
const edits: Edit[] = [];
for (let i = 0; i < stmts.length; i++) {
const stmt = stmts[i]!;
if (!ts.isVariableStatement(stmt)) continue;
const list = stmt.declarationList;
if ((list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0) continue;
for (const decl of list.declarations.nodes) {
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) continue;
const name = decl.name.text;
const init = unparen(decl.initializer);
if (
!ts.isBinaryExpression(init) ||
(init.operatorToken.kind !== ts.SyntaxKind.BarBarToken &&
init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken)
)
continue;
const left = unparen(init.left);
if (!ts.isIdentifier(left) || left.text !== name) continue;
if (mentionedBefore(stmts, i, name) || writtenElsewhere(sf, name, decl)) continue;
const right = init.right;
edits.push({
start: tokenStartOfNode(decl.initializer),
end: decl.initializer.end,
// A literal stays bare: inference types `var ns = {}` from the
// members later statements add (a parenthesized one it does not).
text:
ts.isObjectLiteralExpression(unparen(right)) ||
ts.isArrayLiteralExpression(unparen(right))
? sf.text.slice(tokenStartOfNode(right), right.end)
: `(${sf.text.slice(tokenStartOfNode(right), right.end)})`,
});
}
}
if (edits.length === 0) return null;
return applyEdits(sf.text, 0, sf.text.length, edits);
}
/** Whether a top-level statement before index `at` mentions `name`. */
function mentionedBefore(stmts: readonly ts.Statement[], at: number, name: string): boolean {
for (let i = 0; i < at; i++) {
let found = false;
ts.walkPreorder(stmts[i]!, (n) => {
if (found) return "stop";
if (ts.isIdentifier(n) && n.text === name) found = true;
return undefined;
});
if (found) return true;
}
return false;
}
/** Whether anything other than `decl` declares or writes `name`. */
function writtenElsewhere(sf: ts.SourceFile, name: string, decl: ts.VariableDeclaration): boolean {
let written = false;
ts.walkPreorder(sf, (n) => {
if (written) return "stop";
if (n === decl.name) return undefined;
if (ts.isBinaryExpression(n) && isAssignmentOperator(n.operatorToken.kind)) {
if (isName(n.left, name)) written = true;
} else if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken ||
n.operator === ts.SyntaxKind.MinusMinusToken) &&
isName(n.operand, name)
) {
written = true;
} else if (
(ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)) &&
n.name !== undefined &&
n.name.text === name
) {
written = true;
} else if (
(ts.isVariableDeclaration(n) || ts.isParameter(n) || ts.isBindingElement(n)) &&
n !== decl &&
n.name !== undefined &&
bindsName(n.name, name)
) {
// Another declaration of the name (a redeclaration, or a local that
// shadows it) — keep the original text rather than reason about it.
written = true;
} else if (
(ts.isForInStatement(n) || ts.isForOfStatement(n)) &&
!ts.isVariableDeclarationList(n.initializer) &&
isName(n.initializer, name)
) {
written = true;
}
return undefined;
});
return written;
}
function bindsName(binding: ts.Node, name: string): boolean {
if (ts.isIdentifier(binding)) return binding.text === name;
let found = false;
ts.walkPreorder(binding, (n) => {
if (found) return "stop";
if (ts.isIdentifier(n) && n.text === name) found = true;
return undefined;
});
return found;
}
function isName(node: ts.Node, name: string): boolean {
const n = unparen(node as ts.Expression);
return ts.isIdentifier(n) && n.text === name;
}
function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
@@ -505,6 +505,10 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
// literals they build.
const enums = original === null ? null : services.enumIifes(original, path);
if (enums !== null) original = enums;
// Guarded namespace declarations (`var ns = ns || {}`) read the
// hoisted `undefined`: the declaration is its default.
const namespaces = original === null ? null : services.namespaceDefaults(original, path);
if (namespaces !== null) original = namespaces;
// A module that names its export object through a variable reaches
// its members through `exports`.
const exportsVar = original === null ? null : services.cjsExportsVar(original, path);
@@ -517,6 +521,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
aliased !== null ||
fnExports !== null ||
enums !== null ||
namespaces !== null ||
exportsVar !== null;
if (original !== null) {
const namepaths = services.jsDocNamepaths(path, original);
@@ -14,6 +14,10 @@ import {
} from "./npm-static-builtin-aliases.js";
import { mayHoldUmdWrapper, unwrapUmdFactory } from "./npm-static-umd.js";
import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js";
import {
mayHoldNamespaceDefault,
rewriteNamespaceDefaults,
} from "./npm-static-namespace-defaults.js";
import { mayHoldTscBuiltinImport, unwrapTscBuiltinImports } from "./npm-static-tsc-helpers.js";
import { builtinPackageAliasFor } from "./package-shims.js";
import * as syntax from "./npm-static-declaration-syntax.js";
@@ -132,6 +136,13 @@ export class FrontendServices {
if (!mayHoldEnumIife(source)) return null;
return rewriteEnumIifes(this.parse(path, source, "js"));
}
/** A package module with its guarded namespace declarations
* (`var ns = ns || {}`) respelled as plain ones (null: none). */
namespaceDefaults(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldNamespaceDefault(source)) return null;
return rewriteNamespaceDefaults(this.parse(path, source, "js"));
}
/** A package module with its requires of builtin-aliased polyfill
* packages respelled as the builtins (null: none). */
builtinAliasRequires(source: string, path: string): string | null {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/legacy-namespaces/main.ts"
],
"diags": []
}
+26
View File
@@ -0,0 +1,26 @@
// A namespace-style library in the pre-module idiom (guarded namespace
// declarations, constructor functions with prototype methods, enums as
// objects, a revealing module, a handler registry, exported through
// `module.exports = { app }`), used from TypeScript.
import { app } from "legacy-ns";
const v = new app.geom.Vec(3, 4);
console.log(v.length(), v.add(new app.geom.Vec(1, 1)).scale(2).x, v instanceof app.geom.Vec);
console.log(Object.keys(app).join(","), Object.keys(app.math).join(","), Object.keys(app.geom.Kind).join(","));
console.log(app.math.clamp(12, 0, 10), app.math.lerp(0, 10, 0.25), app.text.pad(7, 3), app.text.label(1, 42));
const shapes = [];
for (let i = 0; i < 12; i++) {
const center = new app.geom.Vec(app.math.lerp(-5, 5, (i % 4) / 3), app.math.lerp(-5, 5, Math.floor(i / 4) / 2));
shapes.push(new app.geom.Shape(i % 3, center, 1 + (i % 4)));
}
let area = 0;
let hits = 0;
for (let step = 0; step < 200; step++) {
const probe = new app.geom.Vec(app.math.clamp(Math.sin(step) * 7, -5, 5), app.math.clamp(Math.cos(step * 0.7) * 7, -5, 5));
for (const s of shapes) {
area += app.registry.run("area", s, probe);
hits += app.registry.run("hits", s, probe);
}
}
console.log(Math.round(area), hits, shapes[4].contains(shapes[4].center), shapes[0].area());
@@ -0,0 +1,2 @@
declare const app: any;
export { app };
@@ -0,0 +1,97 @@
// A namespace-style geometry and text library in the pre-module idiom:
// guarded namespace declarations, constructor functions with prototype
// methods, enums as objects, a revealing module, and a static registry.
var app = app || {};
app.math = app.math || {};
app.geom = app.geom || {};
app.math.EPSILON = 1e-9;
app.math.clamp = function (value, lo, hi) {
return value < lo ? lo : value > hi ? hi : value;
};
app.math.lerp = function (a, b, t) {
return a + (b - a) * t;
};
app.math.hypot = function (x, y) {
return Math.sqrt(x * x + y * y);
};
app.geom.Kind = { POINT: 0, CIRCLE: 1, RECT: 2 };
/** @constructor */
app.geom.Vec = function (x, y) {
this.x = x;
this.y = y;
};
app.geom.Vec.prototype.add = function (other) {
return new app.geom.Vec(this.x + other.x, this.y + other.y);
};
app.geom.Vec.prototype.scale = function (k) {
return new app.geom.Vec(this.x * k, this.y * k);
};
app.geom.Vec.prototype.length = function () {
return app.math.hypot(this.x, this.y);
};
/** @constructor */
app.geom.Shape = function (kind, center, size) {
this.kind = kind;
this.center = center;
this.size = size;
};
app.geom.Shape.prototype.area = function () {
switch (this.kind) {
case app.geom.Kind.CIRCLE:
return Math.PI * this.size * this.size;
case app.geom.Kind.RECT:
return this.size * this.size;
default:
return 0;
}
};
app.geom.Shape.prototype.contains = function (p) {
var d = p.add(this.center.scale(-1)).length();
return d <= this.size + app.math.EPSILON;
};
app.text = (function () {
var digits = "0123456789";
function pad(n, width) {
var s = "";
var v = n;
do {
s = digits.charAt(v % 10) + s;
v = Math.floor(v / 10);
} while (v > 0);
while (s.length < width) s = "0" + s;
return s;
}
function label(kind, n) {
return (kind === app.geom.Kind.CIRCLE ? "c" : kind === app.geom.Kind.RECT ? "r" : "p") + pad(n, 4);
}
return { pad: pad, label: label };
})();
app.registry = {
handlers: {
area: function (shape) {
return shape.area();
},
hits: function (shape, probe) {
return shape.contains(probe) ? 1 : 0;
},
},
run: function (name, shape, probe) {
return app.registry.handlers[name](shape, probe);
},
};
module.exports = { app };
@@ -0,0 +1 @@
{ "name": "legacy-ns", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "legacy-namespaces-case", "private": true, "type": "module" }