Skip to content

Commit 7630853

Browse files
authored
feat(devkit): migrate @nx/devkit/src/... deep imports (#35541)
## Current Behavior After #34946, `@nx/devkit` ships a strict `exports` map. Deep imports like `@nx/devkit/src/utils/...` and `@nx/devkit/src/generators/...` are no longer reachable through Node module resolution. Workspaces upgrading to `23.x` that reference those paths break with module-resolution errors at runtime / type-check time. ## Expected Behavior `nx migrate` runs an automated rewrite that covers the realistic shapes of these deep imports. The migration walks every `.ts`/`.tsx`/`.cts`/`.mts` file in the workspace and: 1. **Buckets named imports by symbol.** Each `@nx/devkit/src/...` import declaration is parsed via the TypeScript compiler API (lazy-loaded with `ensurePackage`). Specifiers in the `internal.ts` re-export list go to `@nx/devkit/internal`; all others go to `@nx/devkit`. Mixed imports split into two declarations. 2. **Falls back for non-named shapes.** Default imports, namespace imports, side-effect imports, `require(...)` calls, and dynamic `import(...)` get the specifier swapped to `@nx/devkit/internal` (the safe default — it re-exports every previously deep-importable symbol). 3. **Collapses duplicate imports.** A second AST pass groups `import { ... } from '@nx/devkit'` and `import { ... } from '@nx/devkit/internal'` declarations by `(specifier, isTypeOnly)`, merging each 2+ group into one declaration with deduplicated specifiers. This handles both the duplicates the rewrite produced and any pre-existing devkit imports the user already had. Edits are stacked via `applyChangesToString` (devkit's offset-tracking text-mutation helper), then `formatFiles` normalizes formatting. ### Example Before: ```ts import { Tree } from '@nx/devkit'; import { dasherize, names } from '@nx/devkit/src/utils/string-utils'; import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; ``` After: ```ts import { Tree, names } from '@nx/devkit'; import { dasherize, addPlugin } from '@nx/devkit/internal'; ``` ### Tests 29 unit tests cover: single-bucket and mixed-bucket rewrites, `as` aliases, `import type` and inline `type` modifiers, multi-line imports, side-effect / default / namespace fallbacks, `require()` and dynamic `import()` fallbacks, quote-style preservation, pre-existing-import merge, public/internal independence, value-vs-type segregation, specifier deduplication, and a sanity test that every name in `DEVKIT_INTERNAL_SYMBOLS` is bucketed as internal. A user-facing `update-deep-imports.md` lives next to the implementation; the astro-docs build picks it up via the existing `packages/*/src/migrations/**/*.md` input glob. ## Related Issue(s) Follow-up to #34946.
1 parent e8aa612 commit 7630853

4 files changed

Lines changed: 689 additions & 1 deletion

File tree

packages/devkit/migrations.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
{
2-
"generators": {},
2+
"generators": {
3+
"update-devkit-deep-imports": {
4+
"version": "23.0.0-beta.6",
5+
"description": "Rewrite imports from `@nx/devkit/src/...` to `@nx/devkit` (for public symbols) or `@nx/devkit/internal` (for the rest), since deep imports are no longer reachable through the package's `exports` map.",
6+
"implementation": "./dist/src/migrations/update-23-0-0/update-deep-imports"
7+
}
8+
},
39
"packageJsonUpdates": {},
410
"version": "0.1"
511
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#### Update `@nx/devkit` deep imports
2+
3+
`@nx/devkit` now ships a strict `exports` map, so deep imports like `@nx/devkit/src/utils/...` and `@nx/devkit/src/generators/...` are no longer reachable through Node module resolution.
4+
5+
This migration scans every `.ts`, `.tsx`, `.cts`, and `.mts` file in your workspace and rewrites those deep imports to one of the supported entry points:
6+
7+
- Symbols that are part of the stable `@nx/devkit` public API are routed to `@nx/devkit`.
8+
- Symbols that were previously only reachable through deep imports are routed to `@nx/devkit/internal`.
9+
10+
After rewriting, the migration **collapses duplicate imports** so a file never ends up with two `import ... from '@nx/devkit'` (or `@nx/devkit/internal`) lines — including merging into any matching import you already had.
11+
12+
#### Sample Code Changes
13+
14+
##### Before
15+
16+
```ts
17+
import { Tree } from '@nx/devkit';
18+
import { dasherize, names } from '@nx/devkit/src/utils/string-utils';
19+
import { addPlugin } from '@nx/devkit/src/utils/add-plugin';
20+
```
21+
22+
##### After
23+
24+
```ts
25+
import { Tree, names } from '@nx/devkit';
26+
import { dasherize, addPlugin } from '@nx/devkit/internal';
27+
```
28+
29+
`names` was already in the public API, so it joins the existing `@nx/devkit` import. `dasherize` and `addPlugin` move to `@nx/devkit/internal`, and the two `/internal` imports are collapsed into one.
30+
31+
#### Fallback for non-named imports
32+
33+
For deep-import shapes that can't be split by symbol — default imports, namespace imports, side-effect imports, `require(...)` calls, and dynamic `import(...)` — the migration rewrites the specifier to `@nx/devkit/internal` as a best guess, since most symbols that previously lived under `@nx/devkit/src/...` ended up there.
34+
35+
```ts
36+
// Before
37+
const { dasherize } = require('@nx/devkit/src/utils/string-utils');
38+
39+
// After
40+
const { dasherize } = require('@nx/devkit/internal');
41+
```
42+
43+
If the symbol you're after is part of the stable public API instead, the rewritten import will fail to resolve against `@nx/devkit/internal` — switch it to `@nx/devkit` by hand.
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { type Tree } from '@nx/devkit';
2+
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
3+
import migration, {
4+
DEVKIT_INTERNAL_SYMBOLS,
5+
rewriteDevkitDeepImports,
6+
} from './update-deep-imports';
7+
8+
describe('update-deep-imports migration', () => {
9+
let tree: Tree;
10+
11+
beforeEach(() => {
12+
tree = createTreeWithEmptyWorkspace();
13+
});
14+
15+
describe('rewriteDevkitDeepImports', () => {
16+
it('routes a single internal symbol to @nx/devkit/internal', () => {
17+
const input = `import { dasherize } from '@nx/devkit/src/utils/string-utils';\n`;
18+
expect(rewriteDevkitDeepImports(input)).toBe(
19+
`import { dasherize } from '@nx/devkit/internal';\n`
20+
);
21+
});
22+
23+
it('routes a single public symbol to @nx/devkit', () => {
24+
const input = `import { names } from '@nx/devkit/src/utils/names';\n`;
25+
expect(rewriteDevkitDeepImports(input)).toBe(
26+
`import { names } from '@nx/devkit';\n`
27+
);
28+
});
29+
30+
it('splits mixed public and internal symbols across two declarations', () => {
31+
const input = `import { dasherize, names } from '@nx/devkit/src/utils/string-utils';\n`;
32+
const output = rewriteDevkitDeepImports(input);
33+
expect(output).toContain(`import { names } from '@nx/devkit';`);
34+
expect(output).toContain(
35+
`import { dasherize } from '@nx/devkit/internal';`
36+
);
37+
});
38+
39+
it('preserves `as` aliases', () => {
40+
const input = `import { dasherize as toKebab } from '@nx/devkit/src/utils/string-utils';\n`;
41+
expect(rewriteDevkitDeepImports(input)).toContain(
42+
`import { dasherize as toKebab } from '@nx/devkit/internal';`
43+
);
44+
});
45+
46+
it('handles `import type` declarations', () => {
47+
const input = `import type { FileExtensionType } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';\n`;
48+
expect(rewriteDevkitDeepImports(input)).toContain(
49+
`import type { FileExtensionType } from '@nx/devkit/internal';`
50+
);
51+
});
52+
53+
it('preserves inline `type` modifiers on individual specifiers', () => {
54+
const input = `import { type FileExtensionType, addPlugin } from '@nx/devkit/src/x';\n`;
55+
const output = rewriteDevkitDeepImports(input);
56+
expect(output).toContain(
57+
`import { type FileExtensionType, addPlugin } from '@nx/devkit/internal';`
58+
);
59+
});
60+
61+
it('drops redundant inline `type` when the whole import is already type-only', () => {
62+
const input = `import type { type FileExtensionType } from '@nx/devkit/src/x';\n`;
63+
const output = rewriteDevkitDeepImports(input);
64+
expect(output).toContain(
65+
`import type { FileExtensionType } from '@nx/devkit/internal';`
66+
);
67+
});
68+
69+
it('handles multi-line named imports', () => {
70+
const input = `import {\n dasherize,\n names,\n classify,\n} from '@nx/devkit/src/utils/string-utils';\n`;
71+
const output = rewriteDevkitDeepImports(input);
72+
expect(output).toContain(`import { names } from '@nx/devkit';`);
73+
expect(output).toContain(
74+
`import { dasherize, classify } from '@nx/devkit/internal';`
75+
);
76+
});
77+
78+
it('rewrites multiple deep imports in one file', () => {
79+
const input =
80+
`import { dasherize } from '@nx/devkit/src/utils/string-utils';\n` +
81+
`import { names } from '@nx/devkit/src/utils/names';\n`;
82+
const output = rewriteDevkitDeepImports(input);
83+
expect(output).toContain(
84+
`import { dasherize } from '@nx/devkit/internal';`
85+
);
86+
expect(output).toContain(`import { names } from '@nx/devkit';`);
87+
});
88+
89+
it('falls back to /internal for side-effect imports', () => {
90+
const input = `import '@nx/devkit/src/utils/some-side-effect';\n`;
91+
expect(rewriteDevkitDeepImports(input)).toContain(
92+
`import '@nx/devkit/internal';`
93+
);
94+
});
95+
96+
it('falls back to /internal for default imports', () => {
97+
const input = `import x from '@nx/devkit/src/utils/foo';\n`;
98+
const output = rewriteDevkitDeepImports(input);
99+
expect(output).toContain(`'@nx/devkit/internal'`);
100+
expect(output).toContain(`import x from`);
101+
});
102+
103+
it('falls back to /internal for namespace imports', () => {
104+
const input = `import * as devkit from '@nx/devkit/src/utils/foo';\n`;
105+
const output = rewriteDevkitDeepImports(input);
106+
expect(output).toContain(
107+
`import * as devkit from '@nx/devkit/internal';`
108+
);
109+
});
110+
111+
it('falls back to /internal for require()', () => {
112+
const input = `const x = require('@nx/devkit/src/utils/foo');\n`;
113+
expect(rewriteDevkitDeepImports(input)).toBe(
114+
`const x = require('@nx/devkit/internal');\n`
115+
);
116+
});
117+
118+
it('falls back to /internal for dynamic import()', () => {
119+
const input = `const x = await import('@nx/devkit/src/utils/foo');\n`;
120+
expect(rewriteDevkitDeepImports(input)).toBe(
121+
`const x = await import('@nx/devkit/internal');\n`
122+
);
123+
});
124+
125+
it('preserves quote style on fallback paths', () => {
126+
const input = `const x = require("@nx/devkit/src/utils/foo");\n`;
127+
expect(rewriteDevkitDeepImports(input)).toBe(
128+
`const x = require("@nx/devkit/internal");\n`
129+
);
130+
});
131+
132+
it('does not touch unrelated @nx/devkit imports', () => {
133+
const input = `import { Tree } from '@nx/devkit';\n`;
134+
expect(rewriteDevkitDeepImports(input)).toBe(input);
135+
});
136+
137+
it('does not touch unrelated @nx/devkit/internal imports', () => {
138+
const input = `import { dasherize } from '@nx/devkit/internal';\n`;
139+
expect(rewriteDevkitDeepImports(input)).toBe(input);
140+
});
141+
});
142+
143+
describe('migration runner', () => {
144+
it('rewrites deep imports across .ts/.tsx/.cts/.mts files', async () => {
145+
tree.write(
146+
'libs/foo/src/a.ts',
147+
`import { dasherize } from '@nx/devkit/src/utils/string-utils';\n`
148+
);
149+
tree.write(
150+
'libs/foo/src/b.tsx',
151+
`import { addPlugin } from '@nx/devkit/src/utils/add-plugin';\n`
152+
);
153+
tree.write(
154+
'libs/foo/src/c.cts',
155+
`const { classify } = require('@nx/devkit/src/utils/string-utils');\n`
156+
);
157+
tree.write(
158+
'libs/foo/src/d.mts',
159+
`import { camelize } from '@nx/devkit/src/utils/string-utils';\n`
160+
);
161+
162+
await migration(tree);
163+
164+
expect(tree.read('libs/foo/src/a.ts', 'utf-8')).toContain(
165+
`'@nx/devkit/internal'`
166+
);
167+
expect(tree.read('libs/foo/src/b.tsx', 'utf-8')).toContain(
168+
`'@nx/devkit/internal'`
169+
);
170+
expect(tree.read('libs/foo/src/c.cts', 'utf-8')).toContain(
171+
`'@nx/devkit/internal'`
172+
);
173+
expect(tree.read('libs/foo/src/d.mts', 'utf-8')).toContain(
174+
`'@nx/devkit/internal'`
175+
);
176+
});
177+
178+
it('does not rewrite deep-import strings inside non-TS files', async () => {
179+
const md = `Example: \`import { x } from '@nx/devkit/src/utils/foo';\`\n`;
180+
tree.write('docs/example.md', md);
181+
182+
await migration(tree);
183+
184+
// The deep-import literal must survive untouched in markdown — only TS
185+
// sources should be rewritten. (formatFiles may normalize trailing
186+
// whitespace, so we assert on substring rather than full equality.)
187+
expect(tree.read('docs/example.md', 'utf-8')).toContain(
188+
`'@nx/devkit/src/utils/foo'`
189+
);
190+
});
191+
192+
it('does not touch files that do not contain the deep prefix', async () => {
193+
const content = `import { Tree } from '@nx/devkit';\n`;
194+
tree.write('libs/foo/src/index.ts', content);
195+
196+
await migration(tree);
197+
198+
expect(tree.read('libs/foo/src/index.ts', 'utf-8')).toBe(content);
199+
});
200+
});
201+
202+
describe('collapse', () => {
203+
it('merges a rewritten import into a pre-existing @nx/devkit import', () => {
204+
const input =
205+
`import { Tree } from '@nx/devkit';\n` +
206+
`import { names } from '@nx/devkit/src/utils/names';\n`;
207+
const output = rewriteDevkitDeepImports(input);
208+
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
209+
expect(output).not.toMatch(
210+
/import \{[^}]*\} from '@nx\/devkit';[\s\S]*import \{[^}]*\} from '@nx\/devkit';/
211+
);
212+
});
213+
214+
it('merges a rewritten import into a pre-existing @nx/devkit/internal import', () => {
215+
const input =
216+
`import { dasherize } from '@nx/devkit/internal';\n` +
217+
`import { classify } from '@nx/devkit/src/utils/string-utils';\n`;
218+
const output = rewriteDevkitDeepImports(input);
219+
expect(output).toContain(
220+
`import { dasherize, classify } from '@nx/devkit/internal';`
221+
);
222+
});
223+
224+
it('merges multiple rewritten imports that target the same specifier', () => {
225+
const input =
226+
`import { dasherize } from '@nx/devkit/src/utils/string-utils';\n` +
227+
`import { classify } from '@nx/devkit/src/utils/string-utils';\n` +
228+
`import { camelize } from '@nx/devkit/src/utils/string-utils';\n`;
229+
const output = rewriteDevkitDeepImports(input);
230+
expect(output).toContain(
231+
`import { dasherize, classify, camelize } from '@nx/devkit/internal';`
232+
);
233+
// Only one /internal declaration in the output.
234+
expect((output.match(/from '@nx\/devkit\/internal'/g) ?? []).length).toBe(
235+
1
236+
);
237+
});
238+
239+
it('keeps public and internal collapses independent', () => {
240+
const input =
241+
`import { Tree } from '@nx/devkit';\n` +
242+
`import { dasherize, names } from '@nx/devkit/src/utils/string-utils';\n` +
243+
`import { classify } from '@nx/devkit/src/utils/string-utils';\n`;
244+
const output = rewriteDevkitDeepImports(input);
245+
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
246+
expect(output).toContain(
247+
`import { dasherize, classify } from '@nx/devkit/internal';`
248+
);
249+
});
250+
251+
it('does not merge value imports with type-only imports', () => {
252+
const input =
253+
`import type { Tree } from '@nx/devkit';\n` +
254+
`import { names } from '@nx/devkit/src/utils/names';\n`;
255+
const output = rewriteDevkitDeepImports(input);
256+
expect(output).toContain(`import type { Tree } from '@nx/devkit';`);
257+
expect(output).toContain(`import { names } from '@nx/devkit';`);
258+
});
259+
260+
it('merges duplicate specifiers without repeating them', () => {
261+
const input =
262+
`import { Tree } from '@nx/devkit';\n` +
263+
`import { Tree, names } from '@nx/devkit';\n`;
264+
const output = rewriteDevkitDeepImports(input);
265+
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
266+
expect((output.match(/Tree/g) ?? []).length).toBe(1);
267+
});
268+
269+
it('does not touch a file that already has a single canonical import', () => {
270+
const input = `import { Tree, names } from '@nx/devkit';\n`;
271+
expect(rewriteDevkitDeepImports(input)).toBe(input);
272+
});
273+
274+
it('leaves unrelated imports between merged declarations alone', () => {
275+
const input =
276+
`import { Tree } from '@nx/devkit';\n` +
277+
`import { readFileSync } from 'node:fs';\n` +
278+
`import { names } from '@nx/devkit/src/utils/names';\n`;
279+
const output = rewriteDevkitDeepImports(input);
280+
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
281+
expect(output).toContain(`import { readFileSync } from 'node:fs';`);
282+
});
283+
});
284+
285+
describe('symbol set sanity', () => {
286+
it('treats every name in DEVKIT_INTERNAL_SYMBOLS as internal', () => {
287+
for (const name of DEVKIT_INTERNAL_SYMBOLS) {
288+
const input = `import { ${name} } from '@nx/devkit/src/x';\n`;
289+
const output = rewriteDevkitDeepImports(input);
290+
expect(output).toContain(
291+
`import { ${name} } from '@nx/devkit/internal';`
292+
);
293+
}
294+
});
295+
});
296+
});

0 commit comments

Comments
 (0)