mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-10 01:56:15 +08:00
Close nsbench's gap to its typed twin: store folds, receiver reads, exhaustive table dispatch (#2255)
This commit is contained in:
@@ -238,6 +238,149 @@ function ownLiteralMember(literal: Node, key: string): string | null {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Whether a top-level statement runs no code but its own. Its expressions
|
||||
* are literals, identifiers, functions (not run), object and array literals
|
||||
* without spreads or computed keys, `=` stores, `var` bindings without
|
||||
* patterns, `&&`, `||`, `??`, `?:`, `,`, `!`, `typeof`, `void`, `===` and
|
||||
* `!==`, none of which calls a function, and member reads and stores that
|
||||
* `plainMember` accepts (no getter or setter runs). Every other form can
|
||||
* run code: a call, `new` or tagged template, a class (static blocks and
|
||||
* field initializers run), coercion (arithmetic, `+`, templates with
|
||||
* substitutions, relational and loose comparisons, `in`, `instanceof`,
|
||||
* computed keys), iteration (spreads, destructuring), `delete`, `await` and
|
||||
* `yield`. */
|
||||
function runsNoCode(stmt: ts.Statement, plainMember: (n: Node) => boolean): boolean {
|
||||
if (ts.isFunctionDeclaration(stmt) || ts.isEmptyStatement(stmt)) return true;
|
||||
if (ts.isExpressionStatement(stmt)) return quietExpression(stmt.expression, plainMember);
|
||||
if (!ts.isVariableStatement(stmt)) return false;
|
||||
return stmt.declarationList.declarations.nodes.every(
|
||||
(d) =>
|
||||
ts.isIdentifier(d.name) &&
|
||||
(d.initializer === undefined || quietExpression(d.initializer, plainMember)),
|
||||
);
|
||||
}
|
||||
|
||||
function quietExpression(e: ts.Expression, plainMember: (n: Node) => boolean): boolean {
|
||||
const quiet = (x: ts.Expression): boolean => quietExpression(x, plainMember);
|
||||
if (ts.isParenthesizedExpression(e)) return quiet(e.expression);
|
||||
if (
|
||||
ts.isIdentifier(e) ||
|
||||
ts.isNumericLiteral(e) ||
|
||||
ts.isBigIntLiteral(e) ||
|
||||
ts.isStringLiteral(e) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(e) ||
|
||||
ts.isRegularExpressionLiteral(e) ||
|
||||
ts.isFunctionExpression(e) ||
|
||||
ts.isArrowFunction(e) ||
|
||||
e.kind === ts.SyntaxKind.TrueKeyword ||
|
||||
e.kind === ts.SyntaxKind.FalseKeyword ||
|
||||
e.kind === ts.SyntaxKind.NullKeyword ||
|
||||
e.kind === ts.SyntaxKind.ThisKeyword
|
||||
)
|
||||
return true;
|
||||
if (ts.isObjectLiteralExpression(e))
|
||||
return e.properties.nodes.every((m) => {
|
||||
if (ts.isShorthandPropertyAssignment(m)) return m.objectAssignmentInitializer === undefined;
|
||||
if (!ts.isPropertyAssignment(m)) return false;
|
||||
const name = m.name;
|
||||
if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name))
|
||||
return false;
|
||||
return quiet(m.initializer);
|
||||
});
|
||||
if (ts.isArrayLiteralExpression(e))
|
||||
return e.elements.nodes.every(
|
||||
(x) => ts.isOmittedExpression(x) || (!ts.isSpreadElement(x) && quiet(x)),
|
||||
);
|
||||
if (ts.isTypeOfExpression(e) || ts.isVoidExpression(e)) return quiet(e.expression);
|
||||
if (ts.isPrefixUnaryExpression(e))
|
||||
return e.operator === ts.SyntaxKind.ExclamationToken && quiet(e.operand);
|
||||
if (ts.isConditionalExpression(e))
|
||||
return quiet(e.condition) && quiet(e.whenTrue) && quiet(e.whenFalse);
|
||||
if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e))
|
||||
return quietMember(e, plainMember);
|
||||
if (!ts.isBinaryExpression(e)) return false;
|
||||
switch (e.operatorToken.kind) {
|
||||
case ts.SyntaxKind.EqualsToken:
|
||||
if (ts.isIdentifier(e.left)) return quiet(e.right);
|
||||
return (
|
||||
(ts.isPropertyAccessExpression(e.left) || ts.isElementAccessExpression(e.left)) &&
|
||||
quietMember(e.left, plainMember) &&
|
||||
quiet(e.right)
|
||||
);
|
||||
case ts.SyntaxKind.AmpersandAmpersandToken:
|
||||
case ts.SyntaxKind.BarBarToken:
|
||||
case ts.SyntaxKind.QuestionQuestionToken:
|
||||
case ts.SyntaxKind.CommaToken:
|
||||
case ts.SyntaxKind.EqualsEqualsEqualsToken:
|
||||
case ts.SyntaxKind.ExclamationEqualsEqualsToken:
|
||||
return quiet(e.left) && quiet(e.right);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A member read or store target with a literal key on a receiver that
|
||||
* runs no code, which `plainMember` accepts. */
|
||||
function quietMember(
|
||||
e: ts.PropertyAccessExpression | ts.ElementAccessExpression,
|
||||
plainMember: (n: Node) => boolean,
|
||||
): boolean {
|
||||
if (e.questionDotToken !== undefined) return false;
|
||||
if (
|
||||
ts.isElementAccessExpression(e) &&
|
||||
!ts.isStringLiteral(e.argumentExpression) &&
|
||||
!ts.isNumericLiteral(e.argumentExpression)
|
||||
)
|
||||
return false;
|
||||
return plainMember(e) && quietExpression(e.expression, plainMember);
|
||||
}
|
||||
|
||||
/** Whether an identifier is written where it appears: an assignment or
|
||||
* update target, a destructuring assignment or loop target (through array
|
||||
* and object patterns, defaults and rests), or a `var`, `let`, `const` or
|
||||
* parameter binding of the name (a nested binding shadows it, which only
|
||||
* makes the answer conservative). */
|
||||
function writesBinding(id: ts.Identifier): boolean {
|
||||
let n: Node = id;
|
||||
for (let p = n.parent; p !== undefined; n = p, p = p.parent) {
|
||||
if (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p))
|
||||
return (
|
||||
p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken
|
||||
);
|
||||
if (ts.isBinaryExpression(p)) {
|
||||
const op = p.operatorToken.kind;
|
||||
if (op >= ts.SyntaxKind.FirstAssignment && op <= ts.SyntaxKind.LastAssignment)
|
||||
return p.left === n;
|
||||
return false;
|
||||
}
|
||||
if (ts.isForOfStatement(p) || ts.isForInStatement(p)) return p.initializer === n;
|
||||
if (ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p))
|
||||
return p.name === n;
|
||||
if (
|
||||
ts.isParenthesizedExpression(p) ||
|
||||
ts.isArrayLiteralExpression(p) ||
|
||||
ts.isObjectLiteralExpression(p) ||
|
||||
ts.isSpreadElement(p) ||
|
||||
ts.isSpreadAssignment(p) ||
|
||||
ts.isShorthandPropertyAssignment(p) ||
|
||||
ts.isArrayBindingPattern(p) ||
|
||||
ts.isObjectBindingPattern(p)
|
||||
)
|
||||
continue;
|
||||
if (ts.isPropertyAssignment(p)) {
|
||||
if (p.initializer !== n) return false;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function enclosingFunctionLike(n: Node): Node | null {
|
||||
for (let p = n.parent; p !== undefined; p = p.parent) if (ts.isFunctionLike(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function primitiveLiteralText(e: ts.Expression): string | null {
|
||||
if (
|
||||
ts.isNumericLiteral(e) ||
|
||||
@@ -1273,10 +1416,6 @@ export function planStaticObjectRewrites(
|
||||
if (parent === undefined || ts.isDeleteExpression(parent)) continue;
|
||||
if (!ts.isPropertyAccessExpression(node)) continue;
|
||||
if (topStatement(node) === null) continue;
|
||||
// The receiver's evaluation is dropped, and the binding exists from the
|
||||
// store on (a function) or from its declaration on (a class).
|
||||
if (!pureReceiver(node.expression, node)) continue;
|
||||
if (!runsAfter(node, storeOf.get(field.values[0]!)!)) continue;
|
||||
// The reference replaces the whole member expression: its receiver
|
||||
// must be one that evaluating runs no code.
|
||||
if (!effectFree(node.expression)) {
|
||||
@@ -1287,6 +1426,10 @@ export function planStaticObjectRewrites(
|
||||
kept.push(`reference ${node.getText()}: it may run before the member is stored`);
|
||||
continue;
|
||||
}
|
||||
// The receiver's evaluation is dropped, and the binding exists from the
|
||||
// store on (a function) or from its declaration on (a class).
|
||||
if (!pureReceiver(node.expression, node)) continue;
|
||||
if (!runsAfter(node, storeOf.get(field.values[0]!)!)) continue;
|
||||
if (node.getSourceFile() !== field.values[0]!.node!.getSourceFile()) {
|
||||
const text = crossFileReference(node, field.values[0]!.node!.getSourceFile(), name);
|
||||
if (text === null) continue;
|
||||
@@ -1319,16 +1462,84 @@ export function planStaticObjectRewrites(
|
||||
// the object does and nothing writes it, so every read sees it. (A
|
||||
// member stored after the object was created could be read before the
|
||||
// store; those stay reads.)
|
||||
//
|
||||
// A member stored once by an unconditional top-level statement of the
|
||||
// object's own file (`app.math.EPSILON = 1e-9;`) folds too, at reads that
|
||||
// provably run after the store: reads inside functions, when no code can
|
||||
// run before the store (the statements before it call nothing, so no
|
||||
// function runs and no other module starts), and top-level reads after
|
||||
// it in the same file.
|
||||
const writesOf = new Map<Atom, Map<string, Node[]>>();
|
||||
for (const op of analysis.ops) {
|
||||
if (op.kind !== "write" || op.name === null) continue;
|
||||
for (const a of analysis.nodes[op.recv]!.atoms) {
|
||||
let byKey = writesOf.get(a);
|
||||
if (byKey === undefined) writesOf.set(a, (byKey = new Map()));
|
||||
let l = byKey.get(op.name);
|
||||
if (l === undefined) byKey.set(op.name, (l = []));
|
||||
if (!l.includes(op.node)) l.push(op.node);
|
||||
}
|
||||
}
|
||||
// Member reads and stores whose receiver can only be constant objects or
|
||||
// primitives: neither has accessors of its own, so no getter or setter
|
||||
// runs (a primitive receiver of a store throws, or reads its prototype).
|
||||
const memberReceivers = new Map<Node, Set<Atom>>();
|
||||
for (const op of analysis.ops) {
|
||||
if ((op.kind !== "read" && op.kind !== "write") || op.name === null) continue;
|
||||
let set = memberReceivers.get(op.node);
|
||||
if (set === undefined) memberReceivers.set(op.node, (set = new Set()));
|
||||
for (const a of analysis.nodes[op.recv]!.atoms) set.add(a);
|
||||
}
|
||||
const plainMember = (n: Node): boolean => {
|
||||
const atoms = memberReceivers.get(n);
|
||||
return (
|
||||
atoms !== undefined &&
|
||||
atoms.size > 0 &&
|
||||
[...atoms].every((a) => a.kind === "prim" || constantAtoms.has(a))
|
||||
);
|
||||
};
|
||||
const quietPrefix = new Map<ts.Statement, boolean>();
|
||||
const storedLiteral = (
|
||||
atom: Atom,
|
||||
site: Node,
|
||||
key: string,
|
||||
): { literal: string; stmt: ts.ExpressionStatement; statements: readonly Node[] } | null => {
|
||||
const writes = writesOf.get(atom)?.get(key) ?? [];
|
||||
if (writes.length !== 1) return null;
|
||||
const target = writes[0]!;
|
||||
const assign = target.parent;
|
||||
if (
|
||||
assign === undefined ||
|
||||
!ts.isBinaryExpression(assign) ||
|
||||
assign.left !== target ||
|
||||
assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken
|
||||
)
|
||||
return null;
|
||||
const stmt = assign.parent;
|
||||
if (stmt === undefined || !ts.isExpressionStatement(stmt) || !ts.isSourceFile(stmt.parent))
|
||||
return null;
|
||||
if (stmt.getSourceFile() !== site.getSourceFile()) return null;
|
||||
const literal = primitiveLiteralText(assign.right);
|
||||
if (literal === null) return null;
|
||||
const statements = stmt.parent.statements.nodes;
|
||||
let quiet = quietPrefix.get(stmt);
|
||||
if (quiet === undefined) {
|
||||
quiet = statements
|
||||
.slice(0, statements.indexOf(stmt))
|
||||
.every((s) => runsNoCode(s, plainMember));
|
||||
quietPrefix.set(stmt, quiet);
|
||||
}
|
||||
return quiet ? { literal, stmt, statements } : null;
|
||||
};
|
||||
let foldedReads = 0;
|
||||
for (const { op, atoms, nullish } of mergedOps) {
|
||||
if (op.kind !== "read" || op.name === null) continue;
|
||||
if (atoms.size !== 1) continue;
|
||||
const o = constantAtoms.get([...atoms][0]!);
|
||||
const atom = [...atoms][0]!;
|
||||
const o = constantAtoms.get(atom);
|
||||
if (o === undefined || o.site.kind !== "literal") continue;
|
||||
const field = o.fields.find((f) => f.key === op.name);
|
||||
if (field === undefined || field.stores !== 1) continue;
|
||||
const literal = ownLiteralMember(o.site.node, op.name);
|
||||
if (literal === null) continue;
|
||||
const node = op.node;
|
||||
if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) continue;
|
||||
if (!isPlainRead(node) || topStatement(node) === null) continue;
|
||||
@@ -1340,6 +1551,19 @@ export function planStaticObjectRewrites(
|
||||
kept.push(`constant read ${node.getText()}: it may run before the object is created`);
|
||||
continue;
|
||||
}
|
||||
let literal = ownLiteralMember(o.site.node, op.name);
|
||||
if (literal === null && process.env["SCRIPTC_SHAPE_FOLD_STORES"] !== "0") {
|
||||
const stored = storedLiteral(atom, o.site.node, op.name);
|
||||
if (stored === null) continue;
|
||||
const inFunction = enclosingFunctionLike(node) !== null;
|
||||
const top = topStatement(node)!;
|
||||
const after =
|
||||
top.getSourceFile() === stored.stmt.getSourceFile() &&
|
||||
stored.statements.indexOf(top) > stored.statements.indexOf(stored.stmt);
|
||||
if (!inFunction && !after) continue;
|
||||
literal = stored.literal;
|
||||
}
|
||||
if (literal === null) continue;
|
||||
// An element key needs no parentheses (and a parenthesized literal key
|
||||
// would read as a computed one).
|
||||
const key =
|
||||
@@ -1387,7 +1611,51 @@ export function planStaticObjectRewrites(
|
||||
call: ts.CallExpression;
|
||||
arms: [string, string][];
|
||||
nullish: boolean;
|
||||
exhaustive: boolean;
|
||||
}[] = [];
|
||||
// Whether a key parameter only ever holds one of the table's keys: its
|
||||
// function never escapes, every call of it passes a string literal key
|
||||
// of the table, and its body neither assigns the parameter nor uses
|
||||
// `arguments` (which aliases parameters in sloppy code). The dispatch
|
||||
// then needs no arm for other keys.
|
||||
const literalKeysOnly = (keyDecl: Node, keys: ReadonlySet<string>): boolean => {
|
||||
if (process.env["SCRIPTC_SHAPE_EXHAUSTIVE_TABLES"] === "0") return false;
|
||||
if (!ts.isParameter(keyDecl) || !ts.isIdentifier(keyDecl.name)) return false;
|
||||
const fn = keyDecl.parent;
|
||||
if (fn === undefined || !ts.isFunctionLike(fn) || escapedFns.has(fn)) return false;
|
||||
for (const a of analysis.atoms.atoms)
|
||||
if (a.kind === "function" && a.node === fn && analysis.escaped.has(a)) return false;
|
||||
const index = (fn as ts.FunctionLikeDeclaration).parameters.nodes.indexOf(keyDecl);
|
||||
const name = keyDecl.name.text;
|
||||
// Any write of the name in the function, its other parameters included,
|
||||
// or a direct eval or `arguments` (which aliases parameters in sloppy
|
||||
// code) can give the key another value.
|
||||
let touched = false;
|
||||
const visit = (n: Node): void => {
|
||||
if (touched) return;
|
||||
if (ts.isIdentifier(n) && (n.text === "arguments" || n.text === "eval")) touched = true;
|
||||
else if (ts.isIdentifier(n) && n.text === name && n !== keyDecl.name && writesBinding(n))
|
||||
touched = true;
|
||||
ts.forEachChild(n, (c) => {
|
||||
visit(c);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
for (const p of (fn as ts.FunctionLikeDeclaration).parameters.nodes) visit(p);
|
||||
const body = (fn as { body?: Node }).body;
|
||||
if (body === undefined) return false;
|
||||
visit(body);
|
||||
if (touched) return false;
|
||||
const edges = analysis.calls.filter((c) => c.callee.node === fn);
|
||||
if (edges.length === 0) return false;
|
||||
return edges.every((e) => {
|
||||
if (!ts.isCallExpression(e.site)) return false;
|
||||
const args = e.site.arguments.nodes;
|
||||
if (args.slice(0, index + 1).some((a) => ts.isSpreadElement(a))) return false;
|
||||
const arg = args[index];
|
||||
return arg !== undefined && ts.isStringLiteral(arg) && keys.has(arg.text);
|
||||
});
|
||||
};
|
||||
for (const { op, atoms, nullish } of mergedOps) {
|
||||
if (op.kind !== "computedRead") continue;
|
||||
const node = op.node;
|
||||
@@ -1438,16 +1706,35 @@ export function planStaticObjectRewrites(
|
||||
kept.push(`${where}: it may run before the table is created`);
|
||||
continue;
|
||||
}
|
||||
tableCalls.push({ sf: call.getSourceFile(), call, arms, nullish });
|
||||
tableCalls.push({
|
||||
sf: call.getSourceFile(),
|
||||
call,
|
||||
arms,
|
||||
nullish,
|
||||
exhaustive: literalKeysOnly(keyDecl, new Set(arms.map(([k]) => k))),
|
||||
});
|
||||
}
|
||||
for (const { sf, call, arms, nullish } of tableCalls) {
|
||||
for (const { sf, call, arms, nullish, exhaustive } of tableCalls) {
|
||||
const node = call.expression as ts.ElementAccessExpression;
|
||||
const key = node.argumentExpression.getText();
|
||||
const args = call.arguments.nodes.map((a) => sliceWithCalls(sf, a)).join(", ");
|
||||
const table = sliceWithCalls(sf, node.expression);
|
||||
const text = arms
|
||||
const tested = exhaustive ? arms.slice(0, -1) : arms;
|
||||
const text = tested
|
||||
.map(([k, name]) => `${key} === ${JSON.stringify(k)} ? ${name}(${args}) : `)
|
||||
.join("");
|
||||
if (exhaustive) {
|
||||
callEdits.push({
|
||||
sf,
|
||||
edit: {
|
||||
start: ts.tokenStartOfNode(call),
|
||||
end: call.end,
|
||||
text: `(${nullish ? `${table}[${key}], ` : ""}${text}${arms[arms.length - 1]![1]}(${args}))`,
|
||||
},
|
||||
});
|
||||
tableDispatches++;
|
||||
continue;
|
||||
}
|
||||
callEdits.push({
|
||||
sf,
|
||||
edit: {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/* Static-object specialization (shape-inference/static-objects.ts) keeps two
|
||||
* reads dynamic when the facts it would fold on can change:
|
||||
* - a member stored after a statement that can run code (a coercion such as
|
||||
* `+` can call toString or valueOf) keeps its read in functions, which
|
||||
* that code could call before the store (the statements checked are all
|
||||
* the file's top-level statements before the store);
|
||||
* - a table call whose key parameter the function rebinds (destructuring,
|
||||
* a loop target, another parameter's default or a `var` redeclaration)
|
||||
* keeps reading the table for other keys, though every caller passes one
|
||||
* of the table's keys.
|
||||
* The checks read the rewritten package text SCRIPTC_DEBUG_SHAPE_OVERLAY
|
||||
* prints. */
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { compile } from "../src/index.js";
|
||||
|
||||
const packageSource = `var quiet = quiet || {};
|
||||
quiet.cfg = { U: 1 };
|
||||
quiet.read = function () {
|
||||
return quiet.cfg.V;
|
||||
};
|
||||
quiet.label = quiet.cfg.U;
|
||||
quiet.cfg.V = 6;
|
||||
|
||||
var late = late || {};
|
||||
late.cfg = { U: 1 };
|
||||
late.read = function () {
|
||||
return late.cfg.V;
|
||||
};
|
||||
late.label = "v" + late.cfg.U;
|
||||
late.cfg.V = 5;
|
||||
|
||||
var t = t || {};
|
||||
t.ops = {
|
||||
a: function (v) {
|
||||
return v + 1;
|
||||
},
|
||||
b: function (v) {
|
||||
return v * 2;
|
||||
},
|
||||
};
|
||||
t.plain = function (op, v) {
|
||||
return t.ops[op](v);
|
||||
};
|
||||
t.pattern = function (op, v) {
|
||||
[op] = ["c"];
|
||||
return t.ops[op](v);
|
||||
};
|
||||
t.loop = function (op, v) {
|
||||
for (op of ["c"]);
|
||||
return t.ops[op](v);
|
||||
};
|
||||
t.initializer = function (op, v, w = (op = "c")) {
|
||||
return t.ops[op](v);
|
||||
};
|
||||
t.redeclared = function (op, v) {
|
||||
var op = "c";
|
||||
return t.ops[op](v);
|
||||
};
|
||||
module.exports = { late, quiet, t };
|
||||
`;
|
||||
|
||||
const consumer = `const { late, quiet, t } = require("guard-fixture");
|
||||
console.log(late.read(), late.label, quiet.read(), quiet.label);
|
||||
const tryCall = (call) => {
|
||||
try {
|
||||
return call();
|
||||
} catch (e) {
|
||||
return e.name;
|
||||
}
|
||||
};
|
||||
console.log(t.plain("a", 1), t.plain("b", 2));
|
||||
console.log(tryCall(() => t.pattern("a", 1)), tryCall(() => t.loop("b", 2)));
|
||||
console.log(tryCall(() => t.initializer("a", 1)), tryCall(() => t.redeclared("b", 2)));
|
||||
`;
|
||||
|
||||
let dir = "";
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "scriptc-static-guards-"));
|
||||
const pkg = join(dir, "node_modules/guard-fixture");
|
||||
await mkdir(pkg, { recursive: true });
|
||||
await writeFile(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({ name: "guard-fixture", version: "1.0.0", main: "index.js" }),
|
||||
);
|
||||
await writeFile(join(pkg, "index.js"), packageSource);
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ dependencies: { "guard-fixture": "1.0.0" } }),
|
||||
);
|
||||
await writeFile(join(dir, "main.cjs"), consumer);
|
||||
for (const k of ["SCRIPTC_SHAPE_OVERLAY", "SCRIPTC_DEBUG_SHAPE_OVERLAY", "SCRIPTC_NO_CACHE"])
|
||||
saved[k] = process.env[k];
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** The package text after the structural rewrites. */
|
||||
async function rewrittenPackage(): Promise<string> {
|
||||
process.env["SCRIPTC_SHAPE_OVERLAY"] = "1";
|
||||
process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] = "1";
|
||||
process.env["SCRIPTC_NO_CACHE"] = "1";
|
||||
let text = "";
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
|
||||
text += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
||||
return true;
|
||||
});
|
||||
const result = await compile(join(dir, "main.cjs"), {
|
||||
outDir: dir,
|
||||
outPath: join(dir, "main.ir.json"),
|
||||
outputKind: "ir",
|
||||
npmStatic: ["guard-fixture"],
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
|
||||
const start = text.indexOf("[static-objects] ");
|
||||
const end = text.indexOf("[shape-overlay] ", start);
|
||||
return start < 0 ? "" : text.slice(start, end < 0 ? undefined : end);
|
||||
}
|
||||
|
||||
/** The body of the function the rewritten text assigns to or hoists as `name`. */
|
||||
function body(text: string, name: string): string {
|
||||
const at = text.search(new RegExp(`function [\\w$]*${name}\\b|${name} = function`));
|
||||
return at < 0 ? "" : text.slice(at, text.indexOf("\n}", at));
|
||||
}
|
||||
|
||||
test("a store after code that can run keeps the read; a quiet prefix folds it", async () => {
|
||||
const text = await rewrittenPackage();
|
||||
expect(body(text, "quiet_read")).toMatch(/return \(.*\b6\);/);
|
||||
expect(body(text, "late_read")).toContain("return late.cfg.V");
|
||||
});
|
||||
|
||||
test("a table call whose key parameter is rebound keeps reading the table", async () => {
|
||||
const text = await rewrittenPackage();
|
||||
// Every caller passes "a" or "b": the plain call needs no arm for other keys.
|
||||
expect(body(text, "t_plain")).not.toContain("t.ops[op](v)");
|
||||
for (const name of ["t_pattern", "t_loop", "t_initializer", "t_redeclared"])
|
||||
expect(body(text, name), name).toContain("t.ops[op](v)");
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/npm/cases/static-stores/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// The JavaScript consumer of static-stores (main.ts is the TypeScript one).
|
||||
const { lib, other } = require("static-stores");
|
||||
|
||||
let near = 0;
|
||||
for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++;
|
||||
console.log(near, lib.name(), lib.early, lib.after);
|
||||
console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" }));
|
||||
const key = process.argv.length > 99 ? "dbl" : "inc";
|
||||
console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3));
|
||||
console.log(other.seen, other.check());
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Primitive members stored after their namespace was created, and functions
|
||||
// with and without a receiver (js-main.cjs is the JavaScript consumer).
|
||||
import { lib, other } from "static-stores";
|
||||
|
||||
let near = 0;
|
||||
for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++;
|
||||
console.log(near, lib.name(), lib.early, lib.after);
|
||||
console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" }));
|
||||
const key = process.argv.length > 99 ? "dbl" : "inc";
|
||||
console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3));
|
||||
console.log(other.seen, other.check());
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export declare const lib: {
|
||||
early: number | undefined;
|
||||
after: number;
|
||||
near(x: number): boolean;
|
||||
name(): string;
|
||||
counter: { n: number; inc(): number };
|
||||
twice(a: number): number;
|
||||
tagOf(this: { tag: string }): string;
|
||||
apply(op: "inc" | "dbl", v: number): number;
|
||||
applyAny(op: string, v: number): number | string;
|
||||
};
|
||||
export declare const other: { seen: number; check(): boolean };
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// Primitive members stored after their namespace object was created. Reads
|
||||
// that provably run after the store read the stored literal; a read that can
|
||||
// run before it (top-level code before the store, or any read once code has
|
||||
// run before the store) stays a read.
|
||||
var lib = lib || {};
|
||||
lib.cfg = lib.cfg || {};
|
||||
lib.cfg.EPS = 0.5;
|
||||
lib.cfg.NAME = "v1";
|
||||
lib.early = lib.cfg.LATE;
|
||||
lib.cfg.LATE = 7;
|
||||
lib.after = lib.cfg.LATE + 1;
|
||||
lib.near = function (x) {
|
||||
return Math.abs(x) <= lib.cfg.EPS;
|
||||
};
|
||||
lib.name = function () {
|
||||
return lib.cfg.NAME + ":" + lib.cfg.LATE;
|
||||
};
|
||||
// Functions that read their own receiver, through an arrow, and not at all.
|
||||
lib.counter = {
|
||||
n: 0,
|
||||
inc: function () {
|
||||
this.n++;
|
||||
return this.n;
|
||||
},
|
||||
};
|
||||
lib.twice = function (a) {
|
||||
return a * 2;
|
||||
};
|
||||
lib.tagOf = function () {
|
||||
const read = () => this.tag;
|
||||
return read();
|
||||
};
|
||||
// A constant function table: `apply` is only ever called with literal keys
|
||||
// of the table, so its dispatch needs no arm for other keys; `applyAny` is
|
||||
// called with a computed key and keeps reading the table for other keys.
|
||||
lib.ops = {
|
||||
inc: function (v) {
|
||||
return v + 1;
|
||||
},
|
||||
dbl: function (v) {
|
||||
return v * 2;
|
||||
},
|
||||
};
|
||||
lib.apply = function (op, v) {
|
||||
return lib.ops[op](v);
|
||||
};
|
||||
lib.applyAny = function (op, v) {
|
||||
return lib.ops[op](v);
|
||||
};
|
||||
|
||||
// A store after a call: code ran before it, so no read folds.
|
||||
var other = {};
|
||||
other.log = [];
|
||||
other.note = function (s) {
|
||||
other.log.push(s);
|
||||
return other.log.length;
|
||||
};
|
||||
other.seen = other.note("start");
|
||||
other.flag = true;
|
||||
other.check = function () {
|
||||
return other.flag;
|
||||
};
|
||||
|
||||
module.exports = { lib, other };
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "static-stores", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "static-stores-case", "private": true, "type": "module" }
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Static-object specialization (shape-inference/static-objects.ts): with the
|
||||
* shape overlay on, the npm case static-stores (through its JavaScript
|
||||
* consumer, js-main.cjs) reads primitive members stored after their
|
||||
* namespace object was created as literals where the read provably runs
|
||||
* after the store, keeps the reads that can run before it, and prints what
|
||||
* Node prints. Its functions with and without a receiver read `this` as
|
||||
* Node does. The npm suites run the TypeScript consumer with and without
|
||||
* the overlay. */
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile } from "@scriptc/compiler";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = join(import.meta.dirname, "../..");
|
||||
const entry = join(repoRoot, "tests/fixtures/npm/cases/static-stores/js-main.cjs");
|
||||
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/static-stores");
|
||||
const sanitize = process.env["SCRIPTC_SAN"] === "1";
|
||||
|
||||
test(
|
||||
"members stored after their namespace was created read as literals after the store",
|
||||
{ timeout: 600_000 },
|
||||
async () => {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const result = await compile(entry, {
|
||||
outPath: join(outDir, "main"),
|
||||
outDir,
|
||||
sanitize,
|
||||
shapeOverlay: true,
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const report = (result.warnings ?? []).find((w) => w.code === "SC6103");
|
||||
expect(report?.message).toMatch(/2 function table calls, 4 constant reads\)/);
|
||||
const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" });
|
||||
pending.child.stdin?.end();
|
||||
const { stdout } = await pending;
|
||||
const node = await execFileAsync(process.execPath, [entry], {
|
||||
cwd: dirname(entry),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(stdout).toBe(node.stdout);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user