@knighted/module 1.4.0-rc.0 → 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
@@ -131,6 +131,7 @@ type ModuleOptions = {
131
131
  requireMainStrategy?: 'import-meta-main' | 'realpath'
132
132
  detectCircularRequires?: 'off' | 'warn' | 'error'
133
133
  detectDualPackageHazard?: 'off' | 'warn' | 'error'
134
+ dualPackageHazardScope?: 'file' | 'project'
134
135
  requireSource?: 'builtin' | 'create-require'
135
136
  importMetaPrelude?: 'off' | 'auto' | 'on'
136
137
  cjsDefault?: 'module-exports' | 'auto' | 'none'
@@ -156,7 +157,8 @@ type ModuleOptions = {
156
157
  - `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check.
157
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.
158
159
  - `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.
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.
160
162
  - `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
161
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.
162
164
  - `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
package/dist/cjs/cli.cjs CHANGED
@@ -30,6 +30,7 @@ const defaultOptions = {
30
30
  requireMainStrategy: 'import-meta-main',
31
31
  detectCircularRequires: 'off',
32
32
  detectDualPackageHazard: 'warn',
33
+ dualPackageHazardScope: 'file',
33
34
  requireSource: 'builtin',
34
35
  nestedRequireStrategy: 'create-require',
35
36
  cjsDefault: 'auto',
@@ -156,6 +157,11 @@ const optionsTable = [{
156
157
  short: 'H',
157
158
  type: 'string',
158
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)'
159
165
  }, {
160
166
  long: 'top-level-await',
161
167
  short: 'a',
@@ -249,10 +255,10 @@ const optionsTable = [{
249
255
  }];
250
256
  const buildHelp = enableColor => {
251
257
  const c = colorize(enableColor);
252
- 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));
253
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:'];
254
260
  for (const opt of optionsTable) {
255
- const flag = ` -${opt.short}, --${opt.long}`;
261
+ const flag = opt.short ? ` -${opt.short}, --${opt.long}` : ` --${opt.long}`;
256
262
  const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2));
257
263
  lines.push(`${c.bold(flag)}${pad}${opt.desc}`);
258
264
  }
@@ -288,6 +294,7 @@ const toModuleOptions = values => {
288
294
  appendDirectoryIndex,
289
295
  detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
290
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,
291
298
  topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
292
299
  cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
293
300
  idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
@@ -374,6 +381,9 @@ const summarizeDiagnostics = diags => {
374
381
  const runFiles = async (files, moduleOpts, io, flags) => {
375
382
  const results = [];
376
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;
377
387
  for (const file of files) {
378
388
  const diagnostics = [];
379
389
  const original = await (0, _promises.readFile)(file, 'utf8');
@@ -383,7 +393,8 @@ const runFiles = async (files, moduleOpts, io, flags) => {
383
393
  diagnostics: diag => diagnostics.push(diag),
384
394
  out: undefined,
385
395
  inPlace: false,
386
- filePath: file
396
+ filePath: file,
397
+ detectDualPackageHazard: hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard
387
398
  };
388
399
  let writeTarget;
389
400
  if (!flags.dryRun && !flags.list) {
@@ -405,6 +416,10 @@ const runFiles = async (files, moduleOpts, io, flags) => {
405
416
  }
406
417
  const output = await (0, _module.transform)(file, perFileOpts);
407
418
  const changed = output !== original;
419
+ if (projectHazards) {
420
+ const extras = projectHazards.get(file);
421
+ if (extras?.length) diagnostics.push(...extras);
422
+ }
408
423
  if (flags.list && changed) {
409
424
  logger.info(file);
410
425
  }
@@ -454,7 +469,9 @@ const runCli = async ({
454
469
  allowPositionals: true,
455
470
  options: Object.fromEntries(optionsTable.map(opt => [opt.long, {
456
471
  type: opt.type,
457
- short: opt.short
472
+ ...(opt.short ? {
473
+ short: opt.short
474
+ } : {})
458
475
  }]))
459
476
  });
460
477
  const logger = makeLogger(stdout, stderr);
@@ -3,7 +3,7 @@
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
7
  var _nodeModule = require("node:module");
8
8
  var _nodePath = require("node:path");
9
9
  var _promises = require("node:fs/promises");
@@ -153,98 +153,147 @@ const describeDualPackage = pkgJson => {
153
153
  requireTarget
154
154
  };
155
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);
156
+ const recordUsage = (usages, pkg, kind, spec, subpath, loc, filePath) => {
157
+ const existing = usages.get(pkg) ?? {
158
+ imports: [],
159
+ requires: []
179
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();
180
172
  await (0, _walk.ancestorWalk)(program, {
181
173
  enter(node) {
182
174
  if (node.type === 'ImportDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
183
175
  const pkg = packageFromSpecifier(node.source.value);
184
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
176
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
185
177
  start: node.source.start,
186
178
  end: node.source.end
187
- });
179
+ }, filePath);
188
180
  }
189
181
  if (node.type === 'ExportNamedDeclaration' && node.source && node.source.type === 'Literal' && typeof node.source.value === 'string') {
190
182
  const pkg = packageFromSpecifier(node.source.value);
191
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
183
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
192
184
  start: node.source.start,
193
185
  end: node.source.end
194
- });
186
+ }, filePath);
195
187
  }
196
188
  if (node.type === 'ExportAllDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
197
189
  const pkg = packageFromSpecifier(node.source.value);
198
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
190
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
199
191
  start: node.source.start,
200
192
  end: node.source.end
201
- });
193
+ }, filePath);
202
194
  }
203
195
  if (node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
204
196
  const pkg = packageFromSpecifier(node.source.value);
205
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
197
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
206
198
  start: node.source.start,
207
199
  end: node.source.end
208
- });
200
+ }, filePath);
209
201
  }
210
202
  if (node.type === 'CallExpression' && (0, _lowerCjsRequireToImports.isStaticRequire)(node, shadowedBindings)) {
211
203
  const arg = node.arguments[0];
212
204
  if (arg?.type === 'Literal' && typeof arg.value === 'string') {
213
205
  const pkg = packageFromSpecifier(arg.value);
214
- if (pkg) record(pkg.pkg, 'require', arg.value, pkg.subpath, {
206
+ if (pkg) recordUsage(usages, pkg.pkg, 'require', arg.value, pkg.subpath, {
215
207
  start: arg.start,
216
208
  end: arg.end
217
- });
209
+ }, filePath);
218
210
  }
219
211
  }
220
212
  }
221
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 = [];
222
226
  for (const [pkg, usage] of usages) {
223
227
  const hasImport = usage.imports.length > 0;
224
228
  const hasRequire = usage.requires.length > 0;
225
229
  const combined = [...usage.imports, ...usage.requires];
226
230
  const hasRoot = combined.some(entry => !entry.subpath);
227
231
  const hasSubpath = combined.some(entry => Boolean(entry.subpath));
232
+ const origin = usage.imports[0] ?? usage.requires[0];
233
+ const diagFile = origin?.filePath ?? filePath;
228
234
  if (hasImport && hasRequire) {
229
235
  const importSpecs = usage.imports.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
230
236
  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);
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
+ });
232
244
  }
233
245
  if (hasRoot && hasSubpath) {
234
246
  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);
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
+ });
236
255
  }
237
256
  if (hasImport && hasRequire) {
238
- const manifest = await readPackageManifest(pkg, filePath, cwd, manifestCache);
257
+ const manifest = await readPackageManifest(pkg, diagFile, cwd, manifestCache);
239
258
  if (manifest) {
240
259
  const meta = describeDualPackage(manifest);
241
260
  if (meta.hasHazardSignals) {
242
261
  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);
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
+ });
244
269
  }
245
270
  }
246
271
  }
247
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
+ }
248
297
  };
249
298
 
250
299
  /**
@@ -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',
@@ -158,6 +200,7 @@ const defaultOptions = {
158
200
  requireMainStrategy: 'import-meta-main',
159
201
  detectCircularRequires: 'off',
160
202
  detectDualPackageHazard: 'warn',
203
+ dualPackageHazardScope: 'file',
161
204
  requireSource: 'builtin',
162
205
  nestedRequireStrategy: 'create-require',
163
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 };
@@ -34,6 +34,8 @@ export type ModuleOptions = {
34
34
  detectCircularRequires?: 'off' | 'warn' | 'error';
35
35
  /** Detect divergent import/require usage of the same dual package (default warn). */
36
36
  detectDualPackageHazard?: 'off' | 'warn' | 'error';
37
+ /** Scope for dual package hazard detection. */
38
+ dualPackageHazardScope?: 'file' | 'project';
37
39
  /** Source used to provide require in ESM output. */
38
40
  requireSource?: 'builtin' | 'create-require';
39
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';
@@ -24,6 +24,7 @@ const defaultOptions = {
24
24
  requireMainStrategy: 'import-meta-main',
25
25
  detectCircularRequires: 'off',
26
26
  detectDualPackageHazard: 'warn',
27
+ dualPackageHazardScope: 'file',
27
28
  requireSource: 'builtin',
28
29
  nestedRequireStrategy: 'create-require',
29
30
  cjsDefault: 'auto',
@@ -150,6 +151,11 @@ const optionsTable = [{
150
151
  short: 'H',
151
152
  type: 'string',
152
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)'
153
159
  }, {
154
160
  long: 'top-level-await',
155
161
  short: 'a',
@@ -243,10 +249,10 @@ const optionsTable = [{
243
249
  }];
244
250
  const buildHelp = enableColor => {
245
251
  const c = colorize(enableColor);
246
- 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));
247
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:'];
248
254
  for (const opt of optionsTable) {
249
- const flag = ` -${opt.short}, --${opt.long}`;
255
+ const flag = opt.short ? ` -${opt.short}, --${opt.long}` : ` --${opt.long}`;
250
256
  const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2));
251
257
  lines.push(`${c.bold(flag)}${pad}${opt.desc}`);
252
258
  }
@@ -282,6 +288,7 @@ const toModuleOptions = values => {
282
288
  appendDirectoryIndex,
283
289
  detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
284
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,
285
292
  topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
286
293
  cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
287
294
  idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
@@ -368,6 +375,9 @@ const summarizeDiagnostics = diags => {
368
375
  const runFiles = async (files, moduleOpts, io, flags) => {
369
376
  const results = [];
370
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;
371
381
  for (const file of files) {
372
382
  const diagnostics = [];
373
383
  const original = await readFile(file, 'utf8');
@@ -377,7 +387,8 @@ const runFiles = async (files, moduleOpts, io, flags) => {
377
387
  diagnostics: diag => diagnostics.push(diag),
378
388
  out: undefined,
379
389
  inPlace: false,
380
- filePath: file
390
+ filePath: file,
391
+ detectDualPackageHazard: hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard
381
392
  };
382
393
  let writeTarget;
383
394
  if (!flags.dryRun && !flags.list) {
@@ -399,6 +410,10 @@ const runFiles = async (files, moduleOpts, io, flags) => {
399
410
  }
400
411
  const output = await transform(file, perFileOpts);
401
412
  const changed = output !== original;
413
+ if (projectHazards) {
414
+ const extras = projectHazards.get(file);
415
+ if (extras?.length) diagnostics.push(...extras);
416
+ }
402
417
  if (flags.list && changed) {
403
418
  logger.info(file);
404
419
  }
@@ -448,7 +463,9 @@ const runCli = async ({
448
463
  allowPositionals: true,
449
464
  options: Object.fromEntries(optionsTable.map(opt => [opt.long, {
450
465
  type: opt.type,
451
- short: opt.short
466
+ ...(opt.short ? {
467
+ short: opt.short
468
+ } : {})
452
469
  }]))
453
470
  });
454
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
@@ -146,98 +146,145 @@ const describeDualPackage = pkgJson => {
146
146
  requireTarget
147
147
  };
148
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);
149
+ const recordUsage = (usages, pkg, kind, spec, subpath, loc, filePath) => {
150
+ const existing = usages.get(pkg) ?? {
151
+ imports: [],
152
+ requires: []
172
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();
173
165
  await ancestorWalk(program, {
174
166
  enter(node) {
175
167
  if (node.type === 'ImportDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
176
168
  const pkg = packageFromSpecifier(node.source.value);
177
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
169
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
178
170
  start: node.source.start,
179
171
  end: node.source.end
180
- });
172
+ }, filePath);
181
173
  }
182
174
  if (node.type === 'ExportNamedDeclaration' && node.source && node.source.type === 'Literal' && typeof node.source.value === 'string') {
183
175
  const pkg = packageFromSpecifier(node.source.value);
184
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
176
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
185
177
  start: node.source.start,
186
178
  end: node.source.end
187
- });
179
+ }, filePath);
188
180
  }
189
181
  if (node.type === 'ExportAllDeclaration' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
190
182
  const pkg = packageFromSpecifier(node.source.value);
191
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
183
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
192
184
  start: node.source.start,
193
185
  end: node.source.end
194
- });
186
+ }, filePath);
195
187
  }
196
188
  if (node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string') {
197
189
  const pkg = packageFromSpecifier(node.source.value);
198
- if (pkg) record(pkg.pkg, 'import', node.source.value, pkg.subpath, {
190
+ if (pkg) recordUsage(usages, pkg.pkg, 'import', node.source.value, pkg.subpath, {
199
191
  start: node.source.start,
200
192
  end: node.source.end
201
- });
193
+ }, filePath);
202
194
  }
203
195
  if (node.type === 'CallExpression' && isStaticRequire(node, shadowedBindings)) {
204
196
  const arg = node.arguments[0];
205
197
  if (arg?.type === 'Literal' && typeof arg.value === 'string') {
206
198
  const pkg = packageFromSpecifier(arg.value);
207
- if (pkg) record(pkg.pkg, 'require', arg.value, pkg.subpath, {
199
+ if (pkg) recordUsage(usages, pkg.pkg, 'require', arg.value, pkg.subpath, {
208
200
  start: arg.start,
209
201
  end: arg.end
210
- });
202
+ }, filePath);
211
203
  }
212
204
  }
213
205
  }
214
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 = [];
215
218
  for (const [pkg, usage] of usages) {
216
219
  const hasImport = usage.imports.length > 0;
217
220
  const hasRequire = usage.requires.length > 0;
218
221
  const combined = [...usage.imports, ...usage.requires];
219
222
  const hasRoot = combined.some(entry => !entry.subpath);
220
223
  const hasSubpath = combined.some(entry => Boolean(entry.subpath));
224
+ const origin = usage.imports[0] ?? usage.requires[0];
225
+ const diagFile = origin?.filePath ?? filePath;
221
226
  if (hasImport && hasRequire) {
222
227
  const importSpecs = usage.imports.map(u => u.subpath ? `${pkg}/${u.subpath}` : pkg);
223
228
  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);
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
+ });
225
236
  }
226
237
  if (hasRoot && hasSubpath) {
227
238
  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);
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
+ });
229
247
  }
230
248
  if (hasImport && hasRequire) {
231
- const manifest = await readPackageManifest(pkg, filePath, cwd, manifestCache);
249
+ const manifest = await readPackageManifest(pkg, diagFile, cwd, manifestCache);
232
250
  if (manifest) {
233
251
  const meta = describeDualPackage(manifest);
234
252
  if (meta.hasHazardSignals) {
235
253
  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);
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
+ });
237
261
  }
238
262
  }
239
263
  }
240
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
+ }
241
288
  };
242
289
 
243
290
  /**
@@ -481,4 +528,4 @@ const format = async (src, ast, opts) => {
481
528
  }
482
529
  return code.toString();
483
530
  };
484
- 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',
@@ -155,6 +196,7 @@ const defaultOptions = {
155
196
  requireMainStrategy: 'import-meta-main',
156
197
  detectCircularRequires: 'off',
157
198
  detectDualPackageHazard: 'warn',
199
+ dualPackageHazardScope: 'file',
158
200
  requireSource: 'builtin',
159
201
  nestedRequireStrategy: 'create-require',
160
202
  cjsDefault: 'auto',
@@ -198,4 +240,4 @@ const transform = async (filename, options = defaultOptions) => {
198
240
  }
199
241
  return source;
200
242
  };
201
- export { transform };
243
+ export { transform, collectProjectDualPackageHazards };
package/dist/types.d.ts CHANGED
@@ -34,6 +34,8 @@ export type ModuleOptions = {
34
34
  detectCircularRequires?: 'off' | 'warn' | 'error';
35
35
  /** Detect divergent import/require usage of the same dual package (default warn). */
36
36
  detectDualPackageHazard?: 'off' | 'warn' | 'error';
37
+ /** Scope for dual package hazard detection. */
38
+ dualPackageHazardScope?: 'file' | 'project';
37
39
  /** Source used to provide require in ESM output. */
38
40
  requireSource?: 'builtin' | 'create-require';
39
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.4.0-rc.0",
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",