Skip to content

Commit 7010f60

Browse files
committed
WIP: cjs named exports
1 parent add0a9e commit 7010f60

File tree

8 files changed

+96
-7
lines changed

8 files changed

+96
-7
lines changed

packages/cli/src/problemUtils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const problemFlags = {
99
CJSResolvesToESM: "cjs-resolves-to-esm",
1010
FallbackCondition: "fallback-condition",
1111
CJSOnlyExportsDefault: "cjs-only-exports-default",
12+
CJSNamedExports: "cjs-named-exports",
1213
FalseExportDefault: "false-export-default",
1314
MissingExportEquals: "missing-export-equals",
1415
UnexpectedModuleSyntax: "unexpected-module-syntax",

packages/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
},
5252
"dependencies": {
5353
"@andrewbranch/untar.js": "^1.0.3",
54+
"cjs-module-lexer": "^1.2.3",
5455
"fflate": "^0.8.2",
5556
"semver": "^7.5.4",
5657
"ts-expose-internals-conditionally": "1.0.0-empty.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import ts from "typescript";
2+
import type { Package } from "../../createPackage.js";
3+
import { init, parse } from "cjs-module-lexer";
4+
import { defineCheck } from "../defineCheck.js";
5+
6+
await init();
7+
8+
function* crawlExports(pkg: Package, fileName: string, seen = new Set<string>()): Iterable<string> {
9+
if (seen.has(fileName)) {
10+
return;
11+
}
12+
seen.add(fileName);
13+
const result = parse(pkg.readFile(fileName));
14+
yield* result.exports;
15+
for (const relativeName of result.reexports) {
16+
if (relativeName.startsWith(".")) {
17+
const resolvedName = new URL(relativeName, `cjs://${fileName}`);
18+
yield* crawlExports(pkg, String(resolvedName).slice(6), seen);
19+
}
20+
}
21+
}
22+
23+
export default defineCheck({
24+
name: "CJSNamedExports",
25+
dependencies: ({ entrypoints, subpath, resolutionKind }) => {
26+
const entrypoint = entrypoints[subpath].resolutions[resolutionKind];
27+
const moduleType = entrypoint.implementationResolution?.isCommonJS ? ("cjs" as const) : undefined;
28+
const typesFileName = entrypoint.resolution?.fileName;
29+
const implementationFileName = entrypoint.implementationResolution?.fileName;
30+
return [implementationFileName, typesFileName, resolutionKind, moduleType];
31+
},
32+
execute: ([implementationFileName, typesFileName, resolutionKind, moduleType], context) => {
33+
if (!implementationFileName || !typesFileName || resolutionKind !== "node16-esm" || moduleType !== "cjs") {
34+
return;
35+
}
36+
const exports = [...crawlExports(context.pkg, implementationFileName)];
37+
const host = context.hosts.findHostForFiles([typesFileName])!;
38+
const typesSourceFile = host.getSourceFile(typesFileName)!;
39+
const typeChecker = host.createAuxiliaryProgram([typesFileName]).getTypeChecker();
40+
const typesExports = typeChecker.getExportsOfModule(typesSourceFile.symbol);
41+
const expectedNames = typesExports
42+
.flatMap((node) => [...(node.declarations?.values() ?? [])])
43+
.filter((node) => !ts.isTypeDeclaration(node))
44+
.map((declaration) => declaration.symbol.escapedName);
45+
const missingNames = expectedNames.filter((name) => !exports.includes(String(name)));
46+
if (missingNames.length > 0) {
47+
console.log("missing", missingNames);
48+
return {
49+
kind: "CJSNamedExports",
50+
implementationFileName,
51+
typesFileName,
52+
};
53+
}
54+
},
55+
});

packages/core/src/internal/checks/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import cjsOnlyExportsDefault from "./cjsOnlyExportsDefault.js";
2+
import cjsNamedExports from "./cjsNamedExports.js";
23
import entrypointResolutions from "./entrypointResolutions.js";
34
import exportDefaultDisagreement from "./exportDefaultDisagreement.js";
45
import internalResolutionError from "./internalResolutionError.js";
@@ -9,6 +10,7 @@ export default [
910
entrypointResolutions,
1011
moduleKindDisagreement,
1112
exportDefaultDisagreement,
13+
cjsNamedExports,
1214
cjsOnlyExportsDefault,
1315
unexpectedModuleSyntax,
1416
internalResolutionError,

packages/core/src/internal/getEntrypointInfo.ts

+16-7
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ function getEntrypoints(fs: Package, exportsObject: unknown, options: CheckPacka
2424
const proxies = getProxyDirectories(rootDir, fs);
2525
if (proxies.length === 0) {
2626
if (options?.entrypointsLegacy) {
27-
return fs.listFiles()
28-
.filter(f => !ts.isDeclarationFileName(f) && extensions.has(f.slice(f.lastIndexOf("."))))
29-
.map(f => "." + f.slice(rootDir.length));
27+
return fs
28+
.listFiles()
29+
.filter((f) => !ts.isDeclarationFileName(f) && extensions.has(f.slice(f.lastIndexOf("."))))
30+
.map((f) => "." + f.slice(rootDir.length));
3031
}
3132
return ["."];
3233
}
@@ -96,6 +97,7 @@ export function getEntrypointInfo(
9697
options: CheckPackageOptions | undefined,
9798
): Record<string, EntrypointInfo> {
9899
const packageJson = JSON.parse(fs.readFile(`/node_modules/${packageName}/package.json`));
100+
const typeIsModule = packageJson.type === "module";
99101
let entrypoints = getEntrypoints(fs, packageJson.exports, options);
100102
if (fs.typesPackage) {
101103
const typesPackageJson = JSON.parse(fs.readFile(`/node_modules/${fs.typesPackage.packageName}/package.json`));
@@ -105,10 +107,10 @@ export function getEntrypointInfo(
105107
const result: Record<string, EntrypointInfo> = {};
106108
for (const entrypoint of entrypoints) {
107109
const resolutions: Record<ResolutionKind, EntrypointResolutionAnalysis> = {
108-
node10: getEntrypointResolution(packageName, hosts.node10, "node10", entrypoint),
109-
"node16-cjs": getEntrypointResolution(packageName, hosts.node16, "node16-cjs", entrypoint),
110-
"node16-esm": getEntrypointResolution(packageName, hosts.node16, "node16-esm", entrypoint),
111-
bundler: getEntrypointResolution(packageName, hosts.bundler, "bundler", entrypoint),
110+
node10: getEntrypointResolution(packageName, typeIsModule, hosts.node10, "node10", entrypoint),
111+
"node16-cjs": getEntrypointResolution(packageName, typeIsModule, hosts.node16, "node16-cjs", entrypoint),
112+
"node16-esm": getEntrypointResolution(packageName, typeIsModule, hosts.node16, "node16-esm", entrypoint),
113+
bundler: getEntrypointResolution(packageName, typeIsModule, hosts.bundler, "bundler", entrypoint),
112114
};
113115
result[entrypoint] = {
114116
subpath: entrypoint,
@@ -121,6 +123,7 @@ export function getEntrypointInfo(
121123
}
122124
function getEntrypointResolution(
123125
packageName: string,
126+
typeIsModule: boolean,
124127
host: CompilerHostWrapper,
125128
resolutionKind: ResolutionKind,
126129
entrypoint: string,
@@ -167,6 +170,12 @@ function getEntrypointResolution(
167170

168171
return {
169172
fileName,
173+
isESM:
174+
resolution.resolvedModule.extension === ts.Extension.Mjs ||
175+
(typeIsModule && resolution.resolvedModule.extension === ts.Extension.Js),
176+
isCommonJS:
177+
resolution.resolvedModule.extension === ts.Extension.Cjs ||
178+
(!typeIsModule && resolution.resolvedModule.extension === ts.Extension.Js),
170179
isJson: resolution.resolvedModule.extension === ts.Extension.Json,
171180
isTypeScript: ts.hasTSFileExtension(resolution.resolvedModule.resolvedFileName),
172181
trace,

packages/core/src/problems.ts

+7
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export const problemKindInfo: Record<ProblemKind, ProblemKindInfo> = {
3939
description: "Import resolved to an ESM type declaration file, but a CommonJS JavaScript file.",
4040
docsUrl: "https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseESM.md",
4141
},
42+
CJSNamedExports: {
43+
emoji: "🕵️",
44+
title: "Types include CJS named exports which are not present in the implementation",
45+
shortDescription: "Named CJS types",
46+
description: "docs",
47+
docsUrl: "docs",
48+
},
4249
CJSResolvesToESM: {
4350
emoji: "⚠️",
4451
title: "Entrypoint is ESM-only",

packages/core/src/types.ts

+7
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export interface EntrypointResolutionAnalysis {
7070
export interface Resolution {
7171
fileName: string;
7272
isTypeScript: boolean;
73+
isESM: boolean;
74+
isCommonJS: boolean;
7375
isJson: boolean;
7476
trace: string[];
7577
}
@@ -126,6 +128,10 @@ export interface CJSResolvesToESMProblem extends EntrypointResolutionProblem {
126128
kind: "CJSResolvesToESM";
127129
}
128130

131+
export interface CJSNamedExportsProblem extends FilePairProblem {
132+
kind: "CJSNamedExports";
133+
}
134+
129135
export interface FallbackConditionProblem extends EntrypointResolutionProblem {
130136
kind: "FallbackCondition";
131137
}
@@ -162,6 +168,7 @@ export type Problem =
162168
| FalseESMProblem
163169
| FalseCJSProblem
164170
| CJSResolvesToESMProblem
171+
| CJSNamedExportsProblem
165172
| FallbackConditionProblem
166173
| FalseExportDefaultProblem
167174
| MissingExportEqualsProblem

pnpm-lock.yaml

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)