diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 039d94977fd..4994f763b0d 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -2206,11 +2206,16 @@ export function unsupportedNativeApiDiag( }; } -export function unsupportedPackageImportDiag(pkg: string, loc: SrcLoc): ScrDiagnostic { +export function unsupportedPackageImportDiag( + pkg: string, + loc: SrcLoc, + hint?: string, +): ScrDiagnostic { return npmStaticRefusalDiag( pkg, "its implementation is not part of the compiled module graph", loc, + hint, ); } @@ -2222,6 +2227,16 @@ export function unsupportedPackageValueDiag(pkg: string, loc: SrcLoc): ScrDiagno ); } +/** The package a declaration-only `@types/` package types: resolution + * can answer the types package for a program that imports ``, and the + * diagnostic must name what the program imports (`@types/scope__name` types + * `@scope/name`). Any other name is returned as is. */ +export function runtimePackageName(pkg: string): string { + if (!pkg.startsWith("@types/")) return pkg; + const bare = pkg.slice("@types/".length); + return bare.includes("__") ? `@${bare.replace("__", "/")}` : bare; +} + export function npmStaticRefusalDiag( pkg: string, detail: string, @@ -2230,7 +2245,7 @@ export function npmStaticRefusalDiag( ): ScrDiagnostic { return { code: "SC2013", - message: `cannot compile npm package '${pkg}' statically: ${detail}`, + message: `cannot compile npm package '${runtimePackageName(pkg)}' statically: ${detail}`, loc, milestone: "later", hint: diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 4bcbea62c57..1cffce99296 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -45,6 +45,7 @@ import { import { type ScrDiagnostic, unsupportedNativeApiDiag, + runtimePackageName, unsupportedPackageImportDiag, unsupportedPackageValueDiag, } from "../../diagnostics/diagnostic.js"; @@ -2356,7 +2357,8 @@ function uncompiledImportPackage(lowerer: Lowerer, call: ts.CallExpression): str if (specifier === null) return null; const file = ts.getSourceFileOfNode(call).fileName; if (lowerer.dynImports.get(dynImportKey(file, specifier, !literal)) !== undefined) return null; - return resolveNpmImport(file, specifier)?.packageName ?? null; + const npm = resolveNpmImport(file, specifier); + return npm === null ? null : runtimePackageName(npm.packageName); } /** The package a refusal names as outside the compiled module graph @@ -2425,7 +2427,14 @@ function lowerResolvedImport( // call (lower-node-island.ts, lowerIslandLoadCall). const npm = resolveNpmImport(ts.getSourceFileOfNode(call).fileName, specifier); if (npm !== null) { - lowerer.pushDiag(unsupportedPackageImportDiag(npm.packageName, locOf(call))); + lowerer.pushDiag( + unsupportedPackageImportDiag( + runtimePackageName(npm.packageName), + locOf(call), + "no static import names this package, so it is not compiled: add a static import of it " + + "(`import x from 'pkg'`) so it joins the module graph, or allow a Node island for it", + ), + ); throw new PoisonError(); } lowerer.refuseUnsupportedApi("'import()'", call); diff --git a/tests/harness/lazy-package-import.test.ts b/tests/harness/lazy-package-import.test.ts index 4ec2a243f8d..275c3a4c7d6 100644 --- a/tests/harness/lazy-package-import.test.ts +++ b/tests/harness/lazy-package-import.test.ts @@ -4,7 +4,7 @@ * uncompiled package reports, and that refusal names the package. Under the * automatic policy the package then runs in a Node island (corpus 9537 runs * that against Node); with islands off the refusal is the build's error. */ -import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "vitest"; @@ -33,3 +33,45 @@ test("with islands off, the build error names the lazily imported package", asyn ); expect(result.diagnostics.map((d) => d.code)).not.toContain("SC2012"); }, 120_000); + +test("the refusal names the imported package, not its @types package, and hints at a static import", async () => { + const copy = mkdtempSync(join(tmpdir(), "scriptc-lazy-types-")); + copies.push(copy); + const write = (file: string, text: string): void => { + mkdirSync(join(copy, file, ".."), { recursive: true }); + writeFileSync(join(copy, file), text); + }; + write("package.json", '{"name":"app","private":true,"type":"module"}\n'); + write( + "node_modules/tiny-ms/package.json", + '{"name":"tiny-ms","version":"1.0.0","main":"index.js"}\n', + ); + write("node_modules/tiny-ms/index.js", "module.exports = (s) => s.length;\n"); + write( + "node_modules/@types/tiny-ms/package.json", + '{"name":"@types/tiny-ms","version":"1.0.0","types":"index.d.ts"}\n', + ); + write( + "node_modules/@types/tiny-ms/index.d.ts", + "declare function ms(s: string): number;\nexport = ms;\n", + ); + write( + "main.ts", + 'async function main() {\n const m = await import("tiny-ms");\n console.log(m.default("abc"));\n}\nmain();\n', + ); + const result = await compile(join(copy, "main.ts"), { + outPath: join(copy, "main"), + outDir: copy, + nodeIslands: { mode: "off" }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + const refusals = result.diagnostics.filter((d) => d.code === "SC2013"); + expect(refusals.length).toBeGreaterThan(0); + for (const refusal of refusals) { + expect(refusal.message).toBe( + "cannot compile npm package 'tiny-ms' statically: its implementation is not part of the compiled module graph", + ); + } + expect(refusals[0]?.hint).toContain("add a static import"); +}, 120_000);