mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Merge main
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6897-node-island-declared-arrays/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+8
@@ -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": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6899-node-island-unsafe-record/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6900-node-island-merged-record-in/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6901-node-island-array-intersection/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+7
@@ -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": []
|
||||
}
|
||||
+7
@@ -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": []
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8426-sloppy-this-island/receivers.js",
|
||||
"<repo>/tests/corpus/8426-sloppy-this-island/main.js"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+89
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8741-mixed-tuple-array-slots.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8753-filter-predicate-superset-guard.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8768-entries-filter-generic-instantiation.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/boxed-function-names.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/builtin-values-proxy-intl-views.cjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/emitter-any-event-names.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-identity-pin.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-identity-unknown-scalars.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/generic-identity-unknown.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/global-snapshot-optional-reads.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/http-client-request-path-check.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/http-request-checks-before-socket.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/http-request-option-checks.cjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/http-server-listen-forms.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/http-server-listen-untyped.cjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/net-error-system-properties.cjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/net-listen-untyped-arguments.cjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+10
@@ -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": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/record-unknown-dictionary-views.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/union-clone-override-width.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/union-spread-distribution.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/union-spread-merge.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
@@ -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
Reference in New Issue
Block a user