Skip to content

Commit e0b2b7c

Browse files
feat: dual package hazard allow list.
1 parent 0433589 commit e0b2b7c

9 files changed

Lines changed: 141 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ type ModuleOptions = {
134134
detectCircularRequires?: 'off' | 'warn' | 'error'
135135
detectDualPackageHazard?: 'off' | 'warn' | 'error'
136136
dualPackageHazardScope?: 'file' | 'project'
137+
dualPackageHazardAllowlist?: string | string[]
137138
requireSource?: 'builtin' | 'create-require'
138139
importMetaPrelude?: 'off' | 'auto' | 'on'
139140
cjsDefault?: 'module-exports' | 'auto' | 'none'
@@ -162,6 +163,7 @@ type ModuleOptions = {
162163
- `detectCircularRequires` (`off`): optionally detect relative static require cycles across `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts` (realpath-normalized) and warn/throw.
163164
- `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.
164165
- `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.
166+
- `dualPackageHazardAllowlist` (`[]`): suppress dual-package hazard diagnostics for the listed packages. Accepts a string or array; entries are trimmed and empty values dropped. Applies to both `file` and `project` scopes and is also available via the CLI flag `--dual-package-hazard-allowlist pkg1,pkg2`.
165167
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output. `wrap` runs the file body inside an async IIFE (exports may resolve after the initial tick); `preserve` leaves `await` at top level, which Node will reject for CJS.
166168
- `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.
167169
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.

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.5.0-rc.0",
3+
"version": "1.5.0",
44
"description": "Bidirectional transform for ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/cli.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const defaultOptions: ModuleOptions = {
3535
detectCircularRequires: 'off',
3636
detectDualPackageHazard: 'warn',
3737
dualPackageHazardScope: 'file',
38+
dualPackageHazardAllowlist: [],
3839
requireSource: 'builtin',
3940
nestedRequireStrategy: 'create-require',
4041
cjsDefault: 'auto',
@@ -225,6 +226,12 @@ const optionsTable = [
225226
type: 'string',
226227
desc: 'Scope for dual package hazard detection (file|project)',
227228
},
229+
{
230+
long: 'dual-package-hazard-allowlist',
231+
short: undefined,
232+
type: 'string',
233+
desc: 'Comma-separated packages to ignore for dual package hazard checks',
234+
},
228235
{
229236
long: 'top-level-await',
230237
short: 'a',
@@ -351,15 +358,13 @@ const buildHelp = (enableColor: boolean) => {
351358

352359
return `${lines.join('\n')}\n`
353360
}
354-
355361
const parseEnum = <T extends string>(
356362
value: string | undefined,
357363
allowed: readonly T[],
358364
): T | undefined => {
359365
if (value === undefined) return undefined
360366
return allowed.includes(value as T) ? (value as T) : undefined
361367
}
362-
363368
const parseTransformSyntax = (
364369
value: string | undefined,
365370
): ModuleOptions['transformSyntax'] => {
@@ -369,13 +374,19 @@ const parseTransformSyntax = (
369374
if (value === 'true') return true
370375
return defaultOptions.transformSyntax
371376
}
372-
373377
const parseAppendDirectoryIndex = (value: string | undefined) => {
374378
if (value === undefined) return undefined
375379
if (value === 'false') return false
376380
return value
377381
}
382+
const parseAllowlist = (value: string | string[] | undefined) => {
383+
const values = value === undefined ? [] : Array.isArray(value) ? value : [value]
378384

385+
return values
386+
.flatMap(entry => String(entry).split(','))
387+
.map(item => item.trim())
388+
.filter(Boolean)
389+
}
379390
const toModuleOptions = (values: ParsedValues): ModuleOptions => {
380391
const target =
381392
parseEnum(values.target as string | undefined, ['module', 'commonjs'] as const) ??
@@ -395,7 +406,9 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
395406
const appendDirectoryIndex = parseAppendDirectoryIndex(
396407
values['append-directory-index'] as string | undefined,
397408
)
398-
409+
const dualPackageHazardAllowlist = parseAllowlist(
410+
values['dual-package-hazard-allowlist'] as string | string[] | undefined,
411+
)
399412
const opts: ModuleOptions = {
400413
...defaultOptions,
401414
target,
@@ -420,6 +433,7 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
420433
values['dual-package-hazard-scope'] as string | undefined,
421434
['file', 'project'] as const,
422435
) ?? defaultOptions.dualPackageHazardScope,
436+
dualPackageHazardAllowlist,
423437
topLevelAwait:
424438
parseEnum(
425439
values['top-level-await'] as string | undefined,

src/format.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ const describeDualPackage = (pkgJson: any) => {
180180
return { hasHazardSignals, details, importTarget, requireTarget }
181181
}
182182

183+
const normalizeAllowlist = (allowlist?: Iterable<string>) => {
184+
return new Set(
185+
[...(allowlist ?? [])].map(item => item.trim()).filter(item => item.length > 0),
186+
)
187+
}
188+
183189
type HazardLevel = 'warning' | 'error'
184190

185191
export type PackageUse = {
@@ -323,12 +329,16 @@ const dualPackageHazardDiagnostics = async (params: {
323329
filePath?: string
324330
cwd?: string
325331
manifestCache?: Map<string, any | null>
332+
hazardAllowlist?: Iterable<string>
326333
}) => {
327334
const { usages, hazardLevel, filePath, cwd } = params
328335
const manifestCache = params.manifestCache ?? new Map<string, any | null>()
336+
const allowlist = normalizeAllowlist(params.hazardAllowlist)
329337
const diags: Diagnostic[] = []
330338

331339
for (const [pkg, usage] of usages) {
340+
if (allowlist.has(pkg)) continue
341+
332342
const hasImport = usage.imports.length > 0
333343
const hasRequire = usage.requires.length > 0
334344
const combined = [...usage.imports, ...usage.requires]
@@ -402,6 +412,7 @@ const detectDualPackageHazards = async (params: {
402412
message: string,
403413
loc?: { start: number; end: number },
404414
) => void
415+
hazardAllowlist?: Iterable<string>
405416
}) => {
406417
const { program, shadowedBindings, hazardLevel, filePath, cwd, diagOnce } = params
407418
const manifestCache = new Map<string, any | null>()
@@ -412,6 +423,7 @@ const detectDualPackageHazards = async (params: {
412423
filePath,
413424
cwd,
414425
manifestCache,
426+
hazardAllowlist: params.hazardAllowlist,
415427
})
416428

417429
for (const diag of diags) {
@@ -484,6 +496,7 @@ async function format(src: string, ast: ParseResult, opts: FormatterOptions) {
484496
filePath: opts.filePath,
485497
cwd: opts.cwd,
486498
diagOnce,
499+
hazardAllowlist: opts.dualPackageHazardAllowlist,
487500
})
488501
}
489502

src/module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,10 @@ const collectProjectDualPackageHazards = async (files: string[], opts: ModuleOpt
258258
const diags = await dualPackageHazardDiagnostics({
259259
usages,
260260
hazardLevel,
261+
filePath: opts.filePath,
261262
cwd: opts.cwd,
262263
manifestCache,
264+
hazardAllowlist: opts.dualPackageHazardAllowlist,
263265
})
264266
const byFile = new Map<string, Diagnostic[]>()
265267

@@ -290,6 +292,7 @@ const createDefaultOptions = (): ModuleOptions => ({
290292
detectCircularRequires: 'off',
291293
detectDualPackageHazard: 'warn',
292294
dualPackageHazardScope: 'file',
295+
dualPackageHazardAllowlist: [],
293296
requireSource: 'builtin',
294297
nestedRequireStrategy: 'create-require',
295298
cjsDefault: 'auto',

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export type ModuleOptions = {
5757
detectDualPackageHazard?: 'off' | 'warn' | 'error'
5858
/** Scope for dual package hazard detection. */
5959
dualPackageHazardScope?: 'file' | 'project'
60+
/** Packages to ignore for dual package hazard diagnostics. */
61+
dualPackageHazardAllowlist?: string[]
6062
/** Source used to provide require in ESM output. */
6163
requireSource?: 'builtin' | 'create-require'
6264
/** How to rewrite nested or non-hoistable require calls. */

test/cli.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,58 @@ test('-H error exits on dual package hazard', async () => {
175175
}
176176
})
177177

178+
test('--dual-package-hazard-allowlist suppresses hazards', async () => {
179+
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-allow-'))
180+
const file = join(temp, 'entry.mjs')
181+
const pkgDir = join(temp, 'node_modules', 'x-core')
182+
183+
await mkdir(pkgDir, { recursive: true })
184+
await writeFile(
185+
join(pkgDir, 'package.json'),
186+
JSON.stringify(
187+
{
188+
name: 'x-core',
189+
version: '1.0.0',
190+
exports: {
191+
'.': { import: './x-core.mjs', require: './x-core.cjs' },
192+
'./module': './x-core.mjs',
193+
},
194+
main: './x-core.cjs',
195+
},
196+
null,
197+
2,
198+
),
199+
'utf8',
200+
)
201+
await writeFile(
202+
file,
203+
[
204+
"import { X } from 'x-core/module'",
205+
"const core = require('x-core')",
206+
'console.log(core, X)',
207+
'',
208+
].join('\n'),
209+
'utf8',
210+
)
211+
212+
try {
213+
const result = runCli([
214+
'--target',
215+
'commonjs',
216+
'--cwd',
217+
temp,
218+
'--dual-package-hazard-allowlist',
219+
' x-core ',
220+
'entry.mjs',
221+
])
222+
223+
assert.equal(result.status, 0)
224+
assert.ok(!/dual-package-/.test(result.stderr))
225+
} finally {
226+
await rm(temp, { recursive: true, force: true })
227+
}
228+
})
229+
178230
test('--dual-package-hazard-scope project aggregates across files', async () => {
179231
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-project-'))
180232
const fileImport = join(temp, 'entry.mjs')

test/module.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,54 @@ describe('@knighted/module', () => {
117117
assert.ok(diagnostics.every(d => d.level === 'warning'))
118118
})
119119

120+
it('suppresses dual package hazards via allowlist', async t => {
121+
const temp = await mkdtemp(join(tmpdir(), 'module-dual-hazard-allow-'))
122+
const file = join(temp, 'entry.mjs')
123+
const pkgDir = join(temp, 'node_modules', 'x-core')
124+
125+
await mkdir(pkgDir, { recursive: true })
126+
await writeFile(
127+
join(pkgDir, 'package.json'),
128+
JSON.stringify(
129+
{
130+
name: 'x-core',
131+
version: '1.0.0',
132+
exports: {
133+
'.': { import: './x-core.mjs', require: './x-core.cjs' },
134+
'./module': './x-core.mjs',
135+
},
136+
main: './x-core.cjs',
137+
},
138+
null,
139+
2,
140+
),
141+
'utf8',
142+
)
143+
await writeFile(
144+
file,
145+
[
146+
"import { X } from 'x-core/module'",
147+
"const core = require('x-core')",
148+
'console.log(core, X)',
149+
'',
150+
].join('\n'),
151+
'utf8',
152+
)
153+
154+
t.after(() => rm(temp, { recursive: true, force: true }))
155+
156+
const diagnostics: any[] = []
157+
await transform(file, {
158+
target: 'commonjs',
159+
detectDualPackageHazard: 'warn',
160+
dualPackageHazardAllowlist: [' x-core '],
161+
diagnostics: diag => diagnostics.push(diag),
162+
cwd: temp,
163+
})
164+
165+
assert.equal(diagnostics.length, 0)
166+
})
167+
120168
it('warns on hazard across export forms and dynamic import', async t => {
121169
const temp = await mkdtemp(join(tmpdir(), 'module-dual-hazard-exports-'))
122170
const file = join(temp, 'entry.mjs')

0 commit comments

Comments
 (0)