diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a335c8..fbe4d461 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,8 @@ jobs: node packages/runtime-linux-x64-musl/scripts/verify.mjs - 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" @@ -323,7 +325,7 @@ jobs: } - run: pnpm build - name: Windows path regressions - run: pnpm exec vitest run packages/compiler/test/ts7/program.test.ts packages/cli/test/paths.test.ts + run: pnpm exec vitest run packages/compiler/test/ts7/program-adapter.test.ts packages/compiler/test/ts7/resolver-parity.test.ts packages/cli/test/paths.test.ts - name: Windows Sandbox path regression run: >- pnpm exec vitest run tests/harness/worktree-files.test.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 3ba1d722..9b0c7cb5 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": "981ad58e8b022358310b", + "artifactVersion": "66b252bc9bee6f91f667", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index b649810e..0a317f25 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 cf7a5096..7d442d2a 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", "tests/corpus/2790-dynamic-import-self-name/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/corpus/2708-type-import-link-dynamic/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 301cfe30..69130dd9 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": 2759, "audit-partial": 376, "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 } @@ -62212,39 +62212,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 f14284d5..b8890f40 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 @@ -78465,12 +78465,25 @@ "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/corpus/2707-type-import-link-static-default-reexport/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/corpus/2708-type-import-link-dynamic/main.ts", + "tests/harness/errors.test.ts" + ] }, "anchorSource": "chapter" }, @@ -78627,12 +78640,25 @@ "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/corpus/2707-type-import-link-static-default-reexport/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/corpus/2708-type-import-link-dynamic/main.ts", + "tests/harness/errors.test.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 27575b98..d25c055d 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -86,7 +86,7 @@ { "chapter": "fs", "signatures": ["`filehandle[Symbol.asyncDispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "readline", "signatures": ["`rl[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "timers", "signatures": ["`immediate[Symbol.dispose]()`", "`timeout[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, - { "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/corpus/2707-type-import-link-static-default-reexport/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 4aa3bd4b..522ea8ee 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 6472a7ba..2fb8bcc4 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/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index 1b6f47d0..e82fc613 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -62,6 +62,16 @@ function pathKind(path: string): Extract["ki } } +/** Mirrors TypeScript's system realpath policy: the native implementation + * canonicalizes Windows casing and expands 8.3 short names, while the JS + * implementation remains the fallback for Windows paths at the legacy + * MAX_PATH boundary. */ +function systemRealpath(path: string): string { + return process.platform === "win32" && path.length >= 260 + ? realpathSync(path) + : realpathSync.native(path); +} + const activeTracker = new AsyncLocalStorage(); export class FrontendInputTracker { @@ -159,7 +169,7 @@ export function trackedExists(path: string): boolean { export function trackedRealpath(path: string): string | null { path = resolve(path); try { - const target = realpathSync(path); + const target = systemRealpath(path); record({ op: "realpath", path, target }); return target; } catch { @@ -247,7 +257,7 @@ export function frontendInputsStillMatch( } if (probe.op === "realpath" && probe.target === null) { try { - realpathSync(probe.path); + systemRealpath(probe.path); return generatedOnlyDirectory(probe.path); } catch { return true; @@ -318,7 +328,7 @@ export function frontendInputsStillMatch( } case "realpath": { try { - return realpathSync(probe.path) === probe.target; + return systemRealpath(probe.path) === probe.target; } catch { return probe.target === null; } diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 475ed5a2..f92983bb 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,31 @@ 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. Throw from the synthesized namespace builder, which runs + // inside the Promise reaction, so no module init executes before the + // link failure and the bridge turns it into the import rejection. + 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 7224007c..6565c7d7 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1962,9 +1962,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 e7bf7786..95e30015 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -343,7 +343,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 @@ -425,8 +425,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 6228bede..61f5e190 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -251,8 +251,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"; @@ -2524,7 +2524,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 }; } @@ -2721,6 +2727,310 @@ 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 => { + const project = resolveImport7(program, from, spec); + if (project !== null) return project; + return npmStaticDepSf7(program, from, spec); + }; + + const runtimeExport = ( + dep: ts.SourceFile, + name: string, + seenModules: ReadonlySet = new Set(), + ): ts.Symbol | undefined => { + if (seenModules.has(dep)) return undefined; + const nextSeen = new Set(seenModules); + nextSeen.add(dep); + const module = checker.getSymbolAtLocation(dep); + const exported = module?.getExports().get(name as ts.__String); + if (exported !== undefined) { + let resolved = exported; + const seenSymbols = new Set(); + while ((resolved.flags & ts.SymbolFlags.Alias) !== 0 && !seenSymbols.has(resolved)) { + seenSymbols.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 correctly classifies the declaration as + // type-only, but the native ESM linker sees the runtime placeholder. + 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; + } + + // TypeScript 7's client-side module symbol exposes the module's own + // export table but not names contributed by `export *`. Follow those + // source edges explicitly, applying the ESM ambiguity rule: the same + // resolved symbol may arrive through multiple stars, while distinct + // symbols make the name unavailable. `default` never star-reexports. + if (name === "default") return undefined; + let starExport: ts.Symbol | undefined; + for (const stmt of dep.statements) { + if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly || stmt.exportClause !== undefined) continue; + if (stmt.moduleSpecifier === undefined || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; + const target = resolveEdge(dep, stmt.moduleSpecifier.text); + if (target === null || target.fileName.endsWith(".json") || !isNodeEsmFile7(target)) continue; + const candidate = runtimeExport(target, name, nextSeen); + if (candidate === undefined) continue; + if (starExport !== undefined && starExport !== candidate) return undefined; + starExport = candidate; + } + return starExport; + }; + + 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 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/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 381db68a..5c1a9e43 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -30,7 +30,19 @@ function isDirectory(path: string): boolean { } function realpathOr(path: string): string { - return trackedRealpath(path) ?? path; + return normalizeResolvedPath(trackedRealpath(path) ?? path); +} + +/** TypeScript exposes resolved file names in its platform-independent path + * spelling, including on Windows where node:path and node:fs use `\\`. + * Keep native paths while probing, then normalize every resolver answer at + * the boundary so TS5 and TS7 callers observe the same contract. */ +function normalizeResolvedPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function normalizeResolvedAnswer(path: string | null): string | null { + return path === null ? null : normalizeResolvedPath(path); } interface PkgJson { @@ -329,16 +341,16 @@ export function resolveRelativeModule(fromFile: string, specifier: string): stri // claim, the JS is the code that compiles (npm-static.ts — the tsgo // host hides the same files, so both worlds answer the JS). if (npmStaticPackageOfPath(resolve(fromFile)) !== null) { - return loadAsJsFile(base) ?? loadAsJsDirectory(base); + return normalizeResolvedAnswer(loadAsJsFile(base) ?? loadAsJsDirectory(base)); } const answer = loadAsFile(base) ?? loadAsDirectory(base); // A project declaration TWIN answers its runtime sibling (see // projectDtsRuntimeSibling — Node's truth for project code). if (answer !== null) { const sibling = projectDtsRuntimeSibling(answer); - if (sibling !== null) return sibling; + if (sibling !== null) return normalizeResolvedPath(sibling); } - return answer; + return normalizeResolvedAnswer(answer); } /** The JS-only twin of loadAsFile for --npm-static package internals: @@ -560,9 +572,9 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin // order, and the CJS link check all agree the package compiles as // program modules instead of island-embedding its published dist. const provenance = provenanceEntryFor(specifier); - if (provenance !== null) return provenance; + if (provenance !== null) return normalizeResolvedPath(provenance); const viaPaths = resolveViaProjectPaths(specifier); - if (viaPaths !== null) return viaPaths; + if (viaPaths !== null) return normalizeResolvedPath(viaPaths); const pkgDir = nearestPkgDir(dirname(resolve(fromFile))); if (pkgDir === null) return null; const pkg = pkgJsonOf(pkgDir) as (PkgJson & { imports?: unknown; exports?: unknown; type?: string }) | null; @@ -594,7 +606,7 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin } if (target === null) return null; const path = join(pkgDir, target); - return loadAsFile(path) ?? (isFile(path) ? path : null); + return normalizeResolvedAnswer(loadAsFile(path) ?? (isFile(path) ? path : null)); } /** The one resolver entry point for source modules that compile into the @@ -895,5 +907,5 @@ export function clearResolveCaches(): void { /** True when `path` is under a node_modules directory (the * isExternalLibraryImport test 5.9.3 answers on resolutions). */ export function isNodeModulesPath(path: string): boolean { - return isAbsolute(path) && path.split("/").includes("node_modules"); + return isAbsolute(path) && normalizeResolvedPath(path).split("/").includes("node_modules"); } diff --git a/packages/compiler/src/frontend/ts7/adapter.ts b/packages/compiler/src/frontend/ts7/adapter.ts index 54ed247e..6e090439 100644 --- a/packages/compiler/src/frontend/ts7/adapter.ts +++ b/packages/compiler/src/frontend/ts7/adapter.ts @@ -13,13 +13,13 @@ * * TWO-WORLD DISCIPLINE. typescript@7.0.2 is the REAL "typescript" * dependency; typescript@5.9.3 stays installed under the "typescript5" - * alias for the parser/transpile islands only (npm.ts's module edge scan - * over node_modules JS — 7.0.2 ships no client-side parser — cjs-lexer.ts's - * merve-port lex over CJS source text, and lower-comptime's - * transpileModule). Nothing may hand a 5.9.3 node, type, symbol, or enum - * value to this world or back (cjs-lexer.ts's exports take and answer - * strings and name sets only — its 5.9.3 parse is an implementation - * detail behind that boundary): + * alias for string-bounded parser/transpile islands only. TypeScript 7.0.2 + * ships no client-side parser or transpileModule equivalent, so the npm, + * provenance, semantic-source, CJS-lexer, and comptime helpers retain that + * implementation detail. scripts/test-ts7.mjs owns the exact import + * allowlist. Nothing may hand a 5.9.3 node, type, symbol, or enum value to + * this world or back; every island accepts source strings and returns + * world-neutral facts or rewritten strings: * - Mixing OBJECTS is a compile-time error: every node interface carries * `kind: SyntaxKind` and the two packages declare DISTINCT enums, which * TypeScript treats nominally — a 5.9.3 SourceFile is not assignable @@ -27,17 +27,16 @@ * with @ts-expect-error assertions that pnpm build enforces). * - Mixing ENUM VALUES cannot be fenced by the type system alone (both * erase to number), which is why every enum here re-exports 7's own - * objects symbolically and no scriptc source may import "typescript5" - * outside the two island files. + * objects symbolically and no new scriptc source may import + * "typescript5" outside the enforced island allowlist. * * Census coverage not present here, by design (the survey's MISSING list): * - ts.createSourceFile / ts.preProcessFile — no client-side parser in 7; * the npm.ts edge scan keeps 5.9.3 (island). * - ts.transpileModule — lower-comptime keeps 5.9.3 (island). - * - ts.resolveModuleName / ts.resolveTypeReferenceDirective — resolution - * helpers stay 5.9.3-hosted for now (they take ts.sys-shaped hosts and - * never exchange AST/checker objects with either world; tsgo resolves - * the embedded program itself, server-side). + * - ts.resolveModuleName / ts.resolveTypeReferenceDirective — replaced by + * resolve.ts, the one resolver shared by the TypeScript 7 program graph + * and lowering. * - ts.readConfigFile / ts.parseJsonConfigFileContent — replaced by * Ts7Host.parseConfigFile (tsgo's own config parser, extends resolved * server-side). diff --git a/packages/compiler/src/frontend/ts7/census-check.ts b/packages/compiler/src/frontend/ts7/census-check.ts index f704a84b..d172d3b2 100644 --- a/packages/compiler/src/frontend/ts7/census-check.ts +++ b/packages/compiler/src/frontend/ts7/census-check.ts @@ -5,9 +5,9 @@ * name drops out of the adapter's surface. * * Not present, per the survey's MISSING list and the two-world design: - * ts.Types (a comment-text census artifact, not an API), and the island - * surface that stays 5.9.3 (createSourceFile/preProcessFile/transpileModule - * and the resolution/config-file helpers' 5.9.3 types). */ + * ts.Types (a comment-text census artifact, not an API), the parser and + * transpile surface kept in TypeScript 5 islands, and the resolution/config + * helpers replaced by scriptc and the TypeScript 7 host. */ import type * as ts from "./adapter.js"; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 45a89e27..bde285f7 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -509,8 +509,8 @@ export interface AnalyzeOptions { * 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 as the sanctioned islands: npm.ts's - * parse scan and lower-comptime's transpileModule). Everything after + * (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 { diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index bdb57a1f..dddff267 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5707,6 +5707,13 @@ ], "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/2702-uint8array-to-sorted.ts": { "order": [ "/tests/corpus/2702-uint8array-to-sorted.ts" @@ -5719,30 +5726,73 @@ ], "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-array-sparse-to-sorted.ts": { "order": [ "/tests/corpus/2704-array-sparse-to-sorted.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-array-sparse-sort-append.ts": { "order": [ "/tests/corpus/2705-array-sparse-sort-append.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-array-search-missing-needle.ts": { "order": [ "/tests/corpus/2706-array-search-missing-needle.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-array-search-huge-sparse.ts": { "order": [ "/tests/corpus/2707-array-search-huge-sparse.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/2720-array-read-parity.ts": { "order": [ "/tests/corpus/2720-array-read-parity.ts" @@ -7066,6 +7116,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/coverage.test.ts b/packages/compiler/test/ts7/coverage.test.ts index 2327f5e7..3c0e2f3e 100644 --- a/packages/compiler/test/ts7/coverage.test.ts +++ b/packages/compiler/test/ts7/coverage.test.ts @@ -33,9 +33,10 @@ const ENUMS: [name: string, keys: string[]][] = [ ["DiagnosticCategory", ["Error", "Warning", "Suggestion", "Message"]], ]; -/* The survey's MISSING list that stays 5.9.3-hosted (islands) — the adapter - * must NOT grow look-alikes silently; phase 2 keeps these imports on the - * old package. */ +/* The survey's MISSING list that stays outside the adapter — the adapter + * must NOT grow look-alikes silently. Source parsing/transpilation remains + * in the allowlisted TypeScript 5 islands; resolution and config parsing use + * scriptc's resolver and the TypeScript 7 host instead. */ const DEFERRED = [ "createSourceFile", "preProcessFile", "transpileModule", "resolveModuleName", "resolveTypeReferenceDirective", diff --git a/packages/compiler/test/ts7/order-parity.test.ts b/packages/compiler/test/ts7/order-parity.test.ts index ce39266e..50f476c5 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/packages/compiler/test/ts7/resolver-parity.test.ts b/packages/compiler/test/ts7/resolver-parity.test.ts index 13993eba..26a7d389 100644 --- a/packages/compiler/test/ts7/resolver-parity.test.ts +++ b/packages/compiler/test/ts7/resolver-parity.test.ts @@ -355,7 +355,9 @@ test("synthetic project paths use TypeScript's exact and best-pattern resolution ), ); const from = join(dir, "main.ts"); - expect(resolveProjectModule(from, join(dir, "src/exact.ts"))).toBe(join(dir, "src/exact.ts")); + expect(resolveProjectModule(from, join(dir, "src/exact.ts"))).toBe( + join(dir, "src/exact.ts").replaceAll("\\", "/"), + ); for (const spec of ["exact", "@app/value", "@app/special/value", "@app/missing"]) { const reference = ts5.resolveModuleName( spec, diff --git a/scripts/test-ts7.mjs b/scripts/test-ts7.mjs new file mode 100644 index 00000000..4ecc1b86 --- /dev/null +++ b/scripts/test-ts7.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const ALLOWED_TYPESCRIPT5_IMPORTS = new Set([ + "packages/compiler/src/frontend/cjs-lexer.ts", + "packages/compiler/src/frontend/lowering/lower-comptime.ts", + "packages/compiler/src/frontend/npm-static-rewrite.ts", + "packages/compiler/src/frontend/npm.ts", + "packages/compiler/src/frontend/provenance.ts", + "packages/compiler/src/frontend/ts7/world-check.ts", + "packages/compiler/src/library/semantic-source.ts", + "packages/compiler/test/ts7/harness.ts", + "packages/compiler/test/ts7/resolver-parity.test.ts", +]); + +function sourceFilesUnder(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = resolve(dir, entry.name); + if (entry.isDirectory()) files.push(...sourceFilesUnder(path)); + else if (entry.isFile() && /\.[cm]?tsx?$/.test(entry.name)) files.push(path); + } + return files; +} + +const importPattern = /(?:from\s+|import\s*\()\s*["']typescript5["']/; +const actualImports = new Set( + [ + ...sourceFilesUnder(resolve(repoRoot, "packages/compiler/src")), + ...sourceFilesUnder(resolve(repoRoot, "packages/compiler/test")), + ] + .filter((path) => importPattern.test(readFileSync(path, "utf8"))) + .map((path) => relative(repoRoot, path).replaceAll("\\", "/")), +); + +const missing = [...ALLOWED_TYPESCRIPT5_IMPORTS].filter((path) => !actualImports.has(path)); +const unexpected = [...actualImports].filter((path) => !ALLOWED_TYPESCRIPT5_IMPORTS.has(path)); +if (missing.length > 0 || unexpected.length > 0) { + if (missing.length > 0) console.error(`Stale TypeScript 5 island allowlist entries:\n${missing.join("\n")}`); + if (unexpected.length > 0) console.error(`Unexpected TypeScript 5 imports:\n${unexpected.join("\n")}`); + process.exit(1); +} + +const compilerPackage = JSON.parse( + readFileSync(resolve(repoRoot, "packages/compiler/package.json"), "utf8"), +); +if (compilerPackage.scripts?.build !== "node node_modules/typescript/bin/tsc -p tsconfig.json") { + console.error("@scriptc/compiler must build with the TypeScript 7 toolchain"); + process.exit(1); +} + +const vitest = resolve(repoRoot, "node_modules/vitest/vitest.mjs"); +const result = spawnSync( + process.execPath, + [vitest, "run", "packages/compiler/test/ts7"], + { + cwd: repoRoot, + 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 00000000..91f40884 --- /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 00000000..a0b9042b --- /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 00000000..56bff5ae --- /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 00000000..cdddec28 --- /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 00000000..a438c426 --- /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 00000000..8b192f04 --- /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 00000000..c2951568 --- /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 00000000..630c85ac --- /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 00000000..f085ba41 --- /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 00000000..edf0f562 --- /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 00000000..710e8e50 --- /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 00000000..d4de53a0 --- /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 00000000..e486a89c --- /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 00000000..d6c43281 --- /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 00000000..06feeaf5 --- /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 00000000..78deb085 --- /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 00000000..7e20dcec --- /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 00000000..a9f279dc --- /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 00000000..5c4743a9 --- /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 00000000..382edad0 --- /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 00000000..82b1c425 --- /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 ed9ac424..94cffdbe 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