Skip to content

Commit 0e29901

Browse files
feat: better dual hazard diagnostics. (#41)
1 parent 9a9025f commit 0e29901

11 files changed

Lines changed: 564 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ type ModuleOptions = {
130130
importMetaMain?: 'shim' | 'warn' | 'error'
131131
requireMainStrategy?: 'import-meta-main' | 'realpath'
132132
detectCircularRequires?: 'off' | 'warn' | 'error'
133+
detectDualPackageHazard?: 'off' | 'warn' | 'error'
133134
requireSource?: 'builtin' | 'create-require'
134135
importMetaPrelude?: 'off' | 'auto' | 'on'
135136
cjsDefault?: 'module-exports' | 'auto' | 'none'
@@ -155,6 +156,7 @@ type ModuleOptions = {
155156
- `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check.
156157
- `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.
157158
- `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.
158160
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
159161
- `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.
160162
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Short and long forms are supported.
6969
| -j | --append-js-extension | Append .js to relative imports (off \| relative-only \| all) |
7070
| -i | --append-directory-index | Append directory index (e.g. index.js) or false |
7171
| -c | --detect-circular-requires | Warn/error on circular require (off \| warn \| error) |
72+
| -H | --detect-dual-package-hazard | Warn/error on mixed import/require of dual packages (off \| warn \| error) |
7273
| -a | --top-level-await | TLA handling (error \| wrap \| preserve) |
7374
| -d | --cjs-default | Default interop (module-exports \| auto \| none) |
7475
| -e | --idiomatic-exports | Emit idiomatic exports when safe (off \| safe \| aggressive) |

docs/dual-package-hazard.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Dual Package Hazard Diagnostics
2+
3+
This tool can warn or error when a file mixes specifiers that may trigger the dual package hazard (ESM vs CJS instances of the same package).
4+
5+
## Option
6+
7+
- `detectDualPackageHazard`: `off` | `warn` (default) | `error`
8+
- CLI: `--detect-dual-package-hazard`, short `-H`.
9+
- `warn`: emit diagnostics but continue.
10+
- `error`: diagnostics are emitted and the transform exits non-zero.
11+
- `off`: skip detection.
12+
13+
## What we detect (per file)
14+
15+
- Mixed import/require of the same bare package (including subpaths).
16+
- Diagnostic: `dual-package-mixed-specifiers`.
17+
- Root vs subpath specifiers of the same package (e.g., `pkg` and `pkg/module`).
18+
- Diagnostic: `dual-package-subpath`.
19+
- When both import and require occur, and package.json shows divergent entrypoints (conditional exports, module/main disagreements, or type: module with CJS main).
20+
- Diagnostic: `dual-package-conditional-exports`.
21+
22+
## How it works
23+
24+
- Static string specifiers only (import/export-from, import(), require literals).
25+
- Computes the package root from bare specifiers (ignores relative/absolute, node: builtins, URLs).
26+
- Looks up package.json under `node_modules/<pkg>` relative to the current file/cwd when available.
27+
- Best-effort: if the manifest cannot be read, the manifest-based diagnostic is skipped.
28+
29+
## What is not covered
30+
31+
- Cross-file or whole-project graph analysis; detection is per file only.
32+
- Dynamic or template specifiers; non-literal specifiers are ignored.
33+
- Loader/bundler resolution differences (pnpm linking, aliases, custom conditions).
34+
- Exact equality of root vs subpath targets; we do not stat/resolve to see if they point to the same file, so a root/subpath warning may be conservative.
35+
36+
## Guidance
37+
38+
- Prefer a single specifier form for a given package: either all import or all require, and avoid mixing root and subpath unless you know they share the same build.
39+
- Use `-H error` (or `detectDualPackageHazard: 'error'`) in CI to block new hazards once noise is acceptable for your codebase.
40+
- If you need to suppress noise temporarily, set the option to `warn` while you align specifiers or package metadata.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.3.1",
3+
"version": "1.4.0-rc.0",
44
"description": "Bidirectional transform for ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/cli.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const defaultOptions: ModuleOptions = {
3030
importMetaMain: 'shim',
3131
requireMainStrategy: 'import-meta-main',
3232
detectCircularRequires: 'off',
33+
detectDualPackageHazard: 'warn',
3334
requireSource: 'builtin',
3435
nestedRequireStrategy: 'create-require',
3536
cjsDefault: 'auto',
@@ -211,6 +212,12 @@ const optionsTable = [
211212
type: 'string',
212213
desc: 'Warn/error on circular require (off|warn|error)',
213214
},
215+
{
216+
long: 'detect-dual-package-hazard',
217+
short: 'H',
218+
type: 'string',
219+
desc: 'Warn/error on mixed import/require of dual packages (off|warn|error)',
220+
},
214221
{
215222
long: 'top-level-await',
216223
short: 'a',
@@ -382,6 +389,11 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
382389
values['detect-circular-requires'] as string | undefined,
383390
['off', 'warn', 'error'] as const,
384391
) ?? defaultOptions.detectCircularRequires,
392+
detectDualPackageHazard:
393+
parseEnum(
394+
values['detect-dual-package-hazard'] as string | undefined,
395+
['off', 'warn', 'error'] as const,
396+
) ?? defaultOptions.detectDualPackageHazard,
385397
topLevelAwait:
386398
parseEnum(
387399
values['top-level-await'] as string | undefined,

0 commit comments

Comments
 (0)