diff --git a/packages/compiler/src/frontend/lowering/construction-escapes.test.ts b/packages/compiler/src/frontend/lowering/construction-escapes.test.ts new file mode 100644 index 000000000..e2bd82516 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/construction-escapes.test.ts @@ -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[] => [...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]); +}); diff --git a/packages/compiler/src/frontend/lowering/construction-escapes.ts b/packages/compiler/src/frontend/lowering/construction-escapes.ts new file mode 100644 index 000000000..484920a1c --- /dev/null +++ b/packages/compiler/src/frontend/lowering/construction-escapes.ts @@ -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; +} + +/** 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(); + 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 }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index aebdd41b3..edbfeb63d 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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", diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index c94720ec2..9acbb432d 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -122,6 +122,7 @@ import { lowerObjectFactoryNew } from "./object-factory-new.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; import { classPrototypeData } from "./class-prototypes.js"; +import { constructionEscape } from "./construction-escapes.js"; import { checkedClassConstruction, checkedClassConstructionPacked } from "./class-construction.js"; import { initializeRuntimeStatics, hasRuntimeStatics } from "./class-runtime-statics.js"; import { reflectedClassStaticMethodValue } from "./class-method-values.js"; @@ -443,6 +444,10 @@ export interface ClassInfo { * unassigned read throws the catchable TypeError instead of yielding * an undefined the declared type cannot hold (SEMANTICS.md). */ deferredInitFields?: Set; + /** True when construction may hand the instance to other code (a method + * call, an argument, a callback, or a base constructor that does) — a + * subclass's own fields can then be read before their assignment. */ + constructionEscapes?: true; /** null for the builtin error classes (runtime-provided; no source). * Class EXPRESSIONS carry their ts.ClassExpression here — members, * accessors, and locs read identically off either form. */ @@ -3383,6 +3388,65 @@ export function collectClassShapeInner( ); } + // Fields read before their first assignment. Construction that hands + // the instance to other code (a method call, an argument, a callback, + // a base constructor that does either) before assigning a class-typed + // field lets that code read the field while Node still holds + // undefined. Such fields take the deferred-init representation, whose + // slot (a nullable instance pointer) starts empty and whose reads check + // it. Fields assigned before any exposure keep their plain reads. + const derived = + decl.heritageClauses?.some((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword) ?? + false; + // Generic instances and mixins share layouts across declarations, so + // they only report whether construction exposes the instance. + const escape: { escapes: boolean; assigned: Set | null } = + familyMode || mixin + ? { escapes: derived, assigned: null } + : constructionEscape({ + ctor, + fields: fieldOrder.flatMap((f) => + paramProps.some((pp) => pp.name === f.name) + ? [] + : [{ name: f.name, initializer: f.initializer }], + ), + paramProps: paramProps.map((pp) => pp.name), + derived, + baseEscapes: + base === null || + callableBase !== undefined || + factoryBaseExpression !== undefined || + base.constructionEscapes === true, + isField: (name) => + fields.has(name) && + !methods.has(`set:${name}`) && + !lowerer.findMethodOn(base, `set:${name}`), + privateMethodBody: (call) => { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) return null; + const name = callee.name.text; + const declaration = decl.members.find( + (member): member is ts.MethodDeclaration => + ts.isMethodDeclaration(member) && + member.body !== undefined && + (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) && + member.name.text === name, + ); + if (!declaration?.body || declaration.asteriskToken) return null; + const modifiers = ts.getModifiers(declaration) ?? []; + if ( + modifiers.some( + (m) => + m.kind === ts.SyntaxKind.StaticKeyword || m.kind === ts.SyntaxKind.AsyncKeyword, + ) + ) + return null; + const isPrivate = + ts.isPrivateIdentifier(declaration.name) || + modifiers.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword); + return isPrivate ? declaration.body : null; + }, + }); // The deferred definite-assignment check: a field on the unguarded // list passes only with an unconditional `this.x = ...` at the // constructor's TOP LEVEL — the same standard the JS-class path @@ -3405,7 +3469,7 @@ export function collectClassShapeInner( } } for (const f of unguardedFields) { - if (topAssigned.has(f.name)) continue; + if (topAssigned.has(f.name) || escape.assigned?.has(f.name)) continue; // DEFERRED INITIALIZATION (the Output.initialize idiom — // `stream!: T` assigned inside a method the constructor calls): // the slot becomes the undefined-armed union — allocation writes @@ -3439,6 +3503,21 @@ export function collectClassShapeInner( } } + if (escape.assigned && !inst) { + for (const f of fieldOrder) { + if (f.redeclared || escape.assigned.has(f.name) || deferredInitFields.has(f.name)) continue; + if (paramProps.some((pp) => pp.name === f.name)) continue; + const declared = fields.get(f.name); + if (declared?.kind !== "object" || lowerer.classes.get(declared.className)?.def.runtime) + continue; + const armed = lowerer.withUndefinedArm(declared); + if (armed === null || armed.kind !== "union") continue; + fields.set(f.name, armed); + f.type = armed; + deferredInitFields.add(f.name); + } + } + // Partial overrides of an inherited accessor pair. JS gives the // derived class ONE own accessor property that SHADOWS the whole // inherited pair — the missing half does NOT resolve to the base's @@ -3856,6 +3935,7 @@ export function collectClassShapeInner( ? { classDecorators: { nodes: classDecoratorNodes } } : {}), ...(deferredInitFields.size > 0 ? { deferredInitFields } : {}), + ...(escape.escapes ? { constructionEscapes: true as const } : {}), }; // GENERIC members get their declaring-class backlink now that the // info exists (instance lowering reads it for `this` typing and the diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 0e5e20dfd..749b168ae 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -988,8 +988,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // A known static result type fixes typeof's answer, but its producer // still evaluates: calls, getters, and checked reads can have effects // or throw. Only trivial operands may disappear. - let operand = - runtimeOptionalStorageOperand(lowerer, expr.expression) ?? lowerer.lowerExpr(expr.expression); + let operand = deferredSlotOr( + lowerer, + runtimeOptionalStorageOperand(lowerer, expr.expression) ?? lowerer.lowerExpr(expr.expression), + ); if (operand.type.kind === "jsval") { return { kind: "jsOp", op: "typeof", args: [operand], type: STRING, loc }; } @@ -4137,6 +4139,18 @@ export function lowerUnitComparison( // Two unit literals (`undefined === undefined`): statically decided. return { kind: "boolLit", value: (other.unit === unit.unit) !== negated, type: BOOL, loc }; } + const slot = lowerer.deferredFieldSlot(other); + if (slot) return lowerUnitComparison(lowerer, slot, unit, negated, loc); + // A binding the checker narrowed past the unit can still hold it at + // runtime when its value came from an unassigned deferred-init field; + // the stored tag answers either way. + if ( + other.kind === "unionNarrow" && + other.value.kind === "varRef" && + other.value.type.kind === "union" && + lowerer.armTag(other.value.type.unionId, unit.type) >= 0 + ) + return lowerUnitComparison(lowerer, other.value, unit, negated, loc); if (other.type.kind === "union") { const tag = lowerer.armTag(other.type.unionId, unit.type); if (tag < 0) { @@ -4392,6 +4406,72 @@ function assertedPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr return checkedClassUnionAssertion(lowerer, probe, target, locOf(outer)) ?? probe; } +/** Read-modify-write updates read the member first: an unassigned + * deferred-init receiver throws Node's member-read TypeError. */ +function checkDeferredMemberReceiver( + lowerer: Lowerer, + target: FieldTarget, + access: ts.Expression, +): void { + if ( + target.container !== "class" || + !ts.isPropertyAccessExpression(access) || + target.obj.type.kind !== "object" || + lowerer.deferredFieldSlot(target.obj) === null + ) + return; + target.obj = + lowerer.runtimeOptionalPropertyReceiver( + access.expression, + target.obj, + target.obj.type, + target.field, + ) ?? target.obj; +} + +/** Whether evaluating `expr` cannot store anything or run other code: + * reads, arithmetic, tests and checked extractions (which at most throw). */ +function writeFree(expr: IrExpr): boolean { + switch (expr.kind) { + case "numLit": + case "strLit": + case "boolLit": + case "unitLit": + case "varRef": + return true; + case "fieldGet": + case "recordGet": + return writeFree(expr.obj); + case "bin": + case "logical": + return writeFree(expr.left) && writeFree(expr.right); + case "unary": + case "toBool": + return writeFree(expr.operand); + case "ternary": + return writeFree(expr.cond) && writeFree(expr.then) && writeFree(expr.else_); + case "unionIsTag": + case "unionNarrow": + case "unionWrap": + return writeFree(expr.value); + case "libCall": + return expr.fn === "error.nodeThrow"; + default: + return false; + } +} + +/** The TypeError of a deferred-init field read that finds the field + * unassigned where its declared type cannot hold undefined. */ +export const DEFERRED_READ_MESSAGE = + "undefined is not representable in the target union (a value narrowed or asserted past it still held it)"; + +/** The undefined-armed slot behind a checked deferred-init field read, or + * the value itself (Lowerer.deferredFieldSlot). */ +function deferredSlotOr(lowerer: Lowerer, value: IrExpr): IrExpr { + return lowerer.deferredFieldSlot(value) ?? value; +} + /** A plain value read that may be runtime-optional: an unchecked element * read, or a binding, member or call that carries one. Lowering these * without a destination type is the same as lowering them into one. */ @@ -7449,10 +7529,12 @@ export function runtimeOptionalStorageOperand( /** A presence-test operand: an absence-aware element or field read, an * identifier's runtime-optional storage, or the ordinary lowering. */ export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr { - return ( + // An unassigned deferred-init field observes its undefined slot too. + return deferredSlotOr( + lowerer, lowerAbsenceProbe(lowerer, node) ?? - runtimeOptionalStorageOperand(lowerer, node) ?? - lowerer.lowerExpr(node) + runtimeOptionalStorageOperand(lowerer, node) ?? + lowerer.lowerExpr(node), ); } @@ -9076,7 +9158,10 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio case ts.SyntaxKind.ExclamationToken: { // `!x` is ToBoolean-then-negate: f64/string operands go through toBool. const operand = lowerer.ensureBool( - runtimeOptionalStorageOperand(lowerer, expr.operand) ?? lowerer.lowerExpr(expr.operand), + deferredSlotOr( + lowerer, + runtimeOptionalStorageOperand(lowerer, expr.operand) ?? lowerer.lowerExpr(expr.operand), + ), expr.operand, ); return { kind: "unary", op: "!", operand, type: BOOL, loc }; @@ -9258,6 +9343,7 @@ export function lowerIncDec( ? lowerer.fieldTarget(access) : symbolFieldTarget(lowerer, access); if (target && lowerer.dynConvertible(target.fieldType)) { + checkDeferredMemberReceiver(lowerer, target, access); target.obj = save(target.obj, "%incrementReceiver"); return finish(lowerer.fieldGetExpr(target, loc, access), (value) => lowerer.fieldSetStmt( @@ -9862,8 +9948,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (nullTest) return nullTest; const loose = lowerAbstractEquality( lowerer, - lowerer.lowerExpr(expr.left), - lowerer.lowerExpr(expr.right), + deferredSlotOr(lowerer, lowerer.lowerExpr(expr.left)), + deferredSlotOr(lowerer, lowerer.lowerExpr(expr.right)), op === ts.SyntaxKind.ExclamationEqualsToken, loc, ); @@ -14561,6 +14647,22 @@ export function fieldGetExpr( loc: SrcLoc, blame: ts.Node, ): IrExpr { + // A member read through an unassigned deferred-init field throws Node's + // member-read TypeError instead of the generic checked extraction. + if ( + target.container === "class" && + ts.isPropertyAccessExpression(blame) && + target.obj.type.kind === "object" && + lowerer.deferredFieldSlot(target.obj) !== null + ) { + const checked = lowerer.runtimeOptionalPropertyReceiver( + blame.expression, + target.obj, + target.obj.type, + target.field, + ); + if (checked) target = { ...target, obj: checked }; + } if (target.container === "dynamic") return lowerer.coerceInto( blame, @@ -14721,6 +14823,36 @@ export function fieldGetExpr( target.fieldType.kind === "union" ) { const inner = lowerer.stripUndefinedArm(target.fieldType); + // Reference arms over a stable receiver check inline: the present + // arm is a plain projection of the slot, so ordinary reads keep their + // borrowed form and only an unassigned slot reaches the throw. + const unionId = target.fieldType.unionId; + const presentTag = lowerer.armTag(unionId, inner); + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + if ( + inner.kind === "object" && + presentTag >= 0 && + undefTag >= 0 && + lowerer.unions.get(unionId)?.arms.length === 2 && + isSafeToRepeat(read) + ) { + return { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + negated: false, + value: read, + type: BOOL, + loc, + }, + then: nodeThrowExpr(1, "", DEFERRED_READ_MESSAGE, inner, loc), + else_: { kind: "unionNarrow", unionId, tag: presentTag, value: read, type: inner, loc }, + type: inner, + loc, + }; + } const helper = lowerer.deferredReadHelper(target.fieldType.unionId, inner, loc); if (helper) return { kind: "call", callee: helper, args: [read], type: inner, loc }; } @@ -14746,6 +14878,84 @@ export function fieldSetStmt( loc: SrcLoc, blame: ts.Node, ): IrStmt { + // A member write through an unassigned deferred-init field: the value + // still evaluates first, then the write throws Node's TypeError. + const deferredSlot = + target.container === "class" && + !( + (target.field === "message" || target.field === "name") && + (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) + ) + ? lowerer.deferredFieldSlot(target.obj) + : null; + if (target.container === "class" && deferredSlot?.type.kind === "union") { + const unionId = deferredSlot.type.unionId; + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + const presentTag = lowerer.armTag(unionId, target.obj.type); + if (undefTag >= 0 && presentTag >= 0) { + // A value that cannot write anything leaves the field unchanged, so + // the field is read after it (no snapshot of the slot). + const late = isSafeToRepeat(deferredSlot) && writeFree(value); + const receiver = late + ? null + : lowerer.declareHiddenLocal("%deferredWriteObject", deferredSlot.type); + const stored = lowerer.declareHiddenLocal("%deferredWriteValue", value.type); + const receiverRef = receiver ? varRef(receiver.id, receiver.type, loc) : deferredSlot; + const result = varRef(stored.id, stored.type, loc); + const write: IrStmt = { + kind: "fieldSet", + obj: { + kind: "unionNarrow", + unionId, + tag: presentTag, + value: receiverRef, + type: target.obj.type, + loc, + }, + className: target.className, + field: target.field, + value: result, + loc, + }; + return { + kind: "exprStmt", + expr: { + kind: "seqExpr", + stmts: [ + ...(receiver + ? [{ kind: "varDecl" as const, localId: receiver.id, init: deferredSlot, loc }] + : []), + { kind: "varDecl", localId: stored.id, init: value, loc }, + ], + result: { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + value: receiverRef, + negated: false, + type: BOOL, + loc, + }, + then: nodeThrowExpr( + 1, + "", + `Cannot set properties of undefined (setting '${target.field}')`, + result.type, + loc, + ), + else_: { kind: "seqExpr", stmts: [write], result, type: result.type, loc }, + type: result.type, + loc, + }, + type: result.type, + loc, + }, + loc, + }; + } + } if (target.container === "dynamic") return { kind: "exprStmt", @@ -15143,6 +15353,7 @@ function lowerFieldCompoundValue( const target = targetOf(); if (!target) lowerer.unsupported("SC1090", access, "compound assignment to unsupported field targets"); + checkDeferredMemberReceiver(lowerer, target, access); target.obj = save(target.obj, "%compoundReceiver"); // Accessors observe getter, RHS side effects, then setter, all through // the saved receiver. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index fc8ec30ec..a07b65cfb 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2004,6 +2004,8 @@ export class Lowerer { * optional-read analysis, so ordinary assertions keep their generic * checked-narrow behavior. */ runtimeOptionalSourceValue(node: ts.Expression, value: IrExpr): IrExpr | null { + const slot = this.deferredFieldSlot(value); + if (slot) return slot; let origin: ts.Expression = node; while (ts.isParenthesizedExpression(origin)) origin = origin.expression; let optionalOrigin = @@ -7326,6 +7328,12 @@ export class Lowerer { * else (including a DIFFERENT union) is left for requireExactShape, which * rejects union mismatches with SC2003. */ coerceToExpected(expr: IrExpr, expected: IrType): IrExpr { + // A destination that holds undefined takes an unassigned deferred-init + // field's slot as is (Node passes the undefined along). + if (expected.kind === "union" && this.armTag(expected.unionId, UNDEFINED_T) >= 0) { + const slot = this.deferredFieldSlot(expr); + if (slot) expr = slot; + } if (expr.type.kind === "void" && expected.kind !== "void" && this.neverValued.has(expr)) return this.divergentValue(expr, expected); // Iterator-typed slots: native iterators and differently typed @@ -10854,6 +10862,37 @@ export class Lowerer { return fieldGetExpr(this, target, loc, blame); } + /** The undefined-armed slot read behind a checked deferred-init field + * read, or null. Consumers that observe undefined itself (equality, + * typeof, truthiness, nullish defaults) test the slot instead of the + * checked extraction, which throws for an unassigned field. */ + deferredFieldSlot(value: IrExpr): IrExpr | null { + // The inline form: `slot is undefined ? throw : narrow(slot)`. + if ( + value.kind === "ternary" && + value.cond.kind === "unionIsTag" && + value.else_.kind === "unionNarrow" && + value.else_.value === value.cond.value && + value.then.kind === "libCall" && + value.then.fn === "error.nodeThrow" + ) { + const read = value.cond.value; + return read.kind === "fieldGet" && + read.type.kind === "union" && + this.classes.get(read.className)?.deferredInitFields?.has(read.field) === true + ? read + : null; + } + if (value.kind !== "call" || value.args.length !== 1) return null; + const read = value.args[0]!; + if (read.kind !== "fieldGet" || read.type.kind !== "union") return null; + if (this.classes.get(read.className)?.deferredInitFields?.has(read.field) !== true) return null; + return this.coercions.checkedNarrows.has(value.callee) || + value.callee.startsWith("%deferred.read.") + ? read + : null; + } + fieldSetStmt(target: FieldTarget, value: IrExpr, loc: SrcLoc, blame: ts.Node): IrStmt { return fieldSetStmt(this, target, value, loc, blame); } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 709c2d014..2ef9a882c 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15550,6 +15550,12 @@ "/tests/corpus/4564-literal-choice-strings.ts" ], "diags": [] + }, + "/tests/corpus/4471-unassigned-field-reads.ts": { + "order": [ + "/tests/corpus/4471-unassigned-field-reads.ts" + ], + "diags": [] } } } diff --git a/packages/compiler/test/unassigned-field-reads.test.ts b/packages/compiler/test/unassigned-field-reads.test.ts new file mode 100644 index 000000000..5285c474a --- /dev/null +++ b/packages/compiler/test/unassigned-field-reads.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, serializeModule, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { type IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unassigned-fields-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { + outDir: dir, + outPath: output, + outputKind: "ir", + dynamic: false, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function body(llvm: string, symbol: string): string { + const found = new RegExp(`^define internal [^\\n]*@${symbol}\\([^]*?^}`, "m").exec(llvm); + expect(found, symbol).not.toBeNull(); + return found![0]; +} + +function fieldType(module: IrModule, className: string, field: string): string | undefined { + return module.classes?.find((cls) => cls.name === className)?.fields.find((f) => f.name === field) + ?.type.kind; +} + +test("only fields read before their assignment check their slot", async () => { + const module = await lower(` +class Part { size = 1; } +class Early { + first: Part; + second: Part; + constructor() { + this.first = new Part(); + Early.observe(this); + this.second = new Part(); + } + static observe(e: Early): void { console.log((e.second as Part | undefined) === undefined); } + total(): number { return this.first.size + this.second.size; } +} +class Plain { + first: Part; + constructor() { + this.first = new Part(); + this.describe(); + } + describe(): void { console.log(this.first.size); } +} +console.log(new Early().total()); +new Plain(); +`); + expect(fieldType(module, "Early", "first")).toBe("object"); + expect(fieldType(module, "Early", "second")).toBe("union"); + expect(fieldType(module, "Plain", "first")).toBe("object"); + const llvm = emitLlvmModule(deserializeModule(serializeModule(module)), { pointerBits: 64 }); + const total = body(llvm, "sc_bf__x25_Early_total"); + // The check is inline: no extraction call and no union box on the + // assigned path. + expect(total).not.toContain("@scr_union_new_ref"); + expect(total).not.toMatch(/call [^\n]*union_narrow/); +}); diff --git a/tests/corpus/4471-unassigned-field-reads.ts b/tests/corpus/4471-unassigned-field-reads.ts new file mode 100644 index 000000000..e8f3df983 --- /dev/null +++ b/tests/corpus/4471-unassigned-field-reads.ts @@ -0,0 +1,199 @@ +// Fields observed before the constructor assigns them read as undefined. +class Gauge { + level: number; + constructor(level: number) { + this.level = level; + } +} + +class Panel { + gauge: Gauge; + ready: boolean; + constructor() { + this.ready = Panel.inspectPanel(this); + this.gauge = new Gauge(3); + } + static inspectPanel(panel: Panel): boolean { + const g = panel.gauge as Gauge | undefined; + console.log("panel gauge present:", g !== undefined, typeof panel.gauge); + return panel.gauge === undefined; + } +} +const panel = new Panel(); +console.log(panel.ready, panel.gauge.level); + +// A base constructor calls an override that reads a subclass field. +class Widget { + name: string; + constructor(name: string) { + this.name = name; + this.describe(); + } + describe(): void { + console.log("widget", this.name); + } +} +class Slider extends Widget { + track: Gauge; + constructor() { + super("slider"); + this.track = new Gauge(7); + this.describe(); + } + override describe(): void { + if (this.track) console.log("slider track", this.track.level); + else console.log("slider track missing", this.track === undefined, this.track == null); + try { + console.log(this.track.level); + } catch (e) { + console.log("caught", (e as Error).message); + } + } +} +const slider = new Slider(); +console.log(slider.track.level); + +// A callback registered during construction runs before the assignment. +type Listener = (source: Station) => void; +class Station { + beacon: Gauge; + constructor(listener: Listener) { + listener(this); + this.beacon = new Gauge(11); + listener(this); + } +} +new Station((s) => console.log("beacon", s.beacon?.level, s.beacon ? "set" : "unset")); + +// Initializers run in declaration order, before the constructor body. +class Ledger { + first = this.peek("first"); + entry: Gauge = new Gauge(5); + later = this.peek("later"); + peek(label: string): number { + const e: Gauge | undefined = this.entry; + console.log(label, e === undefined ? "no entry" : e.level); + return e === undefined ? -1 : e.level; + } +} +const ledger = new Ledger(); +console.log(ledger.first, ledger.later, ledger.entry.level); + +// Writes through an unassigned field throw after evaluating the value. +class Relay { + target: Gauge; + constructor() { + try { + Relay.poke(this); + } catch (e) { + console.log("write", (e as Error).message); + } + this.target = new Gauge(1); + Relay.poke(this); + console.log("level", this.target.level); + } + static poke(r: Relay): void { + r.target.level = (console.log("value evaluated"), 9); + } +} +new Relay(); + +// Printing an unassigned field. +class Frame { + inner: Gauge; + constructor() { + console.log(Frame.show(this)); + this.inner = new Gauge(2); + console.log(Frame.show(this)); + } + static show(f: Frame): string { + const value: Gauge | undefined = f.inner; + return value === undefined ? "inner: undefined" : `inner: ${value.level}`; + } +} +new Frame(); + +// Ordinary classes assign before exposing the instance. +class Plain { + a: Gauge; + b: Gauge; + constructor() { + this.a = new Gauge(1); + this.b = new Gauge(this.a.level + 1); + this.report(); + } + report(): void { + console.log("plain", this.a.level, this.b.level); + } +} +new Plain(); + +// Private helpers that only assign fields keep them proven; a helper that +// hands the instance out does not. +class Engine { + private left!: Gauge; + private right!: Gauge; + late: Gauge; + constructor() { + this.setup(); + this.late = Engine.peekLate(this); + console.log("engine", this.left.level, this.right.level, this.late.level); + } + private setup(): void { + this.left = new Gauge(4); + this.right = new Gauge(this.left.level + 1); + } + static peekLate(e: Engine): Gauge { + console.log("late before", (e.late as Gauge | undefined) === undefined); + return new Gauge(9); + } +} +new Engine(); + +class Tracker { + #target: Gauge; + constructor() { + this.#announce(); + this.#target = new Gauge(6); + } + #announce(): void { + Tracker.seen.push(this); + } + level(): number | string { + const t: Gauge | undefined = this.#target; + return t === undefined ? "unset" : t.level; + } + static seen: Tracker[] = []; +} +const tracker = new Tracker(); +console.log("tracker", tracker.level(), Tracker.seen.length); + +// Member updates through an unassigned field throw Node's TypeErrors in +// Node's order: the value first for plain writes, the read for updates. +class Pair { + x = 1; + y = 2; + sum(): number { + return this.x + this.y; + } +} +class Updates { + v: Pair; + w: Pair; + constructor() { + Updates.poke(this); + this.v = new Pair(); + this.w = new Pair(); + Updates.poke(this); + } + static poke(b: Updates): void { + try { b.v.x = -b.w.y; } catch (e) { console.log("1", (e as Error).message); } + try { b.v.x += 3; } catch (e) { console.log("2", (e as Error).message); } + try { b.v.y = 7; } catch (e) { console.log("3", (e as Error).message); } + try { b.v.y = b.v.x * 2; } catch (e) { console.log("4", (e as Error).message); } + try { b.v.x++; } catch (e) { console.log("5", (e as Error).message); } + try { console.log(b.v.sum()); } catch (e) { console.log("6", (e as Error).message); } + console.log("state", b.v?.x, b.v?.y); + } +} +new Updates();