mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 }));
|
||||
}
|
||||
@@ -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
@@ -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 }); }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user