Report inferred shapes for untyped npm JavaScript (#2036)

This commit is contained in:
Chris Tate
2026-10-24 13:49:30 -05:00
committed by GitHub
6 changed files with 3964 additions and 0 deletions
@@ -140,6 +140,7 @@ import {
} from "./type-only-imports.js";
import { moduleSourceCandidates } from "./module-source-candidates.js";
import { planDeclProjection, type DeclProjection } from "./decl-projection.js";
import { reportShapes } from "./shape-inference/index.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
import {
containsJsx,
@@ -1260,6 +1261,10 @@ export function loadProgram(
npmStaticPackages.length > 0,
projector,
);
// Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES).
const shapes = process.env["SCRIPTC_DEBUG_SHAPES"];
if (shapes !== undefined && shapes !== "" && shapes !== "0" && npmStaticPackages.length > 0)
reportShapes(load.program, shapes);
return {
...load,
services,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
/* Shape inference (results/design-shape-inference/DESIGN.md), stage S1:
* the abstract domain of the allocation-site points-to analysis.
*
* An abstract value is a set of atoms. An atom names a set of runtime
* values: a primitive kind, an allocation site (object or array literal, or
* a value a builtin creates at one call site), the instances of one class,
* one class value, one function (closure site), a builtin object, or ⊤ —
* every value the analysis does not track. Value nodes hold atom sets and
* flow into each other along inclusion edges; the solver (analyze.ts)
* attaches load, store and call constraints that fire as atoms arrive. */
import type * as ts from "../ts7/adapter.js";
export type AtomKind =
| "prim"
| "top"
| "object"
| "array"
| "instance"
| "class"
| "function"
| "builtin"
| "promise"
| "collection"
| "dictionary";
export interface Atom {
readonly id: number;
readonly kind: AtomKind;
/** Short display name, e.g. `string`, `ZodString`, `{status,value}@types.js:804`. */
readonly label: string;
/** The syntax that creates the atom: the literal, class or function
* declaration, or the builtin call. Undefined for prims, ⊤ and builtins. */
readonly node?: ts.Node | undefined;
/** builtin atoms: the dotted path (`Object.keys`, `array.push`). */
readonly path?: string | undefined;
/** method atoms of builtin receivers: the receiver atom. */
readonly receiver?: Atom | undefined;
/** function atoms: the analysis context of the function's clone (a
* method's receiver class), "" for the context-insensitive default. */
readonly ctx?: string | undefined;
}
export class AtomTable {
readonly atoms: Atom[] = [];
private readonly byKey = new Map<string, Atom>();
intern(key: string, make: (id: number) => Omit<Atom, "id">): Atom {
let atom = this.byKey.get(key);
if (atom === undefined) {
const id = this.atoms.length;
atom = { id, ...make(id) };
this.atoms.push(atom);
this.byKey.set(key, atom);
}
return atom;
}
}
/** Object.prototype members: a read of one of these names on a plain
* object resolves on the prototype, never to an absent own key. */
export const OBJECT_PROTOTYPE_MEMBERS: ReadonlySet<string> = new Set([
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toLocaleString",
"toString",
"valueOf",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"__proto__",
]);
/** Global names the analysis models as builtin objects. Any other global is ⊤. */
export const MODELED_GLOBALS: ReadonlySet<string> = new Set([
"Object",
"Array",
"JSON",
"Math",
"Number",
"String",
"Boolean",
"Symbol",
"Promise",
"Map",
"Set",
"WeakMap",
"WeakSet",
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"Date",
"RegExp",
"console",
"Reflect",
"parseInt",
"parseFloat",
"isNaN",
"isFinite",
"encodeURIComponent",
"decodeURIComponent",
"encodeURI",
"decodeURI",
"BigInt",
"Proxy",
]);
/** Results of string (and number) methods, by name. Anything else is ⊤. */
type MethodResult = "string" | "number" | "boolean" | "array";
export const STRING_METHOD_RESULTS: ReadonlyMap<string, MethodResult> = new Map<
string,
MethodResult
>([
["at", "string"],
["charAt", "string"],
["charCodeAt", "number"],
["codePointAt", "number"],
["concat", "string"],
["endsWith", "boolean"],
["includes", "boolean"],
["indexOf", "number"],
["lastIndexOf", "number"],
["localeCompare", "number"],
["match", "array"],
["matchAll", "array"],
["normalize", "string"],
["padEnd", "string"],
["padStart", "string"],
["repeat", "string"],
["replace", "string"],
["replaceAll", "string"],
["search", "number"],
["slice", "string"],
["split", "array"],
["startsWith", "boolean"],
["substring", "string"],
["substr", "string"],
["toLowerCase", "string"],
["toUpperCase", "string"],
["toLocaleLowerCase", "string"],
["toLocaleUpperCase", "string"],
["toString", "string"],
["trim", "string"],
["trimEnd", "string"],
["trimStart", "string"],
["valueOf", "string"],
["test", "boolean"],
["toFixed", "string"],
["toPrecision", "string"],
["toExponential", "string"],
]);
@@ -0,0 +1,21 @@
/* Shape inference for untyped npm JavaScript
* (results/design-shape-inference/DESIGN.md). Stage S1: report only. */
import type * as ts from "../ts7/adapter.js";
import { analyzeShapes } from "./analyze.js";
import { buildShapeReport } from "./report.js";
export { analyzeShapes } from "./analyze.js";
export { buildShapeReport } from "./report.js";
/** SCRIPTC_DEBUG_SHAPES: `1` reports package code in hot functions,
* `verbose` every package site, `all` user files too, any other value only
* files whose path contains it. Writes to stderr; changes nothing else. */
export function reportShapes(program: ts.Program, filter: string): void {
const start = performance.now();
const analysis = analyzeShapes(program, program.getTypeChecker());
const report = buildShapeReport(analysis, filter);
const ms = (performance.now() - start).toFixed(0);
process.stderr.write(
`[shapes] ${ms} ms\n${report.lines.map((l) => `[shapes] ${l}`).join("\n")}\n`,
);
}
@@ -0,0 +1,374 @@
/* Shape inference stage S1: the plan the analysis would hand the lowering,
* as a report (DESIGN.md §4.5, §5.5). Nothing here changes compilation.
*
* Per allocation site of package code, the verdict:
* P local — a native record would be exact: no reflection, no added
* keys, no computed writes, and its objects never escape;
* P escaping — exact as a record behind identity-keeping capsules: it
* escapes, but the census leaves every later operation
* answerable (fields untyped code may write become dyn);
* G <reason> — keeps today's dynamic object, with the rule that decided.
* Plus class field types, the census, and the hot functions' value types. */
import * as ts from "../ts7/adapter.js";
import { OBJECT_PROTOTYPE_MEMBERS, type Atom } from "./domain.js";
import { locLabel, type FunctionInfo, type LiteralSite, type ShapeAnalysis } from "./analyze.js";
export interface SiteVerdict {
readonly site: LiteralSite;
readonly label: string;
readonly verdict: "P-local" | "P-escaping" | "G";
readonly reasons: string[];
/** P-escaping: fields untyped code may write (they become dyn slots). */
readonly writableByTop: string[];
readonly freezable: boolean;
readonly brand: boolean;
}
export interface ShapeReport {
readonly sites: SiteVerdict[];
readonly hot: Set<FunctionInfo>;
readonly lines: string[];
}
const MAX_ATOMS_SHOWN = 6;
function fieldKeys(fields: ReadonlyMap<string, number> | undefined): string[] {
return fields === undefined ? [] : [...fields.keys()];
}
function fieldEntries(fields: ReadonlyMap<string, number> | undefined): [string, number][] {
return fields === undefined ? [] : [...fields.entries()];
}
export function describeValue(analysis: ShapeAnalysis, node: number): string {
const atoms = [...analysis.nodes[node]!.atoms];
if (atoms.length === 0) return "∅";
const labels = [...new Set(atoms.map((a) => a.label))];
labels.sort((a, b) => (a === "⊤" ? 1 : b === "⊤" ? -1 : a.localeCompare(b)));
const shown = labels.slice(0, MAX_ATOMS_SHOWN).join(" | ");
if (labels.length <= MAX_ATOMS_SHOWN) return shown;
return `${shown} | +${labels.length - MAX_ATOMS_SHOWN}${labels.includes("⊤") ? " | ⊤" : ""}`;
}
function atomsOf(analysis: ShapeAnalysis, node: number): Set<Atom> {
return analysis.nodes[node]!.atoms;
}
/** Functions reached from call sites in loops (or loop callbacks), and
* everything they call: the static hotness heuristic (DESIGN.md §8.1). */
export function hotFunctions(analysis: ShapeAnalysis): Set<FunctionInfo> {
const hot = new Set<FunctionInfo>();
const callees = new Map<FunctionInfo | null, FunctionInfo[]>();
const work: FunctionInfo[] = [];
for (const c of analysis.calls) {
let l = callees.get(c.caller);
if (l === undefined) callees.set(c.caller, (l = []));
l.push(c.callee);
if (c.inLoop && !hot.has(c.callee)) {
hot.add(c.callee);
work.push(c.callee);
}
}
while (work.length > 0) {
const f = work.pop()!;
for (const g of callees.get(f) ?? []) {
if (!hot.has(g)) {
hot.add(g);
work.push(g);
}
}
}
return hot;
}
export function buildShapeReport(analysis: ShapeAnalysis, filter: string): ShapeReport {
const lines: string[] = [];
const all = filter === "all" || filter === "verbose";
const verbose = filter === "verbose";
const fileFilter = filter !== "1" && !all ? filter : null;
const wanted = (n: ts.Node, inPackage: boolean): boolean => {
if (fileFilter !== null) return n.getSourceFile().fileName.includes(fileFilter);
return all || inPackage;
};
// Census facts: operations whose receiver may be ⊤ reach every escaped atom.
// Index ops by the site atoms they can reach directly.
const byAtom = new Map<Atom, typeof analysis.ops>();
const topOps: typeof analysis.ops = [];
for (const o of analysis.ops) {
const atoms = atomsOf(analysis, o.recv);
for (const a of atoms) {
if (a.kind !== "object" && a.kind !== "array") continue;
let l = byAtom.get(a);
if (l === undefined) byAtom.set(a, (l = []));
l.push(o);
}
if (atoms.has(analysis.top)) topOps.push(o);
}
// Key-order groups: sites with the same key set and different orders need brands.
const orders = new Map<string, Set<string>>();
for (const s of analysis.sites) {
if (s.kind !== "object" || s.keys === null) continue;
const set = [...s.keys].sort().join(",");
let o = orders.get(set);
if (o === undefined) orders.set(set, (o = new Set()));
o.add(s.keys.join(","));
}
const verdicts: SiteVerdict[] = [];
for (const s of analysis.sites) {
if (!s.inPackage && !all && fileFilter === null) continue;
const reasons: string[] = [];
const escapedWhy = analysis.escaped.get(s.atom);
const escapes = escapedWhy !== undefined;
if (s.hasAccessor) reasons.push("accessor in literal");
if (s.hasProto) reasons.push(s.kind === "array" ? "hole in literal" : "__proto__ in literal");
if (s.hasComputedKey) reasons.push("computed key in literal");
for (const sp of s.spreads)
if (atomsOf(analysis, sp).has(analysis.top)) reasons.push("spread of ⊤");
const direct = byAtom.get(s.atom) ?? [];
const ops = escapes ? [...direct, ...topOps] : direct;
const keys = new Set(s.keys ?? []);
const fieldsOf = analysis.fields.get(s.atom);
const knownKeys =
s.keys !== null ? keys : new Set([...fieldKeys(fieldsOf)].filter((k) => k !== "*"));
let freezable = false;
const seen = new Set<string>();
const note = (r: string): void => {
if (!seen.has(r)) {
seen.add(r);
reasons.push(r);
}
};
for (const o of ops) {
const viaTop = !atomsOf(analysis, o.recv).has(s.atom);
const where = `${locLabel(o.node)}${viaTop ? " via ⊤" : ""}`;
switch (o.kind) {
case "delete":
case "defineProperty":
case "setPrototypeOf":
case "seal":
case "proxy":
note(`${o.kind} at ${where}`);
break;
case "assign":
note(`Object.assign target at ${where}`);
break;
case "freeze":
freezable = true;
break;
case "computedWrite":
if (!viaTop)
note(s.kind === "array" ? `index write at ${where}` : `computed write at ${where}`);
break;
case "lengthWrite":
if (s.kind === "array") note(`length write at ${where}`);
break;
case "write":
if (s.kind === "object" && !viaTop && o.name !== null && !knownKeys.has(o.name))
note(`adds key '${o.name}' at ${where}`);
break;
case "read":
if (
s.kind === "object" &&
!underPromiseGuard(o.node) &&
escapes &&
o.name !== null &&
!viaTop &&
!knownKeys.has(o.name) &&
!OBJECT_PROTOTYPE_MEMBERS.has(o.name)
)
note(`reads absent key '${o.name}' at ${where} (escapes)`);
break;
default:
break;
}
}
let brand = false;
if (s.kind === "object" && s.keys !== null) {
const o = orders.get([...s.keys].sort().join(","));
if (o !== undefined && o.size > 1) brand = true;
}
const writableByTop: string[] = [];
if (escapes) {
if (analysis.topComputedWrites.length > 0) writableByTop.push("*");
else for (const k of knownKeys) if (analysis.topWriteNames.has(k)) writableByTop.push(k);
}
const verdict: SiteVerdict["verdict"] =
reasons.length > 0 ? "G" : escapes ? "P-escaping" : "P-local";
const label = s.atom.label;
if (escapes) reasons.push(`escapes: ${escapedWhy}`);
verdicts.push({ site: s, label, verdict, reasons, writableByTop, freezable, brand });
}
const hot = hotFunctions(analysis);
const hotNodes = new Set<ts.Node>([...hot].map((f) => f.node));
// ---- print ----
const pkgFns = [...analysis.functions.values()].filter((f) => f.inPackage);
lines.push(
`analyzed ${analysis.files.length} files (${analysis.packageFiles} package JS), ${analysis.functions.size} functions (${pkgFns.length} in packages), ${analysis.atoms.atoms.length} atoms, ${analysis.nodes.length} value nodes, ${analysis.steps} steps${analysis.budgetExceeded ? " — BUDGET EXCEEDED, results incomplete" : ""}`,
);
const topNames = [...analysis.topWriteNames.entries()].map(
([n, node]) => `${n} (${locLabel(node)})`,
);
lines.push(
`census: writes on ⊤ receivers: ${topNames.length === 0 ? "none" : topNames.slice(0, 20).join(", ")}${topNames.length > 20 ? ` +${topNames.length - 20}` : ""}`,
);
lines.push(
`census: computed writes on ⊤ receivers: ${analysis.topComputedWrites.length === 0 ? "none" : analysis.topComputedWrites.slice(0, 8).map(locLabel).join(", ")}`,
);
const topReflect = topOps.filter(
(o) =>
o.kind !== "read" &&
o.kind !== "computedRead" &&
o.kind !== "write" &&
o.kind !== "computedWrite",
);
lines.push(
`census: reflection on ⊤ receivers: ${topReflect.length === 0 ? "none" : topReflect.map((o) => `${o.kind} (${locLabel(o.node)})`).join(", ")}`,
);
const escapedPkg = [...analysis.escaped.keys()].filter(
(a) =>
a.node !== undefined &&
wanted(a.node, a.node.getSourceFile().fileName.includes("/node_modules/")),
);
lines.push(
`escaped atoms: ${analysis.escaped.size} (${escapedPkg.length} shown below where relevant)`,
);
const firstEscapes = [...analysis.escaped.entries()]
.filter(([, why]) => !why.startsWith("via "))
.slice(0, 12);
for (const [atom, why] of firstEscapes) lines.push(` escape root: ${atom.label}: ${why}`);
const counts = { "P-local": 0, "P-escaping": 0, G: 0 };
const hotCounts = { "P-local": 0, "P-escaping": 0, G: 0 };
const inHot = (n: ts.Node): boolean => {
let p: ts.Node | undefined = n.parent;
while (p !== undefined) {
if (hotNodes.has(p)) return true;
p = p.parent;
}
return false;
};
for (const v of verdicts) {
counts[v.verdict]++;
if (inHot(v.site.node)) hotCounts[v.verdict]++;
}
lines.push(
`sites: ${verdicts.length} (P local ${counts["P-local"]}, P escaping ${counts["P-escaping"]}, G ${counts.G}); in hot functions: P local ${hotCounts["P-local"]}, P escaping ${hotCounts["P-escaping"]}, G ${hotCounts.G}`,
);
for (const v of verdicts) {
if (!wanted(v.site.node, v.site.inPackage)) continue;
if (!verbose && !inHot(v.site.node)) continue;
const extra: string[] = [];
if (v.brand) extra.push("needs brand (key order)");
if (v.freezable) extra.push("freezable");
if (v.writableByTop.length > 0) extra.push(`⊤-writable: ${v.writableByTop.join(",")}`);
const fields = analysis.fields.get(v.site.atom);
const ftypes =
v.site.kind === "array"
? `elem: ${analysis.elements.has(v.site.atom) ? describeValue(analysis, analysis.elements.get(v.site.atom)!) : "∅"}`
: fieldEntries(fields)
.filter(([k]) => k !== "*")
.map(([k, id]) => `${k}: ${describeValue(analysis, id)}`)
.join("; ");
lines.push(
`site ${v.label}${inHot(v.site.node) ? " [hot]" : ""}: ${v.verdict}${v.reasons.length > 0 ? ` — ${v.reasons.join("; ")}` : ""}${extra.length > 0 ? ` [${extra.join("; ")}]` : ""}`,
);
if (ftypes.length > 0) lines.push(` ${ftypes}`);
}
// Class fields.
for (const atom of analysis.atoms.atoms) {
if (atom.kind !== "instance" || atom.node === undefined) continue;
const inPackage = atom.node.getSourceFile().fileName.includes("/node_modules/");
if (!wanted(atom.node, inPackage)) continue;
const fm = analysis.fields.get(atom);
if (fm === undefined || fm.size === 0) continue;
const esc = analysis.escaped.get(atom);
const parts = [...fm.entries()].map(([k, id]) => {
const w =
esc !== undefined &&
!k.startsWith("#") &&
(analysis.topComputedWrites.length > 0 || analysis.topWriteNames.has(k))
? " (⊤-writable)"
: "";
return `${k}: ${describeValue(analysis, id)}${w}`;
});
lines.push(
`class ${atom.label}${esc !== undefined ? ` (escapes: ${esc})` : ""}: ${parts.join("; ")}`,
);
}
// Hot functions.
const hotPkg = [...hot].filter((info) => wanted(info.node, info.inPackage));
lines.push(`hot functions: ${hotPkg.length} (clones per receiver class counted separately)`);
for (const info of hotPkg) {
const n = info.node;
const params = info.params.map((p, i) => `p${i}=${describeValue(analysis, p)}`).join(", ");
const flags = [
info.usesArguments ? "uses arguments" : "",
analysis.escaped.size > 0 && [...analysis.escaped.keys()].some((a) => a.node === n)
? "escapes"
: "",
].filter((x) => x !== "");
lines.push(
`fn ${info.label}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}: this=${describeValue(analysis, info.thisNode)}${params.length > 0 ? `, ${params}` : ""}, ret=${describeValue(analysis, info.ret)}`,
);
}
const why = process.env["SCRIPTC_DEBUG_SHAPES_WHY"];
if (why !== undefined) {
for (const info of analysis.functions.values()) {
if (!info.label.startsWith(why)) continue;
for (const [what, node] of [
["ret", info.ret],
...info.params.map((p, i) => [`p${i}`, p] as const),
] as const) {
if (!analysis.nodes[node]!.atoms.has(analysis.top)) continue;
lines.push(`why ⊤ in ${info.label} ${what}:`);
let cur: number | string | undefined = node;
for (let i = 0; i < 40 && typeof cur === "number"; i++) {
lines.push(` ${analysis.nodeLabel.get(cur) ?? `node ${cur}`}`);
cur = analysis.topCause.get(cur);
}
if (typeof cur === "string") lines.push(` seed: ${cur}`);
}
}
}
return { sites: verdicts, hot, lines };
}
/** A read inside the true arm of a promise test (`isAsync(x)`,
* `x instanceof Promise`): flow-sensitive narrowing removes the record
* arms there. Stage S1's locals are flow-insensitive, so the report
* recognizes the guard syntactically. */
function underPromiseGuard(node: ts.Node): boolean {
let child: ts.Node = node;
let p = node.parent;
while (p !== undefined && !ts.isFunctionLike(p)) {
let cond: ts.Expression | undefined;
if (ts.isIfStatement(p) && p.thenStatement === child) cond = p.expression;
if (ts.isConditionalExpression(p) && p.whenTrue === child) cond = p.condition;
if (cond !== undefined && isPromiseTest(cond)) return true;
child = p;
p = p.parent;
}
return false;
}
function isPromiseTest(e: ts.Expression): boolean {
if (ts.isParenthesizedExpression(e)) return isPromiseTest(e.expression);
if (ts.isBinaryExpression(e)) {
if (e.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword)
return ts.isIdentifier(e.right) && e.right.text === "Promise";
if (e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
return isPromiseTest(e.left) || isPromiseTest(e.right);
return false;
}
if (ts.isCallExpression(e) && ts.isIdentifier(e.expression))
return /^is(Async|Promise|Thenable)$/.test(e.expression.text);
return false;
}
@@ -0,0 +1,302 @@
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";
// A package shaped like a validation library: schema classes with a virtual
// `_parse`, per-field context and result literals, a class holding the
// context, and an output object built with computed keys.
const packageSource = `
export class Status {
constructor() { this.value = "valid"; }
dirty() { if (this.value === "valid") this.value = "dirty"; }
}
class LazyPath {
constructor(parent, data, key) { this.parent = parent; this.data = data; this.key = key; }
get path() { return [this.key]; }
}
export class Base {
constructor(def) { this._def = def; }
ctxOf(input) { return { common: input.parent.common, data: input.data, parent: input.parent }; }
parse(data) {
const root = { common: { issues: [] }, data, parent: null };
const result = this._parse({ data, parent: root });
return result.value;
}
}
export class Str extends Base {
_parse(input) {
const status = new Status();
if (typeof input.data !== "string") status.dirty();
return { status: status.value, value: input.data };
}
}
export class Obj extends Base {
_parse(input) {
const ctx = this.ctxOf(input);
const pairs = [];
for (const key of Object.keys(this._def.shape)) {
const field = this._def.shape[key];
pairs.push({ key, value: field._parse(new LazyPath(ctx, ctx.data[key], key)) });
}
const out = {};
for (const pair of pairs) out[pair.key] = pair.value.value;
return { status: "valid", value: out };
}
}
export const str = () => new Str({ checks: [] });
export const obj = (shape) => new Obj({ shape });
export function leak(value) { globalThis.leaked = value; return value; }
export function reflect() { const o = { a: 1 }; delete o.a; return o; }
class Box {}
function tag(strings, ...values) { leak(values[0]); return strings.raw.length; }
export function arrayProp() { const list = []; list.meta = { listed: 1 }; return leak(list.meta); }
export function computedField(k) {
const box = new Box();
box[k] = { boxed: 1 };
Box[k] = { onClass: 1 };
const fn = function () {};
fn[k] = { onFunction: 1 };
return [leak(box.held), leak(Box.held), leak(fn.held)];
}
export function revive(text) {
const seen = { revived: 1 };
return JSON.parse(text, function (k, v) { globalThis.revived = seen; return v; });
}
class Doc {
constructor() { this.body = { doc: 1 }; }
toJSON() { return leak(this.body); }
}
export function stringified() {
const own = { owned: 1 };
return [
JSON.stringify({ inner: { replaced: 1 } }, (k, x) => leak(x)),
JSON.stringify(new Doc()),
JSON.stringify({ toJSON() { return leak(own); } }),
];
}
export function proxied() { return new Proxy({ proxied: 1 }, {}); }
export function reflectDelete() { const o = { gone: 1 }; Reflect.deleteProperty(o, "gone"); return o; }
export function tagged() { return tag\`a\${{ part: 1 }}b\`; }
export function arrayComputed(k) { const l = []; l[k] = { arrayComputed: 1 }; return leak(l.meta); }
export function shadowed(k) {
const fn = function () {};
fn[k] = { onPrototype: 1 };
const box = new Box();
box[k] = { onConstructor: 1 };
return [leak(fn.prototype), leak(box.constructor)];
}
export function jsonCallbacks(k) {
const inArray = { inArray: 1 };
const list = [];
list.toJSON = function () { globalThis.inArray = inArray; return 1; };
const inFunction = { inFunction: 1 };
const fn = function () {};
fn.toJSON = function () { globalThis.inFunction = inFunction; return 1; };
const underKey = { underKey: 1 };
const o = {};
o[k] = function () { globalThis.underKey = underKey; return 1; };
return JSON.stringify([list, { fn }, o]);
}
export function revivedResult(text) { const r = { fromReviver: 1 }; return leak(JSON.parse(text, function () { return r; })); }
export function constructedProxy() { return Reflect.construct(Proxy, [{ constructedProxy: 1 }, {}]); }
export function globalProxy() { return new globalThis.Proxy({ globalProxy: 1 }, {}); }
`;
const packageTypes = `
export declare class Base { parse(data: unknown): unknown; }
export declare class Str extends Base {}
export declare class Obj extends Base {}
export declare const str: () => Str;
export declare const obj: (shape: Record<string, Base>) => Obj;
export declare function leak<T>(value: T): T;
export declare function reflect(): object;
export declare function arrayProp(): unknown;
export declare function computedField(k: string): unknown;
export declare function revive(text: string): unknown;
export declare function stringified(): unknown;
export declare function proxied(): unknown;
export declare function reflectDelete(): unknown;
export declare function tagged(): unknown;
export declare function arrayComputed(k: string): unknown;
export declare function shadowed(k: string): unknown;
export declare function jsonCallbacks(k: string): unknown;
export declare function revivedResult(text: string): unknown;
export declare function constructedProxy(): unknown;
export declare function globalProxy(): unknown;
`;
const mainSource = `
import {
arrayComputed,
arrayProp,
computedField,
constructedProxy,
globalProxy,
jsonCallbacks,
leak,
obj,
proxied,
reflect,
reflectDelete,
revive,
revivedResult,
shadowed,
str,
stringified,
tagged,
} from "shapes-fixture";
const schema = obj({ a: str(), b: str() });
const input: unknown = JSON.parse('{"a":"x","b":"y"}');
console.log(JSON.stringify(schema.parse(input)));
console.log(JSON.stringify(leak({ kept: 1 })), JSON.stringify(reflect()));
const more = [
arrayProp(),
computedField("held"),
revive("{}"),
stringified(),
proxied(),
reflectDelete(),
tagged(),
arrayComputed("meta"),
shadowed("prototype"),
jsonCallbacks("toJSON"),
revivedResult("{}"),
constructedProxy(),
globalProxy(),
];
console.log(more.length);
`;
let dir: string;
let saved: string | undefined;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "scriptc-shapes-"));
const pkg = join(dir, "node_modules", "shapes-fixture");
await mkdir(pkg, { recursive: true });
await writeFile(
join(pkg, "package.json"),
JSON.stringify({
name: "shapes-fixture",
version: "1.0.0",
type: "module",
main: "index.js",
types: "index.d.ts",
}),
);
await writeFile(join(pkg, "index.js"), packageSource);
await writeFile(join(pkg, "index.d.ts"), packageTypes);
await writeFile(
join(dir, "package.json"),
JSON.stringify({ type: "module", dependencies: { "shapes-fixture": "1.0.0" } }),
);
await writeFile(join(dir, "main.ts"), mainSource);
saved = process.env["SCRIPTC_DEBUG_SHAPES"];
});
afterEach(async () => {
if (saved === undefined) delete process.env["SCRIPTC_DEBUG_SHAPES"];
else process.env["SCRIPTC_DEBUG_SHAPES"] = saved;
vi.restoreAllMocks();
await rm(dir, { recursive: true, force: true });
});
async function report(): Promise<string[]> {
process.env["SCRIPTC_DEBUG_SHAPES"] = "verbose";
const lines: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
for (const l of text.split("\n")) if (l.startsWith("[shapes] ")) lines.push(l.slice(9));
return true;
});
const entry = join(dir, "main.ts");
await compile(entry, {
outDir: dir,
outPath: join(dir, "main.ir.json"),
outputKind: "ir",
npmStatic: ["shapes-fixture"],
});
return lines;
}
function site(lines: string[], label: RegExp): string {
const line = lines.find((l) => l.startsWith("site ") && label.test(l));
if (line === undefined) throw new Error(`no site ${label}:\n${lines.join("\n")}`);
return line;
}
test("S1 report: per-field temporaries are provable records, dynamic objects stay G", async () => {
const lines = await report();
// result literals of both _parse implementations, the pair literal, the
// context and the root context never escape: native records would be exact
// the leaf schema's result: in a function called from a loop (hot)
expect(site(lines, /^site \{status,value\}@shapes-fixture\/index\.js:23 \[hot\]/)).toMatch(
/: P-local/,
);
expect(site(lines, /^site \{status,value\}@shapes-fixture\/index\.js:36/)).toMatch(/: P-local/);
expect(site(lines, /^site \{key,value\}@/)).toMatch(/: P-local/);
expect(site(lines, /^site \{common,data,parent\}@shapes-fixture\/index\.js:12/)).toMatch(
/: P-local/,
);
expect(site(lines, /^site \[\]@shapes-fixture\/index\.js:29/)).toMatch(/: P-local/);
// the output object is built with computed keys
expect(site(lines, /^site \{\}@shapes-fixture\/index\.js:34/)).toMatch(/: G — computed write/);
// a deleted key and a value stored into the global object
expect(site(lines, /^site \{a\}@/)).toMatch(/: G — delete at/);
expect(site(lines, /^site \{kept\}@main\.ts/)).toMatch(/: P-escaping — escapes: stored into ⊤/);
// receiver-typed dispatch: the field's validators are the schema classes
expect(lines.find((l) => l.startsWith("fn Str._parse@"))).toMatch(/p0=LazyPath/);
expect(lines.find((l) => l.startsWith("class LazyPath"))).toMatch(
/parent: \{common,data,parent\}@shapes-fixture\/index\.js:12;/,
);
expect(lines.find((l) => l.startsWith("class Str"))).toMatch(/_def: \{checks\}@/);
});
test("S1 report follows values through array properties, unknown keys, callbacks and reflection", async () => {
const lines = await report();
// a named property of an array, read back and leaked
expect(site(lines, /^site \{listed\}@/)).toMatch(/: P-escaping/);
// computed writes to an instance, a class and a function, read by name
expect(site(lines, /^site \{boxed\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{onClass\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{onFunction\}@/)).toMatch(/: P-escaping/);
// a JSON.parse reviver, a JSON.stringify replacer, and toJSON methods
expect(site(lines, /^site \{revived\}@/)).toMatch(/: P-escaping — escapes: stored into ⊤/);
expect(site(lines, /^site \{replaced\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{doc\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{owned\}@/)).toMatch(/: P-escaping/);
// a Proxy target and Reflect.deleteProperty
expect(site(lines, /^site \{proxied\}@/)).toMatch(/: G — proxy at/);
expect(site(lines, /^site \{gone\}@/)).toMatch(/: G — delete at/);
// a tagged template's substitution, passed to its tag
expect(site(lines, /^site \{part\}@/)).toMatch(/: P-escaping/);
// a named load of an array property written under an unknown key
expect(site(lines, /^site \{arrayComputed\}@/)).toMatch(/: P-escaping/);
// own fields written under unknown keys shadow a function's prototype and an instance's constructor
expect(site(lines, /^site \{onPrototype\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{onConstructor\}@/)).toMatch(/: P-escaping/);
// toJSON on an array, on a function, and stored under an unknown key
expect(site(lines, /^site \{inArray\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{inFunction\}@/)).toMatch(/: P-escaping/);
expect(site(lines, /^site \{underKey\}@/)).toMatch(/: P-escaping/);
// what a JSON.parse reviver returns is in the result
expect(site(lines, /^site \{fromReviver\}@/)).toMatch(/: P-escaping/);
// a proxy constructed through Reflect.construct and globalThis
expect(site(lines, /^site \{constructedProxy\}@/)).toMatch(/: G — proxy at/);
expect(site(lines, /^site \{globalProxy\}@/)).toMatch(/: G — proxy at/);
// the existing verdicts hold
expect(site(lines, /^site \{kept\}@main\.ts/)).toMatch(/: P-escaping — escapes: stored into ⊤/);
});
test("S1 report is off by default and changes nothing", async () => {
delete process.env["SCRIPTC_DEBUG_SHAPES"];
const writes = vi.spyOn(process.stderr, "write");
const entry = join(dir, "main.ts");
await compile(entry, {
outDir: dir,
outPath: join(dir, "main.ir.json"),
outputKind: "ir",
npmStatic: ["shapes-fixture"],
});
expect(writes.mock.calls.some(([c]) => String(c).includes("[shapes]"))).toBe(false);
});