Skip to content

Commit b35d718

Browse files
feat: v4. (#89)
Co-authored-by: Copilot <[email protected]>
1 parent ba12f55 commit b35d718

65 files changed

Lines changed: 2804 additions & 428 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@ It should work similarly for a CJS-first project. Except, your package.json file
6060
> [!IMPORTANT]
6161
> This works best if your CJS-first project uses file extensions in _relative_ specifiers. That is acceptable in CJS and [required in ESM](https://nodejs.org/api/esm.html#import-specifiers). `duel` does not rewrite bare specifiers or remap relative specifiers to directory indexes.
6262
63-
> [!NOTE]
64-
> While `duel` runs it briefly swaps in a temporary package.json with the needed `type`; your original file is restored when the build finishes.
65-
6663
### Build orientation
6764

6865
`duel` infers the primary vs dual build orientation from your `package.json` `type`:
@@ -103,6 +100,15 @@ Assuming an `outDir` of `dist`, running the above will create `dist/esm` and `di
103100

104101
When `--mode` is enabled, `duel` copies sources and runs [`@knighted/module`](https://github.com/knightedcodemonkey/module) **before** `tsc`, so TypeScript sees already-mitigated sources. That pre-`tsc` step is globals-only for `--mode globals` and full lowering for `--mode full`.
105102

103+
### Dual package hazards
104+
105+
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`:
106+
107+
- `--detect-dual-package-hazard [off|warn|error]` (default `warn`): emit diagnostics; `error` exits non-zero.
108+
- `--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.
109+
110+
Project scope is helpful in monorepos or hoisted installs where hazards surface only when looking across files.
111+
106112
## Options
107113

108114
The available options are limited, because you should define most of them inside your project's `tsconfig.json` file.
@@ -112,6 +118,15 @@ The available options are limited, because you should define most of them inside
112118
- `--mode` Optional shorthand for the module transform mode: `none` (default), `globals` (globals-only), `full` (globals + full syntax lowering).
113119
- `--dirs, -d` Outputs both builds to directories inside of `outDir`. Defaults to `false`.
114120
- `--exports, -e` Generate `package.json` `exports` from build output. Values: `wildcard` | `dir` | `name`.
121+
- `--exports-config` Provide a JSON file with `{ "entries": ["./dist/index.js", ...], "main": "./dist/index.js" }` to limit which outputs become exports.
122+
- `--exports-validate` Dry-run exports generation/validation without writing package.json; combine with `--exports` or `--exports-config` to emit after validation.
123+
- `--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).
124+
- `--validate-specifiers` Validate that rewritten specifiers resolve to outputs; defaults to `true` when `--rewrite-policy` is `safe`.
125+
- `--detect-dual-package-hazard [off|warn|error]` Flag mixed import/require usage of dual packages; `error` exits non-zero.
126+
- `--dual-package-hazard-scope [file|project]` Run hazard checks per file (default) or aggregate across the project.
127+
- `--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.
128+
- `--verbose, -V` Verbose logging.
129+
- `--help, -h` Print the help text.
115130

116131
> [!NOTE]
117132
> Exports keys are extensionless by design; the target `import`/`require`/`types` entries keep explicit file extensions so Node resolution remains deterministic.

docs/exports.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,23 @@ Wildcard keys use the first path segment and cover folders; values are wildcarde
153153
- The root `.` entry uses your `main` (if set) to pick the default orientation (import vs require) and mirrors both builds when present.
154154
- If `main` is absent and no non-wildcard subpath exists, `.` is not promoted.
155155
- Windows paths are normalized with `path.posix`.
156+
157+
## Exports config (JSON)
158+
159+
If you want to constrain which built files become exports while keeping a conventional layout, pass `--exports-config <file>`. The file must be JSON with this shape:
160+
161+
```json
162+
{
163+
"entries": ["./dist/index.js", "./dist/folder/module.js"],
164+
"main": "./dist/index.js"
165+
}
166+
```
167+
168+
- `entries` (required): array of strings pointing to emitted files (relative with `./`). Only these bases are exported.
169+
- `main` (optional): overrides the `main` used for the root `.` entry and default orientation.
170+
171+
Convention over configuration remains the default: if you omit `--exports-config`, `duel` scans the output and infers exports automatically.
172+
173+
## Validation-only
174+
175+
Use `--exports-validate` to compute and validate the exports map without writing `package.json`. Combine with `--exports` and/or `--exports-config` to emit after validation. When run alone, it logs success and leaves your package.json untouched.

docs/faq.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,38 @@ Yes. `outDir` controls where Duel (and `tsc`) place emit results. The exclusion
1515
## Can I avoid editing `tsconfig.json`?
1616

1717
You could maintain separate configs (one for emit, one for checking) or lean on project references, but Duel's default workflow assumes a single package-level config. Excluding `dist/` is the simplest, least error-prone way to ensure dual builds, incremental type-checks, and workspace consumers all cooperate.
18+
19+
## How do I detect dual package hazards?
20+
21+
Dual packages can load twice if a project mixes `import` and `require` for the same dependency (especially when conditional exports differ). Use the built-in detector:
22+
23+
- `--detect-dual-package-hazard [off|warn|error]` controls severity (default `warn`; `error` exits non-zero).
24+
- `--dual-package-hazard-scope [file|project]` scopes diagnostics per file (legacy) or across the compiled source set (recommended for monorepos/hoisted installs).
25+
26+
Project scope runs a pre-pass before builds so hazards surface once per package, even if the conflicting usage spans multiple files.
27+
28+
## Why might Duel fall back to unbounded source paths on Windows?
29+
30+
Duel filters TypeScript source paths to prefer files inside your project root. On Windows, certain edge cases in path normalization can cause all paths to be incorrectly filtered out, triggering a fallback to the full unbounded list. Known scenarios include:
31+
32+
### Extended-length / UNC-style paths
33+
34+
`tsc` may emit paths like `\\?\C:\repo\src\file.ts` while `resolve(workingDir)` yields `C:\repo`. After normalization, these can still differ at the prefix level (`\\?\C:\repo` vs `C:\repo`), causing the root-matching logic to fail and filter out valid paths.
35+
36+
### Junctions / symlinked working directories
37+
38+
If your working directory is a junction or symlink (e.g., `C:\link-to-repo`) but `tsc` prints the real path (e.g., `C:\repo`), the normalized root and file paths won't share the same prefix. Every candidate fails the root check, leaving `insideRoot` empty.
39+
40+
### Mixed drive/root representations
41+
42+
Historically, tools can prepend UNC-style prefixes or vary drive letter formatting in ways that survive normalization. These differences make the string-prefix comparison too strict, dropping otherwise valid source files.
43+
44+
### Debugging the fallback
45+
46+
If you encounter this fallback in practice and need to debug it:
47+
48+
1. Add temporary logging to compare the normalized `root` value with a sample of paths from `allPaths`.
49+
2. Look for mismatched prefixes such as `\\?\C:\` vs `C:\`, or junction vs realpath differences.
50+
3. Consider using the real path consistently (via `fs.realpathSync`) if your workflow involves symlinks or junctions.
51+
52+
The fallback ensures real source files aren't silently dropped, but understanding the root cause can help you avoid the edge case entirely.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Performance + Correctness Guide
2+
3+
This document outlines ways to speed up @knighted/duel while preserving the guarantees of dual ESM/CJS output and specifier correctness.
4+
5+
## Goals
6+
7+
- Keep dual ESM/CJS output identical to today (specifier rewrites, extension rewrites, exports generation).
8+
- Reduce redundant work (copying, parsing, type-checking) across runs.
9+
- Make optimizations opt-in or auto-detected to avoid regressions.
10+
11+
## Low-Risk, High-Value Improvements
12+
13+
- **Incremental builds:** Pass `--incremental` (and `--composite` where needed) to both emits and persist `.tsbuildinfo` inside the temp/shadow workspace. Reuse the same shadow dir keyed by project hash to avoid re-parsing unchanged projects.
14+
- **Selective copy only:** Continue skipping `node_modules` and outDir; avoid full-copy fallbacks. Prefer symlink/junction for `node_modules` to cut I/O.
15+
- **Parallelize setup:** Run config copy/patching, node_modules linking, and temp dir prep with `Promise.all` while the primary build initializes. Gate parallelism by CPU count to avoid thrash on small machines.
16+
- **Config hashing:** Only rebuild the temp workspace when tsconfig/package hash changes; otherwise reuse cached copies and `.tsbuildinfo`.
17+
18+
## Emit Strategy Options
19+
20+
- **Single type-check, dual emit (recommended first):** Use the TS program or solution builder API to parse/check once, then emit ESM and CJS with different `module` settings. Keeps correctness, removes the second parse/check.
21+
- **Fast secondary emit (opt-in):** Primary emit and declarations via `tsc`; secondary CJS via transform-only (esbuild/SWC) for speed. Guard behind a flag (e.g., `--fast-secondary`) so default remains pure `tsc`.
22+
- **No redundant types:** Emit declarations once (primary pass), skip `--emitDeclarationOnly` on the secondary.
23+
24+
## TSConfig and Resolution Hygiene
25+
26+
- Honor `include`/`exclude` when selecting files to copy/transform.
27+
- Add an LRU cache for module resolution when traversing project references to reduce repeated lookups.
28+
- Keep specifier and extension rewrite logic unchanged; validate outputs in tests after any performance change.
29+
30+
## Validation and Safety Nets
31+
32+
- Keep exports validation (`--exports-validate`) and specifier rewrite tests as-is; add targeted fixtures if emit paths change.
33+
- When using fast-secondary mode, add a compare step that diffs ESM vs CJS specifier shapes on a sample fixture set to catch regressions.
34+
- Measure before/after: collect timings for copy, parse/check, emit, and total wall-clock.
35+
36+
## Rollout Suggestions
37+
38+
- Start with incremental `.tsbuildinfo` reuse and parallelized setup (lowest risk).
39+
- Next, prototype single type-check + dual emit; measure on a representative monorepo.
40+
- Offer `--fast-secondary` as an opt-in flag; document trade-offs (no type-check on secondary path).
41+
- Keep a `--no-parallel` escape hatch for constrained CI runners.

docs/roadmap.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Roadmap
22

3-
- Deprecate old flags in help output in favor of `--mode`.
43
- Consider auto-enabling `globals` mode when the build would hit TypeScript 58658 scenarios, with `--mode none` as an explicit opt-out.
5-
- Remove deprecated flags in a major release after a migration window.
4+
- Decide coupling of `--rewrite-policy` with `--validate-specifiers` (fail fast when `warn|safe` + validation=false, or document decoupling).
5+
- Consider a `--quiet` flag to reduce log chatter alongside warnings/hazards.
6+
- Memoize resolver existence checks to trim repeated sync fs hits during rewrite, if profiling shows it matters.
7+
8+
## Optimize temp-copy overhead
9+
10+
- **Measure first:** add timing/logs (bytes copied, copy duration, hazard pre-scan duration); optional `npm run bench:copy` task.
11+
- **Default skips:** always exclude `node_modules`, caches (`.turbo`, `.next`, `.cache`), and `dist`/build outputs when safe (e.g., no refs relying on dist).
12+
- **User filters:** consider `--copy-include` / `--copy-exclude` globs to bound what is copied for very large repos.
13+
- **Reuse artifacts:** when project references exist, reuse existing `dist` intelligently; otherwise favor skipping `dist` to cut I/O.
14+
- **Avoid double work:** keep hazard aggregation single-pass; short-circuit if hazard checks are off.

docs/v4-migration.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Duel v4 Migration Guide
2+
3+
This guide highlights behavior changes introduced in v4 and how to adapt existing workflows.
4+
5+
## Breaking/Behavioral Changes
6+
7+
- **Specifier rewrites now default to safer behavior.** `--rewrite-policy` now defaults to `safe`, and `--validate-specifiers` is forced on when policy is `safe`. Missing targets skip rewrites and emit warnings instead of silently rewriting.
8+
- **Dual-package hazard detection enabled by default.** `--detect-dual-package-hazard` now defaults to `warn`, and `--dual-package-hazard-scope` defaults to `file`. You may see new warnings (or errors if configured).
9+
- **Build pipeline runs in a temp workspace copy.** Dual builds no longer mutate the root `package.json`; a temp copy is created with an adjusted `type`. External tools that watched in-place `package.json` edits will see different behavior.
10+
- **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.
11+
- **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.
12+
- **Exports tooling additions.** New flags (`--exports-config`, `--exports-validate`) are available; when used, they can emit warnings or fail on invalid configs.
13+
- **Deprecated flags removed.** `--modules`, `--transform-syntax`, and `--target-extension` are gone; use `--mode globals` or `--mode full` instead.
14+
15+
## Restoring v3-like Behavior
16+
17+
- **Specifier rewrites:** use `--rewrite-policy warn --validate-specifiers false` to continue rewriting even when targets are missing (previous behavior). To fully bypass rewrites, set `--rewrite-policy skip`.
18+
- **Hazard detection:** disable by passing `--detect-dual-package-hazard off` (or set scope to `project` only if you want aggregated warnings).
19+
- **Build/package.json side effects:** if tooling depended on in-place `package.json` mutation, update it to read outputs from the temp dual build outputs (`dist/esm` / `dist/cjs` or `outDir` variants). No flag restores the old mutation pattern.
20+
- **TypeScript references:** if build mode changes output undesirably, remove `references` or run your own `tsc -p` before calling `duel`.
21+
22+
## Recommended Migration Steps
23+
24+
1. Pick a rewrite policy:
25+
- Safety-first (default): keep `--rewrite-policy safe` (default) and address any missing-target warnings by fixing paths or adding files.
26+
- Legacy: add `--rewrite-policy warn --validate-specifiers false` to mimic v3 rewrites.
27+
2. Decide on hazard handling:
28+
- Keep defaults to surface hazards.
29+
- Silence: `--detect-dual-package-hazard off`.
30+
3. Check CI/build scripts: remove assumptions about `package.json` being mutated; consume artifacts from the generated outDir(s).
31+
4. Projects with TS references: expect `tsc -b`; validate output paths and adjust if needed.
32+
5. If using exports helpers: verify `--exports-config` files and watch for new validation warnings/errors.
33+
34+
## New/Notable Flags
35+
36+
- `--rewrite-policy [safe|warn|skip]` (default: `safe`)
37+
- `--validate-specifiers` (defaults to `true` when policy is `safe`; otherwise `false`)
38+
- `--detect-dual-package-hazard [off|warn|error]` (default: `warn`)
39+
- `--dual-package-hazard-scope [file|project]` (default: `file`)
40+
- `--exports-config <path>`
41+
- `--exports-validate`
42+
- `--verbose`
43+
44+
## Quick FAQ
45+
46+
- **Why are some rewrites skipped now?** Missing targets + `rewrite-policy safe` causes skips with warnings. Use `warn` to force rewrites or fix the missing files.
47+
- **Can I suppress hazard warnings?** Yes, `--detect-dual-package-hazard off`.
48+
- **Why isn’t package.json changed anymore?** v4 writes to a temp copy to avoid mutating your root; watch the emitted outDir instead.

eslint.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default [
1818
},
1919
rules: {
2020
'no-console': 'error',
21+
'no-shadow': 'error',
2122
'n/no-process-exit': 'off',
2223
'n/hashbang': [
2324
'error',
@@ -55,4 +56,13 @@ export default [
5556
],
5657
},
5758
},
59+
{
60+
files: ['test/unit.js'],
61+
rules: {
62+
'no-console': 'off',
63+
},
64+
},
65+
{
66+
ignores: ['test/__fixtures__/projectRefs/packages/**'],
67+
},
5868
]

package-lock.json

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

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/duel",
3-
"version": "3.2.5",
3+
"version": "4.0.0-rc.0",
44
"description": "TypeScript dual packages.",
55
"type": "module",
66
"main": "dist/esm/duel.js",
@@ -23,9 +23,11 @@
2323
"prettier": "prettier -w *.js *.md docs/*.md src/*.js test/*.js",
2424
"prettier:check": "prettier --check --ignore-unknown *.js *.md docs/*.md src/*.js test/*.js",
2525
"lint": "eslint src/*.js test/*.js",
26+
"test:focus": "node --test --test-reporter=spec test/unit.js",
27+
"test:unit": "c8 --reporter=text --reporter=text-summary --reporter=lcov node --test --test-reporter=spec test/unit.js",
2628
"test:integration": "node --test --test-reporter=spec test/integration.js",
2729
"test:monorepos": "node --test --test-reporter=spec test/monorepos.js",
28-
"test": "c8 --reporter=text --reporter=text-summary --reporter=lcov node --trace-deprecation --test --test-reporter=spec test/integration.js test/monorepos.js",
30+
"test": "c8 --reporter=text --reporter=text-summary --reporter=lcov node --trace-deprecation --test --test-reporter=spec test/integration.js test/monorepos.js test/rewritePolicy.js test/unit.js",
2931
"build": "node src/duel.js --dirs --mode globals",
3032
"prepack": "npm run build",
3133
"prepare": "husky"
@@ -83,15 +85,15 @@
8385
"vite": "^7.2.4"
8486
},
8587
"dependencies": {
86-
"@knighted/module": "^1.3.1",
88+
"@knighted/module": "^1.4.0-rc.2",
8789
"find-up": "^8.0.0",
8890
"get-tsconfig": "^4.13.0",
8991
"glob": "^13.0.0",
9092
"read-package-up": "^12.0.0"
9193
},
9294
"lint-staged": {
9395
"*.{js,mjs,cjs,ts,cts,mts,jsx,tsx}": [
94-
"eslint --max-warnings=0",
96+
"eslint --max-warnings=0 --no-warn-ignored",
9597
"prettier --check --ignore-unknown"
9698
],
9799
"*.{md,json,yml,yaml}": [

0 commit comments

Comments
 (0)