Files
scriptc/packages/compiler/test/shape-inference.test.ts
T
Chris Tate cae940eb0b Read added primitive members and unmatched exec groups as unknown values
- A member that String.prototype, Number.prototype, Boolean.prototype and
  Object.prototype lack reads undefined only while no write may add it.
  Assignments to one of those prototypes, Object.defineProperty,
  Object.assign, setPrototypeOf and delete on one, writes to an unknown
  value, and passing a prototype to unknown code add the name (or every
  name), and the reads already made take an unknown value as well. String
  indices past the end follow the same rule.
- RegExp.prototype.exec results hold undefined as well as strings: a
  capture group that did not take part in the match is undefined, as in
  String.prototype.match results.
2026-10-25 10:31:13 -05:00

339 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }, {}); }
function same(value) { return value; }
String.prototype.shout = function () { return this.toUpperCase(); };
function addedMember(s) { return s.shout; }
function missingMember(s) { return s.whisper; }
Object.defineProperty(Number.prototype, "half", { get() { return this / 2; } });
function definedMember(n) { return n.half; }
function execGroups(s) { const m = /(a)|(b)/.exec(s); return m === null ? "none" : m[2]; }
export function primitives() {
const out = [];
for (const s of ["a", "b"]) out.push(addedMember(s), missingMember(s), definedMember(s.length), execGroups(s));
return out;
}
export function manyShapes() {
return [same({ w0: 0 }), same({ w1: 1 }), same({ w2: 2 }), same({ w3: 3 }), same({ w4: 4 }), same({ w5: 5 }), same({ w6: 6 }), same({ w7: 7 }), same({ w8: 8 }), same({ w9: 9 }), same({ w10: 10 }), same({ w11: 11 }), same({ w12: 12 }), same({ w13: 13 }), same({ w14: 14 }), same({ w15: 15 }), same({ w16: 16 }), same({ w17: 17 }), same({ w18: 18 }), same({ w19: 19 }), same({ w20: 20 }), same({ w21: 21 }), same({ w22: 22 }), same({ w23: 23 }), same({ w24: 24 }), same({ w25: 25 }), same({ w26: 26 }), same({ w27: 27 }), same({ w28: 28 }), same({ w29: 29 }), same({ w30: 30 }), same({ w31: 31 }), same({ w32: 32 }), same({ w33: 33 }), same({ w34: 34 }), same({ w35: 35 }), same({ w36: 36 }), same({ w37: 37 }), same({ w38: 38 }), same({ w39: 39 })].length;
}
`;
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;
export declare function manyShapes(): number;
export declare function primitives(): unknown;
`;
const mainSource = `
import {
arrayComputed,
arrayProp,
computedField,
constructedProxy,
globalProxy,
manyShapes,
jsonCallbacks,
leak,
obj,
primitives,
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(),
manyShapes(),
primitives(),
];
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/);
// a value set past the widening limit: its values escape
expect(site(lines, /^site \{w0\}@/)).toMatch(/: P-escaping — escapes: a value set too large/);
// 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);
});
test("S1 report: members added to primitive prototypes and unmatched capture groups", async () => {
const lines = await report();
const ret = (name: string): string | undefined =>
lines.find((l) => l.startsWith(`fn ${name}@`))?.replace(/^.*, ret=/, "");
// String.prototype.shout is assigned and Number.prototype.half defined: unknown values
expect(ret("addedMember")).toBe("undefined | ⊤");
expect(ret("definedMember")).toBe("undefined | ⊤");
// a name no prototype has and no write adds
expect(ret("missingMember")).toBe("undefined");
// RegExp.prototype.exec: a group that did not take part is undefined
expect(ret("execGroups")).toBe("string | undefined");
});