@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 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,
@@ -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 warnOnce = (codeId, message, loc) => {
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: 'warning',
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.');
@@ -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',
@@ -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 warnOnce = (codeId, message, loc) => {
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: 'warning',
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. */
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.0",
4
4
  "description": "Bidirectional transform for ES modules and CommonJS.",
5
5
  "type": "module",
6
6
  "main": "dist/module.js",