Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 0 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ jobs:
path: npm-debug.log
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Pack
run: npm pack
- name: Push to NPM registry
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Assuming an `outDir` of `dist`, running the above will create `dist/esm` and `di
`tsc` is asymmetric: `import.meta` globals fail in a CJS-targeted build, but CommonJS globals like `__filename`/`__dirname` pass when targeting ESM, causing runtime errors in the compiled output. See [TypeScript#58658](https://github.com/microsoft/TypeScript/issues/58658). Use `--mode` to mitigate:

- `--mode globals` [rewrites module globals](https://github.com/knightedcodemonkey/module/blob/main/docs/globals-only.md#rewrites-at-a-glance).
- `--mode full` adds syntax lowering _in addition to_ the globals rewrite. TS sources keep a pre-`tsc` guard (`transformSyntax: "globals-only"`) so TypeScript controls declaration emit; JS/JSX and the dual CJS rewrite path are fully lowered. See the [mode matrix](docs/mode-matrix.md) for details.
- `--mode full` adds syntax lowering _in addition to_ the globals rewrite. TS goes through globals-only pre-processing before `tsc` (so declaration emit stays correct), while JS/JSX and the dual CJS rewrite path are fully lowered. See the [mode matrix](docs/mode-matrix.md) for details.

```json
"scripts": {
Expand All @@ -111,6 +111,7 @@ When `--mode` is enabled, `duel` copies sources and runs [`@knighted/module`](ht
Mixed `import`/`require` of the same dual package (especially when conditional exports differ) can create two module instances. `duel` exposes the detector from `@knighted/module`:

- `--detect-dual-package-hazard [off|warn|error]` (default `warn`): emit diagnostics; `error` exits non-zero.
- `--dual-package-hazard-allowlist <pkg>[,<pkg>...]`: comma-separated packages to ignore for hazard reporting (e.g., `react`).
- `--dual-package-hazard-scope [file|project]` (default `file`): per-file checks or a project-wide pre-pass that aggregates package usage across all compiled sources before building.

Project scope is helpful in monorepos or hoisted installs where hazards surface only when looking across files.
Expand All @@ -128,7 +129,8 @@ These are the CLI options `duel` supports to work alongside your project's `tsco
- `--exports-validate` Dry-run exports generation/validation without writing package.json; combine with `--exports` or `--exports-config` to emit after validation.
- `--rewrite-policy [safe|warn|skip]` Control how specifier rewrites behave when a matching target is missing (`safe` warns and skips, `warn` rewrites and warns, `skip` leaves specifiers untouched).
- `--validate-specifiers` Validate that rewritten specifiers resolve to outputs; defaults to `true` when `--rewrite-policy` is `safe`.
- `--detect-dual-package-hazard [off|warn|error]` Flag mixed import/require usage of dual packages; `error` exits non-zero.
- `--detect-dual-package-hazard, -H [off|warn|error]` Flag mixed import/require usage of dual packages; `error` exits non-zero. If project-scope checks lack file paths, or file-scope checks return pathless diagnostics, Duel falls back to file-scope reporting during transforms so diagnostics include locations.
- `--dual-package-hazard-allowlist <pkg>[,<pkg>...]` Comma-separated packages to ignore when reporting dual package hazards (e.g., `react`).
- `--dual-package-hazard-scope [file|project]` Run hazard checks per file (default) or aggregate across the project.
- `--copy-mode [sources|full]` Temp copy strategy. `sources` (default) copies only files participating in the build (plus configs); `full` mirrors the previous whole-project copy.
- `--verbose, -V` Verbose logging.
Expand Down
3 changes: 2 additions & 1 deletion docs/v4-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ This guide highlights behavior changes introduced in v4 and how to adapt existin
- **IMPORTANT:** The temp-copy flow adds some I/O for large repos (copying sources/reference packages and running transforms there). `node_modules` is skipped; when references exist, existing `dist` may be reused. Very large projects may see modestly slower runs compared to the old in-place mutation.
- **Cache/shadow location is project-local.** `.duel-cache` now lives under the project root (e.g., `<project>/.duel-cache`) instead of the parent directory to avoid “filesystem invasion.” Temp shadow workspaces and tsbuildinfo cache files stay inside that folder. Add `.duel-cache/` to your `.gitignore`.
- **Project references run with `tsc -b`.** When `tsconfig.json` contains references, builds switch to TypeScript build mode. Output shape can differ from `tsc -p` for some setups.
- **Referenced configs must be patchable.** Duel now fails fast if a referenced `tsconfig` lives outside the project/parent root or cannot be parsed in the temp workspace. Move references inside the repo and fix invalid configs so both primary and dual builds stay isolated.
- **Referenced configs must be patchable.** Duel now fails fast if a referenced `tsconfig` lives outside the allowed workspace boundary (package root, packages root, or repo root, excluding `node_modules`) or cannot be parsed in the temp workspace. Move references inside the repo and fix invalid configs so both primary and dual builds stay isolated.
- **Dual CJS builds enforce CJS semantics.** The shadow workspace now uses `type: "commonjs"` plus `module: "NodeNext"` for the dual build, so TypeScript will error on CJS-incompatible syntax like `import.meta` unless you adjust code or opt into `--mode globals`/`--mode full` (v3 previously allowed this to slip through).
- **Exports tooling additions.** New flags (`--exports-config`, `--exports-validate`) are available; when used, they can emit warnings or fail on invalid configs.
- **Deprecated flags removed.** `--modules`, `--transform-syntax`, and `--target-extension` are gone; use `--mode globals` or `--mode full` instead.
- **Copy strategy defaults to sources.** `--copy-mode sources` is the default (minimal temp copy of inputs/configs). Use `--copy-mode full` to mirror the entire project like v3.

## Restoring v3-like Behavior

Expand Down
24 changes: 12 additions & 12 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/duel",
"version": "4.0.0-rc.5",
"version": "4.0.0-rc.6",
"description": "TypeScript dual packages.",
"type": "module",
"main": "dist/esm/duel.js",
Expand Down Expand Up @@ -89,7 +89,7 @@
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.13",
"@jridgewell/trace-mapping": "^0.3.31",
"@knighted/module": "^1.5.0-rc.0",
"@knighted/module": "^1.5.0",
"find-up": "^8.0.0",
"get-tsconfig": "^4.13.0",
"glob": "^13.0.0",
Expand Down
94 changes: 84 additions & 10 deletions src/duel.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
readExportsConfig,
processDiagnosticsForFile,
exitOnDiagnostics,
filterDualPackageDiagnostics,
maybeLinkNodeModules,
runExportsValidationBlock,
createTempCleanup,
Expand All @@ -40,10 +41,17 @@ const handleErrorAndExit = message => {
process.exit(exitCode)
}

const logDiagnostics = (diags, projectDir) => {
const logDiagnostics = (diags, projectDir, hazardAllowlist = null) => {
let hasError = false

for (const diag of diags) {
if (hazardAllowlist && diag?.code?.startsWith('dual-package') && diag?.message) {
const match = /Package '([^']+)'/.exec(diag.message)
const pkg = match?.[1]

if (pkg && hazardAllowlist.has(pkg)) continue
}

const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : ''
const rel = diag.filePath ? `${relative(projectDir, diag.filePath)}` : ''
const location = rel ? `${rel}: ` : ''
Expand Down Expand Up @@ -78,6 +86,7 @@ const duel = async args => {
rewritePolicy,
validateSpecifiers,
detectDualPackageHazard,
dualPackageHazardAllowlist,
dualPackageHazardScope,
verbose,
copyMode,
Expand Down Expand Up @@ -227,6 +236,13 @@ const duel = async args => {
const shadowDualOutDir = join(subDir, requireWorkspaceRelative(absoluteDualOutDir))
const hazardMode = detectDualPackageHazard ?? 'warn'
const hazardScope = dualPackageHazardScope ?? 'file'
const hazardAllowlist = new Set(
(dualPackageHazardAllowlist ?? []).map(entry => entry.trim()).filter(Boolean),
)
const logDiagnosticsWithAllowlist = diags =>
logDiagnostics(diags, projectDir, hazardAllowlist)
const applyHazardAllowlist = diagnostics =>
filterDualPackageDiagnostics(diagnostics ?? [], hazardAllowlist)
function mapReferencesToShadow(references = [], options) {
const { resolveRefPath, toShadowPathFn, fromDir } = options

Expand Down Expand Up @@ -506,13 +522,29 @@ const duel = async args => {
cwd: projectDir,
})
: null
const filteredProjectHazards = projectHazards
? new Map(
[...projectHazards.entries()].map(([key, diags]) => [
key,
applyHazardAllowlist(diags ?? []),
]),
)
: null
const projectHazardsHaveDiagnostics = filteredProjectHazards
? [...filteredProjectHazards.values()].some(diags => diags?.length)
: false
const projectHazardsHaveLocations = filteredProjectHazards
? [...filteredProjectHazards.values()].some(diags =>
diags?.some(diag => diag?.filePath),
)
: false

if (projectHazards) {
if (filteredProjectHazards) {
let hasHazardError = false

for (const diags of projectHazards.values()) {
for (const diags of filteredProjectHazards.values()) {
if (!diags?.length) continue
const errored = logDiagnostics(diags, projectDir)
const errored = logDiagnosticsWithAllowlist(diags)
hasHazardError = hasHazardError || errored
}

Expand Down Expand Up @@ -732,8 +764,24 @@ const duel = async args => {
ignore: `${subDir.replace(/\\/g, '/')}/**/node_modules/**`,
},
)

let transformDiagnosticsError = false
/**
* If project-scope hazards didn't surface file paths, fall back to
* file-scope detection during the transform pass so we can emit
* per-file diagnostics. Otherwise, keep project scope to avoid
* duplicate warnings.
*/
const shouldFallbackToFileScope =
hazardScope === 'project' &&
projectHazardsHaveDiagnostics &&
!projectHazardsHaveLocations
const transformHazardScope = shouldFallbackToFileScope ? 'file' : hazardScope
const transformHazardMode =
hazardScope === 'project'
? shouldFallbackToFileScope
? hazardMode
: 'off'
: hazardMode

for (const file of toTransform) {
if (file.split(/[/\\]/).includes('node_modules')) continue
Expand All @@ -746,17 +794,23 @@ const duel = async args => {
out: file,
target: isCjsBuild ? 'commonjs' : 'module',
transformSyntax: transformSyntaxMode,
// Project-level hazards are collected above; disable file-scope repeats during transform.
detectDualPackageHazard: hazardScope === 'project' ? 'off' : hazardMode,
dualPackageHazardScope: hazardScope,
detectDualPackageHazard: transformHazardMode,
dualPackageHazardScope: transformHazardScope,
dualPackageHazardAllowlist: [...hazardAllowlist],
cwd: projectDir,
diagnostics: diag => diagnostics.push(diag),
})

const normalizedDiagnostics = diagnostics.map(diag =>
!diag?.filePath && transformHazardScope === 'file'
? { ...diag, filePath: file }
: diag,
)
const filteredDiagnostics = applyHazardAllowlist(normalizedDiagnostics)
const errored = processDiagnosticsForFile(
diagnostics,
filteredDiagnostics,
projectDir,
logDiagnostics,
logDiagnosticsWithAllowlist,
)
transformDiagnosticsError = transformDiagnosticsError || errored
}
Expand Down Expand Up @@ -810,11 +864,25 @@ const duel = async args => {
},
)
const rewriteSyntaxMode = dualTarget === 'commonjs' ? true : syntaxMode
let rewriteDiagnosticsError = false
const handleRewriteDiagnostic = diag => {
const filtered = applyHazardAllowlist([diag])
const errored = processDiagnosticsForFile(
filtered,
projectDir,
logDiagnosticsWithAllowlist,
)
rewriteDiagnosticsError = rewriteDiagnosticsError || errored
}

await rewriteSpecifiersAndExtensions(filenames, {
target: dualTarget,
ext: dualTargetExt,
syntaxMode: rewriteSyntaxMode,
detectDualPackageHazard: hazardMode,
dualPackageHazardScope: hazardScope,
dualPackageHazardAllowlist: [...hazardAllowlist],
onDiagnostics: handleRewriteDiagnostic,
rewritePolicy,
validateSpecifiers,
onWarn: message => logWarn(message),
Expand All @@ -834,13 +902,19 @@ const duel = async args => {
ext: '.cjs',
// Always lower syntax for primary CJS output when dirs mode rewrites primary build.
syntaxMode: true,
detectDualPackageHazard: hazardMode,
dualPackageHazardScope: hazardScope,
dualPackageHazardAllowlist: [...hazardAllowlist],
onDiagnostics: handleRewriteDiagnostic,
rewritePolicy,
validateSpecifiers,
onWarn: message => logWarn(message),
onRewrite: (from, to) => logVerbose(`Rewrote specifiers in ${from} -> ${to}`),
})
}

exitOnDiagnostics(rewriteDiagnosticsError)

const esmRoot = isCjsBuild ? primaryOutDir : absoluteDualOutDir
const cjsRoot = isCjsBuild ? absoluteDualOutDir : primaryOutDir

Expand Down
25 changes: 25 additions & 0 deletions src/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ const cliOptions = [
value: '[off|warn|error]',
desc: 'Detect mixed import/require use of dual packages.',
},
{
long: 'dual-package-hazard-allowlist',
value: '[pkg1,pkg2]',
desc: 'Comma-separated packages to ignore for dual package hazard checks.',
},
{
long: 'dual-package-hazard-scope',
value: '[file|project]',
Expand Down Expand Up @@ -147,6 +152,9 @@ const init = async args => {
short: 'H',
default: 'warn',
},
'dual-package-hazard-allowlist': {
type: 'string',
},
'dual-package-hazard-scope': {
type: 'string',
default: 'file',
Expand Down Expand Up @@ -191,6 +199,7 @@ const init = async args => {
'rewrite-policy': rewritePolicy,
'validate-specifiers': validateSpecifiers,
'detect-dual-package-hazard': detectDualPackageHazard,
'dual-package-hazard-allowlist': dualPackageHazardAllowlist,
'dual-package-hazard-scope': dualPackageHazardScope,
verbose,
mode,
Expand Down Expand Up @@ -269,6 +278,21 @@ const init = async args => {
return false
}

const hazardAllowlist = dualPackageHazardAllowlist
? dualPackageHazardAllowlist
.split(',')
.map(item => item.trim())
.filter(Boolean)
: []

if (dualPackageHazardAllowlist && hazardAllowlist.length === 0) {
logError(
'--dual-package-hazard-allowlist expects a comma-separated list of package names',
)

return false
}

if (!['file', 'project'].includes(dualPackageHazardScope)) {
logError('--dual-package-hazard-scope expects one of: file | project')

Expand Down Expand Up @@ -309,6 +333,7 @@ const init = async args => {
rewritePolicy,
validateSpecifiers: validateSpecifiersFinal,
detectDualPackageHazard,
dualPackageHazardAllowlist: hazardAllowlist,
dualPackageHazardScope,
verbose,
copyMode,
Expand Down
8 changes: 8 additions & 0 deletions src/resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ const rewriteSpecifiersAndExtensions = async (filenames, options = {}) => {
target,
ext,
syntaxMode,
detectDualPackageHazard,
dualPackageHazardAllowlist,
dualPackageHazardScope,
onDiagnostics,
rewritePolicy = 'safe',
validateSpecifiers = false,
onRewrite = () => {},
Expand Down Expand Up @@ -221,6 +225,10 @@ const rewriteSpecifiersAndExtensions = async (filenames, options = {}) => {
rewriteSpecifier,
transformSyntax: syntaxMode,
sourceMap: true,
diagnostics: diag => onDiagnostics?.(diag, filename),
...(detectDualPackageHazard !== undefined ? { detectDualPackageHazard } : {}),
...(dualPackageHazardAllowlist !== undefined ? { dualPackageHazardAllowlist } : {}),
...(dualPackageHazardScope !== undefined ? { dualPackageHazardScope } : {}),
...(outFilename === filename ? { inPlace: true } : { out: outFilename }),
}

Expand Down
Loading
Loading