diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 3192a8397..bada3c7c4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -3704,7 +3704,10 @@ function lowerPromiseThenPresence( expected?: (IrType & { kind: "array" }) | (IrType & { kind: "record" }),): IrExpr { const loc = locOf(expr); const ctxType = lowerer.checker.getContextualType(expr); - const tsType = ctxType ?? lowerer.typeOf(expr); + // `as const satisfies readonly T[]` checks against an array but keeps + // the literal's inferred tuple type. Build that shape; an actual array + // destination can use the ordinary tuple-to-array coercion afterward. + const tsType = underConstAssertion(expr) ? lowerer.typeOf(expr) : ctxType ?? lowerer.typeOf(expr); let mapped = expected ?? lowerer.mapTypeOf(tsType); // A JS literal whose OWN inferred type is never-tainted // (neverTaintedJsType — the evolving `const gb = []`, the mixed @@ -3827,17 +3830,13 @@ function lowerPromiseThenPresence( // literal constructs the tuple's record shape — one positional field // per element, source order (which IS index order, so evaluation order // is JS-exact). tsc has already checked the arity; the recount below - // backstops `as` smuggling. Spreads have no fixed positions — fenced. + // backstops `as` smuggling. Fixed tuple spreads have known positions. if (mapped?.kind === "record") { const shape = lowerer.shapes.get(mapped.shapeId); if (shape?.tuple) { const spread = expr.elements.find(ts.isSpreadElement); if (spread) { - lowerer.unsupported( - "SC1090", - spread, - "spread elements in tuple literals (positions must be spelled out)", - ); + return lowerTupleSpreadLiteral(lowerer, expr, mapped, shape); } if (expr.elements.length !== shape.fields.length) { // tsc padded an UNDER-LENGTH literal against an optional-element @@ -4109,6 +4108,65 @@ function lowerPromiseThenPresence( }; } +/** Build a fixed tuple from positional elements and fixed tuple spreads. + * Capture each value in source order: deferring the reads until recordLit + * would observe a later element's mutations of the spread source. */ +function lowerTupleSpreadLiteral( + lowerer: Lowerer, + expr: ts.ArrayLiteralExpression, + type: IrType & { kind: "record" }, + shape: IrRecordShape, +): IrExpr { + const loc = locOf(expr); + const byName = new Map(shape.fields.map((field) => [field.name, field.type])); + const stmts: IrStmt[] = []; + const fields: { name: string; value: IrExpr }[] = []; + const append = (node: ts.Expression, value: IrExpr): void => { + const name = String(fields.length); + const expected = byName.get(name); + if (!expected) lowerer.badType(expr, lowerer.typeOf(expr)); + const coerced = lowerer.coerceInto(node, value, expected); + const temp = lowerer.declareHiddenLocal("%tupleElement", expected); + const at = locOf(node); + stmts.push({ kind: "varDecl", localId: temp.id, init: coerced, loc: at }); + fields.push({ name, value: varRef(temp.id, expected, at) }); + }; + for (const element of expr.elements) { + if (!ts.isSpreadElement(element)) { + const expected = byName.get(String(fields.length)); + if (!expected) lowerer.badType(element, lowerer.typeOf(element)); + append(element, lowerer.lowerExprExpecting(element, expected)); + continue; + } + const source = lowerer.lowerExpr(element.expression); + const sourceShape = source.type.kind === "record" ? lowerer.shapes.get(source.type.shapeId) : undefined; + if (!sourceShape?.tuple) { + // Empty tuples use a zero-length array representation. Preserve a + // producing call's effects even though there are no positions to copy. + const sourceTs = lowerer.typeOf(element.expression); + if (source.type.kind === "array" && lowerer.checker.isTupleType(sourceTs) && + lowerer.checker.getTypeArguments(sourceTs as ts.TypeReference).length === 0) { + stmts.push({ kind: "exprStmt", expr: source, loc: locOf(element) }); + continue; + } + lowerer.unsupported("SC1090", element, "spreading a variable-length value into a fixed tuple literal"); + } + const temp = lowerer.declareHiddenLocal("%tupleSpread", source.type); + const at = locOf(element); + stmts.push({ kind: "varDecl", localId: temp.id, init: source, loc: at }); + const receiver = varRef(temp.id, source.type, at); + const positions = [...sourceShape.fields].sort((a, b) => Number(a.name) - Number(b.name)); + for (const field of positions) { + append(element.expression, { + kind: "recordGet", obj: receiver, shapeId: sourceShape.id, + field: field.name, type: field.type, loc: at, + }); + } + } + if (fields.length !== shape.fields.length) lowerer.badType(expr, lowerer.typeOf(expr)); + return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc }; +} + /** Convert an undefined-armed numeric value at an arithmetic use. Ordinary * reads preserve their tagged value; JavaScript's ToNumber(undefined) is NaN, diff --git a/packages/compiler/src/frontend/type-mapper.test.ts b/packages/compiler/src/frontend/type-mapper.test.ts new file mode 100644 index 000000000..44e482522 --- /dev/null +++ b/packages/compiler/src/frontend/type-mapper.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; +import { F64, STRING, VOID, type IrType } from "../ir/ir.js"; +import { formatIrType, ShapeRegistry, UnionRegistry } from "./type-mapper.js"; + +describe("IR type diagnostics", () => { + test("preserves small types, repeated sibling shapes, and array precedence", () => { + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + const record: IrType = { kind: "record", shapeId: shapes.intern([{ name: "value", type: F64 }]) }; + const union: IrType = { kind: "union", unionId: unions.intern([F64, STRING]) }; + const callback: IrType = { kind: "func", params: [record, record], ret: union }; + expect(formatIrType({ kind: "array", elem: callback }, shapes, unions)) + .toBe("(({ value: number }, { value: number }) => number | string)[]"); + expect(formatIrType({ kind: "array", elem: union }, shapes, unions)).toBe("(number | string)[]"); + expect(formatIrType({ kind: "map", key: STRING, value: { kind: "set", elem: F64 } }, shapes, unions)) + .toBe("Map>"); + expect(formatIrType({ kind: "generator", async: true, yieldT: record, retT: VOID, nextT: F64 }, shapes, unions)) + .toBe("AsyncGenerator<{ value: number }, void, number>"); + }); + + test("breaks recursive record/union paths without hiding later siblings", () => { + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + const record: IrType = { kind: "record", shapeId: shapes.intern([]) }; + const union: IrType = { kind: "union", unionId: unions.intern([record, STRING]) }; + shapes.get(record.shapeId)!.fields.push({ name: "next", type: union }); + const seen = new Set(); + expect(formatIrType({ kind: "func", params: [record, record], ret: VOID }, shapes, unions, seen)) + .toBe("({ next: ... | string }, { next: ... | string }) => void"); + expect(seen.size).toBe(0); + }); + + test("keeps numeric tuple order, accessor spelling, and index signatures", () => { + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + const tuple = shapes.intern([ + { name: "10", type: STRING }, { name: "2", type: F64 }, { name: "0", type: VOID }, + ], true); + expect(formatIrType({ kind: "record", shapeId: tuple }, shapes, unions)).toBe("[void, number, string]"); + const shapeId = shapes.intern([ + { name: "%get:value", type: { kind: "func", params: [], ret: F64 } }, + { name: "%set:value", type: { kind: "func", params: [F64], ret: VOID } }, + ], false, STRING); + expect(formatIrType({ kind: "record", shapeId }, shapes, unions)) + .toBe("{ get value(): number; set value(number); [key: string]: string }"); + }); + + test("bounds expansion of a shared acyclic type graph", () => { + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + let type: IrType = STRING; + // Only sixteen shapes, but naive expansion repeats the leaf 65,536 times. + for (let i = 0; i < 16; i++) { + type = { kind: "record", shapeId: shapes.intern([{ name: "left", type }, { name: "right", type }]) }; + } + const seen = new Set(); + const text = formatIrType(type, shapes, unions, seen); + expect(text.length).toBeLessThanOrEqual(4096); + expect(text).toMatch(/^\{ left: \{ left:/); + expect(text.endsWith("...")).toBe(true); + expect(seen.size).toBe(0); + expect(formatIrType(type, shapes, unions, seen)).toBe(text); + }); + + test("bounds deeply nested wrappers without overflowing the stack", () => { + let type: IrType = STRING; + for (let i = 0; i < 10_000; i++) type = { kind: "promise", inner: type }; + const text = formatIrType(type, new ShapeRegistry(), new UnionRegistry()); + expect(text.startsWith("Promise { + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + const id = unions.intern([]); + const arms = unions.get(id)!.arms; + for (let i = 0; i < 2000; i++) arms.push(STRING); + // Formatting must stop before reaching this arm, not slice a completed string. + Object.defineProperty(arms, 1999, { get() { throw new Error("visited after output was full"); } }); + const text = formatIrType({ kind: "union", unionId: id }, shapes, unions); + expect(text.length).toBeLessThanOrEqual(4096); + expect(text.endsWith("...")).toBe(true); + const longName = formatIrType({ kind: "object", className: "x".repeat(10_000) }, shapes, unions); + expect(longName.length).toBeLessThanOrEqual(4096); + expect(longName.endsWith("...")).toBe(true); + }); +}); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index dfe3c0045..785724e56 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -411,158 +411,226 @@ export class UnionRegistry { /** Human-readable rendering of an IrType for diagnostics (records expand to * their canonical field list, unions to their arms; `checker.typeToString` - * can't — it never sees IR types). `seen` breaks recursive shapes/unions: - * a back-reference renders as "..." instead of expanding forever. */ + * can't — it never sees IR types). Bound the traversal as well as its output: + * cycle detection alone still expands a shared acyclic graph exponentially. + * The same budget covers every sibling, and deep wrapper chains truncate + * before exhausting the JS stack. Small types retain their full spelling. */ export function formatIrType(t: IrType, shapes: ShapeRegistry, unions: UnionRegistry, seen: Set = new Set()): string { - switch (t.kind) { - case "f64": - return "number"; - case "string": - return "string"; - case "bool": - return "boolean"; - case "dyn": - return "unknown"; - case "caught": - // What tsc calls the binding; the fence messages carry the real story. - return "unknown"; - case "void": - return "void"; - case "undefinedT": - return "undefined"; - case "nullT": - return "null"; - case "array": { - // Union/func elements need the parens TS syntax would ("(number | - // string)[]" — without them the [] reads as binding to the last arm). - const elem = formatIrType(t.elem, shapes, unions, seen); - return t.elem.kind === "union" || t.elem.kind === "func" ? `(${elem})[]` : `${elem}[]`; - } - case "bytes": - // The u8 kind reads as Uint8Array (Buffer maps here too — one - // runtime representation; the message stays honest either way). - return t.elem === "u8" ? "Uint8Array" : t.elem === "u32" ? "Uint32Array" : t.elem === "i32" ? "Int32Array" : t.elem === "f32" ? "Float32Array" : "Float64Array"; - case "map": - return `Map<${formatIrType(t.key, shapes, unions, seen)}, ${formatIrType(t.value, shapes, unions, seen)}>`; - case "set": - return `Set<${formatIrType(t.elem, shapes, unions, seen)}>`; - case "func": - return `(${t.params.map((p) => formatIrType(p, shapes, unions, seen)).join(", ")}) => ${formatIrType(t.ret, shapes, unions, seen)}`; - case "object": - // Runtime-provided error classes carry '%'-prefixed IR names - // ("%Error") so user classes can never collide; diagnostics show the - // source-level name. - return t.className.startsWith("%") ? t.className.slice(1) : t.className; - case "classval": - // The static side, in TS's own spelling. - return `typeof ${t.className.startsWith("%") ? t.className.slice(1) : t.className}`; - case "record": { - const shape = shapes.get(t.shapeId); - if (!shape) return `{ /* unknown shape ${t.shapeId} */ }`; - if (seen.has(t.shapeId)) return "..."; // the recursive knot - seen.add(t.shapeId); - try { - if (shape.tuple) { - const byIndex = [...shape.fields].sort((a, b) => Number(a.name) - Number(b.name)); - return `[${byIndex.map((f) => formatIrType(f.type, shapes, unions, seen)).join(", ")}]`; - } - const members = shape.fields.map((f) => { - // Accessor slots print in TS's accessor spelling, not the - // reserved '%'-field encoding. - const slot = accessorSlotProp(f.name); - if (slot && f.type.kind === "func") { - return slot.kind === "get" - ? `get ${slot.prop}(): ${formatIrType(f.type.ret, shapes, unions, seen)}` - : `set ${slot.prop}(${formatIrType(f.type.params[0] ?? VOID, shapes, unions, seen)})`; + const maxLength = 4096; + const maxDepth = 32; + let output = ""; + let truncated = false; + const append = (text: string): void => { + if (truncated) return; + // Reserve the marker so a full buffer can always report truncation. + const remaining = maxLength - 3 - output.length; + if (text.length > remaining) { + output += text.slice(0, remaining) + "..."; + truncated = true; + } else { + output += text; + } + }; + const list = (types: readonly IrType[], separator: string, depth: number): void => { + for (let i = 0; i < types.length && !truncated; i++) { + if (i > 0) append(separator); + visit(types[i]!, depth); + } + }; + const visit = (t: IrType, depth: number): void => { + if (truncated) return; + if (depth >= maxDepth) return append("..."); + const child = (type: IrType): void => visit(type, depth + 1); + switch (t.kind) { + case "f64": + return append("number"); + case "string": + return append("string"); + case "bool": + return append("boolean"); + case "dyn": + return append("unknown"); + case "caught": + // What tsc calls the binding; the fence messages carry the real story. + return append("unknown"); + case "void": + return append("void"); + case "undefinedT": + return append("undefined"); + case "nullT": + return append("null"); + case "array": { + // Union/func elements need the parens TS syntax would ("(number | + // string)[]" — without them the [] reads as binding to the last arm). + const parens = t.elem.kind === "union" || t.elem.kind === "func"; + if (parens) append("("); + child(t.elem); + return append(parens ? ")[]" : "[]"); + } + case "bytes": + // The u8 kind reads as Uint8Array (Buffer maps here too — one + // runtime representation; the message stays honest either way). + return append(t.elem === "u8" ? "Uint8Array" : t.elem === "u32" ? "Uint32Array" : t.elem === "i32" ? "Int32Array" : t.elem === "f32" ? "Float32Array" : "Float64Array"); + case "map": + append("Map<"); + child(t.key); + append(", "); + child(t.value); + return append(">"); + case "set": + append("Set<"); + child(t.elem); + return append(">"); + case "func": + append("("); + list(t.params, ", ", depth + 1); + append(") => "); + return child(t.ret); + case "object": + // Runtime-provided error classes carry '%'-prefixed IR names + // ("%Error") so user classes can never collide; diagnostics show the + // source-level name. + return append(t.className.startsWith("%") ? t.className.slice(1) : t.className); + case "classval": + // The static side, in TS's own spelling. + append("typeof "); + return append(t.className.startsWith("%") ? t.className.slice(1) : t.className); + case "record": { + const shape = shapes.get(t.shapeId); + if (!shape) return append(`{ /* unknown shape ${t.shapeId} */ }`); + if (seen.has(t.shapeId)) return append("..."); // the recursive knot + seen.add(t.shapeId); + try { + if (shape.tuple) { + const byIndex = [...shape.fields].sort((a, b) => Number(a.name) - Number(b.name)); + append("["); + for (let i = 0; i < byIndex.length && !truncated; i++) { + if (i > 0) append(", "); + child(byIndex[i]!.type); + } + return append("]"); + } + if (shape.fields.length === 0 && !shape.indexValue) return append("{}"); + append("{ "); + for (let i = 0; i < shape.fields.length && !truncated; i++) { + if (i > 0) append("; "); + const f = shape.fields[i]!; + // Accessor slots print in TS's accessor spelling, not the + // reserved '%'-field encoding. + const slot = accessorSlotProp(f.name); + if (slot && f.type.kind === "func") { + append(slot.kind === "get" ? "get " : "set "); + append(slot.prop); + if (slot.kind === "get") { + append("(): "); + child(f.type.ret); + } else { + append("("); + child(f.type.params[0] ?? VOID); + append(")"); + } + } else { + append(f.name); + append(": "); + child(f.type); + } } - return `${f.name}: ${formatIrType(f.type, shapes, unions, seen)}`; - }); - if (shape.indexValue) { - members.push(`[key: string]: ${formatIrType(shape.indexValue, shapes, unions, seen)}`); + if (shape.indexValue) { + if (shape.fields.length > 0) append("; "); + append("[key: string]: "); + child(shape.indexValue); + } + return append(" }"); + } finally { + seen.delete(t.shapeId); // sibling occurrences still expand } - if (members.length === 0) return "{}"; - return `{ ${members.join("; ")} }`; - } finally { - seen.delete(t.shapeId); // sibling occurrences still expand } - } - case "union": { - const def = unions.get(t.unionId); - if (!def) return `/* union ${t.unionId} */`; - if (seen.has(t.unionId)) return "..."; // the recursive knot - seen.add(t.unionId); - try { - return def.arms.map((a) => formatIrType(a, shapes, unions, seen)).join(" | "); - } finally { - seen.delete(t.unionId); + case "union": { + const def = unions.get(t.unionId); + if (!def) return append(`/* union ${t.unionId} */`); + if (seen.has(t.unionId)) return append("..."); // the recursive knot + seen.add(t.unionId); + try { + return list(def.arms, " | ", depth + 1); + } finally { + seen.delete(t.unionId); + } + } + case "jsval": + return append("any"); + case "regex": + return append("RegExp"); + case "date": + return append("Date"); + case "url": + return append("URL"); + case "searchParams": + return append("URLSearchParams"); + case "symbol": + return append("symbol"); + case "bigint": + return append("bigint"); + case "stats": + return append("Stats"); + case "fileHandle": + return append("FileHandle"); + case "spawnRes": + return append("SpawnSyncReturns"); + case "child": + return append("ChildProcess"); + case "netServer": + return append("Server"); + case "netSocket": + return append("Socket"); + case "http2Session": + return append("Http2Session"); + case "http2Stream": + return append("Http2Stream"); + case "dgramSocket": + return append("dgram.Socket"); + case "testCtx": + return append("TestContext"); + case "httpReq": + return append("IncomingMessage"); + case "httpRes": + return append("ServerResponse"); + case "httpClientReq": + return append("ClientRequest"); + case "secureCtx": + return append("SecureContext"); + case "cryptoHash": + return append("Hash"); + case "cryptoHmac": + return append("Hmac"); + case "fsWatcher": + return append("FSWatcher"); + case "childStream": + return append("Readable"); + case "childWriter": + return append("Writable"); + case "procStream": + return append("WriteStream"); + case "moduleNs": + append("module namespace '"); + append(t.moduleId); + return append("'"); + case "promise": + append("Promise<"); + child(t.inner); + return append(">"); + case "generator": + append(t.async ? "AsyncGenerator<" : "Generator<"); + list([t.yieldT, t.retT, t.nextT], ", ", depth + 1); + return append(">"); + default: { + const _exhaustive: never = t; + void _exhaustive; + throw new InternalCompilerError("unreachable"); } } - case "jsval": - return "any"; - case "regex": - return "RegExp"; - case "date": - return "Date"; - case "url": - return "URL"; - case "searchParams": - return "URLSearchParams"; - case "symbol": - return "symbol"; - case "bigint": - return "bigint"; - case "stats": - return "Stats"; - case "fileHandle": - return "FileHandle"; - case "spawnRes": - return "SpawnSyncReturns"; - case "child": - return "ChildProcess"; - case "netServer": - return "Server"; - case "netSocket": - return "Socket"; - case "http2Session": - return "Http2Session"; - case "http2Stream": - return "Http2Stream"; - case "dgramSocket": - return "dgram.Socket"; - case "testCtx": - return "TestContext"; - case "httpReq": - return "IncomingMessage"; - case "httpRes": - return "ServerResponse"; - case "httpClientReq": - return "ClientRequest"; - case "secureCtx": - return "SecureContext"; - case "cryptoHash": - return "Hash"; - case "cryptoHmac": - return "Hmac"; - case "fsWatcher": - return "FSWatcher"; - case "childStream": - return "Readable"; - case "childWriter": - return "Writable"; - case "procStream": - return "WriteStream"; - case "moduleNs": - return `module namespace '${t.moduleId}'`; - case "promise": - return `Promise<${formatIrType(t.inner, shapes, unions, seen)}>`; - case "generator": - return `${t.async ? "AsyncGenerator" : "Generator"}<${formatIrType(t.yieldT, shapes, unions, seen)}, ${formatIrType(t.retT, shapes, unions, seen)}, ${formatIrType(t.nextT, shapes, unions, seen)}>`; - default: { - const _exhaustive: never = t; - void _exhaustive; - throw new InternalCompilerError("unreachable"); - } - } + }; + visit(t, 0); + return output; } /** True when the declaration sits inside `declare module ""` (or diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index aa050a7b6..602457b25 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -7277,6 +7277,12 @@ ], "diags": [] }, + "/tests/corpus/3069-tuple-spread.ts": { + "order": [ + "/tests/corpus/3069-tuple-spread.ts" + ], + "diags": [] + }, "/tests/corpus/400-fib.ts": { "order": [ "/tests/corpus/400-fib.ts" diff --git a/tests/corpus/3069-tuple-spread.ts b/tests/corpus/3069-tuple-spread.ts new file mode 100644 index 000000000..49e314f92 --- /dev/null +++ b/tests/corpus/3069-tuple-spread.ts @@ -0,0 +1,64 @@ +// Fixed tuple spreads preserve positions, source order, and fresh storage. +let calls = 0; +const source: [string, number] = ["before", 2]; +function take(): [string, number] { + calls++; + console.log("take", calls); + return source; +} +function change(): string { + console.log("change"); + source[0] = "after"; + source[1] = 9; + return "tail"; +} +function prefix(): boolean { + console.log("prefix"); + return true; +} +const tuple: [boolean, string, number, string, string, number] = [prefix(), ...take(), change(), ...take()]; +console.log(JSON.stringify(tuple), calls); +source[0] = "later"; +tuple[1] = "copy"; +console.log(JSON.stringify(source), JSON.stringify(tuple)); + +// Lexical field ordering must not reorder positions 10 and 11 before 2. +const many = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const; +const combined = ["start", ...many, "end"] as const; +console.log(JSON.stringify(combined)); + +// The compiler builds its IR kind tables with this exact type-only pattern. +const kinds = ["number", "string"] as const satisfies readonly string[]; +const moreKinds = [...kinds, "boolean"] as const; +console.log(JSON.stringify(moreKinds)); + +function empty(): [] { + console.log("empty"); + return []; +} +const withEmpty: [number, string] = [...empty(), 3, ...empty(), "yes", ...empty()]; +console.log(JSON.stringify(withEmpty)); + +// Spread slots use their destination types, including union wrapping. +const pair: [number, string] = [1, "two"]; +const widened: [number | string, number | string, boolean] = [...pair, false]; +console.log(JSON.stringify(widened)); + +// Elements are copied shallowly: referenced records retain their identity. +const record = { value: 1 }; +const refs: [typeof record, string] = [record, "ref"]; +const copied: [typeof record, string, number] = [...refs, 5]; +record.value = 8; +console.log(JSON.stringify(copied)); + +// A throwing source prevents later elements from being evaluated. +function fail(): [number, string] { + console.log("fail"); + throw new Error("spread failed"); +} +try { + const stopped: [boolean, number, string, string] = [prefix(), ...fail(), change()]; + console.log(JSON.stringify(stopped)); +} catch (error) { + if (error instanceof Error) console.log(error.message); +} diff --git a/tests/diagnostics/tuples.ts b/tests/diagnostics/tuples.ts index a210a1fc0..b87e17138 100644 --- a/tests/diagnostics/tuples.ts +++ b/tests/diagnostics/tuples.ts @@ -1,7 +1,7 @@ // Tuple + ref-element-array fences — what stays OUT of the new surface and // why: dynamic tuple indexing (heterogeneous positions have no single -// element type), optional/rest tuple elements (no fixed shape), for-of over -// tuples, spread into tuple literals, join on ref-element arrays (JS would +// element type), optional/rest tuple elements (no fixed shape), variable-length +// spreads into tuple literals, join on ref-element arrays (JS would // recursively toString), and Map-element arrays (hashed storage has no // element representation in the array runtime). const pair: [string, number] = ["a", 1]; @@ -21,8 +21,8 @@ for (const part of pair) { console.log(part); } -// Spread has no fixed positions inside a tuple literal. -const copy: [string, number] = [...pair]; +// An array's runtime length cannot determine a fixed tuple's positions. +const copy = [...(["a", 1] as (string | number)[])] as [string, number]; // The arity constant folds only off side-effect-free receivers. function mk(): [string, number] { diff --git a/tests/fixtures/self-hosting/package.json b/tests/fixtures/self-hosting/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/tests/fixtures/self-hosting/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/tests/fixtures/self-hosting/source-locations.ts b/tests/fixtures/self-hosting/source-locations.ts new file mode 100644 index 000000000..62ae096fd --- /dev/null +++ b/tests/fixtures/self-hosting/source-locations.ts @@ -0,0 +1,15 @@ +import { SourceLocations } from "../../../packages/compiler/src/backend/source-locations.js"; + +// Exercise the real compiler module, including every offset around each +// newline form, astral characters, empty files, and failed lookups. +const text = "one\r\ntwo\rthree\nfour\u2028five\u2029😀six\n"; +const sources = new Map(); +sources.set("main.ts", text); +sources.set("empty.ts", ""); +sources.set("other.ts", "other"); +const locations = new SourceLocations(sources); +for (const file of ["main.ts", "empty.ts", "other.ts", "missing.ts"]) { + for (let start = -1; start <= text.length + 1; start++) { + console.log(JSON.stringify(locations.position({ file, start, end: start }))); + } +} diff --git a/tests/fixtures/self-hosting/tsconfig.json b/tests/fixtures/self-hosting/tsconfig.json new file mode 100644 index 000000000..1de2c6d40 --- /dev/null +++ b/tests/fixtures/self-hosting/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "incremental": false, "types": ["node"] }, + "include": ["*.ts"] +} diff --git a/tests/fixtures/self-hosting/validate.ts b/tests/fixtures/self-hosting/validate.ts new file mode 100644 index 000000000..77666186c --- /dev/null +++ b/tests/fixtures/self-hosting/validate.ts @@ -0,0 +1,7 @@ +import { readFileSync } from "node:fs"; +import type { IrModule } from "../../../packages/compiler/src/ir/ir.js"; +import { validateModule } from "../../../packages/compiler/src/ir/validate.js"; + +// Probe validation independently of the serializer's reviver boundary. +const mod = JSON.parse(readFileSync(process.argv[2]!, "utf8")) as IrModule; +console.log(JSON.stringify(validateModule(mod))); diff --git a/tests/harness/__snapshots__/tuples.ts.txt b/tests/harness/__snapshots__/tuples.ts.txt index 02baae461..e7715887e 100644 --- a/tests/harness/__snapshots__/tuples.ts.txt +++ b/tests/harness/__snapshots__/tuples.ts.txt @@ -30,11 +30,11 @@ tuples.ts:16:7 - error SC2011: values of type '[string, ...number[]]' have no st hint: build with --dynamic to run these values in the embedded engine (adds ~620KB to the binary), or restate the type inside the static surface (https://scriptc.dev/limitations describes the boundary) -tuples.ts:25:33 - error SC1090: spread elements in tuple literals (positions must be spelled out) are not supported yet +tuples.ts:25:15 - error SC1090: spreading a variable-length value into a fixed tuple literal is not supported yet - 24 | // Spread has no fixed positions inside a tuple literal. - 25 | const copy: [string, number] = [...pair]; - | ^~~~~~~ + 24 | // An array's runtime length cannot determine a fixed tuple's positions. + 25 | const copy = [...(["a", 1] as (string | number)[])] as [string, number]; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | tuples.ts:31:13 - error SC1090: '.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first) is not supported yet diff --git a/tests/harness/self-hosting.test.ts b/tests/harness/self-hosting.test.ts new file mode 100644 index 000000000..c74e763e7 --- /dev/null +++ b/tests/harness/self-hosting.test.ts @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { analyze, compile } from "@scriptc/compiler"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); + +test("analyzes the compiler's IR validator without exhausting type diagnostics", () => { + const entry = join(root, "tests/fixtures/self-hosting/validate.ts"); + const { coverage } = analyze(entry, { dynamic: false }); + expect(coverage.preflightFailed).toBe(false); + expect(coverage.stats.statementsTotal).toBeGreaterThan(0); +}); + +// Keep these outside the ordinary corpus: they import implementation files +// beyond the fixture directory, which the corpus oracle cache does not hash. +// Node executes the actual TS modules through tsx's .js → .ts resolution. +for (const component of ["source-locations"]) { + for (const backend of ["c", "llvm"] as const) { + test(`self-hosting ${component}: ${backend} matches Node`, async () => { + const entry = join(root, "tests/fixtures/self-hosting", `${component}.ts`); + const outDir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-self-hosting-")); + try { + const oracle = spawnSync(process.execPath, ["--import", "tsx", entry], { + cwd: root, timeout: 30_000, maxBuffer: 1024 * 1024, + }); + expect(oracle.error).toBeUndefined(); + expect(oracle.signal).toBeNull(); + expect(oracle.status, oracle.stderr.toString()).toBe(0); + expect(oracle.stdout.length).toBeGreaterThan(0); + const built = await compile(entry, { + outDir, + outPath: join(outDir, process.platform === "win32" ? "program.exe" : "program"), + backend, + dynamic: false, + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const native = spawnSync(built.binaryPath, [], { cwd: root, timeout: 30_000, maxBuffer: 1024 * 1024 }); + expect(native.error).toBeUndefined(); + expect(native.signal).toBeNull(); + expect(native.status, native.stderr.toString()).toBe(oracle.status); + expect(native.stdout).toEqual(oracle.stdout); + expect(native.stderr).toEqual(oracle.stderr); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); + } +}