diff --git a/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts b/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts new file mode 100644 index 000000000..69db96201 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts @@ -0,0 +1,202 @@ +/* A record assertion used as the object of a member PRESENCE PROBE: + * `typeof (stored as R).member`, `(stored as R).member !== undefined`, + * `null != (stored as R).member`. + * + * Node erases a type assertion, so the read answers off the value actually + * stored: arms of the stored union that declare the member answer their + * slot, and the others answer undefined (native records are closed). An + * eager checked extraction of R at the assertion would instead trap on + * every other arm, turning the probe — code that exists precisely BECAUSE + * the stored value may be another arm — into a TypeError where Node + * answers "undefined". + * + * The assertion's extraction is therefore deferred to the member read. + * lowerAsExpression erases it for this use; property access answers the + * read per arm when `assertedMemberRead` can (arms that declare the member + * against arms that do not), and applies the extraction the assertion + * would otherwise have taken eagerly when it cannot + * (applyDeferredAssertion). + * + * Only a probe defers. A real use of the asserted value — the member + * called, compared by identity, or read in any other context — keeps the + * checked extraction, so a lying assertion still throws at the use + * (divergence 38's lying-assertion stance). Types don't lie; a probe + * simply isn't the use. + */ +import * as ts from "../../ts7/adapter.js"; +import { type IrExpr, type IrType, type SrcLoc, isUnitType, typeEquals } from "../../../ir/ir.js"; +import type { Lowerer } from "../lowerer.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { hostedStorageView } from "./hosted-narrowing.js"; + +/** The assertion node at the root of `node`, through parentheses; null + * when `node` is not an (`as` / ``) assertion. */ +export function assertedReceiverNode( + node: ts.Expression, +): ts.AsExpression | ts.TypeAssertion | null { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ts.isAsExpression(e) || ts.isTypeAssertion(e) ? e : null; +} + +/** The property access this assertion is the receiver of, through + * parentheses: `(u as R).member`. Null for any other use, and for the + * optional chain `(u as R)?.member` (a unit arm answers undefined there + * instead of Node's read-of-undefined TypeError). */ +function assertedReceiverAccess( + assertion: ts.AsExpression | ts.TypeAssertion, +): ts.PropertyAccessExpression | null { + let use: ts.Node = assertion; + for (;;) { + const parent = use.parent; + if (parent && ts.isParenthesizedExpression(parent) && parent.expression === use) { + use = parent; + continue; + } + break; + } + const access = use.parent; + return access && + ts.isPropertyAccessExpression(access) && + access.expression === use && + access.questionDotToken === undefined + ? access + : null; +} + +/** An operand that names the absent values (`undefined`, `null`), through + * parentheses and the `void 0` spelling. */ +function isAbsenceLiteral(node: ts.Expression): boolean { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (e.kind === ts.SyntaxKind.NullKeyword) return true; + if (ts.isIdentifier(e)) return e.text === "undefined"; + return ts.isVoidExpression(e) && ts.isNumericLiteral(e.expression) && e.expression.text === "0"; +} + +/** The property access this assertion is the receiver of when the read is + * a presence probe: the operand of `typeof`, or an operand of `===` / + * `!==` / `==` / `!=` against undefined or null. Null for the optional + * chain and for every real use (a call's callee, an identity comparison, + * a read that flows into a typed slot), which keep the assertion's checked + * extraction. */ +export function assertedProbeAccessOf( + assertion: ts.AsExpression | ts.TypeAssertion, +): ts.PropertyAccessExpression | null { + const access = assertedReceiverAccess(assertion); + if (access === null) return null; + let use: ts.Node = access; + while (ts.isParenthesizedExpression(use.parent) && use.parent.expression === use) + use = use.parent; + const parent = use.parent; + if (parent === undefined) return null; + if (ts.isTypeOfExpression(parent) && parent.expression === use) return access; + if (ts.isBinaryExpression(parent)) { + const comparable = parent.left === use || (parent.right === use && parent.left !== use); + if (!comparable) return null; + const op = parent.operatorToken.kind; + const equality = + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.EqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + if (!equality) return null; + const other = parent.left === use ? parent.right : parent.left; + if (isAbsenceLiteral(other)) return access; + } + return null; +} + +/** True when the union's record arms read `member` unevenly: at least one + * declares it and at least one does not. Those are exactly the arms + * assertedMemberRead answers per arm; anything else keeps the assertion's + * extraction (and with it the checked-narrow fence for a lying one). */ +export function partialRecordMember(lowerer: Lowerer, union: IrType, member: string): boolean { + if (union.kind !== "union") return false; + const arms = lowerer.unions.get(union.unionId)?.arms ?? []; + let records = 0; + let declaring = 0; + for (const arm of arms) { + if (isUnitType(arm)) continue; + if (arm.kind !== "record") return false; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple || shape.indexValue) return false; + records++; + if (shape.fields.some((field) => field.name === member)) declaring++; + } + return records > 0 && declaring > 0 && declaring < records; +} + +/** The asserted record type whose checked extraction is deferred for the + * probe `access`, or null when the read keeps the ordinary assertion + * lowering. `value` is the assertion's lowered operand. */ +export function deferredMemberReadTarget( + lowerer: Lowerer, + assertion: ts.AsExpression | ts.TypeAssertion, + access: ts.PropertyAccessExpression, + value: IrExpr, +): IrType | null { + if (assertedProbeAccessOf(assertion) !== access) return null; + if (value.type.kind !== "union") return null; + const target = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(assertion.type)); + if (target?.kind !== "record") return null; + if (!partialRecordMember(lowerer, value.type, access.name.text)) return null; + return target; +} + +/** Whether the property access `expr` is a probe read through an assertion + * that the per-arm answer claims (the same shape deferredMemberReadTarget + * recognizes). Checker-derived field targets must not claim it first: the + * field target would apply the assertion's checked extraction and trap on + * the arm the probe exists to detect. */ +export function deferredProbeReadAccess( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): boolean { + const assertion = assertedReceiverNode(access.expression); + if (assertion === null) return false; + if (assertedProbeAccessOf(assertion) !== access) return false; + const value = tryLowerExpression(lowerer, access.expression); + return value !== null && deferredMemberReadTarget(lowerer, assertion, access, value) !== null; +} + +/** The stored value of an `in` probe whose receiver is an erased assertion + * (`"stream" in (stored as R)`): Node asks membership of the value actually + * stored, which for a union of fixed record shapes the union path answers + * per arm. Null when the receiver is not such an assertion, or the stored + * value is not a union of fixed record shapes (those keep the ordinary + * lowering, and its refusals). */ +export function assertedInOperand(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + let rhs = node; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (!ts.isAsExpression(rhs) && !ts.isTypeAssertion(rhs)) return null; + const stored = tryLowerExpression(lowerer, rhs.expression); + if (stored === null || stored.type.kind !== "union") return null; + const arms = lowerer.unions.get(stored.type.unionId)?.arms ?? []; + const fixed = arms.every((arm) => { + if (arm.kind !== "record") return false; + const shape = lowerer.shapes.get(arm.shapeId); + return shape !== undefined && !shape.tuple && !shape.indexValue; + }); + return arms.length > 0 && fixed ? stored : null; +} + +/** Apply the extraction lowerAsExpression would have applied to the + * assertion's operand, for a member read with no per-arm answer. */ +export function applyDeferredAssertion( + lowerer: Lowerer, + value: IrExpr, + target: IrType, + loc: SrcLoc, +): IrExpr { + if (value.type.kind === "union" && target.kind !== "union" && !typeEquals(target, value.type)) { + const helper = lowerer.narrowedArmHelper(value.type.unionId, target, loc); + if (helper !== null) return lowerer.checkedNarrowCall(helper, value, target, loc); + } + if (target.kind === "union" || target.kind === "record") { + const hosted = hostedStorageView(lowerer, value, target, loc); + if (hosted) return hosted; + } + lowerer.assertedValues.set(value, target); + return value; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 24c3e80c6..e95ed65b4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -56,6 +56,7 @@ import { assertsGlobalObject, extractionCopiesReference } from "../assertion-ide import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js"; import { isDeclBoundaryTypeNode } from "../../decl-projection.js"; import { isMessageListenerCall } from "./message-listeners.js"; +import { assertedProbeAccessOf, deferredMemberReadTarget } from "./asserted-receiver.js"; /** `c ? a : b`. An explicit destination constructs each branch at that * layout before joining it. Without one, retain the expression's inferred @@ -1407,6 +1408,17 @@ export function lowerAsExpression( if (inner.type.kind !== "dyn") { const targetTs0 = lowerer.checker.getTypeFromTypeNode(expr.type); const target = lowerer.mapTypeOf(targetTs0); + // A PRESENCE PROBE of a member read through the assertion (`typeof + // (u as R).member`, `(u as R).member !== undefined`): the read answers + // off the value actually stored, since Node erases the assertion and an + // arm that does not declare the member answers undefined. The read + // answers that per arm (assertedMemberRead); an eager extraction of R + // here would trap first and turn the probe into a TypeError. Defer to + // the read (asserted-receiver.ts); every real use keeps the extraction. + { + const access = assertedProbeAccessOf(expr); + if (access && deferredMemberReadTarget(lowerer, expr, access, inner) !== null) return inner; + } if (target?.kind === "object") { const asserted = checkedClassAssertion(lowerer, inner, target, locOf(expr)); if (asserted) return asserted; diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e15204324..1799febd2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -125,6 +125,12 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../. import { typedArrayLacksMember } from "../builtins/typed-array-members.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { + applyDeferredAssertion, + assertedReceiverNode, + deferredMemberReadTarget, + deferredProbeReadAccess, +} from "./asserted-receiver.js"; import { lowerRecordInheritedMember } from "../record-inherited.js"; import { DATE_METHOD_HINT } from "../builtins/dates.js"; import { @@ -1398,6 +1404,25 @@ export function lowerPropertyAccessExpr( // receiver that lowers cleanly means the MEMBER is the gap — name // the property and the receiver's type instead of "property access". let recvLowered = lowerer.lowerExpr(expr.expression); + // A PRESENCE PROBE of a member read through an erased assertion + // (`typeof (u as R).member`, `(u as R).member !== undefined`): Node + // reads the property off the value actually stored, so an arm that does + // not declare it answers undefined. lowerAsExpression deferred the + // assertion's checked extraction for this read; answer it per arm + // (assertedMemberRead), and only a member with no per-arm answer applies + // the extraction the assertion would otherwise have taken (a lying + // assertion still throws on a real use, never on the probe). + { + const assertion = assertedReceiverNode(expr.expression); + const target = assertion + ? deferredMemberReadTarget(lowerer, assertion, expr, recvLowered) + : null; + if (target !== null) { + const read = assertedMemberRead(lowerer, expr, recvLowered, loc); + if (read) return read; + recvLowered = applyDeferredAssertion(lowerer, recvLowered, target, loc); + } + } // A receiver whose value union holds record types the checker's subtype // reduction folded into its type (`{ status } | { status, value }` typed // `{ status }`, a call of a function with an inferred return type): read @@ -3145,6 +3170,12 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi }; } } + // A presence probe of a member read through a record assertion + // (`typeof (u as R).member`): the field target comes from the ASSERTED + // checker type and would apply the checked extraction, trapping on the + // very arm the probe exists to detect. Leave the read to the per-arm + // answer in lowerPropertyAccessExpr (asserted-receiver.ts). + if (deferredProbeReadAccess(lowerer, expr)) return null; const target = lowerer.fieldTarget(expr); if (target) return lowerer.fieldGetExpr(target, locOf(expr), expr); if (expr.questionDotToken) return null; diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 7cc573085..9c3fbaaa9 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -50,6 +50,7 @@ import { type ClassInfo, storedClassValueType } from "../lower-classes.js"; import { classInstanceOf } from "../class-dynamic-dispatch.js"; import { InternalCompilerError } from "../../../errors.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { assertedInOperand } from "./asserted-receiver.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { checkedClassInstanceOf } from "../class-construction.js"; import { unsupportedDiag } from "../../../diagnostics/diagnostic.js"; @@ -1672,7 +1673,11 @@ export function lowerInExpression( if (probed) return probed; } } - let recv = lowerer.lowerExpr(expr.right); + // `"stream" in (stored as R)` probes the value actually stored (Node + // erases the assertion): a union of fixed record shapes answers per arm + // below, so the assertion's checked extraction must not run first (it + // would trap on the very arm the probe exists to detect). + let recv = assertedInOperand(lowerer, expr.right) ?? lowerer.lowerExpr(expr.right); const classInfo = recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined; if ( diff --git a/packages/compiler/test/asserted-arm-probe-emission.test.ts b/packages/compiler/test/asserted-arm-probe-emission.test.ts new file mode 100644 index 000000000..cddd7f2ee --- /dev/null +++ b/packages/compiler/test/asserted-arm-probe-emission.test.ts @@ -0,0 +1,96 @@ +// A feature-detection probe of a member read through a type assertion must +// answer off the value actually stored (Node erases `as`), never throw the +// checked-extraction TypeError. A real use of the same read keeps the +// checked conversion ("types don't lie": a lying assertion throws at the +// use, not at the probe). +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, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface Streams { stream: () => string; fetch?: () => string }", + "interface Options { name: string; api: Streams | Record }", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-asserted-arm-probe-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("typeof probe of a member read through an assertion reads per arm", async () => { + const compiled = await compileToIr([ + "function detect(input: Options): boolean {", + ' return typeof (input.api as Streams).stream === "function";', + "}", + 'console.log(detect({ name: "one", api: { stream: () => "one" } }));', + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // A checked extraction of the asserted arm would trap on the record arm; + // the probe answers from the stored union's runtime tag instead. + const detect = bodies(compiled.module!, "detect"); + expect(detect).toContain('"unionIsTag"'); + expect(detect).not.toContain('"nodeThrow"'); +}); + +test("undefined and null presence probes read per arm", async () => { + for (const probe of [ + "(input.api as Streams).stream !== undefined", + "(input.api as Streams).stream == null", + ]) { + const compiled = await compileToIr([ + "function detect(input: Options): boolean {", + ` return ${probe};`, + "}", + 'console.log(detect({ name: "one", api: { stream: () => "one" } }));', + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const detect = bodies(compiled.module!, "detect"); + expect(detect).not.toContain('"nodeThrow"'); + } +}); + +test("a real use of the asserted member keeps the checked extraction", async () => { + const compiled = await compileToIr([ + "function call(input: Options): string {", + " return (input.api as Streams).stream();", + "}", + 'console.log(call({ name: "one", api: { stream: () => "one" } }));', + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // The real use still applies the assertion's checked extraction: an arm + // that is not Streams throws at the read (the trap is the nodeThrow). + expect(bodies(compiled.module!, "call")).toMatch(/union\.narrow|nodeThrow/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/asserted-arm-probe.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/asserted-arm-probe.ts.json new file mode 100644 index 000000000..db640597f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/asserted-arm-probe.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/asserted-arm-probe.ts" + ], + "diags": [] +} diff --git a/tests/corpus/asserted-arm-probe.ts b/tests/corpus/asserted-arm-probe.ts new file mode 100644 index 000000000..5219bda0b --- /dev/null +++ b/tests/corpus/asserted-arm-probe.ts @@ -0,0 +1,84 @@ +// Feature-detection probes of a member read through a type assertion. +// +// `input.api` is `Streams | Partial>` (optional) or +// `Streams | Record` (required). Node erases `as`, so +// `(input.api as Streams).stream` reads the property off the value actually +// stored: when that is the record arm, the read answers undefined. The +// probe therefore answers "undefined" (false), where an eager checked +// extraction of Streams would throw on the record arm. A real use of the +// same read — calling the member when the wrong arm is stored — still +// throws, at the call. +type Api = "alpha" | "beta"; + +interface Streams { + stream: () => string; + fetch?: () => string; +} + +interface Options { + name: string; + api?: Streams | Partial>; +} + +interface Required { + name: string; + api: Streams | Record; +} + +function detectTypeof(input: Options): boolean { + return typeof (input.api as Streams).stream === "function"; +} + +function detectUndefined(input: Options): boolean { + return (input.api as Streams).stream !== undefined; +} + +function detectNullish(input: Options): boolean { + return (input.api as Streams).stream == null; +} + +function detectRequired(input: Required): boolean { + return typeof (input.api as Streams).stream === "function"; +} + +function requiredIn(input: Required): boolean { + return "stream" in (input.api as Streams); +} + +// The probe guards the real use: the call runs only on the arm that has +// the member, so the assertion never lies at the call. +function streamName(input: Options): string { + if (typeof (input.api as Streams).stream === "function") return (input.api as Streams).stream(); + return "none"; +} + +// A real use that reaches the wrong arm throws, as it does in Node. +function callStream(input: Options): string { + try { + return (input.api as Streams).stream(); + } catch (err) { + return err instanceof TypeError ? "TypeError" : "other"; + } +} + +const one: Streams = { stream: () => "one" }; +const withFetch: Streams = { stream: () => "both", fetch: () => "fetch" }; +const registry: Partial> = { alpha: one, beta: withFetch }; +const empty: Partial> = {}; +const required: Record = { alpha: one, beta: withFetch }; + +console.log(detectTypeof({ name: "one", api: one })); +console.log(detectTypeof({ name: "registry", api: registry })); +console.log(detectTypeof({ name: "empty", api: empty })); +console.log(detectUndefined({ name: "one", api: one })); +console.log(detectUndefined({ name: "registry", api: registry })); +console.log(detectNullish({ name: "one", api: one })); +console.log(detectNullish({ name: "registry", api: empty })); +console.log(streamName({ name: "one", api: one })); +console.log(streamName({ name: "registry", api: registry })); +console.log(detectRequired({ name: "one", api: one })); +console.log(detectRequired<"alpha" | "beta">({ name: "registry", api: required })); +console.log(requiredIn({ name: "one", api: one })); +console.log(requiredIn<"alpha" | "beta">({ name: "registry", api: required })); +console.log(callStream({ name: "one", api: withFetch })); +console.log(callStream({ name: "registry", api: registry })); diff --git a/tests/harness/corpus-costs/asserted-arm-probe.ts.json b/tests/harness/corpus-costs/asserted-arm-probe.ts.json new file mode 100644 index 000000000..4b6a7c664 --- /dev/null +++ b/tests/harness/corpus-costs/asserted-arm-probe.ts.json @@ -0,0 +1 @@ +{ "plain": 5031, "san": 5482 }