diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 6399558bd..534fad940 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -42,6 +42,11 @@ declare module "node:fs" { /* The bare-encoding spelling — the options record's encoding key alone. */ export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void; export function writeFileSync(path: string | URL, data: string | Uint8Array): void; + /* The file-descriptor form: Node writes the whole data to an already + * open descriptor and leaves it open (closeSync owns its lifetime, and + * the write lands at the descriptor's current offset). */ + export function writeFileSync(fd: number, data: string): void; + export function writeFileSync(fd: number, data: string, encoding: "utf8" | "utf-8"): void; export function appendFileSync(path: string | URL, data: string | Uint8Array): void; export function existsSync(path: string | URL): boolean; export function mkdirSync(path: string | URL): void; diff --git a/packages/compiler/src/backend/llvm/arenas.test.ts b/packages/compiler/src/backend/llvm/arenas.test.ts index 1ab4b010a..a0885bba4 100644 --- a/packages/compiler/src/backend/llvm/arenas.test.ts +++ b/packages/compiler/src/backend/llvm/arenas.test.ts @@ -67,6 +67,38 @@ console.log(total); expect(body).not.toContain("ScrArena.closed"); expect(body).not.toContain("@scr_arena_disposal"); } + // A record literal stores every field, and no dispose entry reads them: + // the record's constructor does not clear the object. A class constructor + // may leave fields unset, so the class's does. + const record = constructors.find((body) => body.includes("@sc_rnew_"))!; + const cls = constructors.find((body) => !body.includes("@sc_rnew_"))!; + expect(record).not.toContain("@llvm.memset"); + expect(cls).toContain("@llvm.memset"); +}); + +test("record literals in an arena that is not closed keep the clear their dispose entries need", async () => { + // The items hold outside strings, so their groups are not closed. + const llvm = await llvmFor(` +interface Item { + label: string; + next: Item | null; +} +function chain(labels: string[], n: number): Item { + return n === 0 ? { label: labels[0]!, next: null } : { label: labels[n % labels.length]!, next: chain(labels, n - 1) }; +} +function length(item: Item): number { + return item.next === null ? item.label.length : item.label.length + length(item.next); +} +const labels = ["a", "bb", "ccc"]; +let total = 0; +for (let round = 0; round < 4; round++) total += length(chain(labels, 10 + round)); +console.log(total); +`); + expect(llvm).toMatch(/@scr_arena_open\(ptr %[\w.]+, i32 0\)/); + const items = arenaConstructors(llvm).filter((body) => body.includes("@sc_rnew_")); + expect(items.length).toBe(1); + expect(items[0]).toContain(".dispose"); + expect(items[0]).toContain("@llvm.memset"); }); test("an arena that is not closed keeps the dispose list in every arena-aware constructor", async () => { diff --git a/packages/compiler/src/backend/llvm/arenas.ts b/packages/compiler/src/backend/llvm/arenas.ts index 3e3954856..426d3a9e3 100644 --- a/packages/compiler/src/backend/llvm/arenas.ts +++ b/packages/compiler/src/backend/llvm/arenas.ts @@ -275,6 +275,12 @@ export function arenaCurrentAddress(host: ArenaHost, dest: string): string[] | n * the arena is full. The bump, the clear, the header and the dispose entry * are inline (scr_arena.c's ScrArena ABI); scr_arena_obj takes a new chunk * and scr_arena_disposal grows the list. + * + * `clear` is what the bump path zeroes: the whole object, or only the + * collector header, for callers that store every word of the object before + * anything reads it (a record literal of a shape with no dispose entries: + * `init` stores the count and the shape number, and the literal every + * field). scr_arena_obj and the heap constructor return zeroed objects. */ export function arenaNewLines( host: ArenaHost, @@ -284,6 +290,7 @@ export function arenaNewLines( dispose: string | null, init: string[], result: string, + clear: "object" | "header" = "object", ): string[] { const S = host.sizeType; // ScrArena's fields and dispose entries are pointer-sized (scr_arena.c). @@ -328,8 +335,13 @@ export function arenaNewLines( `carve:`, ` %end = getelementptr inbounds i8, ptr %bp, ${S} %n`, ` store ptr %end, ptr %a`, - ` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} %n, i1 false)`, ); + if (clear === "object") + lines.push(` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} %n, i1 false)`); + else if (cyc !== null) + lines.push( + ` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} ${CYC_HDR}, i1 false)`, + ); if (cyc !== null) lines.push( ` %id = trunc i32 %tid to i16`, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 14476833f..e3e7e3797 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -557,6 +557,7 @@ export const LIB_FN_SYMS: Record = { "fs.readFileSyncBuf": "scr_fs_read_file_bytes", "fs.readFileSyncBytes": "scr_fs_read_file_bytes", "fs.writeFileSyncBytes": "scr_fs_write_file_bytes", + "fs.writeFileSyncFd": "scr_fs_write_file_fd", "fs.appendFileSyncBytes": "scr_fs_append_file_bytes", "fs.readFdSyncBytes": "scr_fs_read_fd_bytes", // Stats snapshots (scr_lib.c): statSync/lstatSync throw like the other diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 489b5d028..993a1ffa5 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -2387,8 +2387,20 @@ export function emitRecordShapes( ` %shp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${SHAPE_WORD_INDEX}`, ` store i32 ${shapeNumber}, ptr %shp ; shape number`, ); + // A record literal covers every field of its shape (ir.ts recordLit), + // so without dispose entries, which read the fields at the drop, no + // field is read before the literal stores it. defs.push( - ...arenaNewLines(host, newSym, sizeOf, traced ? cycType : null, dispose, init, "%o"), + ...arenaNewLines( + host, + newSym, + sizeOf, + traced ? cycType : null, + dispose, + init, + "%o", + dispose === null ? "header" : "object", + ), ); if (dispose !== null) defs.push( diff --git a/packages/compiler/src/frontend/lowering/builtins/array-classes.ts b/packages/compiler/src/frontend/lowering/builtins/array-classes.ts index 1922be0af..58a948372 100644 --- a/packages/compiler/src/frontend/lowering/builtins/array-classes.ts +++ b/packages/compiler/src/frontend/lowering/builtins/array-classes.ts @@ -647,26 +647,27 @@ export function lowerWithArraySpecies( // instance is created (with that length for map, 0 otherwise), then // the method runs to that length and its elements move into the // instance. - const bounded = - result.kind === "call" && - result.args[0]?.kind === "varRef" && - result.args[0].localId === local.id - ? boundedArrayHofHelper(lowerer, result.callee) - : null; - if (bounded === null || result.kind !== "call") + const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }]; + let bound: IrLocal | undefined; + const rebound = rebuildSpeciesCall(result, local.id, (inner) => { + const args: IrExpr[] = [receiverRef]; + for (const arg of inner.args.slice(1)) { + const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type); + stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc }); + args.push({ kind: "varRef", localId: held.id, type: arg.type, loc }); + } + const length = lowerer.declareHiddenLocal("%speciesLength", F64); + bound = length; + args.push({ kind: "varRef", localId: length.id, type: F64, loc }); + const helper = boundedArrayHofHelper(lowerer, inner.callee); + return helper === null ? null : { ...inner, callee: helper, args }; + }); + if (rebound === null || bound === undefined) lowerer.unsupported( "SC1090", call, `'${access.name.text}' in this form when the program declares a class that extends Array`, ); - const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }]; - const args: IrExpr[] = [receiverRef]; - for (const arg of result.args.slice(1)) { - const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type); - stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc }); - args.push({ kind: "varRef", localId: held.id, type: arg.type, loc }); - } - const bound = lowerer.declareHiddenLocal("%speciesLength", F64); stmts.push({ kind: "varDecl", localId: bound.id, @@ -681,7 +682,6 @@ export function lowerWithArraySpecies( loc, }); const boundRef: IrExpr = { kind: "varRef", localId: bound.id, type: F64, loc }; - args.push(boundRef); const instance = lowerer.declareHiddenLocal("%speciesInstance", receiver.type); const length: IrExpr = mode.resultLength ? boundRef @@ -706,7 +706,7 @@ export function lowerWithArraySpecies( fn: "array.speciesFinish", args: [ { kind: "varRef", localId: instance.id, type: receiver.type, loc }, - { ...result, callee: bounded, args }, + rebound, { kind: "boolLit", value: mode.setLength, type: BOOL, loc }, ], type: result.type, @@ -736,6 +736,31 @@ export function lowerWithArraySpecies( }; } +/** `e` with the `%arr.` helper call over `receiverLocal` rebuilt by + * `rebuild`, or null when `e` holds no such call. The helper call is the + * one taking the receiver as its first argument; a filter whose callback + * narrows converts the filtered array after the helper ran, so the call + * can sit behind a wrapper (the checked-narrowing call), which this + * rebuild leaves in place. */ +function rebuildSpeciesCall( + e: IrExpr, + receiverLocal: string, + rebuild: (inner: IrExpr & { kind: "call" }) => IrExpr | null, +): IrExpr | null { + if (e.kind !== "call") return null; + const first = e.args[0]; + if (first !== undefined && first.kind === "varRef" && first.localId === receiverLocal) + return rebuild(e); + for (let i = 0; i < e.args.length; i++) { + const replaced = rebuildSpeciesCall(e.args[i]!, receiverLocal, rebuild); + if (replaced === null) continue; + const args = e.args.slice(); + args[i] = replaced; + return { ...e, args }; + } + return null; +} + /** `C.of(...items)` and `C.from(iterable)` on a class declared `extends * Array` that declares neither: Array.of / Array.from's elements, in an * instance the class constructs (`new C(items.length)` for of, `new C()` diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 6b5e2d8ae..a308f2216 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -997,6 +997,42 @@ export function lowerFsWriteBytesCall( return null; } +// The writeFileSync(fd, data[, "utf8"]) form — Node accepts a file +// descriptor where it accepts a path (write to a descriptor openSync +// returned, the descriptor staying open: closeSync owns its lifetime). +// Routed by the ARGUMENT's static type, like readFileSync(fd). +export function lowerFsWriteDescriptorCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const supported = 'only string data is supported: writeFileSync(fd, data, "utf8")'; + const dataNode = expr.arguments.nodes[1]!; + const dataIr = lowerer.mapTypeOf(lowerer.typeOf(dataNode)); + if (dataIr?.kind !== "string") { + lowerer.noLowering( + `writeFileSync(fd) of '${dataIr ? lowerer.fmt(dataIr) : lowerer.checker.typeToString(lowerer.typeOf(dataNode))}' data`, + dataNode, + supported, + ); + } + if (expr.arguments.nodes.length > 3) { + lowerer.noLowering( + `writeFileSync(fd) with ${expr.arguments.nodes.length} arguments`, + expr, + supported, + ); + } + const fd = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, F64); + return { + kind: "libCall", + fn: "fs.writeFileSyncFd", + args: [fd, lowerer.lowerExprExpecting(dataNode, STRING)], + type: VOID, + loc, + }; +} + export function lowerFsWriteOptionsCall( lowerer: Lowerer, expr: ts.CallExpression, diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index b21c964f9..bf5223d2f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -14,6 +14,7 @@ import { lowerFsReadSyncCall, lowerFsWriteSyncCall, lowerFsReadDescriptorCall, + lowerFsWriteDescriptorCall, lowerFsEncodedRead, lowerFsPromisesEncodedRead, lowerFsMkdirSyncCall, @@ -331,6 +332,17 @@ export function lowerBuiltinModuleCall( const lowered = lowerFsWriteBytesCall(lowerer, expr, bi, loc); if (lowered) return lowered; } + // writeFileSync(fd, data): Node accepts an open descriptor where it + // accepts a path. Routed by the FIRST ARGUMENT's static type, like + // readFileSync(fd) (before the path forms complete the data type). + if ( + bi.module === "fs" && + bi.member === "writeFileSync" && + expr.arguments.nodes.length >= 2 && + lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "f64" + ) { + return lowerFsWriteDescriptorCall(lowerer, expr, loc); + } if ( bi.module === "fs" && (bi.member === "writeFileSync" || bi.member === "appendFileSync") && diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index c3b2cd479..fdd16da90 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -5,7 +5,11 @@ import * as ts from "../../ts7/adapter.js"; import type { Lowerer } from "../lowerer.js"; import { countedFor, varRef } from "../../../ir/build.js"; import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js"; -import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js"; +import { + maybePromiseResolve, + maybePromiseUnionOf, + promiseOrUnitUnionOf, +} from "../maybe-promise.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { BOOL, @@ -670,11 +674,17 @@ export function lowerPromiseStaticCall( if (v.type.kind !== "promise") lowerer.badType(argNode, argT); return v; } - const maybePromise = argIr ? maybePromiseUnionOf(lowerer, argIr) : null; + // A promise-or-unit union (`Promise | void`, + // `Promise | undefined`) resolves exactly like a maybe-promise + // one: Node adopts a promise operand and fulfills every other value + // as itself (a nullish arm fulfills undefined). + const maybePromise = argIr + ? (maybePromiseUnionOf(lowerer, argIr) ?? promiseOrUnitUnionOf(lowerer, argIr)) + : null; if (maybePromise) { const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); const v = lowerer.lowerExpr(argNode); - const mp = maybePromiseUnionOf(lowerer, v.type); + const mp = maybePromiseUnionOf(lowerer, v.type) ?? promiseOrUnitUnionOf(lowerer, v.type); const resolved = resultT?.kind === "promise" && mp ? maybePromiseResolve(lowerer, v, mp, resultT, loc) diff --git a/packages/compiler/src/frontend/lowering/builtins/x509.ts b/packages/compiler/src/frontend/lowering/builtins/x509.ts index 5d81070eb..1bd895eee 100644 --- a/packages/compiler/src/frontend/lowering/builtins/x509.ts +++ b/packages/compiler/src/frontend/lowering/builtins/x509.ts @@ -62,6 +62,22 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc ) return; const seen = new Map(); + // A type is recursive when it reaches itself without naming a shape or a + // union: `type Rec = string | Rec[]` passes through the array, set, map + // or promise case below. Those kinds have no id to memoize by, so a type + // met again while it is still being visited answers false: what a cycle + // adds to `holds` (a certificate exists somewhere in the type) is + // reachability, and nothing outside the cycle makes it reachable. + const visiting = new Set(); + const descend = (t: IrType, visit: () => boolean): boolean => { + if (visiting.has(t)) return false; + visiting.add(t); + try { + return visit(); + } finally { + visiting.delete(t); + } + }; const holds = (t: IrType): boolean => { switch (t.kind) { case "record": { @@ -79,13 +95,24 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc } case "array": case "set": - return holds(t.elem); + return descend(t, () => holds(t.elem)); case "map": - return holds(t.key) || holds(t.value); + return descend(t, () => holds(t.key) || holds(t.value)); case "promise": - return holds(t.inner); - case "union": - return lowerer.unions.get(t.unionId)?.arms.some(holds) === true; + return descend(t, () => holds(t.inner)); + case "union": { + // Unions have an id, so a cycle that names one is answered from the + // memo above; the identity guard covers a cycle that does not. + const key = `union:${t.unionId}`; + const known = seen.get(key); + if (known !== undefined) return known; + return descend(t, () => { + seen.set(key, false); + const result = lowerer.unions.get(t.unionId)?.arms.some(holds) === true; + seen.set(key, result); + return result; + }); + } default: return false; } diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 5eee71231..eca6e5ed2 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -3382,10 +3382,13 @@ export function lowerTuplePredicateCall( * of the receiver's length when it starts. ArraySpeciesCreate reads the * receiver's length before the species constructor runs, so a constructor * that appends to the receiver does not extend the loop - * (lowerWithArraySpecies). Null when `name` is no such helper. */ + * (lowerWithArraySpecies). Every helper that reads a length is one, the + * flatMap whose callback returns a union included, whether or not a + * pipeline can fuse it (arrHofInfo holds the fusable ones); forEach makes + * no array, so it stays out. Null when `name` is no such helper. */ export function boundedArrayHofHelper(lowerer: Lowerer, name: string): string | null { - const info = lowerer.arrHofInfo.get(name); - if (!info || info.method === "forEach") return null; + if (!name.startsWith("%arr.")) return null; + if (lowerer.arrHofInfo.get(name)?.method === "forEach") return null; const bounded = `${name}.bounded`; if (lowerer.liftedFnNamed(bounded)) return bounded; const fn = lowerer.liftedFnNamed(name); diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index cadee368a..2a3ad64c0 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1158,6 +1158,37 @@ export function lowerArrayFromCall( loc, }; } + // `Array.from(typedArray, mapper)`: the element read is live (a + // mapper that writes the source changes later reads, as in Node), + // so this is a loop over the typed array, not a toArray snapshot. + if (src.type.kind === "bytes" && args.length === 2) { + const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64)); + if (callback.arity > 2) + lowerer.noLowering("Array.from mapper with an array parameter", args[1]!); + const fnRet = callback.fnArg.type.ret; + if (fnRet.kind === "void" || fnRet.kind === "func") + lowerer.badType(call, lowerer.typeOf(call)); + requireProducedArrayElement(lowerer, call, "'Array.from(typedArray, mapper)'", fnRet); + const outElem = callbackArrayElement(lowerer, call, fnRet); + // The element kind rides in the key because the lifted helper's + // source parameter carries it (the reads themselves are always f64). + const key = `fromBytes:${src.type.elem}:${typeKey(outElem)}:${typeKey(fnRet)}:${callback.arity}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%arr.fromBytes", key); + lowerer.arrHofHelpers.set(key, helper); + lowerer.liftedFns.push( + buildArrayFromBytesFn(lowerer, helper, src.type, outElem, fnRet, callback.arity, loc), + ); + } + return { + kind: "call", + callee: helper, + args: [src, callback.fnArg], + type: arrayOf(outElem), + loc, + }; + } // A union of typed arrays and number arrays (indexed-unions.ts). const indexedArms = indexedUnionArms(lowerer, src.type); if (indexedArms && args.length === 1) @@ -1320,6 +1351,96 @@ function buildArrayFromArrayFn( }; } +/** The `Array.from(typedArray, mapper)` generation loop: every index is + * read through the typed array itself (live, like Node's iteration), so + * a mapper that writes the source is visible to the indexes it has not + * visited yet. */ +function buildArrayFromBytesFn( + lowerer: Lowerer, + name: string, + bytesT: IrType & { kind: "bytes" }, + outElem: IrType, + fnRet: IrType, + arity: number, + loc: SrcLoc, +): IrFunction { + const outT = arrayOf(outElem); + const fnT = funcOf([F64, F64].slice(0, arity), fnRet); + const a = varRef("a.0", bytesT, loc); + const i = varRef("i.0", F64, loc); + const out = varRef("out.0", outT, loc); + // The index parameter is the second one, never read for an index the + // element covers, so an element-only mapper drops it. + const reads: IrExpr[] = [ + { kind: "bytesIntrinsic", method: "get", receiver: a, args: [i], type: F64, loc }, + i, + ]; + const mapped: IrExpr = { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: reads.slice(0, arity), + type: fnRet, + loc, + }; + const held = !typeEquals(fnRet, outElem); + const value: IrExpr = held ? varRef("v.0", fnRet, loc) : mapped; + return { + name, + params: [ + { localId: "a.0", name: "a", type: bytesT }, + { localId: "f.0", name: "f", type: fnT }, + ], + returnType: outT, + locals: [ + { id: "a.0", name: "a", type: bytesT, mutable: true }, + { id: "f.0", name: "f", type: fnT, mutable: true }, + { id: "out.0", name: "out", type: outT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: true }, + ...(held ? [{ id: "v.0", name: "v", type: fnRet, mutable: false }] : []), + ], + body: [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: outT, loc }, + loc, + }, + { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, + { + kind: "while", + cond: { + kind: "bin", + op: "<", + left: i, + right: { + kind: "bytesIntrinsic", + method: "length", + receiver: a, + args: [], + type: F64, + loc, + }, + type: BOOL, + loc, + }, + body: [ + ...(held ? [{ kind: "varDecl", localId: "v.0", init: mapped, loc } as const] : []), + arrayValueStore(lowerer, out, i, value, outElem, loc), + { + kind: "assign", + localId: "i.0", + value: { kind: "bin", op: "+", left: i, right: numLit(1, loc), type: F64, loc }, + loc, + }, + ], + loc, + }, + { kind: "return", value: out, loc }, + ], + loc, + }; +} + /** `Array.from(s)` / `[...s]` on a STRING: the code-point split into a * fresh string[] (scr_str_code_points). */ export function strCharsCall(src: IrExpr, loc: SrcLoc): IrExpr { diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 8f41aa93e..4a35b3aee 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3592,11 +3592,12 @@ export function lowerUnionProperty( * message. The caller (the property/element dispatch) maybeNarrows. */ export function lowerUnionKeyedRead( lowerer: Lowerer, - expr: ts.Expression, + expr: ts.Node, unionId: string, value: IrExpr, key: IrExpr, literalField: string | null, + missingFieldAsUndefined = false, ): IrExpr | null { const def = lowerer.unions.get(unionId); if (!def) return null; @@ -3646,6 +3647,14 @@ export function lowerUnionKeyedRead( } if (!shape.indexValue) { if (literalField === null && shape.fields.length > 0) continue; + // A record arm that does not declare the key at all answers + // UNDEFINED in JS. Destructuring asks for that answer (its own + // desugar reads a missing property as undefined, Node-exact); a + // dotted read keeps refusing until narrowing proves the arm. + if (literalField !== null && missingFieldAsUndefined) { + push(UNDEFINED_T); + continue; + } return null; } if (!pushAnswer(shape.indexValue)) return null; @@ -3690,6 +3699,12 @@ export function lowerUnionKeyedRead( if (!surfaces(declared)) return null; continue; } + if (literalField !== null && !shape.indexValue && missingFieldAsUndefined) { + // The missing-field answer above must surface as the join's + // undefined arm (the backend's keyed-read miss path stores it). + if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null; + continue; + } const ovfShape: IrRecordShape = literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape; if (!recordKeyResultOk(lowerer, ovfShape, type)) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 4c108d06a..f1398c66e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -279,7 +279,11 @@ import { isThisParameter, type ParamShape } from "./call-signatures.js"; import { dynamicOverloadChoice } from "./overload-choice.js"; import { lowerStringIteratorValue } from "./iterator-adapters.js"; import { checkedArmExtraction } from "./coercions/unions.js"; -import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; +import { + maybePromiseReturnStmt, + promiseArmedUnionOf, + promiseOrUnitUnionOf, +} from "./maybe-promise.js"; import { requireObjLitGenericReceiver, objLitGenericFnNodeOf, @@ -4888,9 +4892,10 @@ const DYN_STRING_ONLY_METHODS = new Set([ /** `Array.isArray(v)` — a real runtime test on `unknown` values (the checked-dynamic tree's * array kind: dyn arrays answer true, bytes/objects/scalars false — exactly * JS, Uint8Array included), a compile-time constant on statically-typed - * ones (an `T[]` value IS an array, every other static kind is not; folded - * only over side-effect-free reads, the `in`-operator discipline; unions - * fence with the narrow-first hint). Null when the callee isn't THE + * ones (an `T[]` value IS an array, every other static kind is not; a + * computed argument is evaluated and its value discarded, the + * `in`-operator discipline; unions fence with the narrow-first hint). Null + * when the callee isn't THE * stdlib Array.isArray, so the chain keeps trying. */ function lowerArrayIsArrayCall( lowerer: Lowerer, @@ -4960,14 +4965,23 @@ function lowerArrayIsArrayCall( ); } if (arg.type.kind === "caught") return null; - if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") { - return { kind: "boolLit", value: lowerer.isArrayValueType(arg.type), type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - call, - "statically-decided Array.isArray on computed arguments (bind the value to a variable first)", - ); + const folded: IrExpr = { + kind: "boolLit", + value: lowerer.isArrayValueType(arg.type), + type: BOOL, + loc, + }; + if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") return folded; + // A computed argument (`Array.isArray(make())`, `Array.isArray([1, 2])`) + // still answers from its static type, so evaluate the argument for its + // effects and discard the value. + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: arg, loc }], + result: folded, + type: BOOL, + loc, + }; } /** Predicate declarations currently being inlined — re-entrancy guard @@ -5998,9 +6012,19 @@ export function lowerPromiseMethodCall( call: ts.CallExpression, access: ts.PropertyAccessExpression, ): IrExpr | null { - if (call.questionDotToken || access.questionDotToken) return null; + if (lowerer.chainBlocked(access, call)) return null; const member = access.name.text; if (member !== "then" && member !== "catch" && member !== "finally") return null; + // A call that ENDS an optional chain (`refreshing?.catch(h)`, + // `client?.close().catch(h)`) checker-types as the chain's + // undefined-armed union while the body this lowering builds is the + // promise arm — `finishOptionalChain` wraps it back into the union. + // Take the arm so the handler typing sees the promise it handles. + const callResultType = (): IrType | undefined => { + const t = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!t || t.kind === "promise") return t ?? undefined; + return promiseOrUnitUnionOf(lowerer, t)?.promise ?? t; + }; let recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)) ?? dynFallbackType(lowerer, access.expression, lowerer.typeOf(access.expression)); @@ -6117,7 +6141,7 @@ export function lowerPromiseMethodCall( const rejectedNode = call.arguments.nodes[1]!; const fulfilledAbsent = isAbsentHandler(fulfilledNode); const rejectedAbsent = isAbsentHandler(rejectedNode); - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const resultT = callResultType(); // An untyped call in JavaScript whose receiver arrived as a typed // promise (a function instantiated with one): Promise.prototype.then // over the promise as a checked-dynamic value, which shares its @@ -6516,7 +6540,7 @@ export function lowerPromiseMethodCall( `then handlers whose parameter is not the settled value's type (expected '${lowerer.fmt(inner)}', got '${lowerer.fmt(param)}')`, ); } - let resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + let resultT = callResultType(); // JavaScript whose declared result type has no mapping (zod's // `Promise.all(items).then((r) => ParseStatus.mergeArray(status, r))`, // typed by a .d.ts union): the handler's value settles the promise as @@ -6864,7 +6888,7 @@ export function lowerPromiseMethodCall( "value — take `(e)` or `(e: unknown)` and narrow with instanceof)", ); } - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const resultT = callResultType(); if (resultT?.kind !== "promise") { lowerer.noLowering( "catch with this handler's result type", @@ -6908,7 +6932,10 @@ export function lowerPromiseMethodCall( if (ts.isBlock(hb)) { catchBody = lowerer.lowerStmts(hb.statements.nodes); } else if (R.kind === "void") { - catchBody = [{ kind: "exprStmt", expr: lowerer.lowerExpr(hb), loc: locOf(hb) }]; + // The handler's value is discarded, so the body lowers as a + // statement: `() => undefined` is a pure no-op (a bare unit + // literal has no IR value), not an expression to evaluate. + catchBody = [lowerer.lowerExprStatement(hb)]; } else { // Bare-expression handler: `(e) => v` (promise results flatten // through the async-return path, like any `return v`). diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index b2e452faf..690f8a5ef 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -27,7 +27,11 @@ import { checkedPrivateReceiver, localPrivateBrandClass } from "./private-brands import { staticCondition } from "./static-typeof.js"; import { constituentTypes } from "../ts7/checker.js"; import type { Lowerer } from "./lowerer.js"; -import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js"; +import { + lowerMaybePromiseAwait, + maybePromiseUnionOf, + promiseArmedUnionOf, +} from "./maybe-promise.js"; import { checkedClassUnionAssertion } from "./class-assertions.js"; import { BIGINT_T, @@ -676,9 +680,23 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { loc, }; } - if (inner.kind === "union" || inner.kind === "dyn") { - // A union inner would need an arm-wise re-tag into the result - + if (inner.kind === "union") { + // A UNION inner (`Promise | undefined`): the awaited + // result is the checker's own `Awaited` of the operand, which + // the maybe-promise lowering already produces — it awaits the + // narrowed promise arm and coerces its settled union, and the + // unit arms keep JS's one-microtask hop. Only the promise-or- + // unit shape reaches here, so the maybe-promise union is the + // same union with its only data arms being units. + const armed = promiseArmedUnionOf(lowerer, value.type); + if (armed) return lowerMaybePromiseAwait(lowerer, expr, value, armed, loc); + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (the promise's inner type has no combined result union — await the promise arm after narrowing instead)`, + ); + } + if (inner.kind === "dyn") { lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 2afb40c60..afc14196e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -10,8 +10,12 @@ import { lowerIndexedUnionNumberRead, lowerIndexedUnionRead, } from "./indexed-unions.js"; -import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js"; -import { hostsAll } from "./expressions/hosted-narrowing.js"; +import { + declaredMemberOf, + isPromisePrototypeRead, + lowerUnionKeyedRead, +} from "./expressions/property-access.js"; +import { hostedStorageView, hostsAll } from "./expressions/hosted-narrowing.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; import { lowerForAwaitDynStream, nodeStreamInterfaceSide } from "./dyn-streams.js"; import { lowerForAwaitValueStream } from "./builtins/hash-stream.js"; @@ -3538,6 +3542,34 @@ export function lowerBindingPattern( .get(srcType.unionId) ?.arms.every((arm) => arm.kind === "record" || arm.kind === "object") ) { + const arms = lowerer.unions.get(srcType.unionId)!.arms; + if (!unionObjectPatternFits(lowerer, pattern, arms)) { + // NARROW FIRST: the checker may have refined the union at this + // very site (`if (u.kind === "a") { const { x } = u }`, an `in` + // guard) while the IR value keeps the stored, wider union. Bind + // the narrowed view once and destructure that. + const view = narrowedUnionPatternSource( + lowerer, + pattern, + present, + srcType.unionId, + locOf(pattern), + ); + if (view !== null) { + const tmp = lowerer.declareHiddenLocal("%destrN", view.type); + out.push({ kind: "varDecl", localId: tmp.id, init: view, loc: locOf(pattern) }); + lowerer.lowerBindingPattern( + pattern, + () => ({ kind: "varRef", localId: tmp.id, type: view.type, loc: locOf(pattern) }), + view.type, + isLet, + out, + dynSpell, + allowDynObject, + ); + return; + } + } lowerUnionObjectPattern(lowerer, pattern, srcRef, srcType, isLet, out); return; } @@ -3950,11 +3982,235 @@ function lowerUrlObjectPattern( } } +/** The fresh record for `{ a, ...rest }` over a UNION source: per arm, + * the unconsumed fields copied out at destructure time into a fresh + * record of the checker's arm-wise rest type, selected by the runtime + * tag. Null-safe answer: an arm whose remaining fields do not spell an + * arm of the checker's rest type keeps the named fence. */ +function unionObjectRestValue( + lowerer: Lowerer, + el: ts.BindingElement, + srcRef: () => IrExpr, + srcType: IrType & { kind: "union" }, + arms: readonly IrType[], +): IrExpr { + const loc = locOf(el); + const restT = patternBindingType(lowerer, el.name!); + const fence: (why: string) => never = (why) => lowerer.unsupported("SC1031", el, why); + if (restT === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + // The consumed set is the pattern's literal keys: a computed sibling + // would make it a runtime fact. + const consumed = new Set(); + const pattern = el.parent; + if (ts.isObjectBindingPattern(pattern)) { + for (const sib of pattern.elements.nodes) { + if (sib === el || sib.dotDotDotToken) continue; + const key = sib.propertyName ?? sib.name; + if (key && ts.isComputedPropertyName(key)) { + fence("rest bindings beside computed keys (the consumed set is a runtime fact)"); + } + if (key && (ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key))) { + consumed.add(key.text); + } + } + } + const pieces: { tag: number; value: IrExpr }[] = []; + for (let tag = 0; tag < arms.length; tag++) { + const arm = arms[tag]!; + if (arm.kind !== "record") { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.indexValue) { + fence( + "rest bindings over index-signature shapes (the undeclared entries would need overflow packing)", + ); + } + const rem = shape.fields.filter((f) => !consumed.has(f.name)); + if (rem.some((f) => f.name.startsWith("%"))) { + fence("rest bindings over accessor shapes (the entries are closures, not data)"); + } + const armRef: IrExpr = { + kind: "unionNarrow", + unionId: srcType.unionId, + tag, + value: srcRef(), + type: arm, + loc, + }; + const packed: IrExpr = lowerer.keyOrder.copy( + { + kind: "recordLit", + fields: rem.map((f) => ({ + name: f.name, + value: lowerer.presenceKeepingCopy( + armRef, + arm.shapeId, + f.name, + { + kind: "recordGet", + obj: armRef, + shapeId: arm.shapeId, + field: f.name, + type: f.type, + loc, + }, + loc, + ), + })), + type: { + kind: "record", + shapeId: lowerer.shapes.intern(rem.map((f) => ({ name: f.name, type: f.type }))), + }, + loc, + }, + arm.shapeId, + ); + const coerced = lowerer.coerceInto(el, packed, restT); + if (!typeEquals(coerced.type, restT)) { + fence( + `rest bindings over union sources (the remaining fields of an arm do not spell an arm of '${lowerer.fmt(restT)}')`, + ); + } + pieces.push({ tag, value: coerced }); + } + let result: IrExpr | null = null; + for (let i = pieces.length - 1; i >= 0; i--) { + const piece = pieces[i]!; + result = + result === null + ? piece.value + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: srcType.unionId, + tag: piece.tag, + negated: false, + value: srcRef(), + type: BOOL, + loc, + }, + then: piece.value, + else_: result, + type: restT, + loc, + }; + } + if (result === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + return result; +} + +/** The one type every arm declares for `propName`, or null when an arm + * does not declare it (or types it differently, or not at all). */ +function unionPatternCommonType( + lowerer: Lowerer, + arms: readonly IrType[], + propName: string, +): IrType | null { + let common: IrType | null = null; + for (const arm of arms) { + let ft: IrType | undefined; + if (arm.kind === "record") { + ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; + } else if (arm.kind === "object") { + ft = lowerer.classes.get(arm.className)?.fields.get(propName); + } + if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { + return null; + } + common = ft; + } + return common; +} + +/** Whether the unionDisc fast path lowers this pattern over these arms + * unchanged: no rest element, and every element's key folds to one name + * every arm declares with one type. */ +function unionObjectPatternFits( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + arms: readonly IrType[], +): boolean { + for (const el of pattern.elements.nodes) { + if (el.name === undefined) continue; + if (el.dotDotDotToken) return false; + const prop = el.propertyName ?? el.name; + const propName = + ts.isIdentifier(prop) || + ts.isComputedPropertyName(prop) || + ts.isStringLiteralLike(prop) || + ts.isNumericLiteral(prop) + ? patternKeyNameOf(lowerer, prop as ts.PropertyName) + : null; + if (propName === null) return false; + if (unionPatternCommonType(lowerer, arms, propName) === null) return false; + } + return true; +} + +/** The CHECKER-NARROWED view of a destructuring source: `if (u.kind === + * "a") { const { x } = u }` or an `in` guard refines the type at the + * pattern's own site, while the IR value keeps the stored (wider) union + * and the un-narrowed lowering. Bind the value to the narrowed sub-union + * — a trusted arm extraction for one arm, a re-tag with the excluded + * arms as traps for a sub-union — so the pattern reads the arms the + * checker proved. Null when the checker did not narrow, when the + * refinement is an intersection of stored records (hostedStorageView + * keeps that layout), or when nothing was excluded. */ +function narrowedUnionPatternSource( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + value: IrExpr, + fromId: string, + loc: SrcLoc, +): IrExpr | null { + const from = lowerer.unions.get(fromId); + if (!from) return null; + const narrowed = lowerer.irTypeOf(pattern); + if (narrowed.kind === "void" || isUnitType(narrowed)) return null; + const siteArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!siteArms || siteArms.length === 0) return null; + const allowed = new Set(); + for (const arm of siteArms) { + const tag = lowerer.armTag(fromId, arm); + // Not a stored arm: a checker intersection that refines the stored + // records, which keeps the value's layout. + if (tag < 0) return hostedStorageView(lowerer, value, narrowed, loc); + allowed.add(tag); + } + if (allowed.size === 0 || allowed.size === from.arms.length) return null; + if (allowed.size === 1) { + const tag = [...allowed][0]!; + // The checker proved the tag; the extraction traps on a mismatch + // instead of reading another arm. + return { kind: "unionNarrow", unionId: fromId, tag, value, type: from.arms[tag]!, loc }; + } + const arms = from.arms.filter((_, tag) => allowed.has(tag)); + const subId = lowerer.unions.transform(from, arms); + const helper = lowerer.narrowedRetagHelper(pattern, fromId, subId, loc); + if (!helper) return null; + return { + kind: "call", + callee: helper, + args: [value], + type: { kind: "union", unionId: subId }, + loc, + }; +} + /** An object pattern over a union of records and class instances * (`function f({ kind, path }: Add | Remove)`): each element reads a data - * field every arm declares with one type, through the runtime tag, as - * `value.field` does. Rest elements and fields the arms type differently - * stay fenced. */ + * field through the runtime tag, as `value.field` does — the shared + * `unionDisc` when every arm declares it with one type, else the + * `unionKeyGet` join (an arm that does not declare the key answers + * undefined, as JS reads it). A rest element packs each arm's unconsumed + * fields into a fresh record. Class arms still need same-typed fields. */ function lowerUnionObjectPattern( lowerer: Lowerer, pattern: ts.ObjectBindingPattern, @@ -3967,7 +4223,17 @@ function lowerUnionObjectPattern( for (const el of pattern.elements.nodes) { if (el.name === undefined) continue; if (el.dotDotDotToken) { - lowerer.unsupported("SC1031", el, `rest bindings over union sources (${NARROW_FIRST})`); + // `{ a, ...rest }` over a union: each arm packs its own unconsumed + // fields into a FRESH record (JS's CopyDataProperties makes a fresh + // object too), and the checker's rest type is the union of those + // per-arm packs. The tag decides which pack runs. + lowerer.bindPatternTarget( + el.name, + unionObjectRestValue(lowerer, el, srcRef, srcType, arms), + isLet, + out, + ); + continue; } const prop = el.propertyName ?? el.name; const propName = @@ -3984,35 +4250,43 @@ function lowerUnionObjectPattern( "destructuring with computed keys that do not fold to one property name", ); } - let common: IrType | null = null; - for (const arm of arms) { - let ft: IrType | undefined; - if (arm.kind === "record") { - ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; - } else if (arm.kind === "object") { - ft = lowerer.classes.get(arm.className)?.fields.get(propName); - } - if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { - common = null; - break; - } - common = ft; - } - if (common === null) { - lowerer.unsupported( - "SC1031", + const common = unionPatternCommonType(lowerer, arms, propName); + let value: IrExpr; + if (common !== null) { + value = { + kind: "unionDisc", + unionId: srcType.unionId, + field: propName, + value: srcRef(), + type: common, + loc: locOf(el), + }; + } else { + // The unionDisc generalization (`unionKeyGet`): each arm answers + // its own declared type and an arm that does not declare the + // key answers undefined — exactly JS's desugar, which reads a + // missing property as undefined. + const loc = locOf(el); + const joined = lowerUnionKeyedRead( + lowerer, el, - `destructuring '${propName}' from a union whose arms type it differently (every arm must be a record or class with a same-typed field '${propName}' — ${NARROW_FIRST})`, + srcType.unionId, + srcRef(), + { kind: "strLit", value: propName, type: STRING, loc }, + propName, + true, ); + if (joined === null) { + lowerer.unsupported( + "SC1031", + el, + `destructuring '${propName}' from a union whose arms answer it with types that have no combined representation (every arm's '${ + propName + }' must join into one union — ${NARROW_FIRST})`, + ); + } + value = joined; } - let value: IrExpr = { - kind: "unionDisc", - unionId: srcType.unionId, - field: propName, - value: srcRef(), - type: common, - loc: locOf(el), - }; if (el.initializer) value = applyBindingDefault(lowerer, el, value); lowerer.bindPatternTarget(el.name, value, isLet, out); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e6b6a4653..475fab7e7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -225,6 +225,7 @@ import { recordTextCodecClass } from "../../ir/ir.js"; import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; import { dynamicSlotOperationCounts, dynamicTierWarning } from "./dynamic-tier.js"; import { promoteJsvals } from "../../ir/jsval.js"; +import { returnDispatchReceivers } from "./receiver-returns.js"; import { analyzeTiers, dynamicStatementCount, @@ -9464,6 +9465,7 @@ export class Lowerer { applyHandleProperties(this, functions); installBufferValue(functions); installFetchBodyWebStreams(this, functions); + returnDispatchReceivers(this, functions); // Globals typed by a class that never REGISTERED (a JS class whose // collection fenced — Symbol-keyed fields, an unsupported base): the diff --git a/packages/compiler/src/frontend/lowering/maybe-promise.ts b/packages/compiler/src/frontend/lowering/maybe-promise.ts index 8323c2e91..8cc7d7e35 100644 --- a/packages/compiler/src/frontend/lowering/maybe-promise.ts +++ b/packages/compiler/src/frontend/lowering/maybe-promise.ts @@ -23,6 +23,18 @@ export function maybePromiseUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUn return armed; } +/** The promise arm of a promise-or-unit union (`Promise | void`, + * `Promise | undefined`): exactly one promise arm and only nullish + * arms beside it, so no other arm carries a value the promise path would + * have to reconcile. A guard that proves the value present (`?.`, an + * explicit narrow) leaves this arm alone. */ +export function promiseOrUnitUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null { + const armed = promiseArmedUnionOf(lowerer, t); + if (!armed) return null; + if (!armed.def.arms.every((a, i) => i === armed.promiseTag || isUnitType(a))) return null; + return armed; +} + /** Any union with exactly one promise arm: a maybe-promise union or a * promise-or-unit union (`Promise | void`). An async `return` * resolves both the same way: adopt the promise arm, fulfill otherwise. */ diff --git a/packages/compiler/src/frontend/lowering/receiver-returns.ts b/packages/compiler/src/frontend/lowering/receiver-returns.ts new file mode 100644 index 000000000..5e83f236a --- /dev/null +++ b/packages/compiler/src/frontend/lowering/receiver-returns.ts @@ -0,0 +1,131 @@ +/* Methods that return their receiver, called through an untyped value. + * + * A JavaScript method that ends every path with `return this` (a fluent + * setter such as `step(dx, dy) { this.x += dx; return this; }`) boxes its + * receiver on every call: a lookup of the instance's live capsule, or a new + * capsule when it has none. A class dispatch helper + * (`%dyn.class.call.N`) that calls such a method already holds a capsule + * of the same instance at its exact class: its receiver after + * normalization. The helper now calls a copy of the method that returns + * nothing and answers its own receiver, so the call boxes nothing. Other + * callers (vtables, typed code, method values) keep the method itself. */ +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { VOID } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import type { Lowerer } from "./lowerer.js"; + +const DISPATCH_PREFIX = "%dyn.class.call."; +const BOX_EXACT_PREFIX = "%dyn.class.boxExact:"; + +/** The name of a method's copy that returns nothing. */ +function receiverCopyName(method: string): string { + return `%dyn.class.receiver:${method.slice(1)}`; +} + +export function returnDispatchReceivers(lowerer: Lowerer, functions: IrFunction[]): void { + const byName = new Map(functions.map((fn) => [fn.name, fn])); + const copies = new Map(); + const copyOf = (name: string): IrFunction | null => { + const known = copies.get(name); + if (known !== undefined) return known; + const method = byName.get(name); + const copy = method && returnsReceiver(lowerer, method) ? receiverCopy(method) : null; + copies.set(name, copy); + if (copy) functions.push(copy); + return copy; + }; + for (const helper of functions) { + if (!helper.name.startsWith(DISPATCH_PREFIX)) continue; + const receiver = helper.params[0]?.localId; + if (receiver === undefined || !normalizedOnly(helper, receiver)) continue; + for (const stmt of helper.body) { + if (stmt.kind !== "if" || stmt.then.length !== 1) continue; + const only = stmt.then[0]!; + if (only.kind !== "return" || only.value === null || only.value.kind !== "call") continue; + const call = only.value; + if (!receiverArgument(call.args[0], receiver)) continue; + const copy = copyOf(call.callee); + if (!copy) continue; + stmt.then = [ + { + kind: "exprStmt", + expr: { ...call, callee: copy.name, type: VOID }, + loc: only.loc, + }, + { kind: "return", value: varRef(receiver, only.value.type, only.loc), loc: only.loc }, + ]; + } + } +} + +/** The helper's receiver is assigned only by its normalization, so it is + * the capsule of the instance's own class wherever a branch reads it. */ +function normalizedOnly(helper: IrFunction, receiver: string): boolean { + return everyStmtList(helper.body, { + stmt: (s) => + !( + (s.kind === "assign" || s.kind === "forOf") && + s.localId === receiver && + !( + s.kind === "assign" && + s.value.kind === "call" && + s.value.callee === "%dyn.class.normalize" + ) + ), + expr: (e) => !((e.kind === "assignExpr" || e.kind === "incDec") && e.localId === receiver), + }); +} + +/** `dynCheck(receiver)`, possibly upcast to the method's class. */ +function receiverArgument(arg: IrExpr | undefined, receiver: string): boolean { + if (arg?.kind === "upcast") arg = arg.value; + return arg?.kind === "dynCheck" && arg.value.kind === "varRef" && arg.value.localId === receiver; +} + +/** Whether every path of a synchronous method returns `this` boxed at its + * exact class. */ +function returnsReceiver(lowerer: Lowerer, fn: IrFunction): boolean { + if (fn.async !== undefined || fn.generator !== undefined) return false; + if (fn.captures?.length || fn.classCaptures?.length) return false; + if (fn.returnType.kind !== "dyn") return false; + const self = fn.params[0]; + if (self?.type.kind !== "object" || self.name !== "this") return false; + const last = fn.body[fn.body.length - 1]; + if (last?.kind !== "return") return false; + const isSelf = (value: IrExpr | null): boolean => { + if (value === null) return false; + if (value.kind === "call" && value.callee.startsWith(BOX_EXACT_PREFIX)) + return thisRef(value.args[0], self.localId); + // Without exact boxing the capsule names the static class, which is + // the instance's own class only when nothing extends it. + if (value.kind === "dynFrom" && value.liveRef === undefined && value.fnName === undefined) { + const className = self.type.kind === "object" ? self.type.className : ""; + return ( + thisRef(value.value, self.localId) && + (lowerer.classes.get(className)?.subclasses.length ?? 1) === 0 + ); + } + return false; + }; + return everyStmtList(fn.body, { + stmt: (s) => s.kind !== "return" || isSelf(s.value), + expr: () => true, + }); +} + +function thisRef(value: IrExpr | undefined, thisId: string): boolean { + return value?.kind === "varRef" && value.localId === thisId; +} + +/** A copy of `method` whose returns return nothing. */ +function receiverCopy(method: IrFunction): IrFunction { + const copy = structuredClone(method); + copy.name = receiverCopyName(method.name); + copy.returnType = VOID; + copy.body = transformStmtList(copy.body, { + stmt: (s): IrStmt => (s.kind === "return" ? { ...s, value: null } : s), + expr: (e) => e, + }); + return copy; +} diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 97fd3ccda..03b65bd54 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2197,7 +2197,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. - writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync"], + // The bytes-data form, the { mode } options form, and the + // file-descriptor data form. + writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"], appendFileSync: ["fs.appendFileSyncBytes"], // The fs stream members (bytesRead/bytesWritten, pending, path, close). createReadStream: [ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index a87a448c5..050699bd4 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -920,6 +920,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "emitter.setDefaultMaxChk", "fs.readFileSyncBytes", "fs.writeFileSyncBytes", + "fs.writeFileSyncFd", "fs.appendFileSyncBytes", "zlib.inflateSync", "zlib.inflateRawSync", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ba35a92e3..bfb39cf16 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5289,6 +5289,11 @@ function buildLibFnSignatures() { "fs.mkdtempSync": { argTypes: [STRING], result: STRING }, "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, "fs.readFdSync": { argTypes: [F64, STRING], result: STRING }, + /** The writeFileSync(fd, data) form: a write(2) loop over the whole + * string at the descriptor's current offset, leaving the descriptor + * open (closeSync owns it). Errors carry the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ + "fs.writeFileSyncFd": { argTypes: [F64, STRING], result: VOID }, "fs.readFdSyncBytes": { argTypes: [F64], result: BYTES_U8 }, /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ diff --git a/packages/compiler/src/ir/jsval.test.ts b/packages/compiler/src/ir/jsval.test.ts index 610bfa804..d2eadf18d 100644 --- a/packages/compiler/src/ir/jsval.test.ts +++ b/packages/compiler/src/ir/jsval.test.ts @@ -344,3 +344,66 @@ test("a parameter read by dyn consumers or passed cells keeps the dyn ABI", () = expect(twice.returnType).toEqual(DYN); expect(validateModule(mod)).toEqual([]); }); + +/** `for (...) %g.sum = %g.sum + o.step` in a dynamic function. */ +function globalLoop(name: string, global: string): IrFunction { + return fn(name, [ + { + kind: "while", + cond: { kind: "boolLit", value: false, type: BOOL, loc }, + body: [ + { + kind: "assign", + localId: global, + value: op("dyn.add", ref(global), keyGet(ref("o"), "step")), + loc, + }, + ], + loc, + }, + { kind: "return", value: ref("o"), loc }, + ]); +} + +test("a dyn module global written by operators in a loop becomes a jsval global", () => { + const loop = globalLoop("loop", "%g.e.sum"); + // %g.sum.x, once: a dyn consumer. + const report = fn("report", [ + { kind: "exprStmt", expr: keyGet(ref("%g.e.sum"), "x"), loc }, + { kind: "return", value: ref("o"), loc }, + ]); + const mod = moduleOf(loop, report); + mod.globals = [{ id: "%g.e.sum", name: "sum", type: DYN, mutable: true }]; + promoteJsvals(mod); + expect(mod.globals[0]!.type).toEqual(JSVAL); + // The loop's own result (o, a jsval result) is the second js.fromDyn. + expect(jsCalls(loop.body)).toEqual(["js.add", "js.fromDyn", "js.fromDyn"]); + // One box for the read; the second call is the jsval result o. + expect(jsCalls(report.body)).toEqual(["js.toDyn", "js.fromDyn"]); + expect(validateModule(mod)).toEqual([]); +}); + +test("a module global a static function reads stays dyn", () => { + const loop = globalLoop("loop", "%g.e.sum"); + const reader: IrFunction = { + name: "reader", + params: [], + returnType: DYN, + locals: [], + body: [{ kind: "return", value: ref("%g.e.sum"), loc }], + loc, + }; + const mod = moduleOf(loop, reader); + expect(reader.tier).toBeUndefined(); + mod.globals = [{ id: "%g.e.sum", name: "sum", type: DYN, mutable: true }]; + promoteJsvals(mod); + expect(mod.globals[0]!.type).toEqual(DYN); + expect(jsCalls(loop.body)).toEqual([ + "js.toDyn", + "js.add", + "js.fromDyn", + "js.fromDyn", + "js.fromDyn", + ]); + expect(validateModule(mod)).toEqual([]); +}); diff --git a/packages/compiler/src/ir/jsval.ts b/packages/compiler/src/ir/jsval.ts index 4828e9a90..fad4a55c7 100644 --- a/packages/compiler/src/ir/jsval.ts +++ b/packages/compiler/src/ir/jsval.ts @@ -32,7 +32,9 @@ * of the parameter by dyn consumers box more often than its callers do; * - a dyn local becomes a jsval local when the boxing it saves on writes * of operator results outweighs the boxing its reads by dyn consumers add - * (loop depth weights both). + * (loop depth weights both); + * - a dyn module global becomes a jsval global by the same measure, summed + * over every function, when only dynamic-tier functions reference it. * * The rewrite changes representation only: every js builtin implements its * dyn twin's JS semantics, including ToPrimitive order and error messages @@ -116,10 +118,12 @@ export function promoteJsvals(mod: IrModule): number { const storesJs = (e: IrExpr): boolean => e.kind === "call" && sites.site(e.callee)?.kind === "set"; const params = jsvalParams(mod, direct, results, storesJs); + const globals = promotableGlobals(mod, results, params, storesJs); let count = 0; for (const fn of mod.functions) - if (jsvalEligible(fn) && promoteFunction(fn, results, params, storesJs)) count++; + if (jsvalEligible(fn) && promoteFunction(fn, results, params, storesJs, globals)) count++; for (const fn of mod.functions) if (results.has(fn.name)) fn.returnType = JSVAL; + for (const global of mod.globals ?? []) if (globals.has(global.id)) global.type = JSVAL; return count; } @@ -273,6 +277,22 @@ function promotableLocals( credits: ReadonlyMap = new Map(), jsParams: ReadonlyMap = new Map(), ): Set { + const candidates = localCandidates(fn, credits); + if (candidates.size === 0) return new Set(); + const blocked = new Set(); + scanBindingCosts(fn, candidates, blocked, producer, jsResult, storesJs, jsParams); + const promoted = new Set(); + for (const [id, cost] of candidates) + if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); + return promoted; +} + +/** The dyn locals of a function that may become jsval locals, parameters + * with their credit. */ +function localCandidates( + fn: IrFunction, + credits: ReadonlyMap, +): Map { const candidates = new Map(); for (const local of fn.locals) { if (local.type.kind === "dyn" && local.boxed === undefined && local.tdz === undefined) @@ -286,8 +306,69 @@ function promotableLocals( } for (const c of fn.captures ?? []) candidates.delete(c.localId); for (const c of fn.classCaptures ?? []) candidates.delete(c.localId); + return candidates; +} + +/** The dyn module globals that become jsval globals: those that only + * dynamic-tier functions reference, by reads and plain writes, and whose + * saved boxing outweighs the added boxing summed over every function, the + * same measure as for locals. Libraries keep every global (the host reads + * exported bindings), and so do bindings with a temporal dead zone. */ +function promotableGlobals( + mod: IrModule, + results: ReadonlySet, + jsParams: ReadonlyMap, + storesJs: (e: IrExpr) => boolean, +): Set { + const candidates = new Map(); + if (mod.lib !== undefined) return new Set(); + for (const global of mod.globals ?? []) + if (global.type.kind === "dyn" && global.tdz === undefined && global.initFlag === undefined) + candidates.set(global.id, { saved: 0, added: 0 }); if (candidates.size === 0) return new Set(); const blocked = new Set(); + const producer = (e: IrExpr): boolean => + isProducerCall(e) || (e.kind === "call" && results.has(e.callee)); + for (const fn of mod.functions) { + if (!jsvalEligible(fn)) { + // A static function reads and writes the binding as a cell (I1). + everyStmtList(fn.body, { + expr: (e) => { + if ( + (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") && + candidates.has(e.localId) + ) + blocked.add(e.localId); + return true; + }, + stmt: (st) => { + if ((st.kind === "assign" || st.kind === "forOf") && candidates.has(st.localId)) + blocked.add(st.localId); + return true; + }, + }); + continue; + } + scanBindingCosts(fn, candidates, blocked, producer, results.has(fn.name), storesJs, jsParams); + } + const promoted = new Set(); + for (const [id, cost] of candidates) + if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); + return promoted; +} + +/** Adds to `candidates` the boxing a jsval binding saves and adds in `fn`, + * and to `blocked` the candidates `fn` references other than by reads and + * plain writes. */ +function scanBindingCosts( + fn: IrFunction, + candidates: ReadonlyMap, + blocked: Set, + producer: (e: IrExpr) => boolean, + jsResult: boolean, + storesJs: (e: IrExpr) => boolean, + jsParams: ReadonlyMap, +): void { const write = (id: string, value: IrExpr | null, weight: number): void => { const cost = candidates.get(id); if (cost === undefined) return; @@ -431,10 +512,6 @@ function promotableLocals( } }; for (const s of fn.body) stmt(s, 1); - const promoted = new Set(); - for (const [id, cost] of candidates) - if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); - return promoted; } /** A statement-level sequence whose result nothing reads. */ @@ -449,6 +526,7 @@ function promoteFunction( results: ReadonlySet, params: ReadonlyMap, storesJs: (e: IrExpr) => boolean, + globals: ReadonlySet, ): boolean { const resultCall = (e: IrExpr): boolean => e.kind === "call" && results.has(e.callee); const operatorCall = (e: IrExpr): boolean => isProducerCall(e); @@ -460,6 +538,7 @@ function promoteFunction( new Map(), params, ); + for (const id of globals) promoted.add(id); const ownParams = params.get(fn.name); fn.params.forEach((p, i) => { if (ownParams?.[i] === true) promoted.add(p.localId); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json new file mode 100644 index 000000000..9bcba109c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8420-x509-recursive-dyn-type.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json new file mode 100644 index 000000000..da74ec380 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8421-array-subclass-species-forms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json new file mode 100644 index 000000000..c04737333 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8422-array-isarray-computed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json new file mode 100644 index 000000000..6862c988b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/array-from-typed-array-mapper.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json new file mode 100644 index 000000000..7cbf7bf9e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-method-returns-receiver.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json new file mode 100644 index 000000000..fc0d4f6ef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/dynamic-module-let-imports/counter.mjs", + "/tests/corpus/dynamic-module-let-imports/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json new file mode 100644 index 000000000..107fa6e7a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-module-let-numbers.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..25021cc12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-write-file-sync-fd.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json new file mode 100644 index 000000000..5ff12d854 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-catch-optional-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json new file mode 100644 index 000000000..c85693cc2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-resolve-promise-or-unit.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json new file mode 100644 index 000000000..f9ad9a7b3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-void-catch-undefined.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json new file mode 100644 index 000000000..f62e66a8a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-await-promise-arm.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..99385b8d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-destructuring-join-narrow.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e8009d25a..67f18c0d8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4242,6 +4242,25 @@ double scr_fs_write_str_sync(double fd, ScrStr *data, double position, return wrote; } +/* writeFileSync(fd, data): Node writes the WHOLE data to the descriptor + * and leaves it open (closeSync owns its lifetime), at the descriptor's + * current offset — libuv's uv_fs_write loop, so a pipe's short writes + * resume until the data is gone. Errors keep the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ +void scr_fs_write_file_fd(double fd, ScrStr *data) { + char *owned; + const char *bytes = scr_str_utf8(data, &owned); + size_t at = 0; + while (at < data->len) { + double wrote = scr_fs_write_bytes(fd, bytes + at, data->len - at, -1); + /* A throw leaves 0 (or the short count a partial write reported); + * a zero-length write on a nonempty slice would spin, so stop. */ + if (wrote <= 0) break; + at += (size_t)wrote; + } + scr_str_utf8_end(owned); +} + /* fs.readSync(fd, buffer, offset, length[, position]) — the buffer form. * Node validates offset/length against the buffer before reading and * throws ERR_OUT_OF_RANGE; here the checks clamp to the same contract and diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 1d8d91d97..fa88873af 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -289,6 +289,10 @@ ScrStr *scr_fs_read_file(ScrStr *path); ScrStr *scr_fs_realpath(ScrStr *path); ScrStr *scr_fs_realpath_promise(ScrStr *path); void scr_fs_write_file(ScrStr *path, ScrStr *data); +/* writeFileSync(fd, data): a write(2) loop over the whole string at the + * descriptor's current offset; the descriptor stays open (closeSync owns + * it). Errors carry the fd operation's errno shape. */ +void scr_fs_write_file_fd(double fd, ScrStr *data); void scr_fs_append_file(ScrStr *path, ScrStr *data); bool scr_fs_exists(ScrStr *path); void scr_fs_mkdir(ScrStr *path); diff --git a/tests/corpus/8420-x509-recursive-dyn-type.ts b/tests/corpus/8420-x509-recursive-dyn-type.ts new file mode 100644 index 000000000..99d4991d2 --- /dev/null +++ b/tests/corpus/8420-x509-recursive-dyn-type.ts @@ -0,0 +1,42 @@ +// A recursive type that cycles through an array and a set, next to a +// certificate: the fence that refuses an X509Certificate in an `unknown` +// slot (x509.ts) walks the converted value's type, and a cycle through +// anything but a record used to recurse until the stack ran out — a crash, +// not a diagnostic. The types are named here the way a JSON-ish union is, +// and the certificate is the one 7090 parses. +import { X509Certificate } from "node:crypto"; + +// A self-signed certificate without alternative names (as in 7090). +const PLAIN = `-----BEGIN CERTIFICATE----- +MIIC8jCCAdoCAQcwDQYJKoZIhvcNAQELBQAwPjEaMBgGA1UEAwwRcGxhaW4uZXhh +bXBsZS5vcmcxIDAeBgkqhkiG9w0BCQEWEXBsYWluQGV4YW1wbGUub3JnMCAXDTI2 +MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjA+MRowGAYDVQQDDBFwbGFpbi5l +eGFtcGxlLm9yZzEgMB4GCSqGSIb3DQEJARYRcGxhaW5AZXhhbXBsZS5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq01NcNAy9Ng04mVuAFvmZUvsm +rY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag7Khmj4nmeoXN +of5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUNovA6JjGKH99b +8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtFrduAWz01q0HM +02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJNOd4AgwZaCT02 +KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqSHQZenxupAgMB +AAEwDQYJKoZIhvcNAQELBQADggEBAJX1DGuiSLoCKBDI3oCgAu7X86G01ovEgZJr +8JrmGHW5q7ujjo53t46h8XPm3LJ8UV9/MZ+Yry43xB2txXYv3b3g/i+7JdTWLgUD +INA5VWvVkEZfm0lBEpU1Agpy4lV5uUCKZTgP/3MY5hnJ0YhQIs4BOjbwueMRFshL +yyHVb73MMbpWaZ7+T368seafa+ekeuY6gQly/HacuszjoYd6KKV3n8eDewenClCF +HoOF1Y4I+CvmR+a6B3iBczJ+a4tceFIIWsZUIeqzvGHpM7G9YwlmbvGprfLgR8Nv +igUib/D9tU3hdL6VWN5nMjdXV/8Le4nDnBjVBjYDkVbsnmT91AQ= +-----END CERTIFICATE----- +`; + +type Rec = string | Rec[]; +type RecSet = number | Set; + +const cert = new X509Certificate(PLAIN); +const rec: Rec = ["a", ["b"]]; +const set: RecSet = new Set([1, 2]); + +function describe(value: unknown): string { + return typeof value; +} + +console.log(describe(rec), rec.length, describe(set), set.size); +console.log(cert.subject); diff --git a/tests/corpus/8421-array-subclass-species-forms.ts b/tests/corpus/8421-array-subclass-species-forms.ts new file mode 100644 index 000000000..67c119c80 --- /dev/null +++ b/tests/corpus/8421-array-subclass-species-forms.ts @@ -0,0 +1,32 @@ +// One class that extends Array sends every array method call in the program +// through the species lowering, plain-array receivers included. Two callback +// forms the species path used to refuse: a filter whose callback narrows the +// element union (a type predicate, or a discriminant test tsc infers one +// from), which wraps the filtered array in a checked-narrowing conversion, +// and a flatMap whose callback returns a union holding an array arm. +class Stack extends Array {} + +type Item = { kind: "a"; n: number } | { kind: "b"; s: string }; +const items: Item[] = [ + { kind: "a", n: 1 }, + { kind: "b", s: "x" }, + { kind: "a", n: 2 }, +]; +const isA = (item: Item): item is { kind: "a"; n: number } => item.kind === "a"; + +const tagged = items.filter(isA); +const inferred = items.filter((item) => item.kind === "b"); +const blocked = items.filter((item) => { + return item.kind === "b"; +}); +const flattened = items.flatMap((item) => (item.kind === "a" ? [item.n] : [])); +const mixed = items.flatMap((item) => (item.kind === "a" ? [item.n] : undefined)); +console.log(tagged.length, tagged.map((item) => item.n).join(",")); +console.log(inferred.length, inferred.map((item) => item.s).join(","), blocked.length); +console.log(flattened.join(","), mixed.length, mixed.join(",")); + +const stack = Stack.from([1, 2, 3]); +const even = stack.filter((n) => n % 2 === 0); +const doubled = stack.flatMap((n) => (n > 1 ? [n, n * 10] : [])); +console.log(even instanceof Stack, even.join(","), stack.length); +console.log(doubled instanceof Stack, doubled.join(","), stack.length); diff --git a/tests/corpus/8422-array-isarray-computed.ts b/tests/corpus/8422-array-isarray-computed.ts new file mode 100644 index 000000000..c0a91491c --- /dev/null +++ b/tests/corpus/8422-array-isarray-computed.ts @@ -0,0 +1,28 @@ +// `Array.isArray` over a computed argument: a call, an array literal, an +// object literal. The answer comes from the argument's static type, so the +// argument is evaluated for its effects and its value discarded. A union +// argument keeps answering by its runtime tag, and side effects of the +// argument survive the fold. +function rows(): number[] { + return [1, 2]; +} +function label(): string { + return "rows"; +} +type Pair = [number, string]; +function pair(): Pair { + return [3, "three"]; +} +let evals = 0; +function counted(): number[] { + evals++; + return []; +} + +console.log(Array.isArray(rows()), Array.isArray([1, 2]), Array.isArray({ a: 1 })); +console.log(Array.isArray(label()), Array.isArray(pair())); +console.log(Array.isArray(counted()), evals); +const maybe: string | string[] = ["a"]; +const narrow: string | string[] = "a"; +console.log(Array.isArray(maybe), Array.isArray(narrow)); +console.log(Array.isArray(rows().map((n) => n * 2))); diff --git a/tests/corpus/array-from-typed-array-mapper.ts b/tests/corpus/array-from-typed-array-mapper.ts new file mode 100644 index 000000000..a6ee1012e --- /dev/null +++ b/tests/corpus/array-from-typed-array-mapper.ts @@ -0,0 +1,25 @@ +// `Array.from(typedArray, mapper)`: the mapper receives each element as a +// number and its index, and the element reads are LIVE — iteration reads +// index i after visiting i-1, so a mapper that writes the source changes +// the values the loop has not visited yet. The result is a plain array of +// the mapper's results. + +const bytes = new Uint8Array([104, 105, 33]); +console.log(Array.from(bytes, (byte) => String.fromCharCode(byte)).join("")); +console.log(Array.from(bytes, (byte, index) => `${index}:${byte}`).join(",")); + +const live = new Uint8Array([1, 2, 3]); +const doubled = Array.from(live, (value, index) => { + if (index === 0) live[2] = 30; + return value * 2; +}); +console.log(doubled.join(",")); + +// A mapper that ignores its parameters, and an empty source. +console.log(Array.from(new Uint8Array(0), () => 7).length); +console.log(Array.from(bytes, () => "x").join("")); + +// Element identity survives the map: objects are not copied. +const words = new Uint8Array([1, 2]); +const boxed = Array.from(words, (word) => ({ word })); +console.log(boxed.length, boxed[1]!.word); diff --git a/tests/corpus/dynamic-method-returns-receiver.mjs b/tests/corpus/dynamic-method-returns-receiver.mjs new file mode 100644 index 000000000..c83c2b45c --- /dev/null +++ b/tests/corpus/dynamic-method-returns-receiver.mjs @@ -0,0 +1,72 @@ +// Untyped methods that return their receiver, called through untyped +// values: chains, identity, subclasses, overrides and partial returns. +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + move(dx, dy) { + this.x += dx; + this.y += dy; + return this; + } + scale(k) { + if (k === 0) { + this.x = 0; + this.y = 0; + return this; + } + this.x *= k; + this.y *= k; + return this; + } + maybe(flag) { + if (flag) return this; + return { x: -1 }; + } + check(v) { + if (v < 0) throw new RangeError("negative " + v); + this.x += v; + return this; + } +} +class Tagged extends Point { + constructor(x, y, tag) { + super(x, y); + this.tag = tag; + } + move(dx, dy) { + super.move(dx, dy); + return this.tag; + } +} +class Plain extends Point {} +const points = [new Point(1, 2), new Tagged(3, 4, "t"), new Plain(5, 6), new Point(7, 8)]; +let sum = 0; +for (let r = 0; r < 200; r++) { + const p = points[r % points.length]; + const moved = p.move(1, -1); + if (typeof moved === "object") { + sum += moved.x + moved.y; + if (moved !== p) throw new Error("identity lost"); + } else sum += moved.length; + const scaled = p.scale(r % 3); + sum += scaled === p ? 1 : 0; + sum += p.maybe(r % 2 === 0).x; +} +console.log(sum); +const chain = points[0].move(1, 1).scale(2).move(-1, 0); +console.log(chain === points[0], chain.x, chain.y, chain instanceof Point); +const plain = points[2]; +const r1 = plain.move(2, 2); +r1.extra = "seen"; +console.log(plain.extra, r1 === plain, Object.keys(r1).join(","), r1.constructor.name); +try { + points[3].check(1).check(-2); +} catch (e) { + console.log(e instanceof RangeError, e.message, points[3].x); +} +const results = []; +for (const p of points) results.push(p.scale(1)); +console.log(results.map((p, i) => p === points[i]).join(",")); +console.log(JSON.stringify(points)); diff --git a/tests/corpus/dynamic-module-let-imports/counter.mjs b/tests/corpus/dynamic-module-let-imports/counter.mjs new file mode 100644 index 000000000..d67864f87 --- /dev/null +++ b/tests/corpus/dynamic-module-let-imports/counter.mjs @@ -0,0 +1,13 @@ +// Module-level bindings of an untyped module that a typed module imports. +export let total = 0; +let hidden = 0; +export function run(rounds) { + for (let i = 0; i < rounds; i++) { + total += i * 3; + hidden += i; + } + return hidden; +} +export function reset(value) { + total = value; +} diff --git a/tests/corpus/dynamic-module-let-imports/main.ts b/tests/corpus/dynamic-module-let-imports/main.ts new file mode 100644 index 000000000..b61a6c36b --- /dev/null +++ b/tests/corpus/dynamic-module-let-imports/main.ts @@ -0,0 +1,6 @@ +// A typed module reads a live binding that untyped code updates in a loop. +import { reset, run, total } from "./counter.mjs"; + +console.log(run(100), total); +reset(2.5); +console.log(run(10), total); diff --git a/tests/corpus/dynamic-module-let-numbers.mjs b/tests/corpus/dynamic-module-let-numbers.mjs new file mode 100644 index 000000000..1f08260b7 --- /dev/null +++ b/tests/corpus/dynamic-module-let-numbers.mjs @@ -0,0 +1,53 @@ +// Module-level `let` bindings that untyped code updates in loops: numbers, +// then values of other kinds, read by functions declared before and after. +function readTotal() { + return total; +} +let total = 0; +let label = "n"; +let mixed = 1; +let big = 10n; +let counter = 0; +var hoisted; +function bump(n) { + return n * 2 + 1; +} +for (let i = 0; i < 1000; i++) { + total += bump(i); + label += i % 10 === 0 ? i : ""; + mixed = mixed * 1.5 + i; + big += BigInt(i); + counter++; +} +console.log(total, readTotal(), label.length, mixed, big, counter); +total = total / 0; +console.log(total, readTotal()); +total = -0; +console.log(Object.is(total, -0), 1 / total); +total = NaN; +console.log(total === total, String(total)); +mixed = "text"; +mixed += 5; +console.log(mixed, typeof mixed); +mixed = { value: 3 }; +mixed.value += 4; +console.log(mixed.value, JSON.stringify(mixed)); +mixed = undefined; +console.log(mixed, typeof mixed, mixed ?? "fallback"); +mixed = null; +console.log(mixed === null, `${mixed}`); +hoisted = 0; +for (let i = 0; i < 10; i++) hoisted += i * 0.1; +console.log(hoisted); +const add = (n) => { + total = (Number.isNaN(total) ? 0 : total) + n; + return total; +}; +for (let i = 0; i < 5; i++) add(i); +console.log(total, readTotal()); +let product = 1; +for (let i = 1; i <= 25; i++) product *= i; +console.log(product, product > Number.MAX_SAFE_INTEGER); +let text = 0; +for (let i = 0; i < 3; i++) text = text + "" + i; +console.log(text, typeof text); diff --git a/tests/corpus/fs-write-file-sync-fd.ts b/tests/corpus/fs-write-file-sync-fd.ts new file mode 100644 index 000000000..cfe992598 --- /dev/null +++ b/tests/corpus/fs-write-file-sync-fd.ts @@ -0,0 +1,14 @@ +// writeFileSync(fd, data): Node accepts an open file descriptor where it +// accepts a path, writes the whole data from the descriptor's current +// offset, and leaves the descriptor open. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const scratch = path.join(os.tmpdir(), `scriptc-write-fd-${process.pid}.txt`); +const fd = fs.openSync(scratch, "w"); +fs.writeFileSync(fd, "hello "); +fs.writeFileSync(fd, "world"); +fs.closeSync(fd); +console.log(fs.readFileSync(scratch, "utf8")); +fs.rmSync(scratch); diff --git a/tests/corpus/promise-catch-optional-chain.ts b/tests/corpus/promise-catch-optional-chain.ts new file mode 100644 index 000000000..638fb8417 --- /dev/null +++ b/tests/corpus/promise-catch-optional-chain.ts @@ -0,0 +1,64 @@ +// `?.` reaches a promise method through a receiver that may be nullish: +// `p?.catch(handler)` guards the method read (JS calls nothing when `p` is +// nullish), and the chain tail `f()?.then(..).catch(..)` guards the whole +// tail. The guarded call itself is an ordinary promise method call, so the +// handler typing sees the promise arm, and the chain result is +// `Promise<..> | undefined`. + +interface Client { + name: string; +} + +class Box { + opening: Promise | undefined; + pending: Promise | undefined; + + constructor(opening: Promise | undefined, pending: Promise | undefined) { + this.opening = opening; + this.pending = pending; + } + + async run(): Promise { + this.opening?.catch(() => undefined); + console.log("1 opening guarded"); + await this.pending?.catch(() => undefined); + console.log("2 pending guarded"); + } +} + +function open(name: string): Promise { + return Promise.resolve({ name }); +} + +function pick(flag: boolean): Promise | undefined { + return flag ? Promise.resolve("value") : undefined; +} + +function tail(flag: boolean): Promise { + return pick(flag)! + .then((value) => { + console.log("tail then", value); + return value.length; + }) + .catch(() => undefined); +} + +function guardedTail(flag: boolean): void { + pick(flag)?.then((value) => { + console.log("guarded then", value); + return value.length; + }); + console.log("3 tail attached", flag); +} + +const present = new Box(open("digest"), Promise.resolve()); +const absent = new Box(undefined, undefined); +present + .run() + .then(() => absent.run()) + .then(() => tail(true)) + .then(() => { + guardedTail(true); + guardedTail(false); + }) + .then(() => console.log("done")); diff --git a/tests/corpus/promise-resolve-promise-or-unit.ts b/tests/corpus/promise-resolve-promise-or-unit.ts new file mode 100644 index 000000000..209021ab0 --- /dev/null +++ b/tests/corpus/promise-resolve-promise-or-unit.ts @@ -0,0 +1,34 @@ +// `Promise.resolve(u)` over a promise-or-unit union (`Promise | +// void`): Node adopts a promise operand — the SAME promise comes +// back — and fulfills a nullish one as undefined. The settled turn of the +// adopted promise is unchanged, so a handler chain observes the same +// microtask order either way. + +let runs = 0; + +async function step(label: string): Promise { + runs++; + await Promise.resolve(); + console.log("step", label, runs); +} + +// A callback whose declared result is `Promise | void`: the +// async path answers a promise, the sync one answers undefined. +function handler(flag: boolean): Promise | void { + if (flag) return step("async"); + runs++; + console.log("sync handler", runs); +} + +function call(flag: boolean): Promise { + return Promise.resolve(handler(flag)).then(() => console.log("call done", flag)); +} + +const pending: Promise | void = Promise.resolve(); +console.log("identity", Promise.resolve(pending) === pending); + +console.log("start"); +call(true) + .then(() => call(false)) + .then(() => call(true)) + .then(() => console.log("runs", runs)); diff --git a/tests/corpus/promise-void-catch-undefined.ts b/tests/corpus/promise-void-catch-undefined.ts new file mode 100644 index 000000000..ee7285d8a --- /dev/null +++ b/tests/corpus/promise-void-catch-undefined.ts @@ -0,0 +1,31 @@ +// `Promise.catch(() => undefined)` — the handler's value is +// discarded and the recovered promise settles as void. The handler runs +// exactly once, and the chain continues on the next turn whether the +// receiver fulfilled or rejected. + +const ok: Promise = Promise.resolve(); +const bad: Promise = Promise.reject(new Error("boom")); + +ok.catch(() => undefined).then(() => console.log("fulfilled recovered")); + +bad + .catch(() => { + console.log("rejected recovered"); + }) + .then(() => console.log("rejected continued")); + +let runs = 0; + +function recover(): Promise { + runs++; + return runs % 2 === 1 ? Promise.reject(new Error("odd")) : Promise.resolve(); +} + +async function run(): Promise { + await recover().catch(() => undefined); + console.log("awaited recover", runs); + await recover().catch(() => undefined); + console.log("awaited recover", runs); +} + +void run().then(() => console.log("done")); diff --git a/tests/corpus/union-await-promise-arm.ts b/tests/corpus/union-await-promise-arm.ts new file mode 100644 index 000000000..91d9f4485 --- /dev/null +++ b/tests/corpus/union-await-promise-arm.ts @@ -0,0 +1,24 @@ +// Awaiting a union whose promise arm settles a union +// (`Promise | undefined`): the promise arm is awaited, and a +// non-promise arm answers its own value after one microtask turn. +function maybe(flag: boolean): Promise | undefined { + if (!flag) return undefined; + return Promise.resolve(flag ? "value" : null); +} + +async function main(): Promise { + const absent = await maybe(false); + console.log(String(absent)); + const settled = await maybe(true); + console.log(String(settled)); + const nil = await maybe(true); + console.log(nil === null); + + // Awaiting a non-promise arm takes exactly one microtask turn. + const order: string[] = []; + Promise.resolve().then(() => order.push("microtask")); + const hopped = await maybe(false); + order.push(`after:${String(hopped)}`); + console.log(order.join(",")); +} +main(); diff --git a/tests/corpus/union-destructuring-join-narrow.ts b/tests/corpus/union-destructuring-join-narrow.ts new file mode 100644 index 000000000..85de1faff --- /dev/null +++ b/tests/corpus/union-destructuring-join-narrow.ts @@ -0,0 +1,73 @@ +// Object patterns over a union whose arms type a field differently (or do +// not declare it), narrowed sources, and rest elements over a union. +interface Wide { + kind: "wide"; + value: number; + extra?: string; +} +interface Narrow { + kind: "narrow"; + value: string; + extra?: undefined; +} +type Item = Wide | Narrow; + +// The joined read: `number | string`, `string | undefined`. +function read(item: Item): void { + const { value, extra } = item; + console.log(item.kind, String(value), String(extra)); +} +read({ kind: "wide", value: 7, extra: "e" }); +read({ kind: "narrow", value: "v" }); + +interface Delta { + type: "delta"; + partial: { text: string }; + other: number; +} +interface Final { + type: "final"; + partial: { text: number }; + tail: boolean; +} +interface Bare { + type: "bare"; + other: number; +} +type Wire = Delta | Final | Bare; + +// A rest element over a union packs each arm's own remaining fields. +function rest(event: Delta | Final): string { + const { partial: _partial, ...kept } = event; + return JSON.stringify(kept); +} +console.log(rest({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(rest({ type: "final", partial: { text: 3 }, tail: true })); + +// A discriminant test and an `in` guard narrow the source; the pattern +// reads the arms the checker proved. +function narrowed(event: Wire): string { + if (event.type === "delta") { + const { partial, other } = event; + return `tag:${partial.text}:${other}`; + } + if ("partial" in event) { + const { partial } = event; + return `in:${String(partial.text)}`; + } + return "none"; +} +console.log(narrowed({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(narrowed({ type: "final", partial: { text: 3 }, tail: true })); +console.log(narrowed({ type: "bare", other: 4 })); + +// An `in` guard alone narrows to a SUB-union: both remaining arms keep +// their own field types. +function inOnly(w: Wire): string { + if (!("partial" in w)) return "none"; + const { partial } = w; + return String(partial.text); +} +console.log(inOnly({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(inOnly({ type: "final", partial: { text: 3 }, tail: true })); +console.log(inOnly({ type: "bare", other: 4 })); diff --git a/tests/diagnostics/destructuring-fences.ts b/tests/diagnostics/destructuring-fences.ts index 0ee60579d..25ee180c2 100644 --- a/tests/diagnostics/destructuring-fences.ts +++ b/tests/diagnostics/destructuring-fences.ts @@ -42,7 +42,7 @@ interface E2 { label: string; } function fromUnion(u: E1 | E2): string { - const { b } = u; // union source whose arms type the field differently: narrow first + const { b } = u; // arms type the field differently: the joined read compiles return String(b); } console.log(fromUnion({ kind: "one", b: "s", count: 1 })); diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index 0952436e1..b3eff41f5 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -12,7 +12,7 @@ const g = globalThis; const nan = NaN; // lowers now — a non-finite numeric literal like Infinity const s5 = String(5); const n5 = Number.NaN; // lowers now, with the other Number constants -const isArr = Array.isArray([1]); +const isArr = Array.isArray([1]); // lowers now: a computed argument folds after it is evaluated const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal) const aliasedAssign = Object.assign(merged, { b: 2 }); // aliased targets stay fenced (real mutation of a live object) const refl = Reflect.has({ a: 1 }, "a"); diff --git a/tests/harness/__snapshots__/destructuring-fences.ts.txt b/tests/harness/__snapshots__/destructuring-fences.ts.txt index 41026a7c8..02484d872 100644 --- a/tests/harness/__snapshots__/destructuring-fences.ts.txt +++ b/tests/harness/__snapshots__/destructuring-fences.ts.txt @@ -19,22 +19,6 @@ destructuring-fences.ts:31:9 - error SC1090: reading a property that has only a | ^~~~~~ 32 | console.log(onlyIn); -destructuring-fences.ts:45:11 - error SC1031: destructuring 'b' from a union whose arms type it differently (every arm must be a record or class with a same-typed field 'b' — narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - - 44 | function fromUnion(u: E1 | E2): string { - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - | ^ - 46 | return String(b); - -destructuring-fences.ts:46:17 - error SC2004: uses of 'b' inherit the blocker on its declaration - - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - 46 | return String(b); - | ^ - 47 | } - - hint: the declaration of 'b' did not compile — fix the diagnostic reported there and these sites clear with it - destructuring-fences.ts:51:7 - error SC1031: object destructuring of non-record values (the source is array-typed) are not supported yet 50 | const xs = [1, 2, 3]; diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 313a9391b..6ba862c53 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -1,10 +1,3 @@ -stdlib-fence.ts:15:15 - error SC1090: statically-decided Array.isArray on computed arguments (bind the value to a variable first) are not supported yet - - 14 | const n5 = Number.NaN; // lowers now, with the other Number constants - 15 | const isArr = Array.isArray([1]); - | ^~~~~~~~~~~~~~~~~~ - 16 | const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal) - stdlib-fence.ts:17:23 - error SC2020: 'Object.assign' is part of the standard library types but has no scriptc lowering yet 16 | const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal) diff --git a/tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json b/tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json new file mode 100644 index 000000000..03614a023 --- /dev/null +++ b/tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 1600 } diff --git a/tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json b/tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json new file mode 100644 index 000000000..c328ae618 --- /dev/null +++ b/tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json @@ -0,0 +1 @@ +{ "plain": 1850, "san": 1250 } diff --git a/tests/harness/corpus-costs/8422-array-isarray-computed.ts.json b/tests/harness/corpus-costs/8422-array-isarray-computed.ts.json new file mode 100644 index 000000000..b042dfb6e --- /dev/null +++ b/tests/harness/corpus-costs/8422-array-isarray-computed.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 1375 } diff --git a/tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json b/tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json new file mode 100644 index 000000000..094458e73 --- /dev/null +++ b/tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 1200 } diff --git a/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..5bfc37eb6 --- /dev/null +++ b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json @@ -0,0 +1 @@ +{ "plain": 3750, "san": 5900 } diff --git a/tests/harness/corpus-costs/promise-catch-optional-chain.ts.json b/tests/harness/corpus-costs/promise-catch-optional-chain.ts.json new file mode 100644 index 000000000..ba681e2a0 --- /dev/null +++ b/tests/harness/corpus-costs/promise-catch-optional-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 1000, "san": 900 } diff --git a/tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json b/tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json new file mode 100644 index 000000000..035725f07 --- /dev/null +++ b/tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json @@ -0,0 +1 @@ +{ "plain": 900, "san": 900 } diff --git a/tests/harness/corpus-costs/promise-void-catch-undefined.ts.json b/tests/harness/corpus-costs/promise-void-catch-undefined.ts.json new file mode 100644 index 000000000..a72de5c97 --- /dev/null +++ b/tests/harness/corpus-costs/promise-void-catch-undefined.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 900 } diff --git a/tests/harness/corpus-costs/union-await-promise-arm.ts.json b/tests/harness/corpus-costs/union-await-promise-arm.ts.json new file mode 100644 index 000000000..6eebf7e03 --- /dev/null +++ b/tests/harness/corpus-costs/union-await-promise-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 3150, "san": 4400 } diff --git a/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..2117fc7cd --- /dev/null +++ b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 2900 }