Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compare ESM named exports to types #166

Merged
merged 13 commits into from
Aug 27, 2024
21 changes: 21 additions & 0 deletions docs/problems/NamedExports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# πŸ•΅οΈ Named ESM exports

Module advertises named exports which will not exist at runtime.

## Explanation

Static analysis of the distributed **JavaScript** files in this module found that the exports
declared in **TypeScript** do not exist when imported from ESM.

## Consequences

Consumers of this module will run into problems when importing named exports.

```ts
import { utility } from "pkg";
// SyntaxError: The requested module 'pkg' does not provide an export named 'utility'
```

## Common causes

[...]
1 change: 1 addition & 0 deletions packages/cli/src/problemUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const problemFlags = {
CJSResolvesToESM: "cjs-resolves-to-esm",
FallbackCondition: "fallback-condition",
CJSOnlyExportsDefault: "cjs-only-exports-default",
NamedExports: "named-exports",
FalseExportDefault: "false-export-default",
MissingExportEquals: "missing-export-equals",
UnexpectedModuleSyntax: "unexpected-module-syntax",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"type": "module",
"imports": {
"#internal/*": "./dist/internal/*"
"#*": "./dist/*"
},
"exports": {
".": {
Expand All @@ -51,6 +51,7 @@
},
"dependencies": {
"@andrewbranch/untar.js": "^1.0.3",
"cjs-module-lexer": "^1.2.3",
"fflate": "^0.8.2",
"semver": "^7.5.4",
"typescript": "5.6.1-rc",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/checkPackage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Package } from "./createPackage.js";
import { init as initCjsLexer } from "cjs-module-lexer";
import checks from "./internal/checks/index.js";
import type { AnyCheck, CheckDependenciesContext } from "./internal/defineCheck.js";
import { createCompilerHosts } from "./internal/multiCompilerHost.js";
Expand Down Expand Up @@ -60,6 +61,7 @@ export async function checkPackage(pkg: Package, options?: CheckPackageOptions):
bundler: {},
};

await initCjsLexer();
const problems: Problem[] = [];
const problemIdsToIndices = new Map<string, number[]>();
visitResolutions(entrypointResolutions, (analysis, info) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/internal/checks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import cjsOnlyExportsDefault from "./cjsOnlyExportsDefault.js";
import namedExports from "./namedExports.js";
import entrypointResolutions from "./entrypointResolutions.js";
import exportDefaultDisagreement from "./exportDefaultDisagreement.js";
import internalResolutionError from "./internalResolutionError.js";
Expand All @@ -9,6 +10,7 @@ export default [
entrypointResolutions,
moduleKindDisagreement,
exportDefaultDisagreement,
namedExports,
cjsOnlyExportsDefault,
unexpectedModuleSyntax,
internalResolutionError,
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/internal/checks/namedExports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import ts from "typescript";
import { defineCheck } from "../defineCheck.js";
import { getEsmModuleNamespace } from "../esm/esmNamespace.js";

export default defineCheck({
name: "NamedExports",
dependencies: ({ entrypoints, subpath, resolutionKind }) => {
const entrypoint = entrypoints[subpath].resolutions[resolutionKind];
const typesFileName = entrypoint.resolution?.fileName;
const implementationFileName = entrypoint.implementationResolution?.fileName;
return [implementationFileName, typesFileName, resolutionKind];
},
execute: ([implementationFileName, typesFileName, resolutionKind], context) => {
if (!implementationFileName || !typesFileName || resolutionKind !== "node16-esm") {
return;
}

// Get declared exported names from TypeScript
const host = context.hosts.findHostForFiles([typesFileName])!;
const typesSourceFile = host.getSourceFile(typesFileName)!;
const expectedNames = (() => {
if (typesSourceFile.scriptKind === ts.ScriptKind.JSON) {
// TypeScript reports top-level JSON keys as exports which is WRONG WRONG WRONG. A JSON file
// never export anything other than `default`.
return ["default"];
} else {
// nb: This is incomplete and reports type-only exports. This should be fixed to only return
// expected runtime exports.
const typeChecker = host.createAuxiliaryProgram([typesFileName]).getTypeChecker();
const typesExports = typeChecker.getExportsAndPropertiesOfModule(typesSourceFile.symbol);
return Array.from(
new Set(
typesExports
.flatMap((node) => [...(node.declarations?.values() ?? [])])
.filter((node) => !ts.isTypeAlias(node) && !ts.isTypeDeclaration(node) && !ts.isNamespaceBody(node))
.map((declaration) => declaration.symbol.escapedName)
.map(String),
),
);
}
})();

// Get actual exported names as seen by nodejs
const exports = (() => {
try {
return getEsmModuleNamespace(context.pkg, implementationFileName);
} catch {
// nb: If this fails then the result is indeterminate. This could happen in many cases, but
// a common one would be for packages which re-export from another another package.
}
})();
if (exports) {
const missing = expectedNames.filter((name) => !exports.includes(name));
if (missing.length > 0) {
const lengthWithoutDefault = (names: readonly string[]) => names.length - (names.includes("default") ? 1 : 0);
return {
kind: "NamedExports",
implementationFileName,
typesFileName,
isMissingAllNamed: lengthWithoutDefault(missing) === lengthWithoutDefault(expectedNames),
missing,
};
}
}
},
});
6 changes: 6 additions & 0 deletions packages/core/src/internal/esm/cjsBindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Exports } from "cjs-module-lexer";
import { parse as cjsParse } from "cjs-module-lexer";

export function getCjsModuleBindings(sourceText: string): Exports {
return cjsParse(sourceText);
}
32 changes: 32 additions & 0 deletions packages/core/src/internal/esm/cjsNamespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Package } from "../../createPackage.js";
import { getCjsModuleBindings } from "./cjsBindings.js";
import { cjsResolve } from "./cjsResolve.js";

export function getCjsModuleNamespace(fs: Package, file: URL, seen = new Set<string>()) {
seen.add(file.pathname);
const { exports, reexports } = getCjsModuleBindings(fs.readFile(file.pathname));

// CJS always exports `default`
if (!exports.includes("default")) {
exports.push("default");
}

// Additionally, resolve facade reexports
const lastResolvableReexport = (() => {
for (const source of reexports.reverse()) {
try {
return cjsResolve(fs, source, file);
} catch {}
}
})();
if (
lastResolvableReexport &&
lastResolvableReexport.format === "commonjs" &&
!seen.has(lastResolvableReexport.resolved.pathname)
) {
const extra = getCjsModuleNamespace(fs, lastResolvableReexport.resolved, seen);
exports.push(...extra.filter((name) => !exports.includes(name)));
}

return exports;
}
Loading
Loading