Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ type ModuleOptions = {
detectCircularRequires?: 'off' | 'warn' | 'error'
detectDualPackageHazard?: 'off' | 'warn' | 'error'
dualPackageHazardScope?: 'file' | 'project'
dualPackageHazardAllowlist?: string[]
requireSource?: 'builtin' | 'create-require'
importMetaPrelude?: 'off' | 'auto' | 'on'
cjsDefault?: 'module-exports' | 'auto' | 'none'
Expand Down Expand Up @@ -162,6 +163,7 @@ type ModuleOptions = {
- `detectCircularRequires` (`off`): optionally detect relative static require cycles across `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts` (realpath-normalized) and warn/throw.
- `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.
- `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.
- `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.
- `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.
- `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.
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.5.0-rc.0",
"version": "1.5.0",
"description": "Bidirectional transform for ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand Down
22 changes: 18 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const defaultOptions: ModuleOptions = {
detectCircularRequires: 'off',
detectDualPackageHazard: 'warn',
dualPackageHazardScope: 'file',
dualPackageHazardAllowlist: [],
requireSource: 'builtin',
nestedRequireStrategy: 'create-require',
cjsDefault: 'auto',
Expand Down Expand Up @@ -225,6 +226,12 @@ const optionsTable = [
type: 'string',
desc: 'Scope for dual package hazard detection (file|project)',
},
{
long: 'dual-package-hazard-allowlist',
short: undefined,
type: 'string',
desc: 'Comma-separated packages to ignore for dual package hazard checks',
},
{
long: 'top-level-await',
short: 'a',
Expand Down Expand Up @@ -351,15 +358,13 @@ const buildHelp = (enableColor: boolean) => {

return `${lines.join('\n')}\n`
}

const parseEnum = <T extends string>(
value: string | undefined,
allowed: readonly T[],
): T | undefined => {
if (value === undefined) return undefined
return allowed.includes(value as T) ? (value as T) : undefined
}

const parseTransformSyntax = (
value: string | undefined,
): ModuleOptions['transformSyntax'] => {
Expand All @@ -369,13 +374,19 @@ const parseTransformSyntax = (
if (value === 'true') return true
return defaultOptions.transformSyntax
}

const parseAppendDirectoryIndex = (value: string | undefined) => {
if (value === undefined) return undefined
if (value === 'false') return false
return value
}
const parseAllowlist = (value: string | string[] | undefined) => {
const values = value === undefined ? [] : Array.isArray(value) ? value : [value]

return values
.flatMap(entry => String(entry).split(','))
.map(item => item.trim())
.filter(Boolean)
}
const toModuleOptions = (values: ParsedValues): ModuleOptions => {
const target =
parseEnum(values.target as string | undefined, ['module', 'commonjs'] as const) ??
Expand All @@ -395,7 +406,9 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
const appendDirectoryIndex = parseAppendDirectoryIndex(
values['append-directory-index'] as string | undefined,
)

const dualPackageHazardAllowlist = parseAllowlist(
values['dual-package-hazard-allowlist'] as string | string[] | undefined,
)
const opts: ModuleOptions = {
...defaultOptions,
target,
Expand All @@ -420,6 +433,7 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
values['dual-package-hazard-scope'] as string | undefined,
['file', 'project'] as const,
) ?? defaultOptions.dualPackageHazardScope,
dualPackageHazardAllowlist,
topLevelAwait:
parseEnum(
values['top-level-await'] as string | undefined,
Expand Down
13 changes: 13 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ const describeDualPackage = (pkgJson: any) => {
return { hasHazardSignals, details, importTarget, requireTarget }
}

const normalizeAllowlist = (allowlist?: Iterable<string>) => {
return new Set(
[...(allowlist ?? [])].map(item => item.trim()).filter(item => item.length > 0),
)
}

type HazardLevel = 'warning' | 'error'

export type PackageUse = {
Expand Down Expand Up @@ -323,12 +329,16 @@ const dualPackageHazardDiagnostics = async (params: {
filePath?: string
cwd?: string
manifestCache?: Map<string, any | null>
hazardAllowlist?: Iterable<string>
}) => {
const { usages, hazardLevel, filePath, cwd } = params
const manifestCache = params.manifestCache ?? new Map<string, any | null>()
const allowlist = normalizeAllowlist(params.hazardAllowlist)
const diags: Diagnostic[] = []

for (const [pkg, usage] of usages) {
if (allowlist.has(pkg)) continue

const hasImport = usage.imports.length > 0
const hasRequire = usage.requires.length > 0
const combined = [...usage.imports, ...usage.requires]
Expand Down Expand Up @@ -402,6 +412,7 @@ const detectDualPackageHazards = async (params: {
message: string,
loc?: { start: number; end: number },
) => void
hazardAllowlist?: Iterable<string>
}) => {
const { program, shadowedBindings, hazardLevel, filePath, cwd, diagOnce } = params
const manifestCache = new Map<string, any | null>()
Expand All @@ -412,6 +423,7 @@ const detectDualPackageHazards = async (params: {
filePath,
cwd,
manifestCache,
hazardAllowlist: params.hazardAllowlist,
})

for (const diag of diags) {
Expand Down Expand Up @@ -484,6 +496,7 @@ async function format(src: string, ast: ParseResult, opts: FormatterOptions) {
filePath: opts.filePath,
cwd: opts.cwd,
diagOnce,
hazardAllowlist: opts.dualPackageHazardAllowlist,
})
}

Expand Down
3 changes: 3 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,10 @@ const collectProjectDualPackageHazards = async (files: string[], opts: ModuleOpt
const diags = await dualPackageHazardDiagnostics({
usages,
hazardLevel,
filePath: opts.filePath,
cwd: opts.cwd,
manifestCache,
hazardAllowlist: opts.dualPackageHazardAllowlist,
})
const byFile = new Map<string, Diagnostic[]>()

Expand Down Expand Up @@ -290,6 +292,7 @@ const createDefaultOptions = (): ModuleOptions => ({
detectCircularRequires: 'off',
detectDualPackageHazard: 'warn',
dualPackageHazardScope: 'file',
dualPackageHazardAllowlist: [],
requireSource: 'builtin',
nestedRequireStrategy: 'create-require',
cjsDefault: 'auto',
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export type ModuleOptions = {
detectDualPackageHazard?: 'off' | 'warn' | 'error'
/** Scope for dual package hazard detection. */
dualPackageHazardScope?: 'file' | 'project'
/** Packages to ignore for dual package hazard diagnostics. */
dualPackageHazardAllowlist?: string[]
/** Source used to provide require in ESM output. */
requireSource?: 'builtin' | 'create-require'
/** How to rewrite nested or non-hoistable require calls. */
Expand Down
176 changes: 176 additions & 0 deletions test/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,182 @@ test('-H error exits on dual package hazard', async () => {
}
})

test('--dual-package-hazard-allowlist suppresses hazards', async () => {
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-allow-'))
const file = join(temp, 'entry.mjs')
const pkgDir = join(temp, 'node_modules', 'x-core')

await mkdir(pkgDir, { recursive: true })
await writeFile(
join(pkgDir, 'package.json'),
JSON.stringify(
{
name: 'x-core',
version: '1.0.0',
exports: {
'.': { import: './x-core.mjs', require: './x-core.cjs' },
'./module': './x-core.mjs',
},
main: './x-core.cjs',
},
null,
2,
),
'utf8',
)
await writeFile(
file,
[
"import { X } from 'x-core/module'",
"const core = require('x-core')",
'console.log(core, X)',
'',
].join('\n'),
'utf8',
)

try {
const result = runCli([
'--target',
'commonjs',
'--cwd',
temp,
'--dual-package-hazard-allowlist',
' x-core ',
'entry.mjs',
])

assert.equal(result.status, 0)
assert.ok(!/dual-package-/.test(result.stderr))
} finally {
await rm(temp, { recursive: true, force: true })
}
})

test('--dual-package-hazard-allowlist parses multiple comma-separated packages', async () => {
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-allow-multi-'))
const file = join(temp, 'entry.mjs')
const packages = ['x-core', 'y-core', 'z-core']

for (const pkg of packages) {
const pkgDir = join(temp, 'node_modules', pkg)
await mkdir(pkgDir, { recursive: true })
await writeFile(
join(pkgDir, 'package.json'),
JSON.stringify(
{
name: pkg,
version: '1.0.0',
exports: {
'.': { import: './index.mjs', require: './index.cjs' },
'./module': './index.mjs',
},
main: './index.cjs',
},
null,
2,
),
'utf8',
)
}

await writeFile(
file,
[
"import { X } from 'x-core/module'",
"const core = require('x-core')",
"import { Y } from 'y-core/module'",
"const y = require('y-core')",
"import { Z } from 'z-core/module'",
"const z = require('z-core')",
'console.log(core, X, y, Y, z, Z)',
'',
].join('\n'),
'utf8',
)

try {
const result = runCli([
'--target',
'commonjs',
'--cwd',
temp,
'--dual-package-hazard-allowlist',
' x-core , , y-core ',
'entry.mjs',
])

assert.equal(result.status, 0)
assert.match(result.stderr, /z-core/)
assert.ok(!/x-core/.test(result.stderr))
assert.ok(!/y-core/.test(result.stderr))
} finally {
await rm(temp, { recursive: true, force: true })
}
})

test('--dual-package-hazard-allowlist parses comma-delimited list without spaces', async () => {
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-allow-csv-'))
const file = join(temp, 'entry.mjs')
const packages = ['x-core', 'y-core', 'z-core']

for (const pkg of packages) {
const pkgDir = join(temp, 'node_modules', pkg)
await mkdir(pkgDir, { recursive: true })
await writeFile(
join(pkgDir, 'package.json'),
JSON.stringify(
{
name: pkg,
version: '1.0.0',
exports: {
'.': { import: './index.mjs', require: './index.cjs' },
'./module': './index.mjs',
},
main: './index.cjs',
},
null,
2,
),
'utf8',
)
}

await writeFile(
file,
[
"import { X } from 'x-core/module'",
"const core = require('x-core')",
"import { Y } from 'y-core/module'",
"const y = require('y-core')",
"import { Z } from 'z-core/module'",
"const z = require('z-core')",
'console.log(core, X, y, Y, z, Z)',
'',
].join('\n'),
'utf8',
)

try {
const result = runCli([
'--target',
'commonjs',
'--cwd',
temp,
'--dual-package-hazard-allowlist',
'x-core,y-core',
'entry.mjs',
])

assert.equal(result.status, 0)
assert.match(result.stderr, /z-core/)
assert.ok(!/x-core/.test(result.stderr))
assert.ok(!/y-core/.test(result.stderr))
} finally {
await rm(temp, { recursive: true, force: true })
}
})

test('--dual-package-hazard-scope project aggregates across files', async () => {
const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-project-'))
const fileImport = join(temp, 'entry.mjs')
Expand Down
Loading