diff --git a/README.md b/README.md index a84b633..0c4652e 100644 --- a/README.md +++ b/README.md @@ -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' @@ -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`. diff --git a/package-lock.json b/package-lock.json index 556ee5e..03445ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/module", - "version": "1.5.0-rc.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.5.0-rc.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "glob": "^13.0.0", diff --git a/package.json b/package.json index cbbfc38..b03d035 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index 155c967..be9aae0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,6 +35,7 @@ const defaultOptions: ModuleOptions = { detectCircularRequires: 'off', detectDualPackageHazard: 'warn', dualPackageHazardScope: 'file', + dualPackageHazardAllowlist: [], requireSource: 'builtin', nestedRequireStrategy: 'create-require', cjsDefault: 'auto', @@ -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', @@ -351,7 +358,6 @@ const buildHelp = (enableColor: boolean) => { return `${lines.join('\n')}\n` } - const parseEnum = ( value: string | undefined, allowed: readonly T[], @@ -359,7 +365,6 @@ const parseEnum = ( if (value === undefined) return undefined return allowed.includes(value as T) ? (value as T) : undefined } - const parseTransformSyntax = ( value: string | undefined, ): ModuleOptions['transformSyntax'] => { @@ -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) ?? @@ -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, @@ -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, diff --git a/src/format.ts b/src/format.ts index b01303b..55eb560 100644 --- a/src/format.ts +++ b/src/format.ts @@ -180,6 +180,12 @@ const describeDualPackage = (pkgJson: any) => { return { hasHazardSignals, details, importTarget, requireTarget } } +const normalizeAllowlist = (allowlist?: Iterable) => { + return new Set( + [...(allowlist ?? [])].map(item => item.trim()).filter(item => item.length > 0), + ) +} + type HazardLevel = 'warning' | 'error' export type PackageUse = { @@ -323,12 +329,16 @@ const dualPackageHazardDiagnostics = async (params: { filePath?: string cwd?: string manifestCache?: Map + hazardAllowlist?: Iterable }) => { const { usages, hazardLevel, filePath, cwd } = params const manifestCache = params.manifestCache ?? new Map() + 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] @@ -402,6 +412,7 @@ const detectDualPackageHazards = async (params: { message: string, loc?: { start: number; end: number }, ) => void + hazardAllowlist?: Iterable }) => { const { program, shadowedBindings, hazardLevel, filePath, cwd, diagOnce } = params const manifestCache = new Map() @@ -412,6 +423,7 @@ const detectDualPackageHazards = async (params: { filePath, cwd, manifestCache, + hazardAllowlist: params.hazardAllowlist, }) for (const diag of diags) { @@ -484,6 +496,7 @@ async function format(src: string, ast: ParseResult, opts: FormatterOptions) { filePath: opts.filePath, cwd: opts.cwd, diagOnce, + hazardAllowlist: opts.dualPackageHazardAllowlist, }) } diff --git a/src/module.ts b/src/module.ts index a039326..f6deded 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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() @@ -290,6 +292,7 @@ const createDefaultOptions = (): ModuleOptions => ({ detectCircularRequires: 'off', detectDualPackageHazard: 'warn', dualPackageHazardScope: 'file', + dualPackageHazardAllowlist: [], requireSource: 'builtin', nestedRequireStrategy: 'create-require', cjsDefault: 'auto', diff --git a/src/types.ts b/src/types.ts index 2891088..d68311e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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. */ diff --git a/test/cli.ts b/test/cli.ts index 700eacf..f3e6b94 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -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') diff --git a/test/module.ts b/test/module.ts index 688b1a1..e7dc277 100644 --- a/test/module.ts +++ b/test/module.ts @@ -117,6 +117,117 @@ describe('@knighted/module', () => { assert.ok(diagnostics.every(d => d.level === 'warning')) }) + it('suppresses dual package hazards via allowlist', async t => { + const temp = await mkdtemp(join(tmpdir(), 'module-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', + ) + + t.after(() => rm(temp, { recursive: true, force: true })) + + const diagnostics: any[] = [] + await transform(file, { + target: 'commonjs', + detectDualPackageHazard: 'warn', + dualPackageHazardAllowlist: [' x-core '], + diagnostics: diag => diagnostics.push(diag), + cwd: temp, + }) + + assert.equal(diagnostics.length, 0) + }) + + it('supports multi-package allowlists and ignores empty entries', async t => { + const temp = await mkdtemp(join(tmpdir(), 'module-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', + ) + + t.after(() => rm(temp, { recursive: true, force: true })) + + const diagnostics: any[] = [] + await transform(file, { + target: 'commonjs', + detectDualPackageHazard: 'warn', + dualPackageHazardAllowlist: [' x-core ', '', 'y-core', ' '], + diagnostics: diag => diagnostics.push(diag), + cwd: temp, + }) + + assert.ok( + diagnostics.length > 0, + 'expected remaining hazards for non-allowlisted pkg', + ) + assert.ok(diagnostics.every(d => /z-core/.test(d.message))) + const codes = new Set(diagnostics.map(d => d.code)) + assert.ok(codes.has('dual-package-mixed-specifiers')) + assert.ok(codes.has('dual-package-conditional-exports')) + }) + it('warns on hazard across export forms and dynamic import', async t => { const temp = await mkdtemp(join(tmpdir(), 'module-dual-hazard-exports-')) const file = join(temp, 'entry.mjs')