Merge main

This commit is contained in:
Chris Tate
2026-10-30 09:45:28 -05:00
249 changed files with 6924 additions and 386 deletions
@@ -1,6 +1,8 @@
- **EventEmitter:** Literal event names use fixed argument tuples.
Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion.
Unrestricted names, symbols, and names that may reach internal events are unsupported.
Unrestricted string names on plain emitters register and emit through checked-dynamic listeners, with Node's `error` and meta-event behavior.
Unrestricted names on streams, symbols, and prefixed or suffixed names that may reach internal events are unsupported.
Once the program registers a listener under an unrestricted name, typed `listeners()`/`rawListeners()` results are unsupported for events that literal names register with typed listeners.
An emitter reached through an untyped value shares one listener list with its typed uses, so untyped registrations and emits reach typed listeners and typed emits reach untyped listeners.
An untyped `emit` of an event that has a listener with typed parameters converts its arguments to the event's typed tuple before any listener runs, and throws a `TypeError` when they do not convert.
Through an untyped value, `listeners()` and `rawListeners()` of an event with a listener that has typed parameters are unsupported, and `emit('error')` of an object that is not an Error with no `error` listener is unsupported.
@@ -4,7 +4,7 @@ Empty record layouts are also snapshots because they have no property storage.
`scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal.
Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage.
A record or class instance converted to a string-keyed dictionary type such as `Record<string, string>`, directly or through a `{}`, `object` or `unknown` binding or parameter, keeps its identity when the program writes through, compares or inspects values of that dictionary type.
A record or class instance converted to a string-keyed dictionary type such as `Record<string, string>`, directly, through a `{}`, `object` or `unknown` binding or parameter, or as an element of a constant `unknown[]` array that holds such records, keeps its identity when the program writes through, compares or inspects values of that dictionary type.
Every dictionary type with the same value type then uses checked dynamic storage, which is slower than a native dictionary.
Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js.
These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects.
@@ -1,6 +1,8 @@
Casts from values whose type is only known at runtime validate the value.
For example, `JSON.parse(s) as Config` throws a catchable error if the data does not match `Config`, identifying the offending path, such as `expected number at $.port, got string`.
A TypeScript assertion alone does not perform this validation when run under Node.js.
A record or class instance cast from `unknown` to a dictionary type such as `Record<string, number>` keeps its identity, and the cast does not validate it.
Reading a value that does not match the dictionary's value type throws a `TypeError`, such as `expected number at $, got string`, where Node.js returns the value.
Casts between program classes are checked too: casting an instance to a class it does not extend throws a `TypeError`, where Node.js reads the members structurally.
`SC6004` reports calls that pass such an instance to a function that casts it.
+2
View File
@@ -178,6 +178,8 @@ declare module "net" {
getConnections(callback: (error: Error | null, count: number) => void): Server;
/* Node answers the server itself (`return this` chaining). */
listen(port: number, callback?: () => void): Server;
/* No port: an arbitrary unused one. */
listen(callback?: () => void): Server;
/* The positional bind address (the options form's host in argument
* spelling). */
listen(port: number, host: string, callback?: () => void): Server;
+1 -1
View File
@@ -294,7 +294,7 @@ export async function linkNativeExecutable(
...hugeTextLinkerArgs(plan.hugeText === true, (withLld && lld.length !== 0) || zig),
...plan.driverFlags,
...mapArgs,
...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)),
...(withLld ? inputs : inputsWithoutLld(plan.target, inputs)),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
privateOut,
+10 -4
View File
@@ -60,9 +60,15 @@ test.skipIf(process.platform === "win32")(
},
);
test("Linux links without lld leave out the glibc math unit", () => {
test("glibc links without lld leave out the glibc math unit", () => {
const inputs = ["/pack/scr_lib.o", "/pack/scr_glibc_math.o", "/tmp/program.o"];
expect(inputsWithoutLld("linux", inputs)).toEqual(["/pack/scr_lib.o", "/tmp/program.o"]);
expect(inputsWithoutLld("darwin", inputs)).toEqual(inputs);
expect(inputsWithoutLld("windows", inputs)).toEqual(inputs);
const target = (platform: string, name: string) => ({ platform, name });
expect(inputsWithoutLld(target("linux", "linux-x64-gnu"), inputs)).toEqual([
"/pack/scr_lib.o",
"/tmp/program.o",
]);
expect(inputsWithoutLld(target("linux", "linux-x64-musl"), inputs)).toEqual(inputs);
expect(inputsWithoutLld(target("linux", "linux-arm64-musl"), inputs)).toEqual(inputs);
expect(inputsWithoutLld(target("darwin", "darwin-arm64"), inputs)).toEqual(inputs);
expect(inputsWithoutLld(target("windows", "win32-x64-msvc"), inputs)).toEqual(inputs);
});
+8 -3
View File
@@ -58,10 +58,15 @@ export function lldLinkerArgs(
* libm needed, and it keeps every regular definition of a name that a needed
* shared library also defines, hidden or not: the unit would add all of its
* code to every executable and save nothing. Its functions answer what
* glibc's do, so linking glibc's own instead is equivalent.
* glibc's do, so linking glibc's own instead is equivalent. A musl
* executable is static and has no libm.so to keep needed, so its links keep
* the unit, which replaces the libc's slower definitions there.
*/
export function inputsWithoutLld(platform: string, inputs: readonly string[]): string[] {
return platform === "linux"
export function inputsWithoutLld(
target: { readonly platform: string; readonly name: string },
inputs: readonly string[],
): string[] {
return target.platform === "linux" && !target.name.endsWith("-musl")
? inputs.filter((input) => basename(input) !== "scr_glibc_math.o")
: [...inputs];
}
+100 -4
View File
@@ -223,6 +223,9 @@ export interface DynHost extends WalkerHost {
/** The closures with a jsval body (IrFunction.jsEntry) on targets with
* jsval words, which the box builders give a jsval entry. */
jsEntryClosures?(): readonly JsEntryClosure[];
/** The entry point and JavaScript name of each named function the module
* makes closures of (LlEmitter.closureNames). */
closureNames?(): readonly { entry: string; name: string }[];
}
/** A closure with a jsval body (IrFunction.jsEntry), for the box builders. */
@@ -309,6 +312,8 @@ export class LlDyn {
return this.dynFuncBoxHelper(args[0] as IrType & { kind: "func" });
case "dyn.jsEntryThunk":
return this.jsEntryThunk(args[0] as string, args[1] as IrType[], args[2] as IrType);
case "dyn.unnamedFuncBoxHelper":
return this.unnamedFuncBoxHelper(args[0] as IrType & { kind: "func" }) ?? "";
case "dyn.dynFuncAdapterHelper":
return this.dynFuncAdapterHelper(args[0] as IrType & { kind: "func" });
case "dyn.dynToStrHelper":
@@ -3561,7 +3566,7 @@ export class LlDyn {
}
case "func": {
const r = B.tmp();
B.line(`${r} = call ptr @${this.dynFuncBoxHelper(t)}(ptr %v, ptr null)`);
B.line(`${r} = ${this.unnamedFuncBoxCall(t, "%v")}`);
B.terminate(`ret ptr ${r}`);
break;
}
@@ -3972,7 +3977,7 @@ export class LlDyn {
// function boundary (the dynFrom func special case, sans name).
const p = emitUnionPeek(B, nullable, "%v");
const r = B.tmp();
B.line(`${r} = call ptr @${this.dynFuncBoxHelper(arm)}(ptr ${p}, ptr null)`);
B.line(`${r} = ${this.unnamedFuncBoxCall(arm, p)}`);
B.terminate(`ret ptr ${r}`);
} else {
const p = emitUnionPeek(B, nullable, "%v");
@@ -5359,9 +5364,8 @@ export class LlDyn {
private toDynExpr(B: BlockBuilder, t: IrType, expr: string): string {
if (t.kind === "dyn") return this.retainDyn(B, expr);
if (t.kind === "func") {
const box = this.dynFuncBoxHelper(t);
const r = B.tmp();
B.line(`${r} = call ptr @${box}(ptr ${expr}, ptr null)`);
B.line(`${r} = ${this.unnamedFuncBoxCall(t, expr)}`);
return r;
}
@@ -5651,6 +5655,98 @@ export class LlDyn {
return name;
}
private closureNames: { symbol: string; count: number } | null | undefined;
/** The module's closure-name lookup (`sc_closure_name(closure)`, over a
* table of `{ ptr entry, ptr name }` pairs), defined on first use; null
* when no named function becomes a closure. */
private closureNameTable(): { symbol: string; count: number } | null {
if (this.closureNames !== undefined) return this.closureNames;
const entries = this.host.closureNames?.() ?? [];
if (entries.length === 0) return (this.closureNames = null);
const host = this.host;
const S = host.sizeType;
const table = "sc_closure_names";
const symbol = "sc_closure_name";
// The closure's entry point (an adapter's root's, ScrClosure.identity)
// compared with each entry; null when none matches.
this.defs.push(
`@${table} = internal constant [${entries.length} x { ptr, ptr }] [${entries
.map((e) => `{ ptr, ptr } { ptr @${e.entry}, ptr ${host.cstr(e.name)} }`)
.join(", ")}]`,
``,
`define internal ptr @${symbol}(ptr %v) ${FN_ATTRS} { ; the JavaScript name of a closure's function`,
`entry:`,
` %idp = getelementptr inbounds %ScrClosure, ptr %v, i64 0, i32 6`,
` %id = load ptr, ptr %idp`,
` %own = icmp eq ptr %id, null`,
` %root = select i1 %own, ptr %v, ptr %id`,
` %fnp = getelementptr inbounds %ScrClosure, ptr %root, i64 0, i32 1`,
` %fn = load ptr, ptr %fnp`,
` br label %loop`,
`loop:`,
` %i = phi ${S} [ 0, %entry ], [ %next, %step ]`,
` %done = icmp eq ${S} %i, ${entries.length}`,
` br i1 %done, label %none, label %test`,
`test:`,
` %ep = getelementptr inbounds [${entries.length} x { ptr, ptr }], ptr @${table}, i64 0, ${S} %i, i32 0`,
` %e = load ptr, ptr %ep`,
` %hit = icmp eq ptr %e, %fn`,
` br i1 %hit, label %found, label %step`,
`step:`,
` %next = add ${S} %i, 1`,
` br label %loop`,
`found:`,
` %np = getelementptr inbounds [${entries.length} x { ptr, ptr }], ptr @${table}, i64 0, ${S} %i, i32 1`,
` %name = load ptr, ptr %np`,
` ret ptr %name`,
`none:`,
` ret ptr null`,
`}`,
``,
);
return (this.closureNames = { symbol, count: entries.length });
}
private readonly unnamedFuncBoxes = new Map<string, string>();
/** `sc_dfu_<n>(ptr closure) -> ptr`: the box of a function value with no
* static name (one inside a record, array or other value being
* converted), named from the module's closure-name table. The plain box
* when no named function becomes a closure. */
unnamedFuncBoxHelper(t: IrType & { kind: "func" }): string | null {
if ((this.host.closureNames?.() ?? []).length === 0) return null;
const box = this.dynFuncBoxHelper(t);
const key = typeKey(t);
const existing = this.unnamedFuncBoxes.get(key);
if (existing) return existing;
const name = `sc_dfu_${this.unnamedFuncBoxes.size}`;
this.unnamedFuncBoxes.set(key, name);
this.host.journalAllocated?.(name, "dyn.unnamedFuncBoxHelper", [t]);
// The first box defines the table and its lookup.
const table = this.closureNameTable()!;
this.defs.push(
`define internal ptr @${name}(ptr %v) ${FN_ATTRS} { ; box ${key} into dyn with its own name`,
`entry:`,
` %n = call ptr @${table.symbol}(ptr %v)`,
` %r = call ptr @${box}(ptr %v, ptr %n)`,
` ret ptr %r`,
`}`,
``,
);
this.host.journalBuilt?.();
return name;
}
/** The call that boxes the function value `value` of type `t` without a
* static name (unnamedFuncBoxHelper). */
unnamedFuncBoxCall(t: IrType & { kind: "func" }, value: string): string {
const named = this.unnamedFuncBoxHelper(t);
return named === null
? `call ptr @${this.dynFuncBoxHelper(t)}(ptr ${value}, ptr null)`
: `call ptr @${named}(ptr ${value})`;
}
/** The box builder dynFrom emits for one closure signature. */
dynFuncBoxHelper(t: IrType & { kind: "func" }): string {
const key = typeKey(t);
@@ -924,6 +924,7 @@ const POLL_EVERY_ITERATION = "%worker.poll.every";
/** Runtime library calls (readsLoopMetrics). */
const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"];
const CLOSURE_KINDS: readonly IrNodeKind[] = ["closure"];
/** [A-Za-z0-9_] */
function isIdentifierByte(code: number): boolean {
@@ -2465,6 +2466,7 @@ export class LlEmitter {
);
},
pendingTestLines: (dest) => this.pendingTestLines(dest),
closureNames: () => this.closureNames(),
virtualEntry: (implFn) => this.virtualEntry(implFn),
programLifetime: this.programLifetime,
regionAliasTag: (word) => this.memoryAliasAttachment(`region.${word}`),
@@ -2808,6 +2810,33 @@ export class LlEmitter {
return this.heapCensus === null ? out : [...out, this.heapCensus.definitions().join("\n")];
}
/** The entry point and JavaScript name of each named function the
* module makes closures of: a declared function's wrapper (its interned
* closure's entry) or a lifted function's call target, with its
* displayName, else its sourceName. Function boxes without a static name
* look the closure's entry up in this table. */
private closureNameEntries: readonly { entry: string; name: string }[] | undefined;
closureNames(): readonly { entry: string; name: string }[] {
if (this.closureNameEntries !== undefined) return this.closureNameEntries;
const entries = new Map<string, string>();
const scan = this.nodeIndex.scan(CLOSURE_KINDS);
for (const fn of this.mod.functions) {
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind !== "closure") return true;
const target = this.fnByName.get(node.fnName);
const name =
target?.displayName ??
(target?.sourceName !== "<anonymous>" ? target?.sourceName : undefined);
if (target === undefined || name === undefined || name === "") return true;
const entry =
target.captures === undefined ? mangleWrapper(node.fnName) : this.callTarget(node.fnName);
if (!entries.has(entry)) entries.set(entry, name);
return true;
});
}
return (this.closureNameEntries = [...entries].map(([entry, name]) => ({ entry, name })));
}
/** The dispose-observer gates the emitted frees and record writers test
* (alloc.ts): defined after every helper is emitted, because dynCheck
* builders, JSON schemas and weak collection constructors are requested
@@ -8598,6 +8627,7 @@ for (const op of [
"dyn.dynFuncThunkHelper",
"dyn.dynFuncBoxHelper",
"dyn.jsEntryThunk",
"dyn.unnamedFuncBoxHelper",
"dyn.dynFuncAdapterHelper",
"dyn.dynToStrHelper",
"dyn.dynIterNHelper",
@@ -120,10 +120,12 @@ export function emitDynamicExpr(
// A closure boxes as the checked-dynamic tree's function kind: retained closure +
// the per-signature call thunk + the interned signature key. The
// best-effort name rides along (null when the lowering had none).
const name = e.fnName !== undefined && e.fnName !== "" ? host.cstr(e.fnName) : "null";
const box = host.dyn.dynFuncBoxHelper(v.type);
const t = B.tmp();
B.line(`${t} = call ptr @${box}(ptr ${v.name}, ptr ${name})`);
if (e.fnName !== undefined && e.fnName !== "") {
const name = host.cstr(e.fnName);
const box = host.dyn.dynFuncBoxHelper(v.type);
B.line(`${t} = call ptr @${box}(ptr ${v.name}, ptr ${name})`);
} else B.line(`${t} = ${host.dyn.unnamedFuncBoxCall(v.type, v.name)}`);
if (e.fnLength !== undefined) {
host.declare(`declare void @scr_dyn_function_set_length(ptr, i32)`);
B.line(`call void @scr_dyn_function_set_length(ptr ${t}, i32 ${e.fnLength})`);
@@ -1488,6 +1488,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
// the client request forms have non-generic shapes — special-cased.
"net.serverPort": "scr_net_server_port",
"net.serverListening": "scr_net_server_listening",
"net.listenPortDyn": "scr_net_listen_port_dyn",
"net.sockSetRef": "scr_net_sock_set_ref",
"net.serverSetRef": "scr_net_server_set_ref",
"net.serverOff": "scr_net_server_off",
+4
View File
@@ -165,6 +165,10 @@ export type CompileFailure = {
export interface CompileWarnings {
warnings?: ScrDiagnostic[];
sourceTexts?: Map<string, string>;
/** The Node islands the build ran: the npm packages and the program
* modules (absolute file paths). Reported again on an executable cache
* hit, so a runner can tell an island result from a native one. */
islands?: { packages: string[]; modules: string[] };
}
export type CompileSourceResult =
@@ -234,6 +234,18 @@ export function nodeIslandModuleDiag(message: string, loc: SrcLoc): ScrDiagnosti
};
}
/** SC8006 — a program module that runs in a Node island names a data file
* by a path where there is none while a file of that name is in its
* directory: the build ships data files by their exact path only. */
export function nodeIslandDataFileDiag(message: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8006",
message,
loc,
hint: "name the data file by its path relative to the module that reads it; the build ships a Node island module's data files by their exact path",
};
}
/* ── SC6xxx: divergences from Node (warnings) ─────────────────────────── */
/** SC6001 — a value passed to (or declared as) a narrower record type is
@@ -354,6 +366,55 @@ export function nativeSubclassOfIslandDiag(loc: SrcLoc, detail?: string): ScrDia
};
}
/** SC8006 — the entry module patches a builtin module's CommonJS object.
* The entry always stays native (a whole-file island is a different
* feature), so the patch cannot run: it belongs in a module of its own,
* which then runs in a Node island. */
export function nodeIslandEntryPatchDiag(
file: string,
member: string,
module: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8006",
message: `the entry module patches '${member}' on the CommonJS ${module} object; move the patch into a module of its own, which then runs in a Node island`,
loc,
hint: `the entry module ${file} stays native; only a separate module runs in Node`,
};
}
/** SC8006 — a user TypeScript module patches a builtin module's CommonJS
* object but uses syntax Node cannot run with type stripping only. */
export function nodeIslandStripDiag(file: string, construct: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8006",
message: `'${file}' patches a builtin module's CommonJS object, but Node cannot strip its types: ${construct}`,
loc,
hint: "Node runs TypeScript by erasing types: avoid enums, namespaces, constructor parameter properties, decorators, JSX, import =/export =, extensionless relative imports and value imports of types; a separate .js module can hold this patch",
};
}
/** SC8020 — native code reads a builtin module member that an island
* module patches: Node would show the read the patch, the native build
* cannot. ES module bindings of builtins never see the patch (in Node
* either), so the named import is the exact spelling. */
export function nodeIslandPatchedReadDiag(
member: string,
patchFile: string,
loc: SrcLoc,
): ScrDiagnostic {
const dot = member.indexOf(".");
const module = dot < 0 ? member : member.slice(0, dot);
const property = dot < 0 ? member : member.slice(dot + 1);
return {
code: "SC8020",
message: `'${member}' is patched by ${patchFile}, which runs in a Node island; Node would show this read through the CommonJS ${module} object the patch, the native build cannot. Use \`import { ${property} } from "node:${module}"\` (ES module bindings never see the patch, in Node either), or move this code into ${patchFile}.`,
loc,
hint: `node:${module} ES module bindings keep the original function, in Node too`,
};
}
/** SC8020 — a stateful module the program compiles is duplicated across
* the Node island boundary (an explicit "duplicate", or a form NI-6 cannot
* share yet): each side has its own state. */
@@ -453,6 +514,22 @@ export function nodeIslandNoteDiag(
};
}
/** SC8001 — a program module runs in a Node island (a note, once per island
* module in the build output; DESIGN.md note 1 §3.2). */
export function nodeIslandModuleNoteDiag(
file: string,
klass: string,
reason: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8001",
message: `module '${file}' runs in a Node island: ${klass}: ${reason}`,
loc,
hint: "sloppy-mode code Node runs; --node-islands=off compiles this module natively instead",
};
}
/** SC8010 — a value crosses an island boundary in a way NI-4 cannot keep
* exact yet (callbacks, native objects, remote promises). */
export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic {
@@ -151,6 +151,8 @@ interface EarlyExecutableCacheStamp {
native: EarlyExecutableNativeFeatures;
/** The build's performance warnings (SC61xx), which a cache hit repeats. */
performance?: ScrDiagnostic[];
/** The Node islands the build ran, which a cache hit reports again. */
islands?: { packages: string[]; modules: string[] };
/** Programs that call into Node.js: the libnode pack identity the build
* recorded (EarlyExecutableCacheOptions.libnodeIdentity). */
libnode?: string;
@@ -266,6 +268,8 @@ export interface EarlyExecutableCacheHit {
frontend: FrontendInputSnapshot;
/** The performance warnings the build reported (SC61xx), repeated on a hit. */
performance?: ScrDiagnostic[];
/** The Node islands the build ran, repeated on a hit. */
islands?: { packages: string[]; modules: string[] };
/** How a --profile-use build's profile matched, reported again on a hit. */
profileMatch?: NativeProfileMatch;
}
@@ -274,6 +278,16 @@ export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit {
nativeDependencies?: NativeArtifactDependency[];
}
function isIslands(value: unknown): value is { packages: string[]; modules: string[] } {
if (typeof value !== "object" || value === null) return false;
const islands = value as Record<string, unknown>;
return (
Array.isArray(islands.packages) &&
Array.isArray(islands.modules) &&
[...islands.packages, ...islands.modules].every((item) => typeof item === "string")
);
}
function isProfileMatch(value: unknown): value is NativeProfileMatch {
if (typeof value !== "object" || value === null) return false;
const match = value as Record<string, unknown>;
@@ -756,6 +770,7 @@ async function readExecutableCache(
...(Array.isArray(stamp.performance) && stamp.performance.length > 0
? { performance: stamp.performance }
: {}),
...(isIslands(stamp.islands) ? { islands: stamp.islands } : {}),
...(isProfileMatch(stamp.profileMatch) ? { profileMatch: stamp.profileMatch } : {}),
...(ir === null ? {} : { irPath: paths.irPath }),
...(llvmSourcePath === undefined ? {} : { llvmSourcePath }),
@@ -947,6 +962,7 @@ export async function publishEarlyExecutableCache(
...(result.performance === undefined || result.performance.length === 0
? {}
: { performance: result.performance }),
...(result.islands === undefined ? {} : { islands: result.islands }),
...(result.native.nodeIsland === true ? { libnode: options.libnodeIdentity?.() ?? "" } : {}),
...(result.profileMatch === undefined ? {} : { profileMatch: result.profileMatch }),
};
+315 -14
View File
@@ -1,9 +1,14 @@
import { realpathSync } from "node:fs";
import type { CompileFailure, CompileRequestOptions } from "../compile-types.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { frontendDisposed } from "../frontend/dispose-hook.js";
import {
checkerPanicDiag,
nodeIslandEntryPatchDiag,
nodeIslandModuleNoteDiag,
nodeIslandNoteDiag,
nodeIslandPatchedReadDiag,
nodeIslandStripDiag,
nodeIslandRetryFailedDiag,
npmStaticRefusalDiag,
iceDiag,
@@ -24,15 +29,25 @@ import type { FrontendFactory } from "../frontend/pipeline.js";
import type { UnitStore } from "../frontend/lowering/unit-reuse.js";
import type { CompilationTiming } from "../timing.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
import {
isMarkedUserModule,
nodeIslandPolicy,
type NodeIslandPolicy,
} from "../node-islands/policy.js";
import { buildNodeIslandReport } from "../node-islands/report.js";
import type { IslandPackage, NodeIslandSet } from "../node-islands/boundary.js";
import { classifyDiagnostic, isIslandReasonCode } from "../node-islands/classify.js";
import type { IslandModule, IslandPackage, NodeIslandSet } from "../node-islands/boundary.js";
import {
classifyDiagnostic,
classifyMarker,
isIslandReasonCode,
} from "../node-islands/classify.js";
import { packageDirOf, packageVersionOf } from "../node-islands/origin.js";
import { describe } from "../node-islands/summary.js";
import { resolveBareModule } from "../frontend/resolve.js";
import { isEsmFile } from "../frontend/lowering/lower-node-island.js";
import type { PackageEdge } from "../node-islands/scan.js";
import type { NodeIslandScan, PackageEdge } from "../node-islands/scan.js";
import type { NodeIslandMarker } from "../node-islands/detect.js";
import { isJsSourceFileName } from "../frontend/tsc-codes.js";
export interface PreparedExecutableModule {
ok: true;
@@ -42,6 +57,10 @@ export interface PreparedExecutableModule {
/** Divergence warnings (SC6xxx): the build succeeds; these sites can
* behave differently from Node. */
warnings: ScrDiagnostic[];
/** The Node islands the build ran (the SC8001 notes name them with their
* reasons): packages by name, modules by file. A cached build repeats
* them. */
islands?: { packages: string[]; modules: string[] };
}
/** Release the parser/checker before native object generation begins.
@@ -57,6 +76,7 @@ export function prepareExecutableModule(
lowerIslands: ReadonlyMap<string, IslandPackage> = new Map(),
retryFences = true,
unitStore: UnitStore | null = null,
lowerModules: ReadonlyMap<string, IslandModule> = new Map(),
): PreparedExecutableModule | CompileFailure {
// The island decision reads the detector's scan unless islands are off.
const islandScan =
@@ -66,11 +86,22 @@ export function prepareExecutableModule(
// Node islands (scope-B NI-4): packages refused natively run in Node,
// unless the policy says otherwise. The frontend reloads without them,
// so their declarations type the program.
const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands);
const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands, lowerModules);
// A patched builtin the native build cannot represent (an entry module
// patch, or a native read of a patched member) fails here.
if (islandDecision !== null && islandDecision.errors.length > 0) {
const diagnostics = islandDecision.errors;
const sourceTexts = fe.sourceTexts();
fe.dispose();
return { ok: false, diagnostics, sourceTexts };
}
if (islandDecision !== null) {
fe.dispose();
fe = createFrontend(entryPath, opts.npmStatic, undefined, undefined, {
islandPackages: new Set(islandDecision.packages.keys()),
...(islandDecision.modules.size > 0
? { islandModules: new Set(islandDecision.modules.keys()) }
: {}),
islandScan,
});
timing("frontend-island-reload");
@@ -121,6 +152,7 @@ export function prepareExecutableModule(
grown,
retryFences,
unitStore,
lowerModules,
);
}
return fail(fe.preflight);
@@ -159,7 +191,11 @@ export function prepareExecutableModule(
lowerIslands,
fe.nodeIslandScan?.({ userJs: false }).edges,
);
if (grown !== null) {
// Sloppy user modules the lowering refused join the same retry
// (DESIGN.md note 1 §3.2): one reload with every island at once,
// never one round per module.
const grownModules = loweringModules(entryPath, opts, lowered.diagnostics, lowerModules);
if (grown !== null || grownModules !== null) {
fe.dispose();
frontendDisposed();
disposed = true;
@@ -170,9 +206,10 @@ export function prepareExecutableModule(
buildPlatform,
createFrontend,
timing,
grown,
grown ?? lowerIslands,
retryFences,
unitStore,
grownModules ?? lowerModules,
);
}
// A failed program whose compiled packages need the runtime or a
@@ -198,6 +235,8 @@ export function prepareExecutableModule(
timing,
marked,
false,
undefined,
lowerModules,
);
}
return fail(lowered.diagnostics);
@@ -258,6 +297,14 @@ export function prepareExecutableModule(
...(lowered.divergences ?? []),
...(lowered.performance ?? []),
],
...(islandDecision === null
? {}
: {
islands: {
packages: [...islandDecision.packages.keys()].sort(),
modules: [...islandDecision.modules.keys()].sort(),
},
}),
};
// Packages compiled with runtime fences that need the JavaScript
// runtime or a mutable object model (fenceIslands): build again with
@@ -284,6 +331,7 @@ export function prepareExecutableModule(
islands,
false,
unitStore,
lowerModules,
);
if (islanded.ok) return islanded;
firstError ??= islanded.diagnostics[0] ?? null;
@@ -298,9 +346,105 @@ export function prepareExecutableModule(
return prepared;
}
/** The packages a build runs in Node islands, or null for none: under
* `--node-islands=auto` (the default) every npm package the native
* pipeline refused, plus packages marked `node`; under `explicit` only the
/** Whether two spellings name the same file (the checker resolves through
* symlinks; a CLI argument may not). */
function sameFile(a: string, b: string): boolean {
if (samePath(a, b)) return true;
try {
const real = realpathSync(a);
const other = realpathSync(b);
return samePath(real, other);
} catch {
return false;
}
}
function samePath(a: string, b: string): boolean {
return a.replaceAll("\\", "/") === b.replaceAll("\\", "/");
}
/** The pre-scan reasons that make a user module a sloppy-semantics island
* by themselves (DESIGN.md note 1 §3.1): the constructs whose behavior
* needs the JavaScript engine and that a syntax scan decides exactly, plus
* the conservative `this` marker (decision S2). */
const SLOPPY_REASONS: ReadonlySet<string> = new Set([
"with",
"direct-eval",
"annexb-block-function",
"sloppy-this",
]);
/** A module that patches a builtin module's CommonJS object (the user's
* decision, 2026-10-29): its patch is meant for code that shares Node's
* builtin, so it runs in Node, where an island consumer sees it. */
const PATCH_REASONS: ReadonlySet<string> = new Set(["builtin-patch"]);
/** The program modules that run in Node islands, by file: modules marked
* with `--node-island=<path>` or `scriptc.nodeIslands.modules`, and (under
* `--node-islands=auto`) user JavaScript the pre-scan found a sloppy-only
* construct in (decision S3). The entry stays native: a program whose entry
* is an island runs the whole file in Node, which is a different feature. */
interface ModuleIslandDecision {
modules: Map<string, IslandModule>;
/** The entry module's own `builtin-patch` marker: the entry stays native
* (a whole-file island is a different feature), so the build refuses it
* with SC8006. */
entryPatch: { file: string; marker: NodeIslandMarker } | null;
/** User TypeScript modules with a `builtin-patch` marker that Node
* cannot run with type stripping only (H3.5): SC8006, kept native. */
stripProblems: { file: string; construct: string; loc: SrcLoc }[];
}
function moduleIslands(
entryPath: string,
policy: NodeIslandPolicy,
scan: NodeIslandScan,
): ModuleIslandDecision {
const modules = new Map<string, IslandModule>();
let entryPatch: ModuleIslandDecision["entryPatch"] = null;
const stripProblems: ModuleIslandDecision["stripProblems"] = [];
for (const m of scan.modules) {
if (m.origin !== "user") continue;
const patches = m.markers.filter((marker) => PATCH_REASONS.has(marker.reason));
// A TypeScript module islands only for a builtin patch (H3.2).
if (!isJsSourceFileName(m.file) && patches.length === 0) continue;
if (sameFile(m.file, entryPath)) {
if (patches.length > 0 && entryPatch === null)
entryPatch = { file: m.file, marker: patches[0]! };
continue;
}
if (m.stripProblem !== null) {
stripProblems.push({ file: m.file, ...m.stripProblem });
continue;
}
const marked = isMarkedUserModule(policy, m.file);
const sloppy = m.markers.filter((marker) => SLOPPY_REASONS.has(marker.reason));
if (
marked === null &&
(policy.mode !== "auto" || (sloppy.length === 0 && patches.length === 0))
)
continue;
const first = sloppy[0] ?? patches[0];
const reason = first === undefined ? null : classifyMarker(first);
modules.set(m.file, {
file: m.file,
class: reason === null ? "marked" : reason.class,
reason:
reason === null
? marked === "cli"
? "marked with --node-island"
: 'marked "node" in scriptc.nodeIslands.modules'
: describe(reason),
decision: marked === null ? "auto" : "marked",
});
}
return { modules, entryPatch, stripProblems };
}
/** The packages and modules a build runs in Node islands, or null for none:
* under `--node-islands=auto` (the default) every npm package the native
* pipeline refused plus the user modules the pre-scan found a sloppy
* construct in, and packages marked `node`; under `explicit` only the
* marked ones; under `off`, none (refusals stay SC2013). Each island is
* reported once as an SC8001 note. */
function decideNodeIslands(
@@ -308,7 +452,8 @@ function decideNodeIslands(
opts: CompileRequestOptions,
fe: ReturnType<FrontendFactory>,
lowerIslands: ReadonlyMap<string, IslandPackage>,
): (NodeIslandSet & { notes: ScrDiagnostic[] }) | null {
lowerModules: ReadonlyMap<string, IslandModule>,
): (NodeIslandSet & { notes: ScrDiagnostic[]; errors: ScrDiagnostic[] }) | null {
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
if (policy.mode === "off" || fe.nodeIslandScan === undefined) return null;
// A package marked native stays refused: the build reports it rather
@@ -321,10 +466,74 @@ function decideNodeIslands(
: [],
);
for (const name of lowerIslands.keys()) refused.add(name);
if (refused.size === 0 && policy.forcedNode.size === 0) return null;
// The pre-scan's user-JavaScript markers exist only where a build may act
// on them.
const scan = fe.nodeIslandScan({ userJs: policy.mode === "auto" });
const moduleDecision = moduleIslands(entryPath, policy, scan);
const modules = moduleDecision.modules;
// Modules the lowering refused keep their first reason (the reload no
// longer compiles them).
for (const [file, island] of lowerModules) if (!modules.has(file)) modules.set(file, island);
const errors: ScrDiagnostic[] = [];
if (moduleDecision.entryPatch !== null) {
const { file, marker } = moduleDecision.entryPatch;
errors.push(
nodeIslandEntryPatchDiag(
file,
marker.member ?? marker.construct,
builtinModuleOf(marker.member),
marker.loc,
),
);
}
for (const problem of moduleDecision.stripProblems)
errors.push(nodeIslandStripDiag(problem.file, problem.construct, problem.loc));
// Who else must see the patch (H3.4): every non-islanded module that
// reads a patched member through the builtin's CommonJS object. An npm
// package joins the island set (a patch meant for it must reach it); a
// user module is SC8020, because native code cannot see the patch.
const patchedMembers = new Map<string, string>();
for (const m of scan.modules) {
if (!modules.has(m.file)) continue;
for (const marker of m.markers) {
if (!PATCH_REASONS.has(marker.reason) || marker.member === undefined) continue;
if (!patchedMembers.has(marker.member)) patchedMembers.set(marker.member, m.file);
}
}
const readerPackages = new Map<
string,
{ packageDir: string; member: string; patchFile: string }
>();
if (policy.mode === "auto" && patchedMembers.size > 0) {
for (const m of scan.modules) {
if (modules.has(m.file)) continue;
for (const read of m.cjsReads) {
const patchFile = patchFileOf(patchedMembers, read.member);
if (patchFile === undefined) continue;
if (m.origin === "npm" && m.packageName !== null && m.packageDir !== null) {
if (!readerPackages.has(m.packageName) && !refused.has(m.packageName))
readerPackages.set(m.packageName, {
packageDir: m.packageDir,
member: read.member,
patchFile,
});
} else if (m.origin === "user") {
errors.push(nodeIslandPatchedReadDiag(read.member, patchFile, read.loc));
}
}
}
}
if (errors.length > 0) return { packages: new Map(), modules: new Map(), notes: [], errors };
if (
refused.size === 0 &&
policy.forcedNode.size === 0 &&
modules.size === 0 &&
readerPackages.size === 0
)
return null;
const report = buildNodeIslandReport({
entry: entryPath,
scan: fe.nodeIslandScan({ userJs: false }),
scan,
npm: fe.npmStatic,
diagnostics: [],
fences: [],
@@ -397,7 +606,64 @@ function decideNodeIslands(
),
);
}
return packages.size === 0 ? null : { packages, notes };
// npm packages that read a patched member join the island set: their
// patch lives in the island's Node, and only a shared builtin shows it.
for (const [name, reader] of readerPackages) {
if (packages.has(name)) continue;
const version = packageVersionOf(reader.packageDir);
const reason = `it reads '${reader.member}' through the CommonJS ${builtinModuleOf(reader.member)} object, which island module ${reader.patchFile} patches`;
packages.set(name, {
name,
version,
dir: reader.packageDir,
class: "object-model",
reason,
});
notes.push(
nodeIslandNoteDiag(
name,
version,
"object-model",
reason,
fe.npmImportSites.get(name) ?? { file: entryPath, start: 0, end: 0 },
),
);
}
for (const island of [...modules.values()].sort((a, b) =>
a.file < b.file ? -1 : a.file > b.file ? 1 : 0,
))
notes.push(
nodeIslandModuleNoteDiag(island.file, island.class, island.reason, {
file: island.file,
start: 0,
end: 0,
}),
);
return packages.size === 0 && modules.size === 0 ? null : { packages, modules, notes, errors };
}
/** The builtin module a patched member belongs to ("fs" of
* "fs.readFileSync"). */
function builtinModuleOf(member: string | undefined): string {
if (member === undefined) return "";
const end = /[.[]/.exec(member);
return end === null ? member : member.slice(0, end.index);
}
/** The island module that patches `member`, matching an exact member or a
* whole-module patch ("fs.*"). A read the scan cannot name ("fs.*", a
* computed key or a rest element) matches every patch of its module, and so
* does a patch through a computed key ("fs[…]"). */
function patchFileOf(patched: ReadonlyMap<string, string>, member: string): string | undefined {
const module = builtinModuleOf(member);
const exact = patched.get(member) ?? patched.get(`${module}.*`);
if (exact !== undefined) return exact;
const unnamedRead = member === `${module}.*`;
for (const [patch, file] of patched) {
if (builtinModuleOf(patch) !== module) continue;
if (unnamedRead || patch.includes("[")) return file;
}
return undefined;
}
/** The island set grown by the npm packages whose own modules refused to
@@ -478,6 +744,41 @@ function loweringIslands(
return grown.size === known.size ? null : grown;
}
/** The island modules grown by the lowering's refusals in user JavaScript
* (`--node-islands=auto` only), or null when there are none: a sloppy
* module whose semantics the native pipeline refuses (an implicit global,
* a mapped `arguments`, a `with` the pre-scan could not decide) runs in
* Node. One retry adds every one of them at once. */
function loweringModules(
entryPath: string,
opts: CompileRequestOptions,
diagnostics: readonly ScrDiagnostic[],
known: ReadonlyMap<string, IslandModule>,
): Map<string, IslandModule> | null {
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
if (policy.mode !== "auto" || known.size > 16) return null;
const grown = new Map(known);
for (const d of diagnostics) {
if (!isIslandReasonCode(d.code)) continue;
const file = d.loc.file;
// Packages run in Node through their own decision (loweringIslands).
if (file.includes("/node_modules/") || packageDirOf(file) !== null) continue;
if (!isJsSourceFileName(file) || sameFile(file, entryPath)) continue;
if (grown.has(file)) continue;
const reason = classifyDiagnostic(d, "refusal");
// A gap is a construct scriptc does not implement, not a sloppy
// semantic: running it in Node would hide a compiler limitation.
if (reason.class === "gap") continue;
grown.set(file, {
file,
class: reason.class,
reason: describe(reason),
decision: "auto",
});
}
return grown.size === known.size ? null : grown;
}
/** Preflight refusals at import statements of natively compiled npm
* packages (`--node-islands=auto` only): those packages become islands. */
function preflightIslands(
@@ -1,4 +1,4 @@
/** The standard namespace objects (`Math`, `JSON`, `Reflect`) read as
/** The standard namespace objects (`Math`, `JSON`, `Reflect`, `Intl`) read as
* JavaScript values. Each is one object per program, built on first use,
* with Node's own properties in Node's order: the functions are writable,
* configurable and non-enumerable, the Math constants are read-only and
@@ -106,6 +106,21 @@ function namespaceMembers(name: string): readonly NamespaceMember[] | null {
{ key: "set", length: 3 },
{ key: "setPrototypeOf", length: 2 },
];
case "Intl":
return [
{ key: "getCanonicalLocales", length: 1 },
{ key: "supportedValuesOf", length: 1 },
{ key: "DateTimeFormat", length: 0 },
{ key: "NumberFormat", length: 0 },
{ key: "Collator", length: 0 },
{ key: "PluralRules", length: 0 },
{ key: "RelativeTimeFormat", length: 0 },
{ key: "ListFormat", length: 0 },
{ key: "Locale", length: 1 },
{ key: "DisplayNames", length: 2 },
{ key: "Segmenter", length: 0 },
{ key: "DurationFormat", length: 0 },
];
default:
return null;
}
@@ -270,7 +285,9 @@ function functionValue(lowerer: Lowerer, canonical: string, key: string, loc: Sr
? mathMemberValue(lowerer, key, loc)
: canonical === "JSON"
? jsonMemberValue(lowerer, key, loc)
: reflectMemberValue(lowerer, key, loc);
: canonical === "Reflect"
? reflectMemberValue(lowerer, key, loc)
: null;
if (value !== null)
return value.type.kind === "dyn"
? value
@@ -28,6 +28,7 @@ import {
arrayOf,
canBoxFuncIntoDyn,
funcOf,
isUnitType,
} from "../../../ir/ir.js";
import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js";
import { builtinFunctionValue } from "../lower-exprs.js";
@@ -869,7 +870,16 @@ export function lowerProcessProperty(
return { kind: "libCall", fn: "process.execPath", args: [], type: STRING, loc };
}
if (member === "env") {
const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr));
let mapped = lowerer.mapTypeOf(lowerer.typeOf(expr));
// `(globalThis as { process?: { env?: E } }).process?.env`: the cast
// types env as optional, but Node always defines it. Snapshot into the
// record arm; the caller's coercion wraps it into the optional type.
if (mapped?.kind === "union") {
const arms = lowerer.unions.get(mapped.unionId)?.arms ?? [];
const records = arms.filter((arm) => arm.kind === "record");
if (records.length === 1 && arms.every((arm) => arm.kind === "record" || isUnitType(arm)))
mapped = records[0]!;
}
if (mapped?.kind === "record") {
const helper = envSnapshotHelper(lowerer, mapped.shapeId, loc);
if (helper !== null) {
@@ -973,15 +973,53 @@ export class ClassProtocols {
callsNamed: (name: string) => readonly (ts.CallExpression | ts.NewExpression)[],
): boolean {
if (this.isProgramClassInstance(type) || this.isRecordWithFields(type)) return true;
if (!isDynamicView(this.checker, type) || !ts.isIdentifier(source)) return false;
if (!isDynamicView(this.checker, type)) return false;
const holdsObjectType = (held: ts.Type | undefined): boolean =>
held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held));
const holdsObject = (expr: ts.Expression): boolean =>
holdsObjectType(attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr))));
const constInitializer = (name: ts.Identifier): ts.Expression | undefined => {
const decl = attempt(() => this.checker.getSymbolAtLocation(name))?.valueDeclaration;
return decl !== undefined &&
ts.isVariableDeclaration(decl) &&
ts.isVariableDeclarationList(decl.parent) &&
(decl.parent.flags & ts.NodeFlags.Const) !== 0
? decl.initializer
: undefined;
};
if (ts.isElementAccessExpression(source)) {
// An element of a constant `unknown[]` binding holds what the
// binding's initializer stores: the elements of an array of records
// or class instances, or what a `map` callback returns.
let base = source.expression;
while (ts.isParenthesizedExpression(base)) base = base.expression;
const initializer = ts.isIdentifier(base) ? constInitializer(base) : undefined;
if (initializer === undefined) return false;
const array = attempt(() => this.checker.getTypeAtLocation(initializer));
if (
array !== undefined &&
this.checker.isArrayType(array) &&
holdsObjectType(this.checker.getTypeArguments(array as ts.TypeReference)[0])
)
return true;
let call = initializer;
while (ts.isParenthesizedExpression(call)) call = call.expression;
if (
!ts.isCallExpression(call) ||
!ts.isPropertyAccessExpression(call.expression) ||
call.expression.name.text !== "map"
)
return false;
const callback = call.arguments.nodes[0];
return (
callback !== undefined &&
(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) &&
functionResults(callback).some(holdsObject)
);
}
if (!ts.isIdentifier(source)) return false;
const decl = attempt(() => this.checker.getSymbolAtLocation(source))?.valueDeclaration;
if (decl === undefined) return false;
const holdsObject = (expr: ts.Expression): boolean => {
const held = attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr)));
return (
held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held))
);
};
if (ts.isParameter(decl)) {
// A parameter holds what its callers pass: the calls of the function
// by name whose signature resolves to it.
@@ -1000,13 +1038,8 @@ export class ClassProtocols {
);
});
}
return (
ts.isVariableDeclaration(decl) &&
decl.initializer !== undefined &&
ts.isVariableDeclarationList(decl.parent) &&
(decl.parent.flags & ts.NodeFlags.Const) !== 0 &&
holdsObject(decl.initializer)
);
const initializer = constInitializer(source);
return initializer !== undefined && holdsObject(initializer);
}
/** A program object type with declared properties that is not a class
@@ -1371,6 +1404,20 @@ const IDENTITY_METHODS = new Set([
"includes",
]);
/** The values a function expression answers: an arrow's expression body,
* or the operands of its own return statements. */
function functionResults(fn: ts.ArrowFunction | ts.FunctionExpression): ts.Expression[] {
if (!ts.isBlock(fn.body)) return [fn.body];
const results: ts.Expression[] = [];
const visit = (node: ts.Node): void => {
if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return;
if (ts.isReturnStatement(node) && node.expression !== undefined) results.push(node.expression);
ts.forEachChild(node, visit);
};
ts.forEachChild(fn.body, visit);
return results;
}
/** An expression that denotes an object that already exists, not a fresh
* literal or call result. */
function isExistingObject(node: ts.Expression): boolean {
@@ -69,7 +69,7 @@ import {
} from "./array-iteration.js";
import { dynamicReceiverThrows } from "./dynamic-receivers.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { checkedFilterNarrowing } from "./filter-predicates.js";
import { checkedFilterNarrowing, supersetGuardFields } from "./filter-predicates.js";
import { omittableTrailingParam } from "../coercions/function-adapters.js";
/** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct
@@ -262,6 +262,19 @@ function filterKeptElem(
"keep the receiver's element type instead — annotate the callback's return ': boolean' " +
"(the checker then skips the predicate) or annotate the result with the receiver's own " +
"element type — and narrow the elements after";
// A record target that declares fields the receiver's record element has no
// slot for: the elements were narrowed when they were stored, so those
// fields no longer exist at runtime and no per-field check can recover them.
const widened = supersetGuardFields(lowerer, elem, outElem);
const missingFields =
widened === null
? ""
: ` (the receiver's element type has no slot for ${widened
.slice(0, 4)
.map((field) => `'${field}'`)
.join(", ")}${widened.length > 4 ? ` and ${widened.length - 4} more` : ""} — each ` +
`element was narrowed when it was stored, so those fields do not exist at runtime and ` +
`the predicate cannot make them real)`;
const written =
!booleanFilter &&
((!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) ||
@@ -278,14 +291,14 @@ function filterKeptElem(
"SC1090",
argNode,
`narrowing '.filter' to '${lowerer.fmt(outElem)}' through a callback VALUE (no checked ` +
`conversion from '${lowerer.fmt(elem)}' — ${annotateEscape})`,
`conversion from '${lowerer.fmt(elem)}'${missingFields} — ${annotateEscape})`,
);
}
lowerer.unsupported(
"SC1090",
argNode,
`narrowing '.filter' to '${lowerer.fmt(outElem)}' with a hand-written type predicate (no ` +
`checked conversion from '${lowerer.fmt(elem)}' — ${annotateEscape})`,
`checked conversion from '${lowerer.fmt(elem)}'${missingFields} — ${annotateEscape})`,
);
}
const classRefinement =
@@ -543,6 +543,21 @@ export function lowerArrayMethodCall(
: dispatched;
}
}
// A checker ARRAY type with no mapping whose VALUE lowers to a static
// array: `Object.entries(value).filter(([, item]) => ...)` inside a
// generic body, where the checker spells the entry element as a deferred
// conditional over the lib overload's own type parameter
// (`[string, keyof T extends never ? unknown : T[keyof T]]`) that no
// bound-type-parameter resolver can decide, while the Object.entries
// lowering answers the concrete tuple array the value actually has. Ride
// the ordinary tables on the lowered element type, as the checker-untyped
// probe above does (re-lowering is pure IR construction); the stdlib
// member gate below still applies, because the checker does type this
// receiver.
if (receiverIr === null && lowerer.checker.isArrayType(checkerReceiver)) {
const probe = lowerer.lowerExpr(access.expression);
if (probe.type.kind === "array") receiverIr = probe.type;
}
if (receiverIr?.kind === "union") {
const unionHof = lowerArrayUnionHofCall(lowerer, call, access, receiverIr);
if (unionHof) return unionHof;
@@ -71,6 +71,31 @@ export function checkedFilterNarrowing(
return name;
}
/** The fields a written predicate's record target declares that the
* receiver's record element has no slot for, or null when there are none
* (or when the pair is not a plain record narrowing).
*
* An element narrowed when it was STORED (`const messages: { role: string }[]
* = [wider, ...]` copies each wider object into the element's layout) lost
* those fields before the filter ran: the predicate cannot make them real,
* so the narrowing stays refused with this reason instead of a generic
* missing-conversion report. */
export function supersetGuardFields(
lowerer: Lowerer,
from: IrType,
to: IrType,
): readonly string[] | null {
if (from.kind !== "record" || to.kind !== "record" || from.shapeId === to.shapeId) return null;
const source = lowerer.shapes.get(from.shapeId);
const target = lowerer.shapes.get(to.shapeId);
if (!source || !target) return null;
if (source.tuple || target.tuple || source.indexValue || target.indexValue) return null;
const absent = target.fields.filter(
(field) => !source.fields.some((own) => own.name === field.name),
);
return absent.length === 0 ? null : absent.map((field) => field.name);
}
/** The interned helper converting one `from` value to `to`, or null. */
function elementHelper(
lowerer: Lowerer,
@@ -59,6 +59,7 @@ import { tryLowerExpression } from "./try-lower-expression.js";
import { fenceNodeModuleMutation, lowerRequireCacheHas } from "../lower-node-module.js";
import { errorToStringCall } from "../error-methods.js";
import { classToStringConversion, classUnionToString } from "../class-to-primitive.js";
import { noteDynamicIdentityComparison } from "../generic-functions.js";
import { isPerfEntryType, perfEntryTag } from "../builtins/performance.js";
import { isX509Type, x509ToString } from "../builtins/x509.js";
import { guardNullProto, nullProtoRecordShape } from "../null-prototype.js";
@@ -1706,6 +1707,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
? { liveRef: true as const }
: {}),
};
if (reference.kind === "func")
noteDynamicIdentityComparison(lowerer, expr, expr.left, expr.right);
return {
kind: "dynScalarEq",
left: scalarSide === left ? boxed : left,
@@ -1761,6 +1764,12 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
// reference kinds by identity (scr_dyn_strict_eq).
scalarSide.type.kind === "dyn")
) {
// A generic declaration read through `unknown` compares as the
// function object it names, which is one object in JavaScript but
// one closed instance per signature here: the whole-program check
// refuses it when the program holds the declaration at more than
// one signature.
noteDynamicIdentityComparison(lowerer, expr, expr.left, expr.right);
return {
kind: "dynScalarEq",
left,
@@ -2722,6 +2731,11 @@ export function lowerStrictEquality(
};
}
if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) {
// A generic declaration read through `unknown` compares as the function
// object it names, which is one object in JavaScript but one closed
// instance per signature here: the whole-program check refuses it when
// the program holds the declaration at more than one signature.
noteDynamicIdentityComparison(lowerer, node, leftNode, rightNode);
return {
kind: "dynScalarEq",
left: lowerer.coerceToExpected(idLeft, DYN),
@@ -43,6 +43,7 @@ import {
symbolFieldDisplayName,
} from "../symbol-fields.js";
import { planUnionRetag } from "../union-retag.js";
import { literalValues } from "../../literal-values.js";
import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js";
import { builtinGlobalValue } from "./property-access.js";
import { holdsEvolvingArray } from "../lower-stmts.js";
@@ -185,6 +186,43 @@ function pureKeyExpr(lowerer: Lowerer, expr: ts.Expression): boolean {
return false;
}
/** Whether expr reads no state that evaluating other code could change:
* literals, `const` bindings, and unary operators over those. */
function constantInitializer(lowerer: Lowerer, expr: ts.Expression): boolean {
if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr))
return constantInitializer(lowerer, expr.expression);
if (ts.isAsExpression(expr) || ts.isTypeAssertion(expr))
return constantInitializer(lowerer, expr.expression);
if (ts.isStringLiteralLike(expr) || ts.isNumericLiteral(expr)) return true;
if (
expr.kind === ts.SyntaxKind.TrueKeyword ||
expr.kind === ts.SyntaxKind.FalseKeyword ||
expr.kind === ts.SyntaxKind.NullKeyword
)
return true;
if (
ts.isPrefixUnaryExpression(expr) &&
(expr.operator === ts.SyntaxKind.MinusToken ||
expr.operator === ts.SyntaxKind.PlusToken ||
expr.operator === ts.SyntaxKind.ExclamationToken ||
expr.operator === ts.SyntaxKind.TildeToken)
)
return constantInitializer(lowerer, expr.operand);
if (ts.isIdentifier(expr)) {
const decls = lowerer.checker.getSymbolAtLocation(expr)?.declarations ?? [];
return (
decls.length > 0 &&
decls.every(
(d) =>
ts.isVariableDeclaration(d) &&
ts.isVariableDeclarationList(d.parent) &&
(ts.getCombinedNodeFlags(d) & ts.NodeFlags.Const) !== 0,
)
);
}
return false;
}
/** A property's field name: identifier/string-literal keys directly,
* foldable computed keys through literalComputedKey (the syntax pass
* fenced everything else). */
@@ -849,6 +887,10 @@ export function lowerObjectLiteral(
lowerer: Lowerer,
expr: ts.ObjectLiteralExpression,
expected?: IrType & { kind: "record" },
/** Build exactly at `expected` (one arm of a distributed union spread,
* lowerDistributedUnionSpread): the checker types the literal as the
* whole union, so its contextual type cannot choose the layout. */
exact = false,
): IrExpr {
// A spread of an untyped (`any`) source into a typed slot: TypeScript
// types such a literal `any`, and the copy holds whatever own properties
@@ -967,7 +1009,7 @@ export function lowerObjectLiteral(
) {
return lowerDynObjectLiteral(lowerer, expr);
}
const unionClone = lowerUnionRecordClone(lowerer, expr);
const unionClone = exact ? null : lowerUnionRecordClone(lowerer, expr);
if (unionClone !== null) return unionClone;
// The RUNTIME-KEYED literal (JS): a computed key that doesn't fold to a
// compile-time string means the literal's shape is not a compile-time
@@ -1105,7 +1147,7 @@ export function lowerObjectLiteral(
) {
tsType = lowerer.checker.getAwaitedType(tsType) ?? tsType;
}
let mapped = lowerer.mapTypeOf(tsType);
let mapped = exact && expected ? expected : lowerer.mapTypeOf(tsType);
// A checked-dynamic source has runtime keys even when later explicit
// fields make the literal's inferred type look like a fixed record.
// Keep a checked-dynamic destination in that representation so copying
@@ -1313,14 +1355,25 @@ export function lowerObjectLiteral(
lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE);
}
if (!mapped || mapped.kind !== "record") {
// Distribute against the contextual union when there is one (its arms
// carry the discriminants), else against the literal's own union.
const ctx = lowerer.mapTypeOf(tsType);
const unionCtx = ctx?.kind === "union" ? ctx : mapped;
const distributed = exact ? null : lowerDistributedUnionSpread(lowerer, expr, unionCtx);
if (distributed) return distributed;
// A spread merge over UNION-typed sources (`{ ...base, ...override }`
// with `base`, `override` of arm-union types) has one field set per
// combination of source arms, so its own type is a union of records.
// No single arm of the contextual union can hold that value without
// dropping the fields the other arms contributed (Node keeps them
// all), and a runtime tag would be a guess about which arm the value
// "is". Refuse, naming the candidate arms.
// "is". Build the object Node builds instead — the checked-dynamic
// copy — and let the union's checked conversion validate it.
if (mapped?.kind === "union") {
if (!exact && unionCtx?.kind === "union") {
const merged = lowerUnionSpreadMergeDyn(lowerer, expr, unionCtx);
if (merged) return merged;
}
const members = tsType.isUnionType() ? ts.constituentTypes(tsType) : [tsType];
const arms = [...new Set(members.map((m) => lowerer.checker.typeToString(m)))];
lowerer.pushDiag(
@@ -1945,7 +1998,7 @@ export function lowerObjectLiteral(
? lowerConditionalRecordSpread(lowerer, condRecord, locOf(prop))
: prop === expr.properties.nodes[0] && leadingSpreadLowered !== null
? leadingSpreadLowered
: lowerer.lowerExpr(srcNode),
: (lowerer.spreadArmOverrides.get(srcNode) ?? lowerer.lowerExpr(srcNode)),
);
{
const narrowed = checkedSpreadSource(lowerer, srcNode, srcLowered);
@@ -2732,6 +2785,267 @@ function isEmptyArrayLiteral(node: ts.Node): boolean {
return ts.isArrayLiteralExpression(inner) && inner.elements.nodes.length === 0;
}
/** `{ ...u, k: v }` where the spread source `u` is a union of records (and
* unit arms): TypeScript types the literal as the union of one spread per
* source arm, so it is built that way. The source evaluates once into a
* hidden local; a tag dispatch builds the running arm's literal with that
* arm as the source, at the destination arm chosen for it:
* 1. the arms the discriminant's last writer selects (an explicit
* property, or the source arm when it carries the field);
* 2. among those, the source arm itself (`{ ...command, id }`);
* 3. else the one arm whose keys are exactly the keys the literal writes.
* Every key a contributor may write must be a field of the destination:
* the record path's width copy would drop the others, and Node keeps them.
* So a genuine merge (`{ ...http, ...patch }` where `patch` may carry a
* stdio-only key) stays refused. A stored source the checker narrowed
* converts through the checked re-tag first, so an excluded arm (a hidden
* absence arm included) throws the catchable TypeError. Null when the
* literal is not this shape, before anything is lowered except the
* source; the caller keeps its refusal. */
function lowerDistributedUnionSpread(
lowerer: Lowerer,
expr: ts.ObjectLiteralExpression,
mapped: IrType | null,
): IrExpr | null {
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
if (mapped?.kind !== "union") return null;
const target = lowerer.unions.get(mapped.unionId);
if (!target) return null;
const props = expr.properties.nodes;
// Exactly one distributed source: a plain spread whose checker type maps
// to a union of records and unit arms. Several would be a product.
let spreadIndex = -1;
let srcNode: ts.Expression | null = null;
for (let i = 0; i < props.length; i++) {
const prop = props[i]!;
if (!ts.isSpreadAssignment(prop)) continue;
if (
conditionalSpreadOf(prop.expression) !== null ||
andSpreadOf(lowerer, prop.expression) !== null ||
conditionalRecordSpreadOf(lowerer, prop.expression) !== null
)
continue;
let node: ts.Expression = prop.expression;
while (ts.isParenthesizedExpression(node)) node = node.expression;
if (lowerer.mapTypeOf(lowerer.typeOf(node))?.kind !== "union") continue;
if (srcNode !== null) return null;
spreadIndex = i;
srcNode = node;
}
if (srcNode === null) return null;
const site = srcNode;
// The source evaluates before the dispatch, so everything written before
// it must be free of effects. When the source itself may have effects, an
// earlier property may also not read a value they could change: those
// properties must be constants.
const sourcePure = pureKeyExpr(lowerer, site);
for (const prop of props.slice(0, spreadIndex)) {
if (sourcePure && ts.isShorthandPropertyAssignment(prop)) continue;
if (
ts.isPropertyAssignment(prop) &&
(sourcePure
? pureKeyExpr(lowerer, prop.initializer)
: constantInitializer(lowerer, prop.initializer))
)
continue;
return null;
}
const siteType = lowerer.mapTypeOf(lowerer.typeOf(site));
if (siteType?.kind !== "union") return null;
const siteDef = lowerer.unions.get(siteType.unionId);
if (!siteDef || !siteDef.arms.every((arm) => arm.kind === "record" || isUnitType(arm)))
return null;
const discField = target.discriminant?.field;
// The discriminant values the literal's LAST writer of that field gives
// this source arm; null when unknown.
const discriminantOf = (tag: number, arm: IrType): ReturnType<typeof literalValues> => {
if (discField === undefined) return null;
for (let i = props.length - 1; i >= 0; i--) {
const prop = props[i]!;
if (i === spreadIndex) {
if (arm.kind !== "record") continue;
if (!lowerer.shapes.get(arm.shapeId)?.fields.some((f) => f.name === discField)) continue;
const own = siteDef.discriminant;
return own?.field === discField
? (own.cases.find((c) => c.tag === tag)?.values ?? null)
: null;
}
if (ts.isSpreadAssignment(prop)) return null;
if (
(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) &&
propNameText(lowerer, prop.name) === discField
)
return ts.isPropertyAssignment(prop)
? literalValues(lowerer.typeOf(prop.initializer))
: literalValues(lowerer.typeOf(prop.name));
}
return null;
};
// Every key some contributor may write for this source arm; null when a
// contributor has no fixed key set (an index signature, a non-record).
const writtenKeys = (arm: IrType): Set<string> | null => {
const names = new Set<string>();
for (let i = 0; i < props.length; i++) {
const prop = props[i]!;
if (ts.isSpreadAssignment(prop)) {
let node: ts.Expression = prop.expression;
while (ts.isParenthesizedExpression(node)) node = node.expression;
const t = i === spreadIndex ? arm : lowerer.mapTypeOf(lowerer.typeOf(node));
if (t !== null && isUnitType(t)) continue;
if (t?.kind !== "record") return null;
const shape = lowerer.shapes.get(t.shapeId);
if (!shape || shape.indexValue || shape.tuple) return null;
for (const f of shape.fields) names.add(f.name);
} else if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
names.add(propNameText(lowerer, prop.name));
} else {
return null;
}
}
return names;
};
const destinationOf = (tag: number, arm: IrType): (IrType & { kind: "record" }) | null => {
const keys = writtenKeys(arm);
if (keys === null) return null;
let candidates = target.arms
.map((a, t) => ({ a, t }))
.filter((c): c is { a: IrType & { kind: "record" }; t: number } => c.a.kind === "record")
.filter((c) => {
const fields = lowerer.shapes.get(c.a.shapeId)!.fields;
return [...keys].every((k) => fields.some((f) => f.name === k));
});
const values = discriminantOf(tag, arm);
if (values && values.length > 0 && target.discriminant) {
const cases = target.discriminant.cases;
candidates = candidates.filter((c) =>
(cases.find((k) => k.tag === c.t)?.values ?? []).some((v) => values.includes(v)),
);
}
if (candidates.length === 1) return candidates[0]!.a;
if (arm.kind === "record") {
const same = candidates.find((c) => c.a.shapeId === arm.shapeId);
if (same) return same.a;
}
const exactKeys = candidates.filter(
(c) => lowerer.shapes.get(c.a.shapeId)!.fields.length === keys.size,
);
return exactKeys.length === 1 ? exactKeys[0]!.a : null;
};
// Plan every arm before lowering anything.
const plan: { tag: number; arm: IrType; destination: IrType & { kind: "record" } }[] = [];
for (let tag = 0; tag < siteDef.arms.length; tag++) {
const arm = siteDef.arms[tag]!;
const destination = destinationOf(tag, arm);
if (destination === null) return null;
plan.push({ tag, arm, destination });
}
const loc = locOf(expr);
let source = lowerer.lowerExpr(site);
if (source.type.kind === "union" && !typeEquals(source.type, siteType)) {
const helper = lowerer.narrowedRetagHelper(
site,
source.type.unionId,
siteType.unionId,
source.loc,
);
if (helper)
source = { kind: "call", callee: helper, args: [source], type: siteType, loc: source.loc };
}
if (!typeEquals(source.type, siteType)) return null;
const saved = lowerer.declareHiddenLocal("%spreadArms", siteType);
const ref = varRef(saved.id, siteType, loc);
let result: IrExpr | null = null;
for (let i = plan.length - 1; i >= 0; i--) {
const { tag, arm, destination } = plan[i]!;
const armValue: IrExpr =
arm.kind === "record"
? { kind: "unionNarrow", value: ref, unionId: siteType.unionId, tag, type: arm, loc }
: {
kind: "recordLit",
fields: [],
type: { kind: "record", shapeId: lowerer.shapes.intern([]) },
loc,
};
lowerer.spreadArmOverrides.set(site, armValue);
let built: IrExpr;
try {
built = lowerObjectLiteral(lowerer, expr, destination, true);
} finally {
lowerer.spreadArmOverrides.delete(site);
}
const wrapped = lowerer.coerceToExpected(built, mapped);
if (!typeEquals(wrapped.type, mapped)) return null;
result =
result === null
? wrapped
: {
kind: "ternary",
cond: {
kind: "unionIsTag",
value: ref,
unionId: siteType.unionId,
tag,
negated: false,
type: BOOL,
loc,
},
then: wrapped,
else_: result,
type: mapped,
loc,
};
}
if (result === null) return null;
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: saved.id, init: source, loc }],
result,
type: mapped,
loc,
};
}
/** `{ ...a, ...b }` over union-typed sources that lowerDistributedUnionSpread
* declined: no single destination arm holds every key the contributors may
* write (`provider-composer`'s two all-optional compat unions, an aggregate
* config merged from a http|stdio union plus a patch naming both). Node
* builds ONE plain object: each source's own enumerable keys copy in source
* order (later writers win), then the explicit fields write in their own
* positions. Build exactly that object — the checked-dynamic literal — and
* validate it into the declared union at the slot. The value keeps its
* runtime keys (reflection, JSON, and inspection see what Node sees); a
* value the union cannot hold throws the catchable TypeError at the
* conversion instead of being reinterpreted as one arm.
*
* Null when the shape is not the SC2003 one (no union-typed spread source),
* when the build must not demote (inside a capture buffer, or under
* `--tier=static-only`, which keeps today's refusal), or when the union has
* no checked extraction. The caller then keeps its refusal. */
function lowerUnionSpreadMergeDyn(
lowerer: Lowerer,
expr: ts.ObjectLiteralExpression,
target: IrType & { kind: "union" },
): IrExpr | null {
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
const spreadsUnion = expr.properties.nodes.some((prop) => {
if (!ts.isSpreadAssignment(prop)) return false;
let node: ts.Expression = prop.expression;
while (ts.isParenthesizedExpression(node)) node = node.expression;
return lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "union";
});
if (!spreadsUnion) return null;
if (lowerer.tierMode !== "auto" || lowerer.diagSink !== null) return null;
if (
!canDynCheckTo(
target,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
)
)
return null;
return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), target);
}
/** A union of plain records can preserve its existing variant while
* replacing shared fields. Dispatch before constructing the copy: only
* the selected arm evaluates overrides, and recordClone snapshots fields
@@ -1,4 +1,5 @@
import { isNavigatorGlobal } from "../builtins/navigator.js";
import { SNAPSHOT_GLOBALS, stdlibGlobalNameOf } from "../surfaces.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
@@ -187,6 +188,24 @@ function neverNullishCallee(lowerer: Lowerer, node: ts.Expression): boolean {
return parts(lowerer.typeOf(node)).every((part) => !(part.flags & nullable));
}
/** `proc?.env` for `const proc = (globalThis as { process?: P }).process`,
* and `globalThis.process?.env`: the receiver is a global Node always
* defines (a snapshot alias is plumbing for the global itself), so the
* guarded step is the plain member read (the navigator rule). */
function alwaysDefinedGlobal(lowerer: Lowerer, node: ts.Expression): boolean {
let inner = node;
while (ts.isParenthesizedExpression(inner)) inner = inner.expression;
// The bare global's own spelling (`console?.log`) keeps its existing path.
if (ts.isIdentifier(inner)) {
const symbol = lowerer.resolveValueSymbol(inner);
if (symbol === null || !lowerer.stdlibGlobalAliases.has(symbol)) return false;
} else if (!ts.isPropertyAccessExpression(inner)) {
return false;
}
const name = stdlibGlobalNameOf(lowerer, inner);
return name !== null && SNAPSHOT_GLOBALS.has(name);
}
export function lowerOptionalChain(
lowerer: Lowerer,
expr: ts.CallExpression | ts.PropertyAccessExpression | ts.ElementAccessExpression,
@@ -240,7 +259,7 @@ export function lowerOptionalChain(
// reports its own diagnostics) instead of failing on the value read.
// `navigator?.userAgent`: Node's navigator global is never nullish and
// has no value form, so the guarded step is the plain member read.
if (isNavigatorGlobal(lowerer, recvNode)) {
if (isNavigatorGlobal(lowerer, recvNode) || alwaysDefinedGlobal(lowerer, recvNode)) {
lowerer.chainHandled.add(dotNode);
try {
return lowerer.lowerExpr(expr);
@@ -5052,7 +5052,10 @@ const ERROR_CONSTRUCTOR_VALUES: ReadonlySet<string> = new Set([
* constructor, and dynamic values answer `.constructor` with the same one,
* so `o.constructor === Object` and `err.constructor === TypeError` are
* exact identity tests, and `err.constructor.name` is the constructor's
* name. */
* name. SharedArrayBuffer, DataView, BigInt64Array, BigUint64Array and
* Proxy have values with Node's name, length and instanceof answers;
* calling one throws Node's TypeError, and constructing through the value
* is fenced. */
const GLOBAL_CONSTRUCTOR_VALUES: ReadonlySet<string> = new Set([
"Object",
"Function",
@@ -5064,6 +5067,11 @@ const GLOBAL_CONSTRUCTOR_VALUES: ReadonlySet<string> = new Set([
"WeakMap",
"WeakSet",
"FinalizationRegistry",
"SharedArrayBuffer",
"DataView",
"BigInt64Array",
"BigUint64Array",
"Proxy",
...ERROR_CONSTRUCTOR_VALUES,
]);
@@ -0,0 +1,99 @@
/* The JavaScript names of object literals' methods.
*
* A function boxed into a checked value inside a record, an array or
* another converted value has no static name at the box, so the backend
* takes it from the function (LlEmitter.closureNames): its sourceName, or
* for an object literal's method, whose stack-frame name differs, its
* displayName. displayName is set only in modules that box such a
* function, so other modules lower as before. */
import * as ts from "../ts7/adapter.js";
import type { IrFunction, IrType } from "../../ir/ir.js";
import { typeKey } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { Lowerer } from "./lowerer.js";
/** Notes the name of the lifted function `fnName` when `node` is a method
* of an object literal with a static key. */
export function noteObjectMethodName(lowerer: Lowerer, node: ts.Node, fnName: string): void {
if (!ts.isMethodDeclaration(node) || !ts.isObjectLiteralExpression(node.parent)) return;
const name = node.name;
if (ts.isIdentifier(name) || ts.isStringLiteral(name))
lowerer.objectMethodNames.set(fnName, name.text);
else if (ts.isNumericLiteral(name))
lowerer.objectMethodNames.set(fnName, String(Number(name.text)));
}
/** Gives the noted methods their displayName when the module boxes a
* function without a static name, or a value that can hold one. */
export function finalizeFunctionNames(lowerer: Lowerer, functions: readonly IrFunction[]): void {
if (lowerer.objectMethodNames.size === 0) return;
const seen = new Map<string, boolean>();
let boxes = false;
for (const fn of functions) {
everyStmtList(fn.body, {
stmt: () => !boxes,
expr: (e) => {
// A function boxed on its own with a static name needs none.
if (
e.kind === "dynFrom" &&
(e.value.type.kind === "func"
? e.fnName === undefined || e.fnName === ""
: reachesFunction(lowerer, e.value.type, seen))
)
boxes = true;
return !boxes;
},
});
if (boxes) break;
}
if (!boxes) return;
for (const fn of functions) {
const name = lowerer.objectMethodNames.get(fn.name);
if (name !== undefined) fn.displayName = name;
}
}
/** Whether a value of `t` can hold a function value. */
function reachesFunction(lowerer: Lowerer, t: IrType, seen: Map<string, boolean>): boolean {
const key = typeKey(t);
const known = seen.get(key);
if (known !== undefined) return known;
seen.set(key, false);
let result = false;
switch (t.kind) {
case "func":
result = true;
break;
case "record": {
const shape = lowerer.shapes.get(t.shapeId);
result =
(shape?.fields.some((field) => reachesFunction(lowerer, field.type, seen)) ?? false) ||
(shape?.indexValue !== undefined && reachesFunction(lowerer, shape.indexValue, seen));
break;
}
case "array":
case "set":
result = reachesFunction(lowerer, t.elem, seen);
break;
case "map":
result = reachesFunction(lowerer, t.key, seen) || reachesFunction(lowerer, t.value, seen);
break;
case "union":
result = (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) =>
reachesFunction(lowerer, arm, seen),
);
break;
case "promise":
result = reachesFunction(lowerer, t.inner, seen);
break;
case "object":
result = [...(lowerer.classes.get(t.className)?.fields.values() ?? [])].some((field) =>
reachesFunction(lowerer, field, seen),
);
break;
default:
result = false;
}
seen.set(key, result);
return result;
}
@@ -22,6 +22,7 @@ import {
typeEquals,
} from "../../ir/ir.js";
import { helperLoc, nodeThrowExpr } from "../../ir/build.js";
import { unsupportedDiag } from "../../diagnostics/diagnostic.js";
import { isJsSourceFile, locOf } from "../program.js";
import { typeKey } from "../type-mapper.js";
import { functionCanReturnUndefined } from "../function-completion.js";
@@ -145,6 +146,11 @@ export interface GenericFnInfo {
/** Parameter index → symbol for the function parameters whose `.name`
* the body reads (static-function-names.ts); computed on first use. */
nameReadParams?: Map<number, ts.Symbol>;
/** Names of the instances value references produced (pinnedGenericClosure,
* erasedGenericInstance, the implicit-any default): more than one means the
* program holds this declaration as several function objects, while
* JavaScript has one. */
valueInstances?: Set<string>;
}
/** One enclosing-frame value a generic arrow/function-expression binding
@@ -1440,6 +1446,19 @@ export function lowerGenericInstance(
}
}
/** Records that `instance` is one of `info`'s value instances (see
* `valueInstances`). Written as its own statements rather than
* `(x ??= new Set()).add(...)`: the self-hosted frontend fences a Set method
* call on a non-Set receiver, and this compiler compiles its own source. */
function noteValueInstance(info: GenericFnInfo, instance: string): void {
let instances = info.valueInstances;
if (instances === undefined) {
instances = new Set<string>();
info.valueInstances = instances;
}
instances.add(instance);
}
/** A generic function taken as a VALUE, monomorphized by flow. A function
* value needs ONE concrete signature; tsc pins one at exactly two
* reference shapes — an instantiation EXPRESSION (`identity<number>`,
@@ -1464,6 +1483,7 @@ export function lowerGenericFnValue(
// closure per function, so `f === f` holds like any declaration).
if (info.implicitParams) {
const inst = implicitDefaultInstance(lowerer, ref, info);
noteValueInstance(info, inst.name);
const funcType: IrType = funcTypeFromParamShapes(inst.params, inst.returnType);
lowerer.requireExactArityValue(ref, ref, inst.params, funcType);
lowerer.noteEdge(inst.name);
@@ -1513,6 +1533,10 @@ export function lowerGenericFnValue(
if (callable.length === 1) target = callable[0]![0]!;
}
}
if (!target) {
target = identityComparisonPin(lowerer, ref);
if (target) lowerer.genericIdentityPins.push({ info, node: ref });
}
if (!target) fenceUnpinned();
return guardEarlyBindingValue(
lowerer,
@@ -1522,6 +1546,41 @@ export function lowerGenericFnValue(
);
}
/** The pinning signature of a generic declaration compared by identity
* (`held === ident`, `held !== ident`, and the loose forms): the other
* operand's one concrete call signature. A held value of that type that is
* this declaration is its instance at that signature, whose interned
* closure is the one this reference names, so the comparison answers as
* in JavaScript, provided the program holds the declaration at that one
* signature only (checkGenericIdentityPins). Null when the other operand
* has no single concrete call signature (another generic value, a union of
* callables, a non-function). */
function identityComparisonPin(lowerer: Lowerer, ref: ts.Expression): ts.Signature | null {
let operand: ts.Node = ref;
if (
ref.parent !== undefined &&
ts.isPropertyAccessExpression(ref.parent) &&
ref.parent.name === ref
)
operand = ref.parent;
while (operand.parent !== undefined && ts.isParenthesizedExpression(operand.parent))
operand = operand.parent;
const bin = operand.parent;
if (bin === undefined || !ts.isBinaryExpression(bin)) return null;
const op = bin.operatorToken.kind;
if (
op !== ts.SyntaxKind.EqualsEqualsEqualsToken &&
op !== ts.SyntaxKind.ExclamationEqualsEqualsToken &&
op !== ts.SyntaxKind.EqualsEqualsToken &&
op !== ts.SyntaxKind.ExclamationEqualsToken
)
return null;
const other = bin.left === operand ? bin.right : bin.left;
const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(other));
if (sigs.length !== 1 || sigs[0]!.getTypeParameters()?.length) return null;
return sigs[0]!;
}
/** The value a generic binding declared inside a function stores when a
* nested function read it before the declaration lowered (`const read =
* () => id; const id: (x: number) => number = <T>(x: T): T => x`): such
@@ -1628,6 +1687,7 @@ function pinnedGenericClosure(
// nothing: mapType answers null for an unsubstituted parameter.
if (info.typeParams.some((tp) => !bindings.get(tp))) fenceUnpinned();
const inst = genericValueInstance(lowerer, ref, info, bindings);
noteValueInstance(info, inst.name);
// The value's type is the completed ABI signature. Typed rest keeps its
// packed-array slot; dynRest stays hidden behind the rest marker.
const funcType: IrType = funcTypeFromParamShapes(inst.params, inst.returnType);
@@ -1677,12 +1737,153 @@ export function erasedGenericInstance(
new Map(info.typeParams.map((tp) => [tp, DYN])),
);
instance.erased = true;
noteValueInstance(info, instance.name);
return instance;
} finally {
lowerer.erasingDepth--;
}
}
/** After every body lowered: an identity comparison of a generic
* declaration is exact only when the program holds the declaration as one
* function object. Otherwise a held value may be another instance's
* closure, where JavaScript has one function, so the comparison is
* refused instead of answering false. */
export function checkGenericIdentityPins(lowerer: Lowerer): void {
if (lowerer.genericIdentityCheckDone) return;
lowerer.genericIdentityCheckDone = true;
const seen = new Map<ts.Node, Set<GenericFnInfo>>();
const report = (info: GenericFnInfo, node: ts.Node, dynamic: boolean): void => {
let infos = seen.get(node);
if (infos === undefined) seen.set(node, (infos = new Set()));
if (infos.has(info)) return;
infos.add(info);
const held = info.valueInstances?.size ?? 0;
if (held <= 1) return;
const through = dynamic ? " through a dynamic value" : "";
lowerer.pushDiag(
unsupportedDiag(
"SC1090",
locOf(node),
`comparing the generic function '${info.baseName}' by identity${through} while the program holds it at ${held} different signatures (each is a separate compiled function object; JavaScript has one) — compare against a value pinned once, or hold '${info.baseName}' at a single signature`,
),
);
};
for (const { info, node } of lowerer.genericIdentityPins) report(info, node, false);
// Dynamic comparisons resolve their provenance only when some declaration
// is held at more than one signature: the symbol lookups are the
// expensive part, and every other program answers them all at one
// instance. Recorded at lowering, resolved here so the pins' instance
// sets are complete.
let ambiguous = false;
for (const info of lowerer.genericFnsBySymbol.values()) {
if ((info.valueInstances?.size ?? 0) > 1) {
ambiguous = true;
break;
}
}
if (!ambiguous) return;
for (const { node, left, right } of lowerer.dynIdentityComparisons) {
for (const operand of [left, right]) {
const info = genericDeclarationBehindValue(lowerer, operand);
if (info) report(info, node, true);
}
}
}
/** The generic declaration whose value instance an expression names, when
* the expression is a binding initialized (directly, or through casts,
* aliasing bindings and property initializers) from that declaration. Used
* where a comparison reads the value through `unknown`/`any`, so the
* declaration is not syntactically at the compared site. */
export function genericDeclarationBehindValue(
lowerer: Lowerer,
node: ts.Node,
): GenericFnInfo | null {
let current: ts.Node | undefined = node;
const seen = new Set<ts.Node>();
while (current !== undefined && !seen.has(current)) {
seen.add(current);
if (
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isNonNullExpression(current)
) {
current = current.expression;
continue;
}
if (!ts.isIdentifier(current) && !ts.isPropertyAccessExpression(current)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(
ts.isPropertyAccessExpression(current) ? current.name : current,
);
if (symbol === undefined) return null;
const info = lowerer.genericFnsBySymbol.get(symbol);
if (info !== undefined) return info;
const decl = symbol.valueDeclaration ?? symbol.declarations?.[0];
if (decl === undefined) return null;
if (!ts.isVariableDeclaration(decl) && !ts.isPropertyDeclaration(decl) && !ts.isParameter(decl))
return null;
current = decl.initializer;
}
return null;
}
/** An identity comparison at the dynamic boundary (`unknown`/`any`
* operands): when either operand reads a generic declaration's value
* instance, the comparison is exact only while the program holds that
* declaration at one signature (checkGenericIdentityPins). */
export function noteDynamicIdentityComparison(
lowerer: Lowerer,
node: ts.Node,
left: ts.Node,
right: ts.Node,
): void {
// A function compared with a string, number, boolean, bigint or symbol
// is unequal whichever instance it is, as in JavaScript.
if (isPrimitiveTyped(lowerer, left) || isPrimitiveTyped(lowerer, right)) return;
// The only work done while lowering. No instance count can gate this:
// a value reference's instance is interned when its binding settles,
// which can be later than the comparison that reads it, so the notes
// are resolved at the end of lowering, when every instance is known.
if (mightNameBinding(left) || mightNameBinding(right))
lowerer.dynIdentityComparisons.push({ node, left, right });
}
const PRIMITIVE_TYPE_FLAGS =
ts.TypeFlags.StringLike |
ts.TypeFlags.NumberLike |
ts.TypeFlags.BooleanLike |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.ESSymbolLike;
/** Whether the checker types `node` as a string, number, boolean, bigint or
* symbol (a union with any other member is not). */
function isPrimitiveTyped(lowerer: Lowerer, node: ts.Node): boolean {
return (lowerer.typeOf(node).flags & PRIMITIVE_TYPE_FLAGS) !== 0;
}
/** Whether an expression could name a binding at all — an identifier or a
* property access, through parentheses and casts. A literal, a call, an
* operator expression: nothing to resolve. */
function mightNameBinding(node: ts.Node): boolean {
let current: ts.Node | undefined = node;
for (let hops = 0; hops < 16 && current !== undefined; hops++) {
if (ts.isIdentifier(current) || ts.isPropertyAccessExpression(current)) return true;
if (
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isNonNullExpression(current) ||
ts.isSatisfiesExpression(current)
) {
current = current.expression;
continue;
}
return false;
}
return false;
}
/** The instance a pinned VALUE reference names: the declaration's modes
* over the DECLARED types mapped under the bindings (mapType's resolver
* substitutes — the instance-body trick), which is the same result the
@@ -24,13 +24,16 @@
* same vector handles varying or inference-unknown JavaScript emit tuples
* when every known listener accepts checked-dynamic parameters or no
* parameters. Typed listener constraints keep the fixed-tuple refusal.
* The remaining cases are fenced:
* unrestricted names, symbol names, conflicting literal tuples, the
* meta-events' listener-function argument (meta listeners take at most
* Unrestricted names on plain emitters take the checked-dynamic receiver's
* bridge. The remaining cases are fenced:
* unrestricted names on streams, symbol names, conflicting literal tuples,
* the meta-events' listener-function argument (meta listeners take at most
* the event name; dyn meta listeners fence — Node
* passes the listener function second, which has no dyn conversion), and
* listeners()/rawListeners() of an event with any dynamic
* registration (the bucket has no one honest element type).
* registration, or, once the program registers a listener under an
* unrestricted name, of an event that literal names register with typed
* listeners (the bucket has no one honest element type).
*
* Two special names: 'error' is forced to the one-%Error tuple (emit
* routes through emitter.emitError — no listener means the payload
@@ -234,6 +237,7 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
if (lowerer.emitterEventTable) return lowerer.emitterEventTable;
const table = new Map<string, EventSig>();
const computedPatterns: ComputedEventPattern[] = [];
let anyNameListener = false;
const sigOf = (name: string): EventSig => {
let sig = table.get(name);
if (!sig)
@@ -412,6 +416,9 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
) {
computedPatterns.push({ ...pattern, listener: !isEmit });
}
// A listener under an unrestricted name may receive any event
// (lowerFlexListenerCall's any-name path).
if (!pattern && !isEmit && streamSidesOf(lowerer, info) === null) anyNameListener = true;
return;
}
const name = literal;
@@ -515,6 +522,7 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
}
lowerer.emitterEventTable = table;
lowerer.emitterComputedPatterns = computedPatterns;
lowerer.emitterAnyNameListener = anyNameListener;
return table;
}
@@ -700,6 +708,21 @@ function patternMatchesName(pattern: ComputedEventPattern, key: string): boolean
return name.startsWith(pattern.prefix) && name.endsWith(pattern.suffix);
}
/** Whether a listener registered under a computed name may sit in the
* bucket of the event keyed `key`: a pattern registration that matches it,
* or any registration under an unrestricted name (a string, so never the
* errorMonitor key). Such a listener crossed
* the checked-dynamic boundary, so the bucket has no one static signature. */
function computedListenerMayHold(lowerer: Lowerer, key: string): boolean {
emitterEvents(lowerer);
return (
(lowerer.emitterAnyNameListener && key !== ERROR_MONITOR_EVENT) ||
computedPatterns(lowerer).some(
(pattern) => pattern.listener && patternMatchesName(pattern, key),
)
);
}
/** A computed event can use the variable-arity dyn path only when its
* syntax proves a nonempty prefix or suffix excluding internal events. */
function computedEventPattern(
@@ -741,6 +764,7 @@ function lowerFlexListenerCall(
prepend: boolean,
loc: SrcLoc,
onFn: "emitter.onFlex" | "emitter.onAny" = "emitter.onFlex",
bridge = false,
): IrExpr {
if (receiver.type.kind !== "object") {
lowerer.noLowering(
@@ -770,6 +794,15 @@ function lowerFlexListenerCall(
lowerer.noLowering(`${member} with a non-function computed-name listener`, cbNode);
}
const recvT = receiver.type;
// An UNRESTRICTED name (`bridge`) may be any event, including 'error',
// the meta events, and the literal events typed code fixes a tuple for:
// register through the checked-dynamic receiver's bridge (typed adapters
// for those events, the thunk alone otherwise), which keeps Node's
// contract for each.
if (bridge && registering) {
const bridged = lowerDynamicEmitterOn(lowerer, receiver, name, boxed, once, prepend, loc);
if (bridged) return bridged;
}
const key = `${registering ? onFn : "emitter.offDyn"}:${typeKey(recvT)}`;
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
@@ -1521,7 +1554,7 @@ export function lowerEmitterMethodCall(
}
return receiver;
};
const lowerFlexEmit = (name: IrExpr, kind = "computed-name"): IrExpr => {
const lowerFlexEmit = (name: IrExpr, kind = "computed-name", anyName = false): IrExpr => {
const below: ClassInfo[] = [];
collectEmitOverridesBelow(info, below);
if (emitOverrideAtOrAbove(info) || below.length > 0) {
@@ -1540,6 +1573,20 @@ export function lowerEmitterMethodCall(
);
}
const payload = args.slice(1).map((arg) => lowerer.lowerExprExpecting(arg, DYN));
if (anyName) {
// Any event name: the checked-dynamic receiver's emit, which keeps
// 'error' and the meta events' contracts and converts the arguments
// for listeners registered with a typed tuple.
return (
lowerDynamicEmitterEmit(lowerer, receiver, name, payload, loc) ?? {
kind: "libCall",
fn: "emitter.emitAny",
args: [receiver, name, ...payload],
type: BOOL,
loc,
}
);
}
return {
kind: "libCall",
fn: "emitter.emitFlex",
@@ -1642,7 +1689,11 @@ export function lowerEmitterMethodCall(
}
const registering = REGISTER_MEMBERS.has(member);
if (literalEventName(lowerer, args[0]!) === null) {
computedEventPattern(lowerer, info, member, args[0]!);
// An unrestricted name on a plain emitter takes the any-name path;
// streams keep the fence (their runtime events carry fixed ABIs).
const anyName =
computedEventPatternOf(lowerer, args[0]!) === null && streamSidesOf(lowerer, info) === null;
if (!anyName) computedEventPattern(lowerer, info, member, args[0]!);
const receiver = lowerReceiver();
const name = lowerer.lowerExprExpecting(args[0]!, STRING);
return lowerFlexListenerCall(
@@ -1655,6 +1706,8 @@ export function lowerEmitterMethodCall(
member === "once" || member === "prependOnceListener",
member === "prependListener" || member === "prependOnceListener",
loc,
anyName ? "emitter.onAny" : "emitter.onFlex",
anyName,
);
}
const name = eventNameOf(lowerer, member, args[0]!);
@@ -1830,6 +1883,7 @@ export function lowerEmitterMethodCall(
const pattern = computedEventPatternOf(lowerer, args[0]!);
const mayBeObserved =
pattern === null ||
lowerer.emitterAnyNameListener ||
computedPatternOverlapsReserved(pattern) ||
[...table].some(
([candidate, sig]) =>
@@ -1854,6 +1908,10 @@ export function lowerEmitterMethodCall(
loc,
};
}
if (pattern === null && streamSidesOf(lowerer, info) === null) {
const name = lowerer.lowerExprExpecting(args[0]!, STRING);
return lowerFlexEmit(name, "computed-name", true);
}
computedEventPattern(lowerer, info, member, args[0]!);
const name = lowerer.lowerExprExpecting(args[0]!, STRING);
return lowerFlexEmit(name);
@@ -2052,7 +2110,7 @@ export function lowerEmitterMethodCall(
if (listenerArrayIsDyn(lowerer, name, sig)) {
return dynListenerArray(lowerer, table, lowerReceiver(), name, call, loc);
}
if (sig?.dynListener) {
if (sig?.dynListener || computedListenerMayHold(lowerer, name)) {
// A dyn-adapted registration means the runtime bucket can hold
// originals of MIXED signatures — no one honest element type.
lowerer.noLowering(
@@ -3034,7 +3092,7 @@ export function lowerEventsEmitterHelper(
if (listenerArrayIsDyn(lowerer, name, sig)) {
return dynListenerArray(lowerer, table, receiver, name, call, loc);
}
if (sig?.dynListener) {
if (sig?.dynListener || computedListenerMayHold(lowerer, name)) {
lowerer.noLowering(
`events.getEventListeners of the event '${name}'`,
call,
@@ -40,6 +40,7 @@ import {
import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js";
import { isStrictCode } from "./strict-mode.js";
import { lowerSloppyGlobalReceiver } from "./lower-global-value.js";
import { noteObjectMethodName } from "./function-names.js";
/** Save the call-site receiver before defaults or body effects. Escaping
* arrows capture this binding instead of reading a later call's receiver. */
@@ -550,6 +551,7 @@ export function lowerLambda(
if (isAsync) lifted.async = true;
if (fnCtx.generator) lifted.generator = fnCtx.generator;
lowerer.liftedFns.push(lifted);
noteObjectMethodName(lowerer, node, fnName);
return { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
} catch (e) {
// JS sources defer LAMBDA poisons like function declarations
@@ -335,7 +335,15 @@ function appendEntryModules(
* (tests, coverage on broken files) may arrive with an empty order — fall
* back to the entry alone. */
export function splitFiles(lowerer: Lowerer): FileParts[] {
const files = lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry];
// A declaration file is never a runtime module: it carries no
// implementation, so it has no %init to lower and its imports never load
// anything. Preflight keeps declarations out of the module order (a
// specifier that resolves only to declarations is refused at its own
// statement); this filter is the invariant that keeps a stray declaration
// from being lowered as code.
const files = (lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]).filter(
(sf) => !sf.isDeclarationFile,
);
return files.map((sf) => {
const fp: FileParts = { sf, fnDecls: [], classDecls: [], topStmts: [] };
const rebindable = collectRebindableFunctions(lowerer, sf);
@@ -645,8 +653,21 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void {
if (
reexport === null &&
(!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier))
)
) {
// A star or namespace re-export (`export * from "pkg"`) names
// nothing the re-exporting module could bind: an island package's
// is refused at the boundary instead of silently dropped. Any
// other module keeps the previous skip, and a type-only one is
// erased like the type-only named re-export above.
if (
ts.isExportDeclaration(stmt) &&
!stmt.isTypeOnly &&
stmt.moduleSpecifier !== undefined &&
ts.isStringLiteral(stmt.moduleSpecifier)
)
collectIslandImport(lowerer, fp.sf, stmt, stmt.moduleSpecifier.text);
continue;
}
const specNode =
ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)
? stmt.moduleSpecifier
@@ -672,6 +693,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void {
// graph: resolveImport follows its node_modules link to the loaded
// TypeScript entry. Its internal `.js` spellings have already been
// resolved to `.ts` by that graph.
// A relative import of a program module the pre-scan put in a Node
// island (DESIGN.md note 1 §3.2): the island loads at this module's
// position and its bindings are island values.
if (lowerer.nodeIslands !== null && collectIslandImport(lowerer, fp.sf, stmt, spec)) continue;
const projectDep = isRelativeSpecifier(spec)
? null
: resolveImport(lowerer.program, fp.sf, spec);
@@ -3238,7 +3263,14 @@ export function lowerFileInit(
});
ctx.hoistedFnDecls.add(stmt);
}
const islandActions = lowerer.islandImportActions.get(sf);
const islandPlaced = new Set<ts.Statement>();
for (const { stmt, dep } of orderedImportsOf(lowerer.program, sf)) {
const island = islandActions?.get(stmt);
if (island !== undefined) {
header.push(...island);
islandPlaced.add(stmt);
}
const npm = lowerer.npmInitActions.get(sf)?.get(stmt);
if (npm !== undefined && npm.length > 0) {
header.push(...npm);
@@ -3307,6 +3339,11 @@ export function lowerFileInit(
}
}
}
// An island load whose statement the import order leaves out keeps the
// prelude's place.
const islandUnplaced: IrStmt[] = [];
for (const [stmt, actions] of islandActions ?? [])
if (!islandPlaced.has(stmt)) islandUnplaced.push(...actions);
if (asyncDeps.length === 1) {
const dep = asyncDeps[0]!;
const promiseT: IrType = { kind: "promise", inner: VOID };
@@ -3445,7 +3482,7 @@ export function lowerFileInit(
if (ms && ms.sf === sf) statics.push({ pos: ms.pos, info: c });
}
statics.sort((a, b) => a.pos - b.pos);
const body = [...header, ...prelude];
const body = [...header, ...islandUnplaced, ...prelude];
let at = 0;
for (const stmt of stmts) {
// `<=`: a mixin instantiation's position IS its statement's start
@@ -16,11 +16,12 @@
* checked against the declared result type, like `JSON.parse(s) as T`; a
* thrown Error crosses as its nearest intrinsic class with its message and
* string `code`. */
import { readFileSync, realpathSync } from "node:fs";
import { readFileSync, realpathSync, statSync } from "node:fs";
import { resolveFrom } from "../../node-islands/resolve.js";
import { dirname, isAbsolute, join } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import * as ts from "../ts7/adapter.js";
import { isJsSourceFile, locOf } from "../program.js";
import { isRelativeSpecifier } from "../workspace-registry.js";
import type { NodeApiMember } from "../node-api-import.js";
import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js";
import {
@@ -31,6 +32,7 @@ import {
VOID,
type IrExpr,
type IrGlobal,
type IrLocal,
type IrStmt,
type IrType,
type SrcLoc,
@@ -42,14 +44,16 @@ import { resolveBareModule } from "../resolve.js";
import {
nativeSubclassOfIslandDiag,
nodeIslandBoundaryDiag,
nodeIslandDataFileDiag,
nodeIslandModuleDiag,
} from "../../diagnostics/diagnostic.js";
import {
crossingOf,
isForeignType,
isIslandProgramModule,
isIslandSubclassDecl,
markIslandSubclass,
islandPackageOfFile,
islandOwnerOfFile,
type NodeIslandSet,
} from "../../node-islands/boundary.js";
import { stripTypeWrappers } from "./typed-arguments.js";
@@ -138,7 +142,10 @@ export function lowerNodeApiCall(
const caller = callerPath(call);
const problem = lowerer.nodeIslandModules.add(caller, specifierNode.text);
if (problem !== null) {
lowerer.pushDiag(nodeIslandModuleDiag(`${surface}: ${problem}`, locOf(specifierNode)));
const diag = lowerer.nodeIslandModules.isDataFileProblem(problem)
? nodeIslandDataFileDiag
: nodeIslandModuleDiag;
lowerer.pushDiag(diag(`${surface}: ${problem}`, locOf(specifierNode)));
throw new PoisonError();
}
const specifier = lowerer.lowerExprExpecting(specifierNode, STRING);
@@ -288,21 +295,54 @@ export function isEsmFile(file: string): boolean {
}
}
/** The file a relative (or absolute) specifier names from `fromFile`, as
* Node's CommonJS/ESM resolution would find it, or null. Extension fallback
* and directory indexes only: an island module is a file the program
* already loaded, and `exports` maps stay Node's job. */
function relativeIslandFile(fromFile: string, spec: string): string | null {
const base = isAbsolute(spec) ? spec : resolve(dirname(fromFile), spec);
const candidates = [base];
if (!/\.[cm]?js$/.test(base)) {
for (const ext of [".js", ".cjs", ".mjs"]) candidates.push(base + ext);
}
for (const ext of [".js", ".cjs", ".mjs"]) candidates.push(join(base, `index${ext}`));
for (const candidate of candidates) {
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
/* the next candidate */
}
}
return null;
}
/** The island module file `spec` names from `fromFile`, or null when the
* specifier does not resolve into an island package. */
* specifier does not resolve into an island package or module. */
export function islandModuleFile(
islands: NodeIslandSet,
fromFile: string,
spec: string,
kind: "import" | "require",
): string | null {
const js = resolveBareModule(fromFile, spec, "js-only", kind);
if (js === null) return null;
if (islandPackageOfFile(islands, js.typesFile) === null) return null;
let file: string;
if (isRelativeSpecifier(spec) || isAbsolute(spec)) {
// A program module the pre-scan put in a Node island (DESIGN.md note 1
// §3.2): the file itself is the island, resolved as Node would.
if (islands.modules.size === 0) return null;
const resolved = relativeIslandFile(fromFile, spec);
if (resolved === null) return null;
if (islandOwnerOfFile(islands, resolved) === null) return null;
file = resolved;
} else {
const js = resolveBareModule(fromFile, spec, "js-only", kind);
if (js === null) return null;
if (islandOwnerOfFile(islands, js.typesFile) === null) return null;
file = js.typesFile;
}
try {
return realpathSync(js.typesFile);
return realpathSync(file);
} catch {
return js.typesFile;
return file;
}
}
@@ -338,7 +378,10 @@ function islandLoadPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
}
const problem = lowerer.nodeIslandModules.addLoaded(real);
if (problem !== null) {
lowerer.pushDiag(nodeIslandModuleDiag(problem, loc));
const diag = lowerer.nodeIslandModules.isDataFileProblem(problem)
? nodeIslandDataFileDiag
: nodeIslandModuleDiag;
lowerer.pushDiag(diag(problem, loc));
throw new PoisonError();
}
lowerer.nodeIslandLoads.push({ literal, path: real });
@@ -368,8 +411,16 @@ function loadIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
/** A static import's load: `nodeIsland.defer(path, snapshot)`, which the
* build decides after lowering (node-islands/snapshot.ts, scope-B NI-7):
* a module proved effect-free at load keeps it with its interface snapshot
* and evaluates on first use; any other becomes nodeIsland.require. */
* and evaluates on first use; any other becomes nodeIsland.require.
*
* A program module that runs in an island loads eagerly
* (`nodeIsland.require`): deciding a deferral means evaluating the module
* in the build-time probe, and the build never runs the program's own
* source. */
function deferIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
const islands = lowerer.nodeIslands;
if (islands !== null && isIslandProgramModule(islands, path))
return loadIslandModule(lowerer, path, loc);
return {
kind: "libCall",
fn: "nodeIsland.defer",
@@ -390,9 +441,26 @@ function runsAtModuleEvaluation(node: ts.Node): boolean {
return false;
}
/** The %init statements that run in `stmt`'s place in its file's %init
* header (Lowerer.islandImportActions). */
function islandStatementActions(lowerer: Lowerer, sf: ts.SourceFile, stmt: ts.Statement): IrStmt[] {
let byStatement = lowerer.islandImportActions.get(sf);
if (byStatement === undefined) {
byStatement = new Map();
lowerer.islandImportActions.set(sf, byStatement);
}
let actions = byStatement.get(stmt);
if (actions === undefined) {
actions = [];
byStatement.set(stmt, actions);
}
return actions;
}
/** A static import (or re-export) of an island package: loads the module
* in the importer's %init prelude. Returns false when `spec` is not an
* island module. */
* in the importer's %init header, in the statement's place among the
* dependencies' %init calls, as Node evaluates it in source order. Returns
* false when `spec` is not an island module. */
export function collectIslandImport(
lowerer: Lowerer,
sf: ts.SourceFile,
@@ -404,16 +472,48 @@ export function collectIslandImport(
const path = islandModuleFile(islands, sf.fileName, spec, "import");
if (path === null) return false;
if (ts.isExportDeclaration(stmt)) {
lowerer.pushDiag(
nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)),
);
// `export { Type } from "typebox"`: the re-exporting module loads the
// island module and every name it re-exports binds the island's own
// export, so importers of this module reach it through the alias chain
// (islandBindingOf). A namespace or star re-export has no name to bind.
const clause = stmt.exportClause;
if (stmt.isTypeOnly || clause === undefined || !ts.isNamedExports(clause)) {
lowerer.pushDiag(
nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)),
);
return true;
}
lowerer.usesNodeIsland = true;
const exportLoc = locOf(stmt);
const exported = islandModuleGlobal(lowerer, path);
const initActions = islandStatementActions(lowerer, sf, stmt);
initActions.push({
kind: "assign",
localId: exported.global.id,
value: deferIslandModule(lowerer, path, exportLoc),
loc: exportLoc,
});
for (const el of clause.elements.nodes) {
if (el.isTypeOnly) continue;
const name = el.propertyName?.text ?? el.name.text;
// CommonJS named exports are the namespace's snapshot (below).
if (!exported.esm && name !== "default")
initActions.push(islandSnapshotAssign(lowerer, exported, name, exportLoc));
registerIslandReexport(
lowerer,
stmt,
el,
name === "default"
? { kind: "default", module: exported }
: { kind: "named", module: exported, name },
);
}
return true;
}
lowerer.usesNodeIsland = true;
const loc = locOf(stmt);
const binding = islandModuleGlobal(lowerer, path);
const actions = lowerer.jsonInitActions.get(sf) ?? [];
lowerer.jsonInitActions.set(sf, actions);
const actions = islandStatementActions(lowerer, sf, stmt);
actions.push({
kind: "assign",
localId: binding.global.id,
@@ -448,31 +548,36 @@ export function collectIslandImport(
return undefined;
});
}
{
for (const name of snapshotNames) {
if (name === "default") continue;
let g = binding.snapshots.get(name);
if (g === undefined) {
g = {
id: `%g.island.${lowerer.globalsList.length}`,
name,
type: DYN,
mutable: true,
};
lowerer.globalsList.push(g);
binding.snapshots.set(name, g);
}
actions.push({
kind: "assign",
localId: g.id,
value: islandGet(varRef(binding.global.id, DYN, loc), name, loc),
loc,
});
}
for (const name of snapshotNames) {
if (name === "default") continue;
actions.push(islandSnapshotAssign(lowerer, binding, name, loc));
}
return true;
}
/** A CommonJS island name's snapshot global and the %init action that
* assigns it once the module evaluated (Node's namespace snapshots named
* exports). */
function islandSnapshotAssign(
lowerer: Lowerer,
binding: IslandModuleBinding,
name: string,
loc: SrcLoc,
): IrStmt {
let g = binding.snapshots.get(name);
if (g === undefined) {
g = { id: `%g.island.${lowerer.globalsList.length}`, name, type: DYN, mutable: true };
lowerer.globalsList.push(g);
binding.snapshots.set(name, g);
}
return {
kind: "assign",
localId: g.id,
value: islandGet(varRef(binding.global.id, DYN, loc), name, loc),
loc,
};
}
function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr {
return {
kind: "libCall",
@@ -483,12 +588,55 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr {
};
}
type IslandBinding =
export type IslandBinding =
| { kind: "required"; module: IslandModuleBinding }
| { kind: "namespace"; module: IslandModuleBinding }
| { kind: "default"; module: IslandModuleBinding }
| { kind: "named"; module: IslandModuleBinding; name: string };
/** Records that `specifier`'s exported name binds `binding`: importers of
* the re-exporting module reach the island's own export through the alias
* chain, whatever they spell it (`export { Num as NumberSchema }`). The
* island's export symbol is the key a chain ends on, so it is registered
* beside the specifier's own symbol. */
function registerIslandReexport(
lowerer: Lowerer,
stmt: ts.ExportDeclaration,
specifier: ts.ExportSpecifier,
binding: IslandBinding,
): void {
const named = lowerer.checker.getSymbolAtLocation(specifier.name);
if (named !== undefined) lowerer.islandReexports.set(named, binding);
const moduleSpecifier = stmt.moduleSpecifier;
if (moduleSpecifier === undefined) return;
const moduleSymbol = lowerer.checker.getSymbolAtLocation(moduleSpecifier);
if (moduleSymbol === undefined) return;
const exported = lowerer.checker.getExportsOfModule(moduleSymbol);
const found = exported.find(
(s) => ts.symbolName(s) === (specifier.propertyName?.text ?? specifier.name.text),
);
if (found === undefined) return;
lowerer.islandReexports.set(found, binding);
if ((found.flags & ts.SymbolFlags.Alias) !== 0)
lowerer.islandReexports.set(lowerer.checker.getAliasedSymbol(found), binding);
}
/** The island binding an alias chain reaches, or null: a name re-exported
* by a native module resolves to the island's export symbol itself. */
function islandReexportOf(lowerer: Lowerer, symbol: ts.Symbol | undefined): IslandBinding | null {
if (symbol === undefined || lowerer.islandReexports.size === 0) return null;
const seen = new Set<ts.Symbol>();
let s: ts.Symbol | undefined = symbol;
while (s !== undefined && !seen.has(s)) {
seen.add(s);
const bound = lowerer.islandReexports.get(s);
if (bound !== undefined) return bound;
if ((s.flags & ts.SymbolFlags.Alias) === 0) return null;
s = lowerer.checker.getAliasedSymbol(s);
}
return null;
}
function moduleSpecifierOf(decl: ts.Node | undefined): string | null {
return ts.isImportDeclaration(decl) && ts.isStringLiteral(decl.moduleSpecifier)
? decl.moduleSpecifier.text
@@ -506,6 +654,10 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding
const islands = lowerer.nodeIslands;
if (islands === null) return null;
const symbol = lowerer.checker.getSymbolAtLocation(ident);
// A name a native module re-exports from an island package binds the
// island's own export: the import's alias chain reaches it.
const reexport = islandReexportOf(lowerer, symbol);
if (reexport !== null) return reexport;
const decl = symbol ? symbol.declarations[0] : undefined;
if (decl === undefined) return null;
let importDecl: ts.Node | undefined;
@@ -595,12 +747,19 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
if ((tsType.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 && isDiscarded(node))
return raw;
const mapped = lowerer.mapTypeOf(tsType);
if (mapped === null)
if (mapped === null) {
// An intersection of containers (`Choice[] & GenChoice[]`: the
// island's array extended by the caller's own type) has no native
// representation of its own. Where the program only uses it through
// island operations it stays a handle, like an array the island does
// not declare.
if (containerType(lowerer, tsType) && handleUseOk(lowerer, node)) return raw;
boundaryFail(
lowerer,
`an island value of type '${lowerer.checker.typeToString(tsType)}'`,
node,
);
}
if (mapped.kind === "dyn") return raw;
if (mapped.kind === "void") return raw;
// An island promise is the same promise on both sides (the runtime keeps
@@ -643,7 +802,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
if (!copiedType(lowerer, mapped) && containerType(lowerer, tsType) && handleUseOk(lowerer, node))
return raw;
// Otherwise an array of copied values is a copy (above).
if (copiedArrayType(lowerer, mapped)) {
if (copyableArrayType(lowerer, mapped)) {
const copy: IrExpr = {
kind: "libCall",
fn: "nodeIsland.copyArrays",
@@ -653,7 +812,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
};
return lowerer.coerceToExpected(copy, mapped);
}
if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType))
if (!copiedType(lowerer, mapped) && copiedRecordType(lowerer, tsType))
return lowerer.coerceToExpected(raw, mapped);
if (!copiedType(lowerer, mapped))
boundaryFail(
@@ -670,6 +829,31 @@ function copiedArrayType(lowerer: Lowerer, t: IrType): boolean {
return copiedType(lowerer, t.elem) || copiedArrayType(lowerer, t.elem);
}
/** An array of copied values, or a union of one with null and/or
* undefined (`string[] | undefined`): the copy converts whatever the
* island value holds (a non-array crosses as itself, so a nullish value
* stays nullish) and the checked conversion throws on an element of the
* wrong type. */
function copyableArrayType(lowerer: Lowerer, t: IrType): boolean {
if (t.kind !== "union") return copiedArrayType(lowerer, t);
const def = lowerer.unions.get(t.unionId);
if (def === undefined) return false;
let arrays = 0;
for (const arm of def.arms) {
if (arm.kind === "nullT" || arm.kind === "undefinedT") continue;
if (!copiedArrayType(lowerer, arm)) return false;
arrays++;
}
return arrays > 0;
}
/** The IR type an island value crosses as when it is a copyable array
* (`copyableArrayType`), or null when the declared type is not one. */
function copyableCrossing(lowerer: Lowerer, node: ts.Expression): IrType | null {
const mapped = lowerer.mapTypeOf(lowerer.typeOf(node));
return mapped !== null && copyableArrayType(lowerer, mapped) ? mapped : null;
}
/** Whether a value of IR type `t` is copied at the boundary: primitives,
* strings, null/undefined, and unions of those. */
function copiedType(lowerer: Lowerer, t: IrType): boolean {
@@ -776,7 +960,8 @@ function builtinHandleClass(type: ts.Type): boolean {
/** Arrays, tuples, Maps and Sets (every arm of a union, nullish arms
* aside), and the builtin classes of builtinHandleClass: the object types
* an island hands back without declaring them. */
* an island hands back without declaring them. An intersection of those
* (`Choice[] & GenChoice[]`) is a container too: it is a value of each. */
function containerType(lowerer: Lowerer, type: ts.Type): boolean {
if (type.isUnionType())
return ts
@@ -786,6 +971,14 @@ function containerType(lowerer: Lowerer, type: ts.Type): boolean {
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
containerType(lowerer, t),
);
if ((type.flags & ts.TypeFlags.Intersection) !== 0)
return ts
.constituentTypes(type)
.every(
(t) =>
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
containerType(lowerer, t),
);
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
if (lowerer.checker.isArrayType(type) || lowerer.checker.isTupleType(type)) return true;
const name = type.getSymbol()?.escapedName;
@@ -1115,7 +1308,11 @@ export function classConstructHelper(
/** Native values NI-5 exports into the island by reference: functions,
* promises, and object types other than arrays, tuples, Maps and Sets
* (records, interfaces, class instances). A union qualifies when every
* arm does or is copied. */
* arm does or is copied, and an intersection of them qualifies: a merged
* generic record (`Omit<Base, "k"> & Extras`, the `Type.Object(props)`
* shape) is one native object, and the island reads the program's own
* object, not a copy of it. An intersection with a primitive arm
* (`string & {brand}`) is a primitive value, which the runtime copies. */
function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean {
const checker = lowerer.checker;
if (type.isUnionType())
@@ -1126,6 +1323,14 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean {
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
exportedByReference(lowerer, t),
);
if ((type.flags & ts.TypeFlags.Intersection) !== 0)
return ts
.constituentTypes(type)
.every(
(t) =>
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
exportedByReference(lowerer, t),
);
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
// Arrays and tuples cross as Proxies over the native array (NI-5b).
void checker;
@@ -1134,6 +1339,25 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean {
return true;
}
/** A record type an island object crosses OUT into as a deep checked
* copy: the object types exportedByReference names, and intersections of
* them — a mapped pair (`{[K in Fg]: V} & {[K in Bg]?: V}`) or a merged
* generic record (`Omit<Base, "k"> & Extras`) is one record, so the
* island's object converts into the native one the same way a plain
* record's does. An intersection with a primitive arm (`string & {brand}`)
* is a primitive value, which copies as its arm. */
function copiedRecordType(lowerer: Lowerer, type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Intersection) !== 0)
return ts
.constituentTypes(type)
.every(
(t) =>
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
copiedRecordType(lowerer, t),
);
return exportedByReference(lowerer, type);
}
/** A fresh object or array literal crosses as a copy (a plain dyn object
* or array the runtime copies into V8): nothing native can alias it. Its
* members cross by the same rules (toIsland). */
@@ -1341,6 +1565,16 @@ function lowerIslandChain(
return stmts.length > 0 ? { kind: "seqExpr", stmts, result, type: result.type, loc } : result;
}
/** The local an island handle binding reads here: its own binding, retyped
* checked-dynamic, or, inside a closure, the capture of an enclosing
* function's binding, which resolveLocal registers. */
function islandHandleLocal(lowerer: Lowerer, ident: ts.Identifier): IrLocal | null {
const origin = lowerer.peekLocal(ident);
if (origin === null) return null;
origin.type = DYN;
return lowerer.resolveLocal(ident);
}
/** Expressions on island values (the lowerExpr hook; null: not an island
* expression, the ordinary lowering applies). */
export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | null {
@@ -1355,17 +1589,30 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr |
const b = islandBindingOf(lowerer, expr);
if (b === null) {
if (!islandHandleBinding(lowerer, expr)) return null;
if (!handleUseOk(lowerer, expr))
// The DECLARED type decides the crossing, not the use: an island
// handle whose type is an array of copied values crosses as a copy of
// its elements wherever native code needs a native value, and the
// checked conversion throws on an element of the wrong type. Every
// other native use stays refused (the value is a handle).
const ok = handleUseOk(lowerer, expr);
const copy = ok ? null : copyableCrossing(lowerer, expr);
if (!ok && copy === null)
boundaryFail(
lowerer,
`an island value of type '${lowerer.checker.typeToString(lowerer.typeOf(expr))}' used as a native value (island arrays, Maps and Sets stay handles: read members, call methods, index or spread them)`,
expr,
);
lowerer.usesNodeIsland = true;
const g = lowerer.globalOf(expr);
if (g === null) return null; // the local is declared dyn
g.type = DYN;
return varRef(g.id, DYN, loc);
const slot = lowerer.globalOf(expr) ?? islandHandleLocal(lowerer, expr);
if (slot === null) return null; // the local is declared dyn
slot.type = DYN;
const ref = varRef(slot.id, DYN, loc);
return copy === null
? ref
: lowerer.coerceToExpected(
{ kind: "libCall", fn: "nodeIsland.copyArrays", args: [ref], type: DYN, loc },
copy,
);
}
lowerer.usesNodeIsland = true;
return fromIsland(lowerer, islandBindingValue(lowerer, b, expr, loc), expr);
@@ -1531,11 +1531,11 @@ function lowerNetServerMethodCall(
return { kind: "libCall", fn, args: [receiver], type: VOID, loc };
}
if (name === "listen") {
if (args.length < 1 || args.length > 3) {
if (args.length > 3) {
lowerer.noLowering(
`listen with ${args.length} arguments`,
call,
"the supported form is listen(port[, host][, callback]) — port 0 binds an ephemeral port",
"the supported form is listen([port][, host][, callback]) — port 0 binds an ephemeral port",
);
}
// Node answers the server (`return this` chaining — `const s =
@@ -1549,6 +1549,90 @@ function lowerNetServerMethodCall(
return receiverReturningCall(lowerer, fn, callArgs, NETSERVER_T, loc);
};
const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T);
// listen() and listen(callback): Node binds an arbitrary unused port
// (normalizeArgs takes a function first argument as the callback).
if (args.length === 0) return listenResult("net.listen", [receiver, numLit(0, loc)]);
const first = lowerer.mapTypeOf(lowerer.typeOf(args[0]!));
// An untyped first argument: a checked-dynamic value, a JavaScript value
// read into an untyped slot, or a checker `any`.
const untyped =
args.length > 0 &&
(first?.kind === "dyn" ||
first?.kind === "jsval" ||
(lowerer.typeOf(args[0]!).flags & ts.TypeFlags.Any) !== 0);
if (args.length === 1 && first?.kind === "func") {
const { cb } = lowerCallbackArg(
lowerer,
args[0]!,
"listen callbacks",
0,
() => false,
"use ()",
[],
);
return listenResult("net.listenCb", [receiver, numLit(0, loc), cb]);
}
// An untyped first argument takes its port from net.listenPortDyn,
// Node's normalizeArgs and validatePort at run time: 0 for a function,
// undefined or null, the number of a port number or numeric string,
// ERR_SOCKET_BAD_PORT for a bad port, and the fence for an options
// object or a pipe path, which have no lowering from untyped values.
const portOfUntyped = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "net.listenPortDyn",
args: [
value,
ladderFenceExpr(
lowerer,
"listen with a runtime options object or pipe path",
args[0]!,
"pass the port as a number, the path as a string, or the options as an object literal",
),
],
type: F64,
loc,
});
if (args.length === 1 && untyped) {
// An untyped single argument: a function is the callback on an
// arbitrary port; any other value is the port.
const server = lowerer.declareHiddenLocal("%listenServer", NETSERVER_T);
const arg = lowerer.declareHiddenLocal("%listenArg", DYN);
const serverRef = (): IrExpr => varRef(server.id, NETSERVER_T, loc);
const argRef = (): IrExpr => varRef(arg.id, DYN, loc);
const listenStmt = (fn: IrLibFn, callArgs: IrExpr[]): IrStmt => ({
kind: "exprStmt",
expr: { kind: "libCall", fn, args: callArgs, type: VOID, loc },
loc,
});
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: server.id, init: receiver, loc },
{
kind: "varDecl",
localId: arg.id,
init: lowerer.coerceToExpected(lowerer.lowerExpr(args[0]!), DYN),
loc,
},
{
kind: "if",
cond: { kind: "dynTest", test: "function", value: argRef(), type: BOOL, loc },
then: [
listenStmt("net.listenCb", [
serverRef(),
numLit(0, loc),
{ kind: "dynCheck", value: argRef(), type: funcOf([], VOID), loc },
]),
],
else_: [listenStmt("net.listen", [serverRef(), portOfUntyped(argRef())])],
loc,
},
],
result: serverRef(),
type: NETSERVER_T,
loc,
};
}
// The options-object form — listen({ port, host?, ipv6Only?, reusePort? }[, cb]),
// the portless listenOnProxyInterface shape: host binds that ONE
// address (an IP literal — the runtime has no resolver here; absent
@@ -1762,7 +1846,9 @@ function lowerNetServerMethodCall(
);
return listenResult("net.listenPathCb", [receiver, path, cb]);
}
const port = lowerer.lowerExprExpecting(args[0]!, F64);
const port = untyped
? portOfUntyped(lowerer.coerceToExpected(lowerer.lowerExpr(args[0]!), DYN))
: lowerer.lowerExprExpecting(args[0]!, F64);
// The optional middle host — listen(port, '127.0.0.1'[, cb]): a
// string-typed second argument is the bind address (the net.connect
// disambiguation), routed through the options-form entry.
@@ -6499,6 +6585,33 @@ function lowerHttp2ModuleCall(
);
}
/** The scalar request options the literal walk lowers, with the type it
* lowers each one at. */
const LITERAL_REQUEST_OPTION_KINDS: ReadonlyMap<string, "string" | "f64"> = new Map([
["hostname", "string"],
["host", "string"],
["port", "f64"],
["path", "string"],
["method", "string"],
["timeout", "f64"],
["maxHeaderSize", "f64"],
]);
/** Whether a literal's scalar option holds a value of another type than the
* literal walk lowers it at, which sends the literal to the runtime walk. */
function runtimeCheckedOption(
lowerer: Lowerer,
prop: ts.PropertyAssignment | ts.ShorthandPropertyAssignment,
): boolean {
const want = LITERAL_REQUEST_OPTION_KINDS.get(
(prop.name as ts.Identifier | ts.StringLiteral).text,
);
if (want === undefined) return false;
const value = ts.isPropertyAssignment(prop) ? prop.initializer : prop.name;
const t = lowerer.mapTypeOf(lowerer.typeOf(value));
return t !== null && t.kind !== want;
}
/** The checked-dynamic options value of a request whose options the
* runtime reads: a non-literal options value (a RequestOptions variable —
* checked-dynamic storage — or a record that converts to one), or a
@@ -6528,7 +6641,11 @@ function runtimeRequestOptions(
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
(prop.name.text === "socketPath" ||
// a client certificate: the runtime request walk reads cert and key
(secureish && (prop.name.text === "cert" || prop.name.text === "key")))
(secureish && (prop.name.text === "cert" || prop.name.text === "key")) ||
// a scalar option whose value is not the walk's type (an untyped
// or union value, a string port): the runtime walk runs Node's
// checks on it (ERR_INVALID_ARG_TYPE with the option's name)
runtimeCheckedOption(lowerer, prop))
);
});
if (!needsRuntime) return null;
@@ -415,13 +415,24 @@ export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] {
}
for (const island of lowerer.nodeIslands?.packages.values() ?? [])
packageJsFiles(island.dir, queue);
// A program module the build runs in a Node island is loaded by Node, so
// the program modules IT imports are shared with it like an island
// package's (DESIGN.md note 1 §3.2). It may be TypeScript (Node strips
// its types), so it is scanned whatever its extension.
const islandModules = new Set<string>();
for (const module of lowerer.nodeIslands?.modules.values() ?? []) {
const file = real(module.file);
islandModules.add(file);
queue.push(file);
}
if (queue.length === 0 && candidates.size === 0) return plans;
const seen = new Set<string>();
// Classes island code extends, by the compiled module that exports them.
const extended = new Map<ts.SourceFile, string>();
while (queue.length > 0 && seen.size < MAX_ISLAND_FILES) {
const file = queue.pop()!;
if (seen.has(file) || compiled.has(file) || !isJsFile(file)) continue;
if (seen.has(file) || compiled.has(file)) continue;
if (!isJsFile(file) && !islandModules.has(file)) continue;
seen.add(file);
let text: string;
try {
@@ -1127,6 +1127,10 @@ export function predeclareForwardCapture(lowerer: Lowerer, symbol: ts.Symbol): b
lowerer.dynConvertible(type)
)
type = DYN;
// An island handle binding holds a checked-dynamic value whatever its
// declared type says: its own declaration lowers as one, and so must
// the slot a closure captured first.
if (islandHandleBinding(lowerer, decl.name)) type = DYN;
if (!TDZ_KINDS.has(type.kind) || isUnitType(type)) return false;
const name = decl.name.text;
const count = entry.ctx.localCounters.get(name) ?? 0;
@@ -58,7 +58,7 @@ import {
setActiveNodeIslands,
type NodeIslandSet,
} from "../../node-islands/boundary.js";
import type { IslandModuleBinding } from "./lower-node-island.js";
import type { IslandBinding, IslandModuleBinding } from "./lower-node-island.js";
import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js";
import {
needsGeneralNodeStreams,
@@ -249,6 +249,7 @@ import {
import { DeferredModuleInitializers } from "./deferred-module-initializers.js";
import { finalizeClassMethodValues } from "./class-method-values.js";
import { finalizePrivateBrands } from "./private-brands.js";
import { finalizeFunctionNames } from "./function-names.js";
import { type ClassSymbolKey, INSPECT_CUSTOM_MEMBER, tableKeyFieldType } from "./symbol-fields.js";
import type { HttpClientFnBinding } from "./lower-server.js";
export type { WidthLift } from "./width-lift.js";
@@ -538,6 +539,7 @@ import {
inferTypeParamBindings,
lowerGenericInstance,
genericLocalClassBinding,
checkGenericIdentityPins,
} from "./generic-functions.js";
import { bindingNeverReassigned } from "./binding-analysis.js";
import { isMessageListenerCall } from "./expressions/message-listeners.js";
@@ -2597,6 +2599,17 @@ export class Lowerer {
* type parameters are bound to `unknown`, so lambdaSignature admits
* them. */
readonly erasingLambdas = new Set<ts.Node>();
/** Identity comparisons of a generic declaration met while lowering
* (directly against the declaration, or through a dynamic value): exact
* only while the program holds that declaration as one function object
* (checkGenericIdentityPins). */
readonly genericIdentityPins: { info: GenericFnInfo; node: ts.Node }[] = [];
/** Identity comparisons whose operands are dynamic values, recorded while
* lowering and resolved by checkGenericIdentityPins only when some
* generic declaration is held at more than one signature. */
readonly dynIdentityComparisons: { node: ts.Node; left: ts.Node; right: ts.Node }[] = [];
/** The identity comparison check reports once, after all lowering. */
genericIdentityCheckDone = false;
/** Function expressions lowered against a fixed target signature with a
* tuple-typed `arguments` (typed-arguments.ts). */
readonly typedArgumentsLambdas = new Set<ts.Node>();
@@ -2705,6 +2718,9 @@ export class Lowerer {
requestInitPropMutatedSyms: Set<ts.Symbol> | null = null;
emitterEventTable: Map<string, EventSig> | null = null;
emitterComputedPatterns: ComputedEventPattern[] = [];
/** Some site registers a listener under an unrestricted computed name
* (lower-event-emitter.ts emitterEvents). */
emitterAnyNameListener = false;
/** The EventTarget engine's handleEvent reader was lifted
* (lower-event-target.ts noteEventListeners). */
handleEventReader = false;
@@ -3560,6 +3576,9 @@ export class Lowerer {
readonly neverValued = new Set<IrExpr>();
readonly chainNarrowedType = new Map<ts.Node, ts.Type>();
readonly chainHandled = new Set<ts.Node>();
/** One arm's narrowed value for a spread source while a distributed
* union spread lowers that arm (lowerDistributedUnionSpread). */
readonly spreadArmOverrides = new Map<ts.Node, IrExpr>();
/** for-of-over-matchAll bindings whose `.index` reads the companion-index
* array: binding SYMBOL → the hidden number[] of match start indices plus
* the hidden cursor holding THIS iteration's position (registered while
@@ -3679,6 +3698,12 @@ export class Lowerer {
readonly globalsList: IrGlobal[] = [];
readonly npmInitActions = new Map<ts.SourceFile, Map<ts.Statement, IrStmt[]>>();
/** Per-file %init statements of static island imports and re-exports,
* by the import or export statement that loads the island module: the
* importer's %init header runs them in that statement's place among its
* dependencies' %init calls, so the island module evaluates in source
* order with the native dependencies around it. */
readonly islandImportActions = new Map<ts.SourceFile, Map<ts.Statement, IrStmt[]>>();
/** Per-file %init PRELUDE statements for JSON imports: bakeable DATA
* assignments with no observable evaluation order of their own —
* prepended by lowerFileInit so the bindings are live before any
@@ -3775,6 +3800,9 @@ export class Lowerer {
readonly nodeIslandLoads: { literal: IrExpr & { kind: "strLit" }; path: string }[] = [];
/** Island handle bindings by symbol (lower-node-island.ts). */
readonly islandHandleBindings = new Map<ts.Symbol, boolean>();
/** Names a native module re-exports from an island package, by the
* symbol an alias chain reaches (lower-node-island.ts). */
readonly islandReexports = new Map<ts.Symbol, IslandBinding>();
/** `instanceof` expressions the island lowering already handles. */
readonly islandInstanceofHandled = new Set<ts.Node>();
/** NI-6: the modules shared with Node islands (lower-shared-modules.ts),
@@ -3799,6 +3827,9 @@ export class Lowerer {
>();
/** Classes used as ThreadSlots keys: their hierarchies carry a slot word. */
readonly threadSlotKeys = new Set<string>();
/** The JavaScript names of object literals' methods, by lifted function
* (function-names.ts). */
readonly objectMethodNames = new Map<string, string>();
readonly forkTargetIdByPath = new Map<string, number>();
/** Namespace ModuleBlocks this program lowers, filled by splitFiles:
* "flattened" — an instantiated namespace whose body joined the file's
@@ -4308,6 +4339,8 @@ export class Lowerer {
checkedTable("genericFunctionDemandOwner", () => this.genericFunctionDemandOwner.size),
checkedTable("genericClassDemandOwner", () => this.genericClassDemandOwner.size),
checkedTable("erasingLambdas", () => this.erasingLambdas.size),
checkedTable("genericIdentityPins", () => this.genericIdentityPins.length),
checkedTable("dynIdentityComparisons", () => this.dynIdentityComparisons.length),
checkedTable("typedArgumentsLambdas", () => this.typedArgumentsLambdas.size),
checkedTable("tupleAliasSymbols", () => this.tupleAliasSymbols.size),
checkedTable("nullishFuncResults", () => this.nullishFuncResults.size),
@@ -4398,6 +4431,7 @@ export class Lowerer {
checkedTable("globalsByDeclNode", () => this.globalsByDeclNode.size),
checkedTable("globalsList", () => this.globalsList.length),
checkedTable("npmInitActions", () => this.npmInitActions.size),
checkedTable("islandImportActions", () => this.islandImportActions.size),
checkedTable("jsonInitActions", () => this.jsonInitActions.size),
checkedTable("jsonRequireModules", () => this.jsonRequireModules.size),
checkedTable("jsonRequireCalls", () => this.jsonRequireCalls.size),
@@ -4417,6 +4451,7 @@ export class Lowerer {
checkedTable("threadSlotNames", () => this.threadSlotNames.size),
checkedTable("threadSlotFamilies", () => this.threadSlotFamilies.size),
checkedTable("threadSlotKeys", () => this.threadSlotKeys.size),
checkedTable("objectMethodNames", () => this.objectMethodNames.size),
checkedTable("forkTargetIdByPath", () => this.forkTargetIdByPath.size),
checkedTable("nsBlocks", () => this.nsBlocks.size),
checkedTable("initNameOf", () => this.initNameOf.size),
@@ -4572,6 +4607,9 @@ export class Lowerer {
// NI-5: whether a symbol names an island handle binding depends only on
// its declaration, so the answer is a memo keyed by symbol.
"islandHandleBindings",
// The island names native modules re-export: a pure function of the
// program's modules, collected before lowering begins.
"islandReexports",
// The modules shared with the islands (NI-6): a pure function of the
// program's modules, computed once, and the TDZ flags its plan gives a
// statement.
@@ -4637,6 +4675,7 @@ export class Lowerer {
"neverValued",
"chainNarrowedType",
"chainHandled",
"spreadArmOverrides",
"splitIdentifierUses",
"typeMemo",
"fnStack",
@@ -4736,10 +4775,14 @@ export class Lowerer {
fixed("errorAccessorReflection", () => this.errorAccessorReflection),
fixed("subclassLinks", () => this.subclassLinks),
fixed("errorPathCopies", () => this.errorPathCopies),
// A latch of the end-of-lowering audit (checkGenericIdentityPins): a
// unit never touches it.
fixed("genericIdentityCheckDone", () => this.genericIdentityCheckDone),
fixed("errorMethodDispatchRevision", () => this.errorMethodDispatchRevision),
fixed("errorPropertyDispatchRevision", () => this.errorPropertyDispatchRevision),
fixed("childAdditions", () => this.childAdditions),
fixed("emitterEventTable", () => this.emitterEventTable),
fixed("emitterAnyNameListener", () => this.emitterAnyNameListener),
fixed("globalThisWrittenNames", () => this.globalThisWrittenNames),
fixed("importRetryModules", () => this.importRetryModules),
fixed("localSubclassIndex", () => this.localSubclassIndex),
@@ -9754,6 +9797,9 @@ export class Lowerer {
if (this.alreadyFlushed.has(symbol)) continue;
for (const d of [...diags]) this.pushDiag(d);
}
// Coverage visits more bodies; more value instances can only add
// refusals to the identity comparison check.
checkGenericIdentityPins(this);
return {
module: null,
diagnostics: this.diags,
@@ -9770,7 +9816,12 @@ export class Lowerer {
/** Final retention, pruning, and module assembly shared by ordinary emit
* and the retained reachability worklist. */
finishModule(functions: IrFunction[]): LowerResult {
// Every instance of every generic value is known now (the
// monomorphization worklists drained): an identity comparison against
// one is exact only if the declaration is held at a single signature.
checkGenericIdentityPins(this);
boxBuiltinValuesChecked(this, functions);
finalizeFunctionNames(this, functions);
if (this.diags.length === 0) fuseArrayPipelines(this, functions);
finalizeClassMethodValues(this, functions);
finalizePrivateBrands(this, functions);
@@ -3773,26 +3773,29 @@ export function stdlibGlobalAliasNameOf(
// These globals have provenance-aware call or member lowering, so a
// stable snapshot keeps the same dispatch as the original global.
// Other callable globals still use their ordinary value paths.
return [
"process",
"console",
"globalThis",
"performance",
"Array",
"Buffer",
"BigInt",
"Number",
"String",
"Boolean",
// Code generation from strings throws Node's EvalError at every call
// (code-generation.ts); zod 4 probes `const F = Function; new F("")`.
"Function",
"eval",
].includes(name)
? name
: null;
return SNAPSHOT_GLOBALS.has(name) ? name : null;
}
/** The globals a `const x = <global>` snapshot aliases (stdlibGlobalAliasNameOf).
* Node defines every one of them, so an optional step over one of them
* (`proc?.env`, `globalThis.process?.env`) is the plain member read. */
export const SNAPSHOT_GLOBALS: ReadonlySet<string> = new Set([
"process",
"console",
"globalThis",
"performance",
"Array",
"Buffer",
"BigInt",
"Number",
"String",
"Boolean",
// Code generation from strings throws Node's EvalError at every call
// (code-generation.ts); zod 4 probes `const F = Function; new F("")`.
"Function",
"eval",
]);
/** `buffer.Buffer` through a buffer-module namespace binding (`var buffer
* = require('buffer')`): the module's Buffer IS the global constructor. */
export function bufferNamespaceMember(lowerer: Lowerer, expr: ts.Expression): boolean {
+40 -4
View File
@@ -146,6 +146,10 @@ export interface FrontendOptions {
/** Packages that run in Node islands (scope-B NI-4): never compiled,
* typed by their declarations. */
islandPackages?: ReadonlySet<string>;
/** Program modules that run in Node islands (DESIGN.md note 1 §3.2):
* their bodies are never compiled; their imports lower to island loads.
* Absolute file paths. */
islandModules?: ReadonlySet<string>;
/** The Node island report (coverage): the project's scriptc settings and
* the detector's scan of the program and its npm packages. Builds do not
* read either. */
@@ -565,6 +569,11 @@ export function runFrontend(
}
}
/** A file path as the frontend and the island decision spell it. */
function normalizeFile(file: string): string {
return file.replaceAll("\\", "/");
}
/** Rounds of dropping single refused overlay annotations before the
* fallback drops whole files' overlays. */
const OVERLAY_ANNOTATION_ROUNDS = 4;
@@ -586,6 +595,18 @@ function loadFrontend(
beginShapeOverlayBuild();
const statuses: NpmStaticStatus[] = [];
const npmSites = new Map<string, SrcLoc>();
// Program modules that run in Node islands (DESIGN.md note 1 §3.2): their
// bodies are not lowered, so their sloppy semantics stay Node's, and a
// diagnostic anchored in one does not fail the program the build does not
// compile.
const islandModules =
options.islandModules === undefined || options.islandModules.size === 0
? null
: new Set([...options.islandModules].map(normalizeFile));
const inIslandModule = (loc: SrcLoc | undefined): boolean =>
islandModules !== null && loc !== undefined && islandModules.has(normalizeFile(loc.file));
const withoutIslandModules = (diags: ScrDiagnostic[]): ScrDiagnostic[] =>
islandModules === null ? diags : diags.filter((d) => !inIslandModule(d.loc));
// Node island detection (the report, or a build that may run islands):
// how each package was refused, and the modules of packages removed from
// the program.
@@ -848,8 +869,9 @@ function loadFrontend(
const patchDiagnostics = typePatchDiagnostics()?.diagnostics ?? [];
const buildWarnings = [...(report ? [] : configDiagnostics), ...patchDiagnostics];
return {
preflight:
preflight: withoutIslandModules(
report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight,
),
...(buildWarnings.length > 0 ? { warnings: buildWarnings } : {}),
entryText: () => finalLoad.entry.text,
entryExports: () => entryFunctionExports(finalLoad.entry),
@@ -896,9 +918,23 @@ function loadFrontend(
return texts;
},
lower: (opts, valid) => {
// The module order without the island modules, computed once per load:
// a reload keeps the array the lowering appends its dynamic import
// modules to. The map lives as long as this lowering, which owns the
// loads it keys.
const islandFiltered = new Map<readonly SourceFile[], SourceFile[]>();
const lowerOrder = (l: typeof finalLoad): SourceFile[] => {
if (islandModules === null) return l.moduleOrder;
let kept = islandFiltered.get(l.moduleOrder);
if (kept === undefined) {
kept = l.moduleOrder.filter((sf) => !islandModules.has(normalizeFile(sf.fileName)));
islandFiltered.set(l.moduleOrder, kept);
}
return kept;
};
const lowerLoad = (l: typeof finalLoad): LowerResult => {
if (l.shapeOverlayState !== undefined) restoreShapeOverlayState(l.shapeOverlayState);
return lowerToIr(l.program, l.entry, l.moduleOrder, {
return lowerToIr(l.program, l.entry, lowerOrder(l), {
...opts,
frontendServices: l.services,
startupCrash: l.startupCrash ?? null,
@@ -964,7 +1000,7 @@ function loadFrontend(
const t1 = performance.now();
// The preflight fills the module order; the tsc world reads the
// files as written, so it answers as the first load did.
const reloadPreflight = checkPreflight(finalLoad);
const reloadPreflight = withoutIslandModules(checkPreflight(finalLoad));
if (reloadPreflight.length > 0)
return { ...lowered, module: null, diagnostics: reloadPreflight };
const t2 = performance.now();
@@ -1066,7 +1102,7 @@ function loadFrontend(
setShapeOverlayDropped(true);
finalLoad.dispose();
finalLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes });
const reloadPreflight = checkPreflight(finalLoad);
const reloadPreflight = withoutIslandModules(checkPreflight(finalLoad));
if (reloadPreflight.length > 0)
return { ...lowered, module: null, diagnostics: reloadPreflight };
lowered = lowerOnce();
+63 -1
View File
@@ -43,6 +43,7 @@ import { isFrontendIsland } from "../node-islands/boundary.js";
import { builtinModules } from "node:module";
import { dirname, isAbsolute, resolve } from "node:path";
import * as ts from "./ts7/adapter.js";
import { isSourcePhaseImport } from "./ts7/ast-node.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import {
commonJsModuleSyntaxDiag,
@@ -5539,6 +5540,23 @@ export function checkPreflightTs7(
}
}
/** Whether a declaration file has the runtime file TypeScript pairs it
* with (`x.d.ts` with `x.js`, `x.d.mts` with `x.mjs`, `x.d.cts` with
* `x.cjs`): a specifier that resolves to the declaration then still names a
* module Node can load. */
function declarationHasRuntimeFile(declaration: ts.SourceFile): boolean {
const name = declaration.fileName;
const pairs: [string, string][] = [
[".d.ts", ".js"],
[".d.mts", ".mjs"],
[".d.cts", ".cjs"],
];
for (const [declared, runtime] of pairs)
if (name.endsWith(declared))
return trackedFileExists(name.slice(0, -declared.length) + runtime);
return false;
}
function preflight7(load: LoadResult): {
diags: ScrDiagnostic[];
moduleOrder: ts.SourceFile[];
@@ -6067,6 +6085,21 @@ function preflight7(load: LoadResult): {
continue;
}
}
// A re-export whose specifier resolves ONLY to a declaration file
// has no runtime module to link (Node throws
// ERR_MODULE_NOT_FOUND), and a declaration file is not a program
// module: an edge to it would lower the declarations' own imports
// as code.
if (reDep !== null && reDep.isDeclarationFile && !declarationHasRuntimeFile(reDep)) {
diags.push(
unsupportedDiag(
"SC1010",
locOf7(stmt),
`the '${fromSpec}' re-export (it resolves only to type declarations — there is no runtime implementation to compile)`,
),
);
continue;
}
if (reDep && !reDep.fileName.endsWith(".json")) {
deps.push({ dep: reDep, stmt });
depPositions.get(sf)!.push({ dep: reDep, pos: ts.tokenStartOfNode(stmt) });
@@ -6075,6 +6108,14 @@ function preflight7(load: LoadResult): {
}
if (!ts.isImportDeclaration(stmt)) continue;
if (erasedTypeOnlyImport(stmt)) continue;
// `import source x from "./x.wasm"` (TypeScript 7.1) binds the module's source object, such
// as a WebAssembly.Module; compiled programs do not support it yet.
if (isSourcePhaseImport(stmt.importClause)) {
diags.push(
unsupportedDiag("SC1090", locOf7(stmt), "source phase imports (`import source`)"),
);
continue;
}
// Import-like nodes whose specifier the parse could not shape into a
// string literal — the jsdoc `/** @import x = require(...) */`
@@ -6257,6 +6298,22 @@ function preflight7(load: LoadResult): {
const clause = stmt.importClause;
const dep = isRelative ? resolveImport7(program, sf, spec) : (npmStaticDep ?? projDep);
const isJson = dep !== null && dep.fileName.endsWith(".json");
// A specifier that resolves ONLY to a declaration file has no runtime
// module to link: Node throws ERR_MODULE_NOT_FOUND at this import.
// Declarations are not program modules — taking one as a module-order
// edge would lower its imports as if it were code (SC2013 for every
// specifier inside a package's `.d.ts`). Type-only imports were
// already erased above; this is the value form.
if (dep !== null && dep.isDeclarationFile && !isJson && !declarationHasRuntimeFile(dep)) {
diags.push(
unsupportedDiag(
"SC1010",
locOf7(stmt),
`the '${spec}' import (it resolves only to type declarations — there is no runtime implementation to compile)`,
),
);
continue;
}
// SELF-imports: the bindings alias this module's OWN declarations,
// so a TOP-LEVEL read lexically above the aliased declaration is
// Node's TDZ ReferenceError at runtime (exactly what tsc's TS2448
@@ -6539,6 +6596,10 @@ function preflight7(load: LoadResult): {
}
}
if (earlyRequireFence(req, bindingKind, loc)) continue;
// A declaration file is never a runtime module (see the import
// arm): the require throws MODULE_NOT_FOUND in Node, so it is not
// a module-order edge.
if (dep !== null && dep.isDeclarationFile) continue;
if (dep) deps.push({ dep });
}
}
@@ -6617,7 +6678,8 @@ function preflight7(load: LoadResult): {
lazy ? { dep, lazy: true, call } : { dep, call };
const projectDep = resolveImport7(program, sf, spec, "require");
if (projectDep !== null) {
if (!projectDep.fileName.endsWith(".json")) deps.push(edgeTo(projectDep));
if (!projectDep.fileName.endsWith(".json") && !projectDep.isDeclarationFile)
deps.push(edgeTo(projectDep));
continue;
}
if (isRelativeSpecifier(spec) || spec.startsWith("#")) continue;
@@ -0,0 +1,46 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { checkPreflightTs7 } from "./program.js";
import { Ts7Api } from "./ts7/api.js";
import { Ts7Host } from "./ts7/program-host.js";
import { Ts7RpcClient } from "./ts7/rpc-client.js";
import { TS7_FILE_SYSTEM_CALLBACKS } from "./ts7/rpc-filesystem.js";
import { spawnTs7Wire } from "./ts7/rpc-process.js";
import { ts7TestServers } from "./ts7/test-servers.js";
test("an `import source` declaration from tsc 7.1 is reported as unsupported (SC1090)", () => {
const server = ts7TestServers().find((candidate) => candidate.ts71)!;
const dir = mkdtempSync(join(tmpdir(), "scriptc-source-phase-"));
const entry = join(dir, "main.ts");
writeFileSync(join(dir, "package.json"), '{"type":"module"}');
const declaration = 'import source mod from "./x.wasm";';
writeFileSync(entry, `${declaration}\nconsole.log(typeof mod);\n`);
const host = new Ts7Host(
(options) =>
new Ts7Api(
new Ts7RpcClient(
spawnTs7Wire(server.executable, [
"--api",
"--cwd",
options.cwd,
`--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`,
]),
),
options.fs,
options.collectTiming,
),
{ cwd: dir },
);
try {
const { diags } = checkPreflightTs7(entry, host);
const unsupported = diags.filter((diag) => diag.code === "SC1090");
expect(unsupported.map(({ message, loc }) => [message, loc.start, loc.end])).toEqual([
["source phase imports (`import source`) are not supported yet", 0, declaration.length],
]);
} finally {
host.close();
rmSync(dir, { recursive: true, force: true });
}
});
+13 -1
View File
@@ -14,7 +14,7 @@ import {
type AstFileReference,
type AstNodeHandle,
} from "./ast-wire.js";
import type { SourceFile } from "./ast-types.js";
import type { ImportClause, SourceFile } from "./ast-types.js";
import { SyntaxKind, NodeFlags } from "./enums.js";
/** Receives the file name of a source file whose raw text or line
@@ -32,6 +32,17 @@ export function observeSourceText(observer: ((fileName: string) => void) | undef
* list field returns a new AstNodeList over the same array, so `nodes` keeps
* its identity and a decoded file holds no list objects. The position is
* decoded from the wire when it is read; most readers only use `nodes`. */
/** An import clause's `phaseModifier` for TypeScript 7.1's `import source`,
* which TypeScript 7.0's syntax kinds have no keyword for. */
export const SOURCE_PHASE_MODIFIER = -1;
/** Whether `clause` is an `import source` clause (`SOURCE_PHASE_MODIFIER`). */
export function isSourcePhaseImport(clause: ImportClause | undefined): boolean {
if (clause === undefined) return false;
const phase: number | undefined = clause.phaseModifier;
return phase === SOURCE_PHASE_MODIFIER;
}
export class AstNodeList<T extends AstNode = AstNode> {
constructor(
private readonly wire: AstWireFile,
@@ -623,6 +634,7 @@ export class AstNode {
get phaseModifier(): number | undefined {
if (this.kind !== AstKind.ImportClause) return undefined;
const value = (this.data >>> 24) & 3;
if (value === 3) return SOURCE_PHASE_MODIFIER;
return value === 1 ? AstKind.TypeKeyword : value === 2 ? AstKind.DeferKeyword : undefined;
}
get token(): number | undefined {
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { AstFile } from "./ast-node.js";
import { AstFile, SOURCE_PHASE_MODIFIER } from "./ast-node.js";
import { AstWireFile, PROTOCOL_VERSION_7_1 } from "./ast-wire.js";
import {
AstKind,
@@ -118,6 +118,12 @@ test("protocol 9 nodes that TypeScript 7.0 has no form for are rejected", () =>
view.setUint32(at + NODE_OFFSET_DATA, view.getUint32(at + NODE_OFFSET_DATA, true) | 4, true),
);
expect(() => new AstWireFile(attributes)).toThrow("import attributes on a module declaration");
const version = bytes("protocol9", "main.ts");
version[3] = 6;
expect(() => new AstWireFile(version)).toThrow("unsupported protocol version 6");
});
test("protocol 9 import source clauses decode with the source phase", () => {
const source = changed("main.ts", AstKind.ImportClause, (view, at) =>
view.setUint32(
at + NODE_OFFSET_DATA,
@@ -125,8 +131,13 @@ test("protocol 9 nodes that TypeScript 7.0 has no form for are rejected", () =>
true,
),
);
expect(() => new AstWireFile(source)).toThrow("`import source`");
const version = bytes("protocol9", "main.ts");
version[3] = 6;
expect(() => new AstWireFile(version)).toThrow("unsupported protocol version 6");
const file = new AstFile(source);
let sourceClauses = 0;
for (let index = 1; index < file.wire.nodeCount; index++)
if (
file.wire.kind(index) === AstKind.ImportClause &&
file.node(index).phaseModifier === SOURCE_PHASE_MODIFIER
)
sourceClauses++;
expect(sourceClauses).toBe(1);
});
@@ -143,8 +143,9 @@ export function syntaxKind71(kind: number): number {
/** The node table of a protocol 7.1 response in TypeScript 7.0's terms: each
* kind above SourceKeyword one lower, and each module declaration's child
* mask without the attributes slot. Throws for the nodes 7.0 has no form
* for: a SourceKeyword node, a module declaration with import attributes,
* and an `import source` clause. */
* for: a SourceKeyword node and a module declaration with import attributes.
* An `import source` clause keeps its phase, which `phaseModifier` reports as
* SOURCE_PHASE_MODIFIER. */
function wordsAs70(words: Uint32Array, count: number): Uint32Array {
const result = new Uint32Array(words);
for (let index = 1; index < count; index++) {
@@ -159,8 +160,6 @@ function wordsAs70(words: Uint32Array, count: number): Uint32Array {
if ((data & 4) !== 0)
throw new AstDecodeError("import attributes on a module declaration are not supported");
result[word + WORD_DATA] = ((data & ~0xff) | (data & 3) | ((data & 8) >>> 1)) >>> 0;
} else if (kind === AstKind.ImportClause && ((data >>> 24) & 3) === 3) {
throw new AstDecodeError("`import source` is not supported");
}
}
return result;
+35 -3
View File
@@ -540,10 +540,11 @@ export async function compile(
}
/** Divergence warnings collected while a compile lowers (none when a cache
* hit skips lowering). */
* hit skips lowering), and the Node islands the build ran. */
interface CompileWarningSink {
warnings: ScrDiagnostic[];
sourceTexts: Map<string, string> | null;
islands?: { packages: string[]; modules: string[] };
}
/** The package.json texts type patch warnings point into, re-read for a
@@ -561,6 +562,15 @@ function cachedWarningSources(warnings: readonly ScrDiagnostic[]): Map<string, s
return texts;
}
/** The Node islands a build ran, stored with its cache entry so a cache hit
* reports them again (a runner reads them to tell an island pass from a
* native one). */
function islandFields(sink: CompileWarningSink | undefined): {
islands?: { packages: string[]; modules: string[] };
} {
return sink?.islands === undefined ? {} : { islands: sink.islands };
}
/** The build's performance warnings (SC61xx), stored with its cache entry
* so a cache hit repeats them. */
function performanceWarnings(sink: CompileWarningSink | undefined): {
@@ -586,8 +596,15 @@ function withCompileWarnings<T extends CompileRequestResult>(
result: T,
sink: CompileWarningSink,
): T {
if (!result.ok || sink.warnings.length === 0) return result;
return { ...result, warnings: sink.warnings, sourceTexts: sink.sourceTexts ?? new Map() };
if (!result.ok) return result;
const islands = sink.islands === undefined ? {} : { islands: sink.islands };
if (sink.warnings.length === 0) return { ...result, ...islands };
return {
...result,
warnings: sink.warnings,
sourceTexts: sink.sourceTexts ?? new Map(),
...islands,
};
}
/** Build-time API compatibility fence: a caller's existing CompileOptions
@@ -1031,6 +1048,9 @@ interface PreparedExecutable {
nativeFeatures: EarlyExecutableNativeFeatures;
programSplit: ReturnType<typeof splitLlvmProgram>;
sourceTexts: Map<string, string>;
/** The Node islands the build ran, reported with its result (a runner
* reads them to tell an island result from a native one). */
islands?: { packages: string[]; modules: string[] };
}
/** Finish frontend work in a separate scope so the AST, checker caches and
@@ -1108,6 +1128,8 @@ async function prepareExecutableInput(
warningSink.warnings = prepared.warnings;
warningSink.sourceTexts = sourceTexts;
}
if (warningSink !== undefined && prepared.islands !== undefined)
warningSink.islands = prepared.islands;
const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
const defaultSourcePaths = {
@@ -1267,6 +1289,7 @@ async function prepareExecutableInput(
nativeFeatures,
programSplit,
sourceTexts,
...(prepared.islands === undefined ? {} : { islands: prepared.islands }),
};
}
@@ -1452,6 +1475,7 @@ type FrontendProcessResult =
nativeFeatures: EarlyExecutableNativeFeatures;
sourceTexts: [string, string][];
warnings: ScrDiagnostic[];
islands?: { packages: string[]; modules: string[] };
frontend: FrontendInputSnapshot;
}
| {
@@ -1512,6 +1536,8 @@ async function prepareExecutableInputInChild(
warningSink.warnings = result.warnings;
warningSink.sourceTexts = sourceTexts;
}
if (warningSink !== undefined && result.kind === "prepared" && result.islands !== undefined)
warningSink.islands = result.islands;
if (result.kind === "diagnostics")
return { ok: false, diagnostics: result.diagnostics, sourceTexts };
return {
@@ -1523,6 +1549,7 @@ async function prepareExecutableInputInChild(
nativeFeatures: result.nativeFeatures,
programSplit: null,
sourceTexts,
...(result.islands === undefined ? {} : { islands: result.islands }),
};
}
@@ -1600,6 +1627,7 @@ export async function runFrontendProcess(requestPath: string, resultPath: string
nativeFeatures: prepared.nativeFeatures,
sourceTexts: [...prepared.sourceTexts],
warnings: warnings.warnings,
...(prepared.islands === undefined ? {} : { islands: prepared.islands }),
frontend,
};
} else if (!prepared.ok) {
@@ -2015,6 +2043,8 @@ async function compileTracked(
warningSink.warnings = earlyHit.performance;
warningSink.sourceTexts = cachedWarningSources(earlyHit.performance);
}
if (warningSink !== undefined && earlyHit.islands !== undefined)
warningSink.islands = earlyHit.islands;
const executableCacheOptions = earlyCacheOptions;
// Route/proof metadata is independently evictable. A full-compiler
// fallback that still finds the validated payload repairs that lightweight
@@ -2261,6 +2291,7 @@ async function compileTracked(
frontend: frontendInputs.snapshot(),
...(irSourcePath === undefined ? {} : { irPath: irSourcePath }),
...performanceWarnings(warningSink),
...islandFields(warningSink),
...profileMatchField(nativeProgramObject),
});
publishedExecutable = true;
@@ -2283,6 +2314,7 @@ async function compileTracked(
frontend: frontendInputs.snapshot(),
...(irSourcePath === undefined ? {} : { irPath: irSourcePath }),
...performanceWarnings(warningSink),
...islandFields(warningSink),
...profileMatchField(nativeProgramObject),
}).catch(() => undefined);
}
@@ -992,6 +992,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"net.connectCb",
"net.connectPath",
"net.connectPathCb",
// listen's port from a checked-dynamic first argument: ERR_SOCKET_BAD_PORT,
// or the fence for forms without a lowering.
"net.listenPortDyn",
// resetAndDestroy on a TLS socket throws ERR_INVALID_HANDLE_TYPE.
"net.sockResetAndDestroy",
// SocketAddress's option checks and parse()'s input check.
@@ -1711,6 +1711,7 @@ function buildLibFnSignatures() {
* compiler-rendered fence — ALWAYS THROWS (the error.nodeThrow
* polymorphic-result carve-out). May-throw seed. */
"net.connectOptsChk": { argTypes: [DYN, STRING], result: VOID },
"net.listenPortDyn": { argTypes: [DYN, STRING], result: F64 },
"net.connectCb": {
argTypes: [F64, STRING, { kind: "func", params: [], ret: VOID }],
result: NETSOCKET_T,
+26 -20
View File
@@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js";
/** A digest of the coders below: an encoding written with other coders is
* refused instead of misread, also after a change that keeps IR_VERSION. */
export const IR_CODEC_LAYOUT = "c8e9ba1a465defc2";
export const IR_CODEC_LAYOUT = "23df825e49b75d14";
/** Writes `mod` field by field (codec.ts describes the bytes). */
export function encodeIrModule(w: IrWriter, mod: IrModule): void {
@@ -25,7 +25,7 @@ export function decodeIrFunction(r: IrReader): IrFunction {
}
const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"];
const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","jsEntry","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"];
const KEYS1: readonly string[] = ["name","sourceName","displayName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","jsEntry","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"];
const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"];
const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"];
const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"];
@@ -404,6 +404,11 @@ function encIrFunction(w: IrWriter, v: IrFunction): void {
w.byte(1);
w.str(v.sourceName);
}
if (v.displayName === undefined) w.byte(0);
else {
w.byte(1);
w.str(v.displayName);
}
if (v.ownsPrototype === undefined) w.byte(0);
else {
w.byte(1);
@@ -473,24 +478,25 @@ function encIrFunction(w: IrWriter, v: IrFunction): void {
function decIrFunction(r: IrReader): IrFunction {
const f0 = r.str();
const f1 = r.tag(2, "IrFunction.sourceName") === 0 ? undefined : r.str();
const f2 = r.tag(2, "IrFunction.ownsPrototype") === 0 ? undefined : (r.bool() as NonNullable<IrFunction["ownsPrototype"]>);
const f3 = decT15(r);
const f4 = decIrType(r);
const f5 = decT17(r);
const f6 = r.tag(2, "IrFunction.captures") === 0 ? undefined : decT15(r);
const f7 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r);
const f8 = decT19(r);
const f9 = decSrcLoc(r);
const f10 = r.tag(2, "IrFunction.jsEntry") === 0 ? undefined : r.str();
const f11 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable<IrFunction["tier"]>);
const f12 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool();
const f13 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable<IrFunction["fastRead"]>);
const f14 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable<IrFunction["plainLoops"]>);
const f15 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable<IrFunction["async"]>);
const f16 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r);
const f17 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str();
const f18 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str();
const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { jsEntry: f10 } : {}), ...(f11 !== undefined ? { tier: f11 } : {}), ...(f12 !== undefined ? { speculativeDispatch: f12 } : {}), ...(f13 !== undefined ? { fastRead: f13 } : {}), ...(f14 !== undefined ? { plainLoops: f14 } : {}), ...(f15 !== undefined ? { async: f15 } : {}), ...(f16 !== undefined ? { generator: f16 } : {}), ...(f17 !== undefined ? { asyncCacheGlobal: f17 } : {}), ...(f18 !== undefined ? { asyncCycleCacheGlobal: f18 } : {}) };
const f2 = r.tag(2, "IrFunction.displayName") === 0 ? undefined : r.str();
const f3 = r.tag(2, "IrFunction.ownsPrototype") === 0 ? undefined : (r.bool() as NonNullable<IrFunction["ownsPrototype"]>);
const f4 = decT15(r);
const f5 = decIrType(r);
const f6 = decT17(r);
const f7 = r.tag(2, "IrFunction.captures") === 0 ? undefined : decT15(r);
const f8 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r);
const f9 = decT19(r);
const f10 = decSrcLoc(r);
const f11 = r.tag(2, "IrFunction.jsEntry") === 0 ? undefined : r.str();
const f12 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable<IrFunction["tier"]>);
const f13 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool();
const f14 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable<IrFunction["fastRead"]>);
const f15 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable<IrFunction["plainLoops"]>);
const f16 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable<IrFunction["async"]>);
const f17 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r);
const f18 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str();
const f19 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str();
const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { displayName: f2 } : {}), ...(f3 !== undefined ? { ownsPrototype: f3 } : {}), params: f4, returnType: f5, locals: f6, ...(f7 !== undefined ? { captures: f7 } : {}), ...(f8 !== undefined ? { classCaptures: f8 } : {}), body: f9, loc: f10, ...(f11 !== undefined ? { jsEntry: f11 } : {}), ...(f12 !== undefined ? { tier: f12 } : {}), ...(f13 !== undefined ? { speculativeDispatch: f13 } : {}), ...(f14 !== undefined ? { fastRead: f14 } : {}), ...(f15 !== undefined ? { plainLoops: f15 } : {}), ...(f16 !== undefined ? { async: f16 } : {}), ...(f17 !== undefined ? { generator: f17 } : {}), ...(f18 !== undefined ? { asyncCacheGlobal: f18 } : {}), ...(f19 !== undefined ? { asyncCycleCacheGlobal: f19 } : {}) };
return o;
}
@@ -135,6 +135,7 @@ const FUNCTION_FIELDS: Record<StringFields<Omit<IrFunction, "body">>, Role> = {
name: "other",
sourceName: "other",
jsEntry: "reference",
displayName: "other",
asyncCacheGlobal: "other",
asyncCycleCacheGlobal: "other",
"params[].name": "other",
+4
View File
@@ -1966,6 +1966,10 @@ export interface IrFunction {
name: string;
/** Source function name for native error stack frames. */
sourceName?: string;
/** The function's JavaScript `name` when it differs from sourceName: an
* object literal's method. Present only in modules that box function
* values into checked values (function-names.ts). */
displayName?: string;
/** Ordinary authored function declarations/expressions own a prototype. */
ownsPrototype?: true;
params: IrParam[];
+1 -1
View File
@@ -668,7 +668,7 @@ export class NativeCompiler {
...hugeTextLinkerArgs(hugeText, (withLld && lld.length !== 0) || zig),
...plan.driverFlags,
...mapArgs,
...(withLld ? plan.inputs : inputsWithoutLld(target.platform, plan.inputs)),
...(withLld ? plan.inputs : inputsWithoutLld(target, plan.inputs)),
...systemLibraryLinkerArgs(target, plan.systemLibraries),
"-o",
stagedOutput,
+50 -3
View File
@@ -14,6 +14,7 @@
* calls. A union is foreign when every arm that is not null or undefined
* is foreign (the dyn representation holds null and undefined). */
import * as ts from "../frontend/ts7/adapter.js";
import { trackedRealpath } from "../frontend/input-tracker.js";
import { packageDirOf } from "./origin.js";
/** One package the build runs in a Node island. */
@@ -28,9 +29,54 @@ export interface IslandPackage {
reason: string;
}
/** The program's island packages, by package name. */
/** One program module the build runs in a Node island (DESIGN.md
* note 1 §3.2): a user JavaScript file whose semantics need the JavaScript
* engine, keyed by its absolute path. */
export interface IslandModule {
/** The module file (absolute, as the checker spells it). */
file: string;
/** The island class (runtime, object-model, gap, packaging) and the first
* reason, for the SC8001 note. */
class: string;
reason: string;
/** Marked by the user (`--node-island`, `nodeIslands.modules`) or
* detected by the pre-scan under `--node-islands=auto`. */
decision: "marked" | "auto";
}
/** The program's island packages, by package name, and island modules, by
* absolute file path. */
export interface NodeIslandSet {
packages: ReadonlyMap<string, IslandPackage>;
modules: ReadonlyMap<string, IslandModule>;
}
function normalizePath(file: string): string {
return file.replaceAll("\\", "/");
}
/** The island module at `file`, or null. */
export function islandModuleOfFile(islands: NodeIslandSet, file: string): IslandModule | null {
if (islands.modules.size === 0) return null;
return islands.modules.get(file) ?? islands.modules.get(normalizePath(file)) ?? null;
}
/** Whether `file` (a path as the checker spells it, or its real path) is a
* program module of the island set. */
export function isIslandProgramModule(islands: NodeIslandSet, file: string): boolean {
if (islandModuleOfFile(islands, file) !== null) return true;
for (const module of islands.modules.values())
if ((trackedRealpath(module.file) ?? module.file) === file) return true;
return false;
}
/** The island (package or module) that owns a declaration or JavaScript
* file, or null. Modules first: a user module is never inside a package. */
export function islandOwnerOfFile(
islands: NodeIslandSet,
file: string,
): IslandPackage | IslandModule | null {
return islandModuleOfFile(islands, file) ?? islandPackageOfFile(islands, file);
}
/** The island package that owns a declaration or JavaScript file, or null.
@@ -65,7 +111,7 @@ function declaredInIsland(islands: NodeIslandSet, symbol: ts.Symbol | null | und
if (decls.length === 0) return false;
if (islandSubclassDecls.size > 0 && decls.some((d) => islandSubclassDecls.has(d))) return true;
return decls.every(
(d) => islandPackageOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null,
(d) => islandOwnerOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null,
);
}
@@ -150,7 +196,8 @@ export function crossingOf(
let active: NodeIslandSet | null = null;
export function setActiveNodeIslands(islands: NodeIslandSet | null): void {
active = islands !== null && islands.packages.size > 0 ? islands : null;
active =
islands !== null && (islands.packages.size > 0 || islands.modules.size > 0) ? islands : null;
}
export function activeNodeIslands(): NodeIslandSet | null {
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "vitest";
import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js";
import { scanModule } from "./detect.js";
import { scanModule, scanModuleFacts } from "./detect.js";
afterAll(() => closeSourceParser());
@@ -13,7 +13,8 @@ function markers(source: string, ext = "cjs"): string[] {
describe("R1 runtime markers", () => {
test("R1.1 code from strings", () => {
expect(markers("eval(code);")).toEqual(["R1.1 code-from-string"]);
// A bare eval() is also a sloppy-mode construct (R2.4 direct-eval).
expect(markers("eval(code);")).toEqual(["R1.1 code-from-string", "R2.4 direct-eval"]);
expect(markers("(0, eval)(code);")).toEqual(["R1.1 code-from-string"]);
expect(markers("const f = new Function('a', 'return a');")).toEqual(["R1.1 code-from-string"]);
expect(markers("const f = Function('a', body);")).toEqual(["R1.1 code-from-string"]);
@@ -80,13 +81,16 @@ describe("R2 object-model markers", () => {
expect(markers("const util = require('util'); util.inherits(A, B);")).toEqual([
"R2.1 constructor-function",
]);
// `this` in a plain function is also the conservative sloppy-this marker
// (decision S2): the file cannot see a call site in another module.
expect(markers("function Child() { Parent.call(this, 1); }")).toEqual([
"R2.1 constructor-function",
"R2.4 sloppy-this",
]);
expect(markers("class C {} const c = new C();")).toEqual([]);
expect(
markers("function wrap(fn) { return function () { return fn.apply(this, arguments); }; }"),
).toEqual([]);
).toEqual(["R2.4 sloppy-this"]);
});
test("R2.2 prototype mutation", () => {
@@ -133,12 +137,83 @@ describe("R2 object-model markers", () => {
expect(markers("class C { m(a) { a = 2; return arguments[0]; } }")).toEqual([]);
});
test("R2.4 direct eval in sloppy mode", () => {
expect(markers("eval(code);")).toEqual(["R1.1 code-from-string", "R2.4 direct-eval"]);
expect(markers("function f() { return eval('x'); }")).toEqual([
"R1.1 code-from-string",
"R2.4 direct-eval",
]);
// Indirect, aliased and builtin-object calls read no local scope.
expect(markers("(0, eval)(code);")).toEqual(["R1.1 code-from-string"]);
// An aliased eval is not a call the scan recognizes as code-from-string.
expect(markers("const e = eval; e(code);")).toEqual([]);
expect(markers("globalThis.eval(code);")).toEqual(["R1.1 code-from-string"]);
expect(markers("function eval(code) { return code; } eval('x');")).toEqual([]);
// Strict code's direct eval cannot see the caller's scope: no marker.
expect(markers("'use strict'; eval(code);")).toEqual(["R1.1 code-from-string"]);
expect(markers("export function f() { return eval('x'); }", "mjs")).toEqual([
"R1.1 code-from-string",
]);
});
test("R2.4 Annex B block functions in sloppy mode", () => {
// A block-level function declaration also creates a var-like binding in
// the enclosing function (Annex B.3.2).
expect(markers("if (x) { function f() {} f(); }")).toEqual(["R2.4 annexb-block-function"]);
expect(markers("{ function f() {} }")).toEqual(["R2.4 annexb-block-function"]);
expect(markers("function g() { { function f() {} } }")).toEqual(["R2.4 annexb-block-function"]);
// A declaration in a function body or at the top level is ordinary, and
// a strict block declaration is block-scoped.
expect(markers("function f() {}")).toEqual([]);
expect(markers("function g() { function f() {} }")).toEqual([]);
expect(markers("'use strict'; if (x) { function f() {} }")).toEqual([]);
expect(markers("export function g() { if (x) { function f() {} } }", "mjs")).toEqual([]);
});
test("R2.4 'this' in a plain sloppy call (decision S2)", () => {
// A plain call's `this` is the global object in sloppy mode and
// undefined in strict mode: the file cannot see its call sites, so any
// plain function's `this` marks the module.
expect(markers("function f() { return this; }")).toEqual(["R2.4 sloppy-this"]);
expect(markers("const f = function () { return this; };")).toEqual(["R2.4 sloppy-this"]);
expect(markers("function f() { const g = () => this; return g(); }")).toEqual([
"R2.4 sloppy-this",
]);
expect(markers("function f() { return { m() { return this; } }; }")).toEqual([]);
// A receiver-carrying call, a class body, a class member initializer,
// top-level `this` (the exports object) and strict code carry no marker.
expect(markers("const o = { m() { return this; } }; o.m();")).toEqual([]);
// A property-assigned function expression is indistinguishable from a
// bare-call site in another module: conservative marking (S2).
expect(markers("const o = { m: function () { return this; } };")).toEqual(["R2.4 sloppy-this"]);
expect(markers("class C { m() { return this; } static s = this; }")).toEqual([]);
expect(markers("this.x = 1;")).toEqual([]);
expect(markers("'use strict'; function f() { return this; }")).toEqual([]);
expect(markers("export function f() { return this; }", "mjs")).toEqual([]);
});
test("R2.4 receiver-proven functions stay native (decision S5)", () => {
const sloppy = (source: string) => markers(source).filter((m) => m.startsWith("R2.4"));
// Native passes the receiver for both shapes: `new X()` and a
// `X.prototype.name` method. Marking them would move native passes.
expect(sloppy("function Ctor() { this.x = 1; } new Ctor();")).toEqual([]);
expect(sloppy("const Ctor = function () { this.x = 1; }; new Ctor();")).toEqual([]);
expect(sloppy("function M() {} M.prototype.m = function () { return this; };")).toEqual([]);
// The name is also called plainly, so the receiver is not proven.
expect(sloppy("function Ctor() { return this; } new Ctor(); Ctor();")).toEqual([
"R2.4 sloppy-this",
]);
// A callback's receiver belongs to its callee, which the file cannot
// see: conservative marking (S2).
expect(sloppy("function f() { return this; } [1].map(f);")).toEqual(["R2.4 sloppy-this"]);
});
test("R2.5 builtin and global patching", () => {
expect(markers("Array.prototype.last = function () {};")).toEqual(["R2.5 builtin-write"]);
expect(markers("Promise = Bluebird;")).toEqual(["R2.5 builtin-write"]);
expect(markers("globalThis.fetch = f;")).toEqual(["R2.5 builtin-write"]);
expect(markers("const fs = require('fs'); fs.close = patched;")).toEqual([
"R2.5 builtin-write",
"R2.5 builtin-patch",
]);
expect(markers("const p = new Proxy(t, h);")).toEqual(["R2.5 proxy"]);
// Modeled process state and tuning knobs stay; so do redeclared names.
@@ -149,6 +224,106 @@ describe("R2 object-model markers", () => {
});
});
describe("R2.5 builtin patches (H3.1)", () => {
/** The markers and the member each builtin patch names. */
function patches(source: string, ext = "cjs"): string[] {
const sf = parseSourceFile(`/virtual/p${serial++}.${ext}`, source, "js");
return scanModuleFacts(sf).markers.map(
(m) => `${m.reason} ${m.member === undefined ? "-" : m.member}`,
);
}
test("a write through the CommonJS object is a builtin patch", () => {
expect(patches("const fs = require('fs'); fs.readFileSync = f;")).toEqual([
"builtin-patch fs.readFileSync",
]);
});
test("createRequire(), casts and aliases resolve to the same object", () => {
expect(
patches(
"const r = createRequire(import.meta.url); const fs = r('node:fs') as typeof import('fs'); (fs as { readFileSync: unknown }).readFileSync = f;",
"mjs",
),
).toEqual(["builtin-patch fs.readFileSync"]);
expect(
patches(
"const r = createRequire(import.meta.url); const fs = r('node:fs') as typeof import('fs'); const m = fs as { readFileSync: unknown }; m.readFileSync = f;",
"mjs",
),
).toEqual(["builtin-patch fs.readFileSync"]);
});
test("a default import is the CommonJS object", () => {
expect(patches("import fs from 'node:fs'; fs.writeFileSync = f;", "mjs")).toEqual([
"builtin-patch fs.writeFileSync",
]);
});
test("defineProperty, Object.assign, delete and bind are patches too", () => {
expect(
patches("const fs = require('fs'); Object.defineProperty(fs, 'readFileSync', { value: f });"),
).toEqual(["builtin-patch fs.readFileSync"]);
expect(patches("const fs = require('fs'); Object.assign(fs, { readFileSync: f });")).toEqual([
"builtin-patch fs.readFileSync",
]);
expect(patches("const fs = require('fs'); Object.assign(fs, source);")).toEqual([
"builtin-patch fs.*",
]);
expect(patches("const fs = require('fs'); delete fs.readFileSync;")).toEqual([
"builtin-patch fs.readFileSync",
]);
expect(
patches("const fs = require('fs'); fs.readFileSync = fs.readFileSync.bind(fs);"),
).toEqual(["builtin-patch fs.readFileSync"]);
});
test("writes Node itself refuses are not patches", () => {
// A namespace import is not the CommonJS object: Node throws on the
// write, and the module stays as it is today.
expect(patches("import * as fs from 'node:fs'; fs.x = 1;", "mjs")).toEqual(["builtin-write -"]);
expect(patches("import { readFileSync } from 'node:fs'; readFileSync(p);", "mjs")).toEqual([]);
expect(patches("const fs = { readFileSync: 1 }; fs.readFileSync = 2;")).toEqual([]);
expect(patches("process.exitCode = 1;")).toEqual([]);
});
});
test("CommonJS object reads are recorded for the patch check (H3.4)", () => {
const reads = (source: string, ext = "cjs"): string[] => {
const sf = parseSourceFile(`/virtual/r${serial++}.${ext}`, source, "js");
return scanModuleFacts(sf).cjsReads.map((read) => read.member);
};
expect(reads("const fs = require('fs'); fs.readFileSync(p);")).toEqual(["fs.readFileSync"]);
expect(reads("import { readFileSync } from 'node:fs'; readFileSync(p);", "mjs")).toEqual([]);
// A write is not a read; an ES module read through the namespace import
// is not the CommonJS object either.
expect(reads("const fs = require('fs'); fs.readFileSync = f;")).toEqual([]);
expect(reads("import fs from 'node:fs'; fs.readFileSync(p);", "mjs")).toEqual([
"fs.readFileSync",
]);
expect(reads("import * as fs from 'node:fs'; fs.readFileSync(p);", "mjs")).toEqual([]);
// Bracket notation: a literal key names the member, a computed one reads
// a member the scan cannot name.
expect(reads("const fs = require('fs'); fs['readFileSync'](p);")).toEqual(["fs.readFileSync"]);
expect(reads("const fs = require('fs'); fs[name](p);")).toEqual(["fs.*"]);
expect(reads("const fs = require('fs'); fs['readFileSync'] = f;")).toEqual([]);
// Destructuring reads each property it takes when it runs.
expect(reads("const { readFileSync } = require('node:fs');")).toEqual(["fs.readFileSync"]);
expect(reads("const fs = require('fs'); const { readFileSync: read, ...rest } = fs;")).toEqual([
"fs.readFileSync",
"fs.*",
]);
expect(reads("const { promises: { readFile } } = require('fs');")).toEqual([
"fs.promises",
"fs.promises.readFile",
]);
expect(
reads("import fs from 'node:fs'; let readFileSync; ({ readFileSync } = fs);", "mjs"),
).toEqual(["fs.readFileSync"]);
// A namespace import is not the CommonJS object.
expect(reads("import * as fs from 'node:fs'; const { readFileSync } = fs;", "mjs")).toEqual([]);
});
test("markers come back in source order with their locations", () => {
const source = "Object.setPrototypeOf(a, b);\neval(x);\n";
const sf = parseSourceFile(`/virtual/order${serial++}.cjs`, source, "js");
@@ -156,6 +331,7 @@ test("markers come back in source order with their locations", () => {
expect(found.map((m) => [m.rule, source.slice(m.loc.start, m.loc.end)])).toEqual([
["R2.2", "Object.setPrototypeOf(a, b)"],
["R1.1", "eval(x)"],
["R2.4", "eval(x)"],
]);
});
+421 -41
View File
@@ -9,8 +9,10 @@
* declare it at all, and a builtin module object when the file binds it to
* require("<builtin>") or imports it from one. Checker-dependent parts of
* R2.3 (writes the declared type does not cover, freeze followed by writes)
* and R2.4 (assignments to undeclared identifiers, `this` as the global
* object) are left to the lowering. */
* and R2.4 (assignments to undeclared identifiers) are left to the lowering;
* `this` in a plain call is marked conservatively (decision S2): a file
* cannot see its call sites, so every `this` in a sloppy plain function
* marks the module. */
import type { SrcLoc } from "../ir/ir.js";
import * as ts from "../frontend/ts7/adapter.js";
import { SUPPORTED_BUILTIN_MODULES } from "../frontend/builtin-modules.js";
@@ -23,6 +25,18 @@ export interface NodeIslandMarker {
/** The construct, as a short phrase for messages. */
construct: string;
loc: SrcLoc;
/** For `builtin-patch`: the builtin module member the module patches
* ("fs.readFileSync"; "fs.*" when the source is not an object literal). */
member?: string;
}
/** A member read through a builtin module's CommonJS object (H3.4): the
* patch a Node island module installs is visible to such a read, which a
* native build cannot reproduce. */
export interface CjsRead {
/** The member read ("fs.readFileSync"). */
member: string;
loc: SrcLoc;
}
/** Markers past this count in one module add nothing to a report. */
@@ -125,12 +139,37 @@ function builtinModuleName(spec: string): string | null {
return null;
}
/** Parentheses and the TypeScript wrappers Node erases (`as`,
* `satisfies`, `!` and the angle-bracket assertion). */
function stripWrappers(node: ts.Node): ts.Node {
let value = node;
for (;;) {
if (
ts.isParenthesizedExpression(value) ||
ts.isAsExpression(value) ||
ts.isTypeAssertion(value) ||
ts.isNonNullExpression(value) ||
ts.isSatisfiesExpression(value)
)
value = value.expression;
else return value;
}
}
function literalText(node: ts.Node | undefined): string | null {
if (node === undefined) return null;
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
return null;
}
/** A property name the scan can spell: an identifier, a string or number
* literal, or a computed literal key. */
function staticKey(node: ts.Node): string | null {
if (ts.isIdentifier(node) || ts.isNumericLiteral(node)) return node.text;
if (ts.isComputedPropertyName(node)) return literalText(stripWrappers(node.expression));
return literalText(node);
}
/** A specifier that folds at build time: a literal, or a concatenation of
* literals. */
function isFoldableSpecifier(node: ts.Node): boolean {
@@ -145,6 +184,15 @@ function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
/** The key of an object-literal property (`name`, `"name"` or a
* template without substitutions). */
function literalKeyName(name: ts.Node | undefined): string | null {
if (name === undefined) return null;
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name))
return name.text;
return null;
}
function propertyName(node: ts.Node): string | null {
if (ts.isPropertyAccessExpression(node)) {
const name = node.name;
@@ -157,12 +205,12 @@ function propertyName(node: ts.Node): string | null {
/** `a.b.c` as "a.b.c" (element accesses with literal keys included), or
* null when the chain has a computed key or does not start at a name. */
function chainText(node: ts.Node): string | null {
if (ts.isIdentifier(node)) return node.text;
if (node.kind === ts.SyntaxKind.ThisKeyword) return "this";
if (ts.isParenthesizedExpression(node)) return chainText(node.expression);
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const base = chainText(node.expression);
const name = propertyName(node);
const value = stripWrappers(node);
if (ts.isIdentifier(value)) return value.text;
if (value.kind === ts.SyntaxKind.ThisKeyword) return "this";
if (ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value)) {
const base = chainText(value.expression);
const name = propertyName(value);
if (base === null || name === null) return null;
return `${base}.${name}`;
}
@@ -171,15 +219,16 @@ function chainText(node: ts.Node): string | null {
/** Like chainText, with computed keys spelled `[…]`: `process.env[…]`. */
function memberText(node: ts.Node): string | null {
const text = chainText(node);
const value = stripWrappers(node);
const text = chainText(value);
if (text !== null) return text;
if (ts.isElementAccessExpression(node)) {
const base = memberText(node.expression);
if (ts.isElementAccessExpression(value)) {
const base = memberText(value.expression);
return base === null ? null : `${base}[…]`;
}
if (ts.isPropertyAccessExpression(node)) {
const base = memberText(node.expression);
const name = propertyName(node);
if (ts.isPropertyAccessExpression(value)) {
const base = memberText(value.expression);
const name = propertyName(value);
return base === null || name === null ? null : `${base}.${name}`;
}
return null;
@@ -187,15 +236,11 @@ function memberText(node: ts.Node): string | null {
/** The identifier a member chain starts at (`a` of `a.b[c].d`). */
function chainRoot(node: ts.Node): ts.Identifier | null {
let cur = node;
let cur = stripWrappers(node);
for (;;) {
if (ts.isIdentifier(cur)) return cur;
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) {
cur = cur.expression;
continue;
}
if (ts.isParenthesizedExpression(cur)) {
cur = cur.expression;
cur = stripWrappers(cur.expression);
continue;
}
return null;
@@ -236,6 +281,29 @@ function isFunctionBoundary(node: ts.Node): boolean {
);
}
/** The name a plain function value is bound to in the same file
* (`function f`, `var f = function`), for the constructor refinement. */
function boundFunctionName(fn: ts.FunctionDeclaration | ts.FunctionExpression): string | null {
if (ts.isFunctionDeclaration(fn)) return fn.name?.text ?? null;
const parent = fn.parent;
if (parent === undefined) return null;
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
if (ts.isPropertyAssignment(parent)) return propertyName(parent.name);
return null;
}
/** `X.prototype.name = function () {}`: a method assignment, whose every
* call through the same spelling has a receiver. */
function isPrototypeFunctionAssignment(left: ts.PropertyAccessExpression, right: ts.Node): boolean {
let value = right;
while (ts.isParenthesizedExpression(value)) value = value.expression;
return (
ts.isFunctionExpression(value) &&
ts.isPropertyAccessExpression(left.expression) &&
propertyName(left.expression) === "prototype"
);
}
/** One module's pre-scan. */
class ModuleScanner {
readonly markers: NodeIslandMarker[] = [];
@@ -250,6 +318,21 @@ class ModuleScanner {
private readonly builtinBindings = new Map<string, string>();
/** Local names bound to a createRequire(...) result. */
private readonly createdRequires = new Set<string>();
/** Local names bound to a builtin module's CommonJS object: require(),
* a createRequire'd require's result, a default import, or an alias of
* one. Writes through these names are patches of the module object. */
private readonly cjsBuiltinObjects = new Set<string>();
/** Members read through a CommonJS builtin object (H3.4). */
readonly cjsReads: CjsRead[] = [];
/** Names the file constructs with `new`: a constructor's `this` is never
* the global object, so its `this` uses need no island (decision S5). */
private readonly constructed = new Set<string>();
/** Names the file calls as a bare identifier, `f(...)`: a name used both
* ways is a constructor and a plain function, so it still marks. */
private readonly bareCalled = new Set<string>();
/** Function expressions the file assigns to `X.prototype.name`: every
* same-file call of them is a receiver call (decision S5). */
private readonly prototypeFunctions = new Set<ts.Node>();
private readonly strictness = new Map<ts.Node, boolean>();
private readonly moduleStrict: boolean;
@@ -272,13 +355,20 @@ class ModuleScanner {
return this.markers;
}
private add(rule: NodeIslandRule, reason: string, construct: string, node: ts.Node): void {
private add(
rule: NodeIslandRule,
reason: string,
construct: string,
node: ts.Node,
member?: string,
): void {
if (this.markers.length >= MARKER_CAP) return;
this.markers.push({
rule,
reason,
construct,
loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end },
...(member === undefined ? {} : { member }),
});
}
@@ -287,18 +377,22 @@ class ModuleScanner {
if (ts.isIdentifier(name)) {
this.declared.add(name.text);
if (init === undefined) return;
let value = init;
while (ts.isParenthesizedExpression(value)) value = value.expression;
const value = stripWrappers(init);
if (ts.isFunctionExpression(value) || ts.isArrowFunction(value))
this.functions.add(name.text);
else if (ts.isClassExpression(value)) this.classes.add(name.text);
const bound = this.builtinOf(value);
if (bound !== null) this.builtinBindings.set(name.text, bound);
if (bound !== null) {
this.builtinBindings.set(name.text, bound);
if (!bound.includes(".") && this.isCjsObjectValue(value))
this.cjsBuiltinObjects.add(name.text);
}
if (ts.isCallExpression(value) && this.isCreateRequireCallee(value.expression))
this.createdRequires.add(name.text);
return;
}
if (ts.isObjectBindingPattern(name)) {
this.recordCjsPatternReads(name, init);
const base = init === undefined ? null : this.builtinOf(init);
for (const element of name.elements.nodes) {
if (!ts.isBindingElement(element)) continue;
@@ -319,11 +413,25 @@ class ModuleScanner {
}
}
/** A value that is a builtin module's CommonJS object: a require() or a
* createRequire'd require's result of a builtin, or an alias of one. */
private isCjsObjectValue(value: ts.Node): boolean {
if (ts.isIdentifier(value)) return this.cjsBuiltinObjects.has(value.text);
if (!ts.isCallExpression(value)) return false;
return (
this.isRequireCallee(value.expression) ||
(ts.isIdentifier(value.expression) && this.createdRequires.has(value.expression.text))
);
}
/** `require("fs")`, `require("node:util").inherits` → "fs", "util.inherits". */
private builtinOf(node: ts.Node): string | null {
let value = node;
while (ts.isParenthesizedExpression(value)) value = value.expression;
if (ts.isCallExpression(value) && this.isRequireCallee(value.expression)) {
const value = stripWrappers(node);
if (
ts.isCallExpression(value) &&
(this.isRequireCallee(value.expression) ||
(ts.isIdentifier(value.expression) && this.createdRequires.has(value.expression.text)))
) {
const spec = literalText(value.arguments.nodes[0]);
return spec === null ? null : builtinModuleName(spec);
}
@@ -362,6 +470,17 @@ class ModuleScanner {
this.bindName(node.name, node.initializer);
} else if (ts.isParameter(node)) {
this.bindName(node.name, undefined);
} else if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) {
this.constructed.add(node.expression.text);
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
this.bareCalled.add(node.expression.text);
} else if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isPropertyAccessExpression(node.left) &&
isPrototypeFunctionAssignment(node.left, node.right)
) {
this.prototypeFunctions.add(node.right);
} else if (ts.isImportDeclaration(node)) {
const spec = literalText(node.moduleSpecifier);
const module = spec === null ? null : builtinModuleName(spec);
@@ -369,7 +488,12 @@ class ModuleScanner {
if (clause !== undefined) {
if (clause.name !== undefined) {
this.declared.add(clause.name.text);
if (module !== null) this.builtinBindings.set(clause.name.text, module);
if (module !== null) {
this.builtinBindings.set(clause.name.text, module);
// A default import is the CommonJS object (unlike a namespace
// or named import, through which Node refuses writes).
this.cjsBuiltinObjects.add(clause.name.text);
}
}
const bindings = clause.namedBindings;
if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
@@ -427,12 +551,75 @@ class ModuleScanner {
return BUILTIN_GLOBALS.has(name) && !this.declared.has(name);
}
/** A function declaration whose parent is a block that is not a function
* body: Annex B.3.2's block-level declaration, which in sloppy code also
* creates a `var`-like binding in the enclosing function (DESIGN.md note 1
* §3.1, construct 5). A declaration directly in a function body or at the
* top level of a script is ordinary. */
private isAnnexBBlockFunction(fn: ts.FunctionDeclaration): boolean {
const parent = fn.parent;
if (parent === undefined || !ts.isBlock(parent)) return false;
const owner = parent.parent;
if (owner === undefined) return false;
// A function-like body, a class static block or a class member
// initializer block is not a bare block statement.
return (owner as ts.FunctionLikeDeclaration).body !== parent;
}
/** Whether the file shows the function is only ever called with a
* receiver, so native's missing sloppy `this` cannot be observed: it is
* constructed with `new`, or it is a `X.prototype.name` method (decision
* S5 — the marker islands what native gets wrong, and native passes the
* receiver for both shapes). */
private receiverSafe(fn: ts.FunctionDeclaration | ts.FunctionExpression): boolean {
if (this.prototypeFunctions.has(fn)) return true;
const name = boundFunctionName(fn);
return name !== null && this.constructed.has(name) && !this.bareCalled.has(name);
}
/** `this` in a plain function call: a sloppy function that is not a
* method, constructor, accessor or class member, and is not an arrow
* function (whose `this` is its enclosing function's). Conservative
* (decision S2): a file-level scan cannot see cross-file call sites, so
* any `this` in such a function marks the module, except the shapes the
* file itself proves are receiver calls (decision S5). Top-level `this`
* is not this marker: it is the module's exports object, and the test262
* profile's script-environment exclusion covers the script form. */
private visitThis(node: ts.Node): void {
if (this.isStrict(node)) return;
for (let cur: ts.Node | undefined = node.parent; cur !== undefined; cur = cur.parent) {
// An arrow function has no `this` of its own: keep looking outward.
if (ts.isArrowFunction(cur)) continue;
if (ts.isFunctionDeclaration(cur) || ts.isFunctionExpression(cur)) {
if (this.receiverSafe(cur)) return;
this.add("R2.4", "sloppy-this", "'this' in a plain call in sloppy mode", node);
return;
}
// A method, constructor or accessor has a receiver; a class member
// initializer and a class static block have their own `this`; a
// property assignment's function is commonly a method too.
if (
isFunctionBoundary(cur) ||
ts.isClassDeclaration(cur) ||
ts.isClassExpression(cur) ||
ts.isClassStaticBlockDeclaration(cur) ||
ts.isPropertyDeclaration(cur) ||
ts.isPropertyAssignment(cur)
)
return;
// Top-level `this` is the module's exports object, not a call site.
if (ts.isSourceFile(cur)) return;
}
}
private visit(node: ts.Node): void {
if (ts.isCallExpression(node)) this.visitCall(node);
else if (ts.isNewExpression(node)) this.visitNew(node);
else if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind))
else if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
this.visitWrite(node.left, node, "write");
else if (ts.isDeleteExpression(node)) this.visitDelete(node);
if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken)
this.recordCjsPatternReads(node.left, node.right);
} else if (ts.isDeleteExpression(node)) this.visitDelete(node);
else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
if (
node.operator === ts.SyntaxKind.PlusPlusToken ||
@@ -440,7 +627,9 @@ class ModuleScanner {
)
this.visitWrite(node.operand, node, "write");
} else if (ts.isWithStatement(node)) {
this.add("R2.4", "with", "a 'with' statement", node);
// A `with` in strict code is an early error (Node refuses the file
// too), so only sloppy code carries the sloppy-semantics marker.
if (!this.isStrict(node)) this.add("R2.4", "with", "a 'with' statement", node);
} else if (ts.isImportDeclaration(node)) {
const spec = literalText(node.moduleSpecifier);
if (spec !== null) this.visitSpecifier(spec, node);
@@ -454,6 +643,11 @@ class ModuleScanner {
const text = chainText(node);
if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node))
this.add("R2.4", "sloppy-arguments", `'${text}'`, node);
this.recordCjsRead(node);
} else if (ts.isElementAccessExpression(node)) {
this.recordCjsRead(node);
} else if (node.kind === ts.SyntaxKind.ThisKeyword) {
this.visitThis(node);
} else if (ts.isObjectLiteralExpression(node)) {
for (const prop of node.properties.nodes) {
if (!ts.isPropertyAssignment(prop)) continue;
@@ -467,6 +661,104 @@ class ModuleScanner {
}
}
/** Records a read of a member of a builtin module's CommonJS object, so
* a patch installed by an island module can be checked against it
* (H3.4). The left of an assignment (and a delete or ++/-- target) is a
* write, not a read. A computed key reads a member the scan cannot name,
* so it is recorded as the whole module ("fs.*"). */
private recordCjsRead(node: ts.PropertyAccessExpression | ts.ElementAccessExpression): void {
const root = chainRoot(node);
if (root === null || !this.cjsBuiltinObjects.has(root.text)) return;
const module = this.builtinBindings.get(root.text);
if (module === undefined) return;
const text = chainText(node);
const parent = node.parent;
if (parent !== undefined) {
if (
ts.isBinaryExpression(parent) &&
isAssignmentOperator(parent.operatorToken.kind) &&
parent.left === node
)
return;
if (
ts.isDeleteExpression(parent) ||
ts.isPrefixUnaryExpression(parent) ||
ts.isPostfixUnaryExpression(parent)
)
return;
}
this.pushCjsRead(text === null ? `${module}.*` : module + text.slice(root.text.length), node);
}
private pushCjsRead(member: string, node: ts.Node): void {
this.cjsReads.push({
member,
loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end },
});
}
/** The reads of a destructuring whose source is a builtin module's
* CommonJS object (`const { readFileSync } = require("fs")`,
* `({ readFileSync } = fs)`): each property it takes is read when the
* destructuring runs, so it sees a patch installed before. A rest element
* or a computed key reads members the scan cannot name ("fs.*"). */
private recordCjsPatternReads(pattern: ts.Node, source: ts.Node | undefined): void {
if (source === undefined) return;
const value = stripWrappers(source);
if (!this.isCjsObjectValue(value)) return;
const module = this.builtinOf(value);
if (module === null || module.includes(".")) return;
this.recordPatternReads(pattern, module);
}
private recordPatternReads(pattern: ts.Node, base: string): void {
const target = stripWrappers(pattern);
const elements: {
key: ts.Node | undefined;
value: ts.Node | undefined;
rest: boolean;
node: ts.Node;
}[] = [];
if (ts.isObjectBindingPattern(target)) {
for (const element of target.elements.nodes) {
if (!ts.isBindingElement(element)) continue;
elements.push({
key: element.propertyName ?? element.name,
value: element.name,
rest: element.dotDotDotToken !== undefined,
node: element,
});
}
} else if (ts.isObjectLiteralExpression(target)) {
for (const prop of target.properties.nodes) {
if (ts.isPropertyAssignment(prop))
elements.push({ key: prop.name, value: prop.initializer, rest: false, node: prop });
else if (ts.isShorthandPropertyAssignment(prop))
elements.push({ key: prop.name, value: undefined, rest: false, node: prop });
else if (ts.isSpreadAssignment(prop))
elements.push({ key: undefined, value: undefined, rest: true, node: prop });
}
} else return;
for (const element of elements) {
const key = element.rest || element.key === undefined ? null : staticKey(element.key);
if (key === null) {
this.pushCjsRead(`${base.split(".")[0]}.*`, element.node);
continue;
}
const member = `${base}.${key}`;
this.pushCjsRead(member, element.node);
// A nested pattern reads the member's own properties too.
if (element.value !== undefined) {
let inner = stripWrappers(element.value);
// `{ readFileSync = fallback }` (a default) and `{ a: { b } = {} }`.
if (ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.EqualsToken)
inner = stripWrappers(inner.left);
if (ts.isObjectBindingPattern(inner) || ts.isObjectLiteralExpression(inner))
this.recordPatternReads(inner, member);
}
}
}
private visitSpecifier(spec: string, node: ts.Node): void {
const builtin = builtinModuleName(spec);
if (builtin === "vm") this.add("R1.1", "vm", "the 'vm' module", node);
@@ -506,16 +798,23 @@ class ModuleScanner {
// eval, Function
let target = callee;
while (ts.isParenthesizedExpression(target)) target = target.expression;
// A bare `eval(...)` is the intrinsic's DIRECT call: it reads and
// creates bindings in the caller's scope, and in sloppy mode creates
// globals (DESIGN.md note 1 §3.1). `globalThis.eval(...)`, the
// comma-operator idiom `(0, eval)(...)` and the other spellings are
// indirect: they observe no caller scope, so only R1.1.
const direct = ts.isIdentifier(target) && target.text === "eval" && !this.declared.has("eval");
if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.CommaToken)
target = target.right;
const text = chainText(target);
if (text === null) return;
if (
(text === "eval" && !this.declared.has("eval")) ||
text === "globalThis.eval" ||
text === "global.eval" ||
text === "window.eval"
) {
if (text === "eval" && !this.declared.has("eval")) {
this.add("R1.1", "code-from-string", "eval()", call);
if (direct && !this.isStrict(call))
this.add("R2.4", "direct-eval", "a direct call of eval() in sloppy mode", call);
return;
}
if (text === "globalThis.eval" || text === "global.eval" || text === "window.eval") {
this.add("R1.1", "code-from-string", "eval()", call);
return;
}
@@ -574,10 +873,67 @@ class ModuleScanner {
this.isGlobal("Object")) ||
(text === "Reflect.defineProperty" && this.isGlobal("Reflect"))
) {
// A definition on a builtin module's CommonJS object is a patch the
// code loading that object must see (R2.5, H3.1.4).
const target = first === undefined ? undefined : stripWrappers(first);
const builtin =
target !== undefined && ts.isIdentifier(target) && this.cjsBuiltinObjects.has(target.text)
? (this.builtinBindings.get(target.text) ?? null)
: null;
if (builtin !== null) {
const key = literalText(args[1] === undefined ? undefined : stripWrappers(args[1]));
this.add(
"R2.5",
"builtin-patch",
`'${text}()' on the builtin module '${builtin}'`,
call,
key === null ? `${builtin}.*` : `${builtin}.${key}`,
);
return;
}
if (first !== undefined && !this.isModuleObject(first) && !this.inInteropHelper(call))
this.add("R2.3", "define-property", `'${text}()'`, call);
return;
}
// Object.assign(fs, …) on a builtin module's CommonJS object: every
// literal key is a patch (R2.5, H3.1.4).
if (text === "Object.assign" && this.isGlobal("Object") && first !== undefined) {
const target = stripWrappers(first);
if (ts.isIdentifier(target) && this.cjsBuiltinObjects.has(target.text)) {
const builtin = this.builtinBindings.get(target.text) ?? null;
if (builtin !== null) {
const source = args.length === 2 ? stripWrappers(args[1]!) : undefined;
const keys =
source !== undefined && ts.isObjectLiteralExpression(source)
? source.properties.nodes
.map((property) =>
ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)
? literalKeyName(property.name)
: null,
)
.filter((key): key is string => key !== null)
: [];
if (keys.length === 0)
this.add(
"R2.5",
"builtin-patch",
`'Object.assign()' on the builtin module '${builtin}'`,
call,
`${builtin}.*`,
);
else
for (const key of keys)
this.add(
"R2.5",
"builtin-patch",
`'Object.assign()' on the builtin module '${builtin}'`,
call,
`${builtin}.${key}`,
);
return;
}
}
}
if (text.endsWith(".__defineGetter__") || text.endsWith(".__defineSetter__")) {
this.add("R2.3", "define-property", `'${text.slice(text.lastIndexOf(".") + 1)}()'`, call);
return;
@@ -733,11 +1089,15 @@ class ModuleScanner {
if (binding !== undefined) {
const member = binding + spelled.slice(root.text.length);
if (!this.allowedWrite(member)) {
// A write through a builtin module's CommonJS object patches that
// object; only code sharing Node's builtin can see it (H3.1).
const patch = this.cjsBuiltinObjects.has(root.text);
this.add(
"R2.5",
"builtin-write",
patch ? "builtin-patch" : "builtin-write",
`a ${what} of the builtin module member '${member}'`,
node,
patch ? member : undefined,
);
}
return;
@@ -789,6 +1149,13 @@ class ModuleScanner {
/** Observable `arguments` aliasing in a sloppy function: writes to
* arguments[i], or to a parameter while `arguments` is read. */
private visitFunction(fn: ts.FunctionLikeDeclaration): void {
if (ts.isFunctionDeclaration(fn) && this.isAnnexBBlockFunction(fn) && !this.isStrict(fn))
this.add(
"R2.4",
"annexb-block-function",
"a function declaration in a block in sloppy mode",
fn,
);
const body = fn.body;
if (body === undefined || !ts.isBlock(body)) return;
if (this.isStrict(body.statements.nodes[0] ?? body)) return;
@@ -842,8 +1209,21 @@ interface ArgumentsUse {
paramWrite: ts.Node | null;
}
/** The R1/R2 markers of one JavaScript module, in source order. */
/** The R1/R2 markers and CommonJS-object reads of one module, in source
* order. */
export function scanModuleFacts(sf: ts.SourceFile): {
markers: NodeIslandMarker[];
cjsReads: CjsRead[];
} {
const scanner = new ModuleScanner(sf);
const markers = scanner.scan();
return {
markers: markers.sort((a, b) => a.loc.start - b.loc.start),
cjsReads: scanner.cjsReads.sort((a, b) => a.loc.start - b.loc.start),
};
}
/** The R1/R2 markers of one module, in source order. */
export function scanModule(sf: ts.SourceFile): NodeIslandMarker[] {
const markers = new ModuleScanner(sf).scan();
return markers.sort((a, b) => a.loc.start - b.loc.start);
return scanModuleFacts(sf).markers;
}
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { describe, expect, test } from "vitest";
import {
NodeIslandModuleCollector,
namedDataPaths,
nodeIslandManifest,
readNodeIslandModules,
requireModuleSpecifiers,
@@ -19,6 +20,26 @@ function project(files: Record<string, string>): string {
return root;
}
describe("data paths a module names", () => {
test("string literals, and consecutive literal arguments joined", () => {
const names = namedDataPaths(`
const a = readFileSync(join(here, "fallback", "payload.txt"));
const b = "assets/config.json";
const c = resolve(__dirname, "x", base, "y");
// "comment.txt"
const d = \`\${dir}/template.txt\`;
`);
expect([...names.keys()].sort()).toEqual([
"assets/config.json",
"fallback",
"fallback/payload.txt",
"payload.txt",
"x",
"y",
]);
});
});
describe("module specifiers in JavaScript", () => {
test("string-literal loads in every form", () => {
const scan = scanModuleSpecifiers(`
@@ -249,6 +270,101 @@ describe("the collected module tree", () => {
expect(NodeIslandModuleCollector.callerPath(null, join(root, "main.ts"))).toBe("main.ts");
});
test("a data file the module names beside itself ships with it", () => {
const root = project({
"src/main.ts": "",
"src/loader.ts": [
'import { join } from "node:path";',
'const here = "/x";',
'const fallback = join(here, "fallback", "payload.txt");',
].join("\n"),
"src/fallback/payload.txt": "from the fallback path\n",
"src/unrelated.txt": "not named by the module\n",
"src/helper.ts": "export const h = 1;",
});
const collector = new NodeIslandModuleCollector();
expect(collector.add(join(root, "src/main.ts"), "./loader.ts")).toBeNull();
const paths = collector.tree()!.files.map((file) => file.path);
expect(paths).toContain("fallback/payload.txt");
expect(paths).not.toContain("unrelated.txt");
expect(paths).not.toContain("helper.ts");
});
test("named data stays inside the module's directory: links out of it are not followed", () => {
const outside = project({ "secrets/config.json": "{}", "token.txt": "secret\n" });
const root = project({
"src/main.ts": "",
"src/loader.ts":
'const names = ["linked/config.json", "data/token.txt", "data/payload.txt", "data/loop/data/payload.txt"];',
"src/data/payload.txt": "shipped\n",
});
symlinkSync(join(outside, "secrets"), join(root, "src/linked"), "dir");
symlinkSync(join(outside, "token.txt"), join(root, "src/data/token.txt"));
// A link back up the tree must not loop the walk.
symlinkSync(join(root, "src"), join(root, "src/data/loop"), "dir");
const collector = new NodeIslandModuleCollector();
expect(collector.add(join(root, "src/main.ts"), "./loader.ts")).toBeNull();
const paths = collector.tree()!.files.map((file) => file.path);
expect(paths).toContain("data/payload.txt");
expect(paths.filter((path) => path.endsWith("config.json"))).toEqual([]);
expect(paths.filter((path) => path.endsWith("token.txt"))).toEqual([]);
expect(paths.filter((path) => path.includes("loop"))).toEqual([]);
});
test("only the exact path a module names ships, never a file of the same name elsewhere", () => {
const outside = project({ "outside.txt": "private\n" });
const root = project({
"src/main.ts": "",
"src/loader.cjs": [
'const fs = require("node:fs");',
'const config = fs.readFileSync(__dirname + "/" + "assets/config.json");',
'const up = "../notes.txt";',
`const away = "${join(outside, "outside.txt")}";`,
'const deps = "node_modules/dep/config.json";',
].join("\n"),
"src/assets/config.json": "{}",
"src/private/config.json": '{ "token": "secret" }',
"src/config.json": '{ "token": "secret" }',
"src/node_modules/dep/config.json": "{}",
"notes.txt": "beside the source directory\n",
});
const collector = new NodeIslandModuleCollector();
expect(collector.add(join(root, "src/main.ts"), "./loader.cjs")).toBeNull();
const paths = collector.tree()!.files.map((file) => file.path);
expect(paths.filter((path) => !path.endsWith(".cjs"))).toEqual(["assets/config.json"]);
});
test("a joined data path ships even when another directory has a file of that name", () => {
const root = project({
"src/main.ts": "",
"src/loader.cjs":
'const { join } = require("node:path");\nrequire("node:fs").readFileSync(join(__dirname, "fallback", "payload.txt"));\n',
"src/a-other/payload.txt": "not named by the module\n",
"src/fallback/payload.txt": "shipped\n",
});
const collector = new NodeIslandModuleCollector();
expect(collector.add(join(root, "src/main.ts"), "./loader.cjs")).toBeNull();
const paths = collector.tree()!.files.map((file) => file.path);
expect(paths).toContain("fallback/payload.txt");
expect(paths).not.toContain("a-other/payload.txt");
});
test("a named file that is only elsewhere in the module's directory is a build error", () => {
const root = project({
"src/main.ts": "",
"src/loader.cjs":
'const fs = require("node:fs");\nfs.readFileSync(__dirname + "/config.json");\n',
"src/private/config.json": '{ "token": "secret" }',
});
const collector = new NodeIslandModuleCollector();
const problem = collector.add(join(root, "src/main.ts"), "./loader.cjs");
expect(problem).toBe(
`${join(root, "src/loader.cjs")}:2 names the file 'config.json', which is not at that path beside the module; the build ships a module's data files by their exact path, so 'private/config.json' does not ship with the Node island. Name the file by its path relative to the module ('private/config.json')`,
);
expect(collector.isDataFileProblem(problem!)).toBe(true);
expect(collector.tree()!.files.map((file) => file.path)).not.toContain("private/config.json");
});
test("the manifest names relative paths only, and the data is checked against it", () => {
const root = project({ "main.ts": "", "m.cjs": "exports.f = () => 1;" });
const collector = new NodeIslandModuleCollector();
@@ -176,6 +176,14 @@ export class NodeIslandModuleCollector {
private readonly loaded = new Set<string>();
/** Program modules whose source the program provides (addProvided). */
private readonly provided = new Set<string>();
/** The problems addNamedData reported (isDataFileProblem). */
private readonly dataProblems = new Set<string>();
/** Whether `problem`, returned by add or addLoaded, is a data file the
* build does not ship (addNamedData) rather than a module. */
isDataFileProblem(problem: string): boolean {
return this.dataProblems.has(problem);
}
/** Collects the module `specifier` names from `caller` (a real path).
* Returns null, or the reason the build cannot ship it. */
@@ -324,6 +332,8 @@ export class NodeIslandModuleCollector {
if (!TRACED.test(file)) return null;
const text = trackedReadFile(file);
if (text === null) return `cannot read ${file}`;
const dataProblem = this.addNamedData(file, text);
if (dataProblem !== null) return dataProblem;
const scan = scanModuleSpecifiers(text);
if (scan.computed.length > 0) {
const first = scan.computed[0]!;
@@ -337,6 +347,82 @@ export class NodeIslandModuleCollector {
return null;
}
/** Data files beside a program module that runs in an island: the module
* resolves them from its own directory (photon redirects a package's
* read to the wasm next to the patching module), and the island runs from
* the shipped tree, so a file the module names by its path relative to
* the module's directory ships with it (namedDataPaths). Only the exact
* path ships, and module sources ship through the traced walk, never as
* data. A named path stays inside the module's directory: one that leads
* outside it, through `..` or a symbolic link, or into node_modules or
* .git, is not shipped, so nothing from elsewhere on the machine is
* embedded.
*
* Returns the reason the build cannot ship the module when it names a
* file by a path where there is none while a file of that name is inside
* its directory: the island would read a file the build left out. */
private addNamedData(moduleFile: string, text: string): string | null {
const root = dirname(moduleFile);
const inside = (path: string): boolean => {
const rel = relative(root, path);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
};
const unresolved: { name: string; at: number }[] = [];
/** The names of the files this module's named paths ship. */
const shippedNames = new Set<string>();
for (const [name, at] of namedDataPaths(text)) {
if (isAbsolute(name) || name.includes("\0")) continue;
const path = resolve(root, name);
if (!inside(path) || path === moduleFile || TRACED.test(path)) continue;
const parts = relative(root, path).split(sep);
if (parts.includes("node_modules") || parts.includes(".git")) continue;
if (!trackedFileExists(path)) {
if (basename(name).includes(".")) unresolved.push({ name, at });
continue;
}
const target = trackedRealpath(path);
if (target === null || !inside(target)) continue;
// A link inside the directory stays a link in the tree (placed).
this.files.add(this.placed(path));
shippedNames.add(basename(path));
}
if (unresolved.length === 0) return null;
// The files of the module's directory by name, links leading outside it
// not followed.
const byName = new Map<string, string>();
const visited = new Set<string>();
const walk = (dir: string): void => {
const real = trackedRealpath(dir);
if (real === null || (real !== root && !inside(real)) || visited.has(real)) return;
visited.add(real);
const entries = trackedAccessibleEntries(dir);
if (entries === null) return;
for (const file of entries.files) {
const path = join(dir, file);
if (byName.has(file) || TRACED.test(file) || path === moduleFile) continue;
const target = trackedRealpath(path);
if (target !== null && inside(target)) byName.set(file, path);
}
for (const sub of entries.directories)
if (sub !== "node_modules" && sub !== ".git") walk(join(dir, sub));
};
walk(root);
for (const { name, at } of unresolved) {
// A name of a file another named path ships (`"payload.txt"` beside
// `join(here, "fallback", "payload.txt")`) is not missing, wherever
// other files of that name are.
if (shippedNames.has(basename(name))) continue;
const found = byName.get(basename(name));
if (found === undefined || this.files.has(trackedRealpath(found) ?? found)) continue;
const line = text.slice(0, at).split("\n").length;
const there = portable(relative(root, found));
const problem = `${moduleFile}:${line} names the file '${name}', which is not at that path beside the module; the build ships a module's data files by their exact path, so '${there}' does not ship with the Node island. Name the file by its path relative to the module ('${there}')`;
this.dataProblems.add(problem);
return problem;
}
return null;
}
/** A package: its whole directory, then its dependencies. */
private addPackage(found: string): void {
const dir = this.placed(found);
@@ -590,6 +676,59 @@ export interface ModuleSpecifierScan {
computed: { at: number; form: string }[];
}
/** The relative paths a module's source names by string literals, with the
* offset of the first literal that names each: each
* literal (`"data/table.json"`), and the literals passed as consecutive
* arguments of one call joined with `/`, as `path.join(here, "fallback",
* "payload.txt")` names `fallback/payload.txt`. Comments and template
* literals with substitutions name nothing; `__dirname + "/data.json"` names
* `data.json`. */
export function namedDataPaths(text: string): Map<string, number> {
const tokens = moduleTokens(text);
const names = new Map<string, number>();
const add = (name: string, at: number): void => {
if (!names.has(name)) names.set(name, at);
};
/** The open brackets around the current token. */
const open: string[] = [];
let run: Token[] = [];
const end = (): void => {
if (run.length > 1) add(run.map((t) => t.value).join("/"), run[0]!.at);
run = [];
};
for (let k = 0; k < tokens.length; k++) {
const t = tokens[k]!;
if (t.kind === "string") {
if (t.value.includes("${")) {
end();
continue;
}
add(t.value, t.at);
const before = tokens[k - 1];
const after = tokens[k + 1];
// `__dirname + "/data.json"` names data.json beside the module.
if (before?.kind === "punct" && before.value === "+" && t.value.startsWith("/"))
add(t.value.slice(1), t.at);
const argument =
open[open.length - 1] === "(" &&
before?.kind === "punct" &&
(before.value === "(" || before.value === ",") &&
after?.kind === "punct" &&
(after.value === ")" || after.value === ",");
if (!argument) end();
else run.push(t);
continue;
}
if (t.kind === "punct") {
if (t.value === "(" || t.value === "[" || t.value === "{") open.push(t.value);
else if (t.value === ")" || t.value === "]" || t.value === "}") open.pop();
}
if (t.kind !== "punct" || t.value !== ",") end();
}
end();
return names;
}
/** The string-literal arguments of `requireModule(...)` calls in source
* code, comments and template text excluded. */
export function requireModuleSpecifiers(text: string): string[] {
+2 -3
View File
@@ -4,9 +4,8 @@
* become islands only when marked (`nodeIslands.modules`,
* `--node-island=<path>`); `"native"` (`--native=<pkg>`) makes one
* dependency strict; `--node-islands=off` makes the whole program strict.
*
* This version is report-only: the policy decides what WOULD run in Node,
* and coverage reports it, but code generation does not change. */
* The decision drives code generation: an island package or module is not
* compiled natively but loaded by the program's Node island. */
import { isAbsolute, join, resolve, dirname } from "node:path";
import type { ProjectConfig } from "../project-config.js";
@@ -0,0 +1,96 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { PROBE_RECORDER } from "./probe-recorder.js";
const made: string[] = [];
afterEach(() => {
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function project(files: Record<string, string>): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-probe-recorder-")));
made.push(root);
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), text);
}
return root;
}
interface ProbeResult {
path: string;
throws?: string;
root?: number;
}
/** Runs the recorder in a Node process of its own, as the probe host runs
* it in libnode, over `modules`. */
function probe(root: string, modules: string[]): ProbeResult[] {
writeFileSync(join(root, "recorder.cjs"), PROBE_RECORDER);
writeFileSync(
join(root, "run.cjs"),
`const out = require("./recorder.cjs").probe({ modules: ${JSON.stringify(modules)} });\n` +
`process.stdout.write(JSON.stringify(out.results));\n`,
);
const run = spawnSync(process.execPath, [join(root, "run.cjs")], { cwd: root, encoding: "utf8" });
expect(run.status, run.stderr).toBe(0);
return JSON.parse(run.stdout) as ProbeResult[];
}
// A source file that, when it evaluates, writes `evaluated-<name>` beside itself.
const marker = (name: string): string =>
`require("fs").writeFileSync(require("path").join(__dirname, "evaluated-${name}"), "");\nmodule.exports = { n: 1 };\n`;
test("the probe evaluates an installed package", () => {
const root = project({
"node_modules/pkg/package.json": '{ "name": "pkg" }',
"node_modules/pkg/index.js": "module.exports = { answer: 42 };\n",
});
const [result] = probe(root, [join(root, "node_modules/pkg/index.js")]);
expect(result!.throws).toBeUndefined();
expect(result!.root).toBeDefined();
});
test("the probe never evaluates the program's own source", () => {
const root = project({
"src/program.js": marker("program"),
"src/shared.js": marker("shared"),
"node_modules/reaches/package.json": '{ "name": "reaches" }',
"node_modules/reaches/index.js": 'module.exports = require("../../src/shared.js");\n',
});
const results = probe(root, [
join(root, "src/program.js"),
join(root, "node_modules/reaches/index.js"),
]);
expect(results.map((r) => r.throws)).toEqual([
`the build does not evaluate ${join(root, "src/program.js")}, a file of the program`,
`the build does not evaluate ${join(root, "src/shared.js")}, a file of the program`,
]);
expect(existsSync(join(root, "src/evaluated-program"))).toBe(false);
expect(existsSync(join(root, "src/evaluated-shared"))).toBe(false);
});
test("a package that catches the refusal does not defer, nor does one that loads it later", () => {
const root = project({
"src/config.js": marker("config"),
"node_modules/catches/package.json": '{ "name": "catches" }',
"node_modules/catches/index.js":
'let config;\ntry { config = require("../../src/config.js"); } catch { config = { fallback: true }; }\nmodule.exports = config;\n',
"node_modules/later/package.json": '{ "name": "later" }',
"node_modules/later/index.js": 'module.exports = { config: require("catches") };\n',
"node_modules/clean/package.json": '{ "name": "clean" }',
"node_modules/clean/index.js": "module.exports = { ok: true };\n",
});
const results = probe(root, [
join(root, "node_modules/catches/index.js"),
join(root, "node_modules/later/index.js"),
join(root, "node_modules/clean/index.js"),
]);
const refusal = `the build does not evaluate ${join(root, "src/config.js")}, a file of the program`;
expect(results.map((r) => r.throws)).toEqual([refusal, refusal, undefined]);
expect(results.map((r) => r.root === undefined)).toEqual([true, true, false]);
expect(existsSync(join(root, "src/evaluated-config"))).toBe(false);
});
@@ -20,6 +20,9 @@
* (island-local, since native code has its own builtins, but
* observable by other island modules evaluated out of order);
* - every file the load required (the module's closure), and a throw;
* a load that resolves a file outside every node_modules directory (the
* program's own source) throws before that file evaluates, and its
* record is that throw even when the load catches it;
*
* and takes the interface snapshot of the module's value (snapshot.ts).
* Calls made by Node's own internals (the module loader reading files,
@@ -188,6 +191,11 @@ exports.probe = function probe(input) {
const inputs = [], volatile = [], effects = [], tty = new Map(), envKeys = new Map();
const files = new Set();
let recording = false;
// The first program file the current load asked for (the resolve hook),
// and, for every file a load with a refusal evaluated, that program file:
// the module cache keeps what such a file exported without it.
let refused = null;
const refusedBy = new Map();
const note = (list, what) => {
if (recording && !internalCaller()) list.push(list === volatile ? what : what + " (" + lastCaller + ")");
};
@@ -429,11 +437,21 @@ exports.probe = function probe(input) {
undo.push(() => hook.disable());
// The closure: a resolve hook (no frame of ours stays on the stack while
// modules evaluate, so Node's node_modules checks see the real callers).
// The probe evaluates installed packages only: a file outside every
// node_modules directory is the program's own source (a program module,
// or a project file a package requires), and its load throws before the
// file evaluates, so the module that asked for it stays eager.
const hooks = Module.registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (recording && typeof result.url === "string" && result.url.startsWith("file:"))
files.add(require("node:url").fileURLToPath(result.url));
if (recording && typeof result.url === "string" && result.url.startsWith("file:")) {
const file = require("node:url").fileURLToPath(result.url);
if (!/[\\/]node_modules[\\/]/.test(file)) {
if (refused === null) refused = file;
throw new Error("the build does not evaluate " + file + ", a file of the program");
}
files.add(file);
}
return result;
},
});
@@ -501,6 +519,7 @@ exports.probe = function probe(input) {
pendingTimers.clear();
loadPromises.clear();
tty.clear(); envKeys.clear(); files.clear();
refused = null;
files.add(path);
currentFiles = files;
currentPackageDir = null;
@@ -512,6 +531,15 @@ exports.probe = function probe(input) {
recording = true;
try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); }
recording = false;
// A load that catches the refusal evaluates a fallback the executable
// never runs: its exports are not the module's, and neither are those of
// a later load that reaches one of its files through the module cache.
if (refused === null)
for (const f of files) if (refusedBy.has(f)) { refused = refusedBy.get(f); break; }
if (refused !== null) {
threw = "the build does not evaluate " + refused + ", a file of the program";
for (const f of files) if (!refusedBy.has(f)) refusedBy.set(f, refused);
}
const after = shot();
const root = threw === undefined ? value(exportsValue, 0) : undefined;
const mutations = [];
@@ -20,6 +20,8 @@ function mod(pkg: string | null, file: string, markers: NodeIslandMarker[] = [])
packageDir: pkg === null ? null : `${NM}/${pkg}`,
packageName: pkg,
markers,
cjsReads: [],
stripProblem: null,
dropped: false,
};
}
@@ -69,7 +69,11 @@ const REASON_RULES: Record<string, NodeIslandRule> = {
with: "R2.4",
"sloppy-arguments": "R2.4",
"sloppy-mode": "R2.4",
"direct-eval": "R2.4",
"annexb-block-function": "R2.4",
"sloppy-this": "R2.4",
"builtin-write": "R2.5",
"builtin-patch": "R2.5",
proxy: "R2.5",
};
+57 -2
View File
@@ -12,7 +12,8 @@ import { resolveBareModule } from "../frontend/resolve.js";
import { isJsSourceFileName } from "../frontend/tsc-codes.js";
import { isRelativeSpecifier, packageNameOfSpecifier } from "../frontend/workspace-registry.js";
import { moduleSourceCandidates } from "../frontend/module-source-candidates.js";
import { scanModule, type NodeIslandMarker } from "./detect.js";
import { scanModuleFacts, type CjsRead, type NodeIslandMarker } from "./detect.js";
import { stripProblem, type StripProblem } from "./type-strip.js";
import { originOf, packageDirOf, type Origin } from "./origin.js";
export interface ScannedModule {
@@ -22,6 +23,11 @@ export interface ScannedModule {
packageDir: string | null;
packageName: string | null;
markers: NodeIslandMarker[];
/** Members the module reads through a builtin's CommonJS object (H3.4). */
cjsReads: CjsRead[];
/** A user TypeScript module with a `builtin-patch` marker that Node
* cannot run with type stripping only (H3.5): it stays native. */
stripProblem: StripProblem | null;
/** True for a module of a package the drop-and-reload loop removed. */
dropped: boolean;
}
@@ -94,6 +100,44 @@ function specifiersOf(program: ts.Program, sf: ts.SourceFile): { spec: string; n
return out;
}
/** The diagnostics of a construct that sloppy-mode Node accepts: the
* frontend compiles every file under the project's `strict` options (with
* `alwaysStrict`), so a `with`, an octal literal, or a strict-only reserved
* word reports here even though the file itself is a CommonJS script that
* Node runs sloppy. JavaScript grammar errors arrive in the bind pass
* (TS1101 among them), so both passes filter by this set. They cannot hide
* a real syntax error: they exist only for code the engine parses in sloppy
* mode. */
const SLOPPY_LEGAL_CODES: ReadonlySet<number> = new Set([
1100, // invalid use of a strict-mode reserved word
1101, // 'with' statements are not allowed in strict mode
1102, // 'delete' of an identifier in strict mode
1121, // an octal literal
1487, // an octal escape sequence
1210, // a class body's strict-mode restriction
1212, // a strict-mode reserved word
1213, // a strict-mode reserved word in a class
1214, // a strict-mode reserved word in a module
1215, // invalid use of a strict-mode-only construct in a module
]);
/** Whether the file parses as the sloppy JavaScript the island would run:
* a file with a genuine syntax or early error cannot run in an island as
* written (Node refuses it too, at load), so its pre-scan markers must not
* put it there — a parse-negative test stays the compile refusal it is
* (decision S5: the markers narrow, never move, the native passes). */
function sloppyParsable(program: ts.Program, sf: ts.SourceFile): boolean {
try {
const sloppyLegal = (d: ts.Diagnostic): boolean => SLOPPY_LEGAL_CODES.has(d.code);
return (
program.getSyntacticDiagnostics(sf).every(sloppyLegal) &&
program.getBindDiagnostics(sf).every(sloppyLegal)
);
} catch {
return true;
}
}
/** Scans `files`: markers for npm JavaScript (and for user JavaScript when
* `userJs`), and package edges for every file when `edges` (a program with
* no npm package has none to find). */
@@ -112,13 +156,24 @@ export function scanFiles(
if (origin === "builtin" || origin === "shim") continue;
const at = origin === "npm" ? packageDirOf(sf.fileName) : null;
const js = isJsSourceFileName(sf.fileName);
const markers = js && (origin === "npm" || opts.userJs) ? scanModule(sf) : [];
// A user TypeScript module is scanned only for the builtin-patch marker
// (H3.2): its other markers are JavaScript semantics, and sloppy
// parsing is never required of TypeScript.
const userTs = !js && origin === "user" && !sf.isDeclarationFile && opts.userJs;
let facts: ReturnType<typeof scanModuleFacts> = { markers: [], cjsReads: [] };
if ((js && (origin === "npm" || opts.userJs)) || userTs) facts = scanModuleFacts(sf);
let markers = userTs
? facts.markers.filter((marker) => marker.reason === "builtin-patch")
: facts.markers;
if (js && markers.length > 0 && !sloppyParsable(program, sf)) markers = [];
modules.push({
file: sf.fileName,
origin,
packageDir: at === null ? null : at.dir,
packageName: at === null ? null : at.name,
markers,
cjsReads: facts.cjsReads,
stripProblem: userTs && markers.length > 0 ? stripProblem(program, sf) : null,
dropped: opts.dropped,
});
if (!opts.edges) continue;
@@ -1,8 +1,10 @@
/* Deferred island modules: the decision, the build-time probe and the
* interface snapshot (scope-B NI-7, DESIGN.md §2.7, §5.4).
*
* Lowering emits `nodeIsland.defer(path, "")` for every static import of an
* island module (lower-node-island.ts). After lowering, once the build
* Lowering emits `nodeIsland.defer(path, "")` for every static import of a
* module of an island package (lower-node-island.ts); a program module that
* runs in an island loads eagerly, so the probe never evaluates the
* program's own source. After lowering, once the build
* knows the libnode pack the executable will load, finalizeDeferredIslands
* decides each one:
*
@@ -78,9 +78,18 @@ export interface NodeIslandReport {
unknownPackages: string[];
}
/** A reason whose report sentence says what it means for the build, in
* addition to the pre-scan construct. */
const REASON_SENTENCES: Readonly<Record<string, string>> = {
"builtin-patch": "it patches a builtin module's CommonJS export, which only code in Node can see",
};
/** "R2.1 constructor functions: 'new Foo()' of a function" or
* "SC2012: <message>". */
export function describe(r: NodeIslandReason): string {
const sentence = REASON_SENTENCES[r.reason];
if (sentence !== undefined)
return `${r.rule === null ? "" : `${r.rule} `}${r.reason}: ${sentence} (${r.message})`;
if (r.source === "pre-scan" && r.rule !== null) return `${r.rule} ${r.message}`;
const rule = r.rule === null ? "" : `${r.rule} `;
return r.code === null ? `${rule}${r.message}` : `${rule}${r.code}: ${r.message}`;
@@ -0,0 +1,43 @@
import { afterAll, expect, test } from "vitest";
import type * as ts from "../frontend/ts7/adapter.js";
import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js";
import { stripProblem } from "./type-strip.js";
afterAll(() => closeSourceParser());
let serial = 0;
/** The SC8006 construct for a TypeScript module, or null. None of these
* cases needs the checker (only a plain named import of a type does). */
function problem(source: string): string | null {
const sf = parseSourceFile(`/virtual/s${serial++}.ts`, source, "ts");
const program = {
getTypeChecker(): never {
throw new Error("the checker is not needed here");
},
} as unknown as ts.Program;
return stripProblem(program, sf)?.construct ?? null;
}
test("a relative specifier Node resolves needs its file extension", () => {
expect(problem('import { a } from "./a";')).toBe(
"the relative import './a' without a file extension",
);
expect(problem('export { a } from "./a";')).toBe(
"the relative export from './a' without a file extension",
);
expect(problem('const m = await import("./a");')).toBe(
"the relative import() of './a' without a file extension",
);
expect(problem('import "./a.ts";\nexport * from "./b.js";')).toBeNull();
});
test("type-only imports and exports are erased, so their specifiers need no extension", () => {
expect(problem('import type { A } from "./types";')).toBeNull();
expect(problem('import type Default from "./types";')).toBeNull();
expect(problem('export type { A } from "./types";')).toBeNull();
expect(problem('export type * from "./types";')).toBeNull();
// An inline `type` element leaves the declaration, which Node still loads.
expect(problem('import { type A } from "./types";')).toBe(
"the relative import './types' without a file extension",
);
});
@@ -0,0 +1,184 @@
/* Node runs TypeScript by erasing types in place ("strip" mode: process.
* features.typescript === "strip" in the embedded libnode). It refuses
* syntax that needs a transform (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX) and it
* resolves relative imports only with their written extension, so a module
* that needs either cannot run in a Node island as written (H3.5). The
* checks are syntactic except the one for a plain `import { T }` of a
* type, which needs the checker: Node keeps that import and fails at load. */
import type { SrcLoc } from "../ir/ir.js";
import * as ts from "../frontend/ts7/adapter.js";
export interface StripProblem {
/** The construct, as a short phrase for the SC8006 message. */
construct: string;
loc: SrcLoc;
}
/** Extensions Node's type-stripping loader resolves for a relative
* specifier. Node requires one for an ES module specifier, written in
* full. */
const RESOLVED_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"];
function locOf(node: ts.Node): SrcLoc {
return {
file: ts.getSourceFileOfNode(node).fileName,
start: ts.tokenStartOfNode(node),
end: node.end,
};
}
function isRelativeSpecifier(spec: string): boolean {
return spec.startsWith("./") || spec.startsWith("../");
}
function hasResolvedExtension(spec: string): boolean {
const clean = spec.replace(/[?#].*$/, "");
return RESOLVED_EXTENSIONS.some((extension) => clean.endsWith(extension));
}
function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
return node.modifiers?.nodes.some((modifier) => modifier.kind === kind) ?? false;
}
/** Whether a namespace/module declaration has runtime values (anything
* besides types and other ambient declarations). */
function namespaceHasValues(body: ts.Node | undefined): boolean {
if (body === undefined) return false;
if (ts.isModuleDeclaration(body)) return true;
if (!ts.isModuleBlock(body)) return false;
return body.statements.nodes.some((statement) => !isTypeOnlyStatement(statement));
}
function isTypeOnlyStatement(statement: ts.Node): boolean {
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) return true;
if (ts.isModuleDeclaration(statement)) return !namespaceHasValues(statement.body);
return false;
}
/** The value meaning of an imported name, following alias symbols. */
function importedValue(checker: ts.TypeChecker, element: ts.ImportSpecifier): boolean | null {
const local = element.name;
let symbol = checker.getSymbolAtLocation(local);
if (symbol === undefined) return null;
const seen = new Set<ts.Symbol>();
while ((symbol.flags & ts.SymbolFlags.Alias) !== 0 && !seen.has(symbol)) {
seen.add(symbol);
symbol = checker.getAliasedSymbol(symbol);
}
return (symbol.flags & ts.SymbolFlags.Value) !== 0;
}
/** The first construct in `sf` that Node cannot run with type stripping
* only, or null. Only called for a user TypeScript module the pre-scan
* marked as patching a builtin, so the walk costs nothing elsewhere. */
export function stripProblem(program: ts.Program, sf: ts.SourceFile): StripProblem | null {
let checker: ts.TypeChecker | null = null;
let found: StripProblem | null = null;
ts.walkPreorder(sf, (node) => {
if (found !== null) return "stop";
const report = (construct: string): void => {
found = { construct, loc: locOf(node) };
};
if (ts.isEnumDeclaration(node)) {
if (!hasModifier(node, ts.SyntaxKind.DeclareKeyword)) report("an 'enum' declaration");
return undefined;
}
if (ts.isModuleDeclaration(node)) {
if (!hasModifier(node, ts.SyntaxKind.DeclareKeyword) && namespaceHasValues(node.body))
report("a 'namespace' declaration with values");
return undefined;
}
if (ts.isConstructorDeclaration(node)) {
for (const parameter of node.parameters.nodes) {
if (
parameter.modifiers?.nodes.some(
(modifier) =>
modifier.kind === ts.SyntaxKind.PublicKeyword ||
modifier.kind === ts.SyntaxKind.PrivateKeyword ||
modifier.kind === ts.SyntaxKind.ProtectedKeyword ||
modifier.kind === ts.SyntaxKind.ReadonlyKeyword,
) === true
) {
report("a constructor parameter property");
return undefined;
}
}
return undefined;
}
if (ts.isImportEqualsDeclaration(node)) {
report("an 'import = require()' declaration");
return undefined;
}
if (ts.isExportAssignment(node) && node.isExportEquals === true) {
report("an 'export =' assignment");
return undefined;
}
if (
node.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.Decorator) === true
) {
report("a decorator");
return undefined;
}
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
report("JSX");
return undefined;
}
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
// `import type` is erased whole, so Node never resolves its specifier.
if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) return undefined;
const spec = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null;
if (spec !== null && isRelativeSpecifier(spec) && !hasResolvedExtension(spec)) {
report(`the relative import '${spec}' without a file extension`);
return undefined;
}
if (
clause === undefined ||
clause.namedBindings === undefined ||
!ts.isNamedImports(clause.namedBindings)
)
return undefined;
checker ??= program.getTypeChecker();
for (const element of clause.namedBindings.elements.nodes) {
if (element.isTypeOnly) continue;
if (importedValue(checker, element) === false) {
found = {
construct: `the import of '${element.name.text}', which is only a type (use \`import type\`)`,
loc: locOf(element),
};
return "stop";
}
}
return undefined;
}
if (ts.isExportDeclaration(node)) {
const specifier = node.moduleSpecifier;
// `export type { T } from` and `export type * from` are erased whole.
if (
!node.isTypeOnly &&
specifier !== undefined &&
ts.isStringLiteral(specifier) &&
isRelativeSpecifier(specifier.text) &&
!hasResolvedExtension(specifier.text)
)
report(`the relative export from '${specifier.text}' without a file extension`);
return undefined;
}
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.nodes.length > 0
) {
const argument = node.arguments.nodes[0];
if (
argument !== undefined &&
ts.isStringLiteral(argument) &&
isRelativeSpecifier(argument.text) &&
!hasResolvedExtension(argument.text)
)
report(`the relative import() of '${argument.text}' without a file extension`);
}
return undefined;
});
return found;
}
@@ -0,0 +1,69 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
/* A `.filter` type predicate whose target is a SUPERSET of the receiver
* element's declared fields is narrowed to what the value really carries: a
* checked-dynamic source's own properties are read and verified field by
* field at the narrowed element, so a predicate that keeps a value the
* checks do not describe throws the catchable TypeError instead of handing
* out a record with invented fields. Node yields `undefined` reads for the
* same program (the record-layout divergence).
*
* The truthful predicate below narrows exactly the element that carries all
* three fields; the lying one keeps an element without `content`. */
test("a superset `.filter` guard is verified field by field", async () => {
const dir = mkdtempSync("/tmp/scriptc-filter-superset-");
try {
const entry = join(dir, "main.ts");
writeFileSync(
entry,
`
interface SystemMessage { role: "system"; content: string; timestamp: number }
const raw: unknown[] = [
{ role: "system", content: "json", timestamp: 20 },
{ role: "user", content: "hi" },
{ role: "system", timestamp: 21 },
];
const isText = (v: unknown): boolean => typeof v === "string";
function honest(r: unknown): r is SystemMessage {
const row = r as { role?: unknown; content?: unknown; timestamp?: unknown };
return typeof row.role === "string" && row.role === "system" &&
isText(row.content) && typeof row.timestamp === "number";
}
const kept = raw.filter(honest);
console.log("kept", kept.length, kept.map((m) => m.content + ":" + m.timestamp).join(","));
// A guard that returns true for an element the checks do not describe.
const lying = (r: unknown): r is SystemMessage => (r as { role: string }).role === "system";
try {
for (const message of raw.filter(lying)) console.log("read", message.content);
} catch (error) {
console.log("rejected", error instanceof TypeError);
}
`,
);
const result = await compile(entry, {
outDir: dir,
outPath: join(dir, "program"),
sanitize: process.env["SCRIPTC_SAN"] === "1",
});
if (!result.ok) throw new Error(JSON.stringify(result.diagnostics));
const child = spawnSync(result.binaryPath, [], { encoding: "utf8", timeout: 30_000 });
expect(child.error).toBeUndefined();
expect({
status: child.status,
signal: child.signal,
stdout: child.stdout,
stderr: child.stderr,
}).toEqual({
status: 0,
signal: null,
stderr: "",
stdout: "kept 1 json:20\nrejected true\n",
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
@@ -88,7 +88,7 @@ test("noUnusedLocals and noUnusedParameters report nothing in declaration files"
const codes = (extension: string, text: string): string[] =>
checkPreflightTs7(
project(options, {
"main.ts": 'import "./extra.js";\nexport {};\n',
"main.ts": 'export type {} from "./extra.js";\n',
[`extra.${extension}`]: text,
}),
).diags.map((d) => d.message);
@@ -0,0 +1,17 @@
{
"order": [
"<repo>/tests/corpus/6897-node-island-builtin-patch/loader.ts",
"<repo>/tests/corpus/6897-node-island-builtin-patch/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Could not find a declaration file for module 'payload-reader'. '<repo>/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/index.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/payload-reader` if it exists or add a new declaration (.d.ts) file containing `declare module 'payload-reader';`",
"loc": {
"file": "<repo>/tests/corpus/6897-node-island-builtin-patch/loader.ts",
"start": 1175,
"end": 1191
}
}
]
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6897-node-island-declared-arrays/main.ts"
],
"diags": []
}
@@ -0,0 +1,8 @@
{
"order": [
"<repo>/tests/corpus/6898-node-island-reexport/barrel.ts",
"<repo>/tests/corpus/6898-node-island-reexport/barrel2.ts",
"<repo>/tests/corpus/6898-node-island-reexport/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6899-node-island-unsafe-record/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6900-node-island-merged-record-in/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6901-node-island-array-intersection/main.ts"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/8424-sloppy-annexb-block-function-island/annexb.js",
"<repo>/tests/corpus/8424-sloppy-annexb-block-function-island/main.js"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/8425-sloppy-direct-eval-island/scopes.js",
"<repo>/tests/corpus/8425-sloppy-direct-eval-island/main.js"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/8426-sloppy-this-island/receivers.js",
"<repo>/tests/corpus/8426-sloppy-this-island/main.js"
],
"diags": []
}
@@ -0,0 +1,89 @@
{
"order": [
"<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"<repo>/tests/corpus/8427-sloppy-with-island/main.js"
],
"diags": [
{
"code": "SC0001",
"message": "'with' statements are not allowed in strict mode.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 194,
"end": 198
}
},
{
"code": "SC0001",
"message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 194,
"end": 207
}
},
{
"code": "SC0001",
"message": "'with' statements are not allowed in strict mode.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 295,
"end": 299
}
},
{
"code": "SC0001",
"message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 295,
"end": 308
}
},
{
"code": "SC0001",
"message": "'with' statements are not allowed in strict mode.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 422,
"end": 426
}
},
{
"code": "SC0001",
"message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 422,
"end": 447
}
},
{
"code": "SC0001",
"message": "'with' statements are not allowed in strict mode.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 528,
"end": 532
}
},
{
"code": "SC0001",
"message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 528,
"end": 541
}
},
{
"code": "SC0001",
"message": "'with' statements are not allowed in strict mode.",
"loc": {
"file": "<repo>/tests/corpus/8427-sloppy-with-island/lookup.js",
"start": 548,
"end": 552
}
}
]
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8741-mixed-tuple-array-slots.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8753-filter-predicate-superset-guard.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8768-entries-filter-generic-instantiation.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/boxed-function-names.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/builtin-values-proxy-intl-views.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-any-event-names.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-identity-pin.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-identity-unknown-scalars.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-identity-unknown.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/global-snapshot-optional-reads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/http-client-request-path-check.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/http-request-checks-before-socket.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/http-request-option-checks.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/http-server-listen-forms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/http-server-listen-untyped.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/net-error-system-properties.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/net-listen-untyped-arguments.cjs"
],
"diags": []
}
@@ -0,0 +1,10 @@
{
"order": [
"<repo>/tests/corpus/node-island-import-order/native-a.ts",
"<repo>/tests/corpus/node-island-import-order/barrel.ts",
"<repo>/tests/corpus/node-island-import-order/native-b.ts",
"<repo>/tests/corpus/node-island-import-order/user.ts",
"<repo>/tests/corpus/node-island-import-order/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-unknown-dictionary-views.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-clone-override-width.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-spread-distribution.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-spread-merge.ts"
],
"diags": []
}
@@ -0,0 +1,18 @@
{
"order": [
"<repo>/tests/diagnostics/declaration-only-module/main.ts"
],
"diags": [
{
"code": "SC1010",
"message": "the './types.js' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet",
"loc": {
"file": "<repo>/tests/diagnostics/declaration-only-module/main.ts",
"start": 246,
"end": 281
},
"milestone": "M4",
"hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported"
}
]
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/ee-any-name-listeners.ts"
],
"diags": []
}

Some files were not shown because too many files have changed in this diff Show More