diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec2710059..54a0e2043 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: - run: pnpm --filter @scriptc/llvm-darwin-arm64 build:native - run: pnpm --filter @scriptc/runtime-darwin-arm64 build:native - run: pnpm build + - name: TypeScript 7 full parity sweep + if: matrix.flavor == 'plain' && matrix.shard == 1 + run: pnpm test:ts7 # Separate vitest invocations because the shard axes must not mix: a file # lands in exactly ONE --shard slice, so an env-sharded file behind # --shard would run only one of its three case slices and silently @@ -141,6 +144,8 @@ jobs: version: 0.16.0 - run: pnpm --filter @scriptc/runtime-wasm32-wasi build:native - run: pnpm build + - name: TypeScript 7 full parity sweep + run: pnpm test:ts7 - name: Linux helper object format and no-clang output contract run: | PATH="$RUNNER_TEMP/traps:$PATH" @@ -306,6 +311,8 @@ jobs: pnpm install --frozen-lockfile } - run: pnpm build + - name: TypeScript 7 full parity sweep + run: pnpm test:ts7 - name: Windows path regressions run: pnpm exec vitest run packages/compiler/test/ts7/program.test.ts packages/cli/test/paths.test.ts - name: Windows Sandbox path regression diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index cd5e3eca9..339147302 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "23946578017afb1446f3", + "artifactVersion": "af5f71a4e38e710a5e12", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 9bd7966d5..f5d4dcd66 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -697,19 +697,19 @@ "entries": 2, "apiEntries": 2, "static": { - "supported": 0, + "supported": 2, "partial": 0, "refused": 0, - "not-implemented": 2, + "not-implemented": 0, "by-design": 0, "unreviewed": 0, "not-applicable": 0 }, "dynamic": { - "supported": 0, + "supported": 2, "partial": 0, "refused": 0, - "not-implemented": 2, + "not-implemented": 0, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -58639,12 +58639,12 @@ "inherited": false }, "static": { - "status": "not-implemented", + "status": "supported", "detail": "Derived from the exact API rows in this chapter.", "verification": "derived" }, "dynamic": { - "status": "not-implemented", + "status": "supported", "detail": "Derived from the exact API rows in this chapter.", "verification": "derived" } @@ -58667,14 +58667,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "Not implemented in scriptc's static module-loader subset yet.", - "verification": "declared-gap" + "status": "supported", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { - "status": "not-implemented", - "detail": "Not implemented in the embedded module-loader subset yet.", - "verification": "declared-gap" + "status": "supported", + "detail": "Implemented for the documented embedded module-loader subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/dynamic-support.json b/internal/compatibility/dynamic-support.json index 8dcc9cecf..d841f0178 100644 --- a/internal/compatibility/dynamic-support.json +++ b/internal/compatibility/dynamic-support.json @@ -29,7 +29,7 @@ { "chapter": "esm", "symbols": ["meta", "import.meta.resolve", "dirname", "filename", "main", "data:", "require", "__filename", "require.main", "require.resolve", "NODE_PATH", "require.extensions", "require.cache"], "status": "not-implemented" }, { "chapter": "packages", "symbols": ["package.json", "\"name\"", "\"main\"", "\"type\"", "\"exports\"", "\"imports\""], "status": "partial", "evidence": ["tests/fixtures/npm/cases/dual-entry/main.ts", "tests/fixtures/npm/cases/self-name/main.ts", "tests/fixtures/npm/cases/scoped-nested/main.ts"] }, { "chapter": "packages", "symbols": ["--input-type"], "status": "not-applicable" }, - { "chapter": "typescript", "symbols": ["type"], "status": "not-implemented" } + { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2705-type-import-link-dynamic/main.ts", "tests/corpus/2706-type-import-link-dynamic-success/main.ts", "tests/harness/errors.test.ts"] } ], "modules": { "events": { "exports": ["EventEmitter", "once", "on", "listenerCount", "getEventListeners", "setMaxListeners", "defaultMaxListeners", "errorMonitor", "captureRejectionSymbol"], "members": ["emitter.addListener", "emitter.emit", "emitter.eventNames", "emitter.getMaxListeners", "emitter.listenerCount", "emitter.listeners", "emitter.off", "emitter.on", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.removeAllListeners", "emitter.removeListener", "emitter.setMaxListeners", "emitter.rawListeners", "events.getEventListeners", "events.listenerCount", "events.once", "events.on", "events.setMaxListeners"], "evidence": ["tests/fixtures/npm/cases/stream-shims/main.ts"] }, diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 779fef995..bd603168e 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -3,25 +3,25 @@ "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", "summary": { - "taskCount": 3369, - "tierItemCount": 6673, + "taskCount": 3368, + "tierItemCount": 6671, "tiers": { "static": { "replace-refusal": 73, "verify-gap": 2764, "audit-partial": 371, "classify": 3, - "implement": 93 + "implement": 92 }, "dynamic": { "audit-partial": 745, "verify-gap": 2302, "replace-refusal": 101, - "implement": 221 + "implement": 220 } }, "priorities": { - "high": 4510, + "high": 4508, "normal": 1411, "low": 752 } @@ -62178,39 +62178,6 @@ } } }, - { - "id": "typescript:e0b7094a6baa", - "chapter": "typescript", - "label": "Importing types without type keyword", - "signature": "Importing types without `type` keyword", - "apiSymbol": "type", - "docsUrl": "https://nodejs.org/docs/v24.15.0/api/typescript.html#importing-types-without-type-keyword", - "nodeStability": { - "index": "2", - "level": 2, - "text": "Stable", - "inherited": true - }, - "priority": "high", - "tiers": { - "static": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", - "source": "compiler-feature:typescript.type", - "tests": [] - }, - "dynamic": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", - "source": "island-feature:typescript.type", - "tests": [] - } - } - }, { "id": "net:a4534171f578", "chapter": "net", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 3478bddf7..00a803033 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -770,19 +770,19 @@ "entries": 11, "apiEntries": 2, "static": { - "supported": 0, + "supported": 2, "partial": 0, "refused": 0, - "not-implemented": 2, + "not-implemented": 0, "by-design": 0, "unreviewed": 0, "not-applicable": 0 }, "dynamic": { - "supported": 0, + "supported": 2, "partial": 0, "refused": 0, - "not-implemented": 2, + "not-implemented": 0, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -78423,12 +78423,23 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "derived:descendants" + "status": "supported", + "evidence": "derived:descendants", + "tests": [ + "tests/corpus/2702-type-import-link-static/main.ts", + "tests/corpus/2703-type-import-link-static-reexport/main.ts", + "tests/corpus/2704-type-import-link-static-success/main.ts", + "tests/harness/errors.test.ts" + ] }, "dynamic": { - "status": "not-implemented", - "evidence": "derived:descendants" + "status": "supported", + "evidence": "derived:descendants", + "tests": [ + "tests/corpus/2705-type-import-link-dynamic/main.ts", + "tests/corpus/2706-type-import-link-dynamic-success/main.ts", + "tests/harness/errors.test.ts" + ] }, "anchorSource": "chapter" }, @@ -78585,12 +78596,23 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-feature:typescript.type" + "status": "supported", + "evidence": "compiler-feature:typescript.type", + "tests": [ + "tests/corpus/2702-type-import-link-static/main.ts", + "tests/corpus/2703-type-import-link-static-reexport/main.ts", + "tests/corpus/2704-type-import-link-static-success/main.ts", + "tests/harness/errors.test.ts" + ] }, "dynamic": { - "status": "not-implemented", - "evidence": "island-feature:typescript.type" + "status": "supported", + "evidence": "island-feature:typescript.type", + "tests": [ + "tests/corpus/2705-type-import-link-dynamic/main.ts", + "tests/corpus/2706-type-import-link-dynamic-success/main.ts", + "tests/harness/errors.test.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index baccf0463..687620927 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -78,7 +78,7 @@ { "chapter": "esm", "symbols": ["meta", "import.meta.resolve", "data:", "require", "__filename", "require.main", "require.resolve", "NODE_PATH", "require.extensions", "require.cache"], "status": "not-implemented" }, { "chapter": "packages", "symbols": ["package.json", "\"name\"", "\"main\"", "\"type\"", "\"exports\"", "\"imports\""], "status": "partial", "evidence": ["tests/corpus/2092-package-imports/main.ts", "tests/corpus/2120-package-self-import/main.ts", "tests/corpus/2124-imports-field-wildcard/main.ts", "tests/corpus/2390-dot-requires/main.cjs"] }, { "chapter": "packages", "symbols": ["--input-type"], "status": "not-applicable" }, - { "chapter": "typescript", "symbols": ["type"], "status": "not-implemented" } + { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/harness/errors.test.ts"] } ], "dedicated": [ { "symbols": ["console.log", "console.info", "console.debug", "console.error", "console.warn"], "status": "partial", "evidence": ["tests/corpus/1460-console-error-warn.ts", "tests/corpus/2440-console-inspect-args.ts"] }, diff --git a/package.json b/package.json index 4aa3bd4b4..522ea8ee3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "pnpm -r --filter \"./packages/*\" run build", "build:fresh": "rm -rf packages/compiler/dist packages/cli/dist node_modules/.cache/scriptc-tsc && pnpm build", "test": "vitest run", + "test:ts7": "node scripts/test-ts7.mjs", "test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts", "test:cache-identity": "node tests/harness/cache-identity.mjs", "test:sandbox": "node scripts/sandbox-test.mjs", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index e68f97fe6..666363937 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -29,7 +29,7 @@ "access": "public" }, "scripts": { - "build": "node node_modules/typescript5/bin/tsc -p tsconfig.json" + "build": "node node_modules/typescript/bin/tsc -p tsconfig.json" }, "dependencies": { "@scriptc/runtime": "workspace:*", diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 475ed5a2a..32dc5df39 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -8,7 +8,7 @@ import type { Lowerer } from "./lowerer.js"; import { 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 { requiresDynamicApiDiag, requiresDynamicPackageDiag } from "../../diagnostics/diagnostic.js"; -import { isCjsJsFile, isJsSourceFile, locOf, npmPackageNameOf } from "../program.js"; +import { esmNamedImportLinkCrash, isCjsJsFile, isJsSourceFile, locOf, npmPackageNameOf } from "../program.js"; import { foldedStringKeyOf, lowerDynObjectLiteral, pureReemittable } from "./lower-exprs.js"; import { PoisonError, dynUndefinedExpr, newFnCtx, nodeThrowExpr, own } from "./lowerer.js"; import { @@ -2794,13 +2794,32 @@ export function lowerStaticReadableStreamReaderCall( lowerer.fnStack.push(fnCtx); try { const body: IrStmt[] = []; + const linkCrash = esmNamedImportLinkCrash(lowerer.program, dep); + if (linkCrash !== null) { + // A dynamic import links its graph when the returned promise is + // settled. Preserve that timing by throwing from the synthesized + // namespace builder, which runs inside the Promise reaction; the + // bridge turns the throw into the import promise's rejection. No + // module init may run before this link failure. + body.push({ + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [{ kind: "strLit", value: linkCrash.message, type: STRING, loc }], + type: { kind: "object", className: linkCrash.className }, + loc, + }, + loc, + }); + } // A synchronous entry has no run-once guard, so its historical // self-import path must not call %init again. An ASYNC entry does // have the stronger evaluation-promise cache: awaiting that cached // promise is essential for top-level `await import("./self")`, // which deadlocks (and ultimately exits 13) in Node rather than // exposing a half-evaluated namespace. - if (dep !== lowerer.entry || isAsync) { + if (linkCrash === null && (dep !== lowerer.entry || isAsync)) { const call: IrExpr = { kind: "call", callee: initName, diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5cb3a40f8..84532be62 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1963,9 +1963,8 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts }] : []; // Node's startup refusal (a resolution the graph carries that Node - // rejects — preflight's Node-order resolution walk — or the module- - // LINK SyntaxError of a named import of a CommonJS export its lexer - // cannot detect — cjsNamedImportLinkCheck): the graph is refused + // rejects — preflight's Node-order resolution walk — or a module-LINK + // SyntaxError from either named-import checker): the graph is refused // before ANY module evaluates, so %main opens with exactly that throw // (message and error class both Node's) and the entry init below it // never runs. The init still lowers — the program must otherwise diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0b97d4832..21a42031b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -348,7 +348,7 @@ export interface LowerOptions { * path.win32 keep answering THEIR platform everywhere, like Node's. */ targetPlatform?: string; /** Node's startup refusal (LoadResult.startupCrash — preflight's - * resolution walk and CJS named-import link check): the program + * resolution walk and named-import link checks): the program * compiles to that startup crash. */ startupCrash?: StartupCrash | null; /** LIBRARY mode's reachability roots: the profile-mapped exports of the @@ -430,8 +430,8 @@ export interface LowererMode { /** The build's target platform (LowerOptions.targetPlatform — lowerToIr * passes it to every pass). Defaults to the host. */ targetPlatform?: string; - /** Node's startup refusal (preflight's resolution walk / CJS named- - * import link check): %main opens with exactly this throw, before any + /** Node's startup refusal (preflight's resolution walk / named-import + * link checks): %main opens with exactly this throw, before any * module init — Node refuses the whole graph before anything evaluates, * so nothing runs. */ startupCrash?: StartupCrash | null; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 5bc47cbb6..b9e8a04c8 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -227,8 +227,8 @@ export interface LoadResult { /** See LoadResult.startupCrash: Node's exact error message, the IR error * class that carries it (a RUNTIME_ERROR_CLASSES name — %Error for the * resolver's ERR_MODULE_NOT_FOUND family, %TypeError for invalid-specifier - * refusals, %SyntaxError for the CJS link check), and the source position - * of the refused edge. */ + * refusals, %SyntaxError for either named-import link check), and the source + * position of the refused edge. */ export interface StartupCrash { message: string; className: "%Error" | "%TypeError" | "%SyntaxError"; @@ -2490,7 +2490,13 @@ function preflight7(load: LoadResult): { } } - const linkCrash = resolveCrash !== null ? null : cjsNamedImportLinkCheck(program, entry, order, diags); + const linkCrash = resolveCrash !== null + ? null + : earlierLinkCrash( + order, + cjsNamedImportLinkCheck(program, entry, order, diags), + esmNamedImportLinkCheck(program, entry, order, diags), + ); return { diags, moduleOrder: order, startupCrash: resolveCrash ?? linkCrash }; } @@ -2689,6 +2695,284 @@ function cjsNamedImportLinkCheck( }; } +/* ── the native ESM named-import link check ───────────────────────────── + * TypeScript accepts `import { Shape } from "./types.ts"` when Shape is an + * interface or type alias: the checker is answering a TYPE question. Node's + * ESM linker asks a VALUE question instead. Because Node 24's strip-only + * execution leaves that import request in the module graph, the request + * fails before any module evaluates unless the source uses `import type`. + * + * Keep this separate from cjsNamedImportLinkCheck. CommonJS exports are + * determined by Node's source lexer and have their own interop message and + * ordering rules; native ESM exports come from the resolved TypeScript + * module symbol and use Node's generic missing-export SyntaxError. */ +interface BadEsmImport { + exportName: string; + spec: string; + nameNode: ts.Node; + sf: ts.SourceFile; +} + +interface EsmNamedImportLinkAnalysis { + crash: StartupCrash | null; + visited: Set; + firstMissingOf: (sf: ts.SourceFile) => BadEsmImport | null; +} + +/** Node's strip-only loader exposes an empty `default` export for a default + * interface only when the `.ts` file is in an ambiguous (typeless) package + * scope. Explicitly ESM files (`.mts` and `.mjs`) and `.ts` files in a + * `"type": "module"` package do not get that placeholder. */ +function hasNodeTsDefaultInterfacePlaceholder7(sf: ts.SourceFile): boolean { + return sf.fileName.endsWith(".ts") && nearestPackageType(sf.fileName) === null; +} + +/** Finds the first native-ESM link failure in the static module graph rooted + * at `entry`. This is used both for startup linking of the entry graph and + * for dynamic imports: a dynamically loaded graph rejects its import promise + * at this same link point instead of running any module body. */ +function analyzeEsmNamedImportLinks( + program: ts.Program, + entry: ts.SourceFile, +): EsmNamedImportLinkAnalysis { + const checker = program.getTypeChecker(); + const resolveEdge = (from: ts.SourceFile, spec: string): ts.SourceFile | null => { + if (isRelativeSpecifier(spec)) return resolveImport7(program, from, spec); + const npmStatic = npmStaticDepSf7(program, from, spec); + if (npmStatic !== null) return npmStatic; + const p = resolveProjectImport(from.fileName, spec); + return p !== null ? (program.getSourceFile(p) ?? null) : null; + }; + + const runtimeExport = ( + dep: ts.SourceFile, + name: string, + ): ts.Symbol | undefined => { + const module = checker.getSymbolAtLocation(dep); + const exported = module?.getExports().get(name as ts.__String); + if (exported === undefined) return undefined; + let resolved = exported; + const seen = new Set(); + while ((resolved.flags & ts.SymbolFlags.Alias) !== 0 && !seen.has(resolved)) { + seen.add(resolved); + resolved = checker.getAliasedSymbol(resolved); + } + if (resolved.flags & ts.SymbolFlags.Value) return resolved; + // Node's strip-only TypeScript loader materializes a direct `export + // default interface X {}` as an empty default export in an ambiguous + // `.ts` file. The checker quite rightly classifies the declaration as + // type-only, but the native ESM linker sees the runtime placeholder. + // This is a property of the source file's Node module format, not of + // whether the request is direct or re-exported. + if ( + name === "default" && + hasNodeTsDefaultInterfacePlaceholder7(dep) && + checker.declarationsOf(resolved).some( + (declaration) => + ts.isInterfaceDeclaration(declaration) && + ts.getModifiers(declaration)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true && + ts.getModifiers(declaration)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true, + ) + ) { + return resolved; + } + return undefined; + }; + + const firstMissingOf = (sf: ts.SourceFile): BadEsmImport | null => { + const imports: { local: string; exportName: string; spec: string; nameNode: ts.Node; dep: ts.SourceFile }[] = []; + for (const stmt of sf.statements) { + if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; + const clause = stmt.importClause; + if (clause === undefined || clause.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; + const spec = stmt.moduleSpecifier.text; + const dep = resolveEdge(sf, spec); + if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep)) continue; + if (clause.name !== undefined) { + imports.push({ local: clause.name.text, exportName: "default", spec, nameNode: clause.name, dep }); + } + if (clause.namedBindings === undefined || !ts.isNamedImports(clause.namedBindings)) continue; + for (const element of clause.namedBindings.elements) { + if (element.isTypeOnly) continue; + const nameNode = element.propertyName ?? element.name; + imports.push({ + local: element.name.text, + exportName: nameNode.text, + spec, + nameNode, + dep, + }); + } + } + // Node checks regular named requests in local-binding order. Keeping the + // same order as the CommonJS linker above matters when one statement + // asks for more than one erased TypeScript export. + imports.sort((a, b) => (a.local < b.local ? -1 : a.local > b.local ? 1 : 0)); + for (const request of imports) { + if (runtimeExport(request.dep, request.exportName) === undefined) { + return { + exportName: request.exportName, + spec: request.spec, + nameNode: request.nameNode, + sf, + }; + } + } + + for (const stmt of sf.statements) { + if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly) continue; + const moduleSpecifier = stmt.moduleSpecifier; + const exportClause = stmt.exportClause; + if (moduleSpecifier === undefined || exportClause === undefined) continue; + if (!ts.isStringLiteral(moduleSpecifier) || !ts.isNamedExports(exportClause)) continue; + const spec = moduleSpecifier.text; + const dep = resolveEdge(sf, spec); + if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep)) continue; + for (const element of exportClause.elements) { + if (element.isTypeOnly) continue; + const nameNode = element.propertyName ?? element.name; + if (runtimeExport(dep, nameNode.text) === undefined) { + return { exportName: nameNode.text, spec, nameNode, sf }; + } + } + } + return null; + }; + + // The ESM instantiate graph is the same depth-first graph used by the CJS + // linker: dependencies are linked before the importing module, and a + // CommonJS/JSON target is a leaf for this native-export check. + const visited = new Set(); + const dfs = (sf: ts.SourceFile): BadEsmImport | null => { + if (visited.has(sf)) return null; + visited.add(sf); + for (const stmt of sf.statements) { + if (!ts.isImportDeclaration(stmt) && !(ts.isExportDeclaration(stmt) && !stmt.isTypeOnly)) continue; + if (ts.isImportDeclaration(stmt) && stmt.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; + const moduleSpecifier = stmt.moduleSpecifier; + if (moduleSpecifier === undefined || !ts.isStringLiteral(moduleSpecifier)) continue; + const dep = resolveEdge(sf, moduleSpecifier.text); + if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep)) continue; + const bad = dfs(dep); + if (bad !== null) return bad; + } + return firstMissingOf(sf); + }; + + // Native ESM linking itself starts only from an ESM entry. The caller + // supplies dynamic-only roots here too; each such root gets its own + // promise rejection rather than a startup crash. + const bad = isNodeEsmFile7(entry) ? dfs(entry) : null; + if (bad !== null) { + return { + crash: { + message: `The requested module '${bad.spec}' does not provide an export named '${bad.exportName}'`, + className: "%SyntaxError", + loc: locOf7(bad.nameNode), + }, + visited, + firstMissingOf, + }; + } + + return { crash: null, visited, firstMissingOf }; +} + +export function esmNamedImportLinkCrash( + program: ts.Program, + entry: ts.SourceFile, +): StartupCrash | null { + return analyzeEsmNamedImportLinks(program, entry).crash; +} + +function esmNamedImportLinkCheck( + program: ts.Program, + entry: ts.SourceFile, + moduleOrder: ts.SourceFile[], + diags: ScrDiagnostic[], +): StartupCrash | null { + const analysis = analyzeEsmNamedImportLinks(program, entry); + if (analysis.crash !== null) return analysis.crash; + + // A CommonJS entry can synchronously require an ESM graph. Node links that + // graph at the require site, after the CommonJS module has already begun + // evaluating, so this cannot use startupCrash (which would move the error + // before earlier output). Keep the compile-time fence used by the CJS link + // checker for the analogous mid-evaluation failure instead. + for (const sf of moduleOrder) { + if (!isNodeEsmFile7(sf) || analysis.visited.has(sf)) continue; + const childFailure = analysis.firstMissingOf(sf); + if (childFailure !== null) { + diags.push( + unsupportedDiag( + "SC1013", + locOf7(childFailure.nameNode), + "a named import of an unavailable export in an ES module reached through require() (Node throws its SyntaxError mid-evaluation at that require; make the import match the module's runtime exports)", + ), + ); + } + } + return null; +} + +/** Both named-export link checks run independently because their export + * questions are different (Node's CJS lexer versus TypeScript's value + * symbols). Their first failures still share one Node instantiate order: the + * module postorder established by preflight, then regular named imports by + * local binding name, then source-order re-exports. Pick the earlier result + * so a native ESM failure in a child cannot be hidden by a CJS failure in a + * later parent. */ +function earlierLinkCrash( + moduleOrder: ts.SourceFile[], + first: StartupCrash | null, + second: StartupCrash | null, +): StartupCrash | null { + if (first === null) return second; + if (second === null) return first; + const moduleIndex = new Map(moduleOrder.map((sf, index) => [sf.fileName, index])); + const firstIndex = moduleIndex.get(first.loc.file) ?? Number.MAX_SAFE_INTEGER; + const secondIndex = moduleIndex.get(second.loc.file) ?? Number.MAX_SAFE_INTEGER; + if (firstIndex !== secondIndex) return firstIndex < secondIndex ? first : second; + if (first.loc.start !== second.loc.start) { + const sf = moduleOrder[firstIndex]; + if (sf === undefined) return first.loc.start < second.loc.start ? first : second; + const rank = (crash: StartupCrash): [number, number] => { + const entries: { local: string; start: number; kind: 0 | 1 }[] = []; + for (const stmt of sf.statements) { + if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) { + const clause = stmt.importClause; + if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; + if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const element of clause.namedBindings.elements) { + if (!element.isTypeOnly) { + entries.push({ local: element.name.text, start: (element.propertyName ?? element.name).getStart(sf), kind: 0 }); + } + } + } + } + } + entries.sort((a, b) => (a.local < b.local ? -1 : a.local > b.local ? 1 : 0)); + const direct = entries.findIndex((entry) => entry.start === crash.loc.start); + if (direct >= 0) return [0, direct]; + let reexport = 0; + for (const stmt of sf.statements) { + const exportClause = ts.isExportDeclaration(stmt) ? stmt.exportClause : undefined; + if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly || exportClause === undefined || !ts.isNamedExports(exportClause)) continue; + for (const element of exportClause.elements) { + if (!element.isTypeOnly && (element.propertyName ?? element.name).getStart(sf) === crash.loc.start) return [1, reexport]; + if (!element.isTypeOnly) reexport++; + } + } + return [2, crash.loc.start]; + }; + const firstRank = rank(first); + const secondRank = rank(second); + if (firstRank[0] !== secondRank[0]) return firstRank[0] < secondRank[0] ? first : second; + return firstRank[1] <= secondRank[1] ? first : second; + } + return first; +} + /* node's builtin-module name list, for the SC1010 wording decision ("the * 'fs' module" vs the generic package message) — shared verbatim with the * retired 5.9.3 lane's builtinModules use. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 332f88d22..ab9f75513 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5695,6 +5695,56 @@ ], "diags": [] }, + "/tests/corpus/2702-type-import-link-static/main.ts": { + "order": [ + "/tests/corpus/2702-type-import-link-static/types.ts", + "/tests/corpus/2702-type-import-link-static/main.ts" + ], + "diags": [] + }, + "/tests/corpus/2703-type-import-link-static-reexport/main.ts": { + "order": [ + "/tests/corpus/2703-type-import-link-static-reexport/types.ts", + "/tests/corpus/2703-type-import-link-static-reexport/middle.ts", + "/tests/corpus/2703-type-import-link-static-reexport/main.ts" + ], + "diags": [] + }, + "/tests/corpus/2704-type-import-link-static-success/main.ts": { + "order": [ + "/tests/corpus/2704-type-import-link-static-success/types.ts", + "/tests/corpus/2704-type-import-link-static-success/main.ts" + ], + "diags": [] + }, + "/tests/corpus/2705-type-import-link-dynamic/main.ts": { + "order": [ + "/tests/corpus/2705-type-import-link-dynamic/types.ts", + "/tests/corpus/2705-type-import-link-dynamic/main.ts" + ], + "diags": [] + }, + "/tests/corpus/2706-type-import-link-dynamic-success/main.ts": { + "order": [ + "/tests/corpus/2706-type-import-link-dynamic-success/types.ts", + "/tests/corpus/2706-type-import-link-dynamic-success/main.ts" + ], + "diags": [] + }, + "/tests/corpus/2707-type-import-link-static-default-reexport/main.ts": { + "order": [ + "/tests/corpus/2707-type-import-link-static-default-reexport/types.ts", + "/tests/corpus/2707-type-import-link-static-default-reexport/reexport.ts", + "/tests/corpus/2707-type-import-link-static-default-reexport/main.ts" + ], + "diags": [] + }, + "/tests/corpus/2708-type-import-link-dynamic/main.ts": { + "order": [ + "/tests/corpus/2708-type-import-link-dynamic/main.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" @@ -6771,6 +6821,25 @@ } ] }, + "/tests/diagnostics/cjs-require-esm-type-link/main.js": { + "order": [ + "/tests/diagnostics/cjs-require-esm-type-link/types.mts", + "/tests/diagnostics/cjs-require-esm-type-link/child.mts", + "/tests/diagnostics/cjs-require-esm-type-link/main.js" + ], + "diags": [ + { + "code": "SC1013", + "message": "a named import of an unavailable export in an ES module reached through require() (Node throws its SyntaxError mid-evaluation at that require; make the import match the module's runtime exports) is not supported yet", + "loc": { + "file": "/tests/diagnostics/cjs-require-esm-type-link/child.mts", + "start": 9, + "end": 14 + }, + "milestone": "later" + } + ] + }, "/tests/diagnostics/cjs-require-tdz/main.js": { "order": [ "/tests/diagnostics/cjs-require-tdz/main.js" diff --git a/packages/compiler/test/ts7/order-parity.test.ts b/packages/compiler/test/ts7/order-parity.test.ts index ce39266e7..50f476c5a 100644 --- a/packages/compiler/test/ts7/order-parity.test.ts +++ b/packages/compiler/test/ts7/order-parity.test.ts @@ -60,7 +60,10 @@ const baseline = JSON.parse(readFileSync(baselinePath, "utf8")) as { /** Machine-independent spelling: absolute repo paths become "/…" in * file fields AND message text (cycle messages embed paths). */ -const rel = (s: string): string => s.split(repoRoot + "/").join("/"); +const rel = (s: string): string => + s.replaceAll("\\", "/").split(repoRoot.replaceAll("\\", "/") + "/").join("/"); + +const relativeName = (s: string): string => rel(s).replace(/^\//, ""); function nativeAnswer(host: Ts7Host, entry: string): BaselineEntry { const t7 = checkPreflightTs7(entry, host); @@ -134,12 +137,12 @@ if (UPDATE) { }); } else { describe(`preflight/order canary vs recorded 5.9.3 baselines (${entries.length} entries${FULL ? ", full sweep" : ""})`, () => { - test.for(chunks.map((c) => [`${c[0]!.slice(repoRoot.length + 1)} … +${c.length - 1}`, c] as const))( + test.for(chunks.map((c) => [`${relativeName(c[0]!)} … +${c.length - 1}`, c] as const))( "%s", async ([, chunk]) => { for (const entry of chunk) { await new Promise((r) => setImmediate(r)); // keep the worker RPC alive - const name = entry.slice(repoRoot.length + 1); + const name = relativeName(entry); const recorded = baseline.entries[rel(entry)]; expect( recorded, diff --git a/scripts/test-ts7.mjs b/scripts/test-ts7.mjs new file mode 100644 index 000000000..1dffe79b9 --- /dev/null +++ b/scripts/test-ts7.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const result = spawnSync( + pnpm, + ["exec", "vitest", "run", "packages/compiler/test/ts7"], + { + stdio: "inherit", + env: { ...process.env, SCRIPTC_TS7_ALL: "1" }, + }, +); + +if (result.error !== undefined) { + console.error(result.error.message); + process.exitCode = 1; +} else if (result.signal !== null) { + console.error(`TypeScript 7 parity sweep terminated by ${result.signal}`); + process.exitCode = 1; +} else { + process.exitCode = result.status ?? 1; +} diff --git a/tests/corpus/2702-type-import-link-static/main.ts b/tests/corpus/2702-type-import-link-static/main.ts new file mode 100644 index 000000000..91f40884b --- /dev/null +++ b/tests/corpus/2702-type-import-link-static/main.ts @@ -0,0 +1,7 @@ +// @exit: 1 +// A plain named import is a runtime ESM request even when TypeScript's +// checker resolves the requested binding as an interface. Node's strip-only +// loader rejects the graph at link time before either module evaluates. +import { value, Shape as MissingShape } from "./types.ts"; + +console.log("never runs", value); diff --git a/tests/corpus/2702-type-import-link-static/types.ts b/tests/corpus/2702-type-import-link-static/types.ts new file mode 100644 index 000000000..a0b9042bb --- /dev/null +++ b/tests/corpus/2702-type-import-link-static/types.ts @@ -0,0 +1,6 @@ +export interface Shape { + value: number; +} + +export const value = 7; +console.log("types evaluated"); diff --git a/tests/corpus/2703-type-import-link-static-reexport/main.ts b/tests/corpus/2703-type-import-link-static-reexport/main.ts new file mode 100644 index 000000000..56bff5aeb --- /dev/null +++ b/tests/corpus/2703-type-import-link-static-reexport/main.ts @@ -0,0 +1,6 @@ +// @exit: 1 +// The same runtime-export check follows an aliased type-only re-export. The +// failure belongs to this re-export request, before the importing entry runs. +import { value, RenamedShape } from "./middle.ts"; + +console.log("never runs", value); diff --git a/tests/corpus/2703-type-import-link-static-reexport/middle.ts b/tests/corpus/2703-type-import-link-static-reexport/middle.ts new file mode 100644 index 000000000..cdddec288 --- /dev/null +++ b/tests/corpus/2703-type-import-link-static-reexport/middle.ts @@ -0,0 +1,2 @@ +export { Shape as RenamedShape, value } from "./types.ts"; +console.log("middle evaluated"); diff --git a/tests/corpus/2703-type-import-link-static-reexport/types.ts b/tests/corpus/2703-type-import-link-static-reexport/types.ts new file mode 100644 index 000000000..a438c426c --- /dev/null +++ b/tests/corpus/2703-type-import-link-static-reexport/types.ts @@ -0,0 +1,6 @@ +export interface Shape { + value: number; +} + +export const value = 9; +console.log("types evaluated"); diff --git a/tests/corpus/2704-type-import-link-static-success/main.ts b/tests/corpus/2704-type-import-link-static-success/main.ts new file mode 100644 index 000000000..8b192f044 --- /dev/null +++ b/tests/corpus/2704-type-import-link-static-success/main.ts @@ -0,0 +1,8 @@ +// Type-qualified imports are erased from Node's runtime graph. Inline type +// specifiers and import type therefore execute the value import normally. +import { type Shape, value } from "./types.ts"; +import type { Shape as ImportedShape } from "./types.ts"; + +type LocalShape = Shape & ImportedShape; +const sample: LocalShape = { value }; +console.log("success", sample.value); diff --git a/tests/corpus/2704-type-import-link-static-success/types.ts b/tests/corpus/2704-type-import-link-static-success/types.ts new file mode 100644 index 000000000..c29515683 --- /dev/null +++ b/tests/corpus/2704-type-import-link-static-success/types.ts @@ -0,0 +1,6 @@ +export interface Shape { + value: number; +} + +export const value = 11; +console.log("types evaluated"); diff --git a/tests/corpus/2705-type-import-link-dynamic/main.ts b/tests/corpus/2705-type-import-link-dynamic/main.ts new file mode 100644 index 000000000..630c85acf --- /dev/null +++ b/tests/corpus/2705-type-import-link-dynamic/main.ts @@ -0,0 +1,7 @@ +// @dynamic +// @exit: 1 +// Dynamic islands do not change the ESM loader's instantiate contract: a +// plain import of an interface is still a missing runtime export. +import { value, Shape as MissingShape } from "./types.ts"; + +console.log("never runs", value); diff --git a/tests/corpus/2705-type-import-link-dynamic/types.ts b/tests/corpus/2705-type-import-link-dynamic/types.ts new file mode 100644 index 000000000..f085ba412 --- /dev/null +++ b/tests/corpus/2705-type-import-link-dynamic/types.ts @@ -0,0 +1,6 @@ +export interface Shape { + value: number; +} + +export const value = 13; +console.log("types evaluated"); diff --git a/tests/corpus/2706-type-import-link-dynamic-success/main.ts b/tests/corpus/2706-type-import-link-dynamic-success/main.ts new file mode 100644 index 000000000..edf0f5625 --- /dev/null +++ b/tests/corpus/2706-type-import-link-dynamic-success/main.ts @@ -0,0 +1,8 @@ +// @dynamic +// Type-qualified imports stay out of the island's runtime module graph. +import { type Shape, value } from "./types.ts"; +import type { Shape as ImportedShape } from "./types.ts"; + +type LocalShape = Shape & ImportedShape; +const sample: LocalShape = { value }; +console.log("dynamic success", sample.value); diff --git a/tests/corpus/2706-type-import-link-dynamic-success/types.ts b/tests/corpus/2706-type-import-link-dynamic-success/types.ts new file mode 100644 index 000000000..710e8e509 --- /dev/null +++ b/tests/corpus/2706-type-import-link-dynamic-success/types.ts @@ -0,0 +1,6 @@ +export interface Shape { + value: number; +} + +export const value = 17; +console.log("types evaluated"); diff --git a/tests/corpus/2707-type-import-link-static-default-reexport/main.ts b/tests/corpus/2707-type-import-link-static-default-reexport/main.ts new file mode 100644 index 000000000..d4de53a03 --- /dev/null +++ b/tests/corpus/2707-type-import-link-static-default-reexport/main.ts @@ -0,0 +1,7 @@ +// @exit: 1 +// An explicit ESM package does not expose a strip-only default-interface +// placeholder, including when a second module re-exports that default. +import DefaultShape from "./reexport.ts"; + +type LocalShape = DefaultShape; +console.log("never runs"); diff --git a/tests/corpus/2707-type-import-link-static-default-reexport/reexport.ts b/tests/corpus/2707-type-import-link-static-default-reexport/reexport.ts new file mode 100644 index 000000000..e486a89cf --- /dev/null +++ b/tests/corpus/2707-type-import-link-static-default-reexport/reexport.ts @@ -0,0 +1 @@ +export { default } from "./types.ts"; diff --git a/tests/corpus/2707-type-import-link-static-default-reexport/types.ts b/tests/corpus/2707-type-import-link-static-default-reexport/types.ts new file mode 100644 index 000000000..d6c432813 --- /dev/null +++ b/tests/corpus/2707-type-import-link-static-default-reexport/types.ts @@ -0,0 +1,3 @@ +export default interface Shape { + value: number; +} diff --git a/tests/corpus/2708-type-import-link-dynamic/child.ts b/tests/corpus/2708-type-import-link-dynamic/child.ts new file mode 100644 index 000000000..06feeaf5d --- /dev/null +++ b/tests/corpus/2708-type-import-link-dynamic/child.ts @@ -0,0 +1,2 @@ +import { Shape } from "./types.ts"; +console.log("child evaluated"); diff --git a/tests/corpus/2708-type-import-link-dynamic/main.ts b/tests/corpus/2708-type-import-link-dynamic/main.ts new file mode 100644 index 000000000..78deb085c --- /dev/null +++ b/tests/corpus/2708-type-import-link-dynamic/main.ts @@ -0,0 +1,10 @@ +// @dynamic +// @exit: 0 +// A missing type-only export in a dynamic child rejects import() at link time, +// before the child or its dependency evaluates. +try { + await import("./child.ts"); + console.log("unexpected"); +} catch (error) { + console.log(error instanceof Error ? error.message : String(error)); +} diff --git a/tests/corpus/2708-type-import-link-dynamic/types.ts b/tests/corpus/2708-type-import-link-dynamic/types.ts new file mode 100644 index 000000000..7e20dcec7 --- /dev/null +++ b/tests/corpus/2708-type-import-link-dynamic/types.ts @@ -0,0 +1,4 @@ +export interface Shape { + value: number; +} +console.log("types evaluated"); diff --git a/tests/diagnostics/cjs-require-esm-type-link/child.mts b/tests/diagnostics/cjs-require-esm-type-link/child.mts new file mode 100644 index 000000000..a9f279dcc --- /dev/null +++ b/tests/diagnostics/cjs-require-esm-type-link/child.mts @@ -0,0 +1,3 @@ +import { Shape } from './types.mts'; +console.log('child evaluated'); +export const value = 3; diff --git a/tests/diagnostics/cjs-require-esm-type-link/main.js b/tests/diagnostics/cjs-require-esm-type-link/main.js new file mode 100644 index 000000000..5c4743a9d --- /dev/null +++ b/tests/diagnostics/cjs-require-esm-type-link/main.js @@ -0,0 +1,7 @@ +// A CommonJS require reaches an ESM graph. Node links that graph at the +// require site and rejects a plain import of a type-only export there. +'use strict'; + +console.log('before require'); +require('./child.mts'); +console.log('after require'); diff --git a/tests/diagnostics/cjs-require-esm-type-link/types.mts b/tests/diagnostics/cjs-require-esm-type-link/types.mts new file mode 100644 index 000000000..382edad0b --- /dev/null +++ b/tests/diagnostics/cjs-require-esm-type-link/types.mts @@ -0,0 +1,2 @@ +export interface Shape { value: number } +console.log('types evaluated'); diff --git a/tests/harness/__snapshots__/cjs-require-esm-type-link/main.js.txt b/tests/harness/__snapshots__/cjs-require-esm-type-link/main.js.txt new file mode 100644 index 000000000..82b1c4257 --- /dev/null +++ b/tests/harness/__snapshots__/cjs-require-esm-type-link/main.js.txt @@ -0,0 +1,5 @@ +cjs-require-esm-type-link/child.mts:1:10 - error SC1013: a named import of an unavailable export in an ES module reached through require() (Node throws its SyntaxError mid-evaluation at that require; make the import match the module's runtime exports) is not supported yet + + 1 | import { Shape } from './types.mts'; + | ^~~~~ + 2 | console.log('child evaluated'); \ No newline at end of file diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index ed9ac424a..94cffdbe0 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -523,6 +523,173 @@ console.log('never runs', a); }); }); +// Node's strip-only TypeScript loader keeps ordinary named imports in the +// ESM request graph. If the requested declaration is type-only, Node refuses +// during linking even though the TypeScript checker accepts the import. The +// corpus pins stdout and exit; these assertions pin scriptc's exact uncaught +// SyntaxError message and the local-name ordering used to choose the first +// failure. +describe("native ESM type-import link SyntaxError messages", () => { + test("an unqualified interface import fails before any module evaluates", async () => { + const r = await compileAndRun( + "esm-type-link", + `import { value, Shape as MissingShape } from './types.ts'; +console.log('never runs', value); +`, + "ts", + { + "types.ts": `export interface Shape { value: number } +export const value = 7; +console.log('types evaluated'); +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n", + ); + }); + + test("a child re-export failure wins before the parent import is linked", async () => { + const r = await compileAndRun( + "esm-type-link-reexport", + `import { Zed as zed, Aa as aaa, value } from './middle.ts'; +console.log('never runs', value); +`, + "ts", + { + "middle.ts": `export { Shape as Zed, Other as Aa, value } from './types.ts'; +console.log('middle evaluated'); +`, + "types.ts": `export interface Shape { value: number } +export type Other = string; +export const value = 9; +console.log('types evaluated'); +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n", + ); + }); + + test("direct imports choose the first missing binding by local name", async () => { + const r = await compileAndRun( + "esm-type-link-order", + `import { Shape as zed, Other as aaa } from './types.ts'; +console.log('never runs'); +`, + "ts", + { + "types.ts": `export interface Shape { value: number } +export type Other = string; +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Other'\n", + ); + }); + + test("dynamic islands retain the native ESM link failure", async () => { + const r = await compileAndRun( + "esm-type-link-dynamic", + `// @dynamic +import { Shape as MissingShape } from './types.ts'; +console.log('never runs'); +`, + "ts", + { "types.ts": "export interface Shape { value: number }\nconsole.log('types evaluated');\n" }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n", + ); + }); + + test("default interface exports satisfy Node's strip-only default link", async () => { + const r = await compileAndRun( + "esm-default-interface-link", + `import DefaultShape from './types.ts'; +type LocalShape = DefaultShape; +console.log('ok'); +`, + "ts", + { "types.ts": "export default interface DefaultShape { value: number }\n" }, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("ok\n"); + expect(r.stderr).toBe(""); + }); + + test("explicit module packages do not expose default-interface placeholders", async () => { + const r = await compileAndRun( + "esm-default-interface-explicit-module", + `import DefaultShape from './types.ts'; +type LocalShape = DefaultShape; +console.log('never runs'); +`, + "ts", + { + "package.json": `{ "type": "module" }\n`, + "types.ts": "export default interface DefaultShape { value: number }\n", + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'default'\n", + ); + }); + + test("typeless packages preserve default-interface placeholders through re-exports", async () => { + const r = await compileAndRun( + "esm-default-interface-reexport", + `import DefaultShape from './reexport.ts'; +type LocalShape = DefaultShape; +console.log('ok'); +`, + "ts", + { + "reexport.ts": `export { default } from './types.ts';\n`, + "types.ts": "export default interface DefaultShape { value: number }\n", + }, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("ok\n"); + expect(r.stderr).toBe(""); + }); + + test("a child native-ESM failure wins over a later parent CJS failure", async () => { + const r = await compileAndRun( + "esm-type-link-before-cjs", + `import './middle.ts'; +import { missing } from './table.cjs'; +console.log('never runs', missing); +`, + "ts", + { + "middle.ts": `import { Shape } from './types.ts'; +console.log('middle never runs'); +`, + "types.ts": "export interface Shape { value: number }\nconsole.log('types evaluated');\n", + "table.cjs": "module.exports = { missing: 7 };\n", + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n", + ); + }); +}); + describe("checked-dynamic/island boundary fences (scriptc-only)", () => { test("an island-typed argument into a call through 'unknown' runs Node-exactly (the retired SC1101 fence)", async () => { // `this` in a plain JS function is the checked-dynamic ambient