Look up exports whose names start with "__" by their escaped key

TypeScript keys a symbol table entry whose name starts with `__` with an
extra `_` (`__x` is stored as `___x`; `symbolName` removes it). Native ESM
link checking looked an import up by its own spelling, so a program that
imports such an export compiled to Node's startup SyntaxError:

    // dep.ts
    export const __x = 1;
    // main.ts
    import { __x } from "./dep.ts"; // The requested module './dep.ts'
                                    // does not provide an export named '__x'

`escapedSymbolName`, the inverse of `symbolName`, gives a name's table key.
Link checking, external type lookups, CommonJS export lookups and the
`this` check of ES module exports now look names up through it. `Object.keys`
over a module namespace skipped every key starting with `__` to drop
TypeScript's internal names, so it also dropped these exports; it now
skips only internal names and lists exports by their own names.

Closes #1582.

Co-authored-by: thekevinbrown <1531750+thekevinbrown@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-10-23 15:04:56 -05:00
co-authored by thekevinbrown
parent 850710fbea
commit 19d6736b01
14 changed files with 82 additions and 7 deletions
@@ -381,7 +381,7 @@ function esModuleExportIgnoresThis(
depth: number,
): boolean {
const moduleSym = lowerer.checker.getSymbolAtLocation(sf);
const exported = moduleSym?.getExports().get(name as ts.__String);
const exported = moduleSym?.getExports().get(ts.escapedSymbolName(name));
if (exported !== undefined) {
const target =
exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported;
@@ -8777,11 +8777,13 @@ function lowerObjectStaticCall(
const moduleSymbol = source ? lowerer.checker.getSymbolAtLocation(source) : undefined;
const names: string[] = [];
moduleSymbol?.getExports().forEach((symbol, key) => {
// Internal names (`__export`) start with `__`; an exported name that
// does is keyed with an extra `_` (escapedSymbolName).
const name = String(key);
if (name.startsWith("__") || name === "export=") return;
if ((name.startsWith("__") && !name.startsWith("___")) || name === "export=") return;
const target =
symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol;
if (target.flags & ts.SymbolFlags.Value) names.push(name);
if (target.flags & ts.SymbolFlags.Value) names.push(ts.symbolName(symbol));
});
names.sort();
const loc = locOf(call);
@@ -4219,7 +4219,7 @@ export class Lowerer {
// exact mappings, while star exports resolve to the mapped declaration
// owner through externalTypeSpecifiersByFile.
const moduleSymbol = this.checker.getSymbolAtLocation(sf);
const exported = moduleSymbol?.getExports().get(exportName as ts.__String);
const exported = moduleSymbol?.getExports().get(ts.escapedSymbolName(exportName));
return this.externalTypeSpecifierOfSymbol(exported, seenSymbols, seenExports);
}
@@ -4362,7 +4362,7 @@ export class Lowerer {
cjsModuleExportSymbol(sf: ts.SourceFile, name: string): ts.Symbol | undefined {
const moduleSym = this.checker.getSymbolAtLocation(sf);
const exports = moduleSym?.getExports();
const direct = exports?.get(name as ts.__String);
const direct = exports?.get(ts.escapedSymbolName(name));
if (direct) return direct;
// tsgo represents `module.exports = { ... }` as one `export=` symbol
// and does not duplicate the object literal's properties into the
+1 -1
View File
@@ -6437,7 +6437,7 @@ function analyzeEsmNamedImportLinks(
const nextSeen = new Set(seenModules);
nextSeen.add(dep);
const module = checker.getSymbolAtLocation(dep);
const exported = module?.getExports().get(name as ts.__String);
const exported = module?.getExports().get(ts.escapedSymbolName(name));
if (exported !== undefined) {
let resolved = exported;
const seenSymbols = new Set<ts.Symbol>();
@@ -28,6 +28,7 @@ import {
SemanticSnapshot,
SemanticType,
symbolNameOf,
escapedSymbolName,
} from "./semantic-model.js";
import type { __String } from "./ast-types.js";
import {
@@ -313,6 +314,11 @@ test("symbol tables keep escaped keys, display names, canonical context and cach
const exports = parent.getExports();
expect(symbolNameOf(members.get("___x" as __String))).toBe("__x");
expect(symbolNameOf(exports.get("__x" as __String))).toBe("__x");
// A name that starts with `__` is keyed with an extra `_`.
expect(symbolNameOf(members.get(escapedSymbolName("__x")))).toBe("__x");
expect(escapedSymbolName("x")).toBe("x");
expect(escapedSymbolName("_x")).toBe("_x");
expect(escapedSymbolName("___y")).toBe("____y");
expect(parent.getMembers()).toBe(members);
expect(parent.getExports()).toBe(exports);
expect(parent.getParent()).toBe(first.symbol(symbol(2)));
@@ -454,6 +454,12 @@ export function symbolName(symbol: SemanticSymbol): string {
return name.startsWith("___") ? name.slice(1) : name;
}
/** The key of the name `name` in a symbol table (`getExports()`, `getMembers()`): the name, with
* an extra `_` when it starts with `__`. The inverse of `symbolName`. */
export function escapedSymbolName(name: string): __String {
return (name.startsWith("__") ? `_${name}` : name) as __String;
}
/** `symbolName(symbol)`, or undefined without a symbol. */
export function symbolNameOf(symbol: SemanticSymbol | null | undefined): string | undefined {
return symbol === undefined || symbol === null ? undefined : symbolName(symbol);
@@ -30,7 +30,7 @@ export type {
SemanticSignature as Signature,
SemanticSymbol as Symbol,
} from "./semantic-model.js";
export { symbolName, symbolNameOf } from "./semantic-model.js";
export { escapedSymbolName, symbolName, symbolNameOf } from "./semantic-model.js";
export type {
SemanticIndexInfo as IndexInfo,
SemanticTypePredicate as TypePredicate,
@@ -0,0 +1,10 @@
{
"order": [
"<repo>/tests/corpus/7306-dunder-exports/dep.ts",
"<repo>/tests/corpus/7306-dunder-exports/star.ts",
"<repo>/tests/corpus/7306-dunder-exports/facade.ts",
"<repo>/tests/corpus/7306-dunder-exports/table.cjs",
"<repo>/tests/corpus/7306-dunder-exports/main.ts"
],
"diags": []
}
+19
View File
@@ -0,0 +1,19 @@
export const __x = 1;
export const ___y = "three";
export let __count = 0;
export function __bump(): number {
__count++;
return __count;
}
export class __Point {
a: number;
b: number;
constructor(a: number, b: number) {
this.a = a;
this.b = b;
}
sum(): number {
return this.a + this.b;
}
}
export const plain = "plain";
@@ -0,0 +1,2 @@
export { __x, __x as __alias, ___y as renamed } from "./dep.ts";
export * from "./star.ts";
+20
View File
@@ -0,0 +1,20 @@
// Exports whose names start with "__": named imports of a const, a function,
// a class and a live let, a name with three underscores, aliased re-exports,
// names reached through `export *`, namespace member reads and keys, and
// CommonJS exports read through require.
import { __x, ___y, __count, __bump, __Point, plain } from "./dep.ts";
import { __alias, renamed, __starred, __twice } from "./facade.ts";
import * as facade from "./facade.ts";
import * as depNs from "./dep.ts";
import { createRequire } from "node:module";
console.log(__x, ___y, plain);
console.log(__count, __bump(), __bump(), __count);
console.log(new __Point(2, 3).sum());
console.log(__alias, renamed, __starred, __twice(21));
console.log(facade.__x, facade.__starred, facade.__twice(2));
console.log(Object.keys(depNs).join(","));
const require = createRequire(import.meta.url);
const table = require("./table.cjs");
console.log(table.__a, table.__hello("dunder"), table.b);
+4
View File
@@ -0,0 +1,4 @@
export const __starred = "from export *";
export function __twice(n: number): number {
return n * 2;
}
@@ -0,0 +1,5 @@
exports.__a = 10;
exports.__hello = function (name) {
return "hello " + name;
};
exports.b = 20;
@@ -0,0 +1 @@
{ "plain": 2400, "san": 3600 }