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: 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 sources use globals-only transformation before `tsc` to keep declaration emit 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,8 @@ import {
readExportsConfig,
processDiagnosticsForFile,
exitOnDiagnostics,
hazardPackageFromMessage,
filterDualPackageDiagnostics,
maybeLinkNodeModules,
runExportsValidationBlock,
createTempCleanup,
Expand All @@ -40,10 +42,16 @@ 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 pkg = hazardPackageFromMessage(diag.message)

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
29 changes: 29 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,25 @@ const init = async args => {
return false
}

const hazardAllowlistProvided = dualPackageHazardAllowlist !== undefined
const hazardAllowlistRaw = dualPackageHazardAllowlist ?? ''
const hasAllowlistContent = /[^,\s]/.test(hazardAllowlistRaw)

if (hazardAllowlistProvided && !hasAllowlistContent) {
logError(
'--dual-package-hazard-allowlist expects a comma-separated list of package names',
)

return false
}

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

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

Expand Down Expand Up @@ -309,6 +337,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