feat(compiler): run program preflight with native frontend services

- Compile production program loading across module graphs, packages, and configuration
- Preserve optional defaults and support runtime URL bases and regex split captures
- Isolate host execution services and initialize lowering tables outside module cycles
This commit is contained in:
Chris Tate
2026-09-28 17:31:11 -05:00
committed by GitHub
parent e517a251ae
commit 2a23ddc37d
49 changed files with 1261 additions and 312 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ const results = await Promise.all(jobs); // number[] — compiles
- `child_process.fork(modulePath, args?, options?)` compiles when `modulePath` resolves at build time from `new URL("./worker.ts", import.meta.url)` or `fileURLToPath(new URL("./worker.ts", import.meta.url))`, including a never-reassigned `const` alias and statically resolvable template parts. The worker and its imports are embedded in the executable; the native binary re-executes itself and starts only that worker module. Arguments begin at `process.argv[2]` in the worker, matching Node's positions under the documented process-shape divergence below. The default inherited stdio and an inline options object with `cwd`, `env`, `silent`, `windowsHide`, or `[stdin, stdout, stderr, "ipc"]` compile. `execArgv` is accepted but has no native effect because no Node executable or source loader starts at runtime. Parent `ChildProcess` and child `process` channels support `send(message, callback?)`, `connected`, `disconnect()`, and `on`/`once` for `"message"` and `"disconnect"`; message listeners may return `void` or `Promise<void>`. Messages use Node's default newline-delimited JSON serialization, so their static types must be JSON-stringifiable or `unknown` backed by the checked JSON tree. `send()` returns the native channel's backpressure state, its callback runs later with `null` or an `Error`, and `disconnect()` changes `connected` synchronously before either side's later `"disconnect"` event. Runtime-valued worker paths, custom `execPath`, advanced serialization, transferred sockets/servers, uid/gid, timeout/signal options, and other stdio arrays remain explicit refusals.
- The type checker sees the full standard library; only the supported surface compiles. Reaching declared-but-unlowered surface is SC2020 with the supported alternatives in the hint — e.g. parts of the regex API (`re.exec`), `Symbol`, `globalThis` as a value in static builds, and array/Map/Set methods beyond the lowered sets.
- `Date` values support zero-argument construction, one number/string argument, storage and passing, `getTime`/`valueOf`, `toISOString`, the local and UTC calendar getters, and `getTimezoneOffset`. `Date.parse(dateString)` accepts one string using the same bounded parser as `new Date(dateString).getTime()`. Date-armed unions, the year/month field constructor, setters, identity comparisons, throwing `Date` values, and locale/string formatters remain fenced; the supported string grammar is described below.
- `URL` supports construction from one absolute string, plus build-time-resolvable relative literals and templates against `import.meta.url`, and read-only `protocol`, `origin`, `username`, `password`, `pathname`, `href`, `host`, `hostname`, `port`, `search`, and `hash` getters. `searchParams` remains a live view. Runtime-valued base arguments and setters remain fenced.
- `URL` supports absolute inputs and relative inputs with runtime string or `URL` bases, including optional bases, and read-only `protocol`, `origin`, `username`, `password`, `pathname`, `href`, `host`, `hostname`, `port`, `search`, and `hash` getters. `searchParams` remains a live view. Setters remain fenced. Non-ASCII and percent-escaped hosts are rejected; opaque paths retain their input bytes without the WHATWG C0 encoding pass.
- `fs.openSync` accepts inline bitwise OR expressions of `fs.constants.O_RDONLY`, `O_WRONLY`, `O_RDWR`, `O_CREAT`, `O_EXCL`, `O_NOFOLLOW`, `O_NONBLOCK`, `O_TRUNC`, and `O_APPEND`, with an optional creation mode. Computed numeric flags remain fenced because their bit values differ by target. `fstatSync`, `fchmodSync`, `fsyncSync`, and `linkSync` compile at direct call sites. On Windows, a numeric open with `O_NOFOLLOW` and `fchmodSync` throw `ENOSYS` because the CRT cannot enforce those contracts; programs requiring them should use a POSIX target.
- Numeric `process.exitCode = value` writes in statement position set the implicit exit status; `process.exit()` with no argument uses that status. Reading or resetting `process.exitCode`, and assignments used as values, remain fenced.
- Map keys and Set elements support numbers and strings by value, and records, class instances, arrays, symbols, and server handles by identity. Unions consisting only of these identity types also compile. Collections with `unknown` slots, including JavaScript's unannotated `new Map()` and `new Set()`, use native checked values: supported primitives compare by value and supported object references retain their identity. BigInts, symbols, nested collections, Error objects, and promises whose payload requires an adapter cannot cross into those checked slots; `Promise<unknown>` retains its original identity. Other statically typed key forms, mixed scalar/reference unions, nullable key unions, and key conversions that copy an existing reference remain fenced.
@@ -2,6 +2,6 @@
"schemaVersion": 3,
"nodeVersion": "24.15.0",
"nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119",
"artifactVersion": "32ff384d1109c5f95380",
"artifactVersion": "413a7021676b19ceaf47",
"rowCount": 3662
}
@@ -1178,9 +1178,9 @@
"apiEntries": 62,
"static": {
"supported": 0,
"partial": 5,
"partial": 6,
"refused": 0,
"not-implemented": 57,
"not-implemented": 56,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -82322,9 +82322,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Supports absolute inputs and relative inputs with string or URL bases; setters and some WHATWG parsing behavior remain unsupported.",
"verification": "test-backed"
},
"dynamic": {
"status": "partial",
+1 -1
View File
@@ -8,7 +8,7 @@ import tseslint from "typescript-eslint";
const TS5_ISLANDS = [
"packages/compiler/src/frontend/npm.ts",
"packages/compiler/src/frontend/cjs-lexer.ts",
"packages/compiler/src/frontend/lowering/lower-comptime.ts",
"packages/compiler/src/frontend/comptime-node.ts",
"packages/compiler/src/frontend/ts7/world-check.ts",
// The standalone native parser compares source spans against TS5 as
// a test oracle; no oracle node crosses into the native syntax passes.
@@ -8,8 +8,8 @@
"tiers": {
"static": {
"replace-refusal": 47,
"verify-gap": 2571,
"audit-partial": 594,
"verify-gap": 2570,
"audit-partial": 595,
"classify": 2,
"implement": 81
},
@@ -91106,12 +91106,14 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:url",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:url.URL",
"tests": [
"tests/corpus/url-runtime-base.ts"
]
},
"dynamic": {
"status": "partial",
@@ -1299,9 +1299,9 @@
"apiEntries": 62,
"static": {
"supported": 0,
"partial": 5,
"partial": 6,
"refused": 0,
"not-implemented": 57,
"not-implemented": 56,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -108311,7 +108311,8 @@
"evidence": "derived:descendants",
"tests": [
"tests/corpus/1794-searchparams-url-live.ts",
"tests/corpus/2946-url-port-hash.ts"
"tests/corpus/2946-url-port-hash.ts",
"tests/corpus/url-runtime-base.ts"
]
},
"dynamic": {
@@ -108423,8 +108424,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:url"
"status": "partial",
"evidence": "compiler-feature:url.URL",
"tests": [
"tests/corpus/url-runtime-base.ts"
]
},
"dynamic": {
"status": "partial",
+3
View File
@@ -598,6 +598,9 @@ function publicDetail(tier) {
if (source.startsWith("surface-manifest:")) return "Implemented for the call shapes accepted by the compiler lowering.";
if (source.startsWith("compiler-dedicated:")) return "Implemented by a dedicated static compiler/runtime path.";
if (source.startsWith("compiler-feature:")) {
if (source === "compiler-feature:url.URL") {
return "Supports absolute inputs and relative inputs with string or URL bases; setters and some WHATWG parsing behavior remain unsupported.";
}
if (/^compiler-feature:(?:globals\.Text(?:Encoder|Decoder)|util\.(?:util\.Text(?:Encoder|Decoder)|text(?:Encoder\.encode|Decoder\.decode)))$/.test(source)) {
return "Native codec values support UTF-8 encoding and whole-buffer decoding with recognized literal labels and default options.";
}
@@ -150,6 +150,7 @@
{ "chapter": "stream", "signatures": ["`writable` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2895-child-stdin-write-after-end.ts"] },
{ "chapter": "stream", "signatures": ["Event: `'drain'`"], "status": "partial", "evidence": ["tests/corpus/2894-child-stdin-backpressure.ts"] },
{ "chapter": "webstreams", "signatures": ["`readableStream.values([options])`"], "status": "partial", "evidence": ["tests/corpus/2862-web-readable-async-iteration.ts"] },
{ "chapter": "url", "signatures": ["Class: `URL`"], "status": "partial", "evidence": ["tests/corpus/url-runtime-base.ts"] },
{ "chapter": "url", "signatures": ["`hash` Type: {string}", "`port` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/2946-url-port-hash.ts"] },
{ "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts"] },
{ "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/corpus/2707-type-import-link-static-default-reexport/main.ts", "tests/harness/errors.test.ts"] }
+1 -1
View File
@@ -1446,7 +1446,7 @@ interface URL {
toString(): string;
}
declare var URL: {
new (input: string, base?: string | URL): URL;
new (input: string | { toString: () => string }, base?: string | URL): URL;
};
/* URLSearchParams — the WHATWG application/x-www-form-urlencoded list.
+2
View File
@@ -5101,6 +5101,8 @@ function emitPathUrlLibCall(state: LibCallState): Temp {
}
case "url.new":
return finish(`scr_url_new(${arg(0)})`);
case "url.newBase":
return finish(`scr_url_new_base(${arg(0)}, ${arg(1)})`);
case "url.protocol":
return finish(`scr_url_protocol(${arg(0)})`);
case "url.origin":
+2 -2
View File
@@ -212,8 +212,8 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
f.throws = true;
break;
case "regexIntrinsic":
// replaceAll and matchAll without /g throw Node's TypeError;
// split throws on a pattern with capture groups — all catchable.
// Keep the conservative exception check for these operations;
// replaceAll and matchAll without /g throw Node's TypeError.
if (rec.method === "replaceAll" || rec.method === "split" || rec.method === "matchAll" || rec.method === "matchAllInto") {
f.throws = true;
}
@@ -361,6 +361,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
// constructions +1; url.new, the fileURLToPath pair, the win32
// pathToFileURL flavor, and sp.fromPairs throw catchably (may-throw).
"url.new": "scr_url_new",
"url.newBase": "scr_url_new_base",
"url.protocol": "scr_url_protocol",
"url.origin": "scr_url_origin",
"url.username": "scr_url_username",
@@ -0,0 +1,36 @@
import { expect, test } from "vitest";
import { evaluateNodeComptime } from "./comptime-node.js";
test("the Node evaluator erases callback types and preserves structured values", () => {
const result = evaluateNodeComptime(`(): { total: number; names: string[] } => {
const values: number[] = [2, 3, 4];
return { total: values.reduce((sum, value) => sum + value, 0), names: ["雪", "hello"] };
}`, 1000);
expect(result).toEqual({ total: 9, names: ["雪", "hello"] });
});
test("every callback gets fresh globals without Node host bindings", () => {
expect(evaluateNodeComptime('() => { globalThis.marker = 42; return globalThis.marker; }', 1000)).toBe(42);
expect(evaluateNodeComptime('() => typeof globalThis.marker', 1000)).toBe("undefined");
expect(evaluateNodeComptime('() => [typeof process, typeof require, typeof console, typeof setTimeout]', 1000))
.toEqual(["undefined", "undefined", "undefined", "undefined"]);
expect(evaluateNodeComptime('() => JSON.parse("{\\"value\\":42}").value', 1000)).toBe(42);
});
test("throws and timeout codes reach the lowerer's diagnostic boundary", () => {
expect(() => evaluateNodeComptime('() => { throw new Error("callback failed"); }', 1000)).toThrow("callback failed");
let error: unknown;
try { evaluateNodeComptime('() => { while (true) {} }', 20); }
catch (caught) { error = caught; }
expect(error).toMatchObject({ code: "ERR_SCRIPT_EXECUTION_TIMEOUT" });
expect(evaluateNodeComptime('() => 21 * 2', 1000)).toBe(42);
});
test("non-bakeable results retain their identity for shared validation", () => {
expect(evaluateNodeComptime('() => undefined', 1000)).toBeUndefined();
expect(evaluateNodeComptime('() => NaN', 1000)).toBeNaN();
expect(evaluateNodeComptime('() => Infinity', 1000)).toBe(Infinity);
expect(typeof evaluateNodeComptime('() => () => 1', 1000)).toBe("function");
const cyclic = evaluateNodeComptime('() => { const value = {}; value.self = value; return value; }', 1000) as { self: unknown };
expect(cyclic.self).toBe(cyclic);
});
@@ -0,0 +1,14 @@
import vm from "node:vm";
import ts5 from "typescript5";
/** The Node client's compile-time evaluator. Only source text and the
* resulting value cross this boundary; TS5 ASTs never enter the frontend.
* Every invocation gets fresh globals and a bounded execution window. */
export function evaluateNodeComptime(source: string, timeoutMs: number): unknown {
const js = ts5.transpileModule(`(${source})()`, {
compilerOptions: { target: ts5.ScriptTarget.ESNext },
}).outputText;
// Shadow V8's otherwise silent per-context console. The capture walk
// rejects direct console references before evaluation.
return vm.runInNewContext(js, { console: undefined }, { timeout: timeoutMs });
}
@@ -0,0 +1,25 @@
/** The fixed-width Buffer numeric methods by source name → the readNum/
* writeNum kind token. Node declares BOTH capitalizations ("UInt" is the
* original, "Uint" the aliased spelling) — the tables carry both. */
export const BUF_NUM_METHODS: Record<string, string | undefined> = (() => {
const out: Record<string, string> = {};
for (const rw of ["read", "write"]) {
for (const u of ["UInt", "Uint"]) {
out[`${rw}${u}8`] = "u8";
out[`${rw}${u}16BE`] = "u16be";
out[`${rw}${u}16LE`] = "u16le";
out[`${rw}${u}32BE`] = "u32be";
out[`${rw}${u}32LE`] = "u32le";
}
out[`${rw}Int8`] = "i8";
out[`${rw}Int16BE`] = "i16be";
out[`${rw}Int16LE`] = "i16le";
out[`${rw}Int32BE`] = "i32be";
out[`${rw}Int32LE`] = "i32le";
out[`${rw}FloatBE`] = "f32be";
out[`${rw}FloatLE`] = "f32le";
out[`${rw}DoubleBE`] = "f64be";
out[`${rw}DoubleLE`] = "f64le";
}
return out;
})();
@@ -1,3 +1,4 @@
import { BUF_NUM_METHODS } from "./buffer-numeric-methods.js";
import * as ts from "../../ts7/adapter.js";
import { BIGINT_T, BOOL, BYTES_U8, DYN, F64, IrBytesElem, IrBytesIntrinsicMethod, IrExpr, IrType, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, bytesOf, typeEquals } from "../../../ir/ir.js";
import { locOf } from "../../program.js";
@@ -684,32 +685,6 @@ export function bufEncoding(lowerer: Lowerer, what: string, encNode: ts.Expressi
return v;
}
/** The fixed-width Buffer numeric methods by source name → the readNum/
* writeNum kind token. Node declares BOTH capitalizations ("UInt" is the
* original, "Uint" the aliased spelling) — the tables carry both. */
const BUF_NUM_METHODS: Record<string, string | undefined> = (() => {
const out: Record<string, string> = {};
for (const rw of ["read", "write"]) {
for (const u of ["UInt", "Uint"]) {
out[`${rw}${u}8`] = "u8";
out[`${rw}${u}16BE`] = "u16be";
out[`${rw}${u}16LE`] = "u16le";
out[`${rw}${u}32BE`] = "u32be";
out[`${rw}${u}32LE`] = "u32le";
}
out[`${rw}Int8`] = "i8";
out[`${rw}Int16BE`] = "i16be";
out[`${rw}Int16LE`] = "i16le";
out[`${rw}Int32BE`] = "i32be";
out[`${rw}Int32LE`] = "i32le";
out[`${rw}FloatBE`] = "f32be";
out[`${rw}FloatLE`] = "f32le";
out[`${rw}DoubleBE`] = "f64be";
out[`${rw}DoubleLE`] = "f64le";
}
return out;
})();
const BUF_BIGINT_METHODS: Record<string, { sign: boolean; le: boolean } | undefined> = {
readBigInt64BE: { sign: true, le: false },
readBigInt64LE: { sign: true, le: true },
@@ -517,12 +517,23 @@ export function lowerRegexMethodCall(lowerer: Lowerer, call: ts.CallExpression,
const args = name === "split"
? [lowerer.lowerExpr(arg0), lowerSplitLimitArg(lowerer, call.arguments[1], loc)]
: call.arguments.map((a) => lowerer.lowerExpr(a));
if (name !== "split" && args[1]?.type.kind !== "string") {
lowerer.unsupported(
"SC1120",
call.arguments[1] ?? call,
"function replacement values (replacements must be string templates)",
);
if (name !== "split") {
const replacement = args[1];
const templateType = (type: IrType): boolean => type.kind === "union"
? lowerer.unions.get(type.unionId)!.arms.every(templateType)
: type.kind === "string" || type.kind === "f64" || type.kind === "bool" ||
type.kind === "bigint" || isUnitType(type);
if (!replacement || !templateType(replacement.type)) {
lowerer.unsupported(
"SC1120",
call.arguments[1] ?? call,
"function replacement values (replacements must be string templates)",
);
}
// Array iteration can carry an explicit undefined even when the
// checker spells string. RegExp replacement applies ToString once,
// including when the pattern has no match; it does not call that value.
args[1] = coerceStringSearchValue(lowerer, replacement, call.arguments[1]!, loc);
}
return {
kind: "regexIntrinsic",
@@ -1,3 +1,4 @@
import { staticTextDecoderEncoding } from "./text-decoder-encoding.js";
import { InternalCompilerError } from "../../errors.js";
/* Builtin-surface lowering: node builtin-module calls (fs, path, os, url,
* crypto, child_process spawn/spawnSync and child/stats/spawn-result
@@ -797,6 +798,7 @@ function lowerBuiltinOptionalDefault(
call.getSourceFile().fileName,
specifier,
lowerer.targetPlatform,
lowerer.frontendServices,
);
if (result === null) {
lowerer.noLowering(
@@ -8649,84 +8651,6 @@ type TextCodecCtor = {
ctor: ts.NewExpression;
};
/* WHATWG label registry → native decoder id. The first 27 ids stay in the
* same order as scripts/gen-text-decoder-tables.mjs; the remaining ids match
* scr_bytes.c's compact enum. ISO-8859-8-I shares ISO-8859-8's byte table,
* and GBK/gb18030 share one decoder (their decoders are identical). */
const TEXT_DECODER_SINGLE_BYTE_NAMES = [
"ibm866", "iso-8859-2", "iso-8859-3", "iso-8859-4", "iso-8859-5",
"iso-8859-6", "iso-8859-7", "iso-8859-8", "iso-8859-10", "iso-8859-13",
"iso-8859-14", "iso-8859-15", "iso-8859-16", "koi8-r", "koi8-u",
"macintosh", "windows-874", "windows-1250", "windows-1251", "windows-1252",
"windows-1253", "windows-1254", "windows-1255", "windows-1256", "windows-1257",
"windows-1258", "x-mac-cyrillic",
] as const;
const TEXT_DECODER_UTF8_LABELS = new Set([
"unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8", "utf-8", "utf8", "x-unicode20utf8",
]);
const TEXT_DECODER_LEGACY_LABELS: Record<string, number | undefined> = (() => {
const labels: Record<string, number> = Object.create(null) as Record<string, number>;
const add = (encoding: number, names: readonly string[]): void => {
for (const name of names) labels[name] = encoding;
};
const single = (name: typeof TEXT_DECODER_SINGLE_BYTE_NAMES[number], aliases: readonly string[]): void => {
add(TEXT_DECODER_SINGLE_BYTE_NAMES.indexOf(name), aliases);
};
single("ibm866", ["866", "cp866", "csibm866", "ibm866"]);
single("iso-8859-2", ["csisolatin2", "iso-8859-2", "iso-ir-101", "iso8859-2", "iso88592", "iso_8859-2", "iso_8859-2:1987", "l2", "latin2"]);
single("iso-8859-3", ["csisolatin3", "iso-8859-3", "iso-ir-109", "iso8859-3", "iso88593", "iso_8859-3", "iso_8859-3:1988", "l3", "latin3"]);
single("iso-8859-4", ["csisolatin4", "iso-8859-4", "iso-ir-110", "iso8859-4", "iso88594", "iso_8859-4", "iso_8859-4:1988", "l4", "latin4"]);
single("iso-8859-5", ["csisolatincyrillic", "cyrillic", "iso-8859-5", "iso-ir-144", "iso8859-5", "iso88595", "iso_8859-5", "iso_8859-5:1988"]);
single("iso-8859-6", ["arabic", "asmo-708", "csiso88596e", "csiso88596i", "csisolatinarabic", "ecma-114", "iso-8859-6", "iso-8859-6-e", "iso-8859-6-i", "iso-ir-127", "iso8859-6", "iso88596", "iso_8859-6", "iso_8859-6:1987"]);
single("iso-8859-7", ["csisolatingreek", "ecma-118", "elot_928", "greek", "greek8", "iso-8859-7", "iso-ir-126", "iso8859-7", "iso88597", "iso_8859-7", "iso_8859-7:1987", "sun_eu_greek"]);
single("iso-8859-8", ["csiso88598e", "csiso88598i", "csisolatinhebrew", "hebrew", "iso-8859-8", "iso-8859-8-e", "iso-8859-8-i", "iso-ir-138", "iso8859-8", "iso88598", "iso_8859-8", "iso_8859-8:1988", "logical", "visual"]);
single("iso-8859-10", ["csisolatin6", "iso-8859-10", "iso-ir-157", "iso8859-10", "iso885910", "l6", "latin6"]);
single("iso-8859-13", ["iso-8859-13", "iso8859-13", "iso885913"]);
single("iso-8859-14", ["iso-8859-14", "iso8859-14", "iso885914"]);
single("iso-8859-15", ["csisolatin9", "iso-8859-15", "iso8859-15", "iso885915", "iso_8859-15", "l9"]);
single("iso-8859-16", ["iso-8859-16"]);
single("koi8-r", ["cskoi8r", "koi", "koi8", "koi8-r", "koi8_r"]);
single("koi8-u", ["koi8-ru", "koi8-u"]);
single("macintosh", ["csmacintosh", "mac", "macintosh", "x-mac-roman"]);
single("windows-874", ["dos-874", "iso-8859-11", "iso8859-11", "iso885911", "tis-620", "windows-874"]);
single("windows-1250", ["cp1250", "windows-1250", "x-cp1250"]);
single("windows-1251", ["cp1251", "windows-1251", "x-cp1251"]);
single("windows-1252", ["ansi_x3.4-1968", "ascii", "cp1252", "cp819", "csisolatin1", "ibm819", "iso-8859-1", "iso-ir-100", "iso8859-1", "iso88591", "iso_8859-1", "iso_8859-1:1987", "l1", "latin1", "us-ascii", "windows-1252", "x-cp1252"]);
single("windows-1253", ["cp1253", "windows-1253", "x-cp1253"]);
single("windows-1254", ["cp1254", "csisolatin5", "iso-8859-9", "iso-ir-148", "iso8859-9", "iso88599", "iso_8859-9", "iso_8859-9:1989", "l5", "latin5", "windows-1254", "x-cp1254"]);
single("windows-1255", ["cp1255", "windows-1255", "x-cp1255"]);
single("windows-1256", ["cp1256", "windows-1256", "x-cp1256"]);
single("windows-1257", ["cp1257", "windows-1257", "x-cp1257"]);
single("windows-1258", ["cp1258", "windows-1258", "x-cp1258"]);
single("x-mac-cyrillic", ["x-mac-cyrillic", "x-mac-ukrainian"]);
add(27, ["x-user-defined"]);
add(28, ["csunicode", "iso-10646-ucs-2", "ucs-2", "unicode", "unicodefeff", "utf-16", "utf-16le"]);
add(29, ["unicodefffe", "utf-16be"]);
add(30, ["chinese", "csgb2312", "csiso58gb231280", "gb18030", "gb2312", "gb_2312", "gb_2312-80", "gbk", "iso-ir-58", "x-gbk"]);
add(31, ["big5", "big5-hkscs", "cn-big5", "csbig5", "x-x-big5"]);
add(32, ["cseucpkdfmtjapanese", "euc-jp", "x-euc-jp"]);
add(33, ["csiso2022jp", "iso-2022-jp"]);
add(34, ["csshiftjis", "ms932", "ms_kanji", "shift-jis", "shift_jis", "sjis", "windows-31j", "x-sjis"]);
add(35, ["cseuckr", "csksc56011987", "euc-kr", "iso-ir-149", "korean", "ks_c_5601-1987", "ks_c_5601-1989", "ksc5601", "ksc_5601", "windows-949"]);
return labels;
})();
type StaticTextDecoderEncoding = { kind: "utf8" } | { kind: "legacy"; id: number };
/** TextDecoder's get-an-encoding normalization: trim ASCII whitespace and
* fold ASCII case only (Unicode case folding must not manufacture a label). */
function staticTextDecoderEncoding(label: string): StaticTextDecoderEncoding | null {
const normalized = label
.replace(/^[\u0009\u000a\u000c\u000d\u0020]+|[\u0009\u000a\u000c\u000d\u0020]+$/g, "")
.replace(/[A-Z]/g, (char) => char.toLowerCase());
if (TEXT_DECODER_UTF8_LABELS.has(normalized)) return { kind: "utf8" };
const id = own(TEXT_DECODER_LEGACY_LABELS, normalized);
return id === undefined ? null : { kind: "legacy", id };
}
/** Box a codec's immutable encoding in an owned record. Unlike erased
* aliases, this value can live in fields, arguments, and closure captures. */
export function lowerTextCodecNew(lowerer: Lowerer, ctor: ts.NewExpression, cls: TextCodecCtor["cls"]): IrExpr {
@@ -302,21 +302,7 @@ export interface GenericInstance {
// default when the argument was omitted or undefined, then the
// pattern destructures the picked value (declareParams).
const raw = lowerer.irTypeOf(param.name);
// A DYNAMIC-TIER pattern source (`function f({} = a)` with
// `a: any` — jsval for island values, dyn for the checked-dynamic
// dyn): the slot holds its tier's undefined DIRECTLY, so the ABI
// is the slot itself — no synthesized union; the prologue tests
// undefined at runtime (declareParams).
if (raw.kind === "dyn" || raw.kind === "jsval") {
return { type: raw, mode: "omittable", bodyType: raw };
}
const bodyType = lowerer.stripUndefinedArm(raw);
lowerer.checkDefaultParamBodyType(param, bodyType);
const abi = bodyType.kind === "union" ? lowerer.withUndefinedArmOf(bodyType) : lowerer.withUndefinedArm(bodyType);
if (!abi) {
lowerer.badType(param.name, lowerer.typeOf(param.name)); // defensive: unknown union id
}
return { type: abi, mode: "omittable", bodyType };
return defaultParameterShape(lowerer, param, raw);
}
return { type: lowerer.runtimeOptionalBindingType(param.name, lowerer.irTypeOf(param.name)), mode: "required" };
}
@@ -349,39 +335,7 @@ export interface GenericInstance {
}
if (param.initializer) {
const raw = lowerer.irTypeOf(param.name);
// A DYNAMIC-TIER defaulted param (`function f(x = a)` with `a: any`
// — tsc types x any; jsval for island values, dyn for the checked-
// dynamic dyn): the slot holds its tier's undefined directly, so
// the ABI is the slot itself and the prologue's default test is the
// runtime undefined test (declareParams).
if (raw.kind === "dyn" || raw.kind === "jsval") {
return { type: raw, mode: "omittable", bodyType: raw };
}
// A default that may ITSELF be undefined (`x = process.env.FOO`):
// tsc keeps undefined in the body's type, so there is nothing to
// narrow — the ABI union IS the body type and the prologue passes a
// present argument through unchanged (declareParams's pass-through
// branch). The generic strip-and-narrow below would demand a
// `string`-typed default and fence on the union re-tag.
if (lowerer.bareUndefinedArmedUnion(raw)) {
const initT = lowerer.mapTypeOf(lowerer.typeOf(param.initializer));
if (initT && (initT.kind === "undefinedT" || lowerer.bareUndefinedArmedUnion(initT))) {
return { type: raw, mode: "omittable", bodyType: raw };
}
}
const bodyType = lowerer.stripUndefinedArm(raw);
lowerer.checkDefaultParamBodyType(param, bodyType);
// A UNION body type (`tlds: string | string[] = "localhost"`) arms
// the ABI with undefined ON TOP of the body's arms; the prologue
// re-tags a present argument back into the body union (undefined
// sorts last among arm typeKeys in practice, so the mapping is
// usually the identity prefix — the interned retag helper handles
// any order).
const abi = bodyType.kind === "union" ? lowerer.withUndefinedArmOf(bodyType) : lowerer.withUndefinedArm(bodyType);
if (!abi) {
lowerer.badType(param.name, lowerer.typeOf(param.name)); // defensive: unknown union id
}
return { type: abi, mode: "omittable", bodyType };
return defaultParameterShape(lowerer, param, raw);
}
const type = lowerer.runtimeOptionalBindingType(param.name, lowerer.irTypeOf(param.name));
if (type.kind === "void") {
@@ -429,14 +383,7 @@ export interface GenericInstance {
if (mapped.kind !== "array") lowerer.badType(blameOf?.(declParam, i) ?? declParam.name, tsType);
return { type: mapped, mode: "rest" };
}
if (declParam.initializer) {
if (mapped.kind === "dyn" || mapped.kind === "jsval") {
return { type: mapped, mode: "omittable", bodyType: mapped };
}
const bodyType = lowerer.stripUndefinedArm(mapped);
lowerer.checkDefaultParamBodyType(declParam, bodyType);
return { type: lowerer.withUndefinedArm(bodyType), mode: "omittable", bodyType };
}
if (declParam.initializer) return defaultParameterShape(lowerer, declParam, mapped);
if (declParam.questionToken && !lowerer.bareUndefinedArmedUnion(mapped)) {
lowerer.unsupported("SC1090", declParam, `optional parameters of type '${lowerer.fmt(mapped)}'`);
}
@@ -444,6 +391,29 @@ export interface GenericInstance {
});
}
/** A default expression may itself yield undefined. Keep that arm in the
* body and parameter-property storage, including for literal `undefined`
* and void expressions: the general type mapper represents those as VOID,
* so checking only a mapped undefinedT silently erased a valid value.
* Shared by declared and instantiated signatures, including destructuring. */
function defaultParameterShape(lowerer: Lowerer, param: ts.ParameterDeclaration, raw: IrType): ParamShape {
if (raw.kind === "dyn" || raw.kind === "jsval") {
return { type: raw, mode: "omittable", bodyType: raw };
}
if (lowerer.bareUndefinedArmedUnion(raw) && param.initializer) {
const initializer = lowerer.typeOf(param.initializer);
const parts = initializer.isUnionType() ? ts.constituentTypes(initializer) : [initializer];
if (parts.some((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0)) {
return { type: raw, mode: "omittable", bodyType: raw };
}
}
const bodyType = lowerer.stripUndefinedArm(raw);
lowerer.checkDefaultParamBodyType(param, bodyType);
const abi = bodyType.kind === "union" ? lowerer.withUndefinedArmOf(bodyType) : lowerer.withUndefinedArm(bodyType);
if (!abi) lowerer.badType(param.name, lowerer.typeOf(param.name));
return { type: abi, mode: "omittable", bodyType };
}
/** The fences on a defaulted parameter's body type: it becomes the value
* arm of the synthesized `T | undefined` ABI union, so it must be a valid
* single arm. Functions, Maps and Sets are valid: the ABI union's test is
@@ -6,7 +6,7 @@ import { InternalCompilerError } from "../../errors.js";
* hierarchy registration. */
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { BOOL, DATE_T, DYN, F64, bytesOf, IrClassDef, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, UNDEFINED_T, URL_T, VOID, arrayOf, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, typeEquals } from "../../ir/ir.js";
import { BOOL, DATE_T, DYN, F64, bytesOf, IrClassDef, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, typeEquals } from "../../ir/ir.js";
import { MAX_GENERIC_INSTANCES, appendImplicitUndefinedReturn, bodyReadsArguments, generatorMeta, genericCallInstance, implicitAnyParamSymbolsOf, implicitCallInstance, implicitMonoFile, omittedArgFor, type GenericFnInfo, type ParamShape } from "./lower-calls.js";
import { isGenericCallableMemberType, jsOpenObjectType, typeKey } from "../type-mapper.js";
import { cjsClassExprWholeExportOf, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, isNodeTypesPath, locOf } from "../program.js";
@@ -25,7 +25,7 @@ import { lowerHttpAgentNew, lowerHttpServerNew } from "./lower-server.js";
import { ambientNsRootOf, ambientUndefReadType, ambientUndefVarRootOf, ambientUndefinedFnSymbolOf, fenceEarlyAliasUse, fenceEarlyNsMemberRef, nsMemberIdentOf, nsUndefRead } from "./lower-namespaces.js";
import { mixinResultBindingClassOf, type MixinInstanceInfo } from "./lower-mixins.js";
import { rejectStaticThis } from "./static-this.js";
import { staticForkString } from "../fork-target.js";
import { lowerUrlNew } from "./lower-url.js";
import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js";
export interface ClassInfo {
@@ -5230,9 +5230,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
if (streamInfo) return lowerStreamNew(lowerer, expr, streamInfo);
// `new URL(input)`: the WHATWG URL class (stdlib/@types provenance —
// a user's own `class URL` resolves through classBySymbol below).
// One string argument; invalid input throws a catchable TypeError
// ("Invalid URL"), like Node. The lib's base-argument form
// typechecks and is fenced here.
// Invalid input or base throws a catchable TypeError ("Invalid URL").
// `new RegExp(pattern, flags?)`: runtime construction over the same
// libregexp engine the literals ride. The pattern compiles EAGERLY,
// so bad input throws Node's catchable SyntaxError at construction.
@@ -5260,35 +5258,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
return { kind: "libCall", fn: "regex.new", args: [pattern, flags], type: { kind: "regex" }, loc };
}
if (symbol && symbol.name === "URL" && lowerer.isStdlibSymbol(symbol)) {
const args = expr.arguments ?? [];
if (args.length === 2) {
const folded = staticForkString(lowerer.program, expr);
if (folded === null) {
lowerer.noLowering(
"new URL with a runtime-valued base",
expr,
"a relative literal or statically resolvable template against import.meta.url compiles",
symbol,
);
}
return {
kind: "libCall",
fn: "url.new",
args: [{ kind: "strLit", value: folded, type: STRING, loc }],
type: URL_T,
loc,
};
}
if (args.length !== 1) {
lowerer.noLowering(
`new URL with ${args.length} argument${args.length === 1 ? "" : "s"}`,
expr,
"one absolute-URL string, or a static relative input against import.meta.url, is supported",
symbol,
);
}
const input = lowerer.lowerExprExpecting(args[0]!, STRING);
return { kind: "libCall", fn: "url.new", args: [input], type: URL_T, loc };
return lowerUrlNew(lowerer, expr);
}
// `new URLSearchParams(init?)`: the WHATWG list (stdlib provenance —
// see lowerSearchParamsNew for the lowered init shapes).
@@ -1,9 +1,7 @@
import { InternalCompilerError } from "../../errors.js";
/* comptime(fn) lowering: run the closed callback under node:vm at compile
/* comptime(fn) lowering: run the closed callback through the host at compile
* time (with a timeout), then bake the produced VALUE into the IR as
* literals — records/arrays/unions included, subject to comptimeBakeable. */
import vm from "node:vm";
import ts5 from "typescript5";
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { IrExpr, IrType } from "../../ir/ir.js";
@@ -81,24 +79,13 @@ function describeComptimeValue(v: unknown): string {
`(only number, string, boolean, arrays, and records lower to literals)`,
);
}
// 5. Evaluate. Types are stripped first (the extracted text is
// TypeScript; vm runs JavaScript), then the IIFE runs in a FRESH vm
// context: JS intrinsics (JSON, Object, ...) exist, the compiler's own
// globals and the shipped declarations' Node-isms (console, process,
// fs, setTimeout) do not — so a comptime island cannot leak side effects
// into the build, and the vm timeout bounds runaway loops.
// The TRANSPILE ISLAND: typescript@7.0.2 ships no client-side
// transpiler, so this one call keeps 5.9.3 (adapter.ts's two-world
// rules — only TEXT crosses this boundary, never AST/checker objects).
const js = ts5.transpileModule(`(${cb.getText()})()`, {
compilerOptions: { target: ts5.ScriptTarget.ESNext },
}).outputText;
// 5. The host executes the extracted callback under a finite budget.
// Shared lowering stays independent of the host's execution engine;
// capture checks and result validation apply to every evaluator.
let result: unknown;
try {
// `console: undefined` shadows V8's built-in per-context console (a
// silent inspector hook) so no code path can log into the void — the
// capture walk already rejects direct uses with a better message.
result = vm.runInNewContext(js, { console: undefined }, { timeout: COMPTIME_TIMEOUT_MS });
if (lowerer.frontendServices === undefined) throw new Error("compile-time evaluation requires frontend services");
result = lowerer.frontendServices.evaluateComptime(cb.getText(), COMPTIME_TIMEOUT_MS);
} catch (e) {
// Errors born inside the vm context (and the timeout error itself) are
// CROSS-REALM objects — `instanceof Error` is false — so the code and
@@ -36,10 +36,10 @@ import { newFnCtx, own } from "./lowerer.js";
import { appendImplicitUndefinedReturn } from "./lower-calls.js";
import { bufEncoding, knownBufEncoding } from "./containers/bytes.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { BOOL, DYN, F64, IrExpr, IrFunction, IrLibFn, IrStmt, IrType, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, VOID, arrayOf, bytesOf, canBoxFuncIntoDyn, funcOf, typeEquals, typeKey } from "../../ir/ir.js";
import { BOOL, DYN, F64, IrExpr, IrFunction, IrLibFn, IrStmt, IrType, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, VOID, arrayOf, BYTES_U8, canBoxFuncIntoDyn, funcOf, typeEquals, typeKey } from "../../ir/ir.js";
import { boolLit, numLit, strLit } from "../../ir/build.js";
const BYTES = bytesOf("u8");
const BYTES = BYTES_U8;
/** The stream sides of a receiver class: the nearest stream-class
* ancestor's, or null off the stream hierarchy. */
@@ -0,0 +1,75 @@
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { BOOL, STRING, UNDEFINED_T, URL_T, isUnitType, type IrExpr, type IrStmt } from "../../ir/ir.js";
import { strLit, varRef } from "../../ir/build.js";
import { locOf } from "../program.js";
import { staticForkString } from "../fork-target.js";
import { lowerStaticallyUndefinedArgument } from "./optional-arguments.js";
/** Both argument expressions run before the constructor converts input,
* then base. Keep that ordering even when a conversion invokes user code. */
export function lowerUrlNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
const args = expr.arguments ?? [];
const loc = locOf(expr);
if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) {
return lowerer.noLowering("URL construction with spread, missing, or extra arguments", expr);
}
if (args.length === 2) {
const folded = staticForkString(lowerer.program, expr);
if (folded !== null) return { kind: "libCall", fn: "url.new", args: [strLit(folded, loc)], type: URL_T, loc };
}
const stmts: IrStmt[] = [];
const values = args.map((node): IrExpr => {
const absent = lowerStaticallyUndefinedArgument(lowerer, node);
if (absent !== null) {
stmts.push({ kind: "exprStmt", expr: absent, loc: locOf(node) });
return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(node) };
}
const value = lowerer.lowerExpr(node);
if (isUnitType(value.type)) {
stmts.push({ kind: "exprStmt", expr: value, loc: value.loc });
return { kind: "unitLit", unit: value.type.kind === "nullT" ? "null" : "undefined", type: value.type, loc: value.loc };
}
const local = lowerer.declareHiddenLocal("%urlArgument", value.type);
stmts.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc });
return varRef(local.id, value.type, value.loc);
});
const stringify = (value: IrExpr, node: ts.Expression): IrExpr => {
if (value.type.kind === "url") return { kind: "libCall", fn: "url.href", args: [value], type: STRING, loc };
if (isUnitType(value.type)) return strLit(value.type.kind === "nullT" ? "null" : "undefined", loc);
if (value.type.kind === "union") {
const unionId = value.type.unionId;
const arms = lowerer.unions.get(unionId)!.arms;
let result: IrExpr = strLit("", loc);
for (let tag = arms.length - 1; tag >= 0; tag--) {
const arm: IrExpr = { kind: "unionNarrow", unionId, tag, value, type: arms[tag]!, loc };
const text = stringify(arm, node);
result = tag === arms.length - 1 ? text : {
kind: "ternary", cond: { kind: "unionIsTag", unionId, tag, value, negated: false, type: BOOL, loc },
then: text, else_: result, type: STRING, loc,
};
}
return result;
}
if (value.type.kind === "dyn" || value.type.kind === "record") {
const boxed = lowerer.coerceInto(node, value, { kind: "dyn" });
return { kind: "libCall", fn: "dyn.toStringCoerce", args: [boxed], type: STRING, loc };
}
return lowerer.ensureString(value, node);
};
const input = lowerer.declareHiddenLocal("%urlInput", STRING);
stmts.push({ kind: "varDecl", localId: input.id, init: stringify(values[0]!, args[0]!), loc });
const inputRef = varRef(input.id, STRING, loc);
const absolute: IrExpr = { kind: "libCall", fn: "url.new", args: [inputRef], type: URL_T, loc };
const base = values[1];
let result: IrExpr = absolute;
if (base && base.type.kind !== "undefinedT") {
const resolved: IrExpr = { kind: "libCall", fn: "url.newBase", args: [inputRef, stringify(base, args[1]!)], type: URL_T, loc };
const tag = base.type.kind === "union" ? lowerer.armTag(base.type.unionId, UNDEFINED_T) : -1;
const absent: IrExpr | null = tag >= 0 && base.type.kind === "union"
? { kind: "unionIsTag", unionId: base.type.unionId, tag, value: base, negated: false, type: BOOL, loc }
: base.type.kind === "dyn" ? { kind: "dynTest", test: "undefined", value: base, type: BOOL, loc } : null;
result = absent ? { kind: "ternary", cond: absent, then: absolute, else_: resolved, type: URL_T, loc } : resolved;
}
return { kind: "seqExpr", stmts, result, type: URL_T, loc };
}
@@ -1,5 +1,6 @@
import type { FrontendServices } from "../services.js";
import { InternalCompilerError } from "../../errors.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js";
import { ClassDynamicDispatch } from "./class-dynamic-dispatch.js";
import { finalizeClassMethodValues } from "./class-method-values.js";
/* AST + checker → IR.
@@ -8869,6 +8870,12 @@ export class Lowerer {
const decls = rawDecls.filter((p) => !isThisParameter(p));
const params: IrParam[] = [];
const prologue: IrStmt[] = [];
const defaultValue = (node: ts.Expression, expected: IrType): IrExpr => {
const undefinedValue = lowerStaticallyUndefinedArgument(this, node);
if (undefinedValue === null) return this.lowerExprExpecting(node, expected);
const absent: IrExpr = { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(node) };
return defaultAfterUndefined(undefinedValue, this.coerceInto(node, absent, expected));
};
decls.forEach((decl, i) => {
const shape = shapes[i]!;
if (ts.isArrayBindingPattern(decl.name) || ts.isObjectBindingPattern(decl.name)) {
@@ -8895,7 +8902,7 @@ export class Lowerer {
// dynamic dyn): the slot holds its tier's undefined directly,
// so the default test is the runtime undefined test — then
// the pattern destructures the picked value.
const dflt = this.lowerExprExpecting(decl.initializer, abi);
const dflt = defaultValue(decl.initializer, abi);
const src = this.declareHiddenLocal("%psrc", abi);
const inRef = (): IrExpr => ({ kind: "varRef", localId: slot.id, type: abi, loc });
const isUndef: IrExpr =
@@ -8944,7 +8951,7 @@ export class Lowerer {
}
}
if (!present) this.unsupported("SC1090", decl, "this parameter form"); // defensive: abi = bodyType + undefined by construction
const dflt = this.lowerExprExpecting(decl.initializer, shape.bodyType);
const dflt = defaultValue(decl.initializer, shape.bodyType);
const src = this.declareHiddenLocal("%psrc", shape.bodyType);
prologue.push({
kind: "varDecl",
@@ -8975,7 +8982,7 @@ export class Lowerer {
abi.kind === "jsval"
? { kind: "jsOp", op: "eq", args: [inRef(), { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc }], type: BOOL, loc }
: { kind: "dynTest", test: "undefined", value: inRef(), type: BOOL, loc };
const dflt = this.lowerExprExpecting(decl.initializer, abi);
const dflt = defaultValue(decl.initializer, abi);
const body = this.declareLocal(decl.name, name, abi, true);
prologue.push({
kind: "varDecl",
@@ -8997,7 +9004,7 @@ export class Lowerer {
// the body keeps the full `T | undefined` union (tsc's type),
// so a present argument passes through unchanged and an omitted
// one takes the default AS IS — no narrow on either branch.
const dflt = this.lowerExprExpecting(decl.initializer, abi);
const dflt = defaultValue(decl.initializer, abi);
const body = this.declareLocal(decl.name, name, abi, true);
prologue.push({
kind: "varDecl",
@@ -9022,7 +9029,7 @@ export class Lowerer {
// it), and every other arm maps by identity.
const retag = this.unionRetagHelper(abi.unionId, shape.bodyType.unionId, loc);
if (!retag) this.unsupported("SC1090", decl, "this parameter form"); // defensive
const dflt = this.lowerExprExpecting(decl.initializer, shape.bodyType);
const dflt = defaultValue(decl.initializer, shape.bodyType);
const body = this.declareLocal(decl.name, name, shape.bodyType, true);
prologue.push({
kind: "varDecl",
@@ -9044,7 +9051,7 @@ export class Lowerer {
// The default lowers BEFORE the body local binds, so a same-named
// outer binding referenced in it can never resolve to the fresh
// local (tsc separately rejects `x = x`).
const dflt = this.lowerExprExpecting(decl.initializer, shape.bodyType);
const dflt = defaultValue(decl.initializer, shape.bodyType);
const body = this.declareLocal(decl.name, name, shape.bodyType, true);
prologue.push({
kind: "varDecl",
@@ -0,0 +1,39 @@
import { expect, test } from "vitest";
import { staticTextDecoderEncoding } from "./text-decoder-encoding.js";
const encodings = [
"ibm866", "iso-8859-2", "iso-8859-3", "iso-8859-4", "iso-8859-5",
"iso-8859-6", "iso-8859-7", "iso-8859-8", "iso-8859-10", "iso-8859-13",
"iso-8859-14", "iso-8859-15", "iso-8859-16", "koi8-r", "koi8-u",
"macintosh", "windows-874", "windows-1250", "windows-1251", "windows-1252",
"windows-1253", "windows-1254", "windows-1255", "windows-1256", "windows-1257",
"windows-1258", "x-mac-cyrillic", "x-user-defined", "utf-16le", "utf-16be",
"gb18030", "big5", "euc-jp", "iso-2022-jp", "shift_jis", "euc-kr",
];
test("static encoding IDs retain the native decoder table order", () => {
for (const [id, name] of encodings.entries()) {
expect(staticTextDecoderEncoding(name), name).toEqual({ kind: "legacy", id });
expect(staticTextDecoderEncoding(` \t${name.toUpperCase()}\r\n\f`), name).toEqual({ kind: "legacy", id });
}
for (const label of ["utf-8", "unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8", "utf8", "x-unicode20utf8"]) {
expect(staticTextDecoderEncoding(`\t${label.toUpperCase()} `), label).toEqual({ kind: "utf8" });
}
});
test("Node aliases select the same encoding families", () => {
for (const label of ["latin1", "ascii", "cp1252", "csisolatin2", "ISO_8859-6:1987", "koi8", "cp866", "windows-31j", "ms932", "ks_c_5601-1987", "utf-16", "unicodefffe", "big5-hkscs", "cseucpkdfmtjapanese"]) {
const parsed = staticTextDecoderEncoding(label);
expect(parsed?.kind, label).toBe("legacy");
if (parsed?.kind === "legacy") expect(encodings[parsed.id], label).toBe(new TextDecoder(label).encoding);
}
});
test("label normalization does not admit Unicode whitespace or prototype keys", () => {
for (const label of ["constructor", "toString", "__proto__", "hasOwnProperty", "", "utf-32", "utf-7", "replacement", "\u00a0utf-8", "utf-8\u00a0", "\vutf-8", "utf-8\0"]) {
expect(staticTextDecoderEncoding(label), JSON.stringify(label)).toBeNull();
expect(() => new TextDecoder(label), JSON.stringify(label)).toThrow();
}
// This compiler slice deliberately recognizes ASCII aliases only.
for (const label of ["Koi8-r", "ſhift_jis"]) expect(staticTextDecoderEncoding(label)).toBeNull();
});
@@ -0,0 +1,80 @@
/* WHATWG label registry → native decoder id. The first 27 ids stay in the
* same order as scripts/gen-text-decoder-tables.mjs; the remaining ids match
* scr_bytes.c's compact enum. ISO-8859-8-I shares ISO-8859-8's byte table,
* and GBK/gb18030 share one decoder (their decoders are identical). */
const TEXT_DECODER_SINGLE_BYTE_NAMES = [
"ibm866", "iso-8859-2", "iso-8859-3", "iso-8859-4", "iso-8859-5",
"iso-8859-6", "iso-8859-7", "iso-8859-8", "iso-8859-10", "iso-8859-13",
"iso-8859-14", "iso-8859-15", "iso-8859-16", "koi8-r", "koi8-u",
"macintosh", "windows-874", "windows-1250", "windows-1251", "windows-1252",
"windows-1253", "windows-1254", "windows-1255", "windows-1256", "windows-1257",
"windows-1258", "x-mac-cyrillic",
] as const;
const TEXT_DECODER_UTF8_LABELS = new Set([
"unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8", "utf-8", "utf8", "x-unicode20utf8",
]);
const TEXT_DECODER_LEGACY_LABELS: Record<string, number | undefined> = (() => {
const labels: Record<string, number> = Object.create(null) as Record<string, number>;
const add = (encoding: number, names: readonly string[]): void => {
for (const name of names) labels[name] = encoding;
};
const single = (name: typeof TEXT_DECODER_SINGLE_BYTE_NAMES[number], aliases: readonly string[]): void => {
add(TEXT_DECODER_SINGLE_BYTE_NAMES.indexOf(name), aliases);
};
single("ibm866", ["866", "cp866", "csibm866", "ibm866"]);
single("iso-8859-2", ["csisolatin2", "iso-8859-2", "iso-ir-101", "iso8859-2", "iso88592", "iso_8859-2", "iso_8859-2:1987", "l2", "latin2"]);
single("iso-8859-3", ["csisolatin3", "iso-8859-3", "iso-ir-109", "iso8859-3", "iso88593", "iso_8859-3", "iso_8859-3:1988", "l3", "latin3"]);
single("iso-8859-4", ["csisolatin4", "iso-8859-4", "iso-ir-110", "iso8859-4", "iso88594", "iso_8859-4", "iso_8859-4:1988", "l4", "latin4"]);
single("iso-8859-5", ["csisolatincyrillic", "cyrillic", "iso-8859-5", "iso-ir-144", "iso8859-5", "iso88595", "iso_8859-5", "iso_8859-5:1988"]);
single("iso-8859-6", ["arabic", "asmo-708", "csiso88596e", "csiso88596i", "csisolatinarabic", "ecma-114", "iso-8859-6", "iso-8859-6-e", "iso-8859-6-i", "iso-ir-127", "iso8859-6", "iso88596", "iso_8859-6", "iso_8859-6:1987"]);
single("iso-8859-7", ["csisolatingreek", "ecma-118", "elot_928", "greek", "greek8", "iso-8859-7", "iso-ir-126", "iso8859-7", "iso88597", "iso_8859-7", "iso_8859-7:1987", "sun_eu_greek"]);
single("iso-8859-8", ["csiso88598e", "csiso88598i", "csisolatinhebrew", "hebrew", "iso-8859-8", "iso-8859-8-e", "iso-8859-8-i", "iso-ir-138", "iso8859-8", "iso88598", "iso_8859-8", "iso_8859-8:1988", "logical", "visual"]);
single("iso-8859-10", ["csisolatin6", "iso-8859-10", "iso-ir-157", "iso8859-10", "iso885910", "l6", "latin6"]);
single("iso-8859-13", ["iso-8859-13", "iso8859-13", "iso885913"]);
single("iso-8859-14", ["iso-8859-14", "iso8859-14", "iso885914"]);
single("iso-8859-15", ["csisolatin9", "iso-8859-15", "iso8859-15", "iso885915", "iso_8859-15", "l9"]);
single("iso-8859-16", ["iso-8859-16"]);
single("koi8-r", ["cskoi8r", "koi", "koi8", "koi8-r", "koi8_r"]);
single("koi8-u", ["koi8-ru", "koi8-u"]);
single("macintosh", ["csmacintosh", "mac", "macintosh", "x-mac-roman"]);
single("windows-874", ["dos-874", "iso-8859-11", "iso8859-11", "iso885911", "tis-620", "windows-874"]);
single("windows-1250", ["cp1250", "windows-1250", "x-cp1250"]);
single("windows-1251", ["cp1251", "windows-1251", "x-cp1251"]);
single("windows-1252", ["ansi_x3.4-1968", "ascii", "cp1252", "cp819", "csisolatin1", "ibm819", "iso-8859-1", "iso-ir-100", "iso8859-1", "iso88591", "iso_8859-1", "iso_8859-1:1987", "l1", "latin1", "us-ascii", "windows-1252", "x-cp1252"]);
single("windows-1253", ["cp1253", "windows-1253", "x-cp1253"]);
single("windows-1254", ["cp1254", "csisolatin5", "iso-8859-9", "iso-ir-148", "iso8859-9", "iso88599", "iso_8859-9", "iso_8859-9:1989", "l5", "latin5", "windows-1254", "x-cp1254"]);
single("windows-1255", ["cp1255", "windows-1255", "x-cp1255"]);
single("windows-1256", ["cp1256", "windows-1256", "x-cp1256"]);
single("windows-1257", ["cp1257", "windows-1257", "x-cp1257"]);
single("windows-1258", ["cp1258", "windows-1258", "x-cp1258"]);
single("x-mac-cyrillic", ["x-mac-cyrillic", "x-mac-ukrainian"]);
add(27, ["x-user-defined"]);
add(28, ["csunicode", "iso-10646-ucs-2", "ucs-2", "unicode", "unicodefeff", "utf-16", "utf-16le"]);
add(29, ["unicodefffe", "utf-16be"]);
add(30, ["chinese", "csgb2312", "csiso58gb231280", "gb18030", "gb2312", "gb_2312", "gb_2312-80", "gbk", "iso-ir-58", "x-gbk"]);
add(31, ["big5", "big5-hkscs", "cn-big5", "csbig5", "x-x-big5"]);
add(32, ["cseucpkdfmtjapanese", "euc-jp", "x-euc-jp"]);
add(33, ["csiso2022jp", "iso-2022-jp"]);
add(34, ["csshiftjis", "ms932", "ms_kanji", "shift-jis", "shift_jis", "sjis", "windows-31j", "x-sjis"]);
add(35, ["cseuckr", "csksc56011987", "euc-kr", "iso-ir-149", "korean", "ks_c_5601-1987", "ks_c_5601-1989", "ksc5601", "ksc_5601", "windows-949"]);
return labels;
})();
type StaticTextDecoderEncoding = { kind: "utf8" } | { kind: "legacy"; id: number };
/** TextDecoder's get-an-encoding normalization: trim ASCII whitespace and
* fold ASCII case only (Unicode case folding must not manufacture a label). */
export function staticTextDecoderEncoding(label: string): StaticTextDecoderEncoding | null {
const trimmed = label.replace(/^[\u0009\u000a\u000c\u000d\u0020]+|[\u0009\u000a\u000c\u000d\u0020]+$/g, "");
let normalized = "";
for (let index = 0; index < trimmed.length; index++) {
const code = trimmed.charCodeAt(index);
normalized += code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : trimmed[index];
}
if (TEXT_DECODER_UTF8_LABELS.has(normalized)) return { kind: "utf8" };
const id = TEXT_DECODER_LEGACY_LABELS[normalized];
return id === undefined ? null : { kind: "legacy", id };
}
+10 -4
View File
@@ -720,7 +720,11 @@ export function loadProgram(
if (methods !== undefined) {
const byClass = new Map(declarationOverloads.get(targetNorm) ?? []);
byClass.set(localName, new Map([...methods].map(([name, signatures]) => [name, signatures.map((signature) => ({
parameters: signature.parameters.map((parameter) => ({ ...parameter, type: renameSelf(parameter.type) })),
parameters: signature.parameters.map((parameter) => ({
name: parameter.name,
type: renameSelf(parameter.type),
optional: parameter.optional,
})),
returnType: renameSelf(signature.returnType),
}))])));
declarationOverloads.set(targetNorm, byClass);
@@ -782,8 +786,9 @@ export function loadProgram(
/** 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
* preflight diagnostics. The lowerer runs only on programs that pass. */
export function checkPreflight(load: LoadResult): ScrDiagnostic[] {
* preflight diagnostics. Preserve the caller's complete load shape when
* mutating it, including its owned services and disposal callback. */
export function checkPreflight<T extends LoadResult>(load: T): ScrDiagnostic[] {
const { diags, moduleOrder, startupCrash } = preflight7(load);
load.moduleOrder = moduleOrder;
load.startupCrash = startupCrash;
@@ -2018,7 +2023,8 @@ export function makeCycleAdmission(
}
sccVerdict.set(comp, reason);
}
const clusterReason = sccVerdict.get(comp)!;
const clusterReason = sccVerdict.get(comp);
if (clusterReason === undefined) throw new Error("missing module-cycle verdict");
if (clusterReason !== null) return clusterReason;
const use = backEdgeUseOffence7(program, importer, e.stmt);
if (use !== null) {
+3 -2
View File
@@ -52,6 +52,7 @@ interface PkgJson {
types?: string;
typings?: string;
main?: string;
imports?: unknown;
exports?: unknown;
/** The workspace ROOT's member declaration: an array of directory globs
* (or `{ packages: [...] }` — the tool-config twin of the same field). */
@@ -588,14 +589,14 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin
if (viaPaths !== null) return normalizeResolvedPath(viaPaths);
const pkgDir = nearestPkgDir(dirname(resolve(fromFile)));
if (pkgDir === null) return null;
const pkg = pkgJsonOf(pkgDir) as (PkgJson & { imports?: unknown; exports?: unknown; type?: string }) | null;
const pkg = pkgJsonOf(pkgDir);
if (!pkg) return null;
let target: string | null = null;
if (specifier.startsWith("#")) {
// Node's validity rule: only the bare "#" is never resolvable ("#/..."
// matches "#/*"-pattern keys on current Node).
if (specifier === "#") return null;
target = resolvePackageImports((pkg as { imports?: unknown }).imports, specifier, EXPORT_CONDITIONS);
target = resolvePackageImports(pkg.imports, specifier, EXPORT_CONDITIONS);
// An imports target that is not "./"-relative is PACKAGE_RESOLVE'd as
// a bare specifier (Node's PACKAGE_IMPORTS_EXPORTS_RESOLVE): the
// project-internal half of that re-entry is the self-name rule below —
@@ -1,6 +1,11 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { resolveImportMetaRuntime, runtimePathForTarget } from "./runtime-resolve.js";
import { FrontendServices } from "./services.js";
import { clearResolveCaches } from "./resolve.js";
test("WASI resolution metadata uses guest paths", () => {
const source = join(process.cwd(), "tests", "program", "main.mjs");
@@ -17,3 +22,52 @@ test("Windows resolution metadata uses target URL semantics on any host", () =>
value: "file:///C:/work/asset.js",
});
});
test("runtime package resolution uses the supplied host and import conditions without evaluating code", () => {
const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-runtime-resolve-"));
const services = new FrontendServices(() => { throw new Error("introspection must not open a parser connection"); }, directory);
try {
const entry = join(directory, "main.mjs");
const pkg = join(directory, "node_modules/native-resolve-fixture");
mkdirSync(pkg, { recursive: true });
writeFileSync(entry, "export {};");
writeFileSync(join(pkg, "package.json"), JSON.stringify({
name: "native-resolve-fixture", type: "module",
exports: {
".": { types: "./index.d.ts", import: "./import.mjs", require: "./require.cjs" },
"./feature": "./feature.mjs",
"./private": null,
},
}));
writeFileSync(join(pkg, "index.d.ts"), "export const value: number;");
writeFileSync(join(pkg, "import.mjs"), 'throw new Error("do not execute while resolving");');
writeFileSync(join(pkg, "require.cjs"), 'throw new Error("wrong condition");');
writeFileSync(join(pkg, "feature.mjs"), "export const feature = true;");
expect(resolveImportMetaRuntime(entry, "native-resolve-fixture", process.platform, services)).toEqual({
ok: true, value: pathToFileURL(realpathSync(join(pkg, "import.mjs"))).href,
});
expect(resolveImportMetaRuntime(entry, "native-resolve-fixture/feature", process.platform, services)).toEqual({
ok: true, value: pathToFileURL(realpathSync(join(pkg, "feature.mjs"))).href,
});
const refused = resolveImportMetaRuntime(entry, "native-resolve-fixture/private", process.platform, services);
expect(refused).toMatchObject({ ok: false, error: { name: "Error", code: "ERR_PACKAGE_PATH_NOT_EXPORTED" } });
expect(() => resolveImportMetaRuntime(entry, "native-resolve-fixture", process.platform)).toThrow("requires frontend services");
} finally {
services.close();
clearResolveCaches();
rmSync(directory, { recursive: true, force: true });
}
});
test("pure runtime URL and builtin resolution needs no process services", () => {
const entry = join(process.cwd(), "main.mjs");
expect(resolveImportMetaRuntime(entry, "fs", process.platform)).toEqual({ ok: true, value: "node:fs" });
expect(resolveImportMetaRuntime(entry, "node:fs", process.platform)).toEqual({ ok: true, value: "node:fs" });
expect(resolveImportMetaRuntime(entry, "https://example.com/a/../b", process.platform)).toEqual({
ok: true, value: "https://example.com/b",
});
expect(resolveImportMetaRuntime(entry, "https://[invalid]/", process.platform)).toMatchObject({
ok: false, error: { name: "TypeError", code: "ERR_INVALID_URL" },
});
expect(resolveImportMetaRuntime(entry, "#unimplemented", process.platform)).toBeNull();
});
@@ -15,7 +15,8 @@ import { createRequire, isBuiltin } from "node:module";
import { isAbsolute } from "node:path";
import { pathToFileURL } from "node:url";
import { markFrontendInputsUnstable } from "./input-tracker.js";
import { NpmGraphBuilder, probeNodeImportRefusal } from "./npm-node.js";
import { NpmGraphBuilder, probeNodeImportRefusal } from "./npm.js";
import type { FrontendServices } from "./services.js";
import { wasiGuestPath } from "../wasi-paths.js";
export interface RuntimeResolveError {
@@ -60,6 +61,7 @@ export function resolveImportMetaRuntime(
fromFile: string,
specifier: string,
targetPlatform: string,
services?: FrontendServices,
): RuntimeResolveResult | null {
const base = targetFileUrl(fromFile, targetPlatform);
if (
@@ -77,7 +79,8 @@ export function resolveImportMetaRuntime(
}
if (specifier.startsWith("#")) return null;
const builder = new NpmGraphBuilder();
if (services === undefined) throw new Error("package runtime resolution requires frontend services");
const builder = new NpmGraphBuilder(services);
const key = builder.resolveForIntrospection(fromFile, specifier, "import");
if (key !== null) return { ok: true, value: targetFileUrl(key, targetPlatform) };
const refusal = probeNodeImportRefusal(fromFile, specifier);
@@ -1,8 +1,9 @@
import { FrontendServices } from "./services.js";
import { Ts7Api } from "./ts7/rpc-api.js";
import { evaluateNodeComptime } from "./comptime-node.js";
export function createNodeFrontendServices(): FrontendServices {
return new FrontendServices((options) => new Ts7Api(options));
return new FrontendServices((options) => new Ts7Api(options), process.cwd(), evaluateNodeComptime);
}
let shared: FrontendServices | undefined;
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, expect, test, vi } from "vitest";
import { FrontendServices } from "./services.js";
import { FrontendServices, type ComptimeEvaluator } from "./services.js";
import { Ts7Api } from "./ts7/rpc-api.js";
import { checkPreflight, isNodeEsmFile, loadProgram } from "./program.js";
import { clearResolveCaches } from "./resolve.js";
@@ -13,7 +13,7 @@ afterEach(() => {
clearResolveCaches();
for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function setup() {
function setup(evaluator?: ComptimeEvaluator) {
const dir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-services-"));
directories.push(dir);
const connections: { api: Ts7Api; close: ReturnType<typeof vi.spyOn>; options: Ts7ApiOptions }[] = [];
@@ -22,9 +22,28 @@ function setup() {
connections.push({ api, close: vi.spyOn(api, "close"), options });
return api;
});
return { dir, connections, factory, services: new FrontendServices(factory, dir) };
return { dir, connections, factory, services: new FrontendServices(factory, dir, evaluator) };
}
test("compile-time execution belongs to the supplied host and closes with its services", () => {
const value = { table: [1, 2, 3] };
const evaluate = vi.fn(() => value);
const { services, factory } = setup(evaluate);
expect(services.evaluateComptime("() => [1, 2, 3]", 2000)).toBe(value);
expect(evaluate).toHaveBeenCalledExactlyOnceWith("() => [1, 2, 3]", 2000);
expect(factory).not.toHaveBeenCalled();
services.close();
expect(() => services.evaluateComptime("() => 1", 2000)).toThrow("closed");
expect(evaluate).toHaveBeenCalledTimes(1);
});
test("a host without a compile-time evaluator refuses explicitly", () => {
const { services, factory } = setup();
try { expect(() => services.evaluateComptime("() => 1", 2000)).toThrow("does not provide compile-time evaluation"); }
finally { services.close(); }
expect(factory).not.toHaveBeenCalled();
});
test("service construction and irrelevant rewrites open no connections", () => {
const { services, factory } = setup();
expect(services.rewriteCjs("exports.value = 1;", "index.js")).toBeNull();
+18 -4
View File
@@ -6,16 +6,25 @@ import { isBundlerCjsCandidate } from "./npm-static-rewrite-syntax.js";
import * as syntax from "./npm-static-declaration-syntax.js";
import type { SourceFile } from "./ts7/ast-types.js";
/** Evaluate an isolated, self-contained TypeScript callback under a finite
* budget. Hosts supply execution; shared lowering owns capture checks and
* verifies the returned value before baking it into the program. */
export type ComptimeEvaluator = (source: string, timeoutMs: number) => unknown;
/** Own the syntax and semantic services for a compiler client. The supplied
* connection factory is the only process boundary; parsing, projection and
* filesystem resolution use the same implementation in native and Node
* clients. Program hosts belong to the caller that creates them. */
* connection factory and optional evaluator provide host operations;
* parsing, projection and filesystem resolution share their implementation
* across native and Node clients. Program hosts belong to their callers. */
export class FrontendServices {
private parser: Ts7SourceParser | undefined;
private readonly fetchAnalyzer: NpmFetchAnalyzer;
private closed = false;
constructor(private readonly createApi: Ts7ApiFactory, private readonly cwd = process.cwd()) {
constructor(
private readonly createApi: Ts7ApiFactory,
private readonly cwd = process.cwd(),
private readonly comptimeEvaluator: ComptimeEvaluator | undefined = undefined,
) {
this.fetchAnalyzer = new NpmFetchAnalyzer((options) => createApi({ ...options, collectTiming: false }), cwd);
}
@@ -25,6 +34,11 @@ export class FrontendServices {
return this.parser ??= new Ts7SourceParser((options) => this.createApi({ ...options, collectTiming: false }), this.cwd);
}
parse(path: string, source: string, kind: Ts7SourceKind): SourceFile { return this.sourceParser().parse(path, source, kind); }
evaluateComptime(source: string, timeoutMs: number): unknown {
this.ensureOpen();
if (this.comptimeEvaluator === undefined) throw new Error("this compiler host does not provide compile-time evaluation");
return this.comptimeEvaluator(source, timeoutMs);
}
createProgramHost(options?: Ts7HostOptions): Ts7Host {
this.ensureOpen();
return new Ts7Host(this.createApi, { ...options, cwd: options?.cwd ?? this.cwd });
+2
View File
@@ -2350,6 +2350,7 @@ export type IrLibFn =
* encoded slashes, and non-empty hosts. url.pathToFileURL resolves the
* path (getcwd) and never throws. */
| "url.new"
| "url.newBase"
| "url.protocol"
| "url.origin"
| "url.username"
@@ -7855,6 +7856,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"fs.readdirSync",
"fs.readdirTypesSync",
"url.new",
"url.newBase",
"url.fileURLToPathUrl",
"url.fileURLToPathStr",
// The win32-target flavor of pathToFileURL (same runtime entry point —
+1
View File
@@ -330,6 +330,7 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"bigint.dataViewGet": { argTypes: [BYTES_U8, F64, BOOL, BOOL], result: BIGINT_T },
"bigint.dataViewSet": { argTypes: [BYTES_U8, F64, BIGINT_T, BOOL], result: VOID },
"url.new": { argTypes: [STRING], result: URL_T },
"url.newBase": { argTypes: [STRING, STRING], result: URL_T },
"url.protocol": { argTypes: [URL_T], result: STRING },
"url.origin": { argTypes: [URL_T], result: STRING },
"url.username": { argTypes: [URL_T], result: STRING },
@@ -8662,6 +8662,12 @@
],
"diags": []
},
"<repo>/tests/corpus/default-undefined-parameters.ts": {
"order": [
"<repo>/tests/corpus/default-undefined-parameters.ts"
],
"diags": []
},
"<repo>/tests/corpus/error-cause-writes.js": {
"order": [
"<repo>/tests/corpus/error-cause-writes.js"
@@ -8901,6 +8907,18 @@
],
"diags": []
},
"<repo>/tests/corpus/regex-optional-templates.ts": {
"order": [
"<repo>/tests/corpus/regex-optional-templates.ts"
],
"diags": []
},
"<repo>/tests/corpus/regex-split-captures.ts": {
"order": [
"<repo>/tests/corpus/regex-split-captures.ts"
],
"diags": []
},
"<repo>/tests/corpus/renderer-export-dictionaries.js": {
"order": [
"<repo>/tests/corpus/renderer-export-dictionaries.js"
@@ -9037,6 +9055,12 @@
],
"diags": []
},
"<repo>/tests/corpus/url-runtime-base.ts": {
"order": [
"<repo>/tests/corpus/url-runtime-base.ts"
],
"diags": []
},
"<repo>/tests/diagnostics/accessors.ts": {
"order": [
"<repo>/tests/diagnostics/accessors.ts"
+19 -16
View File
@@ -628,17 +628,7 @@ ScrArr *scr_regex_split_limit(ScrStr *s, ScrRegex *re, double limit_num) {
uint8_t *bc = scr_regex_bc(re);
uint32_t limit = scr_to_uint32(limit_num);
if (limit == 0) return scr_arr_new(SCR_ELEM_STR, 0);
if (lre_get_capture_count(bc) > 1) {
/* JS splices every capture group's value into the result between the
* pieces, changing the array's SHAPE per match — not modeled this
* slice. Catchable, scriptc-specific. */
static const char msg[] =
"split() with capture groups in the pattern is not "
"supported (JS splices the captured values into the result); use a "
"non-capturing group (?:...)";
scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1);
return NULL; /* callers are compiler-emitted pending checks */
}
int capture_count = lre_get_capture_count(bc);
int re_flags = lre_get_flags(bc);
bool unicode = (re_flags & (LRE_FLAG_UNICODE | LRE_FLAG_UNICODE_SETS)) != 0;
bool sticky = (re_flags & LRE_FLAG_STICKY) != 0;
@@ -648,7 +638,7 @@ ScrArr *scr_regex_split_limit(ScrStr *s, ScrRegex *re, double limit_num) {
const uint8_t *ubase = (const uint8_t *)u;
ScrArr *out = scr_arr_new(SCR_ELEM_STR, 0);
/* ECMA-262 22.2.6.14 (Symbol.split, no limit). The spec probes every
/* ECMA-262 22.2.6.14 (Symbol.split). The spec probes every
* position with a sticky matcher; for non-sticky patterns a forward
* SEARCH from q is equivalent (the earliest match position >= q is the
* first probe that would succeed) and one exec replaces the per-position
@@ -671,22 +661,35 @@ ScrArr *scr_regex_split_limit(ScrStr *s, ScrRegex *re, double limit_num) {
}
int start = (int)((capture[0] - ubase) >> 1);
int end = (int)((capture[1] - ubase) >> 1);
/* The sticky algorithm never probes the position after the subject.
* A forward search can find a zero-width match there; exclude it. */
if (start == len) break;
if (end == p) {
/* Zero-length match adjacent to the previous split point: advance
* (start == q == p here — the search cannot skip a match). */
q = scr_advance(u, len, start, unicode);
} else {
scr_arr_push_ref(out, scr_str_from_utf16(u, p, start));
if (out->len == limit) {
free(capture);
free(u);
return out;
if (out->len == limit) goto done;
for (int group = 1; group < capture_count; group++) {
const uint8_t *from = capture[2 * group];
const uint8_t *to = capture[2 * group + 1];
if (from == NULL || to == NULL) {
// Nonparticipating captures are present undefined elements,
// distinct from both empty strings and holes.
scr_arr_set_undefined(out, (double)out->len);
} else {
scr_arr_push_ref(out, scr_str_from_utf16(u,
(int)((from - ubase) >> 1), (int)((to - ubase) >> 1)));
}
if (out->len == limit) goto done;
}
p = end;
q = p;
}
}
scr_arr_push_ref(out, scr_str_from_utf16(u, p, len));
done:
free(capture);
free(u);
return out;
+5 -3
View File
@@ -1158,8 +1158,8 @@ ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs);
* test() on a g/y-flagged regex aborts with a clear message.
*
* Ownership: subjects/replacements are BORROWED; string/array results
* return +1. replace_all without /g and split on a pattern with capture
* groups THROW catchable TypeErrors (callers are compiler-emitted pending
* return +1. replace_all without /g THROWS a catchable TypeError
* (callers are compiler-emitted pending
* checks); every other failure mode aborts.
*/
typedef struct ScrRegex {
@@ -1200,7 +1200,8 @@ ScrStr *scr_regex_flags(ScrRegex *re); /* +1 */
ScrStr *scr_regex_replace(ScrStr *s, ScrRegex *re, ScrStr *rep);
/* replaceAll: throws Node's TypeError when /g is missing (may-throw). */
ScrStr *scr_regex_replace_all(ScrStr *s, ScrRegex *re, ScrStr *rep);
/* split: capture-free patterns only — capture groups throw (may-throw).
/* split: captured substrings are interleaved with the split pieces;
* unmatched groups become present undefined elements, not holes.
* limit uses ToUint32; the no-limit wrapper supplies 2^32-1. */
ScrArr *scr_regex_split(ScrStr *s, ScrRegex *re);
ScrArr *scr_regex_split_limit(ScrStr *s, ScrRegex *re, double limit);
@@ -3034,6 +3035,7 @@ void scr_fs_close(double fd);
typedef struct ScrUrl ScrUrl;
ScrUrl *scr_url_new(ScrStr *input); /* +1, or throws */
ScrUrl *scr_url_new_base(ScrStr *input, ScrStr *base); /* +1, or throws */
ScrUrl *scr_url_retain(ScrUrl *u);
void scr_url_release(ScrUrl *u);
void *scr_url_retain_v(void *p);
+130 -7
View File
@@ -237,7 +237,13 @@ static bool seg_is_dotdot(const char *s, size_t len) {
* after the authority (or after the scheme's slashes), '?'/'#' excluded.
* Backslashes act as slashes iff `special`. Output starts with '/' for
* every non-empty result; empty stays empty (git://host has path ""). */
static ScrStr *parse_rooted_path(const char *raw, size_t len, bool special) {
static bool url_drive_letter(const char *raw, size_t len) {
return len == 2 && ((raw[0] >= 'a' && raw[0] <= 'z') ||
(raw[0] >= 'A' && raw[0] <= 'Z')) &&
(raw[1] == ':' || raw[1] == '|');
}
static ScrStr *parse_rooted_path_mode(const char *raw, size_t len, bool special, bool file) {
if (len == 0) return scr_str_new("", 0); /* no path at all (git://host) */
/* Collected segment list: one output buffer + an index of segment start
* offsets (popping a ".." truncates to the previous start). */
@@ -259,7 +265,7 @@ static ScrStr *parse_rooted_path(const char *raw, size_t len, bool special) {
const char *seg = raw + seg_begin;
size_t seg_len = i - seg_begin;
if (seg_is_dotdot(seg, seg_len)) {
if (seg_count > 0) {
if (seg_count > 0 && !(file && seg_count == 1 && url_drive_letter(out.data, out.len))) {
seg_count--;
out.len = seg_starts[seg_count];
}
@@ -292,7 +298,8 @@ static ScrStr *parse_rooted_path(const char *raw, size_t len, bool special) {
}
seg_starts[seg_count++] = out.len;
for (size_t j = 0; j < seg_len; j++) {
ub_push_encoded(&out, (unsigned char)seg[j], enc_path);
const char c = file && seg_count == 1 && url_drive_letter(seg, seg_len) && j == 1 ? ':' : seg[j];
ub_push_encoded(&out, (unsigned char)c, enc_path);
}
}
if (at_end) break;
@@ -311,6 +318,10 @@ static ScrStr *parse_rooted_path(const char *raw, size_t len, bool special) {
return ub_take(&ser);
}
static ScrStr *parse_rooted_path(const char *raw, size_t len, bool special) {
return parse_rooted_path_mode(raw, len, special, false);
}
/* Parses `authority` (between the slashes and the path/query/fragment):
* [userinfo@]host[:port]. Returns false (throws) on invalid input. */
static bool parse_authority(const char *raw, size_t len, bool special, bool is_file,
@@ -515,7 +526,13 @@ ScrUrl *scr_url_new(ScrStr *input) {
size_t astart = is_file ? 2 : slashes;
size_t aend = astart;
while (aend < body_len && body[aend] != '/' && body[aend] != '\\') aend++;
if (!parse_authority(body + astart, aend - astart, special, is_file, scheme, sl,
size_t path_start = aend;
if (is_file && url_drive_letter(body + astart, aend - astart)) {
userinfo = scr_str_new("", 0);
host = scr_str_new("", 0);
port = scr_str_new("", 0);
path_start = astart - 1;
} else if (!parse_authority(body + astart, aend - astart, special, is_file, scheme, sl,
&userinfo, &host, &port)) {
scr_str_release(userinfo);
scr_str_release(host);
@@ -525,7 +542,7 @@ ScrUrl *scr_url_new(ScrStr *input) {
return NULL;
}
has_authority = true;
path = parse_rooted_path(body + aend, body_len - aend, special);
path = parse_rooted_path_mode(body + path_start, body_len - path_start, special, is_file);
if (path->len == 0) {
scr_str_release(path);
path = scr_str_new("/", 1); /* special URLs never have empty paths */
@@ -542,14 +559,14 @@ ScrUrl *scr_url_new(ScrStr *input) {
/* Root the path: parse_rooted_path treats one leading sep as the
* root; prepend one if absent so "tmp" parses as "/tmp". */
if (plen > 0 && (p[0] == '/' || p[0] == '\\')) {
path = parse_rooted_path(p, plen, special);
path = parse_rooted_path_mode(p, plen, special, true);
} else {
UrlBuf rb;
ub_init(&rb);
ub_push(&rb, '/');
ub_append(&rb, p, plen);
ScrStr *tmp = ub_take(&rb);
path = parse_rooted_path(tmp->data, tmp->len, special);
path = parse_rooted_path_mode(tmp->data, tmp->len, special, true);
scr_str_release(tmp);
}
if (path->len == 0) {
@@ -632,6 +649,112 @@ ScrUrl *scr_url_new(ScrStr *input) {
return u;
}
/* Resolve a relative reference using the parsed base's component boundaries.
* Reuse the absolute parser for encoding, dot segments, authority validation,
* and canonicalization. The base is parsed even for an absolute input: an
* invalid supplied base is a constructor error in Node. */
ScrUrl *scr_url_new_base(ScrStr *input, ScrStr *base_input) {
ScrUrl *base = scr_url_new(base_input);
if (!base) return NULL;
size_t begin = 0, end = input->len;
while (begin < end && (unsigned char)input->data[begin] <= 0x20) begin++;
while (end > begin && (unsigned char)input->data[end - 1] <= 0x20) end--;
UrlBuf cleaned;
ub_init(&cleaned);
for (size_t i = begin; i < end; i++) {
char c = input->data[i];
if (c != '\t' && c != '\n' && c != '\r') ub_push(&cleaned, c);
}
const char *raw = cleaned.data;
size_t len = cleaned.len;
size_t scheme_len = 0;
if (len && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z'))) {
size_t i = 1;
while (i < len && ((raw[i] >= 'a' && raw[i] <= 'z') ||
(raw[i] >= 'A' && raw[i] <= 'Z') || (raw[i] >= '0' && raw[i] <= '9') ||
raw[i] == '+' || raw[i] == '-' || raw[i] == '.')) i++;
if (i < len && raw[i] == ':') scheme_len = i;
}
bool special = is_special_scheme(base->scheme->data, base->scheme->len);
bool file = base->scheme->len == 4 && memcmp(base->scheme->data, "file", 4) == 0;
bool same_scheme = scheme_len == base->scheme->len;
for (size_t i = 0; same_scheme && i < scheme_len; i++) {
char c = raw[i];
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
if (c != base->scheme->data[i]) same_scheme = false;
}
ScrUrl *result = NULL;
if (scheme_len && (!same_scheme || !special)) {
result = scr_url_new(input);
goto done;
}
if (scheme_len) {
raw += scheme_len + 1;
len -= scheme_len + 1;
}
bool slash = len && (raw[0] == '/' || (special && raw[0] == '\\'));
bool authority = slash && len > 1 && (raw[1] == '/' || (special && raw[1] == '\\'));
bool opaque = !base->has_authority && (!base->path->len || base->path->data[0] != '/');
if (opaque && (!len || raw[0] != '#')) {
scr_url_throw_invalid();
goto done;
}
UrlBuf absolute;
ub_init(&absolute);
ub_append(&absolute, base->scheme->data, base->scheme->len);
ub_push(&absolute, ':');
if (authority) {
ub_append(&absolute, raw, len);
} else {
size_t path_len = 0;
while (path_len < len && raw[path_len] != '?' && raw[path_len] != '#') path_len++;
bool drive = file && path_len >= 2 && url_drive_letter(raw, 2) &&
(path_len == 2 || raw[2] == '/' || raw[2] == '\\');
if (base->has_authority) {
ub_append(&absolute, "//", 2);
if (!drive) {
if (base->userinfo->len) {
ub_append(&absolute, base->userinfo->data, base->userinfo->len);
ub_push(&absolute, '@');
}
ub_append(&absolute, base->host->data, base->host->len);
if (base->port->len) {
ub_push(&absolute, ':');
ub_append(&absolute, base->port->data, base->port->len);
}
}
}
if (!path_len) {
ub_append(&absolute, base->path->data, base->path->len);
if (!len || raw[0] == '#') ub_append(&absolute, base->query->data, base->query->len);
} else if (drive) {
ub_push(&absolute, '/');
} else if (slash) {
// A rooted file reference retains its base drive unless it supplies
// a replacement drive. Dot segments must never pop that drive root.
bool replacement_drive = path_len >= 3 && url_drive_letter(raw + 1, 2) &&
(path_len == 3 || raw[3] == '/' || raw[3] == '\\');
if (file && !replacement_drive && base->path->len >= 3 &&
url_drive_letter(base->path->data + 1, 2)) {
ub_append(&absolute, base->path->data, 3);
}
} else {
size_t directory = base->path->len;
while (directory && base->path->data[directory - 1] != '/') directory--;
if (directory) ub_append(&absolute, base->path->data, directory);
else ub_push(&absolute, '/');
}
ub_append(&absolute, raw, len);
}
ScrStr *resolved = ub_take(&absolute);
result = scr_url_new(resolved);
scr_str_release(resolved);
done:
free(cleaned.data);
scr_url_release(base);
return result;
}
ScrStr *scr_url_protocol(ScrUrl *u) {
UrlBuf b;
ub_init(&b);
+1 -1
View File
@@ -8,7 +8,7 @@ import { spawnSync } from "node:child_process";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const ALLOWED_TYPESCRIPT5_IMPORTS = new Set([
"packages/compiler/src/frontend/lowering/lower-comptime.ts",
"packages/compiler/src/frontend/comptime-node.ts",
"packages/compiler/src/frontend/ts7/world-check.ts",
"packages/compiler/src/frontend/ts7/source-parser.test.ts",
"packages/compiler/src/library/semantic-source.ts",
@@ -0,0 +1,62 @@
// @transform-types
type Evaluate = (source: string, budget: number) => unknown;
class Host {
constructor(private readonly evaluate: Evaluate | undefined = undefined) {}
run(): unknown { return this.evaluate === undefined ? "missing" : this.evaluate("source", 2000); }
}
console.log(new Host().run());
console.log(new Host(undefined).run());
console.log(new Host((source, budget): unknown => source + budget).run());
function optional(callback: Evaluate | undefined = undefined): unknown {
return callback === undefined ? "missing" : callback("function", 1000);
}
console.log(optional(), optional(undefined), optional((text, budget): unknown => text + budget));
let calls = 0;
function absent(): undefined { calls++; return undefined; }
function text(value: string | undefined = absent()): string {
return value === undefined ? "absent" : value;
}
console.log(text(), calls, text("provided"), calls, text(undefined), calls);
function number(value: number | undefined = void (calls += 10)): number {
return value === undefined ? -1 : value;
}
console.log(number(), calls, number(0), calls, number(undefined), calls);
class Base {
constructor(public value: string | undefined = undefined) {}
read(): string { return this.value ?? "base missing"; }
}
class Derived extends Base {
constructor(value: string | undefined = undefined) { super(value); }
}
console.log(new Derived().read(), new Derived("present").read());
class Methods {
label(value: string | undefined = undefined): string { return value ?? "method missing"; }
static count(value: number | undefined = undefined): number { return value ?? -2; }
}
console.log(new Methods().label(), new Methods().label("method"));
console.log(Methods.count(), Methods.count(3));
const lambda = (value: string | undefined = undefined): string => value ?? "lambda missing";
console.log(lambda(), lambda("lambda"), lambda(undefined));
function generic<T>(fallback: T, value: T | undefined = undefined): T {
return value === undefined ? fallback : value;
}
console.log(generic("fallback"), generic("fallback", "value"), generic(10), generic(10, 20));
let available = false;
function maybe(): string | undefined { return available ? "default" : undefined; }
function changing(value: string | undefined = maybe()): string { return value ?? "missing"; }
console.log(changing());
available = true;
console.log(changing(), changing("provided"));
// A default excludes an omitted argument only when its result does.
function required(value: string = "ordinary"): string { return value; }
console.log(required(), required(undefined), required("present"));
+32
View File
@@ -0,0 +1,32 @@
function extensions(file: string, values: string[]): string[] {
const out: string[] = [];
for (const extension of values) out.push(file.replace(/\.(js|mjs|cjs)$/, extension));
return out;
}
console.log(extensions("entry.js", [".ts", ".tsx", ".mts", ".cts"]).join(","));
console.log(extensions("entry.json", [".ts", ".mts"]).join(","));
function optional(index: number): void {
// Unchecked array reads can yield undefined despite a string checker
// type. RegExp's replacement conversion consumes that actual value.
const values = ["$&-$`-$'", ""];
const value = values[index];
console.log("left right left".replace(/left/, value));
console.log("left right left".replace(/left/g, value));
console.log("left right left".replaceAll(/left/g, value));
console.log("unchanged".replace(/missing/, value));
}
optional(0);
optional(1);
optional(2);
const templates = ["$1", "$2$1", "$$", "$<word>"];
for (const template of templates) {
console.log("ab ab".replace(/(?<word>a)(b)/g, template));
console.log("😀a😀".replace(/(?:)/gu, template));
}
let order = "";
function receiver(): string { order += "receiver;"; return "abc"; }
function pattern(): RegExp { order += "pattern;"; return /b/; }
function replacement(): string[] { order += "replacement;"; return []; }
console.log(receiver().replace(pattern(), replacement()[0]), order);
+60
View File
@@ -0,0 +1,60 @@
function split(input: string, separator: RegExp, limit?: number): void {
const values = limit === undefined ? input.split(separator) : input.split(separator, limit);
console.log(JSON.stringify(values), values.length, values.join("|"));
for (let index = 0; index < values.length; index++) {
const value = values[index];
console.log(index, typeof value, value === undefined ? "missing capture" : value);
}
let visited = 0;
values.forEach(() => { visited++; });
console.log("visited", visited);
}
split("left=right;tail", /([=;])/);
split("a,b;c", /(,)|(;)()/);
split("aaa", /(a)(a)?/);
split("abc", /((b))/);
split("a,b", /(?<separator>,)/);
split("a,b;c", /(?:,)|(;)/);
split("abc", /()/);
split("abc", /()$/);
split("abc", /(^)/);
split("abc", /($)/);
split("abc", /(?=(b))/);
split("abc", /(?<=(b))/);
split("abc", /x*(x)?/);
split("", /()/);
split("", /(x)/);
split(",", /(,)/);
split(",a,", /(,)/);
split("a\nb\rc", /(\r?\n)|(\r)/);
split("a,b,c", /(,)/g);
split("a,b,c", /(,)/y);
split("a,b,c", /(,)/gy);
split("a💡b💡c", /(💡)/u);
split("💡💡", /()/u);
// Surrogate halves follow the documented UTF-8 replacement behavior;
// pin non-Unicode advancement through lengths without serializing halves.
const units = "💡💡".split(/()/);
console.log(units.length, units.map((value) => value === undefined ? -1 : value.length).join(","));
for (const limit of [0, 1, 2, 3, 4, 5, 6, 100, -1, 2.9, NaN, Infinity, 4294967296, 4294967298]) {
split("a,b;c", /(,)|(;)()/, limit);
}
// Reusing a regex must not leak capture slots between participating and
// nonparticipating groups or retain any result array from the previous call.
const reused = /(,)|(;)()/;
for (let index = 0; index < 5; index++) {
split(index % 2 === 0 ? "a,b" : "x;y", reused, 4);
}
// The declaration projector needs both the separators and the type names.
const declaration = "Box | Box[] | Map<string, Box>";
console.log(declaration.split(/([\s[\]<>()|,]+)/).map((name) => name === "Box" ? "Internal" : name).join(""));
let order = "";
function source(): string { order += "source;"; return "a,b"; }
function pattern(): RegExp { order += "pattern;"; return /(,)/; }
function count(): number { order += "limit;"; return 2; }
console.log(JSON.stringify(source().split(pattern(), count())), order);
+51
View File
@@ -0,0 +1,51 @@
// Both inputs arrive at runtime: the compiler cannot fold these URL calls.
function resolve(input: string, base: string): void {
try {
const value = new URL(input, base);
console.log(value.href, value.protocol, value.host, value.pathname, value.search, value.hash);
} catch (error) {
if (error instanceof Error) console.log(error.name, error.message);
}
}
const http = "https://user:p@host:444/a/b?before#frag";
for (const input of ["", "#", "#next", "?", "?q#f", "../c", "/x", "//new/x", "https:c", "https:/c", "https://c", "https:", "other:x"])
resolve(input, http);
for (const input of ["./a", "../a", "../../../../a", "%2e%2e/a", ".%2e/a", "a/./b/../c", "a//b", "a/..", "a/.", " a b ", "a\tb\nc\rd", "a\\b", "\\root", "\\\\new\\path"])
resolve(input, http);
for (const input of ["/root", "../..", "../../..", "../c", "D:/root", "D|/root", "file:foo", "//server/share", "//D:/root", "/D|/root"])
resolve(input, "file:///C:/a/b");
for (const input of ["a", "/a", "../a", "//new/path", "?query", "#hash", ""])
resolve(input, "file://server/share/dir/file?query#hash");
for (const input of ["#next", "", "?q", "x", "http://host/", "data:next"])
resolve(input, "data:a?b#f");
for (const base of ["custom://Host/a/b", "custom:/a/b", "custom://Host", "http://host/", "file:///tmp/project/main.ts"])
for (const input of ["next", "../next", "?q", "#f", "/root", "//Other/x"])
resolve(input, base);
resolve("https://valid.example/", "invalid");
resolve("relative", "invalid");
resolve("//[bad]/", http);
resolve("//host:65536/", http);
resolve("é/你好?x=é#你好", http);
resolve("child", " HTTP://EXAMPLE.COM:80/a/../b?x#y ");
let order = "";
function input(): string { order += "input;"; return "../next.ts"; }
function base(): string { order += "base;"; return "file:///tmp/project/main.ts"; }
console.log(new URL(input(), base()).href, order);
function optional(input: string | URL, base?: string | URL): void {
try { console.log(new URL(input, base).href); }
catch (error) { if (error instanceof Error) console.log(error.name, error.message); }
}
optional("https://absolute.example/a");
optional("relative");
optional("next", new URL("https://base.example/dir/"));
optional(new URL("https://input.example/a"), "https://base.example/");
optional("../x", "https://base.example/a/b");
console.log(new URL("https://absolute.example/", void (order += "undefined;")).href, order);
const coercedInput = { toString: (): string => { order += "inputString;"; return "../coerced"; } };
function inputObject(): { toString: () => string } { order += "inputObject;"; return coercedInput; }
function baseObject(): string { order += "baseObject;"; return "https://base.example/a/b"; }
console.log(new URL(inputObject(), baseObject()).href, order);
+100
View File
@@ -0,0 +1,100 @@
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadProgram, checkPreflight } from "../../../packages/compiler/src/frontend/program.js";
import type { FrontendServices } from "../../../packages/compiler/src/frontend/services.js";
import type { ScrDiagnostic } from "../../../packages/compiler/src/diagnostics/diagnostic.js";
export interface FrontendProgramRequest {
root: string;
cases: {
name: string;
entry: string;
npmStatic: string[];
externalTypes: [string, string][];
edits: { path: string; source: string }[];
}[];
}
interface DiagnosticReport {
code: string;
message: string;
file: string;
start: number;
end: number;
}
interface ProgramReport {
name: string;
error: string;
diagnostics: DiagnosticReport[];
order: string[];
sources: { path: string; text: string }[];
external: { file: string; specifiers: string[] }[];
startup: { message: string; className: string; file: string; start: number; end: number } | null;
entryIdentity: boolean;
released: boolean;
}
function normalized(value: string, root: string): string {
return value.split("\\").join("/")
.split(realpathSync(root).split("\\").join("/") + "/").join("")
.split(root.split("\\").join("/") + "/").join("");
}
function diagnosticReport(diagnostic: ScrDiagnostic, root: string): DiagnosticReport {
return {
code: diagnostic.code,
message: normalized(diagnostic.message, root),
file: normalized(diagnostic.loc.file, root),
start: diagnostic.loc.start,
end: diagnostic.loc.end,
};
}
/** Run the production loader repeatedly through one service owner. Each load
* owns its program host; malformed inputs must not poison later projects. */
export function runFrontendPrograms(services: FrontendServices, input: string, output: string): void {
const request = JSON.parse(readFileSync(input, "utf8")) as FrontendProgramRequest;
const reports: ProgramReport[] = [];
for (const item of request.cases) {
for (const edit of item.edits) writeFileSync(join(request.root, edit.path), edit.source);
const report: ProgramReport = {
name: item.name, error: "", diagnostics: [], order: [], sources: [], external: [],
startup: null, entryIdentity: false, released: false,
};
try {
const entry = join(request.root, item.entry);
const load = loadProgram(entry, services, {
npmStatic: item.npmStatic,
externalTypes: item.externalTypes.map(([specifier, path]) => [specifier, join(request.root, path)]),
});
try {
report.entryIdentity = load.program.getSourceFile(entry) === load.entry;
report.diagnostics = checkPreflight(load).map((diagnostic) => diagnosticReport(diagnostic, request.root));
report.order = load.moduleOrder.map((source) => normalized(source.fileName, request.root));
report.sources = load.program.getSourceFiles()
.filter((source) => !source.isDeclarationFile)
.map((source) => ({ path: normalized(source.fileName, request.root), text: source.text }));
for (const [file, specifiers] of load.externalTypeSpecifiersByFile) {
report.external.push({ file: normalized(file, request.root), specifiers: [...specifiers] });
}
const crash = load.startupCrash;
if (crash !== undefined && crash !== null) report.startup = {
message: normalized(crash.message, request.root), className: crash.className,
file: normalized(crash.loc.file, request.root), start: crash.loc.start, end: crash.loc.end,
};
} finally { load.dispose(); }
report.released = load.program.analysis.nodeEsmFiles.size === 0 && load.program.analysis.createRequireReasons.size === 0;
} catch (error) {
report.error = normalized(error instanceof Error ? error.message : String(error), request.root);
}
reports.push(report);
// Retain completed cases when a later native run crashes or times out.
writeFileSync(output, JSON.stringify(reports));
}
services.close();
let refused = false;
try { services.createProgramHost(); } catch { refused = true; }
if (!refused) throw new Error("closed frontend services accepted a new program host");
services.close();
}
+6
View File
@@ -0,0 +1,6 @@
import { createNodeFrontendServices } from "../../../packages/compiler/src/frontend/services-node.js";
import { runFrontendPrograms } from "./frontend-program-cases.js";
const services = createNodeFrontendServices();
try { runFrontendPrograms(services, process.argv[3]!, process.argv[4]!); }
finally { services.close(); }
+7
View File
@@ -0,0 +1,7 @@
import { FrontendServices } from "../../../packages/compiler/src/frontend/services.js";
import { createNativeTs7Api } from "../../../packages/compiler/src/frontend/ts7/native-api.js";
import { runFrontendPrograms } from "./frontend-program-cases.js";
const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable: process.argv[2]! }));
try { runFrontendPrograms(services, process.argv[3]!, process.argv[4]!); }
finally { services.close(); }
-18
View File
@@ -4,9 +4,6 @@
* - The statefulness fence at runtime: test() on a g/y regex that flowed
* through a variable (the frontend only sees literal receivers) must
* abort with the fence message, never silently model lastIndex.
* - split() on a pattern with capture groups: Node splices the captured
* values into the result — scriptc throws a catchable TypeError naming
* the limitation instead, so the caught branch is asserted here.
* - The two size/stability pins: a regex-free program must not reference
* the regex runtime at all (byte-identical link line — its C names no
* ScrRegex symbol), and a regex-USING static binary pays libregexp
@@ -88,21 +85,6 @@ console.log(check(/a/g, "abc"));
);
});
test("split() with capture groups throws a catchable TypeError (Node would splice them in)", async () => {
const r = await build(
"split-captures",
`try {
const parts = "a,b".split(/(,)/);
console.log("unreachable", parts.length);
} catch {
console.log("caught");
}
`,
);
const { stdout } = await execFileAsync(r.binaryPath, []);
expect(stdout).toBe("caught\n");
});
test("a pattern the engine rejects aborts at first use with a SyntaxError message", async () => {
// tsc's parser (and V8) accept 300 capture groups; libregexp caps
// captures at 255, so the engine rejects the pattern at its LAZY
@@ -0,0 +1,270 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { analyze, compile } from "@scriptc/compiler";
import { expect, test } from "vitest";
import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js";
import type { FrontendProgramRequest } from "../fixtures/self-hosting/frontend-program-cases.js";
const root = join(import.meta.dirname, "../..");
const entry = join(root, "tests/fixtures/self-hosting/frontend-program.ts");
const oracle = join(root, "tests/fixtures/self-hosting/frontend-program-node.ts");
const nativeSources = join(root, "packages/compiler/native");
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const sanitize = process.env["SCRIPTC_SAN"] === "1";
function inputs(directory: string): FrontendProgramRequest {
const cases: FrontendProgramRequest["cases"] = [];
function add(name: string, files: Record<string, string>, options: {
entry?: string;
npmStatic?: string[];
externalTypes?: [string, string][];
} = {}): void {
for (const [path, source] of Object.entries({ "package.json": '{"type":"module"}', ...files })) {
const file = join(directory, name, path);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, source);
}
cases.push({
name, entry: `${name}/${options.entry ?? "main.ts"}`, npmStatic: options.npmStatic ?? [],
externalTypes: (options.externalTypes ?? []).map(([specifier, path]) => [specifier, `${name}/${path}`]), edits: [],
});
}
add("diamond", {
"main.ts": 'import { left } from "./left.js"; import { right } from "./right.js"; console.log(left + right);',
"left.ts": 'import { value } from "./leaf.js"; export const left = value + 1;',
"right.ts": 'export { value as right } from "./leaf.js";',
"leaf.ts": 'export const value = 20;',
});
add("type-edges", {
"main.ts": 'import type { Value } from "./types.js"; import "./side.js"; const item: Value = { value: 3 }; console.log(item.value);',
"types.ts": 'export interface Value { value: number }',
"side.ts": 'console.log("side effect");',
});
add("safe-cycle", {
"main.ts": 'import { first } from "./first.js"; console.log(first(2));',
"first.ts": 'import { second } from "./second.js"; export function first(n: number): number { return n ? second(n - 1) : 0; }',
"second.ts": 'import { first } from "./first.js"; export function second(n: number): number { return first(n); }',
});
add("unsafe-cycle", {
"main.ts": 'import { first } from "./first.js"; console.log(first);',
"first.ts": 'import { second } from "./second.js"; export const first: number = second + 1;',
"second.ts": 'import { first } from "./first.js"; export const second: number = first + 1;',
});
add("commonjs", {
"main.cjs": 'const left = require("./left.cjs"); const right = require("./right.cjs"); console.log(left.value + right.value);',
"left.cjs": 'const leaf = require("./leaf.cjs"); exports.value = leaf.value + 1;',
"right.cjs": 'module.exports = require("./leaf.cjs");',
"leaf.cjs": 'exports.value = 5;',
}, { entry: "main.cjs" });
add("create-require", {
"main.ts": 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const answer = require("./answer.cjs"); console.log(answer.value);',
"answer.cjs": 'exports.value = 42;',
});
add("package-imports", {
"package.json": JSON.stringify({ name: "own-package", type: "module", imports: { "#value": "./value.ts" }, exports: { "./other": "./other.ts" } }),
"main.ts": 'import { value } from "#value"; import { other } from "own-package/other"; console.log(value + other);',
"value.ts": 'export const value = 2;',
"other.ts": 'export const other = 3;',
});
add("config-alias", {
"tsconfig.json": '{ // adopted paths, with a trailing comma\n "compilerOptions": { "strict": true, "paths": { "@value": ["./lib/value.ts"] }, }, }',
"main.ts": 'import { value } from "@value"; console.log(value);',
"lib/value.ts": 'export const value = 7;',
});
add("config-extends", {
"base.json": '{"compilerOptions":{"strict":true,"paths":{"@value":["./lib/value.ts"]}}}',
"tsconfig.json": '{"extends":"./base.json","compilerOptions":{"noUncheckedIndexedAccess":true}}',
"main.ts": 'import { value } from "@value"; const values = [value]; console.log(values[0] ?? 0);',
"lib/value.ts": 'export const value = 8;',
});
add("strict-floor", {
"tsconfig.json": '{"compilerOptions":{"strict":false}}',
"main.ts": 'console.log("floor");',
});
add("config-malformed", {
"tsconfig.json": '{"compilerOptions": ',
"main.ts": 'console.log("malformed config");',
});
add("type-error", {
"main.ts": 'const value: string = 123; console.log(value);',
});
add("syntax-error", {
"main.ts": 'export const value = ;',
});
add("missing-module", {
"main.ts": 'import "missing-native-frontend-package"; console.log("unreachable");',
});
add("cjs-link-error", {
"main.ts": 'import { hidden } from "./hidden.cjs"; console.log(hidden);',
"hidden.cjs": 'const name = "hidden"; module.exports = { [name]: 1 };',
});
add("unsupported-builtin", {
"main.ts": 'import vm from "node:vm"; console.log(vm);',
});
add("fork-url", {
"main.ts": 'import { fork } from "node:child_process"; import { fileURLToPath } from "node:url"; const target = new URL("./worker.ts", import.meta.url); fork(fileURLToPath(target));',
"worker.ts": 'import { value } from "./worker-leaf.js"; console.log(value);',
"worker-leaf.ts": 'export const value = 9;',
});
add("unicode", {
"main.ts": 'import { value } from "./café 雪.js"; console.log(value);',
"café 雪.ts": 'export const value = "☕ 雪";',
});
add("declaration-twin", {
"main.ts": 'import { value } from "./value.js"; console.log(value + 1);',
"value.js": 'export const value = 4;',
"value.d.ts": 'export const value: string;',
});
add("npm-static", {
"main.ts": 'import { value } from "native-fixture"; console.log(value + 1);',
"node_modules/native-fixture/package.json": '{"name":"native-fixture","type":"module","main":"index.js","types":"index.d.ts","sideEffects":false}',
"node_modules/native-fixture/index.js": 'export { value } from "./value.js";',
"node_modules/native-fixture/value.js": 'export const value = 11;',
"node_modules/native-fixture/index.d.ts": 'export const value: number;',
}, { npmStatic: ["native-fixture"] });
add("npm-class-alias", {
"main.ts": 'import { PublicBox } from "native-box"; const box = new PublicBox(); box.value("hello"); console.log(box.value());',
"node_modules/native-box/package.json": '{"name":"native-box","type":"module","main":"index.js","types":"index.d.ts","sideEffects":false}',
"node_modules/native-box/index.js": 'export { InternalBox as PublicBox } from "./box.js";',
"node_modules/native-box/box.js": 'export class InternalBox { constructor() { this.text = ""; } value(next) { if (next === undefined) return this.text; this.text = next; return this; } }',
"node_modules/native-box/index.d.ts": 'export { PublicBox } from "./box.js";',
"node_modules/native-box/box.d.ts": 'export class PublicBox { value(): string; value(next: string): this; }',
}, { npmStatic: ["native-box"] });
add("external-types", {
"main.ts": 'import { answer } from "host-api"; console.log(answer());',
"host.d.ts": 'export { answer } from "./host-detail.js";',
"host-detail.d.ts": 'export function answer(): number;',
}, { externalTypes: [["host-api", "host.d.ts"], ["host-alias", "host.d.ts"]] });
add("external-invalid", {
"main.ts": 'console.log("invalid mapping");',
"host.d.ts": 'export function answer(): number;',
}, { externalTypes: [["host-*", "host.d.ts"]] });
add("reload", {
"main.ts": 'import { value } from "./value.js"; console.log(value);',
"value.ts": 'export const value = "before";',
});
cases.push({
name: "reload-error", entry: "reload/main.ts", npmStatic: [], externalTypes: [],
edits: [{ path: "reload/value.ts", source: 'export const value: number = "invalid";' }],
});
cases.push({
name: "reload-repaired", entry: "reload/main.ts", npmStatic: [], externalTypes: [],
edits: [{ path: "reload/value.ts", source: 'export const value = "after";' }],
});
// Revisit an opted package without opt-in, then restore it. Resolver state
// must follow this load rather than whichever project ran first.
cases.push({ name: "npm-without-opt-in", entry: "npm-static/main.ts", npmStatic: [], externalTypes: [], edits: [] });
cases.push({ name: "npm-restored", entry: "npm-static/main.ts", npmStatic: ["native-fixture"], externalTypes: [], edits: [] });
return { root: directory, cases };
}
interface Report {
name: string;
error: string;
diagnostics: { code: string; message: string }[];
order: string[];
sources: { path: string; text: string }[];
external: { file: string; specifiers: string[] }[];
startup: { message: string; className: string } | null;
entryIdentity: boolean;
released: boolean;
}
function verify(reports: Report[]): void {
const byName = new Map(reports.map((report) => [report.name, report]));
const get = (name: string): Report => {
const report = byName.get(name);
expect(report, name).toBeDefined();
return report!;
};
for (const report of reports) {
if (report.name === "external-invalid") continue;
expect(report.error, report.name).toBe("");
expect(report.entryIdentity, report.name).toBe(true);
expect(report.released, report.name).toBe(true);
}
for (const name of ["diamond", "type-edges", "safe-cycle", "commonjs", "create-require", "package-imports", "config-alias", "config-extends", "fork-url", "unicode", "declaration-twin", "npm-static", "npm-class-alias", "reload", "reload-repaired", "npm-restored"]) {
expect(get(name).diagnostics, name).toEqual([]);
expect(get(name).startup, name).toBeNull();
}
expect(get("diamond").order).toEqual(["diamond/leaf.ts", "diamond/left.ts", "diamond/right.ts", "diamond/main.ts"]);
expect(get("type-edges").order).toEqual(["type-edges/side.ts", "type-edges/main.ts"]);
expect(get("safe-cycle").order).toEqual(["safe-cycle/second.ts", "safe-cycle/first.ts", "safe-cycle/main.ts"]);
expect(get("unsafe-cycle").diagnostics.some((d) => d.code === "SC1016")).toBe(true);
expect(get("commonjs").order).toEqual(["commonjs/leaf.cjs", "commonjs/left.cjs", "commonjs/right.cjs", "commonjs/main.cjs"]);
expect(get("create-require").order).toEqual(["create-require/answer.cjs", "create-require/main.ts"]);
expect(get("package-imports").order).toEqual(["package-imports/value.ts", "package-imports/other.ts", "package-imports/main.ts"]);
expect(get("config-alias").order).toEqual(["config-alias/lib/value.ts", "config-alias/main.ts"]);
expect(get("config-extends").order).toEqual(["config-extends/lib/value.ts", "config-extends/main.ts"]);
expect(get("strict-floor").diagnostics.some((d) => d.message.includes("strictNullChecks"))).toBe(true);
for (const name of ["config-malformed", "type-error", "syntax-error", "reload-error"]) {
expect(get(name).diagnostics.length, name).toBeGreaterThan(0);
}
expect(get("unsupported-builtin").diagnostics.some((d) => d.code === "SC1010")).toBe(true);
expect(get("missing-module").diagnostics.some((d) => d.code === "SC1010" && d.message.includes("missing-native-frontend-package"))).toBe(true);
expect(get("cjs-link-error").startup?.className).toBe("%SyntaxError");
expect(get("fork-url").sources.map((source) => source.path)).toContain("fork-url/worker.ts");
expect(get("fork-url").sources.map((source) => source.path)).toContain("fork-url/worker-leaf.ts");
expect(get("unicode").order).toEqual(["unicode/café 雪.ts", "unicode/main.ts"]);
expect(get("declaration-twin").order).toEqual(["declaration-twin/value.js", "declaration-twin/main.ts"]);
expect(get("npm-static").order).toEqual(["npm-static/node_modules/native-fixture/value.js", "npm-static/node_modules/native-fixture/index.js", "npm-static/main.ts"]);
expect(get("npm-class-alias").sources.find((source) => source.path.endsWith("/box.js"))?.text).toContain("@overload");
expect(get("external-types").external).toEqual([
{ file: "external-types/host.d.ts", specifiers: ["host-api", "host-alias"] },
{ file: "external-types/host-detail.d.ts", specifiers: ["host-api", "host-alias"] },
]);
expect(get("external-invalid").error).toContain("invalid external type specifier");
expect(get("reload").sources.find((source) => source.path.endsWith("/value.ts"))?.text).toContain('"before"');
expect(get("reload-repaired").sources.find((source) => source.path.endsWith("/value.ts"))?.text).toContain('"after"');
expect(get("npm-without-opt-in").order).not.toContain("npm-static/node_modules/native-fixture/value.js");
expect(get("npm-restored").order).toEqual(get("npm-static").order);
}
for (const backend of ["c", "llvm"] as const) {
test(`production program loading and preflight run without Node (${backend})`, async () => {
const directory = mkdtempSync(join(tempRoot, "scriptc-frontend-program-"));
try {
const object = join(directory, "process.o");
execFileSync("clang", ["-std=c11", "-Wall", "-Wextra", "-Werror", ...(sanitize ? ["-fsanitize=address"] : []),
"-c", join(nativeSources, "ts7-process.c"), "-o", object]);
const profile = join(directory, "ffi.json");
writeFileSync(profile, JSON.stringify({
...JSON.parse(readFileSync(join(nativeSources, "ts7-process.ffi.json"), "utf8")), libraries: [object],
}));
const request = join(directory, "request.json");
const expected = join(directory, "node.json");
writeFileSync(request, JSON.stringify(inputs(directory)));
const node = spawnSync(process.execPath, ["--import", "tsx", oracle, ts7Executable(), request, expected], { encoding: "utf8", timeout: 90_000 });
expect(node.error, node.stderr).toBeUndefined();
expect(node.status, node.stderr).toBe(0);
expect(node.stdout).toBe("");
expect(node.stderr).toBe("");
const nodeReports = JSON.parse(readFileSync(expected, "utf8")) as Report[];
verify(nodeReports);
// Restore edits made by the Node run before starting the native client.
writeFileSync(request, JSON.stringify(inputs(directory)));
const { coverage } = analyze(entry, { dynamic: false, ffiProfilePath: profile });
expect(coverage.preflightFailed, JSON.stringify(coverage.diagnostics)).toBe(false);
expect(coverage.stats.statementsFailed, JSON.stringify(coverage.diagnostics)).toBe(0);
expect(coverage.stats.statementsIsland).toBe(0);
expect(coverage.stats.functionsSkipped).toBe(0);
const built = await compile(entry, {
backend, dynamic: false, optimization: "dev", sanitize, ffiProfilePath: profile,
outDir: directory, outPath: join(directory, process.platform === "win32" ? "frontend.exe" : "frontend"),
});
if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const report = join(directory, "native.json");
const run = spawnSync(built.binaryPath, [ts7Executable(), request, report], { encoding: "utf8", timeout: 90_000 });
expect(run.error, run.stderr).toBeUndefined();
expect(run.signal, run.stderr).toBeNull();
expect(run.status, run.stderr).toBe(0);
expect(run.stdout).toBe(node.stdout);
expect(run.stderr).toBe(node.stderr);
const nativeReports = JSON.parse(readFileSync(report, "utf8")) as Report[];
expect(nativeReports).toEqual(nodeReports);
verify(nativeReports);
} finally { rmSync(directory, { recursive: true, force: true }); }
});
}