@nx/vite 23.0.0-beta.21 → 23.0.0-beta.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/plugins/nx-copy-assets.plugin.d.ts +6 -0
  2. package/dist/plugins/nx-copy-assets.plugin.js +8 -0
  3. package/dist/plugins/nx-tsconfig-paths.plugin.d.ts +7 -0
  4. package/dist/plugins/nx-tsconfig-paths.plugin.js +9 -0
  5. package/dist/src/generators/configuration/configuration.js +2 -0
  6. package/dist/src/generators/convert-to-inferred/convert-to-inferred.js +2 -0
  7. package/dist/src/generators/init/init.js +2 -0
  8. package/dist/src/generators/init/lib/utils.js +1 -1
  9. package/dist/src/generators/init/schema.json +1 -1
  10. package/dist/src/generators/setup-paths-plugin/setup-paths-plugin.js +2 -0
  11. package/dist/src/migrations/update-23-0-0/ai-instructions-for-vitest-3.md +604 -0
  12. package/dist/src/migrations/update-23-0-0/ai-instructions-for-vitest-4.md +817 -0
  13. package/dist/src/migrations/update-23-0-0/lib/ast-edits.d.ts +26 -0
  14. package/dist/src/migrations/update-23-0-0/lib/ast-edits.js +49 -0
  15. package/dist/src/migrations/update-23-0-0/lib/ci-files.d.ts +3 -0
  16. package/dist/src/migrations/update-23-0-0/lib/ci-files.js +30 -0
  17. package/dist/src/migrations/update-23-0-0/lib/vitest-config-files.d.ts +5 -0
  18. package/dist/src/migrations/update-23-0-0/lib/vitest-config-files.js +34 -0
  19. package/dist/src/migrations/update-23-0-0/migrate-to-vitest-3.d.ts +10 -0
  20. package/dist/src/migrations/update-23-0-0/migrate-to-vitest-3.js +335 -0
  21. package/dist/src/migrations/update-23-0-0/migrate-to-vitest-4.d.ts +17 -0
  22. package/dist/src/migrations/update-23-0-0/migrate-to-vitest-4.js +726 -0
  23. package/dist/src/plugins/plugin.d.ts +3 -3
  24. package/dist/src/utils/assert-supported-vite-version.d.ts +2 -0
  25. package/dist/src/utils/assert-supported-vite-version.js +8 -0
  26. package/dist/src/utils/deprecation.d.ts +4 -0
  27. package/dist/src/utils/deprecation.js +26 -1
  28. package/dist/src/utils/ensure-dependencies.js +1 -1
  29. package/dist/src/utils/generator-utils.js +2 -0
  30. package/dist/src/utils/versions.d.ts +1 -0
  31. package/dist/src/utils/versions.js +7 -1
  32. package/migrations.json +116 -9
  33. package/package.json +6 -6
@@ -0,0 +1,726 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = migrateToVitest4;
4
+ const devkit_1 = require("@nx/devkit");
5
+ const internal_1 = require("@nx/js/internal");
6
+ const tsquery_1 = require("@phenomnomnominal/tsquery");
7
+ const picomatch = require("picomatch");
8
+ const ast_edits_1 = require("./lib/ast-edits");
9
+ const ci_files_1 = require("./lib/ci-files");
10
+ const vitest_config_files_1 = require("./lib/vitest-config-files");
11
+ let ts;
12
+ /**
13
+ * Hybrid migration paired with `ai-instructions-for-vitest-4.md`. Applies the
14
+ * deterministic, AST-tractable Vitest 3.x → 4.0 changes mechanically and
15
+ * forwards an `agentContext` describing any shape it could not handle so the
16
+ * paired prompt's agent can finish the rest.
17
+ *
18
+ * The "apply" set is intentionally narrow (renames, deletions of dead options,
19
+ * single-property string rewrites). Anything requiring cross-cutting surgery
20
+ * (e.g. pool option flattening, `deps.* → server.deps.*` merge, browser
21
+ * provider function-form rewrite, workspace file inlining) is detected and
22
+ * logged for the agent.
23
+ */
24
+ async function migrateToVitest4(tree) {
25
+ ts ??= (0, internal_1.ensureTypescript)();
26
+ const unhandled = [];
27
+ (0, vitest_config_files_1.visitVitestConfigFiles)(tree, (filePath) => processVitestConfig(tree, filePath, unhandled));
28
+ // Workspace files are removed entirely in v4 — their presence is always a
29
+ // signal that the agent needs to inline them into the root config.
30
+ (0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
31
+ if (!(0, vitest_config_files_1.isVitestWorkspaceFile)(filePath))
32
+ return;
33
+ unhandled.push(`${filePath}: \`vitest.workspace.*\` files are removed in Vitest 4. ` +
34
+ `Inline its project list into the root \`vitest.config.*\` under \`test.projects\` and delete this file.`);
35
+ });
36
+ (0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
37
+ if (!(0, vitest_config_files_1.isJsOrTsFile)(filePath))
38
+ return;
39
+ processImportsAndCustomCode(tree, filePath, unhandled);
40
+ });
41
+ (0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
42
+ if (filePath.endsWith('package.json')) {
43
+ processPackageJson(tree, filePath, unhandled);
44
+ }
45
+ else if (filePath.endsWith('project.json')) {
46
+ processProjectJson(tree, filePath, unhandled);
47
+ }
48
+ else if (isEnvFile(filePath)) {
49
+ processEnvFile(tree, filePath, unhandled);
50
+ }
51
+ });
52
+ scanCiFiles(tree, unhandled);
53
+ await (0, devkit_1.formatFiles)(tree);
54
+ // Always-shown reminder for state we cannot reach from the workspace tree.
55
+ const nextSteps = [
56
+ `If your CI provider stores Vitest env vars in its dashboard (GitHub Actions repo/org secrets, GitLab CI/CD variables, Vercel/Netlify env vars, etc.), rename \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` to \`VITEST_MAX_WORKERS\`, and \`VITE_NODE_DEPS_MODULE_DIRECTORIES\` to \`VITEST_MODULE_DIRECTORIES\`. The pre-pass only handles in-repo files.`,
57
+ ];
58
+ const result = {
59
+ nextSteps,
60
+ };
61
+ if (unhandled.length > 0)
62
+ result.agentContext = unhandled;
63
+ return result;
64
+ }
65
+ const ENV_FILE_MATCHERS = [picomatch('**/.env'), picomatch('**/.env.*')];
66
+ function isEnvFile(filePath) {
67
+ const base = filePath.split('/').pop() ?? '';
68
+ // Skip `.envrc` (direnv) — different syntax surface.
69
+ if (base === '.envrc')
70
+ return false;
71
+ return ENV_FILE_MATCHERS.some((m) => m(filePath));
72
+ }
73
+ const COVERAGE_DEAD_OPTIONS = new Set([
74
+ 'all',
75
+ 'extensions',
76
+ 'ignoreEmptyLines',
77
+ 'experimentalAstAwareRemapping',
78
+ ]);
79
+ const POOL_FLATTEN_OPTIONS = new Set(['execArgv', 'isolate']);
80
+ const REMOVED_REPORTER_CALLBACKS = new Set([
81
+ 'onCollected',
82
+ 'onSpecsCollected',
83
+ 'onPathsCollected',
84
+ 'onTaskUpdate',
85
+ 'onFinished',
86
+ ]);
87
+ function processVitestConfig(tree, filePath, unhandled) {
88
+ const contents = tree.read(filePath, 'utf-8');
89
+ // Cheap precheck: skip files that don't mention any v4 target.
90
+ if (!mightContainV4Targets(contents))
91
+ return;
92
+ const sourceFile = (0, tsquery_1.ast)(contents);
93
+ const edits = [];
94
+ collectCoverageRemovalEdits(sourceFile, contents, edits);
95
+ collectWorkspaceRenameEdits(sourceFile, edits);
96
+ collectDepsOptimizerWebEdits(sourceFile, edits);
97
+ collectUseAtomicsRemovalEdits(sourceFile, contents, edits);
98
+ collectMinWorkersRemovalEdits(sourceFile, contents, edits);
99
+ collectReporterRenameEdits(sourceFile, contents, edits);
100
+ collectPoolOptionDetectLogs(sourceFile, filePath, unhandled);
101
+ collectDepsServerMoveLogs(sourceFile, filePath, unhandled);
102
+ collectMatchGlobsLogs(sourceFile, filePath, unhandled);
103
+ collectBrowserStringProviderLogs(sourceFile, filePath, unhandled);
104
+ collectTesterScriptsLogs(sourceFile, filePath, unhandled);
105
+ collectCustomReporterCallbackLogs(sourceFile, filePath, unhandled);
106
+ if (edits.length > 0) {
107
+ tree.write(filePath, (0, ast_edits_1.applyTextEdits)(contents, edits));
108
+ }
109
+ }
110
+ function mightContainV4Targets(contents) {
111
+ // Hit-test: keywords we care about. Cheap string scan to avoid parsing
112
+ // every config file in the workspace.
113
+ return /\b(workspace|projects|coverage|useAtomics|minWorkers|reporters|provider|deps|poolOptions|singleFork|singleThread|maxThreads|maxForks|environmentMatchGlobs|poolMatchGlobs|testerScripts|onCollected|onSpecsCollected|onPathsCollected|onTaskUpdate|onFinished|vmThreads)\b/.test(contents);
114
+ }
115
+ /**
116
+ * Coverage option removals (V4-1): `all`, `extensions`, `ignoreEmptyLines`,
117
+ * `experimentalAstAwareRemapping` are all removed in v4. Anchored to
118
+ * `test.coverage`.
119
+ */
120
+ function collectCoverageRemovalEdits(sourceFile, contents, edits) {
121
+ for (const optionName of COVERAGE_DEAD_OPTIONS) {
122
+ const identifiers = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${optionName}]`);
123
+ for (const id of identifiers) {
124
+ if (!isInsideTestSubProperty(id, 'coverage'))
125
+ continue;
126
+ const owningProperty = id.parent;
127
+ edits.push((0, ast_edits_1.removeNodeWithTrailingCommaEdit)(contents, owningProperty));
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * `test.workspace` → `test.projects` (V4-2). The external `vitest.workspace.*`
133
+ * file form is detected separately at the top level and always logged.
134
+ */
135
+ function collectWorkspaceRenameEdits(sourceFile, edits) {
136
+ const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=workspace]');
137
+ for (const id of identifiers) {
138
+ if (!isImmediateChildOfTest(id))
139
+ continue;
140
+ edits.push((0, ast_edits_1.replaceNodeTextEdit)(id, 'projects'));
141
+ }
142
+ }
143
+ /**
144
+ * `deps.optimizer.web` → `deps.optimizer.client` (V4-4). Anchored to
145
+ * `test.deps.optimizer` so the `web` rename only applies to that nesting.
146
+ */
147
+ function collectDepsOptimizerWebEdits(sourceFile, edits) {
148
+ const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=web]');
149
+ for (const id of identifiers) {
150
+ if (!isInsideChain(id, ['test', 'deps', 'optimizer']))
151
+ continue;
152
+ edits.push((0, ast_edits_1.replaceNodeTextEdit)(id, 'client'));
153
+ }
154
+ }
155
+ /**
156
+ * Remove `poolOptions.threads.useAtomics` (V4-5).
157
+ */
158
+ function collectUseAtomicsRemovalEdits(sourceFile, contents, edits) {
159
+ const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=useAtomics]');
160
+ for (const id of identifiers) {
161
+ if (!isInsideChain(id, ['test', 'poolOptions', 'threads']))
162
+ continue;
163
+ const owningProperty = id.parent;
164
+ edits.push((0, ast_edits_1.removeNodeWithTrailingCommaEdit)(contents, owningProperty));
165
+ }
166
+ }
167
+ /**
168
+ * Remove top-level `test.minWorkers` (V4-6). Behaves as `0` in non-watch mode
169
+ * in v4, so dropping the assignment is safe.
170
+ */
171
+ function collectMinWorkersRemovalEdits(sourceFile, contents, edits) {
172
+ const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=minWorkers]');
173
+ for (const id of identifiers) {
174
+ if (!isImmediateChildOfTest(id))
175
+ continue;
176
+ const owningProperty = id.parent;
177
+ edits.push((0, ast_edits_1.removeNodeWithTrailingCommaEdit)(contents, owningProperty));
178
+ }
179
+ }
180
+ /**
181
+ * Reporter renames inside `test.reporters` (V4-7).
182
+ * `'verbose'` → `'tree'`
183
+ * `'basic'` → `['default', { summary: false }]`
184
+ */
185
+ function collectReporterRenameEdits(sourceFile, contents, edits) {
186
+ const reportersProperties = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment:has(Identifier[name=reporters])').filter((p) => ts.isIdentifier(p.name) &&
187
+ p.name.text === 'reporters' &&
188
+ isImmediateChildOfTest(p.name));
189
+ for (const property of reportersProperties) {
190
+ const init = property.initializer;
191
+ if (!ts.isArrayLiteralExpression(init))
192
+ continue;
193
+ for (const element of init.elements) {
194
+ if (!ts.isStringLiteral(element))
195
+ continue;
196
+ if (element.text === 'verbose') {
197
+ edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, element, 'tree'));
198
+ }
199
+ else if (element.text === 'basic') {
200
+ edits.push((0, ast_edits_1.replaceNodeTextEdit)(element, `['default', { summary: false }]`));
201
+ }
202
+ }
203
+ }
204
+ }
205
+ /**
206
+ * Detect (but do not modify) pool-related options whose v4 semantics require
207
+ * cross-cutting surgery: `singleThread`/`singleFork`, `maxThreads`/`maxForks`,
208
+ * `poolOptions.{forks,threads}.{execArgv,isolate}`,
209
+ * `poolOptions.vmThreads.memoryLimit`.
210
+ */
211
+ function collectPoolOptionDetectLogs(sourceFile, filePath, unhandled) {
212
+ const singleHits = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=/^single(Thread|Fork)$/]');
213
+ for (const id of singleHits) {
214
+ const literalValue = readBooleanLiteralValue(id.parent);
215
+ if (literalValue === true) {
216
+ unhandled.push(`${filePath}: \`${id.text}: true\` is removed in Vitest 4. ` +
217
+ `Replace it with \`maxWorkers: 1, isolate: false\` at the top of the \`test\` block, then remove \`${id.text}\`.`);
218
+ }
219
+ else if (literalValue === false) {
220
+ unhandled.push(`${filePath}: \`${id.text}: false\` is removed in Vitest 4 (it was already the default). ` +
221
+ `Delete the property.`);
222
+ }
223
+ else {
224
+ unhandled.push(`${filePath}: \`${id.text}\` is removed in Vitest 4 and the value here is not a boolean literal. ` +
225
+ `If it evaluates to \`true\`, replace with \`maxWorkers: 1, isolate: false\` at the top of \`test\` and remove \`${id.text}\`. ` +
226
+ `If it evaluates to \`false\`, delete it.`);
227
+ }
228
+ }
229
+ const maxHits = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=/^max(Threads|Forks)$/]');
230
+ for (const id of maxHits) {
231
+ if (!isImmediateChildOfTest(id))
232
+ continue;
233
+ const literalValue = readNumericLiteralValue(id.parent);
234
+ const currentValueDescr = literalValue !== undefined ? ` (current value: ${literalValue})` : '';
235
+ unhandled.push(`${filePath}: \`test.${id.text}\`${currentValueDescr} is removed in Vitest 4. ` +
236
+ `Replace with the pool-agnostic \`maxWorkers\` option. ` +
237
+ `If both \`maxThreads\` and \`maxForks\` are set with different values, pick the one matching the pool the project actually uses.`);
238
+ }
239
+ for (const optionName of POOL_FLATTEN_OPTIONS) {
240
+ const ids = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${optionName}]`);
241
+ for (const id of ids) {
242
+ const insideForks = isInsideChain(id, ['test', 'poolOptions', 'forks']);
243
+ const insideThreads = isInsideChain(id, [
244
+ 'test',
245
+ 'poolOptions',
246
+ 'threads',
247
+ ]);
248
+ if (!insideForks && !insideThreads)
249
+ continue;
250
+ const pool = insideForks ? 'forks' : 'threads';
251
+ unhandled.push(`${filePath}: \`test.poolOptions.${pool}.${optionName}\` was flattened in Vitest 4. ` +
252
+ `Move it to the top-level \`test.${optionName}\` (one value for all pools).`);
253
+ }
254
+ }
255
+ const memoryLimitIds = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=memoryLimit]');
256
+ for (const id of memoryLimitIds) {
257
+ if (!isInsideChain(id, ['test', 'poolOptions', 'vmThreads']))
258
+ continue;
259
+ unhandled.push(`${filePath}: \`test.poolOptions.vmThreads.memoryLimit\` was renamed and lifted in Vitest 4. ` +
260
+ `Move it to top-level \`test.vmMemoryLimit\`.`);
261
+ }
262
+ }
263
+ function readBooleanLiteralValue(property) {
264
+ const init = property.initializer;
265
+ if (init.kind === ts.SyntaxKind.TrueKeyword)
266
+ return true;
267
+ if (init.kind === ts.SyntaxKind.FalseKeyword)
268
+ return false;
269
+ return undefined;
270
+ }
271
+ function readNumericLiteralValue(property) {
272
+ const init = property.initializer;
273
+ if (ts.isNumericLiteral(init))
274
+ return Number(init.text);
275
+ return undefined;
276
+ }
277
+ /**
278
+ * Detect `test.deps.{external,inline,fallbackCJS}`. Moving them under
279
+ * `test.server.deps` requires conflict-aware merging that we don't attempt
280
+ * mechanically.
281
+ */
282
+ function collectDepsServerMoveLogs(sourceFile, filePath, unhandled) {
283
+ for (const name of ['external', 'inline', 'fallbackCJS']) {
284
+ const ids = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${name}]`);
285
+ for (const id of ids) {
286
+ if (!isInsideChain(id, ['test', 'deps']))
287
+ continue;
288
+ // Don't flag `test.deps.optimizer.<...>` — only direct children of deps.
289
+ if (!isImmediateChildOfProperty(id, 'deps'))
290
+ continue;
291
+ unhandled.push(`${filePath}: \`test.deps.${name}\` moved to \`test.server.deps.${name}\` in Vitest 4. ` +
292
+ `Move the property, merging with any existing \`server.deps\` block.`);
293
+ }
294
+ }
295
+ }
296
+ /**
297
+ * `test.poolMatchGlobs` / `test.environmentMatchGlobs` are removed in v4;
298
+ * their replacement is per-project conditions inside `test.projects`. Always
299
+ * a manual rewrite.
300
+ */
301
+ function collectMatchGlobsLogs(sourceFile, filePath, unhandled) {
302
+ for (const name of ['poolMatchGlobs', 'environmentMatchGlobs']) {
303
+ const ids = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${name}]`);
304
+ for (const id of ids) {
305
+ if (!isImmediateChildOfTest(id))
306
+ continue;
307
+ unhandled.push(`${filePath}: \`test.${name}\` is removed in Vitest 4. ` +
308
+ `Express the same per-glob configuration via \`test.projects\` entries with conditions.`);
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * `browser.provider` as a string is no longer valid in v4 — it must be the
314
+ * return value of a provider function imported from a per-provider package.
315
+ */
316
+ function collectBrowserStringProviderLogs(sourceFile, filePath, unhandled) {
317
+ const providers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment:has(Identifier[name=provider]) > StringLiteral');
318
+ for (const lit of providers) {
319
+ if (!isInsideTestSubProperty(lit, 'browser'))
320
+ continue;
321
+ unhandled.push(`${filePath}: \`browser.provider\` is no longer a string in Vitest 4 (current value: ${lit.getText()}). ` +
322
+ `Install the matching \`@vitest/browser-<provider>\` package and use the function form, ` +
323
+ `e.g. \`provider: playwright(...)\`.`);
324
+ }
325
+ }
326
+ /**
327
+ * `browser.testerScripts` (array of scripts) was replaced by
328
+ * `browser.testerHtmlPath` (single HTML file) — a semantic change.
329
+ */
330
+ function collectTesterScriptsLogs(sourceFile, filePath, unhandled) {
331
+ const ids = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=testerScripts]');
332
+ for (const id of ids) {
333
+ if (!isInsideTestSubProperty(id, 'browser'))
334
+ continue;
335
+ unhandled.push(`${filePath}: \`browser.testerScripts\` was removed in Vitest 4 in favor of \`browser.testerHtmlPath\`. ` +
336
+ `Combine the script contents into a single HTML file and point \`testerHtmlPath\` at it.`);
337
+ }
338
+ }
339
+ /**
340
+ * Custom reporter callbacks removed in v4: `onCollected`, `onSpecsCollected`,
341
+ * `onPathsCollected`, `onTaskUpdate`, `onFinished`. They can appear as
342
+ * `PropertyAssignment` (object literal: `onCollected: () => ...`),
343
+ * `MethodDeclaration` (shorthand: `onCollected() {}`), or class method on a
344
+ * reporter class — the search broadens past `PropertyAssignment` to cover
345
+ * all three.
346
+ */
347
+ function collectCustomReporterCallbackLogs(sourceFile, filePath, unhandled) {
348
+ for (const name of REMOVED_REPORTER_CALLBACKS) {
349
+ const ids = (0, tsquery_1.query)(sourceFile, `Identifier[name=${name}]`);
350
+ const matchedAsName = ids.some(isPropertyOrMethodName);
351
+ if (matchedAsName) {
352
+ unhandled.push(`${filePath}: custom reporter uses the removed-in-v4 \`${name}\` callback. ` +
353
+ `Migrate to the v4 \`onTestModuleCollected\` / \`onTestCaseResult\` / \`onTestRunEnd\` API.`);
354
+ }
355
+ }
356
+ }
357
+ function isPropertyOrMethodName(id) {
358
+ const parent = id.parent;
359
+ if (!parent)
360
+ return false;
361
+ if (!(ts.isPropertyAssignment(parent) ||
362
+ ts.isShorthandPropertyAssignment(parent) ||
363
+ ts.isMethodDeclaration(parent) ||
364
+ ts.isGetAccessorDeclaration(parent) ||
365
+ ts.isSetAccessorDeclaration(parent) ||
366
+ ts.isMethodSignature(parent) ||
367
+ ts.isPropertySignature(parent))) {
368
+ return false;
369
+ }
370
+ // The identifier must be the NAME of the property/method, not an
371
+ // initializer-side reference.
372
+ return parent.name === id;
373
+ }
374
+ /**
375
+ * Handles import-side concerns:
376
+ * - `@vitest/browser/context` → `vitest/browser` (V4-3a, mechanical when
377
+ * the subpath is exactly `/context`).
378
+ * - `@vitest/browser/utils` (logged — semantic restructure).
379
+ * - `defineWorkspace` from `vitest/config` (logged — file removed in v4).
380
+ * - `jest-snapshot` imports inside custom matcher files (logged).
381
+ */
382
+ function processImportsAndCustomCode(tree, filePath, unhandled) {
383
+ const contents = tree.read(filePath, 'utf-8');
384
+ const hasBrowserPackage = contents.includes('@vitest/browser');
385
+ const hasDefineWorkspace = contents.includes('defineWorkspace');
386
+ const hasJestSnapshot = contents.includes('jest-snapshot');
387
+ if (!hasBrowserPackage && !hasDefineWorkspace && !hasJestSnapshot)
388
+ return;
389
+ const sourceFile = (0, tsquery_1.ast)(contents);
390
+ const importDecls = (0, tsquery_1.query)(sourceFile, 'ImportDeclaration');
391
+ const edits = [];
392
+ for (const decl of importDecls) {
393
+ if (!ts.isStringLiteral(decl.moduleSpecifier))
394
+ continue;
395
+ const spec = decl.moduleSpecifier.text;
396
+ if (spec === '@vitest/browser/context') {
397
+ edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, decl.moduleSpecifier, 'vitest/browser'));
398
+ }
399
+ else if (spec === '@vitest/browser/utils') {
400
+ unhandled.push(`${filePath}: imports from '@vitest/browser/utils' moved into 'vitest/browser' under a named \`utils\` export in Vitest 4. ` +
401
+ `Rewrite to \`import { utils } from 'vitest/browser'\` and rebind the used helpers.`);
402
+ }
403
+ else if (spec === '@vitest/browser') {
404
+ unhandled.push(`${filePath}: bare \`@vitest/browser\` imports — the package's public surface moved into \`vitest/browser\` (and the per-provider packages) in Vitest 4. ` +
405
+ `Rewrite the import to \`vitest/browser\` and rebind any helpers that moved to the per-provider package.`);
406
+ }
407
+ else if (spec === 'jest-snapshot') {
408
+ unhandled.push(`${filePath}: custom matcher imports from 'jest-snapshot'. ` +
409
+ `In Vitest 4, import the snapshot helpers from 'vitest' via the \`Snapshots\` namespace.`);
410
+ }
411
+ // `defineWorkspace` is named-imported from vitest/config — flag any usage.
412
+ const named = decl.importClause?.namedBindings;
413
+ if (named && ts.isNamedImports(named)) {
414
+ const usesDefineWorkspace = named.elements.some((el) => el.name.text === 'defineWorkspace');
415
+ if (usesDefineWorkspace) {
416
+ unhandled.push(`${filePath}: \`defineWorkspace\` is removed in Vitest 4 (the external \`vitest.workspace.*\` form is gone). ` +
417
+ `Replace with \`defineConfig\` and inline the project list under \`test.projects\` in the root vitest config.`);
418
+ }
419
+ }
420
+ }
421
+ if (edits.length > 0) {
422
+ tree.write(filePath, (0, ast_edits_1.applyTextEdits)(contents, edits));
423
+ }
424
+ }
425
+ const ENV_RENAMES = [
426
+ [/\bVITEST_MAX_THREADS\b/g, 'VITEST_MAX_WORKERS'],
427
+ [/\bVITEST_MAX_FORKS\b/g, 'VITEST_MAX_WORKERS'],
428
+ [/\bVITE_NODE_DEPS_MODULE_DIRECTORIES\b/g, 'VITEST_MODULE_DIRECTORIES'],
429
+ ];
430
+ function processPackageJson(tree, filePath, unhandled) {
431
+ const contents = tree.read(filePath, 'utf-8');
432
+ let parsed;
433
+ try {
434
+ parsed = JSON.parse(contents);
435
+ }
436
+ catch {
437
+ return;
438
+ }
439
+ let changed = false;
440
+ if (parsed.scripts && typeof parsed.scripts === 'object') {
441
+ for (const [name, value] of Object.entries(parsed.scripts)) {
442
+ if (typeof value !== 'string')
443
+ continue;
444
+ let updated = value;
445
+ for (const [re, replacement] of ENV_RENAMES) {
446
+ updated = updated.replace(re, replacement);
447
+ }
448
+ if (updated !== value) {
449
+ parsed.scripts[name] = updated;
450
+ changed = true;
451
+ }
452
+ }
453
+ }
454
+ // `@vitest/browser` surface moved into the main `vitest` package and the
455
+ // per-provider packages. Removing the dep without installing the new
456
+ // provider package would break browser runs, so log instead of editing.
457
+ for (const bucket of [
458
+ 'dependencies',
459
+ 'devDependencies',
460
+ 'peerDependencies',
461
+ 'optionalDependencies',
462
+ ]) {
463
+ const deps = parsed[bucket];
464
+ if (!deps || typeof deps !== 'object')
465
+ continue;
466
+ if ('@vitest/browser' in deps) {
467
+ unhandled.push(`${filePath}: \`@vitest/browser\` is no longer needed at the top level in Vitest 4 — ` +
468
+ `install the matching per-provider package (\`@vitest/browser-playwright\`, \`@vitest/browser-webdriverio\`, ` +
469
+ `or \`@vitest/browser-preview\`) and remove the \`@vitest/browser\` entry.`);
470
+ break; // one note per file is enough
471
+ }
472
+ }
473
+ if (changed) {
474
+ const trailingNewline = contents.endsWith('\n') ? '\n' : '';
475
+ tree.write(filePath, JSON.stringify(parsed, null, 2) + trailingNewline);
476
+ }
477
+ }
478
+ /**
479
+ * Rewrite `.env` / `.env.*` files: rename the legacy Vitest env vars to the
480
+ * v4-aware names. `VITEST_MAX_THREADS`/`VITEST_MAX_FORKS` only get renamed
481
+ * when exactly one of them is present in the file — both map to the same
482
+ * pool-agnostic `VITEST_MAX_WORKERS`, so a blind rename would produce two
483
+ * conflicting assignments (the latter would silently win per dotenv
484
+ * semantics). When both are present the rename is skipped and the conflict
485
+ * is forwarded to the agent.
486
+ */
487
+ function processEnvFile(tree, filePath, unhandled) {
488
+ const contents = tree.read(filePath, 'utf-8');
489
+ if (!/(?:VITEST_MAX_THREADS|VITEST_MAX_FORKS|VITE_NODE_DEPS_MODULE_DIRECTORIES)\b/.test(contents)) {
490
+ return;
491
+ }
492
+ const hasThreads = /^(?:\s*(?:export\s+)?)VITEST_MAX_THREADS\b/m.test(contents);
493
+ const hasForks = /^(?:\s*(?:export\s+)?)VITEST_MAX_FORKS\b/m.test(contents);
494
+ let updated = contents;
495
+ if (hasThreads && hasForks) {
496
+ unhandled.push(`${filePath}: both \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` are set — both collapse to the single pool-agnostic \`VITEST_MAX_WORKERS\` in Vitest 4. ` +
497
+ `Decide which value should win based on the pool the project uses (\`test.pool\` in the vitest config) and rename only that line.`);
498
+ }
499
+ else if (hasThreads) {
500
+ updated = updated.replace(/^((?:\s*(?:export\s+)?))VITEST_MAX_THREADS\b/gm, '$1VITEST_MAX_WORKERS');
501
+ }
502
+ else if (hasForks) {
503
+ updated = updated.replace(/^((?:\s*(?:export\s+)?))VITEST_MAX_FORKS\b/gm, '$1VITEST_MAX_WORKERS');
504
+ }
505
+ updated = updated.replace(/^((?:\s*(?:export\s+)?))VITE_NODE_DEPS_MODULE_DIRECTORIES\b/gm, '$1VITEST_MODULE_DIRECTORIES');
506
+ if (updated !== contents) {
507
+ tree.write(filePath, updated);
508
+ }
509
+ }
510
+ /**
511
+ * Walk all targets in `project.json` and apply v4 env-var renames inline:
512
+ * - `options.env` keys
513
+ * - `options.{args,command,commands}` string content (VAR=value prefixes)
514
+ * Same conflict guard as `.env` files.
515
+ */
516
+ function processProjectJson(tree, filePath, unhandled) {
517
+ const contents = tree.read(filePath, 'utf-8');
518
+ let parsed;
519
+ try {
520
+ parsed = JSON.parse(contents);
521
+ }
522
+ catch {
523
+ return;
524
+ }
525
+ const targets = parsed?.targets;
526
+ if (!targets || typeof targets !== 'object')
527
+ return;
528
+ let changed = false;
529
+ for (const [targetName, target] of Object.entries(targets)) {
530
+ const options = target?.options;
531
+ if (!options || typeof options !== 'object')
532
+ continue;
533
+ // `options.env`: rename keys, with the same threads/forks conflict guard.
534
+ if (options.env && typeof options.env === 'object') {
535
+ const hasThreads = 'VITEST_MAX_THREADS' in options.env;
536
+ const hasForks = 'VITEST_MAX_FORKS' in options.env;
537
+ if (hasThreads && hasForks) {
538
+ unhandled.push(`${filePath} (target \`${targetName}\`): both \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` are set in \`options.env\` — both collapse to \`VITEST_MAX_WORKERS\` in Vitest 4. ` +
539
+ `Decide which value should win and rename only that key.`);
540
+ }
541
+ else if (hasThreads) {
542
+ options.env = renameObjectKey(options.env, 'VITEST_MAX_THREADS', 'VITEST_MAX_WORKERS');
543
+ changed = true;
544
+ }
545
+ else if (hasForks) {
546
+ options.env = renameObjectKey(options.env, 'VITEST_MAX_FORKS', 'VITEST_MAX_WORKERS');
547
+ changed = true;
548
+ }
549
+ if ('VITE_NODE_DEPS_MODULE_DIRECTORIES' in options.env) {
550
+ options.env = renameObjectKey(options.env, 'VITE_NODE_DEPS_MODULE_DIRECTORIES', 'VITEST_MODULE_DIRECTORIES');
551
+ changed = true;
552
+ }
553
+ }
554
+ // `options.args` / `options.command` / `options.commands`: in-string renames.
555
+ if (typeof options.args === 'string') {
556
+ const rewritten = rewriteInlineEnvVars(options.args, filePath, targetName, unhandled);
557
+ if (rewritten !== options.args) {
558
+ options.args = rewritten;
559
+ changed = true;
560
+ }
561
+ }
562
+ else if (Array.isArray(options.args)) {
563
+ for (let i = 0; i < options.args.length; i++) {
564
+ const entry = options.args[i];
565
+ if (typeof entry !== 'string')
566
+ continue;
567
+ const rewritten = rewriteInlineEnvVars(entry, filePath, targetName, unhandled);
568
+ if (rewritten !== entry) {
569
+ options.args[i] = rewritten;
570
+ changed = true;
571
+ }
572
+ }
573
+ }
574
+ if (typeof options.command === 'string') {
575
+ const rewritten = rewriteInlineEnvVars(options.command, filePath, targetName, unhandled);
576
+ if (rewritten !== options.command) {
577
+ options.command = rewritten;
578
+ changed = true;
579
+ }
580
+ }
581
+ if (Array.isArray(options.commands)) {
582
+ for (let i = 0; i < options.commands.length; i++) {
583
+ const entry = options.commands[i];
584
+ if (typeof entry !== 'string')
585
+ continue;
586
+ const rewritten = rewriteInlineEnvVars(entry, filePath, targetName, unhandled);
587
+ if (rewritten !== entry) {
588
+ options.commands[i] = rewritten;
589
+ changed = true;
590
+ }
591
+ }
592
+ }
593
+ }
594
+ if (changed) {
595
+ const trailingNewline = contents.endsWith('\n') ? '\n' : '';
596
+ tree.write(filePath, JSON.stringify(parsed, null, 2) + trailingNewline);
597
+ }
598
+ }
599
+ function renameObjectKey(obj, oldKey, newKey) {
600
+ const rebuilt = {};
601
+ for (const [k, v] of Object.entries(obj)) {
602
+ rebuilt[k === oldKey ? newKey : k] = v;
603
+ }
604
+ return rebuilt;
605
+ }
606
+ const VITEST_MAX_THREADS_INLINE = /\bVITEST_MAX_THREADS\b/g;
607
+ const VITEST_MAX_FORKS_INLINE = /\bVITEST_MAX_FORKS\b/g;
608
+ const VITE_NODE_DEPS_INLINE = /\bVITE_NODE_DEPS_MODULE_DIRECTORIES\b/g;
609
+ function rewriteInlineEnvVars(value, filePath, targetName, unhandled) {
610
+ const hasThreads = /\bVITEST_MAX_THREADS\b/.test(value);
611
+ const hasForks = /\bVITEST_MAX_FORKS\b/.test(value);
612
+ let updated = value;
613
+ if (hasThreads && hasForks) {
614
+ unhandled.push(`${filePath} (target \`${targetName}\`): \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` both appear in the same command — both collapse to \`VITEST_MAX_WORKERS\` in Vitest 4. ` +
615
+ `Decide which value should win and rename only that occurrence.`);
616
+ }
617
+ else if (hasThreads) {
618
+ updated = updated.replace(VITEST_MAX_THREADS_INLINE, 'VITEST_MAX_WORKERS');
619
+ }
620
+ else if (hasForks) {
621
+ updated = updated.replace(VITEST_MAX_FORKS_INLINE, 'VITEST_MAX_WORKERS');
622
+ }
623
+ updated = updated.replace(VITE_NODE_DEPS_INLINE, 'VITEST_MODULE_DIRECTORIES');
624
+ return updated;
625
+ }
626
+ /**
627
+ * Scan CI provider configs for v4 legacy env-var tokens and forward the file
628
+ * paths to the agent. YAML structure varies by provider; mechanical edits
629
+ * risk breaking comments, anchors, and multi-line strings.
630
+ */
631
+ function scanCiFiles(tree, unhandled) {
632
+ (0, ci_files_1.visitCiFiles)(tree, (filePath, contents) => {
633
+ const tokens = [];
634
+ if (/\bVITEST_MAX_THREADS\b/.test(contents)) {
635
+ tokens.push('`VITEST_MAX_THREADS`');
636
+ }
637
+ if (/\bVITEST_MAX_FORKS\b/.test(contents)) {
638
+ tokens.push('`VITEST_MAX_FORKS`');
639
+ }
640
+ if (/\bVITE_NODE_DEPS_MODULE_DIRECTORIES\b/.test(contents)) {
641
+ tokens.push('`VITE_NODE_DEPS_MODULE_DIRECTORIES`');
642
+ }
643
+ if (tokens.length === 0)
644
+ return;
645
+ unhandled.push(`${filePath}: found legacy Vitest env-var token(s) in this CI config: ${tokens.join(', ')}. ` +
646
+ `Rename \`VITEST_MAX_THREADS\` / \`VITEST_MAX_FORKS\` → \`VITEST_MAX_WORKERS\` (pick a single value when both are set), and \`VITE_NODE_DEPS_MODULE_DIRECTORIES\` → \`VITEST_MODULE_DIRECTORIES\`.`);
647
+ });
648
+ }
649
+ /**
650
+ * Returns true if `node` lives inside a property whose name is
651
+ * `propertyName`, whose own parent is a property named `test`.
652
+ */
653
+ function isInsideTestSubProperty(node, propertyName) {
654
+ const owning = findEnclosingPropertyAssignment(node, propertyName);
655
+ if (!owning)
656
+ return false;
657
+ return !!findEnclosingPropertyAssignment(owning, 'test');
658
+ }
659
+ /**
660
+ * Returns true if `node`'s identifier sits as a direct property of a
661
+ * `test:` block (i.e. `test.{name}`, not `test.something.{name}`).
662
+ */
663
+ function isImmediateChildOfTest(node) {
664
+ // The Identifier's parent is a PropertyAssignment; that PA's parent is the
665
+ // ObjectLiteralExpression of `test`; that OLE's parent is the `test:`
666
+ // PropertyAssignment itself.
667
+ const pa = node.parent;
668
+ if (!pa || !ts.isPropertyAssignment(pa))
669
+ return false;
670
+ const ole = pa.parent;
671
+ if (!ole || !ts.isObjectLiteralExpression(ole))
672
+ return false;
673
+ const testPa = ole.parent;
674
+ if (!testPa || !ts.isPropertyAssignment(testPa))
675
+ return false;
676
+ return ts.isIdentifier(testPa.name) && testPa.name.text === 'test';
677
+ }
678
+ /**
679
+ * Like `isImmediateChildOfTest` but parameterised on the immediate parent's
680
+ * property name.
681
+ */
682
+ function isImmediateChildOfProperty(node, propertyName) {
683
+ const pa = node.parent;
684
+ if (!pa || !ts.isPropertyAssignment(pa))
685
+ return false;
686
+ const ole = pa.parent;
687
+ if (!ole || !ts.isObjectLiteralExpression(ole))
688
+ return false;
689
+ const ownerPa = ole.parent;
690
+ if (!ownerPa || !ts.isPropertyAssignment(ownerPa))
691
+ return false;
692
+ return ts.isIdentifier(ownerPa.name) && ownerPa.name.text === propertyName;
693
+ }
694
+ /**
695
+ * Returns true if `node`'s ancestors include a chain of property names —
696
+ * outer-most first. So `['test', 'deps', 'optimizer']` matches a node living
697
+ * inside `test.deps.optimizer.<something>` (any sub-property name, e.g.
698
+ * `web`).
699
+ */
700
+ function isInsideChain(node, chain) {
701
+ // Walk inner → outer through enclosing properties, expecting names to
702
+ // match `chain` from the last (innermost) to the first (outermost).
703
+ let current = node.parent;
704
+ let chainIndex = chain.length - 1;
705
+ while (current && chainIndex >= 0) {
706
+ if (ts.isPropertyAssignment(current) && ts.isIdentifier(current.name)) {
707
+ if (current.name.text === chain[chainIndex]) {
708
+ chainIndex--;
709
+ }
710
+ }
711
+ current = current.parent;
712
+ }
713
+ return chainIndex < 0;
714
+ }
715
+ function findEnclosingPropertyAssignment(node, name) {
716
+ let current = node.parent;
717
+ while (current) {
718
+ if (ts.isPropertyAssignment(current) &&
719
+ ts.isIdentifier(current.name) &&
720
+ current.name.text === name) {
721
+ return current;
722
+ }
723
+ current = current.parent;
724
+ }
725
+ return undefined;
726
+ }