@knighted/module 1.3.1 → 1.4.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cjs/cli.cjs +7 -0
- package/dist/cjs/format.cjs +238 -3
- package/dist/cjs/module.cjs +1 -0
- package/dist/cjs/types.d.cts +2 -0
- package/dist/cli.js +7 -0
- package/dist/format.js +238 -3
- package/dist/module.js +1 -0
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,6 +130,7 @@ type ModuleOptions = {
|
|
|
130
130
|
importMetaMain?: 'shim' | 'warn' | 'error'
|
|
131
131
|
requireMainStrategy?: 'import-meta-main' | 'realpath'
|
|
132
132
|
detectCircularRequires?: 'off' | 'warn' | 'error'
|
|
133
|
+
detectDualPackageHazard?: 'off' | 'warn' | 'error'
|
|
133
134
|
requireSource?: 'builtin' | 'create-require'
|
|
134
135
|
importMetaPrelude?: 'off' | 'auto' | 'on'
|
|
135
136
|
cjsDefault?: 'module-exports' | 'auto' | 'none'
|
|
@@ -155,6 +156,7 @@ type ModuleOptions = {
|
|
|
155
156
|
- `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check.
|
|
156
157
|
- `importMetaPrelude` (`auto`): emit a no-op `void import.meta.filename;` touch. `on` always emits; `off` never emits; `auto` emits only when helpers that reference `import.meta.*` are synthesized (e.g., `__dirname`/`__filename` in CJS→ESM, require-main shims, createRequire helpers). Useful for bundlers/transpilers that do usage-based `import.meta` polyfilling.
|
|
157
158
|
- `detectCircularRequires` (`off`): optionally detect relative static require cycles and warn/throw.
|
|
159
|
+
- `detectDualPackageHazard` (`warn`): flag when a file mixes `import` and `require` of the same package or combines root and subpath specifiers that can resolve to separate module instances (dual packages). Set to `error` to fail the transform.
|
|
158
160
|
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
|
|
159
161
|
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback. Precedence: the callback (if provided) runs first; if it returns a string, that wins. If it returns `undefined` or `null`, the appenders still apply.
|
|
160
162
|
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
|
package/dist/cjs/cli.cjs
CHANGED
|
@@ -29,6 +29,7 @@ const defaultOptions = {
|
|
|
29
29
|
importMetaMain: 'shim',
|
|
30
30
|
requireMainStrategy: 'import-meta-main',
|
|
31
31
|
detectCircularRequires: 'off',
|
|
32
|
+
detectDualPackageHazard: 'warn',
|
|
32
33
|
requireSource: 'builtin',
|
|
33
34
|
nestedRequireStrategy: 'create-require',
|
|
34
35
|
cjsDefault: 'auto',
|
|
@@ -150,6 +151,11 @@ const optionsTable = [{
|
|
|
150
151
|
short: 'c',
|
|
151
152
|
type: 'string',
|
|
152
153
|
desc: 'Warn/error on circular require (off|warn|error)'
|
|
154
|
+
}, {
|
|
155
|
+
long: 'detect-dual-package-hazard',
|
|
156
|
+
short: 'H',
|
|
157
|
+
type: 'string',
|
|
158
|
+
desc: 'Warn/error on mixed import/require of dual packages (off|warn|error)'
|
|
153
159
|
}, {
|
|
154
160
|
long: 'top-level-await',
|
|
155
161
|
short: 'a',
|
|
@@ -281,6 +287,7 @@ const toModuleOptions = values => {
|
|
|
281
287
|
appendJsExtension: appendJsExtension,
|
|
282
288
|
appendDirectoryIndex,
|
|
283
289
|
detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
|
|
290
|
+
detectDualPackageHazard: parseEnum(values['detect-dual-package-hazard'], ['off', 'warn', 'error']) ?? defaultOptions.detectDualPackageHazard,
|
|
284
291
|
topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
|
|
285
292
|
cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
|
|
286
293
|
idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
|
package/dist/cjs/format.cjs
CHANGED
|
@@ -4,6 +4,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.format = void 0;
|
|
7
|
+
var _nodeModule = require("node:module");
|
|
8
|
+
var _nodePath = require("node:path");
|
|
9
|
+
var _promises = require("node:fs/promises");
|
|
7
10
|
var _magicString = _interopRequireDefault(require("magic-string"));
|
|
8
11
|
var _async = require("./helpers/async.cjs");
|
|
9
12
|
var _identifier = require("./helpers/identifier.cjs");
|
|
@@ -24,6 +27,225 @@ var _url = require("./utils/url.cjs");
|
|
|
24
27
|
var _walk = require("./walk.cjs");
|
|
25
28
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
26
29
|
const isRequireMainMember = (node, shadowed) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && node.object.name === 'require' && !shadowed.has('require') && node.property.type === 'Identifier' && node.property.name === 'main';
|
|
30
|
+
const builtinSpecifiers = new Set(_nodeModule.builtinModules.map(mod => mod.startsWith('node:') ? mod.slice(5) : mod).flatMap(mod => {
|
|
31
|
+
const parts = mod.split('/');
|
|
32
|
+
const base = parts[0];
|
|
33
|
+
return parts.length > 1 ? [mod, base] : [mod];
|
|
34
|
+
}));
|
|
35
|
+
const stripQuery = value => value.includes('?') || value.includes('#') ? value.split(/[?#]/)[0] ?? value : value;
|
|
36
|
+
const packageFromSpecifier = spec => {
|
|
37
|
+
const cleaned = stripQuery(spec);
|
|
38
|
+
if (!cleaned) return null;
|
|
39
|
+
if (cleaned.startsWith('node:')) return null;
|
|
40
|
+
if (/^(?:\.?\.?\/|\/)/.test(cleaned)) return null;
|
|
41
|
+
if (/^[a-zA-Z][a-zA-Z+.-]*:/.test(cleaned)) return null;
|
|
42
|
+
const parts = cleaned.split('/');
|
|
43
|
+
if (cleaned.startsWith('@')) {
|
|
44
|
+
if (parts.length < 2) return null;
|
|
45
|
+
const pkg = `${parts[0]}/${parts[1]}`;
|
|
46
|
+
if (builtinSpecifiers.has(pkg) || builtinSpecifiers.has(parts[1] ?? '')) return null;
|
|
47
|
+
const subpath = parts.slice(2).join('/');
|
|
48
|
+
return {
|
|
49
|
+
pkg,
|
|
50
|
+
subpath
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const pkg = parts[0] ?? '';
|
|
54
|
+
if (!pkg || builtinSpecifiers.has(pkg)) return null;
|
|
55
|
+
const subpath = parts.slice(1).join('/');
|
|
56
|
+
return {
|
|
57
|
+
pkg,
|
|
58
|
+
subpath
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
const fileExists = async filename => {
|
|
62
|
+
try {
|
|
63
|
+
const stats = await (0, _promises.stat)(filename);
|
|
64
|
+
return stats.isFile();
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const findPackageManifest = async (pkg, filePath, cwd) => {
|
|
70
|
+
const startDir = filePath ? (0, _nodePath.dirname)((0, _nodePath.resolve)(filePath)) : (0, _nodePath.resolve)(cwd ?? process.cwd());
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
let dir = startDir;
|
|
73
|
+
while (!seen.has(dir)) {
|
|
74
|
+
seen.add(dir);
|
|
75
|
+
const candidate = (0, _nodePath.join)(dir, 'node_modules', pkg, 'package.json');
|
|
76
|
+
if (await fileExists(candidate)) return candidate;
|
|
77
|
+
const parent = (0, _nodePath.dirname)(dir);
|
|
78
|
+
if (parent === dir) break;
|
|
79
|
+
dir = parent;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
const readPackageManifest = async (pkg, filePath, cwd, cache) => {
|
|
84
|
+
const start = (0, _nodePath.resolve)(filePath ? (0, _nodePath.dirname)(filePath) : cwd ?? process.cwd());
|
|
85
|
+
const cacheKey = `${pkg}@${start}`;
|
|
86
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
87
|
+
const manifestPath = await findPackageManifest(pkg, filePath, cwd);
|
|
88
|
+
if (!manifestPath) {
|
|
89
|
+
cache.set(cacheKey, null);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const raw = await (0, _promises.readFile)(manifestPath, 'utf8');
|
|
94
|
+
const json = JSON.parse(raw);
|
|
95
|
+
cache.set(cacheKey, json);
|
|
96
|
+
return json;
|
|
97
|
+
} catch {
|
|
98
|
+
cache.set(cacheKey, null);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const analyzeExportsTargets = exportsField => {
|
|
103
|
+
const root = exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField) ?
|
|
104
|
+
// @ts-expect-error -- loose lookup of root export condition
|
|
105
|
+
exportsField['.'] ?? exportsField : exportsField;
|
|
106
|
+
if (typeof root === 'string') {
|
|
107
|
+
return {
|
|
108
|
+
importTarget: root,
|
|
109
|
+
requireTarget: root
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (root && typeof root === 'object') {
|
|
113
|
+
const record = root;
|
|
114
|
+
const importTarget = typeof record.import === 'string' ? record.import : undefined;
|
|
115
|
+
const requireTarget = typeof record.require === 'string' ? record.require : undefined;
|
|
116
|
+
const defaultTarget = typeof record.default === 'string' ? record.default : undefined;
|
|
117
|
+
return {
|
|
118
|
+
importTarget: importTarget ?? defaultTarget,
|
|
119
|
+
requireTarget: requireTarget ?? defaultTarget
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
importTarget: undefined,
|
|
124
|
+
requireTarget: undefined
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const describeDualPackage = pkgJson => {
|
|
128
|
+
const {
|
|
129
|
+
importTarget,
|
|
130
|
+
requireTarget
|
|
131
|
+
} = analyzeExportsTargets(pkgJson?.exports);
|
|
132
|
+
const moduleField = typeof pkgJson?.module === 'string' ? pkgJson.module : undefined;
|
|
133
|
+
const mainField = typeof pkgJson?.main === 'string' ? pkgJson.main : undefined;
|
|
134
|
+
const typeField = typeof pkgJson?.type === 'string' ? pkgJson.type : undefined;
|
|
135
|
+
const divergentExports = importTarget && requireTarget && importTarget !== requireTarget;
|
|
136
|
+
const divergentModuleMain = moduleField && mainField && moduleField !== mainField;
|
|
137
|
+
const typeModuleMainCjs = typeField === 'module' && typeof mainField === 'string' && mainField.endsWith('.cjs');
|
|
138
|
+
const hasHazardSignals = divergentExports || divergentModuleMain || typeModuleMainCjs;
|
|
139
|
+
const details = [];
|
|
140
|
+
if (divergentExports) {
|
|
141
|
+
details.push(`exports import -> ${importTarget}, require -> ${requireTarget}`);
|
|
142
|
+
}
|
|
143
|
+
if (divergentModuleMain) {
|
|
144
|
+
details.push(`module -> ${moduleField}, main -> ${mainField}`);
|
|
145
|
+
}
|
|
146
|
+
if (typeModuleMainCjs) {
|
|
147
|
+
details.push(`type: module with CommonJS main (${mainField})`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
hasHazardSignals,
|
|
151
|
+
details,
|
|
152
|
+
importTarget,
|
|
153
|
+
requireTarget
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const detectDualPackageHazards = async params => {
|
|
157
|
+
const {
|
|
158
|
+
program,
|
|
159
|
+
shadowedBindings,
|
|
160
|
+
hazardLevel,
|
|
161
|
+
filePath,
|
|
162
|
+
cwd,
|
|
163
|
+
diagOnce
|
|
164
|
+
} = params;
|
|
165
|
+
const usages = new Map();
|
|
166
|
+
const manifestCache = new Map();
|
|
167
|
+
const record = (pkg, kind, spec, subpath, loc) => {
|
|
168
|
+
const existing = usages.get(pkg) ?? {
|
|
169
|
+
imports: [],
|
|
170
|
+
requires: []
|
|
171
|
+
};
|
|
172
|
+
const bucket = kind === 'import' ? existing.imports : existing.requires;
|
|
173
|
+
bucket.push({
|
|
174
|
+
spec,
|
|
175
|
+
subpath,
|
|
176
|
+
loc
|
|
177
|
+
});
|
|
178
|
+
usages.set(pkg, existing);
|
|
179
|
+
};
|
|
180
|
+
await (0, _walk.ancestorWalk)(program, {
|
|
181
|
+
enter(node) {
|
|
182
|
+
if (node.type === 'ImportDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
183
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
184
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
185
|
+
start: node.source.start,
|
|
186
|
+
end: node.source.end
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (node.type === 'ExportNamedDeclaration' && node.source && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
190
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
191
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
192
|
+
start: node.source.start,
|
|
193
|
+
end: node.source.end
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (node.type === 'ExportAllDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
197
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
198
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
199
|
+
start: node.source.start,
|
|
200
|
+
end: node.source.end
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
204
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
205
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
206
|
+
start: node.source.start,
|
|
207
|
+
end: node.source.end
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (node.type === 'CallExpression' && (0, _lowerCjsRequireToImports.isStaticRequire)(node, shadowedBindings)) {
|
|
211
|
+
const arg = node.arguments[0];
|
|
212
|
+
if (arg?.type === 'Literal' && typeof arg.value === 'string') {
|
|
213
|
+
const pkg = packageFromSpecifier(arg.value);
|
|
214
|
+
if (pkg) record(pkg.pkg, 'require', arg.value, pkg.subpath, {
|
|
215
|
+
start: arg.start,
|
|
216
|
+
end: arg.end
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
for (const [pkg, usage] of usages) {
|
|
223
|
+
const hasImport = usage.imports.length > 0;
|
|
224
|
+
const hasRequire = usage.requires.length > 0;
|
|
225
|
+
const combined = [...usage.imports, ...usage.requires];
|
|
226
|
+
const hasRoot = combined.some(entry => !entry.subpath);
|
|
227
|
+
const hasSubpath = combined.some(entry => Boolean(entry.subpath));
|
|
228
|
+
if (hasImport && hasRequire) {
|
|
229
|
+
const importSpecs = usage.imports.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
|
|
230
|
+
const requireSpecs = usage.requires.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
|
|
231
|
+
diagOnce(hazardLevel, 'dual-package-mixed-specifiers', `Package '${pkg}' is loaded via import (${importSpecs.join(', ')}) and require (${requireSpecs.join(', ')}); conditional exports can instantiate it twice.`, usage.imports[0]?.loc ?? usage.requires[0]?.loc);
|
|
232
|
+
}
|
|
233
|
+
if (hasRoot && hasSubpath) {
|
|
234
|
+
const subpaths = combined.filter(entry => entry.subpath).map(entry => `${pkg}/${entry.subpath}`);
|
|
235
|
+
diagOnce(hazardLevel, 'dual-package-subpath', `Package '${pkg}' is referenced via root specifier '${pkg}' and subpath(s) ${subpaths.join(', ')}; mixing them loads separate module instances.`, combined.find(entry => entry.subpath)?.loc ?? combined[0]?.loc);
|
|
236
|
+
}
|
|
237
|
+
if (hasImport && hasRequire) {
|
|
238
|
+
const manifest = await readPackageManifest(pkg, filePath, cwd, manifestCache);
|
|
239
|
+
if (manifest) {
|
|
240
|
+
const meta = describeDualPackage(manifest);
|
|
241
|
+
if (meta.hasHazardSignals) {
|
|
242
|
+
const detail = meta.details.length ? ` (${meta.details.join('; ')})` : '';
|
|
243
|
+
diagOnce(hazardLevel, 'dual-package-conditional-exports', `Package '${pkg}' exposes different entry points for import vs require${detail}. Mixed usage can produce distinct instances.`, usage.imports[0]?.loc ?? usage.requires[0]?.loc);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
27
249
|
|
|
28
250
|
/**
|
|
29
251
|
* Node added support for import.meta.main.
|
|
@@ -53,22 +275,35 @@ const format = async (src, ast, opts) => {
|
|
|
53
275
|
// eslint-disable-next-line no-console -- used for opt-in diagnostics
|
|
54
276
|
console.error(diag.message);
|
|
55
277
|
};
|
|
56
|
-
const
|
|
57
|
-
const key = `${codeId}:${loc?.start ?? ''}`;
|
|
278
|
+
const diagOnce = (level, codeId, message, loc) => {
|
|
279
|
+
const key = `${level}:${codeId}:${loc?.start ?? ''}`;
|
|
58
280
|
if (warned.has(key)) return;
|
|
59
281
|
warned.add(key);
|
|
60
282
|
emitDiagnostic({
|
|
61
|
-
level
|
|
283
|
+
level,
|
|
62
284
|
code: codeId,
|
|
63
285
|
message,
|
|
64
286
|
filePath: opts.filePath,
|
|
65
287
|
loc
|
|
66
288
|
});
|
|
67
289
|
};
|
|
290
|
+
const warnOnce = (codeId, message, loc) => diagOnce('warning', codeId, message, loc);
|
|
68
291
|
const transformMode = opts.transformSyntax;
|
|
69
292
|
const fullTransform = transformMode === true;
|
|
70
293
|
const moduleIdentifiers = await (0, _identifiers.collectModuleIdentifiers)(ast.program);
|
|
71
294
|
const shadowedBindings = new Set([...moduleIdentifiers.entries()].filter(([, meta]) => meta.declare.length > 0).map(([name]) => name));
|
|
295
|
+
const hazardMode = opts.detectDualPackageHazard ?? 'warn';
|
|
296
|
+
if (hazardMode !== 'off') {
|
|
297
|
+
const hazardLevel = hazardMode === 'error' ? 'error' : 'warning';
|
|
298
|
+
await detectDualPackageHazards({
|
|
299
|
+
program: ast.program,
|
|
300
|
+
shadowedBindings,
|
|
301
|
+
hazardLevel,
|
|
302
|
+
filePath: opts.filePath,
|
|
303
|
+
cwd: opts.cwd,
|
|
304
|
+
diagOnce
|
|
305
|
+
});
|
|
306
|
+
}
|
|
72
307
|
if (opts.target === 'module' && fullTransform) {
|
|
73
308
|
if (shadowedBindings.has('module') || shadowedBindings.has('exports')) {
|
|
74
309
|
throw new Error('Cannot transform to ESM: module or exports is shadowed in module scope.');
|
package/dist/cjs/module.cjs
CHANGED
|
@@ -157,6 +157,7 @@ const defaultOptions = {
|
|
|
157
157
|
importMetaMain: 'shim',
|
|
158
158
|
requireMainStrategy: 'import-meta-main',
|
|
159
159
|
detectCircularRequires: 'off',
|
|
160
|
+
detectDualPackageHazard: 'warn',
|
|
160
161
|
requireSource: 'builtin',
|
|
161
162
|
nestedRequireStrategy: 'create-require',
|
|
162
163
|
cjsDefault: 'auto',
|
package/dist/cjs/types.d.cts
CHANGED
|
@@ -32,6 +32,8 @@ export type ModuleOptions = {
|
|
|
32
32
|
requireMainStrategy?: 'import-meta-main' | 'realpath';
|
|
33
33
|
/** Detect circular require usage level. */
|
|
34
34
|
detectCircularRequires?: 'off' | 'warn' | 'error';
|
|
35
|
+
/** Detect divergent import/require usage of the same dual package (default warn). */
|
|
36
|
+
detectDualPackageHazard?: 'off' | 'warn' | 'error';
|
|
35
37
|
/** Source used to provide require in ESM output. */
|
|
36
38
|
requireSource?: 'builtin' | 'create-require';
|
|
37
39
|
/** How to rewrite nested or non-hoistable require calls. */
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ const defaultOptions = {
|
|
|
23
23
|
importMetaMain: 'shim',
|
|
24
24
|
requireMainStrategy: 'import-meta-main',
|
|
25
25
|
detectCircularRequires: 'off',
|
|
26
|
+
detectDualPackageHazard: 'warn',
|
|
26
27
|
requireSource: 'builtin',
|
|
27
28
|
nestedRequireStrategy: 'create-require',
|
|
28
29
|
cjsDefault: 'auto',
|
|
@@ -144,6 +145,11 @@ const optionsTable = [{
|
|
|
144
145
|
short: 'c',
|
|
145
146
|
type: 'string',
|
|
146
147
|
desc: 'Warn/error on circular require (off|warn|error)'
|
|
148
|
+
}, {
|
|
149
|
+
long: 'detect-dual-package-hazard',
|
|
150
|
+
short: 'H',
|
|
151
|
+
type: 'string',
|
|
152
|
+
desc: 'Warn/error on mixed import/require of dual packages (off|warn|error)'
|
|
147
153
|
}, {
|
|
148
154
|
long: 'top-level-await',
|
|
149
155
|
short: 'a',
|
|
@@ -275,6 +281,7 @@ const toModuleOptions = values => {
|
|
|
275
281
|
appendJsExtension: appendJsExtension,
|
|
276
282
|
appendDirectoryIndex,
|
|
277
283
|
detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
|
|
284
|
+
detectDualPackageHazard: parseEnum(values['detect-dual-package-hazard'], ['off', 'warn', 'error']) ?? defaultOptions.detectDualPackageHazard,
|
|
278
285
|
topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
|
|
279
286
|
cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
|
|
280
287
|
idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
|
package/dist/format.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { builtinModules } from 'node:module';
|
|
2
|
+
import { dirname, join, resolve as pathResolve } from 'node:path';
|
|
3
|
+
import { readFile as fsReadFile, stat as fsStat } from 'node:fs/promises';
|
|
1
4
|
import MagicString from 'magic-string';
|
|
2
5
|
import { hasTopLevelAwait, isAsyncContext } from './helpers/async.js';
|
|
3
6
|
import { isIdentifierName } from './helpers/identifier.js';
|
|
@@ -17,6 +20,225 @@ import { collectModuleIdentifiers } from './utils/identifiers.js';
|
|
|
17
20
|
import { isValidUrl } from './utils/url.js';
|
|
18
21
|
import { ancestorWalk } from './walk.js';
|
|
19
22
|
const isRequireMainMember = (node, shadowed) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && node.object.name === 'require' && !shadowed.has('require') && node.property.type === 'Identifier' && node.property.name === 'main';
|
|
23
|
+
const builtinSpecifiers = new Set(builtinModules.map(mod => mod.startsWith('node:') ? mod.slice(5) : mod).flatMap(mod => {
|
|
24
|
+
const parts = mod.split('/');
|
|
25
|
+
const base = parts[0];
|
|
26
|
+
return parts.length > 1 ? [mod, base] : [mod];
|
|
27
|
+
}));
|
|
28
|
+
const stripQuery = value => value.includes('?') || value.includes('#') ? value.split(/[?#]/)[0] ?? value : value;
|
|
29
|
+
const packageFromSpecifier = spec => {
|
|
30
|
+
const cleaned = stripQuery(spec);
|
|
31
|
+
if (!cleaned) return null;
|
|
32
|
+
if (cleaned.startsWith('node:')) return null;
|
|
33
|
+
if (/^(?:\.?\.?\/|\/)/.test(cleaned)) return null;
|
|
34
|
+
if (/^[a-zA-Z][a-zA-Z+.-]*:/.test(cleaned)) return null;
|
|
35
|
+
const parts = cleaned.split('/');
|
|
36
|
+
if (cleaned.startsWith('@')) {
|
|
37
|
+
if (parts.length < 2) return null;
|
|
38
|
+
const pkg = `${parts[0]}/${parts[1]}`;
|
|
39
|
+
if (builtinSpecifiers.has(pkg) || builtinSpecifiers.has(parts[1] ?? '')) return null;
|
|
40
|
+
const subpath = parts.slice(2).join('/');
|
|
41
|
+
return {
|
|
42
|
+
pkg,
|
|
43
|
+
subpath
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const pkg = parts[0] ?? '';
|
|
47
|
+
if (!pkg || builtinSpecifiers.has(pkg)) return null;
|
|
48
|
+
const subpath = parts.slice(1).join('/');
|
|
49
|
+
return {
|
|
50
|
+
pkg,
|
|
51
|
+
subpath
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const fileExists = async filename => {
|
|
55
|
+
try {
|
|
56
|
+
const stats = await fsStat(filename);
|
|
57
|
+
return stats.isFile();
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const findPackageManifest = async (pkg, filePath, cwd) => {
|
|
63
|
+
const startDir = filePath ? dirname(pathResolve(filePath)) : pathResolve(cwd ?? process.cwd());
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
let dir = startDir;
|
|
66
|
+
while (!seen.has(dir)) {
|
|
67
|
+
seen.add(dir);
|
|
68
|
+
const candidate = join(dir, 'node_modules', pkg, 'package.json');
|
|
69
|
+
if (await fileExists(candidate)) return candidate;
|
|
70
|
+
const parent = dirname(dir);
|
|
71
|
+
if (parent === dir) break;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
const readPackageManifest = async (pkg, filePath, cwd, cache) => {
|
|
77
|
+
const start = pathResolve(filePath ? dirname(filePath) : cwd ?? process.cwd());
|
|
78
|
+
const cacheKey = `${pkg}@${start}`;
|
|
79
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
80
|
+
const manifestPath = await findPackageManifest(pkg, filePath, cwd);
|
|
81
|
+
if (!manifestPath) {
|
|
82
|
+
cache.set(cacheKey, null);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const raw = await fsReadFile(manifestPath, 'utf8');
|
|
87
|
+
const json = JSON.parse(raw);
|
|
88
|
+
cache.set(cacheKey, json);
|
|
89
|
+
return json;
|
|
90
|
+
} catch {
|
|
91
|
+
cache.set(cacheKey, null);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const analyzeExportsTargets = exportsField => {
|
|
96
|
+
const root = exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField) ?
|
|
97
|
+
// @ts-expect-error -- loose lookup of root export condition
|
|
98
|
+
exportsField['.'] ?? exportsField : exportsField;
|
|
99
|
+
if (typeof root === 'string') {
|
|
100
|
+
return {
|
|
101
|
+
importTarget: root,
|
|
102
|
+
requireTarget: root
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (root && typeof root === 'object') {
|
|
106
|
+
const record = root;
|
|
107
|
+
const importTarget = typeof record.import === 'string' ? record.import : undefined;
|
|
108
|
+
const requireTarget = typeof record.require === 'string' ? record.require : undefined;
|
|
109
|
+
const defaultTarget = typeof record.default === 'string' ? record.default : undefined;
|
|
110
|
+
return {
|
|
111
|
+
importTarget: importTarget ?? defaultTarget,
|
|
112
|
+
requireTarget: requireTarget ?? defaultTarget
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
importTarget: undefined,
|
|
117
|
+
requireTarget: undefined
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
const describeDualPackage = pkgJson => {
|
|
121
|
+
const {
|
|
122
|
+
importTarget,
|
|
123
|
+
requireTarget
|
|
124
|
+
} = analyzeExportsTargets(pkgJson?.exports);
|
|
125
|
+
const moduleField = typeof pkgJson?.module === 'string' ? pkgJson.module : undefined;
|
|
126
|
+
const mainField = typeof pkgJson?.main === 'string' ? pkgJson.main : undefined;
|
|
127
|
+
const typeField = typeof pkgJson?.type === 'string' ? pkgJson.type : undefined;
|
|
128
|
+
const divergentExports = importTarget && requireTarget && importTarget !== requireTarget;
|
|
129
|
+
const divergentModuleMain = moduleField && mainField && moduleField !== mainField;
|
|
130
|
+
const typeModuleMainCjs = typeField === 'module' && typeof mainField === 'string' && mainField.endsWith('.cjs');
|
|
131
|
+
const hasHazardSignals = divergentExports || divergentModuleMain || typeModuleMainCjs;
|
|
132
|
+
const details = [];
|
|
133
|
+
if (divergentExports) {
|
|
134
|
+
details.push(`exports import -> ${importTarget}, require -> ${requireTarget}`);
|
|
135
|
+
}
|
|
136
|
+
if (divergentModuleMain) {
|
|
137
|
+
details.push(`module -> ${moduleField}, main -> ${mainField}`);
|
|
138
|
+
}
|
|
139
|
+
if (typeModuleMainCjs) {
|
|
140
|
+
details.push(`type: module with CommonJS main (${mainField})`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
hasHazardSignals,
|
|
144
|
+
details,
|
|
145
|
+
importTarget,
|
|
146
|
+
requireTarget
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
const detectDualPackageHazards = async params => {
|
|
150
|
+
const {
|
|
151
|
+
program,
|
|
152
|
+
shadowedBindings,
|
|
153
|
+
hazardLevel,
|
|
154
|
+
filePath,
|
|
155
|
+
cwd,
|
|
156
|
+
diagOnce
|
|
157
|
+
} = params;
|
|
158
|
+
const usages = new Map();
|
|
159
|
+
const manifestCache = new Map();
|
|
160
|
+
const record = (pkg, kind, spec, subpath, loc) => {
|
|
161
|
+
const existing = usages.get(pkg) ?? {
|
|
162
|
+
imports: [],
|
|
163
|
+
requires: []
|
|
164
|
+
};
|
|
165
|
+
const bucket = kind === 'import' ? existing.imports : existing.requires;
|
|
166
|
+
bucket.push({
|
|
167
|
+
spec,
|
|
168
|
+
subpath,
|
|
169
|
+
loc
|
|
170
|
+
});
|
|
171
|
+
usages.set(pkg, existing);
|
|
172
|
+
};
|
|
173
|
+
await ancestorWalk(program, {
|
|
174
|
+
enter(node) {
|
|
175
|
+
if (node.type === 'ImportDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
176
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
177
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
178
|
+
start: node.source.start,
|
|
179
|
+
end: node.source.end
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (node.type === 'ExportNamedDeclaration' && node.source && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
183
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
184
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
185
|
+
start: node.source.start,
|
|
186
|
+
end: node.source.end
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (node.type === 'ExportAllDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
190
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
191
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
192
|
+
start: node.source.start,
|
|
193
|
+
end: node.source.end
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
|
|
197
|
+
const pkg = packageFromSpecifier(node.source.value);
|
|
198
|
+
if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
|
|
199
|
+
start: node.source.start,
|
|
200
|
+
end: node.source.end
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (node.type === 'CallExpression' && isStaticRequire(node, shadowedBindings)) {
|
|
204
|
+
const arg = node.arguments[0];
|
|
205
|
+
if (arg?.type === 'Literal' && typeof arg.value === 'string') {
|
|
206
|
+
const pkg = packageFromSpecifier(arg.value);
|
|
207
|
+
if (pkg) record(pkg.pkg, 'require', arg.value, pkg.subpath, {
|
|
208
|
+
start: arg.start,
|
|
209
|
+
end: arg.end
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
for (const [pkg, usage] of usages) {
|
|
216
|
+
const hasImport = usage.imports.length > 0;
|
|
217
|
+
const hasRequire = usage.requires.length > 0;
|
|
218
|
+
const combined = [...usage.imports, ...usage.requires];
|
|
219
|
+
const hasRoot = combined.some(entry => !entry.subpath);
|
|
220
|
+
const hasSubpath = combined.some(entry => Boolean(entry.subpath));
|
|
221
|
+
if (hasImport && hasRequire) {
|
|
222
|
+
const importSpecs = usage.imports.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
|
|
223
|
+
const requireSpecs = usage.requires.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
|
|
224
|
+
diagOnce(hazardLevel, 'dual-package-mixed-specifiers', `Package '${pkg}' is loaded via import (${importSpecs.join(', ')}) and require (${requireSpecs.join(', ')}); conditional exports can instantiate it twice.`, usage.imports[0]?.loc ?? usage.requires[0]?.loc);
|
|
225
|
+
}
|
|
226
|
+
if (hasRoot && hasSubpath) {
|
|
227
|
+
const subpaths = combined.filter(entry => entry.subpath).map(entry => `${pkg}/${entry.subpath}`);
|
|
228
|
+
diagOnce(hazardLevel, 'dual-package-subpath', `Package '${pkg}' is referenced via root specifier '${pkg}' and subpath(s) ${subpaths.join(', ')}; mixing them loads separate module instances.`, combined.find(entry => entry.subpath)?.loc ?? combined[0]?.loc);
|
|
229
|
+
}
|
|
230
|
+
if (hasImport && hasRequire) {
|
|
231
|
+
const manifest = await readPackageManifest(pkg, filePath, cwd, manifestCache);
|
|
232
|
+
if (manifest) {
|
|
233
|
+
const meta = describeDualPackage(manifest);
|
|
234
|
+
if (meta.hasHazardSignals) {
|
|
235
|
+
const detail = meta.details.length ? ` (${meta.details.join('; ')})` : '';
|
|
236
|
+
diagOnce(hazardLevel, 'dual-package-conditional-exports', `Package '${pkg}' exposes different entry points for import vs require${detail}. Mixed usage can produce distinct instances.`, usage.imports[0]?.loc ?? usage.requires[0]?.loc);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
20
242
|
|
|
21
243
|
/**
|
|
22
244
|
* Node added support for import.meta.main.
|
|
@@ -46,22 +268,35 @@ const format = async (src, ast, opts) => {
|
|
|
46
268
|
// eslint-disable-next-line no-console -- used for opt-in diagnostics
|
|
47
269
|
console.error(diag.message);
|
|
48
270
|
};
|
|
49
|
-
const
|
|
50
|
-
const key = `${codeId}:${loc?.start ?? ''}`;
|
|
271
|
+
const diagOnce = (level, codeId, message, loc) => {
|
|
272
|
+
const key = `${level}:${codeId}:${loc?.start ?? ''}`;
|
|
51
273
|
if (warned.has(key)) return;
|
|
52
274
|
warned.add(key);
|
|
53
275
|
emitDiagnostic({
|
|
54
|
-
level
|
|
276
|
+
level,
|
|
55
277
|
code: codeId,
|
|
56
278
|
message,
|
|
57
279
|
filePath: opts.filePath,
|
|
58
280
|
loc
|
|
59
281
|
});
|
|
60
282
|
};
|
|
283
|
+
const warnOnce = (codeId, message, loc) => diagOnce('warning', codeId, message, loc);
|
|
61
284
|
const transformMode = opts.transformSyntax;
|
|
62
285
|
const fullTransform = transformMode === true;
|
|
63
286
|
const moduleIdentifiers = await collectModuleIdentifiers(ast.program);
|
|
64
287
|
const shadowedBindings = new Set([...moduleIdentifiers.entries()].filter(([, meta]) => meta.declare.length > 0).map(([name]) => name));
|
|
288
|
+
const hazardMode = opts.detectDualPackageHazard ?? 'warn';
|
|
289
|
+
if (hazardMode !== 'off') {
|
|
290
|
+
const hazardLevel = hazardMode === 'error' ? 'error' : 'warning';
|
|
291
|
+
await detectDualPackageHazards({
|
|
292
|
+
program: ast.program,
|
|
293
|
+
shadowedBindings,
|
|
294
|
+
hazardLevel,
|
|
295
|
+
filePath: opts.filePath,
|
|
296
|
+
cwd: opts.cwd,
|
|
297
|
+
diagOnce
|
|
298
|
+
});
|
|
299
|
+
}
|
|
65
300
|
if (opts.target === 'module' && fullTransform) {
|
|
66
301
|
if (shadowedBindings.has('module') || shadowedBindings.has('exports')) {
|
|
67
302
|
throw new Error('Cannot transform to ESM: module or exports is shadowed in module scope.');
|
package/dist/module.js
CHANGED
|
@@ -154,6 +154,7 @@ const defaultOptions = {
|
|
|
154
154
|
importMetaMain: 'shim',
|
|
155
155
|
requireMainStrategy: 'import-meta-main',
|
|
156
156
|
detectCircularRequires: 'off',
|
|
157
|
+
detectDualPackageHazard: 'warn',
|
|
157
158
|
requireSource: 'builtin',
|
|
158
159
|
nestedRequireStrategy: 'create-require',
|
|
159
160
|
cjsDefault: 'auto',
|
package/dist/types.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type ModuleOptions = {
|
|
|
32
32
|
requireMainStrategy?: 'import-meta-main' | 'realpath';
|
|
33
33
|
/** Detect circular require usage level. */
|
|
34
34
|
detectCircularRequires?: 'off' | 'warn' | 'error';
|
|
35
|
+
/** Detect divergent import/require usage of the same dual package (default warn). */
|
|
36
|
+
detectDualPackageHazard?: 'off' | 'warn' | 'error';
|
|
35
37
|
/** Source used to provide require in ESM output. */
|
|
36
38
|
requireSource?: 'builtin' | 'create-require';
|
|
37
39
|
/** How to rewrite nested or non-hoistable require calls. */
|