diff --git a/packages/compiler/src/ffi/ffi-manifest.ts b/packages/compiler/src/ffi/ffi-manifest.ts index f60da04c..d5711861 100644 --- a/packages/compiler/src/ffi/ffi-manifest.ts +++ b/packages/compiler/src/ffi/ffi-manifest.ts @@ -224,7 +224,7 @@ interface UnresolvedFfiReleaseParam { callback: { release: string }; } -type UnresolvedFfiParamClass = FfiParamClass | UnresolvedFfiReleaseParam; +type UnresolvedFfiParamClass = FfiValueParamClass | FfiCallbackParam | FfiContextParam | UnresolvedFfiReleaseParam; function callbackParam( value: unknown, @@ -260,12 +260,12 @@ function callbackParam( const params = callback["params"].map((entry, i): FfiCallbackParamClass | FfiContextParam => { const entryPath = `${path}.callback.params[${i}]`; checkClassVersion(entry, entryPath, format); - const allowed = format >= 3 + const allowed: readonly string[] = format >= 3 ? FFI_CALLBACK_PARAM_CLASSES : FFI_FORMAT_2_CALLBACK_PARAM_CLASSES; if ( typeof entry === "string" && - (allowed as readonly string[]).includes(entry) + allowed.includes(entry) ) { return entry as FfiCallbackParamClass; } @@ -451,7 +451,7 @@ export function loadFfiProfile( } for (const [id, cb] of callbacks) { const callbackContexts = cb.params.filter( - (param): param is FfiContextParam => typeof param === "object", + (param) => typeof param === "object", ); for (const context of callbackContexts) { if (context.context !== id) { @@ -483,8 +483,11 @@ export function loadFfiProfile( return { name, symbol, params, returns: returns as FfiReturnClass }; }); + // Collect registrations before resolving releases so declarations may + // reference a callback later in the manifest. Build resolved descriptors + // explicitly: mutating an unresolved array cannot prove its new ABI type. + const retained = new Map(); if (format >= 4) { - const retained = new Map(); for (const fn of functions) { for (const param of fn.params) { if ( @@ -497,82 +500,80 @@ export function loadFfiProfile( } } } - for (const [i, fn] of functions.entries()) { - for (const [j, param] of fn.params.entries()) { - if ( - typeof param !== "object" || - !("callback" in param) || - !("release" in param.callback) - ) continue; - const target = param.callback.release; - const descriptor = retained.get(target); - if (descriptor === undefined) { - const [binding, id, ...extra] = target.split(":"); - const candidate = extra.length === 0 && binding !== undefined && id !== undefined - ? functions.find((entry) => entry.name === binding)?.params.find( - (entry) => - typeof entry === "object" && - "callback" in entry && - !("release" in entry.callback) && - entry.callback.id === id, - ) - : undefined; - if ( - candidate !== undefined && - typeof candidate === "object" && - "callback" in candidate && - !("release" in candidate.callback) - ) { - throw new FfiProfileError( - `release '${target}' in 'functions[${i}].params[${j}]' targets a non-retained callback`, - ); - } - throw new FfiProfileError( - `release '${target}' in 'functions[${i}].params[${j}]' has no matching retained callback`, - ); - } - /* The emitted lifecycle is pin -> require -> native call -> commit -> - * release. A call that registers its own release target either - * retires the released pin during the commit sweep or satisfies the - * pre-call require with the pin it just created, so the - * release-validation trap cannot hold. */ - const registeredBySameCall = fn.params.some( - (entry) => - typeof entry === "object" && - "callback" in entry && - !("release" in entry.callback) && - `${fn.name}:${entry.callback.id}` === target, - ); - if (registeredBySameCall) { - throw new FfiProfileError( - `release '${target}' in 'functions[${i}].params[${j}]' targets a retained callback registered by the same call; registration and release must be separate bindings`, - ); - } - const inheritedContext = descriptor.params.some( - (entry) => typeof entry === "object", - ); - const contextCount = fn.params.filter( - (entry) => typeof entry === "object" && "context" in entry && entry.context === target, - ).length; - if (inheritedContext !== (contextCount === 1)) { - throw new FfiProfileError( - inheritedContext - ? `release '${target}' must declare its context exactly once in the native function parameter list because the retained callback has a context` - : `release '${target}' must not declare a context in the native function parameter list because the retained callback has none`, - ); - } - fn.params[j] = { - callback: { - release: target, - params: descriptor.params, - returns: descriptor.returns, - }, - }; - } - } } - - const resolvedFunctions = functions as FfiFunction[]; + const resolvedFunctions: FfiFunction[] = functions.map((fn, i): FfiFunction => ({ + name: fn.name, + symbol: fn.symbol, + returns: fn.returns, + params: fn.params.map((param, j): FfiParamClass => { + if (typeof param !== "object" || !("callback" in param)) return param; + if (!("release" in param.callback)) return { callback: param.callback }; + const target = param.callback.release; + const descriptor = retained.get(target); + if (descriptor === undefined) { + const [binding, id, ...extra] = target.split(":"); + const candidate = extra.length === 0 && binding !== undefined && id !== undefined + ? functions.find((entry) => entry.name === binding)?.params.find( + (entry) => + typeof entry === "object" && + "callback" in entry && + !("release" in entry.callback) && + entry.callback.id === id, + ) + : undefined; + if ( + candidate !== undefined && + typeof candidate === "object" && + "callback" in candidate && + !("release" in candidate.callback) + ) { + throw new FfiProfileError( + `release '${target}' in 'functions[${i}].params[${j}]' targets a non-retained callback`, + ); + } + throw new FfiProfileError( + `release '${target}' in 'functions[${i}].params[${j}]' has no matching retained callback`, + ); + } + /* The emitted lifecycle is pin -> require -> native call -> commit -> + * release. A call that registers its own release target either + * retires the released pin during the commit sweep or satisfies the + * pre-call require with the pin it just created, so the + * release-validation trap cannot hold. */ + const registeredBySameCall = fn.params.some( + (entry) => + typeof entry === "object" && + "callback" in entry && + !("release" in entry.callback) && + `${fn.name}:${entry.callback.id}` === target, + ); + if (registeredBySameCall) { + throw new FfiProfileError( + `release '${target}' in 'functions[${i}].params[${j}]' targets a retained callback registered by the same call; registration and release must be separate bindings`, + ); + } + const inheritedContext = descriptor.params.some( + (entry) => typeof entry === "object", + ); + const contextCount = fn.params.filter( + (entry) => typeof entry === "object" && "context" in entry && entry.context === target, + ).length; + if (inheritedContext !== (contextCount === 1)) { + throw new FfiProfileError( + inheritedContext + ? `release '${target}' must declare its context exactly once in the native function parameter list because the retained callback has a context` + : `release '${target}' must not declare a context in the native function parameter list because the retained callback has none`, + ); + } + return { + callback: { + release: target, + params: descriptor.params, + returns: descriptor.returns, + }, + }; + }), + })); const libraries = stringArray(root["libraries"], "libraries").map((path) => resolve(dirname(profilePath), path) diff --git a/packages/compiler/src/frontend/lib-contract.ts b/packages/compiler/src/frontend/lib-contract.ts index e4b928c5..bbce704a 100644 --- a/packages/compiler/src/frontend/lib-contract.ts +++ b/packages/compiler/src/frontend/lib-contract.ts @@ -106,7 +106,7 @@ export interface ContractFacts { const CONVENTION_CONSTS = new Set(["modelUnbound", "msgUnbound", "appearanceMsg", "chromeMsg", "envMsgs"]); const CONTRACT_GLOBAL_TYPES = new Set(["Array", "ReadonlyArray", "Uint8Array"]); -const moduleTypeBindings = new WeakMap>(); +type TypeBindings = Map>; function locOf(file: ts.SourceFile, node: ts.Node): SrcLoc { return { file: file.fileName, start: node.getStart(), end: node.end }; @@ -128,8 +128,8 @@ function propName(name: ts.Node): string | null { * `ReadonlyArray`, or `Uint8Array` by text can publish a slice/bytes contract * for a user-declared record. Imports and declarations are module-scoped * regardless of statement order, so one syntax-tree pass is sufficient. */ -function localTypeBindings(file: ts.SourceFile): Set { - const cached = moduleTypeBindings.get(file); +function localTypeBindings(file: ts.SourceFile, bindings: TypeBindings): Set { + const cached = bindings.get(file); if (cached !== undefined) return cached; const names = new Set(); const add = (name: ts.Identifier | undefined): void => { @@ -156,14 +156,14 @@ function localTypeBindings(file: ts.SourceFile): Set { if (ts.isNamespaceImport(clause.namedBindings)) add(clause.namedBindings.name); else for (const el of clause.namedBindings.elements) add(el.name); } - moduleTypeBindings.set(file, names); + bindings.set(file, names); return names; } /** Whether a bare type name reaches the ambient global rather than a local * declaration/import or a type parameter in an enclosing declaration. */ -function isUnshadowedGlobalType(file: ts.SourceFile, node: ts.TypeNode, name: string): boolean { - if (localTypeBindings(file).has(name)) return false; +function isUnshadowedGlobalType(file: ts.SourceFile, node: ts.TypeNode, name: string, bindings: TypeBindings): boolean { + if (localTypeBindings(file, bindings).has(name)) return false; for (let scope = node.parent; scope !== undefined && scope !== file; scope = scope.parent) { const params = scope.typeParameters; if (params?.some((p) => ts.isTypeParameterDeclaration(p) && p.name.text === name) === true) return false; @@ -171,7 +171,7 @@ function isUnshadowedGlobalType(file: ts.SourceFile, node: ts.TypeNode, name: st return true; } -function shapeOfMembers(file: ts.SourceFile, members: readonly ts.Node[], onBad: (text: string) => void): ContractField[] { +function shapeOfMembers(file: ts.SourceFile, members: readonly ts.Node[], onBad: (text: string) => void, bindings: TypeBindings): ContractField[] { const fields: ContractField[] = []; for (const m of members) { if (!ts.isPropertySignature(m)) { @@ -183,7 +183,7 @@ function shapeOfMembers(file: ts.SourceFile, members: readonly ts.Node[], onBad: onBad("a computed property name"); continue; } - const shape = m.type !== undefined ? typeShape(file, m.type) : ({ k: "unsupported", text: "missing type annotation" } as const); + const shape = m.type !== undefined ? typeShape(file, m.type, bindings) : ({ k: "unsupported", text: "missing type annotation" } as const); fields.push({ name, optional: m.postfixToken?.kind === ts.SyntaxKind.QuestionToken, shape, loc: locOf(file, m) }); } return fields; @@ -192,7 +192,7 @@ function shapeOfMembers(file: ts.SourceFile, members: readonly ts.Node[], onBad: /** The syntactic shape of a type node, over the closed vocabulary the * sidecar schema can express. Anything else lands as `unsupported` with * the source text preserved for the refusal message. */ -function typeShape(file: ts.SourceFile, node: ts.TypeNode): ContractTypeShape { +function typeShape(file: ts.SourceFile, node: ts.TypeNode, bindings: TypeBindings): ContractTypeShape { switch (node.kind) { case ts.SyntaxKind.BooleanKeyword: return { k: "bool" }; @@ -207,13 +207,13 @@ function typeShape(file: ts.SourceFile, node: ts.TypeNode): ContractTypeShape { default: break; } - if (ts.isParenthesizedTypeNode(node)) return typeShape(file, node.type); + if (ts.isParenthesizedTypeNode(node)) return typeShape(file, node.type, bindings); // `readonly T[]` is the same contract shape as `T[]`: readonly is a // checker-only view, and format 1 has one mutability-neutral slice // spelling. Preserve the wrapped shape so readonly tuples still reach // the projector's explicit tuple refusal. if (ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.ReadonlyKeyword) { - return typeShape(file, node.type); + return typeShape(file, node.type, bindings); } if (ts.isLiteralTypeNode(node)) { const lit = node.literal; @@ -221,24 +221,24 @@ function typeShape(file: ts.SourceFile, node: ts.TypeNode): ContractTypeShape { if (lit.kind === ts.SyntaxKind.NullKeyword) return { k: "absent", unit: "null" }; return { k: "unsupported", text: node.getText(file) }; } - if (ts.isArrayTypeNode(node)) return { k: "array", elem: typeShape(file, node.elementType) }; + if (ts.isArrayTypeNode(node)) return { k: "array", elem: typeShape(file, node.elementType, bindings) }; if (ts.isTupleTypeNode(node)) { const elems: ContractTypeShape[] = []; for (const e of node.elements) { // Named tuple members carry the type under `.type`. const t = ts.isNamedTupleMember(e) ? e.type : (e as ts.TypeNode); - elems.push(typeShape(file, t)); + elems.push(typeShape(file, t, bindings)); } return { k: "tuple", elems }; } if (ts.isUnionTypeNode(node)) { - return { k: "union", parts: node.types.map((t) => typeShape(file, t)) }; + return { k: "union", parts: node.types.map((t) => typeShape(file, t, bindings)) }; } if (ts.isTypeLiteralNode(node)) { let bad: string | null = null; const fields = shapeOfMembers(file, node.members, (text) => { bad = text; - }); + }, bindings); if (bad !== null) return { k: "unsupported", text: bad }; return { k: "object", fields }; } @@ -251,10 +251,10 @@ function typeShape(file: ts.SourceFile, node: ts.TypeNode): ContractTypeShape { if (ts.isTypeReferenceNode(node)) { if (!ts.isIdentifier(node.typeName)) return { k: "unsupported", text: node.getText(file) }; const name = node.typeName.text; - const global = CONTRACT_GLOBAL_TYPES.has(name) && isUnshadowedGlobalType(file, node, name); + const global = CONTRACT_GLOBAL_TYPES.has(name) && isUnshadowedGlobalType(file, node, name, bindings); if (global && name === "Uint8Array" && (node.typeArguments?.length ?? 0) === 0) return { k: "bytes" }; if (global && (name === "Array" || name === "ReadonlyArray") && node.typeArguments?.length === 1) { - return { k: "array", elem: typeShape(file, node.typeArguments[0]!) }; + return { k: "array", elem: typeShape(file, node.typeArguments[0]!, bindings) }; } if ((node.typeArguments?.length ?? 0) > 0) return { k: "unsupported", text: node.getText(file) }; return { k: "ref", name }; @@ -325,6 +325,9 @@ function siteOf(file: ts.SourceFile, node: ts.Node): string { * canonical path order), each in statement (declaration) order. Call * before the frontend is disposed. */ export function entryContractFacts(entry: ts.SourceFile, modules: readonly ts.SourceFile[] = []): ContractFacts { + // One contract read owns its syntax cache; no AST or native handle is + // retained after the projection finishes. + const bindings: TypeBindings = new Map(); const facts: ContractFacts = { types: [], multiSiteTypes: [], @@ -378,7 +381,7 @@ export function entryContractFacts(entry: ts.SourceFile, modules: readonly ts.So let bad: string | null = null; const fields = shapeOfMembers(file, stmt.members, (text) => { bad = text; - }); + }, bindings); const generic = (stmt.typeParameters?.length ?? 0) > 0; const heritage = (stmt.heritageClauses?.length ?? 0) > 0; facts.types.push({ @@ -399,7 +402,7 @@ export function entryContractFacts(entry: ts.SourceFile, modules: readonly ts.So facts.types.push({ name, form: "alias", - shape: generic ? { k: "unsupported", text: "a generic type alias" } : typeShape(file, stmt.type), + shape: generic ? { k: "unsupported", text: "a generic type alias" } : typeShape(file, stmt.type, bindings), loc: locOf(file, stmt), }); } @@ -435,9 +438,9 @@ export function entryContractFacts(entry: ts.SourceFile, modules: readonly ts.So name: stmt.name.text, params: stmt.parameters.map((p) => ({ name: ts.isIdentifier(p.name) ? p.name.text : "", - shape: p.type !== undefined ? typeShape(entry, p.type) : null, + shape: p.type !== undefined ? typeShape(entry, p.type, bindings) : null, })), - returns: stmt.type !== undefined ? typeShape(entry, stmt.type) : null, + returns: stmt.type !== undefined ? typeShape(entry, stmt.type, bindings) : null, generic: (stmt.typeParameters?.length ?? 0) > 0, loc: locOf(entry, stmt), }); @@ -459,7 +462,8 @@ export function entryContractFacts(entry: ts.SourceFile, modules: readonly ts.So facts.malformedConsts.push({ name, detail: "must be a string literal naming a msg arm", loc }); continue; } - facts[name] = { value: v.text, loc }; + if (name === "appearanceMsg") facts.appearanceMsg = { value: v.text, loc }; + else facts.chromeMsg = { value: v.text, loc }; continue; } if (name === "envMsgs") { @@ -480,7 +484,8 @@ export function entryContractFacts(entry: ts.SourceFile, modules: readonly ts.So facts.malformedConsts.push({ name, detail: "must be an array of string literals", loc }); continue; } - facts[name === "modelUnbound" ? "modelUnbound" : "msgUnbound"] = { value: v, loc }; + if (name === "modelUnbound") facts.modelUnbound = { value: v, loc }; + else facts.msgUnbound = { value: v, loc }; } } } diff --git a/packages/compiler/src/frontend/lowering/abstract-equality.ts b/packages/compiler/src/frontend/lowering/abstract-equality.ts index d0f26034..5788fda4 100644 --- a/packages/compiler/src/frontend/lowering/abstract-equality.ts +++ b/packages/compiler/src/frontend/lowering/abstract-equality.ts @@ -1,5 +1,5 @@ import { InternalCompilerError } from "../../errors.js"; -import { BOOL, DYN_HANDLE_KINDS, F64, IrExpr, IrStmt, IrType, SrcLoc, isUnitType, typeEquals } from "../../ir/ir.js"; +import { BOOL, DYN_HANDLE_KINDS, F64, type IrExpr, type IrStmt, type IrType, type SrcLoc, isUnitType, typeEquals } from "../../ir/ir.js"; import { boolLit, numLit, varRef } from "../../ir/build.js"; import type { Lowerer } from "./lowerer.js"; diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 2c1ce171..d9b9e7c6 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -1,4 +1,4 @@ -import { BOOL, F64, IrExpr, IrStmt, IrType, JSVAL, SrcLoc, UNDEFINED_T, typeEquals, typeKey, unionContainerArmsOk } from "../../ir/ir.js"; +import { BOOL, F64, type IrExpr, type IrStmt, type IrType, JSVAL, type SrcLoc, UNDEFINED_T, typeEquals, typeKey, unionContainerArmsOk } from "../../ir/ir.js"; import { varRef } from "../../ir/build.js"; import type { Lowerer } from "./lowerer.js"; import { dynUndefinedExpr } from "./lowerer.js"; diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 0485ed61..c3ef7c94 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -1,6 +1,6 @@ import { BUF_NUM_METHODS } from "./buffer-numeric-methods.js"; import * as ts from "../../ts7/adapter.js"; -import { BYTES_ELEMENT_SIZE, BIGINT_T, BOOL, BYTES_U8, DYN, F64, IrBytesElem, IrBytesIntrinsicMethod, IrExpr, IrType, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, bytesOf, typeEquals } from "../../../ir/ir.js"; +import { BYTES_ELEMENT_SIZE, BIGINT_T, BOOL, BYTES_U8, DYN, F64, type IrBytesElem, type IrBytesIntrinsicMethod, type IrExpr, type IrType, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, bytesOf, typeEquals } from "../../../ir/ir.js"; import { locOf } from "../../program.js"; import type { Lowerer } from "../lowerer.js"; import { dynUndefinedExpr, own } from "../lowerer.js"; diff --git a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts index 2b0ce3f8..1603788a 100644 --- a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts +++ b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts @@ -1,5 +1,5 @@ import * as ts from "../../ts7/adapter.js"; -import { BOOL, DYN, F64, IrExpr, IrFunction, IrStmt, IrType, STRING, SrcLoc, UNDEFINED_T, arrayOf, isUnitType, typeEquals, typeKey } from "../../../ir/ir.js"; +import { BOOL, DYN, F64, type IrExpr, type IrFunction, type IrStmt, type IrType, STRING, type SrcLoc, UNDEFINED_T, arrayOf, isUnitType, typeEquals, typeKey } from "../../../ir/ir.js"; import { numLit, strLit, varRef } from "../../../ir/build.js"; import { locOf } from "../../program.js"; import type { Lowerer } from "../lowerer.js"; diff --git a/packages/compiler/src/frontend/lowering/lib-boundary.ts b/packages/compiler/src/frontend/lowering/lib-boundary.ts index c1bdcc4a..03e9c616 100644 --- a/packages/compiler/src/frontend/lowering/lib-boundary.ts +++ b/packages/compiler/src/frontend/lowering/lib-boundary.ts @@ -38,7 +38,7 @@ * cleanly before. The validator stays the backstop for anything else. */ import type { Lowerer } from "./lowerer.js"; import { PoisonError } from "./lowerer.js"; -import { canAdaptDynFuncTo, canMarshalTypedFuncIntoIsland, DYN, DYN_HANDLE_KINDS, IrExpr, IrType, JSVAL, SrcLoc, STRING, isDynTypedRefType, isUnitType, typeEquals } from "../../ir/ir.js"; +import { canAdaptDynFuncTo, canMarshalTypedFuncIntoIsland, DYN, DYN_HANDLE_KINDS, type IrExpr, type IrType, JSVAL, type SrcLoc, STRING, isDynTypedRefType, isUnitType, typeEquals } from "../../ir/ir.js"; import { LIB_FN_SIGS, REGEX_INTRINSIC_SIGS, STR_INTRINSIC_SIGS } from "../../ir/validate.js"; import { unionMismatchDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-array-sort.ts b/packages/compiler/src/frontend/lowering/lower-array-sort.ts index c0a2eb9c..58640cdf 100644 --- a/packages/compiler/src/frontend/lowering/lower-array-sort.ts +++ b/packages/compiler/src/frontend/lowering/lower-array-sort.ts @@ -2,14 +2,14 @@ import { BOOL, BYTES_U8, F64, - IrExpr, - IrFunction, - IrLocal, - IrParam, - IrStmt, - IrType, + type IrExpr, + type IrFunction, + type IrLocal, + type IrParam, + type IrStmt, + type IrType, JSVAL, - SrcLoc, + type SrcLoc, arrayOf, funcOf, } from "../../ir/ir.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index 30543246..9dd4422f 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -30,7 +30,7 @@ import { jsFuncNameOf, own } from "./lowerer.js"; import { NARROW_FIRST } from "./surfaces.js"; import { inspectExpr, typeReachesItself } from "./lower-inspect.js"; import { abstractEqualityExpr, abstractEqualitySupported } from "./abstract-equality.js"; -import { BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrExpr, IrLibFn, IrStmt, IrType, REGEX, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; +import { BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, type IrExpr, type IrLibFn, type IrStmt, type IrType, REGEX, RUNTIME_ERROR_CLASSES, STRING, type SrcLoc, VOID, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../ir/build.js"; /** node:assert/strict binds the loose NAMES to the strict comparisons — diff --git a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts index fd052bef..750deaf1 100644 --- a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts +++ b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts @@ -6,7 +6,7 @@ import * as ts from "../ts7/adapter.js"; import { InternalCompilerError } from "../../errors.js"; import type { Lowerer } from "./lowerer.js"; -import { BOOL, DYN, IrExpr, IrStmt, IrType, VOID } from "../../ir/ir.js"; +import { BOOL, DYN, type IrExpr, type IrStmt, type IrType, VOID } from "../../ir/ir.js"; import { locOf } from "../program.js"; import { dynUndefinedExpr } from "./lowerer.js"; import { extractIteratorValue } from "./lower-generators.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 64ef25cc..00bae2b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -17,7 +17,7 @@ import { trackedReadFile } from "../input-tracker.js"; import { requireResolvePathsRuntime, resolveImportMetaRuntime, resolveRequireRuntime, type RuntimeResolveError, type RuntimeResolveResult } from "../runtime-resolve.js"; import { invalidJsonModuleDiag, nativeAddonDiag, requiresDynamicImportDiag } from "../../diagnostics/diagnostic.js"; import { - BuiltinModuleFn, + type BuiltinModuleFn, builtinModuleFnOf, FS_READDIR_DOCUMENTED_OPTIONS, FS_WATCH_DOCUMENTED_OPTIONS, @@ -41,7 +41,7 @@ import { registerHttpClientFnBinding, voidizedCallback } from "./lower-server.js import { pairsSnapshotHelper } from "./pairs-snapshot.js"; import { isJsonStringifyDynamicType } from "../../ir/ir.js"; import { bufEncoding } from "./containers/bytes.js"; -import { BOOL, BYTES_U8, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, FSWATCHER_T, PROCSTREAM_T, IrExpr, IrFunction, IrLibFn, IrLocal, IrStmt, IrType, JSVAL, NULL_T, SEARCH_PARAMS_T, SPAWNRES_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, funcOf, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; +import { BOOL, BYTES_U8, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, FSWATCHER_T, PROCSTREAM_T, type IrExpr, type IrFunction, type IrLibFn, type IrLocal, type IrStmt, type IrType, JSVAL, NULL_T, SEARCH_PARAMS_T, SPAWNRES_T, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, funcOf, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../ir/build.js"; import { staticForkModulePath } from "../fork-target.js"; import { tsgoPath } from "../dts-paths.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index b8bf76f0..362bacaa 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -8,7 +8,7 @@ import { AstNode } from "../ts7/ast-node.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import type { Lowerer } from "./lowerer.js"; import { lowerGenMethodCall } from "./lower-generators.js"; -import { BYTES_ELEMENT_NAME, BIGINT_T, BOOL, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, ffiClassType, ffiSourceParamTypes, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js"; +import { BYTES_ELEMENT_NAME, BIGINT_T, BOOL, CAUGHT, DYN, F64, type IrExpr, type IrFunction, type IrLocal, type IrParam, type IrStmt, type IrType, JSVAL, STRING, SYMBOL_T, type SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, ffiClassType, ffiSourceParamTypes, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js"; import type { IrFfiCallbackParam, IrFfiCallbackParamClass, IrFfiImport, IrFfiReleaseParam } from "../../ir/ir.js"; import { isJsSourceFile, isNodeEsmFile, locOf, requireSpecOf } from "../program.js"; import { genResultRecord, isGenericCallableMemberType, jsOpenObjectType, typeKey } from "../type-mapper.js"; @@ -7135,6 +7135,7 @@ function loweredTemplateStrings( ret = VOID; } } + ret = lowerer.contextualFunctionReturns.get(node) ?? ret; // A plain JS function reading `arguments` receives one hidden dyn // array. With named parameters, the array contains every actual // argument and only ES modules are admitted: sloppy-mode arguments @@ -7218,7 +7219,7 @@ function loweredTemplateStrings( // The synthetic trailing dyn-array param carries `arguments`. // Parameterized functions receive all actual arguments; zero-param // functions receive the same array through their rest ABI. - if (funcType.rest && !shapes.some((s) => s.mode === "dynRest" || s.mode === "islandRest")) { + if (funcType.rest && funcType.restAbi === undefined && !shapes.some((s) => s.mode === "dynRest" || s.mode === "islandRest")) { const argsLocal = lowerer.declareHiddenLocal("%arguments", DYN); params.push({ localId: argsLocal.id, name: "%arguments", type: DYN }); fnCtx.argumentsLocal = argsLocal; diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 9255ecbf..94758e55 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6,7 +6,7 @@ import { InternalCompilerError } from "../../errors.js"; * hierarchy registration. */ import * as ts from "../ts7/adapter.js"; import type { FnCtx, Lowerer } from "./lowerer.js"; -import { BOOL, DATE_T, DYN, F64, bytesOf, IrClassDef, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, typeEquals } from "../../ir/ir.js"; +import { BOOL, DATE_T, DYN, F64, bytesOf, type IrClassDef, type IrExpr, type IrFunction, type IrLocal, type IrParam, type IrStmt, type IrType, JSVAL, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, typeEquals } from "../../ir/ir.js"; import { MAX_GENERIC_INSTANCES, appendImplicitUndefinedReturn, bodyReadsArguments, generatorMeta, genericCallInstance, implicitAnyParamSymbolsOf, implicitCallInstance, implicitMonoFile, omittedArgFor, type GenericFnInfo, type ParamShape } from "./lower-calls.js"; import { isGenericCallableMemberType, jsOpenObjectType, typeKey } from "../type-mapper.js"; import { cjsClassExprWholeExportOf, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, isNodeTypesPath, locOf } from "../program.js"; @@ -44,6 +44,14 @@ export function storedClassValueType(lowerer: Lowerer, expression: ts.Expression return lowerer.mapTypeOf(lowerer.typeOf(expression)); } +export interface ClassMethodSignature { + params: ParamShape[]; + ret: IrType; + abstract?: true; + async?: true; + gen?: NonNullable; +} + export interface ClassInfo { def: IrClassDef; localClass?: { @@ -80,7 +88,7 @@ export interface ClassInfo { * walk IS lexical resolution and privates never join vtables (JS's * no-dynamic-dispatch semantics by construction). A `gen` entry has a * generator body whose direct calls enter through its spawn wrapper. */ - methods: Map }>; + methods: Map; /** Own JS overrides whose return cannot use the inherited ABI. The slot * retains that ABI, but its implementation throws before executing. */ methodEntryFences?: Map; diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index 928bb812..7563338c 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -4,7 +4,7 @@ import { InternalCompilerError } from "../../errors.js"; * literals — records/arrays/unions included, subject to comptimeBakeable. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { IrExpr, IrType } from "../../ir/ir.js"; +import { type IrExpr, type IrType } from "../../ir/ir.js"; import { comptimeFailedDiag } from "../../diagnostics/diagnostic.js"; import { locOf } from "../program.js"; import { PoisonError } from "./lowerer.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index e8378fe0..eaf99da9 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -5,7 +5,7 @@ import { InternalCompilerError } from "../../errors.js"; * record operations. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { BOOL, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrMapIntrinsicMethod, IrParam, IrRecordShape, IrSetIntrinsicMethod, IrStmt, IrType, JSVAL, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, funcOf, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isUnitType, typeEquals } from "../../ir/ir.js"; +import { BOOL, CAUGHT, DYN, F64, type IrExpr, type IrFunction, type IrLocal, type IrMapIntrinsicMethod, type IrParam, type IrRecordShape, type IrSetIntrinsicMethod, type IrStmt, type IrType, JSVAL, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, funcOf, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isUnitType, typeEquals } from "../../ir/ir.js"; import { ARRAY_METHODS, MAP_METHODS, SET_COMBINE_METHODS, SET_METHODS } from "./surfaces.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js"; @@ -14,7 +14,7 @@ import { islandPrimitiveExit, lowerDynDispatchMethodCall } from "./lower-calls.j import { buildArraySortFn } from "./lower-array-sort.js"; import { arrayIndexPresent, arrayValueRead, arrayValueStore, arrayValueType, currentArrayIndexPresent } from "./array-values.js"; import { typeKey } from "../type-mapper.js"; -import { WidthLift, nodeThrowExpr } from "./lowerer.js"; +import { type WidthLift, nodeThrowExpr } from "./lowerer.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../ir/build.js"; import { defaultAfterUndefined, lowerPositionArgument, lowerStaticallyUndefinedArgument, positionNumber } from "./optional-arguments.js"; import { lowerArrayCopyWithin, lowerArrayFill } from "./array-indexed-mutation.js"; @@ -235,6 +235,18 @@ function fenceProducedArrayElem(lowerer: Lowerer, node: ts.Node, producer: strin // static arrIntrinsic over a jsval (the validator ICE). { const receiver = lowerer.lowerExpr(access.expression); + if (receiver.type.kind === "union") { + const arms = lowerer.unions.get(receiver.type.unionId)?.arms; + const present = arms?.find((arm): boolean => arm.kind === "array"); + if (present?.kind === "array" && arms?.every((arm) => typeEquals(arm, present) || isUnitType(arm))) { + // A predicate can narrow the element type without changing the + // original array layout. Extract that stored array before choosing + // a helper, then adapt callback arguments at their actual ABI. + const array = lowerer.runtimeOptionalPropertyReceiver(access.expression, receiver, present, name) ?? + lowerer.coerceInto(access.expression, receiver, present); + return lowerer.withExpressionOverride(access.expression, array, () => lowerArrayMethodCall(lowerer, call, access)); + } + } if (receiver.type.kind === "record" && lowerer.shapes.get(receiver.type.shapeId)?.tuple) { return lowerTupleReadMethodCall(lowerer, call, access, receiver); } @@ -268,16 +280,11 @@ function fenceProducedArrayElem(lowerer: Lowerer, node: ts.Node, producer: strin "assign it to an array-typed binding first (the validated extraction), then call the method", ); } - // An evolving-`any` array binding under --dynamic whose flow type - // EVOLVED past `any[]` (`const fns = []; fns.push(() => 1); - // fns.map(...)` — the binding lowered array at its `any[]` - // declaration, while tsc's evolving-array analysis answers the - // pushed element type at this site): the VALUE's element type is - // the truth — ride the explicit-`any[]` handle-element lowering - // (pushes marshal in, HOF callbacks bind the handle, results exit - // per the checker type), never a typed intrinsic over a jsval- - // element array (the validator ICE). - if (receiver.type.kind === "array" && receiver.type.elem.kind === "jsval" && !typeEquals(receiverIr, receiver.type)) { + // Flow predicates and evolving-any analysis can refine the checker + // element type without changing the array's storage. Helpers and + // callback array arguments must retain the original layout and + // identity; element conversions happen at the callback boundary. + if (receiver.type.kind === "array" && !typeEquals(receiverIr, receiver.type)) { receiverIr = receiver.type; elem = receiver.type.elem; } @@ -1179,7 +1186,7 @@ function arraySearchHelper( * loop passes exactly what it declares (JS passes everything; a callback * only sees the parameters it names). Returns the lowered callback and * its declared arity. */ - function hofCallbackArg(lowerer: Lowerer, argNode: ts.Expression, lead: IrType[], arrT: IrType, bindUntyped = false): + function hofCallbackArg(lowerer: Lowerer, argNode: ts.Expression, lead: IrType[], arrT: IrType, bindUntyped = false, expectedReturn?: IrType): { fnArg: IrExpr & { type: IrType & { kind: "func" } }; arity: number } { const full = [...lead, F64, arrT]; // A DYN-receiver HOF's callback (`parsed.flatMap((value) => ...)`): @@ -1212,15 +1219,16 @@ function arraySearchHelper( lowerer.jsvalParamOverrides.add(p); }); } - // A checker-untyped array expression whose lowered value supplies a - // concrete static element type still contextually types an inline HOF - // callback with any/unknown. Bind only unannotated parameters to the - // exact ABI the synthesized loop passes. The override is temporary: - // one callback AST may be lowered under several generic instances. + // An untyped or flow-refined array can contextually type an inline + // callback differently from the stored element and array layout. Bind + // unannotated parameters to the helper's ABI when a boundary adapter + // cannot carry it. Include the optional-read prepass's parameter type + // in this comparison. The override is temporary because one callback + // AST can lower under several generic instances. const contextual: { symbol: ts.Symbol; previous: IrType | undefined }[] = []; const previousImplicit = lowerer.implicitParamTypes; let contextualTs: Map | null = null; - if (bindUntyped && (ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode))) { + if (ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode)) { argNode.parameters.forEach((param, i) => { const expected = full[i]; if ( @@ -1230,7 +1238,10 @@ function arraySearchHelper( return; } const checkerType = lowerer.checker.getTypeAtLocation(param.name); - if ((checkerType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) return; + const untyped = (checkerType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0; + const raw = untyped ? null : lowerer.mapTypeOf(checkerType); + const mapped = raw ? lowerer.runtimeOptionalBindingType(param.name, raw) : null; + if (untyped ? !bindUntyped : mapped === null || lowerer.coercibleValue(expected, mapped)) return; const symbol = lowerer.checker.getSymbolAtLocation(param.name); if (!symbol) return; contextual.push({ symbol, previous: lowerer.runtimeOptionalBindingTypes.get(symbol) }); @@ -1250,6 +1261,10 @@ function arraySearchHelper( }); } if (contextualTs !== null) lowerer.implicitParamTypes = contextualTs; + const previousReturn = lowerer.contextualFunctionReturns.get(argNode); + if (expectedReturn && (ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode))) { + lowerer.contextualFunctionReturns.set(argNode, expectedReturn); + } let fnArg: IrExpr; try { fnArg = lowerer.lowerExpr(argNode); @@ -1260,6 +1275,8 @@ function arraySearchHelper( else lowerer.runtimeOptionalBindingTypes.set(symbol, previous); } lowerer.implicitParamTypes = previousImplicit; + if (previousReturn === undefined) lowerer.contextualFunctionReturns.delete(argNode); + else lowerer.contextualFunctionReturns.set(argNode, previousReturn); } // A callback can accept a wider parameter type than the array supplies // (a reusable predicate over a union is common). Its closure still has @@ -1393,12 +1410,10 @@ function filterResultElem( const checkerReceiver = access ? lowerer.mapTypeOf(lowerer.typeOf(access.expression)) : null; - // Evolving-any arrays of island handles retain their jsval element ABI, - // while the checker later describes both the receiver and unchanged - // filter result by the evolved callable type. Equal checker elements mean - // no predicate narrowing occurred; preserve the runtime element home. + // Equal checker elements mean the filter introduces no new refinement. + // Preserve the stored element layout even if an earlier predicate or + // evolving-any analysis changed the receiver's flow type. if ( - elem.kind === "jsval" && valueT.kind === "jsval" && result?.kind === "array" && checkerReceiver?.kind === "array" && typeEquals(result.elem, checkerReceiver.elem) ) { @@ -2174,24 +2189,29 @@ export function tryLowerNumericIndexRead(lowerer: Lowerer, operand: IrExpr, loc: }; } // find visits holes as undefined and can infer a predicate that selects - // a subset of the element union. Only an inline, inferred predicate is - // evidence for that narrower representation; explicit assertions keep + // a subset of the element union. A result matching the receiver's flow + // element inherits that earlier refinement. Otherwise only an inline, + // inferred predicate proves a new narrowing; explicit assertions keep // the same refusal boundary as filter. - const resultT = bindUntyped ? arrayValueType(lowerer, elem) : lowerer.irTypeOf(call); + const valueT = arrayValueType(lowerer, elem); + const checkerReceiver = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + const checkerResult = lowerer.irTypeOf(call); + const unchangedElement = checkerReceiver?.kind === "array" && + typeEquals(checkerResult, arrayValueType(lowerer, checkerReceiver.elem)); + const resultT = bindUntyped ? valueT : checkerResult; if (resultT.kind !== "union") lowerer.badType(call, lowerer.typeOf(call)); // defensive: T | undefined always maps to a union const undefTag = lowerer.armTag(resultT.unionId, UNDEFINED_T); if (undefTag < 0) lowerer.badType(call, lowerer.typeOf(call)); - const valueT = arrayValueType(lowerer, elem); let retag: string | null = null; if (!lowerer.coercibleValue(valueT, resultT)) { const inline = ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode); - if (!inline || argNode.type !== undefined) { + if (!unchangedElement && (!inline || argNode.type !== undefined)) { lowerer.unsupported("SC1090", argNode, `narrowing '.${method}' requires an inline callback with an inferred predicate (annotate the return ': boolean' to keep the receiver's element type)`); } - const signature = lowerer.checker.getSignatureFromDeclaration(argNode); + const signature = inline ? lowerer.checker.getSignatureFromDeclaration(argNode) : undefined; const predicate = signature ? lowerer.checker.getTypePredicateOfSignature(signature) : undefined; - if (!predicate || predicate.parameterIndex !== 0 || valueT.kind !== "union") { + if ((!unchangedElement && (!predicate || predicate.parameterIndex !== 0)) || valueT.kind !== "union") { lowerer.unsupported("SC1090", call, `'.${method}' result narrowing without a predicate over its element`); } retag = lowerer.narrowedRetagHelper(call, valueT.unionId, resultT.unionId, loc); @@ -2571,7 +2591,7 @@ export function tryLowerNumericIndexRead(lowerer: Lowerer, operand: IrExpr, loc: const accT = lowerer.runtimeOptionalReduceTypes.get(call) ?? (hasInit ? lowerer.irTypeOf(call) : arrayValueType(lowerer, elem)); if (accT.kind === "void" || accT.kind === "func") lowerer.badType(call, lowerer.typeOf(call)); - const { fnArg, arity } = hofCallbackArg(lowerer, argNode, [accT, arrayValueType(lowerer, elem)], arrT); + const { fnArg, arity } = hofCallbackArg(lowerer, argNode, [accT, arrayValueType(lowerer, elem)], arrT, false, accT); const helper = reduceHelper(lowerer, method, elem, accT, fnArg.type.ret, arity, hasInit, loc); const args: IrExpr[] = [receiver, fnArg]; if (hasInit) args.push(lowerer.lowerExprExpecting(call.arguments[1]!, accT)); diff --git a/packages/compiler/src/frontend/lowering/lower-dgram.ts b/packages/compiler/src/frontend/lowering/lower-dgram.ts index bdee08dc..1858afa8 100644 --- a/packages/compiler/src/frontend/lowering/lower-dgram.ts +++ b/packages/compiler/src/frontend/lowering/lower-dgram.ts @@ -11,7 +11,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { ladderFenceExpr } from "./lowerer.js"; import { isJsSourceFile, locOf } from "../program.js"; -import { BOOL, canBoxFuncIntoDyn, DGRAMSOCK_T, DYN, F64, IrExpr, IrLibFn, IrType, SrcLoc, STRING, UNDEFINED_T, VOID } from "../../ir/ir.js"; +import { BOOL, canBoxFuncIntoDyn, DGRAMSOCK_T, DYN, F64, type IrExpr, type IrLibFn, type IrType, type SrcLoc, STRING, UNDEFINED_T, VOID } from "../../ir/ir.js"; import { DNS_LOOKUP_DOCUMENTED_OPTIONS, fenceOrDropOptionKey } from "./surfaces.js"; import { boolLit } from "../../ir/build.js"; import { resultIsDiscarded } from "./call-position.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-enums.ts b/packages/compiler/src/frontend/lowering/lower-enums.ts index 6c2678ed..09638719 100644 --- a/packages/compiler/src/frontend/lowering/lower-enums.ts +++ b/packages/compiler/src/frontend/lowering/lower-enums.ts @@ -29,7 +29,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { locOf } from "../program.js"; -import { F64, IrExpr, STRING } from "../../ir/ir.js"; +import { F64, type IrExpr, STRING } from "../../ir/ir.js"; /** The member's source-name text (identifiers and string-literal names — * the only name forms tsc allows on enum members). */ diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 55d8fc66..1b4728a4 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -47,7 +47,7 @@ import type { Lowerer } from "./lowerer.js"; import { dynFallbackType, newFnCtx } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; import { isJsSourceFile, locOf } from "../program.js"; -import { arrayOf, BOOL, canBoxFuncIntoDyn, canConvertToDyn, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, isUnitType, STRING, SrcLoc, typeEquals, typeKey, VOID } from "../../ir/ir.js"; +import { arrayOf, BOOL, canBoxFuncIntoDyn, canConvertToDyn, DYN, F64, type IrExpr, type IrFunction, type IrLocal, type IrParam, type IrStmt, type IrType, isUnitType, STRING, type SrcLoc, typeEquals, typeKey, VOID } from "../../ir/ir.js"; import { STREAM_FORCED_EVENT_NAMES, streamForcedTuple, streamSidesOf } from "./lower-stream.js"; import { boolLit, strLit, varRef } from "../../ir/build.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-expando.ts b/packages/compiler/src/frontend/lowering/lower-expando.ts index 9d75d011..22782111 100644 --- a/packages/compiler/src/frontend/lowering/lower-expando.ts +++ b/packages/compiler/src/frontend/lowering/lower-expando.ts @@ -32,7 +32,7 @@ * precedent); runtime-valued keys keep their fences. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { IrExpr, IrGlobal, IrStmt, IrType } from "../../ir/ir.js"; +import { type IrExpr, type IrGlobal, type IrStmt, type IrType } from "../../ir/ir.js"; import { isJsSourceFile, locOf } from "../program.js"; import { isUnitOnlyTsType, unitOnlyUnion } from "../type-mapper.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 30cb432b..fb08a87e 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -14,9 +14,9 @@ import { pathToFileURL } from "node:url"; import type { Lowerer } from "./lowerer.js"; import { OBJECT_CALLABLE_VALUES } from "./surfaces.js"; import { wasiGuestPath } from "../../wasi-paths.js"; -import { BIGINT_T, BYTES_ELEMENT_NAME, BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrBytesElem, IrExpr, IrFunction, IrJsOp, IrLocal, IrRecordShape, IrStmt, IrType, JSVAL, NULL_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_ERROR_CLASSES, SEARCH_PARAMS_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canAdaptDynFuncTo, canDynCheckTo, canBoxFuncIntoDyn, funcOf, isDynTypedRefType, isSupportedArrayElem, isUnitType, jsOpResultKind, shapeHasAccessorSlots, typeEquals, typeKey, unionContainerArmsOk } from "../../ir/ir.js"; +import { BIGINT_T, BYTES_ELEMENT_NAME, BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, type IrBytesElem, type IrExpr, type IrFunction, type IrJsOp, type IrLocal, type IrRecordShape, type IrStmt, type IrType, JSVAL, NULL_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_ERROR_CLASSES, SEARCH_PARAMS_T, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, canAdaptDynFuncTo, canDynCheckTo, canBoxFuncIntoDyn, funcOf, isDynTypedRefType, isSupportedArrayElem, isUnitType, jsOpResultKind, shapeHasAccessorSlots, typeEquals, typeKey, unionContainerArmsOk } from "../../ir/ir.js"; import { cjsClassExprWholeExportOf, cjsExportAssignmentOf, cjsExportDiscardReason, isCjsExportTableLiteral, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, isNodeEsmFile, locOf } from "../program.js"; -import { ARRAY_METHODS, builtinConstLit, builtinFenceHintOf, builtinModuleConstOf, builtinModulesArrayLit, builtinModuleFnOf, COMPOUND_ASSIGN_OPS, CompoundOp, ISLAND_SURFACE, isChildSurfaceMember, MAP_METHODS, NARROW_FIRST, SET_METHODS, STRING_INDEX_METHODS, STR_METHODS, UNSUPPORTED_EXPR, sideEffectFreeOptionValue, stdlibGlobalNameOf } from "./surfaces.js"; +import { ARRAY_METHODS, builtinConstLit, builtinFenceHintOf, builtinModuleConstOf, builtinModulesArrayLit, builtinModuleFnOf, COMPOUND_ASSIGN_OPS, type CompoundOp, ISLAND_SURFACE, isChildSurfaceMember, MAP_METHODS, NARROW_FIRST, SET_METHODS, STRING_INDEX_METHODS, STR_METHODS, UNSUPPORTED_EXPR, sideEffectFreeOptionValue, stdlibGlobalNameOf } from "./surfaces.js"; import { UNSUPPORTED, blockedBindingUseDiag, requiresDynamicPackageDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js"; import { PoisonError, dynUndefinedExpr, jsFuncNameOf, neverTaintedJsType, nodeThrowExpr, own } from "./lowerer.js"; import { lowerCollectionSpread, lowerNpmStaticSafeIndexRead, lowerSafeIndexRead, strCharsCall, tryLowerNumericIndexRead } from "./lower-containers.js"; @@ -3058,7 +3058,30 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { * results (several non-unit arms) and defaults that change the result * type are fenced with narrow-first hints. */ export function lowerNullishCoalesce(lowerer: Lowerer, expr: ts.BinaryExpression, loc: SrcLoc): IrExpr { - const left = lowerAbsenceProbe(lowerer, expr.left) ?? lowerer.lowerExpr(expr.left); + // `a ?? b ?? c` is a left-nested AST. Lower its spine bottom-up so + // long dispatch chains do not retain a full expression/binary lowering + // frame per operand in a native compiler. The pair lowering still + // decides each result layout and preserves the lazy right operand. + const parents: ts.BinaryExpression[] = []; + let first = expr; + while ( + ts.isBinaryExpression(first.left) && + first.left.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + !lowerer.chainRecvByNode.has(first.left) + ) { + parents.push(first); + first = first.left; + } + let result = lowerNullishPair(lowerer, first, first === expr ? loc : locOf(first), + lowerAbsenceProbe(lowerer, first.left) ?? lowerer.lowerExpr(first.left)); + for (let i = parents.length - 1; i >= 0; i--) { + const parent = parents[i]!; + result = lowerNullishPair(lowerer, parent, parent === expr ? loc : locOf(parent), result); + } + return result; + } + +function lowerNullishPair(lowerer: Lowerer, expr: ts.BinaryExpression, loc: SrcLoc, left: IrExpr): IrExpr { if (left.type.kind === "dyn") { // `a ?? b` on a CHECKED-DYNAMIC left: the deciding test is the // runtime kind (scr_dyn_is_nullish — UNDEF/NULL take the default; @@ -5700,8 +5723,11 @@ export function lowerElementCompound(lowerer: Lowerer, expr: ts.BinaryExpression // Optional array reads carry the source array's payload type plus an // undefined arm. arrayValueStore turns that arm into the runtime's // UNDEFINED state while keeping number[]/string[] payload storage scalar. - const value = lowerer.lowerExpr(expr.right); - return arrayValueStore(lowerer, arr, index, value, receiverIr.elem, locOf(expr)); + let literal = expr.right; + while (ts.isParenthesizedExpression(literal) || ts.isSatisfiesExpression(literal)) literal = literal.expression; + const value = ts.isObjectLiteralExpression(literal) || ts.isArrayLiteralExpression(literal) + ? lowerer.lowerExprExpecting(literal, arr.type.elem) : lowerer.lowerExpr(expr.right); + return arrayValueStore(lowerer, arr, index, value, arr.type.elem, locOf(expr)); } /** Environment property names use ToPrimitive with the string hint, so diff --git a/packages/compiler/src/frontend/lowering/lower-generators.ts b/packages/compiler/src/frontend/lowering/lower-generators.ts index da1f9d86..12fc3240 100644 --- a/packages/compiler/src/frontend/lowering/lower-generators.ts +++ b/packages/compiler/src/frontend/lowering/lower-generators.ts @@ -9,7 +9,7 @@ import { InternalCompilerError } from "../../errors.js"; * g.next()` binds and reads flow. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { BOOL, DYN, IrExpr, IrStmt, IrType, SrcLoc, UNDEFINED_T, VOID, isUnitType, typeEquals } from "../../ir/ir.js"; +import { BOOL, DYN, type IrExpr, type IrStmt, type IrType, type SrcLoc, UNDEFINED_T, VOID, isUnitType, typeEquals } from "../../ir/ir.js"; import { locOf } from "../program.js"; import { genResultRecord } from "../type-mapper.js"; import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index f64f3ffe..ed977f48 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -38,7 +38,7 @@ import * as ts from "../ts7/adapter.js"; import { unsignedHex } from "../../format-integer.js"; import type { Lowerer } from "./lowerer.js"; import { isJsSourceFile } from "../program.js"; -import { BOOL, DYN, F64, IrExpr, IrStmt, IrType, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, canConvertToDyn, canDynCheckTo, recordTextCodecClass, shapeHasAccessorSlots, typeKey } from "../../ir/ir.js"; +import { BOOL, DYN, F64, type IrExpr, type IrStmt, type IrType, RUNTIME_ERROR_CLASSES, STRING, type SrcLoc, canConvertToDyn, canDynCheckTo, recordTextCodecClass, shapeHasAccessorSlots, typeKey } from "../../ir/ir.js"; import type { ClassInfo } from "./lower-classes.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { boolLit, numLit, strLit, varRef } from "../../ir/build.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 7eacef7f..ee93cc66 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -5,8 +5,8 @@ import { InternalCompilerError } from "../../errors.js"; * package boundary fences for node_modules-declared symbols. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { arrayOf, BOOL, BYTES_U8, DYN, F64, IrExpr, IrStmt, IrType, JSVAL, MAX_ISLAND_CALLBACK_ARITY, STRING, VOID, canConvertToDyn, canMarshalTypedFuncIntoIsland, islandPromisePayloadTag, isUnitType } from "../../ir/ir.js"; -import { ISLAND_SURFACE, IslandFnEntry, STATIC_MATH_FNS, STATIC_MATH_PROPS, boundaryIntoIslandMsg } from "./surfaces.js"; +import { arrayOf, BOOL, BYTES_U8, DYN, F64, type IrExpr, type IrStmt, type IrType, JSVAL, MAX_ISLAND_CALLBACK_ARITY, STRING, VOID, canConvertToDyn, canMarshalTypedFuncIntoIsland, islandPromisePayloadTag, isUnitType } from "../../ir/ir.js"; +import { ISLAND_SURFACE, type IslandFnEntry, STATIC_MATH_FNS, STATIC_MATH_PROPS, boundaryIntoIslandMsg } from "./surfaces.js"; import { requiresDynamicApiDiag, requiresDynamicPackageDiag } from "../../diagnostics/diagnostic.js"; import { esmNamedImportLinkCrash, isCjsJsFile, isJsSourceFile, locOf, npmPackageNameOf } from "../program.js"; import { foldedStringKeyOf, lowerDynObjectLiteral } from "./expressions/object-literals.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-mixins.ts b/packages/compiler/src/frontend/lowering/lower-mixins.ts index 9342d565..37977d0b 100644 --- a/packages/compiler/src/frontend/lowering/lower-mixins.ts +++ b/packages/compiler/src/frontend/lowering/lower-mixins.ts @@ -37,8 +37,8 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { PoisonError } from "./lowerer.js"; -import { IrType } from "../../ir/ir.js"; -import { ClassInfo, builtinStreamInfoOf, exactClassOfReceiver, propertyAssignedClassInfoOf } from "./lower-classes.js"; +import { type IrType } from "../../ir/ir.js"; +import { type ClassInfo, builtinStreamInfoOf, exactClassOfReceiver, propertyAssignedClassInfoOf } from "./lower-classes.js"; /** A recognized mixin function: one base-class parameter, a body that * defines and returns exactly one class extending that parameter, and no diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 0d030d96..a2011cc8 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -14,7 +14,7 @@ import { isRelativeSpecifier } from "../workspace-registry.js"; import { canonicalBuiltinModule, cjsExportAssignmentOf, cjsExportDiscardReason, entryPackageFilePredicate, isCjsJsFile, isJsSourceFile, isRequireStatement, locOf, makeCycleAdmission, orderedImportsOf, resolveImport, resolveNpmImport } from "../program.js"; import type { CycleEdge } from "../program.js"; import { invalidJsonModuleDiag, npmEmbedFailedDiag, requiresDynamicImportDiag } from "../../diagnostics/diagnostic.js"; -import { BOOL, DYN, F64, IrClassDef, IrExpr, IrFunction, IrGlobal, IrRecordShape, IrStmt, IrType, IrUnionDef, JSVAL, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, isUnitType } from "../../ir/ir.js"; +import { BOOL, DYN, F64, type IrClassDef, type IrExpr, type IrFunction, type IrGlobal, type IrRecordShape, type IrStmt, type IrType, type IrUnionDef, JSVAL, RUNTIME_ERROR_CLASSES, STRING, type SrcLoc, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, isUnitType } from "../../ir/ir.js"; import { ENTRY_NAME, PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefinedExpr, importCallHandleType, newFnCtx, staticImportNamespaceType, uncheckedOverloadHandleCall } from "./lowerer.js"; import { builtinMemberRequireDecl, builtinNamespaceDestructureModuleOf, createRequireBindingDecl, createRequireNamespaceDecl, createRequireProgramModuleDecl, createRequireSpecOf, isPromisifyCall, registerBuiltinCallableAlias, stripTypeCasts } from "./lower-builtins.js"; import { bindingContextualGenericFnNodeOf, bindingGenericFnAliasInfoOf, bindingGenericFnInfoOf, bindingGenericFnNodeOf, bindingNeverReassigned, deadUnmappableBinding, funcTypeFromParamShapes, implicitLocalFnInfoOf, implicitLocalFnNodeOf, nullishGenericBindingUnitOf, registerOverloadedCallableAlias } from "./lower-calls.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index 86c59669..88609cdd 100644 --- a/packages/compiler/src/frontend/lowering/lower-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/lower-namespaces.ts @@ -41,7 +41,7 @@ import type { Lowerer } from "./lowerer.js"; import { boundIdentifiersOf } from "./lowerer.js"; import type { FileParts } from "./lower-modules.js"; import { locOf, resolveImport } from "../program.js"; -import { F64, IrExpr, IrStmt, IrType, STRING } from "../../ir/ir.js"; +import { F64, type IrExpr, type IrStmt, type IrType, STRING } from "../../ir/ir.js"; /** True when this module declaration produces NO runtime construct at all: * ambient (`declare namespace/module`, `declare global`, string-named diff --git a/packages/compiler/src/frontend/lowering/lower-node-module.ts b/packages/compiler/src/frontend/lowering/lower-node-module.ts index afcf89d0..fa11b7a9 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-module.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-module.ts @@ -10,7 +10,7 @@ import type { Lowerer } from "./lowerer.js"; import type { FileParts } from "./lower-modules.js"; import { isNodeEsmFile, locOf } from "../program.js"; import { runtimePathForTarget } from "../runtime-resolve.js"; -import { BOOL, F64, IrExpr, IrStmt, IrType, NULL_T, STRING, UNDEFINED_T, VOID, arrayOf } from "../../ir/ir.js"; +import { BOOL, F64, type IrExpr, type IrStmt, type IrType, NULL_T, STRING, UNDEFINED_T, VOID, arrayOf } from "../../ir/ir.js"; import { boolLit, numLit, strLit, varRef } from "../../ir/build.js"; const MODULE_FIELDS = new Set([ diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 83299f80..76c86db8 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -12,7 +12,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { ladderFenceExpr, nodeThrowExpr } from "./lowerer.js"; import { isJsSourceFile, locOf } from "../program.js"; -import { arrayOf, BOOL, BYTES_U8, canBoxFuncIntoDyn, canConvertToDyn, DYN, DYN_HANDLE_KINDS, F64, funcOf, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, IrExpr, IrLibFn, IrStmt, IrType, NETSERVER_T, NETSOCKET_T, NULL_T, SECURECTX_T, STRING, UNDEFINED_T, SrcLoc, typeKey, VOID } from "../../ir/ir.js"; +import { arrayOf, BOOL, BYTES_U8, canBoxFuncIntoDyn, canConvertToDyn, DYN, DYN_HANDLE_KINDS, F64, funcOf, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, type IrExpr, type IrLibFn, type IrStmt, type IrType, NETSERVER_T, NETSOCKET_T, NULL_T, SECURECTX_T, STRING, UNDEFINED_T, type SrcLoc, typeKey, VOID } from "../../ir/ir.js"; import { AGENT_DOCUMENTED_OPTIONS, builtinFenceHintOf, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ba512b60..218951cf 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -9,16 +9,16 @@ import type { Lowerer } from "./lowerer.js"; import { arrayValueRead, arrayValueStore, arrayValueType, unionArrayValueRead } from "./array-values.js"; import { lowerForAwaitGenerator, lowerForOfGenerator, lowerYieldStarStatement, type GenType } from "./lower-generators.js"; import { lowerForAwaitBuiltin } from "./lower-async-iteration.js"; -import { BOOL, BYTES_U8, CAUGHT, DYN, F64, IrExpr, IrGlobal, IrLocal, IrStmt, IrType, JSVAL, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js"; +import { BOOL, BYTES_U8, CAUGHT, DYN, F64, type IrExpr, type IrGlobal, type IrLocal, type IrStmt, type IrType, JSVAL, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js"; import { PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefinedExpr, importCallHandleType, neverTaintedJsType, staticImportNamespaceType, stmtUsesIsland, uncheckedOverloadHandleCall } from "./lowerer.js"; import { enforceLibBoundary } from "./lib-boundary.js"; import { cjsExportAssignmentOf, cjsExportDiscardReason, cjsExportTargetLiteral, isCjsJsFile, isEsModuleStamp, isJsSourceFile, isNodeEsmFile, locOf, requireSpecOf } from "../program.js"; -import { COMPOUND_ASSIGN_OPS, CompoundOp, STR_METHODS, UNSUPPORTED_STMT, isStdlibMember, sideEffectFreeOptionValue, stdlibGlobalAliasDecl, stdlibGlobalAliasNameOf, stdlibGlobalNameOf } from "./surfaces.js"; +import { COMPOUND_ASSIGN_OPS, type CompoundOp, STR_METHODS, UNSUPPORTED_STMT, isStdlibMember, sideEffectFreeOptionValue, stdlibGlobalAliasDecl, stdlibGlobalAliasNameOf, stdlibGlobalNameOf } from "./surfaces.js"; import { isNativeBuiltinValueInitializer } from "./lower-builtin-values.js"; import { isProvenanceSourceFile } from "../provenance-registry.js"; import { ambientUndefVarRootOf, lowerImportEquals, nsUndefRead, nsWritableTarget, trapDeclRootOf } from "./lower-namespaces.js"; import { expandoWritableTarget, lowerExpandoAssignStmt } from "./lower-expando.js"; -import { ForOfIterProjection, lowerForOfArrayIter, lowerForOfMap, lowerForOfSearchParams, lowerForOfSet, lowerSafeIndexRead, objectIterOverIndexShape, strCharsCall } from "./lower-containers.js"; +import { type ForOfIterProjection, lowerForOfArrayIter, lowerForOfMap, lowerForOfSearchParams, lowerForOfSet, lowerSafeIndexRead, objectIterOverIndexShape, strCharsCall } from "./lower-containers.js"; import { bindingContextualGenericFnNodeOf, bindingGenericFnAliasInfoOf, bindingGenericFnInfoOf, bindingGenericFnNodeOf, bindingNeverReassigned, deadUnmappableBinding, implicitLocalFnInfoOf, implicitLocalFnNodeOf, implicitMethodCallInfersReturn, nullishExprUnitOf, nullishGenericBindingUnitOf, recordKeysArrayCall, registerOverloadedCallableAlias } from "./lower-calls.js"; import { isMixinFnBinding, mixinResultBindingClassOf } from "./lower-mixins.js"; import type { ClassInfo, ClassIteratorInfo } from "./lower-classes.js"; diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 3a1ff09c..6d266f78 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -36,7 +36,7 @@ import { newFnCtx, own } from "./lowerer.js"; import { appendImplicitUndefinedReturn } from "./lower-calls.js"; import { bufEncoding, knownBufEncoding } from "./containers/bytes.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { BOOL, DYN, F64, IrExpr, IrFunction, IrLibFn, IrStmt, IrType, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, VOID, arrayOf, BYTES_U8, canBoxFuncIntoDyn, funcOf, typeEquals, typeKey } from "../../ir/ir.js"; +import { BOOL, DYN, F64, type IrExpr, type IrFunction, type IrLibFn, type IrStmt, type IrType, RUNTIME_STREAM_CLASSES, STRING, type SrcLoc, VOID, arrayOf, BYTES_U8, canBoxFuncIntoDyn, funcOf, typeEquals, typeKey } from "../../ir/ir.js"; import { boolLit, numLit, strLit } from "../../ir/build.js"; const BYTES = BYTES_U8; diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index a2adfbf3..eb475b50 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -16,7 +16,7 @@ import * as ts from "../ts7/adapter.js"; import { resultIsDiscarded } from "./call-position.js"; import type { Lowerer } from "./lowerer.js"; import { locOf } from "../program.js"; -import { IrExpr, IrType, SrcLoc, STRING, VOID, isUnitType } from "../../ir/ir.js"; +import { type IrExpr, type IrType, type SrcLoc, STRING, VOID, isUnitType } from "../../ir/ir.js"; import { numLit, strLit } from "../../ir/build.js"; const TEST_FN_HINT = diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index acffe86b..a1c4aae4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -107,14 +107,14 @@ import { UnionRegistry, withUndefinedArm as withUndefinedArmCanonical, } from "../type-mapper.js"; -import { CompoundOp, IslandFnEntry, boundaryIntoIslandMsg, boundaryOutOfIslandMsg, BuiltinModuleFn, builtinConstLit, builtinModuleConstOf, builtinModulesArrayLit, builtinFenceHintOf, builtinModuleFnOf, stdlibMemberFence, isStdlibMember, isStdlibSymbol, isStdlibGlobal, stdlibGlobalMember, nodeTypesOnlySymbol } from "./surfaces.js"; -import { FileParts, splitFiles, collectProgram, collectNpmImports, collectJsonImports, moduleArtifacts, collectGlobals, declSymbolOf, defaultExportSymbolOf, lowerFileInit, lowerDefaultExport, buildMain, appendDynamicImportModules, appendForkModules } from "./lower-modules.js"; +import { type CompoundOp, type IslandFnEntry, boundaryIntoIslandMsg, boundaryOutOfIslandMsg, type BuiltinModuleFn, builtinConstLit, builtinModuleConstOf, builtinModulesArrayLit, builtinFenceHintOf, builtinModuleFnOf, stdlibMemberFence, isStdlibMember, isStdlibSymbol, isStdlibGlobal, stdlibGlobalMember, nodeTypesOnlySymbol } from "./surfaces.js"; +import { type FileParts, splitFiles, collectProgram, collectNpmImports, collectJsonImports, moduleArtifacts, collectGlobals, declSymbolOf, defaultExportSymbolOf, lowerFileInit, lowerDefaultExport, buildMain, appendDynamicImportModules, appendForkModules } from "./lower-modules.js"; import { prepareCjsModuleGraph } from "./lower-node-module.js"; -import { ClassInfo, ClassIteratorInfo, GenericClassInfo, registerBuiltinErrorClasses, registerBuiltinEmitterClass, registerBuiltinStreamClasses, builtinErrorInfoOf, builtinEmitterInfoOf, builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, classIteratorNextCall, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, classMemberNameOf, classValueRef, collectClassShape, exactClassOfReceiver, collectClassShapeInner, ctorAbiEquals, findMethodOn, findStaticOn, findGenericMethodOn, findGenericStaticOn, genericClassInstanceType, isSubclassOf, inHierarchy, overrideBelow, staticShadowBelow, upcastTo, lowerClassMembers, lowerClassCtor, lowerClassExpression, lowerClassExpressionInfo, lowerClassMethodMember, lowerClassValueProperty, lowerStaticMethod, throwingSetterFn, fieldInitStmts, lowerStaticFieldInits, lowerStaticFieldRead, lowerDerivedCtorBody, superCallStmt, lowerSuperMethodCall, superThisRef, lowerSuperAccessorRead, lowerSuperAccessorWrite, inheritsBuiltinErrorCtor, inheritsBuiltinEmitterCtor, errorConstructorArgs, lowerNew, accessorCall } from "./lower-classes.js"; -import { MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } from "./lower-mixins.js"; +import { type ClassInfo, type ClassMethodSignature, type ClassIteratorInfo, type GenericClassInfo, registerBuiltinErrorClasses, registerBuiltinEmitterClass, registerBuiltinStreamClasses, builtinErrorInfoOf, builtinEmitterInfoOf, builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, classIteratorNextCall, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, classMemberNameOf, classValueRef, collectClassShape, exactClassOfReceiver, collectClassShapeInner, ctorAbiEquals, findMethodOn, findStaticOn, findGenericMethodOn, findGenericStaticOn, genericClassInstanceType, isSubclassOf, inHierarchy, overrideBelow, staticShadowBelow, upcastTo, lowerClassMembers, lowerClassCtor, lowerClassExpression, lowerClassExpressionInfo, lowerClassMethodMember, lowerClassValueProperty, lowerStaticMethod, throwingSetterFn, fieldInitStmts, lowerStaticFieldInits, lowerStaticFieldRead, lowerDerivedCtorBody, superCallStmt, lowerSuperMethodCall, superThisRef, lowerSuperAccessorRead, lowerSuperAccessorWrite, inheritsBuiltinErrorCtor, inheritsBuiltinEmitterCtor, errorConstructorArgs, lowerNew, accessorCall } from "./lower-classes.js"; +import { type MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } from "./lower-mixins.js"; import { implicitAnyParamSymbolsOf } from "./lower-calls.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, funcTypeFromParamShapes, implicitMonoFile, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction, validateFfiImports } from "./lower-calls.js"; +import { type ParamShape, type FnSig, type GenericFnInfo, type GenericInstance, bindingNeverReassigned, bodyReadsArguments, funcTypeFromParamShapes, implicitMonoFile, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction, validateFfiImports } from "./lower-calls.js"; import { lowerArrayMethodCall, lowerMapMethodCall, lowerMapForEachCall, buildMapForEachFn, lowerRecordOvfCaptureHelper, lowerEnvToPairsHelper, lowerSetMethodCall, lowerSetForEachCall, buildSetForEachFn } from "./lower-containers.js"; import { lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew } from "./containers/bytes.js"; import { lowerRegexMethodCall, lowerStringMethodCall } from "./containers/string-and-regexp.js"; @@ -129,7 +129,7 @@ import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js" import { lowerUtilModuleCall } from "./lower-inspect.js"; import { lowerComptime, comptimeBakeable, rejectComptimeCaptures, comptimeValueToIr } from "./lower-comptime.js"; import { lowerStmts, noteBlockedBindings, isBlockedBinding, lowerScopedBlock, predeclareForwardCapture, predeclareForwardFnDecl, predeclareForwardVar, lowerStmt, lowerVarStatement, lowerDestructuringDecl, lowerDestructuringAssignParts, lowerBindingPattern, lowerJsvalBindingPattern, checkBindingElement, bindPatternTarget, isParseArgsDynCheckerType, lowerVarDeclList, lowerVarDecl, lowerSwitch, lowerTry, lowerExprStatement, lowerForOf, lowerForStatement } from "./lower-stmts.js"; -import { FieldTarget, lowerExpr, maybeNarrow, lowerUnitComparison, lowerNullishCoalesce, lowerCondition, ensureBool, requireTruthyUnion, eqComparableUnion, lowerIntrinsicProperty, lowerArrayLiteral, lowerElementAccess, lowerElementWrite, lowerRecordKeyRead, ensureString, lowerTemplate, lowerAsExpression, lowerPrefixUnary, lowerBinary, lowerCaughtTypeofTest, caughtRead, caughtLocalOf, caughtToString, lowerInstanceOf, lowerRegexLiteral, lowerFieldRead, lowerUnionProperty, fieldTarget, fieldGetExpr, fieldSetStmt, lowerFieldCompound, uniqueSymbolKeyOf } from "./lower-exprs.js"; +import { type FieldTarget, lowerExpr, maybeNarrow, lowerUnitComparison, lowerNullishCoalesce, lowerCondition, ensureBool, requireTruthyUnion, eqComparableUnion, lowerIntrinsicProperty, lowerArrayLiteral, lowerElementAccess, lowerElementWrite, lowerRecordKeyRead, ensureString, lowerTemplate, lowerAsExpression, lowerPrefixUnary, lowerBinary, lowerCaughtTypeofTest, caughtRead, caughtLocalOf, caughtToString, lowerInstanceOf, lowerRegexLiteral, lowerFieldRead, lowerUnionProperty, fieldTarget, fieldGetExpr, fieldSetStmt, lowerFieldCompound, uniqueSymbolKeyOf } from "./lower-exprs.js"; import { finishOptionalChain, isOptionalChainTail, lowerOptionalChain } from "./expressions/optional-chains.js"; import { foldedStringKeyOf, lowerDynObjectLiteral, lowerObjectLiteral, lowerShorthandValue, rejectThisInObjectMethod } from "./expressions/object-literals.js"; import type { ExpandoMembers } from "./lower-expando.js"; @@ -1276,6 +1276,9 @@ export class Lowerer { /** Concise callback/lambda returns promoted by the HOF callback prepass; * arrows have no declaration symbol to key in fnSigsBySymbol. */ readonly runtimeOptionalFunctionReturns = new Map(); + /** Intrinsic callback slots can supply a destination for fresh return + * literals, such as a reduce accumulator with recursive union fields. */ + readonly contextualFunctionReturns = new Map(); /** Arithmetic over an unchecked string read can answer either NaN or a * string. Bindings and returns use this marker to retain that result union * through checker-bare string annotations. */ @@ -2875,7 +2878,9 @@ export class Lowerer { params: ParamShape[]; returnType: IrType; top?: FnSig; - method?: { params: ParamShape[]; ret: IrType }; + // Keep the complete method record: return promotion mutates the + // class registry, so a narrower structural copy would lose the write. + method?: ClassMethodSignature; }; const signatureBySymbol = new Map(); for (const [symbol, sig] of this.fnSigsBySymbol) signatureBySymbol.set(symbol, { params: sig.params, returnType: sig.returnType, top: sig }); @@ -5692,6 +5697,9 @@ export class Lowerer { const sameFamily = (src.kind === "record" && arm.kind === "record") || (src.kind === "array" && arm.kind === "array") || + // Tuples already lift into ordinary array slots. Consider that + // same conversion when the array is an arm of a union too. + (src.kind === "record" && this.shapes.get(src.shapeId)?.tuple === true && arm.kind === "array") || (src.kind === "object" && arm.kind === "record") || (src.kind === "record" && arm.kind === "object"); if (sameFamily && this.widthLiftPlan(src, arm) !== null) candidates.push({ tag: i, arm }); @@ -9696,7 +9704,7 @@ export class Lowerer { ); } - private withExpressionOverride(node: ts.Expression, value: IrExpr, lower: () => T): T { + withExpressionOverride(node: ts.Expression, value: IrExpr, lower: () => T): T { const previous = this.chainRecvByNode.get(node); this.chainRecvByNode.set(node, value); try { diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index a2f015f9..e150e462 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -7,7 +7,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { UNSUPPORTED } from "../../diagnostics/diagnostic.js"; -import { BOOL, BYTES_U8, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, IrExpr, IrLibFn, IrStrIntrinsicMethod, IrType, RUNTIME_ERROR_CLASSES, SPAWNRES_T, STATS_T, STRING, URL_T, VOID, arrayOf } from "../../ir/ir.js"; +import { BOOL, BYTES_U8, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, type IrExpr, type IrLibFn, type IrStrIntrinsicMethod, type IrType, RUNTIME_ERROR_CLASSES, SPAWNRES_T, STATS_T, STRING, URL_T, VOID, arrayOf } from "../../ir/ir.js"; import { isJsSourceFile, isNodeTypesPath, requireSpecOf } from "../program.js"; /** Statement-level constructs rejected wholesale, keyed by syntax kind. */ diff --git a/packages/compiler/src/frontend/pipeline-native.ts b/packages/compiler/src/frontend/pipeline-native.ts new file mode 100644 index 00000000..453d94ec --- /dev/null +++ b/packages/compiler/src/frontend/pipeline-native.ts @@ -0,0 +1,32 @@ +import { runFrontend, type Frontend } from "./pipeline.js"; +import { loadProgram } from "./program.js"; +import { FrontendServices } from "./services.js"; +import { createNativeTs7Api } from "./ts7/native-api.js"; + +/** Native callers supply the installed TS7 executable explicitly. The + * returned frontend owns its final program and shared syntax services; + * intermediate npm scout and fallback loads close as soon as replaced. */ +export function runNativeFrontend( + entryPath: string, + executable: string, + npmStatic?: readonly string[] | "auto" | "lib", + externalTypes?: Readonly>, +): Frontend { + const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable })); + try { + const frontend = runFrontend(entryPath, (path, options) => loadProgram(path, services, { + npmStatic: options.npmStatic ?? [], + externalTypes: Object.entries(options.externalTypes ?? {}), + }), npmStatic, externalTypes); + return { + ...frontend, + dispose: () => { + try { frontend.dispose(); } + finally { services.close(); } + }, + }; + } catch (error) { + services.close(); + throw error; + } +} diff --git a/packages/compiler/src/frontend/pipeline.test.ts b/packages/compiler/src/frontend/pipeline.test.ts new file mode 100644 index 00000000..7f52a224 --- /dev/null +++ b/packages/compiler/src/frontend/pipeline.test.ts @@ -0,0 +1,81 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { runFrontend, type ProgramLoader } from "./pipeline.js"; +import * as program from "./program.js"; +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; + +const opened: ReturnType[] = []; +const preflight = vi.spyOn(program, "checkPreflight"); +afterEach(() => { opened.length = 0; preflight.mockReset(); }); + +const loader: ProgramLoader = () => { + // Resource-ownership tests inject only the load fields the orchestration + // reads. Real program loading and native execution have integration tests. + const load = { + entry: { fileName: "/main.ts", text: "", statements: [] }, + moduleOrder: [], + dispose: vi.fn(), + } as unknown as ReturnType; + opened.push(load); + return load; +}; +const typeError: ScrDiagnostic = { + code: "SC0001", message: "the program does not typecheck", + loc: { file: "/main.ts", start: 0, end: 1 }, +}; + +test("transfers ownership of a reused scout to the returned frontend", () => { + preflight.mockReturnValue([]); + const frontend = runFrontend("/main.ts", loader, "auto"); + expect(opened).toHaveLength(1); + expect(opened[0]!.dispose).not.toHaveBeenCalled(); + frontend.dispose(); + frontend.dispose(); + expect(opened[0]!.dispose).toHaveBeenCalledTimes(1); +}); + +test.each([undefined, "auto", "lib"] as const)("closes a load when preflight throws (%s)", (mode) => { + preflight.mockImplementation(() => { throw new Error("preflight failed"); }); + expect(() => runFrontend("/main.ts", loader, mode)).toThrow("preflight failed"); + expect(opened).toHaveLength(1); + expect(opened[0]!.dispose).toHaveBeenCalledTimes(1); +}); + +test("closes the current program and an interrupted per-package probe", () => { + preflight.mockReturnValueOnce([typeError]).mockImplementationOnce(() => { throw new Error("probe failed"); }); + expect(() => runFrontend("/main.ts", loader, ["fixture"])).toThrow("probe failed"); + expect(opened).toHaveLength(2); + for (const load of opened) expect(load.dispose).toHaveBeenCalledTimes(1); +}); + +test("closes the current program if opening a probe fails", () => { + preflight.mockReturnValue([typeError]); + const failingLoader: ProgramLoader = (path, options) => { + if (opened.length > 0) throw new Error("could not open probe"); + return loader(path, options); + }; + expect(() => runFrontend("/main.ts", failingLoader, ["fixture"])).toThrow("could not open probe"); + expect(opened).toHaveLength(1); + expect(opened[0]!.dispose).toHaveBeenCalledTimes(1); +}); + +test("does not retain or close disposed fallback loads twice", () => { + preflight.mockReturnValueOnce([typeError]).mockReturnValueOnce([typeError]).mockReturnValue([]); + const frontend = runFrontend("/main.ts", loader, ["fixture"]); + expect(opened).toHaveLength(3); + expect(frontend.npmStatic).toMatchObject([{ package: "fixture", status: "fallback" }]); + expect(opened[0]!.dispose).toHaveBeenCalledTimes(1); + expect(opened[1]!.dispose).toHaveBeenCalledTimes(1); + expect(opened[2]!.dispose).not.toHaveBeenCalled(); + frontend.dispose(); + for (const load of opened) expect(load.dispose).toHaveBeenCalledTimes(1); +}); + +test("preserves the original failure and continues cleanup when a disposer throws", () => { + preflight.mockImplementationOnce(() => { + vi.mocked(opened[0]!.dispose).mockImplementation(() => { throw new Error("close failed"); }); + return [typeError]; + }).mockImplementationOnce(() => { throw new Error("probe failed"); }); + expect(() => runFrontend("/main.ts", loader, ["fixture"])).toThrow("probe failed"); + expect(opened).toHaveLength(2); + for (const load of opened) expect(load.dispose).toHaveBeenCalledTimes(1); +}); diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts new file mode 100644 index 00000000..174b39ff --- /dev/null +++ b/packages/compiler/src/frontend/pipeline.ts @@ -0,0 +1,439 @@ +/** Shared frontend orchestration. Hosts supply a program loader so Node + * and native callers use the same npm selection, preflight and lowering. */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import type { NpmStaticStatus } from "../coverage/report.js"; +import { entryFunctionExports, type EntryExportInfo } from "./lib-exports.js"; +import { entryContractFacts, type ContractFacts } from "./lib-contract.js"; +import { canonicalBuiltinModule, checkPreflight, isNodeTypesPath, locOf, requiresOf, resolveNpmImport, type LoadResult } from "./program.js"; +import { npmStaticIneligibleReason, npmStaticOffenders, npmStaticPackageOfPath } from "./npm-static.js"; +import { clearResolveCaches, resolveBareModule } from "./resolve.js"; +import { isJsSourceFileName } from "./tsc-codes.js"; +import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js"; +import { lowerToIr, type LowerOptions, type LowerResult } from "./lowering/lowerer.js"; +import type { FrontendServices } from "./services.js"; +import { isImportDeclaration, isExportDeclaration, isStringLiteral } from "./ts7/adapter.js"; + +export interface ProgramLoadOptions { + npmStatic?: readonly string[]; + externalTypes?: Readonly> | undefined; +} + +/** A load owns its program resources until LoadResult.dispose(). Host + * transports may own additional resources around the whole frontend. */ +export type ProgramLoader = (entryPath: string, options: ProgramLoadOptions) => LoadResult & { + services: FrontendServices; + dispose: () => void; +}; + +/* ── the frontend, one pipeline shape ─────────────────────────────────── + * Load → preflight → lowering all ride the ONE tsgo program (program.ts + + * lowering/ over the ts7 adapter) — the native TypeScript compiler is the + * only frontend since the phase-4 flip retired the 5.9.3 pipeline + * (typescript@5.9.3 survives solely behind the source-string parser and + * transpilation islands enforced by scripts/test-ts7.mjs). Everything after + * lowering is IR-world, so analyze() and compile() consume this one + * Frontend shape. */ +export interface Frontend { + preflight: ScrDiagnostic[]; + /** The entry source file's text (emitCModule's header comment input). */ + entryText: () => string; + /** Library mode's resolution input: the entry file's exported function + * declarations (call before dispose — it reads the ts7 AST). */ + entryExports: () => Map; + /** The contract sidecar's projection input: the entry file's exported + * function signatures and convention consts, plus the whole graph's + * exported type declarations, in declaration order (call before + * dispose — it reads the ts7 AST). */ + entryContract: () => ContractFacts; + sourceTexts: () => Map; + lower: (opts: LowerOptions) => LowerResult; + /** --npm-static: each requested (or auto-detected) package's outcome — + * compiled statically, or fallen back with the first refusal reason. */ + npmStatic: NpmStaticStatus[]; + /** Library mode only (empty otherwise): each judged npm package's first + * import site, the anchor for the SC4020 static-or-refuse teaching. */ + npmImportSites: ReadonlyMap; + /** Releases the frontend's resources (the spawned tsgo server). Call + * exactly once, after the last lower(). */ + dispose: () => void; +} + +/** --npm-static=auto (and library mode's mandatory twin): one throwaway + * load finds every bare npm import the program's own modules make, then + * the eligibility heuristics (npm-static.ts) pick the packages whose + * shipped JS is worth attempting. Rejected candidates report their reason + * so the coverage output says why auto skipped them. + * + * "lib" widens the scan to the STATIC-OR-REFUSE posture (a fallback + * status is a build-stopping SC4020 there, never an island note): + * - opted-in packages' OWN files are scanned too — import statements + * and top-level requires alike — so runFrontend's fixpoint loop + * judges every bare edge the growing graph exposes (the executable + * lane leaves a package's deps to the island; the library lane has + * no island); + * - a bare specifier no TYPES resolution answers but whose runtime JS + * resolves (a package with no own .d.ts) is judged instead of + * skipped — it fails the bar by name, not as a generic import fence; + * - `judged` dedups across fixpoint iterations and `sites` records + * each package's first import site, the SC4020 anchor. */ +function detectAutoPackages( + load: LoadResult, + statuses: NpmStaticStatus[], + mode: "auto" | "lib" = "auto", + judged?: Set, + sites?: Map, +): string[] { + // package → the resolved types file AND the file whose import found it: + // the runtime-JS probe below must resolve from the SAME importing file, + // or a package visible only to a nested package.json realm (a pnpm + // monorepo's packages/*/node_modules, unreachable from the entry's own + // walk-up) answers "no runtime JS" for perfectly ordinary installs. + const seen = new Map(); + for (const sf of [...load.moduleOrder, load.entry]) { + if (mode === "auto" && sf.fileName.includes("/node_modules/")) continue; + const edges: { spec: string; loc: SrcLoc }[] = []; + for (const stmt of sf.statements) { + if ( + (isImportDeclaration(stmt) || isExportDeclaration(stmt)) && + stmt.moduleSpecifier && isStringLiteral(stmt.moduleSpecifier) + ) { + edges.push({ spec: stmt.moduleSpecifier.text, loc: locOf(stmt) }); + } else if (mode === "lib") { + // CJS packages spell their dep edges as top-level requires; the + // import-statement scan alone would miss every one of them. + for (const req of requiresOf(stmt)) edges.push({ spec: req.spec, loc: locOf(req.node) }); + } + } + for (const { spec, loc } of edges) { + if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue; + // Bare builtin names ("fs", "path") are the builtin machinery's + // business (and the SC4005 async_free gate's, in library mode) — + // never npm candidates. Auto keeps its original path (the + // @types/node answer skips them below), byte-for-byte. + if (mode === "lib" && canonicalBuiltinModule(spec) !== null) continue; + const npm = resolveNpmImport(sf.fileName, spec); + if (npm !== null && isNodeTypesPath(npm.typesFile)) continue; + if (npm === null) { + if (mode !== "lib") continue; + const js = resolveBareModule(sf.fileName, spec, "js-only"); + if (js === null || judged!.has(js.packageName)) continue; + judged!.add(js.packageName); + sites!.set(js.packageName, loc); + statuses.push({ package: js.packageName, status: "fallback", detail: "it ships no own .d.ts declaration surface" }); + continue; + } + if (judged?.has(npm.packageName)) continue; + if (!seen.has(npm.packageName)) { + seen.set(npm.packageName, { typesFile: npm.typesFile, fromFile: sf.fileName }); + sites?.set(npm.packageName, loc); + } + } + } + const chosen: string[] = []; + for (const [pkg, { typesFile, fromFile }] of seen) { + judged?.add(pkg); + const jsEntry = resolveBareModule(fromFile, pkg, "js-only"); + const reason = npmStaticIneligibleReason( + pkg, + typesFile, + jsEntry !== null && isJsSourceFileName(jsEntry.typesFile) ? jsEntry.typesFile : null, + ); + if (reason === null) chosen.push(pkg); + else statuses.push({ package: pkg, status: "fallback", detail: mode === "lib" ? reason : `auto: ${reason}` }); + } + return chosen; +} + +/** The opted-in packages a consumer-anchored tsc message NAMES: module + * specifiers in `Module '"spec"'` phrasings, and resolved file paths in + * `import("…")` type spellings — the two ways the checker points at an + * import surface from the importer's side. */ +function packagesNamedByDiag(message: string, optedIn: ReadonlySet): Set { + const hits = new Set(); + for (const m of message.matchAll(/Module '"([^"]+)"'/g)) { + const spec = m[1]!; + const prefix = packageNameOfSpecifier(spec); + if (optedIn.has(prefix)) hits.add(prefix); + } + for (const m of message.matchAll(/import\("([^"]+)"\)/g)) { + const pkg = npmStaticPackageOfPath(m[1]!); + if (pkg !== null && optedIn.has(pkg)) hits.add(pkg); + } + return hits; +} + +/** The one frontend, three npm postures: `undefined`/explicit package + * lists and `"auto"` are the executable lane's (--npm-static; fallback = + * island). `"lib"` is library mode's mandatory auto twin — the same + * eligibility bar and the same opt-in machinery, but every fallback + * status the shared loops record becomes compileLibrary's SC4020 + * static-or-refuse teaching, and the detection closes over the opted-in + * packages' own bare edges (no island exists to serve a dep from). */ +export function runFrontend( + entryPath: string, + loadProgram: ProgramLoader, + npmStatic?: readonly string[] | "auto" | "lib", + externalTypes?: Readonly>, +): Frontend { + // A preflight or package probe may throw after its host has opened. Track + // only live loads, releasing each immediately on normal fallback; this + // also closes both the current program and a failed temporary probe. + const active: ReturnType[] = []; + const trackedLoader: ProgramLoader = (path, options) => { + const load = loadProgram(path, options); + active.push(load); + return { + ...load, + dispose: () => { + const index = active.indexOf(load); + if (index < 0) return; + active.splice(index, 1); + load.dispose(); + }, + }; + }; + try { + return loadFrontend(entryPath, trackedLoader, npmStatic, externalTypes); + } catch (error) { + for (const load of active) { + // Preserve the original failure and attempt every remaining cleanup. + try { load.dispose(); } catch { /* best effort after a failed load */ } + } + throw error; + } +} + +function loadFrontend( + entryPath: string, + loadProgram: ProgramLoader, + npmStatic?: readonly string[] | "auto" | "lib", + externalTypes?: Readonly>, +): Frontend { + // Resolver package/workspace metadata is intentionally shared across the + // several load attempts of ONE auto-detection fixpoint, but never across + // separate compiles in a long-lived process. A cache miss must observe + // package.json edits before it can publish a new early-library entry. + clearResolveCaches(); + const statuses: NpmStaticStatus[] = []; + const npmSites = new Map(); + const judged = new Set(); + let requested: string[] = []; + let reusableScout: ReturnType | null = null; + let reusablePreflight: ScrDiagnostic[] | null = null; + if (npmStatic === "auto" || npmStatic === "lib") { + const scout = loadProgram(entryPath, { externalTypes }); + let retained = false; + try { + const scoutPreflight = checkPreflight(scout); + requested = + npmStatic === "lib" + ? detectAutoPackages(scout, statuses, "lib", judged, npmSites) + : detectAutoPackages(scout, statuses); + // With no package to opt in, the scout already IS the final frontend: + // same roots, resolution posture, preflight, and module order. Retain it + // instead of spawning a second tsgo server and checking the whole graph + // again — the common library-mode path has no bare npm imports. + if (requested.length === 0) { + reusableScout = scout; + reusablePreflight = scoutPreflight; + retained = true; + } + } finally { + if (!retained) scout.dispose(); + } + } else if (npmStatic !== undefined) { + requested = [...new Set(npmStatic)]; + } + + // One exact --external-types mapping makes the containing package an + // external host boundary, which cannot simultaneously be compiled as a + // package-wide --npm-static program graph. External wins; retain the + // ordinary npm-static fallback record so explicit and auto requests both + // explain why the package did not compile statically. + if (requested.length > 0 && externalTypes !== undefined) { + const externalSpecifiersByPackage = new Map(); + for (const specifier of Object.keys(externalTypes)) { + const pkg = packageNameOfSpecifier(specifier); + const specs = externalSpecifiersByPackage.get(pkg) ?? []; + specs.push(specifier); + externalSpecifiersByPackage.set(pkg, specs); + } + requested = requested.filter((pkg) => { + const specs = externalSpecifiersByPackage.get(pkg); + if (specs === undefined) return true; + statuses.push({ + package: pkg, + status: "fallback", + detail: `mapped as an external host module by --external-types (${specs.map((s) => JSON.stringify(s)).join(", ")})`, + }); + return false; + }); + } + + // The all-or-nothing fallback loop: a preflight diagnostic ANCHORED in + // an opted-in package's files (an unsupported require form, a builtin + // fence) — or an offender the resolution itself reported — drops that + // package from the set and the whole frontend reloads without it, so + // its import takes the ordinary island path. Static compilation of a + // package must never turn a working --dynamic build into a build + // failure. + // + // CONSUMER-anchored attribution (the second source): an opted-in + // package whose inferred export surface breaks the typecheck reports at + // its IMPORT SITES — errors in program files no path-shaped attribution + // reaches, but whose MESSAGES name the package ("Module '"pkg"' has no + // exported member", "typeof import("…/pkg/dist/index")"). Bundle-shaped + // dists carry surfaces inference can only partly reach (type-only + // re-exports have no JS value to chase), and the ratified behavior is + // graceful PER-PACKAGE degradation: the named package drops to the + // island with a note, never a failed gate. Explicit opt-ins degrade + // exactly like auto's — "the user asked for these packages" buys the + // attempt, not a broken build. + let load = reusableScout ?? loadProgram(entryPath, { npmStatic: requested, externalTypes }); + let preflight = reusablePreflight ?? checkPreflight(load); + // Library mode's fixpoint: the opted-in packages' files joined the + // program just now, and THEIR bare edges (import statements and + // top-level requires) name packages the scout could not see. Judge each + // by the same bar — eligible ones join the set and the frontend + // reloads; ineligible ones record the fallback status compileLibrary + // refuses on. Bounded by the dependency count (every iteration settles + // at least one new package for good). + if (npmStatic === "lib") { + for (;;) { + const grown = detectAutoPackages(load, statuses, "lib", judged, npmSites); + if (grown.length === 0) break; + requested = [...requested, ...grown]; + load.dispose(); + load = loadProgram(entryPath, { npmStatic: requested, externalTypes }); + preflight = checkPreflight(load); + } + } + const effective = new Set(requested); + while (effective.size > 0) { + const reasons = new Map(npmStaticOffenders()); + for (const d of preflight) { + const pkg = npmStaticPackageOfPath(d.loc.file); + if (pkg !== null && !reasons.has(pkg)) reasons.set(pkg, `${d.code}: ${d.message}`); + } + if (![...reasons.keys()].some((p) => effective.has(p))) { + const named = new Map(); + for (const d of preflight) { + if (d.code !== "SC0001") continue; + for (const pkg of packagesNamedByDiag(d.message, effective)) { + named.set(pkg, (named.get(pkg) ?? 0) + 1); + } + } + for (const [pkg, count] of named) { + reasons.set( + pkg, + `its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files${npmStatic === "lib" ? "" : " — the package serves from the island instead"} (bundler-emitted surfaces type only as far as inference reaches)`, + ); + } + } + const dropping = [...reasons.keys()].filter((p) => effective.has(p)); + if (dropping.length === 0) break; + for (const p of dropping) { + effective.delete(p); + statuses.push({ package: p, status: "fallback", detail: reasons.get(p)! }); + } + load.dispose(); + load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes }); + preflight = checkPreflight(load); + } + // The last resort, ALL modes: an opt-in can change the PROGRAM's OWN + // typecheck through errors that name no package at all (declaration + // shapes outside the safe overload projection — generic overloads, + // declaration-only members, or a .d.ts type-GUARD an inferred JS + // function cannot reproduce). Those SC0001s anchor in USER files no + // offender or + // message attribution reaches, so each remaining package is probed + // ALONE-dropped (n is the opt-in count — a handful of extra analysis + // loads); culprits whose removal clears the errors fall back with a + // note, and if no subset typechecks, everything drops. Explicit opt-ins + // degrade the same way — the ratified stance for bundle-shaped dists is + // graceful per-package degradation, never a failed gate the user cannot + // act on (the note carries the why). + if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) { + const dropWithNote = (p: string): void => { + effective.delete(p); + statuses.push({ + package: p, + status: "fallback", + detail: + npmStatic === "auto" + ? "auto: the program does not typecheck against its inferred surface" + : npmStatic === "lib" + ? "the program does not typecheck against its inferred surface (type-only declarations and .d.ts type guards have no JS value inference can chase)" + : "the program does not typecheck against its inferred surface (type-only declarations and .d.ts type guards have no JS value inference can chase) — the package serves from the island instead", + }); + }; + // Attribute per package by probing each SOLO (culprits are almost + // always independent — each package's inferred surface breaks its own + // import sites), then reload with the survivors; interaction effects + // that still fail drop everything left. + for (const p of [...effective]) { + const probe = loadProgram(entryPath, { npmStatic: [p], externalTypes }); + const probeDiags = checkPreflight(probe); + probe.dispose(); + if (probeDiags.some((d) => d.code === "SC0001")) dropWithNote(p); + } + load.dispose(); + load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes }); + preflight = checkPreflight(load); + if (preflight.some((d) => d.code === "SC0001") && effective.size > 0) { + for (const p of [...effective]) dropWithNote(p); + load.dispose(); + load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes }); + preflight = checkPreflight(load); + } + } + for (const p of requested) { + if (effective.has(p)) statuses.push({ package: p, status: "static" }); + } + + const finalLoad = load; + return { + preflight, + entryText: () => finalLoad.entry.text, + entryExports: () => entryFunctionExports(finalLoad.entry), + // The contract scans the PROGRAM's source files, not the runtime + // module order: a type-only module (nothing but exported types) has no + // runtime edge and never joins moduleOrder, yet its declarations are + // contract surface. Declaration files (default libs, @types) stay out, + // and so do statically-compiled npm packages' files: their .d.ts is + // dropped by construction (inference types the bodies), so no npm + // declaration can name a wire-contract type — the contract vocabulary + // is authored program surface only, and a workspace-linked package's + // shipped .ts must not smuggle same-name declarations into the type + // table. + entryContract: () => + entryContractFacts( + finalLoad.entry, + finalLoad.program.getSourceFiles().filter((sf) => !sf.isDeclarationFile && npmStaticPackageOfPath(sf.fileName) === null), + ), + // Runtime evaluation order first, then any type-only program modules + // (no runtime edge, so absent from moduleOrder — but they are contract + // surface now, and the library identity hashes cover the WHOLE module + // graph; the Map dedups by fileName). Statically-compiled npm modules + // are in moduleOrder like any program module, so their bytes join the + // library identity hashes (source_hash/build_id) — compiled code is + // identity, whatever directory it came from. + sourceTexts: () => + new Map( + [finalLoad.entry, ...finalLoad.moduleOrder, ...finalLoad.program.getSourceFiles().filter((sf) => !sf.isDeclarationFile)].map( + (sf) => [sf.fileName, sf.text], + ), + ), + lower: (opts) => lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, { + ...opts, + frontendServices: finalLoad.services, + startupCrash: finalLoad.startupCrash ?? null, + externalTypes: finalLoad.externalTypes, + externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile, + }), + npmStatic: statuses, + npmImportSites: npmSites, + dispose: finalLoad.dispose, + }; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index fa31ebbb..6a1c3772 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -51,20 +51,18 @@ import { type SidecarIrTypePattern, } from "./library/sidecar.js"; import { validateSidecar } from "./library/sidecar-validate.js"; -import { entryFunctionExports, type EntryExportInfo } from "./frontend/lib-exports.js"; -import { entryContractFacts, type ContractFacts } from "./frontend/lib-contract.js"; +import type { EntryExportInfo } from "./frontend/lib-exports.js"; +import type { ContractFacts } from "./frontend/lib-contract.js"; import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/ir.js"; import { moduleUsesBigInt } from "./ir/ir.js"; import { serializeModule } from "./ir/serialize.js"; import { validateModule } from "./ir/validate.js"; -import { canonicalBuiltinModule, checkPreflight, isNodeTypesPath, loadProgram, locOf, requiresOf, resolveNpmImport, type LoadResult } from "./frontend/program-node.js"; -import { npmStaticIneligibleReason, npmStaticOffenders, npmStaticPackageOfPath } from "./frontend/npm-static.js"; +import { loadProgram } from "./frontend/program-node.js"; +import { runFrontend } from "./frontend/pipeline.js"; import { provenanceSources } from "./frontend/provenance-registry.js"; -import { clearResolveCaches, resolveBareModule } from "./frontend/resolve.js"; -import { isJsSourceFileName } from "./frontend/tsc-codes.js"; -import { isRelativeSpecifier, packageNameOfSpecifier } from "./frontend/workspace-registry.js"; -import { lowerToIr, type LowerOptions, type LowerResult } from "./frontend/lowering/lowerer.js"; -import type { CoverageInput, NpmStaticStatus } from "./coverage/report.js"; +import { clearResolveCaches } from "./frontend/resolve.js"; +import type { LowerResult } from "./frontend/lowering/lowerer.js"; +import type { CoverageInput } from "./coverage/report.js"; import { loadFfiProfile, type FfiProfile } from "./ffi/ffi-manifest.js"; import { hasForeignFfiCallback } from "./backend/ffi-callbacks.js"; import { FrontendInputTracker, trackedReadFile } from "./frontend/input-tracker.js"; @@ -513,387 +511,6 @@ export interface AnalyzeOptions { externalTypes?: Readonly>; } -/* ── the frontend, one pipeline shape ─────────────────────────────────── - * Load → preflight → lowering all ride the ONE tsgo program (program.ts + - * lowering/ over the ts7 adapter) — the native TypeScript compiler is the - * only frontend since the phase-4 flip retired the 5.9.3 pipeline - * (typescript@5.9.3 survives solely behind the source-string parser and - * transpilation islands enforced by scripts/test-ts7.mjs). Everything after - * lowering is IR-world, so analyze() and compile() consume this one - * Frontend shape. */ -interface Frontend { - preflight: ScrDiagnostic[]; - /** The entry source file's text (emitCModule's header comment input). */ - entryText: () => string; - /** Library mode's resolution input: the entry file's exported function - * declarations (call before dispose — it reads the ts7 AST). */ - entryExports: () => Map; - /** The contract sidecar's projection input: the entry file's exported - * function signatures and convention consts, plus the whole graph's - * exported type declarations, in declaration order (call before - * dispose — it reads the ts7 AST). */ - entryContract: () => ContractFacts; - sourceTexts: () => Map; - lower: (opts: LowerOptions) => LowerResult; - /** --npm-static: each requested (or auto-detected) package's outcome — - * compiled statically, or fallen back with the first refusal reason. */ - npmStatic: NpmStaticStatus[]; - /** Library mode only (empty otherwise): each judged npm package's first - * import site, the anchor for the SC4020 static-or-refuse teaching. */ - npmImportSites: ReadonlyMap; - /** Releases the frontend's resources (the spawned tsgo server). Call - * exactly once, after the last lower(). */ - dispose: () => void; -} - -/** --npm-static=auto (and library mode's mandatory twin): one throwaway - * load finds every bare npm import the program's own modules make, then - * the eligibility heuristics (npm-static.ts) pick the packages whose - * shipped JS is worth attempting. Rejected candidates report their reason - * so the coverage output says why auto skipped them. - * - * "lib" widens the scan to the STATIC-OR-REFUSE posture (a fallback - * status is a build-stopping SC4020 there, never an island note): - * - opted-in packages' OWN files are scanned too — import statements - * and top-level requires alike — so runFrontend's fixpoint loop - * judges every bare edge the growing graph exposes (the executable - * lane leaves a package's deps to the island; the library lane has - * no island); - * - a bare specifier no TYPES resolution answers but whose runtime JS - * resolves (a package with no own .d.ts) is judged instead of - * skipped — it fails the bar by name, not as a generic import fence; - * - `judged` dedups across fixpoint iterations and `sites` records - * each package's first import site, the SC4020 anchor. */ -function detectAutoPackages( - load: LoadResult, - statuses: NpmStaticStatus[], - mode: "auto" | "lib" = "auto", - judged?: Set, - sites?: Map, -): string[] { - // package → the resolved types file AND the file whose import found it: - // the runtime-JS probe below must resolve from the SAME importing file, - // or a package visible only to a nested package.json realm (a pnpm - // monorepo's packages/*/node_modules, unreachable from the entry's own - // walk-up) answers "no runtime JS" for perfectly ordinary installs. - const seen = new Map(); - for (const sf of [...load.moduleOrder, load.entry]) { - if (mode === "auto" && sf.fileName.includes("/node_modules/")) continue; - const edges: { spec: string; loc: SrcLoc }[] = []; - for (const stmt of sf.statements) { - if (ts7IsImportWithStringSpec(stmt)) { - edges.push({ spec: (stmt as { moduleSpecifier: { text: string } }).moduleSpecifier.text, loc: locOf(stmt) }); - } else if (mode === "lib") { - // CJS packages spell their dep edges as top-level requires; the - // import-statement scan alone would miss every one of them. - for (const req of requiresOf(stmt)) edges.push({ spec: req.spec, loc: locOf(req.node) }); - } - } - for (const { spec, loc } of edges) { - if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue; - // Bare builtin names ("fs", "path") are the builtin machinery's - // business (and the SC4005 async_free gate's, in library mode) — - // never npm candidates. Auto keeps its original path (the - // @types/node answer skips them below), byte-for-byte. - if (mode === "lib" && canonicalBuiltinModule(spec) !== null) continue; - const npm = resolveNpmImport(sf.fileName, spec); - if (npm !== null && isNodeTypesPath(npm.typesFile)) continue; - if (npm === null) { - if (mode !== "lib") continue; - const js = resolveBareModule(sf.fileName, spec, "js-only"); - if (js === null || judged!.has(js.packageName)) continue; - judged!.add(js.packageName); - sites!.set(js.packageName, loc); - statuses.push({ package: js.packageName, status: "fallback", detail: "it ships no own .d.ts declaration surface" }); - continue; - } - if (judged?.has(npm.packageName)) continue; - if (!seen.has(npm.packageName)) { - seen.set(npm.packageName, { typesFile: npm.typesFile, fromFile: sf.fileName }); - sites?.set(npm.packageName, loc); - } - } - } - const chosen: string[] = []; - for (const [pkg, { typesFile, fromFile }] of seen) { - judged?.add(pkg); - const jsEntry = resolveBareModule(fromFile, pkg, "js-only"); - const reason = npmStaticIneligibleReason( - pkg, - typesFile, - jsEntry !== null && isJsSourceFileName(jsEntry.typesFile) ? jsEntry.typesFile : null, - ); - if (reason === null) chosen.push(pkg); - else statuses.push({ package: pkg, status: "fallback", detail: mode === "lib" ? reason : `auto: ${reason}` }); - } - return chosen; -} - -/** Duck-typed import-declaration test (the ts7 AST types stay inside the - * frontend; this file only needs the specifier text). */ -function ts7IsImportWithStringSpec(stmt: unknown): stmt is { moduleSpecifier: { text: string } } { - const s = stmt as { kind?: unknown; moduleSpecifier?: { text?: unknown } }; - return typeof s.moduleSpecifier?.text === "string"; -} - -/** The opted-in packages a consumer-anchored tsc message NAMES: module - * specifiers in `Module '"spec"'` phrasings, and resolved file paths in - * `import("…")` type spellings — the two ways the checker points at an - * import surface from the importer's side. */ -function packagesNamedByDiag(message: string, optedIn: ReadonlySet): Set { - const hits = new Set(); - for (const m of message.matchAll(/Module '"([^"]+)"'/g)) { - const spec = m[1]!; - const prefix = packageNameOfSpecifier(spec); - if (optedIn.has(prefix)) hits.add(prefix); - } - for (const m of message.matchAll(/import\("([^"]+)"\)/g)) { - const pkg = npmStaticPackageOfPath(m[1]!); - if (pkg !== null && optedIn.has(pkg)) hits.add(pkg); - } - return hits; -} - -/** The one frontend, three npm postures: `undefined`/explicit package - * lists and `"auto"` are the executable lane's (--npm-static; fallback = - * island). `"lib"` is library mode's mandatory auto twin — the same - * eligibility bar and the same opt-in machinery, but every fallback - * status the shared loops record becomes compileLibrary's SC4020 - * static-or-refuse teaching, and the detection closes over the opted-in - * packages' own bare edges (no island exists to serve a dep from). */ -function runFrontend( - entryPath: string, - npmStatic?: readonly string[] | "auto" | "lib", - externalTypes?: Readonly>, -): Frontend { - // Resolver package/workspace metadata is intentionally shared across the - // several load attempts of ONE auto-detection fixpoint, but never across - // separate compiles in a long-lived process. A cache miss must observe - // package.json edits before it can publish a new early-library entry. - clearResolveCaches(); - const statuses: NpmStaticStatus[] = []; - const npmSites = new Map(); - const judged = new Set(); - let requested: string[] = []; - let reusableScout: ReturnType | null = null; - let reusablePreflight: ScrDiagnostic[] | null = null; - if (npmStatic === "auto" || npmStatic === "lib") { - const scout = loadProgram(entryPath, { externalTypes }); - let retained = false; - try { - const scoutPreflight = checkPreflight(scout); - requested = - npmStatic === "lib" - ? detectAutoPackages(scout, statuses, "lib", judged, npmSites) - : detectAutoPackages(scout, statuses); - // With no package to opt in, the scout already IS the final frontend: - // same roots, resolution posture, preflight, and module order. Retain it - // instead of spawning a second tsgo server and checking the whole graph - // again — the common library-mode path has no bare npm imports. - if (requested.length === 0) { - reusableScout = scout; - reusablePreflight = scoutPreflight; - retained = true; - } - } finally { - if (!retained) scout.dispose(); - } - } else if (npmStatic !== undefined) { - requested = [...new Set(npmStatic)]; - } - - // One exact --external-types mapping makes the containing package an - // external host boundary, which cannot simultaneously be compiled as a - // package-wide --npm-static program graph. External wins; retain the - // ordinary npm-static fallback record so explicit and auto requests both - // explain why the package did not compile statically. - if (requested.length > 0 && externalTypes !== undefined) { - const externalSpecifiersByPackage = new Map(); - for (const specifier of Object.keys(externalTypes)) { - const pkg = packageNameOfSpecifier(specifier); - const specs = externalSpecifiersByPackage.get(pkg) ?? []; - specs.push(specifier); - externalSpecifiersByPackage.set(pkg, specs); - } - requested = requested.filter((pkg) => { - const specs = externalSpecifiersByPackage.get(pkg); - if (specs === undefined) return true; - statuses.push({ - package: pkg, - status: "fallback", - detail: `mapped as an external host module by --external-types (${specs.map((s) => JSON.stringify(s)).join(", ")})`, - }); - return false; - }); - } - - // The all-or-nothing fallback loop: a preflight diagnostic ANCHORED in - // an opted-in package's files (an unsupported require form, a builtin - // fence) — or an offender the resolution itself reported — drops that - // package from the set and the whole frontend reloads without it, so - // its import takes the ordinary island path. Static compilation of a - // package must never turn a working --dynamic build into a build - // failure. - // - // CONSUMER-anchored attribution (the second source): an opted-in - // package whose inferred export surface breaks the typecheck reports at - // its IMPORT SITES — errors in program files no path-shaped attribution - // reaches, but whose MESSAGES name the package ("Module '"pkg"' has no - // exported member", "typeof import("…/pkg/dist/index")"). Bundle-shaped - // dists carry surfaces inference can only partly reach (type-only - // re-exports have no JS value to chase), and the ratified behavior is - // graceful PER-PACKAGE degradation: the named package drops to the - // island with a note, never a failed gate. Explicit opt-ins degrade - // exactly like auto's — "the user asked for these packages" buys the - // attempt, not a broken build. - let load = reusableScout ?? loadProgram(entryPath, { npmStatic: requested, externalTypes }); - let preflight = reusablePreflight ?? checkPreflight(load); - // Library mode's fixpoint: the opted-in packages' files joined the - // program just now, and THEIR bare edges (import statements and - // top-level requires) name packages the scout could not see. Judge each - // by the same bar — eligible ones join the set and the frontend - // reloads; ineligible ones record the fallback status compileLibrary - // refuses on. Bounded by the dependency count (every iteration settles - // at least one new package for good). - if (npmStatic === "lib") { - for (;;) { - const grown = detectAutoPackages(load, statuses, "lib", judged, npmSites); - if (grown.length === 0) break; - requested = [...requested, ...grown]; - load.dispose(); - load = loadProgram(entryPath, { npmStatic: requested, externalTypes }); - preflight = checkPreflight(load); - } - } - const effective = new Set(requested); - while (effective.size > 0) { - const reasons = new Map(npmStaticOffenders()); - for (const d of preflight) { - const pkg = npmStaticPackageOfPath(d.loc.file); - if (pkg !== null && !reasons.has(pkg)) reasons.set(pkg, `${d.code}: ${d.message}`); - } - if (![...reasons.keys()].some((p) => effective.has(p))) { - const named = new Map(); - for (const d of preflight) { - if (d.code !== "SC0001") continue; - for (const pkg of packagesNamedByDiag(d.message, effective)) { - named.set(pkg, (named.get(pkg) ?? 0) + 1); - } - } - for (const [pkg, count] of named) { - reasons.set( - pkg, - `its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files${npmStatic === "lib" ? "" : " — the package serves from the island instead"} (bundler-emitted surfaces type only as far as inference reaches)`, - ); - } - } - const dropping = [...reasons.keys()].filter((p) => effective.has(p)); - if (dropping.length === 0) break; - for (const p of dropping) { - effective.delete(p); - statuses.push({ package: p, status: "fallback", detail: reasons.get(p)! }); - } - load.dispose(); - load = loadProgram(entryPath, { npmStatic: effective, externalTypes }); - preflight = checkPreflight(load); - } - // The last resort, ALL modes: an opt-in can change the PROGRAM's OWN - // typecheck through errors that name no package at all (declaration - // shapes outside the safe overload projection — generic overloads, - // declaration-only members, or a .d.ts type-GUARD an inferred JS - // function cannot reproduce). Those SC0001s anchor in USER files no - // offender or - // message attribution reaches, so each remaining package is probed - // ALONE-dropped (n is the opt-in count — a handful of extra analysis - // loads); culprits whose removal clears the errors fall back with a - // note, and if no subset typechecks, everything drops. Explicit opt-ins - // degrade the same way — the ratified stance for bundle-shaped dists is - // graceful per-package degradation, never a failed gate the user cannot - // act on (the note carries the why). - if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) { - const dropWithNote = (p: string): void => { - effective.delete(p); - statuses.push({ - package: p, - status: "fallback", - detail: - npmStatic === "auto" - ? "auto: the program does not typecheck against its inferred surface" - : npmStatic === "lib" - ? "the program does not typecheck against its inferred surface (type-only declarations and .d.ts type guards have no JS value inference can chase)" - : "the program does not typecheck against its inferred surface (type-only declarations and .d.ts type guards have no JS value inference can chase) — the package serves from the island instead", - }); - }; - // Attribute per package by probing each SOLO (culprits are almost - // always independent — each package's inferred surface breaks its own - // import sites), then reload with the survivors; interaction effects - // that still fail drop everything left. - for (const p of [...effective]) { - const probe = loadProgram(entryPath, { npmStatic: [p], externalTypes }); - const probeDiags = checkPreflight(probe); - probe.dispose(); - if (probeDiags.some((d) => d.code === "SC0001")) dropWithNote(p); - } - load.dispose(); - load = loadProgram(entryPath, { npmStatic: effective, externalTypes }); - preflight = checkPreflight(load); - if (preflight.some((d) => d.code === "SC0001") && effective.size > 0) { - for (const p of [...effective]) dropWithNote(p); - load.dispose(); - load = loadProgram(entryPath, { npmStatic: effective, externalTypes }); - preflight = checkPreflight(load); - } - } - for (const p of requested) { - if (effective.has(p)) statuses.push({ package: p, status: "static" }); - } - - const finalLoad = load; - return { - preflight, - entryText: () => finalLoad.entry.text, - entryExports: () => entryFunctionExports(finalLoad.entry), - // The contract scans the PROGRAM's source files, not the runtime - // module order: a type-only module (nothing but exported types) has no - // runtime edge and never joins moduleOrder, yet its declarations are - // contract surface. Declaration files (default libs, @types) stay out, - // and so do statically-compiled npm packages' files: their .d.ts is - // dropped by construction (inference types the bodies), so no npm - // declaration can name a wire-contract type — the contract vocabulary - // is authored program surface only, and a workspace-linked package's - // shipped .ts must not smuggle same-name declarations into the type - // table. - entryContract: () => - entryContractFacts( - finalLoad.entry, - finalLoad.program.getSourceFiles().filter((sf) => !sf.isDeclarationFile && npmStaticPackageOfPath(sf.fileName) === null), - ), - // Runtime evaluation order first, then any type-only program modules - // (no runtime edge, so absent from moduleOrder — but they are contract - // surface now, and the library identity hashes cover the WHOLE module - // graph; the Map dedups by fileName). Statically-compiled npm modules - // are in moduleOrder like any program module, so their bytes join the - // library identity hashes (source_hash/build_id) — compiled code is - // identity, whatever directory it came from. - sourceTexts: () => - new Map( - [finalLoad.entry, ...finalLoad.moduleOrder, ...finalLoad.program.getSourceFiles().filter((sf) => !sf.isDeclarationFile)].map( - (sf) => [sf.fileName, sf.text], - ), - ), - lower: (opts) => lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, { - ...opts, - frontendServices: finalLoad.services, - startupCrash: finalLoad.startupCrash ?? null, - externalTypes: finalLoad.externalTypes, - externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile, - }), - npmStatic: statuses, - npmImportSites: npmSites, - dispose: finalLoad.dispose, - }; -} - /** Analysis without codegen: how much of the program compiles statically. * Unlike compile(), lowering diagnostics are data here, not failure. */ export function analyze(entryPath: string, opts: AnalyzeOptions = {}): AnalyzeResult { @@ -914,7 +531,7 @@ export function analyze(entryPath: string, opts: AnalyzeOptions = {}): AnalyzeRe } ffi = loaded.profile; } - const fe = runFrontend(entryPath, opts.npmStatic, opts.externalTypes); + const fe = runFrontend(entryPath, loadProgram, opts.npmStatic, opts.externalTypes); try { const emptyStats = { statementsTotal: 0, statementsFailed: 0, statementsIsland: 0, functionsSkipped: 0 }; @@ -1557,7 +1174,7 @@ async function compileTracked( : { llvmRefusal: earlyHit.native.llvmRefusal }), }; } - const fe = runFrontend(entryPath, opts.npmStatic); + const fe = runFrontend(entryPath, loadProgram, opts.npmStatic); let lowered: LowerResult; let entryText: string; let sourceTexts: Map; @@ -2654,7 +2271,7 @@ async function compileLibraryTracked( // no build-transform markers), automatically — the library path has no // island/dynamic tier to offer (SC4006's ground), so eligibility needs // no flag and a miss is a refusal, never a fallback. - const fe = runFrontend(entryPath, "lib"); + const fe = runFrontend(entryPath, loadProgram, "lib"); timing("frontend-load", { entry_bytes: fe.entryText().length, source_files: fe.sourceTexts().size, diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 600af6e8..9ac2db36 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -81,6 +81,27 @@ function expressionModule(expr: IrExpr, unions: IrUnionDef[]): IrModule { }; } +test("nullish chains retain child-before-parent diagnostic order", () => { + const at = (start: number) => ({ ...loc, start }); + const expr: IrExpr = { + kind: "nullish", type: F64, loc: at(4), + left: { + kind: "nullish", type: STRING, loc: at(2), + left: { kind: "numLit", value: 0, type: STRING, loc: at(0) }, + right: { kind: "boolLit", value: true, type: F64, loc: at(1) }, + }, + right: { kind: "strLit", value: "wrong", type: F64, loc: at(3) }, + }; + expect(validateModule(expressionModule(expr, [])).map((error) => [error.loc.start, error.message])).toEqual([ + [0, "in main: numLit must be f64"], + [1, "in main: boolLit must be bool"], + [1, "in main: nullish right operand: expected string, got f64"], + [2, "in main: nullish left must be a union, got string"], + [3, "in main: strLit must be string"], + [4, "in main: nullish left must be a union, got string"], + ]); +}); + test.each(["callValue", "dynCall"] as const)("%s requires a checked-value receiver and preserves it in serialization", (kind) => { const funcType: IrType = { kind: "func", params: [], ret: DYN }; const closure: IrExpr = { kind: "closure", fnName: "callback", captures: [], type: funcType, loc }; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 46940925..b37b00a9 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2168,6 +2168,60 @@ function validateFunction( ) ?? false; }; + function checkNullishOperands(e: IrExpr & { kind: "nullish" }): void { + expectType(e.right, e.type, "nullish right operand"); + // The ISLAND form: `a ?? b` over an engine value — left, right, + // and result are all handles (the emitters' jsval nullish arm). + if (e.left.type.kind === "jsval") { + if (e.type.kind !== "jsval") err("jsval nullish must answer jsval", e.loc); + return; + } + // The CHECKED-DYNAMIC form: the runtime kind decides (the + // emitters' scr_dyn_is_nullish arm) — left, right, and result + // all live in the checked-dynamic tree. + if (e.left.type.kind === "dyn") { + if (e.type.kind !== "dyn") err("dyn nullish must answer dyn", e.loc); + return; + } + if (e.left.type.kind !== "union") { + err(`nullish left must be a union, got ${e.left.type.kind}`, e.loc); + return; + } + const def = unions.get(e.left.type.unionId); + if (!def) { + err(`nullish left references unknown union ${e.left.type.unionId}`, e.loc); + return; + } + if (!def.arms.some(isUnitType)) { + err("nullish left union has no unit arm (frontend must fence)", e.loc); + } + // Two shapes: pass-through (type === left's union) or narrowed + // (type === the union's SINGLE non-unit arm). + if (!typeEquals(e.type, e.left.type)) { + const rest = def.arms.filter((a) => !isUnitType(a)); + if (rest.length !== 1 || !typeEquals(e.type, rest[0]!)) { + err("nullish type must be the left union or its single non-unit arm", e.loc); + } + } + } + + function checkNullishChain(e: IrExpr & { kind: "nullish" }): void { + // Preserve the recursive validator's left/right/parent diagnostic order + // without keeping its large native expression frame for every operand. + const parents: (IrExpr & { kind: "nullish" })[] = []; + let left: IrExpr = e; + while (left.kind === "nullish") { + parents.push(left); + left = left.left; + } + checkExpr(left); + for (let i = parents.length - 1; i >= 0; i--) { + const parent = parents[i]!; + checkExpr(parent.right); + checkNullishOperands(parent); + } + } + function checkExpr(e: IrExpr): void { switch (e.kind) { case "numLit": @@ -2527,45 +2581,9 @@ function validateFunction( expectType(e, bound, "chainRecv"); break; } - case "nullish": { - checkExpr(e.left); - checkExpr(e.right); - expectType(e.right, e.type, "nullish right operand"); - // The ISLAND form: `a ?? b` over an engine value — left, right, - // and result are all handles (the emitters' jsval nullish arm). - if (e.left.type.kind === "jsval") { - if (e.type.kind !== "jsval") err("jsval nullish must answer jsval", e.loc); - break; - } - // The CHECKED-DYNAMIC form: the runtime kind decides (the - // emitters' scr_dyn_is_nullish arm) — left, right, and result - // all live in the checked-dynamic tree. - if (e.left.type.kind === "dyn") { - if (e.type.kind !== "dyn") err("dyn nullish must answer dyn", e.loc); - break; - } - if (e.left.type.kind !== "union") { - err(`nullish left must be a union, got ${e.left.type.kind}`, e.loc); - break; - } - const def = unions.get(e.left.type.unionId); - if (!def) { - err(`nullish left references unknown union ${e.left.type.unionId}`, e.loc); - break; - } - if (!def.arms.some(isUnitType)) { - err("nullish left union has no unit arm (frontend must fence)", e.loc); - } - // Two shapes: pass-through (type === left's union) or narrowed - // (type === the union's SINGLE non-unit arm). - if (!typeEquals(e.type, e.left.type)) { - const rest = def.arms.filter((a) => !isUnitType(a)); - if (rest.length !== 1 || !typeEquals(e.type, rest[0]!)) { - err("nullish type must be the left union or its single non-unit arm", e.loc); - } - } + case "nullish": + checkNullishChain(e); break; - } case "orDefault": { checkExpr(e.left); checkExpr(e.right); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 54482b0d..68380998 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11427,6 +11427,42 @@ "/tests/corpus/callable-module-members.mjs" ], "diags": [] + }, + "/tests/corpus/array-refined-storage.ts": { + "order": [ + "/tests/corpus/array-refined-storage.ts" + ], + "diags": [] + }, + "/tests/corpus/class-array-optional-return.ts": { + "order": [ + "/tests/corpus/class-array-optional-return.ts" + ], + "diags": [] + }, + "/tests/corpus/contextual-array-union-write.ts": { + "order": [ + "/tests/corpus/contextual-array-union-write.ts" + ], + "diags": [] + }, + "/tests/corpus/nullish-long-chain.ts": { + "order": [ + "/tests/corpus/nullish-long-chain.ts" + ], + "diags": [] + }, + "/tests/corpus/tuple-array-union.ts": { + "order": [ + "/tests/corpus/tuple-array-union.ts" + ], + "diags": [] + }, + "/tests/corpus/typed-rest-filtered-return.ts": { + "order": [ + "/tests/corpus/typed-rest-filtered-return.ts" + ], + "diags": [] } } } diff --git a/packages/compiler/tsconfig.json b/packages/compiler/tsconfig.json index 704d670f..9ae67cc8 100644 --- a/packages/compiler/tsconfig.json +++ b/packages/compiler/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "verbatimModuleSyntax": true, "outDir": "dist", "rootDir": "src", "tsBuildInfoFile": "../../node_modules/.cache/scriptc-tsc/compiler.tsbuildinfo" diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 994d9567..23a6661e 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -152,7 +152,9 @@ void scr_init(void) { /* ONE formatter for both console streams — console.error/warn print * byte-identically to console.log in Node (same inspect rendering), only * the stream differs. */ -bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len); +/* A real definition lets relocatable library links localize this hook; + * a tentative common symbol stays externally visible on Mach-O. */ +bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len) = NULL; static void scr_console_write(FILE *out, size_t n, const ScrLogArg *args) { if (scr_stdio_write_hook) { diff --git a/packages/runtime/test/array.test.ts b/packages/runtime/test/array.test.ts index 43bcfd1f..0a86655d 100644 --- a/packages/runtime/test/array.test.ts +++ b/packages/runtime/test/array.test.ts @@ -21,6 +21,7 @@ beforeAll(async () => { join(testDir, "../src/scr_array.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), + join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_closure.c"), join(testDir, "../src/scr_string.c"), diff --git a/packages/runtime/test/path.test.ts b/packages/runtime/test/path.test.ts index 92d27b76..49a411c0 100644 --- a/packages/runtime/test/path.test.ts +++ b/packages/runtime/test/path.test.ts @@ -26,6 +26,7 @@ beforeAll(async () => { join(testDir, "../src/scr_array.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), + join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_closure.c"), join(testDir, "../src/scr_bytes.c"), diff --git a/packages/runtime/test/string.test.ts b/packages/runtime/test/string.test.ts index b6e09f43..70ce663c 100644 --- a/packages/runtime/test/string.test.ts +++ b/packages/runtime/test/string.test.ts @@ -26,6 +26,7 @@ beforeAll(async () => { join(testDir, "../src/scr_array.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), + join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_closure.c"), join(testDir, "../src/scr_bytes.c"), diff --git a/packages/runtime/test/tonumber.test.ts b/packages/runtime/test/tonumber.test.ts index 7eb8a57d..6f891a1e 100644 --- a/packages/runtime/test/tonumber.test.ts +++ b/packages/runtime/test/tonumber.test.ts @@ -34,6 +34,7 @@ test("scr_string_to_number matches Node Number(s) on committed oracle cases", as join(testDir, "../src/scr_array.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), + join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_closure.c"), join(testDir, "../src/scr_bytes.c"), diff --git a/packages/runtime/test/url.test.ts b/packages/runtime/test/url.test.ts index af73097c..64f8e722 100644 --- a/packages/runtime/test/url.test.ts +++ b/packages/runtime/test/url.test.ts @@ -25,6 +25,7 @@ beforeAll(async () => { join(testDir, "../src/scr_array.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), + join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_closure.c"), join(testDir, "../src/scr_bytes.c"), diff --git a/tests/corpus/array-refined-storage.ts b/tests/corpus/array-refined-storage.ts new file mode 100644 index 00000000..5eff3fbc --- /dev/null +++ b/tests/corpus/array-refined-storage.ts @@ -0,0 +1,28 @@ +type Item = { kind: "value"; value: number } | { kind: "empty" }; + +function visit(items: Item[] | undefined): void { + if (!items || !items.every((item) => item.kind === "value")) return; + console.log(items.every((item) => item.value > 0)); + items.forEach((item, index) => console.log(index, item.value)); + console.log(items.flatMap((item) => [item.value, item.value + 1]).join(",")); + console.log(items.map((item, index, source) => { + console.log("same", source === items); + source[index]!.value += 1; + return item.value; + }).join(",")); + console.log(items.some((item) => item.value > 5)); + console.log(items.find((item) => item.value > 5)?.value ?? 0); + console.log(items.filter((item) => item.value > 5).map((item) => item.value).join(",")); +} +visit([{ kind: "value", value: 3 }, { kind: "value", value: 7 }]); +visit([{ kind: "empty" }]); +visit(undefined); + +function labels(values: (string | null)[] | undefined): void { + if (!values || !values.every((value): value is string => value !== null)) return; + values.forEach((value, index) => console.log(index, value)); + console.log(values.flatMap((value) => [value, value + "!"]).join(",")); +} +labels(["one", "two"]); +labels([null]); +labels(undefined); diff --git a/tests/corpus/class-array-optional-return.ts b/tests/corpus/class-array-optional-return.ts new file mode 100644 index 00000000..06969552 --- /dev/null +++ b/tests/corpus/class-array-optional-return.ts @@ -0,0 +1,15 @@ +// Optional return propagation updates the class's original method signature +// through its prepass registry, including forwarded calls and callbacks. +class Values { + read(values: number[], index: number): number { return values[index]; } + forward(values: number[], index: number): number { return this.read(values, index); } + text(values: string[]): string { return values[2]; } +} +const values = new Values(); +for (const index of [0, 1, 2]) { + console.log(values.read([42], index)); + console.log(values.forward([42], index)); +} +console.log(values.text(["one"])); +console.log(values.text(["one", "two", "three"])); +console.log([0, 1, 2].map((index) => values.forward([7], index)).join("|")); diff --git a/tests/corpus/contextual-array-union-write.ts b/tests/corpus/contextual-array-union-write.ts new file mode 100644 index 00000000..f8659c9b --- /dev/null +++ b/tests/corpus/contextual-array-union-write.ts @@ -0,0 +1,52 @@ +import type { IrExpr, IrType, SrcLoc } from "../../packages/compiler/src/ir/ir.js"; + +type Expr = { kind: "number"; value: number } | { kind: "sum"; values: Expr[] }; + +function valueOf(expr: Expr): number { + return expr.kind === "number" ? expr.value : expr.values.reduce((sum, value) => sum + valueOf(value), 0); +} + +const values: Expr[] = [{ kind: "number", value: 1 }]; +values.forEach((value, index) => { + values[index] = { kind: "sum", values: [value, { kind: "number", value: 2 }] }; +}); +console.log(valueOf(values[0]!)); + +const combined = [2, 3, 4].reduce((left, right) => ({ kind: "sum", values: [left, { kind: "number", value: right }] }), { kind: "number", value: 1 }); +console.log(valueOf(combined)); +const reversed = [2, 3, 4].reduceRight((left, right) => ({ kind: "sum", values: [left, { kind: "number", value: right }] }), { kind: "number", value: 1 }); +console.log(valueOf(reversed)); + +// The frontend's recursive expression union has nested type and location +// fields. Construct values directly in that destination, including a +// callback parameter narrowed by one of its nested discriminants. +const location: SrcLoc = { file: "fixture.ts", start: 0, end: 1 }; +const booleanType: IrType = { kind: "bool" }; +const jsType: IrType = { kind: "jsval" }; +const dynType: IrType = { kind: "dyn" }; +const expressions: IrExpr[] = [ + { kind: "boolLit", value: true, type: booleanType, loc: location }, + { kind: "jsOp", op: "undefLit", args: [], type: jsType, loc: location }, +]; +expressions.forEach((value, index) => { + if (value.type.kind === "jsval") { + expressions[index] = { kind: "dynFromJsval", value, type: dynType, loc: value.loc }; + } else { + expressions[index] = { kind: "jsMarshal", value, type: jsType, loc: value.loc }; + } +}); +console.log(expressions.map((value) => value.kind).join(",")); +const initial: IrExpr = { kind: "boolLit", value: true, type: booleanType, loc: location }; +const logical = expressions.reduce((left, right) => ({ + kind: "logical", op: "&&", left, right, type: booleanType, loc: location, +}), initial); +console.log(logical.kind, logical.kind === "logical" && logical.left.kind); +const getters = [{ name: "one", fn: initial }, { name: "two", fn: initial }]; +const withGetters = getters.reduceRight((value, getter) => ({ + kind: "jsOp", op: "defineGetter", args: [ + value, + { kind: "jsMarshal", value: { kind: "strLit", value: getter.name, type: { kind: "string" }, loc: location }, type: jsType, loc: location }, + getter.fn, + ], type: jsType, loc: location, +}), initial); +console.log(withGetters.kind, withGetters.kind === "jsOp" && withGetters.args.length); diff --git a/tests/corpus/nullish-long-chain.ts b/tests/corpus/nullish-long-chain.ts new file mode 100644 index 00000000..54d0a781 --- /dev/null +++ b/tests/corpus/nullish-long-chain.ts @@ -0,0 +1,51 @@ +// Compiler dispatchers often use long left-associated chains. Keep every +// operand observable so compilation must preserve order and short-circuiting. +function dispatch(winner: number): number { + const calls: number[] = []; + function candidate(index: number): number | null | undefined { + calls.push(index); + return index === winner ? 0 : index % 2 === 0 ? null : undefined; + } + const result = + candidate(0) ?? candidate(1) ?? candidate(2) ?? candidate(3) ?? + candidate(4) ?? candidate(5) ?? candidate(6) ?? candidate(7) ?? + candidate(8) ?? candidate(9) ?? candidate(10) ?? candidate(11) ?? + candidate(12) ?? candidate(13) ?? candidate(14) ?? candidate(15) ?? + candidate(16) ?? candidate(17) ?? candidate(18) ?? candidate(19) ?? + candidate(20) ?? candidate(21) ?? candidate(22) ?? candidate(23) ?? + candidate(24) ?? candidate(25) ?? candidate(26) ?? candidate(27) ?? + candidate(28) ?? candidate(29) ?? candidate(30) ?? candidate(31) ?? + candidate(32) ?? candidate(33) ?? candidate(34) ?? candidate(35) ?? + candidate(36) ?? candidate(37) ?? candidate(38) ?? candidate(39) ?? + candidate(40) ?? candidate(41) ?? candidate(42) ?? candidate(43) ?? + candidate(44) ?? candidate(45) ?? candidate(46) ?? candidate(47) ?? + candidate(48) ?? candidate(49) ?? candidate(50) ?? candidate(51) ?? + candidate(52) ?? candidate(53) ?? candidate(54) ?? candidate(55) ?? + candidate(56) ?? candidate(57) ?? candidate(58) ?? candidate(59) ?? + candidate(60) ?? candidate(61) ?? candidate(62) ?? candidate(63) ?? + candidate(64) ?? candidate(65) ?? candidate(66) ?? candidate(67) ?? + candidate(68) ?? candidate(69) ?? candidate(70) ?? candidate(71) ?? + candidate(72) ?? candidate(73) ?? candidate(74) ?? candidate(75) ?? + candidate(76) ?? candidate(77) ?? candidate(78) ?? candidate(79) ?? + candidate(80) ?? candidate(81) ?? candidate(82) ?? candidate(83) ?? + candidate(84) ?? candidate(85) ?? candidate(86) ?? candidate(87) ?? + candidate(88) ?? candidate(89) ?? candidate(90) ?? candidate(91) ?? + candidate(92) ?? candidate(93) ?? candidate(94) ?? candidate(95) ?? -1; + console.log(winner, result, calls.join(",")); + return result; +} +dispatch(0); +dispatch(47); +dispatch(95); +dispatch(96); + +let sequence = ""; +function text(label: string, value: string | null | undefined): string | null | undefined { + sequence += label; + return value; +} +console.log(text("a", null) ?? (text("b", undefined) ?? text("c", "")) ?? text("d", "unused")); +console.log(sequence); +sequence = ""; +console.log((text("a", undefined) ?? text("b", null)) ?? text("c", "end") ?? text("d", "unused")); +console.log(sequence); diff --git a/tests/corpus/tuple-array-union.ts b/tests/corpus/tuple-array-union.ts new file mode 100644 index 00000000..7f6f9aef --- /dev/null +++ b/tests/corpus/tuple-array-union.ts @@ -0,0 +1,36 @@ +type Priority = readonly number[] | { rank: readonly number[] }; + +function describe(priority: Priority): string { + return Array.isArray(priority) ? priority.join(":") : (priority as { rank: readonly number[] }).rank.join(":"); +} + +function withPriority(priority: Priority, callback: () => T): T { + console.log(describe(priority)); + return callback(); +} + +["first", "second"].forEach((name, index) => { + const priority = [2, index] as const; + console.log(withPriority(priority, () => name)); +}); +const direct: Priority = [3, 4] as const; +console.log(describe(direct)); +console.log(describe({ rank: [5, 6] })); + +type Mixed = readonly (number | string)[] | { label: string }; +function showMixed(value: Mixed): void { + console.log(Array.isArray(value) ? value.join(",") : (value as { label: string }).label); +} +const mixed = [7, "eight"] as const; +showMixed(mixed); +showMixed({ label: "record" }); + +type Rows = readonly { value: number }[] | { count: number } | undefined; +function showRows(rows: Rows): void { + if (rows === undefined) { console.log("undefined"); return; } + console.log(Array.isArray(rows) ? rows.map((row): number => row.value).join(",") : (rows as { count: number }).count); +} +const rows = [{ value: 9 }, { value: 10 }] as const; +showRows(rows); +showRows({ count: 11 }); +showRows(undefined); diff --git a/tests/corpus/typed-rest-filtered-return.ts b/tests/corpus/typed-rest-filtered-return.ts new file mode 100644 index 00000000..f53fa611 --- /dev/null +++ b/tests/corpus/typed-rest-filtered-return.ts @@ -0,0 +1,14 @@ +class Value { + readonly value: number; + constructor(value: number) { + this.value = value; + } +} + +const collect = (...values: (Value | undefined | null)[]): Value[] => + values.filter((value) => value !== undefined && value !== null); +const first = new Value(1); +const second = new Value(2); +const result = collect(undefined, first, null, second); +console.log(result.map((value) => value.value).join(","), result[0] === first, result[1] === second); +console.log(collect().length); diff --git a/tests/diagnostics/node-fallback-fence.ts b/tests/diagnostics/node-fallback-fence.ts index 67d3bfd2..85a49e37 100644 --- a/tests/diagnostics/node-fallback-fence.ts +++ b/tests/diagnostics/node-fallback-fence.ts @@ -13,7 +13,7 @@ process.stdin.destroy(); process.on("SIGINT", () => {}); process.once("exit", () => {}); process.off("exit", () => {}); -process.stdin.setEncoding("utf8"); // stays fenced: chunks are bytes +process.stdin.setEncoding("utf8"); // terminal streams accept a text encoding const buf = Buffer.from("x", "utf8").reverse(); // from and fill lower; reverse fences const enc = new TextEncoder().encode("x"); const dec = new TextDecoder().decode(enc); diff --git a/tests/fixtures/npm-static/escape-barrel.ts b/tests/fixtures/npm-static/escape-barrel.ts new file mode 100644 index 00000000..09a3a02f --- /dev/null +++ b/tests/fixtures/npm-static/escape-barrel.ts @@ -0,0 +1 @@ +export { default as escapeStringRegexp } from "escape-string-regexp"; diff --git a/tests/fixtures/self-hosting/ffi-profile.ts b/tests/fixtures/self-hosting/ffi-profile.ts new file mode 100644 index 00000000..bee7394e --- /dev/null +++ b/tests/fixtures/self-hosting/ffi-profile.ts @@ -0,0 +1,7 @@ +import { loadFfiProfile } from "../../../packages/compiler/src/ffi/ffi-manifest.js"; + +const result = loadFfiProfile(process.argv[2]!); +// Compare byte contents independently of Node's Buffer.toJSON wrapper. +console.log(JSON.stringify(result.ok + ? { ok: true, profile: result.profile, profileBytes: Array.from(result.profileBytes) } + : result)); diff --git a/tests/fixtures/self-hosting/frontend-lowering-node.ts b/tests/fixtures/self-hosting/frontend-lowering-node.ts new file mode 100644 index 00000000..4e69d536 --- /dev/null +++ b/tests/fixtures/self-hosting/frontend-lowering-node.ts @@ -0,0 +1,24 @@ +import { writeFileSync } from "node:fs"; +import { loadProgram, checkPreflight } from "../../../packages/compiler/src/frontend/program-node.js"; +import { lowerToIr } from "../../../packages/compiler/src/frontend/lowering/lowerer.js"; +import { serializeModule } from "../../../packages/compiler/src/ir/serialize.js"; +import { loadFfiProfile, type FfiFunction } from "../../../packages/compiler/src/ffi/ffi-manifest.js"; + +let ffiImports: FfiFunction[] = []; +if (process.argv[5]) { + const profile = loadFfiProfile(process.argv[5]); + if (!profile.ok) throw new Error(JSON.stringify(profile.diagnostics)); + ffiImports = profile.profile.functions; +} +const load = loadProgram(process.argv[3]!); +try { + const diagnostics = checkPreflight(load); + if (load.startupCrash) throw new Error(load.startupCrash.message); + console.log(diagnostics.length); + if (diagnostics.length === 0) { + const result = lowerToIr(load.program, load.entry, load.moduleOrder, { dynamic: false, frontendServices: load.services, ffiImports }); + console.log(result.stats.statementsFailed, result.stats.statementsIsland); + for (const diagnostic of result.diagnostics) console.error(diagnostic.code, diagnostic.message); + if (result.module && process.argv[4]) writeFileSync(process.argv[4], serializeModule(result.module)); + } +} finally { load.dispose(); } diff --git a/tests/fixtures/self-hosting/frontend-lowering.ts b/tests/fixtures/self-hosting/frontend-lowering.ts index 676b7c06..0f0bfcfa 100644 --- a/tests/fixtures/self-hosting/frontend-lowering.ts +++ b/tests/fixtures/self-hosting/frontend-lowering.ts @@ -1,20 +1,32 @@ +import { writeFileSync } from "node:fs"; import { loadProgram, checkPreflight } from "../../../packages/compiler/src/frontend/program.js"; import { FrontendServices } from "../../../packages/compiler/src/frontend/services.js"; import { createNativeTs7Api } from "../../../packages/compiler/src/frontend/ts7/native-api.js"; import { lowerToIr } from "../../../packages/compiler/src/frontend/lowering/lowerer.js"; +import { serializeModule } from "../../../packages/compiler/src/ir/serialize.js"; +import { loadFfiProfile, type FfiFunction } from "../../../packages/compiler/src/ffi/ffi-manifest.js"; // Reach the complete production frontend through the native TS7 client. // Keep the call and ordinary error paths in this entry so an audit cannot // accidentally measure only parsing, type mapping, or an isolated helper. const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable: process.argv[2]! })); try { + let ffiImports: FfiFunction[] = []; + if (process.argv[5]) { + const profile = loadFfiProfile(process.argv[5]); + if (!profile.ok) throw new Error(JSON.stringify(profile.diagnostics)); + ffiImports = profile.profile.functions; + } const load = loadProgram(process.argv[3]!, services); try { const diagnostics = checkPreflight(load); + if (load.startupCrash) throw new Error(load.startupCrash.message); console.log(diagnostics.length); if (diagnostics.length === 0) { - const result = lowerToIr(load.program, load.entry, load.moduleOrder, { dynamic: false, frontendServices: services }); + const result = lowerToIr(load.program, load.entry, load.moduleOrder, { dynamic: false, frontendServices: services, ffiImports }); console.log(result.stats.statementsFailed, result.stats.statementsIsland); + for (const diagnostic of result.diagnostics) console.error(diagnostic.code, diagnostic.message); + if (result.module && process.argv[4]) writeFileSync(process.argv[4], serializeModule(result.module)); } } finally { load.dispose(); } } finally { services.close(); } diff --git a/tests/fixtures/self-hosting/frontend-pipeline-node.ts b/tests/fixtures/self-hosting/frontend-pipeline-node.ts new file mode 100644 index 00000000..683caf4e --- /dev/null +++ b/tests/fixtures/self-hosting/frontend-pipeline-node.ts @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { runFrontend } from "../../../packages/compiler/src/frontend/pipeline.js"; +import { loadProgram } from "../../../packages/compiler/src/frontend/program-node.js"; +import { loadFfiProfile, type FfiFunction } from "../../../packages/compiler/src/ffi/ffi-manifest.js"; +import { serializeModule } from "../../../packages/compiler/src/ir/serialize.js"; +import { validateModule } from "../../../packages/compiler/src/ir/validate.js"; + +interface Request { + entry: string; + npmStatic?: string[] | "auto" | "lib"; + externalTypes?: Record; + ffiProfile?: string; + contract?: boolean; +} +const request = JSON.parse(readFileSync(process.argv[3]!, "utf8")) as Request; +let ffiImports: FfiFunction[] = []; +if (request.ffiProfile) { + const profile = loadFfiProfile(request.ffiProfile); + if (!profile.ok) throw new Error(JSON.stringify(profile.diagnostics)); + ffiImports = profile.profile.functions; +} +const frontend = runFrontend(request.entry, loadProgram, request.npmStatic, request.externalTypes); +try { + console.log(JSON.stringify({ preflight: frontend.preflight, npmStatic: frontend.npmStatic })); + if (frontend.preflight.length === 0) { + if (request.contract) console.log(JSON.stringify(frontend.entryContract())); + const result = frontend.lower({ dynamic: false, ffiImports }); + console.log(JSON.stringify({ stats: result.stats, diagnostics: result.diagnostics, + validation: result.module === null ? null : validateModule(result.module) })); + if (result.module && process.argv[4]) writeFileSync(process.argv[4], serializeModule(result.module)); + } +} finally { frontend.dispose(); } diff --git a/tests/fixtures/self-hosting/frontend-pipeline.ts b/tests/fixtures/self-hosting/frontend-pipeline.ts new file mode 100644 index 00000000..f316dfd4 --- /dev/null +++ b/tests/fixtures/self-hosting/frontend-pipeline.ts @@ -0,0 +1,31 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { runNativeFrontend } from "../../../packages/compiler/src/frontend/pipeline-native.js"; +import { loadFfiProfile, type FfiFunction } from "../../../packages/compiler/src/ffi/ffi-manifest.js"; +import { serializeModule } from "../../../packages/compiler/src/ir/serialize.js"; +import { validateModule } from "../../../packages/compiler/src/ir/validate.js"; + +interface Request { + entry: string; + npmStatic?: string[] | "auto" | "lib"; + externalTypes?: Record; + ffiProfile?: string; + contract?: boolean; +} +const request = JSON.parse(readFileSync(process.argv[3]!, "utf8")) as Request; +let ffiImports: FfiFunction[] = []; +if (request.ffiProfile) { + const profile = loadFfiProfile(request.ffiProfile); + if (!profile.ok) throw new Error(JSON.stringify(profile.diagnostics)); + ffiImports = profile.profile.functions; +} +const frontend = runNativeFrontend(request.entry, process.argv[2]!, request.npmStatic, request.externalTypes); +try { + console.log(JSON.stringify({ preflight: frontend.preflight, npmStatic: frontend.npmStatic })); + if (frontend.preflight.length === 0) { + if (request.contract) console.log(JSON.stringify(frontend.entryContract())); + const result = frontend.lower({ dynamic: false, ffiImports }); + console.log(JSON.stringify({ stats: result.stats, diagnostics: result.diagnostics, + validation: result.module === null ? null : validateModule(result.module) })); + if (result.module && process.argv[4]) writeFileSync(process.argv[4], serializeModule(result.module)); + } +} finally { frontend.dispose(); } diff --git a/tests/harness/__snapshots__/node-fallback-fence.ts.txt b/tests/harness/__snapshots__/node-fallback-fence.ts.txt index 20095e04..725fa215 100644 --- a/tests/harness/__snapshots__/node-fallback-fence.ts.txt +++ b/tests/harness/__snapshots__/node-fallback-fence.ts.txt @@ -1,15 +1,6 @@ -node-fallback-fence.ts:16:1 - error SC2020: 'process.stdin.setEncoding' is part of the standard library types but has no scriptc lowering yet - - 15 | process.off("exit", () => {}); - 16 | process.stdin.setEncoding("utf8"); // stays fenced: chunks are bytes - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 17 | const buf = Buffer.from("x", "utf8").reverse(); // from and fill lower; reverse fences - - hint: isTTY, destroy(), on/once of the data/end/error events, and `for await (const chunk of process.stdin)` are the supported stdin surface (or read everything at once: readFileSync(0, "utf8") from node:fs) - node-fallback-fence.ts:17:13 - error SC2020: 'Buffer.reverse' is part of the standard library types but has no scriptc lowering yet - 16 | process.stdin.setEncoding("utf8"); // stays fenced: chunks are bytes + 16 | process.stdin.setEncoding("utf8"); // terminal streams accept a text encoding 17 | const buf = Buffer.from("x", "utf8").reverse(); // from and fill lower; reverse fences | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 18 | const enc = new TextEncoder().encode("x"); diff --git a/tests/harness/self-hosting-bootstrap.test.ts b/tests/harness/self-hosting-bootstrap.test.ts new file mode 100644 index 00000000..a8389337 --- /dev/null +++ b/tests/harness/self-hosting-bootstrap.test.ts @@ -0,0 +1,128 @@ +import { execFile, execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { isDeepStrictEqual, promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compileC, deserializeModule, emitCModule, validateModule } from "@scriptc/compiler"; +import type { compile } from "@scriptc/compiler"; +import * as ir from "../../packages/compiler/src/ir/ir.js"; +import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js"; + +const root = join(import.meta.dirname, "../.."); +const execFileAsync = promisify(execFile); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const options = { cwd: root, timeout: 1_200_000, maxBuffer: 16 * 1024 * 1024 }; + +function nativeFeatures(module: ir.IrModule) { + return { + regex: ir.moduleUsesRegex(module), copying: ir.moduleUsesCopying(module), + inspect: ir.moduleUsesInspect(module), dynInvoke: ir.moduleUsesDynInvoke(module), + symbol: ir.moduleUsesSymbol(module), bigint: ir.moduleUsesBigInt(module), + zlib: ir.moduleUsesZlib(module), assert: ir.moduleUsesAssert(module), + textDecoderLegacy: ir.moduleUsesLegacyTextDecoder(module), fileHandle: ir.moduleUsesFileHandle(module), + fetch: ir.moduleUsesFetch(module), dc: ir.moduleUsesDc(module), dynAsync: ir.moduleUsesDynAsync(module), + events: ir.moduleUsesProcessEvents(module), emitter: ir.moduleUsesEmitter(module), + searchParams: ir.moduleUsesSearchParams(module), qs: ir.moduleUsesQs(module), parseArgs: ir.moduleUsesParseArgs(module), + stream: ir.moduleUsesStream(module), net: ir.moduleUsesNet(module), http: ir.moduleUsesHttpServer(module), + http2: ir.moduleUsesHttp2(module), dgram: ir.moduleUsesDgram(module), watch: ir.moduleUsesFsWatch(module), + nodeTest: ir.moduleUsesNodeTest(module), tls: ir.moduleUsesTls(module), tlsCa: ir.moduleUsesTlsCa(module), + }; +} + +test("the native frontend and C emitter rebuild a working frontend from its TypeScript source", async () => { + const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-bootstrap-")); + const executable = (name: string) => join(directory, name + (process.platform === "win32" ? ".exe" : "")); + const frontend = join(root, "tests/fixtures/self-hosting/frontend-lowering.ts"); + const emitter = join(root, "tests/fixtures/self-hosting/c-emitter.ts"); + const nativeOptions = { ...options, env: { ...process.env, PATH: "" } }; + try { + const nativeSources = join(root, "packages/compiler/native"); + const object = join(directory, "ts7-process.o"); + execFileSync("clang", ["-std=c11", "-Wall", "-Wextra", "-Werror", ...(sanitize ? ["-fsanitize=address"] : []), + "-c", join(nativeSources, "ts7-process.c"), "-o", object]); + const profile = join(directory, "ffi.json"); + writeFileSync(profile, JSON.stringify({ + ...JSON.parse(readFileSync(join(nativeSources, "ts7-process.ffi.json"), "utf8")), libraries: [object], + })); + + const buildSeed = async (entry: string, name: string, ffi?: string) => { + const api = pathToFileURL(join(root, "packages/compiler/src/index.ts")).href; + const built = await execFileAsync(process.execPath, [ + "--import", "tsx", "--input-type=module", "--eval", + `import { compile } from ${JSON.stringify(api)}; + const result = await compile(process.argv[1], { + outDir: process.argv[2], outPath: process.argv[3], backend: 'c', + dynamic: false, optimization: 'dev', sanitize: process.argv[4] === '1', + ffiProfilePath: process.argv[5] || undefined, emitIr: true, + }); + console.log(JSON.stringify({ ...result, sourceTexts: undefined }));`, + entry, directory, executable(name), sanitize ? "1" : "0", ffi ?? "", + ], options); + expect(built.stderr).toBe(""); + const result = JSON.parse(built.stdout) as Awaited>; + if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + if (!("binaryPath" in result)) throw new Error("expected a native frontend executable"); + expect(result.backend).toBe("c"); + expect(result.irPath).toBeDefined(); + return result; + }; + const seed = await buildSeed(frontend, "frontend-seed", profile); + const nativeEmitter = await buildSeed(emitter, "emitter"); + const ownIr = join(directory, "frontend-native.json"); + const self = await execFileAsync(seed.binaryPath, [ts7Executable(), frontend, ownIr, profile], nativeOptions); + expect(self.stdout).toBe("0\n0 0\n"); + expect(self.stderr).toBe(""); + + // Compare complete IR, including layouts and helper signatures. A valid + // module alone can hide different optional-return inference or dropped + // method-registry mutations in the native compiler. + const module = deserializeModule(readFileSync(ownIr, "utf8")); + expect(module.functions.length).toBeGreaterThan(1_000); + expect(validateModule(module)).toEqual([]); + expect(isDeepStrictEqual(module, deserializeModule(readFileSync(seed.irPath!, "utf8"))), "native self-lowering must match the Node seed").toBe(true); + + const cPath = join(directory, "frontend-native.c"); + const emitted = await execFileAsync(nativeEmitter.binaryPath, [ownIr, cPath], nativeOptions); + expect(emitted.stdout).toBe(""); + expect(emitted.stderr).toBe(""); + // Avoid rendering hundreds of megabytes in an assertion failure. + expect(readFileSync(cPath, "utf8") === emitCModule(module, undefined, { debugSources: new Map() }), "native C emission must match Node byte-for-byte").toBe(true); + const rebuilt = executable("frontend-rebuilt"); + await compileC({ cPath, outPath: rebuilt, optimization: "dev", sanitize, linkInputs: [object], ...nativeFeatures(module) }); + + // The rebuilt compiler consumes new source, and its emitted program + // executes. These inputs exercise the two bugs found by self-compiling: + // long nullish dispatch and optional class-method return promotion. + for (const name of ["nullish-long-chain", "class-array-optional-return"]) { + const source = join(root, "tests/corpus", name + ".ts"); + const actualIr = join(directory, name + "-native.json"); + const expectedIr = join(directory, name + "-node.json"); + const expected = await execFileAsync(process.execPath, ["--import", "tsx", + join(root, "tests/fixtures/self-hosting/frontend-lowering-node.ts"), ts7Executable(), source, expectedIr], options); + const actual = await execFileAsync(rebuilt, [ts7Executable(), source, actualIr], nativeOptions); + expect(expected.stdout).toBe("0\n0 0\n"); + expect(actual.stdout).toBe(expected.stdout); + expect(expected.stderr).toBe(""); + expect(actual.stderr).toBe(""); + const program = deserializeModule(readFileSync(actualIr, "utf8")); + expect(program).toEqual(deserializeModule(readFileSync(expectedIr, "utf8"))); + expect(validateModule(program)).toEqual([]); + const programC = join(directory, "program.c"); + const emission = await execFileAsync(nativeEmitter.binaryPath, [actualIr, programC], nativeOptions); + expect(emission.stdout).toBe(""); + expect(emission.stderr).toBe(""); + await compileC({ cPath: programC, outPath: executable("program"), sanitize, ...nativeFeatures(program) }); + const oracle = spawnSync(process.execPath, [source], options); + const native = spawnSync(executable("program"), [], options); + for (const result of [oracle, native]) { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, result.stderr.toString()).toBe(0); + } + expect(native.stdout).toEqual(oracle.stdout); + expect(native.stderr).toEqual(oracle.stderr); + } + } finally { rmSync(directory, { recursive: true, force: true }); } +}, 2_400_000); diff --git a/tests/harness/self-hosting-ffi-profile.test.ts b/tests/harness/self-hosting-ffi-profile.test.ts new file mode 100644 index 00000000..4927a17d --- /dev/null +++ b/tests/harness/self-hosting-ffi-profile.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { analyze, compile } from "@scriptc/compiler"; +import type { FfiProfileLoadResult } from "../../packages/compiler/src/ffi/ffi-manifest.js"; + +const root = join(import.meta.dirname, "../.."); +const entry = join(root, "tests/fixtures/self-hosting/ffi-profile.ts"); +const nativeProfile = join(root, "packages/compiler/native/ts7-process.ffi.json"); +const fn = (name: string, params: unknown[], returns = "void") => ({ name, symbol: name, params, returns }); +const callback = (lifetime: string, params: unknown[], invoke = "script-thread", returns = "void") => ({ + callback: { id: "visit", params, returns, lifetime, invoke }, +}); +const context = { context: "visit" }; +const release = { callback: { release: "register:visit" } }; +const releaseContext = { context: "register:visit" }; +const profile = (format: number, functions: unknown[]) => ({ ffi_format: format, functions }); + +test("the production FFI profile parser lowers entirely statically", () => { + const { coverage } = analyze(entry, { dynamic: false }); + expect(coverage.preflightFailed, JSON.stringify(coverage.diagnostics)).toBe(false); + expect(coverage.stats.statementsFailed, JSON.stringify(coverage.diagnostics)).toBe(0); + expect(coverage.stats.statementsIsland).toBe(0); + expect(coverage.stats.functionsSkipped).toBe(0); +}); + +for (const backend of ["c", "llvm"] as const) { + test(`native FFI profile validation and release resolution (${backend})`, async () => { + const dir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-ffi-profile-")); + try { + const built = await compile(entry, { + outDir: dir, outPath: join(dir, process.platform === "win32" ? "profile.exe" : "profile"), + backend, dynamic: false, optimization: "dev", sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(built.backend).toBe(backend); + const file = join(dir, "input.json"); + const check = (name: string, input: string | undefined, error?: string): FfiProfileLoadResult => { + if (input !== undefined) writeFileSync(file, input); + const inputPath = input === undefined ? join(dir, "missing.json") : file; + const options = { cwd: root, encoding: "utf8" as const, timeout: 15_000, maxBuffer: 1024 * 1024 }; + const node = spawnSync(process.execPath, ["--import", "tsx", entry, inputPath], options); + const native = spawnSync(built.binaryPath, [inputPath], { ...options, env: { ...process.env, PATH: "" } }); + for (const result of [node, native]) { + expect(result.error, name).toBeUndefined(); + expect(result.signal, name + "\n" + result.stderr).toBeNull(); + expect(result.status, name + "\n" + result.stderr).toBe(0); + expect(result.stderr, name).toBe(""); + } + const result = JSON.parse(native.stdout) as FfiProfileLoadResult; + expect(result, name).toEqual(JSON.parse(node.stdout)); + expect(result.ok, name).toBe(error === undefined); + if (!result.ok) { + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.code).toBe("SC5001"); + expect(result.diagnostics[0]!.message, name).toContain(error); + } + return result; + }; + check("native TypeScript transport", readFileSync(nativeProfile, "utf8")); + for (const format of [1, 2, 3, 4, 5, 6]) { + check(`empty format ${format}`, JSON.stringify(profile(format, []))); + check(`scalar format ${format}`, JSON.stringify(profile(format, [fn("scalar", ["f64", "bool", "u8", "u32", "i32", "string", "bytes"], "i32")]))); + } + check("call callback", JSON.stringify(profile(2, [fn("call", [callback("call", ["f64", context]), context])]))); + check("copied callback inputs", JSON.stringify(profile(3, [fn("call", [callback("call", ["cstring", "string", "bytes"])])]))); + check("foreign retained callback", JSON.stringify(profile(5, [fn("register", [callback("retained", ["bytes", context], "foreign"), context])]))); + check("extended scalar classes", JSON.stringify(profile(6, [ + fn("scalars", ["f32", "i8", "u16", "i16", "mutable-bytes"], "f32"), + fn("call", [callback("call", ["f32", "i8", "u16", "i16"], "script-thread", "i16")]), + ]))); + + for (const withContext of [false, true]) { + const params = withContext ? ["f64", context, "bytes"] : ["f64", "bytes"]; + const register = fn("register", withContext ? [callback("retained", params), context] : [callback("retained", params)]); + const unregister = fn("unregister", withContext ? [release, releaseContext] : [release]); + for (const functions of [[register, unregister], [unregister, register]]) { + const result = check("release resolution", JSON.stringify(profile(4, functions))); + if (!result.ok) throw new Error("expected resolved release"); + expect(result.profile.functions.find((item) => item.name === "unregister")!.params[0]).toEqual({ + callback: { release: "register:visit", params, returns: "void" }, + }); + } + } + + const rejects: [string, unknown, string][] = [ + ["root array", [], "must be a JSON object"], + ["root null", null, "must be a JSON object"], + ["unknown format", profile(7, []), "unsupported ffi_format"], + ["unknown key", { ...profile(1, []), typo: true }, "unknown field 'typo'"], + ["invalid binding", profile(1, [fn("bad-name", [])]), "not a plain TypeScript identifier"], + ["duplicate binding", profile(1, [fn("same", []), fn("same", [])]), "declared twice"], + ["versioned scalar", profile(5, [fn("scalar", ["i8"])]), "requires ffi_format 6"], + ["versioned callback", profile(2, [fn("call", [callback("call", ["bytes"])])]), "requires ffi_format 3"], + ["invalid callback class", profile(2, [fn("call", [callback("call", ["bad"])])]), "must be one of"], + ["versioned retained callback", profile(3, [fn("register", [callback("retained", [])])]), "requires ffi_format 4"], + ["versioned release", profile(3, [fn("unregister", [release])]), "requires ffi_format 4"], + ["orphaned context", profile(2, [fn("call", [context])]), "has no matching callback"], + ["missing outer context", profile(2, [fn("call", [callback("call", [context])])]), "exactly once in both"], + ["duplicate context", profile(2, [fn("call", [callback("call", [context]), context, context])]), "appears 2 times"], + ["missing release target", profile(4, [fn("unregister", [release])]), "no matching retained callback"], + ["non-retained target", profile(4, [fn("unregister", [release]), fn("register", [callback("call", [])])]), "targets a non-retained callback"], + ["same call releases registration", profile(4, [fn("register", [callback("retained", []), release])]), "registered by the same call"], + ["release missing context", profile(4, [fn("register", [callback("retained", [context]), context]), fn("unregister", [release])]), "must declare its context"], + ["release extra context", profile(4, [fn("register", [callback("retained", [])]), fn("unregister", [release, releaseContext])]), "must not declare a context"], + ["foreign callback missing context", profile(5, [fn("register", [callback("retained", [], "foreign")])]), "requires a context entry"], + ["foreign callback returns value", profile(5, [fn("register", [callback("retained", [context], "foreign", "f64"), context])]), "requires returns 'void'"], + ["duplicate libraries", { ...profile(1, []), libraries: ["input.json", "input.json"] }, "duplicate path"], + ["directory link input", { ...profile(1, []), libraries: ["."] }, "does not name a file"], + ["missing link input", { ...profile(1, []), libraries: ["missing.a"] }, "cannot be read"], + ["library flag injection", { ...profile(1, []), system_libraries: ["-lwrong"] }, "not a library name"], + ]; + for (const [name, input, error] of rejects) check(name, JSON.stringify(input), error); + check("missing profile", undefined, "cannot read manifest"); + check("relative library paths", JSON.stringify({ ...profile(1, []), libraries: ["input.json"], system_libraries: ["m"] })); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +} diff --git a/tests/harness/self-hosting-frontend-lowering.test.ts b/tests/harness/self-hosting-frontend-lowering.test.ts index c17ffce5..c75383ee 100644 --- a/tests/harness/self-hosting-frontend-lowering.test.ts +++ b/tests/harness/self-hosting-frontend-lowering.test.ts @@ -13,22 +13,33 @@ test("the complete production frontend lowers with zero rejected statements", as // work cannot starve Vitest's worker RPC. Native execution of the complete // frontend is a separate bootstrap milestone. const frontend = pathToFileURL(join(root, "packages/compiler/src/frontend/")).href; + const validator = pathToFileURL(join(root, "packages/compiler/src/ir/validate.ts")).href; + const ffi = pathToFileURL(join(root, "packages/compiler/src/ffi/ffi-manifest.ts")).href; const { stdout, stderr } = await promisify(execFile)(process.execPath, [ "--import", "tsx", "--input-type=module", "--eval", `import { loadProgram, checkPreflight } from ${JSON.stringify(frontend + "program-node.ts")}; import { lowerToIr } from ${JSON.stringify(frontend + "lowering/lowerer.ts")}; + import { validateModule } from ${JSON.stringify(validator)}; + import { loadFfiProfile } from ${JSON.stringify(ffi)}; + const ffi = loadFfiProfile(process.argv[2]); + if (!ffi.ok) throw new Error(JSON.stringify(ffi.diagnostics)); const load = loadProgram(process.argv[1]); try { const preflight = checkPreflight(load); if (preflight.length) throw new Error(JSON.stringify(preflight)); - const result = lowerToIr(load.program, load.entry, load.moduleOrder, { dynamic: false, frontendServices: load.services }); + if (load.startupCrash) throw new Error(JSON.stringify(load.startupCrash)); + const result = lowerToIr(load.program, load.entry, load.moduleOrder, { + dynamic: false, frontendServices: load.services, ffiImports: ffi.profile.functions, + }); console.log(JSON.stringify({ stats: result.stats, diagnostics: result.diagnostics, runtimeFences: result.runtimeFences, - functions: result.module?.functions.length ?? 0 })); + functions: result.module?.functions.length ?? 0, + validation: result.module ? validateModule(result.module) : null })); } finally { load.dispose(); }`, join(root, "tests/fixtures/self-hosting/frontend-lowering.ts"), + join(root, "packages/compiler/native/ts7-process.ffi.json"), ], { cwd: root, timeout: 900_000, maxBuffer: 8 * 1024 * 1024 }); expect(stderr).toBe(""); - const result = JSON.parse(stdout) as Pick & { functions: number }; + const result = JSON.parse(stdout) as Pick & { functions: number; validation: unknown[] | null }; expect(result.diagnostics).toEqual([]); expect(result.runtimeFences).toEqual([]); expect(result.stats.statementsTotal).toBeGreaterThan(50_000); @@ -36,4 +47,5 @@ test("the complete production frontend lowers with zero rejected statements", as expect(result.stats.statementsIsland).toBe(0); expect(result.stats.functionsSkipped).toBe(0); expect(result.functions).toBeGreaterThan(1_000); + expect(result.validation).toEqual([]); }, 930_000); diff --git a/tests/harness/self-hosting-native-frontend.test.ts b/tests/harness/self-hosting-native-frontend.test.ts new file mode 100644 index 00000000..6ce09456 --- /dev/null +++ b/tests/harness/self-hosting-native-frontend.test.ts @@ -0,0 +1,222 @@ +import { execFile, execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compileC, deserializeModule, emitCModule, validateModule } from "@scriptc/compiler"; +import type { compile } from "@scriptc/compiler"; +import type { IrModule } from "../../packages/compiler/src/ir/ir.js"; +import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js"; +import { + moduleUsesAssert, moduleUsesBigInt, moduleUsesCopying, moduleUsesDynInvoke, + moduleUsesInspect, moduleUsesRegex, moduleUsesSymbol, moduleUsesZlib, +} from "../../packages/compiler/src/ir/ir.js"; + +const root = join(import.meta.dirname, "../.."); +const entry = join(root, "tests/fixtures/self-hosting/frontend-pipeline.ts"); +const oracle = join(root, "tests/fixtures/self-hosting/frontend-pipeline-node.ts"); +const execFileAsync = promisify(execFile); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const runOptions = { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" as const }; + +const programs = [ + "001-hello.ts", + "101-arithmetic.ts", + "600-closures-basic.ts", + "711-inheritance-dispatch.ts", + "1005-json-nested.ts", + "1452-return-through-finally.ts", + "2012-generators-return-throw.ts", + "3089-array-find-narrowing.ts", + "tuple-array-union.ts", + "array-refined-storage.ts", + "typed-rest-filtered-return.ts", + "nullish-long-chain.ts", + "class-array-optional-return.ts", + "contextual-array-union-write.ts", +]; + +for (const backend of ["c", "llvm"] as const) { + test(`the complete frontend produces executable IR without Node (${backend})`, async () => { + const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-frontend-")); + const executable = (name: string) => join(directory, name + (process.platform === "win32" ? ".exe" : "")); + try { + const nativeSources = join(root, "packages/compiler/native"); + const object = join(directory, "ts7-process.o"); + execFileSync("clang", ["-std=c11", "-Wall", "-Wextra", "-Werror", ...(sanitize ? ["-fsanitize=address"] : []), + "-c", join(nativeSources, "ts7-process.c"), "-o", object]); + const profile = join(directory, "ffi.json"); + writeFileSync(profile, JSON.stringify({ + ...JSON.parse(readFileSync(join(nativeSources, "ts7-process.ffi.json"), "utf8")), libraries: [object], + })); + // Production lowering can monopolize a worker's synchronous RPC loop. + // Build the actual entry in a child while keeping Vitest responsive. + const api = pathToFileURL(join(root, "packages/compiler/src/index.ts")).href; + const { stdout, stderr } = await execFileAsync(process.execPath, [ + "--import", "tsx", "--input-type=module", "--eval", + `import { compile } from ${JSON.stringify(api)}; + const result = await compile(process.argv[1], { + outDir: process.argv[2], outPath: process.argv[3], backend: process.argv[4], + dynamic: false, optimization: 'dev', sanitize: process.argv[5] === '1', + ffiProfilePath: process.argv[6], + }); + console.log(JSON.stringify({ ...result, sourceTexts: undefined }));`, + entry, directory, executable("frontend"), backend, sanitize ? "1" : "0", profile, + ], { cwd: root, timeout: 900_000, maxBuffer: 16 * 1024 * 1024 }); + expect(stderr).toBe(""); + const built = JSON.parse(stdout) as Awaited>; + if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + if (!("binaryPath" in built)) throw new Error("expected a native frontend executable"); + expect(built.backend).toBe(backend); + + const lowerRequest = (file: string, request: { + entry: string; npmStatic?: string[] | "auto" | "lib"; externalTypes?: Record; + ffiProfile?: string; contract?: boolean; + }): { module: IrModule | null; report: unknown[] } => { + const requestPath = join(directory, "request.json"); + writeFileSync(requestPath, JSON.stringify(request)); + const expectedPath = join(directory, "node.json"); + const actualPath = join(directory, "native.json"); + for (const output of [expectedPath, actualPath]) rmSync(output, { force: true }); + const expected = spawnSync(process.execPath, ["--import", "tsx", oracle, ts7Executable(), requestPath, expectedPath], runOptions); + // The native client launches the selected TS7 executable directly. + // Removing PATH makes an accidental Node subprocess dependency fail. + const actual = spawnSync(built.binaryPath, [ts7Executable(), requestPath, actualPath], { + ...runOptions, env: { ...process.env, PATH: "" }, + }); + for (const result of [expected, actual]) { + expect(result.error, file + "\n" + result.stderr).toBeUndefined(); + expect(result.signal, file + "\n" + result.stderr).toBeNull(); + expect(result.status, file + "\n" + result.stderr).toBe(0); + expect(result.stderr, file).toBe(""); + } + const expectedReport = expected.stdout.trim().split("\n").map((line) => JSON.parse(line)); + const actualReport = actual.stdout.trim().split("\n").map((line) => JSON.parse(line)); + expect(actualReport, file).toEqual(expectedReport); + expect(existsSync(actualPath), file).toBe(existsSync(expectedPath)); + if (!existsSync(actualPath)) return { module: null, report: actualReport }; + const module = deserializeModule(readFileSync(actualPath, "utf8")); + expect(module, file).toEqual(deserializeModule(readFileSync(expectedPath, "utf8"))); + expect(validateModule(module), file).toEqual([]); + + return { module, report: actualReport }; + }; + + for (const file of programs) { + const source = join(root, "tests/corpus", file); + const { module, report } = lowerRequest(file, { entry: source }); + expect(report[0], file).toEqual({ preflight: [], npmStatic: [] }); + expect(report[1], file).toMatchObject({ + stats: { statementsFailed: 0, statementsIsland: 0, functionsSkipped: 0 }, diagnostics: [], validation: [], + }); + if (!module) throw new Error(file + ": missing native IR"); + const cPath = join(directory, "program.c"); + writeFileSync(cPath, emitCModule(module)); + await compileC({ + cPath, outPath: executable("program"), sanitize, + regex: moduleUsesRegex(module), copying: moduleUsesCopying(module), + inspect: moduleUsesInspect(module), dynInvoke: moduleUsesDynInvoke(module), + symbol: moduleUsesSymbol(module), bigint: moduleUsesBigInt(module), + zlib: moduleUsesZlib(module), assert: moduleUsesAssert(module), + }); + const node = spawnSync(process.execPath, [source], runOptions); + const native = spawnSync(executable("program"), [], runOptions); + for (const result of [node, native]) { + expect(result.error, file).toBeUndefined(); + expect(result.signal, file + "\n" + result.stderr).toBeNull(); + expect(result.status, file + "\n" + result.stderr).toBe(0); + } + expect(native.stdout, file).toBe(node.stdout); + const nativeStderr = sanitize + ? native.stderr.replace(/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm, "") + : native.stderr; + expect(nativeStderr, file).toBe(node.stderr); + } + + for (const [file, npmStatic, packages] of [ + ["escape-cli.ts", "auto", ["escape-string-regexp"]], + ["escape-barrel.ts", "auto", ["escape-string-regexp"]], + ["nested/main.ts", "auto", ["shouty"]], + ["slash-cli.ts", ["slash"], ["slash"]], + ] as const) { + const result = lowerRequest(file, { + entry: join(root, "tests/fixtures/npm-static", file), + npmStatic: typeof npmStatic === "string" ? npmStatic : [...npmStatic], + }); + expect(result.report[0], file).toEqual({ preflight: [], npmStatic: packages.map((name) => ({ package: name, status: "static" })) }); + expect(result.report[1], file).toMatchObject({ stats: { statementsFailed: 0 }, diagnostics: [], validation: [] }); + expect(result.module, file).not.toBeNull(); + } + const fallback = lowerRequest("package fallback", { + entry: join(root, "tests/fixtures/npm/cases/2557-toesm-drift/main.ts"), npmStatic: "auto", + }); + expect(fallback.report[0]).toMatchObject({ + npmStatic: [{ package: "gtdrift", status: "fallback", detail: expect.stringContaining("__toESM") }], + }); + + const source = join(directory, "main.ts"); + writeFileSync(join(directory, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(directory, "tsconfig.json"), JSON.stringify({ compilerOptions: { + target: "ES2023", module: "NodeNext", strict: true, noEmit: true, + } })); + writeFileSync(source, 'const value: string = 1; console.log(value);\n'); + const badType = lowerRequest("type error", { entry: source }); + expect(badType.module).toBeNull(); + expect(badType.report[0]).toMatchObject({ preflight: [expect.objectContaining({ code: "SC0001" })] }); + writeFileSync(source, ` + const values: (string | number)[] = ["wrong"]; + console.log(values.find((value): value is number => true)); + `); + const refusal = lowerRequest("static refusal", { entry: source }); + expect(refusal.module).toBeNull(); + expect(refusal.report[1]).toMatchObject({ diagnostics: [expect.objectContaining({ code: "SC1090" })] }); + + // The same production pipeline supplies the library's syntax contract. + // Local type names and convention constants exercise its scoped cache + // and the heterogeneous destination fields used by the projection. + writeFileSync(source, ` + export interface Model { zebra: string; alpha: readonly number[]; } + export type Msg = { kind: "paint"; payload: string } | { kind: "data"; payload: Uint8Array }; + export type ReadonlyArray = { item: T }; + export type Local = ReadonlyArray; + export const modelUnbound = ["zebra"] as const; + export const msgUnbound = ["paint"] as const; + export const appearanceMsg = "paint"; + export const chromeMsg = "paint"; + export const envMsgs = [{ env: "DATA", msg: "data" }] as const; + export function count(value: Model): number { return value.alpha.length; } + `); + const contract = lowerRequest("library contract", { entry: source, npmStatic: "lib", contract: true }); + expect(contract.report[0]).toEqual({ preflight: [], npmStatic: [] }); + expect(contract.report[1]).toMatchObject({ + modelUnbound: { value: ["zebra"] }, msgUnbound: { value: ["paint"] }, + appearanceMsg: { value: "paint" }, chromeMsg: { value: "paint" }, + envMsgs: { value: [{ env: "DATA", msg: "data" }] }, + types: expect.arrayContaining([expect.objectContaining({ name: "Local", shape: { k: "unsupported", text: "ReadonlyArray" } })]), + }); + + writeFileSync(source, 'declare function nativeAdd(a: number, b: number): number; console.log(nativeAdd(20, 22));\n'); + const inputFfi = join(directory, "input-ffi.json"); + writeFileSync(inputFfi, JSON.stringify({ ffi_format: 1, functions: [ + { name: "nativeAdd", symbol: "native_add", params: ["f64", "f64"], returns: "f64" }, + ] })); + const ffiResult = lowerRequest("native bindings", { entry: source, ffiProfile: inputFfi }); + expect(ffiResult.report[1]).toMatchObject({ diagnostics: [], validation: [] }); + if (!ffiResult.module) throw new Error("missing FFI IR"); + const helper = join(directory, "add.c"); + const helperObject = join(directory, "add.o"); + writeFileSync(helper, "double native_add(double a, double b) { return a + b; }\n"); + execFileSync("clang", ["-c", helper, "-o", helperObject]); + const ffiC = join(directory, "ffi.c"); + writeFileSync(ffiC, emitCModule(ffiResult.module)); + await compileC({ cPath: ffiC, outPath: executable("ffi"), sanitize, inspect: true, linkInputs: [helperObject] }); + const ffiRun = spawnSync(executable("ffi"), [], runOptions); + expect(ffiRun.error).toBeUndefined(); + expect(ffiRun.status, ffiRun.stderr).toBe(0); + expect(ffiRun.stdout).toBe("42\n"); + expect(ffiRun.stderr).toBe(""); + } finally { rmSync(directory, { recursive: true, force: true }); } + }, 1_200_000); +} diff --git a/tests/harness/self-hosting-validator.test.ts b/tests/harness/self-hosting-validator.test.ts index fa34816c..9bdb8551 100644 --- a/tests/harness/self-hosting-validator.test.ts +++ b/tests/harness/self-hosting-validator.test.ts @@ -48,6 +48,7 @@ for (const backend of ["c", "llvm"] as const) { "tests/corpus/3089-array-find-narrowing.ts", "tests/corpus/3091-json-recursive-discriminants.ts", "tests/corpus/3109-identity-union-collections.ts", + "tests/corpus/nullish-long-chain.ts", ]) { const irPath = join(dir, "emitted.json"); const emitted = await compile(join(root, source), { outDir: dir, outPath: irPath, outputKind: "ir", dynamic: false });