diff --git a/packages/compiler/src/backend/c/stmts.ts b/packages/compiler/src/backend/c/stmts.ts index 19b4813c2..8f1ab1e7b 100644 --- a/packages/compiler/src/backend/c/stmts.ts +++ b/packages/compiler/src/backend/c/stmts.ts @@ -660,7 +660,12 @@ function emitStmtBody(emitter: CEmitter, s: IrStmt): void { // value and remove the slot from this return's own unwind path. let pendingEntry: { index: number; entry: (typeof emitter.scopes)[number][number] } | null = null; let value: ReturnType | null = null; - if (s.value) value = emitter.emitExpr(s.value); + if (s.value) { + const emitted = emitter.emitExpr(s.value); + // A void expression still runs before finally, but has no C value + // to park in the pending-return slot (or return from the function). + if (s.value.type.kind !== "void") value = emitted; + } const pendingIndex = emitter.pendingReturnScopeIndex; if (pendingIndex !== null && isRefCounted(emitter.currentReturnType)) { const scope = emitter.scopes[pendingIndex]!; diff --git a/packages/compiler/src/frontend/literal-values.test.ts b/packages/compiler/src/frontend/literal-values.test.ts new file mode 100644 index 000000000..8c3561465 --- /dev/null +++ b/packages/compiler/src/frontend/literal-values.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "vitest"; +import { literalValues } from "./literal-values.js"; +import { SemanticSnapshot } from "./ts7/semantic-model.js"; +import { SemanticTypeFlags as TypeFlags, type SemanticTypeData } from "./ts7/semantic-schema.generated.js"; + +function harness() { + const members = new Map(); + const snapshot = new SemanticSnapshot(1, { + text(method, payload) { + const query = JSON.parse(payload) as { objectId: number }; + if (method !== "getTypesOfType" || !members.has(query.objectId)) throw new Error(`unexpected request: ${method}`); + return JSON.stringify(members.get(query.objectId)); + }, + binary(method) { throw new Error(`unexpected request: ${method}`); }, + }); + const project = snapshot.addProject("literal-values", () => undefined); + let id = 0; + const type = (flags: number, value?: string | number | boolean) => project.type({ + id: ++id, flags, ...(value === undefined ? {} : { value }), + }); + const union = (parts: SemanticTypeData[]) => { + parts.forEach((part) => project.type(part)); + const unionId = 1000 + id++; + members.set(unionId, parts); + return project.type({ id: unionId, flags: TypeFlags.Union }); + }; + return { type, union }; +} + +describe("checker literal values", () => { + test.each(["", "plain", "constructor", "__proto__", "\u0000", "line\nfeed", "é😀"])("retains string %j exactly", (value) => { + const { type } = harness(); + expect(literalValues(type(TypeFlags.StringLiteral, value))).toEqual([value]); + }); + + test.each([0, -0, -2.5, Number.MAX_VALUE, Number.MIN_VALUE])("retains finite number %s", (value) => { + const { type } = harness(); + const result = literalValues(type(TypeFlags.NumberLiteral, value)); + expect(result).toEqual([value]); + expect(Object.is(result![0], value)).toBe(true); + }); + + test.each([true, false])("retains boolean %s without truthiness conversion", (value) => { + const { type } = harness(); + expect(literalValues(type(TypeFlags.BooleanLiteral, value))).toEqual([value]); + }); + + test.each([NaN, Infinity, -Infinity])("refuses nonfinite number %s", (value) => { + const { type } = harness(); + expect(literalValues(type(TypeFlags.NumberLiteral, value))).toBeNull(); + }); + + test.each([ + TypeFlags.String, TypeFlags.Number, TypeFlags.Boolean, TypeFlags.BigInt, + TypeFlags.Any, TypeFlags.Unknown, TypeFlags.Never, TypeFlags.Void, + TypeFlags.Null, TypeFlags.Undefined, TypeFlags.ESSymbol, TypeFlags.Object, + TypeFlags.TypeParameter, TypeFlags.TemplateLiteral, + ])("refuses nonliteral flag %s", (flags) => { + const { type } = harness(); + expect(literalValues(type(flags))).toBeNull(); + }); + + test("preserves each scalar kind in a finite union", () => { + const { union } = harness(); + const type = union([ + { id: 1, flags: TypeFlags.StringLiteral, value: "1" }, + { id: 2, flags: TypeFlags.NumberLiteral, value: 1 }, + { id: 3, flags: TypeFlags.BooleanLiteral, value: false }, + { id: 4, flags: TypeFlags.StringLiteral, value: "false" }, + ]); + expect(literalValues(type)).toEqual(["1", 1, false, "false"]); + expect(literalValues(type)).toEqual(["1", 1, false, "false"]); + }); + + test("refuses an empty union instead of claiming a known variant", () => { + expect(literalValues(harness().union([]))).toBeNull(); + }); + + test.each([TypeFlags.String, TypeFlags.Number, TypeFlags.Undefined, TypeFlags.Unknown, TypeFlags.Object])( + "one nonliteral union member (%s) invalidates the complete candidate set", (flags) => { + const { union } = harness(); + expect(literalValues(union([ + { id: 1, flags: TypeFlags.StringLiteral, value: "first" }, + { id: 2, flags }, + { id: 3, flags: TypeFlags.StringLiteral, value: "last" }, + ]))).toBeNull(); + }, + ); + + test("does not return the finite prefix before an infinite member", () => { + const { union } = harness(); + expect(literalValues(union([ + { id: 1, flags: TypeFlags.NumberLiteral, value: 1 }, + { id: 2, flags: TypeFlags.NumberLiteral, value: Infinity }, + ]))).toBeNull(); + }); + + test("excludes bigint literals from the scalar discriminator ABI", () => { + const { type } = harness(); + expect(literalValues(type(TypeFlags.BigIntLiteral, "123456789012345678901234567890"))).toBeNull(); + }); +}); diff --git a/packages/compiler/src/frontend/literal-values.ts b/packages/compiler/src/frontend/literal-values.ts new file mode 100644 index 000000000..bfd89bd70 --- /dev/null +++ b/packages/compiler/src/frontend/literal-values.ts @@ -0,0 +1,17 @@ +import * as ts from "./ts7/adapter.js"; +import type { UnionLiteral } from "./union-discriminants.js"; + +/** Preserve finite literal values before mapping widens their IR types. */ +export function literalValues(type: ts.Type): UnionLiteral[] | null { + const values: UnionLiteral[] = []; + for (const part of type.isUnionType() ? ts.constituentTypes(type) : [type]) { + if (part.flags & ts.TypeFlags.StringLiteral) values.push((part as ts.StringLiteralType).value); + else if (part.flags & ts.TypeFlags.NumberLiteral) { + const value = (part as ts.NumberLiteralType).value; + if (!Number.isFinite(value)) return null; + values.push(value); + } else if (part.flags & ts.TypeFlags.BooleanLiteral) values.push((part as ts.BooleanLiteralType).value); + else return null; + } + return values.length > 0 ? values : null; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index c76f3ef8e..ad3e317ea 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -316,7 +316,7 @@ export function lowerOptionalChain(lowerer: Lowerer, expr: ts.CallExpression | t } const narrowed = rest.length === 1 ? rest[0]! - : { kind: "union" as const, unionId: lowerer.unions.intern(rest) }; + : { kind: "union" as const, unionId: lowerer.unions.transform(def, rest) }; const id = `chain.${lowerer.chainCounter++}`; // A single present arm binds its payload. Multiple present arms bind // the original tagged value, then retag inside the guarded body. The diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index bc7c97a0e..2c542c8ff 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -923,11 +923,12 @@ function lowerArraySearchValues( const scalarNeedle = needleArms.every((arm) => arm.kind === "f64" || arm.kind === "string" || arm.kind === "bool" || arm.kind === "bigint" || arm.kind === "symbol" || isUnitType(arm)); - const valueArms = lowerer.unions.get(valueT.unionId)!.arms; + const valueDef = lowerer.unions.get(valueT.unionId)!; + const valueArms = valueDef.arms; if (scalarNeedle && !valueArms.some((arm) => arm.kind === "func" || arm.kind === "set")) { const arms = [...valueArms]; for (const arm of needleArms) if (!arms.some((existing) => typeEquals(existing, arm))) arms.push(arm); - if (arms.length !== valueArms.length) valueT = { kind: "union", unionId: lowerer.unions.intern(arms) }; + if (arms.length !== valueArms.length) valueT = { kind: "union", unionId: lowerer.unions.transform(valueDef, arms) }; } const directNeedle = method === "lastIndexOf" && elem.kind !== "union" && typeEquals(needle.type, elem); const searchNeedle = directNeedle ? needle : lowerer.coerceInto(argNode, needle, valueT); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index f32e5e647..0283a92fc 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -6517,7 +6517,10 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr } const arms = [...byKey.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); if (arms.length > 1) { - target = { kind: "union", unionId: lowerer.unions.intern(arms) }; + const source = right.type.kind === "union" ? lowerer.unions.get(right.type.unionId) : undefined; + target = { kind: "union", unionId: source + ? lowerer.unions.transform(source, arms) + : lowerer.unions.intern(arms) }; } } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index baf40c851..cb7c80fc3 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -29,6 +29,8 @@ import { resolve } from "node:path"; import { bindingSource } from "../binding-source.js"; import { tsgoPath } from "../dts-paths.js"; import * as ts from "../ts7/adapter.js"; +import { literalValues } from "../literal-values.js"; +import { literalUnionArm } from "../union-discriminants.js"; import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import { anyOpRequiresDynamicDiag, @@ -7658,6 +7660,23 @@ export class Lowerer { * lowering goes through here (via lowerExprExpecting) or calls this * directly when the expression was already lowered. */ coerceInto(node: ts.Node, expr: IrExpr, expected: IrType): IrExpr { + // A fresh literal can retain a wider runtime-optional field after its + // initial contextual layout was chosen. Its known discriminator still + // selects the destination arm; validate that payload before wrapping. + // Work with the already lowered value so property effects run once. + if (expr.type.kind === "record" && expected.kind === "union") { + let literal = node; + while (ts.isParenthesizedExpression(literal)) literal = literal.expression; + const def = this.unions.get(expected.unionId); + if (def?.discriminant && ts.isObjectLiteralExpression(literal)) { + const property = this.checker.getPropertyOfType(this.typeOf(literal), def.discriminant.field); + if (property && !(property.flags & (ts.SymbolFlags.Optional | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor))) { + const values = literalValues(this.checker.getTypeOfSymbol(property)); + const arm = values && literalUnionArm(def, values, (id) => this.shapes.get(id)); + if (arm) expr = this.coerceInto(node, expr, arm); + } + } + } if (expected.kind === "dyn") { const contextual = this.checker.getContextualType(node); const parts = contextual?.isUnionType() ? ts.constituentTypes(contextual) : contextual ? [contextual] : []; @@ -8109,7 +8128,7 @@ export class Lowerer { const rest = def.arms.filter((a) => a.kind !== "undefinedT"); if (rest.length === 1) return rest[0]!; // Removing an arm keeps canonical (typeKey-sorted) order. - return { kind: "union", unionId: this.unions.intern(rest) }; + return { kind: "union", unionId: this.unions.transform(def, rest) }; } /** The interned `T | undefined` union over a non-union arm type — the ABI diff --git a/packages/compiler/src/frontend/lowering/union-retag.ts b/packages/compiler/src/frontend/lowering/union-retag.ts index d36bdc4bb..eae0e86dc 100644 --- a/packages/compiler/src/frontend/lowering/union-retag.ts +++ b/packages/compiler/src/frontend/lowering/union-retag.ts @@ -2,6 +2,7 @@ import { InternalCompilerError } from "../../errors.js"; import { BOOL, F64, STRING, isUnitType, typeEquals } from "../../ir/ir.js"; import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType, IrUnionDef, SrcLoc } from "../../ir/ir.js"; import type { WidthLift } from "./width-lift.js"; +import { discriminantField, discriminantOwners } from "../union-discriminants.js"; type Literal = string | number | boolean; @@ -16,53 +17,6 @@ export type UnionRetagArm = | { kind: "direct"; route: UnionRetagRoute } | { kind: "discriminant"; field: string; fieldType: IrType; routes: UnionRetagRoute[] }; -/** Read only data fields whose literal comparisons have a native ABI. - * Accessors and optional/dynamic slots must not acquire an implicit read - * or an unchecked interpretation while selecting a destination layout. */ -function discriminantField(shape: IrRecordShape | undefined, field: string): IrType | null { - if (field.startsWith("%") || !shape || shape.tuple) return null; - if (shape.fields.some((entry) => entry.name === `%get:${field}` || entry.name === `%set:${field}`)) return null; - const type = shape.fields.find((entry) => entry.name === field)?.type; - return type && (type.kind === "string" || type.kind === "f64" || type.kind === "bool") ? type : null; -} - -function matchesLiteral(type: IrType, value: Literal): boolean { - if (type.kind === "string") return typeof value === "string"; - if (type.kind === "bool") return typeof value === "boolean"; - return type.kind === "f64" && typeof value === "number" && Number.isFinite(value); -} - -/** Check the metadata independently of the width relation. A missing or - * ambiguous literal cannot justify choosing the first compatible arm. - * JSON keys distinguish 1, "1", true and "true", including unusual strings. */ -function discriminantOwners( - union: IrUnionDef, - shapeOf: (id: string) => IrRecordShape | undefined, -): Map | null { - const discriminant = union.discriminant; - if (!discriminant) return null; - const owners = new Map(); - const tags = new Set(); - for (const entry of discriminant.cases) { - const arm = union.arms[entry.tag]; - if (!Number.isInteger(entry.tag) || !arm || arm.kind !== "record" || tags.has(entry.tag)) return null; - tags.add(entry.tag); - const type = discriminantField(shapeOf(arm.shapeId), discriminant.field); - if (!type || entry.values.length === 0) return null; - for (const value of entry.values) { - if (!matchesLiteral(type, value)) return null; - const key = JSON.stringify(value); - const previous = owners.get(key); - if (previous !== undefined && previous !== entry.tag) return null; - owners.set(key, entry.tag); - } - } - for (let tag = 0; tag < union.arms.length; tag++) { - if (union.arms[tag]!.kind === "record" && !tags.has(tag)) return null; - } - return owners; -} - /** Pure conversion planning. A source storage tag need not denote just * one semantic variant: refinements can coalesce recursive record layouts. * When both unions name the same discriminant, its values select target diff --git a/packages/compiler/src/frontend/ts7/semantic-types.ts b/packages/compiler/src/frontend/ts7/semantic-types.ts index c6ea953f3..f1655e923 100644 --- a/packages/compiler/src/frontend/ts7/semantic-types.ts +++ b/packages/compiler/src/frontend/ts7/semantic-types.ts @@ -7,7 +7,9 @@ export type { SemanticType as UnionOrIntersectionType, SemanticType as UnionType, SemanticTupleType as TupleType, - SemanticTupleType as TupleTypeReference, + // A reference carries its element flags on the target, not necessarily on + // itself. Claiming a tuple shape here makes a native presence test constant. + SemanticObjectType as TupleTypeReference, SemanticStringLiteralType as StringLiteralType, SemanticNumberLiteralType as NumberLiteralType, SemanticBooleanLiteralType as BooleanLiteralType, diff --git a/packages/compiler/src/frontend/type-mapper.test.ts b/packages/compiler/src/frontend/type-mapper.test.ts index ceaecce41..dba12355c 100644 --- a/packages/compiler/src/frontend/type-mapper.test.ts +++ b/packages/compiler/src/frontend/type-mapper.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { F64, STRING, UNDEFINED_T, VOID, mapOf, setOf, type IrType } from "../ir/ir.js"; +import { F64, NULL_T, STRING, UNDEFINED_T, VOID, mapOf, setOf, type IrType, type IrUnionDef } from "../ir/ir.js"; import { formatIrType, genResultRecord, ShapeRegistry, UnionRegistry, withUndefinedArm } from "./type-mapper.js"; describe("nullable collection union builders", () => { @@ -135,3 +135,85 @@ describe("union discriminator identity", () => { expect(new Set([numeric, string, bool]).size).toBe(3); }); }); + +describe("canonical optional unions", () => { + function fixture() { + const registry = new UnionRegistry(); + const arms: IrType[] = [{ kind: "record", shapeId: "a" }, { kind: "record", shapeId: "b" }]; + const id = registry.intern(arms, { + field: "kind", cases: [{ tag: 0, values: ["empty", "none"] }, { tag: 1, values: ["value"] }], + }); + return { registry, arms, id, source: registry.get(id)! }; + } + + test("adding and removing undefined recovers the semantic union identity", () => { + const { registry, arms, id, source } = fixture(); + const required: IrType = { kind: "union", unionId: id }; + const optional = withUndefinedArm(required, registry); + if (optional?.kind !== "union") throw new Error("missing optional union"); + const definition = registry.get(optional.unionId)!; + expect(definition.arms).toEqual([...arms, UNDEFINED_T]); + expect(definition.discriminant).toEqual(source.discriminant); + expect(withUndefinedArm(optional, registry)).toBe(optional); + expect(registry.transform(definition, definition.arms.filter((arm) => arm.kind !== "undefinedT"))).toBe(id); + expect(registry.unions).toHaveLength(2); + }); + + test("structural and semantic optional unions stay distinct", () => { + const { registry, arms, source } = fixture(); + const structural = registry.intern([...arms, UNDEFINED_T]); + const semantic = registry.transform(source, [...arms, UNDEFINED_T]); + expect(semantic).not.toBe(structural); + expect(registry.get(structural)!.discriminant).toBeUndefined(); + expect(registry.get(semantic)!.discriminant).toEqual(source.discriminant); + }); + + test("independent literal contracts retain distinct optional identities", () => { + const { registry, arms, source } = fixture(); + const otherId = registry.intern(arms, { + field: "kind", cases: [{ tag: 0, values: ["missing"] }, { tag: 1, values: ["present"] }], + }); + const first = registry.transform(source, [...arms, UNDEFINED_T]); + const second = registry.transform(registry.get(otherId)!, [...arms, UNDEFINED_T]); + expect(first).not.toBe(second); + expect(registry.transform(registry.get(second)!, arms)).toBe(otherId); + expect(registry.transform(registry.get(first)!, arms)).toBe(source.id); + }); + + test("scalar insertion shifts every semantic tag without reassigning aliases", () => { + const { registry, arms, source } = fixture(); + const wide = registry.transform(source, [F64, NULL_T, ...arms, STRING, UNDEFINED_T]); + expect(registry.get(wide)!.discriminant).toEqual({ field: "kind", cases: [ + { tag: 2, values: ["empty", "none"] }, { tag: 3, values: ["value"] }, + ] }); + const narrow = registry.transform(registry.get(wide)!, [arms[1]!, UNDEFINED_T]); + expect(registry.get(narrow)!.discriminant).toEqual({ field: "kind", cases: [{ tag: 0, values: ["value"] }] }); + expect(registry.transform(registry.get(wide)!, arms)).toBe(source.id); + }); + + test("adding a record discards an incomplete semantic contract", () => { + const { registry, arms, source } = fixture(); + const combined = [...arms, { kind: "record" as const, shapeId: "new" }]; + expect(registry.transform(source, combined)).toBe(registry.intern(combined)); + expect(registry.get(registry.transform(source, combined))!.discriminant).toBeUndefined(); + }); + + test("transformed case arrays do not alias source metadata", () => { + const { registry, arms, source } = fixture(); + const transformed = registry.get(registry.transform(source, [...arms, UNDEFINED_T]))!; + expect(transformed.discriminant).not.toBe(source.discriminant); + expect(transformed.discriminant!.cases).not.toBe(source.discriminant!.cases); + expect(transformed.discriminant!.cases[0]!.values).not.toBe(source.discriminant!.cases[0]!.values); + }); + + test("plain scalar and undiscriminated record transformations remain plain", () => { + const registry = new UnionRegistry(); + for (const arms of [[F64, STRING], [{ kind: "record", shapeId: "a" } as IrType, NULL_T]]) { + const plain: IrUnionDef = registry.get(registry.intern(arms))!; + const optional = withUndefinedArm({ kind: "union", unionId: plain.id }, registry); + if (optional?.kind !== "union") throw new Error("missing optional union"); + expect(registry.get(optional.unionId)!.discriminant).toBeUndefined(); + expect(registry.transform(registry.get(optional.unionId)!, arms)).toBe(plain.id); + } + }); +}); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 301b2630d..1ebfdd942 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4,6 +4,8 @@ import { bodyReadsArguments } from "./arguments-usage.js"; import type { IrRecordShape, IrType, IrUnionDef, IrUnionDiscriminant } from "../ir/ir.js"; import { BYTES_ELEMENT_NAME, arrayOf, BOOL, bytesOf, canConvertToDyn, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DYN, F64, funcOf, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, JSVAL, mapOf, NULL_T, PROCSTREAM_T, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, setOf, STRING, SYMBOL_T, typeEquals, typeKey, unionContainerArmsOk, UNDEFINED_T, VOID } from "../ir/ir.js"; import { BIGINT_T } from "../ir/ir.js"; +import { literalValues } from "./literal-values.js"; +import { remapUnionDiscriminant } from "./union-discriminants.js"; import { isJsSourceFile, isNodeTypesPath } from "./program.js"; import { accessorSlotProp, recordTextCodecClass } from "../ir/ir.js"; @@ -419,6 +421,11 @@ export class UnionRegistry { return id; } + /** An exact arm transformation keeps the source's semantic variants. */ + transform(source: IrUnionDef, arms: IrType[]): string { + return this.intern(arms, remapUnionDiscriminant(source, arms)); + } + get(unionId: string): IrUnionDef | undefined { return this.byId.get(unionId); } @@ -1426,9 +1433,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // INSTANTIATION's class (`Box%0`), registered on demand — the Lowerer // hook owns the instance table (monomorphization by flow). if (classDecl.typeParameters) { - const instance = ctx.genericClassInstance - ? ctx.genericClassInstance(classDecl, widened, (argument) => mapType(argument, ctx)) - : null; + const instance = ctx.genericClassInstance?.(classDecl, widened, (argument) => mapType(argument, ctx)) ?? null; // Before the generic declaration's collection turn, the hook can only // return its family shell; after collection the same checker type names // a concrete registered instance. Never memoize either phase's answer. @@ -1671,10 +1676,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // record-holding binding forms compile. User-declared literals with // these member names keep the ordinary record path (their declarations // are not in declaration files). + const promiseDeclarations = psym ? checker.declarationsOf(psym) : []; if ( flags & ts.TypeFlags.Object && - (psym ? checker.declarationsOf(psym).length : 0) > 0 && - (psym ? checker.declarationsOf(psym) : []).every((d) => d.getSourceFile().isDeclarationFile) + promiseDeclarations.length > 0 && + promiseDeclarations.every((d) => d.getSourceFile().isDeclarationFile) ) { const props = checker.getPropertiesOfType(widened); if ( @@ -2998,20 +3004,6 @@ function unionDiscriminant( ): IrUnionDiscriminant | undefined { if (arms.filter((arm) => arm.kind === "record").length < 2 || parts.length < 2) return undefined; const { checker } = ctx; - const literals = (type: ts.Type): (string | number | boolean)[] | null => { - const values: (string | number | boolean)[] = []; - for (const part of type.isUnionType() ? ts.constituentTypes(type) : [type]) { - if (part.flags & ts.TypeFlags.StringLiteral) values.push((part as ts.StringLiteralType).value); - else if (part.flags & ts.TypeFlags.NumberLiteral) { - const value = (part as ts.NumberLiteralType).value; - if (!Number.isFinite(value)) return null; - values.push(value); - } - else if (part.flags & ts.TypeFlags.BooleanLiteral) values.push((part as ts.BooleanLiteralType).value); - else return null; - } - return values.length ? values : null; - }; for (const candidate of checker.getPropertiesOfType(parts[0]!.source)) { if (candidate.name.startsWith("__@")) continue; const byTag = new Map(); @@ -3023,7 +3015,7 @@ function unionDiscriminant( complete = false; break; } - const values = literals(checker.getTypeOfSymbol(prop)); + const values = literalValues(checker.getTypeOfSymbol(prop)); if (!values) { complete = false; break; } const tag = arms.findIndex((arm) => typeEquals(arm, part.mapped)); const grouped = byTag.get(tag) ?? []; @@ -3395,7 +3387,7 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu covered = f ? [f.type] : shape.indexValue ? [shape.indexValue] : null; } else { const allKeys = - (idx.flags & ts.TypeFlags.TypeParameter && resolveTypeParam(idx)?.kind === "string") || + ((idx.flags & ts.TypeFlags.TypeParameter) !== 0 && resolveTypeParam(idx)?.kind === "string") || (idx.isIndexType() && idx.getTarget() === obj); if (allKeys) { covered = shape.fields.map((f) => f.type); @@ -3555,7 +3547,7 @@ export function withUndefinedArm(t: IrType, unions: UnionRegistry): IrType | nul if (def.arms.some((a) => a.kind === "undefinedT")) return t; const arms = [...def.arms, UNDEFINED_T]; arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - return { kind: "union", unionId: unions.intern(arms) }; + return { kind: "union", unionId: unions.transform(def, arms) }; } if ( t.kind === "void" || t.kind === "date" || t.kind === "dyn" || diff --git a/packages/compiler/src/frontend/union-discriminants.test.ts b/packages/compiler/src/frontend/union-discriminants.test.ts new file mode 100644 index 000000000..873efcf5a --- /dev/null +++ b/packages/compiler/src/frontend/union-discriminants.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "vitest"; +import { BOOL, F64, NULL_T, STRING, UNDEFINED_T, type IrRecordShape, type IrType, type IrUnionDef } from "../ir/ir.js"; +import { discriminantField, discriminantOwners, literalUnionArm, remapUnionDiscriminant } from "./union-discriminants.js"; + +const record = (shapeId: string): IrType => ({ kind: "record", shapeId }); + +function fixture() { + const shapes: IrRecordShape[] = [ + { id: "empty", fields: [{ name: "kind", type: STRING }] }, + { id: "text", fields: [{ name: "kind", type: STRING }, { name: "text", type: STRING }] }, + { id: "number", fields: [{ name: "kind", type: F64 }, { name: "number", type: F64 }] }, + { id: "boolean", fields: [{ name: "kind", type: BOOL }, { name: "flag", type: BOOL }] }, + ]; + const union: IrUnionDef = { + id: "values", arms: [record("empty"), record("text"), record("number"), record("boolean"), NULL_T], + discriminant: { field: "kind", cases: [ + { tag: 0, values: ["empty", "none"] }, + { tag: 1, values: ["text", "1", "false", "constructor", "__proto__", "\u0000"] }, + { tag: 2, values: [0, 1, -2.5] }, + { tag: 3, values: [false, true] }, + ] }, + }; + const shapeOf = (id: string) => shapes.find((shape) => shape.id === id); + return { union, shapes, shapeOf }; +} + +describe("literal destination selection", () => { + test.each([ + [["empty"], "empty"], [["none", "empty"], "empty"], [["text", "1"], "text"], + [[1, -2.5, 0], "number"], [[false, true], "boolean"], [["false"], "text"], + [["constructor", "__proto__", "\u0000"], "text"], [[-0], "number"], + ] as [(string | number | boolean)[], string][])("selects all of %j using semantic ownership", (values, expected) => { + const { union, shapeOf } = fixture(); + expect(literalUnionArm(union, values, shapeOf)).toEqual(record(expected)); + }); + + test.each([ + [], ["unknown"], ["text", "unknown"], ["empty", "text"], [1, "1"], [false, "false"], + [NaN], [Infinity], [-Infinity], + ] as (string | number | boolean)[][])("declines absent or ambiguous literal set %j", (...values) => { + const { union, shapeOf } = fixture(); + expect(literalUnionArm(union, values, shapeOf)).toBeNull(); + }); + + test("accepts repeated literals owned by the same layout", () => { + const { union, shapeOf } = fixture(); + union.discriminant!.cases[1]!.values.push("text"); + expect(literalUnionArm(union, ["text", "text"], shapeOf)).toEqual(record("text")); + }); + + test("requires metadata even for a single record arm", () => { + const { union, shapeOf } = fixture(); + delete union.discriminant; + union.arms = [record("text"), UNDEFINED_T]; + expect(literalUnionArm(union, ["text"], shapeOf)).toBeNull(); + }); + + test("does not infer a variant from a field name or storage layout", () => { + const { union, shapeOf } = fixture(); + union.discriminant!.cases[0]!.values = ["text"]; + union.discriminant!.cases[1]!.values = ["empty"]; + expect(literalUnionArm(union, ["text"], shapeOf)).toEqual(record("empty")); + }); + + test("checks all owners before selecting a valid-looking first literal", () => { + const { union, shapeOf } = fixture(); + union.discriminant!.cases[1]!.values.push("empty"); + expect(discriminantOwners(union, shapeOf)).toBeNull(); + expect(literalUnionArm(union, ["empty"], shapeOf)).toBeNull(); + }); + + test.each([-1, 1.5, 100, NaN, Infinity, 4])("rejects invalid or non-record tag %s", (tag) => { + const { union, shapeOf } = fixture(); + union.discriminant!.cases[1]!.tag = tag; + expect(discriminantOwners(union, shapeOf)).toBeNull(); + }); + + test("rejects duplicate tags, missing record cases, and empty cases", () => { + for (const mode of ["duplicate", "missing", "empty"]) { + const { union, shapeOf } = fixture(); + if (mode === "duplicate") union.discriminant!.cases.push({ tag: 0, values: ["extra"] }); + if (mode === "missing") union.discriminant!.cases.pop(); + if (mode === "empty") union.discriminant!.cases[0]!.values = []; + expect(discriminantOwners(union, shapeOf), mode).toBeNull(); + } + }); + + test.each([1, false, NaN, Infinity])("rejects a non-string literal %j in a string slot", (value) => { + const { union, shapeOf } = fixture(); + union.discriminant!.cases[0]!.values = [value]; + expect(discriminantOwners(union, shapeOf)).toBeNull(); + }); + + test("rejects nonfinite numeric metadata even if another record was requested", () => { + const { union, shapeOf } = fixture(); + union.discriminant!.cases[2]!.values.push(Infinity); + expect(literalUnionArm(union, ["text"], shapeOf)).toBeNull(); + }); + + test("does not evaluate accessors, tuple slots, or private fields", () => { + const { shapes } = fixture(); + const ordinary = shapes[0]!; + expect(discriminantField(ordinary, "kind")).toEqual(STRING); + expect(discriminantField(undefined, "kind")).toBeNull(); + expect(discriminantField(ordinary, "missing")).toBeNull(); + expect(discriminantField({ ...ordinary, tuple: true }, "kind")).toBeNull(); + expect(discriminantField({ id: "hidden", fields: [{ name: "%kind", type: STRING }] }, "%kind")).toBeNull(); + for (const prefix of ["%get:", "%set:"]) { + const accessor = { ...ordinary, fields: [...ordinary.fields, { name: `${prefix}kind`, type: STRING }] }; + expect(discriminantField(accessor, "kind")).toBeNull(); + } + expect(discriminantField({ id: "object", fields: [{ name: "kind", type: record("text") }] }, "kind")).toBeNull(); + }); + + test("validates the discriminator's storage in every record", () => { + const { union, shapes, shapeOf } = fixture(); + shapes[0]!.fields[0]!.type = { kind: "union", unionId: "optional" }; + expect(literalUnionArm(union, ["text"], shapeOf)).toBeNull(); + shapes[0]!.fields[0]!.type = STRING; + shapes.splice(2, 1); + expect(literalUnionArm(union, ["text"], shapeOf)).toBeNull(); + }); +}); + +describe("exact union transformations", () => { + test("remaps by complete type identity after permutation and scalar insertion", () => { + const { union } = fixture(); + const arms = [UNDEFINED_T, STRING, record("boolean"), record("text"), F64, record("empty"), record("number")]; + expect(remapUnionDiscriminant(union, arms)).toEqual({ field: "kind", cases: [ + { tag: 2, values: [false, true] }, + { tag: 3, values: ["text", "1", "false", "constructor", "__proto__", "\u0000"] }, + { tag: 5, values: ["empty", "none"] }, + { tag: 6, values: [0, 1, -2.5] }, + ] }); + }); + + test("retains surviving records when removing records and nullish arms", () => { + const { union } = fixture(); + expect(remapUnionDiscriminant(union, [record("number"), record("empty")])).toEqual({ + field: "kind", cases: [{ tag: 0, values: [0, 1, -2.5] }, { tag: 1, values: ["empty", "none"] }], + }); + expect(remapUnionDiscriminant(union, [record("empty"), UNDEFINED_T])).toEqual({ + field: "kind", cases: [{ tag: 0, values: ["empty", "none"] }], + }); + expect(remapUnionDiscriminant(union, [NULL_T, UNDEFINED_T])).toBeUndefined(); + }); + + test("does not give a new record another layout's semantic variants", () => { + const { union } = fixture(); + expect(remapUnionDiscriminant(union, [...union.arms, record("lookalike")])).toBeUndefined(); + expect(remapUnionDiscriminant(union, [record("lookalike"), record("text")])).toBeUndefined(); + }); + + test("does not retain partial metadata from an invalid source", () => { + const { union } = fixture(); + union.discriminant!.cases.pop(); + // Even when the missing record is dropped, the source contract was not complete. + expect(remapUnionDiscriminant(union, [record("empty"), UNDEFINED_T])).toBeUndefined(); + }); + + test.each([-1, 1.1, Infinity, 100, 4])("declines source metadata with tag %s", (tag) => { + const { union } = fixture(); + union.discriminant!.cases[0]!.tag = tag; + expect(remapUnionDiscriminant(union, union.arms)).toBeUndefined(); + }); + + test("declines duplicate source cases and duplicate destination layouts", () => { + const { union } = fixture(); + expect(remapUnionDiscriminant(union, [record("empty"), record("empty")])).toBeUndefined(); + union.discriminant!.cases.push({ tag: 0, values: ["extra"] }); + expect(remapUnionDiscriminant(union, union.arms)).toBeUndefined(); + }); + + test("declines absent and empty metadata", () => { + const { union } = fixture(); + union.discriminant!.cases[0]!.values = []; + expect(remapUnionDiscriminant(union, union.arms)).toBeUndefined(); + delete union.discriminant; + expect(remapUnionDiscriminant(union, union.arms)).toBeUndefined(); + }); + + test("does not mutate input arrays or share mutable case storage", () => { + const { union } = fixture(); + const before = structuredClone(union); + const arms = union.arms.slice().reverse(); + const armOrder = structuredClone(arms); + const result = remapUnionDiscriminant(union, arms)!; + expect(union).toEqual(before); + expect(arms).toEqual(armOrder); + result.cases[0]!.values.push("new"); + result.cases.pop(); + expect(union).toEqual(before); + }); + + test("round trips tags without changing aliases or literal types", () => { + const { union } = fixture(); + const reversed = union.arms.slice().reverse(); + const next: IrUnionDef = { id: "next", arms: reversed, discriminant: remapUnionDiscriminant(union, reversed)! }; + expect(remapUnionDiscriminant(next, union.arms)).toEqual(union.discriminant); + }); +}); diff --git a/packages/compiler/src/frontend/union-discriminants.ts b/packages/compiler/src/frontend/union-discriminants.ts new file mode 100644 index 000000000..299b7c590 --- /dev/null +++ b/packages/compiler/src/frontend/union-discriminants.ts @@ -0,0 +1,96 @@ +import type { IrRecordShape, IrType, IrUnionDef, IrUnionDiscriminant } from "../ir/ir.js"; +import { typeEquals } from "../ir/ir.js"; + +export type UnionLiteral = string | number | boolean; + +/** Only ordinary data slots have a safe literal-comparison ABI. */ +export function discriminantField(shape: IrRecordShape | undefined, field: string): IrType | null { + if (field.startsWith("%") || !shape || shape.tuple) return null; + if (shape.fields.some((entry) => entry.name === `%get:${field}` || entry.name === `%set:${field}`)) return null; + const type = shape.fields.find((entry) => entry.name === field)?.type; + return type && (type.kind === "string" || type.kind === "f64" || type.kind === "bool") ? type : null; +} + +function matchesLiteral(type: IrType, value: UnionLiteral): boolean { + if (type.kind === "string") return typeof value === "string"; + if (type.kind === "bool") return typeof value === "boolean"; + return type.kind === "f64" && typeof value === "number" && Number.isFinite(value); +} + +/** Validate ownership before using semantic variants to choose a payload. + * JSON keys keep numeric, string and boolean literals distinct. */ +export function discriminantOwners( + union: IrUnionDef, + shapeOf: (id: string) => IrRecordShape | undefined, +): Map | null { + const discriminant = union.discriminant; + if (!discriminant) return null; + const owners = new Map(); + const tags = new Set(); + for (const entry of discriminant.cases) { + const arm = union.arms[entry.tag]; + if (!Number.isInteger(entry.tag) || !arm || arm.kind !== "record" || tags.has(entry.tag)) return null; + tags.add(entry.tag); + const type = discriminantField(shapeOf(arm.shapeId), discriminant.field); + if (!type || entry.values.length === 0) return null; + for (const value of entry.values) { + if (!matchesLiteral(type, value)) return null; + const key = JSON.stringify(value); + const previous = owners.get(key); + if (previous !== undefined && previous !== entry.tag) return null; + owners.set(key, entry.tag); + } + } + for (let tag = 0; tag < union.arms.length; tag++) { + if (union.arms[tag]!.kind === "record" && !tags.has(tag)) return null; + } + return owners; +} + +/** A known literal set selects one storage arm only when every value has + * that same owner. The caller must still validate the payload conversion. */ +export function literalUnionArm( + union: IrUnionDef, + values: readonly UnionLiteral[], + shapeOf: (id: string) => IrRecordShape | undefined, +): (IrType & { kind: "record" }) | null { + if (values.length === 0) return null; + const owners = discriminantOwners(union, shapeOf); + if (owners === null) return null; + let tag: number | undefined; + for (const value of values) { + if (typeof value === "number" && !Number.isFinite(value)) return null; + const next = owners.get(JSON.stringify(value)); + if (next === undefined || (tag !== undefined && next !== tag)) return null; + tag = next; + } + const arm = tag === undefined ? undefined : union.arms[tag]; + return arm?.kind === "record" ? arm : null; +} + +/** Preserve semantic cases through an exact arm permutation/subset. + * Adding a unit or scalar does not affect the record discriminator. A new + * record layout cannot inherit another record's literal ownership. */ +export function remapUnionDiscriminant( + source: IrUnionDef, + arms: readonly IrType[], +): IrUnionDiscriminant | undefined { + const original = source.discriminant; + if (!original) return undefined; + const seen = new Set(); + const cases: IrUnionDiscriminant["cases"] = []; + for (const entry of original.cases) { + const arm = source.arms[entry.tag]; + if (!Number.isInteger(entry.tag) || !arm || arm.kind !== "record" || seen.has(entry.tag) || entry.values.length === 0) { + return undefined; + } + seen.add(entry.tag); + const tag = arms.findIndex((candidate) => typeEquals(candidate, arm)); + if (tag >= 0) cases.push({ tag, values: entry.values.slice() }); + } + if (source.arms.some((arm, tag) => arm.kind === "record" && !seen.has(tag))) return undefined; + if (arms.some((arm, tag) => arm.kind === "record" && !cases.some((entry) => entry.tag === tag))) return undefined; + if (cases.length === 0) return undefined; + cases.sort((a, b) => a.tag - b.tag); + return { field: original.field, cases }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 3cf5dad30..489f24fac 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11187,6 +11187,30 @@ "/tests/corpus/typed-array-unknown.ts" ], "diags": [] + }, + "/tests/corpus/return-void-through-finally.ts": { + "order": [ + "/tests/corpus/return-void-through-finally.ts" + ], + "diags": [] + }, + "/tests/corpus/union-literal-scalar-discriminants.ts": { + "order": [ + "/tests/corpus/union-literal-scalar-discriminants.ts" + ], + "diags": [] + }, + "/tests/corpus/union-literal-slot-conversion.ts": { + "order": [ + "/tests/corpus/union-literal-slot-conversion.ts" + ], + "diags": [] + }, + "/tests/corpus/union-optional-semantic-identity.ts": { + "order": [ + "/tests/corpus/union-optional-semantic-identity.ts" + ], + "diags": [] } } } diff --git a/packages/compiler/test/type-mapper-unions.test.ts b/packages/compiler/test/type-mapper-unions.test.ts new file mode 100644 index 000000000..42ec87df9 --- /dev/null +++ b/packages/compiler/test/type-mapper-unions.test.ts @@ -0,0 +1,131 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { loadProgram } from "../src/frontend/program-node.js"; +import * as ts from "../src/frontend/ts7/adapter.js"; +import { mapType, ShapeRegistry, UnionRegistry, withUndefinedArm, type TypeMapperCtx } from "../src/frontend/type-mapper.js"; +import { literalUnionArm } from "../src/frontend/union-discriminants.js"; +import { typeEquals, type IrType } from "../src/ir/ir.js"; + +let directory: string; +let load: ReturnType; +const aliases = new Map(); + +beforeAll(() => { + directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-mapper-unions-")); + const entry = join(directory, "input.ts"); + writeFileSync(entry, ` + export type Choice = { kind: "a" | "alias"; value: number } | { kind: "b"; text: string }; + export type Other = { kind: "x"; value: number } | { kind: "y"; text: string }; + export type Numeric = { kind: 0; value: number } | { kind: -2.5; text: string }; + export type BooleanChoice = { kind: false; value: number } | { kind: true; text: string }; + export type Tree = { kind: "leaf"; value: number } | { kind: "branch"; children: Tree[] }; + export type OptionalChoice = Choice | undefined; + export type OptionalOther = Other | undefined; + export type OptionalNumeric = Numeric | undefined; + export type OptionalBooleanChoice = BooleanChoice | undefined; + export type OptionalTree = Tree | undefined; + export type NullableChoice = Choice | null | undefined; + export type Plain = { value: number } | { text: string }; + export type OptionalPlain = Plain | undefined; + `); + load = loadProgram(entry); + ts.walkPreorder(load.entry, (node) => { + if (ts.isTypeAliasDeclaration(node)) aliases.set(node.name.text, load.program.getTypeChecker().getTypeAtLocation(node.type)); + }); +}); +afterAll(() => { + load?.dispose(); + if (directory) rmSync(directory, { recursive: true, force: true }); +}); + +function context(): TypeMapperCtx { + return { + checker: load.program.getTypeChecker(), shapes: new ShapeRegistry(), unions: new UnionRegistry(), + classNamer: (decl) => decl.name?.text ?? "anonymous", dynamic: false, typeMemo: new Map(), + isStdlibFile: (sf) => load.program.isSourceFileDefaultLibrary(sf), + isNpmFile: () => false, isExternalTypeFile: () => false, + isProgramFile: (sf) => load.moduleOrder.includes(sf), + }; +} +function mapped(name: string, ctx: TypeMapperCtx): IrType & { kind: "union" } { + const result = mapType(aliases.get(name)!, ctx); + if (result?.kind !== "union") throw new Error(`${name} did not map to a union`); + return result; +} + +describe("checker and synthesized optional union identity", () => { + for (const order of ["required-first", "optional-first"]) { + test.each(["Choice", "Other", "Numeric", "BooleanChoice", "Tree"])(`${order}: %s`, (name) => { + const ctx = context(); + if (order === "required-first") mapped(name, ctx); + else mapped(`Optional${name}`, ctx); + const required = mapped(name, ctx); + const optional = mapped(`Optional${name}`, ctx); + const synthesized = withUndefinedArm(required, ctx.unions); + expect(synthesized).toEqual(optional); + const original = ctx.unions.get(required.unionId)!; + const definition = ctx.unions.get(optional.unionId)!; + expect(definition.discriminant?.field).toBe("kind"); + expect(ctx.unions.transform(definition, definition.arms.filter((arm) => arm.kind !== "undefinedT"))).toBe(required.unionId); + for (const variant of original.discriminant!.cases) { + const arm = literalUnionArm(definition, variant.values, (id) => ctx.shapes.get(id)); + expect(arm).toEqual(original.arms[variant.tag]); + } + const count = ctx.unions.unions.length; + for (let iteration = 0; iteration < 4; iteration++) { + expect(mapType(aliases.get(name)!, ctx)).toEqual(required); + expect(withUndefinedArm(required, ctx.unions)).toEqual(optional); + } + expect(ctx.unions.unions).toHaveLength(count); + }); + } + + test("null and undefined can be removed independently without losing record cases", () => { + const ctx = context(); + const required = mapped("Choice", ctx); + const optional = mapped("OptionalChoice", ctx); + const nullable = ctx.unions.get(mapped("NullableChoice", ctx).unionId)!; + expect(ctx.unions.transform(nullable, nullable.arms.filter((arm) => arm.kind !== "nullT"))).toBe(optional.unionId); + expect(ctx.unions.transform(nullable, nullable.arms.filter((arm) => arm.kind !== "nullT" && arm.kind !== "undefinedT"))).toBe(required.unionId); + expect(nullable.discriminant!.cases.map((entry) => entry.values)).toEqual([["a", "alias"], ["b"]]); + }); + + test("identical storage with different literal contracts never shares a canonical union", () => { + const ctx = context(); + const first = mapped("Choice", ctx), second = mapped("Other", ctx); + const left = ctx.unions.get(first.unionId)!, right = ctx.unions.get(second.unionId)!; + expect(left.arms).toEqual(right.arms); + expect(typeEquals(first, second)).toBe(false); + const optionalLeft = withUndefinedArm(first, ctx.unions), optionalRight = withUndefinedArm(second, ctx.unions); + expect(optionalLeft).not.toEqual(optionalRight); + expect(literalUnionArm(left, ["x"], (id) => ctx.shapes.get(id))).toBeNull(); + expect(literalUnionArm(right, ["a"], (id) => ctx.shapes.get(id))).toBeNull(); + }); + + test("a union without a usable discriminator does not gain one from another mapping", () => { + const ctx = context(); + mapped("Choice", ctx); + const plain = mapped("Plain", ctx); + const optional = mapped("OptionalPlain", ctx); + expect(withUndefinedArm(plain, ctx.unions)).toEqual(optional); + expect(ctx.unions.get(plain.unionId)!.discriminant).toBeUndefined(); + expect(ctx.unions.get(optional.unionId)!.discriminant).toBeUndefined(); + }); + + test("making a recursive root optional does not rewrite its child storage", () => { + const ctx = context(); + const tree = mapped("Tree", ctx); + const original = ctx.unions.get(tree.unionId)!; + const branch = literalUnionArm(original, ["branch"], (id) => ctx.shapes.get(id))!; + const children = ctx.shapes.get(branch.shapeId)!.fields.find((field) => field.name === "children")!.type; + if (children.kind !== "array") throw new Error("missing recursive array field"); + expect(children.elem).toEqual(tree); + const optional = withUndefinedArm(tree, ctx.unions); + expect(optional).not.toEqual(tree); + expect(children.elem).toEqual(tree); + expect(ctx.unions.get(tree.unionId)).toBe(original); + expect(original.arms.some((arm) => arm.kind === "undefinedT")).toBe(false); + }); +}); diff --git a/tests/corpus/return-void-through-finally.ts b/tests/corpus/return-void-through-finally.ts new file mode 100644 index 000000000..992c35f8b --- /dev/null +++ b/tests/corpus/return-void-through-finally.ts @@ -0,0 +1,75 @@ +const effects: string[] = []; +function mark(label: string): void { effects.push(label); } + +function direct(): void { return mark("direct"); } +function throughFinally(): void { + try { return mark("try-return"); } + finally { mark("finally"); } +} +function throughCatch(): void { + try { throw new Error("caught"); } + catch (error) { + if (error instanceof Error) return mark(`catch-return:${error.message}`); + } finally { mark("catch-finally"); } +} +function nested(): void { + try { + try { return mark("nested-return"); } + finally { mark("inner-finally"); } + } finally { mark("outer-finally"); } +} +function replaceReturn(): void { + try { return mark("replaced-return"); } + finally { return mark("replacement"); } +} +function fail(): void { mark("throw-expression"); throw new RangeError("void-call"); } +function expressionThrows(): void { + try { return fail(); } + finally { mark("throw-finally"); } +} +function finallyThrows(): void { + try { return mark("before-finally-throw"); } + finally { mark("finally-throw"); throw new Error("finally"); } +} +function suppressThrow(): void { + try { return fail(); } + finally { return mark("suppress-throw"); } +} +function captured(label: string): () => void { + const retained = [label]; + return (): void => { + try { return mark(retained[0]!); } + finally { mark("closure-finally"); } + }; +} +function localVoid(value: number): void { + const result = { label: `local:${value}` }; + const write = (): void => { mark(result.label); }; + try { return write(); } + finally { mark(`local-finally:${result.label}`); } +} +function show(label: string, fn: () => void): void { + effects.length = 0; + try { fn(); console.log(label, "undefined", effects.join(",")); } + catch (error) { + if (error instanceof Error) console.log(label, error.name, error.message, effects.join(",")); + } +} +show("direct", direct); +show("finally", throughFinally); +show("catch", throughCatch); +show("nested", nested); +show("replace", replaceReturn); +show("expression-throws", expressionThrows); +show("finally-throws", finallyThrows); +show("suppress", suppressThrow); +show("closure", captured("captured")); +show("local", () => localVoid(17)); +let count = 0; +function increment(): void { count++; } +function repeat(): void { + try { return increment(); } + finally { count += 2; } +} +for (let i = 0; i < 100; i++) repeat(); +console.log("repeated", count); diff --git a/tests/corpus/union-literal-scalar-discriminants.ts b/tests/corpus/union-literal-scalar-discriminants.ts new file mode 100644 index 000000000..bcfed679f --- /dev/null +++ b/tests/corpus/union-literal-scalar-discriminants.ts @@ -0,0 +1,89 @@ +// Distinct scalar discriminator kinds must not collide when selecting an +// object-literal destination. Payload unions require their own conversion. +type Payload = { kind: "text"; text: string } | { kind: "count"; count: number }; +type Numeric = + | { code: 0; payload: Payload; zero: string } + | { code: -2.5; payload: Payload; negative: number } + | { code: 7; payload: Payload }; +type BooleanChoice = + | { enabled: false; payload: Payload; disabled: string } + | { enabled: true; payload: Payload }; +type Mixed = + | { tag: "1"; payload: Payload; stringOnly: string } + | { tag: 1; payload: Payload; numberOnly: number } + | { tag: false; payload: Payload; booleanOnly: boolean }; + +function payload(value: Payload): string { return value.kind === "text" ? value.text : `${value.count}`; } +function zero(value: Payload): Numeric { return { code: 0, payload: value, zero: "zero" }; } +function negative(value: Payload): Numeric { return { code: -2.5, payload: value, negative: 17 }; } +function seven(value: Payload): Numeric { return { code: 7, payload: value }; } +function numeric(value: Numeric): string { + switch (value.code) { + case 0: return `${value.zero}:${payload(value.payload)}`; + case -2.5: return `${value.negative}:${payload(value.payload)}`; + case 7: return `seven:${payload(value.payload)}`; + } +} +function disabled(value: Payload): BooleanChoice { return { enabled: false, payload: value, disabled: "disabled" }; } +function enabled(value: Payload): BooleanChoice { return { enabled: true, payload: value }; } +function boolean(value: BooleanChoice): string { return `${value.enabled ? "enabled" : value.disabled}:${payload(value.payload)}`; } +function stringTag(value: Payload): Mixed { return { tag: "1", payload: value, stringOnly: "string" }; } +function numberTag(value: Payload): Mixed { return { tag: 1, payload: value, numberOnly: 19 }; } +function booleanTag(value: Payload): Mixed { return { tag: false, payload: value, booleanOnly: true }; } +function mixed(value: Mixed): string { + if (value.tag === "1") return `${value.stringOnly}:${payload(value.payload)}`; + if (value.tag === 1) return `${value.numberOnly}:${payload(value.payload)}`; + return `${value.booleanOnly}:${payload(value.payload)}`; +} +const inputs: Payload[] = [{ kind: "text", text: "retained" }, { kind: "count", count: 31 }]; +for (const input of inputs) { + console.log("numeric", numeric(zero(input)), numeric(negative(input)), numeric(seven(input))); + console.log("boolean", boolean(disabled(input)), boolean(enabled(input))); + console.log("mixed", mixed(stringTag(input)), mixed(numberTag(input)), mixed(booleanTag(input))); +} + +type Aliases = + | { kind: "a" | "b"; payload: Payload; value: number } + | { kind: "c"; payload: Payload }; +function aliased(kind: "a" | "b", value: Payload): Aliases { return { kind, payload: value, value: 41 }; } +function aliasText(value: Aliases): string { + return value.kind === "c" ? payload(value.payload) : `${value.kind}:${value.value}:${payload(value.payload)}`; +} +console.log("aliases", aliasText(aliased("a", inputs[0]!)), aliasText(aliased("b", inputs[1]!))); + +type Keys = + | { kind: "constructor"; payload: Payload; constructorOnly: string } + | { kind: "__proto__"; payload: Payload; protoOnly: number } + | { kind: "\u0000"; payload: Payload }; +function constructor(value: Payload): Keys { return { kind: "constructor", payload: value, constructorOnly: "own" }; } +function proto(value: Payload): Keys { return { kind: "__proto__", payload: value, protoOnly: 43 }; } +function nul(value: Payload): Keys { return { kind: "\u0000", payload: value }; } +function keyText(value: Keys): string { + switch (value.kind) { + case "constructor": return `${value.constructorOnly}:${payload(value.payload)}`; + case "__proto__": return `${value.protoOnly}:${payload(value.payload)}`; + case "\u0000": return `nul:${payload(value.payload)}`; + } +} +console.log("keys", keyText(constructor(inputs[0]!)), keyText(proto(inputs[1]!)), keyText(nul(inputs[0]!))); + +// A spread may replace the discriminator. Selection must use the final +// checker-known value without evaluating either source more than once. +const calls: string[] = []; +function base(): { tag: "1"; payload: Payload; stringOnly: string } { + calls.push("base"); + return { tag: "1", payload: inputs[0]!, stringOnly: "discarded" }; +} +function readNumber(): number { calls.push("number"); return 53; } +function replaced(): Mixed { return { ...base(), tag: 1, numberOnly: readNumber() }; } +console.log("replaced", mixed(replaced()), calls.join(",")); + +function withUndefined(value: Mixed | undefined): Mixed | undefined { return value; } +const candidate = withUndefined(numberTag(inputs[1]!)); +console.log("optional", candidate ? mixed(candidate) : "missing", withUndefined(undefined) === undefined); +let total = 0; +for (let i = 0; i < 100; i++) { + const result = negative(inputs[i % inputs.length]!); + if (result.code === -2.5) total += result.negative; +} +console.log("repeated", total); diff --git a/tests/corpus/union-literal-slot-conversion.ts b/tests/corpus/union-literal-slot-conversion.ts new file mode 100644 index 000000000..893333478 --- /dev/null +++ b/tests/corpus/union-literal-slot-conversion.ts @@ -0,0 +1,100 @@ +// Compiler-like constructors pass recursive union values through parameters, +// unchecked array reads, and record fields before returning a tagged literal. +type ValueType = { kind: "number" } | { kind: "array"; elem: ValueType } | { kind: "choice"; arms: ValueType[] }; +type Expr = + | { kind: "var"; name: string; type: ValueType } + | { kind: "self"; type: ValueType } + | { kind: "number"; value: number; type: ValueType } + | { kind: "pair"; left: Expr; right: Expr; type: ValueType }; + +function variable(name: string, type: ValueType): Expr { return { kind: "var", name, type }; } +function self(type: ValueType): Expr { return ({ kind: "self", type }); } +function number(value: number, type: ValueType): Expr { return { kind: "number", value, type }; } +function pair(left: Expr, right: Expr, type: ValueType): Expr { return { kind: "pair", left, right, type }; } +function describeType(type: ValueType): string { + switch (type.kind) { + case "number": return "number"; + case "array": return `array(${describeType(type.elem)})`; + case "choice": return type.arms.map(describeType).join("|"); + } +} +function describe(expr: Expr): string { + switch (expr.kind) { + case "var": return `var:${expr.name}:${describeType(expr.type)}`; + case "self": return `self:${describeType(expr.type)}`; + case "number": return `number:${expr.value}:${describeType(expr.type)}`; + case "pair": return `(${describe(expr.left)},${describe(expr.right)}):${describeType(expr.type)}`; + } +} +function argument(expr: Expr): string { return describe(expr); } +function recordField(expr: Expr): { expr: Expr } { return { expr }; } +function arrayElement(expr: Expr): Expr[] { return [expr]; } +function callback(fn: () => Expr): Expr { return fn(); } + +const types: ValueType[] = [{ kind: "number" }, { kind: "array", elem: { kind: "number" } }]; +const scalar = types[0]!; +const array = types[1]!; +console.log("returns", describe(variable("x", scalar)), describe(self(array)), describe(number(7, scalar))); +console.log("arguments", argument({ kind: "var", name: "argument", type: scalar })); +console.log("field", describe(recordField({ kind: "var", name: "field", type: array }).expr)); +console.log("element", describe(arrayElement({ kind: "var", name: "element", type: scalar })[0]!)); +console.log("callback", describe(callback(() => ({ kind: "var", name: "callback", type: array })))); +console.log("nested", describe(pair(variable("left", scalar), self(array), { kind: "choice", arms: types }))); + +// The target has a smaller arm (self) with a structurally compatible layout. +// Literal ownership selects var before any payload adaptation occurs. +function fromRecord(input: { name: string; type: ValueType }): Expr { + return { kind: "var", name: input.name, type: input.type }; +} +function fromArray(input: ValueType[], index: number): Expr { + return { kind: "var", name: "indexed", type: input[index] }; +} +function fromNested(input: { types: ValueType[] }): Expr { + return { kind: "var", name: "nested", type: input.types[0]! }; +} +console.log("record-source", describe(fromRecord({ name: "record", type: array }))); +console.log("array-source", describe(fromArray(types, 1))); +console.log("nested-source", describe(fromNested({ types }))); +type MaybeExpr = + | { kind: "var"; name: string; type: ValueType | undefined } + | { kind: "self"; type: ValueType | undefined }; +function optionalFromArray(input: ValueType[], index: number): MaybeExpr { + return { kind: "var", name: "optional", type: input[index] }; +} +const missing = optionalFromArray([], 0); +console.log("missing-payload", missing.kind, missing.type === undefined); +try { console.log(missing.type!.kind); } +catch (error) { if (error instanceof TypeError) console.log("missing-read", error.name); } + +const effects: string[] = []; +function name(label: string): string { effects.push(label); return label; } +function type(label: string, value: ValueType): ValueType { effects.push(label); return value; } +function ordered(): Expr { return { kind: "var", name: name("name"), type: type("type", scalar) }; } +console.log("ordered", describe(ordered()), effects.join(",")); +effects.length = 0; +function source(): { name: string; type: ValueType } { + effects.push("spread"); + return { name: "spread", type: scalar }; +} +function spread(): Expr { return { kind: "var", ...source(), type: type("override", array) }; } +console.log("spread", describe(spread()), effects.join(",")); +effects.length = 0; +function stop(): ValueType { effects.push("throw"); throw new RangeError("payload"); } +function throws(): Expr { return { kind: "var", name: name("before"), type: stop() }; } +try { console.log(describe(throws())); } +catch (error) { if (error instanceof RangeError) console.log("caught", error.name, error.message, effects.join(",")); } + +// Shared child storage keeps identity across literal conversion. Each new +// parent must still allocate its own object and evaluate each property once. +const original = variable("original", array); +const parent = pair(original, original, scalar); +if (parent.kind === "pair") { + console.log("children", parent.left === original, parent.right === original); + if (parent.left.kind === "var") console.log("payload", parent.left.type === array); +} +let checksum = 0; +for (let i = 0; i < 100; i++) { + const item = number(i, types[i % types.length]!); + if (item.kind === "number") checksum += item.value; +} +console.log("repeated", checksum); diff --git a/tests/corpus/union-optional-semantic-identity.ts b/tests/corpus/union-optional-semantic-identity.ts new file mode 100644 index 000000000..c9bd23c06 --- /dev/null +++ b/tests/corpus/union-optional-semantic-identity.ts @@ -0,0 +1,78 @@ +type Value = + | { kind: "empty"; label: string } + | { kind: "number"; value: number; label: string } + | { kind: "pair"; left: Value; right: Value; label: string }; +type Located = Value & { source: { file: string; line: number } }; + +function read(value: Value): string { + switch (value.kind) { + case "empty": return value.label; + case "number": return `${value.label}:${value.value}`; + case "pair": return `${value.label}(${read(value.left)},${read(value.right)})`; + } +} +function optional(value: Value | undefined): Value | undefined { return value; } +function nullable(value: Value | null): Value | null | undefined { return value; } +function located(value: Located | undefined): Value | undefined { return value; } +function required(value: Value | undefined): Value { + if (value === undefined) return { kind: "empty", label: "default" }; + return value; +} +function widened(value: Value | undefined): Value | string | undefined { return value; } +function arrayRead(values: Value[], index: number): Value | undefined { return values[index]; } +function box(value: Value | undefined): { value?: Value } { return value === undefined ? {} : { value }; } + +const empty: Value = { kind: "empty", label: "empty" }; +const number: Value = { kind: "number", value: 11, label: "number" }; +const pair: Value = { kind: "pair", left: empty, right: number, label: "pair" }; +const values: Value[] = [empty, number, pair]; +for (const value of values) { + console.log("optional", read(required(optional(value))), optional(value) === value); + const candidate = nullable(value); + if (candidate != null) console.log("nullable", read(candidate), candidate === value); + const scalarSibling = widened(value); + if (scalarSibling !== undefined && typeof scalarSibling !== "string") { + console.log("widened", read(scalarSibling)); + } + console.log("chain", optional(value)?.label, box(value).value?.label); +} +console.log("units", optional(undefined) === undefined, nullable(null) === null); +console.log("absent", box(undefined).value?.label, optional(undefined)?.label); +console.log("fallback", read(required(undefined))); +console.log("array", arrayRead(values, 8)?.label, arrayRead(values, 2)?.label); + +const locations: Located[] = [ + { kind: "empty", label: "located-empty", source: { file: "a.ts", line: 1 } }, + { kind: "number", value: 19, label: "located-number", source: { file: "a.ts", line: 2 } }, + { kind: "pair", left: empty, right: number, label: "located-pair", source: { file: "b.ts", line: 3 } }, +]; +for (const value of locations) console.log("located", read(required(located(value)))); +console.log("located-absent", located(undefined) === undefined); + +// Adding scalar needles for equality search must leave record identities and +// discriminator ownership unchanged, including a miss and an absent element. +console.log("search", values.includes(number), values.indexOf(pair), values.lastIndexOf(empty)); +console.log("new-object", values.includes({ kind: "number", value: 11, label: "number" })); +const mixed: (Value | string)[] = [empty, "marker", number, pair, number]; +console.log("mixed-search", mixed.includes("marker"), mixed.indexOf(number), mixed.lastIndexOf(number)); +console.log("mixed-miss", mixed.includes("absent"), mixed.indexOf("absent")); + +let calls = 0; +function next(present: boolean): Value | undefined { calls++; return present ? pair : undefined; } +console.log("chain-effects", next(true)?.label, calls, next(false)?.label, calls); +console.log("nullish-effects", read(next(false) ?? number), calls); +console.log("logical-effects", next(true) && "yes", calls); + +// Same stored shapes with a different semantic contract must never borrow +// another union's literal-to-layout mapping from a registry cache. +type Other = { kind: "none"; label: string } | { kind: "some"; value: number; label: string }; +function other(value: Other | undefined): string { + if (value === undefined) return "missing"; + return value.kind === "none" ? value.label : `${value.label}:${value.value}`; +} +const others: Other[] = [{ kind: "none", label: "none" }, { kind: "some", value: 23, label: "some" }]; +console.log("separate", other(others[0]), other(others[1]), other(others[2])); +for (let i = 0; i < 60; i++) { + const value = optional(values[i % values.length]); + if (value) console.log("loop", i, read(value)); +} diff --git a/tests/fixtures/self-hosting/type-mapper-cases.ts b/tests/fixtures/self-hosting/type-mapper-cases.ts new file mode 100644 index 000000000..f547efa8c --- /dev/null +++ b/tests/fixtures/self-hosting/type-mapper-cases.ts @@ -0,0 +1,159 @@ +import { writeFileSync } from "node:fs"; +import * as ts from "../../../packages/compiler/src/frontend/ts7/adapter.js"; +import { ambientDtsPath, fallbackDtsPath, overridesDtsPath, isNodeTypesPath } from "../../../packages/compiler/src/frontend/dts-paths.js"; +import { checkPreflight, loadProgram } from "../../../packages/compiler/src/frontend/program.js"; +import type { FrontendServices } from "../../../packages/compiler/src/frontend/services.js"; +import { literalValues } from "../../../packages/compiler/src/frontend/literal-values.js"; +import { literalUnionArm, type UnionLiteral } from "../../../packages/compiler/src/frontend/union-discriminants.js"; +import { formatIrType, mapType, ShapeRegistry, UnionRegistry, withUndefinedArm } from "../../../packages/compiler/src/frontend/type-mapper.js"; +import type { TypeMapperCtx } from "../../../packages/compiler/src/frontend/type-mapper.js"; +import { F64, STRING, typeEquals } from "../../../packages/compiler/src/ir/ir.js"; +import type { IrType, IrRecordShape, IrUnionDef } from "../../../packages/compiler/src/ir/ir.js"; + +interface Mapping { + name: string; + type: IrType | null; + display: string; + literals: UnionLiteral[] | null; + optional: IrType | null; +} +export interface MappingReport { + mappings: Mapping[]; + records: IrRecordShape[]; + unions: IrUnionDef[]; + memoEntries: number; + variants: string[]; + hooks: string[]; + tuples: string[]; +} + +function requireValue(value: boolean, message: string): void { + if (!value) throw new Error(message); +} + +/** Use the production mapper and registries on live checker types. The + * native executable talks to TS7 directly; the oracle uses its Node client. */ +function mapProgram(services: FrontendServices, entry: string): MappingReport { + const load = loadProgram(entry, services); + try { + requireValue(checkPreflight(load).length === 0, "mapper input failed preflight"); + const checker = load.program.getTypeChecker(); + const ambient = ambientDtsPath(), overrides = overridesDtsPath(), fallback = fallbackDtsPath(); + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + const ctx: TypeMapperCtx = { + checker, shapes, unions, dynamic: false, typeMemo: new Map(), + classNamer: (decl) => decl.name?.text ?? "anonymous", + isStdlibFile: (sf) => sf.fileName === ambient || sf.fileName === overrides || sf.fileName === fallback || + load.program.isSourceFileDefaultLibrary(sf) || (sf.isDeclarationFile && isNodeTypesPath(sf.fileName)), + isNpmFile: () => false, + isExternalTypeFile: () => false, + isProgramFile: (sf) => load.moduleOrder.includes(sf), + }; + const aliases: ts.TypeAliasDeclaration[] = []; + ts.walkPreorder(load.entry, (node) => { + if (ts.isTypeAliasDeclaration(node)) aliases.push(node); + }); + const mappings: Mapping[] = []; + const variants: string[] = []; + const tuples: string[] = []; + for (const alias of aliases) { + if (alias.name.text.startsWith("Hook")) continue; + const source = checker.getTypeAtLocation(alias.type); + if (checker.isTupleType(source)) { + const target = source.getTarget(); + tuples.push(`${alias.name.text}:${JSON.stringify(source.elementFlags ?? null)}:${JSON.stringify(target?.elementFlags ?? null)}:${checker.getTypeArguments(source as ts.TypeReference).length}`); + } + const mapped = mapType(source, ctx); + const warm = mapType(source, ctx); + requireValue(mapped === null ? warm === null : warm !== null && typeEquals(mapped, warm), "warm mapping changed"); + const optional = mapped === null ? null : withUndefinedArm(mapped, unions); + mappings.push({ name: alias.name.text, type: mapped, + display: mapped === null ? "unmapped" : formatIrType(mapped, shapes, unions), + literals: literalValues(source), optional }); + if (mapped?.kind !== "union") continue; + const union = unions.get(mapped.unionId)!; + if (!union.discriminant) continue; + if (optional?.kind !== "union") throw new Error("missing optional union"); + const optionalDef = unions.get(optional.unionId)!; + requireValue(unions.transform(optionalDef, union.arms) === mapped.unionId, "optional round trip changed semantic identity"); + for (const variant of union.discriminant.cases) { + const selected = literalUnionArm(optionalDef, variant.values, (id) => shapes.get(id)); + requireValue(selected !== null && typeEquals(selected, union.arms[variant.tag]!), "literal selected another payload"); + variants.push(`${alias.name.text}:${JSON.stringify(variant.values)}:${selected!.shapeId}`); + } + } + + // These hooks change meaning between calls even when the checker type + // is identical. Their answers must never enter the context-free memo. + const hooks: string[] = []; + const genericAlias = aliases.find((alias) => alias.name.text === "HookGeneric"); + if (!genericAlias) throw new Error("missing generic fixture"); + const genericType = checker.getTypeAtLocation(genericAlias.type); + requireValue(mapType(genericType, ctx) === null, "generic class mapped without a lowering hook"); + let specialization = "first"; + ctx.genericClassInstance = (decl, ref, mapArgument) => { + const argument = checker.getTypeArguments(ref as ts.TypeReference)[0]; + if (argument === undefined) return null; + const mapped = mapArgument(argument); + if (mapped === null) return null; + hooks.push(`${decl.name?.text}:${specialization}:${mapped.kind}`); + return { kind: "object", className: `Box.${specialization}` }; + }; + const firstClass = mapType(genericType, ctx); + specialization = "second"; + const secondClass = mapType(genericType, ctx); + requireValue(firstClass?.kind === "object" && firstClass.className === "Box.first", "first class specialization lost"); + requireValue(secondClass?.kind === "object" && secondClass.className === "Box.second", "class specialization reused a memo"); + + const parameterAlias = aliases.find((alias) => alias.name.text === "HookParameter"); + if (!parameterAlias) throw new Error("missing parameter fixture"); + const parameterType = checker.getTypeAtLocation(parameterAlias.type); + ctx.resolveTypeParam = (_type: ts.Type): IrType | null => F64; + const numberParam = mapType(parameterType, ctx); + ctx.resolveTypeParam = (_type: ts.Type): IrType | null => STRING; + const stringParam = mapType(parameterType, ctx); + requireValue(numberParam?.kind === "f64" && stringParam?.kind === "string", "type parameter binding leaked"); + hooks.push(`parameter:${numberParam!.kind}:${stringParam!.kind}`); + + for (const name of ["HookIndexed", "HookKeys", "HookLiteral"]) { + const alias = aliases.find((entry) => entry.name.text === name); + if (!alias) throw new Error("missing indexed-access fixture"); + const indexed = checker.getTypeAtLocation(alias.type); + const object = indexed.getObjectType(); + if (!object) throw new Error("missing indexed-access receiver"); + let binding: IrType = { kind: "record", shapeId: shapes.intern([ + { name: "value", type: F64 }, { name: "other", type: F64 }, + ]) }; + ctx.resolveTypeParam = (type) => type === object ? binding : STRING; + const numeric = mapType(indexed, ctx); + requireValue(numeric?.kind === "f64", "indexed access did not use the record binding"); + binding = { kind: "record", shapeId: shapes.intern([ + { name: "value", type: STRING }, { name: "other", type: STRING }, + ]) }; + const text = mapType(indexed, ctx); + requireValue(text?.kind === "string", "indexed access reused a previous binding"); + hooks.push(`${name}:${numeric!.kind}:${text!.kind}`); + } + + const anyAlias = aliases.find((alias) => alias.name.text === "AnyValue"); + if (!anyAlias) throw new Error("missing any fixture"); + const anyType = checker.getTypeAtLocation(anyAlias.type); + requireValue(mapType(anyType, ctx) === null, "static any unexpectedly mapped"); + ctx.dynamic = true; + requireValue(mapType(anyType, ctx)?.kind === "jsval", "dynamic any did not map to an island handle"); + ctx.dynamic = false; + requireValue(mapType(anyType, ctx) === null, "dynamic memo escaped into static mapping"); + hooks.push("dynamic memo isolation"); + return { mappings, records: shapes.shapes, unions: unions.unions, + memoEntries: ctx.typeMemo!.size, variants, hooks, tuples }; + } finally { load.dispose(); } +} + +export function runTypeMapper(services: FrontendServices, entry: string, output: string): void { + const first = mapProgram(services, entry); + // Separate semantic projects may recycle handles. Registries, recursive + // placeholders, and mapper memos belong to the individual compiler pass. + const second = mapProgram(services, entry); + writeFileSync(output, JSON.stringify({ first, second })); +} diff --git a/tests/fixtures/self-hosting/type-mapper-node.ts b/tests/fixtures/self-hosting/type-mapper-node.ts new file mode 100644 index 000000000..0d4a05259 --- /dev/null +++ b/tests/fixtures/self-hosting/type-mapper-node.ts @@ -0,0 +1,6 @@ +import { createNodeFrontendServices } from "../../../packages/compiler/src/frontend/services-node.js"; +import { runTypeMapper } from "./type-mapper-cases.js"; + +const services = createNodeFrontendServices(); +try { runTypeMapper(services, process.argv[3]!, process.argv[4]!); } +finally { services.close(); } diff --git a/tests/fixtures/self-hosting/type-mapper.ts b/tests/fixtures/self-hosting/type-mapper.ts new file mode 100644 index 000000000..faa8ab6c9 --- /dev/null +++ b/tests/fixtures/self-hosting/type-mapper.ts @@ -0,0 +1,7 @@ +import { FrontendServices } from "../../../packages/compiler/src/frontend/services.js"; +import { createNativeTs7Api } from "../../../packages/compiler/src/frontend/ts7/native-api.js"; +import { runTypeMapper } from "./type-mapper-cases.js"; + +const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable: process.argv[2]! })); +try { runTypeMapper(services, process.argv[3]!, process.argv[4]!); } +finally { services.close(); } diff --git a/tests/harness/self-hosting-type-mapper-input.ts b/tests/harness/self-hosting-type-mapper-input.ts new file mode 100644 index 000000000..ecfb5d82c --- /dev/null +++ b/tests/harness/self-hosting-type-mapper-input.ts @@ -0,0 +1,85 @@ +/** One semantic project covers the representations used by the compiler's + * own IR and frontend context types, including recursive placeholders. */ +export const typeMapperInput = ` +export type Text = string; +export type NumberValue = number; +export type Flag = boolean; +export type Big = bigint; +export type Token = symbol; +export type Empty = undefined; +export type Nil = null; +export type Nothing = void; +export type NeverValue = never; +export type AnyValue = any; +export type UnknownValue = unknown; +export type TextLiteral = "text"; +export type NumberLiteral = -2.5; +export type BooleanLiteral = false; +export type TextLiterals = "constructor" | "__proto__" | ""; +export type NumberLiterals = 0 | 1 | -2.5; +export type MixedLiterals = "1" | 1 | false; +export type BroadValues = string | number | boolean | null | undefined; +export type TextArray = string[]; +export type ReadonlyArrayValue = readonly number[]; +export type NestedArray = (number | string)[][]; +export type Tuple = readonly [string, number, boolean]; +export type OptionalTuple = [string, number?]; +export type RestTuple = [string, ...number[]]; +export type StringMap = Map; +export type ReadonlyMapValue = ReadonlyMap; +export type StringSet = Set; +export type OptionalMap = Map | undefined; +export type OptionalSet = Set | null; +export type Bytes = Uint8Array; +export type FloatBytes = Float64Array; +export type RegularExpression = RegExp; +export type Clock = Date; +export type Future = Promise; +export type FutureVoid = Promise; +export type Iterate = Generator; +export type IterateVoid = Generator; +export type AsyncIterate = AsyncGenerator; +export type Call = (value: number, label: string) => boolean; +export type OptionalCall = ((value: number) => string) | undefined; +export type RecordValue = { name: string; count: number; enabled: boolean }; +export type OptionalRecord = { name: string; count?: number }; +export type Dictionary = { [name: string]: number }; +export type CallableRecord = { name: string; callback: (value: number) => string }; +export type Accessors = { get value(): number; set value(next: number) }; +export type ReadonlyRecord = Readonly; +export type PartialRecord = Partial; +export type PickRecord = Pick; +export type OmitRecord = Omit; +export type IntersectionRecord = { left: number } & { right: string }; +export interface Linked { value: number; next?: Linked } +export type LinkedRecord = Linked; +export interface Parent { name: string; child?: Child } +export interface Child { value: number; parent?: Parent } +export type MutualRecord = Parent; +export type Expression = + | { kind: "number"; value: number } + | { kind: "binary"; left: Expression; right: Expression } + | { kind: "logical"; left: Expression; right: Expression }; +export type OptionalExpression = Expression | undefined; +export type NullableExpression = Expression | null | undefined; +export type RefinedExpression = Expression & { source: { file: string; start: number } }; +export type RecursiveArray = Expression[]; +export type NumericVariant = { kind: 0; left: string } | { kind: -2.5; right: number }; +export type BooleanVariant = { kind: false; left: string } | { kind: true; right: number }; +export type MixedVariant = { kind: "1"; text: string } | { kind: 1; number: number } | { kind: false; flag: boolean }; +export type AliasedVariant = { kind: "a" | "b"; value: number } | { kind: "c"; text: string }; +export type OtherVariant = { kind: "x" | "y"; value: number } | { kind: "z"; text: string }; +export type PlainUnion = { a: number } | { b: string }; +export type OptionalKind = { kind?: "a"; a: number } | { kind: "b"; b: string }; +export type AccessorKind = { get kind(): "a"; a: number } | { kind: "b"; b: string }; +export type BroadKind = { kind: string; a: number } | { kind: "b"; b: string }; +export class Named { value = 1 } +export type ClassInstance = Named; +export type ClassValue = typeof Named; +export class Box { constructor(public value: T) {} } +export type HookGeneric = Box; +export type HookParameter = T; +export type HookIndexed = T[K]; +export type HookKeys = T[keyof T]; +export type HookLiteral = T["value"]; +`; diff --git a/tests/harness/self-hosting-type-mapper.test.ts b/tests/harness/self-hosting-type-mapper.test.ts new file mode 100644 index 000000000..d7568520c --- /dev/null +++ b/tests/harness/self-hosting-type-mapper.test.ts @@ -0,0 +1,145 @@ +import { execFile, execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import type { AnalyzeResult, compile } from "@scriptc/compiler"; +import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js"; +import type { MappingReport } from "../fixtures/self-hosting/type-mapper-cases.js"; +import { typeMapperInput } from "./self-hosting-type-mapper-input.js"; + +const root = join(import.meta.dirname, "../.."); +const fixtures = join(root, "tests/fixtures/self-hosting"); +const nativeSources = join(root, "packages/compiler/native"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const execFileAsync = promisify(execFile); + +function checkReport(report: MappingReport): void { + const byName = new Map(report.mappings.map((entry) => [entry.name, entry])); + expect(byName.size).toBe(73); + expect(byName.get("Text")!.type).toEqual({ kind: "string" }); + expect(byName.get("NumberValue")!.type).toEqual({ kind: "f64" }); + expect(byName.get("Flag")!.type).toEqual({ kind: "bool" }); + expect(byName.get("AnyValue")!.type).toBeNull(); + expect(byName.get("UnknownValue")!.type).toEqual({ kind: "dyn" }); + expect(byName.get("TextLiteral")!.literals).toEqual(["text"]); + expect(byName.get("NumberLiteral")!.literals).toEqual([-2.5]); + expect(byName.get("BooleanLiteral")!.literals).toEqual([false]); + expect(byName.get("MixedLiterals")!.literals).toEqual(expect.arrayContaining(["1", 1, false])); + expect(byName.get("Text")!.literals).toBeNull(); + expect(byName.get("RecordValue")!.type?.kind).toBe("record"); + expect(byName.get("LinkedRecord")!.display).toContain("..."); + expect(byName.get("MutualRecord")!.display).toContain("..."); + expect(byName.get("StringMap")!.type?.kind).toBe("map"); + expect(byName.get("StringSet")!.type?.kind).toBe("set"); + expect(byName.get("Call")!.type?.kind).toBe("func"); + expect(byName.get("ClassInstance")!.type).toEqual({ kind: "object", className: "Named" }); + expect(byName.get("ClassValue")!.type).toEqual({ kind: "classval", className: "Named" }); + for (const name of ["ProductionType", "ProductionExpression", "ProductionStatement"]) { + const type = byName.get(name)!.type; + expect(type?.kind, name).toBe("union"); + if (type?.kind !== "union") throw new Error(`${name} did not map`); + const def = report.unions.find((union) => union.id === type.unionId)!; + expect(def.arms.length, name).toBeGreaterThan(10); + expect(def.discriminant?.field, name).toBe("kind"); + } + expect(byName.get("ProductionModule")!.type?.kind).toBe("record"); + expect(byName.get("ProductionFunction")!.type?.kind).toBe("record"); + for (const name of ["Expression", "NumericVariant", "BooleanVariant", "MixedVariant", "AliasedVariant", "OtherVariant"]) { + const type = byName.get(name)!.type; + if (type?.kind !== "union") throw new Error(`${name} did not map to a union`); + const def = report.unions.find((union) => union.id === type.unionId)!; + expect(def.discriminant?.field, name).toBe("kind"); + expect(def.discriminant!.cases.length, name).toBeGreaterThanOrEqual(2); + } + for (const name of ["PlainUnion", "OptionalKind", "AccessorKind", "BroadKind"]) { + const type = byName.get(name)!.type; + if (type?.kind !== "union") throw new Error(`${name} did not map to a union`); + expect(report.unions.find((union) => union.id === type.unionId)!.discriminant, name).toBeUndefined(); + } + expect(byName.get("AliasedVariant")!.type).not.toEqual(byName.get("OtherVariant")!.type); + expect(report.variants.length).toBeGreaterThanOrEqual(18); + expect(report.memoEntries).toBeGreaterThan(30); + expect(report.hooks).toEqual([ + "Box:first:f64", "Box:second:f64", "parameter:f64:string", + "HookIndexed:f64:string", "HookKeys:f64:string", "HookLiteral:f64:string", "dynamic memo isolation", + ]); +} + +for (const backend of ["c", "llvm"] as const) { + test(`production type mapper runs against native TS7 (${backend})`, async () => { + const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-type-mapper-")); + try { + const object = join(directory, "process.o"); + execFileSync("clang", ["-std=c11", "-Wall", "-Wextra", "-Werror", ...(sanitize ? ["-fsanitize=address"] : []), + "-c", join(nativeSources, "ts7-process.c"), "-o", object]); + const profile = join(directory, "ffi.json"); + writeFileSync(profile, JSON.stringify({ + ...JSON.parse(readFileSync(join(nativeSources, "ts7-process.ffi.json"), "utf8")), libraries: [object], + })); + const source = join(directory, "input.ts"); + writeFileSync(source, typeMapperInput + ` + import type { IrType, IrExpr, IrStmt, IrModule, IrFunction } from ${JSON.stringify(join(root, "packages/compiler/src/ir/ir.js"))}; + export type ProductionType = IrType; + export type ProductionExpression = IrExpr; + export type ProductionStatement = IrStmt; + export type ProductionModule = IrModule; + export type ProductionFunction = IrFunction; + `); + const expected = join(directory, "node.json"); + const node = spawnSync(process.execPath, ["--import", "tsx", join(fixtures, "type-mapper-node.ts"), ts7Executable(), source, expected], { + encoding: "utf8", timeout: 90_000, + }); + expect(node.error, node.stderr).toBeUndefined(); + expect(node.status, node.stderr).toBe(0); + expect(node.stdout).toBe(""); + expect(node.stderr).toBe(""); + const nodeReport = JSON.parse(readFileSync(expected, "utf8")) as { first: MappingReport; second: MappingReport }; + checkReport(nodeReport.first); + expect(nodeReport.second).toEqual(nodeReport.first); + + const api = pathToFileURL(join(root, "packages/compiler/src/index.ts")).href; + const { stdout, stderr } = await execFileAsync(process.execPath, [ + "--import", "tsx", "--input-type=module", "--eval", + `import { analyze, compile } from ${JSON.stringify(api)}; + const options = { dynamic: false, ffiProfilePath: process.argv[2] }; + const { coverage } = analyze(process.argv[1], options); + const built = await compile(process.argv[1], { + ...options, backend: process.argv[3], optimization: 'dev', sanitize: process.argv[4] === '1', + outDir: process.argv[5], outPath: process.argv[6], + }); + console.log(JSON.stringify({ coverage: { + preflightFailed: coverage.preflightFailed, diagnostics: coverage.diagnostics, stats: coverage.stats, + }, built }));`, + join(fixtures, "type-mapper.ts"), profile, backend, sanitize ? "1" : "0", directory, + join(directory, process.platform === "win32" ? "mapper.exe" : "mapper"), + ], { cwd: root, timeout: 300_000, maxBuffer: 32 * 1024 * 1024 }); + expect(stderr).toBe(""); + const { coverage, built } = JSON.parse(stdout) as { + coverage: AnalyzeResult["coverage"]; + built: Awaited>; + }; + expect(coverage.preflightFailed, JSON.stringify(coverage.diagnostics)).toBe(false); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.stats.statementsFailed).toBe(0); + expect(coverage.stats.statementsIsland).toBe(0); + expect(coverage.stats.functionsSkipped).toBe(0); + if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + if (!("binaryPath" in built)) throw new Error("type mapper did not produce a native executable"); + expect(built.backend).toBe(backend); + expect(built.llvmRefusal).toBeUndefined(); + const output = join(directory, "native.json"); + const native = spawnSync(built.binaryPath, [ts7Executable(), source, output], { encoding: "utf8", timeout: 90_000 }); + expect(native.error, native.stderr).toBeUndefined(); + expect(native.signal, native.stderr).toBeNull(); + expect(native.status, native.stderr).toBe(0); + expect(native.stdout).toBe(node.stdout); + expect(native.stderr).toBe(node.stderr); + const nativeReport = JSON.parse(readFileSync(output, "utf8")) as { first: MappingReport; second: MappingReport }; + expect(nativeReport.first.tuples).toEqual(nodeReport.first.tuples); + expect(nativeReport).toEqual(nodeReport); + } finally { rmSync(directory, { recursive: true, force: true }); } + }); +}