Merge main

This commit is contained in:
Chris Tate
2026-10-25 10:55:32 -05:00
209 changed files with 18081 additions and 8219 deletions
+1
View File
@@ -9,6 +9,7 @@ node_modules/
!tests/corpus/*/node_modules/
!tests/fixtures/npm-static/node_modules/
!tests/coverage-fixtures/wasm/node_modules/
!tests/coverage-fixtures/node-islands*/node_modules/
!tests/coverage-fixtures/dynamic-tier/package/node_modules/
!tests/fixtures/npm-static/nested/inner/node_modules/
!tests/fixtures/npm-static/node_modules/*/node_modules/
+1 -1
View File
@@ -245,7 +245,7 @@ $ scriptc build scale.ts
1 error.
```
A copy such as `slice()` or a spread is checked as if it were the same array as its source. The results of callbacks are not followed, so an array built by `map` or `Array.from` with a mapping function cannot be `Int32[]`; build it with a loop that stores `value | 0`. scriptc refuses an `Int32[]` slot whose array can come from code it cannot see, such as `JSON.parse`, dynamic values or the callers of a function used as a value, and an array that can reach one and escapes to such code, such as a call through a function value (SC7003).
A copy such as `slice()` or a spread is checked as if it were the same array as its source. The results of a callback are followed only when scriptc copies it into the loop that runs the array method: a callback written at a `map` or `filter` call whose captured variables are not reassigned, such as `xs.map((x) => (x * 2) | 0)`. Other callback results are not followed, so for example an array built by `Array.from` with a mapping function cannot be `Int32[]`; build it with a loop that stores `value | 0`. scriptc refuses an `Int32[]` slot whose array can come from code it cannot see, such as `JSON.parse`, dynamic values or the callers of a function used as a value, and an array that can reach one and escapes to such code, such as a call through a function value (SC7003).
## Immutable
+1 -1
View File
@@ -1,7 +1,7 @@
[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result, `Acyclic` only on classes, interfaces and object type aliases, and `ProgramLifetime` only in the `implements` clause of a class declaration; other aliases, interface members and nested type positions are reported.
In programs that use worker threads, a surrounding `try`/`catch` does not discharge `NoThrow`.
`Int32` refuses parameters of functions used as values and fields of classes whose instances are stored in `any` or `unknown` values.
`Int32[]` refuses arrays that can come from or escape to code scriptc cannot see, does not follow callback results (such as `map` results), and checks a copy made by `slice` or a spread as if it were its source array.
`Int32[]` refuses arrays that can come from or escape to code scriptc cannot see, follows callback results only for callbacks written at a `map` or `filter` call that scriptc runs inline, and checks a copy made by `slice` or a spread as if it were its source array.
Element reads that find a value are int32s, but a scalar `Int32` slot still needs `a[i] | 0`, because a read can miss and produce `undefined`.
`Immutable` refuses values stored in `any` or `unknown` or that another reference can still change.
`ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed unless they belong to a released [lifetime region](/docs/experimental#lifetimeregion).
+21 -2
View File
@@ -72,7 +72,9 @@ async function tryFastPath(): Promise<number | null> {
values.sparse ||
// The full compiler resolves `native` against the build host.
values["target-cpu"] !== undefined ||
(values["external-types"] ?? []).length > 0
(values["external-types"] ?? []).length > 0 ||
// The full CLI validates the Node island policy flags.
hasNodeIslandFlags(values)
)
return null;
const backend = values.backend;
@@ -182,6 +184,21 @@ async function tryFastPath(): Promise<number | null> {
});
}
function hasNodeIslandFlags(
values: ReturnType<
typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }>
>["values"],
): boolean {
return (
values["node-islands"] !== undefined ||
(values["node-island"] ?? []).length > 0 ||
(values.native ?? []).length > 0 ||
values.node !== undefined ||
values["explain-island"] !== undefined ||
values["strict-user-js"]
);
}
/** An unchanged program's coverage verdict replays from the verdict cache
* without loading the compiler graph. Anything else (a miss, a comment-only
* edit, options the replay does not cover) defers to the full CLI. */
@@ -212,7 +229,9 @@ async function coverageFastPath(
values["provenance-sources"] ||
values["emit-ir"] ||
(values["npm-static"] ?? []).length > 0 ||
(values["external-types"] ?? []).length > 0
(values["external-types"] ?? []).length > 0 ||
// The replay covers the default Node island policy only.
hasNodeIslandFlags(values)
)
return null;
let startup: typeof import("@scriptc/compiler/startup-cache");
+2 -2
View File
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" },
runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
expect(error?.stderr).toContain("scr_runtime_abi_v43");
expect(error?.stderr).toContain("scr_runtime_abi_v44");
});
});
@@ -64,8 +64,11 @@ static int child_main(int argc, char **argv) {
#endif
int listening = argc >= 9 && strcmp(argv[5], "--listen") == 0 && strcmp(argv[7], "--idle-exit") == 0;
int fixed = listening ? 9 : 5;
if (argc < fixed || argc > fixed + 1 || strcmp(argv[1], "--api") != 0 || strcmp(argv[2], "--cwd") != 0 ||
strncmp(argv[4], "--callbacks=", 12) != 0 || (argc == fixed + 1 && strcmp(argv[fixed], "--timing") != 0))
int at = fixed, timing = 0, libraries = 0;
if (at < argc && strcmp(argv[at], "--timing") == 0) { timing = 1; at++; }
if (at < argc && strcmp(argv[at], "--default-libraries-from-disk") == 0) { libraries = 1; at++; }
if (argc < fixed || at != argc || strcmp(argv[1], "--api") != 0 || strcmp(argv[2], "--cwd") != 0 ||
strncmp(argv[4], "--callbacks=", 12) != 0)
return 71;
const char *mode = argv[3];
#ifndef _WIN32
@@ -76,7 +79,8 @@ static int child_main(int argc, char **argv) {
fputc('\n', stdout);
fputs(argv[4], stdout);
fputc('\n', stdout);
fputs(argc == 6 ? "timing\n" : "plain\n", stdout);
fputs(timing ? "timing\n" : "plain\n", stdout);
if (libraries) fputs("libraries from disk\n", stdout);
return ferror(stdout) ? 72 : 0;
}
if (strcmp(mode, "exit") == 0) return 0;
+18 -9
View File
@@ -161,7 +161,7 @@ static wchar_t *command_line(wchar_t **arguments, size_t count) {
}
static int start_process(Ts7Process *p, char **args, size_t argc) {
wchar_t *wide[6] = {0};
wchar_t *wide[7] = {0};
wchar_t *line = NULL;
HANDLE child_input = NULL, child_output = NULL, child_error = NULL;
LPPROC_THREAD_ATTRIBUTE_LIST attributes = NULL;
@@ -330,7 +330,7 @@ static void stop_process(Ts7Process *p) {
uint32_t scriptc_ts7_open(const uint8_t *executable, size_t executable_size,
const uint8_t *cwd, size_t cwd_size,
const uint8_t *callbacks, size_t callbacks_size,
uint8_t timing) {
uint8_t timing, uint8_t libraries_from_disk) {
if (next_id == 0) { error_text("server handle IDs are exhausted"); return 0; }
char *exe = copy_argument(executable, executable_size, "");
if (exe == NULL) return 0;
@@ -346,8 +346,12 @@ uint32_t scriptc_ts7_open(const uint8_t *executable, size_t executable_size,
}
exit_registered = 1;
}
char *args[] = {exe, "--api", "--cwd", directory, callback_flag, timing ? "--timing" : NULL, NULL};
int started = start_process(p, args, timing ? 6 : 5);
char *args[8] = {exe, "--api", "--cwd", directory, callback_flag};
size_t argc = 5;
if (timing) args[argc++] = "--timing";
if (libraries_from_disk) args[argc++] = "--default-libraries-from-disk";
args[argc] = NULL;
int started = start_process(p, args, argc);
free(exe); free(directory); free(callback_flag);
if (!started) { free(p); return 0; }
p->id = next_id++;
@@ -361,9 +365,10 @@ uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size,
const uint8_t *executable, size_t executable_size,
const uint8_t *cwd, size_t cwd_size,
const uint8_t *callbacks, size_t callbacks_size,
uint8_t timing, uint32_t idle_exit, uint32_t memory) {
uint8_t timing, uint8_t libraries_from_disk, uint32_t idle_exit,
uint32_t memory) {
(void)name; (void)name_size; (void)executable; (void)executable_size; (void)cwd; (void)cwd_size;
(void)callbacks; (void)callbacks_size; (void)timing; (void)idle_exit; (void)memory;
(void)callbacks; (void)callbacks_size; (void)timing; (void)libraries_from_disk; (void)idle_exit; (void)memory;
error_text("a shared checker server needs Unix sockets");
return 0;
}
@@ -455,7 +460,8 @@ uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size,
const uint8_t *executable, size_t executable_size,
const uint8_t *cwd, size_t cwd_size,
const uint8_t *callbacks, size_t callbacks_size,
uint8_t timing, uint32_t idle_exit, uint32_t memory) {
uint8_t timing, uint8_t libraries_from_disk, uint32_t idle_exit,
uint32_t memory) {
if (next_id == 0) { error_text("server handle IDs are exhausted"); return 0; }
char *server = copy_argument(name, name_size, "");
if (server == NULL) return 0;
@@ -491,8 +497,11 @@ uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size,
(void)snprintf(limit, sizeof(limit), "TSC_TS_LISTEN_MEMORY=%lu", (unsigned long)memory);
pid_t pid = -1;
if (callback_flag != NULL) {
char *args[] = {exe, "--api", "--cwd", dir, callback_flag, "--listen", socket_path, "--idle-exit", idle,
timing ? "--timing" : NULL, NULL};
char *args[12] = {exe, "--api", "--cwd", dir, callback_flag, "--listen", socket_path, "--idle-exit", idle};
size_t argc = 9;
if (timing) args[argc++] = "--timing";
if (libraries_from_disk) args[argc++] = "--default-libraries-from-disk";
args[argc] = NULL;
pid = start_server(args, limit);
}
free(exe); free(dir); free(callback_flag);
@@ -1,8 +1,8 @@
{
"ffi_format": 6,
"functions": [
{ "name": "ts7NativeOpen", "symbol": "scriptc_ts7_open", "params": ["string", "string", "string", "bool"], "returns": "u32" },
{ "name": "ts7NativeAttach", "symbol": "scriptc_ts7_attach", "params": ["string", "string", "string", "string", "bool", "u32", "u32"], "returns": "u32" },
{ "name": "ts7NativeOpen", "symbol": "scriptc_ts7_open", "params": ["string", "string", "string", "bool", "bool"], "returns": "u32" },
{ "name": "ts7NativeAttach", "symbol": "scriptc_ts7_attach", "params": ["string", "string", "string", "string", "bool", "bool", "u32", "u32"], "returns": "u32" },
{ "name": "ts7NativeRead", "symbol": "scriptc_ts7_read", "params": ["u32", "mutable-bytes", "u32", "u32"], "returns": "i32" },
{ "name": "ts7NativeWrite", "symbol": "scriptc_ts7_write", "params": ["u32", "bytes", "u32", "u32"], "returns": "i32" },
{ "name": "ts7NativePid", "symbol": "scriptc_ts7_pid", "params": ["u32"], "returns": "u32" },
+3 -2
View File
@@ -12,7 +12,7 @@
uint32_t scriptc_ts7_open(const uint8_t *executable, size_t executable_size,
const uint8_t *cwd, size_t cwd_size,
const uint8_t *callbacks, size_t callbacks_size,
uint8_t timing);
uint8_t timing, uint8_t libraries_from_disk);
/* Connects to the shared checker server `name` (`--api --listen`) in this
* user's private server directory, starting it in its own process group
* with `--idle-exit idle_exit` and a limit of `memory` megabytes
@@ -25,7 +25,8 @@ uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size,
const uint8_t *executable, size_t executable_size,
const uint8_t *cwd, size_t cwd_size,
const uint8_t *callbacks, size_t callbacks_size,
uint8_t timing, uint32_t idle_exit, uint32_t memory);
uint8_t timing, uint8_t libraries_from_disk, uint32_t idle_exit,
uint32_t memory);
int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size,
uint32_t offset, uint32_t length);
int32_t scriptc_ts7_write(uint32_t handle, const uint8_t *bytes, size_t size,
+14 -10
View File
@@ -21,12 +21,14 @@
static const char *executable;
static const char *callbacks = "readFile,fileExists";
static uint32_t start(const char *mode, int timing) {
static uint32_t start_with(const char *mode, int timing, int libraries) {
return scriptc_ts7_open((const uint8_t *)executable, strlen(executable),
(const uint8_t *)mode, strlen(mode),
(const uint8_t *)callbacks, strlen(callbacks), (uint8_t)timing);
(const uint8_t *)callbacks, strlen(callbacks), (uint8_t)timing, (uint8_t)libraries);
}
static uint32_t start(const char *mode, int timing) { return start_with(mode, timing, 0); }
static void fail_message(void) {
uint8_t message[1024];
int32_t size = scriptc_ts7_error(message, sizeof(message));
@@ -83,11 +85,13 @@ static void argument_cases(void) {
"", "a b", "double\"quote", "end\\", "many\\\\\"quotes\\\\", "a;&$()`'b", "h\xc3\xa9llo \xf0\x9f\x8c\x8d",
};
for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) {
for (int timing = 0; timing <= 1; timing++) {
for (int flags = 0; flags < 4; flags++) {
int timing = flags & 1, libraries = flags >> 1;
char mode[256], expected[512];
snprintf(mode, sizeof(mode), "argv:%s", values[i]);
snprintf(expected, sizeof(expected), "%s\n--callbacks=%s\n%s\n", values[i], callbacks, timing ? "timing" : "plain");
uint32_t id = start(mode, timing);
snprintf(expected, sizeof(expected), "%s\n--callbacks=%s\n%s\n%s", values[i], callbacks,
timing ? "timing" : "plain", libraries ? "libraries from disk\n" : "");
uint32_t id = start_with(mode, timing, libraries);
assert(id != 0);
uint8_t actual[512];
read_exact(id, actual, strlen(expected));
@@ -147,10 +151,10 @@ static void failure_cases(void) {
executable = "/scriptc/does-not-exist/tsgo";
for (int i = 0; i < 30; i++) { assert(start("echo", 0) == 0); fail_message(); }
executable = saved;
assert(scriptc_ts7_open(NULL, 0, bytes, 1, bytes, 1, 0) == 0);
assert(scriptc_ts7_open((const uint8_t *)"abc\0def", 7, bytes, 1, bytes, 1, 0) == 0);
assert(scriptc_ts7_open((const uint8_t *)executable, strlen(executable), NULL, 0, bytes, 1, 0) == 0);
assert(scriptc_ts7_open((const uint8_t *)executable, strlen(executable), bytes, 1, NULL, 0, 0) == 0);
assert(scriptc_ts7_open(NULL, 0, bytes, 1, bytes, 1, 0, 0) == 0);
assert(scriptc_ts7_open((const uint8_t *)"abc\0def", 7, bytes, 1, bytes, 1, 0, 0) == 0);
assert(scriptc_ts7_open((const uint8_t *)executable, strlen(executable), NULL, 0, bytes, 1, 0, 0) == 0);
assert(scriptc_ts7_open((const uint8_t *)executable, strlen(executable), bytes, 1, NULL, 0, 0, 0) == 0);
for (int i = 0; i < 30; i++) {
uint32_t id = start("exit", 0);
assert(id != 0);
@@ -177,7 +181,7 @@ static void failure_cases(void) {
static uint32_t attach(const char *name, const char *mode, uint32_t memory) {
return scriptc_ts7_attach((const uint8_t *)name, strlen(name), (const uint8_t *)executable, strlen(executable),
(const uint8_t *)mode, strlen(mode), (const uint8_t *)callbacks, strlen(callbacks),
0, 1, memory);
0, 0, 1, memory);
}
static void expect_line(uint32_t id, const char *expected) {
@@ -9,7 +9,7 @@
* one directly or through a binding read from it. The choice never changes
* behavior. */
import type { IrExpr, IrModule } from "../../ir/ir.js";
import type { IrExpr, IrModule, IrStmt } from "../../ir/ir.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
const APPENDING_METHODS: ReadonlySet<string> = new Set([
@@ -120,3 +120,36 @@ export function findAppendedArrayLiterals(
}
return result;
}
/** `const xs = []` directly followed by a capacity hint for `xs` (an
* `arraySetLength` with `reserve`) whose length reads only literals and
* other locals: the literal can be constructed with that capacity, and the
* hint then has nothing left to do. */
export function reservedArrayLength(
stmts: readonly IrStmt[],
i: number,
): { literal: IrExpr; hint: IrStmt & { kind: "arraySetLength" } } | null {
const decl = stmts[i];
const next = stmts[i + 1];
if (decl?.kind !== "varDecl" || decl.init === null || !isEmptyLiteral(decl.init)) return null;
if (next?.kind !== "arraySetLength" || next.reserve !== true) return null;
if (next.arr.kind !== "varRef" || next.arr.localId !== decl.localId) return null;
return plainLength(next.length, decl.localId) ? { literal: decl.init, hint: next } : null;
}
function plainLength(e: IrExpr, array: string): boolean {
switch (e.kind) {
case "numLit":
return true;
case "varRef":
return e.localId !== array;
case "bin":
return plainLength(e.left, array) && plainLength(e.right, array);
case "ternary":
return (
plainLength(e.cond, array) && plainLength(e.then, array) && plainLength(e.else_, array)
);
default:
return false;
}
}
@@ -13,9 +13,37 @@ import {
import { SourceLocations } from "../source-locations.js";
import { mangleFunction } from "../mangle.js";
/** DWARF describes source bindings at their actual native storage locations. */
/** A request a range emitter made of its debug info that added nodes
* (LlvmDebugInfo.record), with the ids it answered. */
export type DebugRequest =
| { readonly op: "function"; readonly fn: string; readonly result: string }
| {
readonly op: "local";
readonly local: IrLocal;
readonly fn: string;
readonly arg: number;
readonly captured: boolean;
readonly result: { variable: string; location: string };
}
| {
readonly op: "location";
readonly loc: SrcLoc;
readonly fn: string;
readonly result: string;
};
/** DWARF describes source bindings at their actual native storage locations.
*
* Nodes are numbered in request order. A range emitter (emit-module.ts)
* records the requests that added nodes; the emitter that merges the range
* replays them in order, so every node gets the number serial emission
* gives it, and renames the range's text from the ids the range answered to
* its own. A request that added no node answered an id that existed before
* the range started or that an earlier recorded request created. */
export class LlvmDebugInfo {
private readonly nodes: string[] = [];
/** The requests that added nodes, while recording (record). */
private requests: DebugRequest[] | null = null;
private readonly files = new Map<string, string>();
private readonly locations = new Map<string, string>();
private readonly scopes = new Map<string, string>();
@@ -73,7 +101,53 @@ export class LlvmDebugInfo {
return unit;
}
/** Records the requests that add nodes from here on. */
record(): void {
this.requests = [];
}
/** The requests recorded since record. */
recorded(): readonly DebugRequest[] {
return this.requests ?? [];
}
/** Replays a range's recorded requests in order and adds to `ids` the
* rename from the ids the range answered to this debug info's. */
replay(
requests: readonly DebugRequest[],
functionNamed: (name: string) => IrFunction,
ids: Map<string, string>,
): void {
const own = (id: string, to: string | null | undefined): void => {
if (to === null || to === undefined)
throw new Error(`llvm emitter bug: debug request for ${id} added no node on replay`);
if (to !== id) ids.set(id, to);
};
for (const request of requests) {
if (request.op === "function") {
own(request.result, this.function(functionNamed(request.fn)));
continue;
}
const fn = ids.get(request.fn) ?? request.fn;
if (request.op === "location") {
own(request.result, this.location(request.loc, fn));
continue;
}
const local = this.local(request.local, fn, request.arg, request.captured);
own(request.result.variable, local?.variable);
own(request.result.location, local?.location);
}
}
function(fn: IrFunction): string | null {
const before = this.nodes.length;
const id = this.functionNode(fn);
if (this.requests !== null && id !== null && this.nodes.length > before)
this.requests.push({ op: "function", fn: fn.name, result: id });
return id;
}
private functionNode(fn: IrFunction): string | null {
const pos = this.source.position(fn.loc);
if (pos === null) return null;
const file = this.file(pos.file);
@@ -230,6 +304,26 @@ export class LlvmDebugInfo {
fn: string | null,
arg = 0,
captured = false,
): { variable: string; expression: string; location: string } | null {
const before = this.nodes.length;
const result = this.localNodes(local, fn, arg, captured);
if (this.requests !== null && fn !== null && result !== null && this.nodes.length > before)
this.requests.push({
op: "local",
local,
fn,
arg,
captured,
result: { variable: result.variable, location: result.location },
});
return result;
}
private localNodes(
local: IrLocal,
fn: string | null,
arg: number,
captured: boolean,
): { variable: string; expression: string; location: string } | null {
if (fn === null || local.source === undefined) return null;
const pos = this.source.position(local.source.loc);
@@ -279,6 +373,14 @@ export class LlvmDebugInfo {
}
location(loc: SrcLoc, fn: string | null): string | null {
const before = this.nodes.length;
const id = this.locationNode(loc, fn);
if (this.requests !== null && fn !== null && id !== null && this.nodes.length > before)
this.requests.push({ op: "location", loc, fn, result: id });
return id;
}
private locationNode(loc: SrcLoc, fn: string | null): string | null {
if (fn === null) return null;
const pos = this.source.position(loc);
if (pos === null) return null;
+169 -12
View File
@@ -66,6 +66,25 @@ import { recordMayKeepSource, recordSourceFieldOrder } from "./record-sources.js
import type { WalkerHost } from "./walkers.js";
import type { LlStreamTypedRefAdapter } from "./expr-context.js";
/** typeKey with `?` after every `(T | undefined)[]` (optionalElements),
* at any depth of arrays, Maps, Sets and promises: checked conversions to
* `number[][]` and `(number | undefined)[][]` need separate helpers, though
* the two types share a typeKey. */
function checkedConversionKey(t: IrType): string {
switch (t.kind) {
case "array":
return `array<${checkedConversionKey(t.elem)}>${t.optionalElements ? "?" : ""}`;
case "map":
return `map<${checkedConversionKey(t.key)},${checkedConversionKey(t.value)}>`;
case "set":
return `set<${checkedConversionKey(t.elem)}>`;
case "promise":
return `promise<${checkedConversionKey(t.inner)}>`;
default:
return typeKey(t);
}
}
/** Whether to-dyn of a record shape keeps the record itself: an empty shape
* and an event-listener record (a `handleEvent` function field) box their
* identity. Every other shape, tuples included, copies its fields into a
@@ -153,6 +172,18 @@ export const DYN_KIND = {
SYMBOL: 15,
} as const;
/** Union arm kinds whose matchers reject every plain dyn object
* (trustedArmByLiteral). */
const NON_OBJECT_ARM_KINDS: ReadonlySet<IrType["kind"]> = new Set<IrType["kind"]>([
"f64",
"string",
"bool",
"nullT",
"undefinedT",
"bigint",
"symbol",
]);
/** What the dyn helpers need beyond the walker host: interned immortal
* unit instances (undefined-armed dynCheck targets build them). */
export interface DynHost extends WalkerHost {
@@ -394,6 +425,51 @@ export class LlDyn {
B.line(`call void @scr_dyn_build_add(ptr %d, ptr @${name}, ptr ${value})`);
}
/** Whether a builder of `def` may pick a plain object's arm by its literal
* discriminator alone inside a trusted build (scr_dyn_trust_enter): the
* arms' literals are disjoint, and every arm without a literal has a kind
* no plain object matches. The value then matched exactly one arm with a
* literal, the arm whose literal it carries. */
private trustedArmByLiteral(def: IrUnionDef): boolean {
const discriminant = def.discriminant;
if (!discriminant) return false;
const seen = new Set<string>();
for (const { values } of discriminant.cases) {
for (const value of values) {
const key = `${typeof value}:${String(value)}`;
if (seen.has(key)) return false;
seen.add(key);
}
}
return def.arms.every(
(arm, tag) =>
discriminant.cases.some((candidate) => candidate.tag === tag) ||
NON_OBJECT_ARM_KINDS.has(arm.kind),
);
}
/** Per arm of `def`, the dyn kind of the values a builder may convert into
* that arm without matching its structure inside a trusted build, or null.
* A value there already matched one of the union's arms, so the arm is
* known when it is the only arm that takes values of its kind: the arm
* whose literal a plain object carries (trustedArmByLiteral), or the one
* arm a plain object or an array can match at all. */
private trustedArmKinds(def: IrUnionDef): (number | null)[] {
const byLiteral = this.trustedArmByLiteral(def);
const objectArms = def.arms.flatMap((arm, tag) =>
NON_OBJECT_ARM_KINDS.has(arm.kind) ? [] : [tag],
);
return def.arms.map((arm, tag) => {
if (def.discriminant?.cases.some((candidate) => candidate.tag === tag))
return byLiteral ? DYN_KIND.OBJ : null;
if (objectArms.length !== 1 || objectArms[0] !== tag) return null;
if (arm.kind === "array") return DYN_KIND.ARR;
if (arm.kind === "record" && this.host.recordsById.get(arm.shapeId)?.tuple !== true)
return DYN_KIND.OBJ;
return null;
});
}
/** The order a dyn value tries a union's arms in: the canonical order,
* except that among the record arms, those with more required fields go
* first, then those with more fields. Records are width-tolerant, so an
@@ -461,6 +537,7 @@ export class LlDyn {
none: string,
preserveRefs: boolean,
table: string,
trustedKinds: readonly (number | null)[] = def.arms.map(() => null),
): void {
const discriminant = def.discriminant!;
const literalsOf = (tag: number): readonly (string | number | boolean)[] | null =>
@@ -500,7 +577,7 @@ export class LlDyn {
positions.forEach((position, index) => {
const tag = order[position]!;
const failed = B.newLabel("du.c.f");
this.unionArmStructure(B, def, tag, hits[tag]!, failed, preserveRefs);
this.unionArmStructure(B, def, tag, hits[tag]!, failed, preserveRefs, trustedKinds[tag]!);
B.startBlock(failed);
const next = index === positions.length - 1 ? none : B.newLabel("du.c.n");
const same = B.tmp();
@@ -523,12 +600,23 @@ export class LlDyn {
tryCase(-1);
order.forEach((tag, position) => {
B.startBlock(from[position]!);
this.unionArmMatch(B, def, tag, hits[tag]!, after(position), preserveRefs);
this.unionArmMatch(
B,
def,
tag,
hits[tag]!,
after(position),
preserveRefs,
trustedKinds[tag]!,
);
});
}
/** Branch to a variant only after its literal discriminator and complete
* structure match. Use the same path for predicates and builders. */
* structure match. Use the same path for predicates and builders. With a
* `trustedKind` (builders, trustedArmKinds), a value of that dyn kind
* inside a trusted build skips the structure match once its literal
* matched. */
private unionArmMatch(
B: BlockBuilder,
def: IrUnionDef,
@@ -536,6 +624,7 @@ export class LlDyn {
yes: string,
no: string,
preserveRefs = false,
trustedKind: number | null = null,
): void {
const guard = def.discriminant?.cases.find((candidate) => candidate.tag === tag);
if (guard && def.discriminant) {
@@ -564,10 +653,12 @@ export class LlDyn {
});
B.startBlock(structure);
}
this.unionArmStructure(B, def, tag, yes, no, preserveRefs);
this.unionArmStructure(B, def, tag, yes, no, preserveRefs, trustedKind);
}
/** The part of unionArmMatch past the arm's literal test. */
/** The part of unionArmMatch past the arm's literal test. With a
* `trustedKind`, a value of that dyn kind inside a trusted build skips the
* structure match. */
private unionArmStructure(
B: BlockBuilder,
def: IrUnionDef,
@@ -575,7 +666,21 @@ export class LlDyn {
yes: string,
no: string,
preserveRefs: boolean,
trustedKind: number | null = null,
): void {
if (trustedKind !== null) {
this.host.declare(`declare zeroext i1 @scr_dyn_trusted_build()`);
const kind = this.kindOf(B, "%d");
const sameKind = B.tmp();
const trusted = B.tmp();
const skip = B.tmp();
B.line(`${sameKind} = icmp eq i32 ${kind}, ${trustedKind}`);
B.line(`${trusted} = call zeroext i1 @scr_dyn_trusted_build()`);
B.line(`${skip} = and i1 ${sameKind}, ${trusted}`);
const full = B.newLabel("du.full");
B.condBr(skip, yes, full);
B.startBlock(full);
}
const matches = B.tmp();
B.line(
`${matches} = call zeroext i1 @${this.dynMatchHelper(def.arms[tag]!, preserveRefs)}(ptr %d)`,
@@ -673,6 +778,13 @@ export class LlDyn {
/* ── dyn node plumbing ─────────────────────────────────────────────── */
/** Loads a dyn node's kind tag (after the size_t rc word). */
/** i1: the checked-dynamic value `d` is undefined. */
isUndefined(B: BlockBuilder, d: string): string {
const is = B.tmp();
B.line(`${is} = icmp eq i32 ${this.kindOf(B, d)}, ${DYN_KIND.UNDEF}`);
return is;
}
private kindOf(B: BlockBuilder, d: string): string {
const p = B.tmp();
const k = B.tmp();
@@ -1059,7 +1171,7 @@ export class LlDyn {
/** `sc_dm_<n>(ptr d) -> i1` — does this dyn fit T? Never throws. */
dynMatchHelper(t: IrType, preserveRefs = false): string {
const key = typeKey(t);
const key = checkedConversionKey(t);
const matcherKey = preserveRefs ? `identity:${key}` : key;
const existing = this.dynMatchers.get(matcherKey);
if (existing) return existing;
@@ -1374,7 +1486,7 @@ export class LlDyn {
B.startBlock(l0);
const n = this.lenOf(B, "%d");
const items = this.itemsOf(B, "%d");
this.i64Loop(B, "dm.el", n, (i, brNext) => {
this.i64Loop(B, "dm.el", n, (i, brNext, next) => {
this.host.declare(`declare zeroext i1 @scr_dyn_arr_has_index(ptr, ${this.S})`);
const present = B.tmp();
B.line(`${present} = call zeroext i1 @scr_dyn_arr_has_index(ptr %d, ${this.S} ${i})`);
@@ -1385,6 +1497,15 @@ export class LlDyn {
brNext();
B.startBlock(valueLabel);
const e = this.itemAt(B, items, i);
if (t.optionalElements) {
// A `(T | undefined)[]` stored as T elements takes undefined
// elements as well.
const undef = B.tmp();
const checkLabel = B.newLabel("dm.check");
B.line(`${undef} = icmp eq i32 ${this.kindOf(B, e)}, ${DYN_KIND.UNDEF}`);
B.condBr(undef, next, checkLabel);
B.startBlock(checkLabel);
}
const ok = B.tmp();
B.line(`${ok} = call zeroext i1 @${m}(ptr ${e})`);
const lOk = B.newLabel("dm.eo");
@@ -1860,7 +1981,8 @@ export class LlDyn {
* BUILD the typed value (+1), or throw the catchable path-annotated
* TypeError and return a dummy with the pending flag set. */
dynCheckHelper(t: IrType, preserveRefs = false): string {
const key = typeKey(t);
// A `(T | undefined)[]` stored as T elements also takes undefined ones.
const key = checkedConversionKey(t);
const builderKey = preserveRefs ? `identity:${key}` : key;
const existing = this.dynBuilders.get(builderKey);
if (existing) return existing;
@@ -2667,6 +2789,21 @@ export class LlDyn {
B.line(`${ip} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 2`);
B.line(`store ${host.sizeType} ${i}, ptr ${ip}`);
const e = this.itemAt(B, items, i);
if (t.optionalElements) {
// An undefined element is a present undefined element.
const undef = B.tmp();
const storeUndefined = B.newLabel("dca.undef");
const checkLabel = B.newLabel("dca.check");
B.line(`${undef} = icmp eq i32 ${this.kindOf(B, e)}, ${DYN_KIND.UNDEF}`);
B.condBr(undef, storeUndefined, checkLabel);
B.startBlock(storeUndefined);
host.declare(`declare void @scr_arr_set_undefined(ptr, double)`);
const at = B.tmp();
B.line(`${at} = ${sizeToDouble(this.S)} ${this.S} ${i} to double`);
B.line(`call void @scr_arr_set_undefined(ptr ${a}, double ${at})`);
B.br(doneLabel);
B.startBlock(checkLabel);
}
const v = B.tmp();
B.line(`${v} = call ${this.valTy(elem)} @${c}(ptr ${e}, ptr ${pathSlot})`);
this.pendingBail(
@@ -2693,7 +2830,18 @@ export class LlDyn {
`llvm emitter bug: dynCheck of unknown union ${t.unionId}`,
);
// Arms in unionArmOrder, first FULL match wins. The matched arm's
// builder can no longer fail.
// builder can no longer fail, and it runs as a trusted build: a
// nested union inside it picks the arm trustedArmKinds names without
// matching the structure that this match already checked.
const trustedKinds = preserveRefs ? def.arms.map(() => null) : this.trustedArmKinds(def);
const trustedCall = (call: () => void): void => {
host.declare(`declare zeroext i1 @scr_dyn_trust_enter()`);
host.declare(`declare void @scr_dyn_trust_exit(i1 zeroext)`);
const previous = B.tmp();
B.line(`${previous} = call zeroext i1 @scr_dyn_trust_enter()`);
call();
B.line(`call void @scr_dyn_trust_exit(i1 zeroext ${previous})`);
};
const build = (i: number): void => {
const arm = def.arms[i]!;
if (arm.kind === "undefinedT" || arm.kind === "nullT") {
@@ -2721,14 +2869,22 @@ export class LlDyn {
} else if (host.nullableUnions.has(t.unionId)) {
// A nullable union is its (+1) reference payload.
const x = B.tmp();
B.line(`${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`);
trustedCall(() =>
B.line(
`${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`,
),
);
B.terminate(`ret ptr ${x}`);
} else {
const rc = vAdapters(host, arm);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
const x = B.tmp();
const u = B.tmp();
B.line(`${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`);
trustedCall(() =>
B.line(
`${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`,
),
);
B.line(
`${u} = call ptr @scr_union_new_ref(i32 ${i}, ptr ${x}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host, arm)})`,
);
@@ -2749,6 +2905,7 @@ export class LlDyn {
unmatched,
preserveRefs,
`@${name}.cases`,
trustedKinds,
);
for (const i of order) {
B.startBlock(hits[i]!);
@@ -2759,7 +2916,7 @@ export class LlDyn {
for (const i of order) {
const lHit = B.newLabel("dcu.h");
const lNext = B.newLabel("dcu.n");
this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs);
this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs, trustedKinds[i]!);
B.startBlock(lHit);
build(i);
B.startBlock(lNext);
@@ -20,7 +20,12 @@ import { constructorClass } from "../../ir/final-fields.js";
import { findLoopArrayBorrows } from "./loop-array-borrows.js";
import { findCallArrayReads, findLocalArrayReads } from "./local-array-reads.js";
import { findScalarStringSlices, findScratchStringSlices } from "./string-slices.js";
import { findMapReadLifetimes, findReadNumberMapWrites } from "./map-read-lifetimes.js";
import {
findMapReadBorrows,
findMapReadLifetimes,
findReadNumberMapWrites,
} from "./map-read-lifetimes.js";
import { borrowableMapGet } from "./expr-containers.js";
import { findMapEntryFusions } from "./map-entry-fusion.js";
import { findLocalStackUnions } from "./stack-unions.js";
import { findLocalStackObjects, findReassignedStackObjects } from "./stack-objects.js";
@@ -200,6 +205,17 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
fn,
emitter.boxedUnionsById,
emitter.callLifetimes,
emitter.referenceEffects,
);
const stable = emitter.callLifetimes.bindings.get(fn.name);
emitter.borrowedMapGets = findMapReadBorrows(
fn,
emitter.referenceEffects,
(decl) =>
decl.init !== null &&
stable?.has(decl.localId) === true &&
!emitter.mapReadLifetimes.locals.has(decl.localId) &&
borrowableMapGet(emitter, decl.init),
);
emitter.readNumberMapWrites = findReadNumberMapWrites(fn);
emitter.numberMapKeyIntegers = new Map();
@@ -5,6 +5,7 @@
* replay calls the same entry point with the same arguments, so every
* helper gets the number and the definition the serial emission gives it,
* and the range's text is renamed to those numbers. */
import type { DebugRequest } from "./debug-info.js";
import type { LlEmitter } from "./emitter.js";
/** One event of a range's emission, in order. */
@@ -97,6 +98,8 @@ export class EmitJournal {
* worker process can write it. */
export interface ChunkResult {
readonly entries: readonly JournalEntry[];
/** The requests that added debug metadata nodes (LlvmDebugInfo.record). */
readonly debugRequests: readonly DebugRequest[];
readonly flags: {
readonly usesCoroutines: boolean;
readonly usesTimers: boolean;
@@ -152,23 +155,83 @@ export function replayJournal(
}
/** The rename of a range's text: numbered names (`names`, without the
* sigil) and field alias tags (`tags`). One scan for each kind of name:
* a numbered name starts `sc_` after a character that cannot be part of a
* name, and ends at the last digit after its counter prefix. */
* sigil), field alias tags (`tags`) and debug metadata ids (`debugIds`,
* `!N`). One scan for each kind of name: a numbered name starts `sc_`
* after a character that cannot be part of a name, and ends at the last
* digit after its counter prefix. */
export function renameChunk(
names: ReadonlyMap<string, string>,
tags: ReadonlyMap<number, number>,
debugIds: ReadonlyMap<string, string> = new Map(),
): (text: string) => string {
const prefixes = new Set<string>();
for (const name of names.keys()) prefixes.add(name.replace(/\d+$/, ""));
if (prefixes.size === 0 && tags.size === 0) return (text) => text;
if (prefixes.size === 0 && tags.size === 0 && debugIds.size === 0) return (text) => text;
return (text) => {
let out = prefixes.size === 0 ? text : renameNames(text, names, prefixes);
if (tags.size > 0) out = renameTags(out, tags);
if (debugIds.size > 0) out = renameDebugIds(out, debugIds);
return out;
};
}
/** Renames metadata references `!N` outside string literals and comments.
* Debug builds attach no alias tags, so every such reference in a
* function's text is a debug metadata id. A string literal or a comment
* (`;` outside a string literal) ends at the end of its line; a quote
* inside a string literal is escaped (`\22`). */
function renameDebugIds(text: string, ids: ReadonlyMap<string, string>): string {
const parts: string[] = [];
let copied = 0;
// The line of the reference being looked at, and whether the reference
// is inside a string literal or a comment there. The next quote and
// semicolon positions only move forward, so each is found once.
let lineEnd = -1;
let quoted = false;
let comment = false;
let quote = text.indexOf('"');
let semicolon = text.indexOf(";");
for (let at = text.indexOf("!"); at >= 0; at = text.indexOf("!", at + 1)) {
if (at > lineEnd) {
lineEnd = text.indexOf("\n", at);
if (lineEnd < 0) lineEnd = text.length;
quoted = false;
comment = false;
// Quotes and semicolons of earlier lines end with them.
if ((quote >= 0 && quote < at) || (semicolon >= 0 && semicolon < at)) {
const lineStart = text.lastIndexOf("\n", at) + 1;
while (quote >= 0 && quote < lineStart) quote = text.indexOf('"', quote + 1);
while (semicolon >= 0 && semicolon < lineStart)
semicolon = text.indexOf(";", semicolon + 1);
}
}
while (!comment) {
const nextQuote = quote >= 0 && quote < at ? quote : -1;
const nextSemicolon = semicolon >= 0 && semicolon < at ? semicolon : -1;
if (nextQuote < 0 && nextSemicolon < 0) break;
if (nextQuote >= 0 && (nextSemicolon < 0 || nextQuote < nextSemicolon)) {
quoted = !quoted;
quote = text.indexOf('"', quote + 1);
} else {
if (!quoted) comment = true;
semicolon = text.indexOf(";", semicolon + 1);
}
}
if (quoted || comment) continue;
if (at > 0 && isNameChar(text.charCodeAt(at - 1))) continue;
let end = at + 1;
while (end < text.length && isDigit(text.charCodeAt(end))) end++;
if (end === at + 1 || (end < text.length && isNameChar(text.charCodeAt(end)))) continue;
const own = ids.get(text.slice(at, end));
if (own === undefined) continue;
parts.push(text.slice(copied, at), own);
copied = end;
}
if (copied === 0) return text;
parts.push(text.slice(copied));
return parts.join("");
}
function renameNames(
text: string,
names: ReadonlyMap<string, string>,
@@ -134,6 +134,7 @@ function emitFunctionsOnWorkers(emitter: LlEmitter, workers: number): string[] |
let code = 1;
try {
emitter.journal = new EmitJournal();
emitter.debug?.record();
const texts: string[] = [];
for (const fn of functions.slice(range * size, (range + 1) * size)) {
texts.push(emitter.emitFunction(fn));
@@ -230,13 +231,10 @@ const PARTITION_GROUP_END = "; scriptc-group";
export function emitModuleParts(emitter: LlEmitter): string[] {
// Function bodies first (the literal/unit/fn-value tables fill as they
// emit), then the file assembles around them — the runtime ABI’s order.
// Debug metadata is numbered per emitter and not journaled, so a debug
// build emits every body here.
const ranges = emitter.debug === null;
const chunks = emitter.targetOptions.functionChunks ?? 1;
const fnDefs =
(ranges && chunks > 1 ? emitFunctionsInChunks(emitter, chunks) : null) ??
(ranges ? emitFunctionsOnWorkers(emitter, emitter.targetOptions.functionWorkers ?? 1) : null) ??
(chunks > 1 ? emitFunctionsInChunks(emitter, chunks) : null) ??
emitFunctionsOnWorkers(emitter, emitter.targetOptions.functionWorkers ?? 1) ??
[];
if (fnDefs.length === 0)
for (const fn of emitter.mod.functions) {
@@ -1677,8 +1675,8 @@ export function helperDefs(emitter: LlEmitter): string[] {
// order; other differing words decide by their first differing byte,
// and strings shorter than a word by their first byte. Otherwise a
// proven-ASCII string on either side orders by bytes (scr_str_cmp), and
// other pairs call scr_str_cmp_u16_bytes, which only reads the two
// strings, so LLVM can merge repeated comparisons of one pair. Callers
// other pairs call scr_str_cmp_u16_bytes. Both only read the two strings,
// so LLVM can merge repeated comparisons of one pair. Callers
// only test the sign of the result.
if (emitter.decls.has(`declare i32 @scr_str_cmp_u16(ptr, ptr)`)) {
const fn = "scr_str_cmp_u16";
@@ -1808,7 +1806,7 @@ export function helperDefs(emitter: LlEmitter): string[] {
` %anyascii = icmp slt i32 %cws, 0`,
` br i1 %anyascii, label %memorder, label %units`,
`memorder:`,
` %m = call i32 @scr_str_cmp(ptr %a, ptr %b)`,
` %m = call i32 @scr_str_cmp(ptr %a, ptr %b) nounwind willreturn memory(argmem: read)`,
` ret i32 %m`,
`units:`,
` %c = call i32 @scr_str_cmp_u16_bytes(ptr %a, ptr %b) nounwind willreturn memory(argmem: read)`,
@@ -32,7 +32,7 @@ import { allocateLocalUnion, storeLocalUnion } from "./local-union-storage.js";
import { boxAccess, boxNewCall, elemAccess } from "./shapes.js";
import { dynHoleRead } from "./loop-array-borrows.js";
import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js";
import { charAtUnitRead, emitDiscardedSplice } from "./expr-containers.js";
import { charAtUnitRead, emitBorrowedMapGet, emitDiscardedSplice } from "./expr-containers.js";
import { InternalCompilerError } from "../../errors.js";
import {
emitDenseArrayGet,
@@ -105,6 +105,19 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
break;
}
const b = emitter.binding(s.localId);
if (
emitter.borrowedMapGets.has(s.localId) &&
b.kind === "local" &&
s.init?.kind === "mapIntrinsic"
) {
// The map entry's own reference: no retain now, no release at scope
// exit (findMapReadBorrows).
const borrowed = emitBorrowedMapGet(emitter, s.init);
if (borrowed) {
B.line(`store ptr ${borrowed.name}, ptr ${b.slot}`);
break;
}
}
if (emitter.walkBorrows.has(s.localId) && b.kind === "local") {
// A borrowed walk pointer: no retain now, no release at scope exit.
const v = s.init === null ? "null" : emitter.emitReadReceiver(s.init).name;
@@ -465,6 +478,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
emitter.publishedTypes?.containerGuarded(s.arr.type) !== true
)
break;
// The literal it follows was constructed with this capacity
// (reservedArrayLength).
if (emitter.consumedReserves.delete(s)) break;
const arr = emitBorrowedInput(emitter, s.arr);
const length = emitter.emitExpr(s.length);
if (s.arr.type.kind !== "array")
+224 -56
View File
@@ -17,6 +17,7 @@ import {
import { youngNurseryRequested } from "../young-nursery.js";
import { llvmBytes as llBytes, wtf8Bytes } from "../literals.js";
import { InternalCompilerError } from "../../errors.js";
import { serializeModuleParts } from "../../ir/serialize.js";
import { createHash } from "node:crypto";
import {
endsWithJump,
@@ -88,6 +89,8 @@ import {
fillAllocationLength,
int32ArrayKey,
selectInt32Arrays,
type Int32ArrayProof,
type Int32ArrayRevision,
} from "../../ir/int32-arrays.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import {
@@ -103,6 +106,7 @@ import { resolveSelfCaptures } from "../../ir/self-captures.js";
import {
analyzeInt32Slots,
dynamicFieldStores,
Int32RevisionConflict,
type DynamicFieldStores,
Int32Slots,
} from "../../ir/int32-slots.js";
@@ -198,7 +202,7 @@ import { ReferenceEffects } from "./reference-effects.js";
import { ReferenceWrites, type Writes } from "./reference-writes.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { StackCallbacks } from "./stack-callbacks.js";
import { findAppendedArrayLiterals } from "./appended-array-literals.js";
import { findAppendedArrayLiterals, reservedArrayLength } from "./appended-array-literals.js";
import { AmbientReceiverReaders, findConstantCallbacks } from "./constant-callbacks.js";
import {
findLentRecordFields,
@@ -472,14 +476,13 @@ export interface LlvmTargetOptions {
* earlier emission found breaking its rules (UnwindAbiViolation). */
unwindExcluded?: ReadonlySet<string>;
/** Emit the function bodies in this many contiguous ranges, each by its
* own emitter, and merge them in function order (emit-journal.ts). The
* output is the same as one emitter's. Debug builds (`debugSources`)
* emit in one emitter. */
* own emitter, and merge them in function order (emit-journal.ts, and
* debug-info.ts for debug metadata). The output is the same as one
* emitter's. */
functionChunks?: number;
/** Emit the function bodies of a large module on this many forked worker
* processes at a time (emit-module.ts), forked with `forkPrimitives`. The
* output is the same as one emitter's. Debug builds (`debugSources`)
* emit in one emitter. */
* output is the same as one emitter's. */
functionWorkers?: number;
/** The host's process primitives, where it can fork (the native
* compiler's). */
@@ -554,15 +557,60 @@ export interface Int32SlotAnalysis {
readonly slots: Int32Slots;
}
/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it. */
function analyzeModuleInt32Slots(mod: IrModule): Int32SlotAnalysis {
/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it.
* With `revise`, the analysis revises the functions of candidates that drop
* (Int32SlotInputs.revise), and `mod` is the module it started from. */
function analyzeModuleInt32Slots(
mod: IrModule,
revise?: (fnName: string, dropped: ReadonlySet<string>) => IrFunction,
): Int32SlotAnalysis {
const nodeIndex = new IrNodeIndex(mod);
const initializerBindings = findInitializerBindings(mod, nodeIndex);
const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex);
const slots = analyzeInt32Slots(mod, { dynamicStores, initializerBindings, nodes: nodeIndex });
const slots = analyzeInt32Slots(mod, {
dynamicStores,
initializerBindings,
nodes: nodeIndex,
...(revise === undefined ? {} : { revise }),
});
return { mod, nodeIndex, initializerBindings, dynamicStores, slots };
}
/** The candidates an analysis keeps: int32 arrays and checked ones. */
function keptInt32Arrays(analysis: Int32SlotAnalysis): ReadonlySet<string> {
return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]);
}
/** An analysis that revised functions (analyzeModuleInt32Slots' `revise`)
* as the analysis of `mod`, the module of the functions it ended with: the
* node index of `mod`, and its initializer bindings and dynamic stores,
* which must be the ones the analysis read. Null when they differ. */
function revisedAnalysis(analysis: Int32SlotAnalysis, mod: IrModule): Int32SlotAnalysis | null {
const nodeIndex = new IrNodeIndex(mod);
const initializerBindings = findInitializerBindings(mod, nodeIndex);
const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex);
const read = analysis.initializerBindings;
if (initializerBindings.size !== read.size) return null;
for (const [name, globals] of initializerBindings) {
const other = read.get(name);
if (other?.length !== globals.length || globals.some((g, i) => other[i] !== g)) return null;
}
const stores = analysis.dynamicStores;
if (
dynamicStores.all !== stores.all ||
dynamicStores.names.size !== stores.names.size ||
[...dynamicStores.names].some((name) => !stores.names.has(name))
)
return null;
return {
mod,
nodeIndex,
initializerBindings,
dynamicStores,
slots: analysis.slots,
};
}
/** The functions whose calls the backend may emit as calls of their
* result-slot entries (record-results.ts). */
function resultEntryTargets(mod: IrModule): ReadonlySet<string> {
@@ -577,57 +625,127 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
// lowered in memory or read back from its serialized form.
const mod = unshareNodes(input);
// Each round of selectInt32Arrays analyzes the module it marks; the
// emitter of the module it returns reuses that round's analysis.
const rounds: { last: Int32SlotAnalysis | null } = { last: null };
const proven = (marked: IrModule): ReadonlySet<string> => {
const analysis = analyzeModuleInt32Slots(marked);
rounds.last = analysis;
return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]);
// Each proof selectInt32Arrays asks for analyzes a marked module; the
// emitter of the module it returns reuses the last one. A proof that
// revised functions analyzed the module of the functions it ended with.
const rounds: {
last: Int32SlotAnalysis | null;
functions: readonly IrFunction[] | null;
revising: boolean;
} = { last: null, functions: null, revising: true };
const proven = (marked: IrModule, revise: Int32ArrayRevision | null): Int32ArrayProof | null => {
rounds.functions = null;
if (revise === null || !rounds.revising) {
rounds.last = analyzeModuleInt32Slots(marked);
return { kept: keptInt32Arrays(rounds.last), functions: null };
}
const latest = new Map<string, IrFunction>();
try {
rounds.last = analyzeModuleInt32Slots(marked, (fnName, dropped) => {
const fn = revise(fnName, dropped);
latest.set(fnName, fn);
return fn;
});
} catch (error) {
if (!(error instanceof Int32RevisionConflict)) throw error;
rounds.last = null;
return null;
}
if (latest.size === 0) return { kept: keptInt32Arrays(rounds.last), functions: null };
rounds.functions = marked.functions.map((fn) => latest.get(fn.name) ?? fn);
return { kept: keptInt32Arrays(rounds.last), functions: rounds.functions };
};
// Keep source storage intact for debugger inspection in dev builds.
const emitted =
options.debugSources === undefined
? selectInt32Arrays(
versionParameters(
addRecordResultEntries(
scalarizeLiteralResults(
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
pairRowSums(
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
if (options.debugSources !== undefined) return new LlEmitter(mod, options, null, null);
const passed = versionParameters(
addRecordResultEntries(
scalarizeLiteralResults(
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
pairRowSums(
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
mangleFunction,
),
mangleFunction,
),
),
),
mangleFunction,
),
mangleFunction,
),
proven,
resultEntryTargets,
)
: mod;
const prepared = rounds.last;
),
),
),
);
let emitted = selectInt32Arrays(passed, proven, resultEntryTargets);
let prepared = rounds.last;
// A proof that revised functions analyzed the module of the functions it
// ended with, which is the selected module unless selection fell back to
// rounds: then the selected module was proved by its own round.
if (prepared !== null && rounds.functions !== null && prepared.mod !== emitted) {
const functions = rounds.functions;
prepared =
emitted !== passed &&
emitted.functions.length === functions.length &&
emitted.functions.every((fn, i) => fn === functions[i])
? revisedAnalysis(prepared, emitted)
: null;
if (prepared === null && emitted !== passed) {
rounds.revising = false;
emitted = selectInt32Arrays(passed, proven, resultEntryTargets);
prepared = rounds.last;
}
}
if (process.env["SCRIPTC_INT32_VERIFY"] === "1")
verifyInt32Selection(passed, emitted, prepared?.mod === emitted ? prepared : null);
return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null);
}
/** SCRIPTC_INT32_VERIFY=1: throws unless the selection with revisions
* (`emitted`, and the analysis the emitter takes over) is the one rounds
* without revisions give: the same module, and the same facts. */
function verifyInt32Selection(
passed: IrModule,
emitted: IrModule,
analysis: Int32SlotAnalysis | null,
): void {
let last: Int32SlotAnalysis | null = null;
const reference = selectInt32Arrays(
passed,
(marked) => {
last = analyzeModuleInt32Slots(marked);
return { kept: keptInt32Arrays(last), functions: null };
},
resultEntryTargets,
);
const text = (mod: IrModule): string => serializeModuleParts(mod).join("");
if (text(reference) !== text(emitted))
throw new InternalCompilerError("int32 selection: the revised module differs from the rounds'");
const facts = (mod: IrModule, prepared: Int32SlotAnalysis | null): string =>
(prepared?.mod === mod ? prepared : analyzeModuleInt32Slots(mod)).slots.summary(mod);
const expected = facts(reference, last);
const actual = facts(emitted, analysis);
if (expected !== actual) {
const want = expected.split("\n");
const got = actual.split("\n");
const line = want.findIndex((entry, i) => entry !== got[i]);
throw new InternalCompilerError(
`int32 selection: the revised analysis differs from the rounds': ${want[line]?.slice(0, 400)} != ${got[line]?.slice(0, 400)}`,
);
}
}
/** Emits the module's parts, emitting it again with the functions an
* UnwindAbiViolation names on the pending-flag ABI. */
function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] {
@@ -1242,6 +1360,9 @@ export class LlEmitter {
readonly checkedNarrows: CheckedNarrows;
callArrayReads = new Map<IrExpr, LocalArrayRead>();
mapReadLifetimes: MapReadLifetimes = { locals: new Map(), arguments: new Map() };
/** Locals of the current function that hold a Map.get result as the
* entry's own reference (findMapReadBorrows, borrowableMapGet). */
borrowedMapGets: ReadonlySet<string> = new Set();
readNumberMapWrites: ReadonlySet<IrExpr> = new Set();
/** The integer views of the current function's number Map/Set keys, by
* the key's double operand (noteNumberMapKey, map-number-lookup.ts). */
@@ -1357,6 +1478,11 @@ export class LlEmitter {
/** Locals whose array literal emitStmts left for the `await Promise.all`
* that reads them (deferredPromiseAllLocal). */
private readonly deferredPromiseAllEntries = new Map<string, ExprOf<"arrayLit">>();
/** Empty array literals that a capacity hint follows directly
* (reservedArrayLength), with that hint. Constructing the literal with
* the hinted capacity consumes the hint, which then emits nothing. */
readonly reservedArrayLengths = new Map<IrExpr, IrStmt & { kind: "arraySetLength" }>();
readonly consumedReserves = new Set<IrStmt>();
/** The promise a `return` emitted past its finally regions already
* produced for its adopting await (borrowed from the return's parked
* slot); that await takes it instead of evaluating its operand. */
@@ -1440,7 +1566,12 @@ export class LlEmitter {
/** Preorder intervals of the runtime error classes under THIS module's
* class-forest numbering (main() stamps scr_error_vts with them, exactly
* like the runtime ABI’s errorVtStampLines). */
readonly errorIntervals: { kind: number; pre: number; post: number; lib: string }[] = [];
readonly errorIntervals: {
kind: number;
pre: number;
post: number;
lib: string;
}[] = [];
/** The runtime emitter vtable's preorder interval, when the program
* touches node:events (the class def rides the module exactly then) —
* main() stamps scr_emitter_vt with it (emitterVtStampLines, ported). */
@@ -1449,7 +1580,12 @@ export class LlEmitter {
* ported): the defs ride every emitter-touching module (the frontend
* collects the whole emitter-rooted tree), and scr_stream.c links on
* the same predicate, so the stamps always have their globals. */
readonly streamIntervals: { vt: string; pre: number; post: number; lib: string }[] = [];
readonly streamIntervals: {
vt: string;
pre: number;
post: number;
lib: string;
}[] = [];
// ── per-function state (reset in emitFunction) ─────────────────────────
B = new BlockBuilder();
@@ -1522,7 +1658,12 @@ export class LlEmitter {
/** Active induction bindings retain their width independently of size_t. */
integerLoopBindings = new Map<
string,
{ slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } }
{
slot: string;
type: "i32" | "i64";
signed?: boolean;
range?: { min: number; max: number };
}
>();
/** Constant number locals whose latest emitted declaration stored an
* exact integer beside the double, such as `const j = i * 4` in a
@@ -1605,7 +1746,11 @@ export class LlEmitter {
* (the fast copy) or as double arrays (an ordinary copy entered only
* after they widened), as `function\0local` keys, and where each of the
* fast copy's writes resumes in the third copy. */
int32Kinds: { int32: Set<string>; wide: Set<string>; resume: Map<IrStmt, string> } | null = null;
int32Kinds: {
int32: Set<string>;
wide: Set<string>;
resume: Map<IrStmt, string>;
} | null = null;
/** The resume label of the fast-copy write being emitted. */
int32KindResume: string | null = null;
/** The function being emitted, for speculative globals. */
@@ -1679,7 +1824,12 @@ export class LlEmitter {
* releases frames/scopes down to the recorded depths and branches to
* `label` (the catch, or the exception-path finally) instead of
* returning out of the function. Entering a try emits no code. */
tryStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number }[] = [];
tryStack: {
label: string;
used: boolean;
frameDepth: number;
scopeDepth: number;
}[] = [];
/** Return type of the function being emitted — the unwind path returns
* a dummy of this type (never read: callers check the flag first). */
currentReturnType: IrType = VOID;
@@ -1738,9 +1888,7 @@ export class LlEmitter {
this.journal = journal;
this.journaled =
journal !== null ||
((options.functionWorkers ?? 1) > 1 &&
options.forkPrimitives !== undefined &&
options.debugSources === undefined);
((options.functionWorkers ?? 1) > 1 && options.forkPrimitives !== undefined);
// One walk of every body, which the analyses below read instead of
// walking the bodies themselves.
this.nodeIndex = int32Analysis?.nodeIndex ?? new IrNodeIndex(mod);
@@ -5113,6 +5261,7 @@ export class LlEmitter {
chunkResult(): ChunkResult {
return {
entries: this.journal!.entries,
debugRequests: this.debug?.recorded() ?? [],
flags: {
usesCoroutines: this.usesCoroutines,
usesTimers: this.usesTimers,
@@ -5148,6 +5297,8 @@ export class LlEmitter {
// emission left them (a helper set guards the declarations its first
// request adds).
replayJournal(this, chunk.entries, names, tags);
const debugIds = new Map<string, string>();
this.debug?.replay(chunk.debugRequests, (name) => this.functionNamed(name), debugIds);
const flags = chunk.flags;
this.usesCoroutines ||= flags.usesCoroutines;
this.usesTimers ||= flags.usesTimers;
@@ -5168,7 +5319,13 @@ export class LlEmitter {
for (const name of chunk.adoptCallees) this.adoptCallees.add(name);
for (const name of chunk.directAwaitCallees) this.directAwaitCallees.add(name);
for (const name of chunk.inlineAwaitCallees) this.inlineAwaitCallees.add(name);
return renameChunk(names, tags);
return renameChunk(names, tags, debugIds);
}
private functionNamed(name: string): IrFunction {
const fn = this.fnByName.get(name);
if (fn === undefined) throw new InternalCompilerError(`llvm emitter bug: no function ${name}`);
return fn;
}
/** Adds a content-named constant a chunk interned. Its symbol is this
@@ -5296,7 +5453,13 @@ export class LlEmitter {
/** An emitter for a range of this module's functions: the same facts,
* empty module-level tables, and a journal of what the range adds. */
chunkEmitter(): LlEmitter {
return new LlEmitter(this.mod, { ...this.targetOptions, functionChunks: 1 }, new EmitJournal());
const chunk = new LlEmitter(
this.mod,
{ ...this.targetOptions, functionChunks: 1 },
new EmitJournal(),
);
chunk.debug?.record();
return chunk;
}
/** The double-ABI entry of a function with an i32 entry: every caller
@@ -5898,6 +6061,8 @@ export class LlEmitter {
if (this.B.isTerminated()) return;
if (cursor) cursor.index = i;
this.stmtCursor = cursor;
const reserve = reservedArrayLength(stmts, i);
if (reserve !== null) this.reservedArrayLengths.set(reserve.literal, reserve.hint);
const deferred = this.deferredPromiseAllLocal(stmts, i);
if (deferred !== null) {
this.deferredPromiseAllEntries.set((stmts[i] as { localId: string }).localId, deferred);
@@ -7195,7 +7360,10 @@ export class LlEmitter {
return childDataDynThunkFor(this, chunk);
}
emitterFixedAdapter(cbT: IrType & { kind: "func" }): { fn: string; shim: string } {
emitterFixedAdapter(cbT: IrType & { kind: "func" }): {
fn: string;
shim: string;
} {
return emitterFixedAdapter(this, cbT);
}
@@ -1145,6 +1145,65 @@ export function emitArrIntrinsic(
}
}
/** Whether `e` is a Map.get that emitBorrowedMapGet can emit without a
* retain: a typed map with number or inline string keys, a reference value,
* and a nullable result (the value's pointer, or the absent arm's). */
export function borrowableMapGet(host: LlvmEmitterContext, e: IrExpr): boolean {
if (e.kind !== "mapIntrinsic" || e.method !== "get" || e.args.length !== 1) return false;
if (!borrowsMapReadInputs(e) || e.receiver.type.kind !== "map") return false;
const { key, value } = e.receiver.type;
if (!isRefCounted(value) || value.kind === "union" || key.kind === "union") return false;
if (e.type.kind !== "union" || !host.nullableUnions.has(e.type.unionId)) return false;
const arg = e.args[0]!;
const access = mapKeyAccess(key);
return (
(access === "f64" && arg.type.kind === "f64") ||
(access === "str" && arg.type.kind === "string")
);
}
/** A borrowableMapGet read as the entry's own reference: no retain, and
* no owner. The caller proves that nothing can change the map while the
* value is used (findMapReadBorrows). Null, before emitting anything, for
* a key held as a split span, which takes the runtime's owned lookup. */
export function emitBorrowedMapGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "mapIntrinsic" },
): LlValue | null {
const arg = e.args[0]!;
if (arg.kind === "varRef" && host.splitSpans.get(arg.localId) !== undefined) return null;
const B = host.B;
const mapType = e.receiver.type as IrType & { kind: "map" };
const r = host.emitStableReceiver(e.receiver, e.args);
const k = emitMapLookupKey(host, e.args[0]!, true);
const numberKey = inlineNumberKey(k);
const stringKey = entryStringKey(k);
const value = mapType.value;
let raw: string;
if (numberKey !== null) raw = emitNumberMapGetRef(host, r.name, numberKey, value, true);
else if (stringKey !== null)
raw = emitStringMapGetRef(
host,
r.name,
stringKey,
value,
compactMapValues(host, mapType.key, value),
true,
);
else throw new InternalCompilerError("llvm emitter bug: borrowed Map.get without an inline key");
const resultType = e.type as IrType & { kind: "union" };
const absent = host.unitInstanceRef(
resultType.unionId,
undefinedArmTag(resultType, host.unionsById),
);
if (absent === "null") return { name: raw, type: e.type };
const isnull = B.tmp(),
t = B.tmp();
B.line(`${isnull} = icmp eq ptr ${raw}, null`);
B.line(`${t} = select i1 ${isnull}, ptr ${absent}, ptr ${raw}`);
return { name: t, type: e.type };
}
export function wrapNullable(
host: LlvmEmitterContext,
raw: string,
@@ -946,6 +946,34 @@ export function emitStringExpr(
}
}
/** scr_arr_reserve_len reserves dense storage only up to this many slots
* (SCR_ARR_DENSE_LIMIT, scr_array.c). */
const RESERVE_LIMIT = 2 ** 20;
/** Elements the inline slots of a small array construction
* (scr_arr_new_small, scr_arr_new_ops_small) hold at least
* (SCR_ARR_SMALL_SLOTS, scr_array.c). */
const SMALL_ARRAY_SLOTS = 3;
/** The capacity scr_arr_reserve_len would reserve for `length` on an empty
* array: the length truncated when it is at least 1 and at most
* RESERVE_LIMIT, else 0. */
function reservedCapacity(host: LlvmEmitterContext, length: IrExpr): string {
const B = host.B;
const n = host.emitExpr(length);
const atLeastOne = B.tmp(),
withinLimit = B.tmp(),
reserves = B.tmp(),
slots = B.tmp(),
capacity = B.tmp();
B.line(`${atLeastOne} = fcmp oge double ${n.name}, ${f64Lit(1)}`);
B.line(`${withinLimit} = fcmp ole double ${n.name}, ${f64Lit(RESERVE_LIMIT)}`);
B.line(`${reserves} = and i1 ${atLeastOne}, ${withinLimit}`);
B.line(`${slots} = select i1 ${reserves}, double ${n.name}, double ${f64Lit(0)}`);
B.line(`${capacity} = fptoui double ${slots} to ${host.sizeType}`);
return capacity;
}
export function emitContainerExpr(
host: LlvmEmitterContext,
e: ExprOf<
@@ -1017,7 +1045,34 @@ export function emitContainerExpr(
? `${arr} = call ptr @scr_arr_new_small(i32 ${SCR_ELEM_I32})`
: `${arr} = call ptr @scr_arr_new(i32 ${SCR_ELEM_I32}, ${host.sizeType} ${capacity})`,
);
} else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
} else {
const hint = host.reservedArrayLengths.get(e);
if (hint !== undefined) {
host.reservedArrayLengths.delete(e);
host.consumedReserves.add(hint);
const capacity = reservedCapacity(host, hint.length);
// The small construction's inline slots hold at least
// SMALL_ARRAY_SLOTS elements; larger capacities are sized.
const fits = B.tmp(),
smallArr = B.tmp(),
sizedArr = B.tmp();
const smallLabel = B.newLabel("arr.cap.small"),
sizedLabel = B.newLabel("arr.cap.sized"),
joined = B.newLabel("arr.cap.j");
B.line(`${fits} = icmp ule ${host.sizeType} ${capacity}, ${SMALL_ARRAY_SLOTS}`);
B.condBr(fits, smallLabel, sizedLabel);
B.startBlock(smallLabel);
B.line(`${smallArr} = ${arrNewCall(host.shapeHost, elem, "0", true)}`);
B.br(joined);
B.startBlock(sizedLabel);
B.line(`${sizedArr} = ${arrNewCall(host.shapeHost, elem, capacity)}`);
B.br(joined);
B.startBlock(joined);
B.line(
`${arr} = phi ptr [ ${smallArr}, %${smallLabel} ], [ ${sizedArr}, %${sizedLabel} ]`,
);
} else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
}
const out = host.own({ name: arr, type: e.type });
const acc: ArrayAccess = int32 ? "i32" : elemAccess(elem);
let pending: LlValue[] = [];
@@ -792,13 +792,16 @@ export function streamTypedRefMaterializeAdapter(
t: IrType,
): LlStreamTypedRefAdapter {
const key = typeKey(t);
const existing = host.liveDynRefAdapters.get(key);
// A `(T | undefined)[]` stored as T elements shares T[]'s capsule type,
// but its adapters also store undefined elements.
const adapterKey = t.kind === "array" && t.optionalElements ? `${key}?` : key;
const existing = host.liveDynRefAdapters.get(adapterKey);
if (existing) return existing;
// The converter depends only on the static type. Intern the complete
// graph across roots and union arms so recursive types emit once.
const snapshot = `sc_ldr_${host.liveDynRefAdapters.size}_materialize`;
const adapter: LlStreamTypedRefAdapter = { snapshot, commit: "null" };
host.liveDynRefAdapters.set(key, adapter);
host.liveDynRefAdapters.set(adapterKey, adapter);
host.journal?.allocated(
snapshot.replace(/_materialize$/, ""),
"streamTypedRefMaterializeAdapter",
@@ -1312,6 +1315,18 @@ function liveArrayOps(
G.terminate(`ret ptr ${boxed}`);
const S = new BlockBuilder();
if (t.optionalElements) {
// A `(T | undefined)[]` stores undefined as a present undefined.
const undef = host.dyn.isUndefined(S, "%v");
const storeUndefined = S.newLabel("live.array.undefined");
const other = S.newLabel("live.array.value");
S.condBr(undef, storeUndefined, other);
S.startBlock(storeUndefined);
host.declare(`declare void @scr_arr_set_undefined(ptr, double)`);
S.line(`call void @scr_arr_set_undefined(ptr %p, double %i)`);
S.terminate(`ret i1 true`);
S.startBlock(other);
}
const fits = S.tmp();
S.line(`${fits} = call zeroext i1 @${host.dyn.dynMatchHelper(elem)}(ptr %v)`);
const store = S.newLabel("live.array.store");
@@ -150,14 +150,16 @@ export function emitStringMapGetScalar(
}
/** Map.get of a reference value: a +1 value, or null when absent — the
* contract of scr_map_get_f64_ref. */
* contract of scr_map_get_f64_ref. `borrow`: the entry's own reference,
* without a retain, for a reader that finishes before the map can change. */
export function emitNumberMapGetRef(
host: LlvmEmitterContext,
map: string,
key: string,
value: IrType,
borrow = false,
): string {
return emitMapEntryGetRef(host, emitNumberMapEntry(host, map, key), value);
return emitMapEntryGetRef(host, emitNumberMapEntry(host, map, key), value, false, borrow);
}
/** Map.get of a reference value under a string key, with scr_map_get_str_ref's
@@ -169,16 +171,25 @@ export function emitStringMapGetRef(
key: string,
value: IrType,
compact = false,
borrow = false,
): string {
return emitMapEntryGetRef(host, emitStringMapEntry(host, map, key, compact), value, compact);
return emitMapEntryGetRef(
host,
emitStringMapEntry(host, map, key, compact),
value,
compact,
borrow,
);
}
/** The +1 value of a found entry, or null for a missing (null) entry. */
/** The +1 value of a found entry (its own reference when `borrow`), or
* null for a missing (null) entry. */
function emitMapEntryGetRef(
host: LlvmEmitterContext,
entry: string,
value: IrType,
compact = false,
borrow = false,
): string {
const B = host.B;
const found = B.tmp();
@@ -188,10 +199,8 @@ function emitMapEntryGetRef(
join = B.newLabel("mapn.join");
B.condBr(found, hit, miss);
B.startBlock(hit);
const retained = host.retainFrame(
compact ? emitCompactMapSlot(host, entry) : emitNumberMapSlot(host, entry, "ptr"),
value,
);
const loaded = compact ? emitCompactMapSlot(host, entry) : emitNumberMapSlot(host, entry, "ptr");
const retained = borrow ? loaded : host.retainFrame(loaded, value);
B.br(join);
B.startBlock(miss);
B.br(join);
@@ -120,7 +120,12 @@ test("local map results keep scalar and owned reference payloads without heap wr
expect(ir).toContain("call ptr @sc_map_entry_str(");
expect(ir).not.toContain("@scr_map_get_");
}
if (value === STRING) expect(ir).toContain("@scr_str_release");
// Nothing can change the map before the tag test, so a reference
// result is the entry's own (findMapReadBorrows): no retain or release.
if (value === STRING) {
expect(ir).not.toContain("@scr_str_retain");
expect(ir).not.toContain("@scr_str_release");
}
}
}
});
@@ -132,7 +137,8 @@ test("nullable record results are the looked-up pointer itself on both ABIs", ()
expect(ir).not.toContain("%ScrUnion");
expect(ir).not.toContain("@scr_union_");
expect(ir).toMatch(/icmp (?:eq|ne) ptr %\w+, null/);
expect(ir).toContain("@sc_rrelease_");
// The pointer is the entry's own reference until the tag test.
expect(ir).not.toContain("@sc_rrelease_");
}
});
@@ -9,6 +9,7 @@ import {
} from "../../ir/ir.js";
import { everyExpr, everyStmt, everyStmtList } from "../../ir/traverse.js";
import type { CallLifetimes } from "./call-lifetimes.js";
import type { ReferenceEffects } from "./reference-effects.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import {
emitNumberMapGetRef,
@@ -69,6 +70,10 @@ export function borrowsMapMutationReceiver(expr: IrExpr): boolean {
export interface LocalMapRead {
/** The Map.get expression itself. */
expr: IrExpr;
/** A local's reference result may stay the entry's own reference: every
* use of the local runs before anything that can change a reference
* edge (findMapReadBorrows), so the entry still holds the value. */
borrowed?: boolean;
type: IrType;
receiver: IrExpr;
key: IrExpr;
@@ -127,6 +132,7 @@ export function findMapReadLifetimes(
fn: IrFunction,
unions: ReadonlyMap<string, IrUnionDef>,
lifetimes: CallLifetimes,
effects?: ReferenceEffects,
): MapReadLifetimes {
const result: MapReadLifetimes = { locals: new Map(), arguments: new Map() };
if (fn.async || fn.generator) return result;
@@ -151,6 +157,125 @@ export function findMapReadLifetimes(
return true;
},
});
if (effects) {
const borrowed = findMapReadBorrows(fn, effects, (decl) => {
const read = result.locals.get(decl.localId);
return read !== undefined && isRefCounted(read.value);
});
for (const id of borrowed) result.locals.get(id)!.borrowed = true;
}
return result;
}
/** Whether a map read's receiver keeps the map past its own statement: a
* local or a field of one. Changing either is an edge change the borrow
* proof sees. A temporary map (a call's result, `new Map()`) is released at
* the end of the declaration, and its entries with it. */
function heldReceiver(expr: IrExpr): boolean {
if (expr.kind === "varRef") return true;
if (expr.kind === "fieldGet" || expr.kind === "recordGet") return heldReceiver(expr.obj);
if (expr.kind === "unionNarrow" || expr.kind === "downcast") return heldReceiver(expr.value);
return false;
}
/** The locals, declared by statements `candidate` accepts, that can hold a
* map entry's own reference instead of a +1 copy. The statements after the
* declaration run in order, and every one that names the local must
* preserve reference edges (ReferenceEffects) and run before any statement
* that may not, so no write, deletion or unknown call can release the value
* while the local is read. An `if` passes its condition's state to both
* branches and joins them; any other statement that names the local is
* judged whole. A later pass of an enclosing loop declares the local again,
* so only the rest of its own statement list matters. The map must be held
* by a local or a field (heldReceiver), the read itself must preserve
* reference edges (a key whose evaluation replaces the receiver's field
* leaves the read on a map the statement releases), and locals declared
* from the local count as its uses. An update such as
* `const c = m.get(k); if (c === undefined) m.set(k, v); else c.n++` keeps
* the borrow: the write runs only on the branch that does not read `c`. */
export function findMapReadBorrows(
fn: IrFunction,
effects: ReferenceEffects,
candidate: (decl: IrStmt & { kind: "varDecl" }) => boolean,
): Set<string> {
const result = new Set<string>();
if (fn.async || fn.generator) return result;
// `ids` holds the local and the locals declared from it: a stable alias
// (`const d = c`) reads the same entry without a reference of its own.
const names = (node: IrStmt | IrExpr, ids: ReadonlySet<string>, isStmt: boolean): boolean => {
let found = false;
const visitor = {
stmt: () => !found,
expr: (expr: IrExpr) => {
if (expr.kind === "varRef" && ids.has(expr.localId)) found = true;
return !found;
},
};
if (isStmt) everyStmt(node as IrStmt, visitor);
else everyExpr(node as IrExpr, visitor);
return found;
};
// Adds the reference locals that `stmt` declares from one in `ids`, at any
// depth: a `var` inside a loop or a try stays in scope after it.
const alias = (stmt: IrStmt, ids: Set<string>): void => {
everyStmt(stmt, {
stmt: (node) => {
if (
node.kind === "varDecl" &&
node.init !== null &&
isRefCounted(node.init.type) &&
names(node.init, ids, false)
)
ids.add(node.localId);
return true;
},
expr: () => true,
});
};
// Whether an edge may have changed after `list`, or null when a statement
// names the local after such a change (or changes edges itself).
const walk = (list: readonly IrStmt[], ids: Set<string>, changed: boolean): boolean | null => {
for (const stmt of list) {
if (stmt.kind === "if") {
const preserving = effects.preserves(stmt.cond);
if (names(stmt.cond, ids, false) && (changed || !preserving)) return null;
const condition = changed || !preserving;
const then = walk(stmt.then, ids, condition);
const otherwise = stmt.else_ === null ? condition : walk(stmt.else_, ids, condition);
if (then === null || otherwise === null) return null;
changed = then || otherwise;
continue;
}
if (stmt.kind === "block") {
const inner = walk(stmt.body, ids, changed);
if (inner === null) return null;
changed = inner;
continue;
}
const preserving = effects.preservesScope([stmt]);
if (names(stmt, ids, true)) {
if (changed || !preserving) return null;
alias(stmt, ids);
}
changed ||= !preserving;
}
return changed;
};
const visit = (list: readonly IrStmt[]): void => {
list.forEach((stmt, index) => {
if (
stmt.kind === "varDecl" &&
stmt.init?.kind === "mapIntrinsic" &&
heldReceiver(stmt.init.receiver) &&
effects.preserves(stmt.init) &&
candidate(stmt) &&
walk(list.slice(index + 1), new Set([stmt.localId]), false) !== null
)
result.add(stmt.localId);
for (const nested of nestedStmtLists(stmt)) visit(nested);
});
};
visit(fn.body);
return result;
}
@@ -305,13 +430,14 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
let owner: StackMapRead["owner"] = null;
const number = inlineNumberKey(key);
const string = entryStringKey(key);
const borrow = read.borrowed === true;
if (number !== null) {
if (isRefCounted(read.value)) {
const raw = emitNumberMapGetRef(host, receiver.name, number, read.value);
const raw = emitNumberMapGetRef(host, receiver.name, number, read.value, borrow);
B.line(`${found} = icmp ne ptr ${raw}, null`);
B.line(`store i64 0, ptr ${payload}`);
B.line(`store ptr ${raw}, ptr ${payload}`);
owner = { slot: payload, type: read.value };
if (!borrow) owner = { slot: payload, type: read.value };
} else {
// Booleans are stored as 0/1, already the widened union payload.
const scalar = emitNumberMapGetScalar(host, receiver.name, number);
@@ -329,8 +455,10 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
let raw: string;
const mapType = read.receiver.type;
const compact = mapType.kind === "map" && compactMapValues(host, mapType.key, mapType.value);
// The runtime's other key kinds return +1, which the frame then owns.
const borrowed = borrow && stringKey !== null;
if (stringKey !== null)
raw = emitStringMapGetRef(host, receiver.name, stringKey, read.value, compact);
raw = emitStringMapGetRef(host, receiver.name, stringKey, read.value, compact, borrowed);
else {
raw = B.tmp();
host.declare(`declare ptr @scr_map_get_${access}_ref(ptr, ${keyType})`);
@@ -339,7 +467,7 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
B.line(`${found} = icmp ne ptr ${raw}, null`);
B.line(`store i64 0, ptr ${payload}`);
B.line(`store ptr ${raw}, ptr ${payload}`);
owner = { slot: payload, type: read.value };
if (!borrowed) owner = { slot: payload, type: read.value };
} else {
const scalar = read.value.kind === "f64" ? "f64" : "bool";
B.line(`store i64 0, ptr ${payload}`);
@@ -187,6 +187,37 @@ function exactPowerOfTwo(value: number): number | null {
return k >= 0 && k <= 52 && 2 ** k === value ? k : null;
}
/** The same speculated operand written twice: a local, a literal, or a
* power of two of the same exponent. */
function sameOperand(a: IrExpr, b: IrExpr): boolean {
if (a.kind === "varRef") return b.kind === "varRef" && a.localId === b.localId;
if (a.kind === "numLit") return b.kind === "numLit" && Object.is(a.value, b.value);
return (
a.kind === "libCall" &&
b.kind === "libCall" &&
a.fn === "math.pow" &&
b.fn === "math.pow" &&
powerOfTwo(a) &&
powerOfTwo(b) &&
sameOperand(a.args[1]!, b.args[1]!)
);
}
/** `a - Math.floor(a / p) * p` (either factor order) with p a power of two
* and `a` a local or literal: the floor-mod of `a` by p. */
function floorModPowerOfTwo(e: IrExpr): { value: IrExpr; power: IrExpr } | null {
if (e.kind !== "bin" || e.op !== "-" || e.right.kind !== "bin" || e.right.op !== "*") return null;
const { left, right } = e.right;
const [floor, power] =
left.kind === "libCall" && left.fn === "math.floor" ? [left, right] : [right, left];
if (floor.kind !== "libCall" || floor.fn !== "math.floor" || floor.args.length !== 1) return null;
const quotient = floor.args[0]!;
if (quotient.kind !== "bin" || quotient.op !== "/") return null;
if (!powerOfTwo(power) || !sameOperand(quotient.right, power)) return null;
if (e.left.kind !== "varRef" && e.left.kind !== "numLit") return null;
return sameOperand(quotient.left, e.left) ? { value: e.left, power } : null;
}
/** Whether `e` is a power of two speculation can shift by:
* `Math.pow(2, n)` or a literal 2^k with k in [0, 52]. */
function powerOfTwo(e: IrExpr): boolean {
@@ -950,6 +981,37 @@ export function emitSpeculativeInteger(
}
case "bin": {
if (e.op !== "+" && e.op !== "-" && e.op !== "*" && e.op !== "%") break;
// The floor-mod of a safe integer by 2^n (n in [0, 52]) is its low n
// bits, also for a negative value: in [0, 2^n), and never -0.
const mod = floorModPowerOfTwo(e);
if (mod) {
const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral);
const n = exponentOf(host, mod.power, bail, reads, integral);
const mask = B.tmp(),
low = B.tmp();
B.line(`${mask} = sub i64 ${shiftOne(host, n)}, 1`);
B.line(`${low} = and i64 ${value}, ${mask}`);
return low;
}
// A product with 2^n shifts: it is safe iff the other factor's
// magnitude is at most (2^53 - 1) >> n, and a zero product of a safe
// integer and a positive power is +0.
if (e.op === "*" && (powerOfTwo(e.right) || powerOfTwo(e.left))) {
const [factor, power] = powerOfTwo(e.right) ? [e.left, e.right] : [e.right, e.left];
const value = emitSpeculativeInteger(host, factor, bail, reads, integral);
const n = exponentOf(host, power, bail, reads, integral);
const limit = B.tmp(),
magnitude = B.tmp(),
outside = B.tmp(),
product = B.tmp();
B.line(`${limit} = ashr i64 ${SAFE}, ${n}`);
host.declare("declare i64 @llvm.abs.i64(i64, i1)");
B.line(`${magnitude} = call i64 @llvm.abs.i64(i64 ${value}, i1 true)`);
B.line(`${outside} = icmp ugt i64 ${magnitude}, ${limit}`);
bail(outside);
B.line(`${product} = shl nsw i64 ${value}, ${n}`);
return product;
}
const a = emitSpeculativeInteger(host, e.left, bail, reads, integral);
const b = emitSpeculativeInteger(host, e.right, bail, reads, integral);
const result = B.tmp();
@@ -478,7 +478,9 @@ test("string comparisons decide identity, lengths and leading bytes before the r
expect(units).toContain("%below = icmp ult i8 %bx, %by");
expect(units).toContain("%tailable = and i1 %samelen, %short");
expect(units).toContain("%anyascii = icmp slt i32 %cws, 0");
expect(units).toContain("call i32 @scr_str_cmp(ptr %a, ptr %b)");
expect(units).toContain(
"call i32 @scr_str_cmp(ptr %a, ptr %b) nounwind willreturn memory(argmem: read)",
);
expect(units).toContain(
"call i32 @scr_str_cmp_u16_bytes(ptr %a, ptr %b) nounwind willreturn memory(argmem: read)",
);
+2 -2
View File
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
export const RUNTIME_ABI_VERSION = 43 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v43" as const;
export const RUNTIME_ABI_VERSION = 44 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v44" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" },
runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -129,7 +129,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" },
runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" },
compiler: {
command: "clang",
identity: "fixture clang",
+91 -12
View File
@@ -4,15 +4,22 @@ import { parseArgs } from "node:util";
import { isExactExternalTypeSpecifier } from "../frontend/program.js";
import { renderDiagnostics } from "../diagnostics/render.js";
import {
COVERAGE_FAIL_ON,
coverageEnvelope,
coveragePasses,
renderCoverage,
renderExplainDynamic,
renderExplainIsland,
type CoverageFailOn,
} from "../coverage/report.js";
import {
NODE_ISLANDS_MODES,
type NodeIslandOptions,
type NodeIslandsMode,
} from "../node-islands/policy.js";
import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js";
import { buildEnvelope } from "../diagnostics/envelope.js";
import { renderWarnings } from "../diagnostics/render.js";
import { renderConfigWarnings, renderWarnings } from "../diagnostics/render.js";
import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
import { isNativeOptimization } from "../backend/optimization.js";
import {
@@ -114,6 +121,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["emit-ir"] ||
values.tier !== undefined ||
values["explain-dynamic"] !== undefined ||
hasNodeIslandFlags(values) ||
!values["keep-llvm"]
) {
fail(
@@ -172,6 +180,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["emit-ir"] ||
values.tier !== undefined ||
values["explain-dynamic"] !== undefined ||
hasNodeIslandFlags(values) ||
!values["keep-llvm"];
if (values.out === undefined || inputs.length === 0 || others) {
fail(
@@ -225,7 +234,8 @@ async function main(args: string[], host: CliHost): Promise<number> {
(values["npm-static"] ?? []).length > 0 ||
externalTypeArgs.length > 0 ||
values.tier !== undefined ||
values["explain-dynamic"] !== undefined
values["explain-dynamic"] !== undefined ||
hasNodeIslandFlags(values)
) {
fail(
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--target-cpu/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
@@ -298,14 +308,9 @@ async function main(args: string[], host: CliHost): Promise<number> {
fail(`--print=diagnostics is a build/coverage option\n\n${USAGE}`);
}
const failOnRaw = values["fail-on"];
if (
failOnRaw !== undefined &&
failOnRaw !== "blockers" &&
failOnRaw !== "divergences" &&
failOnRaw !== "dynamic"
) {
if (failOnRaw !== undefined && !(COVERAGE_FAIL_ON as readonly string[]).includes(failOnRaw)) {
fail(
`unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences, dynamic)\n\n${USAGE}`,
`unknown --fail-on value "${failOnRaw}" (supported: ${COVERAGE_FAIL_ON.join(", ")})\n\n${USAGE}`,
);
}
// --no-dynamic-tier-warning leaves out SC6101 (diagnostics/dynamic-tier.ts).
@@ -326,6 +331,48 @@ async function main(args: string[], host: CliHost): Promise<number> {
if (explainDynamic !== undefined && command !== "coverage") {
fail(`--explain-dynamic is a coverage option\n\n${USAGE}`);
}
const explainIsland = values["explain-island"];
if (explainIsland !== undefined && command !== "coverage") {
fail(`--explain-island is a coverage option\n\n${USAGE}`);
}
if (explainIsland === "") fail(`--explain-island needs a package name\n\n${USAGE}`);
if (values["strict-user-js"] && command !== "coverage") {
fail(`--strict-user-js is a coverage option\n\n${USAGE}`);
}
// The Node island policy's command-line half. Report-only: build and run
// validate it, coverage reports the islands it would choose.
const islandMode = values["node-islands"];
if (islandMode !== undefined && !(NODE_ISLANDS_MODES as readonly string[]).includes(islandMode)) {
fail(
`unknown --node-islands value "${islandMode}" (supported: ${NODE_ISLANDS_MODES.join(", ")})\n\n${USAGE}`,
);
}
const listValues = (raw: string[] | undefined): string[] =>
(raw ?? [])
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v) => v !== "");
const islandArgs = listValues(values["node-island"]);
const nativeArgs = listValues(values.native);
for (const name of nativeArgs) {
if (islandArgs.includes(name))
fail(`--native=${name} and --node-island=${name} contradict each other\n\n${USAGE}`);
}
if (values.node === "") fail(`--node needs the path of a node executable\n\n${USAGE}`);
const nodeIslands: NodeIslandOptions | undefined =
islandMode === undefined &&
islandArgs.length === 0 &&
nativeArgs.length === 0 &&
values.node === undefined &&
!values["strict-user-js"]
? undefined
: {
...(islandMode !== undefined ? { mode: islandMode as NodeIslandsMode } : {}),
...(islandArgs.length > 0 ? { islands: islandArgs } : {}),
...(nativeArgs.length > 0 ? { native: nativeArgs } : {}),
...(values.node !== undefined ? { node: resolve(values.node) } : {}),
...(values["strict-user-js"] ? { strictUserJs: true } : {}),
};
if (printNativeLinkInfo && command !== "build") {
fail(`--print=native-link-info is a build option\n\n${USAGE}`);
}
@@ -468,6 +515,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
...(tier !== undefined ? { tier } : {}),
...(explainDynamic !== undefined ? { explainDynamic: true } : {}),
...(wasmFiles.length > 0 ? { wasm: wasmFiles } : {}),
...(nodeIslands !== undefined ? { nodeIslands } : {}),
});
if (!dynamicTierWarning) delete coverage.performance;
if (printDiagnostics) {
@@ -483,6 +531,17 @@ async function main(args: string[], host: CliHost): Promise<number> {
process.stdout.write(
renderCoverage(coverage, { color, sourceTexts, root: process.cwd() }) + "\n",
);
if (explainIsland !== undefined) {
process.stdout.write(
"\n" +
renderExplainIsland(coverage.nodeIslands, explainIsland, {
color,
sourceTexts,
root: process.cwd(),
}) +
"\n",
);
}
if (explainDynamic !== undefined && coverage.tiers !== undefined) {
process.stdout.write(
"\n" +
@@ -577,14 +636,21 @@ async function main(args: string[], host: CliHost): Promise<number> {
// warning names likely-hot functions that use untyped values. `run`
// keeps stderr for the program's own output.
const color = process.stderr.isTTY ?? false;
const divergences = warnings.filter((d) => !isPerformanceCode(d.code));
const config = warnings.filter((d) => d.code === "SC8004");
const divergences = warnings.filter((d) => !isPerformanceCode(d.code) && d.code !== "SC8004");
const performance = warnings.filter((d) => isPerformanceCode(d.code));
if (divergences.length > 0)
if (config.length > 0)
process.stderr.write(
renderConfigWarnings(config, result.sourceTexts ?? new Map(), { color }) + "\n",
);
if (divergences.length > 0) {
if (config.length > 0) process.stderr.write("\n");
process.stderr.write(
renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n",
);
}
if (performance.length > 0) {
if (divergences.length > 0) process.stderr.write("\n");
if (divergences.length > 0 || config.length > 0) process.stderr.write("\n");
process.stderr.write(renderPerformanceWarnings(performance) + "\n");
}
}
@@ -615,6 +681,19 @@ async function main(args: string[], host: CliHost): Promise<number> {
return 0;
}
/** Whether any Node island flag is present (they belong to build, run and
* coverage). */
function hasNodeIslandFlags(values: ReturnType<typeof parseCli>["values"]): boolean {
return (
values["node-islands"] !== undefined ||
(values["node-island"] ?? []).length > 0 ||
(values.native ?? []).length > 0 ||
values.node !== undefined ||
values["explain-island"] !== undefined ||
values["strict-user-js"]
);
}
/** Both installed and seed commands use this argument and diagnostic contract. */
export async function runCli(args: string[], host: CliHost): Promise<number> {
try {
+33 -3
View File
@@ -31,11 +31,13 @@ Options:
(native-link-info implies --emit=obj and never links;
diagnostics prints the versioned JSON diagnostics
envelope for build and coverage)
--fail-on <blockers|divergences|dynamic>
--fail-on <blockers|divergences|dynamic|node-islands|eager-node-islands|island-gaps>
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
divergences, or also when it has likely-hot functions
that use untyped values (SC6101)
divergences, likely-hot functions that use untyped
values (SC6101), any Node island (eager-node-islands is
the same until deferred evaluation lands), or a gap
island (a package scriptc should compile but cannot yet)
--backend <b> code generator (llvm)
--optimization <release|dev|speed>
native optimization posture (default: release/-O2). dev
@@ -110,6 +112,28 @@ Options:
--no-dynamic-tier-warning
build and coverage: leave out SC6101, the warning that
names likely-hot functions that use untyped values
--node-islands <auto|explicit|off>
Node island policy (default: auto): npm packages outside
the native subset run in Node automatically; explicit
allows only marked islands; off allows none. REPORT-ONLY
in this version: coverage reports the islands a build
would choose; builds compile as before
--node-island <pkg-or-path>
mark an npm package or one of your modules (a path or
glob) to run in a Node island (repeatable, comma lists;
report-only)
--native <pkg> require an npm package to compile natively; outside the
native subset it is SC8003 (repeatable; report-only)
--node <path> the node executable Node islands use (report-only)
--explain-island <pkg>
coverage only: why a package would run in a Node island,
with the first reason per module, the native import
sites and the packages inside its closure
--strict-user-js
coverage only: report constructor functions, prototype
and shape mutation, sloppy-mode semantics and builtin
patching in your own .js/.cjs/.mjs modules as
SC8101–SC8105 blockers
--wasm <file.wasm>
coverage only: report a WebAssembly module the program
loads whose path its sources do not spell (repeatable;
@@ -144,6 +168,12 @@ export const CLI_OPTIONS = {
"explain-dynamic": { type: "string" },
"dynamic-tier-warning": { type: "boolean", default: true },
wasm: { type: "string", multiple: true },
"node-islands": { type: "string" },
"node-island": { type: "string", multiple: true },
native: { type: "string", multiple: true },
node: { type: "string" },
"explain-island": { type: "string" },
"strict-user-js": { type: "boolean", default: false },
lib: { type: "boolean", default: false },
profile: { type: "string" },
help: { type: "boolean", short: "h", default: false },
File diff suppressed because it is too large Load Diff
+4
View File
@@ -6,6 +6,7 @@ import type { NativeProfileMatch } from "./backend/native-profile.js";
import type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
import type { CoverageInput } from "./coverage/report.js";
import type { TierMode } from "./frontend/tier-analysis.js";
import type { NodeIslandOptions } from "./node-islands/policy.js";
export type CompileOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe";
@@ -193,6 +194,9 @@ export interface AnalyzeOptions {
/** `--wasm <file>`: WebAssembly module files the program loads that its
* sources do not name (absolute paths); reported with the discovered ones. */
wasm?: readonly string[];
/** The Node island policy's command-line half (report-only: coverage
* reports which packages and modules would run in a Node island). */
nodeIslands?: NodeIslandOptions;
}
export interface AnalyzeResult {
+281 -3
View File
@@ -17,6 +17,7 @@ import {
type DiagnosticsEnvelope,
type LocatedDiagnostic,
type SourcePosition,
type NodeIslandsEnvelope,
type TierUnitPosition,
type TiersEnvelope,
} from "../diagnostics/envelope.js";
@@ -25,6 +26,14 @@ import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { ProvenanceSources } from "../frontend/provenance-registry.js";
import type { DemotionUnit, TierReport } from "../frontend/tier-analysis.js";
import type { WasmModuleReport } from "../wasm/discover.js";
import {
describe as describeReason,
hasNodeIslandDecisions,
type NodeIslandEntry,
type NodeIslandReport,
} from "../node-islands/summary.js";
import { classDescription } from "../node-islands/rules.js";
import type { SrcLoc } from "../ir/ir.js";
export interface CoverageInput {
file: string;
@@ -65,6 +74,10 @@ export interface CoverageInput {
/** The WebAssembly modules the build can see (wasm/discover.ts): their
* size, validity and the proposals they use. */
wasmModules?: WasmModuleReport[];
/** Node islands, report-only: the packages and user modules that would
* run in a Node island, and why. Present when there is something to
* report. */
nodeIslands?: NodeIslandReport;
/** True when tsc preflight failed — no coverage can be computed. */
preflightFailed: boolean;
}
@@ -148,6 +161,19 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push(` ${c(RED, "not analyzable")}: ${config[0]!.message}`);
return out.join("\n");
}
const projectConfig = input.diagnostics.filter((d) => d.code === "SC8004");
if (projectConfig.length > 0) {
out.push(
` ${c(RED, "not analyzable")}: the project's scriptc configuration has ${projectConfig.length} unknown setting${projectConfig.length === 1 ? "" : "s"}`,
);
for (const d of projectConfig) {
const p = locator.position(d.loc.file, d.loc.start);
out.push(` ${c(DIM, "SC8004")} ${d.message}`);
out.push(` ${c(DIM, `at ${where({ file: d.loc.file, ...p })}`)}`);
if (d.hint !== undefined) out.push(` ${c(DIM, `hint: ${d.hint}`)}`);
}
return out.join("\n");
}
const tsc = input.diagnostics.filter((d) => d.code === "SC0001");
if (tsc.length > 0) {
const world = tsc.filter((d) => d.typeWorld === true).length;
@@ -225,6 +251,22 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push("");
}
// Node islands (report-only): what would run in Node, gap islands
// first (DESIGN.md §1.3: a gap island is compiler work an island hides).
if (input.nodeIslands !== undefined && hasNodeIslandDecisions(input.nodeIslands)) {
renderNodeIslands(
input.nodeIslands,
out,
c,
(loc) => {
const p = locator.position(loc.file, loc.start);
return where({ file: loc.file, ...p });
},
opts.root,
);
out.push("");
}
// The WebAssembly modules the build can see (wasm/discover.ts). A build
// translates the valid ones to native code (wasm/translate.ts) when the
// program uses the WebAssembly API; the others fail to load at run time.
@@ -477,9 +519,24 @@ function displayPath(file: string, root: string | undefined): string {
}
/** What `coverage --fail-on` checks: reached blockers (they fail a build)
* and, optionally, divergences or the performance warning. Unreached
* blockers never fail. */
export type CoverageFailOn = "blockers" | "divergences" | "dynamic";
* and, optionally, divergences, the performance warning or Node islands.
* Unreached blockers never fail. */
export type CoverageFailOn =
| "blockers"
| "divergences"
| "dynamic"
| "node-islands"
| "eager-node-islands"
| "island-gaps";
export const COVERAGE_FAIL_ON: readonly CoverageFailOn[] = [
"blockers",
"divergences",
"dynamic",
"node-islands",
"eager-node-islands",
"island-gaps",
];
/** Whether the analysis passes the selected gate. Preflight failures
* always fail; `failOn` undefined means only preflight fails (the report's
@@ -492,6 +549,16 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b
if (input.diagnostics.length > 0) return false;
if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false;
// The Node island gates (report-only islands count: they are what a
// build would choose). Every island evaluates eagerly until deferred
// evaluation lands, so eager-node-islands equals node-islands for now.
// Would-be policy errors (SC8002, SC8003) fail every island gate.
const islands = input.nodeIslands;
if (islands === undefined) return true;
const anyIsland = failOn === "node-islands" || failOn === "eager-node-islands";
if ((anyIsland || failOn === "island-gaps") && islands.violations.length > 0) return false;
if (anyIsland && (islands.islands.length > 0 || islands.userModules.length > 0)) return false;
if (failOn === "island-gaps" && islands.islands.some((i) => i.class === "gap")) return false;
return true;
}
@@ -555,6 +622,9 @@ export function coverageEnvelope(
}),
...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}),
...(input.wasmModules !== undefined ? { wasmModules: input.wasmModules } : {}),
...(input.nodeIslands !== undefined
? { nodeIslands: nodeIslandsEnvelope(input.nodeIslands, locator) }
: {}),
groups,
diagnostics: located,
};
@@ -692,3 +762,211 @@ export function renderExplainDynamic(
}
return out.join("\n");
}
/* ── Node islands (report-only) ───────────────────────────────────────── */
const ISLAND_REASONS_SHOWN = 1;
function islandName(i: { package: string; version: string | null }): string {
return i.version === null ? i.package : `${i.package}@${i.version}`;
}
function renderNodeIslands(
report: NodeIslandReport,
out: string[],
c: (code: string, s: string) => string,
at: (loc: SrcLoc) => string,
root: string | undefined,
): void {
const n = report.islands.length + report.userModules.length;
out.push(
` ${c(BOLD, "Node islands")} ${c(DIM, `(would-be, report-only: the build is unchanged; --node-islands=${report.policy})`)}`,
);
if (n === 0) out.push(` ${c(DIM, "none")}`);
const widest = Math.max(0, ...report.islands.map((i) => i.class.length));
for (const island of report.islands) {
const tone = island.class === "gap" ? RED : YELLOW;
const decided =
island.decision === "auto"
? ""
: c(
DIM,
` (marked by ${island.decision === "cli" ? "--node-island" : "scriptc.packages"})`,
);
out.push(` ${c(tone, island.class.padEnd(widest))} ${islandName(island)}${decided}`);
for (const r of island.reasons.slice(0, ISLAND_REASONS_SHOWN)) {
const where = r.loc === null ? "" : `${at(r.loc)}: `;
out.push(` ${c(DIM, capMessage(`${where}${describeReason(r)}`))}`);
}
const more = island.reasons.length - ISLAND_REASONS_SHOWN;
const details = [
more > 0
? `+${more} more reason${more === 1 ? "" : "s"} in ${counted(island.modules, "module")}`
: "",
island.carries.length > 0 ? `carries ${island.carries.join(", ")}` : "",
].filter((t) => t !== "");
if (details.length > 0) out.push(` ${c(DIM, details.join("; "))}`);
}
for (const m of report.userModules) {
out.push(
` ${c(YELLOW, "marked".padEnd(widest))} ${displayPath(m.file, root)} ${c(DIM, `(user module, marked by ${m.source === "cli" ? "--node-island" : "nodeIslands.modules"})`)}`,
);
}
for (const v of report.violations) {
out.push(` ${c(RED, v.code)} ${capMessage(v.message)}`);
}
const shared = report.packages.filter((p) => p.status === "shared");
if (shared.length > 0) {
out.push(
` ${c(DIM, `reached from native code and from an island: ${shared.map((p) => p.package).join(", ")}`)}`,
);
}
const native = report.packages.filter((p) => p.status === "native");
if (native.length > 0) {
out.push(
` ${c(DIM, `${counted(native.length, "package")} stay${native.length === 1 ? "s" : ""} native: ${native.map((p) => p.package).join(", ")}`)}`,
);
}
if (report.unknownPackages.length > 0) {
out.push(
` ${c(YELLOW, "note")} not imported by the program: ${report.unknownPackages.join(", ")}`,
);
}
if (report.islands.length > 0) {
out.push(
` ${c(DIM, "hint: scriptc coverage --explain-island=<package> lists every reason")}`,
);
}
}
/** `scriptc coverage --explain-island=<package>`: why a package would run
* in a Node island — every module's first reason, the native import sites
* (the boundary), the packages inside its closure, and what later
* milestones decide. */
export function renderExplainIsland(
report: NodeIslandReport | undefined,
query: string,
opts: CoverageRenderOptions = {},
): string {
const c = (code: string, s: string) => (opts.color ? code + s + RESET : s);
const locator = new SourceLocator(opts.sourceTexts);
const at = (loc: SrcLoc) => {
const p = locator.position(loc.file, loc.start);
return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`;
};
const out: string[] = [];
const matches = (report?.islands ?? []).filter(
(i) => i.package === query || islandName(i) === query,
);
if (matches.length === 0) {
const pkg = (report?.packages ?? []).find((p) => p.package === query);
if (pkg === undefined) {
out.push(` '${query}' is not a Node island: no npm package of that name is in the program`);
} else if (pkg.status === "inside") {
out.push(
` '${query}' is not a Node island of its own: only ${pkg.islands.join(", ")} import${pkg.islands.length === 1 ? "s" : ""} it, so it would run inside that island`,
);
} else if (pkg.status === "shared") {
out.push(
` '${query}' compiles natively and is also reached from the Node island${pkg.islands.length === 1 ? "" : "s"} ${pkg.islands.join(", ")}`,
);
} else {
out.push(` '${query}' is not a Node island: it compiles natively`);
}
return out.join("\n");
}
for (const island of matches) {
out.push(
` ${c(BOLD, "Node island")} ${islandName(island)} ${c(DIM, "(would-be, report-only)")}`,
);
if (island.path !== null) out.push(` path ${displayPath(island.path, opts.root)}`);
out.push(` class ${island.class}: ${classDescription(island.class)}`);
out.push(
` decision ${island.decision === "auto" ? "automatic (npm code outside the native subset)" : island.decision === "cli" ? "marked with --node-island" : 'marked "node" in scriptc.packages'}`,
);
out.push(
` evaluation eager at its module position ${c(DIM, "(deferred evaluation is decided by a later milestone)")}`,
);
if (island.reasons.length > 0) {
out.push(` reasons ${c(DIM, "(first per module; decisive class first)")}`);
const shown = new Set<string>();
for (const r of island.reasons) {
const key = r.loc === null ? "" : r.loc.file;
if (shown.has(key)) continue;
shown.add(key);
const where = r.loc === null ? "package" : at(r.loc);
out.push(` ${r.class.padEnd(12)} ${where}`);
out.push(` ${c(DIM, capMessage(describeReason(r)))}`);
}
const counts = new Map<string, number>();
for (const r of island.reasons) counts.set(r.class, (counts.get(r.class) ?? 0) + 1);
const summary = [...counts].map(([cls, k]) => `${k} ${cls}`).join(", ");
out.push(` all reasons ${island.reasons.length} (${summary})`);
}
if (island.importedFrom.length > 0) {
out.push(` boundary ${counted(island.importedFrom.length, "native import site")}:`);
for (const loc of island.importedFrom.slice(0, SITES_SHOWN)) out.push(` ${at(loc)}`);
if (island.importedFrom.length > SITES_SHOWN)
out.push(` ${c(DIM, `+${island.importedFrom.length - SITES_SHOWN} more`)}`);
out.push(
` ${c(DIM, "copy and handle decisions per boundary site come with compiler-managed islands")}`,
);
} else {
out.push(
` boundary ${c(DIM, "no native import site (reached only from other islands)")}`,
);
}
if (island.carries.length > 0) out.push(` carries ${island.carries.join(", ")}`);
}
return out.join("\n");
}
function nodeIslandsEnvelope(
report: NodeIslandReport,
locator: SourceLocator,
): NodeIslandsEnvelope {
const pos = (loc: SrcLoc): SourcePosition => ({
file: loc.file,
...locator.position(loc.file, loc.start),
});
const entry = (i: NodeIslandEntry) => {
const first = i.reasons[0];
return {
package: i.package,
version: i.version,
path: i.path,
class: i.class,
reason: i.reason,
diagnostic: i.diagnostic,
decision: i.decision,
evaluation: i.evaluation,
...(first !== undefined && first.loc !== null ? { at: pos(first.loc) } : {}),
reasons: i.reasons.map((r) => ({
class: r.class,
rule: r.rule,
reason: r.reason,
code: r.code,
message: r.message,
source: r.source,
...(r.loc !== null ? { at: pos(r.loc) } : {}),
})),
carries: i.carries,
importedFrom: i.importedFrom.map(pos),
};
};
return {
mode: report.mode,
policy: report.policy,
islands: report.islands.map(entry),
userModules: report.userModules.map((m) => ({ file: m.file, decision: m.source })),
npmPackages: report.packages.map((p) => ({
package: p.package,
version: p.version,
path: p.path,
status: p.status,
islands: p.islands,
})),
violations: report.violations.map((v) => ({ code: v.code, message: v.message, ...pos(v.loc) })),
unknownPackages: report.unknownPackages,
};
}
@@ -39,14 +39,14 @@ import { InternalCompilerError } from "../errors.js";
* byte-deterministic: entries sort by id, keys are emitted in one fixed
* order, and the output carries no timestamps or absolute paths. */
import { FENCE_CODES, UNSUPPORTED, webAssemblyDiag } from "../diagnostics/diagnostic.js";
import { NODE24_FETCH_COMPAT_PROFILE } from "../compat/fetch-profile.js";
import { node24FetchCompatProfile } from "../compat/fetch-profile.js";
import { SUPPORTED_BUILTIN_MODULES, SUPPORTED_NODE_MODULES } from "../frontend/builtin-modules.js";
import {
AMBIENT_SURFACE_FNS,
ambientSurfaceFns,
ARRAY_METHODS,
BUILTIN_MODULE_CONSTS,
BUILTIN_MODULE_FENCE_HINTS,
BUILTIN_MODULE_FNS,
builtinModuleFns,
COLLECTION_ITERATOR_METHODS,
COMPOUND_ASSIGN_OPS,
STDLIB_SURFACE,
@@ -88,18 +88,21 @@ export const MANIFEST_SCHEMA_VERSION = 2;
/** The manifest's own scope statement: what the projection covers and —
* just as load-bearing — what it deliberately leaves out, so absence is
* never read as a support claim in either direction. */
const COVERAGE_NOTES: string[] = [
"Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.",
"Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.",
"The ambient-nondeterminism and ambient-authority surfaces the library sidecar's determinism attestation scans ARE projected even where they lower through dedicated code paths — the Date compositions (stdlib.date.*), perf_hooks' performance.now, and the process global's ambient reads and authority calls (node-builtin.process.*) — so a determinism fence can name every surface the attestation demotes on.",
"stdlib and node-builtin member entries name surface whose LOWERED call forms are constrained (arity, argument shapes); declared call forms outside the lowered set are refused per site, with code SC2020 for standard-library and node-builtin surface.",
"Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.",
`The engine-free fetch projection targets Node ${NODE24_FETCH_COMPAT_PROFILE.target.node} with bundled Undici ${NODE24_FETCH_COMPAT_PROFILE.target.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`,
"The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.",
"Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
"Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.",
"No scheduling metadata is published; entry ids are the stable diff keys across releases.",
];
function coverageNotes(): string[] {
const fetchTarget = node24FetchCompatProfile().target;
return [
"Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.",
"Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.",
"The ambient-nondeterminism and ambient-authority surfaces the library sidecar's determinism attestation scans ARE projected even where they lower through dedicated code paths — the Date compositions (stdlib.date.*), perf_hooks' performance.now, and the process global's ambient reads and authority calls (node-builtin.process.*) — so a determinism fence can name every surface the attestation demotes on.",
"stdlib and node-builtin member entries name surface whose LOWERED call forms are constrained (arity, argument shapes); declared call forms outside the lowered set are refused per site, with code SC2020 for standard-library and node-builtin surface.",
"Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.",
`The engine-free fetch projection targets Node ${fetchTarget.node} with bundled Undici ${fetchTarget.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`,
"The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.",
"Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
"Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.",
"No scheduling metadata is published; entry ids are the stable diff keys across releases.",
];
}
/** Stable kebab id segment from prose: quotes dropped, the parenthetical
* tail dropped, non-alphanumeric runs collapsed to one dash. */
@@ -410,7 +413,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
: "recognized module (bare and node:-prefixed specifiers)",
});
}
for (const [mod, members] of Object.entries(BUILTIN_MODULE_FNS)) {
for (const [mod, members] of Object.entries(builtinModuleFns())) {
for (const member of Object.keys(members!)) {
add({
id: `${moduleId(mod)}.${member}`,
@@ -459,10 +462,9 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
}
// ── fetch/Web platform: one explicit, versioned compatibility profile ─
const fetchTarget =
`Node ${NODE24_FETCH_COMPAT_PROFILE.target.node} / ` +
`Undici ${NODE24_FETCH_COMPAT_PROFILE.target.undici}`;
for (const operation of NODE24_FETCH_COMPAT_PROFILE.operations) {
const fetchProfile = node24FetchCompatProfile();
const fetchTarget = `Node ${fetchProfile.target.node} / Undici ${fetchProfile.target.undici}`;
for (const operation of fetchProfile.operations) {
const evidence = operation.evidence.map((item) =>
item.generated !== undefined ? `generated:${item.generated}` : `fixture:${item.fixture!}`,
);
@@ -478,10 +480,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
`differential evidence: ${evidence.join(", ")}`,
});
}
for (const options of [
NODE24_FETCH_COMPAT_PROFILE.requestInit,
NODE24_FETCH_COMPAT_PROFILE.responseInit,
]) {
for (const options of [fetchProfile.requestInit, fetchProfile.responseInit]) {
for (const option of options) {
const evidence = option.evidence.map((item) =>
item.generated !== undefined ? `generated:${item.generated}` : `fixture:${item.fixture!}`,
@@ -497,7 +496,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
});
}
}
for (const row of NODE24_FETCH_COMPAT_PROFILE.inventory.entries) {
for (const row of fetchProfile.inventory.entries) {
if (row.status !== "unsupported") continue;
if (row.code === undefined || row.reason === undefined) {
throw new InternalCompilerError(`non-static fetch inventory row '${row.id}' is incomplete`);
@@ -524,7 +523,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
// Date/perf_hooks/process rows from the one ambient table — each entry
// is fenceable by construction (its row carries the reach-witnessing
// libCall spellings the fence detector uses).
for (const row of AMBIENT_SURFACE_FNS) {
for (const row of ambientSurfaceFns()) {
add({
id: row.id,
kind: row.kind,
@@ -578,7 +577,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
return {
schemaVersion: MANIFEST_SCHEMA_VERSION,
compilerVersion,
coverage: COVERAGE_NOTES,
coverage: coverageNotes(),
entries,
};
}
@@ -15,13 +15,15 @@ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { NpmStaticStatus } from "./report.js";
import type { WasmModuleReport } from "../wasm/discover.js";
import type { NodeIslandReport } from "../node-islands/summary.js";
import { nodeIslandOptionsKey } from "../node-islands/policy.js";
import {
FrontendInputTracker,
frontendInputsStillMatch,
type FrontendInputSnapshot,
} from "../frontend/input-tracker.js";
const SCHEMA = "scriptc.coverage-verdict.v2";
const SCHEMA = "scriptc.coverage-verdict.v3";
/** Key-value storage for verdict entries (best effort: failures miss). */
export interface VerdictStore {
@@ -41,6 +43,7 @@ export interface CachedCoverage {
performance?: ScrDiagnostic[];
npmStatic?: NpmStaticStatus[];
wasmModules?: WasmModuleReport[];
nodeIslands?: NodeIslandReport;
preflightFailed: boolean;
}
@@ -130,6 +133,10 @@ export function verdictKey(
...(opts.tier === "static-only" ? ["tier=static-only"] : []),
// The module files' contents are probes; their names are options.
...(opts.wasm !== undefined && opts.wasm.length > 0 ? [`wasm=${opts.wasm.join("\n")}`] : []),
// The Node island policy's command-line half shapes the report.
...(opts.nodeIslands !== undefined
? [`node-islands=${nodeIslandOptionsKey(opts.nodeIslands)}`]
: []),
].join("\0"),
);
}
@@ -153,6 +160,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null {
if (coverage.performance !== undefined) out.performance = coverage.performance;
if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic;
if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules;
if (coverage.nodeIslands !== undefined) out.nodeIslands = coverage.nodeIslands;
return out;
}
@@ -171,6 +179,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult
if (cached.performance !== undefined) coverage.performance = cached.performance;
if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic;
if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules;
if (cached.nodeIslands !== undefined) coverage.nodeIslands = cached.nodeIslands;
return { coverage, sourceTexts };
}
@@ -65,6 +65,13 @@
* notes, also warnings (coverage --fail-on=dynamic opts in):
* likely-hot functions that use untyped values (SC6101,
* diagnostics/dynamic-tier.ts)
* SC8xxx Node island policy: packages and modules outside the native
* subset that run (or, in report-only mode, would run) in a
* Node island — unknown project configuration (SC8004), a
* package required to be native (SC8003) or every island
* refused (SC8002) while outside the native subset, and the
* R2 object-model constructs in user JavaScript (SC8101–SC8105,
* behind --strict-user-js)
* SC9xxx internal compiler errors (still source-anchored)
*/
import type { SrcLoc } from "../ir/ir.js";
@@ -92,6 +99,11 @@ export interface ScrDiagnostic {
* the error may not reproduce under the project's tsc. Machine-readable
* output reports these under the "environment" category. */
typeWorld?: boolean;
/** Why the construct is outside the native subset, as a Node island
* reason id (node-islands/rules.ts), at the refusal sites whose code
* alone does not say it (a computed require(), a native addon, `with`).
* Report-only: never part of the rendered message. */
reason?: string;
/** A runtime fence in a module's top-level code, outside every function
* and class member body: it throws when the module initializes, which is
* program startup for a module the entry imports. */
@@ -208,6 +220,75 @@ export function unrelatedClassCastDivergenceDiag(
};
}
/* ── SC8xxx: Node island policy ───────────────────────────────────────── */
/** SC8004 — an unknown key or value in `scriptc.packages` or
* `scriptc.nodeIslands`. */
export function projectConfigDiag(
path: string,
detail: string,
hint: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8004",
message: `package.json '${path}' ${detail}`,
loc,
hint,
};
}
/** SC8002 — Node islands are off (or limited to marked islands) and an
* npm package is outside the native subset. */
export function nodeIslandsOffDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8002",
message: `npm package '${pkg}' is outside the native subset (${why}) and Node islands are off`,
loc,
hint: `allow Node islands (--node-islands=auto), mark it with --node-island=${pkg}, or replace the dependency`,
};
}
/** SC8002 — a user module is marked to run in a Node island while
* Node islands are off. */
export function nodeIslandsOffModuleDiag(
file: string,
source: "config" | "cli",
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8002",
message: `user module '${file}' is marked to run in Node (${source === "cli" ? "--node-island" : "scriptc.nodeIslands.modules"}) and Node islands are off`,
loc,
hint: "allow Node islands (--node-islands=auto or explicit), or remove the marking",
};
}
/** SC8003 — a package marked "native" is outside the native subset. */
export function nativePackageDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8003",
message: `npm package '${pkg}' is marked native but is outside the native subset: ${why}`,
loc,
hint: `remove the "native" marking to let it run in a Node island, or replace the construct the reason names`,
};
}
/** SC8101–SC8105 — an R2 object-model construct in the program's own
* JavaScript (R2.1 constructor functions … R2.5 builtin patching). */
export function userJsObjectModelDiag(
code: "SC8101" | "SC8102" | "SC8103" | "SC8104" | "SC8105",
construct: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code,
message: `${construct} needs a mutable JavaScript object model, which native code does not have`,
loc,
hint: "rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules",
};
}
/* ── SC5xxx: native FFI ───────────────────────────────────────────────── */
/** SC5001 — the outbound native-FFI manifest is malformed or unreadable. */
@@ -773,6 +854,7 @@ export function nativeAddonDiag(specifier: string, loc: SrcLoc): ScrDiagnostic {
loc,
milestone: "later",
hint: "expose the native operation as a plain C ABI function in an object or static archive, then bind a signature-only TypeScript declaration with --ffi; Node-API callbacks themselves require Node (https://scriptc.dev/ffi#replacing-a-node-api-addon)",
reason: "native-addon",
};
}
+57 -1
View File
@@ -148,10 +148,60 @@ export interface DiagnosticsEnvelope {
/** coverage only: the WebAssembly modules the build can see, with their
* size, validity, proposals and the sources that found them. */
wasmModules?: WasmModuleReport[];
/** coverage only, when there is something to report: the packages and
* user modules that would run in a Node island (report-only). */
nodeIslands?: NodeIslandsEnvelope;
groups: DiagnosticGroup[];
diagnostics: LocatedDiagnostic[];
}
/** One reason a package is outside the native subset. */
export interface NodeIslandReasonEnvelope {
class: string;
/** R1.1–R2.5, or null for gap and packaging reasons. */
rule: string | null;
reason: string;
code: string | null;
message: string;
source: string;
at?: SourcePosition;
}
/** `scriptc coverage --print=diagnostics`: the Node island decisions. In
* this version they are would-be decisions (mode "would-be"): the build
* does not change. */
export interface NodeIslandsEnvelope {
mode: "would-be";
policy: "auto" | "explicit" | "off";
/** Gap islands first, then by class and name. */
islands: {
package: string;
version: string | null;
path: string | null;
class: string;
reason: string;
diagnostic: string | null;
decision: string;
evaluation: string;
at?: SourcePosition;
reasons: NodeIslandReasonEnvelope[];
carries: string[];
importedFrom: SourcePosition[];
}[];
userModules: { file: string; decision: string }[];
/** Every npm package that is not an island: native, inside an island's
* closure, or shared between native code and an island. */
npmPackages: {
package: string;
version: string | null;
path: string | null;
status: string;
islands: string[];
}[];
violations: (SourcePosition & { code: string; message: string })[];
unknownPackages: string[];
}
/** The divergence band: compiles, but the program can behave differently
* from Node. Warnings, never build failures. */
export function isDivergenceCode(code: string): boolean {
@@ -166,6 +216,7 @@ export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
if (code === "SC0003") return "invalid-source";
if (code === "SC0002" || code === "SC0004") return "environment";
if (/^SC[35]\d{3}$/.test(code)) return "environment";
if (code === "SC8004" || code === "SC8005" || code === "SC8012") return "environment";
if (/^SC9\d{3}$/.test(code)) return "internal";
return "unsupported";
}
@@ -186,6 +237,8 @@ export function hintOf(diag: ScrDiagnostic): string {
return "restate the value's type inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')";
if (/^SC2\d{3}$/.test(code))
return "restate the type inside the compilable set (https://scriptc.dev/limitations describes the boundary)";
if (/^SC8\d{3}$/.test(code))
return "see the Node island policy: npm code outside the native subset runs in Node, user code must be marked (scriptc coverage --explain-island=<package>)";
if (/^SC9\d{3}$/.test(code))
return "this is a compiler bug — please report it with the program that triggers it";
return "see https://scriptc.dev/limitations";
@@ -281,7 +334,10 @@ export function locateDiagnostic(
return {
code: diag.code,
category: categoryOf(diag),
severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error",
severity:
isDivergenceCode(diag.code) || isPerformanceCode(diag.code) || diag.code === "SC8004"
? "warning"
: "error",
scope,
file: diag.loc.file,
line: start.line,
+23 -4
View File
@@ -102,14 +102,12 @@ export function renderDiagnostic(
frame.push(
c(DIM, ` ${" ".repeat(gutterWidth)} | `) +
" ".repeat(caretCol) +
c(/^SC6\d{3}$/.test(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)),
c(isWarningCode(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)),
);
emitLine(lineNum + 1);
}
// The divergence band (SC6xxx) is the one warning severity: the program
// compiles, and the site can behave differently from Node.
const warning = /^SC6\d{3}$/.test(diag.code);
const warning = isWarningCode(diag.code);
out.push(
`${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`,
);
@@ -154,6 +152,27 @@ export function renderDiagnostics(
/** Divergence warnings after a successful build: the rendered sites plus a
* one-line count, on stderr. */
/** The warning severities: the divergence band (SC6xxx), where the program
* compiles and the site can behave differently from Node, and unknown
* settings in the project's scriptc configuration (SC8004), which builds do
* not use. */
export function isWarningCode(code: string): boolean {
return /^SC6\d{3}$/.test(code) || code === "SC8004";
}
/** A build's SC8004 warnings: the unknown scriptc settings it ignored. */
export function renderConfigWarnings(
diags: readonly ScrDiagnostic[],
sourceTextByFile: Map<string, string>,
opts: RenderOptions = {},
): string {
const n = diags.length;
return (
renderDiagnostics([...diags], sourceTextByFile, opts) +
`\n\n${n} warning${n === 1 ? "" : "s"}: the build does not use ${n === 1 ? "this setting" : "these settings"}; scriptc coverage reports Node islands once they are valid.`
);
}
export function renderWarnings(
diags: readonly ScrDiagnostic[],
sourceTextByFile: Map<string, string>,
+5 -1
View File
@@ -122,6 +122,10 @@ export function prepareExecutableModule(
mod: lowered.module!,
sourceTexts,
stats: lowered.stats,
warnings: [...(lowered.divergences ?? []), ...(lowered.performance ?? [])],
warnings: [
...(fe.warnings ?? []),
...(lowered.divergences ?? []),
...(lowered.performance ?? []),
],
};
}
+28 -2
View File
@@ -4,6 +4,10 @@ import type { AnalyzeOptions, AnalyzeResult } from "../compile-types.js";
import { loadFfiProfile, type FfiProfile } from "../ffi/ffi-manifest.js";
import { provenanceSources } from "./provenance-registry.js";
import type { FrontendFactory } from "./pipeline.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
import { buildNodeIslandReport } from "../node-islands/report.js";
import { hasNodeIslandFindings } from "../node-islands/summary.js";
/** Analysis without codegen: how much of the program compiles statically.
* Unlike compile(), lowering diagnostics are data here, not failure. */
@@ -34,7 +38,9 @@ export function analyzeWithFrontend(
}
ffi = loaded.profile;
}
const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes);
const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes, undefined, {
nodeIslands: true,
});
try {
const emptyStats = {
statementsTotal: 0,
@@ -96,6 +102,23 @@ export function analyzeWithFrontend(
...(opts.wasm !== undefined && opts.wasm.length > 0 ? { wasmFiles: opts.wasm } : {}),
});
const provenance = provenanceSources();
// Node islands, report-only: which packages and modules would run in
// Node, from the detector's scan and the policy. Code generation does
// not depend on it.
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
const islandReport =
fe.nodeIslandScan === undefined
? null
: buildNodeIslandReport({
entry: entryPath,
scan: fe.nodeIslandScan({ userJs: policy.strictUserJs }),
npm: fe.npmStatic,
diagnostics: [...lowered.diagnostics, ...(lowered.unreached?.diagnostics ?? [])],
fences: [...lowered.runtimeFences, ...(lowered.unreached?.runtimeFences ?? [])],
policy,
});
const nodeIslands =
islandReport !== null && hasNodeIslandFindings(islandReport) ? islandReport : null;
return {
coverage: {
file: entryPath,
@@ -103,7 +126,9 @@ export function analyzeWithFrontend(
// The import fences report as blockers alongside the statement-level
// ones (use sites of the fenced bindings emit matching diagnostics,
// which the report groups with these).
diagnostics: [...preflight, ...lowered.diagnostics],
// --strict-user-js: R2 constructs in user JavaScript are blockers
// (SC8101–SC8105), as scope B's builds will make them errors.
diagnostics: [...preflight, ...lowered.diagnostics, ...(nodeIslands?.userJs ?? [])],
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}),
...(lowered.performance !== undefined ? { performance: lowered.performance } : {}),
@@ -116,6 +141,7 @@ export function analyzeWithFrontend(
...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}),
...(lowered.tiers !== undefined ? { tiers: lowered.tiers } : {}),
...(lowered.wasmModules !== undefined ? { wasmModules: lowered.wasmModules } : {}),
...(nodeIslands !== null ? { nodeIslands } : {}),
preflightFailed: false,
},
sourceTexts: fe.sourceTexts(),
@@ -65,3 +65,56 @@ test("fork discovery batches only candidate bindings and preserves aliases and s
rmSync(directory, { recursive: true, force: true });
}
});
/** forkTargetPaths over the entry of a program made of `files`, and
* whether it asked the checker for any binding. */
function discover(files: Record<string, string>): { targets: string[]; queried: boolean } {
const directory = mkdtempSync(join(tmpdir(), "scriptc-fork-gate-"));
for (const [name, text] of Object.entries(files)) writeFileSync(join(directory, name), text);
const entry = join(directory, "main.ts");
const host = new Ts7Host((options) => new Ts7Api(options), { cwd: directory });
const program = host.createProgram([entry, fallbackDtsPath()], {
noLib: true,
types: [],
module: ModuleKind.ESNext,
moduleResolution: ModuleResolutionKind.Bundler,
});
const batch = vi.spyOn(program.getTypeChecker(), "prefetchSymbolNodesExact");
try {
const targets = forkTargetPaths(program, [program.getSourceFile(entry)!]).map((path) =>
path.slice(directory.length + 1),
);
return { targets, queried: batch.mock.calls.length > 0 };
} finally {
batch.mockRestore();
program.dispose();
host.close();
rmSync(directory, { recursive: true, force: true });
}
}
test("fork discovery asks the checker nothing when no file names node:child_process", () => {
expect(
discover({
"main.ts":
'function fork(url: URL) { return url; }\nfork(new URL("./child.ts", import.meta.url));\n',
}),
).toEqual({ targets: [], queried: false });
});
test("fork discovery reads a module specifier spelled with escapes", () => {
expect(
discover({
"main.ts":
'import { fork } from "node:child\\u005fprocess";\nfork(new URL("./child.ts", import.meta.url));\n',
}),
).toEqual({ targets: ["child.ts"], queried: true });
});
test("fork discovery reads a package imports entry that maps node:child_process", () => {
const result = discover({
"package.json": JSON.stringify({ type: "module", imports: { "#cp": "node:child_process" } }),
"main.ts": 'import { fork } from "#cp";\nfork(new URL("./child.ts", import.meta.url));\n',
});
expect(result.queried).toBe(true);
});
+76 -1
View File
@@ -1,7 +1,14 @@
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as ts from "./ts7/adapter.js";
import { fallbackDtsPath, isNodeTypesPath, tsgoPath } from "./dts-paths.js";
import {
ambientDtsPath,
fallbackDtsPath,
isNodeTypesPath,
overridesDtsPath,
tsgoPath,
} from "./dts-paths.js";
import { nearestPackageImports } from "./resolve.js";
import { canonicalBuiltinModule } from "./builtin-modules.js";
import { moduleSourceCandidates } from "./module-source-candidates.js";
@@ -292,9 +299,77 @@ export function forkCallModulePath(program: ts.Program, call: ts.CallExpression)
return staticForkModulePath(program, call.arguments.nodes[0]);
}
/** The ways a string or template literal can spell one character of a
* module name: the character, \xHH, \uHHHH, \u{H…}, a legacy octal escape,
* or the character after a backslash where that is not another escape. */
function spelledCharacter(ch: string): string {
const code = ch.charCodeAt(0);
const hex = code.toString(16);
const forms = [
ch,
"\\\\x" + hex.padStart(2, "0"),
"\\\\u" + hex.padStart(4, "0"),
"\\\\u\\{0*" + hex + "\\}",
"\\\\" + code.toString(8),
];
if (!"bfnrtvux0123456789".includes(ch)) forms.push("\\\\" + ch);
return "(?:" + forms.join("|") + ")";
}
/** "child_process" with escapes and line continuations in any of its
* characters (case-insensitive, a superset). */
const SPELLED_CHILD_PROCESS = new RegExp(
[..."child_process"].map(spelledCharacter).join("(?:\\\\(?:\\r\\n|[\\n\\r\\u2028\\u2029]))*"),
"i",
);
/** Whether a call in the program can reach node:child_process's fork. A
* fork binding is an import or alias whose chain names the module in a
* module specifier (a string), or a '#' specifier the nearest package.json
* "imports" field maps. Node's type declarations and scriptc's own declare
* the module, and alias it only as node:child_process, so they are not
* read; any other file that names the module in a string holds
* "child_process" in its text, or an escape that spells it. */
function namesChildProcess(program: ts.Program): boolean {
const known = program.analysis.namesChildProcess;
if (known !== null) return known;
const own = new Set([ambientDtsPath(), overridesDtsPath(), fallbackDtsPath()]);
// Implementation files are already fetched; any other file is read as
// the host serves it, without fetching its syntax tree.
const texts = new Map<string, string>();
for (const sourceFile of program.getImplementationSourceFiles())
texts.set(tsgoPath(sourceFile.fileName), sourceFile.text);
const scopes = new Set<unknown>();
let names = false;
for (const fileName of program.getSourceFileNames()) {
const path = tsgoPath(fileName);
if (isNodeTypesPath(fileName) || own.has(path)) continue;
const text = texts.get(path) ?? program.getSourceText(fileName) ?? "";
if (
text.includes("child_process") ||
(text.includes("\\") && SPELLED_CHILD_PROCESS.test(text))
) {
names = true;
break;
}
const imports = nearestPackageImports(fileName);
if (imports === undefined || scopes.has(imports)) continue;
scopes.add(imports);
if (JSON.stringify(imports).includes("child_process")) {
names = true;
break;
}
}
program.analysis.namesChildProcess = names;
return names;
}
/** Source-order static fork targets in the supplied files, deduplicated by
* resolved path. */
export function forkTargetPaths(program: ts.Program, files: readonly ts.SourceFile[]): string[] {
// Without a binding of node:child_process no call is a fork, and the
// checker queries below are skipped.
if (!namesChildProcess(program)) return [];
const targets: string[] = [];
const seen = new Set<string>();
for (const sourceFile of files) {
@@ -269,6 +269,140 @@ function readsMutableLocal(lowerer: Lowerer, e: IrExpr): boolean {
});
}
/** A number, string or boolean element type. Arrays of it hold a present
* undefined in the element's state, so `(T | undefined)[]` stores T
* elements (the type mapper) and a T[] seen as one stays the same array. */
export function primitiveElement(elem: IrType): boolean {
return elem.kind === "f64" || elem.kind === "string" || elem.kind === "bool";
}
/** `value` is the element type with undefined, stored into an array of a
* primitive element type: a present undefined or the element. */
export function optionalElementValue(lowerer: Lowerer, value: IrExpr, elem: IrType): boolean {
if (!primitiveElement(elem)) return false;
if (value.type.kind === "undefinedT") return true;
return (
value.type.kind === "union" &&
lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 &&
typeEquals(lowerer.stripUndefinedArm(value.type), elem)
);
}
/** A value of a primitive element type that holds a slot until the value
* stored there replaces it (insertedValues), or stands for a slot that is
* not read. */
export function elementPlaceholder(elem: IrType, loc: SrcLoc): IrExpr {
if (elem.kind === "f64") return { kind: "numLit", value: 0, type: F64, loc };
if (elem.kind === "bool") return { kind: "boolLit", value: false, type: BOOL, loc };
return { kind: "strLit", value: "", type: STRING, loc };
}
/** Values inserted into an array (push, unshift, splice, a literal), in
* source order. A value that can be undefined (optionalElementValue) is
* kept in a temporary: the insertion takes a placeholder for it, and
* `fill` then stores the value at the placeholder's index. */
export function insertedValues(
lowerer: Lowerer,
values: readonly IrExpr[],
elem: IrType,
coerce: (value: IrExpr, index: number) => IrExpr,
loc: SrcLoc,
): {
stmts: IrStmt[];
args: IrExpr[];
optional: boolean;
fill: (arr: IrExpr, first: IrExpr) => IrStmt[];
} {
if (!values.some((value) => optionalElementValue(lowerer, value, elem)))
return { stmts: [], args: values.map(coerce), optional: false, fill: () => [] };
const stmts: IrStmt[] = [];
const args: IrExpr[] = [];
const held: { offset: number; value: IrExpr }[] = [];
values.forEach((value, offset) => {
// Every value is evaluated in order before the insertion.
if (value.type.kind === "undefinedT") {
if (value.kind !== "unitLit") stmts.push({ kind: "exprStmt", expr: value, loc });
args.push(elementPlaceholder(elem, loc));
held.push({ offset, value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc } });
return;
}
const temp = lowerer.declareHiddenLocal("%inserted", value.type);
stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc });
const stable = varRef(temp.id, temp.type, loc);
if (optionalElementValue(lowerer, value, elem)) {
args.push(elementPlaceholder(elem, loc));
held.push({ offset, value: stable });
} else {
args.push(coerce(stable, offset));
}
});
const fill = (arr: IrExpr, first: IrExpr): IrStmt[] =>
held.map(({ offset, value }) =>
arrayValueStore(
lowerer,
arr,
offset === 0
? first
: {
kind: "bin",
op: "+",
left: first,
right: { kind: "numLit", value: offset, type: F64, loc },
type: F64,
loc,
},
value,
elem,
loc,
),
);
return { stmts, args, optional: true, fill };
}
/** Statements appending `value` to `arr` inside a lifted helper. A value
* that can be undefined in a primitive array (optionalElementValue) is held
* in the helper's local `slot` (which the caller declares with the value's
* type) and stored as a present undefined or the element; any other value
* converts to the element type. */
export function appendElement(
lowerer: Lowerer,
arr: IrExpr,
value: IrExpr,
elem: IrType,
slot: string,
loc: SrcLoc,
): IrStmt[] {
if (optionalElementValue(lowerer, value, elem) && value.type.kind !== "undefinedT") {
const held = varRef(slot, value.type, loc);
const length: IrExpr = {
kind: "arrIntrinsic",
method: "length",
receiver: arr,
args: [],
type: F64,
loc,
};
return [
{ kind: "varDecl", localId: slot, init: value, loc },
arrayValueStore(lowerer, arr, length, held, elem, loc),
];
}
return [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: arr,
args: [lowerer.coerceToExpected(value, elem)],
type: F64,
loc,
},
loc,
},
];
}
/** Store a stabilized value without changing the array's element ABI. */
export function arrayValueStore(
lowerer: Lowerer,
@@ -279,6 +413,12 @@ export function arrayValueStore(
loc: SrcLoc,
): IrStmt {
if (typeEquals(value.type, elem)) return { kind: "arraySet", arr, index, value, loc };
// An undefined value of a `(T | undefined)[]` (primitiveElement).
if (value.type.kind === "undefinedT" && primitiveElement(elem)) {
const write: IrStmt = { kind: "arraySetUndefined", arr, index, loc };
if (value.kind === "unitLit") return write;
return { kind: "block", body: [{ kind: "exprStmt", expr: value, loc }, write], loc };
}
if (value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0) {
// The tag test and the VALUE arm extraction both consume the value. Keep
// an arbitrary expression (especially an array read or call) single
@@ -237,7 +237,8 @@ export function lowerCreateRequireCall(
const surface = "createRequire's require with a computed specifier or this argument shape";
const hint =
'the compiled module graph is fixed at build time — the one lowered form is require("<static string literal>"); native addons are not loaded';
if (!isJsSourceFile(ts.getSourceFileOfNode(call))) lowerer.noLowering(surface, call, hint);
if (!isJsSourceFile(ts.getSourceFileOfNode(call)))
lowerer.noLowering(surface, call, hint, undefined, "dynamic-require");
return deferredCreateRequireThrow(lowerer, call, surface, hint, loc);
}
const spec = cr.spec;
@@ -3,7 +3,8 @@ import { checkedPromiseAll, thenReader } from "../checked-promise-all.js";
import { InternalCompilerError } from "../../../errors.js";
import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import { varRef } from "../../../ir/build.js";
import { countedFor, varRef } from "../../../ir/build.js";
import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js";
import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js";
import { isJsSourceFile, locOf } from "../../program.js";
import {
@@ -14,6 +15,7 @@ import {
type IrFunction,
type IrStmt,
type IrType,
type SrcLoc,
UNDEFINED_T,
VOID,
arrayOf,
@@ -435,6 +437,28 @@ export function lowerPromiseStaticCall(
inner.kind === "void"
? { kind: "promise", inner: VOID }
: lowerer.mapTypeOf(lowerer.typeOf(call));
// Promise<T | undefined>[] combines into a `(T | undefined)[]`, which
// stores T elements (primitiveElement): collect the values, then copy
// them in with their present undefined values.
if (
resultT?.kind === "promise" &&
resultT.inner.kind === "array" &&
primitiveElement(resultT.inner.elem) &&
optionalElementValue(
lowerer,
{ kind: "unitLit", unit: "undefined", type: inner, loc },
resultT.inner.elem,
)
) {
const joined = optionalPromiseAll(
lowerer,
entries,
inner,
resultT as IrType & { kind: "promise" },
loc,
);
if (joined) return joined;
}
if (
resultT?.kind !== "promise" ||
(inner.kind !== "void" &&
@@ -954,3 +978,139 @@ export function lowerAwaitedUniformPromiseAll(
loc,
};
}
/** `Promise.all(ps)` over `Promise<T | undefined>[]` for a number, string or
* boolean T, whose result is a `(T | undefined)[]` (stored as T elements).
* The combinator joins the entries (promise.allTuple), and its builder
* reads each fulfilled entry into the result with its present undefined,
* inside the combinator's settlement, so the result settles on Node's
* turn. Entries of another type keep the general combinator. */
function optionalPromiseAll(
lowerer: Lowerer,
entries: IrExpr,
inner: IrType,
resultT: IrType & { kind: "promise" },
loc: SrcLoc,
): IrExpr | null {
const entriesT = entries.type;
if (entriesT.kind !== "array" || entriesT.elem.kind !== "promise") return null;
if (!typeEquals(entriesT.elem.inner, inner)) return null;
const outT = resultT.inner as IrType & { kind: "array" };
const voidPromise: IrType & { kind: "promise" } = { kind: "promise", inner: VOID };
const joinedT = arrayOf(voidPromise);
const key = `promise.allOptional:${typeKey(entriesT)}:${typeKey(outT)}`;
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, "%promise.allOptional", key);
lowerer.arrHofHelpers.set(key, helper);
const builder = keyedHelperName(lowerer, "%promise.allOptionalValues", key);
const length = (arr: IrExpr): IrExpr => ({
kind: "arrIntrinsic",
method: "length",
receiver: arr,
args: [],
type: F64,
loc,
});
const joined = varRef("joined.0", joinedT, loc);
const out = varRef("out.0", outT, loc);
lowerer.liftedFns.push({
name: builder,
params: [{ localId: "joined.0", name: "joined", type: joinedT }],
returnType: outT,
locals: [
{ id: "joined.0", name: "joined", type: joinedT, mutable: false },
{ id: "out.0", name: "out", type: outT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "v.0", name: "v", type: inner, mutable: false },
],
body: [
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: outT, loc },
loc,
},
countedFor(loc, length(joined), (index) => [
{
kind: "varDecl",
localId: "v.0",
init: {
kind: "libCall",
fn: "promise.fulfilledValue",
args: [{ kind: "arrayGet", arr: joined, index, type: voidPromise, loc }],
type: inner,
loc,
},
loc,
},
arrayValueStore(lowerer, out, index, varRef("v.0", inner, loc), outT.elem, loc),
]),
{ kind: "return", value: out, loc },
],
loc,
});
const ps = varRef("ps.0", entriesT, loc);
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "ps.0", name: "ps", type: entriesT }],
returnType: resultT,
locals: [
{ id: "ps.0", name: "ps", type: entriesT, mutable: false },
{ id: "joined.0", name: "joined", type: joinedT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
],
body: [
{
kind: "varDecl",
localId: "joined.0",
init: { kind: "arrayLit", elems: [], type: joinedT, loc },
loc,
},
countedFor(loc, length(ps), (index) => [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: joined,
args: [
{
kind: "promiseVoidWiden",
value: { kind: "arrayGet", arr: ps, index, type: entriesT.elem, loc },
type: voidPromise,
loc,
},
],
type: F64,
loc,
},
loc,
},
]),
{
kind: "return",
value: {
kind: "libCall",
fn: "promise.allTuple",
args: [
joined,
{
kind: "closure",
fnName: builder,
captures: [],
type: funcOf([joinedT], outT),
loc,
},
],
type: resultT,
loc,
},
loc,
},
],
loc,
});
}
return { kind: "call", callee: helper, args: [entries], type: resultT, loc };
}
@@ -28,6 +28,7 @@ import { checkedIterableSpread } from "./checked-iterable-spread.js";
import { type ParamShape } from "./call-signatures.js";
import { arrayValueRead, arrayValueType } from "./array-values.js";
import { buildArrayConversion } from "./coercions/builders.js";
import { lowerArraySpreadItems, lowerArrayValueItems } from "./containers/array-construction.js";
import { restTupleElementTypes, typeKey } from "../type-mapper.js";
function fixedTupleSpreadInfo(
@@ -430,6 +431,51 @@ export function completeArgs(
}
}
if (restType.kind !== "array") lowerer.unsupported("SC1090", blame, "this call form");
// A `(T | undefined)[]` rest (stored as T elements) packs undefined
// arguments as present undefined elements.
const restNodes = sources.slice(restAt);
// Plain arguments and spreads, also together with spread tuple fields
// (pre-lowered): each item, in argument order.
if (
restType.optionalElements === true &&
(!restNodes.some(isIr) || restNodes.some((a) => !isIr(a) && ts.isSpreadElement(a)))
) {
const items: (ts.Expression | IrExpr)[] = [];
for (const a of restNodes) {
if (isIr(a)) items.push(a.ir);
else items.push(a);
}
out.push(lowerArraySpreadItems(lowerer, items, restType.elem, restType, loc));
return out;
}
// Tuple-spread values (pre-lowered) and plain arguments pack the same
// way. Each value evaluates once, in argument order.
if (
restType.optionalElements === true &&
!restNodes.some((a) => !isIr(a) && ts.isSpreadElement(a))
) {
const stmts: IrStmt[] = [];
const values = restNodes.map((a): IrExpr => {
// A tuple field is read where its spread appears: a later
// argument may write the tuple.
let value: IrExpr;
if (isIr(a)) {
value = a.ir;
if (value.kind === "seqExpr") {
stmts.push(...value.stmts);
value = value.result;
}
} else value = lowerer.lowerExpr(a as ts.Expression);
const saved = lowerer.declareHiddenLocal("%callArg", value.type);
stmts.push({ kind: "varDecl", localId: saved.id, init: value, loc: value.loc });
return { kind: "varRef", localId: saved.id, type: value.type, loc: value.loc };
});
const items = lowerArrayValueItems(lowerer, values, restType.elem, restType, loc);
out.push(
stmts.length === 0 ? items : { kind: "seqExpr", stmts, result: items, type: restType, loc },
);
return out;
}
// The rest pack is a fresh array per call; surplus SPREADS copy
// their elements in (JS-exact — `f(a, ...xs, b, ...ys)` packs in
// order, sources untouched).
@@ -946,6 +946,8 @@ function appendArgumentsParameter(
"SC1090",
node,
"parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases)",
undefined,
"sloppy-arguments",
);
}
}
@@ -532,6 +532,8 @@ export function lowerUncompiledRequireCall(
"SC1090",
call,
'require() with a computed specifier (the compiled module graph is fixed at build time: require("<string literal>") lowers)',
undefined,
"dynamic-require",
);
}
if (canonicalBuiltinModule(spec) !== null) return null;
@@ -1,12 +1,13 @@
import { keyedHelperName } from "../stable-names.js";
import { constructedValue } from "../constructor-returns.js";
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
import { countedFor, dynUndefinedExpr, varRef } from "../../../ir/build.js";
import type { FieldLift } from "./structural-plans.js";
import { buildArrayConversion } from "./builders.js";
import { arrayValueRead, arrayValueStore, primitiveElement } from "../array-values.js";
import type { WidthLift } from "../width-lift.js";
import { InternalCompilerError } from "../../../errors.js";
import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js";
import { BOOL, DYN, F64, STRING, UNDEFINED_T } from "../../../ir/ir.js";
import { BOOL, DYN, F64, STRING, UNDEFINED_T, typeEquals } from "../../../ir/ir.js";
import { typeKey } from "../../type-mapper.js";
import { findStaticOn, findGenericStaticOn } from "../lower-classes.js";
import { lowerRecordOvfCaptureHelper } from "../containers/indexed-objects.js";
@@ -54,6 +55,9 @@ export function widthCoerce(lowerer: Lowerer, expr: IrExpr, expected: IrType): I
return lowerer.classStaticsProjection(expr.type.className, expected.shapeId, expr.loc);
}
if (expected.kind === "array" && expr.type.kind === "array") {
const optional = optionalArrayHelper(lowerer, expr.type, expected, expr.loc);
if (optional)
return { kind: "call", callee: optional, args: [expr], type: expected, loc: expr.loc };
const helper = lowerer.arrayWidthHelper(expr.type, expected, expr.loc);
if (helper)
return { kind: "call", callee: helper, args: [expr], type: expected, loc: expr.loc };
@@ -518,6 +522,69 @@ export function emptyArrayLiftHelper(
return name;
}
/** Copy an array of `T | undefined` unions (a collection's values, an
* iterator drain) into a `(T | undefined)[]` of a number, string or
* boolean T, which stores T elements: undefined values become present
* undefined elements. Null for other element types. */
function optionalArrayHelper(
lowerer: Lowerer,
fromT: IrType & { kind: "array" },
toT: IrType & { kind: "array" },
loc: SrcLoc,
): string | null {
const from = fromT.elem;
const to = toT.elem;
if (
!primitiveElement(to) ||
from.kind !== "union" ||
lowerer.armTag(from.unionId, UNDEFINED_T) < 0 ||
!typeEquals(lowerer.stripUndefinedArm(from), to)
)
return null;
const key = `arrOptional:${typeKey(from)}:${typeKey(to)}`;
const existing = lowerer.valueHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.width", key);
lowerer.valueHelpers.set(key, name);
const input = varRef("a.0", fromT, loc);
const out = varRef("out.0", toT, loc);
lowerer.liftedFns.push({
name,
params: [{ localId: "a.0", name: "a", type: fromT }],
returnType: toT,
locals: [
{ id: "a.0", name: "a", type: fromT, mutable: true },
{ id: "out.0", name: "out", type: toT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "v.0", name: "v", type: from, mutable: false },
],
body: [
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: toT, loc },
loc,
},
countedFor(
loc,
{ kind: "arrIntrinsic", method: "length", receiver: input, args: [], type: F64, loc },
(index) => [
{
kind: "varDecl",
localId: "v.0",
init: arrayValueRead(lowerer, input, index, from, loc),
loc,
},
arrayValueStore(lowerer, out, index, varRef("v.0", from, loc), to, loc),
],
),
{ kind: "return", value: out, loc },
],
loc,
});
return name;
}
/** Copy an array through its validated element conversion. Intern before
* building the body so recursive element conversions can reuse this helper. */
export function arrayWidthHelper(
@@ -542,14 +609,37 @@ export function arrayWidthHelper(
name,
arrT,
{ kind: "arrayLit", elems: [], type: outT, loc },
(element, _index, result) => ({
kind: "arrIntrinsic",
method: "push",
receiver: result,
args: [lowerer.applyWidthLift(elemLift, element, toElem, loc)],
type: F64,
loc,
}),
(element, index, result) => {
let value = lowerer.applyWidthLift(elemLift, element, toElem, loc);
// A number, string or boolean array can hold present undefined
// elements (`(T | undefined)[]` stores T): they, and holes, copy
// as the target's undefined.
const missing = primitiveElement(fromElem) ? lowerer.wrappedUndefined(toElem, loc) : null;
if (missing !== null)
value = {
kind: "ternary",
cond: {
kind: "bin",
op: "===",
left: { kind: "arrayState", arr: varRef("a.0", arrT, loc), index, type: F64, loc },
right: { kind: "numLit", value: 1, type: F64, loc },
type: BOOL,
loc,
},
then: value,
else_: missing,
type: toElem,
loc,
};
return {
kind: "arrIntrinsic",
method: "push",
receiver: result,
args: [value],
type: F64,
loc,
};
},
loc,
),
);
@@ -42,6 +42,7 @@ import {
arrayValueStore,
arrayValueType,
currentArrayIndexPresent,
primitiveElement,
} from "../array-values.js";
import { typeKey } from "../../type-mapper.js";
import {
@@ -827,15 +828,16 @@ function buildArrayHofFn(
);
returnType = outT;
body = [
readLen,
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: outT, loc },
loc,
},
readLen,
// map's result has the receiver's length, holes where the receiver
// has them. Storage for it is reserved first and the length is set
// has them. Storage for it is reserved first (with the result's
// construction, which directly precedes it) and the length is set
// after the loop, so a result without holes is filled by appends and
// gets no state bytes.
...(method === "map"
@@ -1689,6 +1691,8 @@ function flatMapUnionPlan(
}
const converts = (t: IrType): boolean => {
if (typeEquals(t, outElem)) return true;
// A `(T | undefined)[]` result stores T elements (primitiveElement).
if (t.kind === "undefinedT" && primitiveElement(outElem)) return true;
if (outElem.kind !== "union") return false;
return lowerer.armTag(outElem.unionId, t) >= 0;
};
@@ -1754,24 +1758,21 @@ function flatMapUnionHelper(
};
if (plan.arms[tag] === "empty") return [];
if (plan.arms[tag] === "value") {
// A unit arm's value is the unit itself, which has no narrowed
// representation.
const value: IrExpr = isUnitType(arm)
? { kind: "unitLit", unit: arm.kind === "nullT" ? "null" : "undefined", type: arm, loc }
: narrowed;
return [
arrayValueStore(
lowerer,
outRef(),
outLength(),
lowerer.coerceToExpected(
// A unit arm's value is the unit itself, which has no
// narrowed representation.
isUnitType(arm)
? {
kind: "unitLit",
unit: arm.kind === "nullT" ? "null" : "undefined",
type: arm,
loc,
}
: narrowed,
plan.outElem,
),
// An undefined value is a present undefined element of a
// primitive array (arrayValueStore).
arm.kind === "undefinedT" && primitiveElement(plan.outElem)
? value
: lowerer.coerceToExpected(value, plan.outElem),
plan.outElem,
loc,
),
@@ -2347,6 +2348,43 @@ function reduceHelper(
return name;
}
/** The comparator of a sort over a `(T | undefined)[]`, which stores T
* elements. The comparator only receives present elements (undefined
* elements sort last without a call), so the unannotated parameters of an
* inline comparator take the element type, and the runtime calls it
* without an adapter. */
function lowerOptionalElementComparator(
lowerer: Lowerer,
argNode: ts.Expression,
comparator: ts.Expression,
elem: IrType,
arrT: IrType & { kind: "array" },
): IrExpr {
if (
arrT.optionalElements !== true ||
!(ts.isArrowFunction(comparator) || ts.isFunctionExpression(comparator)) ||
isJsSourceFile(ts.getSourceFileOfNode(comparator))
)
return lowerer.lowerExpr(argNode);
const rebound: { symbol: ts.Symbol; previous: IrType | undefined }[] = [];
for (const param of comparator.parameters.nodes.slice(0, 2)) {
if (param.type || param.initializer || param.dotDotDotToken || !ts.isIdentifier(param.name))
continue;
const symbol = lowerer.checker.getSymbolAtLocation(param.name);
if (!symbol) continue;
rebound.push({ symbol, previous: lowerer.runtimeOptionalBindingTypes.get(symbol) });
lowerer.runtimeOptionalBindingTypes.set(symbol, elem);
}
try {
return lowerer.lowerExpr(argNode);
} finally {
for (const { symbol, previous } of rebound) {
if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(symbol);
else lowerer.runtimeOptionalBindingTypes.set(symbol, previous);
}
}
}
/** `a.sort(cmp)` / `a.toSorted(cmp)`, desugared to an interned synthetic
* function like the other array HOFs. sort mutates and returns the receiver;
* toSorted takes its shallow snapshot INSIDE the helper, after the receiver
@@ -2435,7 +2473,8 @@ export function lowerArraySortCall(
loc,
};
}
let fnArg = undefinedArg ?? lowerer.lowerExpr(argNode);
let fnArg =
undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT);
// Reusable JS comparators often accept checked values. Adapt the array's
// element ABI just as other array callbacks do before sorting.
if (
@@ -4,6 +4,7 @@ import { generatorDrain } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { liftedFreshSpread } from "../coercions/fresh-spread.js";
import { checkedIterableSpread } from "../checked-iterable-spread.js";
import { AstNode } from "../../ts7/ast-node.js";
import { InternalCompilerError } from "../../../errors.js";
import {
dynUndefinedExpr,
@@ -27,7 +28,9 @@ import {
type IrType,
STRING,
type SrcLoc,
UNDEFINED_T,
arrayOf,
isUnitType,
canConvertToDyn,
funcOf,
isRefCounted,
@@ -35,7 +38,13 @@ import {
typeEquals,
} from "../../../ir/ir.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { arrayValueRead, arrayValueStore, arrayValueType } from "../array-values.js";
import {
arrayValueRead,
arrayValueStore,
arrayValueType,
optionalElementValue,
primitiveElement,
} from "../array-values.js";
import { typeKey } from "../../type-mapper.js";
import { newFnCtx } from "../lowerer.js";
import { iteratorCanStep, iteratorValue } from "../iterator-consumption.js";
@@ -51,6 +60,34 @@ import {
} from "./callback-arguments.js";
import { classGeneratorIterable } from "../class-iterables.js";
/** A value whose type admits only null and undefined, stored into an array
* that keeps undefined elements: the checker admits it there only when its
* type is `undefined` (a tuple element typed `undefined` maps to such a
* union), so it is evaluated and stored as undefined. */
function undefinedForNullish(
lowerer: Lowerer,
value: IrExpr,
arrType: IrType & { kind: "array" },
): IrExpr {
if (arrType.optionalElements !== true || value.type.kind !== "union") return value;
const arms = lowerer.unions.get(value.type.unionId)?.arms ?? [];
if (arms.length === 0 || !arms.every(isUnitType)) return value;
const loc = value.loc;
const optional: IrType = {
kind: "union",
unionId: lowerer.unions.intern([arrType.elem, UNDEFINED_T]),
};
const missing = lowerer.wrappedUndefined(optional, loc);
if (!missing) return value;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: value, loc }],
result: missing,
type: optional,
loc,
};
}
/** Build the argument array for a mutating/copying operation while keeping
* the receiver's scalar payload ABI. A missing read is represented as the
* present UNDEFINED state in this temporary, so push/unshift and toSpliced
@@ -86,9 +123,11 @@ export function lowerArrayValueItems(
loc,
},
];
for (const value of values) {
for (const item of values) {
const value = undefinedForNullish(lowerer, item, arrType);
const stored =
lowerer.runtimeOptionalWidening(value.type, elem) !== null
lowerer.runtimeOptionalWidening(value.type, elem) !== null ||
optionalElementValue(lowerer, value, elem)
? value
: lowerer.coerceToExpected(value, elem);
body.push(arrayValueStore(lowerer, outRef, length(), stored, elem, loc));
@@ -96,16 +135,21 @@ export function lowerArrayValueItems(
return { kind: "seqExpr", stmts: body, result: outRef, type: arrType, loc };
}
/** The items of an array built from argument nodes (a spread copies its
* elements in) and values already lowered (a spread tuple's fields), in
* order. Each value is stored when its turn comes. */
export function lowerArraySpreadItems(
lowerer: Lowerer,
nodes: readonly ts.Expression[],
nodes: readonly (ts.Expression | IrExpr)[],
elem: IrType,
arrType: IrType & { kind: "array" },
loc: SrcLoc,
): IrExpr {
const out = lowerer.declareHiddenLocal("%arrayItems", arrType);
const outRef = varRef(out.id, arrType, loc);
const plainItems = nodes.filter((node) => !ts.isSpreadElement(node)).length;
const plainItems = nodes.filter(
(node) => !(node instanceof AstNode) || !ts.isSpreadElement(node),
).length;
const length = (): IrExpr => ({
kind: "arrIntrinsic",
method: "length",
@@ -129,7 +173,23 @@ export function lowerArraySpreadItems(
},
];
for (const node of nodes) {
if (ts.isSpreadElement(node)) {
if (!(node instanceof AstNode)) {
// A spread tuple's first field declares the tuple's storage, which
// the later fields read: its statements join this sequence, whose
// frame lasts until every item is stored.
let item = node;
if (item.kind === "seqExpr") {
body.push(...item.stmts);
item = item.result;
}
const value = undefinedForNullish(lowerer, item, arrType);
const stored =
lowerer.runtimeOptionalWidening(value.type, elem) !== null ||
optionalElementValue(lowerer, value, elem)
? value
: lowerer.coerceToExpected(value, elem);
body.push(arrayValueStore(lowerer, outRef, length(), stored, elem, loc));
} else if (ts.isSpreadElement(node)) {
let source = lowerer.lowerExpr(node.expression);
if (
source.type.kind === "set" &&
@@ -212,7 +272,8 @@ export function lowerArraySpreadItems(
} else {
const value = lowerer.lowerExpr(node);
const stored =
lowerer.runtimeOptionalWidening(value.type, elem) !== null
lowerer.runtimeOptionalWidening(value.type, elem) !== null ||
optionalElementValue(lowerer, value, elem)
? value
: lowerer.coerceInto(node, value, elem);
body.push(arrayValueStore(lowerer, outRef, length(), stored, elem, loc));
@@ -518,6 +579,20 @@ export function lowerArrayOfCall(
if (call.arguments.nodes.some(ts.isSpreadElement)) {
return lowerArraySpreadItems(lowerer, call.arguments.nodes, result.elem, result, locOf(call));
}
// A `(T | undefined)[]` (primitiveElement) stores undefined arguments as
// present undefined elements.
if (result.optionalElements === true) {
const nodes = call.arguments.nodes;
const values = nodes.map((arg) => lowerer.lowerExpr(arg));
if (values.some((value) => optionalElementValue(lowerer, value, result.elem)))
return lowerArrayValueItems(lowerer, values, result.elem, result, locOf(call));
return {
kind: "arrayLit",
elems: values.map((value, i) => lowerer.coerceInto(nodes[i]!, value, result.elem)),
type: result,
loc: locOf(call),
};
}
return {
kind: "arrayLit",
elems: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, result.elem)),
@@ -959,6 +1034,31 @@ export function lowerArrayFromCall(
const arrT = lowerer.mapTypeOf(lowerer.typeOf(call));
if (arrT?.kind !== "array") lowerer.badType(call, lowerer.typeOf(call));
const elem = arrT.elem;
// A `(T | undefined)[]` (primitiveElement): n present undefined
// elements.
const elemTs = lowerer.checker.isArrayType(lowerer.typeOf(call))
? lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0]
: undefined;
if (
primitiveElement(elem) &&
elemTs !== undefined &&
(elemTs.isUnionType() ? ts.constituentTypes(elemTs) : [elemTs]).some(
(part) => (part.flags & ts.TypeFlags.Undefined) !== 0,
)
) {
return {
kind: "arrIntrinsic",
method: "fillUndefined",
receiver: { kind: "arrayNewLen", length: n, type: arrT, loc },
// To the end: fill clamps an infinite end to the length.
args: [
numLit(0, loc),
{ kind: "bin", op: "/", left: numLit(1, loc), right: numLit(0, loc), type: F64, loc },
],
type: arrT,
loc,
};
}
const absent =
elem.kind === "union"
? lowerer.wrappedUndefined(elem, loc) !== null ||
@@ -1085,7 +1185,10 @@ export function lowerArrayFromCall(
// A generator (or an iterator adapted into one) drains in order.
if (src.type.kind === "generator" && args.length === 1) {
const arrT = lowerer.mapTypeOf(lowerer.typeOf(call));
const drained = arrT?.kind === "array" ? generatorDrain(lowerer, src, arrT.elem, loc) : null;
const drained =
arrT?.kind === "array"
? generatorDrain(lowerer, src, arrT.elem, loc, arrT.optionalElements === true)
: null;
if (drained) return drained;
}
lowerer.noLowering(
@@ -1159,6 +1262,11 @@ function buildArrayFromArrayFn(
type: fnRet,
loc,
};
// A result of another type than the element (`T | undefined` into a
// `(T | undefined)[]`, which stores T elements) is held in a local, so
// the store tests and converts one value.
const held = !typeEquals(fnRet, outElem);
const value: IrExpr = held ? varRef("v.0", fnRet, loc) : mapped;
return {
name,
params: [
@@ -1171,6 +1279,7 @@ function buildArrayFromArrayFn(
{ id: "f.0", name: "f", type: fnT, mutable: true },
{ id: "out.0", name: "out", type: outT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
...(held ? [{ id: "v.0", name: "v", type: fnRet, mutable: false }] : []),
],
body: [
{
@@ -1191,7 +1300,8 @@ function buildArrayFromArrayFn(
loc,
},
body: [
arrayValueStore(lowerer, out, i, mapped, outElem, loc),
...(held ? [{ kind: "varDecl", localId: "v.0", init: mapped, loc } as const] : []),
arrayValueStore(lowerer, out, i, value, outElem, loc),
{
kind: "assign",
localId: "i.0",
@@ -32,6 +32,10 @@ import {
arrayValueRead,
arrayValueStore,
arrayValueType,
elementPlaceholder,
insertedValues,
optionalElementValue,
primitiveElement,
} from "../array-values.js";
import { typeKey } from "../../type-mapper.js";
import {
@@ -125,6 +129,14 @@ function lowerOptionalArrayPush(
return { kind: "seqExpr", stmts, result: length(), type: F64, loc };
}
/** The checker type of `node` admits undefined. */
function checkerTypeAdmitsUndefined(lowerer: Lowerer, node: ts.Expression): boolean {
const type = lowerer.typeOf(node);
return (type.isUnionType() ? ts.constituentTypes(type) : [type]).some(
(part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0,
);
}
/** `a.splice(start, count, v)` where v may hold undefined at run time.
* Receiver, start, count, and v evaluate once, in order; a present v is
* inserted as a plain element literal (which a statement-position splice
@@ -658,7 +670,10 @@ export function lowerArrayMethodCall(
const hasSpread = itemNodes.some(ts.isSpreadElement);
const itemProbes = hasSpread ? [] : itemNodes.map((arg) => tryLowerExpression(lowerer, arg));
const statefulItems = itemProbes.some(
(probe) => probe !== null && lowerer.runtimeOptionalWidening(probe.type, elem) !== null,
(probe) =>
probe !== null &&
(lowerer.runtimeOptionalWidening(probe.type, elem) !== null ||
optionalElementValue(lowerer, probe, elem)),
);
const items: IrExpr = hasSpread
? lowerArraySpreadItems(lowerer, itemNodes, elem, receiverIr, loc)
@@ -875,6 +890,65 @@ export function lowerArrayMethodCall(
loc,
};
}
// A `(T | undefined)[]` (primitiveElement) takes undefined values as
// present undefined elements, stored after the insertion.
if (
(name === "push" || name === "unshift") &&
primitiveElement(elem) &&
call.arguments.nodes.some((a) => checkerTypeAdmitsUndefined(lowerer, a))
) {
const nodes = call.arguments.nodes;
const values = nodes.map((a) => lowerer.lowerExpr(a));
const inserted = insertedValues(
lowerer,
values,
elem,
(value, i) => lowerer.coerceInto(nodes[i]!, value, elem),
loc,
);
if (inserted.optional) {
const array = lowerer.declareHiddenLocal("%insertInto", receiver.type);
const arr = varRef(array.id, array.type, loc);
const length = lowerer.declareHiddenLocal("%insertLength", F64);
const count: IrExpr = numLit(values.length, loc);
const first: IrExpr =
name === "push"
? {
kind: "bin",
op: "-",
left: varRef(length.id, F64, loc),
right: count,
type: F64,
loc,
}
: numLit(0, loc);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: array.id, init: receiver, loc },
...inserted.stmts,
{
kind: "varDecl",
localId: length.id,
init: {
kind: "arrIntrinsic",
method: name,
receiver: arr,
args: inserted.args,
type: F64,
loc,
},
loc,
},
...inserted.fill(arr, first),
],
result: varRef(length.id, F64, loc),
type: F64,
loc,
};
}
return { kind: "arrIntrinsic", method: name, receiver, args: inserted.args, type: F64, loc };
}
// Inserted values flow into the element slot like an assignment would:
// union-element arrays wrap plain arm values (coerceInto is inert
// when the types already agree).
@@ -996,9 +1070,12 @@ export function lowerArrayMethodCall(
const hasSpread = itemNodes.some(ts.isSpreadElement);
const itemProbes = hasSpread ? [] : itemNodes.map((arg) => tryLowerExpression(lowerer, arg));
const statefulItems = itemProbes.some(
(probe) => probe !== null && lowerer.runtimeOptionalWidening(probe.type, elem) !== null,
(probe) =>
probe !== null &&
(lowerer.runtimeOptionalWidening(probe.type, elem) !== null ||
optionalElementValue(lowerer, probe, elem)),
);
if (statefulItems && itemNodes.length === 1) {
if (statefulItems && itemNodes.length === 1 && itemProbes[0]?.type.kind === "union") {
return lowerOptionalSpliceItem(
lowerer,
receiver,
@@ -1734,7 +1811,28 @@ function arraySearchHelper(
type: valueT,
loc,
};
const value = directNeedle ? rawRead : optionalValue;
// A direct needle matches only a present value (`match` tests the state
// first); a present undefined of a `(T | undefined)[]` (primitiveElement)
// must not be read as T.
const value = !directNeedle
? optionalValue
: primitiveElement(elem)
? {
kind: "ternary" as const,
cond: {
kind: "bin" as const,
op: "===" as const,
left: stateRef,
right: numLit(1, loc),
type: BOOL,
loc,
},
then: rawRead,
else_: elementPlaceholder(elem, loc),
type: elem,
loc,
}
: rawRead;
const readT = directNeedle ? elem : valueT;
const equal = arraySearchEquality(
lowerer,
@@ -1,9 +1,14 @@
import * as ts from "../../ts7/adapter.js";
import { CONTRACT_TYPES_MODULE } from "../../contract-types.js";
import { keyedHelperName } from "../stable-names.js";
import { writtenLocals } from "../../../ir/callback-specialization.js";
import { AmbientReceiverReaders } from "../../../ir/receiver-readers.js";
import { boolLit, countedFor, numLit, varRef } from "../../../ir/build.js";
import {
BOOL,
F64,
VOID,
type IrBindingSource,
type IrExpr,
type IrFunction,
type IrGlobal,
@@ -16,9 +21,12 @@ import {
typeKey,
} from "../../../ir/ir.js";
import {
everyExpr,
everyExprChild,
everyStmt,
everyStmtChild,
everyStmtList,
transformExpr,
transformStmtList,
} from "../../../ir/traverse.js";
import {
@@ -537,6 +545,65 @@ function matchChain(lowerer: Lowerer, e: IrExpr): Chain | null {
};
}
/** The outermost array-HOF call of `e` on its own, over its receiver. */
function loneChain(lowerer: Lowerer, e: IrExpr): Chain | null {
const top = stageOf(lowerer, e);
if (!top) return null;
const { stage, receiver } = top;
if (receiver.type.kind !== "array") return null;
const stages: Stage[] = [];
let terminal: Terminal;
if (TERMINALS.has(stage.info.method)) {
const init =
stage.info.method === "reduce" && stage.info.hasInit && e.kind === "call" ? e.args[2]! : null;
terminal = { kind: "call", stage, init };
} else if (PRODUCERS.has(stage.info.method)) {
terminal = { kind: "collect" };
stages.push(stage);
} else return null;
return {
source: receiver,
sourceType: receiver.type,
slice: null,
stages,
terminal,
type: e.type,
loc: e.loc,
};
}
/** The callbacks of `chain` in order, or null when its stages cannot run
* in one loop: element types that do not line up, an array argument past
* the first stage, reduce without a seed, or findIndex over holes. */
function chainCallbacks(chain: Chain): Stage[] | null {
const { stages, terminal } = chain;
const terminalStage = terminal.kind === "call" ? terminal.stage : null;
const callbacks = terminalStage ? [...stages, terminalStage] : stages;
// Element types must line up stage to stage.
let elem = chain.sourceType.elem;
for (const stage of callbacks) {
if (!typeEquals(stage.info.elem, elem)) return null;
if (stage.info.method === "filter" && !typeEquals(stage.info.outElem, stage.info.elem))
return null;
if (
(stage.info.method === "map" || stage.info.method === "flatMap") &&
!typeEquals(stage.info.outElem, stage.info.fnRet)
)
return null;
elem = stage.info.outElem;
}
// Index arguments are reproduced; an array argument only names the source.
for (let k = 0; k < callbacks.length; k++) {
const stage = callbacks[k]!;
const lead = stage.info.method === "reduce" ? 1 : 0;
if (stage.info.arity > lead + 3) return null;
if (stage.info.arity === lead + 3 && (k !== 0 || chain.slice)) return null;
}
if (terminalStage?.info.method === "reduce" && !terminalStage.info.hasInit) return null;
if (terminalStage?.info.method === "findIndex" && holeCarrying(chain)) return null;
return callbacks;
}
/* ── the fusion pass ─────────────────────────────────────────────────── */
/** The values a fused loop hands from one stage to the next: a local of a
@@ -550,6 +617,10 @@ interface Plan {
writes: Set<string>;
/** Some callback may throw or loop; at most one does. */
risky: boolean;
/** How the loop inlines callbacks, when not by their effect summaries. */
inline?: InlineTarget;
/** Stack frame name of the loop: the inlined callback's, for a lone call. */
frame?: string;
}
/** Fuse chains of array-HOF helper calls into one loop.
@@ -596,12 +667,35 @@ class PipelineFusion {
readonly added: IrFunction[] = [];
private readonly effects: CallbackEffects;
private readonly helpers = new Map<string, string>();
/** Bindings each function, or a closure it creates, writes after their
* declaration. */
private readonly written: Map<string, Set<string>>;
/** classCaptured's answers by function name. */
private readonly classCaptures = new Map<string, ReadonlySet<string>>();
/** Whether a module imports or re-exports the contract types. */
private readonly contractTypes: boolean;
/** Bindings of the function being fused that inlined callbacks captured. */
private inlinedCaptures = new Set<string>();
/** The helper calls each callback with an undefined receiver; a copied
* body runs under the receiver around the array call instead. */
private readonly receivers: AmbientReceiverReaders;
constructor(
private readonly lowerer: Lowerer,
functions: readonly IrFunction[],
) {
this.effects = new CallbackEffects(functions, lowerer.globalsList);
this.written = writtenLocals({ functions });
this.receivers = new AmbientReceiverReaders(new Map(functions.map((fn) => [fn.name, fn])));
this.contractTypes = lowerer.moduleOrder.some((sf) =>
sf.statements.nodes.some(
(st) =>
(ts.isImportDeclaration(st) || ts.isExportDeclaration(st)) &&
st.moduleSpecifier !== undefined &&
ts.isStringLiteral(st.moduleSpecifier) &&
st.moduleSpecifier.text === CONTRACT_TYPES_MODULE,
),
);
}
run(fn: IrFunction): void {
@@ -612,6 +706,7 @@ class PipelineFusion {
});
if (!candidate) return;
const removed = new Set<string>();
this.inlinedCaptures = new Set();
let body = this.lists(fn.body, fn, removed);
body = transformStmtList(body, {
stmt: (s) => s,
@@ -619,6 +714,7 @@ class PipelineFusion {
});
fn.body = body;
if (removed.size > 0) fn.locals = fn.locals.filter((l) => !removed.has(l.id));
if (this.inlinedCaptures.size > 0) unboxUncaptured(fn, this.inlinedCaptures);
}
/* ── statement lists: fold single-use chain bindings ── */
@@ -733,40 +829,51 @@ class PipelineFusion {
/* ── expressions ── */
private fuse(e: IrExpr, fn: IrFunction): IrExpr | null {
const plan = this.plan(e, fn);
const plan = this.plan(e, fn) ?? this.lonePlan(e, fn);
return plan ? this.emit(plan) : null;
}
/** One array-HOF call on its own (or the last call of a chain that does
* not fuse), when every callback is a closure literal whose body can be
* copied into the loop. A single stage reorders nothing, so the
* callbacks' effects need no model; their captured bindings must keep
* one value while the loop runs (stableTarget), since the loop reads
* them once, when it starts. The loop keeps the helper's order: the
* length read once, each element read when it is visited, holes
* skipped (and kept by map). */
private lonePlan(e: IrExpr, fn: IrFunction): Plan | null {
const chain = loneChain(this.lowerer, e);
if (!chain) return null;
const callbacks = chainCallbacks(chain);
if (!callbacks) return null;
const inline = this.stableTarget(fn);
let frame: string | undefined;
for (const stage of callbacks) {
for (const arg of [stage.slow, stage.fast]) {
if (!arg) continue;
const target = inline(arg);
if (!target) return null;
frame ??= target.sourceName;
}
}
return {
chain,
callbacks,
writes: new Set(),
risky: true,
inline,
...(frame === undefined ? {} : { frame }),
};
}
/** Decide whether `e` is a fusable chain; null when it is not. */
private plan(e: IrExpr, fn: IrFunction): Plan | null {
const chain = matchChain(this.lowerer, e);
if (!chain) return null;
const callbacks = chainCallbacks(chain);
if (!callbacks) return null;
const { stages, terminal } = chain;
const terminalStage = terminal.kind === "call" ? terminal.stage : null;
const callbacks = terminalStage ? [...stages, terminalStage] : stages;
// Element types must line up stage to stage.
let elem = chain.sourceType.elem;
for (const stage of callbacks) {
if (!typeEquals(stage.info.elem, elem)) return null;
if (stage.info.method === "filter" && !typeEquals(stage.info.outElem, stage.info.elem))
return null;
if (
(stage.info.method === "map" || stage.info.method === "flatMap") &&
!typeEquals(stage.info.outElem, stage.info.fnRet)
)
return null;
elem = stage.info.outElem;
}
// Index arguments are reproduced; an array argument only names the source.
for (let k = 0; k < callbacks.length; k++) {
const stage = callbacks[k]!;
const lead = stage.info.method === "reduce" ? 1 : 0;
if (stage.info.arity > lead + 3) return null;
if (stage.info.arity === lead + 3 && (k !== 0 || chain.slice)) return null;
}
if (terminalStage?.info.method === "reduce" && !terminalStage.info.hasInit) return null;
const holes = holeCarrying(chain);
if (terminalStage?.info.method === "findIndex" && holes) return null;
// Effects of each callback, in chain order.
const scope = this.effects.scope(fn, true);
const summaries: Effects[] = [];
@@ -831,30 +938,89 @@ class PipelineFusion {
* are passed in by value, read when the fused call starts; no callback
* in the chain assigns a binding another one reads). */
private readonly inlineTarget: InlineTarget = (arg) => {
const fn = this.closureTarget(arg);
if (!fn) return null;
const captures = new Set((fn.captures ?? []).map((c) => c.localId));
const effects = this.effects.ofFunction(fn);
if (!effects || intersects(effects.writes, captures)) return null;
return inlinableBody(fn, captures) ? fn : null;
};
/** inlineTarget for a callback created in `fn` whose captured bindings
* nothing assigns after their declaration: `fn`'s own locals and
* parameters, written neither by `fn` nor by any closure that shares
* them, and not by the callback body. A binding a local class captures
* counts as written, since its methods may assign it; so does a binding
* a method reads through its class's captures. The callback must
* not observe the ambient receiver (AmbientReceiverReaders). */
private stableTarget(fn: IrFunction): InlineTarget {
const locals = new Map(fn.locals.map((l) => [l.id, l]));
const inherited = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
const written = this.written.get(fn.name);
const classCaptured = this.classCaptured(fn);
return (arg) => {
const target = this.closureTarget(arg);
if (!target || !written || arg.kind !== "closure") return null;
if (!this.receivers.ignores(target)) return null;
for (const id of arg.captures) {
const local = locals.get(id);
if (!local || local.tdz || inherited.has(id) || written.has(id) || classCaptured.has(id))
return null;
}
const captures = new Set((target.captures ?? []).map((c) => c.localId));
const assigns = !everyStmtList(target.body, {
stmt: (st) => !((st.kind === "assign" || st.kind === "forOf") && captures.has(st.localId)),
expr: (ex) =>
!((ex.kind === "assignExpr" || ex.kind === "incDec") && captures.has(ex.localId)),
});
return !assigns && inlinableBody(target, captures) ? target : null;
};
}
/** The bindings of `fn` that a local class it creates captures. */
private classCaptured(fn: IrFunction): ReadonlySet<string> {
const known = this.classCaptures.get(fn.name);
if (known) return known;
const ids = new Set<string>();
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "classRef") for (const id of e.captures ?? []) ids.add(id);
return true;
},
stmt: () => true,
});
this.classCaptures.set(fn.name, ids);
return ids;
}
/** The function behind a closure-literal callback the loop can inline. */
private closureTarget(arg: IrExpr): IrFunction | null {
if (arg.kind !== "closure" || arg.adapts) return null;
const fn = this.effects.fn(arg.fnName);
if (!fn || fn.async || fn.generator || fn.classCaptures?.length) return null;
const captures = new Set((fn.captures ?? []).map((c) => c.localId));
if (captures.size !== arg.captures.length) return null;
const effects = this.effects.ofFunction(fn);
if (!effects || intersects(effects.writes, captures)) return null;
if (fn.locals.some((l) => l.tdz || (l.boxed && !captures.has(l.id)))) return null;
let size = 0;
const small = everyStmtList(fn.body, {
expr: (e) => ++size <= MAX_INLINE_NODES && e.kind !== "closure" && e.kind !== "selfRef",
stmt: (s) => ++size <= MAX_INLINE_NODES && s.kind !== "tryCatch",
});
return small ? fn : null;
};
if ((fn.captures ?? []).length !== arg.captures.length) return null;
return fn;
}
private emit(plan: Plan): IrExpr | null {
const built = new FusedLoop(this.lowerer, plan.chain, this.inlineTarget).build();
const built = new FusedLoop(
this.lowerer,
plan.chain,
plan.inline ?? this.inlineTarget,
this.contractTypes,
).build();
if (!built) return null;
for (const id of built.inlinedCaptures) this.inlinedCaptures.add(id);
let name = this.helpers.get(built.key);
if (!name) {
name = keyedHelperName(this.lowerer, "%arr.fused", built.key);
name = keyedHelperName(this.lowerer, plan.inline ? "%arr.loop" : "%arr.fused", built.key);
this.helpers.set(built.key, name);
this.added.push({ ...built.fn, name });
const helper: IrFunction = { ...built.fn, name };
// A lone call's loop stands in the inlined callback's stack frame.
if (plan.frame !== undefined) helper.sourceName = plan.frame;
this.added.push(helper);
}
return {
kind: "call",
@@ -866,6 +1032,48 @@ class PipelineFusion {
}
}
/** Whether a statement inside `e` (a sequence expression's) returns. */
function returns(e: IrExpr): boolean {
return !everyExpr(e, { expr: () => true, stmt: (s) => s.kind !== "return" });
}
/** Drops the box of each binding in `inlined` that no closure or class
* created in `fn` captures any more: the callbacks that captured it were
* copied into loops, which receive its value. A dead-zone binding keeps the
* box that represents its uninitialized state, and bindings `fn` itself
* captures keep theirs. */
function unboxUncaptured(fn: IrFunction, inlined: ReadonlySet<string>): void {
const captured = new Set<string>();
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "closure" || e.kind === "classRef")
for (const id of e.captures ?? []) captured.add(id);
return true;
},
stmt: () => true,
});
const inherited = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
fn.locals = fn.locals.map((local): IrLocal => {
if (local.boxed !== true || local.tdz === true || !inlined.has(local.id)) return local;
if (captured.has(local.id) || inherited.has(local.id)) return local;
const { boxed: _boxed, ...plain } = local;
return plain;
});
}
/** A callback body the loop can copy: small, no nested closures, recursion
* or try statements, and no boxed locals besides its captures. */
function inlinableBody(fn: IrFunction, captures: ReadonlySet<string>): boolean {
if (fn.locals.some((l) => l.tdz || (l.boxed && !captures.has(l.id)))) return false;
let size = 0;
return everyStmtList(fn.body, {
expr: (e) => ++size <= MAX_INLINE_NODES && e.kind !== "closure" && e.kind !== "selfRef",
stmt: (s) => ++size <= MAX_INLINE_NODES && s.kind !== "tryCatch",
});
}
function intersects(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
for (const id of a) if (b.has(id)) return true;
return false;
@@ -988,10 +1196,13 @@ class FusedLoop {
private next = 0;
private failed = false;
/** `contractTypes`: the program writes contract types, which the
* contract checker binds to source bindings. */
constructor(
private readonly lowerer: Lowerer,
private readonly chain: Chain,
private readonly inlineTarget: InlineTarget,
private readonly contractTypes: boolean,
) {
this.loc = chain.loc;
}
@@ -1000,9 +1211,9 @@ class FusedLoop {
return `${name}.${this.next++}`;
}
private local(name: string, type: IrType, mutable = false): string {
private local(name: string, type: IrType, mutable = false, source?: IrBindingSource): string {
const id = this.fresh(name);
this.locals.push({ id, name, type, mutable });
this.locals.push(source ? { id, name, type, mutable, source } : { id, name, type, mutable });
return id;
}
@@ -1069,21 +1280,50 @@ class FusedLoop {
return true;
},
});
// Copies keep their source bindings, so contract types written on
// them are checked against what the loop stores. In a program that
// writes contract types, a parameter with a source binding keeps its
// own local for the same reason.
const sources = new Map(fn.locals.map((l) => [l.id, l.source]));
const aliased = new Set<string>();
fn.params.forEach((p, index) => {
const arg = args[index]!;
if (arg.kind === "varRef" && !assigned.has(p.localId) && typeEquals(arg.type, p.type)) {
if (
arg.kind === "varRef" &&
!assigned.has(p.localId) &&
typeEquals(arg.type, p.type) &&
!(this.contractTypes && sources.get(p.localId) !== undefined)
) {
renamed.set(p.localId, arg.localId);
aliased.add(p.localId);
}
});
for (const l of fn.locals) {
if (renamed.has(l.id)) continue;
const id = this.local(l.name, l.type, true);
const id = this.local(l.name, l.type, true, l.source);
renamed.set(l.id, id);
}
const rename = (id: string): string => renamed.get(id) ?? id;
const renameExpr = (e: IrExpr): IrExpr => {
switch (e.kind) {
case "varRef":
case "incDec":
case "assignExpr":
return { ...e, localId: rename(e.localId) };
default:
return e;
}
};
// A bound parameter's value is an element or index with no source text
// of its own; it is located at the parameter, without a span.
const bind: IrStmt[] = fn.params.flatMap((p, index): IrStmt[] => {
if (aliased.has(p.localId)) return [];
const source = sources.get(p.localId);
const arg = args[index]!;
const init = source ? { ...arg, loc: { ...source.loc, end: source.loc.start } } : arg;
return [{ kind: "varDecl", localId: rename(p.localId), init, loc }];
});
const labels = new Map<string, string>();
const exit = this.fresh("%fused.exit");
const label = (name: string): string => {
let id = labels.get(name);
if (!id) {
@@ -1092,19 +1332,53 @@ class FusedLoop {
}
return id;
};
const result = fn.returnType.kind === "void" ? null : this.local("ret", fn.returnType, true);
const rename = (id: string): string => renamed.get(id) ?? id;
const body = transformStmtList(fn.body, {
expr: (e) => {
switch (e.kind) {
case "varRef":
case "incDec":
case "assignExpr":
return { ...e, localId: rename(e.localId) };
default:
return e;
/** Locals and labels of a copied statement; returns are the caller's. */
const renameStmt = (st: IrStmt): IrStmt => {
switch (st.kind) {
case "varDecl":
case "assign":
return { ...st, localId: rename(st.localId) };
case "break":
case "continue":
return st.label ? { ...st, label: label(st.label) } : st;
case "forOf": {
const renamedFor = { ...st, localId: rename(st.localId) };
return st.labels ? { ...renamedFor, labels: st.labels.map(label) } : renamedFor;
}
},
case "while":
case "doWhile":
case "for":
case "switch":
case "block":
return st.labels ? { ...st, labels: st.labels.map(label) } : st;
default:
return st;
}
};
// A body whose only return is its last statement (`(x) => x * k`,
// `{ const y = x * 2; return y > 4; }`) needs no exit block: its other
// statements run after the bindings, and the call's value is the
// returned expression.
const last = fn.body[fn.body.length - 1];
const prefix = fn.body.slice(0, -1);
if (
last !== undefined &&
last.kind === "return" &&
last.value !== null &&
typeEquals(last.value.type, fn.returnType) &&
!returns(last.value) &&
prefix.every((st) => everyStmt(st, { expr: () => true, stmt: (x) => x.kind !== "return" }))
) {
const copy = { expr: renameExpr, stmt: renameStmt };
return {
pre: [...bind, ...transformStmtList(prefix, copy)],
value: transformExpr(last.value, copy),
};
}
const exit = this.fresh("%fused.exit");
const result = fn.returnType.kind === "void" ? null : this.local("ret", fn.returnType, true);
const body = transformStmtList(fn.body, {
expr: renameExpr,
stmt: (s): IrStmt => {
switch (s.kind) {
case "return": {
@@ -1124,27 +1398,14 @@ class FusedLoop {
loc: s.loc,
};
}
case "varDecl":
case "assign":
return { ...s, localId: rename(s.localId) };
case "break":
case "continue":
return s.label && s.label !== exit ? { ...s, label: label(s.label) } : s;
case "while":
case "doWhile":
case "for":
case "block":
return s.labels ? { ...s, labels: s.labels.map(label) } : s;
default:
return s;
return renameStmt(s);
}
},
});
const bind: IrStmt[] = fn.params.flatMap((p, index): IrStmt[] =>
aliased.has(p.localId)
? []
: [{ kind: "varDecl", localId: rename(p.localId), init: args[index]!, loc }],
);
const pre: IrStmt[] = [];
if (result !== null) pre.push({ kind: "varDecl", localId: result, init: null, loc });
pre.push({ kind: "block", labels: [exit], body: [...bind, ...body], loc });
@@ -1157,7 +1418,13 @@ class FusedLoop {
};
}
build(): { fn: Omit<IrFunction, "name">; args: IrExpr[]; key: string } | null {
build(): {
fn: Omit<IrFunction, "name">;
args: IrExpr[];
key: string;
/** Bindings the callbacks copied into the loop captured at this site. */
inlinedCaptures: string[];
} | null {
const { chain, loc } = this;
const arrT = chain.sourceType;
const a = this.param("a", chain.source, "src");
@@ -1642,7 +1909,10 @@ class FusedLoop {
};
foldKnownUnions(fn);
scalarizeRecordTemporaries(this.lowerer, fn);
return { fn, args: this.args, key: this.keyParts.join("|") };
const inlinedCaptures = this.callees.flatMap((c) =>
c.inline && c.arg.kind === "closure" ? c.arg.captures : [],
);
return { fn, args: this.args, key: this.keyParts.join("|"), inlinedCaptures };
}
}
@@ -1741,8 +2011,11 @@ function foldKnownUnions(fn: Omit<IrFunction, "name">): void {
case "for":
case "while":
case "doWhile":
case "forOf":
case "block":
return { ...s, body: prune(s.body) };
case "switch":
return { ...s, cases: s.cases.map((c) => ({ ...c, body: prune(c.body) })) };
default:
return s;
}
@@ -1930,6 +2203,19 @@ function scalarizeRecordTemporaries(lowerer: Lowerer, fn: Omit<IrFunction, "name
body: rewriteList(s.body),
},
];
case "forOf":
return [{ ...s, iterable: rewriteExpr(s.iterable), body: rewriteList(s.body) }];
case "switch":
return [
{
...s,
disc: rewriteExpr(s.disc),
cases: s.cases.map((c) => ({
test: c.test === null ? null : rewriteExpr(c.test),
body: rewriteList(c.body),
})),
},
];
case "block":
return [{ ...s, body: rewriteList(s.body) }];
default: {
@@ -2,6 +2,7 @@ import { keyedHelperName } from "../stable-names.js";
import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { primitiveElement } from "../array-values.js";
import { varRef } from "../../../ir/build.js";
import { typeKey } from "../../type-mapper.js";
import {
@@ -144,12 +145,22 @@ export function lowerArrayCallback(
const untyped = (checkerType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
const raw = untyped ? null : lowerer.mapTypeOf(checkerType);
const mapped = raw ? lowerer.runtimeOptionalBindingType(param.name, raw) : null;
// A helper that passes present number, string or boolean elements
// (a `(T | undefined)[]` stores T elements) binds a `T | undefined`
// parameter to T, so the callback takes them without an adapter.
const presentElement =
mapped !== null &&
primitiveElement(expected) &&
mapped.kind === "union" &&
lowerer.armTag(mapped.unionId, UNDEFINED_T) >= 0 &&
typeEquals(lowerer.stripUndefinedArm(mapped), expected);
if (
untyped
? !bindUntyped
: mapped === null ||
(lowerer.coercibleValue(expected, mapped) &&
!runtimeOptionalPairVariant(lowerer, expected, mapped))
!runtimeOptionalPairVariant(lowerer, expected, mapped) &&
!presentElement)
)
return;
const symbol = lowerer.checker.getSymbolAtLocation(param.name);
@@ -1,4 +1,5 @@
import { keyedHelperName } from "../stable-names.js";
import { primitiveElement } from "../array-values.js";
import { nodeThrowExpr, boolLit, countedFor, numLit, strLit, varRef } from "../../../ir/build.js";
import { InternalCompilerError } from "../../../errors.js";
import * as ts from "../../ts7/adapter.js";
@@ -1138,6 +1139,17 @@ function setFromSeedValue(
seed = strCharsCall(lowerer, source, loc);
} else if (source.type.kind === "array" && typeEquals(source.type.elem, setT.elem)) {
seed = source;
} else if (
source.type.kind === "array" &&
primitiveElement(source.type.elem) &&
setT.elem.kind === "union" &&
lowerer.armTag(setT.elem.unionId, UNDEFINED_T) >= 0 &&
typeEquals(lowerer.stripUndefinedArm(setT.elem), source.type.elem)
) {
// A `(T | undefined)[]` stores T elements: its present undefined
// elements copy as the Set's undefined.
seed = lowerer.coerceToExpected(source, arrayOf(setT.elem));
if (!typeEquals(seed.type, arrayOf(setT.elem))) return null;
} else if (source.type.kind === "record") {
const shape = lowerer.shapes.get(source.type.shapeId);
if (!shape?.tuple || !shape.fields.every((field) => typeEquals(field.type, setT.elem)))
@@ -1,6 +1,7 @@
import { keyedHelperName } from "../stable-names.js";
import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js";
import { countedFor, numLit, varRef } from "../../../ir/build.js";
import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js";
import { objectEnumerationReceiver } from "../object-enumeration-receiver.js";
import { InternalCompilerError } from "../../../errors.js";
import * as ts from "../../ts7/adapter.js";
@@ -156,7 +157,14 @@ export function objectIterOverIndexShape(
// also holds numbers): each overflow value checks into the element type,
// so a value of a dropped arm throws instead of mistyping.
let ivNarrows = false;
if (valueT) {
// `T | undefined` values into a `(T | undefined)[]`, which stores T
// elements (primitiveElement): each value is stored with its present
// undefined.
const optionalValues =
member === "values" &&
valueT !== null &&
optionalElementValue(lowerer, { kind: "unitLit", unit: "undefined", type: iv, loc }, valueT);
if (valueT && !optionalValues) {
const ivSurfaces =
typeEquals(iv, valueT) ||
(valueT.kind === "union" && lowerer.armTag(valueT.unionId, iv) >= 0) ||
@@ -350,7 +358,25 @@ export function objectIterOverIndexShape(
loc,
},
then: [fieldStmt],
else_: undefinedElement ? [push(pushedOf(undefinedElement))] : null,
else_: undefinedElement
? [push(pushedOf(undefinedElement))]
: member === "values" && valueT && primitiveElement(valueT)
? [
{
kind: "arraySetUndefined",
arr: outRef,
index: {
kind: "arrIntrinsic",
method: "length",
receiver: outRef,
args: [],
type: F64,
loc,
},
loc,
},
]
: null,
loc,
};
}
@@ -374,35 +400,45 @@ export function objectIterOverIndexShape(
const kRef = varRef("k.0", STRING, loc);
const readValue: IrExpr | null = !valueT
? null
: ivNarrows
: optionalValues
? {
kind: "dynCheck",
value: {
kind: "dynFrom",
value: {
kind: "recordKeyGet",
obj: rRef,
shapeId: argIr.shapeId,
key: kRef,
overflowOnly: true,
type: iv,
loc,
},
type: DYN,
loc,
},
type: valueT,
loc,
}
: {
kind: "recordKeyGet",
obj: rRef,
shapeId: argIr.shapeId,
key: kRef,
overflowOnly: true,
type: valueT,
type: iv,
loc,
};
}
: ivNarrows
? {
kind: "dynCheck",
value: {
kind: "dynFrom",
value: {
kind: "recordKeyGet",
obj: rRef,
shapeId: argIr.shapeId,
key: kRef,
overflowOnly: true,
type: iv,
loc,
},
type: DYN,
loc,
},
type: valueT,
loc,
}
: {
kind: "recordKeyGet",
obj: rRef,
shapeId: argIr.shapeId,
key: kRef,
overflowOnly: true,
type: valueT,
loc,
};
const loopPushed: IrExpr =
member === "keys"
? kRef
@@ -440,7 +476,26 @@ export function objectIterOverIndexShape(
},
loc,
},
push(loopPushed),
...(optionalValues
? [
{ kind: "varDecl" as const, localId: "v.0", init: readValue!, loc },
arrayValueStore(
lowerer,
outRef,
{
kind: "arrIntrinsic",
method: "length",
receiver: outRef,
args: [],
type: F64,
loc,
},
varRef("v.0", iv, loc),
valueT!,
loc,
),
]
: [push(loopPushed)]),
],
),
{ kind: "return", value: outRef, loc },
@@ -457,6 +512,7 @@ export function objectIterOverIndexShape(
{ id: "ks.0", name: "ks", type: ksT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "k.0", name: "k", type: STRING, mutable: false },
...(optionalValues ? [{ id: "v.0", name: "v", type: iv, mutable: false }] : []),
],
body,
loc,
@@ -32,7 +32,7 @@ import { generatorDrain } from "../iterator-adapters.js";
import { isMimeParamsIterable } from "../builtins/mime.js";
import { isSearchParamsIterable } from "../builtins/url.js";
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
import { arrayValueStore } from "../array-values.js";
import { arrayValueStore, primitiveElement } from "../array-values.js";
import { classGeneratorIterable } from "../class-iterables.js";
import { iteratorCanStep } from "../iterator-consumption.js";
@@ -104,6 +104,13 @@ function emptyNeverLiteral(lowerer: Lowerer, expr: ts.ArrayLiteralExpression): b
* tsc's API doesn't surface it (ternary arms under an array context —
* tsc accepts the arm covariantly, but a tagged element representation
* must be BUILT as the slot's element type). */
/** The checker type of `node` admits undefined. */
function mayHoldUndefined(lowerer: Lowerer, node: ts.Expression): boolean {
const type = lowerer.typeOf(node);
const parts = type.isUnionType() ? ts.constituentTypes(type) : [type];
return parts.some((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0);
}
export function lowerArrayLiteral(
lowerer: Lowerer,
expr: ts.ArrayLiteralExpression,
@@ -525,7 +532,13 @@ export function lowerArrayLiteral(
// `[...it]` over a generator (or an iterator adapted into one):
// drain it once, in order, into a fresh element array.
if (src.type.kind === "generator") {
const drained = generatorDrain(lowerer, src, type.elem, locOf(el));
const drained = generatorDrain(
lowerer,
src,
type.elem,
locOf(el),
type.optionalElements === true,
);
if (drained) src = drained;
}
// `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its
@@ -618,6 +631,9 @@ export function lowerArrayLiteral(
let lowered: IrExpr;
const directOptionalCandidate =
runtimeOptionalElements.has(i) ||
// An element that can be undefined in a `(T | undefined)[]`
// (primitiveElement) is stored as a present undefined.
(primitiveElement(type.elem) && mayHoldUndefined(lowerer, el)) ||
ts.isElementAccessExpression(el) ||
(ts.isCallExpression(el) &&
ts.isPropertyAccessExpression(el.expression) &&
@@ -695,7 +711,8 @@ export function lowerArrayLiteral(
continue;
}
let value = el;
if (!typeEquals(value.type, type.elem)) {
// An undefined element is stored directly (arrayValueStore).
if (!typeEquals(value.type, type.elem) && value.type.kind !== "undefinedT") {
const temp = lowerer.declareHiddenLocal("%arrayValue", value.type);
body.push({ kind: "varDecl", localId: temp.id, init: value, loc });
value = varRef(temp.id, temp.type, loc);
@@ -61,6 +61,8 @@ import {
arrayValueType,
lowerSafeBytesRead,
lowerSafeIndexRead,
primitiveElement,
unionArrayValueRead,
} from "../array-values.js";
import { lowerOptionalStringIndex, lowerUnionStringIndex } from "../string-index.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -594,6 +596,8 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
}
}
if (key.type.kind === "f64") {
const optional = optionalArmIndexRead(lowerer, value, key, locOf(expr));
if (optional) return lowerer.maybeNarrow(optional, expr);
const safe = lowerOptionalArrayIndexRead(lowerer, expr, value, key);
if (safe) return safe;
}
@@ -884,9 +888,14 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
// must be the element type (library ABI checks and the catchable
// RangeError contract for an out-of-bounds assert). Where undefined is
// representable, the assertion keeps JavaScript's unchecked read.
// A declared `(T | undefined)[]` element (stored as T) keeps its
// optional read: the assertion then narrows it with a catchable
// TypeError, as for a union element.
const declaredOptional = arr.type.optionalElements === true;
const provenPresent =
ts.isNonNullExpression(expr.parent) &&
expr.parent.expression === expr &&
!declaredOptional &&
!assertedElementKeepsUndefined(lowerer, expr.parent);
if (provenPresent) {
return lowerer.maybeNarrow(
@@ -2437,3 +2446,46 @@ export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrEx
return { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc };
return lowerRecordPropertyKey(lowerer, key, node);
}
/** A number-keyed read of a union of arrays with a `(T | undefined)[]` arm
* (stored as T elements, primitiveElement) whose element facts admit
* missing values: each arm reads its element by its state
* (unionArrayValueRead), where the keyed read would trap on a present
* undefined. The receiver and key evaluate once. Null for other unions. */
function optionalArmIndexRead(
lowerer: Lowerer,
value: IrExpr,
key: IrExpr,
loc: SrcLoc,
): IrExpr | null {
if (value.type.kind !== "union") return null;
const arms = lowerer.unions.get(value.type.unionId)?.arms ?? [];
if (
!arms.some(
(arm) =>
arm.kind === "array" &&
primitiveElement(arm.elem) &&
arrayValueType(lowerer, arm.elem).kind === "union",
)
)
return null;
if (unionArrayValueRead(lowerer, value, key, loc) === null) return null;
const stmts: IrStmt[] = [];
const stable = (e: IrExpr, name: string, keep: boolean): IrExpr => {
if (keep) return e;
const local = lowerer.declareHiddenLocal(name, e.type);
stmts.push({ kind: "varDecl", localId: local.id, init: e, loc });
return varRef(local.id, e.type, loc);
};
// A receiver local stays a direct read only when the key cannot assign
// it (`a[(a = other, 0)]` reads the array `a` held before the key).
const plainKey = key.kind === "varRef" || key.kind === "numLit";
const receiver = stable(
value,
"%indexReceiver",
value.kind === "numLit" || (value.kind === "varRef" && plainKey),
);
const index = stable(key, "%indexKey", plainKey);
const read = unionArrayValueRead(lowerer, receiver, index, loc)!;
return stmts.length === 0 ? read : { kind: "seqExpr", stmts, result: read, type: read.type, loc };
}
@@ -842,7 +842,17 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
"deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead",
eval: "runtime code evaluation cannot be compiled ahead of time",
};
lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined);
lowerer.noLowering(
expr.text,
expr,
globalHints[expr.text],
sym ?? undefined,
expr.text === "WeakRef" || expr.text === "FinalizationRegistry"
? "weak-ref"
: expr.text === "eval"
? "code-from-string"
: undefined,
);
}
}
// An ambient global nothing defines compiles to exactly what Node does
@@ -1,3 +1,4 @@
import { appendElement, optionalElementValue, primitiveElement } from "./array-values.js";
import { keyedHelperName } from "./stable-names.js";
import { dynUndefinedExpr, varRef } from "../../ir/build.js";
import {
@@ -964,16 +965,27 @@ function generatorRetypeAdapter(
return { kind: "call", callee: name, args: [expr], type: expected, loc };
}
/** A checked-dynamic yielded value for a `(T | undefined)[]` of a
* number, string or boolean T: converted to `T | undefined`, which the
* drain stores as a T element or a present undefined. */
function optionalYield(lowerer: Lowerer, value: IrExpr, elem: IrType, optional: boolean): IrExpr {
if (!optional || value.type.kind !== "dyn" || !primitiveElement(elem)) return value;
return lowerer.coerceToExpected(value, lowerer.withUndefinedArm(elem));
}
/** Drain a synchronous generator into a fresh `elem[]` — the eager
* consumers of an iterator (`[...it]`, `Array.from(it)`). Each step resumes
* the generator once and appends its value; completion stops the drain
* without appending the return value, as in JS. Null when the yielded
* values cannot be extracted into the element type. */
* values cannot be extracted into the element type. `optional`: the
* result is a `(T | undefined)[]`, which stores T elements, so a
* checked-dynamic value may also be undefined. */
export function generatorDrain(
lowerer: Lowerer,
source: IrExpr,
elem: IrType,
loc: SrcLoc,
optional = false,
): IrExpr | null {
const gen = source.type;
if (gen.kind !== "generator" || gen.async || gen.yieldT.kind === "void") return null;
@@ -982,14 +994,16 @@ export function generatorDrain(
const valueT = shape?.fields.find((field) => field.name === "value")?.type;
if (!resultType || !valueT) return null;
const arrT: IrType = { kind: "array", elem };
const key = `genDrain:${typeKey(gen)}:${typeKey(elem)}`;
const key = `genDrain:${typeKey(gen)}:${typeKey(elem)}${optional ? "?" : ""}`;
let name = lowerer.valueHelpers.get(key);
if (!name && stepDrivable(gen)) {
// The record-free drive: test done, then take the yielded value.
const genRef = varRef("gen.0", gen, loc) as IrExpr & { type: typeof gen };
const outRef = varRef("out.0", arrT, loc);
const value = lowerer.coerceToExpected(genValue(genRef, loc), elem);
if (!typeEquals(value.type, elem)) return null;
const yielded = optionalYield(lowerer, genValue(genRef, loc), elem, optional);
const present = optionalElementValue(lowerer, yielded, elem);
const value = present ? yielded : lowerer.coerceToExpected(yielded, elem);
if (!present && !typeEquals(value.type, elem)) return null;
name = keyedHelperName(lowerer, "%gen.drain", key);
lowerer.valueHelpers.set(key, name);
lowerer.liftedFns.push({
@@ -999,6 +1013,7 @@ export function generatorDrain(
locals: [
{ id: "gen.0", name: "gen", type: gen, mutable: false },
{ id: "out.0", name: "out", type: arrT, mutable: false },
...(present ? [{ id: "v.0", name: "v", type: value.type, mutable: false }] : []),
],
body: [
{
@@ -1010,20 +1025,7 @@ export function generatorDrain(
{
kind: "while",
cond: { kind: "unary", op: "!", operand: genStep(genRef, loc), type: BOOL, loc },
body: [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: outRef,
args: [value],
type: F64,
loc,
},
loc,
},
],
body: appendElement(lowerer, outRef, value, elem, "v.0", loc),
loc,
},
{ kind: "return", value: outRef, loc },
@@ -1050,8 +1052,10 @@ export function generatorDrain(
loc,
);
if (!raw) return null;
const value = lowerer.coerceToExpected(raw, elem);
if (!typeEquals(value.type, elem)) return null;
const yielded = optionalYield(lowerer, raw, elem, optional);
const present = optionalElementValue(lowerer, yielded, elem);
const value = present ? yielded : lowerer.coerceToExpected(yielded, elem);
if (!present && !typeEquals(value.type, elem)) return null;
name = keyedHelperName(lowerer, "%gen.drain", key);
lowerer.valueHelpers.set(key, name);
lowerer.liftedFns.push({
@@ -1062,6 +1066,7 @@ export function generatorDrain(
{ id: "gen.0", name: "gen", type: gen, mutable: false },
{ id: "out.0", name: "out", type: arrT, mutable: false },
{ id: "step.0", name: "step", type: resultType, mutable: false },
...(present ? [{ id: "v.0", name: "v", type: value.type, mutable: false }] : []),
],
body: [
{
@@ -1101,18 +1106,7 @@ export function generatorDrain(
else_: null,
loc,
},
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: outRef,
args: [value],
type: F64,
loc,
},
loc,
},
...appendElement(lowerer, outRef, value, elem, "v.0", loc),
],
loc,
},
@@ -57,7 +57,7 @@ import {
isUnitType,
typeEquals,
} from "../../ir/ir.js";
import { LIB_FN_SIGS } from "../../ir/builtin-signatures.js";
import { libFnSignatures } from "../../ir/builtin-signatures.js";
import { REGEX_INTRINSIC_SIGS, STR_INTRINSIC_SIGS } from "../../ir/intrinsic-signatures.js";
import { unionMismatchDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js";
@@ -162,7 +162,7 @@ function enforceExprBoundary(lowerer: Lowerer, node: IrExpr): void {
const kind = node.kind;
if (kind === "libCall") {
const e = node;
const sig = LIB_FN_SIGS[e.fn];
const sig = libFnSignatures()[e.fn];
if (!sig) return;
e.args.forEach((a, i) => {
const want = sig.argTypes[i];
@@ -16,7 +16,7 @@ import { strLit, varRef } from "../../ir/build.js";
import type { Lowerer } from "./lowerer.js";
import {
BUILTIN_MODULE_CONSTS,
BUILTIN_MODULE_FNS,
builtinModuleFns,
OBJECT_CALLABLE_VALUES,
builtinConstLit,
builtinModuleConstOf,
@@ -430,7 +430,7 @@ export function pathModuleValue(lowerer: Lowerer, module: string, loc: SrcLoc):
const add = (member: string, value: IrExpr): void => {
fields.push({ key: strLit(member, loc), value: box(value) });
};
for (const member of Object.keys(BUILTIN_MODULE_FNS[module] ?? {})) {
for (const member of Object.keys(builtinModuleFns()[module] ?? {})) {
const value = lowerer.lowerBuiltinCallableValue({ module, member }, loc);
if (value) add(member, value);
}
@@ -512,7 +512,7 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr {
loc,
});
};
for (const member of Object.keys(BUILTIN_MODULE_FNS[module] ?? {})) {
for (const member of Object.keys(builtinModuleFns()[module] ?? {})) {
const callable = lowerer.lowerBuiltinCallableValue({ module, member }, loc);
if (callable) add(member, callable);
}
@@ -1147,7 +1147,10 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
if (!argNode || (ts.isIdentifier(argNode) && argNode.text === "undefined")) {
return { kind: "libCall", fn: "sym.newAnon", args: [], type: SYMBOL_T, loc };
}
const desc = lowerer.lowerExpr(argNode);
let desc = lowerer.lowerExpr(argNode);
// A number or boolean description is its ToString.
if (desc.type.kind === "f64" || desc.type.kind === "bool")
desc = lowerer.ensureString(desc, argNode);
if (desc.type.kind !== "string") {
lowerer.noLowering(
`Symbol with a '${lowerer.fmt(desc.type)}' description`,
@@ -586,6 +586,8 @@ export function fenceNodeModuleMutation(
`${action} through require.cache`,
target,
"a compiled binary has a fixed module graph; cache lookup and Object.keys(require.cache) are read-only",
undefined,
"loader-hook",
);
}
if (
@@ -35,14 +35,7 @@ import { esmNamedImportLinkCrash, isCjsJsFile, locOf, npmPackageNameOf } from ".
import { foldedStringKeyOf, lowerDynObjectLiteral } from "./expressions/object-literals.js";
import { noteEventListeners } from "./lower-event-target.js";
import { PoisonError, newFnCtx, own } from "./lowerer.js";
import {
NODE24_FETCH_COMPAT_PROFILE,
STATIC_HEADERS_CALLS,
STATIC_READABLE_STREAM_CALLS,
STATIC_READABLE_STREAM_READS,
STATIC_RESPONSE_CALLS,
STATIC_RESPONSE_READS,
} from "../../compat/fetch-profile.js";
import { node24FetchCompatProfile, staticFetchMembers } from "../../compat/fetch-profile.js";
import { classGeneratorIterable } from "./class-iterables.js";
import { generatorDrain } from "./iterator-adapters.js";
@@ -434,7 +427,7 @@ function fenceRequestInitObject(
: contextual
? [contextual]
: [];
const rows = NODE24_FETCH_COMPAT_PROFILE.inventory.entries.filter(
const rows = node24FetchCompatProfile().inventory.entries.filter(
(entry) => entry.owner === "RequestInit" && entry.placement === "dictionary",
);
const shadowed = new Set<string>();
@@ -837,7 +830,7 @@ function fetchInventoryStatus(
placement: "static" | "prototype" | "prototype-symbol",
): "static" | "unsupported" | "out-of-scope" | null {
return (
NODE24_FETCH_COMPAT_PROFILE.inventory.entries.find(
node24FetchCompatProfile().inventory.entries.find(
(entry) => entry.owner === owner && entry.member === member && entry.placement === placement,
)?.status ?? null
);
@@ -848,7 +841,7 @@ function fetchInventoryReason(
member: string,
placement: "static" | "prototype" | "prototype-symbol",
): string | undefined {
return NODE24_FETCH_COMPAT_PROFILE.inventory.entries.find(
return node24FetchCompatProfile().inventory.entries.find(
(entry) => entry.owner === owner && entry.member === member && entry.placement === placement,
)?.reason;
}
@@ -951,8 +944,9 @@ export function fenceStaticResponseMember(
member.startsWith("[Symbol.") ? "prototype-symbol" : "prototype",
);
const projected = staticFetchMembers();
const supported = (member: string) =>
use === "call" ? STATIC_RESPONSE_CALLS.has(member) : STATIC_RESPONSE_READS.has(member);
use === "call" ? projected.responseCalls.has(member) : projected.responseReads.has(member);
if (members.every(supported)) return null;
const member = members.find((candidate) => !supported(candidate))!;
if (status(member) === "unsupported") {
@@ -993,12 +987,9 @@ export function fenceStaticHeadersMember(
member.startsWith("[Symbol.") ? "prototype-symbol" : "prototype",
);
if (use === "call" && members.every((member) => STATIC_HEADERS_CALLS.has(member))) {
return null;
}
const member = members.find(
(candidate) => use !== "call" || !STATIC_HEADERS_CALLS.has(candidate),
)!;
const headersCalls = staticFetchMembers().headersCalls;
if (use === "call" && members.every((member) => headersCalls.has(member))) return null;
const member = members.find((candidate) => use !== "call" || !headersCalls.has(candidate))!;
if (status(member) === "unsupported") {
lowerer.noLowering(
`Headers.${member}`,
@@ -1108,7 +1099,7 @@ function fetchInterfaceInventoryOwner(
const symbol = [type.getSymbol(), type.getAliasSymbol()].find(
(candidate) =>
candidate !== undefined &&
NODE24_FETCH_COMPAT_PROFILE.inventory.interfaces.includes(ts.symbolName(candidate)) &&
node24FetchCompatProfile().inventory.interfaces.includes(ts.symbolName(candidate)) &&
lowerer.isStdlibSymbol(candidate),
);
return symbol ? { owner: ts.symbolName(symbol), symbol } : null;
@@ -1123,7 +1114,7 @@ function fenceFetchObjectMembers(
if (!resolved) return;
const { owner, symbol } = resolved;
for (const { member, node } of members) {
const row = NODE24_FETCH_COMPAT_PROFILE.inventory.entries.find(
const row = node24FetchCompatProfile().inventory.entries.find(
(entry) =>
entry.owner === owner &&
entry.member === member &&
@@ -1136,7 +1127,7 @@ function fenceFetchObjectMembers(
// Static data properties retain the ordinary checked-dynamic keyed-read
// lowering. Only a method extraction loses the receiver that its direct
// call bridge would preserve; non-static rows keep their inventory fence.
const operation = NODE24_FETCH_COMPAT_PROFILE.operations.find(
const operation = node24FetchCompatProfile().operations.find(
(candidate) => candidate.id === row.id,
);
if (row.status === "static" && operation?.kind === "property") {
@@ -1235,7 +1226,7 @@ export function lowerFetchElementMethodCall(
return null;
}
const rows = members.map((member) =>
NODE24_FETCH_COMPAT_PROFILE.inventory.entries.find(
node24FetchCompatProfile().inventory.entries.find(
(entry) =>
entry.owner === resolved.owner &&
entry.member === member &&
@@ -1397,10 +1388,11 @@ export function fenceStaticReadableStreamMember(
member.startsWith("[Symbol.") ? "prototype-symbol" : "prototype",
);
const projected = staticFetchMembers();
const supported = (member: string) =>
use === "call"
? STATIC_READABLE_STREAM_CALLS.has(member)
: STATIC_READABLE_STREAM_READS.has(member);
? projected.readableStreamCalls.has(member)
: projected.readableStreamReads.has(member);
if (members.every(supported)) return null;
const member = members.find((candidate) => !supported(candidate))!;
if (status(member) === "unsupported") {
@@ -2222,6 +2214,8 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression
call,
"dynamic import() of computed specifiers (the module graph resolves at " +
"build time — the specifier must be a string literal)",
undefined,
"dynamic-import",
);
}
if (call.arguments.nodes.length !== 1) {
@@ -6586,6 +6586,17 @@ export class Lowerer {
mayBeUndefined: (node) => {
if (explicitlyNonNull(node)) return assertedReadMayBeUndefined(node);
if (mayBeOptional(node)) return true;
// A value the checker types as possibly undefined: `(T | undefined)[]`
// stores T elements (the type mapper), so its undefined values are
// facts of T's arrays.
const checked = typeOrNull(node);
if (
checked !== null &&
(checked.isUnionType() ? ts.constituentTypes(checked) : [checked]).some(
(part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0,
)
)
return true;
// `undefined as unknown as T` keeps the undefined value.
for (
let inner: ts.Expression = node;
@@ -6604,7 +6615,49 @@ export class Lowerer {
const symbol = symbolOf(e);
return symbol !== null && iterationSymbols.has(symbol) && !provenPresent(e, symbol);
},
callbackReturnsUndefined: (node) => callbackReturnsOptional(node),
iterationMayYieldUndefined: (node) => {
const type = typeOrNull(node);
if (type === null || this.checker.isArrayType(type)) return false;
// A tuple yields its elements, in order.
if (this.checker.isTupleType(type))
return panicSafe(
() =>
this.checker
.getTypeArguments(type as ts.TypeReference)
.some((element) =>
(element.isUnionType() ? ts.constituentTypes(element) : [element]).some(
(part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0,
),
),
true,
);
// The first type argument of Set<T>, Map and Set iterators,
// generators and iterables is the value they yield.
const element =
(type.flags & ts.TypeFlags.Object) !== 0
? panicSafe(() => this.checker.getTypeArguments(type as ts.TypeReference)[0], undefined)
: undefined;
if (element === undefined) return false;
return (element.isUnionType() ? ts.constituentTypes(element) : [element]).some(
(part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0,
);
},
callbackReturnsUndefined: (node) => {
if (callbackReturnsOptional(node)) return true;
// A callback the checker types as possibly returning undefined
// (`(m) => map.get(m)`): its results are present undefined
// elements of a `(T | undefined)[]`, which stores T elements.
const type = typeOrNull(node);
return (
type !== null &&
this.checker.getCallSignatures(type).some((signature) => {
const result = this.checker.getReturnTypeOfSignature(signature);
return (result.isUnionType() ? ts.constituentTypes(result) : [result]).some(
(part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0,
);
})
);
},
isGlobal: (node, name) => this.isStdlibGlobal(node, name),
isString: (node) => {
const type = typeOrNull(node);
@@ -6631,6 +6684,14 @@ export class Lowerer {
const rest = sig.params.find((shape) => shape.mode === "rest");
return rest?.type.kind === "array" ? [typeKey(rest.type.elem)] : [];
},
restStart: (call) => {
const callee = peel(call.expression);
if (!ts.isIdentifier(callee) && !ts.isPropertyAccessExpression(callee)) return null;
const symbol = callableSymbolOf(callee);
const sig = symbol ? signatureBySymbol.get(symbol) : undefined;
const at = sig ? sig.params.findIndex((shape) => shape.mode === "rest") : -1;
return at >= 0 ? at : null;
},
markIterationBinding: (node, known) => {
const symbol = symbolOf(node);
if (!symbol) return false;
@@ -6665,6 +6726,7 @@ export class Lowerer {
const cached = scanSites.get(sf);
if (cached !== undefined) return cached;
const nodes: ts.Node[] = [];
const values: ts.Node[] = [];
ts.walkPreorder(sf, (node) => {
if (
ts.isVariableDeclaration(node) ||
@@ -6680,7 +6742,21 @@ export class Lowerer {
ts.isPostfixUnaryExpression(node)
)
nodes.push(node);
// The values these sites store, whose checker types mayBeUndefined
// reads: batched here, since dead bodies are scanned too.
if (ts.isArrayLiteralExpression(node))
for (const element of node.elements.nodes)
values.push(ts.isSpreadElement(element) ? element.expression : element);
else if (ts.isCallExpression(node) || ts.isNewExpression(node))
for (const arg of node.arguments?.nodes ?? [])
values.push(ts.isSpreadElement(arg) ? arg.expression : arg);
else if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken
)
values.push(node.right);
});
this.checker.prefetchCollectionTypes(values);
scanSites.set(sf, nodes);
return nodes;
};
@@ -8844,8 +8920,11 @@ export class Lowerer {
node: ts.Node,
featureOverride?: string,
hintOverride?: string,
reason?: string,
): never {
this.pushDiag(unsupportedDiag(code, locOf(node), featureOverride, hintOverride));
const diag = unsupportedDiag(code, locOf(node), featureOverride, hintOverride);
if (reason !== undefined) diag.reason = reason;
this.pushDiag(diag);
throw new PoisonError();
}
@@ -9071,8 +9150,16 @@ export class Lowerer {
* nothing lowers. Poisons the statement like every other rejection.
* `sym`, when given, picks the wording: surface declared only by the
* adopted @types/node is blamed at @types/node. */
noLowering(surface: string, node: ts.Node, hint?: string, sym?: ts.Symbol | null): never {
this.pushDiag(noLoweringDiag(surface, locOf(node), hint, this.nodeTypesOnlySymbol(sym)));
noLowering(
surface: string,
node: ts.Node,
hint?: string,
sym?: ts.Symbol | null,
reason?: string,
): never {
const diag = noLoweringDiag(surface, locOf(node), hint, this.nodeTypesOnlySymbol(sym));
if (reason !== undefined) diag.reason = reason;
this.pushDiag(diag);
throw new PoisonError();
}
@@ -16,6 +16,9 @@ export interface ElementStateHost {
elementAdmitsUndefined(key: string): boolean;
/** The value stored by `node` can be `undefined` at runtime. */
mayBeUndefined(node: ts.Expression): boolean;
/** Iterating `node`, an iterable other than an array (a Set, a Map's
* values, a generator), can yield `undefined`. */
iterationMayYieldUndefined?(node: ts.Expression): boolean;
/** A callback argument can return `undefined` at runtime. */
callbackReturnsUndefined(node: ts.Expression): boolean;
/** The value may be a plain array-like object, whose missing indices
@@ -28,6 +31,9 @@ export interface ElementStateHost {
/** Element keys of the rest parameter that the spread arguments of
* `call` fill: `[]` when the callee has none, null when it is unknown. */
restKeys(call: ts.CallExpression | ts.NewExpression): readonly string[] | null;
/** The argument index where the callee's rest parameter starts, or null
* when the callee has none or is unknown. */
restStart?(call: ts.CallExpression | ts.NewExpression): number | null;
/** Record that iterating `node`'s binding can observe `undefined`;
* `known` when the iterated array's own facts (not only those shared by
* its element ABI) say so. Returns true when the binding's record grew. */
@@ -353,7 +359,11 @@ export class ArrayElementStates {
/** One stored value, or the elements a spread copies. */
private valueMayBeUndefined(value: ts.Expression): boolean {
if (ts.isSpreadElement(value)) return this.mayYieldUndefined(value.expression);
if (ts.isSpreadElement(value))
return (
this.mayYieldUndefined(value.expression) ||
(this.host.iterationMayYieldUndefined?.(value.expression) ?? false)
);
return this.host.mayBeUndefined(value);
}
@@ -511,14 +521,26 @@ export class ArrayElementStates {
private scanCall(node: ts.CallExpression | ts.NewExpression): boolean {
let changed = false;
const args = node.arguments?.nodes ?? [];
// Spreading a sparse array into a rest parameter fills the holes.
// Spreading a sparse array, or a tuple with undefined elements, into a
// rest parameter fills the holes.
if (args.some((arg) => ts.isSpreadElement(arg))) {
for (const arg of args) {
if (!ts.isSpreadElement(arg) || !this.mayYieldUndefined(arg.expression)) continue;
if (!ts.isSpreadElement(arg) || !this.valueMayBeUndefined(arg)) continue;
const rest = this.host.restKeys(node) ?? this.host.arrayKeys(arg.expression);
if (this.addUndefined(rest)) changed = true;
}
}
// An argument in a rest position that may be undefined is a present
// undefined element of the rest array.
const restStart = this.host.restStart?.(node) ?? null;
if (restStart !== null)
for (const arg of args.slice(restStart))
if (
!ts.isSpreadElement(arg) &&
this.host.mayBeUndefined(arg) &&
this.addUndefined(this.host.restKeys(node))
)
changed = true;
if (this.record(node)) changed = true;
const callee = peel(node.expression);
if (!ts.isPropertyAccessExpression(callee)) return changed;
@@ -16,6 +16,8 @@ export function sanitizeUnregisteredClassTypes(
case "object":
return hasClass(type.className) ? type : F64;
case "array":
type.elem = rewrite(type.elem);
break;
case "set":
type.elem = rewrite(type.elem);
break;
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,7 @@
import { dirname, join, resolve } from "node:path";
import { packageShimRoot, tsgoPath } from "./dts-paths.js";
import { trackedDirectoryExists, trackedReadFile, trackedRealpath } from "./input-tracker.js";
import { readProjectConfig } from "../project-config.js";
import { packageNameOfSpecifier } from "./workspace-registry.js";
import {
BUILTIN_ALIAS_PACKAGES,
@@ -204,36 +205,12 @@ export function packageShimEntries(): string[] {
let unloadedPackages: ReadonlySet<string> = new Set();
/** Reads the project's package-shim configuration: the `scriptc.packages`
* map of the entry's nearest package.json. Called once per program load. */
* entries marked "unloaded" in the entry's nearest package.json
* (project-config.ts validates the rest). Called once per program load. */
export function configurePackageShims(entryPath: string): void {
const packages = new Set<string>();
for (let dir = dirname(resolve(entryPath)); ;) {
const text = trackedReadFile(join(dir, "package.json"));
if (text !== null) {
let parsed: unknown = null;
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
const config =
parsed !== null && typeof parsed === "object"
? (parsed as Record<string, unknown>)["scriptc"]
: undefined;
const map =
config !== null && typeof config === "object"
? (config as Record<string, unknown>)["packages"]
: undefined;
if (map !== null && typeof map === "object") {
for (const [name, kind] of Object.entries(map as Record<string, unknown>)) {
if (kind === "unloaded") packages.add(name);
}
}
break;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
for (const [name, setting] of readProjectConfig(entryPath).packages) {
if (setting.unloaded) packages.add(name);
}
unloadedPackages = packages;
}
@@ -1,4 +1,4 @@
import { runFrontend, type Frontend } from "./pipeline.js";
import { runFrontend, type Frontend, type FrontendOptions } from "./pipeline.js";
import { loadProgram } from "./program.js";
import { FrontendServices, type ComptimeEvaluator } from "./services.js";
import { createNativeTs7Api } from "./ts7/native-api.js";
@@ -13,6 +13,7 @@ export function runNativeFrontend(
externalTypes?: Readonly<Record<string, string>>,
evaluateComptime?: ComptimeEvaluator,
libraryNpmStatic: readonly string[] = [],
options: FrontendOptions = {},
): Frontend {
const services = new FrontendServices(
(options) => createNativeTs7Api({ ...options, executable }),
@@ -31,6 +32,7 @@ export function runNativeFrontend(
npmStatic,
externalTypes,
libraryNpmStatic,
options,
);
return {
...frontend,
+125 -6
View File
@@ -46,6 +46,14 @@ import { CONTRACT_TYPES_MODULE } from "./contract-types.js";
import { THREADS_MODULE } from "./threads-import.js";
import { erasedTypeOnlyImport } from "./type-only-imports.js";
import { EXPERIMENTAL_MODULE } from "./experimental-import.js";
import { readProjectConfig } from "../project-config.js";
import { packageDirOf } from "../node-islands/origin.js";
import {
scanFiles,
type NodeIslandScan,
type RefusalKind,
type RefusalRecord,
} from "../node-islands/scan.js";
/** Where a package-anchored preflight diagnostic sits, as
* `<package path>:<line>` — the SC2013 refusal that drops the package
@@ -104,17 +112,33 @@ export interface Frontend {
npmStatic: NpmStaticStatus[];
/** Each judged npm package's first import site, used to anchor refusals. */
npmImportSites: ReadonlyMap<string, SrcLoc>;
/** The Node island detector's input (report-only): the program's
* modules with their pre-scan markers, package import edges, and how
* each refused package was refused. Call before dispose. */
nodeIslandScan?: ((opts: { userJs: boolean }) => NodeIslandScan) | undefined;
/** Diagnostics that do not fail a build: unknown settings in the
* project's scriptc configuration (SC8004), which builds do not use. */
warnings?: ScrDiagnostic[];
/** Releases the frontend's resources (the spawned tsgo server). Call
* exactly once, after the last lower(). */
dispose: () => void;
}
/** Hosts own transport lifetimes; shared compiler stages own the frontend. */
/** What a frontend computes beyond the build's own needs. */
export interface FrontendOptions {
/** 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. */
nodeIslands?: boolean;
}
export type FrontendFactory = (
entryPath: string,
npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic?: readonly string[],
options?: FrontendOptions,
) => Frontend;
/** Adds the program modules a literal `import()` reaches to `scanned`,
@@ -385,12 +409,37 @@ function packagesNamedByDiag(message: string, optedIn: ReadonlySet<string>): Set
return hits;
}
/** The modules of packages the drop-and-reload loop is about to remove,
* scanned on the program that still holds them. Report-only: a detector
* failure here never changes the build's outcome. */
function scanDroppedPackages(
load: LoadResult,
packages: readonly string[],
into: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] },
): void {
if (packages.length === 0) return;
try {
const wanted = new Set(packages);
const files = load.program.getImplementationSourceFiles().filter((sf) => {
if (sf.isDeclarationFile) return false;
const at = packageDirOf(sf.fileName);
return at !== null && wanted.has(at.name);
});
const scanned = scanFiles(load.program, files, { userJs: false, dropped: true, edges: true });
into.modules.push(...scanned.modules);
into.edges.push(...scanned.edges);
} catch {
/* report-only detection */
}
}
export function runFrontend(
entryPath: string,
loadProgram: ProgramLoader,
npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic: readonly string[] = [],
options: FrontendOptions = {},
): Frontend {
// A preflight or package probe may throw after its host has opened. Track
// only live loads, releasing each immediately on normal fallback; this
@@ -430,6 +479,7 @@ export function runFrontend(
externalTypes,
[...new Set(names)],
transitive,
options,
);
} catch (error) {
for (const load of active) {
@@ -451,6 +501,7 @@ function loadFrontend(
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic: readonly string[] = [],
transitive: Set<string> = new Set(),
options: FrontendOptions = {},
): Frontend {
// Resolver package/workspace metadata is intentionally shared across the
// several load attempts of ONE auto-detection fixpoint, but never across
@@ -459,6 +510,16 @@ function loadFrontend(
clearResolveCaches();
const statuses: NpmStaticStatus[] = [];
const npmSites = new Map<string, SrcLoc>();
// Node island detection (report-only): how each package was refused, and
// the modules of packages removed from the program.
const refusals: RefusalRecord[] = [];
const droppedScan: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] } = {
modules: [],
edges: [],
};
const recordRefusal = (pkg: string, kind: RefusalKind, diag: ScrDiagnostic | null): void => {
refusals.push({ package: pkg, kind, diag });
};
// Explicit library attempts bypass only the auto-selection heuristics.
// The shared preflight/lowering refusal and dependency-closure checks remain.
const judged = new Set<string>(libraryNpmStatic);
@@ -514,6 +575,7 @@ function loadFrontend(
status: "refused",
detail: `mapped as an external host module by --external-types (${specs.map((s) => JSON.stringify(s)).join(", ")})`,
});
recordRefusal(pkg, "external", null);
return false;
});
}
@@ -547,19 +609,25 @@ function loadFrontend(
const typecheckRefused = new Set<string>();
while (effective.size > 0) {
const reasons = new Map<string, string>(npmStaticOffenders());
const kinds = new Map<string, { kind: RefusalKind; diag: ScrDiagnostic | null }>();
for (const pkg of reasons.keys()) kinds.set(pkg, { kind: "offender", diag: null });
// Bundler interop the rewrite could not finish refuses its package
// only where the compiled program can load the file.
if (npmStaticFileDegrades().size > 0) {
const loadable = npmStaticLoadableFiles(load.program, load.moduleOrder);
for (const [file, { pkg, reason }] of npmStaticFileDegrades()) {
if (!reasons.has(pkg) && (loadable === null || loadable.has(file)))
if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) {
reasons.set(pkg, reason);
kinds.set(pkg, { kind: "degrade", diag: null });
}
}
}
for (const d of preflight) {
const pkg = npmStaticPackageOfPath(d.loc.file);
if (pkg !== null && !reasons.has(pkg))
if (pkg !== null && !reasons.has(pkg)) {
reasons.set(pkg, `${d.code} at ${packageDiagWhere(load, d.loc)}: ${d.message}`);
kinds.set(pkg, { kind: "preflight", diag: d });
}
}
if (![...reasons.keys()].some((p) => effective.has(p))) {
const named = new Map<string, number>();
@@ -571,6 +639,7 @@ function loadFrontend(
}
for (const [pkg, count] of named) {
typecheckRefused.add(pkg);
kinds.set(pkg, { kind: "typecheck", diag: null });
reasons.set(
pkg,
`its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files (bundler-emitted surfaces type only as far as inference reaches)`,
@@ -579,9 +648,12 @@ function loadFrontend(
}
const dropping = [...reasons.keys()].filter((p) => effective.has(p));
if (dropping.length === 0) break;
if (options.nodeIslands === true) scanDroppedPackages(load, dropping, droppedScan);
for (const p of dropping) {
effective.delete(p);
statuses.push({ package: p, status: "refused", detail: reasons.get(p)! });
const kind = kinds.get(p);
recordRefusal(p, kind?.kind ?? "offender", kind?.diag ?? null);
}
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
@@ -601,9 +673,12 @@ function loadFrontend(
// graceful per-package degradation, never a failed gate the user cannot
// act on (the note carries the why).
if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) {
const lastResortDropped: string[] = [];
const dropWithNote = (p: string): void => {
effective.delete(p);
typecheckRefused.add(p);
lastResortDropped.push(p);
recordRefusal(p, "typecheck", null);
statuses.push({
package: p,
status: "refused",
@@ -625,11 +700,15 @@ function loadFrontend(
probe.dispose();
if (probeDiags.some((d) => d.code === "SC0001")) dropWithNote(p);
}
if (options.nodeIslands === true)
scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
preflight = checkPreflight(load);
if (preflight.some((d) => d.code === "SC0001") && effective.size > 0) {
for (const p of [...effective]) dropWithNote(p);
if (options.nodeIslands === true)
scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
preflight = checkPreflight(load);
@@ -650,8 +729,16 @@ function loadFrontend(
}
const finalLoad = load;
// An unknown key or value in the project's scriptc configuration makes
// the Node island report unavailable (SC8004). The settings configure
// only that report: a build warns about them and goes on.
const projectConfig = readProjectConfig(entryPath);
const configDiagnostics = projectConfig.diagnostics;
const report = options.nodeIslands === true;
return {
preflight,
preflight:
report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight,
...(!report && configDiagnostics.length > 0 ? { warnings: configDiagnostics } : {}),
entryText: () => finalLoad.entry.text,
entryExports: () => entryFunctionExports(finalLoad.entry),
// The contract scans the PROGRAM's source files, not the runtime
@@ -678,14 +765,23 @@ function loadFrontend(
// are in moduleOrder like any program module, so their bytes join the
// library identity hashes (source_hash/build_id) — compiled code is
// identity, whatever directory it came from.
sourceTexts: () =>
new Map<string, string>(
sourceTexts: () => {
const texts = new Map<string, string>(
[
finalLoad.entry,
...finalLoad.moduleOrder,
...finalLoad.program.getImplementationSourceFiles().filter((sf) => !sf.isDeclarationFile),
].map((sf) => [sf.fileName, sf.text]),
),
);
// SC8004 diagnostics point into the project's package.json.
if (
configDiagnostics.length > 0 &&
projectConfig.manifest !== null &&
projectConfig.manifestText !== null
)
texts.set(projectConfig.manifest, projectConfig.manifestText);
return texts;
},
lower: (opts) => {
const lowered = lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, {
...opts,
@@ -705,6 +801,29 @@ function loadFrontend(
},
npmStatic: statuses,
npmImportSites: npmSites,
nodeIslandScan:
options.nodeIslands !== true
? undefined
: ({ userJs }) => {
const files = new Set<SourceFile>([finalLoad.entry, ...finalLoad.moduleOrder]);
addDynamicImportGraph(finalLoad, files);
// Package edges matter only when the program has npm packages: one
// that loads, one that was refused, or one judged and dropped.
const npm =
statuses.length > 0 ||
droppedScan.modules.length > 0 ||
[...files].some((sf) => sf.fileName.includes("/node_modules/"));
const scanned = scanFiles(finalLoad.program, [...files], {
userJs,
dropped: false,
edges: npm,
});
return {
modules: [...droppedScan.modules, ...scanned.modules],
edges: [...droppedScan.edges, ...scanned.edges],
refusals,
};
},
dispose: finalLoad.dispose,
};
}
@@ -25,6 +25,8 @@ export class ProgramAnalysis {
/** The modules of every import cycle the exact initialization rule admitted. */
readonly exactCycleMembers = new Set<SourceFile>();
cycleEarlyBindings: EarlyBindingsMemo | null = null;
/** Whether some program file can name node:child_process (namesChildProcess). */
namesChildProcess: boolean | null = null;
moduleEarlyBindings: EarlyBindingsMemo | null = null;
/** Top-level require declarations: the first reference to their bindings
* that code running before the require can reach (requireEarlyReference). */
@@ -46,6 +48,7 @@ export class ProgramAnalysis {
this.moduleSourceCandidates.clear();
this.exactCycleMembers.clear();
this.cycleEarlyBindings = null;
this.namesChildProcess = null;
this.moduleEarlyBindings = null;
this.requireEarlyReferences.clear();
this.requireFirstRunnable.clear();
@@ -562,6 +562,12 @@ export function nearestPkgJsonPath(fromFile: string): string | null {
return dir === null ? null : join(dir, "package.json");
}
/** The nearest package scope's "imports" field (undefined without one). */
export function nearestPackageImports(fromFile: string): unknown {
const dir = nearestPkgDir(dirname(resolve(fromFile)));
return dir === null ? undefined : pkgJsonOf(dir)?.imports;
}
/** The target the nearest package scope's "imports" field maps a '#'
* specifier to under Node's ESM runtime conditions: a './' path inside the
* package or another package's specifier. Null when no entry maps it. */
@@ -0,0 +1,45 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, expect, test } from "vitest";
import { checkerReadsLibrariesFromDisk, setCheckerFeatureStore } from "./checker-features.js";
const dir = mkdtempSync(join(tmpdir(), "scriptc-checker-features-"));
afterAll(() => rmSync(dir, { recursive: true, force: true }));
afterEach(() => setCheckerFeatureStore(null));
/** A stand-in checker that records its arguments and exits with `status`. */
function checker(name: string, status: number): { path: string; log: string } {
const path = join(dir, name);
const log = path + ".log";
writeFileSync(path, `#!/bin/sh\necho "$@" >> '${log}'\nexit ${status}\n`);
chmodSync(path, 0o755);
return { path, log };
}
test.skipIf(process.platform === "win32")(
"the checker is asked once whether it reads its library files from the disk, and the answer is stored",
async () => {
const { readFileSync, existsSync } = await import("node:fs");
const entries = new Map<string, string>();
setCheckerFeatureStore({
read: (key) => entries.get(key) ?? null,
write: (key, text) => void entries.set(key, text),
});
const current = checker("current", 0);
const released = checker("released", 2);
expect(checkerReadsLibrariesFromDisk(current.path, {})).toBe(true);
expect(checkerReadsLibrariesFromDisk(released.path, {})).toBe(false);
expect(readFileSync(current.log, "utf8")).toBe("--api --default-libraries-from-disk\n");
expect([...entries.values()].sort()).toEqual(["0", "1"]);
// Later asks use the answers: the checkers do not run again.
expect(checkerReadsLibrariesFromDisk(current.path, {})).toBe(true);
expect(checkerReadsLibrariesFromDisk(released.path, {})).toBe(false);
expect(readFileSync(current.log, "utf8").split("\n").length).toBe(2);
expect(readFileSync(released.log, "utf8").split("\n").length).toBe(2);
// tsgo is never asked.
const tsgo = checker("tsgo", 0);
expect(checkerReadsLibrariesFromDisk(tsgo.path, { SCRIPTC_CHECKER: "tsgo" })).toBe(false);
expect(existsSync(tsgo.log)).toBe(false);
},
);
@@ -0,0 +1,53 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { checkerBackend } from "./checker-backend.js";
import { checkerExecutableIdentity } from "./lib-diagnostics.js";
/** Text entries kept between builds (the native compiler's cache). */
export interface CheckerFeatureStore {
read(key: string): string | null;
write(key: string, text: string): void;
}
let featureStore: CheckerFeatureStore | null = null;
const answers = new Map<string, boolean>();
/** Keeps the answers of checkerReadsLibrariesFromDisk between builds. */
export function setCheckerFeatureStore(store: CheckerFeatureStore | null): void {
featureStore = store;
}
/**
* Whether the tsc-ts at `executable` takes `--api --default-libraries-from-disk`, which has it read the files
* of its default library directory from the disk instead of through the file system callbacks. tsc-ts releases
* before it reject the flag, so it is asked once per executable identity: the stored answer, or else
* `tsc-ts --api --default-libraries-from-disk` with an empty input, which exits with 0 when the flag is known
* and with a usage error when it is not. tsgo is never asked.
*/
export function checkerReadsLibrariesFromDisk(
executable: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (checkerBackend(env) !== "tsc-ts") return false;
const key = createHash("sha256")
.update(JSON.stringify(["default-libraries-from-disk", checkerExecutableIdentity(executable)]))
.digest("hex");
const known = answers.get(key);
if (known !== undefined) return known;
const stored = featureStore?.read(key) ?? null;
let answer: boolean;
if (stored === "1" || stored === "0") answer = stored === "1";
else {
const probe = spawnSync(executable, ["--api", "--default-libraries-from-disk"], {
encoding: "utf8",
stdio: "pipe",
timeout: 10_000,
});
answer = probe.error === undefined && probe.status === 0;
// A probe that could not run says nothing about the flag: ask again next time.
if (probe.error === undefined && probe.status !== null)
featureStore?.write(key, answer ? "1" : "0");
}
answers.set(key, answer);
return answer;
}
@@ -19,14 +19,16 @@ export const CHECKER_SERVER_MEMORY = 2048;
* answers, and the server keeps the files it parsed for the next build.
* Returns the server's name, a digest of everything a server's answers
* depend on besides the files it reads through the build: the tsc-ts
* executable, the working directory, the file system callbacks, timing, and
* the `TSC_TS_*` environment. Returns null when the shared server is not used.
* executable, the working directory, the file system callbacks, timing,
* whether it reads its library files from the disk, and the `TSC_TS_*`
* environment. Returns null when the shared server is not used.
*/
export function checkerServerName(
executable: string,
cwd: string,
timing: boolean,
env: NodeJS.ProcessEnv = process.env,
librariesFromDisk = false,
): string | null {
if (env["SCRIPTC_CHECKER_SERVER"] !== "1") return null;
if (process.platform !== "linux" && process.platform !== "darwin") return null;
@@ -42,6 +44,7 @@ export function checkerServerName(
cwd,
TS7_FILE_SYSTEM_CALLBACKS,
timing,
librariesFromDisk,
tscTsEnv,
]),
)
@@ -1,4 +1,5 @@
import { Ts7Api } from "./api.js";
import { checkerReadsLibrariesFromDisk } from "./checker-features.js";
import { checkerServerName } from "./checker-server.js";
import { attachNativeTs7Process, spawnNativeTs7Wire } from "./native-process.js";
import { Ts7RpcClient } from "./rpc-client.js";
@@ -15,12 +16,17 @@ export function createNativeTs7Api(options: {
shared?: boolean;
}): Ts7Api {
const timing = options.collectTiming ?? false;
// The checker reads its own library files when it can: they are part of the
// installed tsc-ts, so no build serves them differently.
const libraries = checkerReadsLibrariesFromDisk(options.executable);
const server =
options.shared === false ? null : checkerServerName(options.executable, options.cwd, timing);
options.shared === false
? null
: checkerServerName(options.executable, options.cwd, timing, process.env, libraries);
const shared =
server === null
? null
: attachNativeTs7Process(server, options.executable, options.cwd, timing);
: attachNativeTs7Process(server, options.executable, options.cwd, timing, libraries);
if (shared !== null) {
const rpc = new Ts7RpcClient(new Ts7Wire(shared));
try {
@@ -32,7 +38,7 @@ export function createNativeTs7Api(options: {
}
}
return new Ts7Api(
new Ts7RpcClient(spawnNativeTs7Wire(options.executable, options.cwd, timing)),
new Ts7RpcClient(spawnNativeTs7Wire(options.executable, options.cwd, timing, libraries)),
options.fs,
timing,
);
@@ -10,6 +10,7 @@ declare function ts7NativeOpen(
cwd: string,
callbacks: string,
timing: boolean,
librariesFromDisk: boolean,
): number;
declare function ts7NativeAttach(
name: string,
@@ -17,6 +18,7 @@ declare function ts7NativeAttach(
cwd: string,
callbacks: string,
timing: boolean,
librariesFromDisk: boolean,
idleExit: number,
memory: number,
): number;
@@ -63,12 +65,26 @@ export class NativeTs7Process implements Ts7WireIo {
private handle: number;
readonly pid: number;
/** With `handle`, a connection to a shared checker server (attachNativeTs7Process). */
constructor(executable: string, cwd: string, collectTiming = false, handle = 0) {
/** With `handle`, a connection to a shared checker server (attachNativeTs7Process). With
* `librariesFromDisk`, the server reads its default library files from the disk
* (checkerReadsLibrariesFromDisk). */
constructor(
executable: string,
cwd: string,
collectTiming = false,
handle = 0,
librariesFromDisk = false,
) {
this.handle =
handle !== 0
? handle
: ts7NativeOpen(executable, cwd, TS7_FILE_SYSTEM_CALLBACKS, collectTiming);
: ts7NativeOpen(
executable,
cwd,
TS7_FILE_SYSTEM_CALLBACKS,
collectTiming,
librariesFromDisk,
);
if (this.handle === 0) throw nativeError("startup");
this.pid = ts7NativePid(this.handle);
}
@@ -125,6 +141,7 @@ export function attachNativeTs7Process(
executable: string,
cwd: string,
collectTiming = false,
librariesFromDisk = false,
): NativeTs7Process | null {
const handle = ts7NativeAttach(
name,
@@ -132,6 +149,7 @@ export function attachNativeTs7Process(
cwd,
TS7_FILE_SYSTEM_CALLBACKS,
collectTiming,
librariesFromDisk,
CHECKER_SERVER_IDLE_EXIT,
CHECKER_SERVER_MEMORY,
);
@@ -142,6 +160,7 @@ export function spawnNativeTs7Wire(
executable: string,
cwd: string,
collectTiming = false,
librariesFromDisk = false,
): Ts7Wire {
return new Ts7Wire(new NativeTs7Process(executable, cwd, collectTiming));
return new Ts7Wire(new NativeTs7Process(executable, cwd, collectTiming, 0, librariesFromDisk));
}
@@ -152,33 +152,37 @@ export class Ts7Host {
private readonly virtualChanges = new Map<string, "created" | "changed">();
private readonly closingProjects = new Set<string>();
private readonly api: Ts7Api;
/** The text the server reads for a file (its readFile hook). */
private readonly readSource: (fileName: string) => string | null;
private closed = false;
constructor(createApi: Ts7ApiFactory, options?: Ts7HostOptions) {
const virtualFiles = this.virtualFiles;
const shadow = options?.fsShadow ?? null;
// A virtual file, a shadow's replacement, a package shim's declaration
// entry or the file on disk; null when shadowed out of existence or
// missing.
this.readSource = (fileName) => {
const virtual = virtualFiles.get(tsgoPath(fileName));
if (virtual !== undefined) return stripSourceBom(virtual);
if (shadow !== null) {
if (shadow.hideFile(fileName)) return null;
const replacement = shadow.readFile(fileName);
if (replacement !== undefined) return stripSourceBom(replacement);
}
// A package scriptc implements itself (package-shims.ts): its
// installed declaration entry re-exports the shim module, in every
// program the host creates.
const shim = packageShimDeclarationText(fileName);
if (shim !== undefined) return shim;
const source = trackedReadFile(fileName);
return source === null ? null : stripSourceBom(source);
};
this.api = createApi({
cwd: options?.cwd ?? process.cwd(),
collectTiming: options?.collectTiming ?? false,
fs: {
// string => virtual (or shadowed) hit; null => shadowed out of
// existence; undefined => real-FS fallthrough.
readFile: (fileName) => {
const virtual = virtualFiles.get(tsgoPath(fileName));
if (virtual !== undefined) return stripSourceBom(virtual);
if (shadow !== null) {
if (shadow.hideFile(fileName)) return null;
const replacement = shadow.readFile(fileName);
if (replacement !== undefined) return stripSourceBom(replacement);
}
// A package scriptc implements itself (package-shims.ts): its
// installed declaration entry re-exports the shim module, in every
// program the host creates.
const shim = packageShimDeclarationText(fileName);
if (shim !== undefined) return shim;
const source = trackedReadFile(fileName);
return source === null ? null : stripSourceBom(source);
},
readFile: this.readSource,
fileExists: (fileName) => {
if (virtualFiles.has(tsgoPath(fileName))) return true;
if (shadow !== null && shadow.hideFile(fileName)) return false;
@@ -193,6 +197,12 @@ export class Ts7Host {
});
}
/** The text this host serves for a file (null when it serves none),
* without asking the server for its syntax tree. */
sourceText(fileName: string): string | null {
return this.readSource(fileName);
}
/** Registers an in-memory file served to tsgo by the virtual-FS hooks. */
addVirtualFile(path: string, content: string): void {
this.ensureOpen();
@@ -315,6 +325,12 @@ export class Ts7Program {
return this.project.program.getSourceFile(resolve(fileName));
}
/** A file's text as the host serves it, without fetching its syntax
* tree (Ts7Host.sourceText). */
getSourceText(fileName: string): string | null {
return this.host.sourceText(fileName);
}
getSourceFileNames(): readonly string[] {
this.snapshot.ensureActive();
return (this.sourceFileNamesCache ??= this.project.program.getSourceFileNames());
+18 -1
View File
@@ -2349,6 +2349,23 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
elem = unitOnlyUnion(unions);
if (!elem) return null;
// `(T | undefined)[]` of a number, string or boolean T stores T
// elements: an array slot holds a present undefined by its own state,
// so a T[] seen as `(T | undefined)[]` stays the same array.
let optionalElements = false;
if (elem.kind === "union") {
const arms = unions.get(elem.unionId)?.arms;
const present =
arms?.length === 2 ? arms.find((arm) => arm.kind !== "undefinedT") : undefined;
if (
present !== undefined &&
arms!.some((arm) => arm.kind === "undefinedT") &&
(present.kind === "f64" || present.kind === "string" || present.kind === "bool")
) {
elem = present;
optionalElements = true;
}
}
// A dyn ELEMENT makes the WHOLE array the checked-dynamic value:
// `unknown[]`, `object[]`, and the collapsed `(string | object)[]`
// (the plugins-slot shape) — the checked-dynamic tree has real arrays, so length/
@@ -2370,7 +2387,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// drain of the auxiliary-server registries): handles are ordinary
// refcounted REF elements (their `_v` adapters, no trace — both drop
// their closures at their terminal event).
return arrayOf(elem);
return optionalElements ? { kind: "array", elem, optionalElements: true } : arrayOf(elem);
}
// Tuples: `[string, string]` maps to an interned RECORD shape flagged
// `tuple`, with one field per position named by its index ("0", "1", ...)
+20 -4
View File
@@ -46,6 +46,8 @@ export type {
AnalyzeResult,
} from "./compile-types.js";
import { compilePackedLibrary } from "./backend/library-pack.js";
import { node24FetchCompatProfile } from "./compat/fetch-profile.js";
import { libFnSignatures } from "./ir/builtin-signatures.js";
import { InternalCompilerError } from "./errors.js";
import { frontendProcessExecArgv } from "./frontend-process-args.js";
import { type ChildProcess, spawn } from "node:child_process";
@@ -214,11 +216,14 @@ export {
export {
renderCoverage,
renderExplainDynamic,
renderExplainIsland,
coverageEnvelope,
coveragePasses,
type CoverageInput,
type CoverageFailOn,
} from "./coverage/report.js";
export type { NodeIslandReport, NodeIslandEntry } from "./node-islands/summary.js";
export type { NodeIslandOptions, NodeIslandsMode } from "./node-islands/policy.js";
export {
TIER_MODES,
type DemotedUnit,
@@ -246,7 +251,7 @@ export {
type SurfaceManifestEntry,
} from "./coverage/surface-manifest.js";
export {
NODE24_FETCH_COMPAT_PROFILE,
node24FetchCompatProfile,
type FetchCompatEvidence,
type FetchCompatFacet,
type FetchCompatInventory,
@@ -258,7 +263,13 @@ export {
type FetchCompatOption,
type FetchCompatProfile,
} from "./compat/fetch-profile.js";
export { LIB_FN_SIGS } from "./ir/builtin-signatures.js";
export { libFnSignatures } from "./ir/builtin-signatures.js";
/** @deprecated Use `libFnSignatures()`. Built when this module loads; the
* native compiler does not load this module. */
export const LIB_FN_SIGS = libFnSignatures();
/** @deprecated Use `node24FetchCompatProfile()`. Built when this module
* loads; the native compiler does not load this module. */
export const NODE24_FETCH_COMPAT_PROFILE = node24FetchCompatProfile();
export { validateModule } from "./ir/validate.js";
export {
assignLegacyTiers,
@@ -416,8 +427,13 @@ export async function analyzeCached(
return result;
}
const nodeFrontend: FrontendFactory = (entry, npmStatic, externalTypes, libraryNpmStatic) =>
runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic);
const nodeFrontend: FrontendFactory = (
entry,
npmStatic,
externalTypes,
libraryNpmStatic,
options,
) => runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic, options);
/** The checker's part of the executable cache key (`checkerCacheIdentity`), as the CLI's startup route computes it. */
function checkerField(): { checker?: string } {
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,10 @@ const COPIED_NODE_FRACTION = 0.25;
* closure it creates writes through its environment (and transitively the
* closures those create). A parameter or capture absent from its function's
* set holds the value it entered with for the whole call. */
export function writtenLocals(mod: IrModule, nodes?: IrNodeIndex): Map<string, Set<string>> {
export function writtenLocals(
mod: { readonly functions: readonly IrFunction[] },
nodes?: IrNodeIndex,
): Map<string, Set<string>> {
return rebindings(mod, nodes).written;
}
@@ -31,7 +34,7 @@ const REBINDING_KINDS: readonly IrNodeKind[] = [
];
function rebindings(
mod: IrModule,
mod: { readonly functions: readonly IrFunction[] },
nodes?: IrNodeIndex,
): {
written: Map<string, Set<string>>;
+144 -25
View File
@@ -805,6 +805,41 @@ function rewriteCandidates(
return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) };
}
/** A module marked for its int32 array candidates: the candidates of
* each function (int32ArrayCandidates) and the element readers the marking
* inlines. */
interface Int32ArrayMarking {
readonly mod: IrModule;
readonly readers: ReadonlyMap<string, IrFunction>;
readonly candidates: ReadonlyMap<string, ReadonlySet<string>>;
}
/** markInt32ArrayCandidates with the candidates it marked; null when it
* marks none. */
function markCandidates(
mod: IrModule,
keep: ReadonlySet<string> | null,
redirected: ReadonlySet<string>,
): Int32ArrayMarking | null {
if (mod.lib !== undefined) return null;
const readers = new Map<string, IrFunction>();
for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn);
const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected);
if (all.size === 0) return null;
const keys = new Set<string>();
const functions = mod.functions.map((fn) => {
const candidates = all.get(fn.name);
if (!candidates) return fn;
for (const id of candidates) keys.add(int32ArrayKey(fn.name, id));
return rewriteCandidates(fn, candidates, readers);
});
return {
mod: { ...mod, functions, int32ArrayCandidates: [...keys] },
readers,
candidates: all,
};
}
/** Propose the module's int32 array candidates (int32ArrayKey keys in
* `int32ArrayCandidates`), only those in `keep` when given, and rewrite
* their functions. */
@@ -813,42 +848,126 @@ export function markInt32ArrayCandidates(
keep: ReadonlySet<string> | null = null,
redirected: ReadonlySet<string> = new Set(),
): IrModule {
if (mod.lib !== undefined) return mod;
const readers = new Map<string, IrFunction>();
for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn);
const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected);
if (all.size === 0) return mod;
const keys = new Set<string>();
const functions = mod.functions.map((fn) => {
const candidates = all.get(fn.name);
if (!candidates) return fn;
for (const id of candidates) keys.add(int32ArrayKey(fn.name, id));
return rewriteCandidates(fn, candidates, readers);
});
return { ...mod, functions, int32ArrayCandidates: [...keys] };
return markCandidates(mod, keep, redirected)?.mod ?? mod;
}
/** Revises a function of a module marked for int32 array candidates when
* some of them drop: the function marked for its candidates outside
* `dropped` (int32ArrayKey keys), which is the unmarked function when none
* is left. */
export type Int32ArrayRevision = (fnName: string, dropped: ReadonlySet<string>) => IrFunction;
/** What a proof of a marked module (selectInt32Arrays' `prove`) keeps. */
export interface Int32ArrayProof {
/** The candidates it keeps. */
readonly kept: ReadonlySet<string>;
/** The functions of the module it proved, in module order, when it
* revised some through the Int32ArrayRevision it was given; otherwise
* null, for the marked module's own. */
readonly functions: readonly IrFunction[] | null;
}
/** The module the backend emits: rewritten for exactly the candidates the
* int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a
* module without int32 arrays is `mod` itself. A dropped candidate's
* rewrite is undone by marking again without it, until every candidate
* marked is kept. `redirected` names the functions some of whose calls the
* int32 slot analysis (`prove`) keeps, so a module without int32 arrays is
* `mod` itself. `redirected` names the functions some of whose calls the
* backend emits as calls of another function (a result-slot entry): their
* parameters keep doubles. */
* parameters keep doubles.
*
* The proof of the marked module gets an Int32ArrayRevision: as candidates
* drop, it revises their functions and goes on proving the revised module,
* so one analysis ends with the module marked for the candidates it keeps.
* That module is the answer when marking for those candidates again proposes
* exactly them, in the functions the proof revised. A proof that cannot
* follow a revision answers null; then each round marks the module, proves
* it without revisions, and marks again without the dropped candidates,
* until a round keeps every candidate it marks (four rounds at most). */
export function selectInt32Arrays(
mod: IrModule,
prove: (marked: IrModule) => ReadonlySet<string>,
prove: (marked: IrModule, revise: Int32ArrayRevision | null) => Int32ArrayProof | null,
redirected: (mod: IrModule) => ReadonlySet<string> = () => new Set(),
): IrModule {
let keep: ReadonlySet<string> | null = null;
const others = redirected(mod);
for (let round = 0; round < 4; round++) {
const marking = markCandidates(mod, null, others);
if (marking === null) return mod;
const original = new Map(mod.functions.map((fn) => [fn.name, fn]));
const markedFunctions = new Map(marking.mod.functions.map((fn) => [fn.name, fn]));
// Each function's latest revision and the candidates it is marked for.
const revisions = new Map<string, { fn: IrFunction; ids: ReadonlySet<string> }>();
const revise: Int32ArrayRevision = (fnName, dropped) => {
const candidates = marking.candidates.get(fnName) ?? new Set<string>();
const ids = new Set<string>();
for (const id of candidates) if (!dropped.has(int32ArrayKey(fnName, id))) ids.add(id);
const latest = revisions.get(fnName);
if (latest !== undefined && latest.ids.size === ids.size) return latest.fn;
if (latest === undefined && ids.size === candidates.size) return markedFunctions.get(fnName)!;
const fn =
ids.size === 0
? original.get(fnName)!
: rewriteCandidates(original.get(fnName)!, ids, marking.readers);
revisions.set(fnName, { fn, ids });
return fn;
};
const proof = prove(marking.mod, revise);
const proposed = marking.mod.int32ArrayCandidates ?? [];
let keep: ReadonlySet<string> | null = null;
let round = 0;
if (proof !== null && proof.functions === null) {
// The marked module itself was proved: the first round.
if (proposed.every((key) => proof.kept.has(key))) return marking.mod;
keep = new Set(proposed.filter((key) => proof.kept.has(key)));
round = 1;
} else if (proof !== null) {
const settled = settleRevisions(mod, marking, proof, revisions, others);
if (settled !== null) return settled;
}
for (; round < 4; round++) {
const marked = markInt32ArrayCandidates(mod, keep, others);
if (marked === mod) return mod;
const kept = prove(marked);
const proposed = marked.int32ArrayCandidates ?? [];
if (proposed.every((key) => kept.has(key))) return marked;
keep = new Set(proposed.filter((key) => kept.has(key)));
const kept = prove(marked, null)!.kept;
const roundProposed = marked.int32ArrayCandidates ?? [];
if (roundProposed.every((key) => kept.has(key))) return marked;
keep = new Set(roundProposed.filter((key) => kept.has(key)));
}
return mod;
}
/** The module marked for the candidates a revising proof kept, built from
* the functions it proved: null unless marking `mod` for those candidates
* proposes, in each function, exactly the candidates its proved function is
* marked for. `mod` itself when it proposes none. */
function settleRevisions(
mod: IrModule,
marking: Int32ArrayMarking,
proof: Int32ArrayProof,
revisions: ReadonlyMap<string, { fn: IrFunction; ids: ReadonlySet<string> }>,
redirected: ReadonlySet<string>,
): IrModule | null {
const kept = proof.kept;
const functions = proof.functions!;
if (functions.length !== mod.functions.length) return null;
const all = moduleInt32ArrayCandidates(
mod,
new Set(marking.readers.keys()),
new Set([...(marking.mod.int32ArrayCandidates ?? [])].filter((key) => kept.has(key))),
redirected,
);
const keys = new Set<string>();
for (let i = 0; i < mod.functions.length; i++) {
const fn = mod.functions[i]!;
const proved = functions[i]!;
const candidates = all.get(fn.name);
const revision = revisions.get(fn.name);
const marked = revision?.ids ?? marking.candidates.get(fn.name) ?? new Set<string>();
if ((candidates?.size ?? 0) !== marked.size) return null;
if (candidates !== undefined)
for (const id of candidates) {
if (!marked.has(id)) return null;
keys.add(int32ArrayKey(fn.name, id));
}
const expected =
revision?.fn ?? (marking.candidates.has(fn.name) ? marking.mod.functions[i]! : fn);
if (proved !== expected) return null;
}
if (all.size === 0) return mod;
return { ...mod, functions: [...functions], int32ArrayCandidates: [...keys] };
}
+194 -2
View File
@@ -27,7 +27,7 @@ import {
int32ArraySlot,
int32ArrayStores,
} from "./int32-arrays.js";
import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js";
import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js";
/** The storage slot an array-valued expression reads, as an
* IrContracts.int32Elements key: a local or parameter
@@ -406,6 +406,47 @@ export class Int32Slots {
for (const key of data.checkedInt32Arrays) slots.checkedInt32Arrays.add(key);
return slots;
}
/** Every fact, in a canonical order, with each proven write as its
* position in `mod` (function index, then walk order): two analyses of
* equal modules hold the same facts when their summaries are equal. */
summary(mod: IrModule): string {
const sorted = (values: Iterable<string>): string => [...values].sort().join(",");
const lines = [
`fields ${sorted(this.fields)}`,
`params ${sorted([...this.paramFacts].flatMap(([fn, params]) => [...params].map(([id, r]) => `${fn}\0${id}=${r.min}:${r.max}`)))}`,
`returns ${sorted(this.returns)}`,
`returnRanges ${sorted([...this.returnRanges].map(([fn, r]) => `${fn}=${r.min}:${r.max}`))}`,
`boxes ${sorted(this.boxes)}`,
`boxGroups ${sorted([...this.boxGroupOf].map(([member, group]) => `${member}=${group}`))}`,
`called ${sorted(this.called)}`,
`pinned ${sorted(this.pinned)}`,
`int32Locals ${sorted(this.int32Locals)}`,
`int32Elements ${sorted(this.int32Elements)}`,
`nonNegative ${sorted(this.nonNegative)}`,
`pinViolations ${sorted(this.pinViolations)}`,
`narrowedReads ${this.narrowedReads}`,
`int32Arrays ${sorted(this.int32Arrays)}`,
`checkedInt32Arrays ${sorted(this.checkedInt32Arrays)}`,
];
const positions: string[] = [];
mod.functions.forEach((fn, f) => {
let at = 0;
everyStmtList(fn.body, {
expr: (e) => {
if (this.provenWrites.has(e)) positions.push(`${f}:${at}`);
at++;
return true;
},
stmt: () => {
at++;
return true;
},
});
});
lines.push(`provenWrites ${this.provenWrites.size} ${positions.join(",")}`);
return lines.join("\n");
}
}
/** Int32Slots as plain data (Int32Slots.encode). */
@@ -922,6 +963,95 @@ export interface Int32SlotInputs {
initializerBindings?: ReadonlyMap<string, IrGlobal[]>;
/** The module's node index, read instead of walking the bodies. */
nodes?: IrNodeIndex;
/** Revises a function of a module marked for int32 array candidates when
* candidates drop (int32-arrays.ts Int32ArrayRevision): the function
* marked without the `dropped` candidates. With it, a candidate that drops
* (and keeps no checked stores) takes its sites back to the element-reader
* calls the marking inlined, and the fixpoint goes on over the revised
* functions, so its result is the analysis of the module marked for the
* candidates it keeps. A revision that changes more of a function than
* those sites throws Int32RevisionConflict. */
revise?: (fnName: string, dropped: ReadonlySet<string>) => IrFunction;
}
/** A revision (Int32SlotInputs.revise) changed a function in a way the
* fixpoint cannot follow: a call of a function no other call reaches, or
* any change besides element-reader calls taking the place of their
* inlined reads. The module needs an analysis of its own. */
export class Int32RevisionConflict extends Error {}
/** What the analysis's setup reads from a function body, besides direct
* calls: a revision must leave all of it unchanged. */
function revisionStructure(fn: IrFunction): {
calls: { callee: string; args: number }[];
rest: string;
} {
const calls: { callee: string; args: number }[] = [];
const rest: string[] = [];
const boxed = new Set(fn.locals.filter((local) => local.boxed).map((local) => local.id));
const expr = (e: IrExpr): boolean => {
const referenced = exprFunctionReference(e);
if (referenced !== undefined) rest.push(`ref:${referenced}`);
switch (e.kind) {
case "call":
calls.push({ callee: e.callee, args: e.args.length });
break;
case "new":
rest.push(`new:${e.className}:${e.args.length}`);
break;
case "varRef":
if (boxed.has(e.localId)) rest.push(`read:${e.localId}`);
break;
case "assignExpr":
case "incDec":
rest.push(`${e.kind}:${e.localId}`);
break;
case "fieldGet":
case "fieldIncDec":
rest.push(`${e.kind}:${e.className}\0${e.field}`);
break;
case "closure":
rest.push(`closure:${e.fnName}:${e.captures.join(",")}`);
break;
case "classRef":
rest.push(`classRef:${e.className}:${(e.captures ?? []).join(",")}`);
break;
case "virtualCall":
rest.push(`virtualCall:${e.className}.${e.method}`);
break;
case "instanceOfValue":
rest.push(`instanceOf:${e.classValue.kind}`);
break;
}
if (BOUNDARY_KINDS.has(e.kind)) rest.push(`boundary:${e.kind}`);
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "forOf":
rest.push(`forOf:${s.localId}`);
break;
case "tryCatch":
if (s.catchLocalId) rest.push(`catch:${s.catchLocalId}`);
break;
case "assign":
case "varDecl":
if (boxed.has(s.localId)) rest.push(`${s.kind}:${s.localId}`);
break;
case "throw":
rest.push("throw");
break;
}
return everyStmtChild(s, expr, stmt);
};
for (const s of fn.body) stmt(s);
rest.sort();
for (const local of fn.locals)
if (local.boxed || local.tdz)
rest.push(`local:${local.id}:${local.boxed === true}:${local.tdz === true}`);
for (const capture of fn.captures ?? []) rest.push(`capture:${capture.localId}`);
for (const capture of fn.classCaptures ?? []) rest.push(`classCapture:${capture.localId}`);
return { calls, rest: rest.join("\n") };
}
export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): Int32Slots {
@@ -1293,6 +1423,36 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
});
return found;
};
// Candidates dropped without checked stores, and the revision of the
// functions that hold them (Int32SlotInputs.revise).
const dropped = new Set<string>();
const revise = (names: ReadonlySet<string>): void => {
for (const name of names) {
const previous = functions.get(name)!;
const next = inputs.revise!(name, dropped);
if (next === previous) continue;
const before = revisionStructure(previous);
const after = revisionStructure(next);
if (before.rest !== after.rest) throw new Int32RevisionConflict(name);
// The revision adds the element-reader calls back: each must be of a
// function other calls already reach, with its arity.
const counts = new Map<string, number>();
for (const { callee } of before.calls) counts.set(callee, (counts.get(callee) ?? 0) + 1);
for (const { callee, args } of after.calls) {
const left = counts.get(callee) ?? 0;
if (left > 0) {
counts.set(callee, left - 1);
continue;
}
const target = functions.get(callee);
if (!called.has(callee) || target === undefined || target.params.length !== args)
throw new Int32RevisionConflict(name);
reads(`ret:${callee}`, name);
}
for (const left of counts.values()) if (left > 0) throw new Int32RevisionConflict(name);
functions.set(name, next);
}
};
const provenByFunction = new Map<string, IrExpr[]>();
// Each function's parameter and result writes under its latest analysis:
// at the fixpoint, the ranges of every write under the final facts.
@@ -1337,6 +1497,8 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
for (const id of globalArrays) both.add(id);
arrays = both;
}
// Functions whose candidates this visit drops, revised after it.
const revised = new Set<string>();
if (arrays && arrayGroups) {
const requeue = (name: string): void => {
if (queued.has(name)) return;
@@ -1371,9 +1533,39 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
checkableInt32Allocations(fn, id)
)
slots.checkedInt32Arrays.add(key);
else for (const member of group) reason(`array:${member}`, "an element store is not int32");
else {
for (const member of group) reason(`array:${member}`, "an element store is not int32");
if (inputs.revise !== undefined) {
// The revised module no longer marks the group: its functions
// leave the candidate sets the visits below consult.
for (const member of group) {
dropped.add(member);
if (member.startsWith("\0")) {
globalArrays.delete(member.slice(1));
const names = arrayGroups.globalReaders.get(member);
if (names) for (const name of names) revised.add(name);
} else {
const cut = member.indexOf("\0");
const owner = member.slice(0, cut);
const ids = arrayCandidates.get(owner);
ids?.delete(member.slice(cut + 1));
if (ids?.size === 0) arrayCandidates.delete(owner);
revised.add(owner);
}
}
globalArrayFunctions.clear();
for (const [member, names] of arrayGroups.globalReaders)
if (!dropped.has(member)) for (const name of names) globalArrayFunctions.add(name);
}
}
}
}
if (revised.size > 0) {
revise(revised);
// A revised body is analyzed again (it was queued with its readers);
// the writes of the body it replaces no longer count.
if (functions.get(fn.name) !== fn) continue;
}
// An int32 written into a slot still assumed non-negative: widen the
// assumption when the value may be negative, and reanalyze its readers.
const settle = (slot: string, e: IrExpr): void => {
+11 -2
View File
@@ -87,7 +87,16 @@ export type IrType =
| { kind: "date" }
| { kind: "string" } // heap, refcounted, UTF-8
| { kind: "bool" }
| { kind: "array"; elem: IrType } // heap, refcounted, monomorphic elements
| {
kind: "array";
elem: IrType;
/** A `(T | undefined)[]` of a number, string or boolean T, stored as
* T elements (which hold a present undefined by their state). Not
* part of the type's identity (typeEquals, typeKey): it only lets a
* checked conversion from a dynamic value accept undefined
* elements. */
optionalElements?: true;
} // heap, refcounted, monomorphic elements
/** ES `Map<K, V>` — heap, refcounted, insertion-ordered hash map with ONE
* runtime representation (ScrMap) and type-directed key/value handling,
* exactly the array pattern (never per-instantiation structs). Keys use
@@ -3940,7 +3949,7 @@ export type IrExpr =
}
/** Standard-library call (`process` members, node:fs functions). `fn` is a
* closed union; arg/result types are fixed per member (validated against
* LIB_FN_SIGS). Property READS (`process.argv`, `process.platform`) are
* libFnSignatures()). Property READS (`process.argv`, `process.platform`) are
* zero-arg libCalls. Args are BORROWED by the operation (frame temps
* release at statement end); refcounted results come back owned (+1) —
* `process.argv` returns +1 on ONE interned array (JS identity:
+10 -4
View File
@@ -66,7 +66,7 @@ import {
import { unionWideningTags } from "./analysis.js";
import { alwaysReturns } from "./control-flow.js";
import { dynamicOperationOf, functionTier } from "./tiers.js";
import { LIB_FN_SIGS, pipelinePumpedRuns } from "./builtin-signatures.js";
import { libFnSignatures, pipelinePumpedRuns } from "./builtin-signatures.js";
import { ALWAYS_THROW_LIB_FNS } from "./builtin-effects.js";
/** The promisified crypto calls' value kinds besides checked-dynamic. */
@@ -2072,12 +2072,18 @@ function validateFunction(
}
case "arrayNewLen": {
// Mapper-less Array.from({length: n}): absent-slot fill exists
// only for refcounted element kinds (frontend fences scalars).
// only for refcounted element kinds. Number and boolean slots are
// filled with present undefined values right after
// (`(T | undefined)[]`); the frontend fences other scalar uses.
if (e.type.kind !== "array") {
err(`arrayNewLen must be array-typed, got ${e.type.kind}`, e.loc);
break;
}
if (!isRefCounted(e.type.elem)) {
if (
!isRefCounted(e.type.elem) &&
e.type.elem.kind !== "f64" &&
e.type.elem.kind !== "bool"
) {
err(
`arrayNewLen with non-refcounted ${e.type.elem.kind} elements (no absent value)`,
e.loc,
@@ -4104,7 +4110,7 @@ function validateFunction(
}
if (e.fn === "worker.new" && !workers)
errors.push({ message: "Worker construction requires worker execution mode", loc: e.loc });
const sig = LIB_FN_SIGS[e.fn];
const sig = libFnSignatures()[e.fn];
if (!sig) {
err(`libCall of unknown library function "${e.fn as string}"`, e.loc);
return;
+6 -6
View File
@@ -56,9 +56,9 @@ import {
} from "../coverage/surface-manifest.js";
import { libFenceDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js";
import {
AMBIENT_SURFACE_FNS,
ambientSurfaceFns,
BUILTIN_MODULE_CONSTS,
BUILTIN_MODULE_FNS,
builtinModuleFns,
BUILTIN_MODULE_FN_ALIASES,
STATIC_MATH_FNS,
STATIC_MATH_PROPS,
@@ -125,7 +125,7 @@ interface FenceTaxonomy {
/** node-builtin constant ids: reads fold to literals at compile time. */
foldedIds: Set<string>;
/** Ambient dedicated-path surfaces (Date/perf_hooks/process): manifest
* id → the libCall spellings witnessing reach (AMBIENT_SURFACE_FNS —
* id → the libCall spellings witnessing reach (ambientSurfaceFns() —
* the same rows the manifest projects the entries from). */
ambientFns: Map<string, readonly string[]>;
}
@@ -148,7 +148,7 @@ function fenceTaxonomy(): FenceTaxonomy {
const builtinFns = new Map<string, readonly string[]>();
const builtinRoots = new Map<string, readonly string[]>();
const foldedIds = new Set<string>();
for (const [mod, members] of Object.entries(BUILTIN_MODULE_FNS)) {
for (const [mod, members] of Object.entries(builtinModuleFns())) {
const root = new Set<string>();
for (const [member, entry] of Object.entries(members!)) {
if (entry === undefined) continue;
@@ -156,7 +156,7 @@ function fenceTaxonomy(): FenceTaxonomy {
// The bare-path and url tables rebind per TARGET platform
// (builtinModuleFnsOf): a fence on the bare id covers both flavors.
if (mod === "path") {
const w = BUILTIN_MODULE_FNS["path/win32"]?.[member];
const w = builtinModuleFns()["path/win32"]?.[member];
if (w !== undefined) fns.add(w.fn);
}
if (mod === "url" && member === "pathToFileURL") fns.add("url.pathToFileURLWin32");
@@ -173,7 +173,7 @@ function fenceTaxonomy(): FenceTaxonomy {
}
for (const member of Object.keys(STATIC_MATH_PROPS)) foldedIds.add(`stdlib.math.${member}`);
const ambientFns = new Map(
AMBIENT_SURFACE_FNS.map((row) => [row.id, row.fns as readonly string[]]),
ambientSurfaceFns().map((row) => [row.id, row.fns as readonly string[]]),
);
taxonomy = {
byId,
+6
View File
@@ -21,6 +21,7 @@ import {
checkerExecutableIdentity,
setLibDiagnosticsStore,
} from "../frontend/ts7/lib-diagnostics.js";
import { setCheckerFeatureStore } from "../frontend/ts7/checker-features.js";
import { loadNativeToolchain } from "./toolchain.js";
import { buildSanitizedRuntime } from "./sanitizer.js";
import { runCompilerTask } from "./task.js";
@@ -67,6 +68,11 @@ async function main(): Promise<number> {
},
checkerExecutableIdentity(toolchain.ts7Executable),
);
setCheckerFeatureStore({
read: (key) =>
store.read("checker-features", key, NATIVE_STRING_BYTES)?.toString("utf8") ?? null,
write: (key, text) => store.write("checker-features", key, text),
});
}
compiler = new NativeCompiler(toolchain, cache);
return compiler;
+2 -1
View File
@@ -112,7 +112,7 @@ export class NativeCompiler {
private readonly cache: NativeCache | null = null,
) {
const evaluator = toolchain.comptimeExecutable;
this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic) =>
this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic, options) =>
runNativeFrontend(
entry,
toolchain.ts7Executable,
@@ -123,6 +123,7 @@ export class NativeCompiler {
: (source, timeout) =>
evaluateNativeComptime(source, timeout, toolchain.ts7Executable, evaluator),
libraryNpmStatic,
options,
);
}
+26 -1
View File
@@ -1,7 +1,7 @@
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import {
LINUX_ARM64_GNU_TARGET,
nativeCodegenTarget,
@@ -23,6 +23,7 @@ function text(input: ReturnType<ReturnType<typeof fixture>["prepare"]>) {
const scratch: string[] = [];
afterEach(() => {
vi.unstubAllEnvs();
for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true });
});
@@ -197,6 +198,30 @@ test("structurally invalid cached features cause a rebuild even with a valid dig
expect(f.loads()).toBe(2);
});
test("timing, cache placement and emission worker variables keep the frontend output", () => {
for (const name of [
"SCRIPTC_CACHE_DIR",
"SCRIPTC_CACHE_MAX_MB",
"SCRIPTC_EMIT_WORKERS",
"SCRIPTC_EMIT_WORKER_FUNCTIONS",
"SCRIPTC_TIMING",
"SCRIPTC_TEST_FRONTEND_KEY",
])
vi.stubEnv(name, undefined);
const f = fixture();
expect(f.prepare().ok).toBe(true);
vi.stubEnv("SCRIPTC_CACHE_DIR", f.cache.root);
vi.stubEnv("SCRIPTC_CACHE_MAX_MB", "8192");
vi.stubEnv("SCRIPTC_EMIT_WORKERS", "1");
vi.stubEnv("SCRIPTC_EMIT_WORKER_FUNCTIONS", "100");
vi.stubEnv("SCRIPTC_TIMING", "0");
expect(f.prepare().ok).toBe(true);
expect(f.loads()).toBe(1);
vi.stubEnv("SCRIPTC_TEST_FRONTEND_KEY", "1");
expect(f.prepare().ok).toBe(true);
expect(f.loads()).toBe(2);
});
test("serialized IR travels as bounded parts through the frontend cache", () => {
const f = fixture();
const first = f.prepare({ emitIr: true });
+20 -6
View File
@@ -211,6 +211,24 @@ function implementationIdentity(toolchain: NativeToolchain): string {
);
}
/** SCRIPTC_* variables that report on a build, place its cache or set
* backend emission parallelism. The frontend's output does not depend on
* them, so they are left out of its cache key. */
const FRONTEND_INDEPENDENT_VARIABLES = new Set([
"SCRIPTC_CACHE_DIR",
"SCRIPTC_CACHE_MAX_MB",
"SCRIPTC_EMIT_WORKERS",
"SCRIPTC_EMIT_WORKER_FUNCTIONS",
"SCRIPTC_TIMING",
]);
/** The SCRIPTC_* variables a frontend cache entry depends on, sorted by name. */
function frontendEnvironment(): [string, string | undefined][] {
return Object.entries(process.env)
.filter(([name]) => name.startsWith("SCRIPTC_") && !FRONTEND_INDEPENDENT_VARIABLES.has(name))
.sort(([a], [b]) => a.localeCompare(b));
}
function validFeatures(value: unknown): value is NativeLinkFeatures {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const features = value as Record<string, unknown>;
@@ -363,9 +381,7 @@ export function prepareNativeExecutable(
implementation: implementationIdentity(toolchain),
target: toolchain.target.name,
declarations: toolchain.declarationsRoot ?? null,
environment: Object.entries(process.env)
.filter(([name]) => name.startsWith("SCRIPTC_"))
.sort(([a], [b]) => a.localeCompare(b)),
environment: frontendEnvironment(),
}),
);
const cached = readFrontendCache(cache, key, exclusions);
@@ -493,9 +509,7 @@ export function prepareNativeLibrary(
implementation: implementationIdentity(toolchain),
target: toolchain.target.name,
declarations: toolchain.declarationsRoot ?? null,
environment: Object.entries(process.env)
.filter(([name]) => name.startsWith("SCRIPTC_"))
.sort(([a], [b]) => a.localeCompare(b)),
environment: frontendEnvironment(),
}),
);
const cached = readFrontendCache(cache, key, exclusions);
@@ -0,0 +1,131 @@
/* Diagnostic → Node island class (DESIGN.md §1.3). A refusal in a module
* of an npm package is a reason for that package to run in a Node island;
* its class says why: the runtime itself (R1), a mutable object model (R2),
* a shipping problem (packaging), or a construct inside the native subset
* that scriptc cannot compile yet (gap). The reason id on the diagnostic
* (ScrDiagnostic.reason) decides first; the code and message decide for
* refusal sites that do not carry one. */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { NodeIslandMarker } from "./detect.js";
import { classOfRule, ruleInfo, ruleOfReason } from "./rules.js";
import type { RefusalRecord } from "./scan.js";
import type { NodeIslandReason, NodeIslandReasonSource } from "./summary.js";
/** Message shapes of refusal sites that predate reason ids. */
const MESSAGE_REASONS: { code: string; pattern: RegExp; reason: string }[] = [
{ code: "SC2010", pattern: /./, reason: "code-from-string" },
{ code: "SC1010", pattern: /'(?:node:)?vm'/, reason: "vm" },
{ code: "SC1090", pattern: /^dynamic import\(\) of computed/, reason: "dynamic-import" },
{ code: "SC1090", pattern: /require\(\) with a computed specifier/, reason: "dynamic-require" },
{ code: "SC1090", pattern: /'with' statement/, reason: "with" },
{ code: "SC1090", pattern: /sloppy-mode parameter aliases/, reason: "sloppy-arguments" },
{ code: "SC2020", pattern: /^'(?:eval|Function)'/, reason: "code-from-string" },
{
code: "SC2020",
pattern: /createRequire's require with this argument shape/,
reason: "dynamic-require",
},
{ code: "SC2020", pattern: /native addon/, reason: "native-addon" },
{
code: "SC2020",
pattern: /through require\.cache|require\.extensions|Module\._|module\.register/,
reason: "loader-hook",
},
{
code: "SC2020",
pattern: /process\.binding|_linkedBinding|internalBinding|setFlagsFromString/,
reason: "engine-internal",
},
{ code: "SC2020", pattern: /\bWeakRef\b|\bFinalizationRegistry\b/, reason: "weak-ref" },
{ code: "SC1100", pattern: /\bWeakRef\b|\bFinalizationRegistry\b/, reason: "weak-ref" },
];
function reasonIdOf(d: ScrDiagnostic): string | null {
if (d.reason !== undefined) return d.reason;
for (const entry of MESSAGE_REASONS) {
if (entry.code === d.code && entry.pattern.test(d.message)) return entry.reason;
}
return null;
}
/** Codes that are not reasons of the package they appear in: a cascade
* marker repeats a root cause, an import of another refused package is
* that package's island boundary, and divergences compile. */
export function isIslandReasonCode(code: string): boolean {
if (code === "SC2004" || code === "SC2013") return false;
if (/^SC6\d{3}$/.test(code)) return false;
return true;
}
export function classifyDiagnostic(
d: ScrDiagnostic,
source: NodeIslandReasonSource,
): NodeIslandReason {
const id = reasonIdOf(d);
const rule = id === null ? null : ruleOfReason(id);
if (rule !== null && id !== null) {
return {
class: classOfRule(rule),
rule,
reason: id,
code: d.code,
message: d.message,
loc: d.loc,
source,
};
}
return {
class: "gap",
rule: null,
reason: "gap",
code: d.code,
message: d.message,
loc: d.loc,
source,
};
}
export function classifyMarker(m: NodeIslandMarker): NodeIslandReason {
return {
class: classOfRule(m.rule),
rule: m.rule,
reason: m.reason,
code: null,
message: `${ruleInfo(m.rule).title}: ${m.construct}`,
loc: m.loc,
source: "pre-scan",
};
}
/** A refused package's status (pipeline.ts): the drop-and-reload record
* when there is one, else the declaration and shipped-JS eligibility bar,
* which is a packaging reason. */
export function classifyRefusal(
detail: string,
record: RefusalRecord | null,
): NodeIslandReason | null {
if (record !== null && record.kind === "external") return null;
if (record !== null && record.kind === "preflight" && record.diag !== null) {
return classifyDiagnostic(record.diag, "package");
}
if (record !== null && record.kind === "offender" && /require|createRequire/.test(detail)) {
return {
class: "runtime",
rule: "R1.2",
reason: "dynamic-require",
code: "SC2013",
message: detail,
loc: null,
source: "package",
};
}
return {
class: "packaging",
rule: null,
reason: "packaging",
code: "SC2013",
message: detail,
loc: null,
source: "package",
};
}
@@ -0,0 +1,165 @@
import { afterAll, describe, expect, test } from "vitest";
import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js";
import { scanModule } from "./detect.js";
afterAll(() => closeSourceParser());
let serial = 0;
/** The markers of one JavaScript module as `rule reason` strings. */
function markers(source: string, ext = "cjs"): string[] {
const sf = parseSourceFile(`/virtual/m${serial++}.${ext}`, source, "js");
return scanModule(sf).map((m) => `${m.rule} ${m.reason}`);
}
describe("R1 runtime markers", () => {
test("R1.1 code from strings", () => {
expect(markers("eval(code);")).toEqual(["R1.1 code-from-string"]);
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"]);
expect(markers("const vm = require('node:vm');")).toEqual(["R1.1 vm"]);
expect(markers("import vm from 'vm';", "mjs")).toEqual(["R1.1 vm"]);
});
test("R1.1 exceptions: the global-object idiom and a capability probe in try", () => {
expect(markers("const g = Function('return this')();")).toEqual([]);
expect(markers("let ok; try { new Function(''); ok = true; } catch { ok = false; }")).toEqual(
[],
);
});
test("R1.2 dynamic module loading", () => {
expect(markers("require(name);")).toEqual(["R1.2 dynamic-require"]);
expect(markers("import(`./${x}.js`);", "mjs")).toEqual(["R1.2 dynamic-import"]);
expect(markers("const r = createRequire(import.meta.url); r(spec);", "mjs")).toEqual([
"R1.2 dynamic-require",
]);
expect(markers("require('./a' + '.js'); require.resolve(name); import('./b.js');")).toEqual([]);
});
test("R1.3 native addons", () => {
expect(markers("require('./build/addon.node');")).toEqual(["R1.3 native-addon"]);
expect(markers("const b = require('bindings')('addon');")).toEqual(["R1.3 native-addon"]);
expect(markers("process.dlopen(module, path);")).toEqual(["R1.3 native-addon"]);
});
test("R1.4 loader and require hooks", () => {
expect(markers("require.extensions['.coffee'] = compile;")).toEqual(["R1.4 loader-hook"]);
expect(markers("delete require.cache[key];")).toEqual(["R1.4 loader-hook"]);
expect(markers("const Module = require('module'); Module._load = load;")).toEqual([
"R1.4 loader-hook",
]);
expect(markers("const m = require('node:module'); m.registerHooks({});")).toEqual([
"R1.4 loader-hook",
]);
expect(markers("require('require-in-the-middle');")).toEqual(["R1.4 loader-hook"]);
});
test("R1.5 engine internals", () => {
expect(markers("process.binding('fs');")).toEqual(["R1.5 engine-internal"]);
expect(markers("const v8 = require('v8'); v8.setFlagsFromString('--x');")).toEqual([
"R1.5 engine-internal",
]);
expect(markers("global.gc();")).toEqual(["R1.5 engine-internal"]);
});
test("R1.6 runtime-only globals", () => {
expect(markers("const r = new WeakRef(o);")).toEqual(["R1.6 weak-ref"]);
expect(markers("const f = new FinalizationRegistry(cb);")).toEqual(["R1.6 weak-ref"]);
expect(markers("if (typeof WeakRef !== 'undefined') {}")).toEqual([]);
expect(markers("const o = { WeakRef: 1 }; o.WeakRef;")).toEqual([]);
});
});
describe("R2 object-model markers", () => {
test("R2.1 constructor functions", () => {
expect(markers("function F() {} const f = new F();")).toEqual(["R2.1 constructor-function"]);
expect(markers("function F() {} F.prototype.m = function () {};")).toEqual([
"R2.1 constructor-function",
]);
expect(markers("const util = require('util'); util.inherits(A, B);")).toEqual([
"R2.1 constructor-function",
]);
expect(markers("function Child() { Parent.call(this, 1); }")).toEqual([
"R2.1 constructor-function",
]);
expect(markers("class C {} const c = new C();")).toEqual([]);
expect(
markers("function wrap(fn) { return function () { return fn.apply(this, arguments); }; }"),
).toEqual([]);
});
test("R2.2 prototype mutation", () => {
expect(markers("Object.setPrototypeOf(a, b);")).toEqual(["R2.2 prototype-write"]);
expect(markers("o.__proto__ = p;")).toEqual(["R2.2 prototype-write"]);
expect(markers("const o = { __proto__: base };")).toEqual(["R2.2 prototype-write"]);
// The transpiled-Error idiom and null-prototype dictionaries stay.
expect(
markers(
"class E extends Error { constructor() { super(); Object.setPrototypeOf(this, E.prototype); } }",
),
).toEqual([]);
expect(markers("const d = { __proto__: null };")).toEqual([]);
});
test("R2.3 shape mutation outside the checker's view", () => {
expect(markers("Object.defineProperty(o, 'k', { get() { return 1; } });")).toEqual([
"R2.3 define-property",
]);
expect(markers("class C {} C.prototype.extra = 1;")).toEqual(["R2.3 define-property"]);
// Module objects and interop helpers build module records.
expect(markers("Object.defineProperty(exports, '__esModule', { value: true });")).toEqual([]);
expect(
markers(
"var __createBinding = function (o, m, k) { Object.defineProperty(o, k, { enumerable: true, get: function () { return m[k]; } }); };",
),
).toEqual([]);
});
test("R2.4 sloppy-mode semantics", () => {
expect(markers("with (o) { x; }")).toEqual(["R2.4 with"]);
expect(markers("function f(a) { arguments[0] = 1; return a; }")).toEqual([
"R2.4 sloppy-arguments",
]);
expect(markers("function f(a) { a = 2; return arguments[0]; }")).toEqual([
"R2.4 sloppy-arguments",
]);
expect(markers("function f() { return arguments.callee; }")).toEqual(["R2.4 sloppy-arguments"]);
expect(markers("var n = 010;")).toEqual(["R2.4 sloppy-mode"]);
expect(markers("var x = 1; delete x;")).toEqual(["R2.4 sloppy-mode"]);
// Strict code: a prologue, an ES module, a class body.
expect(markers("'use strict'; function f(a) { a = 2; return arguments[0]; }")).toEqual([]);
expect(markers("export function f(a) { a = 2; return arguments[0]; }", "mjs")).toEqual([]);
expect(markers("class C { m(a) { a = 2; return arguments[0]; } }")).toEqual([]);
});
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",
]);
expect(markers("const p = new Proxy(t, h);")).toEqual(["R2.5 proxy"]);
// Modeled process state and tuning knobs stay; so do redeclared names.
expect(
markers("process.exitCode = 1; process.env.X = 'y'; Error.stackTraceLimit = 50;"),
).toEqual([]);
expect(markers("var Promise = require('bluebird'); Promise.config = {};")).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");
const found = scanModule(sf);
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)"],
]);
});
test("computed writes keep the member they write through", () => {
expect(markers("process.env[key] = value;")).toEqual([]);
expect(markers("Array.prototype[name] = fn;")).toEqual(["R2.5 builtin-write"]);
});
@@ -0,0 +1,849 @@
/* The pre-scan (DESIGN.md §1.4, phase 1): a syntactic walk over one
* JavaScript module that finds the R1 (runtime) and R2 (object-model)
* markers of rules.ts. It is a fast path and a source of good reasons; it
* never decides alone that a module is native, and the authoritative signal
* stays the lowering's refusals (classify.ts).
*
* Every check is local to the file: a name is a "function" when the file
* declares it as a function, a builtin global when the file does not
* 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. */
import type { SrcLoc } from "../ir/ir.js";
import * as ts from "../frontend/ts7/adapter.js";
import { SUPPORTED_BUILTIN_MODULES } from "../frontend/builtin-modules.js";
import type { NodeIslandRule } from "./rules.js";
export interface NodeIslandMarker {
rule: NodeIslandRule;
/** The reason id (rules.ts). */
reason: string;
/** The construct, as a short phrase for messages. */
construct: string;
loc: SrcLoc;
}
/** Markers past this count in one module add nothing to a report. */
const MARKER_CAP = 64;
const BUILTIN_GLOBALS = new Set([
"Object",
"Array",
"String",
"Number",
"Boolean",
"Function",
"Symbol",
"BigInt",
"Promise",
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"ReferenceError",
"EvalError",
"URIError",
"AggregateError",
"Map",
"Set",
"WeakMap",
"WeakSet",
"RegExp",
"Date",
"Math",
"JSON",
"Reflect",
"Intl",
"ArrayBuffer",
"SharedArrayBuffer",
"DataView",
"Uint8Array",
"Int8Array",
"Uint16Array",
"Int16Array",
"Uint32Array",
"Int32Array",
"Float32Array",
"Float64Array",
"BigInt64Array",
"BigUint64Array",
"Uint8ClampedArray",
"Buffer",
"process",
"console",
"globalThis",
"global",
"URL",
"URLSearchParams",
"TextEncoder",
"TextDecoder",
"AbortController",
"AbortSignal",
"EventTarget",
"Event",
"fetch",
"setTimeout",
"setInterval",
"setImmediate",
"clearTimeout",
"clearInterval",
"clearImmediate",
"queueMicrotask",
"structuredClone",
]);
/** Writes to builtin members that the native subset models (process
* state, tuning knobs). */
const ALLOWED_BUILTIN_WRITES = new Set([
"process.exitCode",
"process.title",
"process.env",
"Error.stackTraceLimit",
"Buffer.poolSize",
"events.defaultMaxListeners",
"events.EventEmitter.defaultMaxListeners",
"EventEmitter.defaultMaxListeners",
]);
const ADDON_LOADERS = new Set([
"bindings",
"node-gyp-build",
"node-pre-gyp",
"@mapbox/node-pre-gyp",
"prebuild-install",
"node-gyp-build-optional-packages",
]);
const LOADER_HOOK_PACKAGES = new Set(["import-in-the-middle", "require-in-the-middle"]);
function builtinModuleName(spec: string): string | null {
const bare = spec.startsWith("node:") ? spec.slice(5) : spec;
if ((SUPPORTED_BUILTIN_MODULES as readonly string[]).includes(bare)) return bare;
if (bare === "vm" || bare === "v8" || bare === "module" || bare === "inspector") return bare;
return null;
}
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 specifier that folds at build time: a literal, or a concatenation of
* literals. */
function isFoldableSpecifier(node: ts.Node): boolean {
if (literalText(node) !== null) return true;
if (ts.isParenthesizedExpression(node)) return isFoldableSpecifier(node.expression);
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
return isFoldableSpecifier(node.left) && isFoldableSpecifier(node.right);
return false;
}
function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
function propertyName(node: ts.Node): string | null {
if (ts.isPropertyAccessExpression(node)) {
const name = node.name;
return ts.isIdentifier(name) ? name.text : null;
}
if (ts.isElementAccessExpression(node)) return literalText(node.argumentExpression);
return 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);
if (base === null || name === null) return null;
return `${base}.${name}`;
}
return null;
}
/** Like chainText, with computed keys spelled `[…]`: `process.env[…]`. */
function memberText(node: ts.Node): string | null {
const text = chainText(node);
if (text !== null) return text;
if (ts.isElementAccessExpression(node)) {
const base = memberText(node.expression);
return base === null ? null : `${base}[…]`;
}
if (ts.isPropertyAccessExpression(node)) {
const base = memberText(node.expression);
const name = propertyName(node);
return base === null || name === null ? null : `${base}.${name}`;
}
return 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;
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;
continue;
}
return null;
}
}
function hasUseStrict(statements: readonly ts.Node[]): boolean {
for (const stmt of statements) {
if (!ts.isExpressionStatement(stmt)) return false;
const text = literalText(stmt.expression);
if (text === null) return false;
if (text === "use strict") return true;
}
return false;
}
/** ES module syntax at the top level (static import or export): the
* module is strict. The parser's module indicator is not used, since it
* also marks CommonJS files under forced module detection. */
function hasEsmSyntax(sf: ts.SourceFile): boolean {
for (const stmt of sf.statements.nodes) {
if (ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) return true;
if (ts.isExportAssignment(stmt)) return true;
const head = sf.text.slice(ts.tokenStartOfNode(stmt), ts.tokenStartOfNode(stmt) + 7);
if (head === "export ") return true;
}
return false;
}
function isFunctionBoundary(node: ts.Node): boolean {
return (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
ts.isGetAccessorDeclaration(node) ||
ts.isSetAccessorDeclaration(node)
);
}
/** One module's pre-scan. */
class ModuleScanner {
readonly markers: NodeIslandMarker[] = [];
/** Every name the file declares (any scope): a builtin global the file
* redeclares is not the builtin. */
private readonly declared = new Set<string>();
/** Names declared as functions (declarations and function-valued
* variables). */
private readonly functions = new Set<string>();
private readonly classes = new Set<string>();
/** Local name → the builtin module (or `module.member`) it is bound to. */
private readonly builtinBindings = new Map<string, string>();
/** Local names bound to a createRequire(...) result. */
private readonly createdRequires = new Set<string>();
private readonly strictness = new Map<ts.Node, boolean>();
private readonly moduleStrict: boolean;
constructor(private readonly sf: ts.SourceFile) {
const name = sf.fileName;
this.moduleStrict =
name.endsWith(".mjs") ||
name.endsWith(".mts") ||
hasEsmSyntax(sf) ||
hasUseStrict(sf.statements.nodes);
}
scan(): NodeIslandMarker[] {
this.collectDeclarations();
ts.walkPreorder(this.sf, (node) => {
if (this.markers.length >= MARKER_CAP) return "stop";
this.visit(node);
return undefined;
});
return this.markers;
}
private add(rule: NodeIslandRule, reason: string, construct: string, node: ts.Node): 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 },
});
}
private bindName(name: ts.Node | undefined, init: ts.Node | undefined): void {
if (name === undefined) return;
if (ts.isIdentifier(name)) {
this.declared.add(name.text);
if (init === undefined) return;
let value = init;
while (ts.isParenthesizedExpression(value)) value = value.expression;
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 (ts.isCallExpression(value) && this.isCreateRequireCallee(value.expression))
this.createdRequires.add(name.text);
return;
}
if (ts.isObjectBindingPattern(name)) {
const base = init === undefined ? null : this.builtinOf(init);
for (const element of name.elements.nodes) {
if (!ts.isBindingElement(element)) continue;
const local = element.name;
if (local !== undefined && ts.isIdentifier(local) && base !== null) {
const key = element.propertyName;
const member = key !== undefined && ts.isIdentifier(key) ? key.text : local.text;
this.builtinBindings.set(local.text, `${base}.${member}`);
}
this.bindName(local, undefined);
}
return;
}
if (ts.isArrayBindingPattern(name)) {
for (const element of name.elements.nodes) {
if (ts.isBindingElement(element)) this.bindName(element.name, undefined);
}
}
}
/** `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 spec = literalText(value.arguments.nodes[0]);
return spec === null ? null : builtinModuleName(spec);
}
if (ts.isPropertyAccessExpression(value)) {
const base = this.builtinOf(value.expression);
const name = propertyName(value);
return base === null || name === null ? null : `${base}.${name}`;
}
if (ts.isIdentifier(value)) return this.builtinBindings.get(value.text) ?? null;
return null;
}
private isRequireCallee(node: ts.Node): boolean {
return ts.isIdentifier(node) && node.text === "require";
}
private isCreateRequireCallee(node: ts.Node): boolean {
if (ts.isIdentifier(node)) {
return (
node.text === "createRequire" ||
this.builtinBindings.get(node.text) === "module.createRequire"
);
}
return propertyName(node) === "createRequire";
}
private collectDeclarations(): void {
ts.walkPreorder(this.sf, (node) => {
if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
this.declared.add(node.name.text);
this.functions.add(node.name.text);
} else if (ts.isClassDeclaration(node) && node.name !== undefined) {
this.declared.add(node.name.text);
this.classes.add(node.name.text);
} else if (ts.isVariableDeclaration(node)) {
this.bindName(node.name, node.initializer);
} else if (ts.isParameter(node)) {
this.bindName(node.name, undefined);
} else if (ts.isImportDeclaration(node)) {
const spec = literalText(node.moduleSpecifier);
const module = spec === null ? null : builtinModuleName(spec);
const clause = node.importClause;
if (clause !== undefined) {
if (clause.name !== undefined) {
this.declared.add(clause.name.text);
if (module !== null) this.builtinBindings.set(clause.name.text, module);
}
const bindings = clause.namedBindings;
if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
this.declared.add(bindings.name.text);
if (module !== null) this.builtinBindings.set(bindings.name.text, module);
} else if (bindings !== undefined && ts.isNamedImports(bindings)) {
for (const element of bindings.elements.nodes) {
this.declared.add(element.name.text);
const imported = element.propertyName;
const member =
imported !== undefined && ts.isIdentifier(imported)
? imported.text
: element.name.text;
if (module !== null)
this.builtinBindings.set(element.name.text, `${module}.${member}`);
}
}
}
}
return undefined;
});
}
/** Whether the code at `node` runs in strict mode. */
private isStrict(node: ts.Node): boolean {
if (this.moduleStrict) return true;
let cur: ts.Node | undefined = node.parent;
const visited: ts.Node[] = [];
let strict = false;
while (cur !== undefined) {
const known = this.strictness.get(cur);
if (known !== undefined) {
strict = known;
break;
}
visited.push(cur);
if (ts.isClassDeclaration(cur) || ts.isClassExpression(cur)) {
strict = true;
break;
}
if (isFunctionBoundary(cur) || ts.isArrowFunction(cur)) {
const body = (cur as ts.FunctionLikeDeclaration).body;
if (body !== undefined && ts.isBlock(body) && hasUseStrict(body.statements.nodes)) {
strict = true;
break;
}
}
cur = cur.parent;
}
for (const n of visited) this.strictness.set(n, strict);
return strict;
}
private isGlobal(name: string): boolean {
return BUILTIN_GLOBALS.has(name) && !this.declared.has(name);
}
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))
this.visitWrite(node.left, node, "write");
else if (ts.isDeleteExpression(node)) this.visitDelete(node);
else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
if (
node.operator === ts.SyntaxKind.PlusPlusToken ||
node.operator === ts.SyntaxKind.MinusMinusToken
)
this.visitWrite(node.operand, node, "write");
} else if (ts.isWithStatement(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);
} else if (ts.isIdentifier(node)) {
this.visitIdentifier(node);
} else if (ts.isNumericLiteral(node)) {
const text = this.sf.text.slice(ts.tokenStartOfNode(node), node.end);
if (/^0[0-9]+$/.test(text) && !this.isStrict(node))
this.add("R2.4", "sloppy-mode", "a legacy octal literal", node);
} else if (ts.isPropertyAccessExpression(node)) {
const text = chainText(node);
if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node))
this.add("R2.4", "sloppy-arguments", `'${text}'`, node);
} else if (ts.isObjectLiteralExpression(node)) {
for (const prop of node.properties.nodes) {
if (!ts.isPropertyAssignment(prop)) continue;
const name = prop.name;
const key = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null;
if (key === "__proto__" && prop.initializer.kind !== ts.SyntaxKind.NullKeyword)
this.add("R2.2", "prototype-write", "an object literal with a '__proto__' value", prop);
}
} else if (isFunctionBoundary(node)) {
this.visitFunction(node as ts.FunctionLikeDeclaration);
}
}
private visitSpecifier(spec: string, node: ts.Node): void {
const builtin = builtinModuleName(spec);
if (builtin === "vm") this.add("R1.1", "vm", "the 'vm' module", node);
else if (spec.endsWith(".node"))
this.add("R1.3", "native-addon", `the native addon '${spec}'`, node);
else if (ADDON_LOADERS.has(spec))
this.add("R1.3", "native-addon", `the native addon loader '${spec}'`, node);
else if (LOADER_HOOK_PACKAGES.has(spec))
this.add("R1.4", "loader-hook", `the loader hook package '${spec}'`, node);
}
private visitCall(call: ts.CallExpression): void {
const callee = call.expression;
const args = call.arguments.nodes;
const first = args[0];
// import(e) and require(e)
if (callee.kind === ts.SyntaxKind.ImportKeyword) {
if (first !== undefined && !isFoldableSpecifier(first))
this.add("R1.2", "dynamic-import", "import() with a computed specifier", call);
else if (first !== undefined) this.visitSpecifier(literalText(first) ?? "", call);
return;
}
if (
this.isRequireCallee(callee) ||
(ts.isIdentifier(callee) && this.createdRequires.has(callee.text))
) {
if (first !== undefined && !isFoldableSpecifier(first))
this.add("R1.2", "dynamic-require", "require() with a computed specifier", call);
else if (first !== undefined) this.visitSpecifier(literalText(first) ?? "", call);
return;
}
if (ts.isCallExpression(callee) && this.isCreateRequireCallee(callee.expression)) {
if (first !== undefined && !isFoldableSpecifier(first))
this.add("R1.2", "dynamic-require", "createRequire(...)() with a computed specifier", call);
return;
}
// eval, Function
let target = callee;
while (ts.isParenthesizedExpression(target)) target = target.expression;
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"
) {
this.add("R1.1", "code-from-string", "eval()", call);
return;
}
if (text === "Function" && this.isGlobal("Function")) {
this.visitFunctionConstructor(call, args);
return;
}
const root = chainRoot(target);
const rootBinding = root === null ? undefined : this.builtinBindings.get(root.text);
const resolved =
rootBinding !== undefined && root !== null
? rootBinding + text.slice(root.text.length)
: text;
// engine internals
if (
(text === "process.binding" || text === "process._linkedBinding") &&
this.isGlobal("process")
) {
this.add("R1.5", "engine-internal", `'${text}()'`, call);
return;
}
if (text === "internalBinding" && !this.declared.has("internalBinding")) {
this.add("R1.5", "engine-internal", "'internalBinding()'", call);
return;
}
if (resolved === "v8.setFlagsFromString" || text.endsWith(".setFlagsFromString")) {
this.add("R1.5", "engine-internal", "'v8.setFlagsFromString()'", call);
return;
}
if ((text === "global.gc" || text === "globalThis.gc") && this.isGlobal(text.split(".")[0]!)) {
this.add("R1.5", "engine-internal", `'${text}()'`, call);
return;
}
if (text === "process.dlopen" && this.isGlobal("process")) {
this.add("R1.3", "native-addon", "'process.dlopen()'", call);
return;
}
// loader hooks
if (resolved === "module.register" || resolved === "module.registerHooks") {
this.add("R1.4", "loader-hook", `'${resolved}()'`, call);
return;
}
// prototype mutation
if (
(text === "Object.setPrototypeOf" && this.isGlobal("Object")) ||
(text === "Reflect.setPrototypeOf" && this.isGlobal("Reflect"))
) {
// The transpiled-Error idiom `Object.setPrototypeOf(this, …)` stays.
if (first !== undefined && first.kind !== ts.SyntaxKind.ThisKeyword)
this.add("R2.2", "prototype-write", `'${text}()'`, call);
return;
}
// defineProperty outside the module object and interop helpers
if (
((text === "Object.defineProperty" || text === "Object.defineProperties") &&
this.isGlobal("Object")) ||
(text === "Reflect.defineProperty" && this.isGlobal("Reflect"))
) {
if (first !== undefined && !this.isModuleObject(first) && !this.inInteropHelper(call))
this.add("R2.3", "define-property", `'${text}()'`, call);
return;
}
if (text.endsWith(".__defineGetter__") || text.endsWith(".__defineSetter__")) {
this.add("R2.3", "define-property", `'${text.slice(text.lastIndexOf(".") + 1)}()'`, call);
return;
}
// constructor functions
if (
args.length === 2 &&
(resolved === "util.inherits" ||
(text === "inherits" && !this.functions.has("inherits")) ||
text.endsWith(".inherits"))
) {
this.add("R2.1", "constructor-function", `'${text}()'`, call);
return;
}
if (
ts.isPropertyAccessExpression(target) &&
(propertyName(target) === "call" || propertyName(target) === "apply") &&
first !== undefined &&
first.kind === ts.SyntaxKind.ThisKeyword &&
ts.isExpressionStatement(call.parent!)
) {
const fn = target.expression;
const name = ts.isIdentifier(fn) ? fn.text : null;
if (name !== null && /^[A-Z]/.test(name) && !this.classes.has(name))
this.add(
"R2.1",
"constructor-function",
`'${name}.${propertyName(target)}(this, …)'`,
call,
);
}
}
private visitFunctionConstructor(node: ts.Node, args: readonly ts.Node[]): void {
// The global-object idiom Function("return this")() folds to globalThis.
if (args.length === 1) {
const body = literalText(args[0]);
if (body !== null && body.trim() === "return this") return;
}
// A capability probe tested inside try (zod 4's allowsEval) keeps
// today's EvalError lowering.
for (let cur = node.parent; cur !== undefined; cur = cur.parent) {
if (isFunctionBoundary(cur) || ts.isArrowFunction(cur)) break;
if (ts.isBlock(cur) && cur.parent !== undefined && ts.isTryStatement(cur.parent)) {
if (cur.parent.tryBlock === cur) return;
}
}
this.add("R1.1", "code-from-string", "new Function()", node);
}
private visitNew(node: ts.NewExpression): void {
const callee = node.expression;
if (!ts.isIdentifier(callee)) return;
const name = callee.text;
if (name === "Function" && this.isGlobal("Function")) {
this.visitFunctionConstructor(node, node.arguments?.nodes ?? []);
} else if (name === "Proxy" && !this.declared.has("Proxy")) {
this.add("R2.5", "proxy", "new Proxy()", node);
} else if (this.functions.has(name) && !this.classes.has(name)) {
this.add("R2.1", "constructor-function", `'new ${name}()' of a function`, node);
}
}
private isModuleObject(node: ts.Node): boolean {
const text = chainText(node);
return text === "exports" || text === "module.exports" || text === "module";
}
/** Inside a module-interop helper (tsc's __createBinding/__exportStar,
* Babel's _interopRequireWildcard, esbuild's __export): their
* defineProperty calls build module objects the native subset models. */
private inInteropHelper(node: ts.Node): boolean {
for (let cur = node.parent; cur !== undefined; cur = cur.parent) {
let name: string | null = null;
if (ts.isFunctionDeclaration(cur) && cur.name !== undefined) name = cur.name.text;
else if (ts.isVariableDeclaration(cur) && ts.isIdentifier(cur.name)) name = cur.name.text;
else if (ts.isBinaryExpression(cur) && isAssignmentOperator(cur.operatorToken.kind))
name = chainText(cur.left);
if (name !== null && /(^|\.)_/.test(name)) return true;
}
return false;
}
private visitWrite(target: ts.Node, node: ts.Node, what: "write" | "delete"): void {
let left = target;
while (ts.isParenthesizedExpression(left)) left = left.expression;
if (ts.isIdentifier(left)) {
if (this.isGlobal(left.text) && left.text !== "global" && left.text !== "globalThis")
this.add("R2.5", "builtin-write", `a ${what} of the global '${left.text}'`, node);
return;
}
if (!ts.isPropertyAccessExpression(left) && !ts.isElementAccessExpression(left)) return;
const text = chainText(left);
const root = chainRoot(left);
const base = left.expression;
const baseText = chainText(base);
// require.extensions / require.cache
if (
text !== null &&
(text.startsWith("require.extensions") || text.startsWith("require.cache"))
) {
this.add(
"R1.4",
"loader-hook",
`a ${what} of '${text.startsWith("require.cache") ? "require.cache" : "require.extensions"}'`,
node,
);
return;
}
if (baseText === "require.cache" || baseText === "require.extensions") {
this.add("R1.4", "loader-hook", `a ${what} of '${baseText}'`, node);
return;
}
// __proto__ writes
if (propertyName(left) === "__proto__") {
this.add("R2.2", "prototype-write", `a ${what} of '__proto__'`, node);
return;
}
if (root === null) return;
const binding = this.builtinBindings.get(root.text);
// Module internals: Module._load = …, Module.prototype.require = …
if (text !== null) {
const resolved = binding !== undefined ? binding + text.slice(root.text.length) : text;
if (
/^(module|Module)\.(_load|_resolveFilename|_extensions|wrap|_compile|prototype\.require|prototype\._compile)$/.test(
resolved,
) &&
(binding !== undefined || !this.declared.has(root.text) || root.text === "Module")
) {
this.add("R1.4", "loader-hook", `a ${what} of '${resolved}'`, node);
return;
}
// F.prototype…
const parts = text.split(".");
if (parts.length >= 2 && parts[1] === "prototype") {
const name = parts[0]!;
if (this.isGlobal(name)) {
this.add("R2.5", "builtin-write", `a ${what} of '${text}'`, node);
return;
}
if (binding === undefined && this.classes.has(name) && !this.functions.has(name)) {
this.add("R2.3", "define-property", `a ${what} of the class prototype '${text}'`, node);
return;
}
if (binding === undefined && name !== "this") {
this.add("R2.1", "constructor-function", `a ${what} of '${text}'`, node);
return;
}
}
}
// Builtin globals and builtin module objects.
const spelled = memberText(left) ?? `${root.text}[…]`;
if (binding !== undefined) {
const member = binding + spelled.slice(root.text.length);
if (!this.allowedWrite(member)) {
this.add(
"R2.5",
"builtin-write",
`a ${what} of the builtin module member '${member}'`,
node,
);
}
return;
}
if (this.isGlobal(root.text)) {
const member = spelled;
if (!this.allowedWrite(member))
this.add("R2.5", "builtin-write", `a ${what} of the builtin '${member}'`, node);
}
}
private allowedWrite(member: string): boolean {
for (const allowed of ALLOWED_BUILTIN_WRITES) {
if (
member === allowed ||
member.startsWith(`${allowed}.`) ||
member.startsWith(`${allowed}[`)
)
return true;
}
return false;
}
private visitDelete(node: ts.DeleteExpression): void {
let target = node.expression;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (ts.isIdentifier(target)) {
if (!this.isStrict(node))
this.add("R2.4", "sloppy-mode", `'delete ${target.text}' of an identifier`, node);
return;
}
this.visitWrite(target, node, "delete");
}
private visitIdentifier(node: ts.Identifier): void {
const name = node.text;
if (name !== "WeakRef" && name !== "FinalizationRegistry") return;
if (this.declared.has(name)) return;
const parent = node.parent;
if (parent === undefined) return;
// Only value uses: not a property name, declaration name or typeof probe.
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return;
if (ts.isTypeOfExpression(parent)) return;
if ((ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent)) && parent.name === node)
return;
this.add("R1.6", "weak-ref", `'${name}'`, node);
}
/** 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 {
const body = fn.body;
if (body === undefined || !ts.isBlock(body)) return;
if (this.isStrict(body.statements.nodes[0] ?? body)) return;
if (hasUseStrict(body.statements.nodes)) return;
const params = new Set<string>();
for (const p of fn.parameters.nodes) {
if (ts.isIdentifier(p.name)) params.add(p.name.text);
}
const seen: ArgumentsUse = { reads: false, argumentsWrite: null, paramWrite: null };
ts.walkPreorder(body, (node) => {
if (
node !== body &&
(isFunctionBoundary(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node))
)
return "skip";
if (ts.isIdentifier(node) && node.text === "arguments") seen.reads = true;
if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
const left = node.left;
if (
ts.isElementAccessExpression(left) &&
ts.isIdentifier(left.expression) &&
left.expression.text === "arguments"
) {
if (seen.argumentsWrite === null) seen.argumentsWrite = node;
} else if (ts.isIdentifier(left) && params.has(left.text)) {
if (seen.paramWrite === null) seen.paramWrite = node;
}
}
return undefined;
});
if (seen.argumentsWrite !== null)
this.add(
"R2.4",
"sloppy-arguments",
"a write to 'arguments[i]' in sloppy mode",
seen.argumentsWrite,
);
else if (seen.reads && seen.paramWrite !== null)
this.add(
"R2.4",
"sloppy-arguments",
"a parameter write while 'arguments' is read in sloppy mode",
seen.paramWrite,
);
}
}
interface ArgumentsUse {
reads: boolean;
argumentsWrite: ts.Node | null;
paramWrite: ts.Node | null;
}
/** The R1/R2 markers of one JavaScript 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);
}
@@ -0,0 +1,68 @@
/* Where a source file comes from. User code (the program's own tree and
* workspace packages compiled from source) is strict; npm code (installed
* packages under node_modules) is lenient and is decided per package
* instance; shims are scriptc's own registry implementations; builtin
* covers declaration files of the type world. */
import { join } from "node:path";
import { isPackageShimFile } from "../frontend/package-shims.js";
import { trackedReadFile } from "../frontend/input-tracker.js";
export type Origin = "user" | "npm" | "shim" | "builtin";
export function originOf(file: string): Origin {
const path = file.split("\\").join("/");
if (isPackageShimFile(path)) return "shim";
if (/\.d\.[cm]?ts$/.test(path)) return "builtin";
return path.includes("/node_modules/") ? "npm" : "user";
}
/** One installed package: the decision unit for npm code. pnpm keeps
* versions apart in different directories, so the directory identifies the
* instance. */
export interface PackageInstance {
name: string;
/** The package directory (…/node_modules/<name>). */
dir: string;
version: string | null;
}
/** The package instance of a file under node_modules: the LAST
* node_modules segment (nested installs belong to the innermost package),
* scope-aware. Null outside node_modules. */
export function packageDirOf(file: string): { name: string; dir: string } | null {
const path = file.split("\\").join("/");
const parts = path.split("/");
const i = parts.lastIndexOf("node_modules");
if (i < 0 || i + 1 >= parts.length - 1) return null;
const first = parts[i + 1]!;
if (first.startsWith("@")) {
if (i + 2 >= parts.length - 1) return null;
const name = `${first}/${parts[i + 2]!}`;
return { name, dir: parts.slice(0, i + 3).join("/") };
}
return { name: first, dir: parts.slice(0, i + 2).join("/") };
}
/** The `version` field of a package directory's package.json. */
export function packageVersionOf(dir: string): string | null {
let version: string | null = null;
const text = trackedReadFile(join(dir, "package.json"));
if (text !== null) {
try {
const parsed: unknown = JSON.parse(text);
if (parsed !== null && typeof parsed === "object") {
const v = (parsed as Record<string, unknown>)["version"];
if (typeof v === "string") version = v;
}
} catch {
version = null;
}
}
return version;
}
export function packageInstanceOf(file: string): PackageInstance | null {
const at = packageDirOf(file);
if (at === null) return null;
return { name: at.name, dir: at.dir, version: packageVersionOf(at.dir) };
}
@@ -0,0 +1,167 @@
/* The Node island policy (DESIGN.md §1.5–§1.6): user code strict, npm
* lenient. npm code outside the native subset becomes a Node island
* automatically (`nodeIslands.auto`, `--node-islands=auto`); user modules
* 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. */
import { isAbsolute, join, resolve, dirname } from "node:path";
import type { ProjectConfig } from "../project-config.js";
export type NodeIslandsMode = "auto" | "explicit" | "off";
export const NODE_ISLANDS_MODES: readonly NodeIslandsMode[] = ["auto", "explicit", "off"];
/** The command-line half of the policy (`build`, `run` and `coverage`
* accept these). */
export interface NodeIslandOptions {
/** `--node-islands=auto|explicit|off`. */
mode?: NodeIslandsMode;
/** `--node-island=<pkg-or-path>`: packages and user modules forced into a
* Node island. Paths are absolute. */
islands?: readonly string[];
/** `--native=<pkg>`: packages that must compile natively. */
native?: readonly string[];
/** `--node=<path>`: the node executable recorded for islands. */
node?: string;
/** `--strict-user-js`: report R2 constructs in user JavaScript as
* SC8101–SC8105. */
strictUserJs?: boolean;
}
export type DecisionSource = "config" | "cli";
export interface UserModulePattern {
/** An absolute path or glob. */
pattern: string;
source: DecisionSource;
}
export interface NodeIslandPolicy {
mode: NodeIslandsMode;
/** Where the mode came from (an explicit `"auto": false` reads as
* "explicit"). */
modeSource: DecisionSource | "default";
forcedNode: Map<string, DecisionSource>;
forcedNative: Map<string, DecisionSource>;
/** `"unloaded"` packages: never loaded, never islands. */
unloaded: Set<string>;
userModules: UserModulePattern[];
node: string | null;
strictUserJs: boolean;
}
/** Whether a `--node-island` value names a module rather than a package. */
export function isModuleIslandArgument(value: string): boolean {
return (
value.startsWith(".") ||
value.startsWith("/") ||
/\.(?:[cm]?[jt]s|[jt]sx)$/.test(value) ||
value.includes("*")
);
}
export function nodeIslandPolicy(
config: ProjectConfig,
options: NodeIslandOptions = {},
): NodeIslandPolicy {
const forcedNode = new Map<string, DecisionSource>();
const forcedNative = new Map<string, DecisionSource>();
const unloaded = new Set<string>();
for (const [name, setting] of config.packages) {
if (setting.unloaded) unloaded.add(name);
else if (setting.run === "node") forcedNode.set(name, "config");
else if (setting.run === "native") forcedNative.set(name, "config");
}
const userModules: UserModulePattern[] = [];
const base = config.manifest === null ? process.cwd() : dirname(config.manifest);
for (const pattern of config.nodeIslands.modules) {
userModules.push({
pattern: isAbsolute(pattern) ? pattern : join(base, pattern),
source: "config",
});
}
for (const value of options.islands ?? []) {
if (isModuleIslandArgument(value)) {
userModules.push({ pattern: resolve(value), source: "cli" });
} else {
forcedNative.delete(value);
forcedNode.set(value, "cli");
}
}
for (const name of options.native ?? []) {
forcedNode.delete(name);
forcedNative.set(name, "cli");
}
let mode: NodeIslandsMode = "auto";
let modeSource: DecisionSource | "default" = "default";
if (options.mode !== undefined) {
mode = options.mode;
modeSource = "cli";
} else if (!config.nodeIslands.auto) {
mode = "explicit";
modeSource = "config";
}
return {
mode,
modeSource,
forcedNode,
forcedNative,
unloaded,
userModules,
node: options.node ?? config.nodeIslands.node,
strictUserJs: options.strictUserJs === true,
};
}
/** Whether a user module is marked to run in a Node island. */
export function isMarkedUserModule(policy: NodeIslandPolicy, file: string): DecisionSource | null {
const path = file.split("\\").join("/");
for (const entry of policy.userModules) {
if (globMatch(entry.pattern.split("\\").join("/"), path)) return entry.source;
}
return null;
}
/** Glob matching over `/`-separated paths: `**` spans directories, `*` and
* `?` stay inside one segment. */
export function globMatch(pattern: string, path: string): boolean {
return matchSegments(pattern.split("/"), 0, path.split("/"), 0);
}
function matchSegments(pat: string[], pi: number, segs: string[], si: number): boolean {
if (pi === pat.length) return si === segs.length;
const p = pat[pi]!;
if (p === "**") {
for (let k = si; k <= segs.length; k++) if (matchSegments(pat, pi + 1, segs, k)) return true;
return false;
}
if (si === segs.length) return false;
return matchSegment(p, 0, segs[si]!, 0) && matchSegments(pat, pi + 1, segs, si + 1);
}
function matchSegment(p: string, pi: number, s: string, si: number): boolean {
if (pi === p.length) return si === s.length;
const ch = p.charAt(pi);
if (ch === "*") {
for (let k = si; k <= s.length; k++) if (matchSegment(p, pi + 1, s, k)) return true;
return false;
}
if (si === s.length) return false;
if (ch === "?" || ch === s.charAt(si)) return matchSegment(p, pi + 1, s, si + 1);
return false;
}
/** The policy's identity for caches: two analyses with the same identity
* decide the same islands. */
export function nodeIslandOptionsKey(options: NodeIslandOptions | undefined): string {
if (options === undefined) return "";
return [
options.mode ?? "",
(options.islands ?? []).join(","),
(options.native ?? []).join(","),
options.node ?? "",
options.strictUserJs === true ? "strict-user-js" : "",
].join("|");
}
@@ -0,0 +1,279 @@
import { describe, expect, test } from "vitest";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import { emptyProjectConfig, parseProjectConfig } from "../project-config.js";
import type { NodeIslandMarker } from "./detect.js";
import {
globMatch,
isMarkedUserModule,
nodeIslandPolicy,
type NodeIslandOptions,
} from "./policy.js";
import { buildNodeIslandReport, type NpmOutcome } from "./report.js";
import type { NodeIslandScan, PackageEdge, ScannedModule } from "./scan.js";
const NM = "/app/node_modules";
function mod(pkg: string | null, file: string, markers: NodeIslandMarker[] = []): ScannedModule {
return {
file,
origin: pkg === null ? "user" : "npm",
packageDir: pkg === null ? null : `${NM}/${pkg}`,
packageName: pkg,
markers,
dropped: false,
};
}
function marker(file: string, rule: NodeIslandMarker["rule"], reason: string): NodeIslandMarker {
return { rule, reason, construct: reason, loc: { file, start: 0, end: 1 } };
}
function edge(from: string | null, to: string): PackageEdge {
return {
fromDir: from === null ? null : `${NM}/${from}`,
fromFile: from === null ? "/app/main.ts" : `${NM}/${from}/index.js`,
to,
toDir: `${NM}/${to}`,
shim: false,
loc: { file: from === null ? "/app/main.ts" : `${NM}/${from}/index.js`, start: 0, end: 1 },
};
}
function gapFence(pkg: string): ScrDiagnostic {
return {
code: "SC1090",
message: "a builtin function alias as an escaping value",
loc: { file: `${NM}/${pkg}/index.js`, start: 5, end: 9 },
};
}
/** A program: main.ts imports a (constructor functions), b (a gap fence)
* and c (clean); a imports d (clean, only from a) and c. */
function program(): { scan: NodeIslandScan; fences: ScrDiagnostic[] } {
return {
scan: {
modules: [
mod(null, "/app/main.ts"),
mod("a", `${NM}/a/index.js`, [marker(`${NM}/a/index.js`, "R2.1", "constructor-function")]),
mod("b", `${NM}/b/index.js`),
mod("c", `${NM}/c/index.js`),
mod("d", `${NM}/d/index.js`),
],
edges: [edge(null, "a"), edge(null, "b"), edge(null, "c"), edge("a", "d"), edge("a", "c")],
refusals: [],
},
fences: [gapFence("b")],
};
}
function report(
options: NodeIslandOptions = {},
npm: NpmOutcome[] = [],
config = emptyProjectConfig(),
) {
const { scan, fences } = program();
return buildNodeIslandReport({
entry: "/app/main.ts",
scan,
npm,
diagnostics: [],
fences,
policy: nodeIslandPolicy(config, options),
});
}
describe("decisions", () => {
test("auto: packages with reasons are islands, gap islands first; closure and sharing", () => {
const r = report();
expect(r.islands.map((i) => [i.package, i.class, i.decision])).toEqual([
["b", "gap", "auto"],
["a", "object-model", "auto"],
]);
expect(r.islands[0]!.reason).toBe("SC1090: a builtin function alias as an escaping value");
expect(r.islands[1]!.carries).toEqual(["d"]);
expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["c", "shared", ["a"]],
["d", "inside", ["a"]],
]);
expect(r.violations).toEqual([]);
});
test("a refused package is a packaging island, classified by its refusal", () => {
const r = report({}, [
{ package: "c", status: "refused", detail: "auto: its shipped JS looks minified" },
]);
const c = r.islands.find((i) => i.package === "c")!;
expect([c.class, c.reason, c.diagnostic]).toEqual([
"packaging",
"SC2013: auto: its shipped JS looks minified",
"SC2013",
]);
});
test("forced native turns reasons into SC8003; forced node islands a clean package", () => {
const r = report({ native: ["a"], islands: ["c"] });
expect(r.islands.map((i) => [i.package, i.class, i.decision])).toEqual([
["b", "gap", "auto"],
["c", "marked", "cli"],
]);
expect(r.violations.map((v) => v.code)).toEqual(["SC8003"]);
expect(r.violations[0]!.message).toContain("'a' is marked native");
});
test("off: no islands, every package outside the native subset is SC8002", () => {
const r = report({ mode: "off" });
expect(r.islands).toEqual([]);
expect(r.violations.map((v) => [v.code, v.message.split(" (")[0]])).toEqual([
["SC8002", "npm package 'a' is outside the native subset"],
["SC8002", "npm package 'b' is outside the native subset"],
]);
});
test('explicit (or "auto": false): only marked islands', () => {
const config = parseProjectConfig(
"/app/package.json",
JSON.stringify({ scriptc: { nodeIslands: { auto: false }, packages: { b: "node" } } }),
);
const r = report({}, [], config);
expect(r.policy).toBe("explicit");
expect(r.islands.map((i) => [i.package, i.decision])).toEqual([["b", "config"]]);
expect(r.violations.map((v) => v.code)).toEqual(["SC8002"]);
});
test("command-line names the program does not import are reported", () => {
expect(report({ native: ["nope"] }).unknownPackages).toEqual(["nope"]);
});
test("unloaded packages are never islands", () => {
const config = parseProjectConfig(
"/app/package.json",
JSON.stringify({ scriptc: { packages: { a: "unloaded" } } }),
);
expect(report({}, [], config).islands.map((i) => i.package)).toEqual(["b"]);
});
});
describe("user modules", () => {
test("marked by glob in the config or by path on the command line", () => {
const config = parseProjectConfig(
"/app/package.json",
JSON.stringify({ scriptc: { nodeIslands: { modules: ["src/plugins/**/*.ts"] } } }),
);
const policy = nodeIslandPolicy(config, { islands: ["/app/src/legacy.js"] });
expect(isMarkedUserModule(policy, "/app/src/plugins/a/loader.ts")).toBe("config");
expect(isMarkedUserModule(policy, "/app/src/legacy.js")).toBe("cli");
expect(isMarkedUserModule(policy, "/app/src/main.ts")).toBe(null);
});
test("SC81xx for R2 markers in user JavaScript, only with --strict-user-js", () => {
const file = "/app/legacy.js";
const scan: NodeIslandScan = {
modules: [
mod(null, file, [
marker(file, "R2.1", "constructor-function"),
marker(file, "R2.5", "builtin-write"),
marker(file, "R1.1", "code-from-string"),
]),
],
edges: [],
refusals: [],
};
const build = (strictUserJs: boolean) =>
buildNodeIslandReport({
entry: file,
scan,
npm: [],
diagnostics: [],
fences: [],
policy: nodeIslandPolicy(emptyProjectConfig(), { strictUserJs }),
});
expect(build(false).userJs).toEqual([]);
expect(build(true).userJs.map((d) => d.code)).toEqual(["SC8101", "SC8105"]);
});
});
test("globMatch", () => {
expect(globMatch("/a/**/*.ts", "/a/b/c/d.ts")).toBe(true);
expect(globMatch("/a/**/*.ts", "/a/d.ts")).toBe(true);
expect(globMatch("/a/*.ts", "/a/b/d.ts")).toBe(false);
expect(globMatch("/a/?.js", "/a/x.js")).toBe(true);
expect(globMatch("/a/x.js", "/a/x.js")).toBe(true);
});
describe("reachability", () => {
const userEdge = (fromFile: string, to: string): PackageEdge => ({
...edge(null, to),
fromFile,
loc: { file: fromFile, start: 0, end: 1 },
});
const codeFromString = (pkg: string) =>
mod(pkg, `${NM}/${pkg}/index.js`, [
marker(`${NM}/${pkg}/index.js`, "R1.1", "code-from-string"),
]);
const build = (scan: Omit<NodeIslandScan, "refusals">, options: NodeIslandOptions = {}) =>
buildNodeIslandReport({
entry: "/app/main.ts",
scan: { ...scan, refusals: [] },
npm: [],
diagnostics: [],
fences: [],
policy: nodeIslandPolicy(emptyProjectConfig(), options),
});
test("a marked user module owns the packages only it imports", () => {
const r = build(
{
modules: [
mod(null, "/app/main.ts"),
mod(null, "/app/plugin.ts"),
mod("x", `${NM}/x/index.js`),
mod("y", `${NM}/y/index.js`),
],
edges: [userEdge("/app/plugin.ts", "x"), userEdge("/app/plugin.ts", "y"), edge(null, "y")],
},
{ islands: ["/app/plugin.ts"] },
);
expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["x", "inside", ["/app/plugin.ts"]],
["y", "shared", ["/app/plugin.ts"]],
]);
});
test("an island imported by a native package lists that import site", () => {
const r = build({
modules: [mod(null, "/app/main.ts"), mod("n", `${NM}/n/index.js`), codeFromString("i")],
edges: [edge(null, "n"), edge("n", "i")],
});
expect(r.islands.map((i) => [i.package, i.importedFrom.map((l) => l.file)])).toEqual([
["i", [`${NM}/n/index.js`]],
]);
});
test("the dependencies of a shared package are shared with the island", () => {
const r = build({
modules: [
mod(null, "/app/main.ts"),
codeFromString("a"),
mod("s", `${NM}/s/index.js`),
mod("d", `${NM}/d/index.js`),
],
edges: [edge(null, "a"), edge(null, "s"), edge("a", "s"), edge("s", "d")],
});
expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([
["d", "shared", ["a"]],
["s", "shared", ["a"]],
]);
});
test("with islands off, a marked user module is SC8002 and keeps the user JavaScript rules", () => {
const file = "/app/legacy.js";
const r = build(
{ modules: [mod(null, file, [marker(file, "R2.1", "constructor-function")])], edges: [] },
{ mode: "off", islands: [file], strictUserJs: true },
);
expect(r.userModules).toEqual([]);
expect(r.violations.map((v) => v.code)).toEqual(["SC8002"]);
expect(r.violations[0]!.message).toContain(`user module '${file}'`);
expect(r.userJs.map((d) => d.code)).toEqual(["SC8101"]);
});
});

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