diff --git a/README.md b/README.md index cc5af32..dd8f015 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ type ModuleOptions = { requireMainStrategy?: 'import-meta-main' | 'realpath' detectCircularRequires?: 'off' | 'warn' | 'error' detectDualPackageHazard?: 'off' | 'warn' | 'error' + dualPackageHazardScope?: 'file' | 'project' requireSource?: 'builtin' | 'create-require' importMetaPrelude?: 'off' | 'auto' | 'on' cjsDefault?: 'module-exports' | 'auto' | 'none' @@ -156,7 +157,8 @@ type ModuleOptions = { - `requireMainStrategy` (`import-meta-main`): use `import.meta.main` or the realpath-based `pathToFileURL(realpathSync(process.argv[1])).href` check. - `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. - `detectCircularRequires` (`off`): optionally detect relative static require cycles and warn/throw. -- `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. +- `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. - `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output. - `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/docs/cli.md b/docs/cli.md index c961ccb..7f57fb0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -70,6 +70,7 @@ Short and long forms are supported. | -i | --append-directory-index | Append directory index (e.g. index.js) or false | | -c | --detect-circular-requires | Warn/error on circular require (off \| warn \| error) | | -H | --detect-dual-package-hazard | Warn/error on mixed import/require of dual packages (off \| warn \| error) | +| | --dual-package-hazard-scope | Scope for dual package hazard detection (file \| project) | | -a | --top-level-await | TLA handling (error \| wrap \| preserve) | | -d | --cjs-default | Default interop (module-exports \| auto \| none) | | -e | --idiomatic-exports | Emit idiomatic exports when safe (off \| safe \| aggressive) | diff --git a/docs/dual-package-hazard.md b/docs/dual-package-hazard.md index 454250b..09205e3 100644 --- a/docs/dual-package-hazard.md +++ b/docs/dual-package-hazard.md @@ -9,6 +9,10 @@ This tool can warn or error when a file mixes specifiers that may trigger the du - `warn`: emit diagnostics but continue. - `error`: diagnostics are emitted and the transform exits non-zero. - `off`: skip detection. +- `dualPackageHazardScope`: `file` (default) | `project` + - CLI: `--dual-package-hazard-scope` (long-only). + - `file`: run detection independently per file (legacy behavior). + - `project`: aggregate usages across all CLI inputs, then emit one diagnostic set per package. Per-file detection is disabled when this is on. ## What we detect (per file) @@ -28,11 +32,17 @@ This tool can warn or error when a file mixes specifiers that may trigger the du ## What is not covered -- Cross-file or whole-project graph analysis; detection is per file only. +- Cross-file or whole-project graph analysis unless `dualPackageHazardScope: 'project'` is enabled. - Dynamic or template specifiers; non-literal specifiers are ignored. - Loader/bundler resolution differences (pnpm linking, aliases, custom conditions). - 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. +## Project-wide analysis (opt-in) + +- Set `--dual-package-hazard-scope project` (CLI) or `dualPackageHazardScope: 'project'` (API). +- The CLI pre-scans all input files, aggregates package usage (import vs require, root vs subpath), and emits diagnostics per package. Per-file hazard checks are turned off in this mode to avoid duplicate messages. +- Still uses static literal specifiers and manifest reads under `node_modules`; aliasing/path-mapping differences may not be reflected. + ## Guidance - 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. diff --git a/package-lock.json b/package-lock.json index 7ed7d8a..31a3ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/module", - "version": "1.4.0-rc.0", + "version": "1.4.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.4.0-rc.0", + "version": "1.4.0-rc.1", "license": "MIT", "dependencies": { "glob": "^13.0.0", diff --git a/package.json b/package.json index 96882c8..1b7056e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/module", - "version": "1.4.0-rc.0", + "version": "1.4.0-rc.1", "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 d318b6c..cf007df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,7 @@ import { dirname, resolve, relative, join } from 'node:path' import { builtinModules } from 'node:module' import { glob } from 'glob' -import { transform } from './module.js' +import { transform, collectProjectDualPackageHazards } from './module.js' import { parse } from './parse.js' import { format } from './format.js' import { specifier } from './specifier.js' @@ -31,6 +31,7 @@ const defaultOptions: ModuleOptions = { requireMainStrategy: 'import-meta-main', detectCircularRequires: 'off', detectDualPackageHazard: 'warn', + dualPackageHazardScope: 'file', requireSource: 'builtin', nestedRequireStrategy: 'create-require', cjsDefault: 'auto', @@ -218,6 +219,12 @@ const optionsTable = [ type: 'string', desc: 'Warn/error on mixed import/require of dual packages (off|warn|error)', }, + { + long: 'dual-package-hazard-scope', + short: undefined, + type: 'string', + desc: 'Scope for dual package hazard detection (file|project)', + }, { long: 'top-level-await', short: 'a', @@ -315,7 +322,9 @@ type ParsedValues = Parsed['values'] const buildHelp = (enableColor: boolean) => { const c = colorize(enableColor) const maxFlagLength = Math.max( - ...optionsTable.map(opt => ` -${opt.short}, --${opt.long}`.length), + ...optionsTable.map(opt => + opt.short ? ` -${opt.short}, --${opt.long}`.length : ` --${opt.long}`.length, + ), ) const lines = [ `${c.bold('Usage:')} dub [options] `, @@ -329,7 +338,7 @@ const buildHelp = (enableColor: boolean) => { ] for (const opt of optionsTable) { - const flag = ` -${opt.short}, --${opt.long}` + const flag = opt.short ? ` -${opt.short}, --${opt.long}` : ` --${opt.long}` const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2)) lines.push(`${c.bold(flag)}${pad}${opt.desc}`) } @@ -394,6 +403,11 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => { values['detect-dual-package-hazard'] as string | undefined, ['off', 'warn', 'error'] as const, ) ?? defaultOptions.detectDualPackageHazard, + dualPackageHazardScope: + parseEnum( + values['dual-package-hazard-scope'] as string | undefined, + ['file', 'project'] as const, + ) ?? defaultOptions.dualPackageHazardScope, topLevelAwait: parseEnum( values['top-level-await'] as string | undefined, @@ -555,6 +569,12 @@ const runFiles = async ( ) => { const results: FileResult[] = [] const logger = makeLogger(io.stdout, io.stderr) + const hazardScope = moduleOpts.dualPackageHazardScope ?? 'file' + const hazardMode = moduleOpts.detectDualPackageHazard ?? 'warn' + const projectHazards = + hazardScope === 'project' && hazardMode !== 'off' + ? await collectProjectDualPackageHazards(files, moduleOpts) + : null for (const file of files) { const diagnostics: Diagnostic[] = [] @@ -568,6 +588,8 @@ const runFiles = async ( out: undefined, inPlace: false, filePath: file, + detectDualPackageHazard: + hazardScope === 'project' ? 'off' : moduleOpts.detectDualPackageHazard, } let writeTarget: string | undefined @@ -587,6 +609,11 @@ const runFiles = async ( const output = await transform(file, perFileOpts) const changed = output !== original + if (projectHazards) { + const extras = projectHazards.get(file) + if (extras?.length) diagnostics.push(...extras) + } + if (flags.list && changed) { logger.info(file) } @@ -631,7 +658,10 @@ const runCli = async ({ options: Object.fromEntries( optionsTable.map(opt => [ opt.long, - { type: opt.type as 'string' | 'boolean', short: opt.short }, + { + type: opt.type as 'string' | 'boolean', + ...(opt.short ? { short: opt.short } : {}), + }, ]), ), }) diff --git a/src/format.ts b/src/format.ts index 65a9da7..0802f42 100644 --- a/src/format.ts +++ b/src/format.ts @@ -192,46 +192,40 @@ const describeDualPackage = (pkgJson: any) => { type HazardLevel = 'warning' | 'error' -type PackageUse = { +export type PackageUse = { spec: string subpath: string loc?: { start: number; end: number } + filePath?: string } -type PackageUsage = { +export type PackageUsage = { imports: PackageUse[] requires: PackageUse[] } -const detectDualPackageHazards = async (params: { - program: Node - shadowedBindings: Set - hazardLevel: HazardLevel - filePath?: string - cwd?: string - diagOnce: ( - level: HazardLevel, - codeId: string, - message: string, - loc?: { start: number; end: number }, - ) => void -}) => { - const { program, shadowedBindings, hazardLevel, filePath, cwd, diagOnce } = params - const usages = new Map() - const manifestCache = new Map() +const recordUsage = ( + usages: Map, + pkg: string, + kind: 'import' | 'require', + spec: string, + subpath: string, + loc?: { start: number; end: number }, + filePath?: string, +) => { + const existing = usages.get(pkg) ?? { imports: [], requires: [] } + const bucket = kind === 'import' ? existing.imports : existing.requires - const record = ( - pkg: string, - kind: 'import' | 'require', - spec: string, - subpath: string, - loc?: { start: number; end: number }, - ) => { - const existing = usages.get(pkg) ?? { imports: [], requires: [] } - const bucket = kind === 'import' ? existing.imports : existing.requires - bucket.push({ spec, subpath, loc }) - usages.set(pkg, existing) - } + bucket.push({ spec, subpath, loc, filePath }) + usages.set(pkg, existing) +} + +const collectDualPackageUsage = async ( + program: Node, + shadowedBindings: Set, + filePath?: string, +) => { + const usages = new Map() await ancestorWalk(program, { enter(node) { @@ -242,10 +236,15 @@ const detectDualPackageHazards = async (params: { ) { const pkg = packageFromSpecifier(node.source.value) if (pkg) - record(pkg.pkg, 'import', node.source.value, pkg.subpath, { - start: node.source.start, - end: node.source.end, - }) + recordUsage( + usages, + pkg.pkg, + 'import', + node.source.value, + pkg.subpath, + { start: node.source.start, end: node.source.end }, + filePath, + ) } if ( @@ -256,10 +255,15 @@ const detectDualPackageHazards = async (params: { ) { const pkg = packageFromSpecifier(node.source.value) if (pkg) - record(pkg.pkg, 'import', node.source.value, pkg.subpath, { - start: node.source.start, - end: node.source.end, - }) + recordUsage( + usages, + pkg.pkg, + 'import', + node.source.value, + pkg.subpath, + { start: node.source.start, end: node.source.end }, + filePath, + ) } if ( @@ -269,10 +273,15 @@ const detectDualPackageHazards = async (params: { ) { const pkg = packageFromSpecifier(node.source.value) if (pkg) - record(pkg.pkg, 'import', node.source.value, pkg.subpath, { - start: node.source.start, - end: node.source.end, - }) + recordUsage( + usages, + pkg.pkg, + 'import', + node.source.value, + pkg.subpath, + { start: node.source.start, end: node.source.end }, + filePath, + ) } if ( @@ -282,10 +291,15 @@ const detectDualPackageHazards = async (params: { ) { const pkg = packageFromSpecifier(node.source.value) if (pkg) - record(pkg.pkg, 'import', node.source.value, pkg.subpath, { - start: node.source.start, - end: node.source.end, - }) + recordUsage( + usages, + pkg.pkg, + 'import', + node.source.value, + pkg.subpath, + { start: node.source.start, end: node.source.end }, + filePath, + ) } if (node.type === 'CallExpression' && isStaticRequire(node, shadowedBindings)) { @@ -293,21 +307,45 @@ const detectDualPackageHazards = async (params: { if (arg?.type === 'Literal' && typeof arg.value === 'string') { const pkg = packageFromSpecifier(arg.value) if (pkg) - record(pkg.pkg, 'require', arg.value, pkg.subpath, { - start: arg.start, - end: arg.end, - }) + recordUsage( + usages, + pkg.pkg, + 'require', + arg.value, + pkg.subpath, + { + start: arg.start, + end: arg.end, + }, + filePath, + ) } } }, }) + return usages +} + +const dualPackageHazardDiagnostics = async (params: { + usages: Map + hazardLevel: HazardLevel + filePath?: string + cwd?: string + manifestCache?: Map +}) => { + const { usages, hazardLevel, filePath, cwd } = params + const manifestCache = params.manifestCache ?? new Map() + const diags: Diagnostic[] = [] + for (const [pkg, usage] of usages) { const hasImport = usage.imports.length > 0 const hasRequire = usage.requires.length > 0 const combined = [...usage.imports, ...usage.requires] const hasRoot = combined.some(entry => !entry.subpath) const hasSubpath = combined.some(entry => Boolean(entry.subpath)) + const origin = usage.imports[0] ?? usage.requires[0] + const diagFile = origin?.filePath ?? filePath if (hasImport && hasRequire) { const importSpecs = usage.imports.map(u => @@ -317,42 +355,77 @@ const detectDualPackageHazards = async (params: { u.subpath ? `${pkg}/${u.subpath}` : pkg, ) - diagOnce( - hazardLevel, - 'dual-package-mixed-specifiers', - `Package '${pkg}' is loaded via import (${importSpecs.join(', ')}) and require (${requireSpecs.join(', ')}); conditional exports can instantiate it twice.`, - usage.imports[0]?.loc ?? usage.requires[0]?.loc, - ) + diags.push({ + level: hazardLevel, + code: 'dual-package-mixed-specifiers', + message: `Package '${pkg}' is loaded via import (${importSpecs.join(', ')}) and require (${requireSpecs.join(', ')}); conditional exports can instantiate it twice.`, + filePath: diagFile, + loc: origin?.loc, + }) } if (hasRoot && hasSubpath) { const subpaths = combined .filter(entry => entry.subpath) .map(entry => `${pkg}/${entry.subpath}`) - diagOnce( - hazardLevel, - 'dual-package-subpath', - `Package '${pkg}' is referenced via root specifier '${pkg}' and subpath(s) ${subpaths.join(', ')}; mixing them loads separate module instances.`, - combined.find(entry => entry.subpath)?.loc ?? combined[0]?.loc, - ) + const originSubpath = combined.find(entry => entry.subpath) ?? combined[0] + diags.push({ + level: hazardLevel, + code: 'dual-package-subpath', + message: `Package '${pkg}' is referenced via root specifier '${pkg}' and subpath(s) ${subpaths.join(', ')}; mixing them loads separate module instances.`, + filePath: originSubpath?.filePath ?? filePath, + loc: originSubpath?.loc, + }) } if (hasImport && hasRequire) { - const manifest = await readPackageManifest(pkg, filePath, cwd, manifestCache) + const manifest = await readPackageManifest(pkg, diagFile, cwd, manifestCache) if (manifest) { const meta = describeDualPackage(manifest) if (meta.hasHazardSignals) { const detail = meta.details.length ? ` (${meta.details.join('; ')})` : '' - diagOnce( - hazardLevel, - 'dual-package-conditional-exports', - `Package '${pkg}' exposes different entry points for import vs require${detail}. Mixed usage can produce distinct instances.`, - usage.imports[0]?.loc ?? usage.requires[0]?.loc, - ) + diags.push({ + level: hazardLevel, + code: 'dual-package-conditional-exports', + message: `Package '${pkg}' exposes different entry points for import vs require${detail}. Mixed usage can produce distinct instances.`, + filePath: diagFile, + loc: origin?.loc, + }) } } } } + + return diags +} + +const detectDualPackageHazards = async (params: { + program: Node + shadowedBindings: Set + hazardLevel: HazardLevel + filePath?: string + cwd?: string + diagOnce: ( + level: HazardLevel, + codeId: string, + message: string, + loc?: { start: number; end: number }, + ) => void +}) => { + const { program, shadowedBindings, hazardLevel, filePath, cwd, diagOnce } = params + const manifestCache = new Map() + const usages = await collectDualPackageUsage(program, shadowedBindings, filePath) + const diags = await dualPackageHazardDiagnostics({ + usages, + hazardLevel, + filePath, + cwd, + manifestCache, + }) + + for (const diag of diags) { + diagOnce(diag.level, diag.code, diag.message, diag.loc) + } } /** @@ -657,4 +730,4 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => return code.toString() } -export { format } +export { format, collectDualPackageUsage, dualPackageHazardDiagnostics } diff --git a/src/module.ts b/src/module.ts index 22f07b1..3328e04 100644 --- a/src/module.ts +++ b/src/module.ts @@ -6,14 +6,20 @@ import type { Spec } from './specifier.js' import type { TemplateLiteral } from 'oxc-parser' import { parse } from './parse.js' -import { format } from './format.js' +import { + format, + collectDualPackageUsage, + dualPackageHazardDiagnostics, + type PackageUsage, +} from './format.js' import { getLangFromExt } from './utils/lang.js' -import type { ModuleOptions } from './types.js' +import type { ModuleOptions, Diagnostic } from './types.js' import { builtinModules } from 'node:module' import { resolve as pathResolve, dirname as pathDirname, extname, join } from 'node:path' import { readFile as fsReadFile, stat } from 'node:fs/promises' import { parse as parseModule } from './parse.js' import { walk } from './walk.js' +import { collectModuleIdentifiers } from './utils/identifiers.js' type AppendJsExtensionMode = NonNullable type DetectCircularRequires = NonNullable @@ -207,6 +213,64 @@ const detectCircularRequireGraph = async ( await dfs(entryFile, []) } +const mergeUsageMaps = ( + target: Map, + source: Map, +) => { + for (const [pkg, usage] of source) { + const existing = target.get(pkg) ?? { imports: [], requires: [] } + existing.imports.push(...usage.imports) + existing.requires.push(...usage.requires) + target.set(pkg, existing) + } +} + +const collectProjectDualPackageHazards = async (files: string[], opts: ModuleOptions) => { + const hazardMode = opts.detectDualPackageHazard ?? 'warn' + + if (hazardMode === 'off') return new Map() + + const hazardLevel = hazardMode === 'error' ? 'error' : 'warning' + const usages = new Map() + const manifestCache = new Map() + + for (const file of files) { + const code = await readFile(file, 'utf8') + const ast = parseModule(file, code) + const moduleIdentifiers = await collectModuleIdentifiers(ast.program) + const shadowedBindings = new Set( + [...moduleIdentifiers.entries()] + .filter(([, meta]) => meta.declare.length > 0) + .map(([name]) => name), + ) + const perFileUsage = await collectDualPackageUsage( + ast.program, + shadowedBindings, + file, + ) + + mergeUsageMaps(usages, perFileUsage) + } + + const diags = await dualPackageHazardDiagnostics({ + usages, + hazardLevel, + cwd: opts.cwd, + manifestCache, + }) + const byFile = new Map() + + for (const diag of diags) { + const key = diag.filePath ?? files[0] + const existing = byFile.get(key) ?? [] + + existing.push(diag) + byFile.set(key, existing) + } + + return byFile +} + const defaultOptions = { target: 'commonjs', sourceType: 'auto', @@ -221,6 +285,7 @@ const defaultOptions = { requireMainStrategy: 'import-meta-main', detectCircularRequires: 'off', detectDualPackageHazard: 'warn', + dualPackageHazardScope: 'file', requireSource: 'builtin', nestedRequireStrategy: 'create-require', cjsDefault: 'auto', @@ -277,4 +342,4 @@ const transform = async (filename: string, options: ModuleOptions = defaultOptio return source } -export { transform } +export { transform, collectProjectDualPackageHazards } diff --git a/src/types.ts b/src/types.ts index fc778a9..5dd8493 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,8 @@ export type ModuleOptions = { detectCircularRequires?: 'off' | 'warn' | 'error' /** Detect divergent import/require usage of the same dual package (default warn). */ detectDualPackageHazard?: 'off' | 'warn' | 'error' + /** Scope for dual package hazard detection. */ + dualPackageHazardScope?: 'file' | 'project' /** 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 3bd532a..20b2216 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -174,6 +174,187 @@ test('-H error exits on dual package hazard', async () => { } }) +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') + const fileRequire = join(temp, 'entry.cjs') + 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' }, + }, + main: './x-core.cjs', + }, + null, + 2, + ), + 'utf8', + ) + await writeFile(fileImport, "import 'x-core'\n", 'utf8') + await writeFile(fileRequire, "require('x-core')\n", 'utf8') + + try { + const result = runCli([ + '-H', + 'error', + '--dual-package-hazard-scope', + 'project', + '--target', + 'commonjs', + '--cwd', + temp, + '--dry-run', + 'entry.mjs', + 'entry.cjs', + ]) + + assert.equal(result.status, 1) + assert.match(result.stderr, /dual-package-mixed-specifiers/) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('--dual-package-hazard-scope project emits subpath hazard once', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-subpath-')) + const fileRoot = join(temp, 'root.mjs') + const fileSub = join(temp, 'sub.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', main: './index.cjs' }, null, 2), + 'utf8', + ) + await writeFile(fileRoot, "import 'x-core'\n", 'utf8') + await writeFile(fileSub, "import 'x-core/utils'\n", 'utf8') + + try { + const result = runCli([ + '-H', + 'error', + '--dual-package-hazard-scope', + 'project', + '--target', + 'commonjs', + '--cwd', + temp, + '--dry-run', + 'root.mjs', + 'sub.mjs', + ]) + + assert.equal(result.status, 1) + const count = (result.stderr.match(/dual-package-subpath/g) || []).length + assert.equal(count, 1) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('--dual-package-hazard-scope project surfaces conditional-exports', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-conditional-')) + const fileImport = join(temp, 'entry.mjs') + const fileRequire = join(temp, 'entry.cjs') + 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.2.3', + exports: { '.': { import: './x-core.mjs', require: './x-core.cjs' } }, + main: './x-core.cjs', + }, + null, + 2, + ), + 'utf8', + ) + await writeFile(fileImport, "import 'x-core'\n", 'utf8') + await writeFile(fileRequire, "require('x-core')\n", 'utf8') + + try { + const result = runCli([ + '--dual-package-hazard-scope', + 'project', + '--target', + 'commonjs', + '--cwd', + temp, + '--dry-run', + 'entry.mjs', + 'entry.cjs', + ]) + + assert.equal(result.status, 0) + assert.match(result.stderr, /dual-package-mixed-specifiers/) + assert.match(result.stderr, /dual-package-conditional-exports/) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('--dual-package-hazard-scope project emits JSON diagnostics', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-dual-hazard-json-')) + const fileImport = join(temp, 'entry.mjs') + const fileRequire = join(temp, 'entry.cjs') + 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' } }, + main: './x-core.cjs', + }, + null, + 2, + ), + 'utf8', + ) + await writeFile(fileImport, "import 'x-core'\n", 'utf8') + await writeFile(fileRequire, "require('x-core')\n", 'utf8') + + try { + const result = runCli([ + '--dual-package-hazard-scope', + 'project', + '--target', + 'commonjs', + '--cwd', + temp, + '--dry-run', + '--json', + 'entry.mjs', + 'entry.cjs', + ]) + + assert.equal(result.status, 0) + const payload = JSON.parse(result.stdout) + const codes = (payload.files ?? []) + .flatMap((f: any) => f.diagnostics ?? []) + .map((d: any) => d.code) + assert.ok(codes.includes('dual-package-mixed-specifiers')) + assert.ok(codes.includes('dual-package-conditional-exports')) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + test('rewrites __dirname for ESM TS projects (NodeNext)', async () => { const temp = await mkdtemp(join(tmpdir(), 'module-cli-ts-node-next-')) const srcDir = join(temp, 'src')