feat(compiler): run type mapping natively

- Preserve discriminated union identities across optional storage and literal conversions.
- Run the production type mapper against native TypeScript 7, including scriptc's own IR types.
- Correct tuple reference metadata and void returns through finally for native compilation.
This commit is contained in:
Chris Tate
2026-09-28 21:34:24 -05:00
parent 24ab670305
commit 60eee242bf
24 changed files with 1450 additions and 77 deletions
+6 -1
View File
@@ -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<typeof emitter.emitExpr> | 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]!;
@@ -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<number, SemanticTypeData[]>();
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();
});
});
@@ -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;
}
@@ -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
@@ -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);
@@ -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) };
}
}
}
@@ -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
@@ -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<string, number> | null {
const discriminant = union.discriminant;
if (!discriminant) return null;
const owners = new Map<string, number>();
const tags = new Set<number>();
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
@@ -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,
@@ -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);
}
});
});
+14 -22
View File
@@ -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<number, (string | number | boolean)[]>();
@@ -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" ||
@@ -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);
});
});
@@ -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<string, number> | null {
const discriminant = union.discriminant;
if (!discriminant) return null;
const owners = new Map<string, number>();
const tags = new Set<number>();
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<number>();
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 };
}
@@ -11187,6 +11187,30 @@
"<repo>/tests/corpus/typed-array-unknown.ts"
],
"diags": []
},
"<repo>/tests/corpus/return-void-through-finally.ts": {
"order": [
"<repo>/tests/corpus/return-void-through-finally.ts"
],
"diags": []
},
"<repo>/tests/corpus/union-literal-scalar-discriminants.ts": {
"order": [
"<repo>/tests/corpus/union-literal-scalar-discriminants.ts"
],
"diags": []
},
"<repo>/tests/corpus/union-literal-slot-conversion.ts": {
"order": [
"<repo>/tests/corpus/union-literal-slot-conversion.ts"
],
"diags": []
},
"<repo>/tests/corpus/union-optional-semantic-identity.ts": {
"order": [
"<repo>/tests/corpus/union-optional-semantic-identity.ts"
],
"diags": []
}
}
}
@@ -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<typeof loadProgram>;
const aliases = new Map<string, ts.Type>();
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);
});
});
@@ -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);
@@ -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);
@@ -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);
@@ -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));
}
+159
View File
@@ -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 }));
}
+6
View File
@@ -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(); }
+7
View File
@@ -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(); }
@@ -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<string, number>;
export type ReadonlyMapValue = ReadonlyMap<string, number>;
export type StringSet = Set<string>;
export type OptionalMap = Map<string, number> | undefined;
export type OptionalSet = Set<string> | null;
export type Bytes = Uint8Array;
export type FloatBytes = Float64Array;
export type RegularExpression = RegExp;
export type Clock = Date;
export type Future = Promise<string>;
export type FutureVoid = Promise<void>;
export type Iterate = Generator<number, string, boolean>;
export type IterateVoid = Generator<number, void, unknown>;
export type AsyncIterate = AsyncGenerator<string, void, unknown>;
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<RecordValue>;
export type PartialRecord = Partial<RecordValue>;
export type PickRecord = Pick<RecordValue, "name" | "count">;
export type OmitRecord = Omit<RecordValue, "enabled">;
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<T> { constructor(public value: T) {} }
export type HookGeneric = Box<number>;
export type HookParameter<T> = T;
export type HookIndexed<T, K extends keyof T> = T[K];
export type HookKeys<T> = T[keyof T];
export type HookLiteral<T extends { value: unknown }> = T["value"];
`;
@@ -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<ReturnType<typeof compile>>;
};
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 }); }
});
}