@knighted/module 1.3.1 → 1.4.0-rc.1

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 CHANGED
@@ -130,6 +130,8 @@ 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'
134
+ dualPackageHazardScope?: 'file' | 'project'
133
135
  requireSource?: 'builtin' | 'create-require'
134
136
  importMetaPrelude?: 'off' | 'auto' | 'on'
135
137
  cjsDefault?: 'module-exports' | 'auto' | 'none'
@@ -155,6 +157,8 @@ type ModuleOptions = {
155
157
  - `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check.
156
158
  - `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
159
  - `detectCircularRequires` (`off`): optionally detect relative static require cycles and warn/throw.
160
+ - `detectDualPackageHazard` (`warn`): flag when `import` and `require` mix for the same package or root/subpath are combined in ways that can resolve to separate module instances (dual packages). Set to `error` to fail the transform.
161
+ - `dualPackageHazardScope` (`file`): `file` preserves the legacy per-file detector; `project` aggregates package usage across all CLI inputs (useful in monorepos/hoisted installs) and emits one diagnostic per package.
158
162
  - `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
159
163
  - `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
164
  - `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
package/dist/cjs/cli.cjs CHANGED
@@ -29,6 +29,8 @@ const defaultOptions = {
29
29
  importMetaMain: 'shim',
30
30
  requireMainStrategy: 'import-meta-main',
31
31
  detectCircularRequires: 'off',
32
+ detectDualPackageHazard: 'warn',
33
+ dualPackageHazardScope: 'file',
32
34
  requireSource: 'builtin',
33
35
  nestedRequireStrategy: 'create-require',
34
36
  cjsDefault: 'auto',
@@ -150,6 +152,16 @@ const optionsTable = [{
150
152
  short: 'c',
151
153
  type: 'string',
152
154
  desc: 'Warn/error on circular require (off|warn|error)'
155
+ }, {
156
+ long: 'detect-dual-package-hazard',
157
+ short: 'H',
158
+ type: 'string',
159
+ desc: 'Warn/error on mixed import/require of dual packages (off|warn|error)'
160
+ }, {
161
+ long: 'dual-package-hazard-scope',
162
+ short: undefined,
163
+ type: 'string',
164
+ desc: 'Scope for dual package hazard detection (file|project)'
153
165
  }, {
154
166
  long: 'top-level-await',
155
167
  short: 'a',
@@ -243,10 +255,10 @@ const optionsTable = [{
243
255
  }];
244
256
  const buildHelp = enableColor => {
245
257
  const c = colorize(enableColor);
246
- const maxFlagLength = Math.max(...optionsTable.map(opt => ` -${opt.short}, --${opt.long}`.length));
258
+ const maxFlagLength = Math.max(...optionsTable.map(opt => opt.short ? ` -${opt.short}, --${opt.long}`.length : ` --${opt.long}`.length));
247
259
  const lines = [`${c.bold('Usage:')} dub [options] <files...>`, '', 'Examples:', ' dub -t module src/index.cjs --out-dir dist', ' dub -t commonjs src/**/*.mjs -p', ' cat input.cjs | dub -t module --stdin-filename input.cjs', '', 'Options:'];
248
260
  for (const opt of optionsTable) {
249
- const flag = ` -${opt.short}, --${opt.long}`;
261
+ const flag = opt.short ? ` -${opt.short}, --${opt.long}` : ` --${opt.long}`;
250
262
  const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2));
251
263
  lines.push(`${c.bold(flag)}${pad}${opt.desc}`);
252
264
  }
@@ -281,6 +293,8 @@ const toModuleOptions = values => {
281
293
  appendJsExtension: appendJsExtension,
282
294
  appendDirectoryIndex,
283
295
  detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
296
+ detectDualPackageHazard: parseEnum(values['detect-dual-package-hazard'], ['off', 'warn', 'error']) ?? defaultOptions.detectDualPackageHazard,
297
+ dualPackageHazardScope: parseEnum(values['dual-package-hazard-scope'], ['file', 'project']) ?? defaultOptions.dualPackageHazardScope,
284
298
  topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
285
299
  cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
286
300
  idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
@@ -367,6 +381,9 @@ const summarizeDiagnostics = diags => {
367
381
  const runFiles = async (files, moduleOpts, io, flags) => {
368
382
  const results = [];
369
383
  const logger = makeLogger(io.stdout, io.stderr);
384
+ const hazardScope = moduleOpts.dualPackageHazardScope ?? 'file';
385
+ const hazardMode = moduleOpts.detectDualPackageHazard ?? 'warn';
386
+ const projectHazards = hazardScope === 'project' && hazardMode !== 'off' ? await (0, _module.collectProjectDualPackageHazards)(files, moduleOpts) : null;
370
387
  for (const file of files) {
371
388
  const diagnostics = [];
372
389
  const original = await (0, _promises.readFile)(file, 'utf8');
@@ -376,7 +393,8 @@ const runFiles = async (files, moduleOpts, io, flags) => {
376
393
  diagnostics: diag => diagnostics.push(diag),
377
394
  out: undefined,
378
395
  inPlace: false,
379
- filePath: file
396
+ filePath: file,
397
+ detectDualPackageHazard: hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard
380
398
  };
381
399
  let writeTarget;
382
400
  if (!flags.dryRun && !flags.list) {
@@ -398,6 +416,10 @@ const runFiles = async (files, moduleOpts, io, flags) => {
398
416
  }
399
417
  const output = await (0, _module.transform)(file, perFileOpts);
400
418
  const changed = output !== original;
419
+ if (projectHazards) {
420
+ const extras = projectHazards.get(file);
421
+ if (extras?.length) diagnostics.push(...extras);
422
+ }
401
423
  if (flags.list && changed) {
402
424
  logger.info(file);
403
425
  }
@@ -447,7 +469,9 @@ const runCli = async ({
447
469
  allowPositionals: true,
448
470
  options: Object.fromEntries(optionsTable.map(opt => [opt.long, {
449
471
  type: opt.type,
450
- short: opt.short
472
+ ...(opt.short ? {
473
+ short: opt.short
474
+ } : {})
451
475
  }]))
452
476
  });
453
477
  const logger = makeLogger(stdout, stderr);
@@ -3,7 +3,10 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.format = void 0;
6
+ exports.format = exports.dualPackageHazardDiagnostics = exports.collectDualPackageUsage = 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,274 @@ 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 recordUsage = (usages, pkg, kind, spec, subpath, loc, filePath) => {
157
+ const existing = usages.get(pkg) ?? {
158
+ imports: [],
159
+ requires: []
160
+ };
161
+ const bucket = kind === 'import' ? existing.imports : existing.requires;
162
+ bucket.push({
163
+ spec,
164
+ subpath,
165
+ loc,
166
+ filePath
167
+ });
168
+ usages.set(pkg, existing);
169
+ };
170
+ const collectDualPackageUsage = async (program, shadowedBindings, filePath) => {
171
+ const usages = new Map();
172
+ await (0, _walk.ancestorWalk)(program, {
173
+ enter(node) {
174
+ if (node.type === 'ImportDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
175
+ const pkg = packageFromSpecifier(node.source.value);
176
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
177
+ start: node.source.start,
178
+ end: node.source.end
179
+ }, filePath);
180
+ }
181
+ if (node.type === 'ExportNamedDeclaration' && node.source && node.source.type === 'Literal' && typeof node.source.value === 'string') {
182
+ const pkg = packageFromSpecifier(node.source.value);
183
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
184
+ start: node.source.start,
185
+ end: node.source.end
186
+ }, filePath);
187
+ }
188
+ if (node.type === 'ExportAllDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
189
+ const pkg = packageFromSpecifier(node.source.value);
190
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
191
+ start: node.source.start,
192
+ end: node.source.end
193
+ }, filePath);
194
+ }
195
+ if (node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
196
+ const pkg = packageFromSpecifier(node.source.value);
197
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
198
+ start: node.source.start,
199
+ end: node.source.end
200
+ }, filePath);
201
+ }
202
+ if (node.type === 'CallExpression' && (0, _lowerCjsRequireToImports.isStaticRequire)(node, shadowedBindings)) {
203
+ const arg = node.arguments[0];
204
+ if (arg?.type === 'Literal' && typeof arg.value === 'string') {
205
+ const pkg = packageFromSpecifier(arg.value);
206
+ if (pkg) recordUsage(usages, pkg.pkg, 'require', arg.value, pkg.subpath, {
207
+ start: arg.start,
208
+ end: arg.end
209
+ }, filePath);
210
+ }
211
+ }
212
+ }
213
+ });
214
+ return usages;
215
+ };
216
+ exports.collectDualPackageUsage = collectDualPackageUsage;
217
+ const dualPackageHazardDiagnostics = async params => {
218
+ const {
219
+ usages,
220
+ hazardLevel,
221
+ filePath,
222
+ cwd
223
+ } = params;
224
+ const manifestCache = params.manifestCache ?? new Map();
225
+ const diags = [];
226
+ for (const [pkg, usage] of usages) {
227
+ const hasImport = usage.imports.length > 0;
228
+ const hasRequire = usage.requires.length > 0;
229
+ const combined = [...usage.imports, ...usage.requires];
230
+ const hasRoot = combined.some(entry => !entry.subpath);
231
+ const hasSubpath = combined.some(entry => Boolean(entry.subpath));
232
+ const origin = usage.imports[0] ?? usage.requires[0];
233
+ const diagFile = origin?.filePath ?? filePath;
234
+ if (hasImport && hasRequire) {
235
+ const importSpecs = usage.imports.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
236
+ const requireSpecs = usage.requires.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
237
+ diags.push({
238
+ level: hazardLevel,
239
+ code: 'dual-package-mixed-specifiers',
240
+ message: `Package '${pkg}' is loaded via import (${importSpecs.join(', ')}) and require (${requireSpecs.join(', ')}); conditional exports can instantiate it twice.`,
241
+ filePath: diagFile,
242
+ loc: origin?.loc
243
+ });
244
+ }
245
+ if (hasRoot && hasSubpath) {
246
+ const subpaths = combined.filter(entry => entry.subpath).map(entry => `${pkg}/${entry.subpath}`);
247
+ const originSubpath = combined.find(entry => entry.subpath) ?? combined[0];
248
+ diags.push({
249
+ level: hazardLevel,
250
+ code: 'dual-package-subpath',
251
+ message: `Package '${pkg}' is referenced via root specifier '${pkg}' and subpath(s) ${subpaths.join(', ')}; mixing them loads separate module instances.`,
252
+ filePath: originSubpath?.filePath ?? filePath,
253
+ loc: originSubpath?.loc
254
+ });
255
+ }
256
+ if (hasImport && hasRequire) {
257
+ const manifest = await readPackageManifest(pkg, diagFile, cwd, manifestCache);
258
+ if (manifest) {
259
+ const meta = describeDualPackage(manifest);
260
+ if (meta.hasHazardSignals) {
261
+ const detail = meta.details.length ? ` (${meta.details.join('; ')})` : '';
262
+ diags.push({
263
+ level: hazardLevel,
264
+ code: 'dual-package-conditional-exports',
265
+ message: `Package '${pkg}' exposes different entry points for import vs require${detail}. Mixed usage can produce distinct instances.`,
266
+ filePath: diagFile,
267
+ loc: origin?.loc
268
+ });
269
+ }
270
+ }
271
+ }
272
+ }
273
+ return diags;
274
+ };
275
+ exports.dualPackageHazardDiagnostics = dualPackageHazardDiagnostics;
276
+ const detectDualPackageHazards = async params => {
277
+ const {
278
+ program,
279
+ shadowedBindings,
280
+ hazardLevel,
281
+ filePath,
282
+ cwd,
283
+ diagOnce
284
+ } = params;
285
+ const manifestCache = new Map();
286
+ const usages = await collectDualPackageUsage(program, shadowedBindings, filePath);
287
+ const diags = await dualPackageHazardDiagnostics({
288
+ usages,
289
+ hazardLevel,
290
+ filePath,
291
+ cwd,
292
+ manifestCache
293
+ });
294
+ for (const diag of diags) {
295
+ diagOnce(diag.level, diag.code, diag.message, diag.loc);
296
+ }
297
+ };
27
298
 
28
299
  /**
29
300
  * Node added support for import.meta.main.
@@ -53,22 +324,35 @@ const format = async (src, ast, opts) => {
53
324
  // eslint-disable-next-line no-console -- used for opt-in diagnostics
54
325
  console.error(diag.message);
55
326
  };
56
- const warnOnce = (codeId, message, loc) => {
57
- const key = `${codeId}:${loc?.start ?? ''}`;
327
+ const diagOnce = (level, codeId, message, loc) => {
328
+ const key = `${level}:${codeId}:${loc?.start ?? ''}`;
58
329
  if (warned.has(key)) return;
59
330
  warned.add(key);
60
331
  emitDiagnostic({
61
- level: 'warning',
332
+ level,
62
333
  code: codeId,
63
334
  message,
64
335
  filePath: opts.filePath,
65
336
  loc
66
337
  });
67
338
  };
339
+ const warnOnce = (codeId, message, loc) => diagOnce('warning', codeId, message, loc);
68
340
  const transformMode = opts.transformSyntax;
69
341
  const fullTransform = transformMode === true;
70
342
  const moduleIdentifiers = await (0, _identifiers.collectModuleIdentifiers)(ast.program);
71
343
  const shadowedBindings = new Set([...moduleIdentifiers.entries()].filter(([, meta]) => meta.declare.length > 0).map(([name]) => name));
344
+ const hazardMode = opts.detectDualPackageHazard ?? 'warn';
345
+ if (hazardMode !== 'off') {
346
+ const hazardLevel = hazardMode === 'error' ? 'error' : 'warning';
347
+ await detectDualPackageHazards({
348
+ program: ast.program,
349
+ shadowedBindings,
350
+ hazardLevel,
351
+ filePath: opts.filePath,
352
+ cwd: opts.cwd,
353
+ diagOnce
354
+ });
355
+ }
72
356
  if (opts.target === 'module' && fullTransform) {
73
357
  if (shadowedBindings.has('module') || shadowedBindings.has('exports')) {
74
358
  throw new Error('Cannot transform to ESM: module or exports is shadowed in module scope.');
@@ -1,9 +1,31 @@
1
- import type { ParseResult } from 'oxc-parser';
2
- import type { FormatterOptions } from './types.cjs';
1
+ import type { Node, ParseResult } from 'oxc-parser';
2
+ import type { Diagnostic, FormatterOptions } from './types.cjs';
3
+ type HazardLevel = 'warning' | 'error';
4
+ export type PackageUse = {
5
+ spec: string;
6
+ subpath: string;
7
+ loc?: {
8
+ start: number;
9
+ end: number;
10
+ };
11
+ filePath?: string;
12
+ };
13
+ export type PackageUsage = {
14
+ imports: PackageUse[];
15
+ requires: PackageUse[];
16
+ };
17
+ declare const collectDualPackageUsage: (program: Node, shadowedBindings: Set<string>, filePath?: string) => Promise<Map<string, PackageUsage>>;
18
+ declare const dualPackageHazardDiagnostics: (params: {
19
+ usages: Map<string, PackageUsage>;
20
+ hazardLevel: HazardLevel;
21
+ filePath?: string;
22
+ cwd?: string;
23
+ manifestCache?: Map<string, any | null>;
24
+ }) => Promise<Diagnostic[]>;
3
25
  /**
4
26
  * Node added support for import.meta.main.
5
27
  * Added in: v24.2.0, v22.18.0
6
28
  * @see https://nodejs.org/api/esm.html#importmetamain
7
29
  */
8
30
  declare const format: (src: string, ast: ParseResult, opts: FormatterOptions) => Promise<string>;
9
- export { format };
31
+ export { format, collectDualPackageUsage, dualPackageHazardDiagnostics };
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.transform = void 0;
6
+ exports.transform = exports.collectProjectDualPackageHazards = void 0;
7
7
  var _nodePath = require("node:path");
8
8
  var _promises = require("node:fs/promises");
9
9
  var _specifier = require("./specifier.cjs");
@@ -12,6 +12,7 @@ var _format = require("./format.cjs");
12
12
  var _lang = require("./utils/lang.cjs");
13
13
  var _nodeModule = require("node:module");
14
14
  var _walk = require("./walk.cjs");
15
+ var _identifiers = require("./utils/identifiers.cjs");
15
16
  const collapseSpecifier = value => value.replace(/['"`+)\s]|new String\(/g, '');
16
17
  const builtinSpecifiers = new Set(_nodeModule.builtinModules.map(mod => mod.startsWith('node:') ? mod.slice(5) : mod).flatMap(mod => {
17
18
  const parts = mod.split('/');
@@ -144,6 +145,47 @@ const detectCircularRequireGraph = async (entryFile, mode, dirIndex) => {
144
145
  };
145
146
  await dfs(entryFile, []);
146
147
  };
148
+ const mergeUsageMaps = (target, source) => {
149
+ for (const [pkg, usage] of source) {
150
+ const existing = target.get(pkg) ?? {
151
+ imports: [],
152
+ requires: []
153
+ };
154
+ existing.imports.push(...usage.imports);
155
+ existing.requires.push(...usage.requires);
156
+ target.set(pkg, existing);
157
+ }
158
+ };
159
+ const collectProjectDualPackageHazards = async (files, opts) => {
160
+ const hazardMode = opts.detectDualPackageHazard ?? 'warn';
161
+ if (hazardMode === 'off') return new Map();
162
+ const hazardLevel = hazardMode === 'error' ? 'error' : 'warning';
163
+ const usages = new Map();
164
+ const manifestCache = new Map();
165
+ for (const file of files) {
166
+ const code = await (0, _promises.readFile)(file, 'utf8');
167
+ const ast = (0, _parse.parse)(file, code);
168
+ const moduleIdentifiers = await (0, _identifiers.collectModuleIdentifiers)(ast.program);
169
+ const shadowedBindings = new Set([...moduleIdentifiers.entries()].filter(([, meta]) => meta.declare.length > 0).map(([name]) => name));
170
+ const perFileUsage = await (0, _format.collectDualPackageUsage)(ast.program, shadowedBindings, file);
171
+ mergeUsageMaps(usages, perFileUsage);
172
+ }
173
+ const diags = await (0, _format.dualPackageHazardDiagnostics)({
174
+ usages,
175
+ hazardLevel,
176
+ cwd: opts.cwd,
177
+ manifestCache
178
+ });
179
+ const byFile = new Map();
180
+ for (const diag of diags) {
181
+ const key = diag.filePath ?? files[0];
182
+ const existing = byFile.get(key) ?? [];
183
+ existing.push(diag);
184
+ byFile.set(key, existing);
185
+ }
186
+ return byFile;
187
+ };
188
+ exports.collectProjectDualPackageHazards = collectProjectDualPackageHazards;
147
189
  const defaultOptions = {
148
190
  target: 'commonjs',
149
191
  sourceType: 'auto',
@@ -157,6 +199,8 @@ const defaultOptions = {
157
199
  importMetaMain: 'shim',
158
200
  requireMainStrategy: 'import-meta-main',
159
201
  detectCircularRequires: 'off',
202
+ detectDualPackageHazard: 'warn',
203
+ dualPackageHazardScope: 'file',
160
204
  requireSource: 'builtin',
161
205
  nestedRequireStrategy: 'create-require',
162
206
  cjsDefault: 'auto',
@@ -1,3 +1,4 @@
1
- import type { ModuleOptions } from './types.cjs';
1
+ import type { ModuleOptions, Diagnostic } from './types.cjs';
2
+ declare const collectProjectDualPackageHazards: (files: string[], opts: ModuleOptions) => Promise<Map<string, Diagnostic[]>>;
2
3
  declare const transform: (filename: string, options?: ModuleOptions) => Promise<string>;
3
- export { transform };
4
+ export { transform, collectProjectDualPackageHazards };
@@ -32,6 +32,10 @@ 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';
37
+ /** Scope for dual package hazard detection. */
38
+ dualPackageHazardScope?: 'file' | 'project';
35
39
  /** Source used to provide require in ESM output. */
36
40
  requireSource?: 'builtin' | 'create-require';
37
41
  /** How to rewrite nested or non-hoistable require calls. */
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { readFile, mkdir } from 'node:fs/promises';
5
5
  import { dirname, resolve, relative, join } from 'node:path';
6
6
  import { builtinModules } from 'node:module';
7
7
  import { glob } from 'glob';
8
- import { transform } from './module.js';
8
+ import { transform, collectProjectDualPackageHazards } from './module.js';
9
9
  import { parse } from './parse.js';
10
10
  import { format } from './format.js';
11
11
  import { specifier } from './specifier.js';
@@ -23,6 +23,8 @@ const defaultOptions = {
23
23
  importMetaMain: 'shim',
24
24
  requireMainStrategy: 'import-meta-main',
25
25
  detectCircularRequires: 'off',
26
+ detectDualPackageHazard: 'warn',
27
+ dualPackageHazardScope: 'file',
26
28
  requireSource: 'builtin',
27
29
  nestedRequireStrategy: 'create-require',
28
30
  cjsDefault: 'auto',
@@ -144,6 +146,16 @@ const optionsTable = [{
144
146
  short: 'c',
145
147
  type: 'string',
146
148
  desc: 'Warn/error on circular require (off|warn|error)'
149
+ }, {
150
+ long: 'detect-dual-package-hazard',
151
+ short: 'H',
152
+ type: 'string',
153
+ desc: 'Warn/error on mixed import/require of dual packages (off|warn|error)'
154
+ }, {
155
+ long: 'dual-package-hazard-scope',
156
+ short: undefined,
157
+ type: 'string',
158
+ desc: 'Scope for dual package hazard detection (file|project)'
147
159
  }, {
148
160
  long: 'top-level-await',
149
161
  short: 'a',
@@ -237,10 +249,10 @@ const optionsTable = [{
237
249
  }];
238
250
  const buildHelp = enableColor => {
239
251
  const c = colorize(enableColor);
240
- const maxFlagLength = Math.max(...optionsTable.map(opt => ` -${opt.short}, --${opt.long}`.length));
252
+ const maxFlagLength = Math.max(...optionsTable.map(opt => opt.short ? ` -${opt.short}, --${opt.long}`.length : ` --${opt.long}`.length));
241
253
  const lines = [`${c.bold('Usage:')} dub [options] <files...>`, '', 'Examples:', ' dub -t module src/index.cjs --out-dir dist', ' dub -t commonjs src/**/*.mjs -p', ' cat input.cjs | dub -t module --stdin-filename input.cjs', '', 'Options:'];
242
254
  for (const opt of optionsTable) {
243
- const flag = ` -${opt.short}, --${opt.long}`;
255
+ const flag = opt.short ? ` -${opt.short}, --${opt.long}` : ` --${opt.long}`;
244
256
  const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2));
245
257
  lines.push(`${c.bold(flag)}${pad}${opt.desc}`);
246
258
  }
@@ -275,6 +287,8 @@ const toModuleOptions = values => {
275
287
  appendJsExtension: appendJsExtension,
276
288
  appendDirectoryIndex,
277
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,
291
+ dualPackageHazardScope: parseEnum(values['dual-package-hazard-scope'], ['file', 'project']) ?? defaultOptions.dualPackageHazardScope,
278
292
  topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
279
293
  cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
280
294
  idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
@@ -361,6 +375,9 @@ const summarizeDiagnostics = diags => {
361
375
  const runFiles = async (files, moduleOpts, io, flags) => {
362
376
  const results = [];
363
377
  const logger = makeLogger(io.stdout, io.stderr);
378
+ const hazardScope = moduleOpts.dualPackageHazardScope ?? 'file';
379
+ const hazardMode = moduleOpts.detectDualPackageHazard ?? 'warn';
380
+ const projectHazards = hazardScope === 'project' && hazardMode !== 'off' ? await collectProjectDualPackageHazards(files, moduleOpts) : null;
364
381
  for (const file of files) {
365
382
  const diagnostics = [];
366
383
  const original = await readFile(file, 'utf8');
@@ -370,7 +387,8 @@ const runFiles = async (files, moduleOpts, io, flags) => {
370
387
  diagnostics: diag => diagnostics.push(diag),
371
388
  out: undefined,
372
389
  inPlace: false,
373
- filePath: file
390
+ filePath: file,
391
+ detectDualPackageHazard: hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard
374
392
  };
375
393
  let writeTarget;
376
394
  if (!flags.dryRun && !flags.list) {
@@ -392,6 +410,10 @@ const runFiles = async (files, moduleOpts, io, flags) => {
392
410
  }
393
411
  const output = await transform(file, perFileOpts);
394
412
  const changed = output !== original;
413
+ if (projectHazards) {
414
+ const extras = projectHazards.get(file);
415
+ if (extras?.length) diagnostics.push(...extras);
416
+ }
395
417
  if (flags.list && changed) {
396
418
  logger.info(file);
397
419
  }
@@ -441,7 +463,9 @@ const runCli = async ({
441
463
  allowPositionals: true,
442
464
  options: Object.fromEntries(optionsTable.map(opt => [opt.long, {
443
465
  type: opt.type,
444
- short: opt.short
466
+ ...(opt.short ? {
467
+ short: opt.short
468
+ } : {})
445
469
  }]))
446
470
  });
447
471
  const logger = makeLogger(stdout, stderr);
package/dist/format.d.ts CHANGED
@@ -1,9 +1,31 @@
1
- import type { ParseResult } from 'oxc-parser';
2
- import type { FormatterOptions } from './types.js';
1
+ import type { Node, ParseResult } from 'oxc-parser';
2
+ import type { Diagnostic, FormatterOptions } from './types.js';
3
+ type HazardLevel = 'warning' | 'error';
4
+ export type PackageUse = {
5
+ spec: string;
6
+ subpath: string;
7
+ loc?: {
8
+ start: number;
9
+ end: number;
10
+ };
11
+ filePath?: string;
12
+ };
13
+ export type PackageUsage = {
14
+ imports: PackageUse[];
15
+ requires: PackageUse[];
16
+ };
17
+ declare const collectDualPackageUsage: (program: Node, shadowedBindings: Set<string>, filePath?: string) => Promise<Map<string, PackageUsage>>;
18
+ declare const dualPackageHazardDiagnostics: (params: {
19
+ usages: Map<string, PackageUsage>;
20
+ hazardLevel: HazardLevel;
21
+ filePath?: string;
22
+ cwd?: string;
23
+ manifestCache?: Map<string, any | null>;
24
+ }) => Promise<Diagnostic[]>;
3
25
  /**
4
26
  * Node added support for import.meta.main.
5
27
  * Added in: v24.2.0, v22.18.0
6
28
  * @see https://nodejs.org/api/esm.html#importmetamain
7
29
  */
8
30
  declare const format: (src: string, ast: ParseResult, opts: FormatterOptions) => Promise<string>;
9
- export { format };
31
+ export { format, collectDualPackageUsage, dualPackageHazardDiagnostics };
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,272 @@ 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 recordUsage = (usages, pkg, kind, spec, subpath, loc, filePath) => {
150
+ const existing = usages.get(pkg) ?? {
151
+ imports: [],
152
+ requires: []
153
+ };
154
+ const bucket = kind === 'import' ? existing.imports : existing.requires;
155
+ bucket.push({
156
+ spec,
157
+ subpath,
158
+ loc,
159
+ filePath
160
+ });
161
+ usages.set(pkg, existing);
162
+ };
163
+ const collectDualPackageUsage = async (program, shadowedBindings, filePath) => {
164
+ const usages = new Map();
165
+ await ancestorWalk(program, {
166
+ enter(node) {
167
+ if (node.type === 'ImportDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
168
+ const pkg = packageFromSpecifier(node.source.value);
169
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
170
+ start: node.source.start,
171
+ end: node.source.end
172
+ }, filePath);
173
+ }
174
+ if (node.type === 'ExportNamedDeclaration' && node.source && node.source.type === 'Literal' && typeof node.source.value === 'string') {
175
+ const pkg = packageFromSpecifier(node.source.value);
176
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
177
+ start: node.source.start,
178
+ end: node.source.end
179
+ }, filePath);
180
+ }
181
+ if (node.type === 'ExportAllDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
182
+ const pkg = packageFromSpecifier(node.source.value);
183
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
184
+ start: node.source.start,
185
+ end: node.source.end
186
+ }, filePath);
187
+ }
188
+ if (node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
189
+ const pkg = packageFromSpecifier(node.source.value);
190
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
191
+ start: node.source.start,
192
+ end: node.source.end
193
+ }, filePath);
194
+ }
195
+ if (node.type === 'CallExpression' && isStaticRequire(node, shadowedBindings)) {
196
+ const arg = node.arguments[0];
197
+ if (arg?.type === 'Literal' && typeof arg.value === 'string') {
198
+ const pkg = packageFromSpecifier(arg.value);
199
+ if (pkg) recordUsage(usages, pkg.pkg, 'require', arg.value, pkg.subpath, {
200
+ start: arg.start,
201
+ end: arg.end
202
+ }, filePath);
203
+ }
204
+ }
205
+ }
206
+ });
207
+ return usages;
208
+ };
209
+ const dualPackageHazardDiagnostics = async params => {
210
+ const {
211
+ usages,
212
+ hazardLevel,
213
+ filePath,
214
+ cwd
215
+ } = params;
216
+ const manifestCache = params.manifestCache ?? new Map();
217
+ const diags = [];
218
+ for (const [pkg, usage] of usages) {
219
+ const hasImport = usage.imports.length > 0;
220
+ const hasRequire = usage.requires.length > 0;
221
+ const combined = [...usage.imports, ...usage.requires];
222
+ const hasRoot = combined.some(entry => !entry.subpath);
223
+ const hasSubpath = combined.some(entry => Boolean(entry.subpath));
224
+ const origin = usage.imports[0] ?? usage.requires[0];
225
+ const diagFile = origin?.filePath ?? filePath;
226
+ if (hasImport && hasRequire) {
227
+ const importSpecs = usage.imports.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
228
+ const requireSpecs = usage.requires.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
229
+ diags.push({
230
+ level: hazardLevel,
231
+ code: 'dual-package-mixed-specifiers',
232
+ message: `Package '${pkg}' is loaded via import (${importSpecs.join(', ')}) and require (${requireSpecs.join(', ')}); conditional exports can instantiate it twice.`,
233
+ filePath: diagFile,
234
+ loc: origin?.loc
235
+ });
236
+ }
237
+ if (hasRoot && hasSubpath) {
238
+ const subpaths = combined.filter(entry => entry.subpath).map(entry => `${pkg}/${entry.subpath}`);
239
+ const originSubpath = combined.find(entry => entry.subpath) ?? combined[0];
240
+ diags.push({
241
+ level: hazardLevel,
242
+ code: 'dual-package-subpath',
243
+ message: `Package '${pkg}' is referenced via root specifier '${pkg}' and subpath(s) ${subpaths.join(', ')}; mixing them loads separate module instances.`,
244
+ filePath: originSubpath?.filePath ?? filePath,
245
+ loc: originSubpath?.loc
246
+ });
247
+ }
248
+ if (hasImport && hasRequire) {
249
+ const manifest = await readPackageManifest(pkg, diagFile, cwd, manifestCache);
250
+ if (manifest) {
251
+ const meta = describeDualPackage(manifest);
252
+ if (meta.hasHazardSignals) {
253
+ const detail = meta.details.length ? ` (${meta.details.join('; ')})` : '';
254
+ diags.push({
255
+ level: hazardLevel,
256
+ code: 'dual-package-conditional-exports',
257
+ message: `Package '${pkg}' exposes different entry points for import vs require${detail}. Mixed usage can produce distinct instances.`,
258
+ filePath: diagFile,
259
+ loc: origin?.loc
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+ return diags;
266
+ };
267
+ const detectDualPackageHazards = async params => {
268
+ const {
269
+ program,
270
+ shadowedBindings,
271
+ hazardLevel,
272
+ filePath,
273
+ cwd,
274
+ diagOnce
275
+ } = params;
276
+ const manifestCache = new Map();
277
+ const usages = await collectDualPackageUsage(program, shadowedBindings, filePath);
278
+ const diags = await dualPackageHazardDiagnostics({
279
+ usages,
280
+ hazardLevel,
281
+ filePath,
282
+ cwd,
283
+ manifestCache
284
+ });
285
+ for (const diag of diags) {
286
+ diagOnce(diag.level, diag.code, diag.message, diag.loc);
287
+ }
288
+ };
20
289
 
21
290
  /**
22
291
  * Node added support for import.meta.main.
@@ -46,22 +315,35 @@ const format = async (src, ast, opts) => {
46
315
  // eslint-disable-next-line no-console -- used for opt-in diagnostics
47
316
  console.error(diag.message);
48
317
  };
49
- const warnOnce = (codeId, message, loc) => {
50
- const key = `${codeId}:${loc?.start ?? ''}`;
318
+ const diagOnce = (level, codeId, message, loc) => {
319
+ const key = `${level}:${codeId}:${loc?.start ?? ''}`;
51
320
  if (warned.has(key)) return;
52
321
  warned.add(key);
53
322
  emitDiagnostic({
54
- level: 'warning',
323
+ level,
55
324
  code: codeId,
56
325
  message,
57
326
  filePath: opts.filePath,
58
327
  loc
59
328
  });
60
329
  };
330
+ const warnOnce = (codeId, message, loc) => diagOnce('warning', codeId, message, loc);
61
331
  const transformMode = opts.transformSyntax;
62
332
  const fullTransform = transformMode === true;
63
333
  const moduleIdentifiers = await collectModuleIdentifiers(ast.program);
64
334
  const shadowedBindings = new Set([...moduleIdentifiers.entries()].filter(([, meta]) => meta.declare.length > 0).map(([name]) => name));
335
+ const hazardMode = opts.detectDualPackageHazard ?? 'warn';
336
+ if (hazardMode !== 'off') {
337
+ const hazardLevel = hazardMode === 'error' ? 'error' : 'warning';
338
+ await detectDualPackageHazards({
339
+ program: ast.program,
340
+ shadowedBindings,
341
+ hazardLevel,
342
+ filePath: opts.filePath,
343
+ cwd: opts.cwd,
344
+ diagOnce
345
+ });
346
+ }
65
347
  if (opts.target === 'module' && fullTransform) {
66
348
  if (shadowedBindings.has('module') || shadowedBindings.has('exports')) {
67
349
  throw new Error('Cannot transform to ESM: module or exports is shadowed in module scope.');
@@ -246,4 +528,4 @@ const format = async (src, ast, opts) => {
246
528
  }
247
529
  return code.toString();
248
530
  };
249
- export { format };
531
+ export { format, collectDualPackageUsage, dualPackageHazardDiagnostics };
package/dist/module.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- import type { ModuleOptions } from './types.js';
1
+ import type { ModuleOptions, Diagnostic } from './types.js';
2
+ declare const collectProjectDualPackageHazards: (files: string[], opts: ModuleOptions) => Promise<Map<string, Diagnostic[]>>;
2
3
  declare const transform: (filename: string, options?: ModuleOptions) => Promise<string>;
3
- export { transform };
4
+ export { transform, collectProjectDualPackageHazards };
package/dist/module.js CHANGED
@@ -2,13 +2,14 @@ import { resolve } from 'node:path';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { specifier } from './specifier.js';
4
4
  import { parse } from './parse.js';
5
- import { format } from './format.js';
5
+ import { format, collectDualPackageUsage, dualPackageHazardDiagnostics } from './format.js';
6
6
  import { getLangFromExt } from './utils/lang.js';
7
7
  import { builtinModules } from 'node:module';
8
8
  import { resolve as pathResolve, dirname as pathDirname, extname, join } from 'node:path';
9
9
  import { readFile as fsReadFile, stat } from 'node:fs/promises';
10
10
  import { parse as parseModule } from './parse.js';
11
11
  import { walk } from './walk.js';
12
+ import { collectModuleIdentifiers } from './utils/identifiers.js';
12
13
  const collapseSpecifier = value => value.replace(/['"`+)\s]|new String\(/g, '');
13
14
  const builtinSpecifiers = new Set(builtinModules.map(mod => mod.startsWith('node:') ? mod.slice(5) : mod).flatMap(mod => {
14
15
  const parts = mod.split('/');
@@ -141,6 +142,46 @@ const detectCircularRequireGraph = async (entryFile, mode, dirIndex) => {
141
142
  };
142
143
  await dfs(entryFile, []);
143
144
  };
145
+ const mergeUsageMaps = (target, source) => {
146
+ for (const [pkg, usage] of source) {
147
+ const existing = target.get(pkg) ?? {
148
+ imports: [],
149
+ requires: []
150
+ };
151
+ existing.imports.push(...usage.imports);
152
+ existing.requires.push(...usage.requires);
153
+ target.set(pkg, existing);
154
+ }
155
+ };
156
+ const collectProjectDualPackageHazards = async (files, opts) => {
157
+ const hazardMode = opts.detectDualPackageHazard ?? 'warn';
158
+ if (hazardMode === 'off') return new Map();
159
+ const hazardLevel = hazardMode === 'error' ? 'error' : 'warning';
160
+ const usages = new Map();
161
+ const manifestCache = new Map();
162
+ for (const file of files) {
163
+ const code = await readFile(file, 'utf8');
164
+ const ast = parseModule(file, code);
165
+ const moduleIdentifiers = await collectModuleIdentifiers(ast.program);
166
+ const shadowedBindings = new Set([...moduleIdentifiers.entries()].filter(([, meta]) => meta.declare.length > 0).map(([name]) => name));
167
+ const perFileUsage = await collectDualPackageUsage(ast.program, shadowedBindings, file);
168
+ mergeUsageMaps(usages, perFileUsage);
169
+ }
170
+ const diags = await dualPackageHazardDiagnostics({
171
+ usages,
172
+ hazardLevel,
173
+ cwd: opts.cwd,
174
+ manifestCache
175
+ });
176
+ const byFile = new Map();
177
+ for (const diag of diags) {
178
+ const key = diag.filePath ?? files[0];
179
+ const existing = byFile.get(key) ?? [];
180
+ existing.push(diag);
181
+ byFile.set(key, existing);
182
+ }
183
+ return byFile;
184
+ };
144
185
  const defaultOptions = {
145
186
  target: 'commonjs',
146
187
  sourceType: 'auto',
@@ -154,6 +195,8 @@ const defaultOptions = {
154
195
  importMetaMain: 'shim',
155
196
  requireMainStrategy: 'import-meta-main',
156
197
  detectCircularRequires: 'off',
198
+ detectDualPackageHazard: 'warn',
199
+ dualPackageHazardScope: 'file',
157
200
  requireSource: 'builtin',
158
201
  nestedRequireStrategy: 'create-require',
159
202
  cjsDefault: 'auto',
@@ -197,4 +240,4 @@ const transform = async (filename, options = defaultOptions) => {
197
240
  }
198
241
  return source;
199
242
  };
200
- export { transform };
243
+ export { transform, collectProjectDualPackageHazards };
package/dist/types.d.ts CHANGED
@@ -32,6 +32,10 @@ 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';
37
+ /** Scope for dual package hazard detection. */
38
+ dualPackageHazardScope?: 'file' | 'project';
35
39
  /** Source used to provide require in ESM output. */
36
40
  requireSource?: 'builtin' | 'create-require';
37
41
  /** How to rewrite nested or non-hoistable require calls. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knighted/module",
3
- "version": "1.3.1",
3
+ "version": "1.4.0-rc.1",
4
4
  "description": "Bidirectional transform for ES modules and CommonJS.",
5
5
  "type": "module",
6
6
  "main": "dist/module.js",