Skip to content

Commit d5d2642

Browse files
feat: dual package hazard allow list. (#48)
1 parent 0433589 commit d5d2642

File tree

9 files changed

+328
-7
lines changed

9 files changed

+328
-7
lines changed

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[]
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 an array in the API; entries are trimmed and empty values dropped. The CLI flag `--dual-package-hazard-allowlist pkg1,pkg2` parses a comma- or space-separated string into this array. Applies to both `file` and `project` scopes.
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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,182 @@ 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+
230+
test('--dual-package-hazard-allowlist parses multiple comma-separated packages', async () => {
231+
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-allow-multi-'))
232+
const file = join(temp, 'entry.mjs')
233+
const packages = ['x-core', 'y-core', 'z-core']
234+
235+
for (const pkg of packages) {
236+
const pkgDir = join(temp, 'node_modules', pkg)
237+
await mkdir(pkgDir, { recursive: true })
238+
await writeFile(
239+
join(pkgDir, 'package.json'),
240+
JSON.stringify(
241+
{
242+
name: pkg,
243+
version: '1.0.0',
244+
exports: {
245+
'.': { import: './index.mjs', require: './index.cjs' },
246+
'./module': './index.mjs',
247+
},
248+
main: './index.cjs',
249+
},
250+
null,
251+
2,
252+
),
253+
'utf8',
254+
)
255+
}
256+
257+
await writeFile(
258+
file,
259+
[
260+
"import { X } from 'x-core/module'",
261+
"const core = require('x-core')",
262+
"import { Y } from 'y-core/module'",
263+
"const y = require('y-core')",
264+
"import { Z } from 'z-core/module'",
265+
"const z = require('z-core')",
266+
'console.log(core, X, y, Y, z, Z)',
267+
'',
268+
].join('\n'),
269+
'utf8',
270+
)
271+
272+
try {
273+
const result = runCli([
274+
'--target',
275+
'commonjs',
276+
'--cwd',
277+
temp,
278+
'--dual-package-hazard-allowlist',
279+
' x-core , , y-core ',
280+
'entry.mjs',
281+
])
282+
283+
assert.equal(result.status, 0)
284+
assert.match(result.stderr, /z-core/)
285+
assert.ok(!/x-core/.test(result.stderr))
286+
assert.ok(!/y-core/.test(result.stderr))
287+
} finally {
288+
await rm(temp, { recursive: true, force: true })
289+
}
290+
})
291+
292+
test('--dual-package-hazard-allowlist parses comma-delimited list without spaces', async () => {
293+
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-allow-csv-'))
294+
const file = join(temp, 'entry.mjs')
295+
const packages = ['x-core', 'y-core', 'z-core']
296+
297+
for (const pkg of packages) {
298+
const pkgDir = join(temp, 'node_modules', pkg)
299+
await mkdir(pkgDir, { recursive: true })
300+
await writeFile(
301+
join(pkgDir, 'package.json'),
302+
JSON.stringify(
303+
{
304+
name: pkg,
305+
version: '1.0.0',
306+
exports: {
307+
'.': { import: './index.mjs', require: './index.cjs' },
308+
'./module': './index.mjs',
309+
},
310+
main: './index.cjs',
311+
},
312+
null,
313+
2,
314+
),
315+
'utf8',
316+
)
317+
}
318+
319+
await writeFile(
320+
file,
321+
[
322+
"import { X } from 'x-core/module'",
323+
"const core = require('x-core')",
324+
"import { Y } from 'y-core/module'",
325+
"const y = require('y-core')",
326+
"import { Z } from 'z-core/module'",
327+
"const z = require('z-core')",
328+
'console.log(core, X, y, Y, z, Z)',
329+
'',
330+
].join('\n'),
331+
'utf8',
332+
)
333+
334+
try {
335+
const result = runCli([
336+
'--target',
337+
'commonjs',
338+
'--cwd',
339+
temp,
340+
'--dual-package-hazard-allowlist',
341+
'x-core,y-core',
342+
'entry.mjs',
343+
])
344+
345+
assert.equal(result.status, 0)
346+
assert.match(result.stderr, /z-core/)
347+
assert.ok(!/x-core/.test(result.stderr))
348+
assert.ok(!/y-core/.test(result.stderr))
349+
} finally {
350+
await rm(temp, { recursive: true, force: true })
351+
}
352+
})
353+
178354
test('--dual-package-hazard-scope project aggregates across files', async () => {
179355
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-project-'))
180356
const fileImport = join(temp, 'entry.mjs')

0 commit comments

Comments
 (0)