Merge main

This commit is contained in:
Chris Tate
2026-10-23 00:54:10 -05:00
260 changed files with 17352 additions and 426 deletions
+12 -7
View File
@@ -773,17 +773,22 @@ jobs:
run: >-
pnpm exec vitest run tests/harness/worktree-files.test.ts
--testNamePattern "workspace reset keeps Linux Sandbox paths POSIX"
# The Node host checks with tsc-ts, the default, over the named pipe it
# serves on Windows, and with tsgo.
- name: Windows scriptc run smoke
shell: pwsh
run: |
zig version
$output = & node packages/cli/dist/main.js run tests/corpus/001-hello.ts --backend llvm
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$actual = ($output -join "`n").Trim()
if ($actual -ne "hello world") {
throw "unexpected scriptc output: $actual"
foreach ($checker in @("tsc-ts", "tsgo")) {
$env:SCRIPTC_CHECKER = $checker
$output = & node packages/cli/dist/main.js run tests/corpus/001-hello.ts --backend llvm
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$actual = ($output -join "`n").Trim()
if ($actual -ne "hello world") {
throw "unexpected scriptc output with ${checker}: $actual"
}
}
- name: Windows PE subsystem selection
env:
+1
View File
@@ -12,6 +12,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` |
| `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner |
| `dictionary-walks` | cpu | `Record<string, number>` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted |
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
@@ -0,0 +1,6 @@
{
"entry": "workloads/dynamic-methods.mjs",
"args": [
"300000"
]
}
@@ -0,0 +1,36 @@
// Untyped JavaScript calling builtin methods on values it never declares
// types for: string slice/indexOf/toUpperCase/startsWith/padStart, array
// push/pop/at/join/includes/indexOf, Map and Set get/set/has/add, number
// toFixed, and calls of an object's own methods. Argument: the number of
// iterations.
const rounds = Number(process.argv[2] ?? "300000");
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"];
const counts = new Map();
const seen = new Set();
const stack = [];
const recent = [];
const tally = {
total: 0,
add(value) {
this.total += value;
return this.total;
},
};
let checksum = 0;
for (let i = 0; i < rounds; i++) {
const word = words[i % words.length];
const head = word.slice(0, 3).toUpperCase();
const key = head.padStart(4, "-");
if (word.indexOf("e") >= 0 && !word.startsWith("z")) checksum += 1;
counts.set(key, (counts.get(key) ?? 0) + 1);
seen.add(i % 97);
if (seen.has(i % 89)) checksum += 2;
stack.push(i);
if (stack.length > 32) checksum += stack.pop() % 3;
recent.push(word);
if (recent.length > 4) recent.shift();
if (recent.includes("gamma")) checksum += recent.indexOf("gamma");
checksum += tally.add(i % 5) % 11;
if (i % 1000 === 0) checksum += Number((i / 7).toFixed(2)) | 0;
}
console.log(checksum, counts.size, seen.size, stack.at(-1), recent.join(","), tally.total);
+1 -1
View File
@@ -206,7 +206,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>A runtime pack directory to use instead of an installed <code>@scriptc/runtime-&lt;target&gt;</code> package.</dd>
<dt><code>SCRIPTC_CHECKER</code></dt>
<dd>The TypeScript 7 API server that checks the program: <code>tsc-ts</code> (the default), which scriptc includes, or <code>tsgo</code>, from the <code>typescript</code> package scriptc also includes. Both give the same diagnostics and generated code. On Windows the default is <code>tsgo</code>, because tsc-ts's API server does not yet accept the named pipe scriptc connects over there.</dd>
<dd>The TypeScript 7 API server that checks the program: <code>tsc-ts</code> (the default), which scriptc includes, or <code>tsgo</code>, from the <code>typescript</code> package scriptc also includes. Both give the same diagnostics and generated code.</dd>
</dl>
```console
+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: 33, marker: "scr_runtime_abi_v33" },
runtime_abi: { version: 35, marker: "scr_runtime_abi_v35" },
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_v33");
expect(error?.stderr).toContain("scr_runtime_abi_v35");
});
});
+4
View File
@@ -1,6 +1,10 @@
#ifndef _WIN32
#define _POSIX_C_SOURCE 200809L
#endif
/* flock and LOCK_EX/LOCK_UN, which Darwin hides under _POSIX_C_SOURCE. */
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE 1
#endif
#include <stdint.h>
#include <limits.h>
#include <stddef.h>
+1 -1
View File
@@ -40,7 +40,7 @@
},
"dependencies": {
"@scriptc/runtime": "workspace:*",
"tsc-ts": "0.0.7",
"tsc-ts": "0.0.8",
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
@@ -28,6 +28,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
...(features.dynTier ? { dynTier: true } : {}),
...(features.streamDyn ? { streamDyn: true } : {}),
...(features.handleProps ? { handleProps: true } : {}),
...(features.declAdopt ? { declAdopt: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBuffer ? { dynBuffer: true } : {}),
+51 -1
View File
@@ -153,6 +153,8 @@ export interface DynHost extends WalkerHost {
/** See moduleUsesDynBuiltinStream. */
usesDynBuiltinStream?(): boolean;
liveDynUnionRefAdapter(t: IrType & { kind: "union" }): string;
/** The module has declaration-typed boundary casts (IrModule.declBoundary). */
declBoundary?(): boolean;
dynPromiseAdapter(t: IrType): string;
isErrorClass(className: string): boolean;
classSubtypes(className: string): readonly string[];
@@ -4526,6 +4528,31 @@ export class LlDyn {
/** The dyn argument spelling of one static value: dyn passes through
* (+1), funcs box anonymously, everything else rides toDyn. `expr` is
* BORROWED in every arm. Emits into the given builder. */
/** A live capsule of a record or array value (+1; the value is
* borrowed), as `dynFrom` with `liveRef` builds it; null for other
* types. Closures boxed in a declaration-typed boundary program return
* their records and arrays this way. */
private liveToDyn(B: BlockBuilder, t: IrType, expr: string): string | null {
const host = this.host;
if (t.kind === "union") {
const arms = host.unionsById.get(t.unionId)?.arms ?? [];
if (!arms.some((arm) => arm.kind === "record" || arm.kind === "array")) return null;
const boxed = B.tmp();
B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${expr})`);
return boxed;
}
if ((t.kind !== "record" && t.kind !== "array") || !streamTypedRefEligible(t)) return null;
const key = typeKey(t);
const adapter = host.liveDynRefAdapter(t);
const rc = vAdapters(host, t);
const boxed = B.tmp();
B.line(
`${boxed} = call ptr ${typedRefConstructor(host, t)}(ptr ${expr}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`,
);
for (const line of bindLiveArrayOps(host, adapter, boxed)) B.line(line);
return boxed;
}
private toDynExpr(B: BlockBuilder, t: IrType, expr: string): string {
if (t.kind === "dyn") return this.retainDyn(B, expr);
if (t.kind === "func") {
@@ -4657,6 +4684,26 @@ export class LlDyn {
},
"ptr null",
);
// Declaration-typed boundary programs: an argument package code
// passes keeps its identity (scr_dyn_decl_adopt).
const capsule = this.host.declBoundary?.() === true ? this.liveToDyn(B, p, a) : null;
if (capsule !== null) {
host.declare(`declare void @scr_dyn_decl_adopt(ptr, ptr)`);
host.declare(`declare void @scr_dyn_release(ptr)`);
B.line(`call void @scr_dyn_decl_adopt(ptr ${ad}, ptr ${capsule})`);
B.line(`call void @scr_dyn_release(ptr ${capsule})`);
this.pendingBail(
B,
"dfk.adopt",
() => {
t.params.slice(0, i + 1).forEach((q, j) => {
const name = j === i ? a : argNames[j];
if (isRefCounted(q)) B.line(`call void ${releaseSym(host, q)}(ptr ${name})`);
});
},
"ptr null",
);
}
argNames.push(a);
}
});
@@ -4689,7 +4736,10 @@ export class LlDyn {
const r = B.tmp();
B.line(`${r} = call ${retTy} ${fn}(${callArgs})`);
this.pendingBail(B, "dfkr", () => {}, "ptr null");
const out = this.toDynExpr(B, t.ret, r);
const out =
this.host.declBoundary?.() === true
? (this.liveToDyn(B, t.ret, r) ?? this.toDynExpr(B, t.ret, r))
: this.toDynExpr(B, t.ret, r);
if (isRefCounted(t.ret)) B.line(`call void ${releaseSym(host, t.ret)}(ptr ${r})`);
B.terminate(`ret ptr ${out}`);
}
@@ -873,6 +873,8 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
out.push(`@${sym} = internal constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr } { ${body} }`);
}
if (emitter.mapOpsConsts.size > 0) out.push(``);
for (const sym of emitter.methodIdSlots.values()) out.push(`@${sym} = internal global i32 -1`);
if (emitter.methodIdSlots.size > 0) out.push(``);
for (const line of ffiCallbacks.globals) out.push(line);
if (ffiCallbacks.globals.length > 0) out.push(``);
+29 -1
View File
@@ -92,6 +92,7 @@ import {
type IntegerRanges,
} from "../../ir/integer-ranges.js";
import { unshareNodes } from "../../ir/unshare.js";
import { runPairedWhileLoopsAsFor } from "../../ir/paired-while-loops.js";
import { copyStableCaptures } from "../../ir/capture-values.js";
import { resolveSelfCaptures } from "../../ir/self-captures.js";
import { analyzeInt32Slots, dynamicFieldStores, type Int32Slots } from "../../ir/int32-slots.js";
@@ -527,7 +528,9 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)),
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
@@ -1076,6 +1079,10 @@ export class LlEmitter {
readonly cstrs = new Map<string, { sym: string; len: number }>();
/** Interned ScrMapOps descriptors (scr_map.h): initializer → symbol. */
readonly mapOpsConsts = new Map<string, string>();
/** Method-id slots of checked-dynamic method calls
* (scr_dyn_invoke_prepared_id): method name → symbol of an i32 the
* runtime fills with the name's id on the first call. */
readonly methodIdSlots = new Map<string, string>();
/** Type-directed walker functions (JSON serializers, the indent
* rewriter, union ToString/join) — interned per typeKey/unionId, defs
* flushed with the shape helpers. */
@@ -1844,6 +1851,7 @@ export class LlEmitter {
liveDynRefAdapter: (type) => this.liveDynRefAdapter(type),
usesDynBuiltinStream: () => this.usesDynBuiltinStream,
liveDynUnionRefAdapter: (type) => this.liveDynUnionRefAdapter(type),
declBoundary: () => this.mod.declBoundary === true,
dynPromiseAdapter: (type) => this.dynPromiseAdapter(type),
isErrorClass: (name) => this.classMeta.get(name)?.root.def.name === "%Error",
classRootOf: (name) => this.classMeta.get(name)?.root.def.name,
@@ -2736,6 +2744,17 @@ export class LlEmitter {
return `@${c.sym}`;
}
/** The method-id slot for a constant method name (methodIdSlots). */
methodIdSlot(name: string): string {
let sym = this.methodIdSlots.get(name);
if (!sym) {
sym = this.contentSymbol("sc_mid_", name);
this.methodIdSlots.set(name, sym);
}
this.journal?.entry("methodIdSlot", name, sym);
return `@${sym}`;
}
needBadTag(): void {
this.needsBadTag = true;
}
@@ -4853,6 +4872,15 @@ export class LlEmitter {
if (own !== value) names.set(value as string, own);
return;
}
case "methodIdSlot": {
let own = this.methodIdSlots.get(key);
if (own === undefined) {
own = this.contentSymbol("sc_mid_", key);
this.methodIdSlots.set(key, own);
}
if (own !== value) names.set(value as string, own);
return;
}
case "classObj":
addEntry(this.classObjs, key, value as { nameSym: string });
return;
@@ -250,8 +250,10 @@ export function emitDynamicExpr(
}
// Buffer.prototype method names take their scr_bytes_dyn.c entry, which
// runs them on Buffer receivers and dispatches any other receiver.
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared";
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
// Each call passes its method name's id slot, so only the first call
// looks the name up.
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id";
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
let name = host.cstr(e.calleeName);
if (e.calleeNameValue !== undefined) {
const value = emitBorrowedInput(host, e.calleeNameValue);
@@ -260,7 +262,7 @@ export function emitDynamicExpr(
}
const t = B.tmp();
B.line(
`${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
`${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
+1 -1
View File
@@ -732,7 +732,7 @@ function emitKeyed(host: Host, e: ExprOf<"call">, callee: IrFunction): LlValue {
/** dynInvoke's method read on an ordinary object through the site's
* cache, else scr_dyn_prepare_method. Returns the owned method (or NULL,
* which scr_dyn_invoke_prepared answers with the full dispatch). */
* which scr_dyn_invoke_prepared_id answers with the full dispatch). */
export function emitJsIcMethod(host: Host, e: ExprOf<"dynInvoke">, receiver: string): string {
const B = host.B;
const method = e.method;
@@ -973,6 +973,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.typedRefIs": "scr_dyn_typed_ref_is_key",
"dyn.classIs": "scr_dyn_class_is_key",
"dyn.iterPack": "scr_dyn_iter_pack",
"dyn.mathFold": "scr_dyn_math_fold",
"dyn.destructureRest": "scr_dyn_destructure_rest",
"dyn.iterator": "scr_dyn_iterator",
"dyn.asyncIteratorOpen": "scr_dyn_async_iterator_open",
@@ -1587,6 +1588,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.objValueList": "scr_dyn_obj_value_list",
"dyn.objEntries": "scr_dyn_obj_entries",
"dyn.structuredClone": "scr_structured_clone",
"dyn.declAdopt": "scr_dyn_decl_adopt",
"dyn.cloneMissing": "scr_structured_clone_missing",
"dyn.cloneTransferFail": "scr_structured_clone_transfer_fail",
"dyn.cloneTransferFailWith": "scr_structured_clone_transfer_fail_with",
@@ -49,6 +49,7 @@ export interface NativeLinkFeatures {
/** Own properties JavaScript adds to children: scr_dyn_handle_props.c.
* Absent (false) for programs without them. */
handleProps?: boolean;
declAdopt?: boolean;
/** for await over checked-dynamic values: scr_dyn_for_await.c. Absent
* (false) for programs without it. */
dynForAwait?: boolean;
@@ -122,6 +122,7 @@ export interface CcOptions {
streamDyn?: boolean;
/** Own properties JavaScript adds to children: compiles scr_dyn_handle_props.c. */
handleProps?: boolean;
declAdopt?: boolean;
/** for await over checked-dynamic values: compiles scr_dyn_for_await.c. */
dynForAwait?: boolean;
/** events.once with options (events.onceSignal): compiles
@@ -279,6 +279,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.streamDyn === true, "scr_stream_dyn.c"),
// Own properties JavaScript adds to children.
unit((u) => u.opts.handleProps === true, "scr_dyn_handle_props.c"),
// The declaration-typed boundary's adoption.
unit((u) => u.opts.declAdopt === true, "scr_decl_adopt.c"),
// for await over checked-dynamic values.
unit((u) => u.opts.dynForAwait === true, "scr_dyn_for_await.c"),
// for await over checked-dynamic values that may hold native streams.
+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 = 33 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v33" as const;
export const RUNTIME_ABI_VERSION = 35 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v35" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -86,6 +86,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
dynTier: boolean;
streamDyn: boolean;
handleProps: boolean;
declAdopt: boolean;
dynForAwait: boolean;
eventsOnceSignal: boolean;
dynBuffer: boolean;
@@ -283,6 +284,7 @@ export function effectiveRuntimeFeatures(
dynTier: features.dynTier === true,
streamDyn: features.streamDyn === true,
handleProps: features.handleProps === true,
declAdopt: features.declAdopt === true,
dynForAwait: features.dynForAwait === true,
eventsOnceSignal: features.eventsOnceSignal === true,
dynBuffer: features.dynBuffer === true,
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 33, marker: "scr_runtime_abi_v33" },
runtime_abi: { version: 35, marker: "scr_runtime_abi_v35" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -127,7 +127,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 33, marker: "scr_runtime_abi_v33" },
runtime_abi: { version: 35, marker: "scr_runtime_abi_v35" },
compiler: {
command: "clang",
identity: "fixture clang",
@@ -70,6 +70,7 @@ export interface EarlyExecutableNativeFeatures {
dynTier?: boolean;
streamDyn?: boolean;
handleProps?: boolean;
declAdopt?: boolean;
dynForAwait?: boolean;
eventsOnceSignal?: boolean;
dynBuffer?: boolean;
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { applyEdits, isDeclBoundaryFile, isDeclBoundaryTypeName } from "./decl-projection.js";
describe("decl-projection", () => {
test("applyEdits closes the wrap that opened last first", () => {
// `x.y` wrapped from 0 (outer) and from 2 (inner), both closing at 5:
// closing parts rank by their wrap's negated start.
const out = applyEdits("x = a.b;", [
{ start: 4, end: 4, text: "(" },
{ start: 7, end: 7, text: " as Outer)", rank: -4 },
{ start: 6, end: 6, text: "(" },
{ start: 7, end: 7, text: " as Inner)", rank: -6 },
]);
expect(out).toBe("x = (a.(b as Inner) as Outer);");
});
test("applyEdits keeps the first of overlapping replacements and every line", () => {
const text = "type A = z.infer<typeof S>;\nconst x = 1;";
const out = applyEdits(text, [
{ start: 9, end: 26, text: "__scriptc_decl_0" },
{ start: 11, end: 16, text: "nope" },
]);
expect(out).toBe("type A = __scriptc_decl_0;\nconst x = 1;");
});
test("names", () => {
expect(isDeclBoundaryTypeName("__scriptc_decl_3")).toBe(true);
expect(isDeclBoundaryTypeName("Product")).toBe(false);
expect(isDeclBoundaryFile("/app/src/__scriptc_decl_boundary__.d.ts")).toBe(true);
expect(isDeclBoundaryFile("/app/src/types.d.ts")).toBe(false);
});
});
@@ -0,0 +1,793 @@
/* The declaration-typed boundary's projection pass.
*
* An opted-in npm package compiles from its JavaScript, and the program the
* lowering compiles types it from that JavaScript: where the package's
* published declarations are richer (zod's `z.infer`), user code sees `any`.
* This pass asks the DECLARATION world (the same entry checked against the
* packages' declarations) for the types of those user-code positions. Where
* the declared type is pure data, it is printed as structural TypeScript into
* a generated global declaration file, and the user file is rewritten in the
* lowering world:
*
* - type positions that name package types (`z.infer<typeof S>`) name the
* projected alias instead;
* - package-produced expressions (`S.parse(x)`, `r.data`) are wrapped in
* `(<expr> as __scriptc_decl_N)`, the boundary cast;
* - untyped parameters of user callbacks the package calls gain the alias.
*
* The lowering world is then rebuilt from the rewritten text, so the
* checker's own inference carries the projected types through the rest of
* the user code, and the lowering sees an ordinary checked cast at each
* crossing (lowered with `adopt`, see narrowing.ts). No edit adds or removes
* a line break, so positions keep their lines. IR records are structural,
* so a printed type interns to the same shape as any equal type.
*
* Only programs whose user code has such positions are rewritten; every
* other program is untouched (the zero-cost rule). See
* scriptc-perf/results/design-decl-boundary/DESIGN.md. */
import * as ts from "./ts7/adapter.js";
/** Alias names of projected types start with this prefix; the lowering
* recognizes boundary casts and parameters by it. */
export const DECL_BOUNDARY_PREFIX = "__scriptc_decl_";
/** The generated declaration file's base name. */
export const DECL_BOUNDARY_FILE = "__scriptc_decl_boundary__.d.ts";
export function isDeclBoundaryFile(fileName: string): boolean {
return fileName.split("\\").join("/").endsWith(`/${DECL_BOUNDARY_FILE}`);
}
export function isDeclBoundaryTypeName(name: string): boolean {
return name.startsWith(DECL_BOUNDARY_PREFIX);
}
/** True for `T` in `x as T` / `(p: T) =>` when T names a projected alias. */
export function isDeclBoundaryTypeNode(node: ts.TypeNode | undefined): boolean {
return (
node !== undefined &&
ts.isTypeReferenceNode(node) &&
ts.isIdentifier(node.typeName) &&
isDeclBoundaryTypeName(node.typeName.text) &&
node.typeArguments === undefined
);
}
interface Edit {
start: number;
end: number;
text: string;
/** Tie-break among edits at one position (lower first). */
rank?: number;
}
export interface DeclProjection {
/** Rewritten text per user file. */
files: Map<string, string>;
/** The generated declaration file (path and text). */
declFile: string;
declText: string;
/** Human-readable plan (SCRIPTC_DEBUG_DECL_BOUNDARY). */
plan: string[];
}
export interface DeclProjectionInput {
jsProgram: ts.Program;
declProgram: ts.Program;
/** User source files to consider (TypeScript, outside node_modules). */
userFiles: readonly string[];
/** Directory of the generated declaration file. */
outDir: string;
}
function isPackageDeclarationFile(sf: ts.SourceFile | undefined): boolean {
if (sf === undefined || !sf.isDeclarationFile) return false;
const f = sf.fileName.split("\\").join("/");
// The TypeScript standard library ships inside node_modules too.
if (/(^|\/)lib(\.[\w.-]+)?\.d\.ts$/.test(f) || f.startsWith("bundled:")) return false;
return (
f.includes("/node_modules/") &&
!f.includes("/node_modules/@types/node/") &&
!f.includes("/node_modules/typescript/") &&
!f.includes("/node_modules/@typescript/")
);
}
function declaredInPackage(symbol: ts.Symbol | undefined): boolean {
if (symbol === undefined) return false;
return symbol.declarations.some((d) => isPackageDeclarationFile(ts.getSourceFileOfNode(d)));
}
function declaredInUserFile(symbol: ts.Symbol | undefined): boolean {
if (symbol === undefined) return false;
const decls = symbol.declarations;
return (
decls.length > 0 &&
decls.every((d) => {
const sf = ts.getSourceFileOfNode(d);
return !sf.isDeclarationFile && !sf.fileName.split("\\").join("/").includes("/node_modules/");
})
);
}
const ANYISH = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
const READONLY_CHECK_FLAG = 8;
/** Prints declaration-world types as structural TypeScript, or answers null
* when a type is not pure data (DESIGN.md §4.3). */
class TypePrinter {
readonly aliases = new Map<string, string>();
readonly declarations: string[] = [];
private readonly inProgress = new Map<number, { name: string | null }>();
constructor(private readonly checker: ts.TypeChecker) {}
/** The alias naming a projectable type, or null. Top-level any/unknown
* never projects. */
project(type: ts.Type): string | null {
if (type.flags & ANYISH) return null;
const text = this.print(type, 0);
if (text === null || text === "unknown") return null;
let name = this.aliases.get(text);
if (name === undefined) {
name = `${DECL_BOUNDARY_PREFIX}${this.aliases.size}`;
this.aliases.set(text, name);
this.declarations.push(`type ${name} = ${text};`);
}
return name;
}
private print(type: ts.Type, depth: number): string | null {
if (depth > 24) return null;
const f = type.flags;
if (f & ANYISH) return "unknown";
if (f & ts.TypeFlags.String) return "string";
if (f & ts.TypeFlags.Number) return "number";
if (f & ts.TypeFlags.Boolean) return "boolean";
if (f & ts.TypeFlags.Null) return "null";
if (f & ts.TypeFlags.Undefined) return "undefined";
if (f & ts.TypeFlags.EnumLiteral || f & ts.TypeFlags.Enum) return null;
if (f & ts.TypeFlags.BooleanLiteral) return type.intrinsicName === "true" ? "true" : "false";
if (f & ts.TypeFlags.StringLiteral) return JSON.stringify(String(type.value));
if (f & ts.TypeFlags.NumberLiteral) {
const v = Number(type.value);
return Number.isFinite(v) ? String(v) : null;
}
if (type.isUnionType()) {
const parts: string[] = [];
for (const arm of type.getTypes()) {
const p = this.print(arm, depth + 1);
if (p === null || p === "unknown") return null;
if (!parts.includes(p)) parts.push(p);
}
// `true | false` reads better as boolean, and maps the same.
const t = parts.indexOf("true");
const fl = parts.indexOf("false");
if (t >= 0 && fl >= 0) {
parts.splice(Math.max(t, fl), 1);
parts.splice(Math.min(t, fl), 1, "boolean");
}
return parts.map((p) => (/=>/.test(p) ? `(${p})` : p)).join(" | ");
}
if (type.isIntersectionType()) {
// Brands: drop members that only carry unique-symbol keys.
const kept = type.getTypes().filter((m) => !this.isBrandOnly(m));
if (kept.length === 1) return this.print(kept[0]!, depth + 1);
if (kept.length === 0) return null;
if (!kept.every((m) => (m.flags & ts.TypeFlags.Object) !== 0)) return null;
return this.printObject(type, depth);
}
if (f & ts.TypeFlags.Object) return this.printObject(type, depth);
return null;
}
private isBrandOnly(type: ts.Type): boolean {
if (!(type.flags & ts.TypeFlags.Object) || type.isIntersectionType()) return false;
const props = this.checker.getPropertiesOfType(type);
return props.length > 0 && props.every((p) => String(p.escapedName).startsWith("__@"));
}
private printObject(type: ts.Type, depth: number): string | null {
const checker = this.checker;
if (checker.isArrayType(type)) {
const [elem] = checker.getTypeArguments(type);
if (elem === undefined) return null;
const e = this.print(elem, depth + 1);
if (e === null) return null;
const ro = type.getSymbol()?.escapedName === "ReadonlyArray" ? "readonly " : "";
return `${ro}(${e})[]`;
}
if (checker.isTupleType(type)) {
const target = type.getTarget() ?? type;
const flags = target.elementFlags ?? [];
if (flags.some((fl) => fl !== ts.ElementFlags.Required)) return null;
const elems = checker.getTypeArguments(type).slice(0, flags.length);
const out: string[] = [];
for (const e of elems) {
const p = this.print(e, depth + 1);
if (p === null) return null;
out.push(p);
}
return `${target.readonly === true ? "readonly " : ""}[${out.join(", ")}]`;
}
const symbol = type.getSymbol();
if (symbol !== undefined) {
if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Function | ts.SymbolFlags.Method))
return null;
// Interfaces: only user-declared ones describe plain data reliably
// (package and lib interfaces often describe class instances).
if (symbol.flags & ts.SymbolFlags.Interface && !declaredInUserFile(symbol)) return null;
}
if (
checker.getCallSignatures(type).length > 0 ||
checker.getConstructSignatures(type).length > 0
)
return null;
const memo = this.inProgress.get(type.id);
if (memo !== undefined) {
memo.name ??= `${DECL_BOUNDARY_PREFIX}r${type.id}`;
return memo.name;
}
const frame: { name: string | null } = { name: null };
this.inProgress.set(type.id, frame);
try {
const members: string[] = [];
let readonlyCount = 0;
const props = checker.getPropertiesOfType(type);
for (const prop of props) {
const key = String(prop.escapedName);
if (key.startsWith("__@")) continue; // symbol-keyed (brands)
if (
prop.flags &
(ts.SymbolFlags.Method | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)
)
return null;
const propType = checker.getTypeOfSymbol(prop);
const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
let p = this.print(propType, depth + 1);
if (p === null) return null;
const readonly = (prop.checkFlags & READONLY_CHECK_FLAG) !== 0 || hasReadonlyModifier(prop);
if (readonly) readonlyCount++;
const name = /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
if (optional && !/\bundefined\b/.test(p)) p = `${p} | undefined`;
members.push(`${readonly ? "readonly " : ""}${name}${optional ? "?" : ""}: ${p}`);
}
for (const info of checker.getIndexInfosOfType(type)) {
if (!(info.keyType.flags & ts.TypeFlags.String)) return null;
const v = this.print(info.type, depth + 1);
if (v === null) return null;
members.push(`[key: string]: ${v}`);
}
if (members.length === 0) return null; // `{}` admits any non-nullish value
// All-readonly object types may describe frozen values (.readonly()).
if (props.length > 0 && readonlyCount === props.length) return null;
const text = `{ ${members.join("; ")} }`;
if (frame.name !== null) {
this.declarations.push(`type ${frame.name} = ${text};`);
return frame.name;
}
return text;
} finally {
this.inProgress.delete(type.id);
}
}
}
function hasReadonlyModifier(prop: ts.Symbol): boolean {
return prop.declarations.some(
(d) =>
(ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) &&
(d.modifiers?.nodes ?? []).some((m) => m.kind === ts.SyntaxKind.ReadonlyKeyword),
);
}
/** Children of a node in source order (forEachChild order). */
function childrenOf(node: ts.Node): ts.Node[] {
const out: ts.Node[] = [];
ts.forEachChild(
node,
(c) => {
out.push(c);
return undefined;
},
(list) => {
for (const c of list.nodes) out.push(c);
return undefined;
},
);
return out;
}
function isWriteTarget(node: ts.Node): boolean {
let child = node;
let parent = node.parent;
while (parent !== undefined && ts.isParenthesizedExpression(parent)) {
child = parent;
parent = parent.parent;
}
if (parent === undefined) return false;
if (ts.isBinaryExpression(parent) && parent.left === child) {
const k = parent.operatorToken.kind;
return k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment;
}
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
const op = parent.operator;
return op === ts.SyntaxKind.PlusPlusToken || op === ts.SyntaxKind.MinusMinusToken;
}
if (ts.isDeleteExpression(parent)) return true;
if (ts.isForOfStatement(parent) || ts.isForInStatement(parent))
return parent.initializer === child;
if (ts.isShorthandPropertyAssignment(parent)) return true;
if (
ts.isArrayLiteralExpression(parent) ||
ts.isPropertyAssignment(parent) ||
ts.isSpreadElement(parent)
) {
// Destructuring assignment targets: conservative, skip.
let up: ts.Node | undefined = parent;
while (
up !== undefined &&
(ts.isArrayLiteralExpression(up) ||
ts.isObjectLiteralExpression(up) ||
ts.isPropertyAssignment(up) ||
ts.isSpreadElement(up))
)
up = up.parent;
if (
up !== undefined &&
ts.isBinaryExpression(up) &&
up.operatorToken.kind === ts.SyntaxKind.EqualsToken
)
return true;
}
return false;
}
function inOptionalChain(node: ts.Node): boolean {
const parent = node.parent;
if (parent === undefined) return false;
if (
(ts.isPropertyAccessExpression(parent) ||
ts.isElementAccessExpression(parent) ||
ts.isCallExpression(parent)) &&
parent.expression === node &&
(parent.questionDotToken !== undefined || (parent.flags & ts.NodeFlags.OptionalChain) !== 0)
)
return true;
return (
(ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node)) &&
(node.flags & ts.NodeFlags.OptionalChain) !== 0
);
}
function isCallee(node: ts.Node): boolean {
const p = node.parent;
return (
p !== undefined &&
(ts.isCallExpression(p) || ts.isNewExpression(p) || ts.isTaggedTemplateExpression(p)) &&
(ts.isTaggedTemplateExpression(p) ? p.tag === node : p.expression === node)
);
}
function inTypeContext(node: ts.Node): boolean {
for (let p = node.parent; p !== undefined; p = p.parent) {
if (ts.isTypeNode(p) || ts.isTypeQueryNode(p)) return true;
if (ts.isExpressionWithTypeArguments(p) && p.parent && ts.isHeritageClause(p.parent))
return true;
if (ts.isStatement(p)) return false;
}
return false;
}
function unwrapTransparent(node: ts.Expression): ts.Expression {
let e = node;
while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e) || ts.isAwaitExpression(e))
e = e.expression;
return e;
}
/** Plans the projection for one program pair. Answers null when nothing
* in the user files projects (the program stays as it is). */
export function planDeclProjection(input: DeclProjectionInput): DeclProjection | null {
const jsChecker = input.jsProgram.getTypeChecker();
const declChecker = input.declProgram.getTypeChecker();
const printer = new TypePrinter(declChecker);
const plan: string[] = [];
const edits = new Map<string, Edit[]>();
const typeAt = (checker: ts.TypeChecker, node: ts.Node): ts.Type | null => {
try {
return checker.getTypeAtLocation(node);
} catch {
return null;
}
};
const isAnyish = (t: ts.Type | null): boolean => t !== null && (t.flags & ANYISH) !== 0;
const resolveAlias = (s: ts.Symbol | undefined): ts.Symbol | undefined => {
if (s === undefined || !(s.flags & ts.SymbolFlags.Alias)) return s;
try {
return declChecker.getAliasedSymbol(s);
} catch {
return s;
}
};
const declSymbol = (n: ts.Node): ts.Symbol | undefined => {
try {
return resolveAlias(declChecker.getSymbolAtLocation(n));
} catch {
return undefined;
}
};
const namesPackageSymbol = (node: ts.Node): boolean => {
let found = false;
const visit = (n: ts.Node): void => {
if (found) return;
if (ts.isIdentifier(n) && declaredInPackage(declSymbol(n))) {
found = true;
return;
}
for (const c of childrenOf(n)) visit(c);
};
visit(node);
return found;
};
for (const file of input.userFiles) {
const jsSf = input.jsProgram.getSourceFile(file);
const declSf = input.declProgram.getSourceFile(file);
if (jsSf === undefined || declSf === undefined || jsSf.text !== declSf.text) continue;
const fileEdits: Edit[] = [];
/** Candidate expressions (by js node) with their alias. */
const exprCandidates = new Map<ts.Node, string>();
const exprOrder: ts.Node[] = [];
const considerType = (jsNode: ts.Node, declNode: ts.Node): boolean => {
if (
!ts.isTypeReferenceNode(jsNode) &&
!ts.isIndexedAccessTypeNode(jsNode) &&
!ts.isExpressionWithTypeArguments(jsNode)
)
return false;
if (ts.isExpressionWithTypeArguments(jsNode)) {
if (!jsNode.parent || !ts.isHeritageClause(jsNode.parent)) return false;
if (!jsNode.parent.parent || !ts.isInterfaceDeclaration(jsNode.parent.parent)) return false;
}
let jsType: ts.Type | null;
let declType: ts.Type | null;
try {
jsType = ts.isExpressionWithTypeArguments(jsNode)
? jsChecker.getTypeAtLocation(jsNode)
: jsChecker.getTypeFromTypeNode(jsNode as ts.TypeNode);
declType = ts.isExpressionWithTypeArguments(declNode)
? declChecker.getTypeAtLocation(declNode)
: declChecker.getTypeFromTypeNode(declNode as ts.TypeNode);
} catch {
return false;
}
if (!isAnyish(jsType) || declType === null) return false;
if (!namesPackageSymbol(declNode)) return false;
const alias = printer.project(declType);
if (alias === null) return false;
const start = ts.tokenStartOfNode(jsNode);
fileEdits.push({ start, end: jsNode.end, text: alias });
plan.push(`${file}:${start}: type ${jsSf.text.slice(start, jsNode.end)} -> ${alias}`);
return true;
};
const considerExpr = (jsNode: ts.Node, declNode: ts.Node): void => {
if (!ts.isExpression(jsNode)) return;
const isCall =
ts.isCallExpression(jsNode) ||
ts.isNewExpression(jsNode) ||
ts.isTaggedTemplateExpression(jsNode);
const isAccess =
ts.isPropertyAccessExpression(jsNode) || ts.isElementAccessExpression(jsNode);
const isAwait = ts.isAwaitExpression(jsNode);
if (!isCall && !isAccess && !isAwait && !ts.isIdentifier(jsNode)) return;
if (inTypeContext(jsNode) || isCallee(jsNode) || isWriteTarget(jsNode)) return;
if (inOptionalChain(jsNode)) return;
const parent = jsNode.parent;
if (parent !== undefined && ts.isExpressionStatement(parent)) return;
if (
parent !== undefined &&
(ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent) ||
ts.isExportSpecifier(parent))
)
return;
if (ts.isIdentifier(jsNode)) {
if (parent !== undefined && ts.isPropertyAccessExpression(parent) && parent.name === jsNode)
return;
}
const jsType = typeAt(jsChecker, jsNode);
if (!isAnyish(jsType)) return;
// Package-produced?
const d = declNode as ts.Expression;
let produced = false;
if (ts.isCallExpression(d) || ts.isNewExpression(d)) {
const callee = d.expression;
const target = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
produced = declaredInPackage(declSymbol(target));
if (!produced) {
try {
const sig = declChecker.getResolvedSignature(d);
const decl = sig?.declaration;
produced = decl !== undefined && isPackageDeclarationFile(ts.getSourceFileOfNode(decl));
} catch {
produced = false;
}
}
} else if (ts.isTaggedTemplateExpression(d)) {
produced = declaredInPackage(declSymbol(d.tag));
} else if (ts.isPropertyAccessExpression(d) || ts.isElementAccessExpression(d)) {
const recvDecl = typeAt(declChecker, d.expression);
const recvJs = typeAt(jsChecker, (jsNode as ts.PropertyAccessExpression).expression);
const recvProjectable = recvDecl !== null && printer.project(recvDecl) !== null;
produced = !recvProjectable || (recvJs !== null && !isAnyish(recvJs));
} else if (ts.isAwaitExpression(d)) {
const inner = unwrapTransparent(d.expression);
produced =
(ts.isCallExpression(inner) || ts.isPropertyAccessExpression(inner)) &&
(declaredInPackage(
declSymbol(
ts.isCallExpression(inner)
? ts.isPropertyAccessExpression(inner.expression)
? inner.expression.name
: inner.expression
: inner.name,
),
) ||
isAnyish(typeAt(jsChecker, inner)));
} else if (ts.isIdentifier(d)) {
const raw = (() => {
try {
return declChecker.getSymbolAtLocation(d);
} catch {
return undefined;
}
})();
produced =
raw !== undefined &&
(raw.flags & ts.SymbolFlags.Alias) !== 0 &&
declaredInPackage(resolveAlias(raw));
}
if (!produced) return;
// A consumer that takes anything needs no conversion.
try {
const ctx = declChecker.getContextualType(d);
if (ctx !== undefined && ctx.flags & ANYISH) return;
} catch {
// no contextual type
}
const declType = typeAt(declChecker, d);
if (declType === null) return;
const alias = printer.project(declType);
if (alias === null) return;
exprCandidates.set(jsNode, alias);
exprOrder.push(jsNode);
};
const annotated = new Set<ts.Node>();
const considerParam = (jsNode: ts.Node, declNode: ts.Node): void => {
if (!ts.isParameter(jsNode) || jsNode.type !== undefined || !ts.isIdentifier(jsNode.name))
return;
const fn = jsNode.parent;
if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return;
if (jsNode.dotDotDotToken !== undefined) return;
const jsType = typeAt(jsChecker, jsNode.name);
if (!isAnyish(jsType)) return;
const declType = typeAt(declChecker, (declNode as ts.ParameterDeclaration).name);
if (declType === null) return;
// Only parameters the package's declarations type (a callback the
// package calls), not ones the user typed elsewhere.
try {
const ctxFn = declChecker.getContextualType(declNode.parent as ts.Expression);
if (ctxFn === undefined) return;
const sigs = declChecker.getCallSignatures(ctxFn);
if (
!sigs.some(
(s) =>
s.declaration !== undefined &&
isPackageDeclarationFile(ts.getSourceFileOfNode(s.declaration)),
)
)
return;
} catch {
return;
}
const alias = printer.project(declType);
if (alias === null) return;
annotated.add(jsNode);
const name = jsNode.name;
const nameStart = ts.tokenStartOfNode(name);
const text = jsSf.text;
// `x => ...` or `async x => ...`: nothing but the modifiers comes
// before the parameter, which then needs parentheses for its type.
const head = ts.isArrowFunction(fn)
? ((ts.getModifiers(fn) ?? []).at(-1)?.end ?? ts.tokenStartOfNode(fn))
: nameStart;
const bareArrow =
ts.isArrowFunction(fn) &&
fn.parameters.nodes.length === 1 &&
text.slice(head, nameStart).trim() === "";
const afterName = jsNode.questionToken !== undefined ? jsNode.questionToken.end : name.end;
if (bareArrow) {
fileEdits.push({ start: nameStart, end: nameStart, text: "(" });
fileEdits.push({ start: afterName, end: afterName, text: `: ${alias})` });
} else {
fileEdits.push({ start: afterName, end: afterName, text: `: ${alias}` });
}
plan.push(`${file}:${nameStart}: parameter ${name.text}: ${alias}`);
};
const walk = (jsNode: ts.Node, declNode: ts.Node): void => {
if (jsNode.kind !== declNode.kind) return;
if (ts.isTypeNode(jsNode) || ts.isExpressionWithTypeArguments(jsNode)) {
if (considerType(jsNode, declNode)) return;
} else {
considerParam(jsNode, declNode);
considerExpr(jsNode, declNode);
}
const a = childrenOf(jsNode);
const b = childrenOf(declNode);
if (a.length !== b.length) return;
for (let i = 0; i < a.length; i++) walk(a[i]!, b[i]!);
};
walk(jsSf, declSf);
// Suppress candidates whose receiver chain another candidate types.
const symbolTyped = new Map<ts.Symbol, boolean>();
const willBeTyped = (e: ts.Node, depth = 0): boolean => {
if (depth > 32) return false;
if (exprCandidates.has(e)) return true;
if (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e) || ts.isAwaitExpression(e))
return willBeTyped(e.expression, depth + 1);
if (ts.isAsExpression(e)) return false;
if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e))
return willBeTyped(e.expression, depth + 1);
if (ts.isIdentifier(e)) {
let sym: ts.Symbol | undefined;
try {
sym = jsChecker.getSymbolAtLocation(e);
} catch {
return false;
}
if (sym === undefined) return false;
const known = symbolTyped.get(sym);
if (known !== undefined) return known;
symbolTyped.set(sym, false);
let result = false;
const decls = sym.declarations;
if (decls.length === 1) {
const decl = decls[0]!;
if (
ts.isVariableDeclaration(decl) &&
decl.initializer !== undefined &&
decl.type === undefined
)
result = willBeTyped(decl.initializer, depth + 1);
else if (ts.isParameter(decl) && annotated.has(decl)) result = true;
else if (ts.isBindingElement(decl)) result = false;
}
symbolTyped.set(sym, result);
return result;
}
return false;
};
const receiverOf = (e: ts.Node): ts.Node | null => {
if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) return e.expression;
if (ts.isIdentifier(e)) return null;
return null;
};
const kept: ts.Node[] = [];
for (const e of exprOrder) {
const recv = receiverOf(e);
if (recv !== null && willBeTyped(recv)) continue;
// An identifier candidate that is a user binding never gets here
// (only package imports are candidates).
kept.push(e);
}
// Nested candidates: keep only the innermost (an outer one is typed by
// the inner wrap).
const keptSet = new Set(kept);
const containsKept = (outer: ts.Node): boolean => {
let found = false;
const visit = (n: ts.Node): void => {
if (found) return;
for (const c of childrenOf(n)) {
if (keptSet.has(c)) {
found = true;
return;
}
visit(c);
}
};
visit(outer);
return found;
};
for (const e of kept) {
if (
!ts.isAwaitExpression(e) &&
containsKept(e) &&
(ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e))
) {
// `a.b.c` with `a.b` wrapped: the outer access reads typed data.
const recv = receiverOf(e);
if (
recv !== null &&
(keptSet.has(unwrapTransparent(recv as ts.Expression)) || willBeTyped(recv))
)
continue;
}
const alias = exprCandidates.get(e)!;
const start = ts.tokenStartOfNode(e);
const stmt = e.parent;
let prefix = "(";
// A statement that would start with `(` after a line without a
// semicolon continues the previous expression: guard it.
let up: ts.Node | undefined = e;
while (up?.parent !== undefined && !ts.isStatement(up.parent)) up = up.parent;
// Only a statement in a statement list can follow such a line; an
// unbraced body (`for (...) S.parse(x).run()`) follows its header,
// and a `;` there would become the body.
const list = up?.parent?.parent;
if (
up?.parent !== undefined &&
ts.isExpressionStatement(up.parent) &&
ts.tokenStartOfNode(up.parent) === start &&
list !== undefined &&
(ts.isBlock(list) ||
ts.isSourceFile(list) ||
ts.isModuleBlock(list) ||
ts.isCaseClause(list) ||
ts.isDefaultClause(list))
)
prefix = ";(";
void stmt;
fileEdits.push({ start, end: start, text: prefix });
// Closing parts at one position close inner wraps first.
fileEdits.push({ start: e.end, end: e.end, text: ` as ${alias})`, rank: -start });
plan.push(
`${file}:${start}: cast ${jsSf.text.slice(start, Math.min(e.end, start + 60)).replace(/\s+/g, " ")} as ${alias}`,
);
}
if (fileEdits.length === 0) continue;
edits.set(file, fileEdits);
}
if (edits.size === 0) return null;
const files = new Map<string, string>();
for (const [file, list] of edits) {
const sf = input.jsProgram.getSourceFile(file)!;
files.set(file, applyEdits(sf.text, list));
}
const declFile = `${input.outDir.split("\\").join("/")}/${DECL_BOUNDARY_FILE}`;
const declText =
"// Generated by scriptc: types projected from npm package declarations.\n" +
printer.declarations.join("\n") +
"\n";
return { files, declFile, declText, plan };
}
/** Applies insertions and replacements; overlapping replacements keep the
* first. Insertions at one position keep their order. */
export function applyEdits(text: string, edits: readonly Edit[]): string {
const sorted = edits
.map((e, i) => ({ ...e, i }))
.sort(
(a, b) => a.start - b.start || a.end - b.end || (a.rank ?? 0) - (b.rank ?? 0) || a.i - b.i,
);
let out = "";
let at = 0;
for (const e of sorted) {
if (e.start < at) continue; // overlaps an earlier replacement
out += text.slice(at, e.start) + e.text;
at = e.end;
}
return out + text.slice(at);
}
@@ -34,6 +34,31 @@ export function packageShimRoot(): string {
: join(installedDeclarationRoot, "shims");
}
/** The directories of files scriptc itself ships, each with the prefix
* that names its files independently of where scriptc is installed
* (compilerOwnedFileName). IR artifacts spell source locations in them this
* way (serializeModuleParts). */
export function compilerOwnedFileRoots(): readonly { from: string; to: string }[] {
return [{ from: tsgoPath(packageShimRoot()) + "/", to: "scriptc:shims/" }];
}
/** The name of a file scriptc itself ships (a package shim or one of the
* shipped declaration files) that does not depend on where scriptc is
* installed: `scriptc:shims/<path below the shim root>` or
* `scriptc:<declaration file name>`. Null for every other file. Names and
* artifacts that would otherwise spell the installation's absolute path use
* it, so two installations of one compiler emit the same output. */
export function compilerOwnedFileName(file: string): string | null {
const path = tsgoPath(file);
for (const { from, to } of compilerOwnedFileRoots()) {
if (path.startsWith(from)) return to + path.slice(from.length);
}
for (const declaration of [ambientDtsPath(), overridesDtsPath(), fallbackDtsPath()]) {
if (path === declaration) return `scriptc:${path.slice(path.lastIndexOf("/") + 1)}`;
}
return null;
}
/** Path of the shipped ambient declarations — the always-shipped CORE
* (comptime, setTimeout). Part of EVERY program scriptc
* builds, the project-world preflight program included. */
@@ -0,0 +1,123 @@
/* The string-keyed properties Node 24 exposes on its builtin constructor
* and namespace globals, own and inherited, so `"key" in Error` (a feature
* probe such as `"captureStackTrace" in Error`) answers statically. The
* global must be the unshadowed builtin; compiled programs cannot add
* properties to these objects, so the answer is fixed. */
const FUNCTION_PROTOTYPE = [
"length",
"name",
"arguments",
"caller",
"constructor",
"apply",
"bind",
"call",
"toString",
];
const OBJECT_PROTOTYPE = [
"constructor",
"__defineGetter__",
"__defineSetter__",
"hasOwnProperty",
"__lookupGetter__",
"__lookupSetter__",
"isPrototypeOf",
"propertyIsEnumerable",
"toString",
"valueOf",
"__proto__",
"toLocaleString",
];
const CONSTRUCTOR_OWN = ["length", "name", "prototype"];
const ERROR_OWN = [
...CONSTRUCTOR_OWN,
"captureStackTrace",
"prepareStackTrace",
"isError",
"stackTraceLimit",
];
/** Own string keys per global, beyond what the prototype chain adds. */
const CONSTRUCTORS: Readonly<Record<string, readonly string[]>> = {
Error: ERROR_OWN,
// The native error constructors inherit from Error.
TypeError: ERROR_OWN,
RangeError: ERROR_OWN,
SyntaxError: ERROR_OWN,
ReferenceError: ERROR_OWN,
EvalError: ERROR_OWN,
URIError: ERROR_OWN,
Object: [
...CONSTRUCTOR_OWN,
"assign",
"getOwnPropertyDescriptor",
"getOwnPropertyDescriptors",
"getOwnPropertyNames",
"getOwnPropertySymbols",
"hasOwn",
"is",
"preventExtensions",
"seal",
"create",
"defineProperties",
"defineProperty",
"freeze",
"getPrototypeOf",
"setPrototypeOf",
"isExtensible",
"isFrozen",
"isSealed",
"keys",
"entries",
"fromEntries",
"values",
"groupBy",
],
Array: [...CONSTRUCTOR_OWN, "isArray", "from", "fromAsync", "of"],
Promise: [
...CONSTRUCTOR_OWN,
"all",
"allSettled",
"any",
"race",
"resolve",
"reject",
"withResolvers",
"try",
],
Number: [
...CONSTRUCTOR_OWN,
"isFinite",
"isInteger",
"isNaN",
"isSafeInteger",
"parseFloat",
"parseInt",
"MAX_VALUE",
"MIN_VALUE",
"NaN",
"NEGATIVE_INFINITY",
"POSITIVE_INFINITY",
"MAX_SAFE_INTEGER",
"MIN_SAFE_INTEGER",
"EPSILON",
],
String: [...CONSTRUCTOR_OWN, "fromCharCode", "fromCodePoint", "raw"],
Map: [...CONSTRUCTOR_OWN, "groupBy"],
Set: CONSTRUCTOR_OWN,
WeakMap: CONSTRUCTOR_OWN,
WeakSet: CONSTRUCTOR_OWN,
Boolean: CONSTRUCTOR_OWN,
Function: CONSTRUCTOR_OWN,
Date: [...CONSTRUCTOR_OWN, "now", "parse", "UTC"],
BigInt: [...CONSTRUCTOR_OWN, "asUintN", "asIntN"],
};
/** Whether Node answers `key in <name>` with true, or null when `name` is
* not a tabled global. */
export function builtinGlobalHasKey(name: string, key: string): boolean | null {
const own = CONSTRUCTORS[name];
if (own === undefined) return null;
return own.includes(key) || FUNCTION_PROTOTYPE.includes(key) || OBJECT_PROTOTYPE.includes(key);
}
@@ -6,12 +6,14 @@ import {
STRING,
VOID,
isClassOwnEnumerableFieldName,
typeEquals,
type IrExpr,
type IrStmt,
type SrcLoc,
} from "../../ir/ir.js";
import { isJsSourceFile, locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import { PoisonError, type Lowerer } from "./lowerer.js";
import { unsupportedDiag } from "../../diagnostics/diagnostic.js";
import { findMethodOn, type ClassInfo } from "./lower-classes.js";
import { classPropertiesHelper } from "./class-dynamic-dispatch.js";
import { observedPrototypeMethod } from "./class-prototypes.js";
@@ -162,6 +164,7 @@ export function classCallbackCall(
fallback: IrExpr,
loc: SrcLoc,
calleeName = name,
original?: { originalId: () => string | undefined; directCall: () => IrExpr },
): IrExpr {
const bag = lowerer.declareHiddenLocal("%callbackBag", DYN);
const present = lowerer.declareHiddenLocal("%callbackPresent", BOOL);
@@ -186,27 +189,91 @@ export function classCallbackCall(
loc,
};
const observed = observedSlot(lowerer, receiver, name, loc);
const result: IrExpr = observed
? call
: {
kind: "ternary",
cond: varRef(present.id, BOOL, loc),
then: call,
else_:
fallback.type.kind === "void"
? {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: fallback, loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
}
: lowerer.coerceToExpected(fallback, DYN),
type: DYN,
loc,
};
const answer =
fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type);
const boxed =
observed || fallback.type.kind === "void" ? null : lowerer.coerceToExpected(fallback, DYN);
let answer: IrExpr;
if (boxed !== null && boxed.type.kind !== "dyn") {
// A result with no checked-dynamic form (an Iterable<T>, whose
// generator arm does not box) keeps its native type: the unchanged
// method's result stays as it is, and a replacement's dynamic result
// takes the checked conversion into that type.
const converted = lowerer.coerceToExpected(call, fallback.type);
if (!typeEquals(converted.type, fallback.type)) {
lowerer.pushDiag(
unsupportedDiag(
"SC1090",
loc,
`calling replaceable method '${calleeName}' with a '${lowerer.fmt(fallback.type)}' result`,
),
);
throw new PoisonError();
}
answer = {
kind: "ternary",
cond: varRef(present.id, BOOL, loc),
then: converted,
else_: fallback,
type: fallback.type,
loc,
};
} else {
const result: IrExpr = observed
? call
: {
kind: "ternary",
cond: varRef(present.id, BOOL, loc),
then: call,
else_: boxed ?? {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: fallback, loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
},
type: DYN,
loc,
};
answer =
fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type);
}
// Read after observedSlot built the prototype data and its originals.
const originalId = observed ? original?.originalId() : undefined;
if (original && originalId !== undefined) {
// The slot still holds the method's original value: calling it is
// calling the method, so the call stays native (no boxed arguments,
// dynamic call or checked result). A replacement takes the dynamic call.
const unchanged: IrExpr = {
kind: "libCall",
fn: "dyn.sameValue",
args: [varRef(callback.id, DYN, loc), varRef(originalId, DYN, loc)],
type: BOOL,
loc,
};
answer =
fallback.type.kind === "void"
? {
kind: "ternary",
cond: unchanged,
then: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: original.directCall(), loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
},
else_: call,
type: DYN,
loc,
}
: {
kind: "ternary",
cond: unchanged,
then: original.directCall(),
else_: answer,
type: answer.type,
loc,
};
}
return {
kind: "seqExpr",
stmts: [
@@ -908,6 +908,11 @@ export class ClassDynamicDispatch {
private readonly prototypeClasses = new Set<string>();
private prototypeIdentityHelper: IrFunction | null = null;
private readonly prototypeIdentityClasses = new Set<string>();
/** `value instanceof Error` through Error.prototype: boxed instances of
* classes with a heritage evaluated at run time test their prototype
* data's chain. */
private errorPrototypeHelper: IrFunction | null = null;
private readonly errorPrototypeClasses = new Set<string>();
private readonly instancePrototypes = new Map<string, { fn: IrFunction; classes: Set<string> }>();
private readonly checkedCasts = new Map<string, { fn: IrFunction; classes: Set<string> }>();
/** The for-in key helpers, by libCall (dyn and native string[] keys). */
@@ -1114,6 +1119,49 @@ export class ClassDynamicDispatch {
});
changed = true;
}
if (
this.errorPrototypeHelper &&
!this.errorPrototypeClasses.has(className) &&
hasRuntimeHeritage(info)
) {
this.errorPrototypeClasses.add(className);
const loc = this.errorPrototypeHelper.loc;
const type: IrType = { kind: "object", className };
const value = varRef("value", DYN, loc);
const prototype = classPrototypeData(lowerer, info, loc, {
kind: "dynCheck",
value,
type,
loc,
});
if (prototype)
this.errorPrototypeHelper.body.unshift({
kind: "if",
cond: {
kind: "libCall",
fn: "dyn.typedRefIs",
args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }],
type: BOOL,
loc,
},
then: [
{
kind: "return",
value: {
kind: "libCall",
fn: "dyn.inheritsErrorPrototype",
args: [prototype],
type: BOOL,
loc,
},
loc,
},
],
else_: null,
loc,
});
changed = true;
}
if (
this.serializesClasses &&
!this.jsonPrototypes.has(className) &&
@@ -1516,6 +1564,31 @@ export class ClassDynamicDispatch {
}
return { kind: "call", callee: cast.fn.name, args: [expr.value], type, loc: expr.loc };
}
if (expr.kind === "libCall" && expr.fn === "dyn.inheritsErrorPrototype") {
if (!this.errorPrototypeHelper) {
const loc = expr.loc;
this.errorPrototypeHelper = {
name: "%dyn.class.inheritsErrorPrototype",
params: [{ localId: "value", name: "value", type: DYN }],
locals: [{ id: "value", name: "value", type: DYN, mutable: false }],
returnType: BOOL,
loc,
body: [
{ kind: "return", value: { ...expr, args: [varRef("value", DYN, loc)] }, loc },
],
};
lowerer.liftedFns.push(this.errorPrototypeHelper);
this.generated.add(this.errorPrototypeHelper);
changed = true;
}
return {
kind: "call",
callee: this.errorPrototypeHelper.name,
args: expr.args,
type: BOOL,
loc: expr.loc,
};
}
if (
expr.kind === "libCall" &&
expr.fn === "dyn.getPrototype" &&
@@ -5942,3 +6015,11 @@ const INHERITED_OBJECT_METHODS: ReadonlySet<string> = new Set([
"hasOwnProperty",
"propertyIsEnumerable",
]);
/** Whether a class or one of its bases has a heritage evaluated at run
* time, whose prototype chain may reach Error.prototype. */
function hasRuntimeHeritage(info: ClassInfo): boolean {
for (let current: ClassInfo | null = info; current; current = current.base)
if (current.callableBase) return true;
return false;
}
@@ -380,6 +380,13 @@ export function collectClassMethodMutations(
markTarget(node.initializer, node);
if (ts.isPropertyAccessExpression(node) && isErrorPrototypeValue(lowerer, node))
lowerer.errorPrototypeValues = true;
// A heritage evaluated at run time may be Error, which links the
// class's instances to Error.prototype.
if (
(ts.isClassDeclaration(node) || ts.isClassExpression(node)) &&
runtimeHeritage(lowerer, node)
)
lowerer.errorPrototypeValues = true;
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const base = unwrap(node.expression);
const prototype =
@@ -621,3 +628,22 @@ export function collectClassMethodMutations(
if (!receiverFields.has(field)) lowerer.receiverFreeCallbackFields.add(field);
}
}
/** Whether a class's `extends` expression is a value computed at run time:
* anything other than a name of a class declaration or of a builtin
* constructor (`extends Parent` over `const Parent = params.Parent ?? Object`,
* `extends make()`). */
function runtimeHeritage(
lowerer: Lowerer,
node: ts.ClassDeclaration | ts.ClassExpression,
): boolean {
const heritage = node.heritageClauses?.nodes.find(
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
)?.types?.nodes[0]?.expression;
if (heritage === undefined) return false;
if (!ts.isIdentifier(heritage) && !ts.isPropertyAccessExpression(heritage)) return true;
let symbol = lowerer.checker.getSymbolAtLocation(heritage);
if (symbol === undefined) return true;
if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol);
return !(symbol.flags & ts.SymbolFlags.Class) && !lowerer.isStdlibSymbol(symbol);
}
@@ -207,12 +207,21 @@ export function classPrototypeData(
// promise-returning signature) gets no data slot.
const boxedValue = lowerer.coerceToExpected(compiled, DYN);
if (boxedValue.type.kind !== "dyn") continue;
const originalId = `%g.${info.def.prototypeDataHelper}.original.${method}`;
lowerer.globalsList.push({
id: originalId,
name: `${info.def.jsName ?? info.def.name}.prototype.${method}.original`,
type: DYN,
mutable: true,
});
info.prototypeOriginals ??= new Map();
info.prototypeOriginals.set(method, originalId);
const descriptor: IrExpr = {
kind: "dynObjLit",
fields: [
{
key: { kind: "strLit", value: "value", type: STRING, loc },
value: boxedValue,
value: varRef(originalId, DYN, loc),
},
{
key: { kind: "strLit", value: "writable", type: STRING, loc },
@@ -227,6 +236,8 @@ export function classPrototypeData(
loc,
};
const init = helper.body[0]!;
if (init.kind === "if")
init.then.push({ kind: "assign", localId: originalId, value: boxedValue, loc });
if (init.kind === "if")
init.then.push({
kind: "exprStmt",
@@ -288,6 +288,14 @@ export function applyWidthLift(
case "dynIn": {
if (dst.kind !== "dyn")
throw new InternalCompilerError("lowerer bug: dynIn lift against a non-dyn slot");
// The declaration-typed boundary keeps user values' identity when
// they cross into package code (decl-projection.ts).
if (
lowerer.declBoundary &&
!/[\\/]node_modules[\\/]/.test(loc.file) &&
liveBoxable(lowerer, value.type)
)
return { kind: "dynFrom", value, liveRef: true, type: DYN, loc };
return { kind: "dynFrom", value, type: DYN, loc };
}
case "dynOut":
@@ -775,3 +783,18 @@ function buildRecordProjection(
loc,
};
}
/** Records and arrays (and unions holding them), whose identity a live
* capsule keeps. */
function liveBoxable(lowerer: Lowerer, t: IrType, seen = new Set<string>()): boolean {
if (t.kind === "record") {
const shape = lowerer.shapes.get(t.shapeId);
return shape !== undefined && !shape.tuple;
}
if (t.kind === "array") return true;
if (t.kind !== "union" || seen.has(t.unionId)) return false;
seen.add(t.unionId);
return (
lowerer.unions.get(t.unionId)?.arms.some((arm) => liveBoxable(lowerer, arm, seen)) ?? false
);
}
@@ -361,6 +361,21 @@ export function lowerArrayMethodCall(
if (probe.type.kind === "array") {
receiverIr = probe.type;
probedUntyped = true;
} else if (
probe.type.kind === "dyn" &&
!isJsSourceFile(ts.getSourceFileOfNode(access)) &&
lowerer.isStdlibMember(access)
) {
// An `unknown[]` (or `any[]`) array stored checked-dynamic — a
// generic body instantiated at `unknown`, a spread of unknown
// elements: the method runs on the runtime array (dynInvoke, JS
// exact), and a typed result is checked like any member read.
const dispatched = lowerDynDispatchMethodCall(lowerer, call, access, probe, true);
if (!dispatched) return null;
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
return result && result.kind !== "void" && result.kind !== "dyn"
? lowerer.coerceInto(call, dispatched, result)
: dispatched;
}
}
if (receiverIr?.kind === "union") {
@@ -12,10 +12,11 @@ import { toPrimitiveHint } from "./class-dynamic-dispatch.js";
import { GENERATOR_STEP_METHODS, HELPER_METHODS } from "./generator-dynamic-dispatch.js";
/** Runtime entry points that step a checked-dynamic iterable's iterator:
* destructuring rest, and the collection constructors' and
* Object.fromEntries' seeds. */
* destructuring rest, the collection constructors' and Object.fromEntries'
* seeds, and Math.min/Math.max over a spread. */
const RUNTIME_STEP_LIB_FNS: ReadonlySet<string> = new Set([
"dyn.destructureRest",
"dyn.mathFold",
"dyn.nativeSetNew",
"dyn.mapSeedEntries",
"dyn.fromEntries",
@@ -80,6 +81,7 @@ const DISPATCH_LIB_CALLS = new Set<string>([
"dyn.propertyIsEnumerableComputed",
"dyn.defineProperty",
"dyn.getPrototype",
"dyn.inheritsErrorPrototype",
"dyn.reflectGet",
"dyn.reflectSet",
]);
@@ -1,4 +1,5 @@
/* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */
import { predeclareForwardClass } from "../lower-stmts.js";
import { cjsMemberAliasValueOf } from "../cjs-exports-object.js";
import { builtinClassValue, builtinMemberClassKind } from "../builtins/builtin-class-dyn.js";
import * as ts from "../../ts7/adapter.js";
@@ -860,6 +861,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
{
const sym = lowerer.resolveValueSymbol(expr);
const classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined;
// A local class referenced from a function hoisted above its statement:
// its TDZ box, holding the class as a checked-dynamic constructor.
if (!classInfo && sym && predeclareForwardClass(lowerer, sym)) {
const local = lowerer.resolveLocal(expr);
if (local) return { kind: "varRef", localId: local.id, type: local.type, loc };
}
// A GENERIC class name whose CONTEXTUAL type pins one instantiation
// (`const B: new (v: string) => Counter<string> = Counter`): the
// value is that instantiation's class object — the generic-fn
@@ -13,6 +13,7 @@ import {
STRING,
type SrcLoc,
UNDEFINED_T,
VOID,
arrayOf,
canAdaptDynFuncTo,
canDynCheckTo,
@@ -47,6 +48,7 @@ import {
import { isConstAssertionTypeNode, unitOnlyUnion } from "../../type-mapper.js";
import { assertsGlobalObject, extractionCopiesReference } from "../assertion-identity.js";
import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js";
import { isDeclBoundaryTypeNode } from "../../decl-projection.js";
/** `c ? a : b`. An explicit destination constructs each branch at that
* layout before joining it. Without one, retain the expression's inferred
@@ -1516,6 +1518,12 @@ export function lowerAsExpression(
const target = lowerer.mapTypeOf(targetTs) ?? dynFallbackType(lowerer, expr.type, targetTs);
if (!target) lowerer.badType(expr.type, targetTs);
if (target.kind === "dyn") return inner; // `as unknown`: erasure
// The declaration-typed boundary (decl-projection.ts): an identity-
// preserving checked conversion of a value package code produced.
if (isDeclBoundaryTypeNode(expr.type)) {
const adopted = declBoundaryCast(lowerer, inner, target, locOf(expr));
if (adopted) return adopted;
}
// An all-`unknown`-fields record target (`err as { code?: unknown }`
// — the errno-probing idiom): there is nothing to validate (every
// field is unknown, exactly what the dyn value already answers) and
@@ -1603,3 +1611,83 @@ export function lowerAsExpression(
}
return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) };
}
/** True when a type holds a record or an array somewhere (values whose
* identity can be observed). */
function holdsReference(lowerer: Lowerer, t: IrType, seen = new Set<string>()): boolean {
if (t.kind === "record" || t.kind === "array") return true;
if (t.kind !== "union" || seen.has(t.unionId)) return false;
seen.add(t.unionId);
return (
lowerer.unions.get(t.unionId)?.arms.some((arm) => holdsReference(lowerer, arm, seen)) ?? false
);
}
/** `(<package expr> as __scriptc_decl_N)`: check the dynamic value against
* the projected type and build the native value (dynCheck), then adopt:
* every source object or array that something else still references
* becomes a live capsule of the value built from it
* (scr_dyn_decl_adopt), so identity and mutation visibility hold on both
* sides. Scalar targets are ordinary checked casts. */
function declBoundaryCast(
lowerer: Lowerer,
inner: IrExpr,
target: IrType,
loc: SrcLoc,
): IrExpr | null {
if (
!canDynCheckTo(
target,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
) &&
!(target.kind === "union" && lowerer.dynConvertible(target))
)
return null;
if (!holdsReference(lowerer, target)) {
return { kind: "dynCheck", value: inner, type: target, loc };
}
const source = lowerer.declareHiddenLocal("%boundarySource", DYN);
const value = lowerer.declareHiddenLocal("%boundaryValue", target);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: source.id, init: inner, loc },
{
kind: "varDecl",
localId: value.id,
init: {
kind: "dynCheck",
value: varRef(source.id, DYN, loc),
adopt: true,
type: target,
loc,
},
loc,
},
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.declAdopt",
args: [
varRef(source.id, DYN, loc),
{
kind: "dynFrom",
value: varRef(value.id, target, loc),
liveRef: true,
type: DYN,
loc,
},
],
type: VOID,
loc,
},
loc,
},
],
result: varRef(value.id, target, loc),
type: target,
loc,
};
}
@@ -1,3 +1,4 @@
import { isNavigatorGlobal } from "../builtins/navigator.js";
import { isSafeToDiscard } from "./evaluation-safety.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
@@ -223,6 +224,16 @@ export function lowerOptionalChain(
// Native handle methods have no first-class value to guard; when the
// method read itself has no lowering, re-dispatch the plain call (which
// reports its own diagnostics) instead of failing on the value read.
// `navigator?.userAgent`: Node's navigator global is never nullish and
// has no value form, so the guarded step is the plain member read.
if (isNavigatorGlobal(lowerer, recvNode)) {
lowerer.chainHandled.add(dotNode);
try {
return lowerer.lowerExpr(expr);
} finally {
lowerer.chainHandled.delete(dotNode);
}
}
let loweredReceiver: IrExpr | null;
if (dotNode === expr && alwaysPresentStdlibMethod(lowerer, recvNode)) {
loweredReceiver = tryLowerExpression(lowerer, recvNode);
@@ -307,10 +307,10 @@ export function lowerPropertyAccessExpr(
};
}
}
if (!expr.questionDotToken) {
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
}
// Node's navigator is never nullish, so `navigator?.userAgent` (zod's
// Cloudflare probe) folds like `navigator.userAgent`.
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
// util.inspect.custom: Symbol.for('nodejs.util.inspect.custom').
if (isInspectCustomAccess(lowerer, expr)) {
return {
@@ -564,6 +564,19 @@ export function lowerPropertyAccessExpr(
expr.name.text,
)
) {
// A name the program stores on the global object
// (`(_a = globalThis).__config ?? (_a.__config = {})`) reads back from
// the global object's own properties.
const globalType = lowerer.typeOf(staticallySelected(lowerer, expr.expression));
if (globalThisWrittenNames(lowerer, globalType).has(expr.name.text)) {
return {
kind: "dynKeyGet",
value: lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN),
key: { kind: "strLit", value: expr.name.text, type: STRING, loc },
type: DYN,
loc,
};
}
return dynUndefinedExpr(loc);
}
// `Uint8Array.prototype.toBase64` and its siblings, the base64/hex
@@ -4502,3 +4515,29 @@ function contextualArgumentCount(lowerer: Lowerer, fn: ts.Node): number | null {
}
return count;
}
/** The property names the program assigns on the global object, through
* `globalThis` or a binding of its type (`(_a = globalThis).x = v`). */
function globalThisWrittenNames(lowerer: Lowerer, globalType: ts.Type): Set<string> {
const known = lowerer.globalThisWrittenNames;
if (known) return known;
const names = new Set<string>();
lowerer.globalThisWrittenNames = names;
for (const sf of lowerer.program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile || !sf.text.includes("globalThis")) continue;
ts.walkPreorder(sf, (node) => {
if (
ts.isBinaryExpression(node) &&
(node.operatorToken.kind === ts.SyntaxKind.EqualsToken ||
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken ||
node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken ||
node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken) &&
ts.isPropertyAccessExpression(node.left) &&
lowerer.typeOf(node.left.expression) === globalType
)
names.add(node.left.name.text);
return undefined;
});
}
return names;
}
@@ -51,6 +51,7 @@ import { isJsSourceFile, locOf } from "../../program.js";
import { checkedClassInstanceOf } from "../class-construction.js";
import { unsupportedDiag } from "../../../diagnostics/diagnostic.js";
import { globalSymbolKey } from "./global-symbols.js";
import { builtinGlobalHasKey } from "../builtins/global-namespace-keys.js";
import { foldedStringKeyOf } from "./object-literals.js";
import { NARROW_FIRST, sideEffectFreeOptionValue } from "../surfaces.js";
import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors.js";
@@ -1430,6 +1431,16 @@ export function lowerInExpression(
"'in' with computed (non-literal) keys (a const whose type is one string literal folds; a string-typed key answers over index-signature record receivers)",
);
}
// A feature probe on a builtin constructor global (`"captureStackTrace"
// in Error`): the global is a fixed object, so the answer is static.
{
let recvNode = expr.right;
while (ts.isParenthesizedExpression(recvNode)) recvNode = recvNode.expression;
if (ts.isIdentifier(recvNode) && lowerer.isStdlibGlobal(recvNode, recvNode.text)) {
const has = builtinGlobalHasKey(recvNode.text, key);
if (has !== null) return { kind: "boolLit", value: has, type: BOOL, loc };
}
}
// A receiver re-read (the static folds below) is safe for plain reads
// AND for side-effect-free expressions (an object literal of literals
// — the `"a" in { a: true }` shape).
@@ -227,6 +227,11 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
const opaqueSeen = new Set<string>();
const runtimeShapes = new Set<string>();
const runtimeSeen = new Set<string>();
/** Shapes the declaration-typed boundary converts into (adopting casts):
* their values come from package code that builds them in the
* declaration's order, the order their table holds. */
const declaredShapes = new Set<string>();
const declaredSeen = new Set<string>();
const producedBy = (type: IrType, opaque: boolean): void => {
if (opaque) shapesIn(type, opaqueShapes, opaqueSeen);
else shapesIn(type, runtimeShapes, runtimeSeen);
@@ -332,7 +337,8 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
producedBy(e.type, true);
return;
case "dynCheck":
if (!orderPreservingClone(e)) producedBy(e.type, false);
if (e.adopt === true) shapesIn(e.type, declaredShapes, declaredSeen);
else if (!orderPreservingClone(e)) producedBy(e.type, false);
return;
case "dynDestrCheck":
case "caughtCheck":
@@ -392,6 +398,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
f.opaque = true;
}
for (const id of runtimeShapes) factsOf(id).runtime = true;
for (const id of declaredShapes) if (!runtimeShapes.has(id)) factsOf(id).pinned = true;
// ── scope ───────────────────────────────────────────────────────────
// Only plain declared-field shapes with a table take part.
@@ -2430,6 +2430,20 @@ function lowerBigIntConstructorValue(lowerer: Lowerer, arg: IrExpr, loc: SrcLoc)
return { kind: "libCall", fn: "bigint.parse", args: [arg], type: BIGINT_T, loc };
if (arg.type.kind === "f64")
return { kind: "libCall", fn: "bigint.fromF64", args: [arg], type: BIGINT_T, loc };
// Byte storage converts through ToPrimitive, which BigInt then parses: a
// typed array's text is its elements joined with "," (one element reads
// as that element, none as 0n, several throw the SyntaxError for the
// joined text), a Buffer's is its UTF-8 decoding and a DataView's is
// "[object DataView]". The runtime value tells them apart, as for String.
if (arg.type.kind === "bytes") {
const text: IrExpr = {
kind: "toString",
operand: { kind: "dynFrom", value: arg, type: DYN, loc },
type: STRING,
loc,
};
return { kind: "libCall", fn: "bigint.parse", args: [text], type: BIGINT_T, loc };
}
if (arg.type.kind === "bool") {
return {
kind: "ternary",
@@ -2470,6 +2484,7 @@ function lowerBigIntConstructorValue(lowerer: Lowerer, arg: IrExpr, loc: SrcLoc)
arm.kind === "string" ||
arm.kind === "f64" ||
arm.kind === "bool" ||
arm.kind === "bytes" ||
isUnitType(arm),
)
)
@@ -3827,18 +3842,22 @@ function lowerDynReceiverMethodCall(
}
if (access.name.text === "flatMap") return lowerDynArrayFlatMapCall(lowerer, call, access, recv);
// String methods claim only names NO other dyn-representable kind's
// prototype declares (Array carries includes/indexOf/slice too): for
// these, "the receiver is a string, or the call throws V8's TypeError"
// IS Node's semantics for every possible dyn value. Shared names would
// need a receiver-kind dispatch — they keep the fence.
// prototype declares (Array carries includes/indexOf/slice too), so no
// other kind inherits them. Shared names would need a receiver-kind
// dispatch — they keep the fence.
if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable) {
// An object-typed receiver the member is missing from (a JavaScript
// module object whose members are assigned in a function body,
// `common.repeat(" ", n)`) holds a string or an object with that
// member: dispatch on the receiver's runtime kind.
// A checked-dynamic object can still carry its OWN member of that
// name: zod's schemas assign `trim`, `toLowerCase` and `startsWith`
// in their constructors (`z.string().trim().min(1)`), an object's
// prototype can define `trim`, and a JavaScript module object can
// assign `repeat` in a function body (`common.repeat(" ", n)`, an
// object-typed receiver the member is missing from). An untyped
// receiver, or a member the checker cannot type, dispatches on the
// receiver's runtime kind: a string runs the string lowering, anything
// else calls its member as Node does.
if (
!checkerUntyped &&
(lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 &&
(checkerUntyped ||
(lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) &&
!call.questionDotToken &&
!access.questionDotToken &&
!call.arguments.nodes.some(ts.isSpreadElement)
@@ -4290,6 +4309,7 @@ const DYN_DISPATCH_METHODS = new Set([
"shift",
"unshift",
"slice",
"splice",
"at",
"indexOf",
"lastIndexOf",
@@ -8297,6 +8317,12 @@ function lowerObjectStaticCall(
...(fnName !== null ? { fnName } : {}),
};
}
// A class value (`Object.defineProperty(Definition, "name", { value })`)
// boxes to its constructor function, whose own-property table holds the
// definition for every alias of the class.
if (target?.type.kind === "classval" && lowerer.dynConvertible(target.type)) {
target = { kind: "dynFrom", value: target, type: DYN, loc: locOf(call.arguments.nodes[0]!) };
}
const descriptorPrimitive =
member === "getOwnPropertyDescriptor" &&
target &&
@@ -10214,6 +10240,23 @@ export function lowerObjectMethodCall(
type: found.sig.ret,
loc,
};
// The declaring class's original value, built with the observed slot.
// An override below (a virtual call) keeps the dynamic call: a slot
// reassigned to an ancestor's method must not reach the override. So do
// methods declared in unchecked JavaScript, whose results are inferred,
// and calls with a spread argument: the dynamic call drains the spread
// through the iterator's current `next`, which may be a replacement.
const declarerFile = found.declarer.decl ? ts.getSourceFileOfNode(found.declarer.decl) : null;
const original =
virtual ||
call.arguments.nodes.some(ts.isSpreadElement) ||
!declarerFile ||
(isJsSourceFile(declarerFile) && !lowerer.checkedJsFile(declarerFile))
? undefined
: {
originalId: (): string | undefined => found.declarer.prototypeOriginals?.get(method),
directCall: (): IrExpr => fallback,
};
const result = classCallbackCall(
lowerer,
value,
@@ -10222,6 +10265,7 @@ export function lowerObjectMethodCall(
fallback,
loc,
ts.getTextOfNode(access),
original,
);
return {
kind: "seqExpr",
@@ -5,6 +5,7 @@ import {
isDatePrototypeMethod,
lowerDateSuperMethodCall,
} from "./builtins/date-classes.js";
import { predeclareForwardClass } from "./lower-stmts.js";
import { lowerDateStaticCall } from "./builtins/dates.js";
import { keyedHelperName } from "./stable-names.js";
import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js";
@@ -478,6 +479,10 @@ export interface ClassInfo {
prototypeMethodValues?: Map<string, IrExpr>;
prototypeReflectionReady?: true;
prototypeObservedMethodsReady?: true;
/** The globals holding each observed method's original prototype value
* (set when the prototype data is built), by method name. A typed call
* whose slot still holds that value calls the method natively. */
prototypeOriginals?: Map<string, string>;
prototypeReflectionRefusal?: string;
localClass?: {
context: FnCtx | null;
@@ -2356,6 +2361,7 @@ export function collectClassShapeInner(
const checkedStatics =
isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
((base && hasRuntimeStatics(base)) ||
definedThroughReflection(lowerer, decl) ||
decl.members.nodes.some(
(member) =>
ts.isAccessor(member) &&
@@ -3209,15 +3215,6 @@ export function collectClassShapeInner(
symbolMethods.set(key.identity, mName);
}
}
// Error values held as untyped values render through their error
// encoding, which keeps no compiled methods to call.
if (mName === INSPECT_CUSTOM_MEMBER && errorRooted) {
lowerer.unsupported(
"SC1090",
member.name,
"[util.inspect.custom] methods on Error subclasses",
);
}
// Generator calls use their spawn wrapper. Public methods are
// direct calls only when no subclass overrides the method;
// override chains involving generators fence below.
@@ -5489,7 +5486,12 @@ export function classValueRef(lowerer: Lowerer, info: ClassInfo, blame: ts.Node)
const display = info.def.name.replace(/^%|^%m\d+\./, "");
fenceDecorationThrows(lowerer, info, blame);
if (info.localClass && ts.isIdentifier(blame)) {
const stored = lowerer.resolveLocal(blame);
let stored = lowerer.resolveLocal(blame);
// A reference from a function hoisted above the class statement.
if (!stored) {
const symbol = lowerer.resolveValueSymbol(blame);
if (symbol && predeclareForwardClass(lowerer, symbol)) stored = lowerer.resolveLocal(blame);
}
if (stored)
return lowerer.coerceToExpected(
{ kind: "varRef", localId: stored.id, type: stored.type, loc: locOf(blame) },
@@ -11285,3 +11287,37 @@ function genericFieldFnNodeOf(
}
return null;
}
/** Whether a JavaScript class's own binding is the target of
* `Object.defineProperty`/`defineProperties` in its file
* (`Object.defineProperty(Definition, "name", { value })`): its constructor
* then needs a live own-property table. */
function definedThroughReflection(lowerer: Lowerer, decl: ts.ClassLikeDeclaration): boolean {
const name = decl.name;
const sf = ts.getSourceFileOfNode(decl);
if (name === undefined || !sf.text.includes("definePropert")) return false;
const symbol = lowerer.checker.getSymbolAtLocation(name);
if (symbol === undefined) return false;
let found = false;
ts.walkPreorder(sf, (node) => {
if (found) return "stop";
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
(node.expression.name.text === "defineProperty" ||
node.expression.name.text === "defineProperties") &&
lowerer.stdlibGlobalMember(node.expression, "Object") !== null
) {
const target = node.arguments.nodes[0];
if (
target !== undefined &&
ts.isIdentifier(target) &&
target.text === name.text &&
lowerer.checker.getSymbolAtLocation(target) === symbol
)
found = true;
}
return undefined;
});
return found;
}
@@ -1375,8 +1375,15 @@ function customInspectDynHelper(
export function customInspectRegistrations(lowerer: Lowerer, loc: SrcLoc): IrStmt[] {
const stmts: IrStmt[] = [];
for (const info of lowerer.classes.values()) {
if (info.def.runtime || info.def.abstract || isErrorClass(lowerer, info.def.name)) continue;
if (info.def.runtime || info.def.abstract) continue;
// Error classes register when their own properties render statically
// (fieldedErrorReason): the others keep no method to call.
if (isErrorClass(lowerer, info.def.name) && fieldedErrorReason(lowerer, info) !== null)
continue;
if (!lowerer.findMethodOn(info, INSPECT_CUSTOM_MEMBER)) continue;
// Only constructed classes have instances, and only their methods lower
// (the constructor edge reaches the method).
if (!lowerer.classCanBeConstructed(info)) continue;
if (customInspectReason(lowerer, info) !== null) continue;
const t = { kind: "object" as const, className: info.def.name };
const fn = customInspectDynHelper(lowerer, t, loc);
@@ -2496,6 +2496,25 @@ export function lowerStdlibMethodCall(
// method, drains into the number[] the fold reads.
src = classGeneratorIterable(lowerer, src) ?? src;
if (src.type.kind === "generator") src = generatorDrain(lowerer, src, F64, loc) ?? src;
// An untyped iterable (`Math.min(...caps.map(f))` in JavaScript): the
// runtime packs its values and folds their ToNumber like Node. V8 names
// a null or undefined source by its text, calls as `f(...)`.
if (src.type.kind === "dyn") {
const what = ts.isCallExpression(spread.expression)
? `${ts.getTextOfNode(spread.expression.expression)}(...)`
: ts.getTextOfNode(spread.expression);
return {
kind: "libCall",
fn: "dyn.mathFold",
args: [
src,
{ kind: "boolLit", value: name === "max", type: BOOL, loc },
{ kind: "strLit", value: what, type: STRING, loc },
],
type: F64,
loc,
};
}
if (src.type.kind !== "array" || src.type.elem.kind !== "f64") {
lowerer.unsupported(
"SC1090",
@@ -1010,6 +1010,44 @@ export function predeclareForwardCapture(lowerer: Lowerer, symbol: ts.Symbol): b
return false;
}
/** The CLASS-DECLARATION twin of predeclareForwardCapture: a function
* hoisted above a local `class D` (`function make() { return new D(); }`
* reached through an earlier function) captures the class binding, which
* is in its temporal dead zone until the class statement runs. The class
* cannot be collected yet (its heritage may name bindings declared between
* here and the statement), so the binding is pre-declared as a TDZ box
* holding the class as a checked-dynamic constructor; the class statement
* later initializes it, and every reference reads it from there. */
export function predeclareForwardClass(lowerer: Lowerer, symbol: ts.Symbol): boolean {
const decl = symbol.valueDeclaration;
if (!decl || !ts.isClassDeclaration(decl) || decl.name === undefined) return false;
if (!isJsSourceFile(ts.getSourceFileOfNode(decl))) return false;
for (let i = lowerer.activeStmtLists.length - 1; i >= 0; i--) {
const entry = lowerer.activeStmtLists[i]!;
const idx = entry.stmts.indexOf(decl);
if (idx < 0) continue;
if (idx < entry.index || entry.ctx === lowerer.ctx || entry.ctx.tdzPredeclared.has(symbol))
return false;
const name = decl.name.text;
const count = entry.ctx.localCounters.get(name) ?? 0;
entry.ctx.localCounters.set(name, count + 1);
const local: IrLocal = {
id: `${name}.${count}`,
name,
type: DYN,
mutable: false,
boxed: true,
tdz: true,
};
entry.ctx.locals.push(local);
entry.frame.set(symbol, local);
entry.out.push({ kind: "varDecl", localId: local.id, init: null, loc: locOf(decl) });
entry.ctx.tdzPredeclared.set(symbol, local);
return true;
}
return false;
}
/** The FUNCTION-DECLARATION twin of predeclareForwardCapture: JS hoists a
* nested `function f() {}` to scope entry — the binding is LIVE from the
* first statement, so a reference lexically above the declaration in the
@@ -1579,6 +1617,14 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
if (ts.isClassDeclaration(stmt)) {
if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous local class declarations");
const info = lowerer.lowerClassExpressionInfo(stmt);
// An earlier hoisted function captured the class: its TDZ box exists.
const symbol = lowerer.checker.getSymbolAtLocation(stmt.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = lowerer.coerceToExpected(lowerer.lowerClassExpression(stmt), DYN);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(stmt) };
}
const type: IrType = { kind: "classval", className: info.def.name };
const local = lowerer.declareLocal(stmt.name, stmt.name.text, type, true);
// Static members of a function-local class run while the class value is
@@ -5683,7 +5683,10 @@ export function lowerStreamMethodCall(
// argument reaches an object-mode side's _write as passed, null when
// the callback took its place (a byte-mode side encodes a string
// chunk with it).
const writeEnc = (encArg: ts.Expression | undefined, cbArg: ts.Expression | undefined) => {
const writeEnc = (
encArg: ts.Expression | undefined,
cbArg: ts.Expression | undefined,
): IrExpr => {
const receiver = streamReceiverExpr(lowerer, access.expression);
const chunk = lowerer.lowerExprExpecting(args[0]!, DYN);
const tail: IrExpr[] = [];
@@ -5696,7 +5699,7 @@ export function lowerStreamMethodCall(
args: [receiver, numLit(flags, loc), chunk, ...tail],
type: BOOL,
loc,
} satisfies IrExpr;
};
};
if (kind === "dyn") {
if (args.length === 3) return writeEnc(args[1], args[2]);
@@ -1,4 +1,6 @@
import { isDeclBoundaryFile } from "../decl-projection.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { moduleNamespaceObject } from "./module-namespace-elements.js";
import { keyedHelperName } from "./stable-names.js";
import { positionTag, StructuralPositions } from "./structural-positions.js";
import { onlyForwarded, stripTypeWrappers } from "./typed-arguments.js";
@@ -164,7 +166,7 @@ import {
import { DeferredModuleInitializers } from "./deferred-module-initializers.js";
import { finalizeClassMethodValues } from "./class-method-values.js";
import { finalizePrivateBrands } from "./private-brands.js";
import { type ClassSymbolKey, tableKeyFieldType } from "./symbol-fields.js";
import { type ClassSymbolKey, INSPECT_CUSTOM_MEMBER, tableKeyFieldType } from "./symbol-fields.js";
import type { HttpClientFnBinding } from "./lower-server.js";
export type { WidthLift } from "./width-lift.js";
export { newFnCtx, type FnCtx } from "./function-context.js";
@@ -184,7 +186,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js";
*/
import { dirname, relative, resolve } from "node:path";
import { bindingSource } from "../binding-source.js";
import { tsgoPath } from "../dts-paths.js";
import { compilerOwnedFileName, tsgoPath } from "../dts-paths.js";
import * as ts from "../ts7/adapter.js";
import { isBundledShimForwarder } from "../package-shims.js";
import { erasedTypeOnlyImport } from "../type-only-imports.js";
@@ -2412,6 +2414,51 @@ export class Lowerer {
return fallback ?? null;
}
/** A fresh TypeScript data literal (`{ message: "m" }`) passed to a
* checked-dynamic parameter of a callee implemented in JavaScript, whose
* contract lets it reshape the object: a call through an untyped value, an
* untyped parameter of a JavaScript function, or a package-declared
* parameter type (a non-library `.d.ts`) that makes one of the literal's
* properties optional (zod's `normalizeParams` deletes `params.message`).
* No typed alias of a fresh literal exists, so it is built as the plain
* object the callee receives instead of a view whose record layout refuses
* that delete. Standard-library and TypeScript callees, and literals with
* methods or accessors, keep their native storage. */
private reshapeableContextualLiteral(
literal: ts.ObjectLiteralExpression,
contextual: readonly ts.Type[],
): boolean {
const call = literal.parent;
if (
call === undefined ||
!(ts.isCallExpression(call) || ts.isNewExpression(call)) ||
!call.arguments?.nodes.includes(literal) ||
literal.properties.nodes.length === 0 ||
!literal.properties.nodes.every(
(prop) =>
(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) &&
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)),
)
)
return false;
const declaration = this.checker.getResolvedSignature(call)?.declaration;
const file = declaration ? ts.getSourceFileOfNode(declaration) : undefined;
const untyped = contextual.some((part) => part.flags & ts.TypeFlags.Any);
if (file === undefined) return untyped;
if (isJsSourceFile(file)) return untyped;
if (!file.isDeclarationFile || this.isStdlibFile(file)) return false;
if (untyped) return true;
const objects = contextual.filter((part) => part.flags & ts.TypeFlags.Object);
if (objects.length === 0) return false;
return literal.properties.nodes.some((prop) => {
const name = (prop.name as ts.Identifier | ts.StringLiteral).text;
return objects.every((part) => {
const symbol = this.checker.getPropertyOfType(part, name);
return symbol !== undefined && (symbol.flags & ts.SymbolFlags.Optional) !== 0;
});
});
}
/** A TypeScript object literal handed to a JavaScript checked-value
* parameter whose inferred type is open (`constructor({ factory, ...options
* } = {})` — the rest element admits any property): the literal names a
@@ -2975,9 +3022,14 @@ export class Lowerer {
* ABI and shared prototype descriptors instead of call-site specialization. */
readonly prototypeMethodAccesses = new Map<string, ts.Node>();
/** Whether the program reads `Error.prototype`, the checked-dynamic
* object an ES5 constructor function can inherit from: only then can a
* value that is not an Error instance be `instanceof Error`. */
* object an ES5 constructor function can inherit from, or has a class
* whose heritage is evaluated at run time (`extends (Base ?? Error)`):
* only then can a value that is not an Error instance be
* `instanceof Error`. */
errorPrototypeValues = false;
/** The property names the program assigns on the global object
* (property-access.ts), collected on first use. */
globalThisWrittenNames: Set<string> | null = null;
/** Well-known symbol methods (`sym:iterator`) the program may replace
* through a symbol-keyed write: an instance or prototype property, or a
* reflection call. */
@@ -3204,13 +3256,14 @@ export class Lowerer {
/** Structural source positions (structural-positions.ts) for names that
* stay the same when unrelated code moves. */
/** Files are named by their path relative to the entry's directory, so
* names do not depend on where the program sits, and default library
* files by their base name: two checkers install them in different
* directories. */
* names do not depend on where the program sits, default library files
* by their base name (two checkers install them in different
* directories), and the files scriptc ships by their name inside scriptc,
* so names do not depend on where scriptc is installed either. */
readonly positions = new StructuralPositions((sf) =>
this.program.isSourceFileDefaultLibrary(sf)
? `lib:${sf.fileName.slice(sf.fileName.lastIndexOf("/") + 1)}`
: relative(dirname(this.entry.fileName), sf.fileName),
: (compilerOwnedFileName(sf.fileName) ?? relative(dirname(this.entry.fileName), sf.fileName)),
);
/** The outermost node whose token span is exactly `loc`, or null. */
@@ -3242,7 +3295,10 @@ export class Lowerer {
* before it. */
closureName(loc: SrcLoc, suffix = "", prefix = "%fn"): string {
const node = this.nodeAtLoc(loc);
const where = node === null ? `${loc.file}:${loc.start}:${loc.end}` : this.positions.of(node);
const where =
node === null
? `${compilerOwnedFileName(loc.file) ?? loc.file}:${loc.start}:${loc.end}`
: this.positions.of(node);
const key = `${prefix}|${this.closureRoot}|${where}|${suffix}`;
const first = keyedHelperName(this, prefix, key);
const seen = this.closureNameCounts.get(first) ?? 0;
@@ -3383,8 +3439,18 @@ export class Lowerer {
if (interned.has(fn.name)) this.sharedHelpers.add(fn);
if (this.sharedHelpers.size === 0) return;
const shared = new Map([...this.sharedHelpers].map((fn) => [fn.name, fn]));
const earlier = (left: SrcLoc, right: SrcLoc): boolean =>
left.file < right.file || (left.file === right.file && left.start < right.start);
// Files scriptc ships compare by their name inside scriptc, so the
// choice does not depend on where scriptc is installed.
const fileKeys = new Map<string, string>();
const fileKey = (file: string): string => {
let key = fileKeys.get(file);
if (key === undefined) fileKeys.set(file, (key = compilerOwnedFileName(file) ?? file));
return key;
};
const earlier = (left: SrcLoc, right: SrcLoc): boolean => {
if (left.file === right.file) return left.start < right.start;
return fileKey(left.file) < fileKey(right.file);
};
// The earliest use of each shared helper outside the shared helpers, and
// which shared helpers each shared helper uses.
const uses = new Map<string, SrcLoc>();
@@ -3569,6 +3635,18 @@ export class Lowerer {
else if (kind === "failed") s.failed += 1;
}
/** The program holds the declaration-typed boundary's projection
* (decl-projection.ts): user-file record and array values crossing into
* dynamic slots (package parameters) box as live capsules, so a value
* that comes back through a boundary cast keeps its identity. */
private declBoundaryMemo: boolean | null = null;
get declBoundary(): boolean {
this.declBoundaryMemo ??= this.program
.getSourceFiles()
.some((sf) => isDeclBoundaryFile(sf.fileName));
return this.declBoundaryMemo;
}
constructor(
readonly program: ts.Program,
readonly entry: ts.SourceFile,
@@ -3691,8 +3769,11 @@ export class Lowerer {
classImplementedProtocol: (type) => this.classImplementedProtocol(type),
isStdlibFile: this.isStdlibFile,
isNpmFile: this.isNpmFile,
// The declaration-typed boundary's generated types (decl-projection.ts)
// describe data, like an explicitly mapped external type surface.
isExternalTypeFile: (sf) =>
this.externalTypeSpecifiersByFile.has(tsgoPath(resolve(sf.fileName))),
this.externalTypeSpecifiersByFile.has(tsgoPath(resolve(sf.fileName))) ||
isDeclBoundaryFile(sf.fileName),
typeMemo: this.typeMemo,
...(this.generalNodeStreams ? { generalNodeStreams: true } : {}),
...(this.dynNodeStreams ? { dynNodeStreams: true } : {}),
@@ -7712,6 +7793,7 @@ export class Lowerer {
entry: ENTRY_NAME,
...(this.usesWorkers ? { workers: true } : {}),
...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}),
...(this.declBoundary ? { declBoundary: true as const } : {}),
...(this.threadSlotKeys.size > 0
? { threadSlotKeys: [...this.threadSlotKeys].sort() }
: {}),
@@ -8061,6 +8143,19 @@ export class Lowerer {
this.constructedClasses.add(name.slice(1, -".constructor".length));
for (const [owner, methods] of this.virtualEdges)
for (const method of methods) this.noteVirtualEdge(owner, method);
// An instance's [util.inspect.custom] method runs from the runtime
// inspect walk (customInspectRegistrations), which no IR edge shows.
for (
let c = this.classes.get(name.slice(1, -".constructor".length)) ?? null;
c;
c = c.base
) {
const custom = `%${c.def.name}.${INSPECT_CUSTOM_MEMBER}`;
if (units.has(custom)) {
this.onEdge?.(custom);
break;
}
}
}
};
// Class EXPRESSIONS collect while init bodies lower (below): their
@@ -9452,6 +9547,22 @@ export class Lowerer {
}
if (expr.type.kind === "void" && expected.kind !== "void" && this.neverValued.has(expr))
return this.divergentValue(expr, expected);
// A builtin error constructor global stored in a slot of its class value
// type (`{ Parent: Error }`): the class value itself, which a checked
// cast of the dynamic constructor value could not produce.
if (
expected.kind === "classval" &&
expr.kind === "libCall" &&
expr.fn === "dyn.globalConstructor" &&
expr.args[0]?.kind === "strLit" &&
`%${expr.args[0].value}` === expected.className
) {
const info = this.classes.get(expected.className);
if (info?.builtinError && info.valueCtorParams !== undefined) {
this.defineClassValueConstruct(info);
return { kind: "classRef", className: info.def.name, type: expected, loc: expr.loc };
}
}
// process.stdout/stderr into a general Writable slot (general-streams.ts).
const stdioStream = stdioIntoGeneralStream(this, expr, expected);
if (stdioStream) return this.coerceToExpected(stdioStream, expected);
@@ -9558,6 +9669,13 @@ export class Lowerer {
// constructor. `require("events")` is the EventEmitter class itself,
// so its module binding is too (`ReleaseEmitter = EventEmitter` over
// `const EventEmitter = require("events")`).
if (expr.type.kind === "moduleNs" && isJsSourceFileName(expr.loc.file)) {
const object = moduleNamespaceObject(
this,
expr as IrExpr & { type: IrType & { kind: "moduleNs" } },
);
if (object) return object;
}
if (
expr.type.kind === "moduleNs" &&
expr.type.moduleId === "builtin:events" &&
@@ -10564,6 +10682,46 @@ export class Lowerer {
return this.coerceInto(node, value, expected);
}
/** The right side of a write to a member of a typed view of the global
* object (`(globalThis as typeof globalThis & { k?: T }).k = value`, also
* through `??=` and `||=`) that is `value` itself or wraps only it in
* parentheses or `satisfies`; null when `value` is not in such a write. */
private globalViewSlot(value: ts.Expression): ts.Expression | null {
let operand: ts.Expression = value;
while (
operand.parent !== undefined &&
(ts.isParenthesizedExpression(operand.parent) || ts.isSatisfiesExpression(operand.parent))
)
operand = operand.parent;
const write = operand.parent;
if (
write === undefined ||
!ts.isBinaryExpression(write) ||
write.right !== operand ||
(write.operatorToken.kind !== ts.SyntaxKind.EqualsToken &&
write.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionEqualsToken &&
write.operatorToken.kind !== ts.SyntaxKind.BarBarEqualsToken)
)
return null;
let target = write.left;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target))
return null;
const receiver = this.typeOf(target.expression);
const isGlobal = (type: ts.Type): boolean => {
const symbol = type.getSymbol();
return (
symbol !== undefined &&
ts.symbolName(symbol) === "globalThis" &&
symbol.declarations.length === 0
);
};
return isGlobal(receiver) ||
(receiver.isIntersectionType() && ts.constituentTypes(receiver).some(isGlobal))
? operand
: null;
}
/** Lower for a known destination, then apply ordinary value coercion.
* Fresh arrays and records use the destination layout directly. */
lowerExprExpecting(node: ts.Expression, expected: IrType | undefined): IrExpr {
@@ -10631,6 +10789,21 @@ export class Lowerer {
(element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element),
)
) {
// A checked slot that the program declares as a native array (a
// member of a typed view of the global object, `typeof globalThis &
// { cache?: number[] }`) is read back through that declaration. Build
// the native array and store it boxed: every typed read then extracts
// that same array (a dynamic literal would be copied on each read,
// losing writes such as push).
const slot = this.globalViewSlot(x);
const slotType = slot ? this.checker.getContextualType(slot) : undefined;
const declared = slotType && this.checker.getNonNullableType(slotType);
const native =
declared && !(declared.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
? this.mapTypeOf(declared)
: null;
if (native?.kind === "array")
return this.coerceInto(node, this.lowerArrayLiteral(x, native), DYN);
return {
kind: "dynArrLit",
elems: x.elements.nodes.map((element) => this.lowerExprExpecting(element, DYN)),
@@ -10660,6 +10833,7 @@ export class Lowerer {
builtinOption("URLFormatOptions") ||
!this.mapTypeOf(this.typeOf(x)) ||
this.excessJsContextualProperty(x, parts) ||
this.reshapeableContextualLiteral(x, parts) ||
holdsEvolvingArray(this, x)
) {
return lowerDynObjectLiteral(this, x);
@@ -1,6 +1,13 @@
import { dynUndefinedExpr, strLit, varRef } from "../../ir/build.js";
import * as ts from "../ts7/adapter.js";
import { DYN, STRING, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
import {
DYN,
STRING,
type IrExpr,
type IrFunction,
type IrStmt,
type IrType,
} from "../../ir/ir.js";
import { locOf } from "../program.js";
import { newFnCtx, type Lowerer } from "./lowerer.js";
@@ -25,12 +32,13 @@ export function lowerModuleNamespaceElement(
try {
const body: IrStmt[] = [];
const symbol = lowerer.checker.getSymbolAtLocation(source);
symbol?.getExports().forEach((exported, key) => {
for (const exported of symbol ? lowerer.checker.getExportsOfModule(symbol) : []) {
const key = ts.symbolName(exported);
const target =
exported.flags & ts.SymbolFlags.Alias
? lowerer.checker.getAliasedSymbol(exported)
: exported;
if (!(target.flags & ts.SymbolFlags.Value)) return;
if (!(target.flags & ts.SymbolFlags.Value)) continue;
const declaration = target.valueDeclaration;
const identifier = declaration?.name as ts.Node | undefined;
if (!identifier || !ts.isIdentifier(identifier)) {
@@ -43,7 +51,7 @@ export function lowerModuleNamespaceElement(
kind: "strEq",
negated: false,
left: varRef("key", STRING, loc),
right: strLit(String(key), loc),
right: strLit(key, loc),
type: { kind: "bool" },
loc,
},
@@ -51,7 +59,7 @@ export function lowerModuleNamespaceElement(
else_: null,
loc,
});
});
}
body.push({ kind: "return", value: dynUndefinedExpr(loc), loc });
const fn: IrFunction = {
name,
@@ -88,3 +96,78 @@ export function lowerModuleNamespaceElement(
};
return { kind: "call", callee: name, args: [receiver, text], type: DYN, loc };
}
/** A module namespace taken as an untyped value (`{ ...ns }`, `{ iso: ns }`
* in JavaScript): an object holding each value export, keyed in the
* namespace's sorted key order. The copy is exact while every export is a
* binding that never changes after initialization (const, function or
* class); null for other namespaces, which keep their fence. */
export function moduleNamespaceObject(
lowerer: Lowerer,
ns: IrExpr & { type: IrType & { kind: "moduleNs" } },
): IrExpr | null {
const source = lowerer.sourceFileOfModuleNamespace(ns.type);
if (!source) return null;
const symbol = lowerer.checker.getSymbolAtLocation(source);
if (!symbol) return null;
const exports: { key: string; identifier: ts.Identifier }[] = [];
let exact = true;
// The module's resolved exports, which include `export *` members.
for (const exported of lowerer.checker.getExportsOfModule(symbol)) {
const key = ts.symbolName(exported);
const target =
exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported;
if (!(target.flags & ts.SymbolFlags.Value)) continue;
const declaration = target.valueDeclaration;
const identifier = declaration?.name as ts.Node | undefined;
const immutable =
declaration !== undefined &&
(ts.isFunctionDeclaration(declaration) ||
ts.isClassDeclaration(declaration) ||
(ts.isVariableDeclaration(declaration) &&
(ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) !== 0));
if (!identifier || !ts.isIdentifier(identifier) || !immutable) {
exact = false;
break;
}
exports.push({ key, identifier });
}
if (!exact) return null;
exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
const name = `%module.object:${ns.type.moduleId}`;
const loc = ns.loc;
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
const context = newFnCtx(false, null, null, DYN);
const previousClass = lowerer.currentClass;
lowerer.currentClass = null;
lowerer.fnStack.push(context);
try {
const fields = exports.map(({ key, identifier }) => ({
key: strLit(key, loc),
value: lowerer.lowerExprExpecting(identifier, DYN),
}));
if (fields.some((field) => field.value.type.kind !== "dyn")) return null;
const fn: IrFunction = {
name,
params: [],
returnType: DYN,
locals: [...context.locals],
body: [{ kind: "return", value: { kind: "dynObjLit", fields, type: DYN, loc }, loc }],
loc,
};
lowerer.liftedFns.push(fn);
} finally {
lowerer.fnStack.pop();
lowerer.currentClass = previousClass;
}
}
const call: IrExpr = { kind: "call", callee: name, args: [], type: DYN, loc };
if (ns.kind === "moduleNsRef") return call;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: ns, loc }],
result: call,
type: DYN,
loc,
};
}
+9 -3
View File
@@ -685,8 +685,8 @@ function loadFrontend(
...finalLoad.program.getImplementationSourceFiles().filter((sf) => !sf.isDeclarationFile),
].map((sf) => [sf.fileName, sf.text]),
),
lower: (opts) =>
lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, {
lower: (opts) => {
const lowered = lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, {
...opts,
frontendServices: finalLoad.services,
startupCrash: finalLoad.startupCrash ?? null,
@@ -695,7 +695,13 @@ function loadFrontend(
projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false,
externalTypes: finalLoad.externalTypes,
externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile,
}),
});
// The declaration-typed boundary's notes (SC6102) join the
// performance warnings; they never fail a build.
const notes = finalLoad.declBoundaryNotes;
if (notes === undefined) return lowered;
return { ...lowered, performance: [...notes, ...(lowered.performance ?? [])] };
},
npmStatic: statuses,
npmImportSites: npmSites,
dispose: finalLoad.dispose,
+146 -13
View File
@@ -139,6 +139,7 @@ import {
typeOnlyReachedFiles,
} from "./type-only-imports.js";
import { moduleSourceCandidates } from "./module-source-candidates.js";
import { planDeclProjection, type DeclProjection } from "./decl-projection.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
import {
containsJsx,
@@ -386,6 +387,9 @@ export interface LoadResult {
* checkPreflight. */
startupCrash?: StartupCrash | null;
configDiags: ScrDiagnostic[];
/** SC6102 performance notes of the declaration-typed boundary
* (decl-projection.ts): files whose projection was dropped. */
declBoundaryNotes?: ScrDiagnostic[];
/** The files whose JSX the program lowers (jsx-source.ts), by file name.
* The program holds their rewritten text; positions in diagnostics refer
* to it. */
@@ -397,6 +401,9 @@ export interface LoadResult {
/** The program's pre-emit diagnostics, computed once. */
diagnostics: readonly ts.Diagnostic[];
projectWorld: ts.Program | null;
/** The declaration-typed boundary rewrote user files: errors fall
* back to the declaration world (load.projectWorld()), as without it. */
declWorldFallback?: true;
};
/** How scriptc's type world differs from the project's own tsc setup —
* used only to label the TypeScript errors those differences cause. */
@@ -846,6 +853,9 @@ function loadProgram7(
externalTypes: ReadonlyMap<string, string> = new Map(),
/** The --npm-static lowering world: root opted-in packages' deep edges. */
rootNpmStaticEdges = false,
/** The declaration-typed boundary's projection (decl-projection.ts):
* answers rewritten user files for the lowering world, or null. */
projector?: (program: ts.Program) => DeclProjection | null,
): LoadResult & { disposeAll: () => void } {
const config = adoptProjectConfig7(host, entryPath);
const configuredPaths = config.options["paths"];
@@ -925,6 +935,7 @@ function loadProgram7(
// lowering compiles holds them with their JSX lowered.
const jsx = lowerProgramJsx7(program, options);
let tscWorld: LoadResult["tscWorld"];
const declBoundaryNotes: ScrDiagnostic[] = [];
if (jsx.refusal !== null) {
config.diags.push(jsx.refusal);
} else if (jsx.sources.size > 0) {
@@ -940,6 +951,51 @@ function loadProgram7(
};
for (const [file, lowered] of jsx.sources) host.addVirtualFile(file, lowered.text);
program = host.createProgram([...programRoots, overridesDtsPath()], options);
} else if (projector !== undefined) {
// The declaration-typed boundary: user files typed from package
// declarations (decl-projection.ts). The tsc gate reads the original
// text; the lowering compiles the rewritten files.
const projection = projector(program);
if (projection !== null) {
const original = program;
const baseline = new Map<string, number>();
for (const file of projection.files.keys()) {
const sf = original.getSourceFile(file);
if (sf !== undefined) baseline.set(file, original.getSemanticDiagnostics(sf).length);
}
for (const [file, text] of projection.files) host.addVirtualFile(file, text);
host.addVirtualFile(projection.declFile, projection.declText);
programRoots.push(projection.declFile);
program = host.createProgram([...programRoots, overridesDtsPath()], options);
// A rewrite the lowering world disagrees with (new errors) is
// dropped for that file: the file lowers as it would without it.
const dropped: string[] = [];
for (const file of projection.files.keys()) {
const sf = program.getSourceFile(file);
if (sf === undefined) continue;
if (program.getSemanticDiagnostics(sf).length > (baseline.get(file) ?? 0)) {
dropped.push(file);
host.addVirtualFile(file, original.getSourceFile(file)!.text);
}
}
if (dropped.length > 0) {
program.dispose();
program = host.createProgram([...programRoots, overridesDtsPath()], options);
for (const file of dropped) declBoundaryNotes.push(declBoundaryDroppedDiag(file));
}
if (dropped.length === projection.files.size) {
program.dispose();
programRoots.pop();
program = original;
} else {
tscWorld = {
program: original,
diagnostics: ts.getPreEmitDiagnostics(original),
projectWorld: null,
declWorldFallback: true,
};
}
}
}
const entry = program.getSourceFile(entryPath);
if (!entry) throw new Error(`could not load ${entryPath}`);
@@ -950,6 +1006,7 @@ function loadProgram7(
entry,
moduleOrder: [],
configDiags: config.diags,
...(declBoundaryNotes.length > 0 ? { declBoundaryNotes } : {}),
typeWorld: {
nodeFallback: nodeTypes === null,
projectLib: config.projectLib ?? null,
@@ -1156,31 +1213,69 @@ export function loadProgram(
(npmShadow?.hideFile(path) ?? false) || projectDtsRuntimeSibling(path) !== null,
};
const host = services.createProgramHost({ cwd: dirname(entryPath), fsShadow });
let authoredHost: ts.Ts7Host | null = null;
let authoredLoad: ReturnType<typeof loadProgram7> | null = null;
const authored = (): ReturnType<typeof loadProgram7> => {
if (!authoredLoad) {
authoredHost = services.createProgramHost({ cwd: dirname(entryPath) });
authoredLoad = loadProgram7(authoredHost, entryPath, externalTypes);
}
return authoredLoad;
};
const declPackages = npmStaticPackages.filter((pkg) => {
const resolved = resolveBareModule(entryPath, pkg, "types-only");
return resolved !== null && /\.d\.(?:ts|mts|cts)$/.test(resolved.typesFile);
});
const projector =
declPackages.length === 0
? undefined
: (program: ts.Program): DeclProjection | null => {
const userFiles = declBoundaryUserFiles(program, declPackages);
if (userFiles.length === 0) return null;
let projection: DeclProjection | null;
try {
projection = planDeclProjection({
jsProgram: program,
declProgram: authored().program,
userFiles,
outDir: dirname(entryPath),
});
} catch (error) {
if (process.env["SCRIPTC_DEBUG_DECL_BOUNDARY"] === "1") throw error;
return null;
}
if (process.env["SCRIPTC_DEBUG_DECL_BOUNDARY"] === "1") {
process.stderr.write(
`[decl-boundary] ${projection === null ? "no projection" : projection.plan.join("\n[decl-boundary] ")}\n`,
);
if (projection !== null) process.stderr.write(projection.declText);
}
return projection;
};
try {
const load = loadProgram7(host, entryPath, externalTypes, npmStaticPackages.length > 0);
let authoredHost: ts.Ts7Host | null = null;
let authoredLoad: ReturnType<typeof loadProgram7> | null = null;
const load = loadProgram7(
host,
entryPath,
externalTypes,
npmStaticPackages.length > 0,
projector,
);
return {
...load,
services,
projectWorld:
npmStaticPackages.length === 0
? load.projectWorld
: () => {
: () =>
// Check authored code against the package's published declarations.
// Runtime bodies still lower from JavaScript in the original world;
// inference there must not manufacture errors in valid user code.
if (!authoredLoad) {
authoredHost = services.createProgramHost({ cwd: dirname(entryPath) });
authoredLoad = loadProgram7(authoredHost, entryPath, externalTypes);
}
return authoredLoad.projectWorld();
},
authored().projectWorld(),
dispose: () => {
try {
authoredLoad?.disposeAll();
(authoredLoad as ReturnType<typeof loadProgram7> | null)?.disposeAll();
} finally {
authoredHost?.close();
(authoredHost as ts.Ts7Host | null)?.close();
try {
load.disposeAll();
} finally {
@@ -1190,11 +1285,47 @@ export function loadProgram(
},
};
} catch (error) {
(authoredLoad as ReturnType<typeof loadProgram7> | null)?.disposeAll();
(authoredHost as ts.Ts7Host | null)?.close();
host.close();
throw error;
}
}
/** User TypeScript files (outside node_modules) that import one of the
* packages whose declarations the boundary projects from. */
function declBoundaryUserFiles(program: ts.Program, packages: readonly string[]): string[] {
const out: string[] = [];
for (const sf of program.getSourceFiles()) {
const name = sf.fileName.split("\\").join("/");
if (sf.isDeclarationFile || name.includes("/node_modules/") || !/\.[mc]?tsx?$/.test(name))
continue;
if (name.endsWith(".tsx")) continue; // JSX files: not projected yet
const text = sf.text;
if (
packages.some(
(pkg) =>
text.includes(`"${pkg}"`) ||
text.includes(`'${pkg}'`) ||
text.includes(`"${pkg}/`) ||
text.includes(`'${pkg}/`),
)
)
out.push(sf.fileName);
}
return out;
}
/** SC6102 — a projected file the lowering world disagreed with. */
function declBoundaryDroppedDiag(file: string): ScrDiagnostic {
return {
code: "SC6102",
message:
"values from npm packages stay checked-dynamic in this file: the types projected from the packages' declarations do not type-check against their JavaScript",
loc: { file, start: 0, end: 0 },
};
}
/** tsc diagnostics (syntax + types), the supported-import fence, and the
* module evaluation order: fills load.moduleOrder (the SourceFiles
* themselves — the lowering consumes them directly) and returns the
@@ -4801,7 +4932,9 @@ function preflight7(load: LoadResult): {
tscWorld === undefined ? errorsOf(program) : keptErrors(tscWorld.program, tscWorld.diagnostics);
if (tscErrors.length > 0) {
const world =
tscWorld === undefined ? load.projectWorld() : (tscWorld.projectWorld ?? tscWorld.program);
tscWorld === undefined || tscWorld.declWorldFallback === true
? load.projectWorld()
: (tscWorld.projectWorld ?? tscWorld.program);
for (const d of errorsOf(world)) {
const diag = toPassthrough(d);
const lowered = tscWorld === undefined ? undefined : load.jsxSources?.get(diag.loc.file);
@@ -24,15 +24,11 @@ function writePackage(directory: string, name: string, executable?: string): voi
writeFileSync(join(directory, "lib", executable), "");
}
test("tsc-ts is the default checker except on Windows, and SCRIPTC_CHECKER selects one", () => {
expect(checkerBackend({}, "linux")).toBe("tsc-ts");
expect(checkerBackend({ SCRIPTC_CHECKER: "" }, "linux")).toBe("tsc-ts");
expect(checkerBackend({}, "darwin")).toBe("tsc-ts");
expect(checkerBackend({}, "win32")).toBe("tsgo");
expect(checkerBackend({ SCRIPTC_CHECKER: "" }, "win32")).toBe("tsgo");
expect(checkerBackend({ SCRIPTC_CHECKER: "tsc-ts" }, "linux")).toBe("tsc-ts");
expect(checkerBackend({ SCRIPTC_CHECKER: "tsc-ts" }, "win32")).toBe("tsc-ts");
expect(checkerBackend({ SCRIPTC_CHECKER: "tsgo" }, "linux")).toBe("tsgo");
test("tsc-ts is the default checker, and SCRIPTC_CHECKER selects one", () => {
expect(checkerBackend({})).toBe("tsc-ts");
expect(checkerBackend({ SCRIPTC_CHECKER: "" })).toBe("tsc-ts");
expect(checkerBackend({ SCRIPTC_CHECKER: "tsc-ts" })).toBe("tsc-ts");
expect(checkerBackend({ SCRIPTC_CHECKER: "tsgo" })).toBe("tsgo");
expect(() => checkerBackend({ SCRIPTC_CHECKER: "tsc" })).toThrow(
'SCRIPTC_CHECKER must be tsgo or tsc-ts, not "tsc"',
);
@@ -4,17 +4,13 @@ import { fileURLToPath } from "node:url";
/** The TypeScript 7 API servers a compile can check with: tsc-ts, the default,
* and tsgo from the `typescript` package, selected with `SCRIPTC_CHECKER=tsgo`.
* Both serve the same API and give the same results. On Windows the default is
* tsgo: the compiler connects to the server over a named pipe there, and tsc-ts's
* API server has no named-pipe transport yet. */
* Both serve the same API and give the same results. On Windows the Node host
* connects to either over a named pipe the server creates (rpc-process.ts). */
export type CheckerBackend = "tsgo" | "tsc-ts";
export function checkerBackend(
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform,
): CheckerBackend {
export function checkerBackend(env: NodeJS.ProcessEnv = process.env): CheckerBackend {
const value = env["SCRIPTC_CHECKER"];
if (value === undefined || value === "") return platform === "win32" ? "tsgo" : "tsc-ts";
if (value === undefined || value === "") return "tsc-ts";
if (value === "tsc-ts") return "tsc-ts";
if (value === "tsgo") return "tsgo";
throw new Error(`SCRIPTC_CHECKER must be tsgo or tsc-ts, not ${JSON.stringify(value)}`);
@@ -631,6 +631,14 @@ export class CheckerFacade {
return aliased;
}
/** A module symbol's exports, with the members of its `export *`
* declarations resolved (a symbol's own export table lists only the
* module's own declarations). */
getExportsOfModule(symbol: Ts7Symbol): readonly Ts7Symbol[] {
this.ensureActive();
return this.raw.getExportsOfModule(symbol);
}
getDeclaredTypeOfSymbol(symbol: Ts7Symbol): Type {
this.ensureActive();
let type = this.cache.declaredTypeOfSymbol.get(symbol);
+6 -4
View File
@@ -126,6 +126,7 @@ import { moduleRuntimeFeatures } from "./ir/runtime-features.js";
import { serializeModuleParts } from "./ir/serialize.js";
import { readTextParts, writeTextParts } from "./text-parts.js";
import { validateModule } from "./ir/validate.js";
import { compilerOwnedFileRoots } from "./frontend/dts-paths.js";
import { loadProgram } from "./frontend/program-node.js";
import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js";
import {
@@ -600,6 +601,7 @@ async function compileExecutableNative(
...(features.dynTier ? { dynTier: true } : {}),
...(features.streamDyn ? { streamDyn: true } : {}),
...(features.handleProps ? { handleProps: true } : {}),
...(features.declAdopt ? { declAdopt: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBuffer ? { dynBuffer: true } : {}),
@@ -785,7 +787,7 @@ async function prepareExecutableInput(
if (outputKind === "ir") {
await mkdir(dirname(opts.outPath), { recursive: true });
await writeTextParts(opts.outPath, serializeModuleParts(mod));
await writeTextParts(opts.outPath, serializeModuleParts(mod, compilerOwnedFileRoots()));
return { ok: true, artifact: { kind: "ir", path: opts.outPath } };
}
@@ -885,7 +887,7 @@ async function prepareExecutableInput(
if (opts.emitIr) {
irPath = defaultSourcePaths.ir;
irSourcePath = privateSiblingPath(irPath, "ir");
await writeTextParts(irSourcePath, serializeModuleParts(mod));
await writeTextParts(irSourcePath, serializeModuleParts(mod, compilerOwnedFileRoots()));
}
const nativeFeatures = executableNativeFeatures(mod, backend, opts.optimization ?? "release");
@@ -1986,7 +1988,7 @@ async function emitSemanticLibraryHit(
let irPath: string | undefined;
if (opts.emitIr) {
irPath = join(opts.outDir, `${stem}.lib.ir.json`);
await writeTextParts(irPath, serializeModuleParts(mod));
await writeTextParts(irPath, serializeModuleParts(mod, compilerOwnedFileRoots()));
}
await compileLibraryNative(profile, llvmPath, archivePath, opts.sanitize ?? false, hit.native);
timing("native-archive");
@@ -2272,7 +2274,7 @@ async function compileLibraryTracked(
let irPath: string | undefined;
if (opts.emitIr) {
irPath = join(opts.outDir, `${stem}.lib.ir.json`);
await writeTextParts(irPath, serializeModuleParts(mod));
await writeTextParts(irPath, serializeModuleParts(mod, compilerOwnedFileRoots()));
}
const nativeFeatures = libraryNativeFeatures(mod, profile.emission);
@@ -622,6 +622,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
// fence; the zero-argument form always throws ERR_MISSING_ARGS. The
// DOMException clone shares the option validation.
"dyn.structuredClone",
"dyn.declAdopt",
"dyn.cloneMissing",
"dyn.cloneTransferFail",
"dyn.cloneTransferFailWith",
@@ -683,6 +684,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"dyn.propertyIsEnumerableComputed",
// the destructuring pack throws V8's TypeError on non-iterable dyn kinds
"dyn.iterPack",
// ToNumber of each element can run valueOf
"dyn.mathFold",
"dyn.destructureRest",
"dyn.arrayFromIterator",
"dyn.iterator",
@@ -219,6 +219,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* not iterable (cannot read property Symbol(Symbol.iterator))"). In the
* may-throw seed set. */
"dyn.iterPack": { argTypes: [DYN, STRING], result: DYN },
"dyn.mathFold": { argTypes: [DYN, BOOL, STRING], result: F64 },
"dyn.destructureRest": { argTypes: [DYN, STRING], result: DYN },
"dyn.arrayFromIterator": { argTypes: [DYN], result: DYN },
"dyn.iterator": { argTypes: [DYN, STRING], result: DYN },
@@ -3776,6 +3777,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* call: always throws Node's TypeError [ERR_MISSING_ARGS] with Node's
* own (verbatim, doubly-wrapped) message. */
"dyn.structuredClone": { argTypes: [DYN, DYN], result: DYN },
/** The declaration-typed boundary's adoption (decl-projection.ts):
* `source` is the dynamic value a boundary cast just converted, and
* `capsule` a live capsule of the native value built from it. Every
* object or array of `source` that something else still references
* becomes, in place, a live capsule of the native value built from it.
* Throws the path TypeError, changing nothing, when a converted object
* is not plain data (a prototype, accessors, symbols, frozen state). */
"dyn.declAdopt": { argTypes: [DYN, DYN], result: VOID },
"dyn.cloneMissing": { argTypes: [], result: DYN },
/** structuredClone with a NON-EMPTY transfer array of static values:
* nothing static is transferable, so the call always throws Node's
+17 -4
View File
@@ -1,4 +1,6 @@
import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
type SwitchCase = Extract<IrStmt, { kind: "switch" }>["cases"][number];
import { reparseExpr } from "./serialize.js";
import {
everyExprChild,
@@ -382,20 +384,26 @@ function rewriteCandidates(
if (!call || simple(call.args[1]!)) return [s];
const { decl, ref } = bind(call.args[1]!);
const read: IrExpr = { ...call, args: [call.args[0]!, ref] };
return [decl, s.kind === "varDecl" ? { ...s, init: read } : { ...s, value: read }];
if (s.kind === "varDecl") return [decl, { ...s, init: read }];
return [decl, { ...s, value: read }];
});
const hoistCase = (c: SwitchCase): SwitchCase => ({ ...c, body: hoist(c.body) });
const lists = (s: IrStmt): IrStmt => {
switch (s.kind) {
case "if":
return { ...s, then: hoist(s.then), else_: s.else_ === null ? null : hoist(s.else_) };
case "block":
return { ...s, body: hoist(s.body) };
case "while":
return { ...s, body: hoist(s.body) };
case "doWhile":
return { ...s, body: hoist(s.body) };
case "for":
return { ...s, body: hoist(s.body) };
case "forOf":
return { ...s, body: hoist(s.body) };
case "switch":
return { ...s, cases: s.cases.map((c) => ({ ...c, body: hoist(c.body) })) };
return { ...s, cases: s.cases.map((c) => hoistCase(c)) };
case "tryCatch":
return {
...s,
@@ -425,7 +433,10 @@ function rewriteCandidates(
};
}
const call = readerCall(e);
if (!call) return e.kind === "seqExpr" ? { ...e, stmts: hoist(e.stmts) } : e;
if (!call) {
if (e.kind === "seqExpr") return { ...e, stmts: hoist(e.stmts) };
return e;
}
const helper = readers.get(call.callee)!;
const array = call.args[0]!;
const [arrayParam, indexParam] = helper.params;
@@ -448,7 +459,9 @@ function rewriteCandidates(
},
stmt: (x) => x,
});
const typed: IrExpr = { ...body, loc: e.loc };
// The ternary is a fresh copy (reparseExpr): it takes the call's location.
const typed = body;
typed.loc = e.loc;
return stmts.length === 0
? typed
: { kind: "seqExpr", stmts, result: typed, type: typed.type, loc: e.loc };
+4 -1
View File
@@ -1143,7 +1143,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
const key = `l:${fn.name}\0${id}`;
if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue;
slots.dropInt32Elements(key);
const checkable = (value: IrExpr): boolean => int32(value) || ranges.get(value) != null;
const checkable = (value: IrExpr): boolean => {
const range = ranges.get(value);
return int32(value) || range != null;
};
if (values.every(checkable) && checkableInt32Allocations(fn, id))
slots.checkedInt32Arrays.add(`${fn.name}\0${id}`);
else reason(`array:${fn.name}\0${id}`, "an element store is not int32");
@@ -402,6 +402,16 @@ test("paired counters closing in on each other run as integers", () => {
guarded: false,
range: { min: 0, max: 9 },
});
// `hi > lo` and `hi >= lo` are `lo < hi` and `lo <= hi`.
for (const op of [">", ">="] as const) {
const reversed = paired(number(0), number(9));
reversed.cond = { kind: "bin", op, left: ref("hi"), right: ref("lo"), type: BOOL, loc };
expect(matchIntegerPairedForLoop(reversed, vars)).toMatchObject({
low: { localId: "lo", step: 1 },
high: { localId: "hi", step: -1 },
inclusive: op === ">=",
});
}
// A start without a known range is tested at run time.
expect(matchIntegerPairedForLoop(paired(number(0), ref("head")), vars)).toMatchObject({
high: { startGuarded: true },
+11 -8
View File
@@ -382,9 +382,9 @@ export function matchIntegerCountedForLoop(
*
* The first counter only grows and the second only shrinks, by positive
* literal steps, the body writes neither, and the loop runs while the first
* is below (or at most) the second. Every body entry therefore holds both
* between the first start and the second start: exact integers when the
* starts are. A start without a proven integer range is tested at run time
* is below (or at most) the second, written either way round (`hi > lo`).
* Every body entry therefore holds both between the first start and the
* second start: exact integers when the starts are. A start without a proven integer range is tested at run time
* on a versioned loop, as a counted loop's is. */
export interface IntegerPairedCounter {
localId: string;
@@ -412,10 +412,13 @@ export function matchIntegerPairedForLoop(
cond = stmt.cond;
if (init?.kind !== "block" || init.body.length !== 2) return null;
if (update?.kind !== "block" || update.body.length !== 2) return null;
if (cond?.kind !== "bin" || (cond.op !== "<" && cond.op !== "<=")) return null;
if (cond.left.kind !== "varRef" || cond.right.kind !== "varRef") return null;
const lowId = cond.left.localId,
highId = cond.right.localId;
if (cond?.kind !== "bin" || cond.left.kind !== "varRef" || cond.right.kind !== "varRef")
return null;
const below = cond.op === "<" || cond.op === "<=";
if (!below && cond.op !== ">" && cond.op !== ">=") return null;
// `hi > lo` is `lo < hi`.
const lowId = below ? cond.left.localId : cond.right.localId,
highId = below ? cond.right.localId : cond.left.localId;
if (lowId === highId) return null;
const counter = (id: string): IntegerPairedCounter | null => {
const decl = init.body.find((d) => d.kind === "varDecl" && d.localId === id);
@@ -485,7 +488,7 @@ export function matchIntegerPairedForLoop(
return {
low,
high,
inclusive: cond.op === "<=",
inclusive: cond.op === "<=" || cond.op === ">=",
guarded,
// A loop whose starts already fail the condition never enters the body.
range: {
+13
View File
@@ -1044,6 +1044,10 @@ export interface IrModule {
entry: string;
/** Executable roots run in isolated native worker contexts. */
workers?: boolean;
/** The program has declaration-typed boundary casts (decl-projection.ts):
* closures boxed for package code return records and arrays as live
* capsules, so a value that comes back keeps its identity. */
declBoundary?: true;
/** The program constructs LifetimeRegion (@scriptc/experimental): every
* ProgramLifetime instance lives in a counted region (the region.*
* intrinsics; backend/llvm/regions.ts). */
@@ -3886,10 +3890,19 @@ export type IrExpr =
/** `exact`: the value is known to be a capsule this program boxed from
* exactly the class `type` names (a `dyn.typedRefKeyIs` test guards the
* expression), so the check is one load of the capsule's instance. */
/** `adopt`: the declaration-typed boundary's cast (decl-projection.ts).
* The conversion preserves identity: a source object or array that
* something else still references (reference count above one) is
* turned, after the whole check succeeded, into a live capsule of the
* value built from it, so every holder sees the same native value; an
* unshared source is simply consumed. Values must be plain data
* (ordinary objects and arrays without accessors, prototypes, symbols
* or frozen state), otherwise the cast throws the path TypeError. */
| {
kind: "dynCheck";
value: IrExpr;
preserveRefs?: true;
adopt?: true;
exact?: true;
type: IrType;
loc: SrcLoc;
@@ -0,0 +1,182 @@
import { expect, test } from "vitest";
import {
BOOL,
F64,
arrayOf,
type IrExpr,
type IrFunction,
type IrLocal,
type IrModule,
type IrStmt,
} from "./ir.js";
import { runPairedWhileLoopsAsFor } from "./paired-while-loops.js";
const loc = { file: "paired-while-loops.ts", start: 0, end: 0 };
const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, loc });
const array = { kind: "varRef", localId: "a", type: arrayOf(F64), loc } as const;
const element = (index: IrExpr): IrStmt => ({
kind: "exprStmt",
expr: { kind: "arrayGet", arr: array, index, type: F64, loc },
loc,
});
const step = (id: string, op: "+" | "-"): IrStmt => ({
kind: "assign",
localId: id,
value: { kind: "bin", op, left: ref(id), right: number(1), type: F64, loc },
loc,
});
const declare = (id: string, init: IrExpr): IrStmt => ({ kind: "varDecl", localId: id, init, loc });
const number_ = (id: string): IrLocal => ({
id,
name: id.split(".")[0]!,
type: F64,
mutable: true,
});
function whileLoop(
body: IrStmt[] = [element(ref("lo.0")), element(ref("hi.0"))],
updates: IrStmt[] = [step("lo.0", "+"), step("hi.0", "-")],
): IrStmt & { kind: "while" } {
return {
kind: "while",
cond: { kind: "bin", op: "<", left: ref("lo.0"), right: ref("hi.0"), type: BOOL, loc },
body: [...body, ...updates],
loc,
};
}
function program(body: IrStmt[], extra: Partial<IrFunction> = {}): IrModule {
const fn: IrFunction = {
name: "f",
params: [{ localId: "n.0", name: "n", type: F64 }],
returnType: F64,
locals: [
number_("n.0"),
number_("lo.0"),
number_("hi.0"),
{ id: "a", name: "a", type: arrayOf(F64), mutable: false },
],
body,
loc,
...extra,
};
return { functions: [fn], globals: [] } as unknown as IrModule;
}
const rewritten = (mod: IrModule): IrStmt[] => runPairedWhileLoopsAsFor(mod).functions[0]!.body;
test("a two-counter while loop runs as a for loop over new counters", () => {
const mod = program([declare("lo.0", number(0)), declare("hi.0", ref("n.0")), whileLoop()]);
const out = runPairedWhileLoopsAsFor(mod).functions[0]!;
// The declarations stay; the loop's counters are new locals that start
// from them, and the trailing updates are the for loop's update.
expect(out.body.slice(0, 2)).toEqual(mod.functions[0]!.body.slice(0, 2));
expect(out.body[2]).toMatchObject({
kind: "for",
init: {
kind: "block",
body: [
{ kind: "varDecl", localId: "lo.1", init: { kind: "varRef", localId: "lo.0" } },
{ kind: "varDecl", localId: "hi.1", init: { kind: "varRef", localId: "hi.0" } },
],
},
cond: { op: "<", left: { localId: "lo.1" }, right: { localId: "hi.1" } },
update: { kind: "block", body: [{ localId: "lo.1" }, { localId: "hi.1" }] },
body: [
{ expr: { kind: "arrayGet", index: { localId: "lo.1" } } },
{ expr: { kind: "arrayGet", index: { localId: "hi.1" } } },
],
});
expect(out.locals.map((l) => l.id)).toEqual(["n.0", "lo.0", "hi.0", "a", "lo.1", "hi.1"]);
});
test("parameters, reversed comparisons, labels and either update order qualify", () => {
const loop = whileLoop(undefined, [step("hi.0", "-"), step("lo.0", "+")]);
loop.cond = { kind: "bin", op: ">=", left: ref("hi.0"), right: ref("lo.0"), type: BOOL, loc };
loop.labels = ["outer"];
const mod = program([declare("lo.0", number(0)), loop], {
params: [{ localId: "hi.0", name: "hi", type: F64 }],
});
expect(rewritten(mod)[1]).toMatchObject({
kind: "for",
labels: ["outer"],
cond: { op: ">=", left: { localId: "hi.1" }, right: { localId: "lo.1" } },
update: { body: [{ localId: "hi.1" }, { localId: "lo.1" }] },
});
});
test("loops the for form would change, or whose counters are used elsewhere, stay", () => {
const decls = [declare("lo.0", number(0)), declare("hi.0", ref("n.0"))];
const kept = (body: IrStmt[], extra: Partial<IrFunction> = {}): void => {
const mod = program(body, extra);
expect(runPairedWhileLoopsAsFor(mod)).toBe(mod);
};
// A continue that binds to the loop skips the trailing updates.
kept([...decls, whileLoop([{ kind: "continue", loc }, element(ref("lo.0"))])]);
const labeled = whileLoop([{ kind: "continue", label: "outer", loc }, element(ref("lo.0"))]);
labeled.labels = ["outer"];
kept([...decls, labeled]);
// A counter read after the loop, written before it, or declared twice.
kept([...decls, whileLoop(), { kind: "return", value: ref("lo.0"), loc }]);
kept([...decls, step("hi.0", "-"), whileLoop()]);
kept([...decls, declare("lo.0", number(1)), whileLoop()]);
// A declaration outside an enclosing loop: a later entry would resume
// from the values the previous run left.
kept([
...decls,
{
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [whileLoop()],
loc,
},
]);
// A parameter counter inside another loop, for the same reason.
const outerLoop: IrStmt = {
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [whileLoop()],
loc,
};
kept([outerLoop], {
params: [
{ localId: "lo.0", name: "lo", type: F64 },
{ localId: "hi.0", name: "hi", type: F64 },
],
});
// A counter that was never declared, or that this function captures.
kept([declare("lo.0", number(0)), whileLoop()]);
kept([...decls, whileLoop()], { captures: [{ localId: "hi.0", name: "hi", type: F64 }] });
// The body writes a counter, a counter is boxed, or no counter is an index.
kept([...decls, whileLoop([element(ref("lo.0")), step("hi.0", "-")])]);
const boxed = program([...decls, whileLoop()]);
boxed.functions[0]!.locals[2] = { ...number_("hi.0"), boxed: true };
expect(runPairedWhileLoopsAsFor(boxed)).toBe(boxed);
kept([...decls, whileLoop([])]);
});
test("a continue in a nested loop keeps the outer loop eligible", () => {
const inner: IrStmt = {
kind: "while",
cond: { kind: "boolLit", value: false, type: BOOL, loc },
body: [{ kind: "continue", loc }],
loc,
};
// Literal starts have known ranges, so the loop needs no start test and
// may hold a nested loop.
const mod = program([
declare("lo.0", number(0)),
declare("hi.0", number(9)),
whileLoop([inner, element(ref("lo.0"))]),
]);
expect(rewritten(mod)[2]).toMatchObject({ kind: "for", body: [{ kind: "while" }, {}] });
// A start without a known range is tested at run time, on a loop that
// may not nest another.
const guarded = program([
declare("lo.0", number(0)),
declare("hi.0", ref("n.0")),
whileLoop([inner, element(ref("lo.0"))]),
]);
expect(runPairedWhileLoopsAsFor(guarded)).toBe(guarded);
});
@@ -0,0 +1,307 @@
import { matchIntegerPairedForLoop } from "./integer-loops.js";
import { analyzeIntegerRanges, type IntegerRange } from "./integer-ranges.js";
import {
F64,
type IrExpr,
type IrFunction,
type IrLocal,
type IrModule,
type IrStmt,
} from "./ir.js";
import { everyExprChild, everyStmtChild, mapExprChildren, mapStmtChildren } from "./traverse.js";
type WhileLoop = IrStmt & { kind: "while" };
/** Two-counter while loops run as two-counter for loops.
*
* let lo = a, hi = b;
* while (lo < hi) { ...; lo++; hi--; }
*
* runs as `for (let l = lo, h = hi; l < h; l++, h--) { ... }`, whose
* counters the backend stores as integers (matchIntegerPairedForLoop). The
* loop qualifies when:
* - its condition compares the two counters (`<`, `<=`, `>` or `>=`);
* - its body ends with one update of each counter;
* - no `continue` in the body binds to it (a for loop's `continue` runs the
* update, a while loop's skips the body's last statements);
* - the function mentions neither counter outside the loop, except in one
* declaration or as a parameter;
* - every entry starts from the declared or passed values: a declaration is
* an earlier statement of the loop's own statement list, which runs before
* each entry, and a loop over a parameter is in no other loop.
*
* The for loop's counters are new locals that start from the counters'
* values on entry, so the declarations stay where they are and nothing
* between them and the loop moves. The original counters are not read
* after the loop, so they need no final value. A loop that the paired match
* would refuse is left as it is. */
export function runPairedWhileLoopsAsFor(mod: IrModule): IrModule {
let changed = false;
const functions = mod.functions.map((fn) => {
const next = rewriteFunction(fn);
if (next !== fn) changed = true;
return next;
});
return changed ? { ...mod, functions } : mod;
}
/** The local a statement steps: `x++`, `x--` or `x = ...`. */
function updatedLocal(s: IrStmt): string | null {
if (s.kind === "assign") return s.localId;
if (s.kind === "exprStmt" && s.expr.kind === "incDec") return s.expr.localId;
return null;
}
/** The counters of a loop with the qualifying condition and trailing
* updates, or null. */
function loopCounters(loop: WhileLoop, locals: ReadonlyMap<string, IrLocal>): string[] | null {
const cond = loop.cond;
if (cond.kind !== "bin") return null;
if (cond.op !== "<" && cond.op !== "<=" && cond.op !== ">" && cond.op !== ">=") return null;
if (cond.left.kind !== "varRef" || cond.right.kind !== "varRef") return null;
const counters = [cond.left.localId, cond.right.localId];
if (counters[0] === counters[1]) return null;
const n = loop.body.length;
if (n < 2) return null;
const updated = [updatedLocal(loop.body[n - 2]!), updatedLocal(loop.body[n - 1]!)];
if (!counters.every((id) => updated.includes(id))) return null;
for (const id of counters) {
const local = locals.get(id);
if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null;
}
return counters;
}
function isLoop(s: IrStmt): boolean {
return s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf";
}
/** True when a `continue` in the body binds to the loop: an unlabeled one
* outside nested loops, or one that names a label of the loop. */
function continuesLoop(loop: WhileLoop): boolean {
const labels = loop.labels ?? [];
let depth = 0;
let found = false;
const expr = (e: IrExpr): boolean => !found && everyExprChild(e, expr, stmt);
const stmt = (s: IrStmt): boolean => {
if (found) return false;
if (s.kind === "continue") {
if (s.label === undefined ? depth === 0 : labels.includes(s.label)) found = true;
return !found;
}
const nested = isLoop(s);
if (nested) depth++;
const ok = everyStmtChild(s, expr, stmt);
if (nested) depth--;
return ok;
};
loop.body.every(stmt);
return found;
}
/** Mentions of each id in `node`, in any field (a string literal equal to
* an id counts too, which only refuses more loops). */
function countMentions(
node: unknown,
ids: ReadonlySet<string>,
mentions: Map<string, number>,
): void {
if (typeof node === "string") {
if (ids.has(node)) mentions.set(node, (mentions.get(node) ?? 0) + 1);
} else if (Array.isArray(node)) {
for (const item of node) countMentions(item, ids, mentions);
} else if (node !== null && typeof node === "object") {
const record = node as Record<string, unknown>;
for (const key in record)
if (key !== "loc" && key !== "type") countMentions(record[key], ids, mentions);
}
}
/** A loop with the qualifying shape, the statements before it in its
* statement list, and whether it is inside another loop. */
interface Candidate {
loop: WhileLoop;
counters: string[];
before: readonly IrStmt[];
nested: boolean;
}
function findCandidates(fn: IrFunction, locals: ReadonlyMap<string, IrLocal>): Candidate[] {
const candidates: Candidate[] = [];
let depth = 0;
const list = (body: readonly IrStmt[]): void => {
body.forEach((s, i) => {
if (s.kind === "while") {
const counters = loopCounters(s, locals);
if (counters !== null && !continuesLoop(s))
candidates.push({ loop: s, counters, before: body.slice(0, i), nested: depth > 0 });
}
visit(s);
});
};
// Statement lists inside expressions are not searched: their loops stay.
const visit = (s: IrStmt): void => {
const loop = isLoop(s);
if (loop) depth++;
switch (s.kind) {
case "block":
case "while":
case "doWhile":
case "for":
case "forOf":
list(s.body);
break;
case "if":
list(s.then);
if (s.else_ !== null) list(s.else_);
break;
case "switch":
for (const c of s.cases) list(c.body);
break;
case "tryCatch":
list(s.tryBody);
if (s.catchBody !== null) list(s.catchBody);
if (s.finallyBody !== null) list(s.finallyBody);
break;
default:
break;
}
if (loop) depth--;
};
list(fn.body);
return candidates;
}
function rewriteFunction(fn: IrFunction): IrFunction {
const locals = new Map(fn.locals.map((l) => [l.id, l]));
const candidates = findCandidates(fn, locals);
if (candidates.length === 0) return fn;
const ids = new Set<string>();
for (const c of candidates) for (const id of c.counters) ids.add(id);
const total = new Map<string, number>();
countMentions(fn.body, ids, total);
const params = new Set(fn.params.map((p) => p.localId));
// A counter this function reaches through its environment.
const outer = new Map<string, number>();
countMentions([fn.captures ?? [], fn.classCaptures ?? []], ids, outer);
let ranges: ReadonlyMap<IrExpr, IntegerRange | null> | null = null;
const used = new Set(fn.locals.map((l) => l.id));
const added: IrLocal[] = [];
const rewritten = new Map<WhileLoop, Map<string, string>>();
for (const { loop, counters, before, nested } of candidates) {
const inside = new Map<string, number>();
countMentions(loop, new Set(counters), inside);
// Each counter's start: its declaration's initializer, or null for a
// parameter.
const starts = new Map<string, IrExpr | null>();
for (const id of counters) {
if ((outer.get(id) ?? 0) !== 0) break;
const elsewhere = (total.get(id) ?? 0) - (inside.get(id) ?? 0);
if (params.has(id)) {
if (elsewhere === 0 && !nested) starts.set(id, null);
continue;
}
const decl = before.find((s) => s.kind === "varDecl" && s.localId === id);
if (elsewhere === 1 && decl?.kind === "varDecl" && decl.init !== null)
starts.set(id, decl.init);
}
if (starts.size !== counters.length) continue;
const renamed = new Map<string, string>();
const fresh: IrLocal[] = [];
for (const id of counters) {
const local = locals.get(id)!;
let k = 0;
while (used.has(`${local.name}.${k}`)) k++;
const next = `${local.name}.${k}`;
used.add(next);
renamed.set(id, next);
fresh.push({ ...local, id: next });
}
const candidate = pairedForLoop(loop, renamed);
const forLocals = new Map(locals);
for (const local of fresh) forLocals.set(local.id, local);
// A start reads its counter on entry, which holds the initializer's
// value, with that value's range.
ranges ??= analyzeIntegerRanges(fn);
const startRanges = new Map<IrExpr, IntegerRange | null>();
if (candidate.init?.kind === "block")
for (const decl of candidate.init.body) {
if (decl.kind !== "varDecl" || decl.init?.kind !== "varRef") continue;
const init = starts.get(decl.init.localId);
if (!init) continue;
const literal =
init.kind === "numLit" && Number.isSafeInteger(init.value) && !Object.is(init.value, -0);
startRanges.set(
decl.init,
literal ? { min: init.value, max: init.value } : (ranges.get(init) ?? null),
);
}
if (matchIntegerPairedForLoop(candidate, forLocals, startRanges) === null) {
for (const local of fresh) used.delete(local.id);
continue;
}
rewritten.set(loop, renamed);
added.push(...fresh);
}
if (rewritten.size === 0) return fn;
const expr = (e: IrExpr): IrExpr => mapExprChildren(e, expr, stmt);
const stmt = (s: IrStmt): IrStmt => {
const next = mapStmtChildren(s, expr, stmt);
const renamed = s.kind === "while" ? rewritten.get(s) : undefined;
// The body may hold rewritten loops of its own.
return renamed === undefined ? next : pairedForLoop(next as WhileLoop, renamed);
};
return { ...fn, body: fn.body.map(stmt), locals: [...fn.locals, ...added] };
}
/** The for loop over the renamed counters: their starts read the original
* counters, and the body's two trailing updates become the update. */
function pairedForLoop(
loop: WhileLoop,
renamed: ReadonlyMap<string, string>,
): IrStmt & { kind: "for" } {
const expr = (e: IrExpr): IrExpr => {
const next = mapExprChildren(e, expr, stmt);
if (next.kind === "varRef" || next.kind === "incDec" || next.kind === "assignExpr") {
const id = renamed.get(next.localId);
if (id !== undefined) return { ...next, localId: id };
}
return next;
};
const stmt = (s: IrStmt): IrStmt => {
const next = mapStmtChildren(s, expr, stmt);
if (
next.kind === "varDecl" ||
next.kind === "assign" ||
next.kind === "forOf" ||
next.kind === "rethrow"
) {
const id = renamed.get(next.localId);
if (id !== undefined) return { ...next, localId: id };
}
return next;
};
const loc = loop.loc;
const n = loop.body.length;
const declarations: IrStmt[] = [];
for (const [id, next] of renamed)
declarations.push({
kind: "varDecl",
localId: next,
init: { kind: "varRef", localId: id, type: F64, loc },
loc,
});
const result: IrStmt & { kind: "for" } = {
kind: "for",
init: { kind: "block", body: declarations, loc },
cond: expr(loop.cond),
update: { kind: "block", body: loop.body.slice(n - 2).map(stmt), loc },
body: loop.body.slice(0, n - 2).map(stmt),
loc,
};
if (loop.labels !== undefined) result.labels = loop.labels;
return result;
}
@@ -858,6 +858,10 @@ const RUNTIME_FEATURE_RULES = {
}),
/** Own properties JavaScript adds to children (scr_dyn_handle_props.c). */
handleProps: rule({ fns: ["child.propsInstall"] }),
/** The declaration-typed boundary's adoption (scr_decl_adopt.c): its
* casts, and closures boxed in a boundary program, which adopt their
* arguments. */
declAdopt: rule({ fns: ["dyn.declAdopt"], initial: (mod) => mod.declBoundary === true }),
/** for await over checked-dynamic values (scr_dyn_for_await.c). */
dynForAwait: rule({ fns: ["dyn.asyncIteratorOpen", "dyn.asyncIteratorWasSync"] }),
/** Buffer.prototype methods called on checked-dynamic values
@@ -1081,6 +1085,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
stream: false,
streamDyn: false,
handleProps: false,
declAdopt: false,
dynForAwait: false,
dynBuffer: false,
builtinValues: false,
@@ -198,3 +198,24 @@ test("byte decoding keeps the whole-document errors and version refusal", () =>
const sentinel = text.replace('"nan"', '"future"');
expect(() => deserializeModuleBytes(Buffer.from(sentinel))).toThrow("Invalid IR number sentinel");
});
test("file roots respell the source locations under them and nothing else", () => {
const mod = numbers([1, 2]);
const shim = { file: '/opt/scriptc "a"/shims/pkg/index.ts', start: 3, end: 4 };
const other = { file: "/opt/elsewhere/shims/pkg/index.ts", start: 5, end: 6 };
const body = mod.functions[0]!.body as IrStmt[];
body.push(
{ kind: "exprStmt", expr: { kind: "numLit", value: 3, type: F64, loc: shim }, loc: shim },
{ kind: "exprStmt", expr: { kind: "numLit", value: 4, type: F64, loc: other }, loc: other },
);
const roots = [{ from: '/opt/scriptc "a"/shims/', to: "scriptc:shims/" }];
const relocated = deserializeModule(serializeModuleParts(mod, roots).join(""));
const files = (relocated.functions[0]!.body as IrStmt[]).map((stmt) => stmt.loc.file);
expect(files).toEqual([
"numbers.ts",
"numbers.ts",
"scriptc:shims/pkg/index.ts",
"/opt/elsewhere/shims/pkg/index.ts",
]);
expect(serializeModuleParts(mod).join("")).toBe(serializeModule(mod, true));
});
+33 -3
View File
@@ -46,11 +46,41 @@ function encodeFunction(fn: IrFunction): string | undefined {
return ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber);
}
/** A directory whose source location files an IR artifact names by a
* prefix of its own: a location file `from + rest` serializes as
* `to + rest`. */
export interface SerializedFileRoot {
from: string;
to: string;
}
/** Respells the source location files under `roots` in serialized parts.
* A location's file is the first member of its JSON object, and the
* escaped spelling of a path prefix is the prefix of the escaped path. */
function relocateFiles(parts: string[], roots: readonly SerializedFileRoot[]): string[] {
const needles = roots.map(({ from, to }) => ({
from: `"file":${JSON.stringify(from).slice(0, -1)}`,
to: `"file":${JSON.stringify(to).slice(0, -1)}`,
}));
return parts.map((part) =>
needles.reduce(
(text, { from, to }) => (text.includes(from) ? text.replaceAll(from, to) : text),
part,
),
);
}
/** The compact encoding as ordered parts: the module header up to the
* function array, one part per function (with separate comma parts), and the
* header's remainder. Concatenating the parts yields exactly
* `serializeModule(mod, true)`; writers stream them to disk in batches. */
export function serializeModuleParts(mod: IrModule): string[] {
* `serializeModule(mod, true)`; writers stream them to disk in batches.
* `fileRoots` respells the source locations in those directories, so
* artifacts do not depend on where the compiler that wrote them is
* installed. */
export function serializeModuleParts(
mod: IrModule,
fileRoots: readonly SerializedFileRoot[] = [],
): string[] {
// A replacer reads through live native views. Serializing the complete
// function array through one view refreshes every function capsule for
// each element, making large compiler artifacts quadratic. The sentinel
@@ -65,7 +95,7 @@ export function serializeModuleParts(mod: IrModule): string[] {
parts.push(encodeFunction(fn) ?? "null");
}
parts.push("]" + header.slice(offset + slot.length));
return parts;
return fileRoots.length === 0 ? parts : relocateFiles(parts, fileRoots);
}
/** Compiler artifacts use compact JSON; API consumers can retain the
+2
View File
@@ -120,6 +120,8 @@ export function validateModule(mod: IrModule): IrValidationError[] {
const moduleLoc: SrcLoc = { file: mod.sourceFile, start: 0, end: 0 };
if (mod.workers !== undefined && typeof mod.workers !== "boolean")
errors.push({ message: "worker execution mode must be a boolean", loc: moduleLoc });
if (mod.declBoundary !== undefined && mod.declBoundary !== true)
errors.push({ message: "declBoundary must be true when present", loc: moduleLoc });
if (mod.lifetimeRegions !== undefined && mod.lifetimeRegions !== true)
errors.push({ message: "lifetimeRegions must be true when present", loc: moduleLoc });
if (mod.workers && mod.lib !== undefined)
+5 -2
View File
@@ -27,6 +27,7 @@ import {
type FrontendInputExclusions,
trackedReadFile,
} from "../frontend/input-tracker.js";
import { compilerOwnedFileRoots } from "../frontend/dts-paths.js";
import { provenanceSources } from "../frontend/provenance-registry.js";
import { processFork } from "../frontend/process-fork.js";
import { tokenEquivalentSource } from "../frontend/token-equivalence.js";
@@ -377,7 +378,7 @@ export function prepareNativeExecutable(
if (irInput !== "") timing("ir-input");
const ir =
outputKind === "ir" || options.emitIr
? batchTextParts(serializeModuleParts(prepared.mod))
? batchTextParts(serializeModuleParts(prepared.mod, compilerOwnedFileRoots()))
: null;
timing("ir-serialize");
const llvm =
@@ -510,7 +511,9 @@ export function prepareNativeLibrary(
const input: NativeExecutableInput = {
ok: true,
llvm,
ir: options.emitIr ? batchTextParts(serializeModuleParts(prepared.mod)) : null,
ir: options.emitIr
? batchTextParts(serializeModuleParts(prepared.mod, compilerOwnedFileRoots()))
: null,
sidecarJson: prepared.sidecarJson,
features: executableLinkFeatures(prepared.mod),
sources: [...prepared.sourceTexts],
@@ -79,6 +79,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
dynTier: false,
streamDyn: false,
handleProps: false,
declAdopt: false,
dynForAwait: false,
eventsOnceSignal: false,
dynBuffer: false,
@@ -99,11 +99,11 @@ test("native toolchain assets resolve from the physical manifest through package
const direct = loadNativeToolchain(path);
const viaLink = loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"));
expect(viaLink).toEqual(direct);
expect(viaLink.ts7Executable).toBe(join(assets, "typescript/lib/tsc.exe"));
expect(viaLink.ts7Executable).toBe(join(assets, "tsc-ts/lib/tsc-ts.exe"));
expect(
loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"), { SCRIPTC_CHECKER: "tsc-ts" })
loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"), { SCRIPTC_CHECKER: "tsgo" })
.ts7Executable,
).toBe(join(assets, "tsc-ts/lib/tsc-ts.exe"));
).toBe(join(assets, "typescript/lib/tsc.exe"));
expect(viaLink.helperExecutable).toBe(join(assets, "llvm/bin/scriptc-llvm-codegen.exe"));
expect(
loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"), {
+4 -4
View File
@@ -177,11 +177,11 @@ export function loadNativeToolchain(
: [...target.defaultLinkerArgs];
return {
compilerVersion: manifest.compiler_version,
// tsc-ts, the default checker except on Windows, comes with the distribution;
// a development build takes it from the tsc-ts package installed with the
// compiler or for the working directory. SCRIPTC_CHECKER selects a checker.
// tsc-ts, the default checker, comes with the distribution; a development
// build takes it from the tsc-ts package installed with the compiler or for
// the working directory. SCRIPTC_CHECKER selects a checker.
ts7Executable:
checkerBackend(env, host.platform) === "tsgo"
checkerBackend(env) === "tsgo"
? pathFrom(root, manifest.ts7)
: manifest.tsc_ts !== undefined
? pathFrom(root, manifest.tsc_ts)
@@ -378,7 +378,7 @@ done:
expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]);
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v33");
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v35");
const clangExe = join(dir, "clang-program");
const linkDriver = join(dir, "clang-driver.c");
@@ -712,7 +712,7 @@ done:
.split("\n");
expect(undefinedSymbols).toContain("_sf_scale");
expect(undefinedSymbols).toContain("_sf_callback_mix");
expect(undefinedSymbols).toContain("_scr_runtime_abi_v33");
expect(undefinedSymbols).toContain("_scr_runtime_abi_v35");
});
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
@@ -0,0 +1,119 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
type IrRecord = Record<string, unknown>;
function recordsOf(value: unknown, out: IrRecord[] = []): IrRecord[] {
if (value === null || typeof value !== "object") return out;
if (Array.isArray(value)) {
for (const item of value) recordsOf(item, out);
return out;
}
const record = value as IrRecord;
out.push(record);
for (const child of Object.values(record)) recordsOf(child, out);
return out;
}
// An untyped computed write puts every method name in the census, so typed
// calls read the observable slot. While the slot holds the declared method,
// the call is the native method call, not a dynamic call of the slot.
test("typed calls of an observed method call it natively while its slot is unchanged", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-observed-guard-"));
dirs.push(dir);
const entry = join(dir, "main.ts");
const outDir = join(dir, ".scriptc");
const outPath = join(outDir, "main.ir.json");
await writeFile(
join(dir, "untyped.js"),
"export function put(target, key, value) { target[key] = value; }\n",
);
await writeFile(
entry,
[
'import { put } from "./untyped.js";',
"class Repo {",
" items: number[] = [];",
" total(): number { let s = 0; for (const n of this.items) s += n; return s; }",
"}",
"const repo = new Repo();",
"repo.items.push(1, 2);",
"put({}, String(Math.random()), 1);",
"export function sum(r: Repo): number { return r.total(); }",
"console.log(sum(repo));",
"",
].join("\n"),
);
const result = await compile(entry, { outDir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const module = deserializeModule(await readFile(outPath, "utf8"));
expect(validateModule(module)).toEqual([]);
const sum = module.functions.find((fn) => fn.name.endsWith("sum"));
expect(sum).toBeDefined();
const records = recordsOf(sum);
const guards = records.filter(
(record) =>
record.kind === "ternary" &&
(record.cond as IrRecord | undefined)?.kind === "libCall" &&
(record.cond as IrRecord).fn === "dyn.sameValue",
);
expect(guards).toHaveLength(1);
const then = guards[0]!.then as IrRecord;
expect(then.kind).toBe("call");
expect(String(then.callee)).toMatch(/Repo\.total$/);
expect(module.globals?.some((global) => global.name === "Repo.prototype.total.original")).toBe(
true,
);
});
// A spread argument keeps the dynamic call, which drains the spread through
// the iterator's current `next` (a replacement included).
test("typed calls of an observed method with a spread argument keep the dynamic call", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-observed-guard-"));
dirs.push(dir);
const entry = join(dir, "main.ts");
const outDir = join(dir, ".scriptc");
const outPath = join(outDir, "main.ir.json");
await writeFile(
join(dir, "untyped.js"),
"export function put(target, key, value) { target[key] = value; }\n",
);
await writeFile(
entry,
[
'import { put } from "./untyped.js";',
"class Bag {",
" sum(...values: number[]): number { let s = 0; for (const n of values) s += n; return s; }",
"}",
"const bag = new Bag();",
"put({}, String(Math.random()), 1);",
"export function spread(b: Bag, values: number[]): number { return b.sum(...values); }",
"console.log(spread(bag, [1, 2]));",
"",
].join("\n"),
);
const result = await compile(entry, { outDir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const module = deserializeModule(await readFile(outPath, "utf8"));
expect(validateModule(module)).toEqual([]);
const spread = module.functions.find((fn) => fn.name.endsWith("spread"));
expect(spread).toBeDefined();
const guards = recordsOf(spread).filter(
(record) =>
record.kind === "ternary" &&
(record.cond as IrRecord | undefined)?.kind === "libCall" &&
(record.cond as IrRecord).fn === "dyn.sameValue",
);
expect(guards).toHaveLength(0);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7144-paired-while-counters.ts"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/7262-fresh-literal-untyped-param/lib.js",
"<repo>/tests/corpus/7262-fresh-literal-untyped-param/main.ts"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/7365-class-methods-untyped-computed-writes/untyped.js",
"<repo>/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7366-typed-global-view-arrays.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7402-unknown-array-methods.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7403-bigint-of-typed-arrays.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7441-untyped-receiver-own-string-method-names.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7480-builtin-constructor-in.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7481-class-runtime-heritage-object-error.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7482-define-property-function-class-values.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7483-constructor-factory-traits.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7484-hoisted-function-captures-later-class.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7485-global-object-own-properties.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7486-prototype-accessor-string-method-names.js"
],
"diags": []
}
@@ -0,0 +1,9 @@
{
"order": [
"<repo>/tests/corpus/7487-namespace-spread-untyped/shapes.js",
"<repo>/tests/corpus/7487-namespace-spread-untyped/extra.js",
"<repo>/tests/corpus/7487-namespace-spread-untyped/all.js",
"<repo>/tests/corpus/7487-namespace-spread-untyped/main.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7488-math-spread-untyped.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7489-math-spread-generator-only.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7536-inspect-custom-errors.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7537-inspect-custom-errors.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7538-inspect-custom-prototypes.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7565-iterable-replaceable-method-results.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7600-navigator-optional-chain.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7602-error-stack-limit-value.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7603-error-stack-limit-own.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7604-error-stack-limit-frozen.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/types-boundary/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/zod3-adoption/main.ts"
],
"diags": []
}

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