@nx/devkit 23.2.0-beta.11 → 23.2.0-beta.12

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.
@@ -1,11 +1,24 @@
1
1
  "use strict";
2
- var _ExecutorToPluginMigrator_instances, _ExecutorToPluginMigrator_projectGraph, _ExecutorToPluginMigrator_executor, _ExecutorToPluginMigrator_pluginPath, _ExecutorToPluginMigrator_pluginOptionsBuilder, _ExecutorToPluginMigrator_postTargetTransformer, _ExecutorToPluginMigrator_skipTargetFilter, _ExecutorToPluginMigrator_skipProjectFilter, _ExecutorToPluginMigrator_specificProjectToMigrate, _ExecutorToPluginMigrator_logger, _ExecutorToPluginMigrator_nxJson, _ExecutorToPluginMigrator_targetDefaultsForExecutor, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, _ExecutorToPluginMigrator_createNodes, _ExecutorToPluginMigrator_createNodesV2, _ExecutorToPluginMigrator_createNodesResultsForTargets, _ExecutorToPluginMigrator_skippedProjects, _ExecutorToPluginMigrator_init, _ExecutorToPluginMigrator_migrateTarget, _ExecutorToPluginMigrator_migrateProject, _ExecutorToPluginMigrator_mergeInputs, _ExecutorToPluginMigrator_getTargetAndProjectsToMigrate, _ExecutorToPluginMigrator_getTargetDefaultsForExecutor, _ExecutorToPluginMigrator_getCreatedTargetForProjectRoot, _ExecutorToPluginMigrator_getCreateNodesResults;
3
2
  Object.defineProperty(exports, "__esModule", { value: true });
4
3
  exports.NoTargetsToMigrateError = void 0;
4
+ exports.stableStringify = stableStringify;
5
+ exports.collectMigrationScope = collectMigrationScope;
6
+ exports.computeResidualByProject = computeResidualByProject;
7
+ exports.computeStrictCommon = computeStrictCommon;
8
+ exports.subtractCommon = subtractCommon;
9
+ exports.removeDeadExecutorTargetDefault = removeDeadExecutorTargetDefault;
10
+ exports.appendPluginScopedTargetDefault = appendPluginScopedTargetDefault;
11
+ exports.removeHoistedTargetDefault = removeHoistedTargetDefault;
12
+ exports.packageJsonAuthorsTargetIdentity = packageJsonAuthorsTargetIdentity;
13
+ exports.isRegistrationOfPlugin = isRegistrationOfPlugin;
14
+ exports.hoistChangesExistingTargetDefaults = hoistChangesExistingTargetDefaults;
5
15
  exports.readTargetDefaultsForExecutor = readTargetDefaultsForExecutor;
6
16
  exports.migrateProjectExecutorsToPlugin = migrateProjectExecutorsToPlugin;
7
17
  exports.migrateProjectExecutorsToPluginV1 = migrateProjectExecutorsToPluginV1;
8
- const tslib_1 = require("tslib");
18
+ exports.inferOncePerOptionSet = inferOncePerOptionSet;
19
+ exports.generatedIncludeRoots = generatedIncludeRoots;
20
+ exports.harvestConfigurationErrors = harvestConfigurationErrors;
21
+ exports.buildOwnerRootByPath = buildOwnerRootByPath;
9
22
  const minimatch_1 = require("minimatch");
10
23
  const node_assert_1 = require("node:assert");
11
24
  const posix_1 = require("node:path/posix");
@@ -13,81 +26,255 @@ const devkit_exports_1 = require("nx/src/devkit-exports");
13
26
  const devkit_internals_1 = require("nx/src/devkit-internals");
14
27
  const executor_options_utils_1 = require("../executor-options-utils");
15
28
  const target_defaults_utils_1 = require("../target-defaults-utils");
29
+ const batch_conversion_session_1 = require("./batch-conversion-session");
30
+ const conversion_warnings_1 = require("./conversion-warnings");
16
31
  const plugin_migration_utils_1 = require("./plugin-migration-utils");
17
- class ExecutorToPluginMigrator {
18
- constructor(tree, projectGraph, executor, pluginPath, pluginOptionsBuilder, postTargetTransformer, createNodes, createNodesV2, specificProjectToMigrate, filters, logger) {
19
- _ExecutorToPluginMigrator_instances.add(this);
20
- _ExecutorToPluginMigrator_projectGraph.set(this, void 0);
21
- _ExecutorToPluginMigrator_executor.set(this, void 0);
22
- _ExecutorToPluginMigrator_pluginPath.set(this, void 0);
23
- _ExecutorToPluginMigrator_pluginOptionsBuilder.set(this, void 0);
24
- _ExecutorToPluginMigrator_postTargetTransformer.set(this, void 0);
25
- _ExecutorToPluginMigrator_skipTargetFilter.set(this, void 0);
26
- _ExecutorToPluginMigrator_skipProjectFilter.set(this, void 0);
27
- _ExecutorToPluginMigrator_specificProjectToMigrate.set(this, void 0);
28
- _ExecutorToPluginMigrator_logger.set(this, void 0);
29
- _ExecutorToPluginMigrator_nxJson.set(this, void 0);
30
- _ExecutorToPluginMigrator_targetDefaultsForExecutor.set(this, void 0);
31
- _ExecutorToPluginMigrator_targetAndProjectsToMigrate.set(this, void 0);
32
- _ExecutorToPluginMigrator_createNodes.set(this, void 0);
33
- _ExecutorToPluginMigrator_createNodesV2.set(this, void 0);
34
- _ExecutorToPluginMigrator_createNodesResultsForTargets.set(this, void 0);
35
- _ExecutorToPluginMigrator_skippedProjects.set(this, void 0);
36
- this.tree = tree;
37
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_projectGraph, projectGraph, "f");
38
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_executor, executor, "f");
39
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_pluginPath, pluginPath, "f");
40
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_pluginOptionsBuilder, pluginOptionsBuilder, "f");
41
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_postTargetTransformer, postTargetTransformer, "f");
42
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_createNodes, createNodes, "f");
43
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_createNodesV2, createNodesV2, "f");
44
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_specificProjectToMigrate, specificProjectToMigrate, "f");
45
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_skipProjectFilter, filters?.skipProjectFilter ?? ((...args) => false), "f");
46
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_skipTargetFilter, filters?.skipTargetFilter ?? ((...args) => false), "f");
47
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_logger, logger ?? devkit_exports_1.logger, "f");
48
- }
49
- async run() {
50
- await tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_init).call(this);
51
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").size > 0) {
52
- for (const targetName of tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").keys()) {
53
- await tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_migrateTarget).call(this, targetName);
54
- }
55
- }
56
- return tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f");
57
- }
58
- }
59
- _ExecutorToPluginMigrator_projectGraph = new WeakMap(), _ExecutorToPluginMigrator_executor = new WeakMap(), _ExecutorToPluginMigrator_pluginPath = new WeakMap(), _ExecutorToPluginMigrator_pluginOptionsBuilder = new WeakMap(), _ExecutorToPluginMigrator_postTargetTransformer = new WeakMap(), _ExecutorToPluginMigrator_skipTargetFilter = new WeakMap(), _ExecutorToPluginMigrator_skipProjectFilter = new WeakMap(), _ExecutorToPluginMigrator_specificProjectToMigrate = new WeakMap(), _ExecutorToPluginMigrator_logger = new WeakMap(), _ExecutorToPluginMigrator_nxJson = new WeakMap(), _ExecutorToPluginMigrator_targetDefaultsForExecutor = new WeakMap(), _ExecutorToPluginMigrator_targetAndProjectsToMigrate = new WeakMap(), _ExecutorToPluginMigrator_createNodes = new WeakMap(), _ExecutorToPluginMigrator_createNodesV2 = new WeakMap(), _ExecutorToPluginMigrator_createNodesResultsForTargets = new WeakMap(), _ExecutorToPluginMigrator_skippedProjects = new WeakMap(), _ExecutorToPluginMigrator_instances = new WeakSet(), _ExecutorToPluginMigrator_init = async function _ExecutorToPluginMigrator_init() {
60
- const nxJson = (0, devkit_exports_1.readNxJson)(this.tree);
61
- nxJson.plugins ??= [];
62
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_nxJson, nxJson, "f");
63
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, new Map(), "f");
64
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_createNodesResultsForTargets, new Map(), "f");
65
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_skippedProjects, new Set(), "f");
66
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_getTargetDefaultsForExecutor).call(this);
67
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_getTargetAndProjectsToMigrate).call(this);
68
- await tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_getCreateNodesResults).call(this);
69
- }, _ExecutorToPluginMigrator_migrateTarget = async function _ExecutorToPluginMigrator_migrateTarget(targetName) {
70
- for (const projectName of tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").get(targetName)) {
71
- await tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_migrateProject).call(this, projectName, targetName);
72
- }
73
- }, _ExecutorToPluginMigrator_migrateProject = async function _ExecutorToPluginMigrator_migrateProject(projectName, targetName) {
74
- const projectFromGraph = tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_projectGraph, "f").nodes[projectName];
75
- const projectConfig = (0, devkit_exports_1.readProjectConfiguration)(this.tree, projectName);
76
- const createdTarget = tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_getCreatedTargetForProjectRoot).call(this, targetName, projectFromGraph.data.root);
77
- let projectTarget = projectConfig.targets[targetName];
78
- projectTarget = (0, devkit_internals_1.mergeTargetConfigurations)(projectTarget, tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetDefaultsForExecutor, "f"));
79
- delete projectTarget.executor;
80
- (0, plugin_migration_utils_1.deleteMatchingProperties)(projectTarget, createdTarget);
81
- if (projectTarget.inputs && createdTarget.inputs) {
82
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_instances, "m", _ExecutorToPluginMigrator_mergeInputs).call(this, projectTarget, createdTarget);
83
- }
84
- projectTarget = await tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_postTargetTransformer, "f").call(this, projectTarget, this.tree, { projectName, root: projectFromGraph.data.root }, { ...createdTarget, name: targetName });
85
- if (projectTarget.options &&
86
- Object.keys(projectTarget.options).length === 0) {
87
- delete projectTarget.options;
88
- }
89
- if (Object.keys(projectTarget).length > 0) {
90
- projectConfig.targets[targetName] = projectTarget;
32
+ function stableStringify(value) {
33
+ if (value === null || typeof value !== 'object') {
34
+ return JSON.stringify(value) ?? 'undefined';
35
+ }
36
+ if (Array.isArray(value)) {
37
+ return `[${value.map(stableStringify).join(',')}]`;
38
+ }
39
+ const keys = Object.keys(value).sort();
40
+ return `{${keys
41
+ .map((k) => JSON.stringify(k) +
42
+ ':' +
43
+ stableStringify(value[k]))
44
+ .join(',')}}`;
45
+ }
46
+ /**
47
+ * Phase 0: Collect (once). Fold `forEachExecutorOptions` over every
48
+ * migration/executor into a single scope object, applying the skip filters with
49
+ * the exact same warn-vs-throw semantics the migrator used before (a
50
+ * `specificProjectToMigrate` skip throws instead of warning). This is the single
51
+ * authority for filtering; downstream phases only read the returned maps.
52
+ */
53
+ function collectMigrationScope(tree, projectGraph, migrations, defaultPluginOptions, specificProjectToMigrate, logger) {
54
+ const log = logger ?? devkit_exports_1.logger;
55
+ const pluginOptionsByProject = new Map();
56
+ // Same content as pluginOptionsByProject, accumulated in walk order so the
57
+ // conflict check below sees options claimed by earlier targets.
58
+ // pluginOptionsByProject itself is populated in the grouping loop to keep
59
+ // its target-grouped insertion order, which addPluginRegistrations turns
60
+ // into the registration order in nx.json.
61
+ const claimedOptionsByProject = new Map();
62
+ const executorScopes = [];
63
+ const optionSetGroupsByKey = new Map();
64
+ for (const migration of migrations) {
65
+ const skipProjectFilter = migration.skipProjectFilter ?? ((..._args) => false);
66
+ const skipTargetFilter = migration.skipTargetFilter ?? ((..._args) => false);
67
+ for (const executor of migration.executors) {
68
+ const targetAndProjects = new Map();
69
+ const inferenceOptionSetIdsByTarget = new Map();
70
+ const inferenceOptionsByTarget = new Map();
71
+ // Fresh per executor to preserve the previous per-migrator semantics
72
+ // (each executor got its own migrator + skipped set).
73
+ const skippedForExecutor = new Set();
74
+ (0, executor_options_utils_1.forEachExecutorOptions)(tree, executor, (options, projectName, targetName, configurationName) => {
75
+ if (skippedForExecutor.has(projectName) || configurationName) {
76
+ return;
77
+ }
78
+ if (specificProjectToMigrate &&
79
+ projectName !== specificProjectToMigrate) {
80
+ return;
81
+ }
82
+ const skipProjectReason = skipProjectFilter(projectGraph.nodes[projectName].data);
83
+ if (skipProjectReason) {
84
+ skippedForExecutor.add(projectName);
85
+ const errorMsg = `The "${projectName}" project cannot be migrated. ${skipProjectReason}`;
86
+ if (specificProjectToMigrate) {
87
+ throw new Error(errorMsg);
88
+ }
89
+ log.warn(errorMsg);
90
+ return;
91
+ }
92
+ const skipTargetReason = skipTargetFilter(options, projectGraph.nodes[projectName].data);
93
+ if (skipTargetReason) {
94
+ const errorMsg = `The ${targetName} target on project "${projectName}" cannot be migrated. ${skipTargetReason}`;
95
+ if (specificProjectToMigrate) {
96
+ throw new Error(errorMsg);
97
+ }
98
+ log.warn(errorMsg);
99
+ return;
100
+ }
101
+ if (!inferenceOptionsByTarget.has(targetName)) {
102
+ inferenceOptionsByTarget.set(targetName, migration.targetPluginOptionMapper(targetName));
103
+ }
104
+ const inferenceOptions = inferenceOptionsByTarget.get(targetName);
105
+ // One registration cannot map different target names through the
106
+ // same option
107
+ const existing = claimedOptionsByProject.get(projectName);
108
+ const takenOption = Object.keys(inferenceOptions).find((option) => existing?.[option] !== undefined &&
109
+ !isDeepEqual(existing[option], inferenceOptions[option]));
110
+ if (takenOption && !migration.allowSharedOptionOverwrite) {
111
+ const errorMsg = `The ${targetName} target on project "${projectName}" cannot be migrated. The "${takenOption}" plugin option is already set to "${existing[takenOption]}" by another target of the project, and the plugin can only infer one target per option.`;
112
+ if (specificProjectToMigrate) {
113
+ throw new Error(errorMsg);
114
+ }
115
+ log.warn(`${errorMsg} The target keeps its current configuration; rerun the migration once the other target is inferred to convert it.`);
116
+ return;
117
+ }
118
+ claimedOptionsByProject.set(projectName, {
119
+ ...(existing ?? {}),
120
+ ...inferenceOptions,
121
+ });
122
+ if (!targetAndProjects.has(targetName)) {
123
+ targetAndProjects.set(targetName, new Set());
124
+ }
125
+ targetAndProjects.get(targetName).add(projectName);
126
+ });
127
+ for (const [targetName, projs] of targetAndProjects) {
128
+ const inferenceOptions = inferenceOptionsByTarget.get(targetName);
129
+ const key = stableStringify(inferenceOptions);
130
+ if (!optionSetGroupsByKey.has(key)) {
131
+ optionSetGroupsByKey.set(key, {
132
+ id: optionSetGroupsByKey.size,
133
+ options: inferenceOptions,
134
+ targetNames: new Set(),
135
+ migratedRoots: new Set(),
136
+ });
137
+ }
138
+ const optionSetGroup = optionSetGroupsByKey.get(key);
139
+ optionSetGroup.targetNames.add(targetName);
140
+ inferenceOptionSetIdsByTarget.set(targetName, optionSetGroup.id);
141
+ for (const project of projs) {
142
+ optionSetGroup.migratedRoots.add(projectGraph.nodes[project].data.root);
143
+ pluginOptionsByProject.set(project, {
144
+ ...(pluginOptionsByProject.get(project) ?? {}),
145
+ ...inferenceOptions,
146
+ });
147
+ }
148
+ }
149
+ if (targetAndProjects.size === 0) {
150
+ continue;
151
+ }
152
+ executorScopes.push({
153
+ executor,
154
+ migration,
155
+ targetAndProjects,
156
+ inferenceOptionSetIdsByTarget,
157
+ });
158
+ }
159
+ }
160
+ // Apply default plugin options (registration options only).
161
+ for (const [project, options] of pluginOptionsByProject) {
162
+ pluginOptionsByProject.set(project, {
163
+ ...defaultPluginOptions,
164
+ ...options,
165
+ });
166
+ }
167
+ return {
168
+ pluginOptionsByProject,
169
+ optionSetGroups: [...optionSetGroupsByKey.values()],
170
+ executorScopes,
171
+ };
172
+ }
173
+ function stripInferredTarget(fullInferredTarget) {
174
+ const stripped = structuredClone(fullInferredTarget);
175
+ delete stripped.command;
176
+ delete stripped.options?.cwd;
177
+ return stripped;
178
+ }
179
+ function getFullInferredTarget(inferredTargetsByOptionSet, optionSetId, projectRoot, targetName) {
180
+ const inferredByRoot = inferredTargetsByOptionSet.get(optionSetId);
181
+ const inferredTargetsByName = inferredByRoot?.get(projectRoot);
182
+ if (!inferredTargetsByName) {
183
+ throw new Error(`The nx plugin did not find a project inside ${projectRoot}. File an issue at https://github.com/nrwl/nx with information about your project structure.`);
184
+ }
185
+ const inferredTarget = inferredTargetsByName.get(targetName);
186
+ if (!inferredTarget) {
187
+ // The plugin found the project but did not infer a "${targetName}" target
188
+ // for it under this option set: the migration and inference disagree on
189
+ // the target name.
190
+ throw new Error(`The nx plugin found a project inside ${projectRoot} but did not infer a "${targetName}" target for it. File an issue at https://github.com/nrwl/nx with information about your project structure.`);
191
+ }
192
+ return inferredTarget;
193
+ }
194
+ /**
195
+ * Phase 2: Per-project residual (in-memory, no inference). For each
196
+ * `(project, target)` computes the residual exactly as the previous engine did
197
+ * (`mergeTargetConfigurations` with the executor target defaults ->
198
+ * `deleteMatchingProperties` -> input merge -> the plugin's
199
+ * `postTargetTransformer`), plus `baselineFinal = merge(residual, inferred)` as
200
+ * the equivalence oracle. Does NOT write project.json.
201
+ */
202
+ async function computeResidualByProject(tree, projectGraph, scope, inferredTargetsByOptionSet, nxJson, projectConfigsByName = (0, devkit_exports_1.getProjects)(tree)) {
203
+ const residualByProject = new Map();
204
+ for (const executorScope of scope.executorScopes) {
205
+ const targetDefaultsForExecutor = readTargetDefaultsForExecutor(executorScope.executor, nxJson.targetDefaults) ?? {};
206
+ for (const [targetName, projectNames] of executorScope.targetAndProjects) {
207
+ const optionSetId = executorScope.inferenceOptionSetIdsByTarget.get(targetName);
208
+ if (optionSetId === undefined) {
209
+ throw new Error(`Could not find an inference option set for target "${targetName}".`);
210
+ }
211
+ for (const projectName of projectNames) {
212
+ const root = projectGraph.nodes[projectName].data.root;
213
+ const fullInferredTarget = getFullInferredTarget(inferredTargetsByOptionSet, optionSetId, root, targetName);
214
+ const strippedInferredTarget = stripInferredTarget(fullInferredTarget);
215
+ const projectConfig = readCachedProjectConfiguration(tree, projectConfigsByName, projectName);
216
+ const preMigrationTarget = structuredClone(projectConfig.targets[targetName]);
217
+ // Later steps mutate the merged target, which may retain nested
218
+ // references from either input
219
+ let projectTarget = (0, devkit_internals_1.mergeTargetConfigurations)(structuredClone(projectConfig.targets[targetName]), structuredClone(targetDefaultsForExecutor));
220
+ delete projectTarget.executor;
221
+ (0, plugin_migration_utils_1.deleteMatchingProperties)(projectTarget, strippedInferredTarget);
222
+ if (projectTarget.inputs && strippedInferredTarget.inputs) {
223
+ mergeInputs(projectTarget, strippedInferredTarget);
224
+ }
225
+ projectTarget = await executorScope.migration.postTargetTransformer(projectTarget, tree, { projectName, root }, { ...strippedInferredTarget, name: targetName });
226
+ if (projectTarget.options &&
227
+ Object.keys(projectTarget.options).length === 0) {
228
+ delete projectTarget.options;
229
+ }
230
+ const residual = projectTarget;
231
+ const baselineFinal = (0, devkit_internals_1.mergeTargetConfigurations)(structuredClone(residual), structuredClone(fullInferredTarget));
232
+ if (!residualByProject.has(projectName)) {
233
+ residualByProject.set(projectName, new Map());
234
+ }
235
+ residualByProject
236
+ .get(projectName)
237
+ .set(targetName, { residual, baselineFinal, preMigrationTarget });
238
+ }
239
+ }
240
+ }
241
+ return residualByProject;
242
+ }
243
+ /**
244
+ * Phase 3 (single-project-mode variant): write the full residual into each
245
+ * project.json, exactly reproducing the previous per-(project, target) write
246
+ * sequence. Single-project mode never centralizes, so this is its permanent
247
+ * write path.
248
+ */
249
+ function writeResiduals(tree, projectConfigsByName, projectGraph, scope, residualByProject, logger) {
250
+ for (const executorScope of scope.executorScopes) {
251
+ for (const [targetName, projectNames] of executorScope.targetAndProjects) {
252
+ for (const projectName of projectNames) {
253
+ const entry = residualByProject.get(projectName)?.get(targetName);
254
+ if (!entry) {
255
+ continue;
256
+ }
257
+ entry.keptPreMigration = !writeResidualTarget(tree, projectConfigsByName, projectName, targetName, structuredClone(entry.residual), logger);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ /**
263
+ * Write a single residual target into project.json (or delete it if empty).
264
+ * Returns false, writing nothing, when package.json authors the target's
265
+ * identity: without the explicit executor an included same-name script (or an
266
+ * `nx.targets` executor/command) would replace the inferred target in the
267
+ * default plugin layer, so the pre-migration target is kept as is.
268
+ */
269
+ function writeResidualTarget(tree, projectConfigsByName, projectName, targetName, residual, logger) {
270
+ const projectConfig = readCachedProjectConfiguration(tree, projectConfigsByName, projectName);
271
+ const identitySource = packageJsonAuthorsTargetIdentity(tree, projectConfig.root, targetName);
272
+ if (identitySource) {
273
+ (logger ?? devkit_exports_1.logger).warn((0, conversion_warnings_1.keptPreMigrationTargetWarning)(targetName, projectName, identitySource));
274
+ return false;
275
+ }
276
+ if (Object.keys(residual).length > 0) {
277
+ projectConfig.targets[targetName] = residual;
91
278
  }
92
279
  else {
93
280
  delete projectConfig.targets[targetName];
@@ -96,8 +283,633 @@ _ExecutorToPluginMigrator_projectGraph = new WeakMap(), _ExecutorToPluginMigrato
96
283
  projectConfig['// targets'] =
97
284
  `to see all targets run: nx show project ${projectName} --web`;
98
285
  }
99
- (0, devkit_exports_1.updateProjectConfiguration)(this.tree, projectName, projectConfig);
100
- }, _ExecutorToPluginMigrator_mergeInputs = function _ExecutorToPluginMigrator_mergeInputs(target, inferredTarget) {
286
+ (0, devkit_exports_1.updateProjectConfiguration)(tree, projectName, projectConfig);
287
+ // `updateProjectConfiguration` writes a package-based project (no
288
+ // project.json) by spreading the config over package.json `nx`, and deletes an
289
+ // empty `targets` from this very (cached) object before the spread. Two
290
+ // consequences: the old `nx.targets` entry (executor included) survives in
291
+ // package.json, and the cache can no longer take a later write (the Phase 4
292
+ // revert). Delete the entry here and keep the cache writable.
293
+ if (!projectConfig.targets) {
294
+ projectConfig.targets = {};
295
+ deletePackageJsonTarget(tree, projectConfig.root, targetName);
296
+ }
297
+ return true;
298
+ }
299
+ function deletePackageJsonTarget(tree, root, targetName) {
300
+ const packageJsonPath = (0, posix_1.join)(root, 'package.json');
301
+ const packageJson = (0, devkit_exports_1.readJson)(tree, packageJsonPath);
302
+ if (!packageJson.nx?.targets?.[targetName]) {
303
+ return;
304
+ }
305
+ delete packageJson.nx.targets[targetName];
306
+ if (Object.keys(packageJson.nx.targets).length === 0) {
307
+ delete packageJson.nx.targets;
308
+ }
309
+ (0, devkit_exports_1.writeJson)(tree, packageJsonPath, packageJson);
310
+ }
311
+ function readCachedProjectConfiguration(tree, projectConfigsByName, projectName) {
312
+ const projectConfig = projectConfigsByName.get(projectName);
313
+ if (projectConfig) {
314
+ return projectConfig;
315
+ }
316
+ const readProjectConfig = (0, devkit_exports_1.readProjectConfiguration)(tree, projectName);
317
+ projectConfigsByName.set(projectName, readProjectConfig);
318
+ return readProjectConfig;
319
+ }
320
+ function isDeepEqual(a, b) {
321
+ try {
322
+ (0, node_assert_1.deepStrictEqual)(a, b);
323
+ return true;
324
+ }
325
+ catch {
326
+ return false;
327
+ }
328
+ }
329
+ /**
330
+ * Phase 3: the strict-common residual across ALL migrated projects for a
331
+ * target: the values that are deep-equal across every project's residual.
332
+ * Granularity: whole value for top-level target props (`inputs`, `outputs`,
333
+ * `cache`, `dependsOn`, `configurations`, ...); per-key for `options`. A key is
334
+ * common only when EVERY residual carries it with an identical value.
335
+ */
336
+ function computeStrictCommon(residuals) {
337
+ if (residuals.length === 0) {
338
+ return {};
339
+ }
340
+ const [first, ...rest] = residuals;
341
+ const common = {};
342
+ for (const key of Object.keys(first)) {
343
+ if (key === 'options') {
344
+ continue;
345
+ }
346
+ const value = first[key];
347
+ const isCommon = rest.every((residual) => {
348
+ const r = residual;
349
+ return key in r && isDeepEqual(r[key], value);
350
+ });
351
+ if (isCommon) {
352
+ common[key] = structuredClone(value);
353
+ }
354
+ }
355
+ const firstOptions = first.options;
356
+ if (firstOptions && typeof firstOptions === 'object') {
357
+ const commonOptions = {};
358
+ for (const optKey of Object.keys(firstOptions)) {
359
+ const optValue = firstOptions[optKey];
360
+ const isCommon = rest.every((residual) => {
361
+ const options = residual.options;
362
+ return (options &&
363
+ typeof options === 'object' &&
364
+ optKey in options &&
365
+ isDeepEqual(options[optKey], optValue));
366
+ });
367
+ if (isCommon) {
368
+ commonOptions[optKey] = structuredClone(optValue);
369
+ }
370
+ }
371
+ if (Object.keys(commonOptions).length > 0) {
372
+ common.options = commonOptions;
373
+ }
374
+ }
375
+ return common;
376
+ }
377
+ /** `residual` with every property that the strict-common config carries removed. */
378
+ function subtractCommon(residual, common) {
379
+ const deviation = structuredClone(residual);
380
+ for (const key of Object.keys(common)) {
381
+ if (key === 'options') {
382
+ continue;
383
+ }
384
+ const d = deviation;
385
+ if (key in d &&
386
+ isDeepEqual(d[key], common[key])) {
387
+ delete d[key];
388
+ }
389
+ }
390
+ if (common.options && deviation.options) {
391
+ const commonOptions = common.options;
392
+ const deviationOptions = deviation.options;
393
+ for (const optKey of Object.keys(commonOptions)) {
394
+ if (optKey in deviationOptions &&
395
+ isDeepEqual(deviationOptions[optKey], commonOptions[optKey])) {
396
+ delete deviationOptions[optKey];
397
+ }
398
+ }
399
+ if (Object.keys(deviationOptions).length === 0) {
400
+ delete deviation.options;
401
+ }
402
+ }
403
+ return deviation;
404
+ }
405
+ /**
406
+ * Remove the now-dead executor-keyed target default that Phase 2 inlined into
407
+ * every migrated project (mirrors `readTargetDefaultsForExecutor`'s match: the
408
+ * unfiltered entry keyed directly by the executor string).
409
+ */
410
+ function removeDeadExecutorTargetDefault(nxJson, executor) {
411
+ (0, target_defaults_utils_1.updateTargetDefault)(nxJson, { executor }, (_config, info) => info.key === executor && info.filter === undefined ? null : undefined);
412
+ }
413
+ /**
414
+ * Append the hoisted common as a plugin-scoped entry after whatever value the
415
+ * key already holds. Existing entries, the workspace catch-all and any
416
+ * user-authored filtered entries, are never modified, so targets outside this
417
+ * plugin resolve exactly what they resolved before the migration. The entry is
418
+ * appended (never merged into an existing one) so the verification pass can
419
+ * revert precisely this entry and nothing else.
420
+ */
421
+ function appendPluginScopedTargetDefault(nxJson, targetName, pluginPath, common) {
422
+ const entry = {
423
+ filter: { plugin: pluginPath },
424
+ ...structuredClone(common),
425
+ };
426
+ nxJson.targetDefaults ??= {};
427
+ const existing = nxJson.targetDefaults[targetName];
428
+ nxJson.targetDefaults[targetName] =
429
+ existing === undefined
430
+ ? [entry]
431
+ : Array.isArray(existing)
432
+ ? [...existing, entry]
433
+ : [existing, entry];
434
+ return entry;
435
+ }
436
+ /**
437
+ * Remove a previously appended plugin-scoped entry, collapsing the value back
438
+ * to the plain object form when only a lone unfiltered entry remains. The
439
+ * appended entry survives an `updateNxJson`/`readNxJson` round trip only by
440
+ * value, so the last deep-equal occurrence (append order puts ours last) is
441
+ * the one removed.
442
+ */
443
+ function removeHoistedTargetDefault(nxJson, targetName, entry) {
444
+ const value = nxJson.targetDefaults?.[targetName];
445
+ if (!Array.isArray(value)) {
446
+ return;
447
+ }
448
+ const entryKey = stableStringify(entry);
449
+ const remaining = [...value];
450
+ for (let i = remaining.length - 1; i >= 0; i--) {
451
+ if (stableStringify(remaining[i]) === entryKey) {
452
+ remaining.splice(i, 1);
453
+ break;
454
+ }
455
+ }
456
+ if (remaining.length === 0) {
457
+ delete nxJson.targetDefaults[targetName];
458
+ }
459
+ else if (remaining.length === 1 && remaining[0].filter === undefined) {
460
+ const { filter: _filter, ...config } = remaining[0];
461
+ nxJson.targetDefaults[targetName] = config;
462
+ }
463
+ else {
464
+ nxJson.targetDefaults[targetName] = remaining;
465
+ }
466
+ }
467
+ /**
468
+ * Which `package.json` signal, if any, authors an identity for `targetName` in
469
+ * the DEFAULT plugin layer. The package-json plugin turns every included script into
470
+ * an `nx:run-script` target and honors `nx.targets`; either way the target gains
471
+ * an `executor`/`command` in a default layer, which makes Nx's
472
+ * `resolveSourcePlugin` refuse a `filter: { plugin }` targetDefault for it.
473
+ * The hoist uses this to keep the full residual per project instead of silently
474
+ * dropping the centralized keys; the residual write uses it to keep the
475
+ * pre-migration target instead of letting the package.json identity take the
476
+ * target over.
477
+ *
478
+ * Read through the Tree so this sees the same in-memory package.json the rest of
479
+ * the generator reads and writes, rather than a possibly-stale copy on disk.
480
+ */
481
+ function packageJsonAuthorsTargetIdentity(tree, root, targetName) {
482
+ if (!root) {
483
+ return undefined;
484
+ }
485
+ const packageJsonPath = (0, posix_1.join)(root, 'package.json');
486
+ if (!tree.exists(packageJsonPath)) {
487
+ return undefined;
488
+ }
489
+ let packageJson;
490
+ try {
491
+ packageJson = (0, devkit_exports_1.readJson)(tree, packageJsonPath);
492
+ }
493
+ catch {
494
+ // Nx reads this file with the same jsonc-tolerant parser (`//` comments,
495
+ // trailing commas), so a failure here means inference cannot be trusted for
496
+ // it either. Fail closed: treat the identity as authored rather than hoist
497
+ // on a guess.
498
+ return 'unparseable';
499
+ }
500
+ const scripts = packageJson?.scripts ?? {};
501
+ // `readTargetsFromPackageJson` turns each *included* script into an
502
+ // `nx:run-script` target (identity). `nx.includedScripts`, when present,
503
+ // restricts which scripts become targets. Normalize a malformed (non-array)
504
+ // value to the default (all scripts) instead of letting `.includes` throw an
505
+ // uncaught TypeError mid-generator.
506
+ const nxIncludedScripts = packageJson?.nx?.includedScripts;
507
+ const includedScripts = Array.isArray(nxIncludedScripts)
508
+ ? nxIncludedScripts
509
+ : Object.keys(scripts);
510
+ if (includedScripts.includes(targetName)) {
511
+ return 'script';
512
+ }
513
+ // The `nx.targets` check below reads PRE-migration state. In a package-based
514
+ // workspace the target being migrated lives in `nx.targets` itself (that is
515
+ // where its executor is authored), so reading it here would flag every project
516
+ // as authoring its own identity, exclude them all, and centralize nothing.
517
+ // A package-based project's residual is written back to `nx.targets` without
518
+ // the migrated executor (a residual that still carries an executor/command is
519
+ // excluded by the caller before this gate), so post-migration it authors no
520
+ // identity and the hoisted `filter:{plugin}` default resolves. Gate on the
521
+ // same `project.json` signal `updateProjectConfiguration` uses to decide where
522
+ // config lives: only trust an `nx.targets` identity when the project keeps its
523
+ // config in `project.json` (there a package.json `nx.targets` entry is
524
+ // genuinely separate from the migrated target).
525
+ if (!tree.exists((0, posix_1.join)(root, 'project.json'))) {
526
+ return undefined;
527
+ }
528
+ // An `nx.targets` entry authors identity only when it says how to run.
529
+ const nxTarget = packageJson?.nx?.targets?.[targetName];
530
+ return nxTarget != null &&
531
+ (nxTarget.executor !== undefined || nxTarget.command !== undefined)
532
+ ? 'nxTargets'
533
+ : undefined;
534
+ }
535
+ function isRegistrationOfPlugin(registration, pluginPath) {
536
+ return typeof registration === 'string'
537
+ ? registration === pluginPath
538
+ : registration.plugin === pluginPath;
539
+ }
540
+ /**
541
+ * Whether every nx.json `plugins` entry after the migrated plugin's first
542
+ * registration is also the migrated plugin. Only then is the migrated plugin
543
+ * guaranteed to merge last for the targets it infers, keeping the
544
+ * executor/command attribution (and with it `filter: { plugin }`
545
+ * target-default resolution) with this plugin. No registration at all fails
546
+ * closed.
547
+ */
548
+ function pluginRegistrationsFormTail(nxJson, pluginPath) {
549
+ const plugins = nxJson.plugins ?? [];
550
+ const isMigratedPlugin = (plugin) => isRegistrationOfPlugin(plugin, pluginPath);
551
+ const firstIndex = plugins.findIndex(isMigratedPlugin);
552
+ if (firstIndex === -1) {
553
+ return false;
554
+ }
555
+ return plugins.slice(firstIndex + 1).every(isMigratedPlugin);
556
+ }
557
+ /**
558
+ * The effective executor each migrated (project, target) pair resolves to
559
+ * after the migration: the INFERRED target's executor, with a top-level
560
+ * `command` resolving to `nx:run-commands` (mirrors
561
+ * `resolveCommandSyntacticSugar`). Keyed by `"<project>\t<target>"`. Input to
562
+ * the target-default preflight, inline and at batch finalize.
563
+ */
564
+ function computeInferredExecutorByPair(scope, projectGraph, inferredTargetsByOptionSet) {
565
+ const inferredExecutorByPair = new Map();
566
+ for (const executorScope of scope.executorScopes) {
567
+ for (const [targetName, projectNames] of executorScope.targetAndProjects) {
568
+ const optionSetId = executorScope.inferenceOptionSetIdsByTarget.get(targetName);
569
+ for (const projectName of projectNames) {
570
+ const root = projectGraph.nodes[projectName]?.data.root;
571
+ const inferredTarget = getFullInferredTarget(inferredTargetsByOptionSet, optionSetId, root, targetName);
572
+ const inferredExecutor = inferredTarget.executor ??
573
+ (inferredTarget.command ? 'nx:run-commands' : undefined);
574
+ inferredExecutorByPair.set(`${projectName}\t${targetName}`, inferredExecutor);
575
+ }
576
+ }
577
+ }
578
+ return inferredExecutorByPair;
579
+ }
580
+ /**
581
+ * Whether appending the plugin-scoped `targetDefaults[targetName]` entry would
582
+ * change what the existing target defaults resolve to for any eligible migrated
583
+ * pair. Two hazards, both invisible to the Phase 4 verification (it merges no
584
+ * target defaults):
585
+ *
586
+ * - Key displacement: an exact target-name key takes precedence over a glob
587
+ * key (`build-*`), so appending one can silently stop a glob default from
588
+ * contributing to the migrated targets.
589
+ * - Executor masking: an executor-keyed default for the plugin's INFERRED
590
+ * executor (e.g. `nx:run-commands` for command-based inferred targets) takes
591
+ * precedence over the exact key, so the appended entry would never resolve
592
+ * and its keys would be silently dropped.
593
+ *
594
+ * The check resolves the defaults for each pair without and with the
595
+ * hypothetical entry, through the production reader. The hoist is a pure
596
+ * "residual moved into a default" only when the with-entry resolution equals
597
+ * the without-entry resolution with the common merged on top; anything else
598
+ * changes behavior, so the target keeps its full residuals.
599
+ */
600
+ function hoistChangesExistingTargetDefaults(targetDefaults, targetName, common, pluginPath, eligiblePairs, projectNodesByName) {
601
+ const hypotheticalNxJson = {
602
+ targetDefaults: structuredClone(targetDefaults) ?? {},
603
+ };
604
+ appendPluginScopedTargetDefault(hypotheticalNxJson, targetName, pluginPath, common);
605
+ // Per-pair context only matters to `filter.projects` entries under a key
606
+ // this target's lookup can select (its own name, one of the pairs' effective
607
+ // executors, or a glob matching the name); everything else resolves purely
608
+ // from the pair's effective executor, so pairs sharing one resolve
609
+ // identically and only the first needs checking. The appended entry carries
610
+ // no `projects` filter, so scanning the current map covers the hypothetical
611
+ // one too.
612
+ const pairExecutors = new Set();
613
+ for (const { inferredExecutor } of eligiblePairs) {
614
+ if (inferredExecutor !== undefined) {
615
+ pairExecutors.add(inferredExecutor);
616
+ }
617
+ }
618
+ const hasRelevantProjectScopedEntry = Object.entries(targetDefaults ?? {}).some(([key, value]) => {
619
+ // The glob arm mirrors the reader's own selection test exactly; a bare
620
+ // `minimatch` would over-match keys the reader never treats as globs
621
+ // (e.g. `?`, which is not in Nx's glob character set).
622
+ const keyParticipates = key === targetName ||
623
+ pairExecutors.has(key) ||
624
+ ((0, devkit_internals_1.isGlobPattern)(key) && (0, minimatch_1.minimatch)(targetName, key));
625
+ return (keyParticipates &&
626
+ (Array.isArray(value) ? value : [value ?? {}]).some((entry) => entry.filter?.projects !== undefined));
627
+ });
628
+ const checkedContexts = new Set();
629
+ for (const { projectName, inferredExecutor } of eligiblePairs) {
630
+ const contextKey = hasRelevantProjectScopedEntry
631
+ ? `${projectName}\t${inferredExecutor}`
632
+ : `${inferredExecutor}`;
633
+ if (checkedContexts.has(contextKey)) {
634
+ continue;
635
+ }
636
+ checkedContexts.add(contextKey);
637
+ const opts = {
638
+ projectName,
639
+ projectNode: projectNodesByName[projectName],
640
+ // The migrated pair's residual carries no executor/command, so the
641
+ // specified layer (this plugin) owns the identity attribution.
642
+ sourcePlugin: pluginPath,
643
+ };
644
+ const before = (0, devkit_internals_1.readTargetDefaultsForTarget)(targetName, targetDefaults, inferredExecutor, opts);
645
+ const after = (0, devkit_internals_1.readTargetDefaultsForTarget)(targetName, hypotheticalNxJson.targetDefaults, inferredExecutor, opts);
646
+ // The reader merges a key's later entries on top of earlier ones, and the
647
+ // appended entry is last, so an unchanged resolution is exactly the prior
648
+ // result with the common merged over it.
649
+ const expected = before === null
650
+ ? structuredClone(common)
651
+ : (0, devkit_internals_1.mergeTargetConfigurations)(structuredClone(common), structuredClone(before));
652
+ // stableStringify rather than isDeepEqual: deepStrictEqual also compares
653
+ // prototype identity, which rejects structurally equal objects here.
654
+ if (stableStringify(after ?? {}) !== stableStringify(expected)) {
655
+ return true;
656
+ }
657
+ }
658
+ return false;
659
+ }
660
+ /**
661
+ * Phase 3 (centralized variant): hoist the strict-common residual per target
662
+ * into `nx.json` `targetDefaults[targetName]` as a plugin-scoped array entry,
663
+ * remove the dead executor-keyed entries, and write only per-project
664
+ * deviations to project.json. Used for whole-workspace migrations;
665
+ * single-project mode keeps the full residual. Hoisting also requires the
666
+ * plugin's registrations to form the tail of nx.json `plugins` (see
667
+ * `pluginRegistrationsFormTail`), and the appended
668
+ * entry to leave existing target-default resolution unchanged (see
669
+ * `hoistChangesExistingTargetDefaults`); otherwise the affected projects keep
670
+ * their full residuals.
671
+ *
672
+ * Returns the appended entry per target so the verification pass can revert a
673
+ * hoist that turns out to reach a target the migration did not migrate.
674
+ */
675
+ function hoistCommonAndWrite(tree, projectConfigsByName, scope, residualByProject, pluginPath, projectGraph, inferredTargetsByOptionSet, inferredExecutors, pluginPreRegistered, logger) {
676
+ // Group residuals by target name across all migrated projects, tracking which
677
+ // projects contribute each target so we can inspect their default-layer config.
678
+ const residualsByTarget = new Map();
679
+ const projectsByTarget = new Map();
680
+ for (const [projectName, targetMap] of residualByProject) {
681
+ for (const [targetName, entry] of targetMap) {
682
+ if (!residualsByTarget.has(targetName)) {
683
+ residualsByTarget.set(targetName, []);
684
+ projectsByTarget.set(targetName, []);
685
+ }
686
+ residualsByTarget.get(targetName).push(entry.residual);
687
+ projectsByTarget.get(targetName).push(projectName);
688
+ }
689
+ }
690
+ const commonByTarget = new Map();
691
+ // Projects whose target identity is authored in a DEFAULT layer, per target.
692
+ // Nx's `resolveSourcePlugin` refuses a `filter: { plugin }` default for such a
693
+ // target (see nx/.../project-configuration/target-defaults.ts), so those
694
+ // projects can't inherit the hoisted keys and must carry the full config
695
+ // themselves. The partition is PER-PROJECT: one project with authored identity
696
+ // no longer blocks centralization for its siblings.
697
+ const excludedProjectsByTarget = new Map();
698
+ // package.json can reintroduce a migrated executor at resolution after the
699
+ // explicit target is rewritten, invisible to the liveness scan below (the
700
+ // package config is ignored when a sibling project.json exists).
701
+ let packageJsonAuthoredIdentity = false;
702
+ // Deterministic target-name order for stable nx.json output.
703
+ for (const targetName of [...residualsByTarget.keys()].sort()) {
704
+ const residuals = residualsByTarget.get(targetName);
705
+ const projects = projectsByTarget.get(targetName) ?? [];
706
+ // Partition the target's projects. A target's identity is authored in a
707
+ // default layer when either:
708
+ // - the project.json residual carries `executor`/`command` (e.g. @nx/detox
709
+ // stamps a per-project `command`), or
710
+ // - the package-json plugin authors it: a package.json script byte-equal
711
+ // to the target name becomes an `nx:run-script` target (and `nx.targets`
712
+ // is honored).
713
+ // Such projects are EXCLUDED from the hoist (they keep the full residual,
714
+ // or the pre-migration target when package.json authors the identity, see
715
+ // `writeResidualTarget`); the rest are hoist-eligible.
716
+ const excludedProjects = new Set();
717
+ const eligibleResiduals = [];
718
+ for (let i = 0; i < projects.length; i++) {
719
+ const projectName = projects[i];
720
+ const residual = residuals[i];
721
+ const packageJsonAuthored = packageJsonAuthorsTargetIdentity(tree, projectConfigsByName.get(projectName)?.root, targetName);
722
+ if (packageJsonAuthored) {
723
+ packageJsonAuthoredIdentity = true;
724
+ }
725
+ const identityAuthored = residual.executor !== undefined ||
726
+ residual.command !== undefined ||
727
+ packageJsonAuthored;
728
+ if (identityAuthored) {
729
+ excludedProjects.add(projectName);
730
+ }
731
+ else {
732
+ eligibleResiduals.push(residual);
733
+ }
734
+ }
735
+ excludedProjectsByTarget.set(targetName, excludedProjects);
736
+ // Centralization only pays off when at least two ELIGIBLE projects share the
737
+ // target; the common is computed over eligible residuals only, so an excluded
738
+ // project's config never leaks into the shared default.
739
+ const common = eligibleResiduals.length >= 2
740
+ ? computeStrictCommon(eligibleResiduals)
741
+ : {};
742
+ commonByTarget.set(targetName, common);
743
+ }
744
+ // Drop the given targets' centralization (they keep full residuals) and say
745
+ // why. The retention is lossless: the resolved configuration is unchanged,
746
+ // only the deduplication into `targetDefaults` is skipped.
747
+ const retainResiduals = (targetNames, reason) => {
748
+ for (const targetName of targetNames) {
749
+ commonByTarget.set(targetName, {});
750
+ }
751
+ (logger ?? devkit_exports_1.logger).warn((0, conversion_warnings_1.retainedResidualsWarning)(targetNames, reason));
752
+ };
753
+ const centralizableTargets = () => [...commonByTarget.entries()]
754
+ .filter(([, common]) => Object.keys(common).length > 0)
755
+ .map(([targetName]) => targetName)
756
+ .sort();
757
+ // A specified plugin registered AFTER the migrated plugin merges later, so a
758
+ // same-name target it authors takes the executor/command attribution;
759
+ // `resolveSourcePlugin` then names that plugin and rejects this plugin's
760
+ // `filter: { plugin }` default, silently dropping the hoisted keys. The
761
+ // verification pass loads only the migrated plugin, so it cannot observe
762
+ // this. Registrations are written before this runs, so the plugins array is
763
+ // final for THIS generator invocation: hoist only when the migrated plugin's
764
+ // registrations form its tail; otherwise keep the full residuals (the
765
+ // previous engine's output). A batch runner invoking several conversions
766
+ // against one Tree appends more registrations after this one returns, where
767
+ // this tail check would pass on a plugins array that is not final; such
768
+ // invocations never reach this hoist, since a batch child defers
769
+ // centralization entirely to the finalize pass (see BatchConversionSession).
770
+ if (!pluginRegistrationsFormTail((0, devkit_exports_1.readNxJson)(tree), pluginPath)) {
771
+ const skippedTargets = centralizableTargets();
772
+ if (skippedTargets.length > 0) {
773
+ retainResiduals(skippedTargets, `another plugin is registered after ${pluginPath} in nx.json and may take over those targets`);
774
+ }
775
+ }
776
+ // Target-default preflight: even with the plugin merging last, appending the
777
+ // exact target-name entry can change what the EXISTING defaults resolve to
778
+ // for the migrated pairs (see `hoistChangesExistingTargetDefaults`). Checked
779
+ // per pair with the production reader; a rejected target keeps its full
780
+ // residuals. In-memory only, no inference or graph pass.
781
+ const preflightTargets = centralizableTargets();
782
+ if (preflightTargets.length > 0) {
783
+ const currentTargetDefaults = (0, devkit_exports_1.readNxJson)(tree).targetDefaults;
784
+ // The pair's effective executor decides whether an executor-keyed default
785
+ // outranks the appended exact key.
786
+ const inferredExecutorByPair = computeInferredExecutorByPair(scope, projectGraph, inferredTargetsByOptionSet);
787
+ const nonExactNameTargets = [];
788
+ const rejectedTargets = [];
789
+ for (const targetName of preflightTargets) {
790
+ // A `targetDefaults` key resolves as an EXECUTOR key for any target
791
+ // whose effective executor equals it (executor strings are plain
792
+ // strings, no `:` required), and as a glob key when it contains glob
793
+ // characters. So a candidate name that is executor-like (`a:b`),
794
+ // glob-like, or equal to ANY executor this plugin's inference emits
795
+ // cannot be hoisted: the appended key would apply the common to OTHER
796
+ // targets of this plugin, invisible to the per-pair check below (it
797
+ // resolves only the migrated pairs) and to the Phase 4 exposure revert
798
+ // (it looks only for this target name). Rejecting here also keeps the
799
+ // surviving candidates non-interacting, so checking each against the
800
+ // current map alone stays sound.
801
+ if (!(0, target_defaults_utils_1.isExactTargetNameKey)(targetName) ||
802
+ inferredExecutors.has(targetName)) {
803
+ nonExactNameTargets.push(targetName);
804
+ continue;
805
+ }
806
+ const excludedProjects = excludedProjectsByTarget.get(targetName) ?? new Set();
807
+ const eligiblePairs = (projectsByTarget.get(targetName) ?? [])
808
+ .filter((projectName) => !excludedProjects.has(projectName))
809
+ .map((projectName) => ({
810
+ projectName,
811
+ inferredExecutor: inferredExecutorByPair.get(`${projectName}\t${targetName}`),
812
+ }));
813
+ if (hoistChangesExistingTargetDefaults(currentTargetDefaults, targetName, commonByTarget.get(targetName), pluginPath, eligiblePairs, projectGraph.nodes)) {
814
+ rejectedTargets.push(targetName);
815
+ }
816
+ }
817
+ if (nonExactNameTargets.length > 0) {
818
+ retainResiduals(nonExactNameTargets, 'the target name would resolve as an executor or glob targetDefaults key and could apply to other targets');
819
+ }
820
+ if (rejectedTargets.length > 0) {
821
+ retainResiduals(rejectedTargets, 'centralization would change which existing targetDefaults apply');
822
+ }
823
+ }
824
+ // Write per-project deviations first so migrated targets drop their executor
825
+ // before we decide whether an executor-keyed target default is still needed.
826
+ for (const executorScope of scope.executorScopes) {
827
+ for (const [targetName, projectNames] of executorScope.targetAndProjects) {
828
+ const common = commonByTarget.get(targetName) ?? {};
829
+ const excludedProjects = excludedProjectsByTarget.get(targetName) ?? new Set();
830
+ for (const projectName of projectNames) {
831
+ const entry = residualByProject.get(projectName)?.get(targetName);
832
+ if (!entry) {
833
+ continue;
834
+ }
835
+ // Excluded projects keep the FULL residual: the plugin-scoped default
836
+ // won't resolve for them, so nothing may be subtracted. Eligible projects
837
+ // drop the hoisted common and keep only their deviation.
838
+ const toWrite = excludedProjects.has(projectName)
839
+ ? structuredClone(entry.residual)
840
+ : subtractCommon(entry.residual, common);
841
+ entry.keptPreMigration = !writeResidualTarget(tree, projectConfigsByName, projectName, targetName, toWrite, logger);
842
+ }
843
+ }
844
+ }
845
+ // Update nx.json targetDefaults: hoist commons, drop dead executor entries.
846
+ const nxJson = (0, devkit_exports_1.readNxJson)(tree);
847
+ nxJson.targetDefaults ??= {};
848
+ const hoistedByTarget = new Map();
849
+ for (const targetName of [...commonByTarget.keys()].sort()) {
850
+ const common = commonByTarget.get(targetName);
851
+ if (Object.keys(common).length === 0) {
852
+ continue;
853
+ }
854
+ hoistedByTarget.set(targetName, appendPluginScopedTargetDefault(nxJson, targetName, pluginPath, common));
855
+ }
856
+ // A pre-existing registration of this plugin (or a plugin after it) can win
857
+ // a migrated pair's identity after the explicit target is rewritten, and a
858
+ // package-authored identity resurfaces the same way. A registration this
859
+ // migration ADDS is appended last and wins identity itself, so only fresh
860
+ // registrations remove dead executor entries; otherwise fail open.
861
+ // `pluginPreRegistered` is captured by the caller BEFORE registrations are
862
+ // written (they now precede this).
863
+ if (!pluginPreRegistered && !packageJsonAuthoredIdentity) {
864
+ // An executor-keyed targetDefault applies to any RESOLVED target carrying
865
+ // the executor, so liveness needs every source of one: post-write explicit
866
+ // targets, graph targets of untouched pairs (migrated pairs still show the
867
+ // pre-write executor), and everything this plugin's own inference emits.
868
+ const liveExecutors = new Set(inferredExecutors);
869
+ for (const projectConfig of projectConfigsByName.values()) {
870
+ for (const target of Object.values(projectConfig.targets ?? {})) {
871
+ if (target.executor) {
872
+ liveExecutors.add(target.executor);
873
+ }
874
+ }
875
+ }
876
+ for (const [projectName, node] of Object.entries(projectGraph.nodes)) {
877
+ for (const [targetName, target] of Object.entries(node.data.targets ?? {})) {
878
+ if (target.executor &&
879
+ !residualByProject.get(projectName)?.has(targetName)) {
880
+ liveExecutors.add(target.executor);
881
+ }
882
+ }
883
+ }
884
+ for (const executorScope of scope.executorScopes) {
885
+ if (!liveExecutors.has(executorScope.executor)) {
886
+ removeDeadExecutorTargetDefault(nxJson, executorScope.executor);
887
+ }
888
+ }
889
+ }
890
+ if (nxJson.targetDefaults &&
891
+ Object.keys(nxJson.targetDefaults).length === 0) {
892
+ delete nxJson.targetDefaults;
893
+ }
894
+ (0, devkit_exports_1.updateNxJson)(tree, nxJson);
895
+ // A per-project exclusion is otherwise silent (unlike a Phase 4 revert, which
896
+ // warns): the excluded projects simply keep their per-project configuration
897
+ // and nothing is centralized for them. Surface it so a partial or total
898
+ // non-centralization is never indistinguishable from "centralization did not
899
+ // apply".
900
+ const excludedTargets = [...excludedProjectsByTarget.entries()].filter(([, projects]) => projects.size > 0);
901
+ if (excludedTargets.length > 0) {
902
+ const excludedProjectNames = [
903
+ ...new Set(excludedTargets.flatMap(([, projects]) => [...projects])),
904
+ ].sort();
905
+ const targetNames = excludedTargets
906
+ .map(([targetName]) => targetName)
907
+ .sort();
908
+ (logger ?? devkit_exports_1.logger).warn((0, conversion_warnings_1.excludedProjectsWarning)(excludedProjectNames, targetNames));
909
+ }
910
+ return hoistedByTarget;
911
+ }
912
+ function mergeInputs(target, inferredTarget) {
101
913
  const isInputInferred = (input) => {
102
914
  return inferredTarget.inputs.some((inferredInput) => {
103
915
  try {
@@ -132,73 +944,7 @@ _ExecutorToPluginMigrator_projectGraph = new WeakMap(), _ExecutorToPluginMigrato
132
944
  ...inferredTargetExternalDependencyInput.externalDependencies,
133
945
  ]));
134
946
  }
135
- }, _ExecutorToPluginMigrator_getTargetAndProjectsToMigrate = function _ExecutorToPluginMigrator_getTargetAndProjectsToMigrate() {
136
- (0, executor_options_utils_1.forEachExecutorOptions)(this.tree, tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_executor, "f"), (options, projectName, targetName, configurationName) => {
137
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_skippedProjects, "f").has(projectName) || configurationName) {
138
- return;
139
- }
140
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_specificProjectToMigrate, "f") &&
141
- projectName !== tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_specificProjectToMigrate, "f")) {
142
- return;
143
- }
144
- const skipProjectReason = tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_skipProjectFilter, "f").call(this, tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_projectGraph, "f").nodes[projectName].data);
145
- if (skipProjectReason) {
146
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_skippedProjects, "f").add(projectName);
147
- const errorMsg = `The "${projectName}" project cannot be migrated. ${skipProjectReason}`;
148
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_specificProjectToMigrate, "f")) {
149
- throw new Error(errorMsg);
150
- }
151
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_logger, "f").warn(errorMsg);
152
- return;
153
- }
154
- const skipTargetReason = tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_skipTargetFilter, "f").call(this, options, tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_projectGraph, "f").nodes[projectName].data);
155
- if (skipTargetReason) {
156
- const errorMsg = `The ${targetName} target on project "${projectName}" cannot be migrated. ${skipTargetReason}`;
157
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_specificProjectToMigrate, "f")) {
158
- throw new Error(errorMsg);
159
- }
160
- else {
161
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_logger, "f").warn(errorMsg);
162
- }
163
- return;
164
- }
165
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").has(targetName)) {
166
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").get(targetName).add(projectName);
167
- }
168
- else {
169
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").set(targetName, new Set([projectName]));
170
- }
171
- });
172
- }, _ExecutorToPluginMigrator_getTargetDefaultsForExecutor = function _ExecutorToPluginMigrator_getTargetDefaultsForExecutor() {
173
- tslib_1.__classPrivateFieldSet(this, _ExecutorToPluginMigrator_targetDefaultsForExecutor, structuredClone(readTargetDefaultsForExecutor(tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_executor, "f"), tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_nxJson, "f").targetDefaults) ?? {}), "f");
174
- }, _ExecutorToPluginMigrator_getCreatedTargetForProjectRoot = function _ExecutorToPluginMigrator_getCreatedTargetForProjectRoot(targetName, projectRoot) {
175
- const entry = Object.entries(tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_createNodesResultsForTargets, "f").get(targetName)?.projects ?? {}).find(([root]) => root === projectRoot);
176
- if (!entry) {
177
- throw new Error(`The nx plugin did not find a project inside ${projectRoot}. File an issue at https://github.com/nrwl/nx with information about your project structure.`);
178
- }
179
- const createdProject = entry[1];
180
- const createdTarget = structuredClone(createdProject.targets[targetName]);
181
- delete createdTarget.command;
182
- delete createdTarget.options?.cwd;
183
- return createdTarget;
184
- }, _ExecutorToPluginMigrator_getCreateNodesResults = async function _ExecutorToPluginMigrator_getCreateNodesResults() {
185
- if (tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").size === 0) {
186
- return;
187
- }
188
- global.NX_GRAPH_CREATION = true;
189
- try {
190
- for (const targetName of tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_targetAndProjectsToMigrate, "f").keys()) {
191
- const result = await getCreateNodesResultsForPlugin(this.tree, {
192
- plugin: tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_pluginPath, "f"),
193
- options: tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_pluginOptionsBuilder, "f").call(this, targetName),
194
- }, tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_pluginPath, "f"), tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_createNodes, "f"), tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_createNodesV2, "f"), tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_nxJson, "f"));
195
- tslib_1.__classPrivateFieldGet(this, _ExecutorToPluginMigrator_createNodesResultsForTargets, "f").set(targetName, result);
196
- }
197
- }
198
- finally {
199
- global.NX_GRAPH_CREATION = false;
200
- }
201
- };
947
+ }
202
948
  class NoTargetsToMigrateError extends Error {
203
949
  constructor() {
204
950
  super('Could not find any targets to migrate.');
@@ -229,101 +975,739 @@ async function migrateProjectExecutorsToPluginV1(tree, projectGraph, pluginPath,
229
975
  const projects = await migrateProjects(tree, projectGraph, pluginPath, createNodes, undefined, defaultPluginOptions, migrations, specificProjectToMigrate);
230
976
  return projects;
231
977
  }
232
- async function migrateProjects(tree, projectGraph, pluginPath, createNodes, createNodesV2, defaultPluginOptions, migrations, specificProjectToMigrate, logger) {
233
- const projects = new Map();
234
- const spinner = devkit_internals_1.globalSpinner.start(`Calculating migration scope...`, pluginPath);
235
- for (const migration of migrations) {
236
- for (const executor of migration.executors) {
237
- const migrator = new ExecutorToPluginMigrator(tree, projectGraph, executor, pluginPath, migration.targetPluginOptionMapper, migration.postTargetTransformer, createNodes, createNodesV2, specificProjectToMigrate, {
238
- skipProjectFilter: migration.skipProjectFilter,
239
- skipTargetFilter: migration.skipTargetFilter,
240
- }, logger);
241
- const result = await migrator.run();
242
- // invert the result to have a map of projects to their targets
243
- for (const [target, projectList] of result.entries()) {
244
- for (const project of projectList) {
245
- if (!projects.has(project)) {
246
- projects.set(project, {});
978
+ /**
979
+ * Phase 1: Infer (once per distinct option set). Runs a whole-workspace
980
+ * inference per distinct plugin-option set (usually one) instead of once per
981
+ * target and once per project. Builds `inferredTargetsByOptionSet` (option set
982
+ * id -> project root -> target name -> FULL inferred target; residual
983
+ * computation strips `command` / `options.cwd` at the point of use), which
984
+ * Phase 2 (`computeResidualByProject`) reads to compute residuals and
985
+ * `baselineFinal`; plus the matched config files owned by an inferred project
986
+ * root, which Phase 3's registration step reads for analytic include coverage.
987
+ */
988
+ async function inferOncePerOptionSet(tree, pluginPath, createNodes, createNodesV2, nxJson, scope) {
989
+ const inferredTargetsByOptionSet = new Map();
990
+ const rawMatchedFiles = new Set();
991
+ const inferredRoots = new Set();
992
+ const erroredConfigFiles = new Set();
993
+ const inferredExecutors = new Set();
994
+ if (scope.optionSetGroups.length === 0) {
995
+ return {
996
+ inferredTargetsByOptionSet,
997
+ rawMatchedConfigFiles: [],
998
+ matchedConfigFiles: [],
999
+ erroredConfigFiles: [],
1000
+ inferredExecutors,
1001
+ inferredRoots,
1002
+ };
1003
+ }
1004
+ global.NX_GRAPH_CREATION = true;
1005
+ try {
1006
+ for (const group of scope.optionSetGroups) {
1007
+ const inferredByRoot = new Map();
1008
+ inferredTargetsByOptionSet.set(group.id, inferredByRoot);
1009
+ const { result, erroredConfigFiles: groupErroredConfigFiles } = await getCreateNodesResultsForPlugin(tree, { plugin: pluginPath, options: group.options }, pluginPath, createNodes, createNodesV2, nxJson);
1010
+ for (const file of groupErroredConfigFiles) {
1011
+ erroredConfigFiles.add(file);
1012
+ }
1013
+ // The plugin's glob pattern is option-independent, so every option set
1014
+ // matches the same config files; union defensively.
1015
+ for (const file of result?.matchingProjectFiles ?? []) {
1016
+ rawMatchedFiles.add(file);
1017
+ }
1018
+ for (const [root, projectConfig] of Object.entries(result?.projects ?? {})) {
1019
+ // Every root the plugin produces a project for. Config files whose
1020
+ // owning root is NOT one of these produce no project and can be safely
1021
+ // excluded by an `include` without changing the inferred set: this is
1022
+ // why include-necessity must be judged against inferred roots, not the
1023
+ // raw glob (which also matches package.json/project.json/etc.).
1024
+ inferredRoots.add(root);
1025
+ // Executor-keyed targetDefaults can apply to ANY inferred target name,
1026
+ // so the Phase 3 dead-entry removal and the target-default preflight
1027
+ // must see every EFFECTIVE executor: a command-based target resolves
1028
+ // to `nx:run-commands` (resolveCommandSyntacticSugar).
1029
+ for (const target of Object.values(projectConfig.targets ?? {})) {
1030
+ if (target.executor) {
1031
+ inferredExecutors.add(target.executor);
1032
+ }
1033
+ else if (target.command) {
1034
+ inferredExecutors.add('nx:run-commands');
247
1035
  }
248
- projects.set(project, {
249
- ...projects.get(project),
250
- ...migration.targetPluginOptionMapper(target),
251
- });
1036
+ }
1037
+ // Retain clones only for this option set's migrated roots: Phase 2
1038
+ // reads them for migrated (project, target) pairs alone, so keeping
1039
+ // every inferred root would retain up to (option sets x roots) targets
1040
+ // for nothing.
1041
+ if (!group.migratedRoots.has(root)) {
1042
+ continue;
1043
+ }
1044
+ for (const targetName of group.targetNames) {
1045
+ const inferredTarget = projectConfig.targets?.[targetName];
1046
+ if (!inferredTarget) {
1047
+ continue;
1048
+ }
1049
+ // Store the FULL inferred (command-based) target. Residual
1050
+ // computation strips `command`/`options.cwd` at the point of use;
1051
+ // `baselineFinal` (the equivalence oracle) needs the full form.
1052
+ if (!inferredByRoot.has(root)) {
1053
+ inferredByRoot.set(root, new Map());
1054
+ }
1055
+ inferredByRoot
1056
+ .get(root)
1057
+ .set(targetName, structuredClone(inferredTarget));
252
1058
  }
253
1059
  }
254
1060
  }
255
1061
  }
256
- // apply default options
257
- for (const [project, pluginOptions] of projects.entries()) {
258
- projects.set(project, {
259
- ...defaultPluginOptions,
260
- ...pluginOptions,
261
- });
1062
+ finally {
1063
+ global.NX_GRAPH_CREATION = false;
262
1064
  }
263
- await addPluginRegistrations(tree, projects, pluginPath, createNodes, createNodesV2, defaultPluginOptions, projectGraph, spinner);
264
- spinner.succeed(`Migrated configuration for ${projects.size} project(s).\n`);
265
- return projects;
1065
+ // Keep only config files owned by an inferred project root (i.e. files that
1066
+ // actually contribute a project). Include-coverage is decided against these.
1067
+ // Ownership is an O(path depth) ancestor walk against the root Set, not an
1068
+ // O(roots) scan per file: both sets scale with project count.
1069
+ const matchedConfigFiles = [...rawMatchedFiles].filter((file) => isFileOwnedByAnyRoot(file, inferredRoots));
1070
+ return {
1071
+ inferredTargetsByOptionSet,
1072
+ // Every config file the plugin's glob matched, independent of whether this
1073
+ // pass inferred a project from it. The registration step needs the full set
1074
+ // to decide include coverage against non-migrated PROJECT roots: a project
1075
+ // the plugin infers only on a later pass (e.g. cache/error asymmetry) is
1076
+ // absent from `matchedConfigFiles` but its config is still globbed here.
1077
+ rawMatchedConfigFiles: [...rawMatchedFiles],
1078
+ matchedConfigFiles,
1079
+ // Config files the plugin's glob matched but could not infer this pass.
1080
+ // These produced no project (so they're absent from `matchedConfigFiles`),
1081
+ // yet the registration step must still keep its `include` scoped when one
1082
+ // sits outside the migrated roots, otherwise the plugin is widened to that
1083
+ // root, re-hits the same failure at verification, and reverts the hoist.
1084
+ erroredConfigFiles: [...erroredConfigFiles],
1085
+ inferredExecutors,
1086
+ inferredRoots,
1087
+ };
266
1088
  }
267
- async function addPluginRegistrations(tree, projects, pluginPath, createNodes, createNodesV2, defaultPluginOptions, projectGraph, spinner) {
1089
+ /**
1090
+ * Whether `file` (workspace-relative) is owned by any root in `roots`. Walks the
1091
+ * file's ancestor directories and checks Set membership: O(path depth), not
1092
+ * O(roots). Equivalent to `roots.some((root) => isFileUnderRoot(file, root))`:
1093
+ * the root project (`.`) owns workspace-root-level files (no path separator),
1094
+ * and a nested root owns the file when it is (or is an ancestor directory of)
1095
+ * the file.
1096
+ */
1097
+ function isFileOwnedByAnyRoot(file, roots) {
1098
+ if (roots.has(file)) {
1099
+ return true;
1100
+ }
1101
+ const firstSlash = file.indexOf('/');
1102
+ if (firstSlash === -1) {
1103
+ // A workspace-root-level file is owned only by the root project.
1104
+ return roots.has('.');
1105
+ }
1106
+ let dir = file;
1107
+ let slash = dir.lastIndexOf('/');
1108
+ while (slash !== -1) {
1109
+ dir = dir.slice(0, slash);
1110
+ if (roots.has(dir)) {
1111
+ return true;
1112
+ }
1113
+ slash = dir.lastIndexOf('/');
1114
+ }
1115
+ return false;
1116
+ }
1117
+ // Minimatch metacharacters plus extglob prefix operators (`!+@()`). A path
1118
+ // segment containing any of these has glob semantics and cannot be reduced to a
1119
+ // literal project root.
1120
+ // Not reused from `isGlobPattern` (nx/src/utils/globs.ts): its `GLOB_CHARACTERS`
1121
+ // set omits `?`, `!`, `+`, and `@`, so it would treat an extglob/negation as a
1122
+ // literal root and wrongly widen the include.
1123
+ const GLOB_METACHARACTERS = /[*?[\]{}!+@()]/;
1124
+ /**
1125
+ * The set of project roots a generated `include` list scopes to, or `undefined`
1126
+ * if any entry is not one of the two shapes this generator emits: `*` (the root
1127
+ * project) or a literal root followed by a trailing globstar segment (a nested
1128
+ * root). A user-authored include or any `exclude` falls back to the glob engine.
1129
+ *
1130
+ * The globstar branch only qualifies when the root prefix is a LITERAL path.
1131
+ * A prefix carrying glob metacharacters (a wildcard, brace, or extglob segment
1132
+ * before the trailing globstar) is not a shape this generator emits and can't be
1133
+ * reduced to root ownership by string equality, so it falls back to the glob
1134
+ * engine.
1135
+ */
1136
+ function generatedIncludeRoots(include) {
1137
+ const roots = new Set();
1138
+ for (const glob of include) {
1139
+ if (glob === '*') {
1140
+ roots.add('.');
1141
+ }
1142
+ else if (glob.endsWith('/**/*') && glob.length > '/**/*'.length) {
1143
+ const root = glob.slice(0, -'/**/*'.length);
1144
+ if (GLOB_METACHARACTERS.test(root)) {
1145
+ return undefined;
1146
+ }
1147
+ roots.add(root);
1148
+ }
1149
+ else {
1150
+ return undefined;
1151
+ }
1152
+ }
1153
+ return roots;
1154
+ }
1155
+ /**
1156
+ * Whether a registration's `include` globs already cover every config file the
1157
+ * plugin globs that is owned by an inferred project root (so the registration
1158
+ * can be left unscoped). Plugin inference is a pure function of the matched
1159
+ * config-file set, so this answers what the old per-project
1160
+ * `arePluginIncludesRequired` re-inference computed without running any
1161
+ * additional inference, differing only on matched files that contribute no
1162
+ * project: those are invisible here, so an `include` the old diff-based check
1163
+ * kept for their sake is now deleted, letting future config files in such
1164
+ * locations infer eagerly. The Phase 4 verification pass guards the current
1165
+ * behavioral outcome either way.
1166
+ *
1167
+ * Every glob this generator emits scopes to a single root (`*` for the root
1168
+ * project, a nested-root globstar for the rest), so coverage reduces to root
1169
+ * ownership with no glob engine: an O(files * path depth) walk instead of
1170
+ * running minimatch (whose `#matchGlobstar`/`slashSplit` dominate at scale) per
1171
+ * (file, glob). Only user-authored includes or any `exclude` fall through.
1172
+ */
1173
+ function includeCoversAllConfigFiles(include, exclude, configFiles) {
1174
+ if (!include || include.length === 0) {
1175
+ return true;
1176
+ }
1177
+ if (!exclude || exclude.length === 0) {
1178
+ const includeRoots = generatedIncludeRoots(include);
1179
+ if (includeRoots) {
1180
+ return configFiles.every((file) => isFileOwnedByAnyRoot(file, includeRoots));
1181
+ }
1182
+ }
1183
+ // Fallback for user-authored includes / any exclude: defer to the SAME matcher
1184
+ // Nx uses to bind config files to a registration. A hand-rolled
1185
+ // `include.some() && !exclude.some()` cannot express an ordered-override
1186
+ // negation like `["packages/**/*", "!packages/legacy/**/*"]`: `.some()` reports
1187
+ // every file "covered" and deletes the include, which widens the registration
1188
+ // so the fenced-off configs get inferred (and executed). `findMatchingConfigFiles`
1189
+ // applies include/exclude with Nx's exact ordered semantics, so coverage holds
1190
+ // only when every config file survives the same filter Nx would run.
1191
+ return ((0, devkit_internals_1.findMatchingConfigFiles)(configFiles, include, exclude ?? []).length ===
1192
+ configFiles.length);
1193
+ }
1194
+ /**
1195
+ * Harvest the diagnostic messages and the errored config-file paths from a
1196
+ * `ProjectConfigurationsError`. `ProjectConfigurationsError.errors` is a closed
1197
+ * 5-member union; the two members that name a failing config file are
1198
+ * `AggregateCreateNodesError` (a `[file, error]` list) and `MergeNodesError` (a
1199
+ * single `.file`). `ProjectsWithNoNameError` / `MultipleProjectsWithSameNameError`
1200
+ * are artifacts of running with no `project.json` layer (nothing supplies names),
1201
+ * so they are dropped; `WorkspaceValidityError` carries no file and is exempt.
1202
+ */
1203
+ function harvestConfigurationErrors(e) {
1204
+ const entries = [];
1205
+ for (const error of e.errors) {
1206
+ if ((0, devkit_internals_1.isProjectsWithNoNameError)(error) ||
1207
+ (0, devkit_internals_1.isMultipleProjectsWithSameNameError)(error)) {
1208
+ continue;
1209
+ }
1210
+ const message = error.message ?? String(error);
1211
+ if ((0, devkit_internals_1.isAggregateCreateNodesError)(error)) {
1212
+ entries.push({
1213
+ message,
1214
+ files: error.errors
1215
+ .map(([file]) => file)
1216
+ .filter((file) => !!file),
1217
+ pluginIndex: error.pluginIndex,
1218
+ });
1219
+ }
1220
+ else if ((0, devkit_internals_1.isMergeNodesError)(error)) {
1221
+ entries.push({
1222
+ message,
1223
+ files: error.file ? [error.file] : [],
1224
+ pluginIndex: error.pluginIndex,
1225
+ });
1226
+ }
1227
+ else if ((0, devkit_internals_1.isWorkspaceValidityError)(error)) {
1228
+ // Carries no config file and no plugin attribution.
1229
+ entries.push({ message, files: [], pluginIndex: undefined });
1230
+ }
1231
+ else {
1232
+ // Exhaustiveness guard: `ProjectConfigurationsError.errors` is a closed
1233
+ // union. If a sixth member is added, this assignment stops compiling until
1234
+ // it is handled here.
1235
+ const _exhaustive = error;
1236
+ void _exhaustive;
1237
+ }
1238
+ }
1239
+ return {
1240
+ messages: entries.map((entry) => entry.message),
1241
+ erroredConfigFiles: [...new Set(entries.flatMap((entry) => entry.files))],
1242
+ entries,
1243
+ };
1244
+ }
1245
+ /**
1246
+ * Attribution map for errored-config ownership: every root the engine knows
1247
+ * owns a project, i.e. graph project roots plus the roots the plugin inference
1248
+ * produced (a project discovered from a config file alone has no graph node;
1249
+ * graph roots win a collision). Keys are normalized for the
1250
+ * `findProjectForPath` walk; values are the raw roots the per-target
1251
+ * migrated-root sets hold.
1252
+ */
1253
+ function buildOwnerRootByPath(graphRoots, inferredRoots) {
1254
+ const normalizeRoot = (root) => root === '' ? '.' : root.endsWith('/') ? root.slice(0, -1) : root;
1255
+ const ownerRootByPath = new Map();
1256
+ for (const root of graphRoots) {
1257
+ ownerRootByPath.set(normalizeRoot(root), root);
1258
+ }
1259
+ for (const root of inferredRoots) {
1260
+ if (!ownerRootByPath.has(normalizeRoot(root))) {
1261
+ ownerRootByPath.set(normalizeRoot(root), root);
1262
+ }
1263
+ }
1264
+ return ownerRootByPath;
1265
+ }
1266
+ /**
1267
+ * Phase 4: a single verification inference pass over the whole workspace with
1268
+ * the updated `nx.json` plugin registrations. One
1269
+ * `retrieveProjectConfigurations` call runs every registration for this plugin
1270
+ * (the plugin's `createNodes` executes once per registration group). The
1271
+ * equivalence oracle + fallback in `verifyAndFallback` consume the result.
1272
+ */
1273
+ async function runVerificationPass(tree, pluginPath, createNodes, createNodesV2) {
268
1274
  const nxJson = (0, devkit_exports_1.readNxJson)(tree);
269
- // collect createNodes results for each project before adding the plugins
270
- const createNodesResults = new Map();
1275
+ const registrations = (nxJson.plugins ?? []).filter((plugin) => plugin === pluginPath ||
1276
+ (typeof plugin !== 'string' && plugin.plugin === pluginPath));
1277
+ if (registrations.length === 0) {
1278
+ return { result: undefined, errors: [], erroredConfigFiles: [] };
1279
+ }
271
1280
  global.NX_GRAPH_CREATION = true;
272
1281
  try {
273
- let index = 0;
274
- for (const [project, options] of projects.entries()) {
275
- index++;
276
- spinner.updateText(`${index}/${projects.size} - Parsing "${project}" configuration...`);
277
- const projectConfigs = await getCreateNodesResultsForPlugin(tree, { plugin: pluginPath, options }, pluginPath, createNodes, createNodesV2, nxJson);
278
- createNodesResults.set(project, projectConfigs);
1282
+ const plugins = registrations.map((registration) => new devkit_internals_1.LoadedNxPlugin({ createNodes, createNodesV2, name: pluginPath }, registration));
1283
+ return {
1284
+ result: await (0, devkit_internals_1.retrieveProjectConfigurations)({ specifiedPlugins: plugins, defaultPlugins: [] }, tree.root, nxJson),
1285
+ errors: [],
1286
+ erroredConfigFiles: [],
1287
+ };
1288
+ }
1289
+ catch (e) {
1290
+ if (e instanceof devkit_internals_1.ProjectConfigurationsError) {
1291
+ // Verify against the partial result, but keep the causes: they are the
1292
+ // only diagnostic for why affected projects fall back or a hoist reverts.
1293
+ // Recording the errored config files is what makes the revert fail-closed:
1294
+ // a hoist is reverted when an errored file lies outside every migrated root.
1295
+ const { messages, erroredConfigFiles } = harvestConfigurationErrors(e);
1296
+ return {
1297
+ result: e.partialProjectConfigurationsResult,
1298
+ errors: messages,
1299
+ erroredConfigFiles,
1300
+ };
279
1301
  }
1302
+ throw e;
280
1303
  }
281
1304
  finally {
282
1305
  global.NX_GRAPH_CREATION = false;
283
1306
  }
284
- const arePluginIncludesRequired = async (project, pluginConfiguration) => {
285
- global.NX_GRAPH_CREATION = true;
286
- let result;
287
- try {
288
- result = await getCreateNodesResultsForPlugin(tree, pluginConfiguration, pluginPath, createNodes, createNodesV2, nxJson);
1307
+ }
1308
+ /**
1309
+ * Phase 4: run the single verification inference pass, then apply the
1310
+ * equivalence oracle. `retrieveProjectConfigurations` already merges
1311
+ * `targetDefaults` into the inferred targets, so the real post-migration
1312
+ * effective config for a target is approximated by `merge(project.json
1313
+ * deviation, verified inferred+targetDefaults)`. It must deep-equal
1314
+ * `baselineFinal` (the previous engine's migrated effective config, from Phase
1315
+ * 2). CAVEAT: this pass runs with no DEFAULT layer (neither `project.json` nor
1316
+ * `package.json`), so it cannot observe cases where a default layer authors a
1317
+ * target's identity and thereby changes whether a `targetDefaults` entry applies
1318
+ * at all, e.g. a `filter: { plugin }` entry is rejected by
1319
+ * `resolveSourcePlugin` once the target carries `executor` / `command` in a
1320
+ * default layer. That identity can come from the project.json residual itself OR
1321
+ * from the package-json plugin (a script or `nx.targets` entry byte-equal to the
1322
+ * target name). Those targets are kept per-project at the hoist site (they never
1323
+ * reach a plugin-scoped entry), not caught here. Any project that fails,
1324
+ * or that the intended target no longer infers for at all, is restored to the
1325
+ * exact pre-centralization migration output (a full residual; an empty
1326
+ * residual removes the target), and every fallback is summarized in a single
1327
+ * `logger.warn` that asks for manual review rather than asserting equivalence
1328
+ * the pass could not establish.
1329
+ */
1330
+ async function verifyAndFallback(tree, projectGraph, pluginPath, createNodes, createNodesV2, residualByProject, projectConfigsByName, hoistedByTarget, inferredRoots, singleProjectMode, logger) {
1331
+ // Single-project mode never hoists, so there is nothing to reconcile, and
1332
+ // no reason to pay for a whole-workspace verification inference.
1333
+ if (singleProjectMode) {
1334
+ return;
1335
+ }
1336
+ const { result: verifyResult, errors: verificationErrors, erroredConfigFiles, } = await runVerificationPass(tree, pluginPath, createNodes, createNodesV2);
1337
+ if (!verifyResult) {
1338
+ return;
1339
+ }
1340
+ // The hoist's contract is: behavioral equivalence for migrated targets, no
1341
+ // effect on anything else. A root this plugin infers the target for that was
1342
+ // NOT migrated (inferred-only, or covered by a pre-existing registration)
1343
+ // would inherit the plugin-scoped default, so revert that target's hoist
1344
+ // entirely: drop the appended entry and restore every full residual.
1345
+ const migratedRootsByTarget = new Map();
1346
+ for (const [projectName, targetMap] of residualByProject) {
1347
+ const root = projectGraph.nodes[projectName]?.data?.root;
1348
+ for (const targetName of targetMap.keys()) {
1349
+ if (!migratedRootsByTarget.has(targetName)) {
1350
+ migratedRootsByTarget.set(targetName, new Set());
1351
+ }
1352
+ migratedRootsByTarget.get(targetName).add(root);
289
1353
  }
290
- finally {
291
- global.NX_GRAPH_CREATION = false;
1354
+ }
1355
+ const ownerRootByPath = buildOwnerRootByPath(Object.values(projectGraph.nodes).map((node) => node.data.root), inferredRoots);
1356
+ const revertedTargets = new Set();
1357
+ for (const [targetName] of hoistedByTarget) {
1358
+ const migratedRoots = migratedRootsByTarget.get(targetName) ?? new Set();
1359
+ const reachesNonMigratedRoot = Object.entries(verifyResult.projects ?? {}).some(([root, projectConfig]) => projectConfig.targets?.[targetName] !== undefined &&
1360
+ !migratedRoots.has(root));
1361
+ // Fail closed on inference errors. `reachesNonMigratedRoot` is keyed on the
1362
+ // partial result, so an errored root never appears there, yet it may be an
1363
+ // inferred-only root that would inherit the plugin-scoped default once its
1364
+ // config is fixed. Attribute each errored file to the CLOSEST known
1365
+ // project root (not any migrated ancestor: a nested non-migrated project
1366
+ // must not be absorbed by the migrated project above it) and revert unless
1367
+ // that root is one of this target's migrated roots (errors on migrated
1368
+ // roots are handled by the per-project divergence oracle below).
1369
+ const erroredOutsideMigratedRoots = erroredConfigFiles.some((file) => {
1370
+ const ownerRoot = (0, devkit_internals_1.findProjectForPath)(file, ownerRootByPath);
1371
+ return ownerRoot == null || !migratedRoots.has(ownerRoot);
1372
+ });
1373
+ if (reachesNonMigratedRoot || erroredOutsideMigratedRoots) {
1374
+ revertedTargets.add(targetName);
292
1375
  }
293
- const originalResults = createNodesResults.get(project);
294
- return !deepEqual(originalResults, result);
295
- };
296
- let index = 0;
1376
+ }
1377
+ if (revertedTargets.size > 0) {
1378
+ const nxJson = (0, devkit_exports_1.readNxJson)(tree);
1379
+ for (const targetName of revertedTargets) {
1380
+ removeHoistedTargetDefault(nxJson, targetName, hoistedByTarget.get(targetName));
1381
+ }
1382
+ (0, devkit_exports_1.updateNxJson)(tree, nxJson);
1383
+ for (const [projectName, targetMap] of residualByProject) {
1384
+ for (const [targetName, entry] of targetMap) {
1385
+ if (!revertedTargets.has(targetName) || entry.keptPreMigration) {
1386
+ continue;
1387
+ }
1388
+ writeResidualTarget(tree, projectConfigsByName, projectName, targetName, structuredClone(entry.residual), logger);
1389
+ }
1390
+ }
1391
+ // A revert can be triggered by an inference error the pass could not
1392
+ // inspect; surface those errors so the incomplete verification is not silent.
1393
+ (logger ?? devkit_exports_1.logger).warn((0, conversion_warnings_1.revertedTargetsWarning)([...revertedTargets].sort(), verificationErrors));
1394
+ }
1395
+ const divergent = [];
1396
+ const missing = [];
1397
+ for (const [projectName, targetMap] of residualByProject) {
1398
+ const root = projectGraph.nodes[projectName]?.data?.root;
1399
+ for (const [targetName, entry] of targetMap) {
1400
+ if (revertedTargets.has(targetName) || entry.keptPreMigration) {
1401
+ // Restored to the full pre-centralization residual above (or never
1402
+ // migrated): already the intended output, nothing left to verify.
1403
+ continue;
1404
+ }
1405
+ const verifiedInferred = verifyResult.projects?.[root]?.targets?.[targetName];
1406
+ let equivalent = false;
1407
+ if (verifiedInferred) {
1408
+ const projectConfig = readCachedProjectConfiguration(tree, projectConfigsByName, projectName);
1409
+ const deviation = projectConfig.targets?.[targetName] ?? {};
1410
+ const postMigrationFinal = (0, devkit_internals_1.mergeTargetConfigurations)(structuredClone(deviation), structuredClone(verifiedInferred));
1411
+ equivalent = isDeepEqual(postMigrationFinal, entry.baselineFinal);
1412
+ }
1413
+ if (!equivalent) {
1414
+ // Drop this project's hoist: restore its full residual as an explicit
1415
+ // project.json override (which wins over the shared targetDefaults).
1416
+ const written = writeResidualTarget(tree, projectConfigsByName, projectName, targetName, structuredClone(entry.residual), logger);
1417
+ const pair = `${projectName} > ${targetName}`;
1418
+ if (verifiedInferred) {
1419
+ divergent.push(pair);
1420
+ }
1421
+ else {
1422
+ missing.push({
1423
+ pair,
1424
+ root,
1425
+ removed: written && (0, conversion_warnings_1.isDroppedTarget)(entry.residual),
1426
+ });
1427
+ }
1428
+ }
1429
+ }
1430
+ }
1431
+ const anyFallback = divergent.length > 0 || missing.length > 0;
1432
+ if (anyFallback) {
1433
+ // Surface the pass's own errors only when a target vanished from the
1434
+ // verification result: the one fallback class an inference error can
1435
+ // explain. Divergence fallbacks have a verified config; appending
1436
+ // unrelated pass errors to them would misattribute the cause.
1437
+ (logger ?? devkit_exports_1.logger).warn((0, conversion_warnings_1.unverifiedPairsWarning)(divergent, missing, missing.length > 0 ? verificationErrors : []));
1438
+ }
1439
+ // Whenever the verification pass reported errors they must appear in at least
1440
+ // one warning. The revert warning always carries them; the fallback warning
1441
+ // carries them only for a target missing from the verification result (a
1442
+ // divergence fallback has a verified config, so attaching unrelated errors to
1443
+ // it would misattribute the cause). Fire this standalone warning whenever the
1444
+ // errors were surfaced by neither, i.e. nothing reverted AND no fallback
1445
+ // carried them (no fallback at all, or only divergence fallbacks). Otherwise a
1446
+ // broken config file leaves the pass unable to see the whole workspace with no
1447
+ // trace. (`verificationErrors` here has already had the no-project-name noise
1448
+ // filtered out, so it means inference genuinely broke.)
1449
+ const errorsSurfacedByFallbackWarning = missing.length > 0;
1450
+ if (verificationErrors.length > 0 &&
1451
+ revertedTargets.size === 0 &&
1452
+ !errorsSurfacedByFallbackWarning) {
1453
+ // Only claim the migrated targets matched when nothing fell back; a
1454
+ // divergence fallback means at least one did not.
1455
+ (logger ?? devkit_exports_1.logger).warn((0, conversion_warnings_1.verificationErrorsWarning)(verificationErrors, anyFallback));
1456
+ }
1457
+ }
1458
+ /**
1459
+ * Recover the default option keys a plugin fills into its options object during
1460
+ * `createNodes` (Phase 1 mutated each option-set object in place if the plugin
1461
+ * does so). A key qualifies only if it is not one of our own
1462
+ * `defaultPluginOptions` and appears with an identical value across every
1463
+ * option set: the signature of a plugin default fill. (A mapper-provided key
1464
+ * that is constant across every option set is indistinguishable and also
1465
+ * qualifies; harmless, since per-project options are spread over these.)
1466
+ * Reproduces the previous engine's incidental option enrichment without extra
1467
+ * inference.
1468
+ */
1469
+ function derivePluginFilledDefaults(optionSetGroups, defaultPluginOptions) {
1470
+ const filled = {};
1471
+ if (optionSetGroups.length === 0) {
1472
+ return filled;
1473
+ }
1474
+ const defaults = (defaultPluginOptions ?? {});
1475
+ const seen = new Map();
1476
+ for (const group of optionSetGroups) {
1477
+ for (const [key, value] of Object.entries((group.options ?? {}))) {
1478
+ if (key in defaults) {
1479
+ continue;
1480
+ }
1481
+ const existing = seen.get(key);
1482
+ if (!existing) {
1483
+ seen.set(key, { value, count: 1, consistent: true });
1484
+ }
1485
+ else {
1486
+ existing.count++;
1487
+ if (stableStringify(existing.value) !== stableStringify(value)) {
1488
+ existing.consistent = false;
1489
+ }
1490
+ }
1491
+ }
1492
+ }
1493
+ for (const [key, entry] of seen) {
1494
+ if (entry.consistent && entry.count === optionSetGroups.length) {
1495
+ filled[key] = entry.value;
1496
+ }
1497
+ }
1498
+ return filled;
1499
+ }
1500
+ /**
1501
+ * Deferred (batch) variant of Phase 3's hoist + Phase 4: instead of running
1502
+ * them, capture everything the batch finalize pass needs to run them once over
1503
+ * the whole batch. The session clones the mutable structures at staging time.
1504
+ */
1505
+ function stageDeferredPlan(batchStaging, pluginPath, createNodes, createNodesV2, logger, pluginPreRegistered, scope, projectGraph, residualByProject, inferredTargetsByOptionSet, inferredExecutors, inferredRoots, matchedConfigFiles, erroredConfigFiles) {
1506
+ const rootByProject = new Map();
1507
+ const graphNodeByProject = new Map();
1508
+ for (const projectName of residualByProject.keys()) {
1509
+ rootByProject.set(projectName, projectGraph.nodes[projectName]?.data.root);
1510
+ const node = projectGraph.nodes[projectName];
1511
+ if (node) {
1512
+ graphNodeByProject.set(projectName, node);
1513
+ }
1514
+ }
1515
+ const graphRoots = new Set();
1516
+ for (const node of Object.values(projectGraph.nodes)) {
1517
+ graphRoots.add(node.data.root);
1518
+ }
1519
+ // Executors of the pre-migration graph targets, for the finalize liveness
1520
+ // scan (mirrors the inline scan in `hoistCommonAndWrite`, which reads them
1521
+ // from the same graph).
1522
+ const graphExecutorByPair = new Map();
1523
+ for (const [projectName, node] of Object.entries(projectGraph.nodes)) {
1524
+ for (const [targetName, target] of Object.entries(node.data.targets ?? {})) {
1525
+ if (target.executor) {
1526
+ graphExecutorByPair.set(`${projectName}\t${targetName}`, target.executor);
1527
+ }
1528
+ }
1529
+ }
1530
+ batchStaging.stagePlan({
1531
+ pluginPath,
1532
+ createNodes,
1533
+ createNodesV2,
1534
+ logger,
1535
+ pluginPreRegistered,
1536
+ residualByProject,
1537
+ rootByProject,
1538
+ graphNodeByProject,
1539
+ graphRoots,
1540
+ inferredExecutorByPair: computeInferredExecutorByPair(scope, projectGraph, inferredTargetsByOptionSet),
1541
+ inferredExecutors,
1542
+ inferredRoots,
1543
+ matchedConfigFiles,
1544
+ erroredConfigFiles,
1545
+ migratedExecutors: scope.executorScopes.map((executorScope) => executorScope.executor),
1546
+ graphExecutorByPair,
1547
+ });
1548
+ }
1549
+ async function migrateProjects(tree, projectGraph, pluginPath, createNodes, createNodesV2, defaultPluginOptions, migrations, specificProjectToMigrate, logger) {
1550
+ const projects = new Map();
1551
+ const spinner = devkit_internals_1.globalSpinner.start(`Calculating migration scope...`, pluginPath);
1552
+ // Phase 0: Collect (once). Scope + filtering + per-project options.
1553
+ const scope = collectMigrationScope(tree, projectGraph, migrations, defaultPluginOptions, specificProjectToMigrate, logger);
1554
+ const projectConfigsByName = (0, devkit_exports_1.getProjects)(tree);
1555
+ // Phase 1: Infer (once per distinct option set) for the whole workspace.
1556
+ const nxJson = (0, devkit_exports_1.readNxJson)(tree);
1557
+ const { inferredTargetsByOptionSet, matchedConfigFiles, erroredConfigFiles: erroredConfigFilesFromInference, rawMatchedConfigFiles, inferredExecutors, inferredRoots, } = await inferOncePerOptionSet(tree, pluginPath, createNodes, createNodesV2, nxJson, scope);
1558
+ // Phase 2: Per-project residual (in-memory, no re-inference). Also captures
1559
+ // `baselineFinal` (the equivalence oracle consumed in Phase 4).
1560
+ const residualByProject = await computeResidualByProject(tree, projectGraph, scope, inferredTargetsByOptionSet, nxJson, projectConfigsByName);
1561
+ // Some plugins' `createNodes` normalize their options object in place (e.g.
1562
+ // `options.devTargetName ??= 'dev'`). The previous engine inferred once per
1563
+ // project passing the project's registration-options object by reference, so
1564
+ // those objects picked up the plugin's default keys. Phase 1 runs the same
1565
+ // inference on each option-set object, so recover exactly those plugin-filled
1566
+ // default keys (without any extra inference) and merge them back so the
1567
+ // emitted registration options stay identical.
1568
+ const pluginFilledDefaults = derivePluginFilledDefaults(scope.optionSetGroups, defaultPluginOptions);
1569
+ for (const [project, options] of scope.pluginOptionsByProject) {
1570
+ projects.set(project, {
1571
+ ...pluginFilledDefaults,
1572
+ ...options,
1573
+ });
1574
+ }
1575
+ // Phase 3: one-shot plugin registration + analytic include computation.
1576
+ // Runs before the hoist: the hoist is gated on the FINAL position of this
1577
+ // plugin's registrations in the plugins array (a later registration of
1578
+ // another plugin can take a target's identity over), so the array must be in
1579
+ // its final state first. The pre-registration state is captured here for the
1580
+ // hoist's dead-entry removal gate, which needs to know whether this
1581
+ // migration added the registration.
1582
+ const pluginPreRegistered = ((0, devkit_exports_1.readNxJson)(tree).plugins ?? []).some((plugin) => isRegistrationOfPlugin(plugin, pluginPath));
1583
+ addPluginRegistrations(tree, projects, pluginPath, defaultPluginOptions, projectGraph, spinner, matchedConfigFiles, erroredConfigFilesFromInference, rawMatchedConfigFiles);
1584
+ // An open batch session (`infer-targets` with several plugins selected)
1585
+ // defers centralization: full residuals are written now and the hoist,
1586
+ // dead-default cleanup, and verification run once at the batch finalize
1587
+ // pass, over every conversion's staged plan. Single-project mode never
1588
+ // centralizes, so it stays on its inline path even inside a batch.
1589
+ const batchStaging = specificProjectToMigrate
1590
+ ? undefined
1591
+ : (0, batch_conversion_session_1.getActiveBatchStaging)(tree);
1592
+ // Phase 3: Derive strict-common + write. Single-project mode never hoists
1593
+ // (would leak shared config to sibling projects), so it keeps the full
1594
+ // residual in project.json, as does a deferred batch conversion (the batch
1595
+ // finalize pass hoists later); whole-workspace mode hoists the common config
1596
+ // to a plugin-scoped `targetDefaults` entry and writes only per-project
1597
+ // deviations.
1598
+ let hoistedByTarget = new Map();
1599
+ if (specificProjectToMigrate || batchStaging) {
1600
+ writeResiduals(tree, projectConfigsByName, projectGraph, scope, residualByProject, logger);
1601
+ }
1602
+ else {
1603
+ hoistedByTarget = hoistCommonAndWrite(tree, projectConfigsByName, scope, residualByProject, pluginPath, projectGraph, inferredTargetsByOptionSet, inferredExecutors, pluginPreRegistered, logger);
1604
+ }
1605
+ if (batchStaging) {
1606
+ stageDeferredPlan(batchStaging, pluginPath, createNodes, createNodesV2, logger, pluginPreRegistered, scope, projectGraph, residualByProject, inferredTargetsByOptionSet, inferredExecutors, inferredRoots, matchedConfigFiles, erroredConfigFilesFromInference);
1607
+ }
1608
+ else {
1609
+ // Phase 4: single verification inference pass + equivalence oracle. Any
1610
+ // project whose centralized config cannot be verified as equivalent falls
1611
+ // back to a full project.json override (summarized in one logger.warn).
1612
+ await verifyAndFallback(tree, projectGraph, pluginPath, createNodes, createNodesV2, residualByProject, projectConfigsByName, hoistedByTarget, inferredRoots, Boolean(specificProjectToMigrate), logger);
1613
+ }
1614
+ spinner.succeed(`Migrated configuration for ${projects.size} project(s).\n`);
1615
+ return projects;
1616
+ }
1617
+ function addPluginRegistrations(tree, projects, pluginPath, defaultPluginOptions, projectGraph, spinner,
1618
+ // The matched config files owned by an inferred project root (the filtered
1619
+ // subset from the Phase 1 inference: files outside any inferred root
1620
+ // contribute no project and are deliberately excluded). Used to decide
1621
+ // analytically whether a registration's `include` globs already cover
1622
+ // everything (so it can be left unscoped).
1623
+ matchedConfigFiles,
1624
+ // Config files the Phase 1 inference could not load. These produce no project
1625
+ // (so they're absent from `matchedConfigFiles`), but an errored file OUTSIDE
1626
+ // the migrated roots must still keep the `include` scoped: leaving the plugin
1627
+ // unscoped would widen it onto that root, where the verification pass re-hits
1628
+ // the failure and reverts the whole hoist. A harmless project-free config (a
1629
+ // shared/base config that loads fine) is NOT errored, so it does not keep the
1630
+ // include: the plugin may safely apply workspace-wide over it.
1631
+ erroredConfigFiles,
1632
+ // The FULL set of config files the plugin's glob matched, regardless of what
1633
+ // this migration's inference pass produced a project from. Used to detect
1634
+ // config files owned by NON-MIGRATED projects that the plugin would infer once
1635
+ // the registration is workspace-wide, see below.
1636
+ rawMatchedConfigFiles) {
1637
+ const nxJson = (0, devkit_exports_1.readNxJson)(tree);
1638
+ // Config files owned by a NON-MIGRATED project keep the `include` scoped.
1639
+ // `matchedConfigFiles` only lists roots THIS pass inferred; a non-migrated
1640
+ // project the plugin infers only later (cache/error asymmetry) is absent from
1641
+ // it, so an unscoped registration would let the verification pass infer that
1642
+ // root and `reachesNonMigratedRoot` would revert the whole hoist. Judging
1643
+ // coverage against project-graph membership, not this pass's inference,
1644
+ // closes that hole. (A config owned by NO project, e.g. a shared/base config,
1645
+ // is not counted, so it does not force a scoped include.)
1646
+ const migratedRoots = new Set();
1647
+ for (const project of projects.keys()) {
1648
+ const root = projectGraph.nodes[project]?.data?.root;
1649
+ if (root) {
1650
+ migratedRoots.add(root);
1651
+ }
1652
+ }
1653
+ const nonMigratedProjectRoots = new Set();
1654
+ for (const node of Object.values(projectGraph.nodes)) {
1655
+ const root = node?.data?.root;
1656
+ if (root && !migratedRoots.has(root)) {
1657
+ nonMigratedProjectRoots.add(root);
1658
+ }
1659
+ }
1660
+ const nonMigratedProjectConfigFiles = rawMatchedConfigFiles.filter((file) => isFileOwnedByAnyRoot(file, nonMigratedProjectRoots));
1661
+ // Fold errored files (broken configs the plugin would fail to infer) and
1662
+ // non-migrated project configs (roots the plugin would infer workspace-wide)
1663
+ // into the coverage set: an unscoped `include` is chosen only when neither lies
1664
+ // outside the migrated roots.
1665
+ const coverageConfigFiles = [
1666
+ ...matchedConfigFiles,
1667
+ ...erroredConfigFiles,
1668
+ ...nonMigratedProjectConfigFiles,
1669
+ ];
1670
+ const registrationGroups = new Map();
297
1671
  for (const [project, options] of projects.entries()) {
1672
+ const projectIncludeGlob = projectGraph.nodes[project].data.root === '.'
1673
+ ? '*'
1674
+ : (0, posix_1.join)(projectGraph.nodes[project].data.root, '**/*');
1675
+ const key = stableStringify(options);
1676
+ if (!registrationGroups.has(key)) {
1677
+ registrationGroups.set(key, { options, include: [] });
1678
+ }
1679
+ registrationGroups.get(key).include.push(projectIncludeGlob);
1680
+ }
1681
+ let index = 0;
1682
+ for (const { options, include } of registrationGroups.values()) {
298
1683
  index++;
299
- spinner.updateText(`${index}/${projects.size} - Applying "${project}" configuration...`);
1684
+ spinner.updateText(`${index}/${registrationGroups.size} - Applying plugin configuration...`);
300
1685
  const existingPlugin = nxJson.plugins?.find((plugin) => typeof plugin !== 'string' &&
301
1686
  plugin.plugin === pluginPath &&
302
1687
  Object.keys(options).every((key) => plugin.options[key] === options[key] ||
303
1688
  (plugin.options[key] === undefined &&
304
1689
  options[key] === defaultPluginOptions[key])));
305
- const projectIncludeGlob = projectGraph.nodes[project].data.root === '.'
306
- ? '*'
307
- : (0, posix_1.join)(projectGraph.nodes[project].data.root, '**/*');
308
1690
  if (!existingPlugin) {
309
1691
  nxJson.plugins ??= [];
310
1692
  const plugin = {
311
1693
  plugin: pluginPath,
312
1694
  options,
313
- include: [projectIncludeGlob],
1695
+ include: [...include],
314
1696
  };
315
- if (!(await arePluginIncludesRequired(project, plugin))) {
1697
+ if (includeCoversAllConfigFiles(plugin.include, plugin.exclude, coverageConfigFiles)) {
316
1698
  delete plugin.include;
317
1699
  }
318
1700
  nxJson.plugins.push(plugin);
319
1701
  }
320
1702
  else if (existingPlugin.include) {
321
- if (!existingPlugin.include.some((include) => (0, minimatch_1.minimatch)(projectIncludeGlob, include, { dot: true }))) {
322
- existingPlugin.include.push(projectIncludeGlob);
323
- if (!(await arePluginIncludesRequired(project, existingPlugin))) {
324
- delete existingPlugin.include;
1703
+ for (const projectIncludeGlob of include) {
1704
+ if (!existingPlugin.include.some((include) => (0, minimatch_1.minimatch)(projectIncludeGlob, include, { dot: true }))) {
1705
+ existingPlugin.include.push(projectIncludeGlob);
325
1706
  }
326
1707
  }
1708
+ if (includeCoversAllConfigFiles(existingPlugin.include, existingPlugin.exclude, coverageConfigFiles)) {
1709
+ delete existingPlugin.include;
1710
+ }
327
1711
  }
328
1712
  }
329
1713
  spinner.updateText(`Migrations done`);
@@ -331,6 +1715,7 @@ async function addPluginRegistrations(tree, projects, pluginPath, createNodes, c
331
1715
  }
332
1716
  async function getCreateNodesResultsForPlugin(tree, pluginConfiguration, pluginPath, createNodes, createNodesV2, nxJson) {
333
1717
  let projectConfigs;
1718
+ let erroredConfigFiles = [];
334
1719
  try {
335
1720
  const plugin = new devkit_internals_1.LoadedNxPlugin({ createNodes, createNodesV2, name: pluginPath }, pluginConfiguration);
336
1721
  projectConfigs = await (0, devkit_internals_1.retrieveProjectConfigurations)({ specifiedPlugins: [plugin], defaultPlugins: [] }, tree.root, nxJson);
@@ -338,33 +1723,15 @@ async function getCreateNodesResultsForPlugin(tree, pluginConfiguration, pluginP
338
1723
  catch (e) {
339
1724
  if (e instanceof devkit_internals_1.ProjectConfigurationsError) {
340
1725
  projectConfigs = e.partialProjectConfigurationsResult;
1726
+ // Capture the config files this pass could not infer. The registration
1727
+ // step keeps its `include` scoped to migrated roots when an errored file
1728
+ // sits outside them, so the plugin is never widened to a root it would
1729
+ // fail to infer (which the verification pass would otherwise revert).
1730
+ erroredConfigFiles = harvestConfigurationErrors(e).erroredConfigFiles;
341
1731
  }
342
1732
  else {
343
1733
  throw e;
344
1734
  }
345
1735
  }
346
- return projectConfigs;
347
- }
348
- // Checks if two objects are structurely equal, without caring
349
- // about the order of the keys.
350
- function deepEqual(a, b, logKey = '') {
351
- const aKeys = Object.keys(a);
352
- const bKeys = new Set(Object.keys(b));
353
- if (aKeys.length !== bKeys.size) {
354
- return false;
355
- }
356
- for (const key of aKeys) {
357
- if (!bKeys.has(key)) {
358
- return false;
359
- }
360
- if (typeof a[key] === 'object' && typeof b[key] === 'object') {
361
- if (!deepEqual(a[key], b[key], logKey + '.' + key)) {
362
- return false;
363
- }
364
- }
365
- else if (a[key] !== b[key]) {
366
- return false;
367
- }
368
- }
369
- return true;
1736
+ return { result: projectConfigs, erroredConfigFiles };
370
1737
  }