Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions packages/compiler/src/frontend/lowering/construction-escapes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { afterAll, expect, test } from "vitest";
import * as ts from "../ts7/adapter.js";
import { closeSourceParser, parseSourceFile } from "../ts7/source-parser-node.js";
import { constructionEscape, mentionsReceiver } from "./construction-escapes.js";

afterAll(closeSourceParser);

function analyze(source: string, options: { derived?: boolean; baseEscapes?: boolean } = {}) {
const file = parseSourceFile("classes.ts", source, "ts");
let decl: ts.ClassDeclaration | undefined;
ts.walkPreorder(file, (node) => {
if (!decl && ts.isClassDeclaration(node)) decl = node;
});
if (!decl) throw new Error("fixture has no class");
const cls = decl;
const ctor = cls.members.find(ts.isConstructorDeclaration) ?? null;
const fields = cls.members
.filter(ts.isPropertyDeclaration)
.map((member) => ({ name: member.name.getText(), initializer: member.initializer }));
const fieldNames = new Set(fields.map((field) => field.name));
return constructionEscape({
ctor,
fields,
paramProps: [],
derived: options.derived ?? false,
baseEscapes: options.baseEscapes ?? false,
isField: (name) => fieldNames.has(name),
privateMethodBody: (call) => {
if (!ts.isPropertyAccessExpression(call.expression)) return null;
const name = call.expression.name.text;
const method = cls.members.find(
(member): member is ts.MethodDeclaration =>
ts.isMethodDeclaration(member) && member.name.getText() === name,
);
const isPrivate =
method !== undefined &&
(ts.isPrivateIdentifier(method.name) ||
(ts.getModifiers(method)?.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword) ?? false));
return isPrivate ? (method.body ?? null) : null;
},
});
}

const sorted = (set: Set<string>): string[] => [...set].sort();

test("assignments before any exposure are proven", () => {
const result = analyze(`
class A {
a: B; b: B; c: B;
constructor() {
this.a = new B();
this.b = make(this.a);
log("no receiver here");
this.c = new B();
this.report();
}
}`);
expect(result.escapes).toBe(true);
expect(sorted(result.assigned)).toEqual(["a", "b", "c"]);
});

test("a call that receives the instance stops the proof", () => {
const result = analyze(`
class A {
seen: boolean; box: B;
constructor() {
this.seen = check(this);
this.box = new B();
}
}`);
expect(result.escapes).toBe(true);
expect(sorted(result.assigned)).toEqual([]);
});

test("method calls, callbacks and reads of unassigned fields expose the instance", () => {
for (const body of [
"this.describe(); this.box = new B();",
"const f = () => this.box; this.box = new B();",
"this.other = this.box; this.box = new B();",
"if (flag) this.box = new B(); this.box = new B();",
]) {
const result = analyze(`class A { box: B; other: B; constructor() { ${body} } }`);
expect(result.escapes, body).toBe(true);
expect(result.assigned.has("box"), body).toBe(false);
}
});

test("initializers run in order before the constructor body", () => {
const result = analyze(`
class A {
first = new B();
second = this.first;
third = this.compute();
fourth = new B();
constructor() { this.fifth = new B(); }
}`);
expect(result.escapes).toBe(true);
expect(sorted(result.assigned)).toEqual(["first", "second"]);
});

test("private helpers that only assign fields are followed", () => {
const result = analyze(`
class A {
a!: B; b!: B; c: B;
constructor() {
this.setup();
this.c = new B();
}
private setup(): void {
this.a = new B();
this.#more();
}
#more(): void {
this.b = this.a;
}
}`);
expect(result.escapes).toBe(false);
expect(sorted(result.assigned)).toEqual(["a", "b", "c"]);
});

test("public helpers, helpers that return early, and early returns stop the proof", () => {
for (const source of [
"class A { a!: B; constructor() { this.setup(); } setup(): void { this.a = new B(); } }",
"class A { a!: B; constructor() { this.setup(); } private setup(): void { if (x) return; this.a = new B(); } }",
"class A { a!: B; constructor() { if (x) return; this.a = new B(); } }",
]) {
const result = analyze(source);
expect(result.escapes, source).toBe(true);
expect(result.assigned.has("a"), source).toBe(false);
}
});

test("derived classes inherit the base constructor's exposure", () => {
const source = `
class D {
box: B;
constructor() {
super();
this.box = new B();
}
}`;
expect(analyze(source, { derived: true }).escapes).toBe(false);
const exposed = analyze(source, { derived: true, baseEscapes: true });
expect(exposed.escapes).toBe(true);
expect(sorted(exposed.assigned)).toEqual([]);
// Without a constructor, construction is the implicit super call.
expect(analyze("class D { box = new B(); }", { derived: true, baseEscapes: true }).escapes).toBe(
true,
);
expect(sorted(analyze("class D { box = new B(); }", { derived: true }).assigned)).toEqual([
"box",
]);
});

test("nested functions with their own receiver are opaque", () => {
const file = parseSourceFile(
"nested.ts",
"const f = function () { return this; }; const g = () => this;",
"ts",
);
const found: boolean[] = [];
ts.walkPreorder(file, (node) => {
if (ts.isVariableDeclaration(node) && node.initializer)
found.push(mentionsReceiver(node.initializer));
});
expect(found).toEqual([false, true]);
});
165 changes: 165 additions & 0 deletions packages/compiler/src/frontend/lowering/construction-escapes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as ts from "../ts7/adapter.js";

/** Whether `node` mentions the constructor's receiver: a `this` or `super`
* keyword outside nested declarations that bind their own receiver. Arrow
* functions inherit the receiver, so they stay transparent. */
export function mentionsReceiver(node: ts.Node): boolean {
let found = false;
const visit = (child: ts.Node): void => {
if (found) return;
if (
ts.isFunctionExpression(child) ||
ts.isFunctionDeclaration(child) ||
ts.isMethodDeclaration(child) ||
ts.isConstructorDeclaration(child) ||
ts.isGetAccessor(child) ||
ts.isSetAccessor(child) ||
ts.isClassDeclaration(child) ||
ts.isClassExpression(child)
) {
return;
}
if (child.kind === ts.SyntaxKind.ThisKeyword || child.kind === ts.SyntaxKind.SuperKeyword) {
found = true;
return;
}
child.forEachChild(visit);
};
visit(node);
return found;
}

/** The construction order inputs of one class: its own instance field
* initializers in declaration order, its parameter property names, the
* constructor body, and the inherited part of construction. */
export interface ConstructionInputs {
ctor: ts.ConstructorDeclaration | null;
/** Own declared fields in declaration order, with their initializers. */
fields: readonly { name: string; initializer: ts.Expression | undefined }[];
paramProps: readonly string[];
derived: boolean;
/** True when the base class's construction may expose the instance. */
baseEscapes: boolean;
/** True for names that are plain instance fields (not accessors). */
isField: (name: string) => boolean;
/** The body of the class's own method a `this.m(...)` call runs, when no
* subclass can override it (TypeScript `private` and `#private`
* methods); null otherwise. */
privateMethodBody: (call: ts.CallExpression) => ts.Block | null;
}

export interface ConstructionEscape {
/** True when construction may expose the instance to other code (a
* method call, a callback, an argument, a base constructor that does). */
escapes: boolean;
/** Own fields assigned before the first exposure; every own field when
* construction never exposes the instance. */
assigned: Set<string>;
}

/** Private helper bodies are followed this deep. */
const HELPER_DEPTH = 3;

function containsReturn(node: ts.Node): boolean {
let found = false;
const visit = (child: ts.Node): void => {
if (found || ts.isFunctionLike(child) || ts.isClassLikeDeclaration(child)) return;
if (ts.isReturnStatement(child)) {
found = true;
return;
}
child.forEachChild(visit);
};
node.forEachChild(visit);
return found;
}

/** Which own fields construction definitely assigns before the instance can
* be observed by any other code. A field outside `assigned` may be read
* while its slot still holds the pre-assignment undefined. */
export function constructionEscape(inputs: ConstructionInputs): ConstructionEscape {
const assigned = new Set<string>();
const stop = (): ConstructionEscape => ({ escapes: true, assigned });
const thisMember = (node: ts.Node): node is ts.PropertyAccessExpression =>
ts.isPropertyAccessExpression(node) && node.expression.kind === ts.SyntaxKind.ThisKeyword;
// A plain read of an already-assigned field does not expose the instance,
// nor does a private helper that itself only assigns fields and reads
// assigned ones; anything else mentioning the receiver might.
const exposes = (node: ts.Node, depth: number): boolean => {
if (node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.SuperKeyword)
return true;
if (ts.isCallExpression(node) && thisMember(node.expression)) {
if (node.arguments.some((arg) => mentionsReceiver(arg) && exposes(arg, depth))) return true;
const body = depth < HELPER_DEPTH ? inputs.privateMethodBody(node) : null;
if (!body) return true;
return !followStatements(body.statements, depth + 1);
}
if (thisMember(node)) {
const name = node.name.text;
return !(ts.isIdentifier(node.name) && assigned.has(name) && inputs.isField(name));
}
let found = false;
node.forEachChild((child) => {
if (!found && mentionsReceiver(child)) found = exposes(child, depth);
});
return found;
};
/** Walk straight-line statements; false at the first exposure. */
const followStatements = (statements: readonly ts.Statement[], depth: number): boolean => {
for (const stmt of statements) {
// An early return leaves later assignments unproven on its path.
if (ts.isReturnStatement(stmt) || containsReturn(stmt)) return false;
if (!mentionsReceiver(stmt)) continue;
if (
ts.isExpressionStatement(stmt) &&
ts.isBinaryExpression(stmt.expression) &&
stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
thisMember(stmt.expression.left) &&
ts.isIdentifier(stmt.expression.left.name) &&
inputs.isField(stmt.expression.left.name.text)
) {
const right = stmt.expression.right;
if (mentionsReceiver(right) && exposes(right, depth)) return false;
assigned.add(stmt.expression.left.name.text);
continue;
}
if (ts.isExpressionStatement(stmt) && !exposes(stmt.expression, depth)) continue;
return false;
}
return true;
};
const ownInitializers = (): boolean => {
for (const name of inputs.paramProps) assigned.add(name);
for (const field of inputs.fields) {
if (!field.initializer) continue;
if (mentionsReceiver(field.initializer) && exposes(field.initializer, 0)) return false;
assigned.add(field.name);
}
return true;
};
const statements = inputs.ctor?.body?.statements ?? [];
let index = 0;
if (inputs.derived) {
// Own initializers run when super() returns. A base construction that
// exposes the instance exposes it before any own field is assigned.
for (; index < statements.length; index++) {
const stmt = statements[index]!;
const isSuper =
ts.isExpressionStatement(stmt) &&
ts.isCallExpression(stmt.expression) &&
stmt.expression.expression.kind === ts.SyntaxKind.SuperKeyword;
if (isSuper) {
if (stmt.expression.arguments.some((arg) => mentionsReceiver(arg))) return stop();
break;
}
// super() nested inside other statements: keep the analysis simple.
if (mentionsReceiver(stmt)) return stop();
}
if (inputs.ctor?.body && index >= statements.length) return stop();
if (inputs.baseEscapes) return stop();
index++;
}
if (!ownInitializers()) return stop();
if (!followStatements(statements.slice(index), 0)) return stop();
return { escapes: false, assigned };
}
8 changes: 7 additions & 1 deletion packages/compiler/src/frontend/lowering/lower-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// inspect distinction, per arm), Maps/Sets, plain undefined/null,
// regexes, symbols, error values, Buffers. Shapes inspect cannot
// render fence honestly with the reason.
return lowerConsoleInspectArg(lowerer, a, lowered, surface, loc);
return lowerConsoleInspectArg(
lowerer,
a,
lowerer.deferredFieldSlot(lowered) ?? lowered,
surface,
loc,
);
});
return withReceiver({
kind: "intrinsic",
Expand Down
Loading
Loading