Merge main

This commit is contained in:
Chris Tate
2026-10-24 06:44:55 -05:00
90 changed files with 2542 additions and 215 deletions
+11 -6
View File
@@ -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
+2
View File
@@ -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 |
@@ -0,0 +1,6 @@
{
"entry": "workloads/json-unions.ts",
"args": [
"6"
]
}
+155
View File
@@ -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<string, number>): 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);
@@ -0,0 +1,6 @@
{
"entry": "workloads/keyed-option-writes.ts",
"args": [
"4"
]
}
@@ -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<Step, boolean> = { scale: false, offset: false, clamp: false };
function enable(name: Step): void {
enabled[name] = true;
}
const totals: Record<string, number> = {};
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);
@@ -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.
@@ -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.
@@ -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);
});
@@ -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<string>;
declarations: Map<string, number>;
forwards: Map<string, ForwardedUse[]>;
/** 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<string>;
/** Narrowed payloads of union bindings passed directly as call arguments:
* the payload stays in place if the callee's parameter is proved. */
payloadForwards: Map<string, ForwardedUse[]>;
}
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<string, ForwardedUse[]>,
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) ? <libCall> : 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<string> = new Set(),
inlineField: InlineFieldTest = NO_INLINE_FIELDS,
recordDynCopy: RecordDynCopyTest = () => false,
pointerWrap: PointerWrapTest = () => false,
): CallLifetimes {
const usesByFunction = new Map<string, Uses>();
const lazyCaptures = new LazyCaptures(functions);
const nodes = new Map<string, Parameter[]>();
/** Union parameters that keep their payload in place too, which a
* pointer-wrapped argument needs (ForwardedUse.wrapped). */
const payloadNodes = new Map<string, (Parameter | undefined)[]>();
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<string>();
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);
}
+171 -12
View File
@@ -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<string, string>();
private readonly dynBuilders = new Map<string, string>();
@@ -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)`,
@@ -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()`] : []),
@@ -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();
@@ -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<IrClassDef["symbolFields"]>[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} }`,
``,
);
}
@@ -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
* <build-cache>/vendor/<commit>-lre-<flavor>-<target>-<toolchain>/*.o — with the same atomic-rename
* <build-cache>/vendor/<commit>-lre-sections-<flavor>-<target>-<toolchain>/*.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<string[]> {
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",
@@ -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)
@@ -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 };
@@ -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<IrClassDef["symbolFields"]> => {
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) {
@@ -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);
@@ -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 {
@@ -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<string, string>();
@@ -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 });
@@ -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;
@@ -645,8 +645,7 @@ function extendsParameter(
info: GenericFnInfo,
): boolean {
const params = new Set<ts.Symbol>();
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;
@@ -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)!;
@@ -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) {
@@ -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(
@@ -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}`;
}
@@ -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),
@@ -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.
@@ -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 {
@@ -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);
@@ -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<unknown, number[]>();
private readonly readers = new Map<SiteFact, number[]>();
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;
}
@@ -182,11 +182,28 @@ export class StringIndexBounds {
private readonly identifiers = new Map<ts.Node, Map<string, ts.Identifier[]>>();
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 {
@@ -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<string> = new Set(["toStringTag"]);
/** The key of `Symbol.<name>` 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.<name>`: 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
+30 -29
View File
@@ -254,13 +254,18 @@ function aliasedPackageName(pkgDir: string): string | null {
export function npmStaticTransformPkgJson(pkg: Record<string, unknown>): 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<string, unknown>;
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<string>();
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<string, unknown>): 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<string, unknown> =
typeof val === "string"
? { import: val, require: val }
: val !== null && typeof val === "object" && !Array.isArray(val)
? (val as Record<string, unknown>)
: {};
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<string, unknown>)) {
if (key !== "node") {
add(key, value);
continue;
}
const inner: Record<string, unknown> =
typeof value === "string"
? { import: value, require: value }
: value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
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<string, unknown>): 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
+5 -1
View File
@@ -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)) {
+6 -3
View File
@@ -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();
@@ -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);
}
}
@@ -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,
@@ -157,10 +157,12 @@ const MODULE_FIELDS: Record<StringFields<Omit<IrModule, "functions">>, 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<string, () => 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: {
@@ -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;
}
+7 -2
View File
@@ -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
+14 -4
View File
@@ -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,
+22 -3
View File
@@ -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 });
@@ -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",
@@ -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<IrModule> {
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<Step, boolean> = { scale: false, clamp: false };
function enable(name: Step): void {
enabled[name] = true;
}
const totals: Record<string, number> = {};
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);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6305-json-discriminant-dispatch.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6306-discriminant-getter-dispatch.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7660-pointer-union-forwarded-arguments.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7663-pointer-union-stored-payload.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7751-class-well-known-symbol-fields.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8105-url-idna-hosts.js"
],
"diags": []
}
@@ -0,0 +1,9 @@
{
"order": [
"<repo>/tests/corpus/8213-cycle-star-reexport/services.ts",
"<repo>/tests/corpus/8213-cycle-star-reexport/runtime.ts",
"<repo>/tests/corpus/8213-cycle-star-reexport/sdk.ts",
"<repo>/tests/corpus/8213-cycle-star-reexport/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8215-js-any-logical-module-bindings/main.ts"
],
"diags": []
}
@@ -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",
+16
View File
@@ -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);
+4
View File
@@ -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);
+7 -4
View File
@@ -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; }
+29 -2
View File
@@ -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);
+119 -3
View File
@@ -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
+5
View File
@@ -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. */
+25
View File
@@ -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();
+5
View File
@@ -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);
+32 -13
View File
@@ -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;
+4
View File
@@ -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
+3 -1
View File
@@ -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
@@ -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));
@@ -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));
}
@@ -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();
@@ -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("+"));
@@ -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));
+58
View File
@@ -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);
}
@@ -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);
@@ -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})`;
}
@@ -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);
}
@@ -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);
}
@@ -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"));
@@ -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;
@@ -0,0 +1,2 @@
export declare const banner: string;
export declare function usage(kind: "check" | "list"): string;
@@ -0,0 +1 @@
export { banner, usage } from "./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}`;
}
@@ -0,0 +1 @@
{ "name": "app-config", "version": "2.1.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "appConfig": { "configDir": ".app" } }
+4 -2
View File
@@ -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"]);
@@ -0,0 +1 @@
{ "plain": 5844, "san": 7609 }
@@ -0,0 +1 @@
{ "plain": 3800, "san": 4900 }
@@ -0,0 +1 @@
{ "plain": 3800, "san": 4300 }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 4500 }
@@ -0,0 +1 @@
{ "plain": 2500, "san": 3500 }
+89
View File
@@ -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",