diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a38482a..edb2b5731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,17 +217,22 @@ jobs: # worker budget covers both processes; file and case shard axes stay # separate so every fixture still runs exactly once in each lane. # The tests run in a transient scope capped below the runner's 16 GB, so - # the runner itself keeps memory: past 14 GB of RAM the scope swaps, as - # the whole runner did before (a sanitized shard peaks near 16 GB), and - # when RAM and swap run out the kernel kills the scope's largest process. + # the runner itself keeps memory: past 15 GB of RAM the scope swaps, as + # the whole runner did before, and when RAM and swap run out the kernel + # kills the scope's largest process. A sanitized shard peaks near 16 GB, + # and the sanitized self-hosting native-frontend build alone needs 15 GB + # (its clang compile of the frontend takes 10.5 GB). # OOMPolicy=continue leaves the rest of the scope running, so vitest - # reports the killed worker and the job log names the test. + # reports the killed worker and the job log names the test. Node sizes + # its default heap from the memory it may use, which the scope lowers + # below 4 GB; the workers keep the 4 GB heap they had on the bare + # runner. - name: Tests (corpus and integration suites) run: | ulimit -c 0 if [ -d /run/systemd/system ]; then - exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue \ - --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" node scripts/ci-test.mjs + exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=15G -p OOMPolicy=continue \ + --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" NODE_OPTIONS="--max-old-space-size=4096" node scripts/ci-test.mjs fi node scripts/ci-test.mjs diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 4f152e4bb..57d1eaf1a 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -16,6 +16,8 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | +| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast | +| `keyed-option-writes` | cpu | class methods whose names are also keys of option records written through element accesses: `enabled[name] = true` over a union of literal names and `totals["count"] = n` | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | | `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes | diff --git a/benchmarks/runtime/workloads/json-unions.json b/benchmarks/runtime/workloads/json-unions.json new file mode 100644 index 000000000..925e8457b --- /dev/null +++ b/benchmarks/runtime/workloads/json-unions.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/json-unions.ts", + "args": [ + "6" + ] +} diff --git a/benchmarks/runtime/workloads/json-unions.ts b/benchmarks/runtime/workloads/json-unions.ts new file mode 100644 index 000000000..0f684c35f --- /dev/null +++ b/benchmarks/runtime/workloads/json-unions.ts @@ -0,0 +1,155 @@ +// JSON text parsed into discriminated unions: an event log with eight kinds +// of event and a nested expression tree with nine kinds of node, cast with +// `JSON.parse(text) as T`, then aggregated and evaluated. Argument: the +// number of rounds. +type Event = + | { kind: "click"; target: string; x: number; y: number } + | { key: string; kind: "key"; repeat: boolean } + | { kind: "scroll"; offset: number } + | { kind: "resize"; height: number; width: number } + | { kind: "focus"; target: string } + | { kind: "blur"; target: string } + | { kind: "load"; ms: number; url: string } + | { code: number; kind: "error"; message: string }; + +type Expr = + | { kind: "num"; value: number } + | { kind: "var"; name: string } + | { kind: "neg"; operand: Expr } + | { kind: "add"; left: Expr; right: Expr } + | { kind: "sub"; left: Expr; right: Expr } + | { kind: "mul"; left: Expr; right: Expr } + | { kind: "max"; items: Expr[] } + | { else: Expr; kind: "if"; test: Expr; then: Expr } + | { body: Expr; kind: "let"; name: string; value: Expr }; + +let seed = 12345; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +const targets = ["menu", "search", "button", "list", "footer"]; +function eventText(i: number): string { + const target = targets[i % targets.length]!; + switch (i % 8) { + case 0: + return `{"kind":"click","target":"${target}","x":${i % 640},"y":${i % 480}}`; + case 1: + return `{"key":"k${i % 26}","kind":"key","repeat":${i % 3 === 0}}`; + case 2: + return `{"kind":"scroll","offset":${(i * 7) % 2000}}`; + case 3: + return `{"height":${400 + (i % 300)},"kind":"resize","width":${600 + (i % 500)}}`; + case 4: + return `{"kind":"focus","target":"${target}"}`; + case 5: + return `{"kind":"blur","target":"${target}"}`; + case 6: + return `{"kind":"load","ms":${i % 900},"url":"/page/${i % 50}"}`; + default: + return `{"code":${400 + (i % 100)},"kind":"error","message":"failed ${i % 13}"}`; + } +} + +const names = ["a", "b", "c", "d"]; +function exprText(depth: number): string { + const pick = depth <= 0 ? Math.floor(random() * 2) : Math.floor(random() * 9); + switch (pick) { + case 0: + return `{"kind":"num","value":${Math.floor(random() * 100)}}`; + case 1: + return `{"kind":"var","name":"${names[Math.floor(random() * names.length)]!}"}`; + case 2: + return `{"kind":"neg","operand":${exprText(depth - 1)}}`; + case 3: + case 4: + case 5: { + const kind = pick === 3 ? "add" : pick === 4 ? "sub" : "mul"; + return `{"kind":"${kind}","left":${exprText(depth - 1)},"right":${exprText(depth - 1)}}`; + } + case 6: + return `{"items":[${exprText(depth - 1)},${exprText(depth - 2)},${exprText(depth - 2)}],"kind":"max"}`; + case 7: + return `{"else":${exprText(depth - 2)},"kind":"if","test":${exprText(depth - 1)},"then":${exprText(depth - 2)}}`; + default: + return `{"body":${exprText(depth - 1)},"kind":"let","name":"${names[Math.floor(random() * names.length)]!}","value":${exprText(depth - 2)}}`; + } +} + +function evaluate(e: Expr, env: Map): number { + switch (e.kind) { + case "num": + return e.value; + case "var": + return env.get(e.name) ?? 1; + case "neg": + return -evaluate(e.operand, env); + case "add": + return (evaluate(e.left, env) + evaluate(e.right, env)) % 1000003; + case "sub": + return (evaluate(e.left, env) - evaluate(e.right, env)) % 1000003; + case "mul": + return (evaluate(e.left, env) * evaluate(e.right, env)) % 1000003; + case "max": { + let best = -Infinity; + for (const item of e.items) best = Math.max(best, evaluate(item, env)); + return best; + } + case "if": + return evaluate(e.test, env) > 0 ? evaluate(e.then, env) : evaluate(e.else, env); + case "let": { + const inner = new Map(env); + inner.set(e.name, evaluate(e.value, env)); + return evaluate(e.body, inner); + } + } +} + +const rounds = Number(process.argv[2] ?? "6"); +const eventsText = `[${Array.from({ length: 60000 }, (_, i) => eventText(i)).join(",")}]`; +const treesText = `[${Array.from({ length: 400 }, () => exprText(9)).join(",")}]`; +let clicks = 0; +let keys = 0; +let scrolled = 0; +let area = 0; +let loadMs = 0; +let errors = 0; +let focus = 0; +let total = 0; +for (let round = 0; round < rounds; round++) { + const events = JSON.parse(eventsText) as Event[]; + for (const event of events) { + switch (event.kind) { + case "click": + clicks += event.x + event.y; + break; + case "key": + if (event.repeat) keys++; + break; + case "scroll": + scrolled += event.offset; + break; + case "resize": + area += (event.width * event.height) % 1000; + break; + case "focus": + case "blur": + focus += event.target.length; + break; + case "load": + loadMs += event.ms; + break; + case "error": + errors += event.code % 7; + break; + } + } + const trees = JSON.parse(treesText) as Expr[]; + const env = new Map([ + ["a", 3], + ["b", 5], + ]); + for (const tree of trees) total = (total + evaluate(tree, env)) % 1000003; +} +console.log(clicks, keys, scrolled, area, loadMs, errors, focus, total); diff --git a/benchmarks/runtime/workloads/keyed-option-writes.json b/benchmarks/runtime/workloads/keyed-option-writes.json new file mode 100644 index 000000000..802911e7d --- /dev/null +++ b/benchmarks/runtime/workloads/keyed-option-writes.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/keyed-option-writes.ts", + "args": [ + "4" + ] +} diff --git a/benchmarks/runtime/workloads/keyed-option-writes.ts b/benchmarks/runtime/workloads/keyed-option-writes.ts new file mode 100644 index 000000000..95cdbb86b --- /dev/null +++ b/benchmarks/runtime/workloads/keyed-option-writes.ts @@ -0,0 +1,76 @@ +// Class methods whose names are also keys of option records written through +// element accesses: `enabled[name] = true` over a union of literal names and +// `totals["count"] = n`. The writes store booleans and numbers into plain +// records, so the methods keep their native calls. +type Step = "scale" | "offset" | "clamp"; + +const enabled: Record = { scale: false, offset: false, clamp: false }; +function enable(name: Step): void { + enabled[name] = true; +} + +const totals: Record = {}; + +class Transform { + private readonly factor: number; + private readonly shift: number; + constructor(factor: number, shift: number) { + this.factor = factor; + this.shift = shift; + } + scale(x: number): number { + return enabled.scale ? x * this.factor : x; + } + offset(x: number): number { + return enabled.offset ? x + this.shift : x; + } + clamp(x: number): number { + if (!enabled.clamp) return x; + return x < 0 ? 0 : x > 1000 ? 1000 : x; + } +} + +class Counter { + private seen = 0; + private sum = 0; + add(value: number): void { + this.seen++; + this.sum += value; + } + count(): number { + return this.seen; + } + mean(): number { + return this.seen === 0 ? 0 : this.sum / this.seen; + } +} + +const scale = Number(process.argv[2] ?? "1"); +const steps: Step[] = ["scale", "offset", "clamp"]; +for (const step of steps) enable(step); + +const transforms: Transform[] = []; +for (let i = 0; i < 16; i++) transforms.push(new Transform(1 + (i % 5) * 0.25, (i % 7) - 3)); +const counters: Counter[] = []; +for (let i = 0; i < 8; i++) counters.push(new Counter()); + +let seed = 12345; +let checksum = 0; +const rounds = Math.floor(2_000_000 * scale); +for (let i = 0; i < rounds; i++) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + const t = transforms[seed & 15]!; + const value = t.clamp(t.offset(t.scale((seed >>> 8) % 1200))); + const counter = counters[(seed >>> 4) & 7]!; + counter.add(value); + checksum += value; + if ((i & 65535) === 0) totals["count"] = counter.count(); +} + +let mean = 0; +let seen = 0; +for (const counter of counters) { + mean += counter.mean(); + seen += counter.count(); +} +console.log("seen", seen, "count", totals["count"], "mean", (mean / counters.length).toFixed(4), "checksum", checksum); diff --git a/docs/content/limitations/builtin-apis/url.mdx b/docs/content/limitations/builtin-apis/url.mdx index c9eee85b3..dddf2a8f7 100644 --- a/docs/content/limitations/builtin-apis/url.mdx +++ b/docs/content/limitations/builtin-apis/url.mdx @@ -1,5 +1,4 @@ -- **URL:** Hosts of special URLs that hold non-ASCII text, or percent-escapes that decode to it, throw `ERR_INVALID_URL`, and `xn--` labels are kept without Punycode validation. - Absolute inputs and relative inputs with runtime string or URL bases, including optional bases, compile. +- **URL:** Absolute inputs and relative inputs with runtime string or URL bases, including optional bases, compile. `URL.canParse`, `URL.parse`, `toString()`, and `toJSON()` compile; the factories use the same parser as construction, returning `false` or `null` for rejected inputs while preserving conversion errors. `href`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash` support assignment in native code. Invalid component writes are ignored except for invalid `href` values, which throw; credentials are percent-encoded and ports accept valid leading digits. diff --git a/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx b/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx index baff4d190..45659b493 100644 --- a/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx +++ b/docs/content/limitations/types-and-class-definitions/symbol-fields.mdx @@ -1,4 +1,5 @@ - **Symbol fields:** Instance fields require a stable module-level `Symbol()` or `Symbol.for()` binding with a literal description or constant string binding. + A `[Symbol.toStringTag]` field or getter with a string value sets the tag that `String()`, `Object.prototype.toString` and `util.inspect` print; fields keyed by other well-known symbols are unsupported. Reassigned keys, runtime-computed descriptions, distinct symbols with the same description in one class, and copying symbol fields with rest bindings or `Object.assign` are unsupported. JavaScript object spreads copy represented own symbol fields. Equal registry keys share a field across modules. diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts index 90909a024..5c40bb4ce 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts @@ -760,3 +760,154 @@ test("a parameter stored as its last use takes the owned convention", () => { const passed = ctor([{ kind: "exprStmt", expr: call("use", [ref("item", cell)]), loc }]); expect(analyze(passed).borrowed.get("%Box.constructor")).toEqual(new Set([0, 1])); }); + +test("a wrap into a pointer union forwards the wrapped binding to the callee's parameter", () => { + const wrap = (id: string): IrExpr => ({ + kind: "unionWrap", + unionId: "optional", + tag: 0, + value: ref(id, record), + type: optional, + loc, + }); + const leaf = helper("leaf"); + const outer = helper("outer"); + outer.params[0]!.type = outer.locals[0]!.type = record; + outer.locals.push(local("cell", record)); + outer.body = [ + { + kind: "varDecl", + localId: "cell", + init: { kind: "recordLit", fields: [], type: record, loc }, + loc, + }, + { kind: "exprStmt", expr: call("leaf", [wrap("value")]), loc }, + ret(call("leaf", [wrap("cell")])), + ]; + const functions = new Map([leaf, outer].map((fn) => [fn.name, fn])); + const analyzeWith = (pointer: boolean) => + analyzeCallLifetimes( + functions, + undefined, + undefined, + undefined, + undefined, + undefined, + () => pointer, + ); + // A boxed union builds a new box around the payload: a whole-value use. + expect(analyzeWith(false).parameters.get("outer")).toBeUndefined(); + expect(analyzeWith(false).locals.get("outer")).toEqual(new Set()); + // A pointer union passes the payload pointer itself. + expect(analyzeWith(true).parameters.get("outer")).toEqual(new Set([0])); + expect(analyzeWith(true).locals.get("outer")).toEqual(new Set(["cell"])); + // A callee that keeps its parameter keeps the wrapped binding too. + leaf.body.push(ret(ref("value"))); + expect(analyzeWith(true).parameters.get("outer")).toBeUndefined(); + expect(analyzeWith(true).locals.get("outer")).toEqual(new Set()); +}); + +test("a pointer-wrapped binding needs a callee that keeps the narrowed payload in place", () => { + const wrapCell: IrExpr = { + kind: "unionWrap", + unionId: "optional", + tag: 0, + value: ref("cell", record), + type: optional, + loc, + }; + const outer = helper("outer", []); + outer.locals.push(local("cell", record)); + outer.body = [ + { + kind: "varDecl", + localId: "cell", + init: { kind: "recordLit", fields: [], type: record, loc }, + loc, + }, + ret(call("leaf", [wrapCell])), + ]; + const read = (value: IrExpr): IrExpr => ({ + kind: "recordGet", + obj: value, + shapeId: "cell", + field: "x", + type: F64, + loc, + }); + const keeps = (leaf: IrFunction, ...more: IrFunction[]): boolean => { + const result = analyzeCallLifetimes( + new Map([outer, leaf, ...more].map((fn) => [fn.name, fn])), + (className) => className === "Node", + undefined, + undefined, + undefined, + undefined, + () => true, + ); + return result.locals.get("outer")?.has("cell") !== true; + }; + const leafWith = (body: IrStmt[]): IrFunction => ({ ...helper("leaf"), body }); + // Tag tests and field reads of the narrowed payload leave it in place. + expect(keeps(leafWith([{ kind: "exprStmt", expr: tag("value"), loc }]))).toBe(false); + expect(keeps(leafWith([ret(read(narrow("value")))]))).toBe(false); + // So do field reads through a checked narrowing, which throws on the + // other arm (`value.x` of a `T | undefined` parameter). + const checked: IrExpr = { + kind: "ternary", + cond: tag("value"), + then: { kind: "libCall", fn: "error.nodeThrow", args: [], type: record, loc }, + else_: narrow("value"), + type: record, + loc, + }; + expect(keeps(leafWith([ret(read(checked))]))).toBe(false); + // Returning it keeps the payload. + const returnsChecked = leafWith([ret(checked)]); + returnsChecked.returnType = record; + expect(keeps(returnsChecked)).toBe(true); + // Returning the narrowed payload, or a narrowed nullish read, keeps it, + // although the union itself is only projected. + const returned = leafWith([ret(narrow("value"))]); + returned.returnType = record; + expect(analyze(returned).parameters.get("leaf")).toEqual(new Set([0])); + expect(keeps(returned)).toBe(true); + const defaulted = leafWith([ + ret({ kind: "nullish", left: ref("value"), right: ref("other", record), type: record, loc }), + ]); + defaulted.locals.push(local("other", record)); + expect(keeps(defaulted)).toBe(true); + // So does a nullable-pointer field store of the union. + const self: IrType = { kind: "object", className: "Node" }; + const stored = leafWith([ + { + kind: "fieldSet", + obj: ref("this", self), + className: "Node", + field: "next", + value: ref("value"), + loc, + }, + ]); + stored.params = [{ localId: "value", name: "value", type: optional }]; + stored.locals.push(local("this", self)); + expect(keeps(stored)).toBe(true); + // A narrowed payload passed on, or the union passed on, is in place when + // the next callee keeps it in place. + const inner = (body: IrStmt[], type: IrType = record): IrFunction => { + const fn = helper("inner"); + fn.params[0]!.type = fn.locals[0]!.type = type; + fn.body = body; + return fn; + }; + const passNarrowed = leafWith([ret(call("inner", [narrow("value")]))]); + expect(keeps(passNarrowed, inner([ret(read(ref("value", record)))]))).toBe(false); + const returnsRecord = inner([ret(ref("value", record))]); + returnsRecord.returnType = record; + expect(keeps(passNarrowed, returnsRecord)).toBe(true); + const passUnion = leafWith([ret(call("inner", [ref("value")]))]); + expect(keeps(passUnion, inner([ret(read(narrow("value")))], optional))).toBe(false); + const innerReturned = inner([ret(narrow("value"))], optional); + innerReturned.returnType = record; + expect(keeps(passUnion, innerReturned)).toBe(true); +}); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index db5bdb574..becd24805 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -14,6 +14,11 @@ import { storedLastUseRead } from "./local-extents.js"; interface ForwardedUse { callee: string; index: number; + /** The binding reached the call inside a nullable-pointer union + * (PointerWrapTest): the callee's parameter holds the binding itself as + * its payload, so the callee must keep the payload, not only the union, + * in place. */ + wrapped: boolean; } interface Uses { @@ -21,6 +26,13 @@ interface Uses { written: Set; declarations: Map; forwards: Map; + /** Union bindings whose payload a use may keep: a narrowed read that is + * not projected or passed on, a narrowed nullish read, a store into a + * nullable-pointer field. The union itself can still be only projected. */ + payloadKept: Set; + /** Narrowed payloads of union bindings passed directly as call arguments: + * the payload stays in place if the callee's parameter is proved. */ + payloadForwards: Map; } interface Parameter { @@ -99,19 +111,55 @@ const NO_INLINE_FIELDS: InlineFieldTest = { record: () => false, classField: () * reads the record and keeps no reference to it. */ export type RecordDynCopyTest = (shapeId: string) => boolean; +/** Wraps into a nullable-pointer union whose value is the payload pointer + * itself (LlEmitter.borrowableNullableWrap): the wrapped binding reaches the + * callee's parameter unchanged. */ +export type PointerWrapTest = (wrap: IrExpr & { kind: "unionWrap" }) => boolean; + function collectUses( fn: IrFunction, nullableField: NullableFieldTest, registerReturn: boolean, inlineField: InlineFieldTest, recordDynCopy: RecordDynCopyTest, + pointerWrap: PointerWrapTest, ): Uses { const uses: Uses = { invalid: new Set(), written: new Set(), declarations: new Map(), forwards: new Map(), + payloadKept: new Set(), + payloadForwards: new Map(), }; + const forward = ( + table: Map, + localId: string, + use: ForwardedUse, + ): void => { + let forwards = table.get(localId); + if (!forwards) table.set(localId, (forwards = [])); + forwards.push(use); + }; + /** The union binding `x` whose payload `value` is: `unionNarrow(x)`, or a + * checked narrowing `unionIsTag(x) ? : unionNarrow(x)` (either + * order), whose other branch is visited as any use. */ + function narrowedBinding(value: IrExpr): string | null { + if (value.kind === "unionNarrow") + return value.value.kind === "varRef" ? value.value.localId : null; + if ( + value.kind !== "ternary" || + value.cond.kind !== "unionIsTag" || + value.cond.value.kind !== "varRef" + ) + return null; + const id = value.cond.value.localId; + if (value.else_.kind === "libCall" && narrowedBinding(value.then) === id) + return expr(value.else_) ? id : null; + if (value.then.kind === "libCall" && narrowedBinding(value.else_) === id) + return expr(value.then) ? id : null; + return null; + } const aliases: { alias: string; source: string }[] = []; const stringInput = (value: IrExpr): boolean => { if (value.kind === "varRef" && value.type.kind === "string") return true; @@ -145,6 +193,12 @@ function collectUses( if (borrowsMapReadInputs(node)) return expr(node.receiver) && node.args.every(stringInput); break; case "unionNarrow": + // The payload may escape; the union itself does not. + if (node.value.kind === "varRef") { + uses.payloadKept.add(node.value.localId); + return true; + } + break; case "unionIsTag": if (node.value.kind === "varRef") return true; break; @@ -161,12 +215,16 @@ function collectUses( node.left.kind === "varRef" && node.left.type.kind === "union" && node.type.kind !== "union" - ) + ) { + uses.payloadKept.add(node.left.localId); return expr(node.right); + } break; case "fieldGet": case "recordGet": - if (node.obj.kind === "varRef") return true; + // A field read of a binding, or of a union binding's narrowed + // payload, keeps neither. + if (node.obj.kind === "varRef" || narrowedBinding(node.obj) !== null) return true; break; case "dynFrom": // A copied record converts field by field; a live capsule keeps it. @@ -200,13 +258,19 @@ function collectUses( return node.args.every(expr); case "call": node.args.forEach((arg, index) => { - if (arg.kind !== "varRef") { + const narrowed = narrowedBinding(arg); + if (narrowed !== null) { + forward(uses.payloadForwards, narrowed, { callee: node.callee, index, wrapped: false }); + return; + } + const wrapped = + arg.kind === "unionWrap" && arg.value.kind === "varRef" && pointerWrap(arg); + const value = wrapped ? arg.value : arg; + if (value.kind !== "varRef") { expr(arg); return; } - let forwards = uses.forwards.get(arg.localId); - if (!forwards) uses.forwards.set(arg.localId, (forwards = [])); - forwards.push({ callee: node.callee, index }); + forward(uses.forwards, value.localId, { callee: node.callee, index, wrapped }); }); return true; case "assignExpr": @@ -250,8 +314,11 @@ function collectUses( node.value.kind === "varRef" && node.value.type.kind === "union" && nullableField(node.className, node.field) - ) + ) { + // The field retains the payload. + uses.payloadKept.add(node.value.localId); return expr(node.obj); + } const stored = fieldStore( node.obj, node.value, @@ -345,10 +412,14 @@ export function analyzeCallLifetimes( registerReturns: ReadonlySet = new Set(), inlineField: InlineFieldTest = NO_INLINE_FIELDS, recordDynCopy: RecordDynCopyTest = () => false, + pointerWrap: PointerWrapTest = () => false, ): CallLifetimes { const usesByFunction = new Map(); const lazyCaptures = new LazyCaptures(functions); const nodes = new Map(); + /** Union parameters that keep their payload in place too, which a + * pointer-wrapped argument needs (ForwardedUse.wrapped). */ + const payloadNodes = new Map(); const unsafe: Parameter[] = []; const consumed: { fn: string; index: number }[] = []; const result: CallLifetimes = { @@ -361,7 +432,7 @@ export function analyzeCallLifetimes( }; for (const fn of functions.values()) { if (fn.generator !== undefined && fn.classCaptures === undefined) { - const uses = collectUses(fn, nullableField, false, inlineField, recordDynCopy); + const uses = collectUses(fn, nullableField, false, inlineField, recordDynCopy, pointerWrap); const locals = new Map(fn.locals.map((local) => [local.id, local])); const stable = new Set(); for (const param of fn.params) { @@ -386,6 +457,7 @@ export function analyzeCallLifetimes( registerReturns.has(fn.name), inlineField, recordDynCopy, + pointerWrap, ); usesByFunction.set(fn.name, uses); // Class-capturing bodies get only local facts (computed below from @@ -460,23 +532,50 @@ export function analyzeCallLifetimes( return node; }), ); + payloadNodes.set( + fn.name, + fn.params.map((param) => { + if (param.type.kind !== "union") return undefined; + const node: Parameter = { safe: !uses.payloadKept.has(param.localId), callers: new Set() }; + if (!node.safe) unsafe.push(node); + return node; + }), + ); } + /** The fact a forwarded use needs from the callee's parameter. */ + const target = (use: ForwardedUse): Parameter | undefined => + use.wrapped ? payloadNodes.get(use.callee)?.[use.index] : nodes.get(use.callee)?.[use.index]; for (const fn of functions.values()) { const params = nodes.get(fn.name); const uses = usesByFunction.get(fn.name); if (!params || !uses) continue; - fn.params.forEach((param, index) => { - const node = params[index]!; + const depend = (node: Parameter, callees: (Parameter | undefined)[]): void => { if (!node.safe) return; - for (const use of uses.forwards.get(param.localId) ?? []) { - const callee = nodes.get(use.callee)?.[use.index]; + for (const callee of callees) { if (!callee) { node.safe = false; unsafe.push(node); - break; + return; } callee.callers.add(node); } + }; + fn.params.forEach((param, index) => { + const node = params[index]!; + const forwards = uses.forwards.get(param.localId) ?? []; + depend(node, forwards.map(target)); + const payload = payloadNodes.get(fn.name)?.[index]; + if (!payload) return; + // The payload stays in place when the union does, when every callee + // the union reaches keeps its payload too, and when every callee its + // narrowed payload reaches proves that parameter. + depend(payload, [ + node, + ...forwards.map((use) => payloadNodes.get(use.callee)?.[use.index]), + ...(uses.payloadForwards.get(param.localId) ?? []).map( + (use) => nodes.get(use.callee)?.[use.index], + ), + ]); }); } for (let i = 0; i < unsafe.length; i++) { @@ -508,7 +607,7 @@ export function analyzeCallLifetimes( ) continue; const forwards = uses.forwards.get(local.id) ?? []; - if (forwards.every((use) => result.parameters.get(use.callee)?.has(use.index) === true)) { + if (forwards.every((use) => target(use)?.safe === true)) { projected.add(local.id); if (eligible(local) && !uses.written.has(local.id)) safe.add(local.id); } diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 6d02daa8b..da31ffdbb 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -193,6 +193,11 @@ function builtinStreamKind(className: string): number | null { return kinds[className] ?? null; } +/** Unions whose discriminant has fewer string literals keep one literal test + * per arm (dispatchValues): one runtime lookup costs about what a few + * literal tests do. */ +const DISCRIMINANT_DISPATCH_VALUES = 4; + export class LlDyn { private readonly dynMatchers = new Map(); private readonly dynBuilders = new Map(); @@ -413,6 +418,109 @@ export class LlDyn { return order; } + /** The string literals of `def`'s discriminant when its arms are chosen + * by reading the discriminant once (unionArmsByDiscriminant), or null + * when the union keeps one literal test per arm: some literal is not a + * string, or there are fewer than DISCRIMINANT_DISPATCH_VALUES. */ + private dispatchValues(def: IrUnionDef): string[] | null { + const values: string[] = []; + for (const guard of def.discriminant?.cases ?? []) { + if (guard.values.length === 0) return null; + for (const value of guard.values) { + if (typeof value !== "string") return null; + if (!values.includes(value)) values.push(value); + } + } + return values.length < DISCRIMINANT_DISPATCH_VALUES ? null : values; + } + + /** Branches to `hits[tag]` for the first arm in `order` that `%d` matches + * (unionArmMatch), or to `none` when no arm does, for + * a union whose discriminant literals are `values` (dispatchValues). The + * discriminant is read once (scr_dyn_discriminant_case, against the + * constant `table`), and only the arms whose literal it holds and the arms + * without a literal are tested, in `order`: every other arm's literal test + * fails. An arm's failed check can run a getter that changes the + * discriminant, so it is read again after each one; when its case + * changed, the tests continue arm by arm after the failed arm, as they + * would have run from the first arm. A value the runtime cannot classify + * that way (a typed capsule, an accessor, an inherited discriminant) takes + * the tests arm by arm. */ + private unionArmsByDiscriminant( + B: BlockBuilder, + def: IrUnionDef, + values: readonly string[], + order: readonly number[], + hits: readonly string[], + none: string, + preserveRefs: boolean, + table: string, + ): void { + const discriminant = def.discriminant!; + const literalsOf = (tag: number): readonly (string | number | boolean)[] | null => + discriminant.cases.find((candidate) => candidate.tag === tag)?.values ?? null; + const host = this.host; + this.defs.push( + `${table} = private unnamed_addr constant [${values.length} x ptr] [${values.map((value) => `ptr ${host.internLiteral(value)}`).join(", ")}]`, + ``, + ); + host.declare(`declare i32 @scr_dyn_discriminant_case(ptr, ptr, ptr, ${host.sizeType})`); + const classify = (): string => { + const index = B.tmp(); + B.line( + `${index} = call i32 @scr_dyn_discriminant_case(ptr %d, ptr ${host.internLiteral(discriminant.field)}, ptr ${table}, ${host.sizeType} ${values.length})`, + ); + return index; + }; + // The arm-by-arm tests, entered at any arm (`from[i]` tests order[i] + // and the arms after it). + const tests = B.newLabel("du.tests"); + const from = order.map((_, i) => (i === 0 ? tests : B.newLabel("du.t"))); + const after = (position: number): string => + position + 1 < order.length ? from[position + 1]! : none; + // Tests the arms a value in case `k` (-1: no literal) can match. + const tryCase = (k: number): void => { + const value = k < 0 ? null : values[k]!; + const positions = order + .map((_, position) => position) + .filter((position) => { + const literals = literalsOf(order[position]!); + return literals === null || (value !== null && literals.includes(value)); + }); + if (positions.length === 0) { + B.br(none); + return; + } + positions.forEach((position, index) => { + const tag = order[position]!; + const failed = B.newLabel("du.c.f"); + this.unionArmStructure(B, def, tag, hits[tag]!, failed, preserveRefs); + B.startBlock(failed); + const next = index === positions.length - 1 ? none : B.newLabel("du.c.n"); + const same = B.tmp(); + B.line(`${same} = icmp eq i32 ${classify()}, ${k}`); + B.condBr(same, next, after(position)); + if (next !== none) B.startBlock(next); + }); + }; + const index = classify(); + const unmatched = B.newLabel("du.none"); + const cases = values.map(() => B.newLabel("du.case")); + B.terminate( + `switch i32 ${index}, label %${tests} [ i32 -1, label %${unmatched} ${cases.map((label, k) => `i32 ${k}, label %${label}`).join(" ")} ]`, + ); + values.forEach((_, k) => { + B.startBlock(cases[k]!); + tryCase(k); + }); + B.startBlock(unmatched); + tryCase(-1); + order.forEach((tag, position) => { + B.startBlock(from[position]!); + this.unionArmMatch(B, def, tag, hits[tag]!, after(position), preserveRefs); + }); + } + /** Branch to a variant only after its literal discriminator and complete * structure match. Use the same path for predicates and builders. */ private unionArmMatch( @@ -450,6 +558,18 @@ export class LlDyn { }); B.startBlock(structure); } + this.unionArmStructure(B, def, tag, yes, no, preserveRefs); + } + + /** The part of unionArmMatch past the arm's literal test. */ + private unionArmStructure( + B: BlockBuilder, + def: IrUnionDef, + tag: number, + yes: string, + no: string, + preserveRefs: boolean, + ): void { const matches = B.tmp(); B.line( `${matches} = call zeroext i1 @${this.dynMatchHelper(def.arms[tag]!, preserveRefs)}(ptr %d)`, @@ -1278,11 +1398,26 @@ export class LlDyn { ); // Arms in canonical order; any full match answers true. const yes = B.newLabel("dm.y"); - for (let tag = 0; tag < def.arms.length; tag++) { - const ln = B.newLabel("dm.n"); - this.unionArmMatch(B, def, tag, yes, ln, preserveRefs); - B.startBlock(ln); - } + const values = this.dispatchValues(def); + if (values !== null) { + const no = B.newLabel("dm.n"); + this.unionArmsByDiscriminant( + B, + def, + values, + def.arms.map((_, tag) => tag), + def.arms.map(() => yes), + no, + preserveRefs, + `@${name}.cases`, + ); + B.startBlock(no); + } else + for (let tag = 0; tag < def.arms.length; tag++) { + const ln = B.newLabel("dm.n"); + this.unionArmMatch(B, def, tag, yes, ln, preserveRefs); + B.startBlock(ln); + } B.terminate(`ret i1 false`); B.startBlock(yes); B.terminate(`ret i1 true`); @@ -2462,12 +2597,8 @@ export class LlDyn { ); // Arms in unionArmOrder, first FULL match wins. The matched arm's // builder can no longer fail. - for (const i of this.unionArmOrder(def)) { + const build = (i: number): void => { const arm = def.arms[i]!; - const lHit = B.newLabel("dcu.h"); - const lNext = B.newLabel("dcu.n"); - this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); - B.startBlock(lHit); if (arm.kind === "undefinedT" || arm.kind === "nullT") { // A matched unit arm builds nothing: THE interned immortal // instance (rc == SIZE_MAX — no retain owed). @@ -2506,8 +2637,36 @@ export class LlDyn { ); B.terminate(`ret ptr ${u}`); } - B.startBlock(lNext); - } + }; + const order = this.unionArmOrder(def); + const values = this.dispatchValues(def); + if (values !== null) { + const hits = def.arms.map(() => B.newLabel("dcu.h")); + const unmatched = B.newLabel("dcu.n"); + this.unionArmsByDiscriminant( + B, + def, + values, + order, + hits, + unmatched, + preserveRefs, + `@${name}.cases`, + ); + for (const i of order) { + B.startBlock(hits[i]!); + build(i); + } + B.startBlock(unmatched); + } else + for (const i of order) { + const lHit = B.newLabel("dcu.h"); + const lNext = B.newLabel("dcu.n"); + this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); + B.startBlock(lHit); + build(i); + B.startBlock(lNext); + } if (preserveRefs) { B.line( `call void @scr_dyn_check_fail(ptr %path, ptr ${host.cstr("reference with its original layout")}, ptr %d)`, diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 5aa18bf9f..7f4ac73b6 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -344,6 +344,8 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // Fetch-referencing programs register native fetch before main. // Runtime selection compiles scr_fetch.c on the same predicate. const usesFetch = runtimeFeatures.fetch; + // URL programs give the host parser the IDNA unit's ToASCII. + const usesUrlIdna = runtimeFeatures.urlIdna; const snapshotsTlsCa = runtimeFeatures.tls || runtimeFeatures.tlsCa; // The process verdict has the same precedence as the C reference // emitter: node:test owns the final status when present; otherwise an @@ -396,6 +398,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { if (usesHttp2) emitter.declare(`declare void @scr_http2_dyn_install()`); if (usesHttp) emitter.declare(`declare void @scr_http_dyn_install()`); if (usesFetch) emitter.declare(`declare void @scr_fetch_install()`); + if (usesUrlIdna) emitter.declare(`declare void @scr_url_idna_install()`); if (usesNodeTest) { emitter.declare(`declare i32 @scr_test_exit_code()`); @@ -1027,6 +1030,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { ...(emitter.ffiHasForeignCallback ? [` call void @scr_ffi_install()`] : []), ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), ...(usesFetch ? [` call void @scr_fetch_install()`] : []), + ...(usesUrlIdna ? [` call void @scr_url_idna_install()`] : []), ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), ...(usesNetLookup ? [` call void @scr_net_lookup_install()`] : []), ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 1135107d3..608298aa7 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1809,6 +1809,7 @@ export class LlEmitter { const shape = this.recordsById.get(shapeId); return shape !== undefined && !recordDynKeepsIdentity(shape); }, + (wrap) => this.borrowableNullableWrap(wrap) === "ref", ); this.lentStringParameters = this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map(); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 46c2d884f..675932d47 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -20,6 +20,7 @@ import { isRefCounted, shapeHasAccessorSlots, typeKey, + type IrClassDef, } from "../../ir/ir.js"; import { mangleFunction, mangleGlobal, mangleRecordStruct } from "../mangle.js"; import { BlockBuilder } from "./blocks.js"; @@ -272,9 +273,7 @@ export function streamTypedRefCommitAdapter( ); }); } - const symbols = new Map( - (meta.def.symbolFields ?? []).map((symbol) => [symbol.field, symbol.globalId]), - ); + const symbols = new Map((meta.def.symbolFields ?? []).map((symbol) => [symbol.field, symbol])); const fields = meta.def.fields.filter( (field) => (isClassOwnEnumerableFieldName(field.name) || symbols.has(field.name)) && @@ -294,13 +293,15 @@ export function streamTypedRefCommitAdapter( const inline = host.inlineUnions.classField(t.className, field.name); const fieldTy = int32 ? "i32" : llFieldType(field.type, inline); const checkTy = host.llType(field.type); - const symbolGlobal = symbols.get(field.name); - if (symbolGlobal) { + const symbolKey = symbols.get(field.name); + if (symbolKey) { host.declare(`declare ptr @scr_dyn_symbol_key_get(ptr, ptr, i1 zeroext)`); host.declare(`declare void @scr_dyn_release_v(ptr)`); + const keyLines = symbolKeyLines(host, symbolKey, `f${index}_keyraw`); lines.push( - ` %f${index}_keyraw = load ptr, ptr @${mangleGlobal(symbolGlobal)}`, + ...keyLines.load, ` %f${index}_key = call ptr @${host.dyn.toDynHelper(SYMBOL_T)}(ptr %f${index}_keyraw)`, + ...keyLines.release, ); if (field.type.kind === "symbol") { // A base constructor can mutate the instance before this @@ -328,7 +329,7 @@ export function streamTypedRefCommitAdapter( lines.push( ` %${raw} = call ptr @scr_dyn_obj_get(ptr %d, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")})`, ); - if (meta.def.tracksOwnFields && !symbolGlobal) + if (meta.def.tracksOwnFields && !symbolKey) lines.push( ` %f${index}_present = icmp ne ptr %${raw}, null`, ` br i1 %f${index}_present, label %f${index}_read, label %${after}`, @@ -339,7 +340,7 @@ export function streamTypedRefCommitAdapter( ` %${undef} = call ptr @scr_dyn_undefined()`, ` %${input} = select i1 %${missing}, ptr %${undef}, ptr %${raw}`, ` %${next} = call ${field.type.kind === "bool" ? "zeroext " : ""}${checkTy} @${host.dyn.dynCheckHelper(field.type)}(ptr %${input}, ptr null)`, - ...(symbolGlobal ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []), + ...(symbolKey ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []), ...host.pendingTestLines(`%${pending}`), ` br i1 %${pending}, label %done, label %${store}`, `${store}:`, @@ -494,16 +495,18 @@ export function streamTypedRefCommitAdapter( ? `declare void @scr_dyn_symbol_key_placeholder(ptr, ptr)` : `declare void @scr_dyn_key_delete_computed(ptr, ptr, i1 zeroext)`, ); - [...symbols.values()].forEach((globalId, index) => + [...symbols.values()].forEach((symbol, index) => { + const keyLines = symbolKeyLines(host, symbol, `bag_symraw${index}`); lines.push( - ` %bag_symraw${index} = load ptr, ptr @${mangleGlobal(globalId)}`, + ...keyLines.load, ` %bag_sym${index} = call ptr @${host.dyn.toDynHelper(SYMBOL_T)}(ptr %bag_symraw${index})`, + ...keyLines.release, meta.def.tracksOwnFields ? ` call void @scr_dyn_symbol_key_placeholder(ptr %bag, ptr %bag_sym${index})` : ` call void @scr_dyn_key_delete_computed(ptr %bag, ptr %bag_sym${index}, i1 zeroext false)`, ` call void @scr_dyn_release_v(ptr %bag_sym${index})`, - ), - ); + ); + }); const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); lines.push( ` %bag_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${index}`, @@ -700,6 +703,27 @@ export function liveDynUnionRefAdapter( return sym; } +/** The raw ScrSym of a class's symbol field key into `%name`: a module + * key is borrowed from its global, a well-known one comes from the + * runtime's registry (+1, released by the `release` lines once the key + * value holds it). */ +function symbolKeyLines( + host: LlvmEmitterContext, + symbol: NonNullable[number], + name: string, +): { load: string[]; release: string[] } { + if ("globalId" in symbol) + return { load: [` %${name} = load ptr, ptr @${mangleGlobal(symbol.globalId)}`], release: [] }; + host.declare("declare ptr @scr_sym_well_known(ptr)"); + host.declare("declare void @scr_sym_release(ptr)"); + return { + load: [ + ` %${name} = call ptr @scr_sym_well_known(ptr ${host.internLiteral(symbol.wellKnown)})`, + ], + release: [` call void @scr_sym_release(ptr %${name})`], + }; +} + export function streamTypedRefBoxValue( host: LlvmEmitterContext, B: BlockBuilder, @@ -924,8 +948,10 @@ export function streamTypedRefMaterializeAdapter( B.line(`${fieldValue} = sitofp i32 ${raw} to double`); } else if (inline) fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true); else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`); - B.line(`${rawKey} = load ptr, ptr @${mangleGlobal(symbol.globalId)}`); + const keyLines = symbolKeyLines(host, symbol, rawKey.slice(1)); + for (const line of keyLines.load) B.line(line.trim()); const key = host.streamTypedRefBoxValue(B, SYMBOL_T, rawKey); + for (const line of keyLines.release) B.line(line.trim()); const afterSymbol = meta.def.tracksOwnFields || field.type.kind === "symbol" ? B.newLabel("symbol.after") @@ -1490,9 +1516,26 @@ function liveClassOps(host: LlvmEmitterContext, snapshot: string, meta: LlClassM // The constructor's name, as util.inspect prints before the fields. const jsName = meta.def.jsName ?? meta.def.name.replace(/^%/, ""); const className = jsName === "" ? "null" : host.internLiteral(jsName); + // Its Symbol.toStringTag getter's string, for Object.prototype.toString + // and util.inspect. + let stringTag = "null"; + if (meta.def.stringTagHelper) { + // The runtime lends the instance; the helper takes its own reference. + stringTag = `@${snapshot}_ctag`; + const retain = vAdapters(host.shapeHost, { kind: "object", className: meta.def.name }).retain; + host.resolveThunkDefs.push( + `define internal ptr ${stringTag}(ptr %p) ${FN_ATTRS} { ; Symbol.toStringTag of ${meta.def.name}`, + `entry:`, + ` %owned = call ptr ${retain}(ptr %p)`, + ` %tag = call ptr @${mangleFunction(meta.def.stringTagHelper)}(ptr %owned)`, + ` ret ptr %tag`, + `}`, + ``, + ); + } host.resolveThunkDefs.push( `@${names} = internal constant [${members.length} x ptr] [${literals.join(", ")}]`, - `@${ops} = internal constant { ptr, ${host.sizeType}, ptr, ptr } { ptr ${exact}, ${host.sizeType} ${members.length}, ptr @${names}, ptr ${className} }`, + `@${ops} = internal constant { ptr, ${host.sizeType}, ptr, ptr, ptr } { ptr ${exact}, ${host.sizeType} ${members.length}, ptr @${names}, ptr ${className}, ptr ${stringTag} }`, ``, ); } diff --git a/packages/compiler/src/backend/vendor-archives.ts b/packages/compiler/src/backend/vendor-archives.ts index dbcaf2d36..ef63aa4e4 100644 --- a/packages/compiler/src/backend/vendor-archives.ts +++ b/packages/compiler/src/backend/vendor-archives.ts @@ -14,6 +14,10 @@ import type { CcDriver } from "./native/driver.js"; const execFileAsync = promisify(execFile); +/** The section flags the libregexp/libunicode objects build with (the + * runtime pack's executable_section_elimination flags). */ +const LRE_SECTION_FLAGS = ["-ffunction-sections", "-fdata-sections"]; + import { QJS_COMMIT, MBEDTLS_VERSION, @@ -191,13 +195,13 @@ export function createVendorArchives(context: VendorArchiveContext) { (sanitize ? "asan" : "plain") + (driver.argv.length === 1 && driver.argv[0] === "clang" ? "" : "-zigcc") + `-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`; - const cacheDir = join(cacheRoot, `${QJS_COMMIT.slice(0, 12)}-lre-${flavor}`); + const cacheDir = join(cacheRoot, `${QJS_COMMIT.slice(0, 12)}-lre-sections-${flavor}`); return LRE_SOURCES.map((f) => join(cacheDir, f.replace(/\.c$/, ".o"))); } /** The libregexp objects for one flavor, compiled lazily on the first * regex-using build (~1s) and cached under - * /vendor/-lre---/*.o — with the same atomic-rename + * /vendor/-lre-sections---/*.o — with the same atomic-rename * publish (parallel first builds race safely; losers discard their work). * Plain is -Os to keep regex-using binaries small; asan matches the final link so * the sanitized lane instruments the matcher too. Cross targets get their @@ -228,6 +232,10 @@ export function createVendorArchives(context: VendorArchiveContext) { vendor: vendorEngineDir(), sources: LRE_SOURCES, objects: lreObjectPaths(sanitize, driver, buildIdentity, cacheRoot), + // Section elimination keeps only the libunicode tables a program + // reaches: the URL host parser's NFC links the normalization tables + // without the regex property ones. + flags: LRE_SECTION_FLAGS, }); } @@ -296,6 +304,7 @@ export function createVendorArchives(context: VendorArchiveContext) { vendor: string; sources: readonly string[]; objects: string[]; + flags?: readonly string[]; }): Promise { const { sanitize, driver, cacheRoot, flavor, name, vendor, sources, objects } = options; if ((await Promise.all(objects.map(validVendorArtifact))).every(Boolean)) return objects; @@ -312,6 +321,7 @@ export function createVendorArchives(context: VendorArchiveContext) { "-std=c11", ...driver.targetArgs, ...(sanitize ? ["-O1", "-fsanitize=address"] : ["-Os"]), + ...(options.flags ?? []), "-I", vendor, "-c", diff --git a/packages/compiler/src/contracts/int32-elements.ts b/packages/compiler/src/contracts/int32-elements.ts index ca61a8e49..67446f2d1 100644 --- a/packages/compiler/src/contracts/int32-elements.ts +++ b/packages/compiler/src/contracts/int32-elements.ts @@ -709,7 +709,8 @@ export class Int32ElementContracts { const findChainsIn = (st: IrStmt): boolean => everyStmtChild(st, findChains, findChainsIn); for (const st of fn.body) findChainsIn(st); // Parameters whose callers are not all visible hold opaque arrays. - if (this.valueEscaped.has(fn.name) || this.runtimeVirtual(fn.name)) + const runtimeVirtual = this.runtimeVirtual; + if (this.valueEscaped.has(fn.name) || runtimeVirtual(fn.name)) for (const p of fn.params) if (this.holds(p.type)) { const why = this.valueEscaped.has(fn.name) diff --git a/packages/compiler/src/frontend/lowering/builtins/webassembly.ts b/packages/compiler/src/frontend/lowering/builtins/webassembly.ts index 8e5c3a048..f33ebe9fe 100644 --- a/packages/compiler/src/frontend/lowering/builtins/webassembly.ts +++ b/packages/compiler/src/frontend/lowering/builtins/webassembly.ts @@ -403,7 +403,10 @@ export function lowerWebAssemblyValueOf( recv: IrExpr, loc: IrExpr["loc"], ): IrExpr | null { - const ctx = { checker: lowerer.checker, isStdlibFile: lowerer.isStdlibFile }; + const ctx = { + checker: lowerer.checker, + isStdlibFile: (sf: ts.SourceFile) => lowerer.isStdlibFile(sf), + }; if (!isWebAssemblyType(lowerer.typeOf(receiver), ctx)) return null; lowerer.usesWebAssembly = true; return { kind: "libCall", fn: "wasm.valueOf", args: [recv], type: DYN, loc }; diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index f219fd5e5..fc6d0a922 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,5 @@ import { primitiveConstructorOr } from "./lower-exprs.js"; +import { TO_STRING_TAG_FIELD, wellKnownSymbolOfField } from "./symbol-fields.js"; import { InternalCompilerError } from "../../errors.js"; import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js"; import { constructedValue } from "./constructor-returns.js"; @@ -26,6 +27,7 @@ import { isUnitType, typeEquals, typeKey, + type IrClassDef, type IrExpr, type IrFunction, type IrParam, @@ -153,6 +155,41 @@ const STREAM_NUM_PROPERTIES = new Set([ const streamProperty = (name: string): boolean => STREAM_BOOL_PROPERTIES.has(name) || STREAM_NUM_PROPERTIES.has(name); +/** `(instance) => string`, the string a class's inherited + * Symbol.toStringTag getter answers, which the runtime reads for + * Object.prototype.toString and util.inspect of an instance seen as + * `unknown`. None for a class without a string getter, or whose own tag + * field shadows it. */ +function stringTagHelper(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): IrFunction | null { + if (info.fields.has(TO_STRING_TAG_FIELD)) return null; + const getter = findMethodOn(lowerer, info, "get:sym:toStringTag"); + if (getter?.sig.ret.kind !== "string") return null; + const type: IrType = { kind: "object", className: info.def.name }; + const owner = getter.declarer.def.name; + return { + name: `%class.stringTag:${info.def.name}`, + params: [{ localId: "this", name: "this", type }], + locals: [{ id: "this", name: "this", type, mutable: false }], + returnType: STRING, + body: [ + { + kind: "return", + value: accessorCall( + lowerer, + owner, + "get:sym:toStringTag", + lowerer.upcastTo(varRef("this", type, loc), owner), + [], + STRING, + loc, + ), + loc, + }, + ], + loc, + }; +} + function symbolMemberKey( lowerer: Lowerer, info: ClassInfo, @@ -1375,10 +1412,27 @@ export class ClassDynamicDispatch { const info = lowerer.classes.get(className); if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue; if (info.symbolFields && info.def.symbolFields === undefined) { - info.def.symbolFields = [...info.symbolFields].flatMap(([symbol, field]) => { - const global = lowerer.globalsBySymbol.get(symbol); - return global?.type.kind === "symbol" ? [{ field, globalId: global.id }] : []; - }); + info.def.symbolFields = [...info.symbolFields].flatMap( + ([symbol, field]): NonNullable => { + const global = lowerer.globalsBySymbol.get(symbol); + if (global?.type.kind === "symbol") return [{ field, globalId: global.id }]; + const wellKnown = wellKnownSymbolOfField(field); + return wellKnown === null ? [] : [{ field, wellKnown }]; + }, + ); + } + const tagged = (current: ClassInfo): ClassInfo[] => [ + current, + ...current.subclasses.flatMap(tagged), + ]; + for (const current of tagged(info)) { + if (current.def.stringTagHelper !== undefined) continue; + const fn = stringTagHelper(lowerer, current, functions[0]!.loc); + if (!fn) continue; + current.def.stringTagHelper = fn.name; + this.generated.add(fn); + lowerer.liftedFns.push(fn); + changed = true; } if (this.prototypeHelper && !this.prototypeClasses.has(className)) { this.prototypeClasses.add(className); @@ -3662,10 +3716,29 @@ export class ClassDynamicDispatch { name, lowerer.coerceToExpected(varRef(global.id, global.type, loc), DYN), ); + } else if (wellKnownSymbolOfField(name) !== null) { + // A field keyed by a well-known symbol (Symbol.toStringTag). + symbols.set(name, { + kind: "dynFrom", + type: DYN, + loc, + value: { + kind: "libCall", + fn: "sym.wellKnown", + args: [ + { kind: "strLit", value: wellKnownSymbolOfField(name)!, type: STRING, loc }, + ], + type: SYMBOL_T, + loc, + }, + }); } } for (let owner: ClassInfo | null = info; owner; owner = owner.base) { - for (const name of [...owner.methods.keys()]) + for (const method of [...owner.methods.keys()]) { + // Accessors key by the property they define (`get [Symbol.toStringTag]`). + const name = + method.startsWith("get:") || method.startsWith("set:") ? method.slice(4) : method; if (name.startsWith("sym:")) { symbols.set(name, { kind: "dynFrom", @@ -3680,6 +3753,7 @@ export class ClassDynamicDispatch { }, }); } + } } const branches: IrStmt[] = []; if (!dispatch.write && !dispatch.reflect && !info.def.tracksOwnFields) { diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index 22d2a722a..8f12d15de 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -36,9 +36,20 @@ export function collectClassMethodMutations( const computedWrites: { key: ts.Expression | undefined; receiver: ts.Expression | undefined; + /** The written element access, when the write has one. */ + target?: ts.ElementAccessExpression; site: ts.Node; }[] = []; const propertyWrites: { target: ts.PropertyAccessExpression; site: ts.Node }[] = []; + /** Writes and deletes of a method name through an element access with a + * known key (`o["m"] = f`, `o[k] = f` with `k: "m" | "n"`), judged like + * the named writes in propertyWrites. */ + const keyedWrites: { + name: string; + receiver: ts.Expression; + target: ts.ElementAccessExpression; + site: ts.Node; + }[] = []; const reflection: ts.PropertyAccessExpression[] = []; const globals: ts.Identifier[] = []; const jsAssignments: (ts.PropertyAccessExpression | ts.ElementAccessExpression)[] = []; @@ -274,13 +285,32 @@ export function collectClassMethodMutations( sameBinding(receiver, keys.arguments.nodes[0]!) ); }; - const markKey = (key: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { + /** A write of the known key `name`: through an element access, a write + * to a method name is judged like a named write (keyedWrites). */ + const markKeyed = ( + name: string, + site: ts.Node, + receiver?: ts.Expression, + target?: ts.ElementAccessExpression, + ): void => { + if (receiver && target && methods.has(name) && name !== "prototype" && name !== "__proto__") { + receiverFields.add(name); + keyedWrites.push({ name, receiver, target, site }); + } else markName(name, site); + }; + const markKey = ( + key: ts.Expression, + site: ts.Node, + receiver?: ts.Expression, + target?: ts.ElementAccessExpression, + ): void => { const value = unwrap(key); - if (ts.isStringLiteralLike(value)) markName(value.text, site); - else if (ts.isNumericLiteral(value)) markName(String(Number(value.text)), site); + if (ts.isStringLiteralLike(value)) markKeyed(value.text, site, receiver, target); + else if (ts.isNumericLiteral(value)) + markKeyed(String(Number(value.text)), site, receiver, target); // The key keeps its assertions: `slots[at as number]` and `slots[at!]` // write a number key whatever the type of `at`. - else computedWrites.push({ key, receiver, site }); + else computedWrites.push(target ? { key, receiver, target, site } : { key, receiver, site }); }; const markFields = (value: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { value = unwrap(value); @@ -307,7 +337,7 @@ export function collectClassMethodMutations( propertyWrites.push({ target, site }); } else markName(name, site, arrow); } else if (ts.isElementAccessExpression(target)) - markKey(target.argumentExpression, site, target.expression); + markKey(target.argumentExpression, site, target.expression, target); else if (ts.isArrayLiteralExpression(target)) { for (const element of target.elements.nodes) { if (ts.isSpreadElement(element)) markTarget(element.expression, site); @@ -426,7 +456,19 @@ export function collectClassMethodMutations( (ts.isElementAccessExpression(base) && ts.isStringLiteralLike(base.argumentExpression) && base.argumentExpression.text === "prototype"); - if (prototype) { + // `Object.prototype.toString.call(value)` calls the builtin and + // keeps no reference to it, so it replaces nothing. + const builtinCall = + ts.isPropertyAccessExpression(node) && + ts.isPropertyAccessExpression(base) && + ts.isIdentifier(unwrap(base.expression)) && + lowerer.isStdlibGlobal(unwrap(base.expression) as ts.Identifier, "Object") && + ts.isPropertyAccessExpression(node.parent) && + node.parent.expression === node && + (node.parent.name.text === "call" || node.parent.name.text === "apply") && + ts.isCallExpression(node.parent.parent) && + node.parent.parent.expression === node.parent; + if (prototype && !builtinCall) { if (ts.isPropertyAccessExpression(node)) lowerer.noteMethodAccess(node.name.text, node); else if (ts.isStringLiteralLike(node.argumentExpression)) lowerer.noteMethodAccess(node.argumentExpression.text, node); @@ -611,7 +653,7 @@ export function collectClassMethodMutations( if (lowerer.prototypeMethodAccesses.has(name)) lowerer.noteMethodSite(name, site); else lowerer.noteMethodAccess(name, site); } - for (const { key, receiver, site } of computedWrites) { + for (const { key, receiver, target, site } of computedWrites) { // A class method declared with a well-known symbol key is not a write; // any other write with one can replace that method. const symbolName = key ? wellKnownMethodName(unwrap(key)) : null; @@ -628,7 +670,7 @@ export function collectClassMethodMutations( const type = key ? lowerer.typeOf(key) : undefined; const names = type && (literalValues(type) ?? (key ? literalLoopKeys(unwrap(key)) : null)); if (names) { - for (const name of names) markName(String(name), site); + for (const name of names) markKeyed(String(name), site, receiver, target); continue; } if (key && receiver && preservesExistingMethod(key, receiver, site)) continue; @@ -650,6 +692,19 @@ export function collectClassMethodMutations( markUnknownKey(site); } } + lowerer.checker.prefetchClassCollection( + keyedWrites.flatMap((write) => [write.target, write.receiver]), + [], + ); + for (const { name, receiver, target, site } of keyedWrites) { + if ( + !dictionaryView(receiver) && + (functionFree(lowerer.typeOf(target)) || !methodOwnerCanInhabit(receiver, name)) + ) + continue; + if (lowerer.prototypeMethodAccesses.has(name)) lowerer.noteMethodSite(name, site); + else lowerer.noteMethodAccess(name, site); + } if (numericKey) { for (const name of [...methods, ...arrowFields]) { if (String(Number(name)) === name) markName(name, numericKey); diff --git a/packages/compiler/src/frontend/lowering/class-to-primitive.ts b/packages/compiler/src/frontend/lowering/class-to-primitive.ts index 0b107ab4e..8807ac5a5 100644 --- a/packages/compiler/src/frontend/lowering/class-to-primitive.ts +++ b/packages/compiler/src/frontend/lowering/class-to-primitive.ts @@ -13,6 +13,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js"; import { isClassCallback } from "./class-callbacks.js"; +import { TO_STRING_TAG_FIELD } from "./symbol-fields.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { DATE_TIME_FIELD, dateRootOf } from "./builtins/date-classes.js"; import { dateValueMethod } from "./builtins/dates.js"; @@ -29,7 +30,15 @@ type ConversionMethod = "sym:toPrimitive" | "valueOf" | "toString"; * Date`, or through Object.prototype.toString ("[object Object]"). */ type Plan = | { method: ConversionMethod; ret: IrType } - | { method: null; date?: "toString" | "valueOf" }; + | { method: null; date?: "toString" | "valueOf"; tag?: ToStringTag }; + +/** Where Object.prototype.toString finds a class's Symbol.toStringTag: a + * string field (`[Symbol.toStringTag] = "LRUCache"`) or a getter that + * returns a string, declared on `owner`. */ +interface ToStringTag { + owner: ClassInfo; + getter: boolean; +} const PRIMITIVE_KINDS = new Set(["f64", "string", "bool", "bigint", "undefinedT", "nullT"]); @@ -52,6 +61,17 @@ export function classToStringConversion( ); } +/** Object.prototype.toString of a program class instance: "[object " + + * its Symbol.toStringTag + "]" (an own tag field, else an inherited + * getter), or "[object Object]". The class's own conversion methods do not + * take part. Null as for classToStringConversion, and for Date subclasses. */ +export function classObjectTag(lowerer: Lowerer, value: IrExpr, node: ts.Node): IrExpr | null { + if (value.type.kind !== "object") return null; + const info = lowerer.classes.get(value.type.className); + if (!info || dateRootOf(info) !== null) return null; + return classConversion(lowerer, value, "tag", node, STRING, (tag) => tag); +} + /** ToNumber(ToPrimitive(value, "number")) of a program class instance * (`Number(x)`): `[Symbol.toPrimitive]("number")`, then `valueOf` and then * `toString`, and NaN for "[object Object]". Null as for @@ -122,7 +142,7 @@ function mayBeBigint(lowerer: Lowerer, type: IrType): boolean { function classConversion( lowerer: Lowerer, value: IrExpr, - hint: ConversionHint, + hint: ConversionHint | "tag", node: ts.Node, resultType: IrType, finish: (primitive: IrExpr) => IrExpr, @@ -131,7 +151,7 @@ function classConversion( const info = lowerer.classes.get(value.type.className); if (!info || !plainProgramClass(info)) return null; const classes = subtree(lowerer, info); - for (const cls of classes) refuseReplacedConversions(lowerer, cls, node); + if (hint !== "tag") for (const cls of classes) refuseReplacedConversions(lowerer, cls, node); const plans = new Map(classes.map((cls) => [cls, planOf(lowerer, cls, hint, node)])); // A class whose plan differs from its base's starts a group; the deepest // groups are tested first, so each instance takes its own class's plan. @@ -164,12 +184,52 @@ function classConversion( }; return finish(dateValueMethod(plan.date, time, loc)!); } + if (plan.method === null && plan.tag) { + // Object.prototype.toString: "[object " + the tag + "]". + const owner = plan.tag.owner.def.name; + const tag: IrExpr = plan.tag.getter + ? accessorCall( + lowerer, + owner, + "get:sym:toStringTag", + lowerer.upcastTo(object, owner), + [], + STRING, + loc, + ) + : { + kind: "fieldGet", + obj: lowerer.upcastTo(object, owner), + className: owner, + field: TO_STRING_TAG_FIELD, + type: STRING, + loc, + }; + return finish({ + kind: "strConcat", + left: { + kind: "strConcat", + left: { kind: "strLit", value: "[object ", type: STRING, loc }, + right: tag, + type: STRING, + loc, + }, + right: { kind: "strLit", value: "]", type: STRING, loc }, + type: STRING, + loc, + }); + } if (plan.method === null) return finish({ kind: "strLit", value: "[object Object]", type: STRING, loc }); const args: IrExpr[] = plan.method === "sym:toPrimitive" ? [{ kind: "strLit", value: hint, type: STRING, loc }] : []; return finish(accessorCall(lowerer, cls.def.name, plan.method, object, args, plan.ret, loc)); }; + // "[object Object]" reads nothing of the instance, but the receiver + // expression still runs, once. + const own = plans.get(info)!; + if (tops.length === 0 && own.method === null && !own.date && !own.tag && !isSafeToRepeat(value)) + prefix.push({ kind: "exprStmt", expr: value, loc }); let result = convert(info, receiver); for (let i = tops.length - 1; i >= 0; i--) { const top = tops[i]!; @@ -228,10 +288,28 @@ function depth(info: ClassInfo): number { } function samePlan(a: Plan, b: Plan): boolean { - return ( - a.method === b.method && - (a.method !== null || b.method !== null || a.date === (b as { date?: string }).date) - ); + if (a.method !== null || b.method !== null) return a.method === b.method; + return a.date === b.date && a.tag?.owner === b.tag?.owner && a.tag?.getter === b.tag?.getter; +} + +/** The class's Symbol.toStringTag, when it has one: the class at the top + * of the chain whose layout holds the tag field (an own property, which + * shadows any getter), or its getter. Other tag types would make + * Object.prototype.toString answer "Object" or the value depending on the + * run, and are refused. */ +function toStringTagOf(lowerer: Lowerer, info: ClassInfo, node: ts.Node): ToStringTag | null { + const type = info.fields.get(TO_STRING_TAG_FIELD); + if (type !== undefined) { + if (type.kind !== "string") refuse(lowerer, node, info, "a non-string Symbol.toStringTag"); + let owner = info; + while (owner.base?.fields.has(TO_STRING_TAG_FIELD)) owner = owner.base; + return { owner, getter: false }; + } + const getter = findMethodOn(lowerer, info, "get:sym:toStringTag"); + if (!getter) return null; + if (getter.sig.ret.kind !== "string") + refuse(lowerer, node, info, "a non-string Symbol.toStringTag"); + return { owner: getter.declarer, getter: true }; } /** A conversion method the class declares or inherits (a field of that @@ -246,7 +324,16 @@ function declared( return found ? { ret: found.sig.ret } : null; } -function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: ts.Node): Plan { +function planOf( + lowerer: Lowerer, + info: ClassInfo, + hint: ConversionHint | "tag", + node: ts.Node, +): Plan { + if (hint === "tag") { + const tag = toStringTagOf(lowerer, info, node); + return tag ? { method: null, tag } : { method: null }; + } const exotic = declared(lowerer, info, "sym:toPrimitive"); if (exotic) { if (!primitive(lowerer, exotic.ret)) @@ -271,7 +358,8 @@ function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: t refuse(lowerer, node, info, `a '${method}' method that can return an object`); return { method, ret: found.ret }; } - return { method: null }; + const tag = toStringTagOf(lowerer, info, node); + return tag ? { method: null, tag } : { method: null }; } function primitive(lowerer: Lowerer, type: IrType): boolean { @@ -292,8 +380,6 @@ function refuseReplacedConversions(lowerer: Lowerer, info: ClassInfo, node: ts.N if (lowerer.prototypeMethodAccesses.has(method)) refuse(lowerer, node, info, `a '${method}' the program reaches through a prototype`); } - if (findMethodOn(lowerer, info, "get:sym:toStringTag") || info.fields.has("sym:toStringTag")) - refuse(lowerer, node, info, "a Symbol.toStringTag"); } function refuse(lowerer: Lowerer, node: ts.Node, info: ClassInfo, what: string): never { diff --git a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts index 2f839a20b..ff7da5fd7 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts @@ -1019,7 +1019,9 @@ class FusedLoop { private callee(arg: IrExpr & { type: FuncType }): Callee { const known = this.callees.find((c) => c.arg === arg); if (known) return known; - const target = this.inlineTarget(arg); + // Called as a plain function: the loop is not the target's receiver. + const inlineTarget = this.inlineTarget; + const target = inlineTarget(arg); let callee: Callee; if (target && arg.kind === "closure") { const captures = new Map(); diff --git a/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts b/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts index ba5d0684d..f6580c70e 100644 --- a/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts +++ b/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts @@ -1182,10 +1182,10 @@ function bindSource( /** Where chain code allocates its locals: a lifted helper function's own * frame, or hidden locals of the function being lowered (a fused for-of). */ -interface ChainFrame { - local(base: string, type: IrType, mutable: boolean): string; +abstract class ChainFrame { + abstract local(base: string, type: IrType, mutable: boolean): string; /** `id = id + 1` over a number local. */ - bump(id: string): IrStmt; + abstract bump(id: string): IrStmt; } /** `id = id + 1` over a number local. */ @@ -1206,11 +1206,13 @@ function bumpLocal(id: string, loc: SrcLoc): IrStmt { } /** Hidden locals of the function being lowered. */ -class HostFrame implements ChainFrame { +class HostFrame extends ChainFrame { constructor( private readonly lowerer: Lowerer, private readonly loc: SrcLoc, - ) {} + ) { + super(); + } local(base: string, type: IrType, mutable: boolean): string { const local = this.lowerer.declareHiddenLocal(`%${base}`, type); local.mutable = mutable; @@ -1223,11 +1225,13 @@ class HostFrame implements ChainFrame { /** The locals and parameters of a lifted helper function under * construction. */ -export class LiftedFrame implements ChainFrame { +export class LiftedFrame extends ChainFrame { readonly locals: IrLocal[] = []; readonly params: IrParam[] = []; private n = 0; - constructor(private readonly loc: SrcLoc) {} + constructor(private readonly loc: SrcLoc) { + super(); + } local(base: string, type: IrType, mutable: boolean): string { const id = `${base}.${this.n++}`; this.locals.push({ id, name: base, type, mutable }); diff --git a/packages/compiler/src/frontend/lowering/cycle-initialization.ts b/packages/compiler/src/frontend/lowering/cycle-initialization.ts index 4b1e74fcf..8c8ce06e6 100644 --- a/packages/compiler/src/frontend/lowering/cycle-initialization.ts +++ b/packages/compiler/src/frontend/lowering/cycle-initialization.ts @@ -707,11 +707,17 @@ function rewriteFunction( * unchecked. */ export function refuseUncheckedCycleReads( lowerer: Lowerer, - classes: readonly { baseValueGlobal?: string; symbolFields?: { globalId: string }[] }[], + classes: readonly { + baseValueGlobal?: string; + symbolFields?: readonly ({ globalId: string } | { wellKnown: string })[]; + }[], ): void { if (lowerer.cycleInitFlags.size === 0) return; for (const cls of classes) { - const ids = [cls.baseValueGlobal, ...(cls.symbolFields ?? []).map((f) => f.globalId)]; + const ids = [ + cls.baseValueGlobal, + ...(cls.symbolFields ?? []).map((f) => ("globalId" in f ? f.globalId : undefined)), + ]; for (const id of ids) { const flag = id === undefined ? undefined : lowerer.cycleInitFlags.get(id); if (flag === undefined) continue; diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 222cb89f9..7896da578 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -645,8 +645,7 @@ function extendsParameter( info: GenericFnInfo, ): boolean { const params = new Set(); - const fn = info.decl as { parameters?: { nodes: readonly ts.ParameterDeclaration[] } }; - for (const param of fn.parameters?.nodes ?? []) { + for (const param of info.decl.parameters.nodes) { const symbol = ts.isIdentifier(param.name) ? lowerer.checker.getSymbolAtLocation(param.name) : undefined; @@ -672,8 +671,7 @@ function declaredInside(node: ts.Node, owner: ts.Node): boolean { * return gives anything else (or nothing). Nested functions and classes keep * their own returns. */ function returnedLocalClass(lowerer: Lowerer, info: GenericFnInfo): ts.ClassLikeDeclaration | null { - const fn = info.decl as ts.Node & { body?: ts.Node }; - const body = fn.body; + const body = info.decl.body; if (!body) return null; const classOf = (node: ts.Expression): ts.ClassLikeDeclaration | null => { let expr = node; diff --git a/packages/compiler/src/frontend/lowering/instantiation-path.test.ts b/packages/compiler/src/frontend/lowering/instantiation-path.test.ts index a31617106..267b402bc 100644 --- a/packages/compiler/src/frontend/lowering/instantiation-path.test.ts +++ b/packages/compiler/src/frontend/lowering/instantiation-path.test.ts @@ -1,19 +1,23 @@ import { expect, test } from "vitest"; +import type * as ts from "../ts7/adapter.js"; import { extendInstantiationPath, MAX_INSTANTIATION_RECURSION } from "./instantiation-path.js"; +/** A stand-in declaration: paths compare declarations by identity only. */ +const node = (): ts.Node => ({}) as ts.Node; + test("independent declarations and roots do not consume a recursive budget", () => { - const declaration = {}; + const declaration = node(); for (let i = 0; i < MAX_INSTANTIATION_RECURSION * 2; i++) expect(extendInstantiationPath(null, declaration)).not.toBeNull(); let path = extendInstantiationPath(null, declaration)!; for (let i = 0; i < MAX_INSTANTIATION_RECURSION * 2; i++) - path = extendInstantiationPath(path, {})!; + path = extendInstantiationPath(path, node())!; expect(extendInstantiationPath(path, declaration)).not.toBeNull(); }); test("bounds fresh recursive demands across intervening declarations", () => { - const first = {}; - const second = {}; + const first = node(); + const second = node(); let path = extendInstantiationPath(null, first)!; for (let i = 1; i < MAX_INSTANTIATION_RECURSION; i++) { path = extendInstantiationPath(path, second)!; diff --git a/packages/compiler/src/frontend/lowering/instantiation-path.ts b/packages/compiler/src/frontend/lowering/instantiation-path.ts index d79760988..b4a8375f6 100644 --- a/packages/compiler/src/frontend/lowering/instantiation-path.ts +++ b/packages/compiler/src/frontend/lowering/instantiation-path.ts @@ -1,7 +1,9 @@ +import type * as ts from "../ts7/adapter.js"; + /** Queued bodies preserve their demand ancestry. Independent concrete uses * do not consume a recursion budget; only fresh instances on one path do. */ export interface InstantiationPath { - declaration: object; + declaration: ts.Node; parent: InstantiationPath | null; } @@ -9,7 +11,7 @@ export const MAX_INSTANTIATION_RECURSION = 100; export function extendInstantiationPath( parent: InstantiationPath | null, - declaration: object, + declaration: ts.Node, ): InstantiationPath | null { let repetitions = 0; for (let path = parent; path; path = path.parent) { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 073ebed91..14565aebc 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -187,7 +187,7 @@ import { import { isSafeToDiscard } from "./expressions/evaluation-safety.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerChildMethodValue, lowerChildPropertyCall } from "./handle-properties.js"; -import { classToNumberConversion } from "./class-to-primitive.js"; +import { classObjectTag, classToNumberConversion } from "./class-to-primitive.js"; import { hasRuntimeStatics } from "./class-runtime-statics.js"; import { structuralReadType } from "./class-unions.js"; import { @@ -3116,6 +3116,10 @@ function lowerObjectPrototypeCall( const immediate = immediateObjectTagReceiver(lowerer, receiverNode); const receiver = immediate?.effect ?? lowerer.lowerExpr(receiverNode); const tag = immediate?.tag ?? objectToStringTag(lowerer, receiver.type); + if (tag === null && immediate === null && call.arguments.nodes.length === 1) { + const classTag = classObjectTag(lowerer, receiver, receiverNode); + if (classTag) return classTag; + } // A generator object answers its tag through its checked capsule. if (tag === null && receiver.type.kind !== "dyn" && receiver.type.kind !== "generator") { lowerer.noLowering( diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index ac1864051..b829b6ddc 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -8161,7 +8161,10 @@ export function classMemberNameOf(lowerer: Lowerer, name: ts.PropertyName): stri member === "asyncIterator" || member === "dispose" || member === "asyncDispose" || - member === "toPrimitive" + member === "toPrimitive" || + // A toStringTag field keeps its symbol field name; its accessor and + // method forms take the method slot name. + (member === "toStringTag" && !ts.isPropertyDeclaration(name.parent)) ) { return `sym:${member}`; } diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index e7a43bde1..48bb0f9e6 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -18,6 +18,7 @@ import { jsFuncNameOf, type Lowerer } from "./lowerer.js"; import { isJsSourceFile } from "../program.js"; import { BOOL, + CAUGHT, DYN, F64, type IrExpr, @@ -44,9 +45,9 @@ import { shapeKeyOrders, typeKey, } from "../../ir/ir.js"; -import type { ClassInfo } from "./lower-classes.js"; +import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js"; import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js"; -import { symbolFieldDisplayName } from "./symbol-fields.js"; +import { symbolFieldDisplayName, TO_STRING_TAG_FIELD } from "./symbol-fields.js"; import { eventTargetRootOf } from "./lower-event-target.js"; import { dateRootOf } from "./builtins/date-classes.js"; import { lowerSystemErrorMap } from "./lower-system-errors.js"; @@ -2000,8 +2001,86 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { // observable way — Node's inspect omits them entirely (verified), // so a class whose only fields are private prints as `C {}`. const visible = info.def.fields.filter((f) => isClassOwnEnumerableFieldName(f.name)); + // A Symbol.toStringTag getter (an own tag field is listed instead) + // brackets the tag after the name, `Name [tag] {`, when it is a + // nonempty string other than the name. A getter that throws shows + // no tag, as in Node. + const tagGetter = info.fields.has(TO_STRING_TAG_FIELD) + ? null + : findMethodOn(lowerer, info, "get:sym:toStringTag"); + let named: (plain: string, make: (head: IrExpr) => IrExpr) => IrExpr = (plain) => + strLit(plain, loc); + if (tagGetter?.sig.ret.kind === "string") { + const owner = tagGetter.declarer.def.name; + locals.push( + { id: "tag.0", name: "tag", type: STRING, mutable: true }, + { id: "tagError.0", name: "tagError", type: CAUGHT, mutable: false }, + ); + body.push( + { kind: "varDecl", localId: "tag.0", init: strLit("", loc), loc }, + { + kind: "tryCatch", + tryBody: [ + { + kind: "assign", + localId: "tag.0", + value: accessorCall( + lowerer, + owner, + "get:sym:toStringTag", + lowerer.upcastTo(v(), owner), + [], + STRING, + loc, + ), + loc, + }, + ], + catchBody: [], + catchLocalId: "tagError.0", + finallyBody: null, + loc, + }, + ); + const tag = (): IrExpr => varRef("tag.0", STRING, loc); + const shown: IrExpr = { + kind: "logical", + op: "&&", + left: { + kind: "strEq", + negated: true, + left: tag(), + right: strLit("", loc), + type: BOOL, + loc, + }, + right: { + kind: "strEq", + negated: true, + left: tag(), + right: strLit(display, loc), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + const head = concatAll( + [strLit(`${display === "" ? "Object" : display} [`, loc), tag(), strLit("]", loc)], + loc, + ); + named = (plain, make) => ({ + kind: "ternary", + cond: shown, + then: make(head), + else_: strLit(plain, loc), + type: STRING, + loc, + }); + } + const after = (text: string) => (head: IrExpr) => concatAll([head, strLit(text, loc)], loc); if (visible.length === 0) { - body = [ret(strLit(display === "" ? "{}" : `${display} {}`, loc))]; + body.push(ret(named(display === "" ? "{}" : `${display} {}`, after(" {}")))); break; } const get = (field: string, type: IrType): IrExpr => ({ @@ -2012,7 +2091,22 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { type, loc, }); - body = [depthGate(display === "" ? "[Object]" : `[${display}]`), ...begin()]; + body.push( + { + kind: "if", + cond: { kind: "bin", op: ">", left: r(), right: d(), type: BOOL, loc }, + then: [ + ret( + named(display === "" ? "[Object]" : `[${display}]`, (head) => + concatAll([strLit("[", loc), head, strLit("]", loc)], loc), + ), + ), + ], + else_: null, + loc, + }, + ...begin(), + ); // def.fields carries layout order: the base chain first, then own — // exactly the own-property insertion order of a constructor that // assigns in declaration order (SEMANTICS.md 36's stance). SYMBOL- @@ -2041,7 +2135,7 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { ret( end( strLit("", loc), - strLit(display === "" ? "{" : `${display} {`, loc), + named(display === "" ? "{" : `${display} {`, after(" {")), strLit("}", loc), false, boolLit(false, loc), diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 75f18fe37..19f7243ed 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1183,19 +1183,22 @@ function jsDynHoldableInitializer(lowerer: Lowerer, init: ts.Expression | undefi } if (e.kind === ts.SyntaxKind.NullKeyword) return true; if (ts.isIdentifier(e) && e.text === "undefined") return true; - if (ts.isConditionalExpression(e)) { - return ( - jsDynHoldableInitializer(lowerer, e.whenTrue) && - jsDynHoldableInitializer(lowerer, e.whenFalse) - ); - } + // The arms of a conditional or logical expression: checked values, or + // strings, numbers and booleans, which box into the checked-dynamic + // result (`const APP_NAME = config.name || "pi"`). + const arm = (x: ts.Expression): boolean => { + if (jsDynHoldableInitializer(lowerer, x)) return true; + const kind = lowerer.mapTypeOf(lowerer.typeOf(x))?.kind; + return kind === "string" || kind === "f64" || kind === "bool"; + }; + if (ts.isConditionalExpression(e)) return arm(e.whenTrue) && arm(e.whenFalse); if ( ts.isBinaryExpression(e) && (e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || e.operatorToken.kind === ts.SyntaxKind.BarBarToken || e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) ) { - return jsDynHoldableInitializer(lowerer, e.left) && jsDynHoldableInitializer(lowerer, e.right); + return arm(e.left) && arm(e.right); } // A member of a checked module value is itself checked, including a // callable whose inferred overloads cannot describe one native ABI. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index af89c61a4..78980d322 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3756,7 +3756,7 @@ export class Lowerer { positionOf: (node) => this.positions.of(node), shapes: this.shapes, unions: this.unions, - classNamer: this.classNamer, + classNamer: (decl) => this.classNamer(decl), resolveTypeParam: this.typeParamResolver, resolveTypeParamTs: this.typeParamTsResolver, isErasing: () => this.erasingDepth > 0, @@ -3837,8 +3837,8 @@ export class Lowerer { }, mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened), classImplementedProtocol: (type) => this.classImplementedProtocol(type), - isStdlibFile: this.isStdlibFile, - isNpmFile: this.isNpmFile, + isStdlibFile: (sf) => this.isStdlibFile(sf), + isNpmFile: (sf) => this.isNpmFile(sf), // The declaration-typed boundary's generated types (decl-projection.ts) // describe data, like an explicitly mapped external type surface. isExternalTypeFile: (sf) => @@ -8102,16 +8102,24 @@ export class Lowerer { lower: () => IrFunction | null; } >(); - const bodyRoots = (...roots: (ts.Node | undefined | null)[]): ts.Node[] => - roots.filter((root) => root !== undefined && root !== null); - const functionRoots = (decl: ts.FunctionLikeDeclaration): ts.Node[] => - bodyRoots(...decl.parameters.nodes.map((param) => param.initializer), decl.body); - const classCtorRoots = (info: ClassInfo): ts.Node[] => - bodyRoots( - ...(info.ctor?.parameters?.nodes ?? []).map((param) => param.initializer), - info.ctor?.body, - ...info.fieldOrder.map((field) => field.initializer), - ); + // Roots are collected with plain pushes: spread arguments into a rest + // parameter pass the nodes as dynamic values. + const addRoot = (roots: ts.Node[], root: ts.Node | undefined | null): void => { + if (root !== undefined && root !== null) roots.push(root); + }; + const functionRoots = (decl: ts.FunctionLikeDeclaration): ts.Node[] => { + const roots: ts.Node[] = []; + for (const param of decl.parameters.nodes) addRoot(roots, param.initializer); + addRoot(roots, decl.body); + return roots; + }; + const classCtorRoots = (info: ClassInfo): ts.Node[] => { + const roots: ts.Node[] = []; + for (const param of info.ctor?.parameters?.nodes ?? []) addRoot(roots, param.initializer); + addRoot(roots, info.ctor?.body); + for (const field of info.fieldOrder) addRoot(roots, field.initializer); + return roots; + }; const classMemberRoots = (info: ClassInfo): ts.Node[] => { const roots = classCtorRoots(info); for (const { member } of this.classMethodMembers(info)) roots.push(...functionRoots(member)); @@ -8288,7 +8296,8 @@ export class Lowerer { register(`%${cName}.set:${prop}`, [], () => this.throwingSetterFn(info, prop)); } }; - for (const name of collectionEdges) this.onEdge(name); + const onEdge = this.onEdge; + for (const name of collectionEdges) onEdge(name); // Generic instances queued by the bodies above lower here (an instance // body fires edges of its own and can queue further instances of // either kind — function instances and class instantiations drain to @@ -8423,7 +8432,8 @@ export class Lowerer { // profile-mapped exports are called from outside the graph, so they // seed the worklist beside the init bodies. Unknown names are inert // (the export-map resolution reports them as SC4002 later). - for (const root of extraRoots ?? []) this.onEdge?.(root); + const rootEdge = this.onEdge; + for (const root of extraRoots ?? []) rootEdge?.(root); const shuffle = lowerShuffle(); const drainUnits = (): void => { while (queue.length > 0) { @@ -8541,7 +8551,9 @@ export class Lowerer { /** Reachability hook (see emitReachable): fires when lowering resolves a * reference to a lowerable body. */ noteEdge(name: string): void { - if (this.onEdge) this.onEdge(name); + // Called as a plain function: the instance is not its receiver. + const onEdge = this.onEdge; + if (onEdge) onEdge(name); } /** A subclass constructor's super() edge: the base constructor runs, but @@ -11941,12 +11953,15 @@ export class Lowerer { /** Method-like members with their collected method-map names. Body * lowering omits abstract declarations; ABI analysis must include them - * because an abstract-typed call selects the same virtual slot. */ - *classMethodMembers( + * because an abstract-typed call selects the same virtual slot. An array, + * not a generator: a generator's yielded values are dynamic values, which + * would make every syntax node one. */ + classMethodMembers( info: ClassInfo, includeAbstract = false, - ): Generator<{ mName: string; member: ts.MethodDeclaration | ts.AccessorDeclaration }> { - if (!info.decl) return; // builtin error classes: runtime-provided bodies + ): { mName: string; member: ts.MethodDeclaration | ts.AccessorDeclaration }[] { + const members: { mName: string; member: ts.MethodDeclaration | ts.AccessorDeclaration }[] = []; + if (!info.decl) return members; // builtin error classes: runtime-provided bodies for (const member of info.decl.members.nodes) { const fnLike = ts.isMethodDeclaration(member) || ts.isGetAccessor(member) || ts.isSetAccessor(member) @@ -11970,8 +11985,9 @@ export class Lowerer { : `${ts.isGetAccessor(fnLike) ? "get" : "set"}:${baseName}`; const signature = info.methods.get(mName); if (!signature || (!fnLike.body && !(includeAbstract && signature.abstract))) continue; - yield { mName, member: fnLike }; + members.push({ mName, member: fnLike }); } + return members; } lowerClassCtor(info: ClassInfo): IrFunction { diff --git a/packages/compiler/src/frontend/lowering/site-dependencies.test.ts b/packages/compiler/src/frontend/lowering/site-dependencies.test.ts index ca5affcbe..0580a52ee 100644 --- a/packages/compiler/src/frontend/lowering/site-dependencies.test.ts +++ b/packages/compiler/src/frontend/lowering/site-dependencies.test.ts @@ -1,9 +1,13 @@ import { expect, test } from "vitest"; +import type * as ts from "../ts7/adapter.js"; import { SiteDependencies } from "./site-dependencies.js"; +/** A stand-in node fact: facts are compared by identity. */ +const node = (): ts.Node => ({}) as ts.Node; + test("a changed fact makes the sites that read it stale", () => { const dependencies = new SiteDependencies(3); - const a = {}; + const a = node(); const b = "key"; dependencies.begin(0); dependencies.read(a); @@ -20,8 +24,8 @@ test("a changed fact makes the sites that read it stale", () => { test("a visit that changes a fact makes every reader of its facts stale", () => { const dependencies = new SiteDependencies(3); - const shared = {}; - const own = {}; + const shared = node(); + const own = node(); dependencies.begin(0); dependencies.read(shared); dependencies.end(false); @@ -37,7 +41,7 @@ test("a visit that changes a fact makes every reader of its facts stale", () => test("reads outside a visit are not recorded", () => { const dependencies = new SiteDependencies(1); - const fact = {}; + const fact = node(); dependencies.read(fact); dependencies.changed(fact); expect(dependencies.isStale(0)).toBe(false); @@ -45,7 +49,7 @@ test("reads outside a visit are not recorded", () => { test("a site visited again is listed once per fact", () => { const dependencies = new SiteDependencies(3); - const fact = {}; + const fact = node(); for (const site of [0, 2, 1, 2, 0]) { dependencies.begin(site); dependencies.read(fact); diff --git a/packages/compiler/src/frontend/lowering/site-dependencies.ts b/packages/compiler/src/frontend/lowering/site-dependencies.ts index da8531e48..868531353 100644 --- a/packages/compiler/src/frontend/lowering/site-dependencies.ts +++ b/packages/compiler/src/frontend/lowering/site-dependencies.ts @@ -1,3 +1,5 @@ +import type * as ts from "../ts7/adapter.js"; + /** The facts each site of a fixed-point scan read on its last visit, so that * a pass visits again only the sites whose facts changed since. * @@ -13,14 +15,19 @@ * that with a pass over every site once a pass changes nothing, and resumes * when that pass changes a fact: a fact this tracking misses costs passes, * not a different result. */ +/** A fact a site reads: a symbol, a syntax node, or the name of a derived + * fact. A typed union keeps nodes and symbols native values; as `unknown` + * every syntax node would become a dynamic value. */ +export type SiteFact = ts.Node | ts.Symbol | string; + export class SiteDependencies { /** Each fact's entry: the visit that last read it, so that a visit lists a * fact once, followed by the sites that read it, ascending. */ - private readonly readers = new Map(); + private readonly readers = new Map(); private readonly stale: Uint8Array; private site = -1; private visit = 0; - private facts: unknown[] = []; + private facts: SiteFact[] = []; constructor(siteCount: number) { this.stale = new Uint8Array(siteCount); @@ -46,7 +53,7 @@ export class SiteDependencies { } /** The site being visited read `fact`. */ - read(fact: unknown): void { + read(fact: SiteFact): void { if (this.site < 0) return; const entry = this.readers.get(fact); if (entry === undefined) { @@ -72,7 +79,7 @@ export class SiteDependencies { } /** `fact` changed: every site that read it is visited again. */ - changed(fact: unknown): void { + changed(fact: SiteFact): void { const entry = this.readers.get(fact); if (entry !== undefined) for (let i = 1; i < entry.length; i++) this.stale[entry[i]!] = 1; } diff --git a/packages/compiler/src/frontend/lowering/string-index-bounds.ts b/packages/compiler/src/frontend/lowering/string-index-bounds.ts index af1b00ec6..6418aed76 100644 --- a/packages/compiler/src/frontend/lowering/string-index-bounds.ts +++ b/packages/compiler/src/frontend/lowering/string-index-bounds.ts @@ -182,11 +182,28 @@ export class StringIndexBounds { private readonly identifiers = new Map>(); constructor( - private readonly symbolAt: (node: ts.Node) => ts.Symbol | null, - private readonly declarationOf: (symbol: ts.Symbol) => ts.Node | undefined, - private readonly isString: (node: ts.Expression) => boolean, + private readonly lookupSymbol: (node: ts.Node) => ts.Symbol | null, + private readonly lookupDeclaration: (symbol: ts.Symbol) => ts.Node | undefined, + private readonly stringTyped: (node: ts.Expression) => boolean, ) {} + // The lookups are called as plain functions: the instance is not their + // receiver. + private symbolAt(node: ts.Node): ts.Symbol | null { + const lookup = this.lookupSymbol; + return lookup(node); + } + + private declarationOf(symbol: ts.Symbol): ts.Node | undefined { + const lookup = this.lookupDeclaration; + return lookup(symbol); + } + + private isString(node: ts.Expression): boolean { + const test = this.stringTyped; + return test(node); + } + /** True when `read` (an element access on a string receiver) is proven * to name an existing code unit. */ inBounds(read: ts.ElementAccessExpression): boolean { diff --git a/packages/compiler/src/frontend/lowering/symbol-fields.ts b/packages/compiler/src/frontend/lowering/symbol-fields.ts index 56718615e..cdfb9e1b8 100644 --- a/packages/compiler/src/frontend/lowering/symbol-fields.ts +++ b/packages/compiler/src/frontend/lowering/symbol-fields.ts @@ -77,6 +77,8 @@ export function fenceSymbolFieldCopy( } export function classSymbolKeyOf(lowerer: Lowerer, key: ts.Expression): ClassSymbolKey | null { + const wellKnown = wellKnownSymbolKeyOf(lowerer, key); + if (wellKnown !== undefined) return wellKnown; if ( !ts.isIdentifier(key) && (!ts.isPropertyAccessExpression(key) || key.questionDotToken || !ts.isIdentifier(key.name)) @@ -90,6 +92,53 @@ export function classSymbolKeyOf(lowerer: Lowerer, key: ts.Expression): ClassSym return sym ? classSymbolKeyOfSymbol(lowerer, sym) : null; } +/** Well-known symbols a class field may be keyed by: those no protocol + * consults on an instance, or whose protocol the lowering reads from the + * field (Symbol.toStringTag, by the class string conversions). A field + * keyed by any other well-known symbol (Symbol.iterator, toPrimitive, + * hasInstance, ...) would be skipped by its protocol, so it keeps the + * computed-key refusal. */ +const FIELD_WELL_KNOWN_SYMBOLS: ReadonlySet = new Set(["toStringTag"]); + +/** The key of `Symbol.` on the standard global: undefined when the + * expression is not one, null when it is a well-known symbol a field may + * not be keyed by. The identity is the lib's declaration of the property, + * one symbol for every program reference. */ +function wellKnownSymbolKeyOf( + lowerer: Lowerer, + key: ts.Expression, +): ClassSymbolKey | null | undefined { + while (ts.isParenthesizedExpression(key)) key = key.expression; + if ( + !ts.isPropertyAccessExpression(key) || + key.questionDotToken || + !ts.isIdentifier(key.expression) || + !ts.isIdentifier(key.name) || + !lowerer.isStdlibGlobal(key.expression, "Symbol") + ) + return undefined; + const name = key.name.text; + const sym = lowerer.checker.getSymbolAtLocation(key.name); + if (!sym || !(lowerer.typeOf(key).flags & ts.TypeFlags.UniqueESSymbol)) return undefined; + if (!FIELD_WELL_KNOWN_SYMBOLS.has(name)) return null; + return { sym, identity: sym, fieldName: wellKnownSymbolFieldName(name) }; +} + +/** The layout name of a field keyed by the well-known `Symbol.`: a + * reserved symbol-field name, printed as Node spells the key. */ +export function wellKnownSymbolFieldName(name: string): string { + return `${FIELD_PREFIX}Symbol(Symbol.${name})`; +} + +/** The well-known symbol a field name is keyed by (`toStringTag`), or null. */ +export function wellKnownSymbolOfField(field: string): string | null { + const match = /^%symbol:Symbol\(Symbol\.(\w+)\)$/.exec(field); + return match === null ? null : match[1]!; +} + +/** The layout name of a `Symbol.toStringTag` field. */ +export const TO_STRING_TAG_FIELD = wellKnownSymbolFieldName("toStringTag"); + /** A literal Symbol()/Symbol.for() initialized once at module scope. The * whole-file proof also covers var declarations emitted by bundlers. Keys * cannot be used before initialization: a computed class field evaluates diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index eed02c95c..b00e14aa0 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -254,13 +254,18 @@ function aliasedPackageName(pkgDir: string): string | null { export function npmStaticTransformPkgJson(pkg: Record): void { delete pkg["types"]; delete pkg["typings"]; - const transform = (v: unknown): void => { - if (v === null || typeof v !== "object") return; - if (Array.isArray(v)) { - for (const item of v) transform(item); - return; - } - const obj = v as Record; + const transform = (v: unknown): unknown => { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(transform); + // The result is a new object, built in key order; the first entry of a + // key wins. + const entries: [string, unknown][] = []; + const seen = new Set(); + const add = (key: string, value: unknown): void => { + if (seen.has(key)) return; + seen.add(key); + entries.push([key, value]); + }; // 0. The "node" condition HOISTS: Node's runtime resolution matches it // (yaml's `{ types, node: "./dist/index.js", default: "./browser/ // index.js" }` runs the node dist; the browser dist is a DIFFERENT @@ -270,24 +275,18 @@ export function npmStaticTransformPkgJson(pkg: Record): void { // under import+require (either matcher finds it there), an object's // entries splice inline — earlier real keys keep winning, exactly // Node's first-match-in-key-order rule. - if (obj["node"] !== undefined) { - const entries = Object.entries(obj); - for (const k of Object.keys(obj)) delete obj[k]; - for (const [k, val] of entries) { - if (k !== "node") { - if (!(k in obj)) obj[k] = val; - continue; - } - const inner: Record = - typeof val === "string" - ? { import: val, require: val } - : val !== null && typeof val === "object" && !Array.isArray(val) - ? (val as Record) - : {}; - for (const [ik, iv] of Object.entries(inner)) { - if (!(ik in obj)) obj[ik] = iv; - } + for (const [key, value] of Object.entries(v as Record)) { + if (key !== "node") { + add(key, value); + continue; } + const inner: Record = + typeof value === "string" + ? { import: value, require: value } + : value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + for (const [innerKey, innerValue] of Object.entries(inner)) add(innerKey, innerValue); } // 1. The types claims go AFTER the hoist: a node object's own // "types" key (uuid's `node: { types: "./dist/index.d.ts", default: @@ -295,15 +294,17 @@ export function npmStaticTransformPkgJson(pkg: Record): void { // in place tsgo follows it, maps the hidden declaration to its // same-stem JS (dist/index.js, the browser build) and disagrees with // this resolver's runtime answer. - delete obj["types"]; - delete obj["typings"]; - for (const value of Object.values(obj)) transform(value); + return Object.fromEntries( + entries + .filter(([key]) => key !== "types" && key !== "typings") + .map(([key, value]): [string, unknown] => [key, transform(value)]), + ); }; - if (pkg["exports"] !== undefined) transform(pkg["exports"]); + if (pkg["exports"] !== undefined) pkg["exports"] = transform(pkg["exports"]); // The "imports" field maps "#" specifiers with the same condition objects // (chalk 5's `"#supports-color": { node, default }`), so Node's "node" // target hoists there too. - if (pkg["imports"] !== undefined) transform(pkg["imports"]); + if (pkg["imports"] !== undefined) pkg["imports"] = transform(pkg["imports"]); } /** npmStaticTransformPkgJson over a package.json TEXT (the fs shadow's diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 8075379b8..bf75127c4 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3927,10 +3927,14 @@ function exactCycleRefusal7( if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) { return `the default export expression at ${lineOf(stmt)} has no initialization state inside the cycle`; } + // `export * from` binds each name to the declaring member's own + // binding, like a named re-export, so only `export * as ns` (a + // namespace object of a member) has state of its own. if ( ts.isExportDeclaration(stmt) && !stmt.isTypeOnly && - (stmt.exportClause === undefined || ts.isNamespaceExport(stmt.exportClause)) + stmt.exportClause !== undefined && + ts.isNamespaceExport(stmt.exportClause) ) { const dep = targetOf(stmt); if (dep !== null && members.has(dep)) { diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 19728d6ce..14b366bfa 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -52,8 +52,10 @@ export class FrontendServices { } private sourceParser(): Ts7SourceParser { this.ensureOpen(); + // Called as a plain function: the instance is not its receiver. + const createApi = this.createApi; return (this.parser ??= new Ts7SourceParser( - (options) => this.createApi({ ...options, collectTiming: false }), + (options) => createApi({ ...options, collectTiming: false }), this.cwd, )); } @@ -62,9 +64,10 @@ export class FrontendServices { } evaluateComptime(source: string, timeoutMs: number): unknown { this.ensureOpen(); - if (this.comptimeEvaluator === undefined) + const evaluate = this.comptimeEvaluator; + if (evaluate === undefined) throw new Error("this compiler host does not provide compile-time evaluation"); - return this.comptimeEvaluator(source, timeoutMs); + return evaluate(source, timeoutMs); } createProgramHost(options?: Ts7HostOptions): Ts7Host { this.ensureOpen(); diff --git a/packages/compiler/src/frontend/ts7/semantic-model.ts b/packages/compiler/src/frontend/ts7/semantic-model.ts index 405f4775b..04fa8e637 100644 --- a/packages/compiler/src/frontend/ts7/semantic-model.ts +++ b/packages/compiler/src/frontend/ts7/semantic-model.ts @@ -64,7 +64,9 @@ export interface SemanticQuery { export class SemanticProgram { constructor(private readonly sourceFile: (path: string) => AstNode | undefined) {} getSourceFile(path: string): AstNode | undefined { - return this.sourceFile(path); + // Called as a plain function: the instance is not its receiver. + const sourceFile = this.sourceFile; + return sourceFile(path); } } diff --git a/packages/compiler/src/frontend/ts7/source-parser.ts b/packages/compiler/src/frontend/ts7/source-parser.ts index f9029774c..bbb6cae7a 100644 --- a/packages/compiler/src/frontend/ts7/source-parser.ts +++ b/packages/compiler/src/frontend/ts7/source-parser.ts @@ -42,7 +42,9 @@ export class Ts7SourceParser { } private connect(): Ts7Api { - return this.createApi({ + // Called as a plain function: the instance is not its receiver. + const createApi = this.createApi; + return createApi({ cwd: this.cwd, fs: { readFile: (path) => this.files.get(tsgoPath(path)) ?? null, diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 91e1d5f05..db872a525 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -157,10 +157,12 @@ const MODULE_FIELDS: Record>, Role> = { "classes[].errorNameAfter": "other", "classes[].prototypeDataHelper": "reference", "classes[].instancePrototypeHelper": "reference", + "classes[].stringTagHelper": "reference", "classes[].localCaptures[].name": "other", "classes[].localCaptures[].localId": "other", "classes[].symbolFields[].field": "other", "classes[].symbolFields[].globalId": "other", + "classes[].symbolFields[].wellKnown": "other", "classes[].fields[].name": "other", "classes[].methods[]": "method", "classes[].prototypeNames[]": "other", @@ -281,6 +283,10 @@ const REFERENCE_FIXTURES: Record IrModule> = { ...module(), classes: [{ name: "%m0.Cell", fields: [], instancePrototypeHelper: TARGET, loc }], }), + "classes[].stringTagHelper": () => ({ + ...module(), + classes: [{ name: "%m0.Cell", fields: [], stringTagHelper: TARGET, loc }], + }), "lib.exports[].fnName": () => ({ ...module(), lib: { diff --git a/packages/compiler/src/ir/function-references.ts b/packages/compiler/src/ir/function-references.ts index db474e9bb..6696a3c11 100644 --- a/packages/compiler/src/ir/function-references.ts +++ b/packages/compiler/src/ir/function-references.ts @@ -24,6 +24,7 @@ export function moduleFunctionReferences(mod: IrModule): string[] { for (const cls of mod.classes ?? []) { if (cls.prototypeDataHelper !== undefined) names.push(cls.prototypeDataHelper); if (cls.instancePrototypeHelper !== undefined) names.push(cls.instancePrototypeHelper); + if (cls.stringTagHelper !== undefined) names.push(cls.stringTagHelper); } return names; } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index db93ec09c..3818433f8 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1494,6 +1494,9 @@ export interface IrClassDef { prototypeDataHelper?: string; /** Native instance to its actual prototype, including factory captures and subclasses. */ instancePrototypeHelper?: string; + /** Native instance to the string its inherited Symbol.toStringTag getter + * answers, read by the runtime for an instance seen as `unknown`. */ + stringTagHelper?: string; /** Public JavaScript fields record their creation in the shared property * bag. Its descriptors preserve presence and insertion order independently * of native layout order; field values remain in their native slots. */ @@ -1507,8 +1510,10 @@ export interface IrClassDef { * dynamic write keeps `name` and `message` where the bag recorded them * (scr_error_dyn_follow_bag). */ errorBagOrder?: boolean; - /** Stable module symbol identities for public instance layout fields. */ - symbolFields?: { field: string; globalId: string }[]; + /** Stable symbol identities for public instance layout fields: the + * module global holding the key, or the name of the well-known symbol + * (`toStringTag` for `Symbol.toStringTag`). */ + symbolFields?: ({ field: string } & ({ globalId: string } | { wellKnown: string }))[]; /** RUNTIME-PROVIDED class (the builtin Error hierarchy): the struct, RC * helpers, and vtable live in the runtime (ScrError / scr_error_*), so * backends emit no definitions for it — only the preorder-interval diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 118f0ef38..85b21af7b 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -715,6 +715,10 @@ interface RuntimeFeatureRule { /** Gives every rule in the table one type. */ const rule = (r: RuntimeFeatureRule): RuntimeFeatureRule => r; +/* The node:url and WHATWG URL entry points: every URL value comes from + * one, and parsing one can reach the UTS #46 host processing. */ +const URL_PREFIXES: readonly string[] = ["url."]; + /* The HTTP, HTTPS, HTTP/2 and TLS surfaces, which link the net unit. */ const NET_PREFIXES: readonly string[] = ["net.", "http.", "tls.", "https.", "http2."]; @@ -1063,9 +1067,10 @@ const RUNTIME_FEATURE_RULES = { /** scr_url_legacy.c parses and stringifies queries. */ qs: rule({ fns: [...QS_LIB_FNS, ...LEGACY_URL_LIB_FNS] }), /** scr_url_legacy.c lowercases and normalizes hostnames with the regex - * unit's libunicode, NODE_DEBUG matching in scr_util_callable.c and - * readline's terminal unit use the regex unit, and locale case mapping - * runs its default conversion. */ + * unit's libunicode, the WHATWG URL host parser runs UTS #46 through + * scr_idna.c (which links with the regex unit), NODE_DEBUG matching in + * scr_util_callable.c and readline's terminal unit use the regex unit, + * and locale case mapping runs its default conversion. */ regex: rule({ fns: [ ...REGEX_LIB_FNS, @@ -1074,13 +1079,17 @@ const RUNTIME_FEATURE_RULES = { "rl.terminal", "str.localeCase", ], + prefixes: URL_PREFIXES, expr: (node, _tables) => node.kind === "regexLit" || node.kind === "regexIntrinsic" || (node.kind === "strIntrinsic" && CASE_MAPPING_STR_METHODS.has(node.method)), - types: ["regex"], + types: ["regex", "url"], }), searchParams: rule({ fns: ["url.searchParams"], prefixes: ["sp."], types: ["searchParams"] }), + /** URL programs install the IDNA unit's ToASCII for the WHATWG host + * parser (scr_url_idna_install). */ + urlIdna: rule({ prefixes: URL_PREFIXES, types: ["url"] }), /** stdin setEncoding / string data listeners / stdio 'resize' events. A * typed string 'data' listener takes setEncoding-decoded chunks; the * process stream object's setEncoding and 'resize' members are reached @@ -1177,6 +1186,7 @@ function noRuntimeFeatures(): RuntimeFeatures { qs: false, regex: false, searchParams: false, + urlIdna: false, stdioExtended: false, stream: false, streamDyn: false, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index a694e8d2c..5bae241b1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -502,10 +502,29 @@ export function validateModule(mod: IrModule): IrValidationError[] { }); } } - for (const symbol of cls.symbolFields ?? []) { - const global = mod.globals?.find((global) => global.id === symbol.globalId); + if (cls.stringTagHelper !== undefined) { + const helper = functionsByName.get(cls.stringTagHelper); + const receiver = helper?.params[0]?.type; if ( - global?.type.kind !== "symbol" || + !helper || + helper.params.length !== 1 || + receiver?.kind !== "object" || + receiver.className !== cls.name || + helper.returnType.kind !== "string" || + helper.captures !== undefined || + helper.classCaptures !== undefined + ) { + errors.push({ + message: `class ${cls.name}: string tag helper must be a noncapturing (instance) => string function`, + loc: cls.loc, + }); + } + } + for (const symbol of cls.symbolFields ?? []) { + const global = + "globalId" in symbol ? mod.globals?.find((global) => global.id === symbol.globalId) : null; + if ( + ("globalId" in symbol ? global?.type.kind !== "symbol" : symbol.wellKnown === "") || !cls.fields.some((field) => field.name === symbol.field) ) { errors.push({ message: `class ${cls.name}: invalid symbol field metadata`, loc: cls.loc }); diff --git a/packages/compiler/test/chunked-emission.test.ts b/packages/compiler/test/chunked-emission.test.ts index b4912c9f6..2aec1e49b 100644 --- a/packages/compiler/test/chunked-emission.test.ts +++ b/packages/compiler/test/chunked-emission.test.ts @@ -9,9 +9,10 @@ import { inlineRcOption } from "../src/backend/llvm/shapes.js"; // Programs whose function bodies request module-level helpers and tables // that the chunk journal (emit-journal.ts) carries: checked-dynamic -// conversions, inspection, publication walkers, live record views, stream -// adapters, regex instances, colliding literal digests, interned C strings, -// speculative loop sites, typed message listeners and readers. +// conversions (with their discriminant tables), inspection, publication +// walkers, live record views, stream adapters, regex instances, colliding +// literal digests, interned C strings, speculative loop sites, typed message +// listeners and readers. const PROGRAMS = [ "1003-json-parse-unions.ts", "114-live-record-delete.ts", @@ -21,6 +22,7 @@ const PROGRAMS = [ "4900-console-instances.ts", "5612-readable-from-iterables.cjs", "5702-publish-candidate-buffers.ts", + "6305-json-discriminant-dispatch.ts", "7321-speculative-loop-bail-sites.ts", "7360-worker-typed-records.ts", "node24-text-utilities-coercion.js", diff --git a/packages/compiler/test/keyed-method-writes.test.ts b/packages/compiler/test/keyed-method-writes.test.ts new file mode 100644 index 000000000..407530945 --- /dev/null +++ b/packages/compiler/test/keyed-method-writes.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; +import { everyStmtList } from "../src/ir/traverse.js"; + +async function lowerSource(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-keyed-writes-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Calls in `run` that go through a dynamic value. */ +function dynamicCalls(mod: IrModule): number { + const fn = mod.functions.find((f) => f.name === "run"); + if (!fn) throw new Error("no run function"); + let n = 0; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "dynCall" || e.kind === "dynInvoke") n++; + return true; + }, + stmt: () => true, + }); + return n; +} + +const shape = ` +type Step = "scale" | "clamp"; +class Transform { + scale(x: number): number { + return x * 2; + } + clamp(x: number): number { + return x < 0 ? 0 : x; + } +} +`; + +test("element writes of data under method names keep the methods native", async () => { + const mod = await lowerSource(`${shape} +const enabled: Record = { scale: false, clamp: false }; +function enable(name: Step): void { + enabled[name] = true; +} +const totals: Record = {}; +function run(t: Transform): number { + enable("scale"); + totals["clamp"] = 1; + return t.clamp(t.scale(3)); +} +console.log(run(new Transform())); +`); + expect(dynamicCalls(mod)).toBe(0); +}); + +test("element writes through untyped receivers can still replace a method", async () => { + const mod = await lowerSource(`${shape} +function replace(target: object, name: Step): void { + (target as any)[name] = (x: number) => x + 100; +} +function run(t: Transform): number { + replace(t, "scale"); + return t.clamp(t.scale(3)); +} +console.log(run(new Transform())); +`); + expect(dynamicCalls(mod)).toBeGreaterThan(0); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json new file mode 100644 index 000000000..b83ada276 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6305-json-discriminant-dispatch.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json new file mode 100644 index 000000000..ba7cba509 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6306-discriminant-getter-dispatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6306-discriminant-getter-dispatch.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json new file mode 100644 index 000000000..8f317668f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7660-pointer-union-forwarded-arguments.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json new file mode 100644 index 000000000..51998f0eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7663-pointer-union-stored-payload.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7751-class-well-known-symbol-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7751-class-well-known-symbol-fields.ts.json new file mode 100644 index 000000000..2985bdf05 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7751-class-well-known-symbol-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7751-class-well-known-symbol-fields.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json new file mode 100644 index 000000000..50ed1b401 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8105-url-idna-hosts.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8105-url-idna-hosts.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8213-cycle-star-reexport/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8213-cycle-star-reexport/main.ts.json new file mode 100644 index 000000000..c22b59997 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8213-cycle-star-reexport/main.ts.json @@ -0,0 +1,9 @@ +{ + "order": [ + "/tests/corpus/8213-cycle-star-reexport/services.ts", + "/tests/corpus/8213-cycle-star-reexport/runtime.ts", + "/tests/corpus/8213-cycle-star-reexport/sdk.ts", + "/tests/corpus/8213-cycle-star-reexport/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json new file mode 100644 index 000000000..0ae9f6f2b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8215-js-any-logical-module-bindings/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8215-js-any-logical-module-bindings/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index d4794b7d1..479d8aad4 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -386,6 +386,10 @@ async function build() { ? [ await archive("libregexp", lreSources, quickjs, [ ...vendorTarget, + // Section elimination keeps only the libunicode tables a + // program reaches: the URL host parser's NFC links the + // normalization tables without the regex property ones. + ...matrix.executable_section_elimination.compile_flags, "-std=c11", "-Os", "-I", diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 257aa6444..0c91eaaff 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -398,6 +398,10 @@ typedef struct ScrDynTypedClassOps { size_t count; ScrStr *const *names; /* immortal member-name strings */ ScrStr *class_name; /* immortal constructor name; NULL for an anonymous class */ + /* The string an inherited Symbol.toStringTag getter answers for the + * instance (+1), or NULL with a pending exception from the getter; + * NULL when the class has no such getter. */ + ScrStr *(*string_tag)(void *instance); } ScrDynTypedClassOps; /* A compiled JavaScript class whose instances record their own properties @@ -677,6 +681,14 @@ void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry bool scr_dyn_field_eq_str(const ScrDyn *d, const ScrStr *key, const ScrStr *value); bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value); bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value); +/* The case a union's checked match takes from its string discriminant: the + * index in `values` of the string `d`'s own data property `key` holds, or + * -1 when every literal test above fails (`d` is not an object, or its own + * data property `key` holds no string among `values`), or -2 when only the + * tests themselves can tell (a typed capsule, an accessor, or a property + * `d` does not own). Borrowed args. */ +int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values, + size_t count); /* Object.keys/values/entries over the checked-dynamic tree: JS own-key order (array-index * keys ascending first), dyn-array results (+1); values/entries RETAIN @@ -921,6 +933,7 @@ void scr_dyn_install_iterator_symbol(ScrDyn *key); void scr_dyn_install_has_instance_symbol(ScrDyn *key); void scr_dyn_install_async_iterator_symbol(ScrDyn *key); void scr_dyn_install_async_dispose_symbol(ScrDyn *key); +void scr_dyn_install_to_string_tag_symbol(ScrDyn *key); /* True when key is Symbol.asyncDispose (registered by the symbol unit when * the program names it); handle symbol_get ops outside that unit use it. */ bool scr_dyn_is_async_dispose_symbol(const ScrDyn *key); @@ -1084,6 +1097,9 @@ ScrDyn *scr_record_source_apply(ScrDyn *snapshot, const void *rec); /* Attach the prototype member names of a class instance capsule * (idempotent), so `in` sees its methods and accessors. */ void scr_dyn_typed_ref_bind_class(ScrDyn *d, const ScrDynTypedClassOps *ops); +/* A class instance capsule's inherited Symbol.toStringTag string from its + * class's getter (+1); NULL when it has none or the getter threw. */ +ScrStr *scr_dyn_typed_ref_class_tag(const ScrDyn *d); /* Attach the bag and prototype hooks of a capsule of a bag-tracked class * instance (idempotent). */ void scr_dyn_typed_ref_bind_bag_class(ScrDyn *d, const ScrDynTypedBagClassOps *ops); diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 1dd5711a0..ebbfa8411 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -144,6 +144,10 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d, bool to_string) return; case SCR_DYN_TYPED_REF: { + /* ToString of a class instance that converts through + * Object.prototype.toString shows its Symbol.toStringTag getter's + * string. */ + if (to_string && scr_dyn_typed_ref_put_tag(b, d)) return; ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); scr_dyn_display_buf(b, materialized, to_string); scr_dyn_release(materialized); diff --git a/packages/runtime/src/scr_idna.c b/packages/runtime/src/scr_idna.c index 34c9e3882..7507e5caa 100644 --- a/packages/runtime/src/scr_idna.c +++ b/packages/runtime/src/scr_idna.c @@ -1,8 +1,8 @@ /* UTS #46 domain processing for node:url — the conversions Node applies to - * url.parse() hostnames (ToASCII), url.domainToASCII()/domainToUnicode() - * and url.format()'s unicode option: nontransitional mapping, NFC, label - * validity (leading combining marks, ContextJ, and the RFC 5893 bidi rule), - * and Punycode. It matches Node 24's implementation, including its + * WHATWG URL hosts and url.parse() hostnames (ToASCII), + * url.domainToASCII()/domainToUnicode() and url.format()'s unicode option: + * nontransitional mapping, NFC, label validity (leading combining marks, + * ContextJ, and the RFC 5893 bidi rule), and Punycode. It matches Node 24's implementation, including its * validity-data snapshot and the shortcuts its label checks take; the * differential corpus and test/url-legacy-cases.txt (generated from Node) * are the oracle. @@ -511,3 +511,6 @@ ScrStr *scr_idna_to_unicode(const char *p, size_t n) { } return scr_jb_finish(&out); } + +/* The URL host parser's UTS #46 ToASCII (scr_url.c). */ +void scr_url_idna_install(void) { scr_url_host_to_ascii = &scr_idna_to_ascii; } diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 333802c70..75317f420 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1258,6 +1258,20 @@ static bool insp_tag_names_class(const char *tag, const char *cls) { memcmp(tag + tag_len - cls_len, cls, cls_len) == 0; } +/* The Symbol.toStringTag a class instance inherits from its class's + * getter (+1), which Node brackets after the class name when it is a + * nonempty string other than the name. An own tag field is listed with + * the fields instead. A getter that throws shows no tag, as in Node. */ +static ScrStr *insp_inherited_tag(const ScrDyn *instance, const ScrStr *name) { + ScrStr *tag = scr_dyn_typed_ref_class_tag(instance); + if (!tag && scr_exc_pending()) scr_exc_clear(); + if (tag && (tag->len == 0 || (tag->len == name->len && memcmp(tag->data, name->data, name->len) == 0))) { + scr_str_release(tag); + return NULL; + } + return tag; +} + /* A native Set or Map held as a checked-dynamic value: `Set(n) { a, b }`, * `Map(n) { k => v }`, at most 100 entries like Node's maxArrayLength. */ /* Node's formatWeakCollection: the entries of a WeakMap or WeakSet are not @@ -1979,10 +1993,23 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { free(cls); } else if (name && materialized->kind == SCR_DYN_OBJ && scr_dyn_obj_get(materialized, "%error", 6) == NULL) { - char *cls = malloc(name->len + 1); + ScrStr *tag = insp_inherited_tag(d, name); + if (scr_exc_pending()) { + scr_dyn_release(materialized); + return scr_str_new("", 0); + } + size_t tag_len = tag ? tag->len + 3 : 0; + char *cls = malloc(name->len + tag_len + 1); if (!cls) scr_trap("scriptc: out of memory\n"); memcpy(cls, name->data, name->len); - cls[name->len] = '\0'; + if (tag) { + /* Node's `Name [tag]` prefix. */ + memcpy(cls + name->len, " [", 2); + memcpy(cls + name->len + 2, tag->data, tag->len); + cls[name->len + tag_len - 1] = ']'; + scr_str_release(tag); + } + cls[name->len + tag_len] = '\0'; size_t nseen = g_nseen; g_pending_seen = instance; out = scr_insp_obj(materialized, recurse, depth, cls); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 74fd53159..eb03ab6cc 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -202,6 +202,19 @@ void scr_dyn_install_has_instance_symbol(ScrDyn *key) { scr_atexit(scr_has_instance_symbol_cleanup); } +/* Symbol.toStringTag, installed when the program first evaluates it: no + * object holds the key before then. */ +static SCR_TL ScrDyn *scr_to_string_tag_symbol; +static void scr_to_string_tag_symbol_cleanup(void) { + scr_dyn_release(scr_to_string_tag_symbol); + scr_to_string_tag_symbol = NULL; +} +void scr_dyn_install_to_string_tag_symbol(ScrDyn *key) { + if (scr_to_string_tag_symbol) return; + scr_to_string_tag_symbol = scr_dyn_retain(key); + scr_atexit(scr_to_string_tag_symbol_cleanup); +} + void scr_dyn_install_iterator_symbol(ScrDyn *key) { if (scr_iterator_symbol) return; scr_iterator_symbol = scr_dyn_retain(key); @@ -1273,10 +1286,14 @@ void scr_dyn_release(ScrDyn *d) { return; } #ifndef SCR_RC_AUDIT - /* Number and string boxes own at most one string, are never cycle - * candidates and cannot be weak keys: return them to the free list. */ + /* Number and string boxes own at most one string and cannot be weak + * keys: return them to the free list. Their releases never buffer them, + * but a collector pass can (a box that traces its payload reaches them, + * and scr_xg_release buffers what it settles), so a buffered box leaves + * its candidate buffer first. */ if ((d->kind == SCR_DYN_NUM || d->kind == SCR_DYN_STR) && !d->prototype && !d->symbol_properties && !d->symbol_keys && scr_dyn_free_count < SCR_DYN_FREE_MAX) { + scr_cyc_dead(d); if (d->kind == SCR_DYN_STR) scr_str_release(d->v.str); d->v.str = (ScrStr *)scr_dyn_free_misc; scr_dyn_free_misc = d; @@ -1422,6 +1439,24 @@ bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value) { return matches; } +int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values, + size_t count) { + if (!d) return -1; + if (d->kind == SCR_DYN_TYPED_REF) return -2; + if (d->kind != SCR_DYN_OBJ) return -1; + for (size_t i = 0; i < d->v.obj.len; i++) { + const ScrDynEntry *entry = &d->v.obj.entries[i]; + if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue; + if (entry->accessor) return -2; + const ScrDyn *member = entry->value; + if (!member || member->kind != SCR_DYN_STR) return -1; + for (size_t k = 0; k < count; k++) + if (scr_str_eq(member->v.str, values[k])) return (int32_t)k; + return -1; + } + return -2; +} + bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value) { ScrDyn *member = scr_dyn_discriminant(d, key); bool matches = member && member->kind == SCR_DYN_BOOL && member->v.b == value; @@ -3932,6 +3967,12 @@ static bool scr_dyn_name_in_strs(const ScrStr *key, ScrStr *const *names, size_t return false; } +ScrStr *scr_dyn_typed_ref_class_tag(const ScrDyn *d) { + if (!d || d->kind != SCR_DYN_TYPED_REF || d->prototype) return NULL; + const ScrDynTypedClassOps *ops = scr_typed_ref_class_ops(d); + return ops && ops->string_tag ? ops->string_tag(d->v.typed_ref.ptr) : NULL; +} + bool scr_dyn_typed_ref_class_has(const ScrDyn *d, const ScrStr *key) { if (!d || d->kind != SCR_DYN_TYPED_REF || d->prototype) return false; const ScrDynTypedClassOps *ops = scr_typed_ref_class_ops(d); @@ -4020,6 +4061,9 @@ ScrDyn *scr_dyn_index_get(const ScrDyn *d, double index) { return scr_dyn_typed_ref_element(d, i); } +static ScrStr *scr_dyn_typed_ref_conversion_tag(const ScrDyn *d); +static ScrStr *scr_dyn_tag_string(const ScrStr *tag); + ScrDyn *scr_dyn_typed_ref_string_view(const ScrDyn *d) { /* An instance of a class declared `extends Date` converts through its * toString, declared or inherited. With an exception pending the view is @@ -4033,6 +4077,16 @@ ScrDyn *scr_dyn_typed_ref_string_view(const ScrDyn *d) { scr_str_release(empty); return out; } + /* A class instance converting through Object.prototype.toString shows + * its Symbol.toStringTag getter's string. */ + ScrStr *class_tag = scr_dyn_typed_ref_conversion_tag(d); + if (class_tag || scr_exc_pending()) { + ScrStr *text = class_tag ? scr_dyn_tag_string(class_tag) : scr_str_new("", 0); + scr_str_release(class_tag); + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; + } ScrDyn *view = scr_dyn_typed_ref_materialize(d); const char *tag = scr_event_ref_hooks ? scr_event_ref_hooks->string_tag(d) : NULL; if (!tag || view->kind != SCR_DYN_OBJ || scr_dyn_property_owner(view, "toString", 8)) return view; @@ -8321,11 +8375,66 @@ void scr_dyn_throw(ScrDyn *d) { scr_dyn_trace_v, scr_dyn_is_object(d)); } +static ScrStr *scr_dyn_tag_string(const ScrStr *tag) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "[object "); + scr_jb_write(&b, tag->data, tag->len); + scr_jb_putc(&b, ']'); + return scr_jb_finish(&b); +} + +/* The Symbol.toStringTag getter's string of a class instance whose + * ToString is Object.prototype.toString (its class has no toString; the + * string hint never reaches valueOf, since that answers a string): +1, or + * NULL otherwise or with the getter's exception pending. */ +static ScrStr *scr_dyn_typed_ref_conversion_tag(const ScrDyn *d) { + ScrStr *to_string_key = scr_str_new("toString", 8); + bool own_conversion = scr_dyn_typed_ref_class_has(d, to_string_key); + scr_str_release(to_string_key); + return own_conversion ? NULL : scr_dyn_typed_ref_class_tag(d); +} + +bool scr_dyn_typed_ref_put_tag(ScrJsonBuf *b, const ScrDyn *d) { + ScrStr *tag = scr_dyn_typed_ref_conversion_tag(d); + if (!tag) return scr_exc_pending(); + scr_jb_puts(b, "[object "); + scr_jb_write(b, tag->data, tag->len); + scr_jb_putc(b, ']'); + scr_str_release(tag); + return true; +} + +/* Object.prototype.toString of an object whose own or inherited + * @@toStringTag is a string (+1), NULL otherwise or with a pending + * exception from its getter. */ +static ScrStr *scr_dyn_string_tag(const ScrDyn *d) { + ScrStr *getter_tag = scr_dyn_typed_ref_class_tag(d); + if (getter_tag) { + ScrStr *out = scr_dyn_tag_string(getter_tag); + scr_str_release(getter_tag); + return out; + } + if (scr_exc_pending() || !scr_to_string_tag_symbol) return NULL; + ScrDyn *tag = scr_dyn_symbol_key_get(d, scr_to_string_tag_symbol, true); + if (!tag) return NULL; + ScrStr *out = tag->kind == SCR_DYN_STR ? scr_dyn_tag_string(tag->v.str) : NULL; + scr_dyn_release(tag); + return out; +} + ScrStr *scr_dyn_object_tag(const ScrDyn *d) { if (d->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Symbol.toStringTag lookup"); return NULL; } + /* Object.prototype.toString answers a string @@toStringTag over the + * builtin tag of a plain object or a class instance. */ + if ((d->kind == SCR_DYN_OBJ || (d->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_error(d) && + !scr_dyn_generator(d)))) { + ScrStr *tag = scr_dyn_string_tag(d); + if (tag || scr_exc_pending()) return tag; + } if (d->kind == SCR_DYN_TYPED_REF) { if (scr_dyn_ref_is_error(d)) return scr_str_new("[object Error]", 14); if (scr_dyn_generator(d)) { @@ -9668,6 +9777,13 @@ ScrStr *scr_dyn_string_coerce_js(const ScrDyn *d) { return NULL; } if (d->kind == SCR_DYN_TYPED_REF) { + ScrStr *tag = scr_dyn_typed_ref_conversion_tag(d); + if (tag) { + ScrStr *out = scr_dyn_tag_string(tag); + scr_str_release(tag); + return out; + } + if (scr_exc_pending()) return NULL; ScrDyn *materialized = scr_dyn_typed_ref_string_view(d); ScrStr *out = scr_dyn_string_coerce_js(materialized); scr_dyn_release(materialized); @@ -9688,7 +9804,7 @@ ScrStr *scr_dyn_string_coerce_js(const ScrDyn *d) { scr_error_release(error); return text; } - return scr_str_new("[object Object]", 15); + return scr_dyn_object_tag(d); } if (!m || m->kind != SCR_DYN_FUNC) { scr_dyn_release(m); continue; } /* OrdinaryToPrimitive performs a method call, not a bare function diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index de4510264..8a4ecc62d 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -119,6 +119,11 @@ _Static_assert(offsetof(ScrJsonBuf, data) == 0 && offsetof(ScrJsonBuf, len) == s "LLVM backend expects ScrJsonBuf to start with { data, len, cap }"); void scr_jb_init(ScrJsonBuf *b); +/* String conversion of a class instance whose ToString is + * Object.prototype.toString and whose class has a Symbol.toStringTag + * getter: appends "[object Tag]" and answers true, also with the getter's + * exception pending; false when the ordinary conversion applies. */ +bool scr_dyn_typed_ref_put_tag(ScrJsonBuf *b, const ScrDyn *d); /* Release a buffer's text without finishing it (error paths). */ void scr_jb_abandon(ScrJsonBuf *b); /* Append a borrowed byte span verbatim, including embedded NUL bytes. */ diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index a28759876..29ba4bfe3 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -391,6 +391,30 @@ static bool buffered_after_read(ScrDyn *d) { return buffered(d); } +/* A collector pass can buffer a number or string box that it reached + * through an owner that traces it: scr_xg_release, settling an edge a + * restricted pass skipped, calls scr_cyc_on_release on the target. The + * box's last release must take it out of its candidate buffer before its + * block returns to the free list, which hands it to the next box. */ +static void buffered_leaf_release(void) { + for (uint16_t gen = SCR_CYC_NURSERY; gen < SCR_CYC_NGENS; gen++) { + ScrStr *text = scr_str_new("buffered", 8); + ScrDyn *leaves[2] = {scr_dyn_new_str(text), scr_dyn_new_num(0.5)}; + scr_str_release(text); + for (int i = 0; i < 2; i++) { + scr_cyc_set_gen(scr_cyc_hdr(leaves[i]), gen); + scr_cyc_on_release(leaves[i]); + scr_dyn_release(leaves[i]); + } + ScrDyn *fresh[2] = {scr_dyn_new_num(1.5), scr_dyn_new_num(2.5)}; + assert(!buffered(fresh[0]) && !buffered(fresh[1])); + scr_collect_cycles(); + assert(fresh[0]->v.num == 1.5 && fresh[1]->v.num == 2.5); + scr_dyn_release(fresh[0]); + scr_dyn_release(fresh[1]); + } +} + static ScrDyn *parse_text(const char *text) { ScrStr *input = scr_str_new(text, strlen(text)); ScrDyn *parsed = scr_json_parse(input); @@ -823,6 +847,7 @@ int main(void) { shared_property_keys(); checked_leaf_cycles(); checked_number_storage(); + buffered_leaf_release(); json_intact_regions(); json_schema_parse(); json_schema_special_names(); diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index 647c31dc3..727e008b5 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -90,6 +90,11 @@ ScrSym *scr_sym_well_known(ScrStr *name) { scr_dyn_install_has_instance_symbol(key); scr_dyn_release(key); } + if (name->len == 11 && !memcmp(name->data, "toStringTag", 11)) { + ScrDyn *key = scr_dyn_new_symbol(symbol); + scr_dyn_install_to_string_tag_symbol(key); + scr_dyn_release(key); + } if (name->len == 13 && !memcmp(name->data, "asyncIterator", 13)) { ScrDyn *key = scr_dyn_new_symbol(symbol); scr_dyn_install_async_iterator_symbol(key); diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 09ccb87fe..a61286c63 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -21,10 +21,8 @@ * case preserved, C0-encoded), a single "/" roots a normalized path, * anything else is an OPAQUE path with the C0 control set encoded * (data:text/plain,hi there) - * - * DOCUMENTED DIVERGENCE: a special host that holds non-ASCII text after - * percent-decoding is rejected with "Invalid URL" (no UTS #46 processing), - * and "xn--" labels are kept without Punycode validation. + * - UTS #46 processing of non-ASCII hosts through the IDNA unit + * (scr_url_idna_install) * * Failures THROW catchable TypeErrors through the exception cell with * Node's messages; callers are compiler-emitted pending checks. */ @@ -430,16 +428,21 @@ static int hex_val(char c) { return -1; } +/* UTS #46 ToASCII (scr_idna.c), which programs that use URLs install at + * startup (scr_url_idna_install). NULL refuses hosts that hold non-ASCII + * text. */ +ScrStr *(*scr_url_host_to_ascii)(const char *p, size_t n) = NULL; + /* The URL Standard's host parser for the constructor and the setters, * with canonical IPv6 serialization: * - a non-special (opaque) host refuses forbidden host code points and * percent-encodes the C0 control set; - * - a special host is percent-decoded, lowercased, refused when it holds a - * forbidden domain code point, and serialized as dotted decimal when it - * ends in a number (or refused when that number is invalid). - * Divergence: a special host that decodes to non-ASCII needs UTS #46 - * processing, which this unit does not link, so it is refused; "xn--" - * labels are kept without Punycode validation. */ + * - a special host is percent-decoded and lowercased; when that leaves + * non-ASCII text it goes through UTS #46 ToASCII (as Node's host parser + * does, an ASCII host, "xn--" labels included, is only lowercased). The + * result is refused when it holds a forbidden domain code point, and + * serialized as dotted decimal when it ends in a number (or refused when + * that number is invalid). */ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_file, ScrStr **host) { bool ipv6 = host_len > 0 && hp[0] == '['; @@ -467,6 +470,7 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi *host = ub_take(&hb); return true; } + bool non_ascii = false; for (size_t i = 0; i < host_len; i++) { unsigned char c = (unsigned char)hp[i]; int hi, lo; @@ -475,12 +479,27 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi c = (unsigned char)(hi * 16 + lo); i += 2; } - if (url_host_byte_class[c] & URL_FORBIDDEN_DOMAIN) { + if (c >= 0x80) non_ascii = true; + if (c >= 'A' && c <= 'Z') c = (unsigned char)(c - 'A' + 'a'); + ub_push(&hb, (char)c); + } + if (non_ascii) { + /* UTS #46 ToASCII, or refused without the IDNA unit. */ + ScrStr *ascii = scr_url_host_to_ascii ? scr_url_host_to_ascii(hb.data, hb.len) : NULL; + free(hb.data); + if (!ascii || ascii->len == 0) { + scr_str_release(ascii); + return false; + } + ub_init(&hb); + ub_append(&hb, ascii->data, ascii->len); + scr_str_release(ascii); + } + for (size_t i = 0; i < hb.len; i++) { + if (url_host_byte_class[(unsigned char)hb.data[i]] & URL_FORBIDDEN_DOMAIN) { free(hb.data); return false; } - if (c >= 'A' && c <= 'Z') c = (unsigned char)(c - 'A' + 'a'); - ub_push(&hb, (char)c); } if (url_ends_in_number(hb.data, hb.len)) { UrlBuf ip; diff --git a/packages/runtime/src/scr_url.h b/packages/runtime/src/scr_url.h index c7c9c63c8..13a7d093b 100644 --- a/packages/runtime/src/scr_url.h +++ b/packages/runtime/src/scr_url.h @@ -46,6 +46,10 @@ void scr_url_set_checked(ScrUrl *u, ScrStr *field, const ScrDyn *value); /* borr * null and has no lowering for an object that is not a URL. Borrowed; +1, * or NULL with the error pending. */ ScrDyn *scr_url_to_http_options(ScrUrl *u); +/* UTS #46 ToASCII for the host parser: NULL until scr_url_idna_install + * (scr_idna.c, emitted at startup by programs that use URLs) sets it. */ +extern ScrStr *(*scr_url_host_to_ascii)(const char *p, size_t n); +void scr_url_idna_install(void); ScrDyn *scr_url_to_http_options_dyn(const ScrDyn *value); /* node:url's legacy surface (scr_url_legacy.c — linked with the regex, * querystring and dyn-async units). url.parse() answers a checked-dynamic diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 160e33438..9247585e5 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -394,7 +394,8 @@ export function dynamicTierSymbols(names) { * not yet traced to its typed root. bench/thread-slots, * bench/channel-messages and bench/worker-messages, added later, link them * through worker messages: - * node:worker_threads values are dyn objects. bench/dynamic-methods is + * node:worker_threads values are dyn objects. bench/json-unions, added + * later too, decodes JSON.parse trees. bench/dynamic-methods is * untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and * tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also * fails when a listed workload stops linking them, so remove a workload here @@ -414,6 +415,7 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/typed-array-scan-workers", "bench/log-summary", "bench/inventory-report", + "bench/json-unions", ]); /** Names of the functions (text and weak symbols) in `nm` output. Mach-O diff --git a/tests/corpus/6305-json-discriminant-dispatch.ts b/tests/corpus/6305-json-discriminant-dispatch.ts new file mode 100644 index 000000000..ac4d992da --- /dev/null +++ b/tests/corpus/6305-json-discriminant-dispatch.ts @@ -0,0 +1,123 @@ +// JSON cast into a union with many string discriminant literals: the cast +// reads the discriminant once and tests only the arm whose literal it +// holds, plus the arms that are not records. An arm can list several +// literals, and values that are not objects reach the other arms in +// canonical order. Valid casts only (see 1002-json-parse-cast.ts); JSON keys +// and record fields alphabetical for stringify parity. + +type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add" | "mul"; left: Expr; right: Expr } + | { args: Expr[]; kind: "call"; name: string } + | { kind: "method"; name: string; target: Expr } + | { kind: "list"; items: Expr[] }; + +function show(e: Expr): string { + switch (e.kind) { + case "num": + return String(e.value); + case "str": + return JSON.stringify(e.text); + case "neg": + return `-(${show(e.operand)})`; + case "add": + return `(${show(e.left)} + ${show(e.right)})`; + case "mul": + return `(${show(e.left)} * ${show(e.right)})`; + case "call": + return `${e.name}(${e.args.map(show).join(", ")})`; + case "method": + return `${show(e.target)}.${e.name}()`; + case "list": + return `[${e.items.map(show).join(", ")}]`; + } +} + +function evaluate(e: Expr): number { + switch (e.kind) { + case "num": + return e.value; + case "str": + return e.text.length; + case "neg": + return -evaluate(e.operand); + case "add": + return evaluate(e.left) + evaluate(e.right); + case "mul": + return evaluate(e.left) * evaluate(e.right); + case "call": + return e.args.reduce((sum, arg) => sum + evaluate(arg), 0); + case "method": + return evaluate(e.target); + case "list": + return e.items.length; + } +} + +const source = + '{"kind":"add","left":{"kind":"mul","left":{"kind":"num","value":3},' + + '"right":{"kind":"neg","operand":{"kind":"num","value":4}}},' + + '"right":{"args":[{"kind":"str","text":"abc"},{"kind":"num","value":2}],"kind":"call","name":"sum"}}'; +const tree = JSON.parse(source) as Expr; +console.log(show(tree)); +console.log(evaluate(tree)); +console.log(JSON.stringify(tree) === source); + +const method = JSON.parse( + '{"kind":"method","name":"size","target":{"items":[{"kind":"num","value":1}],"kind":"list"}}', +) as Expr; +console.log(show(method), evaluate(method)); + +// A deep list of every arm, built from text. +const parts: string[] = []; +for (let i = 0; i < 200; i++) { + parts.push( + i % 3 === 0 + ? `{"kind":"num","value":${i}}` + : i % 3 === 1 + ? `{"kind":"neg","operand":{"kind":"num","value":${i}}}` + : `{"kind":"mul","left":{"kind":"num","value":${i}},"right":{"kind":"str","text":"xy"}}`, + ); +} +const list = JSON.parse(`{"items":[${parts.join(",")}],"kind":"list"}`) as Expr; +if (list.kind === "list") { + let total = 0; + for (const item of list.items) total += evaluate(item); + console.log(list.items.length, total); +} + +// Arms that are not records: a value that is not an object can only be one +// of them. +type Token = + | { kind: "ident"; name: string } + | { kind: "number"; value: number } + | { kind: "string"; value: string } + | { kind: "punct"; symbol: string } + | { kind: "comment"; line: number; text: string } + | string + | number; + +function describe(token: Token): string { + if (typeof token === "string") return `raw ${token}`; + if (typeof token === "number") return `count ${token}`; + switch (token.kind) { + case "ident": + return `ident ${token.name}`; + case "number": + return `number ${token.value}`; + case "string": + return `string ${JSON.stringify(token.value)}`; + case "punct": + return `punct ${token.symbol}`; + case "comment": + return `comment ${token.line}: ${token.text}`; + } +} + +const tokens = JSON.parse( + '[{"kind":"ident","name":"x"},{"kind":"punct","symbol":"="},{"kind":"number","value":42},' + + '{"kind":"string","value":"hi"},{"kind":"comment","line":3,"text":"note"},"spare",7]', +) as Token[]; +for (const token of tokens) console.log(describe(token)); diff --git a/tests/corpus/6306-discriminant-getter-dispatch.ts b/tests/corpus/6306-discriminant-getter-dispatch.ts new file mode 100644 index 000000000..4f0a1244f --- /dev/null +++ b/tests/corpus/6306-discriminant-getter-dispatch.ts @@ -0,0 +1,55 @@ +// A checked cast into a union with many string discriminant literals reads +// the discriminant once, but a getter that runs while an arm's fields are +// checked can change it. The cast then continues with the arms after the +// one that failed, testing each arm's literal again, and matches the arm +// the new discriminant names. Each getter changes the discriminant on its +// first call only; the program reads the getter once itself, so Node (where +// the cast runs no code) reaches the same state. Valid casts only (see +// 1002-json-parse-cast.ts). + +type Shape = + | { kind: "circle"; radius: number } + | { kind: "square"; side: number } + | { kind: "rect"; height: number; width: number } + | { kind: "tri"; a: number; b: number; c: number }; + +function area(shape: Shape): number { + switch (shape.kind) { + case "circle": + return Math.round(Math.PI * shape.radius * shape.radius); + case "square": + return shape.side * shape.side; + case "rect": + return shape.width * shape.height; + case "tri": { + const s = (shape.a + shape.b + shape.c) / 2; + return Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c)); + } + } +} + +// The circle arm reads `radius`, whose getter turns the value into a square. +let squareReads = 0; +const square: any = { + kind: "circle", + get radius() { + squareReads++; + if (squareReads === 1) square.kind = "square"; + return "none"; + }, + side: 3, +}; +const asSquare = square as Shape; +void square.radius; +console.log(square.kind, asSquare.kind, area(asSquare)); + +// Values without getters take the arm their discriminant names. +const plain: any[] = [ + { kind: "rect", width: 2, height: 5 }, + { kind: "tri", a: 3, b: 4, c: 5 }, + { kind: "circle", radius: 2 }, +]; +for (const value of plain) { + const shape = value as Shape; + console.log(shape.kind, area(shape)); +} diff --git a/tests/corpus/7660-pointer-union-forwarded-arguments.ts b/tests/corpus/7660-pointer-union-forwarded-arguments.ts new file mode 100644 index 000000000..664f79fcb --- /dev/null +++ b/tests/corpus/7660-pointer-union-forwarded-arguments.ts @@ -0,0 +1,127 @@ +// Records and class instances passed to `T | undefined` and `T | null` +// parameters. The callee receives the object pointer itself, so a binding +// that the callee only reads keeps its frame storage; callees that keep, +// return or compare the argument still see one shared object. +interface Vec { + x: number; + y: number; +} +interface Tagged { + name: string; + at: Vec; +} +function vec(x: number, y: number): Vec { + return { x, y }; +} +function len(v: Vec | undefined): number { + return v === undefined ? -1 : Math.sqrt(v.x * v.x + v.y * v.y); +} +function sum(a: Vec | undefined, b: Vec): Vec { + return a === undefined ? b : { x: a.x + b.x, y: a.y + b.y }; +} +function label(t: Tagged | undefined): string { + return t === undefined ? "none" : t.name + "@" + t.at.x + "," + t.at.y; +} +function depth(v: Vec | undefined, n: number): number { + if (v === undefined || n === 0) return 0; + return v.x + depth(v, n - 1); +} + +const kept: (Vec | undefined)[] = []; +function keep(v: Vec | undefined): number { + kept.push(v); + return kept.length; +} +function same(a: Vec | undefined, b: Vec): boolean { + return a === b; +} +function orNull(v: Vec | undefined): Vec | undefined { + return v !== undefined && v.x < 0 ? undefined : v; +} + +class Cell { + value: number; + next: Cell | null; + constructor(value: number, next: Cell | null) { + this.value = value; + this.next = next; + } +} +function cellTotal(c: Cell | null): number { + let total = 0; + for (let at = c; at !== null; at = at.next) total += at.value; + return total; +} +function fail(v: Vec | undefined): number { + if (v !== undefined && v.x > 100) throw new Error("too far: " + v.x); + return v === undefined ? 0 : v.x; +} + +function projections(): void { + const a = vec(3, 4); + const b = sum(a, vec(1, 1)); + console.log("len", len(a), len(b), len(undefined)); + const c = sum(sum(a, b), a); + console.log("sum", c.x, c.y, sum(undefined, a).x); + console.log("depth", depth(a, 5), depth(undefined, 3)); + let total = 0; + for (let i = 0; i < 1000; i++) { + const p = vec(i, i + 1); + total += len(p) + len(sum(p, a)); + } + console.log("loop", total.toFixed(6)); +} + +function mutated(): void { + const p = vec(1, 2); + console.log("before", len(p)); + p.x = 6; + p.y = 8; + console.log("after", len(p)); +} + +function strings(): void { + const prefix = ["a", "b", "c"].join("-"); + const t: Tagged = { name: prefix + "!", at: vec(2, 5) }; + console.log("label", label(t), label(undefined)); + const u: Tagged = { name: label(t), at: t.at }; + console.log("nested", label(u)); +} + +function escapes(): void { + const a = vec(7, 8); + const count = keep(a); + const b = vec(9, 10); + console.log("kept", count, kept[0] === a, kept[0]!.x, keep(b), kept[1]!.y); + a.x = 70; + console.log("shared", kept[0]!.x); + const c = vec(1, 1); + console.log("same", same(c, c), same(c, vec(1, 1)), same(undefined, c)); + const d = vec(-1, 2); + const e = vec(4, 5); + console.log("returned", orNull(d), orNull(e) === e, orNull(e)!.y); +} + +function classes(): void { + const tail = new Cell(3, null); + const list = new Cell(1, new Cell(2, tail)); + console.log("cells", cellTotal(list), cellTotal(tail), cellTotal(null)); +} + +function throwing(): void { + const near = vec(5, 0); + const far = vec(500, 0); + try { + console.log("near", fail(near)); + console.log("far", fail(far)); + } catch (e) { + console.log("caught", (e as Error).message, near.x, far.x); + } +} + +projections(); +mutated(); +strings(); +escapes(); +classes(); +throwing(); diff --git a/tests/corpus/7663-pointer-union-stored-payload.ts b/tests/corpus/7663-pointer-union-stored-payload.ts new file mode 100644 index 000000000..92ac68bfb --- /dev/null +++ b/tests/corpus/7663-pointer-union-stored-payload.ts @@ -0,0 +1,83 @@ +// Records passed to `T | undefined` parameters whose callee keeps the +// narrowed record: pushed into a global array (also through a non-null +// assertion), kept through `??`, stored in a nullable class field or a record +// field, or passed on to a callee that keeps it. Each record must outlive its +// caller's frame. A callee that only reads the narrowed record lets it stay +// where it is. +interface Tagged { + id: number; + tags: string[]; +} +interface Slot { + held: Tagged | undefined; +} +const kept: Tagged[] = []; +const slot: Slot = { held: undefined }; +function pushNarrowed(t: Tagged | undefined): void { + if (t !== undefined) kept.push(t); +} +function pushAsserted(t: Tagged | undefined): void { + kept.push(t!); +} +function keepDefaulted(t: Tagged | undefined): void { + kept.push(t ?? { id: -1, tags: [] }); +} +function storeInSlot(t: Tagged | undefined): void { + if (t !== undefined) slot.held = t; +} +function keepRecord(t: Tagged): void { + kept.push(t); +} +function passOn(t: Tagged | undefined): void { + if (t !== undefined) keepRecord(t); +} +function readOnly(t: Tagged | undefined): number { + return t === undefined ? 0 : t.id + t.tags.length; +} +class Holder { + last: Tagged | undefined = undefined; + take(t: Tagged | undefined): void { + this.last = t; + } +} +const holder = new Holder(); +function fill(round: number): number { + let read = 0; + for (let i = 0; i < 3; i++) { + const a: Tagged = { id: round * 10 + i, tags: ["a", String(i)] }; + pushNarrowed(a); + const b: Tagged = { id: round * 10 + i + 100, tags: ["b"] }; + keepDefaulted(b); + const c: Tagged = { id: round * 10 + i + 200, tags: ["c"] }; + storeInSlot(c); + const d: Tagged = { id: round * 10 + i + 300, tags: ["d", "d"] }; + holder.take(d); + const e: Tagged = { id: round * 10 + i + 400, tags: [] }; + passOn(e); + const g: Tagged = { id: round * 10 + i + 600, tags: ["g", "g", "g"] }; + pushAsserted(g); + const f: Tagged = { id: round * 10 + i + 500, tags: ["f"] }; + read += readOnly(f); + } + return read; +} +// Reuses the stack the callers above ran on, and allocates collectable +// records, before the kept ones are read. +function churn(n: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + const t: Tagged = { id: i, tags: [String(i), String(i + 1)] }; + total += t.tags.length + readOnly(t); + } + return total; +} +let reads = 0; +for (let round = 0; round < 4; round++) { + reads += fill(round); + reads += churn(200); +} +console.log("reads", reads); +console.log("kept", kept.length); +for (const t of kept) console.log(t.id, t.tags.join("+")); +console.log("slot", slot.held === undefined ? "none" : slot.held.id, slot.held?.tags.join("+")); +console.log("holder", holder.last === undefined ? "none" : holder.last.id, holder.last?.tags.join("+")); diff --git a/tests/corpus/7751-class-well-known-symbol-fields.ts b/tests/corpus/7751-class-well-known-symbol-fields.ts new file mode 100644 index 000000000..d57fce3f8 --- /dev/null +++ b/tests/corpus/7751-class-well-known-symbol-fields.ts @@ -0,0 +1,115 @@ +// Class fields keyed by well-known symbols, Symbol.toStringTag first (the +// lru-cache shape), beside toStringTag getters and module symbol keys. +// String conversion, Object.prototype.toString and util.inspect read the +// tag as Node does, through typed values and through `unknown`. + +const limit = Symbol("limit"); + +class Cache { + size = 0; + [limit] = 10; + [Symbol.toStringTag] = "LRUCache"; +} + +class Sub extends Cache { + extra = 1; +} + +class Retag extends Cache { + constructor() { + super(); + this[Symbol.toStringTag] = "Retagged"; + } +} + +class Tagged { + x = 1; + get [Symbol.toStringTag](): string { + return "Custom"; + } +} + +class TaggedSub extends Tagged { + y = 2; +} + +class Named { + get [Symbol.toStringTag](): string { + return "Named"; + } +} + +class Blank { + get [Symbol.toStringTag](): string { + return ""; + } +} + +class Plain { + n = 3; +} + +const c = new Cache(); +console.log(String(c), `${c}`, Object.prototype.toString.call(c), c[Symbol.toStringTag]); +console.log(c); +console.log(Object.keys(c), JSON.stringify(c)); +console.log(new Sub(), String(new Sub())); +const r: Cache = new Retag(); +console.log(String(r), r); +c[Symbol.toStringTag] = "Other"; +console.log(`${c}`, c); + +const t = new Tagged(); +console.log(String(t), `${t}`, t, [t], { deep: { deeper: { t } } }); +const ts: Tagged = new TaggedSub(); +console.log(String(ts), ts); +console.log(new Named(), new Blank(), String(new Blank())); +console.log(String(new Plain()), new Plain()); + +const values: unknown[] = [new Cache(), new Tagged(), new TaggedSub(), new Plain()]; +for (const v of values) { + console.log(Object.prototype.toString.call(v), String(v), (v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag]); +} +console.log(values); + +const literal: unknown = { [Symbol.toStringTag]: "Literal", a: 1 }; +console.log(Object.prototype.toString.call(literal), String(literal), `${literal}`); + +// util.inspect ignores a Symbol.toStringTag getter that throws, typed or +// held as an untyped value. +class Throwing { + get [Symbol.toStringTag](): string { + throw new Error("tag"); + } +} +const throwing: unknown = new Throwing(); +console.log(new Throwing(), [new Throwing()], throwing, [throwing]); + +// A conversion to "[object Object]" still evaluates its receiver. +let made = 0; +function make(): Plain { + made++; + return new Plain(); +} +function fail(): Plain { + throw new Error("receiver"); +} +console.log(Object.prototype.toString.call(make()), String(make()), made); +try { + console.log(Object.prototype.toString.call(fail())); +} catch (error) { + console.log((error as Error).message); +} + +// ToString of a class with valueOf and no toString reads its tag; the +// default hint calls valueOf. +class Valued { + valueOf(): number { + return 1; + } + get [Symbol.toStringTag](): string { + return "Valued"; + } +} +const valued: unknown = new Valued(); +console.log(String(valued), `${valued}`, [valued].join(), valued + "", Object.prototype.toString.call(valued)); diff --git a/tests/corpus/8105-url-idna-hosts.js b/tests/corpus/8105-url-idna-hosts.js new file mode 100644 index 000000000..329566013 --- /dev/null +++ b/tests/corpus/8105-url-idna-hosts.js @@ -0,0 +1,58 @@ +// WHATWG URL hosts that hold non-ASCII text, directly or after +// percent-decoding, go through UTS #46 ToASCII as the host parser in Node +// does: mapped and normalized labels become Punycode, and disallowed code +// points, invalid joiners and bidi violations make the URL invalid. Cases +// from the WPT url test data, plus setters, pathToFileURL UNC hosts and +// URL.canParse. +import url from "node:url"; + +const parses = [ + ["http://é@é", null], + ["http://GOO\u200B\u2060\uFEFFgoo.com", "http://other.com/"], + ["http://www.foo。bar.com", "http://other.com/"], + ["http://Go.com", "http://other.com/"], + ["http://你好你好", "http://other.com/"], + ["https://faß.ExAmPlE/", null], + ["http://0Xc0.0250.01", "http://other.com/"], + ["ftp://%e2%98%83", null], + ["https://%e2%98%83", null], + ["file://a\u00ADb/p", null], + ["file://a%C2%ADb/p", null], + ["file://loC𝐀𝐋𝐇𝐨𝐬𝐭/usr/bin", null], + ["https://a%C2%ADb/", null], + ["http://münchen.de/straße", null], + ["https://日本語.jp/", null], + ["http://例え.テスト/", null], + ["http://xn--mnchen-3ya.de/", null], + ["http://a\u200Db.com/", null], + ["http://אa.com/", null], + ["http://ab�c/", null], + ["ws://ÉXAMPLE.org:8080/x", null], +]; +function describe(value) { + return [value.href, value.host, value.hostname, value.origin].join(" | "); +} +for (const [input, base] of parses) { + let line; + try { + line = describe(base === null ? new URL(input) : new URL(input, base)); + } catch (e) { + line = `throws ${e.code}`; + } + console.log(JSON.stringify(input), "=>", line, URL.canParse(input, base ?? undefined)); +} +const writes = [ + ["host", "https://x/", "ß"], + ["host", "https://example.com/", "a%C2%ADb"], + ["hostname", "https://example.com/", "a%C2%ADb"], + ["hostname", "https://example.com/", "BÜCHER.example"], +]; +for (const [name, href, value] of writes) { + const target = new URL(href); + if (name === "host") target.host = value; + else target.hostname = value; + console.log(name, JSON.stringify(value), "=>", describe(target)); +} +for (const path of ["\\\\münchen\\share\\f", "\\\\BÜCHER\\s\\f"]) { + console.log(JSON.stringify(path), url.pathToFileURL(path, { windows: true }).href); +} diff --git a/tests/corpus/8213-cycle-star-reexport/main.ts b/tests/corpus/8213-cycle-star-reexport/main.ts new file mode 100644 index 000000000..04cd7cea1 --- /dev/null +++ b/tests/corpus/8213-cycle-star-reexport/main.ts @@ -0,0 +1,11 @@ +// A module of an import cycle re-exports another member with `export *` +// (pi-coding-agent's sdk.js → agent-session-runtime.js → +// agent-session-services.js → sdk.js). The re-exported names are live +// bindings of the member that declares them, so they read the same values, +// and the same temporal dead zone errors, as in Node. +import { createSession, describeRuntime, Runtime, SDK_VERSION } from "./sdk.ts"; +import { servicesReport } from "./services.ts"; + +console.log(servicesReport()); +console.log(createSession("main").describe()); +console.log(describeRuntime(), new Runtime("direct").name, SDK_VERSION); diff --git a/tests/corpus/8213-cycle-star-reexport/runtime.ts b/tests/corpus/8213-cycle-star-reexport/runtime.ts new file mode 100644 index 000000000..f3653d255 --- /dev/null +++ b/tests/corpus/8213-cycle-star-reexport/runtime.ts @@ -0,0 +1,17 @@ +import { createServices, servicesLabel } from "./services.ts"; + +export class Runtime { + readonly name: string; + constructor(name: string) { + this.name = name; + } + describe(): string { + return `${this.name} runtime with ${servicesLabel}`; + } +} + +export const runtimeLabel = "runtime"; + +export function describeRuntime(): string { + return `${runtimeLabel} (${typeof createServices})`; +} diff --git a/tests/corpus/8213-cycle-star-reexport/sdk.ts b/tests/corpus/8213-cycle-star-reexport/sdk.ts new file mode 100644 index 000000000..7d67384c4 --- /dev/null +++ b/tests/corpus/8213-cycle-star-reexport/sdk.ts @@ -0,0 +1,9 @@ +import { createServices } from "./services.ts"; + +export * from "./runtime.ts"; + +export const SDK_VERSION = "1.4.0"; + +export function createSession(name: string) { + return createServices(name); +} diff --git a/tests/corpus/8213-cycle-star-reexport/services.ts b/tests/corpus/8213-cycle-star-reexport/services.ts new file mode 100644 index 000000000..62e6b684e --- /dev/null +++ b/tests/corpus/8213-cycle-star-reexport/services.ts @@ -0,0 +1,27 @@ +import { Runtime, SDK_VERSION, describeRuntime, runtimeLabel } from "./sdk.ts"; + +export const servicesLabel = "services"; + +function probe(read: () => unknown): string { + try { + return String(read()); + } catch (error) { + return `${(error as Error).name}: ${(error as Error).message}`; + } +} + +// This module runs first: sdk.ts and runtime.ts have not run yet. +const early = [ + probe(() => SDK_VERSION), + probe(() => runtimeLabel), + probe(() => typeof describeRuntime), + probe(() => new Runtime("early").name), +]; + +export function servicesReport(): string { + return `early ${early.join(" | ")}; late ${SDK_VERSION} ${runtimeLabel}`; +} + +export function createServices(name: string): Runtime { + return new Runtime(name); +} diff --git a/tests/corpus/8215-js-any-logical-module-bindings/main.ts b/tests/corpus/8215-js-any-logical-module-bindings/main.ts new file mode 100644 index 000000000..3a1d5eb17 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/main.ts @@ -0,0 +1,8 @@ +// Module bindings of a JavaScript package whose initializers combine an +// untyped value with a string, number or boolean through `||`, `&&`, `??` +// or `?:` (pi-coding-agent's config.js): other modules and functions read +// the values as in Node. +import { banner, usage } from "app-config"; + +console.log(banner); +console.log(usage("check")); diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js new file mode 100644 index 000000000..faecb8b2b --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/config.js @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +let pkg = {}; +try { + pkg = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf-8")); +} +catch (e) { + if (e.code !== "ENOENT") + throw e; +} +const configName = pkg.appConfig?.name; +export const APP_NAME = configName || "app"; +export const APP_TITLE = configName ? APP_NAME : "App"; +export const CONFIG_DIR = pkg.appConfig?.configDir || ".config"; +export const VERSION = pkg.version ?? "0.0.0"; +export const RETRIES = pkg.appConfig?.retries ?? 3; +export const isBundled = typeof APP_BUNDLED !== "undefined" && APP_BUNDLED; diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts new file mode 100644 index 000000000..4c4706926 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare const banner: string; +export declare function usage(kind: "check" | "list"): string; diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js new file mode 100644 index 000000000..251303802 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/index.js @@ -0,0 +1 @@ +export { banner, usage } from "./usage.js"; diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js new file mode 100644 index 000000000..736cef3c0 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/dist/usage.js @@ -0,0 +1,12 @@ +import { APP_NAME, APP_TITLE, CONFIG_DIR, RETRIES, VERSION, isBundled } from "./config.js"; + +const USAGE = { + check: `${APP_NAME} check --dir ${CONFIG_DIR}`, + list: `${APP_NAME} list`, +}; + +export const banner = `${APP_TITLE} ${VERSION} (bundled: ${isBundled}, retries: ${RETRIES})`; + +export function usage(kind) { + return `${USAGE[kind]} [${APP_NAME.toUpperCase()}_HOME=${CONFIG_DIR}] ${typeof isBundled} ${RETRIES + 1}`; +} diff --git a/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json new file mode 100644 index 000000000..918204988 --- /dev/null +++ b/tests/corpus/8215-js-any-logical-module-bindings/node_modules/app-config/package.json @@ -0,0 +1 @@ +{ "name": "app-config", "version": "2.1.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "appConfig": { "configDir": ".app" } } diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index dd77039ca..29268c841 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -298,8 +298,10 @@ test("pull requests run the full suite on Linux and the darwin suite on macOS", // runner, and directly elsewhere. const linuxRun = linux.steps.at(-1)!.run as string; expect(linuxRun.startsWith("ulimit -c 0\n")).toBe(true); - expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue"); - expect(linuxRun).toMatch(/-- env PATH="\$PATH" HOME="\$HOME" node scripts\/ci-test\.mjs\n/); + expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=15G -p OOMPolicy=continue"); + expect(linuxRun).toMatch( + /-- env PATH="\$PATH" HOME="\$HOME" NODE_OPTIONS="--max-old-space-size=4096" node scripts\/ci-test\.mjs\n/, + ); expect(linuxRun.endsWith("fi\nnode scripts/ci-test.mjs\n")).toBe(true); expect(macos.steps.at(-1)!.run).toBe("node scripts/ci-test.mjs"); for (const job of [linux, macos]) expect(job.strategy.matrix.flavor).toEqual(["plain", "san"]); diff --git a/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json b/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json new file mode 100644 index 000000000..7e2a9d53c --- /dev/null +++ b/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json @@ -0,0 +1 @@ +{ "plain": 5844, "san": 7609 } diff --git a/tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json b/tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json new file mode 100644 index 000000000..2c49e2f02 --- /dev/null +++ b/tests/harness/corpus-costs/6306-discriminant-getter-dispatch.ts.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 4900 } diff --git a/tests/harness/corpus-costs/7751-class-well-known-symbol-fields.ts.json b/tests/harness/corpus-costs/7751-class-well-known-symbol-fields.ts.json new file mode 100644 index 000000000..5ed28c9dd --- /dev/null +++ b/tests/harness/corpus-costs/7751-class-well-known-symbol-fields.ts.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 4300 } diff --git a/tests/harness/corpus-costs/8213-cycle-star-reexport/main.ts.json b/tests/harness/corpus-costs/8213-cycle-star-reexport/main.ts.json new file mode 100644 index 000000000..ef439d096 --- /dev/null +++ b/tests/harness/corpus-costs/8213-cycle-star-reexport/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4500 } diff --git a/tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json b/tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/8215-js-any-logical-module-bindings/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 15068776a..69e97dc6d 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -396,6 +396,95 @@ console.log("unreachable", s.kind); expect(r.stderr).toContain("Uncaught TypeError: expected object | object at $, got object"); }); + test("a union read by its discriminant fails with every arm named, as arm-by-arm tests do", async () => { + // Five record arms with six literals, plus string and number arms: the + // check reads `kind` once and tests only the arm it names and the + // arms that are not records. + const r = await compileAndRun( + "union-discriminant-dispatch", + `type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add" | "mul"; left: Expr; right: Expr } + | { items: Expr[]; kind: "list" } + | string + | number; +const inputs = [ + '{"kind":"num"}', + '{"kind":"nope","value":1}', + '{"kind":7,"value":1}', + '{"value":1}', + '{"kind":"neg","operand":{"kind":"add","left":{"kind":"num","value":1},"right":{"kind":"str","text":5}}}', + 'true', + '{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}', +]; +for (const input of inputs) { + try { + console.log(JSON.stringify(JSON.parse(input) as Expr)); + } catch (error) { + console.log((error as Error).message); + } +} +`, + ); + const any = "number | object | object | object | object | object | string"; + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got boolean`, + '{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}', + "", + ].join("\n"), + ); + }); + + test("a getter that changes the discriminant during an arm's check continues with the later arms", async () => { + // Each getter sets `kind` and fails its arm's check: to a later arm's + // literal (that arm matches), to an earlier arm's literal (arm-by-arm + // tests do not go back), and to a literal no arm holds. + const r = await compileAndRun( + "union-discriminant-getter", + `type Shape = + | { kind: "circle"; radius: number } + | { kind: "square"; side: number } + | { kind: "rect"; height: number; width: number } + | { kind: "tri"; a: number; b: number; c: number }; +const flip = (value: any, to: string): string => { + value.kind = to; + return "none"; +}; +const later: any = { kind: "circle", get radius() { return flip(later, "square"); }, side: 3 }; +const earlier: any = { kind: "square", radius: 2, get side() { return flip(earlier, "circle"); } }; +const unknown: any = { kind: "square", get side() { return flip(unknown, "hexagon"); } }; +const inputs = [later, earlier, unknown]; +for (const input of inputs) { + try { + const shape = input as Shape; + console.log("matched", shape.kind); + } catch (error) { + console.log((error as Error).message); + } +} +`, + ); + const any = "object | object | object | object"; + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "matched square", + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + "", + ].join("\n"), + ); + }); + test("nested paths point at the offending element", async () => { const r = await compileAndRun( "nested-path",