#!/usr/bin/env node /* Finds lowering state whose result depends on the order reachable bodies * lower in. * * Each program is lowered to IR in discovery order and with every seed of * SCRIPTC_LOWER_SHUFFLE_SEED (lowerer.ts), and the IR is compared function * by function. Output assembly does not depend on the order, so every * difference is state one body's lowering leaves for another. The * discovery-order run also audits the lowerer's Map and Set fields: a key * that two bodies touch, at least one of them writing it, makes the field * order-sensitive for both bodies. A function whose IR differs between * orders is attributed to the order-sensitive fields of the body that * produced it. * * node scripts/lowering-order.mjs [--stride 7] [--offset 0] [--limit N] * [--seeds 1,2,3] [--jobs 8] [--entry ]... [--out ] * [--fail-on names|content] * * Needs a built compiler (pnpm build). Writes /results.json and prints * a ranking of fields by the programs whose IR they change. With --fail-on, * it exits with status 1 when some program's IR differs between orders: * in any way (names), or beyond names and ids (content). */ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { cpus, tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; const root = fileURLToPath(new URL("..", import.meta.url)); const dist = join(root, "packages/compiler/dist"); if (process.argv[2] === "--child") { const [, , , entry, irPath, seed, auditPath, snapshotPath] = process.argv; if (seed !== "-") process.env["SCRIPTC_LOWER_SHUFFLE_SEED"] = seed; const module = (path) => import(pathToFileURL(join(dist, path)).href); const audit = auditPath === "-" ? null : installAudit( await module("frontend/lowering/lowerer.js"), await module("frontend/lowering/class-dynamic-dispatch.js"), await module("frontend/lowering/deferred-module-initializers.js"), ); const snapshots = installSnapshot(await module("frontend/lowering/lowerer.js")); const { compile } = await import(pathToFileURL(join(dist, "index.js")).href); const outDir = join(tmpdir(), `scriptc-lowering-order-${process.pid}`); const result = await compile(entry, { outDir, outPath: irPath, backend: "llvm", outputKind: "ir", }); if (audit !== null) writeFileSync(auditPath, JSON.stringify(audit.report())); writeFileSync(snapshotPath, JSON.stringify(snapshots.at(-1) ?? {})); process.exit(result.ok ? 0 : 3); } /** Records the contents of the lowerer's Map and Set fields once reachable * emit finishes, under keys that do not depend on the process: syntax * nodes by file and span, symbols by name and declaration, IR values by id * or name. Values are digested to a bounded depth. */ function installSnapshot({ Lowerer }) { const snapshots = []; const nodeKey = (node) => { try { return `node:${node.getSourceFile().fileName}:${node.kind}:${node.pos}:${node.end}`; } catch { return `node:?:${node.kind}:${node.pos}:${node.end}`; } }; const isNode = (value) => typeof value.kind === "number" && typeof value.pos === "number" && typeof value.end === "number" && typeof value.getSourceFile === "function"; const isSymbol = (value) => typeof value.escapedName === "string" && Array.isArray(value.declarationHandles); const symbolKey = (symbol) => { let declaration = ""; try { const first = symbol.declarations[0]; if (first !== undefined) declaration = nodeKey(first); } catch {} return `symbol:${symbol.escapedName}:${declaration}`; }; const key = (value) => { if (value === null || (typeof value !== "object" && typeof value !== "function")) return `${typeof value}:${String(value)}`; if (isNode(value)) return nodeKey(value); if (isSymbol(value)) return symbolKey(value); for (const field of ["name", "id", "localId"]) if (typeof value[field] === "string" || typeof value[field] === "number") return `${value.constructor?.name}:${field}:${value[field]}`; if (value.def && typeof value.def.name === "string") return `${value.constructor?.name}:def:${value.def.name}`; return null; }; const digest = (value, depth) => { if (value === null || value === undefined || typeof value !== "object") return typeof value === "function" ? "fn" : (JSON.stringify(value) ?? "undefined"); const stable = key(value); if (depth === 0) return stable ?? `<${value.constructor?.name}>`; if (isNode(value) || isSymbol(value)) return stable; if (Array.isArray(value)) return `[${value.map((item) => digest(item, depth - 1)).join(",")}]`; if (value instanceof Map) return `Map{${[...value] .map(([k, v]) => `${key(k) ?? "?"}=>${digest(v, depth - 1)}`) .sort() .join(",")}}`; if (value instanceof Set) return `Set{${[...value] .map((item) => key(item) ?? digest(item, depth - 1)) .sort() .join(",")}}`; const fields = Object.keys(value).sort(); return `${value.constructor?.name ?? "Object"}{${fields.map((field) => `${field}:${digest(value[field], depth - 1)}`).join(",")}}`; }; const emitReachable = Lowerer.prototype.emitReachable; Lowerer.prototype.emitReachable = function (...args) { const result = emitReachable.apply(this, args); const snapshot = {}; for (const [field, value] of Object.entries(this)) { if (!(value instanceof Map) && !(value instanceof Set)) continue; const entries = {}; const unkeyed = []; for (const item of value instanceof Map ? value : [...value].map((v) => [v, true])) { const [k, v] = item; const stable = key(k); const text = digest(v, 3); if (stable === null) unkeyed.push(text); else entries[stable] = text; } snapshot[field] = { entries, unkeyed: unkeyed.sort() }; } snapshots.push(snapshot); return result; }; return snapshots; } /** Wraps the lowering methods that produce bodies and the lowerer's Map and * Set fields; records which bodies touch which keys. */ function installAudit({ Lowerer }, { ClassDynamicDispatch }, { DeferredModuleInitializers }) { const proto = Lowerer.prototype; const tokens = []; const stack = []; const fields = new Map(); const objectIds = new Map(); const keyId = (key) => { if (key === null || (typeof key !== "object" && typeof key !== "function")) return `${typeof key}:${String(key)}`; let id = objectIds.get(key); if (id === undefined) objectIds.set(key, (id = objectIds.size)); return `object:${id}`; }; const current = () => (stack.length === 0 ? null : stack[stack.length - 1]); const touch = (field, key, write) => { const token = current(); if (token === null) return; let keys = fields.get(field); if (keys === undefined) fields.set(field, (keys = new Map())); const id = key === ITERATE ? "*" : keyId(key); let entry = keys.get(id); if (entry === undefined) keys.set(id, (entry = { readers: new Set(), writers: new Set() })); (write ? entry.writers : entry.readers).add(token.id); }; const ITERATE = Symbol("iterate"); const instrument = (field, value) => { const read = (key) => touch(field, key, false); const write = (key) => { touch(field, key, true); // A write changes what every iteration of the field observes. touch(field, ITERATE, true); }; const iterate = () => touch(field, ITERATE, false); const base = value instanceof Map ? Map.prototype : Set.prototype; for (const name of ["get", "has"]) { if (!(name in base)) continue; const original = base[name]; value[name] = function (key) { read(key); return original.call(this, key); }; } for (const name of ["set", "add", "delete"]) { if (!(name in base)) continue; const original = base[name]; value[name] = function (key, ...rest) { write(key); return original.call(this, key, ...rest); }; } for (const name of ["forEach", "keys", "values", "entries", Symbol.iterator]) { const original = base[name]; value[name] = function (...args) { iterate(); return original.apply(this, args); }; } const size = Object.getOwnPropertyDescriptor(base, "size").get; Object.defineProperty(value, "size", { get() { iterate(); return size.call(this); }, }); }; const emitReachable = proto.emitReachable; proto.emitReachable = function (...args) { for (const [field, value] of Object.entries(this)) if (value instanceof Map || value instanceof Set) instrument(field, value); return emitReachable.apply(this, args); }; const produced = (result, into) => { if (result === null || result === undefined) return; if (Array.isArray(result)) for (const fn of result) produced(fn, into); else if (typeof result === "object" && typeof result.name === "string") into.add(result.name); }; // Passes over the lowered bodies after the worklist: the functions they // create belong to the pass. const passes = new Map(); const wrapPass = (owner, label, method) => { const original = owner.prototype[method]; owner.prototype[method] = function (lowerer, ...rest) { const implicit = lowerer.implicitFns?.length ?? 0; const lifted = lowerer.liftedFns?.length ?? 0; try { return original.call(this, lowerer, ...rest); } finally { let names = passes.get(label); if (names === undefined) passes.set(label, (names = new Set())); for (const fn of (lowerer.implicitFns ?? []).slice(implicit)) names.add(fn.name); for (const fn of (lowerer.liftedFns ?? []).slice(lifted)) names.add(fn.name); } }; }; wrapPass(ClassDynamicDispatch, "ClassDynamicDispatch.process", "process"); wrapPass(ClassDynamicDispatch, "ClassDynamicDispatch.finalize", "finalize"); wrapPass(DeferredModuleInitializers, "DeferredModuleInitializers.process", "process"); for (const name of [ "lowerFunction", "lowerClassCtor", "lowerClassMethodMember", "lowerClassMembers", "lowerFileInit", "lowerGenericInstance", "throwingSetterFn", ]) { const original = proto[name]; if (typeof original !== "function") continue; proto[name] = function (...args) { const token = { id: tokens.length, names: new Set() }; tokens.push(token); const implicit = this.implicitFns?.length ?? 0; const lifted = this.liftedFns?.length ?? 0; stack.push(token); try { const result = original.apply(this, args); produced(result, token.names); return result; } finally { stack.pop(); for (const fn of (this.implicitFns ?? []).slice(implicit)) token.names.add(fn.name); for (const fn of (this.liftedFns ?? []).slice(lifted)) token.names.add(fn.name); } }; } return { report() { const out = {}; for (const [field, keys] of fields) { const involved = new Set(); let sensitive = 0; for (const { readers, writers } of keys.values()) { const all = new Set([...readers, ...writers]); if (writers.size === 0 || all.size < 2) continue; sensitive++; for (const id of all) for (const fn of tokens[id].names) involved.add(fn); } if (sensitive > 0) out[field] = { sensitiveKeys: sensitive, functions: [...involved] }; } for (const [label, names] of passes) if (names.size > 0) out[`pass ${label}`] = { sensitiveKeys: 0, functions: [...names] }; return out; }, }; } const { values } = parseArgs({ options: { stride: { type: "string", default: "7" }, offset: { type: "string", default: "0" }, limit: { type: "string", default: "100000" }, seeds: { type: "string", default: "1,2,3" }, jobs: { type: "string", default: String(Math.max(1, Math.floor(cpus().length / 2))) }, entry: { type: "string", multiple: true, default: [] }, out: { type: "string", default: join(tmpdir(), "scriptc-lowering-order") }, "no-audit": { type: "boolean", default: false }, "fail-on": { type: "string" }, }, }); if (values["fail-on"] !== undefined && !["names", "content"].includes(values["fail-on"])) throw new Error(`--fail-on takes names or content, not ${values["fail-on"]}`); const out = resolve(values.out); mkdirSync(out, { recursive: true }); const seeds = values.seeds.split(",").filter(Boolean); const corpus = join(root, "tests/corpus"); const programs = [ ...readdirSync(corpus) .filter((name) => /^\d+-.*\.ts$/.test(name)) .filter((name) => Number.parseInt(name, 10) % Number(values.stride) === Number(values.offset)) .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10)) .slice(0, Number(values.limit)) .map((name) => join(corpus, name)), ...values.entry.map((entry) => resolve(entry)), ]; const child = (entry, irPath, seed, auditPath, snapshotPath) => new Promise((done) => { const proc = spawn( process.execPath, [ "--max-old-space-size=8192", fileURLToPath(import.meta.url), "--child", entry, irPath, seed, auditPath, snapshotPath, ], { stdio: ["ignore", "ignore", "pipe"], env: { ...process.env, SCRIPTC_NO_CACHE: "1" } }, ); let stderr = ""; proc.stderr.on("data", (chunk) => (stderr += chunk)); proc.on("close", (code) => done({ ok: code === 0, stderr: stderr.slice(-2000) })); }); const loadIr = (path) => JSON.parse(readFileSync(path, "utf8")); const firstDiff = (x, y, path = "") => { if (JSON.stringify(x) === JSON.stringify(y)) return null; if (typeof x !== "object" || typeof y !== "object" || x === null || y === null) return path; for (const key of new Set([...Object.keys(x), ...Object.keys(y)])) { const found = firstDiff(x[key], y[key], `${path}/${key}`); if (found !== null) return found; } return path; }; const differingFunctions = (a, b) => { const left = new Map(a.functions.map((fn) => [fn.name, fn])); const right = new Map(b.functions.map((fn) => [fn.name, fn])); const names = new Set(); const paths = {}; for (const [name, fn] of left) { const other = right.get(name); if (other === undefined) names.add(name); else if (JSON.stringify(other) !== JSON.stringify(fn)) { names.add(name); paths[name] = firstDiff(fn, other).replace(/\/\d+/g, "/#"); } } for (const name of right.keys()) if (!left.has(name)) names.add(name); const topLevel = []; for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) if (key !== "functions" && JSON.stringify(a[key]) !== JSON.stringify(b[key])) topLevel.push(key); if (a.functions.map((fn) => fn.name).join("\n") !== b.functions.map((fn) => fn.name).join("\n")) topLevel.push("function order"); return { functions: [...names], topLevel, paths }; }; /** Keys whose strings name functions, locals, globals, shapes, unions, * classes and labels. Lowering numbers these in creation order; every other * string (literal values, field names, library functions) is compared as * is. A `name` names a function, record or union when it is one of the * module's numbered definitions or starts with `%`. */ const ID_KEYS = new Set([ "base", "callee", "captures", "catchLocalId", "className", "fnName", "fromId", "globalId", "id", "int32Locals", "int32Params", "label", "labels", "localId", "retag", "shapeId", "targets", "unionId", ]); /** The module with every numbered function, record and union renamed by a * hash of its own definition (ids with their digits stripped), other * numbered ids by first use within their function or table entry, source * locations dropped, and tables sorted. Orders whose canonical modules * match differ only in names and ids numbered in creation order. */ const canonicalModule = (mod) => { const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12); const m = JSON.parse(JSON.stringify(mod, (k, v) => (k === "loc" ? undefined : v))); const definitions = new Set([ ...(m.functions ?? []).map((fn) => fn.name), ...["records", "unions"].flatMap((key) => (m[key] ?? []).map((item) => item.id)), ]); const isId = (key, value) => ID_KEYS.has(key) || (key === "name" && (definitions.has(value) || value.startsWith("%"))); /** `v` with the strings under id keys passed through `id`. */ const mapIds = (v, id, key) => { if (typeof v === "string") return isId(key, v) ? id(v) : v; if (Array.isArray(v)) return v.map((x) => mapIds(x, id, key)); if (v && typeof v === "object") return Object.fromEntries(Object.keys(v).map((k) => [k, mapIds(v[k], id, k)])); return v; }; const strip = (v) => JSON.stringify(mapIds(v, (id) => id.replace(/\d+/g, "#"))); const rename = new Map(); for (const fn of m.functions ?? []) { if (!/\d/.test(fn.name)) continue; const { name, ...rest } = fn; rename.set(name, `${name.replace(/\d+/g, "#")}~${hash(strip(rest))}`); } for (const key of ["records", "unions"]) for (const item of m[key] ?? []) { const { id, ...rest } = item; if (typeof id === "string" && /\d/.test(id)) rename.set(id, `${id.replace(/\d+/g, "#")}~${hash(strip(rest))}`); } const canonical = (item) => { const scope = new Map(); return mapIds(item, (value) => { const renamed = rename.get(value); if (renamed !== undefined) return renamed; if (!/\d/.test(value)) return value; let id = scope.get(value); if (id === undefined) scope.set(value, (id = scope.size)); return `${value.replace(/\d+/g, "#")}@${id}`; }); }; const out = {}; for (const key of Object.keys(m)) out[key] = Array.isArray(m[key]) ? m[key].map((item) => JSON.stringify(canonical(item))).sort() : JSON.stringify(canonical(m[key])); return out; }; /** Canonical entries present in one order only, by table. */ const contentDifferences = (a, b) => { const found = {}; for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { if (JSON.stringify(a[key]) === JSON.stringify(b[key])) continue; if (!Array.isArray(a[key]) || !Array.isArray(b[key])) { found[key] = ["(differs)"]; continue; } const other = new Set(b[key]); found[key] = a[key] .filter((item) => !other.has(item)) .map((item) => JSON.parse(item).name ?? JSON.parse(item).id ?? "?"); } return found; }; /** Fields whose contents after reachable emit differ between two orders. */ const differingState = (a, b) => { const fields = []; for (const field of new Set([...Object.keys(a), ...Object.keys(b)])) { const x = a[field] ?? { entries: {}, unkeyed: [] }; const y = b[field] ?? { entries: {}, unkeyed: [] }; const keys = new Set([...Object.keys(x.entries), ...Object.keys(y.entries)]); let differs = [...keys].some((k) => x.entries[k] !== y.entries[k]); if (!differs) differs = JSON.stringify(x.unkeyed) !== JSON.stringify(y.unkeyed); if (differs) fields.push(field); } return fields; }; const results = []; let next = 0; await Promise.all( Array.from({ length: Number(values.jobs) }, async () => { while (next < programs.length) { const entry = programs[next++]; const name = basename(entry).replace(/\.ts$/, ""); const dir = join(out, name); mkdirSync(dir, { recursive: true }); const audit = values["no-audit"] ? "-" : join(dir, "audit.json"); const base = await child( entry, join(dir, "base.ir.json"), "-", audit, join(dir, "base.state.json"), ); if (!base.ok) { results.push({ program: name, status: "does not compile" }); continue; } const diffs = {}; const stateDiffs = {}; let baseCanonical = null; for (const seed of seeds) { const run = await child( entry, join(dir, `seed${seed}.ir.json`), seed, "-", join(dir, `seed${seed}.state.json`), ); if (!run.ok) { // A program that lowers in discovery order but not in this one // differs in content. diffs[seed] = { failed: run.stderr, content: { lowering: ["fails in this order"] } }; continue; } const diff = differingFunctions( loadIr(join(dir, "base.ir.json")), loadIr(join(dir, `seed${seed}.ir.json`)), ); const state = differingState( loadIr(join(dir, "base.state.json")), loadIr(join(dir, `seed${seed}.state.json`)), ); if (state.length > 0) stateDiffs[seed] = state; if (diff.functions.length > 0 || diff.topLevel.length > 0) { baseCanonical ??= canonicalModule(loadIr(join(dir, "base.ir.json"))); diff.content = contentDifferences( baseCanonical, canonicalModule(loadIr(join(dir, `seed${seed}.ir.json`))), ); diffs[seed] = diff; } } const auditReport = audit === "-" ? {} : JSON.parse(readFileSync(audit, "utf8")); const changed = new Set(Object.values(diffs).flatMap((diff) => diff.functions ?? [])); const attribution = {}; for (const fn of changed) { const fields = Object.entries(auditReport) .filter(([, entry]) => entry.functions.includes(fn)) .map(([field]) => field); attribution[fn] = fields; } results.push({ program: name, status: Object.keys(diffs).length === 0 ? "order-independent" : Object.values(diffs).some((diff) => Object.keys(diff.content ?? {}).length > 0) ? "order-dependent content" : "order-dependent names", diffs, attribution, sensitiveFields: Object.keys(auditReport), stateFields: [...new Set(Object.values(stateDiffs).flat())], }); process.stderr.write( `${results.length}/${programs.length} ${name}: ${results.at(-1).status}\n`, ); } }), ); writeFileSync(join(out, "results.json"), JSON.stringify(results, null, 2)); const dependent = results.filter((r) => r.status.startsWith("order-dependent")); const content = results.filter((r) => r.status === "order-dependent content"); const byField = new Map(); let unattributed = 0; for (const r of dependent) { const fields = new Set(Object.values(r.attribution).flat()); if (fields.size === 0) unattributed++; for (const field of fields) byField.set(field, (byField.get(field) ?? 0) + 1); } console.log( `programs ${results.length}: ${dependent.length} order-dependent (${content.length} beyond names and ids), ${results.filter((r) => r.status === "does not compile").length} do not compile`, ); for (const r of content) console.log( ` ${r.program}: ${Object.values(r.diffs) .flatMap((diff) => Object.entries(diff.content ?? {}).map( ([table, items]) => `${table}: ${items.slice(0, 4).join(", ")}`, ), ) .slice(0, 3) .join("; ")}`, ); for (const [field, count] of [...byField].sort((a, b) => b[1] - a[1])) console.log(`${String(count).padStart(5)} ${field}`); console.log(`${String(unattributed).padStart(5)} (no audited field)`); const failing = values["fail-on"] === "names" ? dependent : values["fail-on"] === "content" ? content : []; if (failing.length > 0) { console.log(`${failing.length} programs lower to different IR in another order`); process.exitCode = 1; }