Type process.getBuiltinModule results as module namespaces

Under @types/node, process.getBuiltinModule("node:os") types as the
module namespace, which lowered as an unchecked dynamic value and failed.
A string-literal id naming a supported builtin now lowers to the namespace
value an `import * as` binding has, including pi's cast-and-probe spelling
`(process as P).getBuiltinModule?.("node:os") ?? null`; a nullable
namespace binding narrowed present keys the same member tables, and a
namespace is truthy. process.features read as a whole value (`"sea" in
process.features`) is an object holding none of the flags, matching the
existing member reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 15:15:04 -07:00
co-authored by Claude Opus 5.5
parent 5e964965f6
commit 8a02efaa91
8 changed files with 108 additions and 1 deletions
@@ -5756,6 +5756,8 @@ export class LlEmitter {
// Process streams use a numeric descriptor internally, including zero
// for stdin. They remain JavaScript objects in boolean contexts.
case "procStream":
// A module namespace object is always present.
case "moduleNs":
return "true";
case "array":
case "record":
@@ -26,6 +26,7 @@ import {
funcOf,
} from "../../../ir/ir.js";
import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js";
import { canonicalBuiltinModule } from "../../builtin-modules.js";
import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js";
import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js";
@@ -364,6 +365,11 @@ export function lowerProcessProperty(
if (member === "versions") {
return { kind: "libCall", fn: "process.versions", args: [], type: DYN, loc };
}
// process.features as a value (`"sea" in process.features`): an object
// holding none of the flags, consistent with the member reads above.
if (member === "features") {
return { kind: "dynObjLit", type: DYN, loc };
}
// process.noDeprecation / process.throwDeprecation: the values the
// program assigned (undefined before that — no Node flags reach a
// compiled binary).
@@ -1521,3 +1527,36 @@ function processCredentialCall(member: string, loc: SrcLoc): IrExpr {
loc,
};
}
/** `process.getBuiltinModule("node:os")`: a string-literal id naming a
* supported builtin whose call the checker types as that module's
* namespace (@types/node's BuiltInModule map) answers the namespace value
* `import * as ns` binds, so its members key the builtin tables at their
* use sites. The receiver may be cast (`(process as P)`) and the call
* optional: Node 24's process always has the member, and the cast changes
* no runtime value. Other ids keep the dynamic loader (undefined for
* unknown ids, Node's argument errors). */
export function lowerProcessBuiltinModuleNamespace(
lowerer: Lowerer,
call: ts.CallExpression,
): IrExpr | null {
const callee = call.expression;
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "getBuiltinModule")
return null;
const id = call.arguments[0];
if (call.arguments.length !== 1 || !id || !ts.isStringLiteralLike(id)) return null;
let receiver: ts.Expression = callee.expression;
while (
ts.isParenthesizedExpression(receiver) ||
ts.isAsExpression(receiver) ||
ts.isSatisfiesExpression(receiver) ||
ts.isNonNullExpression(receiver)
)
receiver = receiver.expression;
if (!lowerer.isStdlibGlobal(receiver, "process")) return null;
const module = canonicalBuiltinModule(id.text);
if (module === null) return null;
const type = lowerer.mapTypeOf(lowerer.checker.getNonNullableType(lowerer.typeOf(call)));
if (type?.kind !== "moduleNs" || type.moduleId !== `builtin:${module}`) return null;
return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc: locOf(call) };
}
@@ -126,7 +126,10 @@ import {
lowerPerfHooksCall,
perfEntryTag,
} from "./builtins/performance.js";
import { lowerProcStreamMethodCall } from "./builtins/process.js";
import {
lowerProcStreamMethodCall,
lowerProcessBuiltinModuleNamespace,
} from "./builtins/process.js";
import { recordMayKeepSource, throughRecordSource } from "./record-sources.js";
import { lowerReflectCall } from "./builtins/reflection.js";
import {
@@ -363,6 +366,15 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
return { kind: "libCall", fn: "http2.streamNoop", args: [], type: VOID, loc };
}
// `process.getBuiltinModule("node:os")` typed as the module namespace,
// including pi's cast-and-probe spelling
// `(process as P).getBuiltinModule?.("node:os")` (the member always
// exists on Node 24's process object).
{
const namespace = lowerProcessBuiltinModuleNamespace(lowerer, expr);
if (namespace) return namespace;
}
// Optional-chain call forms: `f?.()` (the token on the call) and
// `a?.m()` (the token on the member access). The handled markers keep
// the chain lowering's re-entrant dispatch from looping.
@@ -11569,6 +11569,18 @@ export class Lowerer {
if (mapped?.kind === "moduleNs" && mapped.moduleId.startsWith("builtin:")) {
return mapped.moduleId.slice("builtin:".length);
}
// A nullable namespace binding (`const os: typeof NodeOs | null`, a
// probed process.getBuiltinModule result) read where the checker
// narrowed it present: its members key the same tables.
if (stored?.kind === "union") {
const present = this.unions.get(stored.unionId)?.arms.filter((arm) => !isUnitType(arm));
const only = present?.length === 1 ? present[0] : undefined;
if (only?.kind === "moduleNs" && only.moduleId.startsWith("builtin:")) {
const narrowed = this.mapTypeOf(this.typeOf(expr));
if (narrowed?.kind === "moduleNs" && narrowed.moduleId === only.moduleId)
return only.moduleId.slice("builtin:".length);
}
}
if (ts.isIdentifier(expr)) {
const symbol = this.checker.getSymbolAtLocation(expr);
const decl = symbol ? this.checker.declarationsOf(symbol)[0] : undefined;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6232-process-builtin-module-typed/main.ts"
],
"diags": []
}
@@ -0,0 +1,28 @@
// process.getBuiltinModule and process.features under @types/node: a
// literal id types as the module namespace, so the result is the same
// object `import * as os` binds (pi's user-agent probe casts process and
// calls the member optionally), and process.features is an object a
// feature probe can test with `in` (pi's single-executable check).
import * as os from "node:os";
import type * as NodeOs from "node:os";
const direct = process.getBuiltinModule("node:os");
console.log("direct", direct.platform() === os.platform(), direct.EOL === os.EOL);
console.log("bare id", process.getBuiltinModule("os").arch() === os.arch());
type ProcessWithOsBuiltinModule = typeof process & {
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
};
function loadNodeOs(): typeof NodeOs | null {
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
return null;
}
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
}
const nodeOs = loadNodeOs();
const agent = nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
console.log("agent", agent === `pi (${os.platform()} ${os.release()}; ${os.arch()})`);
console.log("present", nodeOs !== null, nodeOs ? typeof nodeOs.homedir() : "none");
const isNodeSeaBinary = "sea" in process.features && process.features.sea === true;
console.log("sea", isNodeSeaBinary, "sea" in process.features);
@@ -0,0 +1 @@
{ "type": "module" }
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true
}
}