Skip to content

Commit 229b4ec

Browse files
feat: dual package hazard allow list. (#102)
1 parent ea90e58 commit 229b4ec

11 files changed

Lines changed: 317 additions & 29 deletions

File tree

.github/workflows/publish.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ jobs:
2929
path: npm-debug.log
3030
- name: Lint
3131
run: npm run lint
32-
- name: Test
33-
run: npm test
3432
- name: Pack
3533
run: npm pack
3634
- name: Push to NPM registry

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Assuming an `outDir` of `dist`, running the above will create `dist/esm` and `di
9090
`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:
9191

9292
- `--mode globals` [rewrites module globals](https://github.com/knightedcodemonkey/module/blob/main/docs/globals-only.md#rewrites-at-a-glance).
93-
- `--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.
93+
- `--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.
9494

9595
```json
9696
"scripts": {
@@ -111,6 +111,7 @@ When `--mode` is enabled, `duel` copies sources and runs [`@knighted/module`](ht
111111
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`:
112112

113113
- `--detect-dual-package-hazard [off|warn|error]` (default `warn`): emit diagnostics; `error` exits non-zero.
114+
- `--dual-package-hazard-allowlist <pkg>[,<pkg>...]`: comma-separated packages to ignore for hazard reporting (e.g., `react`).
114115
- `--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.
115116

116117
Project scope is helpful in monorepos or hoisted installs where hazards surface only when looking across files.
@@ -128,7 +129,8 @@ These are the CLI options `duel` supports to work alongside your project's `tsco
128129
- `--exports-validate` Dry-run exports generation/validation without writing package.json; combine with `--exports` or `--exports-config` to emit after validation.
129130
- `--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).
130131
- `--validate-specifiers` Validate that rewritten specifiers resolve to outputs; defaults to `true` when `--rewrite-policy` is `safe`.
131-
- `--detect-dual-package-hazard [off|warn|error]` Flag mixed import/require usage of dual packages; `error` exits non-zero.
132+
- `--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.
133+
- `--dual-package-hazard-allowlist <pkg>[,<pkg>...]` Comma-separated packages to ignore when reporting dual package hazards (e.g., `react`).
132134
- `--dual-package-hazard-scope [file|project]` Run hazard checks per file (default) or aggregate across the project.
133135
- `--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.
134136
- `--verbose, -V` Verbose logging.

docs/v4-migration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ This guide highlights behavior changes introduced in v4 and how to adapt existin
1010
- **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.
1111
- **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`.
1212
- **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.
13-
- **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.
13+
- **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.
1414
- **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).
1515
- **Exports tooling additions.** New flags (`--exports-config`, `--exports-validate`) are available; when used, they can emit warnings or fail on invalid configs.
1616
- **Deprecated flags removed.** `--modules`, `--transform-syntax`, and `--target-extension` are gone; use `--mode globals` or `--mode full` instead.
17+
- **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.
1718

1819
## Restoring v3-like Behavior
1920

package-lock.json

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/duel",
3-
"version": "4.0.0-rc.5",
3+
"version": "4.0.0-rc.6",
44
"description": "TypeScript dual packages.",
55
"type": "module",
66
"main": "dist/esm/duel.js",
@@ -89,7 +89,7 @@
8989
"dependencies": {
9090
"@jridgewell/gen-mapping": "^0.3.13",
9191
"@jridgewell/trace-mapping": "^0.3.31",
92-
"@knighted/module": "^1.5.0-rc.0",
92+
"@knighted/module": "^1.5.0",
9393
"find-up": "^8.0.0",
9494
"get-tsconfig": "^4.13.0",
9595
"glob": "^13.0.0",

src/duel.js

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
readExportsConfig,
2525
processDiagnosticsForFile,
2626
exitOnDiagnostics,
27+
hazardPackageFromMessage,
28+
filterDualPackageDiagnostics,
2729
maybeLinkNodeModules,
2830
runExportsValidationBlock,
2931
createTempCleanup,
@@ -40,10 +42,16 @@ const handleErrorAndExit = message => {
4042
process.exit(exitCode)
4143
}
4244

43-
const logDiagnostics = (diags, projectDir) => {
45+
const logDiagnostics = (diags, projectDir, hazardAllowlist = null) => {
4446
let hasError = false
4547

4648
for (const diag of diags) {
49+
if (hazardAllowlist && diag?.code?.startsWith('dual-package') && diag?.message) {
50+
const pkg = hazardPackageFromMessage(diag.message)
51+
52+
if (pkg && hazardAllowlist.has(pkg)) continue
53+
}
54+
4755
const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : ''
4856
const rel = diag.filePath ? `${relative(projectDir, diag.filePath)}` : ''
4957
const location = rel ? `${rel}: ` : ''
@@ -78,6 +86,7 @@ const duel = async args => {
7886
rewritePolicy,
7987
validateSpecifiers,
8088
detectDualPackageHazard,
89+
dualPackageHazardAllowlist,
8190
dualPackageHazardScope,
8291
verbose,
8392
copyMode,
@@ -227,6 +236,13 @@ const duel = async args => {
227236
const shadowDualOutDir = join(subDir, requireWorkspaceRelative(absoluteDualOutDir))
228237
const hazardMode = detectDualPackageHazard ?? 'warn'
229238
const hazardScope = dualPackageHazardScope ?? 'file'
239+
const hazardAllowlist = new Set(
240+
(dualPackageHazardAllowlist ?? []).map(entry => entry.trim()).filter(Boolean),
241+
)
242+
const logDiagnosticsWithAllowlist = diags =>
243+
logDiagnostics(diags, projectDir, hazardAllowlist)
244+
const applyHazardAllowlist = diagnostics =>
245+
filterDualPackageDiagnostics(diagnostics ?? [], hazardAllowlist)
230246
function mapReferencesToShadow(references = [], options) {
231247
const { resolveRefPath, toShadowPathFn, fromDir } = options
232248

@@ -506,13 +522,29 @@ const duel = async args => {
506522
cwd: projectDir,
507523
})
508524
: null
525+
const filteredProjectHazards = projectHazards
526+
? new Map(
527+
[...projectHazards.entries()].map(([key, diags]) => [
528+
key,
529+
applyHazardAllowlist(diags ?? []),
530+
]),
531+
)
532+
: null
533+
const projectHazardsHaveDiagnostics = filteredProjectHazards
534+
? [...filteredProjectHazards.values()].some(diags => diags?.length)
535+
: false
536+
const projectHazardsHaveLocations = filteredProjectHazards
537+
? [...filteredProjectHazards.values()].some(diags =>
538+
diags?.some(diag => diag?.filePath),
539+
)
540+
: false
509541

510-
if (projectHazards) {
542+
if (filteredProjectHazards) {
511543
let hasHazardError = false
512544

513-
for (const diags of projectHazards.values()) {
545+
for (const diags of filteredProjectHazards.values()) {
514546
if (!diags?.length) continue
515-
const errored = logDiagnostics(diags, projectDir)
547+
const errored = logDiagnosticsWithAllowlist(diags)
516548
hasHazardError = hasHazardError || errored
517549
}
518550

@@ -732,8 +764,24 @@ const duel = async args => {
732764
ignore: `${subDir.replace(/\\/g, '/')}/**/node_modules/**`,
733765
},
734766
)
735-
736767
let transformDiagnosticsError = false
768+
/**
769+
* If project-scope hazards didn't surface file paths, fall back to
770+
* file-scope detection during the transform pass so we can emit
771+
* per-file diagnostics. Otherwise, keep project scope to avoid
772+
* duplicate warnings.
773+
*/
774+
const shouldFallbackToFileScope =
775+
hazardScope === 'project' &&
776+
projectHazardsHaveDiagnostics &&
777+
!projectHazardsHaveLocations
778+
const transformHazardScope = shouldFallbackToFileScope ? 'file' : hazardScope
779+
const transformHazardMode =
780+
hazardScope === 'project'
781+
? shouldFallbackToFileScope
782+
? hazardMode
783+
: 'off'
784+
: hazardMode
737785

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

804+
const normalizedDiagnostics = diagnostics.map(diag =>
805+
!diag?.filePath && transformHazardScope === 'file'
806+
? { ...diag, filePath: file }
807+
: diag,
808+
)
809+
const filteredDiagnostics = applyHazardAllowlist(normalizedDiagnostics)
756810
const errored = processDiagnosticsForFile(
757-
diagnostics,
811+
filteredDiagnostics,
758812
projectDir,
759-
logDiagnostics,
813+
logDiagnosticsWithAllowlist,
760814
)
761815
transformDiagnosticsError = transformDiagnosticsError || errored
762816
}
@@ -810,11 +864,25 @@ const duel = async args => {
810864
},
811865
)
812866
const rewriteSyntaxMode = dualTarget === 'commonjs' ? true : syntaxMode
867+
let rewriteDiagnosticsError = false
868+
const handleRewriteDiagnostic = diag => {
869+
const filtered = applyHazardAllowlist([diag])
870+
const errored = processDiagnosticsForFile(
871+
filtered,
872+
projectDir,
873+
logDiagnosticsWithAllowlist,
874+
)
875+
rewriteDiagnosticsError = rewriteDiagnosticsError || errored
876+
}
813877

814878
await rewriteSpecifiersAndExtensions(filenames, {
815879
target: dualTarget,
816880
ext: dualTargetExt,
817881
syntaxMode: rewriteSyntaxMode,
882+
detectDualPackageHazard: hazardMode,
883+
dualPackageHazardScope: hazardScope,
884+
dualPackageHazardAllowlist: [...hazardAllowlist],
885+
onDiagnostics: handleRewriteDiagnostic,
818886
rewritePolicy,
819887
validateSpecifiers,
820888
onWarn: message => logWarn(message),
@@ -834,13 +902,19 @@ const duel = async args => {
834902
ext: '.cjs',
835903
// Always lower syntax for primary CJS output when dirs mode rewrites primary build.
836904
syntaxMode: true,
905+
detectDualPackageHazard: hazardMode,
906+
dualPackageHazardScope: hazardScope,
907+
dualPackageHazardAllowlist: [...hazardAllowlist],
908+
onDiagnostics: handleRewriteDiagnostic,
837909
rewritePolicy,
838910
validateSpecifiers,
839911
onWarn: message => logWarn(message),
840912
onRewrite: (from, to) => logVerbose(`Rewrote specifiers in ${from} -> ${to}`),
841913
})
842914
}
843915

916+
exitOnDiagnostics(rewriteDiagnosticsError)
917+
844918
const esmRoot = isCjsBuild ? primaryOutDir : absoluteDualOutDir
845919
const cjsRoot = isCjsBuild ? absoluteDualOutDir : primaryOutDir
846920

src/init.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ const cliOptions = [
6060
value: '[off|warn|error]',
6161
desc: 'Detect mixed import/require use of dual packages.',
6262
},
63+
{
64+
long: 'dual-package-hazard-allowlist',
65+
value: '[pkg1,pkg2]',
66+
desc: 'Comma-separated packages to ignore for dual package hazard checks.',
67+
},
6368
{
6469
long: 'dual-package-hazard-scope',
6570
value: '[file|project]',
@@ -147,6 +152,9 @@ const init = async args => {
147152
short: 'H',
148153
default: 'warn',
149154
},
155+
'dual-package-hazard-allowlist': {
156+
type: 'string',
157+
},
150158
'dual-package-hazard-scope': {
151159
type: 'string',
152160
default: 'file',
@@ -191,6 +199,7 @@ const init = async args => {
191199
'rewrite-policy': rewritePolicy,
192200
'validate-specifiers': validateSpecifiers,
193201
'detect-dual-package-hazard': detectDualPackageHazard,
202+
'dual-package-hazard-allowlist': dualPackageHazardAllowlist,
194203
'dual-package-hazard-scope': dualPackageHazardScope,
195204
verbose,
196205
mode,
@@ -269,6 +278,25 @@ const init = async args => {
269278
return false
270279
}
271280

281+
const hazardAllowlistProvided = dualPackageHazardAllowlist !== undefined
282+
const hazardAllowlistRaw = dualPackageHazardAllowlist ?? ''
283+
const hasAllowlistContent = /[^,\s]/.test(hazardAllowlistRaw)
284+
285+
if (hazardAllowlistProvided && !hasAllowlistContent) {
286+
logError(
287+
'--dual-package-hazard-allowlist expects a comma-separated list of package names',
288+
)
289+
290+
return false
291+
}
292+
293+
const hazardAllowlist = hasAllowlistContent
294+
? hazardAllowlistRaw
295+
.split(',')
296+
.map(item => item.trim())
297+
.filter(Boolean)
298+
: []
299+
272300
if (!['file', 'project'].includes(dualPackageHazardScope)) {
273301
logError('--dual-package-hazard-scope expects one of: file | project')
274302

@@ -309,6 +337,7 @@ const init = async args => {
309337
rewritePolicy,
310338
validateSpecifiers: validateSpecifiersFinal,
311339
detectDualPackageHazard,
340+
dualPackageHazardAllowlist: hazardAllowlist,
312341
dualPackageHazardScope,
313342
verbose,
314343
copyMode,

src/resolver.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ const rewriteSpecifiersAndExtensions = async (filenames, options = {}) => {
7878
target,
7979
ext,
8080
syntaxMode,
81+
detectDualPackageHazard,
82+
dualPackageHazardAllowlist,
83+
dualPackageHazardScope,
84+
onDiagnostics,
8185
rewritePolicy = 'safe',
8286
validateSpecifiers = false,
8387
onRewrite = () => {},
@@ -221,6 +225,10 @@ const rewriteSpecifiersAndExtensions = async (filenames, options = {}) => {
221225
rewriteSpecifier,
222226
transformSyntax: syntaxMode,
223227
sourceMap: true,
228+
diagnostics: diag => onDiagnostics?.(diag, filename),
229+
...(detectDualPackageHazard !== undefined ? { detectDualPackageHazard } : {}),
230+
...(dualPackageHazardAllowlist !== undefined ? { dualPackageHazardAllowlist } : {}),
231+
...(dualPackageHazardScope !== undefined ? { dualPackageHazardScope } : {}),
224232
...(outFilename === filename ? { inPlace: true } : { out: outFilename }),
225233
}
226234

0 commit comments

Comments
 (0)