Reuse lowered units across builds behind SCRIPTC_INCR_LOWERING=1

This commit is contained in:
Chris Tate
2026-10-28 23:57:15 -05:00
parent c0ef82865d
commit 16ea77108b
19 changed files with 8361 additions and 134 deletions
+2
View File
@@ -136,6 +136,8 @@ jobs:
run: pnpm generate:ts7-ast --check
- name: Verify the generated IR field readers
run: pnpm generate:ir-fields --check
- name: Verify the generated IR codec
run: pnpm generate:ir-codec --check
native_macos:
needs: [validation_scope, lanes]
+1
View File
@@ -16,6 +16,7 @@
"test:ts7": "node scripts/test-ts7.mjs",
"test:checker-identity": "node scripts/checker-identity.mjs",
"generate:ts7-ast": "node scripts/generate-ts7-ast-schema.mjs",
"generate:ir-codec": "node scripts/generate-ir-codec.mjs",
"generate:ir-fields": "node scripts/generate-ir-fields.mjs",
"test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts",
"test:test262": "tsx tests/test262/run.ts",
+9 -1
View File
@@ -20,6 +20,7 @@ import {
} from "../backend/target-diagnostics.js";
import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
import type { UnitStore } from "../frontend/lowering/unit-reuse.js";
import type { CompilationTiming } from "../timing.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
@@ -42,7 +43,9 @@ export interface PreparedExecutableModule {
warnings: ScrDiagnostic[];
}
/** Release the parser/checker before native object generation begins. */
/** Release the parser/checker before native object generation begins.
* `unitStore` keeps the lowered units of this build for the next one
* (unit-reuse.ts). */
export function prepareExecutableModule(
entryPath: string,
opts: CompileRequestOptions,
@@ -52,6 +55,7 @@ export function prepareExecutableModule(
timing: CompilationTiming = () => {},
lowerIslands: ReadonlyMap<string, IslandPackage> = new Map(),
retryFences = true,
unitStore: UnitStore | null = null,
): PreparedExecutableModule | CompileFailure {
// The island decision reads the detector's scan unless islands are off.
const islandScan =
@@ -115,6 +119,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
unitStore,
);
}
return fail(fe.preflight);
@@ -127,6 +132,7 @@ export function prepareExecutableModule(
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
...(unitStore !== null ? { unitStore } : {}),
},
(module) => validateModule(module).length === 0,
);
@@ -165,6 +171,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
unitStore,
);
}
// A failed program whose compiled packages need the runtime or a
@@ -267,6 +274,7 @@ export function prepareExecutableModule(
timing,
islands,
false,
unitStore,
);
if (islanded.ok) return islanded;
firstError ??= islanded.diagnostics[0] ?? null;
@@ -30,7 +30,9 @@ import {
irDataTable,
journalDump,
journalMode,
HeldTables,
lowerWaveRecorded,
type WaveReuse,
mapTable,
setTable,
waveStats,
@@ -41,6 +43,7 @@ import {
type UnitEvents,
type WaveHost,
} from "./unit-journal.js";
import { UnitReuse, unitReuseVerify, verifyStoredUnit, type UnitStore } from "./unit-reuse.js";
import {
moduleNamespaceDynValue,
moduleNamespaceObject,
@@ -961,6 +964,9 @@ export interface LowerOptions {
nodeIslands?: NodeIslandSet | null;
/** Parser/checker services owned by the load; required for npm graph analysis. */
frontendServices?: FrontendServices | undefined;
/** Where reachable emit stores its units' records and finds an earlier
* build's (unit-reuse.ts). */
unitStore?: UnitStore | null;
/** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */
projectCheckJs?: boolean;
/** Coverage: additionally lower the unreached remainder (bodies nothing
@@ -1360,7 +1366,7 @@ function lowerToIrWithIslands(
});
for (const d of extraModuleCycleDiags) reachableEmit.pushDiag(d);
for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d);
const emitted = reachableEmit.emitReachable(options.libRoots);
const emitted = reachableEmit.emitReachable(options.libRoots, options.unitStore ?? null);
let { reachable } = emitted;
let result = emitted.result;
let resultLowerer = reachableEmit;
@@ -9940,7 +9946,10 @@ export class Lowerer {
* demand-driven) and lifted lambdas lower inline with their enclosing
* body; both fire edges through the same hooks and are not units
* themselves. */
emitReachable(extraRoots?: readonly string[]): { reachable: Set<string>; result: LowerResult } {
emitReachable(
extraRoots?: readonly string[],
unitStore: UnitStore | null = null,
): { reachable: Set<string>; result: LowerResult } {
// Frontend passes below rewrite function bodies through the IR mapper
// (caught-value and class dispatch); key-order notes follow the record
// literals they rebuild until the witness pass has read them.
@@ -9998,12 +10007,16 @@ export class Lowerer {
// Every lowerable body, by emitted-function name. The names double as
// retained-function keys and are deterministic by construction
// (qualified declaration names).
// `source` is the declaration whose text a unit lowers and `owners` the
// declarations around it (unit-reuse.ts).
const units = new Map<
string,
{
order: number;
roots: readonly ts.Node[];
lower: () => IrFunction | null;
source?: ts.Node | undefined;
owners?: readonly ts.Node[];
}
>();
// Roots are collected with plain pushes: spread arguments into a rest
@@ -10047,6 +10060,7 @@ export class Lowerer {
order: unitOrder++,
roots: functionRoots(decl),
lower: () => this.lowerFunction(decl),
source: decl,
});
}
}
@@ -10067,6 +10081,7 @@ export class Lowerer {
roots: classCtorRoots(info),
lower: () =>
info.localClass && !info.localClass.ready ? null : this.lowerClassCtor(info),
source: info.decl ?? undefined,
});
// The descriptor helpers of a class that extends Array, reached
// from its constructor.
@@ -10075,11 +10090,13 @@ export class Lowerer {
order: unitOrder++,
roots: [],
lower: () => arrayClassFieldsFn(this, info),
source: info.decl ?? undefined,
});
units.set(`%${cName}.species`, {
order: unitOrder++,
roots: [],
lower: () => arrayClassSpeciesFn(this, info),
source: info.decl ?? undefined,
});
}
for (const { mName, member } of this.classMethodMembers(info)) {
@@ -10090,6 +10107,8 @@ export class Lowerer {
info.localClass && !info.localClass.ready
? null
: this.lowerClassMethodMember(info, member),
source: member,
owners: info.decl ? [info.decl] : [],
});
}
for (const prop of info.throwingSetters) {
@@ -10097,6 +10116,7 @@ export class Lowerer {
order: unitOrder++,
roots: [],
lower: () => this.throwingSetterFn(info, prop),
source: info.decl ?? undefined,
});
}
}
@@ -10106,6 +10126,8 @@ export class Lowerer {
order: unitOrder++,
roots: functionRoots(entry.member),
lower: () => lowerStaticMethod(this, info, name),
source: entry.member,
owners: info.decl ? [info.decl] : [],
});
}
}
@@ -10420,17 +10442,94 @@ export class Lowerer {
),
);
};
const recorded = journalMode() === "serial" ? waveStats() : null;
const host = recorded === null ? null : this.waveHost(loweredUnits, lowerUnitBody);
// With a unit store, every unit is recorded as in the serial mode, and
// a unit applies the record an earlier build stored for it where that
// record still holds (unit-reuse.ts).
const reuse =
unitStore === null
? null
: UnitReuse.open(
unitStore,
this.program,
parts.map((fp) => fp.sf),
{
facts: (declaration) => this.declarationFacts(declaration),
exportsOf: (specifier) => {
const symbol = this.checker.getSymbolAtLocation(specifier);
if (!symbol) return null;
return this.checker
.getExportsOfModule(symbol)
.map((exported) => ts.symbolName(exported))
.sort()
.join("\n");
},
isLocal: (identifier, container) => {
const declarations = this.checker.getSymbolAtLocation(identifier)?.declarations;
if (declarations === undefined || declarations.length === 0) return false;
const sf = ts.getSourceFileOfNode(container);
return declarations.every(
(declaration) =>
ts.getSourceFileOfNode(declaration) === sf &&
declaration.pos >= container.pos &&
declaration.end <= container.end,
);
},
},
);
const recorded =
journalMode() === "serial" || (reuse !== null && reuse.records) ? waveStats() : null;
const host =
recorded === null
? null
: this.waveHost(loweredUnits, lowerUnitBody, (name) => {
const priority = metadataPriority.get(name)!;
return { owner: demandOwner(priority), priority };
});
const dump = recorded === null ? null : journalDump();
const fileOfUnit = (name: string): string | null => {
const source = units.get(name)?.source;
return source === undefined ? null : ts.getSourceFileOfNode(source).fileName;
};
const waveReuse: WaveReuse | null =
reuse === null || !reuse.records
? null
: {
verify: unitReuseVerify(),
stored: (name) => {
// A unit without a declared rank takes the next expression
// rank when it lowers, which applying a record would not
// advance.
const unit = units.get(name);
const file = fileOfUnit(name);
if (unit === undefined || file === null || !metadataPriority.has(name)) return null;
return reuse.candidate(name, file, unit.source ?? null, unit.owners ?? []);
},
check: (name, stored, pending, unit) =>
verifyStoredUnit(name, stored, pending, unit.record, unit.reasons),
note: (name, record, applied, files) => {
const file = fileOfUnit(name);
if (file !== null) reuse.note(name, file, record, applied, files);
},
};
const recordLines: string[] = [];
const drainUnits = (): void => {
// The waves of one drain have only checker prefetches between them, so
// they share the content their units start from (HeldTables).
const held =
host !== null && recorded !== null ? new HeldTables(host.tables, recorded) : null;
while (queue.length > 0) {
const wave = queue.splice(0);
shuffle?.(wave);
this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots));
if (host !== null && recorded !== null) {
lowerWaveRecorded(host, wave, recorded, dump === null ? null : recordLines);
lowerWaveRecorded(
host,
wave,
recorded,
dump === null ? null : recordLines,
waveReuse,
held,
);
continue;
}
for (const name of wave) {
@@ -10447,6 +10546,7 @@ export class Lowerer {
}
}
}
held?.check();
};
drainUnits();
this.restoreGenericInstanceOrder();
@@ -10491,6 +10591,13 @@ export class Lowerer {
}
classDispatch.finalize(this);
if (dump !== null) writeRecords(dump, recordLines);
if (reuse !== null) {
reuse.finish();
if (process.env["SCRIPTC_TIMING"] === "1")
process.stderr.write(
`scriptc lowering ${JSON.stringify({ phase: "unit-reuse", ...reuse.stats })}\n`,
);
}
if (recorded !== null && process.env["SCRIPTC_TIMING"] === "1")
process.stderr.write(
`scriptc lowering ${JSON.stringify({ phase: "unit-records", ...recorded })}\n`,
@@ -10539,11 +10646,57 @@ export class Lowerer {
return { reachable, result: this.finishModule(functions) };
}
/** What the steps before the worklist settled for a top-level
* declaration, as text without source positions (unit-reuse.ts): a
* function's signature, a class's layout and the signatures of its
* methods and statics, and the globals of a variable statement. */
private declarationFacts(declaration: ts.Node): string {
const positionFree = (key: string, value: unknown): unknown =>
key === "loc" || key === "scope" ? undefined : value;
if (ts.isFunctionDeclaration(declaration)) {
const symbol = declSymbolOf(this, declaration);
const sig = symbol ? this.fnSigsBySymbol.get(symbol) : undefined;
return sig === undefined ? "" : JSON.stringify(sig, positionFree);
}
if (ts.isClassDeclaration(declaration)) {
const info = this.classes.get(this.classNamer(declaration));
if (info === undefined) return "";
const staticMethods: { name: string; params: ParamShape[]; ret: IrType }[] = [];
if (info.staticMethods)
for (const [name, method] of info.staticMethods)
staticMethods.push({ name, params: method.params, ret: method.ret });
return JSON.stringify(
{
def: info.def,
fields: [...info.fields],
methods: [...info.methods],
statics: info.staticFields.map((field) => ({
name: field.name,
type: field.type,
runtimeOptional: field.runtimeOptional ?? false,
globalId: field.globalId,
readonly: field.readonly,
})),
staticMethods,
},
positionFree,
);
}
if (ts.isVariableStatement(declaration)) {
const globals: (IrGlobal | null)[] = [];
for (const decl of declaration.declarationList.declarations.nodes)
globals.push(this.globalsByDeclNode.get(decl) ?? null);
return JSON.stringify(globals, positionFree);
}
return "";
}
/** The lowerer's side of a recorded wave (unit-journal.ts).
* `lowerUnitBody` lowers a unit as reachable emit does. */
private waveHost(
loweredUnits: Map<string, IrFunction>,
lowerUnitBody: (name: string) => IrFunction | null,
ownerOf: (name: string) => { owner: GenericDemandOwner; priority: readonly [number, number] },
): WaveHost {
const scalars = this.journalScalars();
const flags = new Map(scalars.map((scalar) => [scalar.name, scalar]));
@@ -10574,6 +10727,35 @@ export class Lowerer {
return undefined;
}
};
// Makes a recorded call again (resolves has answered true for it).
const replay = (event: JournalEvent): void => {
switch (event.kind) {
case "edge":
this.noteEdge(event.name);
break;
case "superConstructorEdge":
this.noteSuperConstructorEdge(event.name);
break;
case "virtualEdge":
this.noteVirtualEdge(classNamed(event.name)!, event.detail!);
break;
case "functionDemand":
this.noteGenericInstanceDemand(instanceNamed(event.name)!);
break;
case "classDemand":
this.noteGenericClassInstanceDemand(classNamed(event.name)!);
break;
case "statement":
this.countStatement(statementNode(event)!, event.detail ?? undefined);
break;
case "failedStatement":
this.countStatementFailed(statementNode(event)!, event.detail ?? undefined);
break;
case "flag":
flags.get(event.name)!.set!();
break;
}
};
return {
tables: this.journalTables(),
scalars,
@@ -10614,6 +10796,15 @@ export class Lowerer {
return true;
}
},
keep: (name, fn, events) => {
if (fn) loweredUnits.set(name, fn);
const { owner, priority } = ownerOf(name);
this.withGenericDemandOwner(owner, () =>
this.shapes.withDeclaredOrderPriority(priority, () => {
for (const event of events) replay(event);
}),
);
},
};
}
@@ -4,6 +4,7 @@ import { Lowerer } from "./lowerer.js";
import {
checkedTable,
contentDigest,
decodeRecordFunction,
encodeUnitFunction,
flagScalar,
functionTable,
@@ -12,7 +13,9 @@ import {
recordUnit,
setTable,
waveStats,
type UnitRecord,
type WaveHost,
type WaveReuse,
} from "./unit-journal.js";
import type { IrFunction } from "../../ir/ir.js";
@@ -225,6 +228,7 @@ function host(lower: (name: string, state: HostState) => IrFunction | null): {
},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
},
};
}
@@ -276,7 +280,10 @@ test("a unit's record lists its events, flags and added entries", () => {
["flag", "flag"],
]);
expect(record!.tables).toEqual({ helpers: [`"key.a"\n"%h.a"`] });
expect(JSON.parse(JSON.stringify(record))).toEqual(record);
// The function is in the binary encoding, and the rest is JSON data.
const { fn: bytes, ...rest } = record!;
expect(bytes).toBeInstanceOf(Uint8Array);
expect(JSON.parse(JSON.stringify(rest))).toEqual(rest);
});
test("a unit that changes an entry it did not add has no record", () => {
@@ -303,6 +310,7 @@ test("a recorded wave throws when a unit changes a held function in place", () =
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
lowerWaveRecorded(subject, ["a"], waveStats());
expect(() => lowerWaveRecorded(subject, ["a", "b"], waveStats())).toThrow(
@@ -310,6 +318,47 @@ test("a recorded wave throws when a unit changes a held function in place", () =
);
});
test("a recorded wave holds the entries an applied record adds after its first lowering unit", () => {
// `first` lowers, which takes the wave's content; `a` applies a stored
// record that adds `%added`; `b` changes `%added` in place without naming
// it, which the end of the wave must see.
const functions: IrFunction[] = [];
const subject: WaveHost = {
tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))],
scalars: [],
lowerRecorded: (name) => {
if (name === "b")
functions
.find((f) => f.name === "%added")!
.locals.push({ id: "x.0", name: "x", type: { kind: "f64" } } as never);
return { fn: fn(`%${name}`), poisoned: false };
},
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
const stored: UnitRecord = {
name: "a",
fn: null,
poisoned: false,
events: [],
tables: { lifted: [encodeUnitFunction(fn("%added"))!] },
reads: {},
};
const reuse: WaveReuse = {
stored: (name) => (name === "a" ? { record: stored, fn: () => null } : null),
verify: false,
check: () => {},
note: () => {},
};
const stats = waveStats();
expect(() => lowerWaveRecorded(subject, ["first", "a", "b"], stats, null, reuse)).toThrow(
"a unit changed lifted entry %added in place without naming it",
);
expect(stats.applied).toBe(1);
});
/** A wave host over one function table holding `%shared` and one checked
* list, whose units change `%shared` in place and name it in their IR. */
function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked: string[] } {
@@ -334,6 +383,7 @@ function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
},
};
}
@@ -352,6 +402,40 @@ test("a unit that changes a function it names in place has no record", () => {
expect(stats).toMatchObject({ recorded: 0, unrecorded: 1, reasons: { "lifted:changed": 1 } });
});
test("a record's binary function names the held functions its strings name", () => {
const functions = [fn("%shared")];
const loc = { file: "b.ts", start: 0, end: 0 };
const subject: WaveHost = {
tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))],
scalars: [],
lowerRecorded: () => ({
fn: {
...fn("%a"),
body: [
{
kind: "exprStmt",
expr: { kind: "call", callee: "%shared", args: [], type: { kind: "f64" }, loc },
loc,
},
] as never,
},
poisoned: false,
}),
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
const unit = recordUnit(subject, "a");
expect(unit.record!.fn).toBeInstanceOf(Uint8Array);
expect(unit.record!.reads).toEqual({
lifted: [["%shared", contentDigest(encodeUnitFunction(functions[0]!)!)]],
});
// The files of the function's source positions, for reuse to check.
expect([...unit.files!].sort()).toEqual(["a.ts", "b.ts"]);
expect(decodeRecordFunction(unit.record!.fn!)).toEqual(unit.fn);
});
test("a record lists the held functions it names, with their digests", () => {
const functions = [fn("%shared"), fn("%unused")];
const subject: WaveHost = {
@@ -373,6 +457,7 @@ test("a record lists the held functions it names, with their digests", () => {
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
const { record } = recordUnit(subject, "a");
// The function the unit added is in its entries, not its reads.
@@ -4,8 +4,9 @@
* unit's lowering reached: the unit's IR, the entries it added to the
* tables that collect lowering results (Lowerer.journalTables), and the
* calls it made into reachability, generic demand and statement counting
* (JournalEvent). Records hold only strings, numbers and names, so they can
* be written to a file and read by another process or another build.
* (JournalEvent). Records hold only the function's bytes (or its JSON),
* strings, numbers and names, so they can be written to a file and read by
* another process or another build.
*
* A unit has no record when its lowering changed state that no record
* carries: a table that is not appendable grew, a field changed, an entry
@@ -23,20 +24,59 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { InternalCompilerError } from "../../errors.js";
import { decodeFunctionBinary, encodeFunctionStrings, functionDigest } from "../../ir/codec.js";
import type { IrFunction } from "../../ir/ir.js";
import { deserializeFunction, encodeFunction } from "../../ir/serialize.js";
/** A function as records hold it, or null when it does not encode. Every
* function a record holds goes through this pair. */
/** A function as table entries hold it, or null when it does not encode.
* Every function an entry holds goes through this pair. */
export function encodeUnitFunction(fn: IrFunction): string | null {
return encodeFunction(fn) ?? null;
}
/** A function from its record encoding (encodeUnitFunction). */
/** A function from its entry encoding (encodeUnitFunction). */
export function decodeUnitFunction(text: string): IrFunction {
return deserializeFunction(text);
}
/** A unit's function as its record holds it: the binary IR encoding, or
* JSON (encodeUnitFunction) when an object in it would not read back with
* its own key order, which frontend passes and the IR's text depend on. */
export type RecordFunction = string | Uint8Array;
/** `fn` as a record holds it, or null when it does not encode. A binary
* encoding comes with its strings and the files its fields named `file`
* hold (encodeFunctionStrings). */
export function encodeRecordFunction(fn: IrFunction): {
readonly fn: RecordFunction;
readonly strings: readonly string[] | null;
readonly files: readonly string[] | null;
} | null {
const binary = encodeFunctionStrings(fn);
if (binary !== null) return { fn: binary.bytes, strings: binary.strings, files: binary.files };
const text = encodeUnitFunction(fn);
return text === null ? null : { fn: text, strings: null, files: null };
}
/** A function from its record encoding (encodeRecordFunction). */
export function decodeRecordFunction(fn: RecordFunction): IrFunction {
return typeof fn === "string"
? decodeUnitFunction(fn)
: decodeFunctionBinary(Buffer.from(fn.buffer, fn.byteOffset, fn.byteLength));
}
/** `record` as a JSON line, with a binary function in base64, marked when
* it was `applied` from an earlier build's store. */
export function recordLine(record: UnitRecord, applied = false): string {
const fn =
record.fn === null || typeof record.fn === "string"
? record.fn
: Buffer.from(record.fn.buffer, record.fn.byteOffset, record.fn.byteLength).toString(
"base64",
);
return JSON.stringify(applied ? { ...record, fn, applied: true } : { ...record, fn });
}
/** A call a unit made while it lowered, which applying its record makes
* again. */
export interface JournalEvent {
@@ -62,9 +102,9 @@ export interface JournalEvent {
/** One unit's record. */
export interface UnitRecord {
readonly name: string;
/** The unit's function in the compact IR encoding, or null when the unit
/** The unit's function (encodeRecordFunction), or null when the unit
* lowered to nothing. */
readonly fn: string | null;
readonly fn: RecordFunction | null;
/** True when the unit's lowering ended in a poison: its diagnostics and
* the edges it fired before poisoning stay, and it has no function. */
readonly poisoned: boolean;
@@ -91,6 +131,12 @@ export interface JournalTable {
readonly entries?: (from: number) => string[] | null;
/** Adds the encoded entries whose keys this process's table lacks. */
readonly apply?: (entries: readonly string[]) => void;
/** Whether every encoded entry whose key this process's table holds
* holds the same value there. */
readonly agrees?: (entries: readonly string[]) => boolean;
/** Whether this process's table holds an entry under the encoded entry's
* key. */
readonly holds?: (entry: string) => boolean;
/** A digest of the content of the entry under `key`, when the table
* holds one before position `before`; null otherwise. */
readonly digest?: (key: string, before: number) => string | null;
@@ -125,10 +171,20 @@ export function contentDigest(text: string): string {
return `${text.length}:${(a >>> 0).toString(16)}${(b >>> 0).toString(16)}`;
}
/** The names quoted in JSON `texts`, unescaped only as far as their
* delimiting quotes: the strings that start with `%` (helper, lifted and
* IR data names) and union ids (`u` and 12 hex digits), which the tables
* a unit reads and changes are keyed by. */
/** Adds to `out` the names among `strings`: the strings that start with `%`
* (helper, lifted and IR data names) and union ids (`u` and 12 hex digits),
* which the tables a unit reads and changes are keyed by. */
export function namedStrings(strings: Iterable<string>, out: Set<string>): void {
for (const text of strings) {
const first = text.charCodeAt(0);
if (first === 37 /* % */) out.add(text);
else if (first === 117 /* u */ && text.length === 13 && /^u[0-9a-f]{12}$/.test(text))
out.add(text);
}
}
/** The names (namedStrings) quoted in JSON `texts`, unescaped only as far
* as their delimiting quotes. */
export function quotedStrings(texts: readonly string[]): Set<string> {
const out = new Set<string>();
const hex = (char: number): boolean => (char >= 48 && char <= 57) || (char >= 97 && char <= 102);
@@ -225,6 +281,10 @@ export interface WaveHost {
statementCounts(): { total: number; failed: number; other: number };
/** Whether `event` names something this process holds. */
resolves(event: JournalEvent): boolean;
/** Keeps a unit's function from its record and makes the record's events
* again, under the unit's generic demand owner and declared-order
* priority. */
keep(name: string, fn: IrFunction | null, events: readonly JournalEvent[]): void;
}
/** Whether reachable emit records its units (SCRIPTC_LOWER_JOURNAL=serial,
@@ -259,10 +319,69 @@ export interface WaveStats {
unrecorded: number;
/** Per table, field or check, the units it left without a record. */
reasons: Record<string, number>;
/** Units whose stored record applied in place of lowering them
* (unit-reuse.ts). */
applied: number;
/** Time taking and checking the tables' contents, finding stored records,
* applying them, and lowering and recording units, in milliseconds. */
holdMs: number;
storedMs: number;
applyMs: number;
recordMs: number;
/** Of recordMs, the time the units' lowering took, and the time a
* recorded unit's results took to be replaced by its record's. */
lowerMs: number;
replaceMs: number;
}
export function waveStats(): WaveStats {
return { waves: 0, units: 0, recorded: 0, unrecorded: 0, reasons: {} };
return {
waves: 0,
units: 0,
recorded: 0,
unrecorded: 0,
reasons: {},
applied: 0,
holdMs: 0,
storedMs: 0,
applyMs: 0,
recordMs: 0,
lowerMs: 0,
replaceMs: 0,
};
}
/** A wave's use of the records an earlier build stored (unit-reuse.ts). */
/** A record an earlier build stored, with its function, which the store
* can keep apart from the record (`record.fn` is null then). */
export interface StoredRecord {
readonly record: UnitRecord;
fn(): IrFunction | null;
}
export interface WaveReuse {
/** The stored record of `name`, when the unit can apply it. */
stored(name: string): StoredRecord | null;
/** Whether units with a stored record lower and are checked against it
* instead (SCRIPTC_INCR_VERIFY=1). */
readonly verify: boolean;
/** Checks the record a unit lowered to against its stored one, whose
* entries the tables did not hold yet at the unit's turn are `pending`. */
check(
name: string,
stored: StoredRecord,
pending: Readonly<Record<string, readonly string[]>>,
unit: RecordedUnit,
): void;
/** Notes the record a unit lowered to, with the files its binary
* function names (RecordedUnit.files), or the stored record it applied
* (`files` null). */
note(
name: string,
record: UnitRecord | null,
applied: boolean,
files: readonly string[] | null,
): void;
}
/** What lowering one unit with its events recording produced. */
@@ -275,6 +394,11 @@ export interface RecordedUnit {
readonly fn: IrFunction | null;
/** Each table's size before the unit. */
readonly before: readonly number[];
/** The time the unit's lowering took. */
readonly lowerMs: number;
/** The files the fields named `file` in the record's function hold, when
* the function is binary. */
readonly files: readonly string[] | null;
}
/** Lowers `name` with its events recording, and makes its record. The unit
@@ -297,7 +421,9 @@ export function recordUnit(
const marks = host.scalars.map((scalar) => scalar.mark());
const counts = host.statementCounts();
const recorder = new UnitEvents();
const lowering = performance.now();
const { fn, poisoned } = host.lowerRecorded(name, recorder);
const lowerMs = performance.now() - lowering;
const reasons: string[] = [];
const grown: number[] = [];
tables.forEach((table, index) => {
@@ -322,8 +448,8 @@ export function recordUnit(
if (now.total - counts.total !== statements || now.failed - counts.failed !== failed)
reasons.push("statements");
if (now.other !== counts.other) reasons.push("diagnostics");
const text = fn === null ? null : encodeUnitFunction(fn);
if (fn !== null && text === null) reasons.push("encoding");
const encoded = fn === null ? null : encodeRecordFunction(fn);
if (fn !== null && encoded === null) reasons.push("encoding");
const added: Record<string, readonly string[]> = {};
if (reasons.length === 0)
for (const index of grown) {
@@ -335,13 +461,14 @@ export function recordUnit(
// A unit uses the entries its IR and new entries name: it reads them, and
// a unit that changes one in place changes one it names.
const texts: string[] = [];
if (text !== null) texts.push(text);
if (typeof encoded?.fn === "string") texts.push(encoded.fn);
for (const index of grown) {
const table = tables[index]!;
const entries = added[table.name] ?? table.entries!(before[index]!);
if (entries !== null) for (const entry of entries) texts.push(entry);
}
const names = quotedStrings(texts);
if (encoded?.strings != null) namedStrings(encoded.strings, names);
if (own !== null) {
if (reasons.length === 0)
tables.forEach((table, index) => {
@@ -369,36 +496,72 @@ export function recordUnit(
}
if (read.length > 0) reads[table.name] = read;
});
if (reasons.length > 0) return { record: null, reasons, fn, before };
if (reasons.length > 0) return { record: null, reasons, fn, before, lowerMs, files: null };
return {
record: { name, fn: text, poisoned, events, tables: added, reads },
record: { name, fn: encoded?.fn ?? null, poisoned, events, tables: added, reads },
reasons,
fn,
before,
lowerMs,
files: encoded?.files ?? null,
};
}
/** Lowers `wave` with every unit recorded. A unit with a record keeps the
* results its record carries: the entries it added are taken out of their
* tables and added again from the record, and its function is the one
* decoded from the record. A unit without one keeps what lowering made.
/** Lowers `wave` with every unit recorded. Without `reuse`, a unit with a
* record keeps the results its record carries: the entries it added are
* taken out of their tables and added again from the record, and its
* function is the one decoded from the record. Otherwise, and for a unit
* without a record, a unit keeps what lowering made.
* Throws an InternalCompilerError when a unit changed the content of an
* entry a table held when the unit started (JournalTable.contents), which
* no record carries.
* `lines`, when given, receives each unit's record as a JSON line. */
* `lines`, when given, receives each unit's record as a JSON line. A unit
* whose stored record `reuse` gives applies it, and lowers when it does not
* apply. With `held`, the wave continues the content the waves before it
* left (HeldTables), and the check waits for HeldTables.check. */
export function lowerWaveRecorded(
host: WaveHost,
wave: readonly string[],
stats: WaveStats,
lines: string[] | null = null,
reuse: WaveReuse | null = null,
held: HeldTables | null = null,
): void {
stats.waves++;
const tables = host.tables;
// Units keep the content of the entries that tables held when they
// started: a record carries a unit's new entries, not changes to others.
const contents = tables.map((table) => (table.contents === undefined ? null : table.contents()));
const contents = held ?? new HeldTables(tables, stats);
let started = 0;
for (const name of wave) {
const unit = recordUnit(host, name, contents);
started = performance.now();
let stored = reuse?.stored(name) ?? null;
stats.storedMs += performance.now() - started;
let pending: Readonly<Record<string, readonly string[]>> = {};
if (stored !== null && reuse!.verify) {
// Verify mode lowers the units whose stored record would apply and
// checks the record they lower to.
if (recordApplies(host, stored.record).length === 0)
pending = pendingEntries(host, stored.record);
else stored = null;
} else if (stored !== null) {
started = performance.now();
const reasons = applyRecord(host, stored.record, stored.fn);
stats.applyMs += performance.now() - started;
if (reasons.length === 0) {
stats.units++;
stats.recorded++;
stats.applied++;
contents.extendTaken();
lines?.push(recordLine(stored.record, true));
reuse!.note(name, stored.record, true, null);
continue;
}
for (const reason of reasons)
stats.reasons[`stored:${reason}`] = (stats.reasons[`stored:${reason}`] ?? 0) + 1;
}
started = performance.now();
const unit = recordUnit(host, name, contents.take());
stats.recordMs += performance.now() - started;
stats.lowerMs += unit.lowerMs;
stats.units++;
const record = unit.record;
if (record === null) {
@@ -406,43 +569,174 @@ export function lowerWaveRecorded(
stats.unrecorded++;
for (const reason of unit.reasons) stats.reasons[reason] = (stats.reasons[reason] ?? 0) + 1;
host.store(name, unit.fn);
if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit);
reuse?.note(name, null, false, null);
continue;
}
lines?.push(JSON.stringify(record));
lines?.push(recordLine(record));
stats.recorded++;
tables.forEach((table, index) => {
const entries = record.tables[table.name];
if (entries === undefined) return;
table.truncate!(unit.before[index]!);
table.apply!(entries);
});
host.store(name, record.fn === null ? null : decodeUnitFunction(record.fn));
// The serial mode replaces a unit's results by its record's, so that the
// build's output is what applying the records gives. Reuse keeps what
// lowering made: its records apply in later builds, which verify mode
// and the edit-sequence tests check.
let fn = unit.fn;
if (reuse === null) {
const replacing = performance.now();
tables.forEach((table, index) => {
const entries = record.tables[table.name];
if (entries === undefined) return;
table.truncate!(unit.before[index]!);
table.apply!(entries);
});
fn = record.fn === null ? null : decodeRecordFunction(record.fn);
stats.replaceMs += performance.now() - replacing;
}
host.store(name, fn);
if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit);
reuse?.note(name, record, false, unit.files);
}
tables.forEach((table, index) => {
const key = contents[index]?.changed() ?? null;
if (key !== null)
throw new InternalCompilerError(
`a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`,
if (held === null) contents.check();
}
/** The content of the tables' entries that recorded units start from
* (JournalTable.contents): units keep the content of the entries the
* tables held when they started, since a record carries a unit's new
* entries, not changes to others. Applying a stored record only adds
* entries, so the content is taken when the first unit lowers, and not at
* all when none does. Consecutive waves with nothing but checker prefetches
* between them share one: the content a wave leaves, as the check finds it,
* is the content the next one starts with. */
export class HeldTables {
private contents: (HeldContents | null)[] | null = null;
constructor(
private readonly tables: readonly JournalTable[],
private readonly stats: WaveStats,
) {}
/** The held content, taken now when it was not yet. */
take(): (HeldContents | null)[] {
if (this.contents === null) {
const taken = performance.now();
this.contents = this.tables.map((table) =>
table.contents === undefined ? null : table.contents(),
);
});
this.stats.holdMs += performance.now() - taken;
}
return this.contents;
}
/** Adds the entries past the held ones, when the content is taken. */
extendTaken(): void {
if (this.contents !== null) for (const entry of this.contents) entry?.extend();
}
/** Throws an InternalCompilerError when an entry a table held changed in
* place since the content was taken, other than by the units that keep
* their changes (HeldContents.refresh). */
check(): void {
const contents = this.contents;
if (contents === null) return;
const started = performance.now();
this.tables.forEach((table, index) => {
const key = contents[index]?.changed() ?? null;
if (key !== null)
throw new InternalCompilerError(
`a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`,
);
});
this.stats.holdMs += performance.now() - started;
}
}
/** Applies `record` in place of lowering its unit, at the unit's turn, or
* returns why the unit has to lower instead. A record applies when every
* entry it adds agrees with what the tables hold, it names only known
* tables, every event resolves and every entry it reads is held with the
* same content. Applying adds its entries, keeps its function (`stored`'s,
* when the store keeps it apart from the record) and makes its events
* again. */
export function applyRecord(
host: WaveHost,
record: UnitRecord,
stored?: () => IrFunction | null,
): string[] {
const reasons = recordApplies(host, record);
if (reasons.length > 0) return reasons;
for (const table of host.tables) {
const entries = record.tables[table.name];
if (entries !== undefined) table.apply!(entries);
}
const fn = record.poisoned
? null
: stored !== undefined
? stored()
: record.fn === null
? null
: decodeRecordFunction(record.fn);
host.keep(record.name, fn, record.events);
return [];
}
/** The entries `record` adds that the tables do not hold yet, by table
* name: what applying it would add at its unit's turn. */
export function pendingEntries(
host: WaveHost,
record: UnitRecord,
): Record<string, readonly string[]> {
const out: Record<string, readonly string[]> = {};
for (const table of host.tables) {
const entries = record.tables[table.name];
if (entries === undefined) continue;
const pending = entries.filter((entry) => table.holds?.(entry) !== true);
if (pending.length > 0) out[table.name] = pending;
}
return out;
}
/** Why `record` does not apply at its unit's turn (applyRecord), or no
* reasons when it does. */
export function recordApplies(host: WaveHost, record: UnitRecord): string[] {
const tables = host.tables;
for (const table of tables) {
const entries = record.tables[table.name];
if (entries === undefined) continue;
if (table.agrees === undefined || table.apply === undefined || !table.agrees(entries))
return [`disagrees:${table.name}`];
}
for (const table of Object.keys(record.tables))
if (!tables.some((held) => held.name === table)) return [`unknown table:${table}`];
for (const event of record.events) if (!host.resolves(event)) return [`event:${event.kind}`];
for (const table of tables) {
const reads = record.reads[table.name];
if (reads === undefined) continue;
if (table.digest === undefined) return [`unknown read:${table.name}`];
const size = table.size();
for (const [key, digest] of reads)
if (table.digest(key, size) !== digest) return [`read:${table.name}`];
}
for (const table of Object.keys(record.reads))
if (!tables.some((held) => held.name === table)) return [`unknown read:${table}`];
return [];
}
/** The content of a keyed table's entries (JournalTable.contents): each
* entry's encoded value, and the order of the keys, since a table only adds
* entries at the end. An entry is found by its key, or by the name its value
* holds (`valueName`): a unit names the helper it got, not the key it was
* looked up by. `text` answers undefined for a key the table does not
* entry's content (`text`: its encoded value, or the value itself when it
* is a string or a number), and the order of the keys, since a table only
* adds entries at the end. An entry is found by its key, or by the name its
* value holds (`valueName`): a unit names the helper it got, not the key it
* was looked up by. `text` answers undefined for a key the table does not
* hold. */
function keyedContents(
size: () => number,
keys: () => Iterable<string>,
text: (key: string) => string | null | undefined,
text: (key: string) => string | number | null | undefined,
valueName: (key: string) => string | null,
): HeldContents {
const texts = new Map<string, string | null>();
const texts = new Map<string, string | number | null>();
let order: string[] = [];
const byName = new Map<string, string>();
const differs = (key: string): boolean => text(key) !== texts.get(key);
const differs = (key: string): boolean => !Object.is(text(key), texts.get(key));
const keyOf = (name: string): string | undefined => (texts.has(name) ? name : byName.get(name));
const held: HeldContents = {
extend: () => {
@@ -499,6 +793,7 @@ export function mapTable(name: string, map: Map<string, string>): JournalTable {
(value) => JSON.stringify(value),
(text) => JSON.parse(text) as string,
(value) => value,
(value) => value,
);
}
@@ -509,6 +804,8 @@ export function countTable(name: string, map: Map<string, number>): JournalTable
map,
(value) => String(value),
(text) => Number(text),
null,
(value) => value,
);
}
@@ -530,13 +827,17 @@ export function irDataTable<V>(name: string, map: Map<string, V>): JournalTable
/** An appendable table over a Map from strings to values that `encode`
* writes, or answers null for, and `decode` reads back. An entry is its key
* as a JSON string, a line break and its value: a JSON string holds no line
* break. A value that does not encode leaves its unit without a record. */
* break. A value that does not encode leaves its unit without a record.
* `content` is what held contents compare (JournalTable.contents): the
* encoding, or the value itself for a table of strings or numbers, which
* only change by being replaced. */
export function codecTable<V>(
name: string,
map: Map<string, V>,
encode: (value: V) => string | null,
decode: (text: string) => V,
nameOf: ((value: V) => string) | null = null,
content: (value: V) => string | number | null = encode,
): JournalTable {
return {
name,
@@ -552,6 +853,13 @@ export function codecTable<V>(
}
return out;
},
agrees: (entries) =>
entries.every((entry) => {
const split = entry.indexOf("\n");
const held = map.get(JSON.parse(entry.slice(0, split)) as string);
return held === undefined || encode(held) === entry.slice(split + 1);
}),
holds: (entry) => map.has(JSON.parse(entry.slice(0, entry.indexOf("\n"))) as string),
apply: (entries) => {
for (const entry of entries) {
const split = entry.indexOf("\n");
@@ -569,7 +877,7 @@ export function codecTable<V>(
() => map.keys(),
(key) => {
const value = map.get(key);
return value === undefined ? undefined : encode(value);
return value === undefined ? undefined : content(value);
},
(key) => {
const value = map.get(key);
@@ -585,6 +893,8 @@ export function setTable(name: string, set: Set<string>): JournalTable {
name,
size: () => set.size,
entries: (from) => [...set].slice(from),
agrees: () => true,
holds: (entry) => set.has(entry),
apply: (entries) => {
for (const entry of entries) set.add(entry);
},
@@ -652,6 +962,14 @@ export function functionTable(
}
return out;
},
agrees: (entries) =>
entries.every((entry) => {
const fn = decodeUnitFunction(entry);
if (!has(fn.name)) return true;
const index = indexOf(fn.name);
return index !== undefined && encodeUnitFunction(functions[index]!) === entry;
}),
holds: (entry) => has(decodeUnitFunction(entry).name),
apply: (entries) => {
for (const entry of entries) {
const fn = decodeUnitFunction(entry);
@@ -663,16 +981,17 @@ export function functionTable(
while (functions.length > from) functions.pop();
changed();
},
// Held functions are their content digests (heldFunction).
contents: () => {
const texts: (string | null)[] = [];
const indexes = new Map<string, number>();
const differs = (index: number): boolean =>
index < functions.length && encodeUnitFunction(functions[index]!) !== texts[index];
index < functions.length && heldFunction(functions[index]!) !== texts[index];
const held: HeldContents = {
extend: () => {
for (let index = texts.length; index < functions.length; index++) {
const fn = functions[index]!;
texts.push(encodeUnitFunction(fn));
texts.push(heldFunction(fn));
indexes.set(fn.name, index);
}
},
@@ -692,7 +1011,7 @@ export function functionTable(
for (const key of keys) {
const index = indexes.get(key);
if (index !== undefined && index < functions.length)
texts[index] = encodeUnitFunction(functions[index]!);
texts[index] = heldFunction(functions[index]!);
}
},
};
@@ -702,6 +1021,17 @@ export function functionTable(
};
}
/** The content a function table holds of `fn` (HeldContents): its digest
* (functionDigest), or its entry encoding when the binary encoder cannot
* write it. */
function heldFunction(fn: IrFunction): string | null {
try {
return functionDigest(fn);
} catch {
return encodeUnitFunction(fn);
}
}
/** A table whose growth leaves the unit without a record. */
export function checkedTable(name: string, size: () => number): JournalTable {
return { name, size };
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -1,6 +1,6 @@
import { InternalCompilerError } from "../errors.js";
import { keyedHelperName, type KeyedNameOwner } from "./lowering/stable-names.js";
import type { HeldContents, JournalTable } from "./lowering/unit-journal.js";
import { contentDigest, type HeldContents, type JournalTable } from "./lowering/unit-journal.js";
import { mockCallRecordType, mockContextType } from "./mock-shapes.js";
import * as ts from "./ts7/adapter.js";
import { bodyReadsArguments } from "./arguments-usage.js";
@@ -716,9 +716,39 @@ export class UnionRegistry {
* the key its arms give. A unit that adds a recursive union changes
* journalSizes, so the unions past a unit's start are interned ones. */
journalTable(): JournalTable {
// The position of each union by id, for the unions indexed so far.
let indexes = new Map<string, number>();
let indexed = 0;
const indexOf = (id: string): number | undefined => {
if (indexed > this.unions.length) {
indexes = new Map();
indexed = 0;
}
for (; indexed < this.unions.length; indexed++)
indexes.set(this.unions[indexed]!.id, indexed);
const index = indexes.get(id);
return index !== undefined && this.unions[index]?.id === id ? index : undefined;
};
return {
name: "unions",
size: () => this.unions.length,
digest: (key, before) => {
if (!this.byId.has(key)) return null;
let index = indexOf(key);
if (index === undefined) {
// A list that was cut and grown again holds other unions at the
// positions indexed before.
indexes = new Map();
indexed = 0;
index = indexOf(key);
}
if (index === undefined || index >= before) return null;
return contentDigest(JSON.stringify(this.unions[index]!));
},
holds: (entry) => {
const def = JSON.parse(entry) as IrUnionDef;
return this.byKey.has(unionKey(def.arms, def.discriminant));
},
entries: (from) => {
const out: string[] = [];
for (const def of this.unions.slice(from)) {
@@ -729,6 +759,12 @@ export class UnionRegistry {
}
return out;
},
agrees: (entries) =>
entries.every((entry) => {
const def = JSON.parse(entry) as IrUnionDef;
const held = this.byKey.get(unionKey(def.arms, def.discriminant));
return held === undefined ? !this.byId.has(def.id) : held === def.id;
}),
apply: (entries) => {
for (const entry of entries) {
const def = JSON.parse(entry) as IrUnionDef;
+39
View File
@@ -23,6 +23,11 @@ import { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js";
import { setLibDiagnosticsStore } from "./frontend/ts7/lib-diagnostics.js";
import { analyzeWithVerdictCache, fileVerdictStore } from "./coverage/verdict-cache.js";
import { coverageCompilerIdentity } from "./coverage/verdict-startup.js";
import {
directoryUnitStore,
unitReuseEnabled,
type UnitStore,
} from "./frontend/lowering/unit-reuse.js";
import { commentOnlyReplay } from "./coverage/verdict-semantic.js";
import { llvmRefusalDiag, targetRefusalDiag } from "./backend/target-diagnostics.js";
import type {
@@ -914,6 +919,37 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD
);
}
/** The unit store of a Node-hosted build (unit-reuse.ts), keyed by the
* compiler, the entry, the options that reach lowering and the target
* platform. */
async function nodeUnitStore(
entryPath: string,
opts: CompileRequestOptions,
ffi: FfiProfile | null,
buildPlatform: string,
): Promise<UnitStore | null> {
if (!unitReuseEnabled() || provenanceSources() !== null) return null;
const root = await prepareBuildCacheRoot(buildCacheRoot());
if (root === null) return null;
const directory = join(root, "units-v1");
const implementation = await coverageCompilerIdentity(directory);
if (implementation === null) return null;
const { outPath: _outPath, outDir: _outDir, ...lowering } = opts;
const key = createHash("sha256")
.update(
JSON.stringify({
schema: 1,
entry: resolve(entryPath),
options: lowering,
ffi,
platform: buildPlatform,
compiler: implementation.digest,
}),
)
.digest("hex");
return directoryUnitStore(directory, key);
}
interface PreparedExecutable {
llvmSource: LlvmInput;
/** The kept LLVM text beside the executable, installed when the build ends. */
@@ -945,6 +981,9 @@ async function prepareExecutableInput(
buildPlatform,
nodeFrontend,
timing,
new Map(),
true,
await nodeUnitStore(entryPath, opts, ffi, buildPlatform),
);
if (!prepared.ok) return prepared;
const { mod, sourceTexts } = prepared;
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../index.js";
import {
decodeFunctionBinary,
decodeModuleBinary,
encodeFunctionBinary,
encodeFunctionInOrder,
encodeFunctionStrings,
encodeModuleBinary,
functionDigest,
} from "./codec.js";
import { IR_CODEC_LAYOUT } from "./codec.generated.js";
import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js";
import { deserializeModuleBytes } from "./serialize.js";
// Programs with many node kinds: classes, closures, async, generators,
// dynamic values, streams, records, regexes and lone surrogates.
const PROGRAMS = [
"1003-json-parse-unions.ts",
"114-live-record-delete.ts",
"1200-regex-test-basics.ts",
"1631-inspect-arrays.ts",
"4900-console-instances.ts",
"4991-lone-surrogate-halves.ts",
"5612-readable-from-iterables.cjs",
"7110-als-completion-callbacks.ts",
"7321-speculative-loop-bail-sites.ts",
];
/** Whether `a` and `b` hold the same data: the same keys with defined
* values, in any order, and numbers equal by Object.is. */
function sameData(a: unknown, b: unknown): boolean {
if (typeof a === "number" && typeof b === "number") return Object.is(a, b);
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return a === b;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const record = (value: object) => value as Record<string, unknown>;
const keys = (value: object) =>
Object.keys(value)
.filter((key) => record(value)[key] !== undefined)
.sort();
const ka = keys(a);
const kb = keys(b);
return (
ka.length === kb.length &&
ka.every((key, index) => key === kb[index] && sameData(record(a)[key], record(b)[key]))
);
}
test.each(PROGRAMS)("%s round-trips through the binary encoding", async (program) => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-codec-"));
try {
const outPath = join(dir, "main.ir.json");
const entry = join(import.meta.dirname, "../../../../tests/corpus", program);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
const bytes = encodeModuleBinary(mod);
const back = decodeModuleBinary(bytes);
expect(sameData(mod, back)).toBe(true);
expect(Buffer.compare(encodeModuleBinary(back), bytes)).toBe(0);
// The JSON reader takes the binary encoding too.
expect(sameData(deserializeModuleBytes(bytes), mod)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
const loc = { file: "/src/main.ts", start: 0, end: 1 };
const fn = (body: IrFunction["body"]): IrFunction => ({
name: "%main",
params: [],
locals: [],
body,
returnType: VOID,
loc,
});
test("numbers keep NaN, the infinities, -0 and integers outside int32", () => {
const values = [
NaN,
Infinity,
-Infinity,
-0,
0,
1,
-1,
2 ** 31,
-(2 ** 31) - 1,
2 ** 53,
0.1,
-1.5e300,
];
const source = fn(
values.map((value) => ({
kind: "exprStmt",
expr: { kind: "numLit", value, type: F64, loc },
loc,
})),
);
const back = decodeFunctionBinary(encodeFunctionBinary(source));
const read = back.body.map((s) =>
s.kind === "exprStmt" && s.expr.kind === "numLit" ? s.expr.value : null,
);
expect(read.length).toBe(values.length);
read.forEach((value, index) => expect(Object.is(value, values[index])).toBe(true));
});
test("strings keep lone surrogates and repeat through the string table", () => {
const texts = ["plain", "\ud83d", "a\udc00b", "😀", "plain", "é"];
const source = fn(
texts.map((value) => ({
kind: "exprStmt",
expr: { kind: "strLit", value, type: STRING, loc },
loc,
})),
);
const back = decodeFunctionBinary(encodeFunctionBinary(source));
expect(
back.body.map((s) => (s.kind === "exprStmt" && s.expr.kind === "strLit" ? s.expr.value : null)),
).toEqual(texts);
});
test("absent optional fields stay absent", () => {
const source: IrModule = {
irVersion: 17,
sourceFile: "/src/main.ts",
functions: [fn([])],
entry: "%main",
};
const back = decodeModuleBinary(encodeModuleBinary(source));
expect(Object.keys(back).sort()).toEqual(["entry", "functions", "irVersion", "sourceFile"]);
expect(Object.keys(back.functions[0]!).sort()).toEqual(Object.keys(source.functions[0]!).sort());
});
test("a function whose objects follow the declared field order reads back with its key order", () => {
// As lowering builds functions: the source name after the name, and the
// async flag set after the literal.
const source: IrFunction = {
name: "%f",
sourceName: "f",
ownsPrototype: true,
params: [],
returnType: VOID,
locals: [{ id: "x.0", name: "x", type: F64, mutable: true }],
body: [{ kind: "return", value: null, loc }],
loc,
};
source.async = true;
const bytes = encodeFunctionInOrder(source);
expect(bytes).not.toBeNull();
const back = decodeFunctionBinary(bytes!);
expect(JSON.stringify(back)).toBe(JSON.stringify(source));
expect(Object.keys(back)).toEqual(Object.keys(source));
});
test("a function with an object out of the declared field order has no in-order encoding", () => {
const source = fn([{ kind: "return", value: null, loc }]);
// `loc` first: spreading the rest keeps its place.
const { loc: first, ...rest } = source;
const shuffled = { loc: first, ...rest } as IrFunction;
expect(encodeFunctionInOrder(shuffled)).toBeNull();
// An object with a key its type does not declare.
expect(encodeFunctionInOrder({ ...source, extra: 1 } as IrFunction)).toBeNull();
// The helper above builds `locals` before `returnType`.
expect(encodeFunctionInOrder(source)).toBeNull();
const ordered: IrFunction = {
name: source.name,
params: [],
returnType: VOID,
locals: [],
body: source.body,
loc,
};
expect(encodeFunctionInOrder(ordered)).not.toBeNull();
});
test("a node whose kind its union does not declare is refused", () => {
const source = fn([{ kind: "noSuchStatement", loc } as never]);
expect(encodeFunctionInOrder(source)).toBeNull();
expect(() => encodeFunctionBinary(source)).toThrow(/cannot encode a IrStmt kind noSuchStatement/);
});
test("an encoding lists its strings and the files its fields named file hold", () => {
const other = { file: "/src/other.ts", start: 2, end: 3 };
const source: IrFunction = {
name: "%f",
params: [],
returnType: VOID,
locals: [],
body: [
{
kind: "exprStmt",
expr: { kind: "strLit", value: "%named", type: STRING, loc: other },
loc,
},
],
loc,
};
const encoded = encodeFunctionStrings(source)!;
expect(encoded.strings).toEqual(expect.arrayContaining(["%f", "%named", "/src/main.ts"]));
expect([...encoded.files].sort()).toEqual(["/src/main.ts", "/src/other.ts"]);
expect(Buffer.compare(encoded.bytes, encodeFunctionInOrder(source)!)).toBe(0);
});
test("a function's digest follows its content and not its key order", () => {
const source = fn([{ kind: "return", value: null, loc }]);
const digest = functionDigest(source);
expect(digest).toMatch(/^\d+:[0-9a-f]{16}$/);
expect(functionDigest(fn([{ kind: "return", value: null, loc }]))).toBe(digest);
// The same fields in another order.
const { loc: first, ...rest } = source;
expect(functionDigest({ loc: first, ...rest } as IrFunction)).toBe(digest);
// A change in place.
source.locals.push({ id: "x.0", name: "x", type: F64, mutable: true });
expect(functionDigest(source)).not.toBe(digest);
// A node the encoder refuses is covered by the function's JSON text.
const refused = fn([{ kind: "noSuchStatement", loc } as never]);
const text = functionDigest(refused);
expect(text).toMatch(/^json:/);
(refused.body[0] as unknown as { extra: number }).extra = 1;
expect(functionDigest(refused)).not.toBe(text);
});
test("bytes another layout wrote are refused", () => {
const bytes = Buffer.from(encodeFunctionBinary(fn([])));
// The layout digest follows the magic as the first new string.
const at = bytes.indexOf(Buffer.from(IR_CODEC_LAYOUT));
bytes[at] = bytes[at] === 0x30 ? 0x31 : 0x30;
expect(() => decodeFunctionBinary(bytes)).toThrow(/layout/);
expect(() => decodeFunctionBinary(Buffer.from("{}"))).toThrow(/not an IR encoding/);
});
+376
View File
@@ -0,0 +1,376 @@
/* The IR's binary encoding: the fields of every node and record, by name in
* the order ir.ts declares them (codec.generated.ts), written without field
* names or JSON structure.
*
* - A length, a union's member or arm index and a string reference are
* unsigned varints (7 bits per byte, low bits first).
* - A number is one byte, 0 for an integer in the int32 range other than
* -0, followed by its zigzag varint, or 1 followed by its IEEE 754 bytes
* (little-endian), which keep NaN, the infinities and -0.
* - A string is a varint: 0 for a new string, which also gives it the next
* index in the string table, otherwise one more than the index of a
* string the encoding already has. A new string's bytes follow, after a
* varint of twice their count: plus one when they are UTF-16LE code units
* (a string with a lone surrogate, which UTF-8 cannot spell), else UTF-8.
* - A boolean is one byte.
* - A field that can be absent or null starts with a byte: 0 absent, then 1
* for null and 2 for a value when both are possible, otherwise 1 for a
* value.
*
* An encoding starts with MAGIC and IR_CODEC_LAYOUT, a digest of the
* generated coders, so a reader refuses bytes that other coders wrote. Decoded objects carry their fields in
* declaration order, with absent optional fields left out; an object read
* back has the key order of the object written when IrWriter.inOrder held. */
import type { IrFunction, IrModule } from "./ir.js";
import {
IR_CODEC_LAYOUT,
decodeIrFunction,
decodeIrModule,
encodeIrFunction,
encodeIrModule,
} from "./codec.generated.js";
/** The first bytes of an encoding: "SCIR". */
const MAGIC = [0x53, 0x43, 0x49, 0x52];
/** Whether `value` has a surrogate code unit without its pair. */
function loneSurrogate(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit < 0xd800 || unit > 0xdfff) continue;
const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
if (unit > 0xdbff || next < 0xdc00 || next > 0xdfff) return true;
index++;
}
return false;
}
export class IrWriter {
private bytes = Buffer.allocUnsafe(1 << 16);
private size = 0;
private readonly strings = new Map<string, number>();
/** The values of the string fields named `file` written so far. */
readonly files = new Set<string>();
private readonly scratch = Buffer.allocUnsafe(8);
/** Whether every object written so far reads back with its own key order
* (`keys`). False once one held a key its type does not declare, or its
* keys in another order than the declaration's. */
inOrder = true;
/** Notes an object's own keys against its type's declared fields. The
* decoder builds an object with its fields in declaration order, so an
* object whose keys come in another order, or include one the type does
* not declare, does not read back as it was. */
keys(own: readonly string[], declared: readonly string[]): void {
if (!this.inOrder) return;
let at = 0;
for (const key of own) {
while (at < declared.length && declared[at] !== key) at++;
if (at === declared.length) {
this.inOrder = false;
return;
}
at++;
}
}
private reserve(count: number): void {
if (this.size + count <= this.bytes.length) return;
let capacity = this.bytes.length * 2;
while (capacity < this.size + count) capacity *= 2;
const grown = Buffer.allocUnsafe(capacity);
this.bytes.copy(grown, 0, 0, this.size);
this.bytes = grown;
}
byte(value: number): void {
this.reserve(1);
this.bytes[this.size++] = value;
}
/** A non-negative integer below 2^53. */
length(value: number): void {
this.reserve(8);
while (value >= 0x80) {
this.bytes[this.size++] = (value % 0x80) + 0x80;
value = Math.floor(value / 0x80);
}
this.bytes[this.size++] = value;
}
bool(value: boolean): void {
this.byte(value ? 1 : 0);
}
num(value: number): void {
if (
Number.isInteger(value) &&
value >= -0x80000000 &&
value <= 0x7fffffff &&
!Object.is(value, -0)
) {
this.byte(0);
this.length(value < 0 ? -value * 2 - 1 : value * 2);
return;
}
this.byte(1);
this.scratch.writeDoubleLE(value, 0);
this.reserve(8);
this.scratch.copy(this.bytes, this.size, 0, 8);
this.size += 8;
}
str(value: string): void {
const known = this.strings.get(value);
if (known !== undefined) {
this.length(known + 1);
return;
}
this.strings.set(value, this.strings.size);
this.length(0);
const utf8 = Buffer.byteLength(value, "utf8");
// UTF-8 cannot spell a lone surrogate, so a string with one is written
// as its UTF-16 code units.
const units = utf8 !== value.length && loneSurrogate(value);
const count = units ? value.length * 2 : utf8;
this.length(count * 2 + (units ? 1 : 0));
this.reserve(count);
if (units) this.bytes.write(value, this.size, count, "utf16le");
else this.bytes.write(value, this.size, count, "utf8");
this.size += count;
}
/** What the encoding could not write, such as a node whose kind its
* union does not declare, or null. Such an encoding does not decode. */
refused: string | null = null;
/** Notes that `what` could not be written (`refused`). */
refuse(what: string): void {
this.inOrder = false;
this.refused ??= what;
}
/** A string field named `file`: a source file the encoding names. */
file(value: string): void {
this.files.add(value);
this.str(value);
}
/** Raw bytes, after their count. */
blob(bytes: Uint8Array): void {
this.length(bytes.length);
this.reserve(bytes.length);
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).copy(this.bytes, this.size);
this.size += bytes.length;
}
/** The strings written so far, each once, in the order first written. */
written(): IterableIterator<string> {
return this.strings.keys();
}
/** Starts a new encoding in the same buffer. */
reset(): void {
this.size = 0;
this.strings.clear();
this.files.clear();
this.inOrder = true;
this.refused = null;
}
/** The bytes written so far. Throws when the encoding refused a value. */
finish(): Buffer {
if (this.refused !== null) throw new Error(`IR codec: cannot encode a ${this.refused}`);
return this.bytes.subarray(0, this.size);
}
}
export class IrReader {
private offset = 0;
private readonly strings: string[] = [];
/** The last presence byte `tag` read. */
lastTag = 0;
constructor(private readonly bytes: Buffer) {}
private fail(what: string): never {
throw new Error(`IR codec: ${what} at byte ${this.offset}`);
}
byte(): number {
if (this.offset >= this.bytes.length) this.fail("unexpected end");
return this.bytes[this.offset++]!;
}
length(): number {
let value = 0;
let scale = 1;
for (;;) {
const byte = this.byte();
value += (byte % 0x80) * scale;
if (byte < 0x80) return value;
scale *= 0x80;
if (scale > 2 ** 56) this.fail("varint too long");
}
}
bool(): boolean {
return this.byte() !== 0;
}
num(): number {
const tag = this.byte();
if (tag === 0) {
const zigzag = this.length();
return zigzag % 2 === 0 ? zigzag / 2 : -(zigzag + 1) / 2;
}
if (tag !== 1) this.fail("invalid number tag");
if (this.offset + 8 > this.bytes.length) this.fail("unexpected end");
const value = this.bytes.readDoubleLE(this.offset);
this.offset += 8;
return value;
}
str(): string {
const reference = this.length();
if (reference > 0) {
const known = this.strings[reference - 1];
if (known === undefined) this.fail("invalid string reference");
return known;
}
const spelled = this.length();
const count = Math.floor(spelled / 2);
if (this.offset + count > this.bytes.length) this.fail("unexpected end");
const value =
spelled % 2 === 0
? this.bytes.toString("utf8", this.offset, this.offset + count)
: this.bytes.toString("utf16le", this.offset, this.offset + count);
this.offset += count;
this.strings.push(value);
return value;
}
/** Raw bytes blob wrote, as a view of the bytes read. */
blob(): Buffer {
const count = this.length();
if (this.offset + count > this.bytes.length) this.fail("unexpected end");
const out = this.bytes.subarray(this.offset, this.offset + count);
this.offset += count;
return out;
}
/** A presence byte below `limit`, kept in lastTag. */
tag(limit: number, where: string): number {
const tag = this.byte();
if (tag >= limit) this.fail(`invalid presence byte for ${where}`);
this.lastTag = tag;
return tag;
}
/** Whether every byte was read. */
done(): boolean {
return this.offset === this.bytes.length;
}
}
function header(w: IrWriter): void {
for (const byte of MAGIC) w.byte(byte);
w.str(IR_CODEC_LAYOUT);
}
function readHeader(r: IrReader): void {
for (const byte of MAGIC) if (r.byte() !== byte) throw new Error("IR codec: not an IR encoding");
const layout = r.str();
if (layout !== IR_CODEC_LAYOUT)
throw new Error(
`IR codec: the encoding has layout ${layout}, this compiler reads ${IR_CODEC_LAYOUT}`,
);
}
/** Whether `bytes` start like an encoding this module writes. */
export function isIrEncoding(bytes: Uint8Array): boolean {
return MAGIC.every((byte, index) => bytes[index] === byte);
}
export function encodeModuleBinary(mod: IrModule): Buffer {
const w = new IrWriter();
header(w);
encodeIrModule(w, mod);
return w.finish();
}
export function decodeModuleBinary(bytes: Buffer): IrModule {
const r = new IrReader(bytes);
readHeader(r);
const mod = decodeIrModule(r);
if (!r.done()) throw new Error("IR codec: bytes after the module");
return mod;
}
export function encodeFunctionBinary(fn: IrFunction): Buffer {
const w = new IrWriter();
header(w);
encodeIrFunction(w, fn);
return w.finish();
}
/** The writer encodeFunctionInOrder reuses: its result is a copy. */
const inOrderWriter = new IrWriter();
/** `fn`'s encoding when it reads back as `fn` is, every object with its
* own key order (IrWriter.inOrder), or null. */
export function encodeFunctionInOrder(fn: IrFunction): Buffer | null {
return encodeFunctionStrings(fn)?.bytes ?? null;
}
/** encodeFunctionInOrder's encoding of `fn`, with the strings it holds,
* each once, and the files its fields named `file` hold (IrWriter.file), or
* null. */
export function encodeFunctionStrings(fn: IrFunction): {
readonly bytes: Buffer;
readonly strings: readonly string[];
readonly files: readonly string[];
} | null {
const w = inOrderWriter;
w.reset();
header(w);
encodeIrFunction(w, fn);
// An encoding that refused a value is not in order either.
return w.inOrder
? { bytes: Buffer.from(w.finish()), strings: [...w.written()], files: [...w.files] }
: null;
}
/** A 64-bit digest of `fn`'s content, the same for functions whose fields
* hold the same values in any key order, with the byte count of the
* content it covers. It covers the binary encoding, or the JSON text when
* the encoder refuses a value (IrWriter.refuse). */
export function functionDigest(fn: IrFunction): string {
const w = inOrderWriter;
w.reset();
encodeIrFunction(w, fn);
if (w.refused === null) return bytesDigest(w.finish());
return `json:${bytesDigest(Buffer.from(JSON.stringify(fn), "utf8"))}`;
}
/** Two 32-bit multiply-xor lanes over `bytes`, FNV-1a's and one with
* another offset and MurmurHash2's multiplier, and the byte count. */
function bytesDigest(bytes: Uint8Array): string {
let a = 0x811c9dc5;
let b = 0x050c5d1f;
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i]!;
a = Math.imul(a ^ byte, 0x01000193);
b = Math.imul(b ^ byte, 0x5bd1e995);
}
const hex = (lane: number): string => (lane >>> 0).toString(16).padStart(8, "0");
return `${bytes.length}:${hex(a)}${hex(b)}`;
}
export function decodeFunctionBinary(bytes: Buffer): IrFunction {
const r = new IrReader(bytes);
readHeader(r);
const fn = decodeIrFunction(r);
if (!r.done()) throw new Error("IR codec: bytes after the function");
return fn;
}
+17 -17
View File
@@ -802,14 +802,14 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo
case "classRef": {
walker.string(node.kind);
walker.string(node.className);
if (!skipType) {
walkTypeFields(node.type, walker, skipType);
}
if (node.captures !== undefined && node.captures !== null) {
for (const item6 of node.captures) {
walker.string(item6);
}
}
if (!skipType) {
walkTypeFields(node.type, walker, skipType);
}
return;
}
case "closure": {
@@ -890,12 +890,12 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo
case "dynFrom": {
walker.string(node.kind);
walkExprFields(node.value, walker, skipType);
if (node.fnName !== undefined && node.fnName !== null) {
walker.string(node.fnName);
}
if (!skipType) {
walkTypeFields(node.type, walker, skipType);
}
if (node.fnName !== undefined && node.fnName !== null) {
walker.string(node.fnName);
}
return;
}
case "dynHasKey": {
@@ -1635,14 +1635,14 @@ export function walkStmtFields(node: IrStmt, walker: IrFieldWalker, skipType: bo
}
case "block": {
walker.string(node.kind);
for (const item30 of node.body) {
walkStmtFields(item30, walker, skipType);
}
if (node.labels !== undefined && node.labels !== null) {
for (const item31 of node.labels) {
walker.string(item31);
for (const item30 of node.labels) {
walker.string(item30);
}
}
for (const item31 of node.body) {
walkStmtFields(item31, walker, skipType);
}
return;
}
case "break": {
@@ -2111,7 +2111,7 @@ export type IrFieldsCurrent = [
Unread<ValuesNotRead<Extract<IrExpr, { kind: "caughtTest" }>["negated"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "caughtToDyn" }>, "kind" | "value" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "chainRecv" }>, "kind" | "id" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "classRef" }>, "kind" | "className" | "captures" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "classRef" }>, "kind" | "className" | "type" | "loc" | "captures">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "closure" }>, "kind" | "fnName" | "captures" | "adapts" | "type" | "loc">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "closure" }>["adapts"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "downcast" }>, "kind" | "value" | "type" | "loc">>,
@@ -2125,9 +2125,9 @@ export type IrFieldsCurrent = [
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynCheck" }>["exact"]>>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynCheck" }>["origin"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynDestrCheck" }>, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynFrom" }>, "kind" | "value" | "fnName" | "fnLength" | "liveRef" | "type" | "loc">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["fnLength"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynFrom" }>, "kind" | "value" | "type" | "loc" | "liveRef" | "fnName" | "fnLength">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["liveRef"]>>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["fnLength"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynHasKey" }>, "kind" | "key" | "negated" | "value" | "type" | "loc">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynHasKey" }>["negated"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynInvoke" }>, "kind" | "recv" | "method" | "calleeName" | "calleeNameValue" | "args" | "type" | "loc">>,
@@ -2240,7 +2240,7 @@ export type IrFieldsCurrent = [
Unread<FieldsNotRead<Extract<IrStmt, { kind: "arraySetUndefined" }>, "kind" | "arr" | "index" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "assign" }>, "kind" | "localId" | "value" | "initializes" | "loc">>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "assign" }>["initializes"]>>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "block" }>, "kind" | "body" | "labels" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "block" }>, "kind" | "labels" | "body" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "break" }>, "kind" | "label" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "bytesSet" }>, "kind" | "arr" | "index" | "value" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "continue" }>, "kind" | "label" | "loc">>,
@@ -2264,9 +2264,9 @@ export type IrFieldsCurrent = [
Unread<FieldsNotRead<Extract<NonNullable<Extract<NonNullable<Extract<IrStmt, { kind: "switch" }>["cases"]>, readonly unknown[]>[number]>, object>, "test" | "body">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "switch" }>, "kind" | "disc" | "cases" | "labels" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "throw" }>, "kind" | "value" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "tryCatch" }>, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "finallyBody" | "suppressFinallyErrors" | "dropFinallyErrors" | "loc">>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["suppressFinallyErrors"]>>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "tryCatch" }>, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "dropFinallyErrors" | "finallyBody" | "suppressFinallyErrors" | "loc">>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["dropFinallyErrors"]>>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["suppressFinallyErrors"]>>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "varDecl" }>, "kind" | "localId" | "init" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "while" }>, "kind" | "cond" | "body" | "labels" | "loc">>,
Unread<FieldsNotRead<Extract<IrType, { kind: "array" }>, "kind" | "elem" | "optionalElements">>,
+42 -42
View File
@@ -1964,14 +1964,10 @@ export interface IrFunction {
/** Original TS name (mangling is a backend concern). Lifted lambdas get
* synthetic '%'-prefixed names ('%' can't appear in a TS identifier). */
name: string;
/** Generated reflection dispatch contains speculative source-member edges.
* Target-only host operations behind those edges retain runtime refusals. */
speculativeDispatch?: boolean;
/** A read helper the backend can answer directly for dyn arrays and live
* array capsules, calling the helper only when that read has no answer.
* `index`: a computed read `(value, key)` whose callers pass a number
* converted to a string as the key. */
fastRead?: "index";
/** Source function name for native error stack frames. */
sourceName?: string;
/** Ordinary authored function declarations/expressions own a prototype. */
ownsPrototype?: true;
params: IrParam[];
returnType: IrType;
/** All locals including params, pre-collected and scope-flat: ids are
@@ -1986,22 +1982,29 @@ export interface IrFunction {
* their first (`this`) parameter. The slot indexes the class object's
* capture array; no closure parameter is added to the method ABI. */
classCaptures?: (IrParam & { slot: number })[];
body: IrStmt[];
loc: SrcLoc;
/** The function's execution tier (ir/tiers.ts). Absent means static: the
* function may use dynamic values only through boundary operations
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
* (invariant I1). "dynamic" admits the dynamic operation vocabulary
* (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field
* from the body (assignLegacyTiers); stage S2 of the two-tier design
* keeps it out of serialized IR, so loaders derive it again. */
tier?: "dynamic";
/** Generated reflection dispatch contains speculative source-member edges.
* Target-only host operations behind those edges retain runtime refusals. */
speculativeDispatch?: boolean;
/** A read helper the backend can answer directly for dyn arrays and live
* array capsules, calling the helper only when that read has no answer.
* `index`: a computed read `(value, key)` whose callers pass a number
* converted to a string as the key. */
fastRead?: "index";
/** Async: the body runs on a fiber; `returnType` is the INNER type T (a
* `return v` fulfills with v) while ordinary call sites receive
* Promise<T>. With `generator` present, call sites receive the lazy
* async-generator object instead. */
async?: true;
/** Async module initializers only: a module-global Promise<T> slot where
* the spawn wrapper caches its first evaluation promise. Every later
* static/dynamic import receives that same promise, including while the
* first evaluation is suspended. */
asyncCacheGlobal?: string;
/** Async cyclic module initializers only: the SCC's shared Promise<T>
* slot. Eager recursive spawning writes member promises from the inside
* out, so the member that actually initiated evaluation writes last and
* becomes the runtime cycle root. Dynamic imports wait on this shared
* completion verdict instead of a build-time-selected member. */
asyncCycleCacheGlobal?: string;
/** Generator (`function*` / `async function*`): the body runs on a fiber created SUSPENDED
* (nothing runs until the first `.next()`; backends may instead compile a
* synchronous body as a stackless coroutine with the same protocol); `returnType` is the
@@ -2030,20 +2033,17 @@ export interface IrFunction {
* that generator's tag. */
inheritsTag?: true;
};
/** Source function name for native error stack frames. */
sourceName?: string;
/** Ordinary authored function declarations/expressions own a prototype. */
ownsPrototype?: true;
/** The function's execution tier (ir/tiers.ts). Absent means static: the
* function may use dynamic values only through boundary operations
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
* (invariant I1). "dynamic" admits the dynamic operation vocabulary
* (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field
* from the body (assignLegacyTiers); stage S2 of the two-tier design
* keeps it out of serialized IR, so loaders derive it again. */
tier?: "dynamic";
body: IrStmt[];
loc: SrcLoc;
/** Async module initializers only: a module-global Promise<T> slot where
* the spawn wrapper caches its first evaluation promise. Every later
* static/dynamic import receives that same promise, including while the
* first evaluation is suspended. */
asyncCacheGlobal?: string;
/** Async cyclic module initializers only: the SCC's shared Promise<T>
* slot. Eager recursive spawning writes member promises from the inside
* out, so the member that actually initiated evaluation writes last and
* becomes the runtime cycle root. Dynamic imports wait on this shared
* completion verdict instead of a build-time-selected member. */
asyncCycleCacheGlobal?: string;
}
export interface IrParam {
@@ -2280,7 +2280,7 @@ export type IrStmt =
/** A bare lexical block `{ ... }` — its own scope. `labels` makes it a
* labeled-break target (`lbl: { ... break lbl; ... }` and the wrapper
* the frontend puts around labeled non-loop statements). */
| { kind: "block"; body: IrStmt[]; labels?: string[]; loc: SrcLoc }
| { kind: "block"; labels?: string[]; body: IrStmt[]; loc: SrcLoc }
/** `throw v`. Any non-void value type can be thrown; ownership of a
* refcounted value MOVES into the runtime's exception cell. Terminates
* the path like `return`: control unwinds to the innermost enclosing
@@ -2324,15 +2324,15 @@ export type IrStmt =
tryBody: IrStmt[];
catchBody: IrStmt[] | null;
catchLocalId: string | null;
finallyBody: IrStmt[] | null;
/** Resource-management cleanup: if the guarded body and cleanup both
* throw, raise a SuppressedError instead of replacing the first error. */
suppressFinallyErrors?: true;
/** Iterator closing: if the guarded body threw and the cleanup throws
* too, the cleanup's error is dropped and the first one propagates.
* After a generator .return() the cleanup's error replaces it, and on
* normal exits it propagates. */
dropFinallyErrors?: true;
finallyBody: IrStmt[] | null;
/** Resource-management cleanup: if the guarded body and cleanup both
* throw, raise a SuppressedError instead of replacing the first error. */
suppressFinallyErrors?: true;
loc: SrcLoc;
};
@@ -3341,7 +3341,7 @@ export type IrExpr =
* every classRef, so a value's construct thunk always has a constructor
* to call; backends emit class objects (and thunks) for exactly the
* classes some classRef in the module names. */
| { kind: "classRef"; className: string; captures?: string[]; type: IrType; loc: SrcLoc }
| { kind: "classRef"; className: string; type: IrType; loc: SrcLoc; captures?: string[] }
/** `new X(args)` through a class VALUE: call the class object's
* construct thunk. `callee` is classval-typed; args are completed
* against `%<callee.className>.constructor`'s ABI — sound because every
@@ -3513,13 +3513,13 @@ export type IrExpr =
| {
kind: "dynFrom";
value: IrExpr;
type: IrType;
loc: SrcLoc;
liveRef?: true;
fnName?: string;
/** A boxed function's `length` when it differs from its parameter
* count (builtin function values). */
fnLength?: number;
liveRef?: true;
type: IrType;
loc: SrcLoc;
}
/** CALLING a dyn value — `fn(a, b)` where fn is checked-dynamic (an
* implicit-any JS binding, a dyn record member, a dynKeyGet result).
+5 -2
View File
@@ -7,6 +7,7 @@
* text parts and decoded from bytes one function at a time; neither direction
* materializes the complete document as a single string. */
import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
import { decodeModuleBinary, isIrEncoding } from "./codec.js";
// Version 17 adds the dynamic tier's jsval type and js.* builtins (two-tier
// design stage S3) and serializes each function's execution tier.
@@ -268,10 +269,12 @@ function functionSpans(bytes: Buffer): FunctionSpans | null {
return null;
}
/** Decode a UTF-8 IR artifact (compact or readable) without materializing
* it as one string: the header and each function are parsed separately.
/** Decode an IR artifact: the binary encoding (codec.ts), or UTF-8 JSON
* (compact or readable) without materializing it as one string: the header
* and each function are parsed separately.
* Split points are ASCII structural bytes, so every range is valid UTF-8. */
export function deserializeModuleBytes(bytes: Buffer): IrModule {
if (isIrEncoding(bytes)) return decodeModuleBinary(bytes);
const spans = functionSpans(bytes);
if (spans === null) return deserializeModule(bytes.toString("utf8"));
const mod = checkedVersion(
+2
View File
@@ -174,6 +174,8 @@ export class NativeCache {
"binary",
"dsym",
"coverage",
"units",
"unit-files",
]) {
const directory = join(this.root, family);
let names: string[];
+48 -13
View File
@@ -32,6 +32,7 @@ import {
import { compilerOwnedFileRoots } from "../frontend/dts-paths.js";
import { provenanceSources } from "../frontend/provenance-registry.js";
import { processFork } from "../frontend/process-fork.js";
import { unitReuseEnabled, type UnitStore } from "../frontend/lowering/unit-reuse.js";
import {
tokenEquivalentSource,
tokenPositionEquivalentSource,
@@ -196,6 +197,21 @@ function equivalentFrontendInput(
};
}
/** The unit store of a build whose unit key is `key`: its program entry
* under that key, and file entries by content digest. */
function nativeUnitStore(cache: NativeCache, key: string): UnitStore {
return {
readProgram: () => cache.read("units", key),
writeProgram: (bytes) => cache.write("units", key, bytes),
readFile: (name) => (/^[0-9a-f]{64}$/.test(name) ? cache.read("unit-files", name) : null),
writeFile: (bytes) => {
const digest = contentDigest(bytes);
if (!cache.has("unit-files", digest)) cache.write("unit-files", digest, bytes, digest);
return digest;
},
};
}
function implementationIdentity(toolchain: NativeToolchain): string {
// Release versions alone cannot distinguish two development builds.
return contentDigest(
@@ -221,6 +237,9 @@ const FRONTEND_INDEPENDENT_VARIABLES = new Set([
"SCRIPTC_CHECKER_SERVER",
"SCRIPTC_EMIT_WORKERS",
"SCRIPTC_EMIT_WORKER_FUNCTIONS",
// Reusing lowered units leaves the lowered module as it is.
"SCRIPTC_INCR_LOWERING",
"SCRIPTC_INCR_VERIFY",
"SCRIPTC_TIMING",
]);
@@ -372,22 +391,28 @@ export function prepareNativeExecutable(
const exclusions = { outputPaths, outputDirectories: [...directories] };
const irInput = process.env[BOOTSTRAP_IR_INPUT] ?? "";
let key: string | null = null;
// The unit store's key (unit-reuse.ts): the frontend's inputs without the
// output paths, which lowering does not read.
let unitKey: string | null = null;
// The entry this build replaces: its probes give the digests of unchanged files.
let previous: FrontendInputSnapshot | undefined;
if (cache !== null && irInput === "" && provenanceSources() === null) {
try {
key = contentDigest(
JSON.stringify({
schema: 1,
entry,
options,
ffi,
implementation: implementationIdentity(toolchain),
target: toolchain.target.name,
declarations: toolchain.declarationsRoot ?? null,
environment: frontendEnvironment(),
}),
);
const inputs = {
schema: 1,
entry,
options,
ffi,
implementation: implementationIdentity(toolchain),
target: toolchain.target.name,
declarations: toolchain.declarationsRoot ?? null,
environment: frontendEnvironment(),
};
key = contentDigest(JSON.stringify(inputs));
if (unitReuseEnabled()) {
const { outPath: _outPath, outDir: _outDir, ...lowering } = options;
unitKey = contentDigest(JSON.stringify({ ...inputs, options: lowering }));
}
const cached = readFrontendCache(cache, key, exclusions);
if (cached !== null && "input" in cached) {
timing("frontend-cache-hit");
@@ -413,7 +438,17 @@ export function prepareNativeExecutable(
irInput !== ""
? preparedFromIr(irInput, entry, options)
: tracker.runSynchronous(() =>
prepareExecutableModule(entry, options, ffi, toolchain.target.platform, frontend, timing),
prepareExecutableModule(
entry,
options,
ffi,
toolchain.target.platform,
frontend,
timing,
new Map(),
true,
cache !== null && unitKey !== null ? nativeUnitStore(cache, unitKey) : null,
),
);
if (!prepared.ok) return prepared;
if (irInput !== "") timing("ir-input");
+315
View File
@@ -0,0 +1,315 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile } from "../src/index.js";
const VARIABLES = [
"SCRIPTC_CACHE_DIR",
"SCRIPTC_INCR_LOWERING",
"SCRIPTC_INCR_VERIFY",
"SCRIPTC_LOWER_JOURNAL_DUMP",
];
afterEach(() => {
for (const name of VARIABLES) delete process.env[name];
});
/** A program in a scratch directory, built with unit reuse into its own
* cache and without it. */
class Workspace {
private builds = 0;
constructor(readonly dir: string) {}
static async create(files: Record<string, string>): Promise<Workspace> {
const workspace = new Workspace(await mkdtemp(join(tmpdir(), "scriptc-unit-reuse-")));
for (const [name, text] of Object.entries(files)) await workspace.write(name, text);
return workspace;
}
write(name: string, text: string): Promise<void> {
return writeFile(join(this.dir, name), text);
}
async edit(name: string, from: string, to: string): Promise<void> {
const text = await readFile(join(this.dir, name), "utf8");
expect(text, `${name} spells ${from}`).toContain(from);
await this.write(name, text.replace(from, to));
}
private async lower(label: string, reuse: boolean, verify = false): Promise<string> {
for (const name of VARIABLES) delete process.env[name];
if (reuse) {
process.env["SCRIPTC_CACHE_DIR"] = join(this.dir, "cache");
process.env["SCRIPTC_INCR_LOWERING"] = "1";
process.env["SCRIPTC_LOWER_JOURNAL_DUMP"] = join(this.dir, label);
if (verify) process.env["SCRIPTC_INCR_VERIFY"] = "1";
}
const outPath = join(this.dir, `${label}.ir.json`);
const result = await compile(join(this.dir, "main.ts"), {
outDir: join(this.dir, "out"),
outPath,
outputKind: "ir",
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
return readFile(outPath, "utf8");
}
/** The units with a record in the last build, recorded or applied. */
units: string[] = [];
/** Builds with reuse, checks the IR against a build without it, and
* returns the units whose stored records applied. A build in verify mode
* follows and must give the same IR. */
async build(): Promise<string[]> {
const label = `build-${this.builds++}`;
const reused = await this.lower(label, true);
expect(reused).toBe(await this.lower(`${label}-plain`, false));
const applied: string[] = [];
this.units = [];
for (const line of (await readFile(join(this.dir, label, "records.jsonl"), "utf8"))
.trimEnd()
.split("\n")) {
// A unit without a record has a line with its reasons.
const record = JSON.parse(line) as { name: string; applied?: boolean; reasons?: string[] };
if (record.reasons !== undefined) continue;
this.units.push(record.name);
if (record.applied === true) applied.push(record.name);
}
this.units.sort();
expect(await this.lower(`${label}-verify`, true, true)).toBe(reused);
return applied.sort();
}
dispose(): Promise<void> {
return rm(this.dir, { recursive: true, force: true });
}
}
const MODULES = {
"shapes.ts": [
"export function area(width: number, height: number): number {",
" return width * height;",
"}",
"export function label(name: string): string {",
" return `shape ${name}`;",
"}",
"export class Box {",
" constructor(readonly width: number) {}",
" scaled(by: number): number {",
" return this.width * by;",
" }",
" named(): string {",
" return label(`box ${this.width}`);",
" }",
"}",
"",
].join("\n"),
"report.ts": [
'import { Box, area } from "./shapes.js";',
"export function report(sizes: number[]): number {",
" let total = 0;",
" for (const size of sizes) total += area(size, size);",
" return total;",
"}",
"export function describe(box: Box): string {",
" return `${box.named()} ${box.scaled(2)}`;",
"}",
"export function twice(value: number): number {",
" return value * 2;",
"}",
"",
].join("\n"),
"format.ts": [
"export function pad(text: string, width: number): string {",
" return text.padStart(width);",
"}",
"",
].join("\n"),
"main.ts": [
'import { Box, label } from "./shapes.js";',
'import { describe, report, twice } from "./report.js";',
'import { pad } from "./format.js";',
'console.log(report([1, 2, 3]), describe(new Box(4)), label("main"), twice(3), pad("x", 4));',
"",
].join("\n"),
};
/** Whether `applied` holds a unit named `name`. */
const has = (applied: readonly string[], name: string): boolean =>
applied.some((unit) => unit.endsWith(`.${name}`) || unit === name);
test("an unchanged rebuild applies every unit's stored record", async () => {
const workspace = await Workspace.create(MODULES);
try {
// The first build into the cache records nothing; build()'s verify build
// after it finds the tables and records.
expect(await workspace.build()).toEqual([]);
const units = workspace.units;
expect(units.length).toBeGreaterThan(4);
expect(await workspace.build()).toEqual(units);
} finally {
await workspace.dispose();
}
}, 300_000);
test("a body edit lowers the units of its file and the units that spell its name", async () => {
const workspace = await Workspace.create(MODULES);
try {
await workspace.build();
await workspace.edit("shapes.ts", "return width * height;", "return width * height + 1;");
const applied = await workspace.build();
// report() spells area(); twice() and pad() spell nothing whose
// declaration changed. The units of shapes.ts lower again.
expect(has(applied, "report")).toBe(false);
expect(has(applied, "twice")).toBe(true);
expect(has(applied, "pad")).toBe(true);
expect(has(applied, "scaled")).toBe(false);
} finally {
await workspace.dispose();
}
}, 300_000);
test("a signature edit lowers the units whose text spells a name it reaches", async () => {
const workspace = await Workspace.create(MODULES);
try {
await workspace.build();
await workspace.edit(
"shapes.ts",
" named(): string {",
' named(prefix = ""): string {\n if (prefix !== "") return prefix;',
);
const applied = await workspace.build();
// The member's surface changed, and with it the type of Box: the units
// that spell named or Box lower again, the others apply.
expect(has(applied, "named")).toBe(false);
expect(has(applied, "report")).toBe(true);
expect(has(applied, "twice")).toBe(true);
} finally {
await workspace.dispose();
}
}, 300_000);
test("an edit above every declaration of a file keeps the other files' units", async () => {
const workspace = await Workspace.create(MODULES);
try {
await workspace.build();
await workspace.edit(
"shapes.ts",
"export function area",
"// A comment.\nexport function area",
);
const applied = await workspace.build();
expect(has(applied, "report")).toBe(true);
expect(has(applied, "twice")).toBe(true);
expect(has(applied, "scaled")).toBe(false);
} finally {
await workspace.dispose();
}
}, 300_000);
test("an added declaration keeps the units that do not spell its name", async () => {
const workspace = await Workspace.create(MODULES);
try {
await workspace.build();
await workspace.edit(
"report.ts",
"export function describe",
"export function unused(): number {\n return 1;\n}\nexport function describe",
);
const applied = await workspace.build();
expect(has(applied, "scaled")).toBe(true);
expect(has(applied, "label")).toBe(true);
expect(has(applied, "pad")).toBe(true);
expect(has(applied, "twice")).toBe(false);
} finally {
await workspace.dispose();
}
}, 300_000);
test("units that use a module's namespace object lower again when its exports change", async () => {
const workspace = await Workspace.create({
"values.ts": "export let count = 1;\nexport function bump(): void {\n count += 1;\n}\n",
"main.ts": [
'import * as values from "./values.js";',
"function keys(): string {",
' return Object.keys(values).join(",");',
"}",
"function current(): number {",
" return values.count;",
"}",
"values.bump();",
"console.log(keys(), current());",
"",
].join("\n"),
});
try {
await workspace.build();
expect(await workspace.build()).toEqual(workspace.units);
await workspace.write(
"values.ts",
"export let count = 1;\nexport function bump(): void {\n count += 1;\n}\nexport const added = 2;\n",
);
// Both units spell the namespace binding, whose object gained a key.
const applied = await workspace.build();
expect(has(applied, "keys")).toBe(false);
expect(has(applied, "current")).toBe(false);
} finally {
await workspace.dispose();
}
}, 300_000);
test("a unit whose record holds another file's positions lowers again when that file changes", async () => {
const workspace = await Workspace.create({
"lib.ts":
'export function add(a: number, b: number): number {\n return a + b;\n}\nexport const label = "lib";\n',
"main.ts": [
'import * as lib from "./lib.js";',
"function show(): string {",
" const { add: plus, label: name } = lib;",
" return `${name} ${plus(1, 2)}`;",
"}",
"function other(): number {",
" return 42;",
"}",
"console.log(show(), other());",
"",
].join("\n"),
});
try {
await workspace.build();
await workspace.edit("lib.ts", "export function add", "// A comment.\nexport function add");
// The build compares its IR, positions included, with a clean build's.
const applied = await workspace.build();
expect(has(applied, "other")).toBe(true);
} finally {
await workspace.dispose();
}
}, 300_000);
test("the first build into a cache writes the declaration tables and no unit records", async () => {
const workspace = await Workspace.create(MODULES);
try {
const cache = join(workspace.dir, "cache");
process.env["SCRIPTC_CACHE_DIR"] = cache;
process.env["SCRIPTC_INCR_LOWERING"] = "1";
const build = async (): Promise<void> => {
const result = await compile(join(workspace.dir, "main.ts"), {
outDir: join(workspace.dir, "out"),
outPath: join(workspace.dir, "main.ir.json"),
outputKind: "ir",
});
expect(result.ok).toBe(true);
};
const entries = async (family: string): Promise<string[]> =>
readdir(join(cache, "units-v1", family)).catch(() => []);
await build();
expect(await entries("program")).toHaveLength(1);
expect(await entries("files")).toEqual([]);
await build();
expect((await entries("files")).length).toBeGreaterThan(0);
} finally {
await workspace.dispose();
}
}, 300_000);
+440
View File
@@ -0,0 +1,440 @@
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
// Generates packages/compiler/src/ir/codec.generated.ts from the types in
// ir.ts: for every object type an IrModule can reach, an encoder that
// writes its fields by name in declaration order and a decoder that reads
// them back into an object literal of that type. Unions that `kind`
// discriminates are written as the member's index; other unions as the
// index of the arm a runtime test picks. The byte format is in codec.ts.
// `--check` fails when the file is stale.
const root = fileURLToPath(new URL("..", import.meta.url));
const require = createRequire(join(root, "package.json"));
const ts = require("typescript");
const irPath = join(root, "packages/compiler/src/ir/ir.ts");
const target = join(root, "packages/compiler/src/ir/codec.generated.ts");
const program = ts.createProgram([irPath], { strict: true, target: ts.ScriptTarget.ES2022 });
const checker = program.getTypeChecker();
const source = program.getSourceFile(irPath);
/** Exported type names of ir.ts, by the type they declare. */
const exported = new Map();
ts.forEachChild(source, (node) => {
if (
(ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
node.typeParameters === undefined
) {
const type = checker.getTypeAtLocation(node.name);
if (!exported.has(type)) exported.set(type, node.name.text);
}
});
/** Exported names by the member set of the union or the single type they
* declare: a field of type `IrExpr | undefined` codes its IrExpr part with
* IrExpr's coder. */
const exportedBy = new Map();
for (const [type, name] of exported) {
const parts = type.isUnion() ? type.types : [type];
const key = parts
.map((p) => p.id)
.sort((a, b) => a - b)
.join("|");
if (!exportedBy.has(key)) exportedBy.set(key, name);
}
const partsKey = (parts) =>
parts
.map((p) => p.id)
.sort((a, b) => a - b)
.join("|");
const used = new Set(["IrModule", "IrFunction"]);
const moduleType = [...exported].find(([, name]) => name === "IrModule")[0];
const functionType = [...exported].find(([, name]) => name === "IrFunction")[0];
const F = ts.TypeFlags;
const NULLISH = F.Null | F.Undefined | F.Void;
const STRING = F.StringLike;
const NUMBER = F.NumberLike;
const BOOLEAN = F.BooleanLike;
/** An intersection no value has: `IrType & { kind: "record" }` distributes
* over IrType's members, and all but one give `kind` two literal types. */
function impossible(type) {
return type.isIntersection() && checker.typeToString(type) === "never";
}
/** The parts of `type` that are not null or undefined, and which of the two
* it allows. */
function split(type) {
const parts = type.isUnion() ? type.types : [type];
return {
present: parts.filter((part) => (part.flags & (NULLISH | F.Never)) === 0 && !impossible(part)),
nullable: parts.some((part) => (part.flags & F.Null) !== 0),
undefinable: parts.some((part) => (part.flags & (F.Undefined | F.Void)) !== 0),
};
}
/** The literal value of `kind` on an object type, or null. */
function kindOf(type) {
const kind = checker.getPropertyOfType(type, "kind");
if (kind === undefined) return null;
const kindType = checker.getTypeOfSymbol(kind);
return kindType.isStringLiteral() ? kindType.value : null;
}
/** How a value of the non-nullish `parts` is coded. */
function shapeOf(parts, where) {
for (const part of parts)
if ((part.flags & (F.Any | F.Unknown)) !== 0) throw new Error(`untyped value at ${where}`);
const objects = parts.filter((part) => (part.flags & (STRING | NUMBER | BOOLEAN)) === 0);
const strings = parts.filter((part) => (part.flags & STRING) !== 0);
const numbers = parts.filter((part) => (part.flags & NUMBER) !== 0);
const booleans = parts.filter((part) => (part.flags & BOOLEAN) !== 0);
if (objects.length === 0) {
const scalarKinds = [strings, numbers, booleans].filter((list) => list.length > 0).length;
if (scalarKinds === 1)
return strings.length > 0
? { code: "string" }
: numbers.length > 0
? { code: "number" }
: { code: "bool" };
}
if (objects.length === 1 && strings.length + numbers.length + booleans.length === 0)
return checker.isArrayType(objects[0])
? { code: "array", item: objects[0] }
: { code: "object", type: objects[0] };
if (
objects.length > 1 &&
strings.length + numbers.length + booleans.length === 0 &&
objects.every((part) => kindOf(part) !== null && !checker.isArrayType(part))
)
return { code: "kinds", members: objects };
// Arms told apart at run time: typeof for scalars, Array.isArray, a
// `kind` literal, or a property only one object arm declares.
const arms = [];
if (strings.length > 0) arms.push({ test: "string", parts: strings });
if (numbers.length > 0) arms.push({ test: "number", parts: numbers });
if (booleans.length > 0) arms.push({ test: "boolean", parts: booleans });
const arrays = objects.filter((part) => checker.isArrayType(part));
if (arrays.length > 1) throw new Error(`several array types at ${where}`);
if (arrays.length === 1) arms.push({ test: "array", parts: arrays });
const records = objects.filter((part) => !checker.isArrayType(part));
for (const record of records) {
const own = checker.getPropertiesOfType(record).map((p) => p.name);
const others = records.filter((other) => other !== record);
const required = checker
.getPropertiesOfType(record)
.filter((p) => (p.flags & ts.SymbolFlags.Optional) === 0)
.map((p) => p.name);
let key = required.find((name) =>
others.every((other) => checker.getPropertyOfType(other, name) === undefined),
);
// A property every arm has, whose object value has a key only this
// arm's does (IrFfiParam's callback.id and callback.release).
if (key === undefined)
for (const outer of required) {
const inner = (t) => {
const p = checker.getPropertyOfType(t, outer);
return p === undefined ? undefined : split(checker.getTypeOfSymbol(p)).present;
};
const mine = inner(record);
if (mine?.length !== 1) continue;
const found = checker
.getPropertiesOfType(mine[0])
.filter((p) => (p.flags & ts.SymbolFlags.Optional) === 0)
.map((p) => p.name)
.find((name) =>
others.every((other) => {
const theirs = inner(other);
return (
theirs === undefined ||
theirs.every((t) => checker.getPropertyOfType(t, name) === undefined)
);
}),
);
if (found !== undefined) {
key = `${outer}.${found}`;
break;
}
}
if (records.length > 1 && key === undefined)
throw new Error(
`no property tells ${checker.typeToString(record)} apart at ${where}: ${own.join(",")}`,
);
arms.push({ test: records.length === 1 ? "object" : `key:${key}`, parts: [record] });
}
return { code: "arms", arms };
}
// One encoder and one decoder per object type, union and array the module
// reaches, named by a number in the order they are first reached.
const coders = new Map();
const order = [];
function coderFor(parts, typeExpr, where) {
const key = partsKey(parts);
const known = coders.get(key);
if (known !== undefined) return known;
const shape = shapeOf(parts, where);
if (shape.code === "string" || shape.code === "number" || shape.code === "bool") return shape;
const exportedName = exportedBy.get(key);
if (exportedName !== undefined) used.add(exportedName);
const name = exportedName ?? `T${order.length}`;
const coder = { ...shape, name, typeExpr: exportedName ?? typeExpr, where };
coders.set(key, coder);
order.push(coder);
return coder;
}
/** The type expression of `parts`, `typeExpr` naming the field or element
* type they come from. */
function presentExpr(typeExpr) {
return `NonNullable<${typeExpr}>`;
}
/** Lines that write `value` of `type`, named `typeExpr`. A string field
* named `file` is written with IrWriter.file, which notes it as a source
* file the encoding names. */
function encodeValue(value, type, typeExpr, where, optional, field = null) {
const { present, nullable, undefinable } = split(type);
const coder = coderFor(present, presentExpr(typeExpr), where);
const write =
coder.code === "string"
? `w.${field === "file" ? "file" : "str"}(${value});`
: coder.code === "number"
? `w.num(${value});`
: coder.code === "bool"
? `w.bool(${value});`
: `enc${coder.name}(w, ${value});`;
if (!nullable && !undefinable && !optional) return [write];
if (nullable && (undefinable || optional))
return [
`if (${value} === undefined) w.byte(0);`,
`else if (${value} === null) w.byte(1);`,
`else {`,
` w.byte(2);`,
` ${write}`,
`}`,
];
const missing = nullable ? "null" : "undefined";
return [`if (${value} === ${missing}) w.byte(0);`, `else {`, ` w.byte(1);`, ` ${write}`, `}`];
}
/** An expression that reads a value of `type`; null-checks are statements
* through `local`. */
function decodeValue(type, typeExpr, where, optional) {
const { present, nullable, undefinable } = split(type);
const coder = coderFor(present, presentExpr(typeExpr), where);
// A scalar read is cast to its literal type (`17`, `true`, a union of
// string literals), which the bytes cannot spell.
const scalar = (call, plain) =>
present.every((part) => part.flags === plain) ? call : `(${call} as ${presentExpr(typeExpr)})`;
const read =
coder.code === "string"
? scalar("r.str()", F.String)
: coder.code === "number"
? scalar("r.num()", F.Number)
: coder.code === "bool"
? present.length === 2
? "r.bool()"
: `(r.bool() as ${presentExpr(typeExpr)})`
: `dec${coder.name}(r)`;
if (!nullable && !undefinable && !optional) return read;
if (nullable && (undefinable || optional))
return `r.tag(3, "${where}") === 0 ? undefined : r.lastTag === 1 ? null : ${read}`;
return `r.tag(2, "${where}") === 0 ? ${nullable ? "null" : "undefined"} : ${read}`;
}
/** Field reads and writes of one object type. */
function objectLines(type, typeExpr, where, kind) {
const enc = [];
const dec = [];
const fields = [];
let index = 0;
for (const property of checker.getPropertiesOfType(type)) {
if (property.name === "kind" && kind !== null) continue;
const fieldType = checker.getTypeOfSymbol(property);
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0;
const fieldExpr = `${typeExpr}[${JSON.stringify(property.name)}]`;
const at = `${where}.${property.name}`;
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(property.name))
throw new Error(`field name ${property.name} at ${where} is not an identifier`);
const access = `v.${property.name}`;
enc.push(...encodeValue(access, fieldType, fieldExpr, at, optional, property.name));
const local = `f${index++}`;
dec.push(`const ${local} = ${decodeValue(fieldType, fieldExpr, at, optional)};`);
fields.push({ name: property.name, local, optional });
}
// The object is built with its fields in declaration order, an absent
// optional field left out where it would stand: an object whose keys
// follow that order (IrWriter.keys) reads back with the same key order.
const literal = [
...(kind === null ? [] : [`kind: ${JSON.stringify(kind)}`]),
...fields.map((f) =>
f.optional
? `...(${f.local} !== undefined ? { ${f.name}: ${f.local} } : {})`
: `${f.name}: ${f.local}`,
),
];
dec.push(`const o: ${typeExpr} = { ${literal.join(", ")} };`);
dec.push("return o;");
const declared = `KEYS${keyLists.length}`;
keyLists.push(
`const ${declared}: readonly string[] = ${JSON.stringify([...(kind === null ? [] : ["kind"]), ...fields.map((f) => f.name)])};`,
);
enc.unshift(`w.keys(Object.keys(v), ${declared});`);
return { enc, dec };
}
/** The declared field names of each object type, for IrWriter.keys. */
const keyLists = [];
const body = [];
function emit(coder) {
const { name, typeExpr } = coder;
if (coder.code === "object") {
const { enc, dec } = objectLines(coder.type, typeExpr, coder.where, null);
body.push(
`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`,
...enc.map((l) => ` ${l}`),
"}",
`function dec${name}(r: IrReader): ${typeExpr} {`,
...dec.map((l) => ` ${l}`),
"}",
"",
);
} else if (coder.code === "array") {
const item = checker.getTypeArguments(coder.item)[0];
const itemExpr = `${typeExpr}[number]`;
const at = `${coder.where}[]`;
const enc = encodeValue("item", item, itemExpr, at, false);
const dec = decodeValue(item, itemExpr, at, false);
body.push(
`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`,
" w.length(v.length);",
" for (const item of v) {",
...enc.map((l) => ` ${l}`),
" }",
"}",
`function dec${name}(r: IrReader): ${typeExpr} {`,
" const length = r.length();",
` const out: ${typeExpr}[number][] = [];`,
` for (let i = 0; i < length; i++) out.push(${dec});`,
" return out;",
"}",
"",
);
} else if (coder.code === "kinds") {
const members = [...coder.members].sort((a, b) => (kindOf(a) < kindOf(b) ? -1 : 1));
const enc = [`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`, " switch (v.kind) {"];
const dec = [`function dec${name}(r: IrReader): ${typeExpr} {`, ` switch (r.length()) {`];
members.forEach((member, index) => {
const kind = kindOf(member);
const memberExpr = `Extract<${typeExpr}, { kind: ${JSON.stringify(kind)} }>`;
const lines = objectLines(member, memberExpr, `${coder.where}<${kind}>`, kind);
enc.push(
` case ${JSON.stringify(kind)}: {`,
` w.length(${index});`,
...lines.enc.map((l) => ` ${l}`),
" return;",
" }",
);
dec.push(` case ${index}: {`, ...lines.dec.map((l) => ` ${l}`), " }");
});
// A kind the union does not declare writes nothing that decodes.
enc.push(" }", ` w.refuse("${name} kind " + String((v as { kind: unknown }).kind));`, "}");
dec.push(" }", ` throw new Error("IR codec: invalid ${name} kind");`, "}", "");
body.push(...enc, ...dec);
} else if (coder.code === "arms") {
const enc = [`function enc${name}(w: IrWriter, v: ${typeExpr}): void {`];
const dec = [`function dec${name}(r: IrReader): ${typeExpr} {`, " switch (r.length()) {"];
coder.arms.forEach((arm, index) => {
const armExpr =
arm.test === "string" || arm.test === "number" || arm.test === "boolean"
? `Extract<${typeExpr}, ${arm.test}>`
: arm.test === "array"
? `Extract<${typeExpr}, readonly unknown[]>`
: arm.test === "object"
? `Exclude<${typeExpr}, string | number | boolean | readonly unknown[]>`
: arm.test.includes(".")
? `Extract<${typeExpr}, { ${arm.test.slice(4).split(".")[0]}: { ${arm.test.slice(4).split(".")[1]}: unknown } }>`
: `Extract<${typeExpr}, { ${arm.test.slice(4)}: unknown }>`;
const test =
arm.test === "string" || arm.test === "number" || arm.test === "boolean"
? `typeof v === ${JSON.stringify(arm.test)}`
: arm.test === "array"
? "Array.isArray(v)"
: arm.test === "object"
? "true"
: arm.test.includes(".")
? `typeof v === "object" && ${JSON.stringify(arm.test.slice(4).split(".")[0])} in v && ${JSON.stringify(arm.test.slice(4).split(".")[1])} in (v as { ${arm.test.slice(4).split(".")[0]}: object }).${arm.test.slice(4).split(".")[0]}`
: `typeof v === "object" && ${JSON.stringify(arm.test.slice(4))} in v`;
const parts = arm.parts;
const armCoder = coderFor(parts, armExpr, `${coder.where}|${index}`);
const write =
armCoder.code === "string"
? "w.str(v);"
: armCoder.code === "number"
? "w.num(v);"
: armCoder.code === "bool"
? "w.bool(v);"
: `enc${armCoder.name}(w, v as ${armExpr});`;
const read =
armCoder.code === "string"
? "r.str()"
: armCoder.code === "number"
? "r.num()"
: armCoder.code === "bool"
? "r.bool()"
: `dec${armCoder.name}(r)`;
enc.push(` if (${test}) {`, ` w.length(${index});`, ` ${write}`, " return;", " }");
dec.push(` case ${index}:`, ` return ${read} as ${typeExpr};`);
});
enc.push(` throw new Error("IR codec: no arm of ${name} matches");`, "}");
dec.push(" }", ` throw new Error("IR codec: invalid ${name} arm");`, "}", "");
body.push(...enc, ...dec);
}
}
coderFor([moduleType], "IrModule", "IrModule");
coderFor([functionType], "IrFunction", "IrFunction");
for (let index = 0; index < order.length; index++) emit(order[index]);
const lines = [
"// Generated from packages/compiler/src/ir/ir.ts by scripts/generate-ir-codec.mjs.",
"// Run `pnpm generate:ir-codec` after changing the IR's types.",
`import type { ${[...used].sort().join(", ")} } from "./ir.js";`,
'import type { IrReader, IrWriter } from "./codec.js";',
"",
"/** A digest of the coders below: an encoding written with other coders is",
" * refused instead of misread, also after a change that keeps IR_VERSION. */",
`export const IR_CODEC_LAYOUT = ${JSON.stringify(createHash("sha256").update(body.join("\n")).digest("hex").slice(0, 16))};`,
"",
"/** Writes `mod` field by field (codec.ts describes the bytes). */",
"export function encodeIrModule(w: IrWriter, mod: IrModule): void {",
" encIrModule(w, mod);",
"}",
"/** Reads a module encodeIrModule wrote. */",
"export function decodeIrModule(r: IrReader): IrModule {",
" return decIrModule(r);",
"}",
"/** Writes one function. */",
"export function encodeIrFunction(w: IrWriter, fn: IrFunction): void {",
" encIrFunction(w, fn);",
"}",
"/** Reads a function encodeIrFunction wrote. */",
"export function decodeIrFunction(r: IrReader): IrFunction {",
" return decIrFunction(r);",
"}",
"",
...keyLists,
"",
...body,
];
const text = lines.join("\n");
if (process.argv.includes("--check")) {
if (readFileSync(target, "utf8") !== text)
throw new Error("codec.generated.ts is stale; run node scripts/generate-ir-codec.mjs");
} else writeFileSync(target, text);