diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 9b0c7cb5..03ad8c38 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": "66b252bc9bee6f91f667", + "artifactVersion": "039dcd2c69e09f0e38f2", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 0a317f25..84a891e5 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -602,18 +602,18 @@ "apiEntries": 27, "static": { "supported": 0, - "partial": 13, + "partial": 15, "refused": 0, - "not-implemented": 11, + "not-implemented": 9, "by-design": 0, "unreviewed": 3, "not-applicable": 0 }, "dynamic": { "supported": 0, - "partial": 18, + "partial": 20, "refused": 0, - "not-implemented": 9, + "not-implemented": 7, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -626,18 +626,18 @@ "apiEntries": 22, "static": { "supported": 0, - "partial": 11, + "partial": 12, "refused": 0, - "not-implemented": 11, + "not-implemented": 10, "by-design": 0, "unreviewed": 0, "not-applicable": 0 }, "dynamic": { "supported": 0, - "partial": 8, + "partial": 9, "refused": 0, - "not-implemented": 14, + "not-implemented": 13, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -56489,14 +56489,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "Not implemented in scriptc's static module-loader subset yet.", - "verification": "declared-gap" + "status": "partial", + "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": "partial", + "detail": "Implemented for the documented embedded module-loader subset.", + "verification": "test-backed" } }, { @@ -56517,14 +56517,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "Not implemented in scriptc's static module-loader subset yet.", - "verification": "declared-gap" + "status": "partial", + "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": "partial", + "detail": "Implemented for the documented embedded module-loader subset.", + "verification": "test-backed" } }, { @@ -57105,14 +57105,14 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "Not implemented in scriptc's static module-loader subset yet.", - "verification": "declared-gap" + "status": "partial", + "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": "partial", + "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 7d442d2a..a8b4dc18 100644 --- a/internal/compatibility/dynamic-support.json +++ b/internal/compatibility/dynamic-support.json @@ -22,11 +22,13 @@ { "chapter": "environment_variables", "symbols": ["process.env"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/misc-shims/main.ts"] }, { "chapter": "environment_variables", "symbols": ["export"], "status": "not-applicable" }, { "chapter": "modules", "symbols": ["__dirname", "__filename", "exports", "module", "require", "cache", "filename", "id", "loaded", "parent", "path"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/create-require/main.ts", "tests/fixtures/npm/cases/dynamic-import/main.ts"] }, - { "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths", "extensions", "main", "module.require", "children", "isPreloading", "paths", "Module"], "status": "not-implemented" }, + { "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/create-require/main.ts"] }, + { "chapter": "modules", "symbols": ["extensions", "main", "module.require", "children", "isPreloading", "paths", "Module"], "status": "not-implemented" }, { "chapter": "modules", "symbols": ["node:", "node_modules"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/builtin-shims/main.ts", "tests/fixtures/npm/cases/create-require/main.ts"] }, { "chapter": "esm", "symbols": ["import", "file:", "node:", "await", "url"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/dynamic-import/main.ts", "tests/corpus/2646-top-level-await.ts"] }, { "chapter": "esm", "signatures": ["`import()` expressions"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/dynamic-import/main.ts", "tests/corpus/2050-dynamic-import-own-module/main.ts", "tests/corpus/2790-dynamic-import-self-name/main.ts"] }, - { "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": "esm", "symbols": ["import.meta.resolve"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/dual-entry/main.ts"] }, + { "chapter": "esm", "symbols": ["meta", "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": "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"] } diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 69130dd9..2e42506d 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -9,15 +9,15 @@ "static": { "replace-refusal": 73, "verify-gap": 2759, - "audit-partial": 376, + "audit-partial": 379, "classify": 3, - "implement": 92 + "implement": 89 }, "dynamic": { - "audit-partial": 745, + "audit-partial": 748, "verify-gap": 2302, "replace-refusal": 101, - "implement": 220 + "implement": 217 } }, "priorities": { @@ -59564,20 +59564,25 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", "source": "compiler-feature:modules.require.resolve", - "tests": [] + "tests": [ + "tests/corpus/2858-import-meta-resolve/main.mjs", + "tests/corpus/2860-require-resolve/main.cjs" + ] }, "dynamic": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", "source": "island-feature:modules.require.resolve", - "tests": [] + "tests": [ + "tests/fixtures/npm/cases/create-require/main.ts" + ] } } }, @@ -59597,20 +59602,25 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", "source": "compiler-feature:modules.require.resolve.paths", - "tests": [] + "tests": [ + "tests/corpus/2858-import-meta-resolve/main.mjs", + "tests/corpus/2860-require-resolve/main.cjs" + ] }, "dynamic": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", "source": "island-feature:modules.require.resolve.paths", - "tests": [] + "tests": [ + "tests/fixtures/npm/cases/create-require/main.ts" + ] } } }, @@ -60321,20 +60331,24 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", "source": "compiler-feature:esm.import.meta.resolve", - "tests": [] + "tests": [ + "tests/corpus/2858-import-meta-resolve/main.mjs" + ] }, "dynamic": { - "status": "not-implemented", - "action": "implement", - "verification": "declared-gap", - "confidence": "medium", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", "source": "island-feature:esm.import.meta.resolve", - "tests": [] + "tests": [ + "tests/fixtures/npm/cases/dual-entry/main.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index b8890f40..0c7840c2 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -675,18 +675,18 @@ "apiEntries": 27, "static": { "supported": 0, - "partial": 13, + "partial": 15, "refused": 0, - "not-implemented": 11, + "not-implemented": 9, "by-design": 0, "unreviewed": 3, "not-applicable": 0 }, "dynamic": { "supported": 0, - "partial": 18, + "partial": 20, "refused": 0, - "not-implemented": 9, + "not-implemented": 7, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -699,18 +699,18 @@ "apiEntries": 22, "static": { "supported": 0, - "partial": 11, + "partial": 12, "refused": 0, - "not-implemented": 11, + "not-implemented": 10, "by-design": 0, "unreviewed": 0, "not-applicable": 0 }, "dynamic": { "supported": 0, - "partial": 8, + "partial": 9, "refused": 0, - "not-implemented": 14, + "not-implemented": 13, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -73697,7 +73697,9 @@ "tests/corpus/1598-cjs-builtin-require/main.js", "tests/corpus/1612-cjs-module-globals.cjs", "tests/corpus/1629-require-main-filename.cjs", - "tests/corpus/2830-cjs-require-self-name/main.cjs" + "tests/corpus/2830-cjs-require-self-name/main.cjs", + "tests/corpus/2858-import-meta-resolve/main.mjs", + "tests/corpus/2860-require-resolve/main.cjs" ] }, "dynamic": { @@ -73971,12 +73973,19 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-feature:modules.require.resolve" + "status": "partial", + "evidence": "compiler-feature:modules.require.resolve", + "tests": [ + "tests/corpus/2858-import-meta-resolve/main.mjs", + "tests/corpus/2860-require-resolve/main.cjs" + ] }, "dynamic": { - "status": "not-implemented", - "evidence": "island-feature:modules.require.resolve" + "status": "partial", + "evidence": "island-feature:modules.require.resolve", + "tests": [ + "tests/fixtures/npm/cases/create-require/main.ts" + ] }, "anchorSource": "exact" }, @@ -73998,12 +74007,19 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-feature:modules.require.resolve.paths" + "status": "partial", + "evidence": "compiler-feature:modules.require.resolve.paths", + "tests": [ + "tests/corpus/2858-import-meta-resolve/main.mjs", + "tests/corpus/2860-require-resolve/main.cjs" + ] }, "dynamic": { - "status": "not-implemented", - "evidence": "island-feature:modules.require.resolve.paths" + "status": "partial", + "evidence": "island-feature:modules.require.resolve.paths", + "tests": [ + "tests/fixtures/npm/cases/create-require/main.ts" + ] }, "anchorSource": "exact" }, @@ -74947,6 +74963,7 @@ "tests": [ "tests/corpus/2646-top-level-await.ts", "tests/corpus/2701-import-meta/main.mjs", + "tests/corpus/2858-import-meta-resolve/main.mjs", "tests/corpus/950-modules-basic/main.ts" ] }, @@ -74955,6 +74972,7 @@ "evidence": "derived:descendants", "tests": [ "tests/corpus/2646-top-level-await.ts", + "tests/fixtures/npm/cases/dual-entry/main.ts", "tests/fixtures/npm/cases/dynamic-import/main.ts" ] }, @@ -75005,12 +75023,18 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-feature:esm.import.meta.resolve" + "status": "partial", + "evidence": "compiler-feature:esm.import.meta.resolve", + "tests": [ + "tests/corpus/2858-import-meta-resolve/main.mjs" + ] }, "dynamic": { - "status": "not-implemented", - "evidence": "island-feature:esm.import.meta.resolve" + "status": "partial", + "evidence": "island-feature:esm.import.meta.resolve", + "tests": [ + "tests/fixtures/npm/cases/dual-entry/main.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index d25c055d..67a144e6 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -75,11 +75,13 @@ { "chapter": "environment_variables", "symbols": ["export"], "status": "not-applicable" }, { "chapter": "globals", "symbols": ["fetch"], "status": "partial", "evidence": ["tests/harness/fetch-conformance.test.ts"] }, { "chapter": "modules", "symbols": ["__dirname", "__filename", "exports", "require", "module.require", "main", "filename"], "status": "partial", "evidence": ["tests/corpus/1612-cjs-module-globals.cjs", "tests/corpus/1596-cjs-modules/main.js", "tests/corpus/1629-require-main-filename.cjs", "tests/corpus/2830-cjs-require-self-name/main.cjs"] }, - { "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths", "cache", "extensions", "children", "id", "isPreloading", "loaded", "parent", "paths", "Module"], "status": "not-implemented" }, + { "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths"], "status": "partial", "evidence": ["tests/corpus/2858-import-meta-resolve/main.mjs", "tests/corpus/2860-require-resolve/main.cjs"] }, + { "chapter": "modules", "symbols": ["cache", "extensions", "children", "id", "isPreloading", "loaded", "parent", "paths", "Module"], "status": "not-implemented" }, { "chapter": "modules", "symbols": ["node:", "node_modules"], "status": "partial", "evidence": ["tests/corpus/1598-cjs-builtin-require/main.js", "tests/corpus/1596-cjs-modules/main.js"] }, { "chapter": "esm", "symbols": ["import", "file:", "node:", "await"], "status": "partial", "evidence": ["tests/corpus/950-modules-basic/main.ts", "tests/corpus/2646-top-level-await.ts"] }, { "chapter": "esm", "symbols": ["url", "dirname", "filename", "main"], "status": "partial", "evidence": ["tests/corpus/2701-import-meta/main.mjs"] }, - { "chapter": "esm", "symbols": ["meta", "import.meta.resolve", "data:", "require", "__filename", "require.main", "require.resolve", "NODE_PATH", "require.extensions", "require.cache"], "status": "not-implemented" }, + { "chapter": "esm", "symbols": ["import.meta.resolve"], "status": "partial", "evidence": ["tests/corpus/2858-import-meta-resolve/main.mjs"] }, + { "chapter": "esm", "symbols": ["meta", "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", "tests/corpus/2830-cjs-require-self-name/main.cjs"] }, { "chapter": "packages", "symbols": ["--input-type"], "status": "not-applicable" }, { "chapter": "child_process", "signatures": ["`subprocess[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 98dd5012..4783c63f 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -307,7 +307,14 @@ declare var global: typeof globalThis; * to the ENTRY module's record ({ filename } — a compiled binary's main * module is always the entry, Node's answer for a directly-run script); * the rest fences per site. */ -declare var require: { +interface ScriptcRequireResolveOptions { + paths?: string[]; +} +interface ScriptcRequireResolve { + (id: string, options?: ScriptcRequireResolveOptions): string; + paths(id: string): string[] | null; +} +interface ScriptcRequire { /* The call signature types NON-IMPORT-SHAPED require expressions (the * checker models import-shaped requires as module aliases regardless, * exactly like under @types/node's NodeRequire). `any`, like @@ -315,9 +322,10 @@ declare var require: { * value model — reached uses fence per site. */ (id: string): any; main: { filename: string } | undefined; - resolve(id: string): string; + resolve: ScriptcRequireResolve; cache: { [id: string]: unknown }; -}; +} +declare var require: ScriptcRequire; /* setImmediate/clearImmediate — Node's macrotask pair (fires after I/O * events of the current loop turn, before timers due later). The handle @@ -914,7 +922,7 @@ declare var performance: import("node:perf_hooks").Performance; * name the builtin, like in Node (the builtin wins over the npm package * named "module" for the bare specifier there too). */ declare module "node:module" { - export function createRequire(filename: string | URL): (id: string) => unknown; + export function createRequire(filename: string | URL): ScriptcRequire; export const builtinModules: string[]; export function isBuiltin(moduleName: string): boolean; export function syncBuiltinESMExports(): void; diff --git a/packages/compiler/src/frontend/input-tracker.test.ts b/packages/compiler/src/frontend/input-tracker.test.ts index f9057c48..59f5ef98 100644 --- a/packages/compiler/src/frontend/input-tracker.test.ts +++ b/packages/compiler/src/frontend/input-tracker.test.ts @@ -8,6 +8,7 @@ import { trackedAccessibleEntries, trackedDirectoryExists, trackedFileExists, + markFrontendInputsUnstable, trackedReadFile, validFrontendInputSnapshot, } from "./input-tracker.js"; @@ -86,6 +87,14 @@ test("a candidate appearing during the frontend prevents cache publication", asy expect(frontendInputsStillMatch(snapshot)).toBe(false); }); +test("an opaque host query can decline frontend cache publication", () => { + const tracker = new FrontendInputTracker(); + tracker.run(() => markFrontendInputsUnstable()); + const snapshot = tracker.snapshot(); + expect(snapshot.stable).toBe(false); + expect(validFrontendInputSnapshot(snapshot)).toBe(false); +}); + test("directory enumeration invalidates workspace discovery", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); scratch.push(dir); diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index e82fc613..2991daf5 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -118,6 +118,16 @@ export class FrontendInputTracker { } } + /** Prevent this frontend result from entering a persistent cache. + * + * Some host APIs perform semantic filesystem discovery internally and do + * not expose the candidates they inspected. Callers may still use their + * exact answer for the current build, but must decline cache publication + * rather than pretend the observation set is complete. */ + markUnstable(): void { + this.stable = false; + } + snapshot(): FrontendInputSnapshot { return { version: 1, @@ -133,6 +143,12 @@ function record(probe: FrontendInputProbe): void { activeTracker.getStore()?.record(probe); } +/** Mark the active frontend as unsafe to persist because an exact host query + * bypassed the tracked filesystem wrappers. No-op outside a frontend run. */ +export function markFrontendInputsUnstable(): void { + activeTracker.getStore()?.markUnstable(); +} + export function trackedReadFile(path: string): string | null { path = resolve(path); try { diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index cfefdc95..be3b1aef 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -8,11 +8,12 @@ import { dirname, resolve } from "node:path"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { PoisonError, dynUndefinedExpr, ladderFenceExpr, nodeThrowExpr, own } from "./lowerer.js"; -import { canonicalBuiltinModule, isJsSourceFile, locOf, requireSpecOf } from "../program.js"; +import { canonicalBuiltinModule, isJsSourceFile, isNodeEsmFile, locOf, requireSpecOf } from "../program.js"; import { isRelativeSpecifier } from "../workspace-registry.js"; import { probeNodeRequireRefusal } from "../npm.js"; import { isNpmStaticPackage } from "../npm-static.js"; import { trackedReadFile } from "../input-tracker.js"; +import { requireResolvePathsRuntime, resolveImportMetaRuntime, resolveRequireRuntime, type RuntimeResolveError, type RuntimeResolveResult } from "../runtime-resolve.js"; import { invalidJsonModuleDiag, requiresDynamicImportDiag } from "../../diagnostics/diagnostic.js"; import { BuiltinModuleFn, @@ -623,6 +624,169 @@ function lowerBuiltinOptionalDefault( }; } + function staticString(node: ts.Expression | undefined): string | null { + if (node === undefined) return null; + const value = stripTypeCasts(node); + return ts.isStringLiteralLike(value) ? value.text : null; + } + + function runtimeResolveThrow(error: RuntimeResolveError, type: IrType, loc: SrcLoc): IrExpr { + return nodeThrowExpr(error.name === "TypeError" ? 1 : 0, error.code, error.message, type, loc); + } + + /** import.meta.resolve("literal") folds through the runtime-module + * resolver, never TypeScript's declaration-file resolver. Relative and + * URL-like names remain valid even when no file exists; bare packages use + * Node's import-condition exports path. */ + export function lowerImportMetaResolveCall( + lowerer: Lowerer, + call: ts.CallExpression, + ): IrExpr | null { + const callee = call.expression; + if ( + !ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined || + callee.name.text !== "resolve" || !ts.isMetaProperty(callee.expression) || + callee.expression.keywordToken !== ts.SyntaxKind.ImportKeyword || + callee.expression.name.text !== "meta" + ) { + return null; + } + if (call.questionDotToken !== undefined || call.arguments.length !== 1) { + lowerer.noLowering( + "import.meta.resolve with this argument shape", + call, + "the lowered form is import.meta.resolve(\"\") using the containing module as its parent", + ); + } + const specifier = staticString(call.arguments[0]); + if (specifier === null) { + lowerer.noLowering( + "import.meta.resolve with a runtime-computed specifier", + call.arguments[0]!, + "a compiled binary has a fixed module graph — pass a string literal", + ); + } + const result = resolveImportMetaRuntime( + call.getSourceFile().fileName, + specifier, + lowerer.targetPlatform, + ); + if (result === null) { + lowerer.noLowering( + `import.meta.resolve of '${specifier}'`, + call, + "relative paths, URL-like names, builtins, and installed package names are supported; package-import aliases remain unsupported", + ); + } + const loc = locOf(call); + return result.ok + ? { kind: "strLit", value: result.value, type: STRING, loc } + : runtimeResolveThrow(result.error, STRING, loc); + } + + function requireResolverBaseFile(lowerer: Lowerer, receiver: ts.Expression): ts.SourceFile | null { + const created = createRequireCalleeFileOf(lowerer, receiver); + if (created !== null) return created; + return lowerer.isStdlibGlobal(receiver, "require") && !isNodeEsmFile(receiver.getSourceFile()) + ? receiver.getSourceFile() + : null; + } + + function staticResolveOptionPaths(node: ts.Expression | undefined): readonly string[] | undefined | null { + if (node === undefined) return undefined; + const value = stripTypeCasts(node); + if (ts.isIdentifier(value) && value.text === "undefined") return undefined; + if (!ts.isObjectLiteralExpression(value)) return null; + let paths: readonly string[] | undefined; + for (const prop of value.properties) { + if (!ts.isPropertyAssignment(prop)) return null; + const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : null; + if (name !== "paths" || paths !== undefined) return null; + const init = stripTypeCasts(prop.initializer); + if (!ts.isArrayLiteralExpression(init)) return null; + const entries: string[] = []; + for (const item of init.elements) { + if (!ts.isStringLiteralLike(item)) return null; + entries.push(item.text); + } + paths = entries; + } + return paths; + } + + /** CommonJS require.resolve and require.resolve.paths for the ambient + * wrapper or a supported createRequire binding. Results are build-time + * constants; failures lower to Node's catchable error object. */ + export function lowerRequireResolveCall( + lowerer: Lowerer, + call: ts.CallExpression, + ): IrExpr | null { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined) return null; + let receiver: ts.Expression; + let pathsCall = false; + if (callee.name.text === "resolve") { + receiver = callee.expression; + } else if ( + callee.name.text === "paths" && ts.isPropertyAccessExpression(callee.expression) && + callee.expression.questionDotToken === undefined && callee.expression.name.text === "resolve" + ) { + receiver = callee.expression.expression; + pathsCall = true; + } else { + return null; + } + const baseFile = requireResolverBaseFile(lowerer, receiver); + if (baseFile === null) return null; + if (call.questionDotToken !== undefined || call.arguments.length < 1 || call.arguments.length > (pathsCall ? 1 : 2)) { + lowerer.noLowering( + pathsCall ? "require.resolve.paths with this argument shape" : "require.resolve with this argument shape", + call, + ); + } + const specifier = staticString(call.arguments[0]); + if (specifier === null) { + lowerer.noLowering( + `${pathsCall ? "require.resolve.paths" : "require.resolve"} with a runtime-computed request`, + call.arguments[0]!, + "a compiled binary has a fixed module graph — pass a string literal", + ); + } + const loc = locOf(call); + if (pathsCall) { + const result = requireResolvePathsRuntime(baseFile.fileName, specifier, lowerer.targetPlatform); + if (result !== null && !Array.isArray(result)) { + return runtimeResolveThrow(result as RuntimeResolveError, lowerer.irTypeOf(call), loc); + } + const raw: IrExpr = result === null + ? { kind: "unitLit", unit: "null", type: NULL_T, loc } + : { + kind: "arrayLit", + elems: result.map((value) => ({ kind: "strLit", value, type: STRING, loc })), + type: arrayOf(STRING), + loc, + }; + return lowerer.coerceInto(call, raw, lowerer.irTypeOf(call)); + } + const paths = staticResolveOptionPaths(call.arguments[1]); + if (paths === null) { + lowerer.noLowering( + "require.resolve with runtime-computed options", + call.arguments[1]!, + "omit options or pass { paths: [\"\", ...] }", + ); + } + const result: RuntimeResolveResult = resolveRequireRuntime( + baseFile.fileName, + specifier, + lowerer.targetPlatform, + paths, + ); + return result.ok + ? { kind: "strLit", value: result.value, type: STRING, loc } + : runtimeResolveThrow(result.error, STRING, loc); + } + /** The builtin modules whose `constants` object bakes as literals at * every access site (the fs.constants precedent, scaled up): http2's * full Node v24 table, and crypto's OpenSSL-constant table. The object diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1da534a4..eb4c1c7a 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -17,7 +17,7 @@ import { ffiBindingDiag, ffiSignatureDiag, libCallbackDiag, requiresDynamicDiag import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import { mixinFnShapeOf } from "./lower-mixins.js"; import { bufEncoding, dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerRegexMethodCall, lowerStringMethodCall, lowerTupleReadMethodCall } from "./lower-containers.js"; -import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerDirentMethodCall, lowerFileHandleMethodCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerWatcherMethodCall } from "./lower-builtins.js"; +import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerDirentMethodCall, lowerFileHandleMethodCall, lowerImportMetaResolveCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerRequireResolveCall, lowerWatcherMethodCall } from "./lower-builtins.js"; import { droppableStatic, lowerAbsenceProbe, lowerPromiseAllTupleCall, lowerPromiseRejectCall, probeLower, templateRawTextOf } from "./lower-exprs.js"; import { httpClientFnBindingOf, isStreamUndefCallExpr, lowerCompatReqStreamOptionalCall, lowerHttpClientFnCall } from "./lower-server.js"; import { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; @@ -3050,6 +3050,11 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (crServed) return crServed; } + { + const resolved = lowerImportMetaResolveCall(lowerer, expr) ?? lowerRequireResolveCall(lowerer, expr); + if (resolved) return resolved; + } + // `process.getuid?.()` — intercepted BEFORE the optional-chain // machinery (the member always exists on a POSIX target, so the // optional call IS the call; `process.getuid` itself has no value diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 07394336..f8038b09 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2005,13 +2005,6 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const sf = expr.getSourceFile(); const fileName = moduleFileName(lowerer, sf); - if (lowerer.dynamic) { - lowerer.unsupported( - "SC1090", - expr, - `'import.meta.${expr.name.text}' in a dynamic island (module-loader metadata is supported only in static ESM)`, - ); - } switch (expr.name.text) { case "url": return { @@ -4088,7 +4081,7 @@ export function lowerOptionalChain(lowerer: Lowerer, expr: ts.CallExpression | t } lowerer.badType(expr, tsType); } - let type = mapped as IrType & { kind: "array" }; + const type = mapped as IrType & { kind: "array" }; // Keep the array payload type fixed. Optional reads are stored through // arrayValueStore, which records UNDEFINED in the state byte while the // payload remains number/string/etc.; widening the array element here diff --git a/packages/compiler/src/frontend/npm.test.ts b/packages/compiler/src/frontend/npm.test.ts new file mode 100644 index 00000000..e6f75880 --- /dev/null +++ b/packages/compiler/src/frontend/npm.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "vitest"; +import { moduleSpecifiersOf } from "./npm.js"; +import { NpmGraphBuilder } from "./npm.js"; + +test("collects import.meta.resolve literals as resolution-only edges", () => { + const result = moduleSpecifiersOf( + 'export const relative = import.meta.resolve("./asset.js");\n' + + 'export const packageUrl = import.meta.resolve("example-package");\n', + "/project/index.mjs", + ); + expect(result.uses).toEqual([ + { + specifier: "./asset.js", + static: false, + require: false, + requireLocal: false, + requireViaHelper: false, + dynamicImport: false, + importMetaResolve: true, + }, + { + specifier: "example-package", + static: false, + require: false, + requireLocal: false, + requireViaHelper: false, + dynamicImport: false, + importMetaResolve: true, + }, + ]); +}); + +test("emits an import-condition edge for an embedded bare import.meta.resolve", () => { + const root = new URL("../../../../tests/fixtures/npm/", import.meta.url).pathname; + const entry = `${root}cases/dual-entry/main.ts`; + const builder = new NpmGraphBuilder(); + builder.addImport(entry, "dual"); + const graph = builder.finish(); + const dual = graph.modules.find((module) => module.key.endsWith("/dual/index.mjs")); + expect(dual).toBeDefined(); + expect(graph.edges).toContainEqual({ + from: dual!.key, + specifier: "cjszoo", + to: expect.stringMatching(/\/cjszoo\/index\.js$/), + kind: "import", + }); +}); + +test("runtime introspection keeps import and require export conditions separate", () => { + const root = new URL("../../../../tests/fixtures/npm/", import.meta.url).pathname; + const entry = `${root}cases/dual-entry/main.ts`; + const builder = new NpmGraphBuilder(); + expect(builder.resolveForIntrospection(entry, "dual", "import")).toMatch(/\/dual\/index\.mjs$/); + expect(builder.resolveForIntrospection(entry, "dual", "require")).toMatch(/\/dual\/index\.cjs$/); +}); diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index 6ae9093f..d9ec6b79 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -596,7 +596,7 @@ const KNOWN_BUILTINS = new Set([ * several forms in one file; any STATIC occurrence makes the edge eager * (Node refuses the whole static graph at link time regardless of what the * lazy sites would have done). */ -interface SpecifierUse { +export interface SpecifierUse { specifier: string; /** import/export declaration — eager, link-time. */ static: boolean; @@ -616,12 +616,16 @@ interface SpecifierUse { requireViaHelper: boolean; /** import("x") — evaluation-time. */ dynamicImport: boolean; + /** import.meta.resolve("x") — resolves synchronously without loading. + * It still needs an emitted edge for bare package names so the island can + * answer from its fixed graph; relative and URL-like names need no edge. */ + importMetaResolve: boolean; } /** moduleSpecifiersOf's full answer: the per-specifier call-site kinds * plus where the file's `__require` binding comes from, when it is not * its own. */ -interface ModuleSpecifiers { +export interface ModuleSpecifiers { uses: SpecifierUse[]; /** The specifier `__require` is IMPORTED from (`import { __require } * from "./chunk-X.js"` — esbuild's shared-helper chunk shape), else @@ -641,7 +645,7 @@ interface ModuleSpecifiers { * __require("literal") (esbuild's external-require helper — collecting its * literal call sites gives bundled dists an honest build-time inventory). * A real parse, never a regex. */ -function moduleSpecifiersOf(source: string, fileName: string): ModuleSpecifiers { +export function moduleSpecifiersOf(source: string, fileName: string): ModuleSpecifiers { const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS); const uses: SpecifierUse[] = []; const bySpec = new Map(); @@ -660,6 +664,7 @@ function moduleSpecifiersOf(source: string, fileName: string): ModuleSpecifiers requireLocal: false, requireViaHelper: false, dynamicImport: false, + importMetaResolve: false, }; bySpec.set(spec, use); uses.push(use); @@ -698,6 +703,14 @@ function moduleSpecifiersOf(source: string, fileName: string): ModuleSpecifiers ts.isStringLiteralLike(arg) ) { push(arg.text, "dynamicImport"); + } else if ( + ts.isPropertyAccessExpression(n.expression) && + n.expression.name.text === "resolve" && + ts.isMetaProperty(n.expression.expression) && + n.expression.expression.keywordToken === ts.SyntaxKind.ImportKeyword && + n.arguments.length >= 1 && arg !== undefined && ts.isStringLiteralLike(arg) + ) { + push(arg.text, null).importMetaResolve = true; } else if ( ts.isIdentifier(n.expression) && (n.expression.text === "require" || n.expression.text === "__require") && @@ -945,6 +958,27 @@ export class NpmGraphBuilder { constructor(private readonly host: Host = realHost) {} + /** Resolve one runtime module without embedding it. This is the package + * half of import.meta.resolve/require.resolve: the answer is executable + * source under Node's import/require conditions, never the declaration-file + * answer from resolve.ts. Diagnostics produced by the graph-oriented + * resolver are discarded here; the introspection lowering supplies Node's + * public error shape separately. */ + resolveForIntrospection( + fromFile: string, + specifier: string, + mode: "import" | "require", + ): string | null { + const before = this.errors.length; + const key = this.resolvePackage(dirname(resolve(fromFile)), specifier, mode, { + importer: resolve(fromFile), + chain: [], + preferModuleField: false, + }); + this.errors.length = before; + return key; + } + /** Registers one user-level npm import and walks everything it reaches. * Idempotent per (importing dir, specifier). */ addImport(fromFile: string, specifier: string): void { @@ -1349,7 +1383,7 @@ export class NpmGraphBuilder { fromDir: string, specifier: string, mode: "import" | "require", - ctx: { importer: string; chain: readonly string[] }, + ctx: { importer: string; chain: readonly string[]; preferModuleField?: boolean }, ): string | null { const name = packageNameOf(specifier); const parts = specifier.split("/"); @@ -1414,7 +1448,10 @@ export class NpmGraphBuilder { target = fromExports; } else if (subpath !== ".") { target = subpath; - } else if (mode === "import" && typeof pkg.module === "string" && pkg.module !== "") { + } else if ( + mode === "import" && ctx.preferModuleField !== false && + typeof pkg.module === "string" && pkg.module !== "" + ) { // No "exports": the "module" field names the ESM build (a // bundler convention Node itself ignores; embedding prefers the // real ES module over require-shimming the CJS "main"). @@ -1575,6 +1612,9 @@ export class NpmGraphBuilder { const spec = use.specifier; const eager = !lazy && use.static; if (spec.startsWith("./") || spec.startsWith("../")) { + if (use.importMetaResolve && !use.static && !use.dynamicImport && !use.require) { + continue; + } // A relative file: no conditions apply, so every call form shares // one "any" edge. A blocked lazy one embeds the import trap only // for import()/static-in-lazy sites — require-reached specs embed @@ -1605,6 +1645,12 @@ export class NpmGraphBuilder { this.walk(to, chain, lazy || !use.static); continue; } + if ( + use.importMetaResolve && !use.static && !use.dynamicImport && !use.require && + (/^[A-Za-z][A-Za-z\d+.-]*:/.test(spec) || spec.startsWith("/")) + ) { + continue; + } const builtin = builtinKeyOf(spec); if (builtin) { let seen = this.builtinsSeen.get(builtin); @@ -1691,7 +1737,7 @@ export class NpmGraphBuilder { this.walk(to, nextChain, true); } } - if (use.static || use.dynamicImport) { + if (use.static || use.dynamicImport || use.importMetaResolve) { const errorsBefore = this.errors.length; const to = this.resolvePackage(dirname(key), spec, "import", { importer: key, chain }); if (to === null) { @@ -1724,7 +1770,9 @@ export class NpmGraphBuilder { ); } pushEdge(key, spec, to, "import"); - this.walk(to, nextChain, lazy || !use.static); + if (!use.importMetaResolve || use.static || use.dynamicImport) { + this.walk(to, nextChain, lazy || !use.static); + } } } } diff --git a/packages/compiler/src/frontend/runtime-resolve.test.ts b/packages/compiler/src/frontend/runtime-resolve.test.ts new file mode 100644 index 00000000..9dd86844 --- /dev/null +++ b/packages/compiler/src/frontend/runtime-resolve.test.ts @@ -0,0 +1,19 @@ +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { resolveImportMetaRuntime, runtimePathForTarget } from "./runtime-resolve.js"; + +test("WASI resolution metadata uses guest paths", () => { + const source = join(process.cwd(), "tests", "program", "main.mjs"); + expect(runtimePathForTarget(source, "wasi")).toBe("/tests/program/main.mjs"); + expect(resolveImportMetaRuntime(source, "./asset with space.js", "wasi")).toEqual({ + ok: true, + value: "file:///tests/program/asset%20with%20space.js", + }); +}); + +test("Windows resolution metadata uses target URL semantics on any host", () => { + expect(resolveImportMetaRuntime("C:\\work\\main.mjs", "./asset.js", "win32")).toEqual({ + ok: true, + value: "file:///C:/work/asset.js", + }); +}); diff --git a/packages/compiler/src/frontend/runtime-resolve.ts b/packages/compiler/src/frontend/runtime-resolve.ts new file mode 100644 index 00000000..3b9c5f8a --- /dev/null +++ b/packages/compiler/src/frontend/runtime-resolve.ts @@ -0,0 +1,128 @@ +/* Node runtime resolution used by compile-time-folded module introspection. + * + * This is deliberately separate from resolve.ts: that resolver answers the + * TypeScript program's TYPE surface and may correctly select a .d.ts file, + * while import.meta.resolve / require.resolve must answer Node's executable + * file or URL. CJS resolution delegates to the pinned Node 24 host for exact + * exports/main/extension/error behavior. The host API does not expose its + * failed lookup candidates, so those builds decline persistent frontend-cache + * publication rather than retaining an incomplete dependency snapshot. ESM + * package success uses the tracked embedded-graph resolver under Node's import + * conditions; relative and URL-like specifiers are pure URL resolution and do + * not require the target to exist, matching Node. */ + +import { createRequire, isBuiltin } from "node:module"; +import { isAbsolute } from "node:path"; +import { pathToFileURL } from "node:url"; +import { markFrontendInputsUnstable } from "./input-tracker.js"; +import { NpmGraphBuilder, probeNodeImportRefusal } from "./npm.js"; +import { wasiGuestPath } from "../wasi-paths.js"; + +export interface RuntimeResolveError { + name: string; + code: string; + message: string; +} + +export type RuntimeResolveResult = + | { ok: true; value: string } + | { ok: false; error: RuntimeResolveError }; + +function errorShape(error: unknown): RuntimeResolveError { + const e = error as { name?: unknown; code?: unknown; message?: unknown }; + return { + name: typeof e?.name === "string" ? e.name : "Error", + code: typeof e?.code === "string" ? e.code : "", + message: typeof e?.message === "string" ? e.message : String(error), + }; +} + +export function runtimePathForTarget(path: string, targetPlatform: string): string { + if (targetPlatform !== "wasi") return path; + return wasiGuestPath(path) ?? path.replaceAll("\\", "/"); +} + +function targetFileUrl(path: string, targetPlatform: string): string { + return pathToFileURL(runtimePathForTarget(path, targetPlatform), { + windows: targetPlatform === "win32", + }).href; +} + +function urlLike(specifier: string): boolean { + return /^[A-Za-z][A-Za-z\d+.-]*:/.test(specifier); +} + +/** import.meta.resolve for a static string and the containing source file. + * Package-import (`#name`) resolution remains a named lowering fence until + * its Node error/success path can share the package-import resolver without + * crossing back into TypeScript's declaration-file answer. */ +export function resolveImportMetaRuntime( + fromFile: string, + specifier: string, + targetPlatform: string, +): RuntimeResolveResult | null { + const base = targetFileUrl(fromFile, targetPlatform); + if ( + specifier.startsWith("./") || specifier.startsWith("../") || + isAbsolute(specifier) || urlLike(specifier) + ) { + try { + return { ok: true, value: new URL(specifier, base).href }; + } catch (error) { + return { ok: false, error: errorShape(error) }; + } + } + if (isBuiltin(specifier)) { + return { ok: true, value: specifier.startsWith("node:") ? specifier : `node:${specifier}` }; + } + if (specifier.startsWith("#")) return null; + + const builder = new NpmGraphBuilder(); + const key = builder.resolveForIntrospection(fromFile, specifier, "import"); + if (key !== null) return { ok: true, value: targetFileUrl(key, targetPlatform) }; + const refusal = probeNodeImportRefusal(fromFile, specifier); + if (refusal !== null) { + return { + ok: false, + error: { name: "Error", code: refusal.code, message: refusal.message }, + }; + } + return null; +} + +/** require.resolve under the containing file's CommonJS resolver. */ +export function resolveRequireRuntime( + fromFile: string, + specifier: string, + targetPlatform: string, + paths?: readonly string[], +): RuntimeResolveResult { + markFrontendInputsUnstable(); + try { + const require = createRequire(pathToFileURL(fromFile)); + const value = paths === undefined + ? require.resolve(specifier) + : require.resolve(specifier, { paths: [...paths] }); + return { + ok: true, + value: isAbsolute(value) ? runtimePathForTarget(value, targetPlatform) : value, + }; + } catch (error) { + return { ok: false, error: errorShape(error) }; + } +} + +/** require.resolve.paths under the containing file's CommonJS resolver. */ +export function requireResolvePathsRuntime( + fromFile: string, + specifier: string, + targetPlatform: string, +): readonly string[] | null | RuntimeResolveError { + markFrontendInputsUnstable(); + try { + const paths = createRequire(pathToFileURL(fromFile)).resolve.paths(specifier); + return paths?.map((path) => runtimePathForTarget(path, targetPlatform)) ?? null; + } catch (error) { + return errorShape(error); + } +} diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index eb87aa32..9602e865 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6064,6 +6064,24 @@ ], "diags": [] }, + "/tests/corpus/2858-import-meta-resolve/main.mjs": { + "order": [ + "/tests/corpus/2858-import-meta-resolve/main.mjs" + ], + "diags": [] + }, + "/tests/corpus/2859-import-meta-resolve-dynamic/main.mjs": { + "order": [ + "/tests/corpus/2859-import-meta-resolve-dynamic/main.mjs" + ], + "diags": [] + }, + "/tests/corpus/2860-require-resolve/main.cjs": { + "order": [ + "/tests/corpus/2860-require-resolve/main.cjs" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" @@ -7648,18 +7666,6 @@ ], "diags": [] }, - "/tests/diagnostics/import-meta-dynamic.ts": { - "order": [ - "/tests/diagnostics/import-meta-dynamic.ts" - ], - "diags": [] - }, - "/tests/diagnostics/import-meta-resolve.ts": { - "order": [ - "/tests/diagnostics/import-meta-resolve.ts" - ], - "diags": [] - }, "/tests/diagnostics/import-meta-write.ts": { "order": [ "/tests/diagnostics/import-meta-write.ts" @@ -8007,6 +8013,12 @@ } ] }, + "/tests/diagnostics/module-resolution-introspection.ts": { + "order": [ + "/tests/diagnostics/module-resolution-introspection.ts" + ], + "diags": [] + }, "/tests/diagnostics/multiple-errors.ts": { "order": [ "/tests/diagnostics/multiple-errors.ts" diff --git a/packages/runtime/src/scr_island.c b/packages/runtime/src/scr_island.c index c97ac416..a8577e97 100644 --- a/packages/runtime/src/scr_island.c +++ b/packages/runtime/src/scr_island.c @@ -2378,6 +2378,27 @@ static char *isl_module_normalize(JSContext *ctx, const char *base, return js_strdup(ctx, target); } +/* import.meta.resolve is module-relative, so each compiled module receives a + * tiny closure carrying its emitted key. The bootstrap owns the JavaScript + * resolution policy (builtins, URL-like names, relative URLs, and known + * embedded edges); this C shim only supplies the lexical module identity. */ +static JSValue isl_import_meta_resolve(JSContext *ctx, JSValueConst this_val, + int argc, JSValueConst *argv, int magic, + JSValueConst *func_data) { + (void)this_val; + (void)magic; + JSValue global = JS_GetGlobalObject(ctx); + JSValue fn = JS_GetPropertyStr(ctx, global, "__scr_import_meta_resolve"); + JS_FreeValue(ctx, global); + if (JS_IsException(fn)) return fn; + JSValueConst args[2] = { + func_data[0], argc > 0 ? argv[0] : JS_UNDEFINED, + }; + JSValue out = JS_Call(ctx, fn, JS_UNDEFINED, 2, args); + JS_FreeValue(ctx, fn); + return out; +} + /* Named export lists for the builtin ESM wrappers. The wrapper source is * `const m = __scr_require("node:x"); export default m; export const * {…} = m;` — the shims themselves live in the bootstrap below. The @@ -2469,6 +2490,12 @@ static JSModuleDef *isl_module_load(JSContext *ctx, const char *name, void *opaq url = JS_NewString(ctx, name); } JS_SetPropertyStr(ctx, meta, "url", url); /* consumed */ + JSValue key = JS_NewString(ctx, name); + JSValueConst data[1] = {key}; + JSValue resolve_fn = JS_NewCFunctionData(ctx, isl_import_meta_resolve, 1, + 0, 1, data); + JS_FreeValue(ctx, key); /* resolve_fn's func_data retains it */ + JS_SetPropertyStr(ctx, meta, "resolve", resolve_fn); /* consumed */ JS_FreeValue(ctx, meta); } JS_FreeValue(ctx, v); @@ -2513,8 +2540,10 @@ static JSValue isl_host_resolve(JSContext *ctx, JSValueConst this_val, int argc, if (from) JS_FreeCString(ctx, from); return JS_EXCEPTION; } - /* host.resolve serves the require shim exclusively — require kind. */ - const char *to = strncmp(spec, "node:", 5) == 0 ? spec : isl_edge_find(from, spec, 2); + /* The require shim asks for kind 2; import.meta.resolve passes a true third + * argument and asks for kind 1, preserving dual-package conditions. */ + int want = argc > 2 && JS_ToBool(ctx, argv[2]) > 0 ? 1 : 2; + const char *to = strncmp(spec, "node:", 5) == 0 ? spec : isl_edge_find(from, spec, want); JSValue r = to ? JS_NewString(ctx, to) : JS_UNDEFINED; JS_FreeCString(ctx, from); JS_FreeCString(ctx, spec); @@ -3442,6 +3471,7 @@ static const char isl_modules_bootstrap[] = " }\n" " const cache = Object.create(null);\n" " const builtins = Object.create(null);\n" + " const builtinModules = ['assert','assert/strict','async_hooks','buffer','child_process','cluster','console','constants','crypto','dgram','diagnostics_channel','dns','dns/promises','domain','events','fs','fs/promises','http','http2','https','inspector','inspector/promises','module','net','os','path','path/posix','path/win32','perf_hooks','process','punycode','querystring','readline','readline/promises','repl','stream','stream/consumers','stream/promises','stream/web','string_decoder','sys','timers','timers/promises','tls','trace_events','tty','url','util','util/types','v8','vm','wasi','worker_threads','zlib'];\n" /* Node's require stack: each CJS module remembers its FIRST requirer * (Node's module.parent / moduleParentCache — the chain is static, * captured at first load, not the dynamic call stack), and a failing @@ -3478,6 +3508,39 @@ static const char isl_modules_bootstrap[] = " }\n" " return to;\n" " };\n" + " const requirePathsFrom = (from, spec) => {\n" + " if (typeof spec !== 'string') {\n" + " const err = new TypeError('The \"request\" argument must be of type string.');\n" + " err.code = 'ERR_INVALID_ARG_TYPE';\n" + " throw err;\n" + " }\n" + " const bare = spec.startsWith('node:') ? spec.slice(5) : spec;\n" + " if (builtinModules.includes(bare) && (spec.startsWith('node:') || bare !== 'test')) return null;\n" + " const slash = from.lastIndexOf('/');\n" + " const base = slash < 0 ? '/' : (from.slice(0, slash) || '/');\n" + " if (spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')) return [base];\n" + " const out = [];\n" + " for (let dir = base;;) {\n" + " out.push((dir === '/' ? '' : dir) + '/node_modules');\n" + " const at = dir.lastIndexOf('/');\n" + " const parent = at <= 0 ? '/' : dir.slice(0, at);\n" + " if (parent === dir) break;\n" + " dir = parent;\n" + " }\n" + " return out;\n" + " };\n" + " const decorateRequire = (req, from) => {\n" + " const resolve = (spec) => {\n" + " if (typeof spec !== 'string') return requirePathsFrom(from, spec);\n" + " const bare = spec.startsWith('node:') ? spec.slice(5) : spec;\n" + " if (builtinModules.includes(bare) && (spec.startsWith('node:') || bare !== 'test')) return spec;\n" + " return resolveFrom(from, spec);\n" + " };\n" + " resolve.paths = (spec) => requirePathsFrom(from, spec);\n" + " req.resolve = resolve;\n" + " req.cache = cache;\n" + " return req;\n" + " };\n" " const requireKey = (key, parent) => {\n" " if (key.startsWith('node:')) {\n" " const b = builtins[key.slice(5)];\n" @@ -3495,8 +3558,7 @@ static const char isl_modules_bootstrap[] = " if (format === 2) { mod.exports = JSON.parse(src); return mod.exports; }\n" " if (format === 0) { delete cache[key]; throw new Error('require() of ES module ' + key); }\n" " const fn = new Function('exports', 'require', 'module', '__filename', '__dirname', src);\n" - " const req = (spec) => requireKey(resolveFrom(key, spec), key);\n" - " req.cache = cache;\n" + " const req = decorateRequire((spec) => requireKey(resolveFrom(key, spec), key), key);\n" " const dir = key.slice(0, key.lastIndexOf('/')) || '/';\n" /* A module whose evaluation THROWS leaves no cache entry — Node * deletes it so a later require re-evaluates (and a lazy require @@ -4244,15 +4306,12 @@ static const char isl_modules_bootstrap[] = " if (key.startsWith('file://')) key = decodeURIComponent(key.slice(7));\n" /* The base file is the created require's parent, like Node: modules * it loads report it in their require stacks. */ - " const req = (spec) => requireKey(spec.startsWith('node:') ? spec : resolveFrom(key, spec), key);\n" - " req.cache = cache;\n" - " return req;\n" + " return decorateRequire((spec) => requireKey(spec.startsWith('node:') ? spec : resolveFrom(key, spec), key), key);\n" " };\n" /* builtinModules/isBuiltin answer Node's QUESTION ("is this name a * Node builtin?") with Node's full list — resolution of unshimmed * ones still fails lazily at the call, the island's documented * shape. */ - " const builtinModules = ['assert','assert/strict','async_hooks','buffer','child_process','cluster','console','constants','crypto','dgram','diagnostics_channel','dns','dns/promises','domain','events','fs','fs/promises','http','http2','https','inspector','inspector/promises','module','net','os','path','path/posix','path/win32','perf_hooks','process','punycode','querystring','readline','readline/promises','repl','stream','stream/consumers','stream/promises','stream/web','string_decoder','sys','timers','timers/promises','tls','trace_events','tty','url','util','util/types','v8','vm','wasi','worker_threads','zlib'];\n" " const isBuiltin = (name) => {\n" " const n = String(name);\n" " return n.startsWith('node:') ? builtinModules.includes(n.slice(5)) : builtinModules.includes(n);\n" @@ -9205,6 +9264,22 @@ static const char isl_modules_bootstrap[] = " }\n" " if (globalThis.global === undefined) globalThis.global = globalThis;\n" " globalThis.__scr_require = requireKey;\n" + " globalThis.__scr_import_meta_resolve = (from, raw) => {\n" + " const spec = String(raw);\n" + " const bare = spec.startsWith('node:') ? spec.slice(5) : spec;\n" + " if (builtinModules.includes(bare) && (spec.startsWith('node:') || bare !== 'test')) return 'node:' + bare;\n" + " const base = from.startsWith('/') ? new URL('file://' + from).href : from;\n" + " if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(spec) || spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')) {\n" + " return new URL(spec, base).href;\n" + " }\n" + " const to = host.resolve(from, spec, true);\n" + " if (to === undefined) {\n" + " const err = new Error(\"Cannot find package '\" + spec + \"' imported from \" + from);\n" + " err.code = 'ERR_MODULE_NOT_FOUND';\n" + " throw err;\n" + " }\n" + " return to.startsWith('/') ? new URL('file://' + to).href : to;\n" + " };\n" " return (key, name) => {\n" " const exports = requireKey(key);\n" " if (name === 'default') return exports;\n" diff --git a/tests/corpus/2858-import-meta-resolve/main.mjs b/tests/corpus/2858-import-meta-resolve/main.mjs new file mode 100644 index 00000000..e223ddd2 --- /dev/null +++ b/tests/corpus/2858-import-meta-resolve/main.mjs @@ -0,0 +1,21 @@ +import { createRequire } from "node:module"; + +// import.meta.resolve is scoped to each compiled module. Relative names are +// URLs even when the target does not exist; builtins canonicalize to node:; +// URL-like inputs normalize without touching the filesystem; installed bare +// packages resolve under Node's ESM import conditions. +console.log("relative", import.meta.resolve("./module with space.mjs")); +console.log("missing", import.meta.resolve("./missing.mjs")); +console.log("builtins", import.meta.resolve("fs"), import.meta.resolve("node:path")); +console.log("url", import.meta.resolve("https://example.com/a/../b")); +console.log("data", import.meta.resolve("data:text/javascript,export default 1")); +console.log("package", import.meta.resolve("typescript").endsWith("/typescript/lib/typescript.js")); +const require = createRequire(import.meta.url); +console.log("createRequire", require.resolve("./module with space.mjs")); +console.log("createRequire core paths", require.resolve.paths("fs")); +try { + import.meta.resolve("definitely-not-installed-here"); + console.log("unexpected resolution"); +} catch (error) { + console.log("missing package", error.code, error.message.split("\n")[0]); +} diff --git a/tests/corpus/2858-import-meta-resolve/module with space.mjs b/tests/corpus/2858-import-meta-resolve/module with space.mjs new file mode 100644 index 00000000..5bd2d564 --- /dev/null +++ b/tests/corpus/2858-import-meta-resolve/module with space.mjs @@ -0,0 +1 @@ +export const marker = "resolved"; diff --git a/tests/corpus/2859-import-meta-resolve-dynamic/main.mjs b/tests/corpus/2859-import-meta-resolve-dynamic/main.mjs new file mode 100644 index 00000000..05c520b2 --- /dev/null +++ b/tests/corpus/2859-import-meta-resolve-dynamic/main.mjs @@ -0,0 +1,9 @@ +// @dynamic +// Embedding the package engine does not move the program's own ESM modules +// into it: their metadata remains per-file compile-time identity. +console.log(import.meta.url); +console.log(import.meta.filename); +console.log(import.meta.dirname); +console.log(import.meta.main); +console.log(import.meta.resolve("./missing package module.mjs")); +console.log(import.meta.resolve("fs")); diff --git a/tests/corpus/2860-require-resolve/cfg.json b/tests/corpus/2860-require-resolve/cfg.json new file mode 100644 index 00000000..8159b9f5 --- /dev/null +++ b/tests/corpus/2860-require-resolve/cfg.json @@ -0,0 +1 @@ +{ "resolved": true } diff --git a/tests/corpus/2860-require-resolve/main.cjs b/tests/corpus/2860-require-resolve/main.cjs new file mode 100644 index 00000000..9c801afe --- /dev/null +++ b/tests/corpus/2860-require-resolve/main.cjs @@ -0,0 +1,19 @@ +// CommonJS resolution introspection returns filenames without evaluating the +// target. The options.paths form searches from its supplied directories, and +// missing requests throw Node's catchable MODULE_NOT_FOUND shape. +console.log("relative", require.resolve("./cfg.json")); +console.log("builtins", require.resolve("fs"), require.resolve("node:path")); +console.log("core paths", require.resolve.paths("fs")); +console.log("relative paths", JSON.stringify(require.resolve.paths("./cfg.json"))); +console.log( + "custom paths", + require.resolve("fs", { + paths: ["tests/corpus/2860-require-resolve/vendor"], + }), +); +try { + require.resolve("./missing.cjs"); + console.log("unexpected resolution"); +} catch (error) { + console.log("missing", error.code, error.message.split("\n")[0]); +} diff --git a/tests/diagnostics/create-require.ts b/tests/diagnostics/create-require.ts index 0df27e95..e743d47d 100644 --- a/tests/diagnostics/create-require.ts +++ b/tests/diagnostics/create-require.ts @@ -12,7 +12,7 @@ import { createRequire } from "node:module"; // The base names some OTHER file: unrecognized — the member fence. const req = createRequire("/tmp/parent.js"); console.log(String(req("./config.cjs"))); - +// The supported base still fences dynamic require calls and program-module loads. const require = createRequire(import.meta.url); // A computed specifier: fenced by name. diff --git a/tests/diagnostics/import-meta-dynamic.ts b/tests/diagnostics/import-meta-dynamic.ts deleted file mode 100644 index 1b41636c..00000000 --- a/tests/diagnostics/import-meta-dynamic.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @dynamic -const url = import.meta.url; -console.log(url); diff --git a/tests/diagnostics/import-meta-resolve.ts b/tests/diagnostics/import-meta-resolve.ts deleted file mode 100644 index 267a516d..00000000 --- a/tests/diagnostics/import-meta-resolve.ts +++ /dev/null @@ -1,2 +0,0 @@ -const resolved = import.meta.resolve("./module.mjs"); -console.log(resolved); diff --git a/tests/diagnostics/module-resolution-introspection.ts b/tests/diagnostics/module-resolution-introspection.ts new file mode 100644 index 00000000..783f316f --- /dev/null +++ b/tests/diagnostics/module-resolution-introspection.ts @@ -0,0 +1,9 @@ +import { createRequire } from "node:module"; + +const specifier = process.env.MODULE_NAME ?? "./fallback.mjs"; +console.log(import.meta.resolve(specifier)); +// createRequire uses the same static-literal boundary. +const require = createRequire(import.meta.url); +console.log(require.resolve(specifier)); +console.log(require.resolve("./fallback.mjs", { paths: process.argv })); +// Dynamic requests and dynamic search paths remain explicit refusals. diff --git a/tests/fixtures/npm/cases/create-require/main.ts b/tests/fixtures/npm/cases/create-require/main.ts index e0fbe95f..199820c7 100644 --- a/tests/fixtures/npm/cases/create-require/main.ts +++ b/tests/fixtures/npm/cases/create-require/main.ts @@ -12,6 +12,11 @@ const require = createRequire(import.meta.url); // A plain CJS package: module.exports members through the island. const zoo = require("cjszoo") as any; console.log("zoo:", zoo.alpha as string, zoo.extra as string, zoo.gamma() as string); +console.log( + "zoo resolve:", + (zoo.resolved as string).endsWith("/cjszoo/core.js"), + (zoo.resolvePaths as string[])[0]?.endsWith("/cjszoo"), +); // The dual package: "exports" splits import/require — require() loads // the CJS arm, exactly Node's createRequire. diff --git a/tests/fixtures/npm/cases/dual-entry/main.ts b/tests/fixtures/npm/cases/dual-entry/main.ts index 7a74dac8..3b571d37 100644 --- a/tests/fixtures/npm/cases/dual-entry/main.ts +++ b/tests/fixtures/npm/cases/dual-entry/main.ts @@ -1,7 +1,9 @@ // @dynamic // Dual-published package: the ESM ("import" condition) entry wins, exactly // like Node resolving an import statement. -import { flavor } from "dual"; +import { cjsUrl, flavor, zooUrl } from "dual"; const f: string = flavor; console.log(f); +console.log(cjsUrl.endsWith("/dual/index.cjs")); +console.log(zooUrl.endsWith("/cjszoo/index.js")); diff --git a/tests/fixtures/npm/node_modules/cjszoo/index.js b/tests/fixtures/npm/node_modules/cjszoo/index.js index 1c54d1db..570b91f1 100644 --- a/tests/fixtures/npm/node_modules/cjszoo/index.js +++ b/tests/fixtures/npm/node_modules/cjszoo/index.js @@ -3,3 +3,5 @@ "use strict"; module.exports = require("./core.js"); module.exports.extra = "extra"; +module.exports.resolved = require.resolve("./core.js"); +module.exports.resolvePaths = require.resolve.paths("./core.js"); diff --git a/tests/fixtures/npm/node_modules/dual/index.d.ts b/tests/fixtures/npm/node_modules/dual/index.d.ts index 424ba43c..29b86da0 100644 --- a/tests/fixtures/npm/node_modules/dual/index.d.ts +++ b/tests/fixtures/npm/node_modules/dual/index.d.ts @@ -1 +1,3 @@ export declare const flavor: string; +export declare const cjsUrl: string; +export declare const zooUrl: string; diff --git a/tests/fixtures/npm/node_modules/dual/index.mjs b/tests/fixtures/npm/node_modules/dual/index.mjs index d505672d..49b8a6a1 100644 --- a/tests/fixtures/npm/node_modules/dual/index.mjs +++ b/tests/fixtures/npm/node_modules/dual/index.mjs @@ -1 +1,3 @@ export const flavor = "esm"; +export const cjsUrl = import.meta.resolve("./index.cjs"); +export const zooUrl = import.meta.resolve("cjszoo"); diff --git a/tests/harness/__snapshots__/create-require.ts.txt b/tests/harness/__snapshots__/create-require.ts.txt index 60f40b62..9518233a 100644 --- a/tests/harness/__snapshots__/create-require.ts.txt +++ b/tests/harness/__snapshots__/create-require.ts.txt @@ -1,3 +1,12 @@ +create-require.ts:13:7 - error SC2011: values of type 'ScriptcRequire' have no static representation but run in the embedded dynamic engine, which this build does not include + + 12 | // The base names some OTHER file: unrecognized — the member fence. + 13 | const req = createRequire("/tmp/parent.js"); + | ^~~ + 14 | console.log(String(req("./config.cjs"))); + + hint: build with --dynamic to run these values in the embedded engine (adds ~620KB to the binary), or restate the type inside the static surface (https://scriptc.dev/limitations describes the boundary) + create-require.ts:13:13 - error SC2020: 'module.createRequire' is part of the standard library types but has no scriptc lowering yet 12 | // The base names some OTHER file: unrecognized — the member fence. @@ -7,6 +16,15 @@ create-require.ts:13:13 - error SC2020: 'module.createRequire' is part of the st hint: the lowered shape is a const binding over createRequire(import.meta.url) (or __filename) whose require calls take STATIC string literals — builtins, relative .json documents, and installed npm packages (under --dynamic) resolve at build time; dynamic specifiers cannot exist in a compiled binary's fixed module graph +create-require.ts:14:20 - error SC2004: uses of 'req' inherit the blocker on its declaration + + 13 | const req = createRequire("/tmp/parent.js"); + 14 | console.log(String(req("./config.cjs"))); + | ^~~ + 15 | // The supported base still fences dynamic require calls and program-module loads. + + hint: the declaration of 'req' did not compile — fix the diagnostic reported there and these sites clear with it + create-require.ts:20:1 - error SC2020: 'createRequire's require with this argument shape' is part of the standard library types but has no scriptc lowering yet 19 | const which = "left" + "-pad"; diff --git a/tests/harness/__snapshots__/import-meta-dynamic.ts.txt b/tests/harness/__snapshots__/import-meta-dynamic.ts.txt deleted file mode 100644 index f91c4fb2..00000000 --- a/tests/harness/__snapshots__/import-meta-dynamic.ts.txt +++ /dev/null @@ -1,6 +0,0 @@ -import-meta-dynamic.ts:2:13 - error SC1090: 'import.meta.url' in a dynamic island (module-loader metadata is supported only in static ESM) is not supported yet - - 1 | // @dynamic - 2 | const url = import.meta.url; - | ^~~~~~~~~~~~~~~ - 3 | console.log(url); \ No newline at end of file diff --git a/tests/harness/__snapshots__/import-meta-resolve.ts.txt b/tests/harness/__snapshots__/import-meta-resolve.ts.txt deleted file mode 100644 index 70800e86..00000000 --- a/tests/harness/__snapshots__/import-meta-resolve.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -import-meta-resolve.ts:1:18 - error SC2020: 'ImportMeta.resolve' is part of the standard library types but has no scriptc lowering yet - - 1 | const resolved = import.meta.resolve("./module.mjs"); - | ^~~~~~~~~~~~~~~~~~~ - 2 | console.log(resolved); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file diff --git a/tests/harness/__snapshots__/module-resolution-introspection.ts.txt b/tests/harness/__snapshots__/module-resolution-introspection.ts.txt new file mode 100644 index 00000000..3dd1b612 --- /dev/null +++ b/tests/harness/__snapshots__/module-resolution-introspection.ts.txt @@ -0,0 +1,26 @@ +module-resolution-introspection.ts:4:33 - error SC2020: 'import.meta.resolve with a runtime-computed specifier' is part of the standard library types but has no scriptc lowering yet + + 3 | const specifier = process.env.MODULE_NAME ?? "./fallback.mjs"; + 4 | console.log(import.meta.resolve(specifier)); + | ^~~~~~~~~ + 5 | // createRequire uses the same static-literal boundary. + + hint: a compiled binary has a fixed module graph — pass a string literal + +module-resolution-introspection.ts:7:29 - error SC2020: 'require.resolve with a runtime-computed request' is part of the standard library types but has no scriptc lowering yet + + 6 | const require = createRequire(import.meta.url); + 7 | console.log(require.resolve(specifier)); + | ^~~~~~~~~ + 8 | console.log(require.resolve("./fallback.mjs", { paths: process.argv })); + + hint: a compiled binary has a fixed module graph — pass a string literal + +module-resolution-introspection.ts:8:47 - error SC2020: 'require.resolve with runtime-computed options' is part of the standard library types but has no scriptc lowering yet + + 7 | console.log(require.resolve(specifier)); + 8 | console.log(require.resolve("./fallback.mjs", { paths: process.argv })); + | ^~~~~~~~~~~~~~~~~~~~~~~ + 9 | // Dynamic requests and dynamic search paths remain explicit refusals. + + hint: omit options or pass { paths: ["", ...] } \ No newline at end of file