diff --git a/astro-docs/src/content/docs/reference/nx-json.mdoc b/astro-docs/src/content/docs/reference/nx-json.mdoc index a26996f20f11e..53122df611623 100644 --- a/astro-docs/src/content/docs/reference/nx-json.mdoc +++ b/astro-docs/src/content/docs/reference/nx-json.mdoc @@ -35,8 +35,9 @@ The following is an expanded example showing all options. Your `nx.json` will li "default": ["{projectRoot}/**/*"], "production": ["!{projectRoot}/**/*.spec.tsx"] }, - "targetDefaults": { - "@nx/js:tsc": { + "targetDefaults": [ + { + "target": "@nx/js:tsc", "inputs": ["production", "^production"], "dependsOn": ["^build"], "options": { @@ -44,13 +45,14 @@ The following is an expanded example showing all options. Your `nx.json` will li }, "cache": true }, - "test": { + { + "target": "test", "cache": true, "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "executor": "@nx/jest:jest" } - }, + ], "release": { "version": { "conventionalCommits": true @@ -211,8 +213,38 @@ Additionally, if there is not a match for either of the above, we look for other Target defaults matching the executor takes precedence over those matching the target name. If we find a target default for a given target, we use it as the base for that target's configuration. +`targetDefaults` is an array of entries. Each entry **must** specify a `target` (name, glob, or executor string). Optional `projects` and `source` filters let you scope a default to a subset of projects or targets inferred by a particular plugin. + +| Field | Type | Description | +| ---------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `target` | `string` | Required. Target name (e.g. `test`), glob (e.g. `e2e-ci--*`), or executor string (e.g. `@nx/vite:test`). | +| `projects` | `string` \| `string[]` | Optional. Restricts the default to matching projects. Accepts project names, globs, directory patterns, tags (`tag:foo`), and negation (`!foo`) — anything `findMatchingProjects` understands. | +| `source` | `string` | Optional. Restricts the default to targets originated by a specific plugin (e.g. `@nx/vite`). Useful when two plugins expose a target with the same name (e.g. `@nx/vite:test` and `@nx/jest:test` both as `test`). | +| other | any field from [Target Configuration](/docs/reference/project-configuration) | The defaults applied when the filter matches. | + +When multiple entries match a given `(target, project)` tuple, the **most specific** entry wins (`target + projects + source` > `target + projects` > `target + source` > `target` alone). Within the same specificity tier, an exact `target` match beats a glob match, and later entries in the array beat earlier ones. + +```json +// nx.json — polyglot example +{ + "targetDefaults": [ + { "target": "test", "cache": true }, + { + "target": "test", + "source": "@nx/vite", + "inputs": ["default", "^production"] + }, + { + "target": "test", + "projects": "tag:dotnet", + "options": { "configuration": "Release" } + } + ] +} +``` + {% aside type="caution" title="Beware" %} -When using a target name as the key of a target default, make sure all the targets with that name use the same executor or that the target defaults you're setting make sense to all targets regardless of the executor they use. Anything set in a target default will also override the configuration of [tasks inferred by plugins](/docs/concepts/inferred-tasks). +When an entry has no `projects` or `source` filter, Nx matches by target name (or executor) alone. Make sure all targets with that name use the same executor, or that the defaults you're setting make sense to all of them. Anything set in a target default will also override the configuration of [tasks inferred by plugins](/docs/concepts/inferred-tasks). {% /aside %} Some common scenarios for this follow. @@ -222,18 +254,19 @@ Some common scenarios for this follow. Named inputs defined in `nx.json` are merged with the named inputs defined in [project level configuration](/docs/reference/project-configuration). In other words, every project has a set of named inputs, and it's defined as: `{...namedInputsFromNxJson, ...namedInputsFromProjectsProjectJson}`. Defining `inputs` for a given target would replace the set of inputs for that target name defined in `nx.json`. -Using pseudocode `inputs = projectJson.targets.build.inputs || nxJson.targetDefaults.build.inputs`. +Using pseudocode `inputs = projectJson.targets.build.inputs || nxJsonTargetDefaults.build.inputs`. You can also define and redefine named inputs. This enables one key use case, where your `nx.json` can define things like this (which applies to every project): ```json // nx.json { - "targetDefaults": { - "test": { + "targetDefaults": [ + { + "target": "test", "inputs": ["default", "^production"] } - } + ] } ``` @@ -267,11 +300,12 @@ defining `targetDefaults` in `nx.json` is helpful. ```json // nx.json { - "targetDefaults": { - "build": { + "targetDefaults": [ + { + "target": "build", "dependsOn": ["^build"] } - } + ] } ``` @@ -289,11 +323,12 @@ Another target default you can configure is `outputs`: ```json // nx.json { - "targetDefaults": { - "build": { + "targetDefaults": [ + { + "target": "build", "outputs": ["{projectRoot}/custom-dist"] } - } + ] } ``` @@ -302,8 +337,9 @@ When defining any options or configurations inside of a target default, you may ```json // nx.json { - "targetDefaults": { - "@nx/js:tsc": { + "targetDefaults": [ + { + "target": "@nx/js:tsc", "options": { "main": "{projectRoot}/src/index.ts" }, @@ -315,18 +351,19 @@ When defining any options or configurations inside of a target default, you may "inputs": ["prod"], "outputs": ["{workspaceRoot}/{projectRoot}"] }, - "build": { + { + "target": "build", "inputs": ["prod"], "outputs": ["{workspaceRoot}/{projectRoot}"], "cache": true } - } + ] } ``` #### Target default priority -Note that the inputs and outputs are specified on both the `@nx/js:tsc` and `build` default configurations. This is **required**, as when reading target defaults Nx will only ever look at one key. If there is a default configuration based on the executor used, it will be read first. If not, Nx will fall back to looking at the configuration based on target name. For instance, running `nx build project` will read the options from `targetDefaults[@nx/js:tsc]` if the target configuration for `build` uses the `@nx/js:tsc executor`. It **would not** read any of the configuration from the `build` target default configuration unless the executor does not match. +Only one entry wins per `(target, project)` tuple — the most specific one (see the table above). In the example above, both entries apply to targets named `build`, but the `@nx/js:tsc` entry matches when the target uses that executor and beats the plain `build` entry because executor matches rank as specific as an exact target-name match. Specify the full config on the winning entry — less-specific entries do not contribute when a more-specific entry matches. {% cardgrid %} {% linkcard title="Configure Outputs for Task Caching" description="This recipe walks you through how to set outputs" href="/docs/guides/tasks--caching/configure-outputs" /%} @@ -339,11 +376,12 @@ In Nx 17 and higher, caching is configured by specifying `"cache": true` in a ta ```json // nx.json { - "targetDefaults": { - "test": { + "targetDefaults": [ + { + "target": "test", "cache": true } - } + ] } ``` @@ -360,13 +398,14 @@ You can configure options specific to a target's executor. As an example, if you ```json // nx.json { - "targetDefaults": { - "@nx/js:tsc": { + "targetDefaults": [ + { + "target": "@nx/js:tsc", "options": { "generateExportsField": true } } - } + ] } ``` @@ -375,14 +414,51 @@ You can also provide defaults for [inferred targets](/docs/concepts/inferred-tas ```json // nx.json { - "targetDefaults": { - "build": { + "targetDefaults": [ + { + "target": "build", "options": { "assetsInlineLimit": 2048, "assetsDir": "static/assets" } } - } + ] +} +``` + +If two plugins expose a target with the same name (e.g. both `@nx/vite` and `@nx/jest` infer `test`), use the `source` filter to scope per-plugin defaults: + +```json +// nx.json +{ + "targetDefaults": [ + { + "target": "test", + "source": "@nx/vite", + "inputs": ["default", "^production"] + }, + { + "target": "test", + "source": "@nx/jest", + "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"] + } + ] +} +``` + +Or use the `projects` filter — which accepts the same patterns as the `--projects` CLI flag (names, globs, `tag:foo`, `!exclude`) — to scope a default to a subset of projects: + +```json +// nx.json +{ + "targetDefaults": [ + { + "target": "build", + "projects": "tag:dotnet", + "options": { "configuration": "Release" } + }, + { "target": "build", "projects": ["apps/*", "!apps/legacy"], "cache": true } + ] } ``` @@ -399,13 +475,14 @@ Task Atomizer plugins create several targets with a similar pattern. For example ```json // nx.json { - "targetDefaults": { - "e2e-ci--**/**": { + "targetDefaults": [ + { + "target": "e2e-ci--**/**", "options": { "headless": true } } - } + ] } ``` diff --git a/astro-docs/src/content/docs/reference/project-configuration.mdoc b/astro-docs/src/content/docs/reference/project-configuration.mdoc index 18f39095340f4..8f5555a790fe8 100644 --- a/astro-docs/src/content/docs/reference/project-configuration.mdoc +++ b/astro-docs/src/content/docs/reference/project-configuration.mdoc @@ -631,6 +631,63 @@ In the case of an explicit target using an executor, you can specify the executo } ``` +### Spread token + +By default, each configuration source overwrites the previous one for every property it defines. The spread token (`"..."`) lets you control merge priority rather than always replacing. + +**In arrays**, `"..."` is substituted with the items from the base array at that position: + +```json +// project.json +{ + "targets": { + "build": { + "inputs": ["my-project-input", "..."] + } + } +} +``` + +If the inferred target has `inputs: ["default", "^production"]`, the result is `["my-project-input", "default", "^production"]`. + +**In objects**, a key of `"..."` set to `true` spreads base properties at that position. Keys defined after `"..."` override base values; keys defined before `"..."` can be overridden by base values. + +```json +// project.json +{ + "targets": { + "build": { + "options": { + "env": { + "MY_VAR": "my-value", + "...": true + } + } + } + } +} +``` + +Nx processes target configuration down to `options[x]` and `configurations[x][y]`; values below that are opaque to the merge pipeline. Spread is therefore resolved at these levels: + +| Level | Example | +| -------------------------------------------------- | --------------------------------------------------------------------------- | +| Target root | `"build": { "dependsOn": ["lint"], "...": true }` | +| `inputs`, `outputs`, `dependsOn`, `syncGenerators` | `"inputs": ["my-input", "..."]` | +| `options` | `"options": { "...": true, "outputPath": "dist/custom" }` | +| `options[x]` | `"options": { "env": { "MY_VAR": "val", "...": true } }` | +| `configurations` | `"configurations": { "my-config": { ... }, "...": true }` | +| `configurations[x]` | `"configurations": { "prod": { "...": true, "sourceMap": false } }` | +| `configurations[x][y]` | `"configurations": { "prod": { "env": { "MY_VAR": "val", "...": true } } }` | + +{% aside type="caution" title="Spread does not apply to deeply nested options" %} +Because `options[x]` and `configurations[x][y]` are the innermost levels Nx inspects, a spread token nested any deeper has no effect. For example, `options.webpack = { "...": true }` works (the spread is at `options[x]`), but `options.webpack.plugins = { "...": true }` is ignored because the spread sits inside the opaque value Nx assigns to `options.webpack`. +{% /aside %} + +{% aside type="note" title="Spread does not apply to `tags` and `implicitDependencies`" %} +`tags` and `implicitDependencies` always merge across configuration sources — later sources contribute additional entries rather than replacing earlier ones. The spread token is neither needed nor supported for these properties. +{% /aside %} + ### Target metadata You can add additional metadata to be attached to a target. For example, you can provide a description stating what the diff --git a/e2e/angular/src/projects-buildable-libs.test.ts b/e2e/angular/src/projects-buildable-libs.test.ts index 2ddeb11b4d577..019d61b2b7873 100644 --- a/e2e/angular/src/projects-buildable-libs.test.ts +++ b/e2e/angular/src/projects-buildable-libs.test.ts @@ -131,23 +131,35 @@ describe('Angular Projects - Buildable Libraries', () => { // update the nx.json updateJson('nx.json', (config) => { - config.targetDefaults ??= {}; - config.targetDefaults['@nx/angular:webpack-browser'] ??= { + const inputs = + config.namedInputs && 'production' in config.namedInputs + ? ['production', '^production'] + : ['default', '^default']; + const defaults: Record = { cache: true, - dependsOn: [`^build`], - inputs: - config.namedInputs && 'production' in config.namedInputs - ? ['production', '^production'] - : ['default', '^default'], - }; - config.targetDefaults['@nx/angular:browser-esbuild'] ??= { - cache: true, - dependsOn: [`^build`], - inputs: - config.namedInputs && 'production' in config.namedInputs - ? ['production', '^production'] - : ['default', '^default'], + dependsOn: ['^build'], + inputs, }; + const targets = [ + '@nx/angular:webpack-browser', + '@nx/angular:browser-esbuild', + ]; + if (Array.isArray(config.targetDefaults)) { + for (const target of targets) { + if ( + !config.targetDefaults.some( + (e: { target?: string }) => e.target === target + ) + ) { + config.targetDefaults.push({ target, ...defaults }); + } + } + } else { + config.targetDefaults ??= {}; + for (const target of targets) { + config.targetDefaults[target] ??= defaults; + } + } return config; }); diff --git a/e2e/js/src/js-generators.ts b/e2e/js/src/js-generators.ts index 07e6b961c670f..81cfe38779ce8 100644 --- a/e2e/js/src/js-generators.ts +++ b/e2e/js/src/js-generators.ts @@ -121,13 +121,34 @@ describe('js e2e', () => { }); const originalNxJson = readFile('nx.json'); updateJson('nx.json', (json) => { - json.targetDefaults.build = { - ...json.targetDefaults.build, - dependsOn: [ - ...(json.targetDefaults.build?.dependsOn || []), - '^my-custom-build', - ], - }; + if (Array.isArray(json.targetDefaults)) { + const idx = json.targetDefaults.findIndex( + (e) => + e.target === 'build' && + e.projects === undefined && + e.source === undefined + ); + const existing = + idx >= 0 ? json.targetDefaults[idx] : { target: 'build' }; + const merged = { + ...existing, + dependsOn: [ + ...((existing.dependsOn as string[] | undefined) ?? []), + '^my-custom-build', + ], + }; + if (idx >= 0) json.targetDefaults[idx] = merged; + else json.targetDefaults.push(merged); + } else { + json.targetDefaults ??= {}; + json.targetDefaults.build = { + ...json.targetDefaults.build, + dependsOn: [ + ...(json.targetDefaults.build?.dependsOn || []), + '^my-custom-build', + ], + }; + } return json; }); diff --git a/e2e/nx/src/nxw.test.ts b/e2e/nx/src/nxw.test.ts index bc6b44a2b8462..b9deb428a407a 100644 --- a/e2e/nx/src/nxw.test.ts +++ b/e2e/nx/src/nxw.test.ts @@ -19,8 +19,12 @@ describe('nx wrapper / .nx installation', () => { beforeAll(() => { runNxWrapper = newWrappedNxWorkspace(); updateJson('nx.json', (json) => { - json.targetDefaults ??= {}; - json.targetDefaults.echo = { cache: true }; + if (Array.isArray(json.targetDefaults)) { + json.targetDefaults.push({ target: 'echo', cache: true }); + } else { + json.targetDefaults ??= {}; + json.targetDefaults.echo = { cache: true }; + } json.installation.plugins = { '@nx/js': getPublishedVersion(), }; diff --git a/e2e/nx/src/run.test.ts b/e2e/nx/src/run.test.ts index 38b9bf8d5fc38..7df3596327611 100644 --- a/e2e/nx/src/run.test.ts +++ b/e2e/nx/src/run.test.ts @@ -488,13 +488,23 @@ describe('Nx Running Tests', () => { const lib = uniq('lib'); updateJson('nx.json', (nxJson) => { - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults[target] = { - executor: 'nx:run-commands', - options: { - command: `echo Hello from ${target}`, - }, - }; + if (Array.isArray(nxJson.targetDefaults)) { + nxJson.targetDefaults.push({ + target, + executor: 'nx:run-commands', + options: { + command: `echo Hello from ${target}`, + }, + }); + } else { + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults[target] = { + executor: 'nx:run-commands', + options: { + command: `echo Hello from ${target}`, + }, + }; + } return nxJson; }); @@ -518,12 +528,21 @@ describe('Nx Running Tests', () => { const lib = uniq('lib'); updateJson('nx.json', (nxJson) => { - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults[`nx:run-commands`] = { - options: { - command: `echo Hello from ${target}`, - }, - }; + if (Array.isArray(nxJson.targetDefaults)) { + nxJson.targetDefaults.push({ + target: `nx:run-commands`, + options: { + command: `echo Hello from ${target}`, + }, + }); + } else { + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults[`nx:run-commands`] = { + options: { + command: `echo Hello from ${target}`, + }, + }; + } return nxJson; }); diff --git a/e2e/nx/src/spread.test.ts b/e2e/nx/src/spread.test.ts new file mode 100644 index 0000000000000..d62b1cbf8615c --- /dev/null +++ b/e2e/nx/src/spread.test.ts @@ -0,0 +1,922 @@ +import { + cleanupProject, + newProject, + readJson, + runCLI, + uniq, + updateFile, + updateJson, +} from '@nx/e2e-utils'; + +describe('Spread Token Merging', () => { + let proj: string; + beforeAll( + () => (proj = newProject({ packages: ['@nx/js'] })), + 10 * 60 * 1000 + ); + afterAll(() => cleanupProject()); + + // Ensures that nx.json is restored to its original state after each test + let existingNxJson; + beforeEach(() => { + existingNxJson = readJson('nx.json'); + }); + afterEach(() => { + updateFile('nx.json', JSON.stringify(existingNxJson, null, 2)); + }); + + function getResolvedProject(name: string) { + return JSON.parse( + runCLI(`show project ${name} --json`, { verbose: false }) + ); + } + + /** + * Creates a local plugin file at tools/.js that infers targets + * for projects matching libs/* /project.json. + */ + function createPlugin( + name: string, + targetFactory: string // JS expression returning targets object; has access to `root` and `name` + ) { + updateFile( + `tools/${name}.js`, + ` + const { dirname, basename } = require('path'); + module.exports = { + createNodesV2: ['libs/*/project.json', (configFiles) => { + const results = []; + for (const configFile of configFiles) { + const root = dirname(configFile); + const name = basename(root); + const targets = (function(root, name) { return ${targetFactory}; })(root, name); + results.push([configFile, { + projects: { + [root]: { targets } + } + }]); + } + return results; + }], + }; + ` + ); + } + + describe('spread in specified plugins (nx.json plugins)', () => { + it('should resolve spread when first specified plugin contains "..."', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Remove any generator-created targets so they don't interfere + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = {}; + return c; + }); + + // Plugin A: defines build target with inputs + createPlugin( + 'plugin-a', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['first-plugin', '...'], + } + }` + ); + + // Plugin B: also defines build target with inputs + createPlugin( + 'plugin-b', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['second-plugin'], + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/plugin-a', './tools/plugin-b']; + return json; + }); + + const project = getResolvedProject(lib); + // plugin-b merges on top of plugin-a: overwrites + expect(project.targets.build.inputs).toEqual(['second-plugin']); + }); + + it('should resolve spread when middle specified plugin contains "..."', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Remove any generator-created targets so they don't interfere + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = {}; + return c; + }); + + createPlugin( + 'plugin-first', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['from-first'], + } + }` + ); + + createPlugin( + 'plugin-middle', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['from-middle', '...'], + } + }` + ); + + createPlugin( + 'plugin-last', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['from-last'], + } + }` + ); + + updateJson('nx.json', (json) => { + // Order: first, middle (with spread), last + // Processing: first sets base, middle spreads first's values, last replaces + json.plugins = [ + './tools/plugin-first', + './tools/plugin-middle', + './tools/plugin-last', + ]; + return json; + }); + + const project = getResolvedProject(lib); + // last plugin wins (no spread), replaces everything + expect(project.targets.build.inputs).toEqual(['from-last']); + }); + + it('should resolve spread when last specified plugin contains "..."', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Remove any generator-created targets so they don't interfere + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = {}; + return c; + }); + + createPlugin( + 'plugin-base', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['base-value'], + } + }` + ); + + createPlugin( + 'plugin-spreader', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['last-value', '...'], + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/plugin-base', './tools/plugin-spreader']; + return json; + }); + + const project = getResolvedProject(lib); + // last plugin spreads: includes base plugin values + expect(project.targets.build.inputs).toEqual([ + 'last-value', + 'base-value', + ]); + }); + }); + + describe('spread in project.json (default plugin)', () => { + it('should resolve spread in project.json inputs with target defaults as base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + inputs: ['from-target-defaults'], + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + inputs: ['from-project-json', '...'], + options: { command: 'echo hello' }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.echo.inputs).toEqual([ + 'from-project-json', + 'from-target-defaults', + ]); + }); + + it('should resolve spread in project.json options with target default options as base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + options: { + command: 'echo hello', + args: ['default-arg-1', 'default-arg-2'], + }, + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + options: { + args: ['project-arg', '...'], + }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.echo.options.args).toEqual([ + 'project-arg', + 'default-arg-1', + 'default-arg-2', + ]); + }); + + it('should resolve object spread in project.json env with target default env as base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + options: { + command: 'echo hello', + env: { + DEFAULT_VAR: 'from-defaults', + SHARED_VAR: 'default-value', + }, + }, + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + options: { + env: { + PROJECT_VAR: 'from-project', + '...': true, + SHARED_VAR: 'project-value', + }, + }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // Object spread includes base, then SHARED_VAR after spread overrides + expect(project.targets.echo.options.env).toEqual({ + PROJECT_VAR: 'from-project', + DEFAULT_VAR: 'from-defaults', + SHARED_VAR: 'project-value', + }); + }); + + it('should resolve spread in project.json inputs with specified plugin inputs as base (no target defaults)', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Specified plugin infers a build target with inputs + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['inferred-1', 'inferred-2'], + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + // No target defaults for build + return json; + }); + + // project.json uses spread to extend the inferred inputs + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + build: { + inputs: ['from-project', '...'], + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // Spread in project.json expands with inferred plugin values + expect(project.targets.build.inputs).toEqual([ + 'from-project', + 'inferred-1', + 'inferred-2', + ]); + }); + + it('should resolve spread in project.json inputs with specified plugin inputs as base (with target defaults overriding)', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Specified plugin infers a build target with inputs + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['inferred'], + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + // Target defaults override the inferred inputs (no spread) + json.targetDefaults = { + build: { + inputs: ['from-defaults'], + }, + }; + return json; + }); + + // project.json uses spread — base should be the resolved value + // after target defaults replaced inferred + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + build: { + inputs: ['from-project', '...'], + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // Target defaults replaced inferred, so project.json spread sees only defaults + expect(project.targets.build.inputs).toEqual([ + 'from-project', + 'from-defaults', + ]); + }); + + it('should resolve spread in project.json options with specified plugin options as base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Specified plugin infers build target with options + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { + command: 'echo build', + args: ['inferred-arg-1', 'inferred-arg-2'], + env: { INFERRED_VAR: 'from-plugin' }, + }, + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + return json; + }); + + // project.json uses spread to extend the inferred options + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + build: { + options: { + args: ['project-arg', '...'], + env: { PROJECT_VAR: 'from-project', '...': true }, + }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.build.options.args).toEqual([ + 'project-arg', + 'inferred-arg-1', + 'inferred-arg-2', + ]); + expect(project.targets.build.options.env).toEqual({ + PROJECT_VAR: 'from-project', + INFERRED_VAR: 'from-plugin', + }); + }); + + it('should fully replace when project.json does not use spread', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + inputs: ['from-target-defaults'], + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + inputs: ['only-from-project'], + options: { command: 'echo hello' }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // No spread: project.json fully replaces + expect(project.targets.echo.inputs).toEqual(['only-from-project']); + }); + }); + + describe('spread in target defaults', () => { + it('should resolve spread in target defaults with specified plugin values as base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Remove any generator-created targets so they don't interfere + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = {}; + return c; + }); + + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['inferred-input'], + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + json.targetDefaults = { + build: { + inputs: ['default-input', '...'], + }, + }; + return json; + }); + + const project = getResolvedProject(lib); + // Target defaults spread includes specified plugin values + expect(project.targets.build.inputs).toEqual([ + 'default-input', + 'inferred-input', + ]); + }); + + it('should override specified plugin values when target defaults do not use spread', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Remove any generator-created targets so they don't interfere + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = {}; + return c; + }); + + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['inferred-input'], + } + }` + ); + + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + json.targetDefaults = { + build: { + inputs: ['only-default'], + }, + }; + return json; + }); + + const project = getResolvedProject(lib); + // No spread: target defaults fully override + expect(project.targets.build.inputs).toEqual(['only-default']); + }); + }); + + describe('three-layer spread chain (specified + target defaults + project.json)', () => { + it('should resolve spread through all three layers for array properties', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Layer 1: specified plugin infers inputs + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['inferred'], + } + }` + ); + + // Layer 2: target defaults use spread to include inferred + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + json.targetDefaults = { + build: { + inputs: ['from-defaults', '...'], + }, + }; + return json; + }); + + // Layer 3: project.json uses spread to include (defaults + inferred) + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + build: { + inputs: ['from-project', '...'], + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // Full chain: project spreads (defaults + inferred) + expect(project.targets.build.inputs).toEqual([ + 'from-project', + 'from-defaults', + 'inferred', + ]); + }); + + it('should resolve spread through all three layers for object option properties', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Layer 1: specified plugin infers env + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { + command: 'echo build', + env: { INFERRED: 'true' }, + }, + } + }` + ); + + // Layer 2: target defaults spread to include inferred env + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + json.targetDefaults = { + build: { + options: { + env: { DEFAULT: 'true', '...': true }, + }, + }, + }; + return json; + }); + + // Layer 3: project.json spreads to include (defaults + inferred) + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + build: { + options: { + env: { PROJECT: 'true', '...': true }, + }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.build.options.env).toEqual({ + PROJECT: 'true', + DEFAULT: 'true', + INFERRED: 'true', + }); + }); + + it('should resolve spread in project.json with specified + defaults base (no spread in defaults)', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // Specified plugin infers inputs + createPlugin( + 'infer-plugin', + `{ + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + inputs: ['inferred'], + } + }` + ); + + // Target defaults override (no spread) + updateJson('nx.json', (json) => { + json.plugins = ['./tools/infer-plugin']; + json.targetDefaults = { + build: { + inputs: ['from-defaults'], + }, + }; + return json; + }); + + // project.json uses spread + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + build: { + inputs: ['from-project', '...'], + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // Target defaults replaced inferred, project.json spreads with defaults + expect(project.targets.build.inputs).toEqual([ + 'from-project', + 'from-defaults', + ]); + }); + }); + + describe('spread in configurations', () => { + it('should resolve spread in project.json configuration arrays with target default configuration as base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + configurations: { + production: { + args: ['default-prod-arg'], + }, + }, + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + options: { command: 'echo hello' }, + configurations: { + production: { + args: ['project-prod-arg', '...'], + }, + }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.echo.configurations.production.args).toEqual([ + 'project-prod-arg', + 'default-prod-arg', + ]); + }); + }); + + describe('spread edge cases', () => { + it('should handle spread when base has no value for the property', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + // No inputs defined in target defaults + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + inputs: ['from-project', '...'], + options: { command: 'echo hello' }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // Spread with no base: just the project values + expect(project.targets.echo.inputs).toEqual(['from-project']); + }); + + it('should strip spread token when project.json is the only source for a target', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + // No plugins define this target, no target defaults exist for it + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + executor: 'nx:run-commands', + inputs: ['project-input-1', '...', 'project-input-2'], + options: { + command: 'echo hello', + args: ['arg1', '...'], + env: { MY_VAR: 'value', '...': true }, + }, + dependsOn: ['prebuild', '...'], + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // '...' in arrays should be removed (nothing to spread) + expect(project.targets.echo.inputs).toEqual([ + 'project-input-1', + 'project-input-2', + ]); + expect(project.targets.echo.options.args).toEqual(['arg1']); + expect(project.targets.echo.dependsOn).toEqual(['prebuild']); + // '...' key in objects should be removed when value is `true` + expect(project.targets.echo.options.env).toEqual({ MY_VAR: 'value' }); + }); + + it('should expand every spread token in an array against the base', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + inputs: ['default-1', 'default-2'], + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + inputs: ['before', '...', 'middle', '...', 'after'], + options: { command: 'echo hello' }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.echo.inputs).toEqual([ + 'before', + 'default-1', + 'default-2', + 'middle', + 'default-1', + 'default-2', + 'after', + ]); + }); + + it('should not treat string "..." value in object as spread (only boolean true triggers spread)', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + options: { + command: 'echo hello', + env: { DEFAULT_VAR: 'value' }, + }, + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + options: { + env: { + PROJECT_VAR: 'value', + '...': 'this is a comment, not a spread', + }, + }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + // String value for '...' is kept as-is, not treated as spread + expect(project.targets.echo.options.env).toEqual({ + PROJECT_VAR: 'value', + '...': 'this is a comment, not a spread', + }); + }); + + it('should support spread in dependsOn with target defaults', () => { + const lib = uniq('lib'); + runCLI(`generate @nx/js:lib libs/${lib}`); + + updateJson('nx.json', (json) => { + json.targetDefaults = { + echo: { + executor: 'nx:run-commands', + dependsOn: ['^build'], + }, + }; + return json; + }); + + updateJson(`libs/${lib}/project.json`, (c) => { + c.targets = { + echo: { + dependsOn: ['prebuild', '...'], + options: { command: 'echo hello' }, + }, + }; + return c; + }); + + const project = getResolvedProject(lib); + expect(project.targets.echo.dependsOn).toEqual(['prebuild', '^build']); + }); + }); +}); diff --git a/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts b/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts index b5087dc30259e..796e74231d956 100644 --- a/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts +++ b/packages/angular/src/generators/setup-ssr/lib/update-project-config.ts @@ -2,7 +2,11 @@ import type { BrowserBuilderOptions, ServerBuilderOptions, } from '@angular-devkit/build-angular'; -import type { Tree } from '@nx/devkit'; +import type { + NxJsonConfiguration, + TargetConfiguration, + Tree, +} from '@nx/devkit'; import { joinPathFragments, logger, @@ -11,6 +15,7 @@ import { updateNxJson, updateProjectConfiguration, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { getProjectSourceRoot } from '@nx/js/src/utils/typescript/ts-solution-setup'; import type { NormalizedGeneratorOptions } from '../schema'; import { @@ -156,10 +161,25 @@ export function updateProjectConfigForBrowserBuilder( 'server' ); } - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults.server ??= {}; - nxJson.targetDefaults.server.cache ??= true; - updateNxJson(tree, nxJson); + const existing = findServerDefault(nxJson.targetDefaults); + if (!existing || existing.cache === undefined) { + upsertTargetDefault(tree, { target: 'server', cache: true }); + } +} + +function findServerDefault( + td: NxJsonConfiguration['targetDefaults'] +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === 'server' && + e.projects === undefined && + e.source === undefined + ); + } + return td['server']; } function getServerOptions( diff --git a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts index 06136167ef23c..d3efbc6ebbb7d 100644 --- a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts +++ b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts @@ -4,9 +4,30 @@ import { NxJsonConfiguration, readJson, readProjectConfiguration, + type TargetConfiguration, + type TargetDefaults, updateJson, updateProjectConfiguration, } from '@nx/devkit'; + +function getDefault( + td: TargetDefaults | undefined, + target: string +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + const found = td.find( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + if (!found) return undefined; + const { target: _t, projects: _p, source: _s, ...rest } = found; + return rest; + } + return td[target]; +} import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { PackageJson } from 'nx/src/utils/package-json'; import { backwardCompatibleVersions } from '../../utils/backward-compatible-versions'; @@ -120,7 +141,7 @@ describe('setupSSR', () => { " `); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults.server).toBeUndefined(); + expect(getDefault(nxJson.targetDefaults, 'server')).toBeUndefined(); }); it('should create the files correctly for ssr when app is standalone', async () => { @@ -195,7 +216,7 @@ describe('setupSSR', () => { " `); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults.server).toBeUndefined(); + expect(getDefault(nxJson.targetDefaults, 'server')).toBeUndefined(); }); it('should support object output option using a custom "outputPath.browser" and "outputPath.server" values', async () => { @@ -401,7 +422,7 @@ describe('setupSSR', () => { " `); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults.server.cache).toBe(true); + expect(getDefault(nxJson.targetDefaults, 'server')?.cache).toBe(true); }); it('should not import from `zone.js/node` in the server file even when the app is not zoneless', async () => { @@ -483,7 +504,7 @@ describe('setupSSR', () => { " `); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults.server.cache).toEqual(true); + expect(getDefault(nxJson.targetDefaults, 'server')?.cache).toEqual(true); }); it('should update build target output path', async () => { diff --git a/packages/angular/src/migrations/update-17-1-0/browser-target-to-build-target.spec.ts b/packages/angular/src/migrations/update-17-1-0/browser-target-to-build-target.spec.ts index 80d5b46175bc8..d7853de70918e 100644 --- a/packages/angular/src/migrations/update-17-1-0/browser-target-to-build-target.spec.ts +++ b/packages/angular/src/migrations/update-17-1-0/browser-target-to-build-target.spec.ts @@ -4,12 +4,19 @@ import { readProjectConfiguration, updateJson, type NxJsonConfiguration, + type TargetDefaultsRecord, type Tree, } from '@nx/devkit'; import * as devkit from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import migration, { executors } from './browser-target-to-build-target'; +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +type LegacyNxJson = Omit & { + targetDefaults?: TargetDefaultsRecord; +}; + describe('browser-target-to-build-target migration', () => { let tree: Tree; @@ -87,7 +94,7 @@ describe('browser-target-to-build-target migration', () => { it.each(executors)( 'should rename "browserTarget" option in nx.json target defaults for a target with the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.serve = { executor, @@ -102,7 +109,7 @@ describe('browser-target-to-build-target migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.serve.options.browserTarget).toBeUndefined(); expect(nxJson.targetDefaults.serve.options.buildTarget).toBe( '{projectName}:serve' @@ -125,7 +132,7 @@ describe('browser-target-to-build-target migration', () => { it.each(executors)( 'should rename "browserTarget" option in nx.json target defaults for the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[executor] = { options: { browserTarget: '{projectName}:serve' }, @@ -139,7 +146,7 @@ describe('browser-target-to-build-target migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect( nxJson.targetDefaults[executor].options.browserTarget ).toBeUndefined(); diff --git a/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-builders.spec.ts b/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-builders.spec.ts index 5b2d861ed0137..39f8d16dca97f 100644 --- a/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-builders.spec.ts +++ b/packages/angular/src/migrations/update-17-1-0/replace-nguniversal-builders.spec.ts @@ -4,10 +4,17 @@ import { readProjectConfiguration, updateJson, type NxJsonConfiguration, + type TargetDefaultsRecord, type Tree, } from '@nx/devkit'; import * as devkit from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +type LegacyNxJson = Omit & { + targetDefaults?: TargetDefaultsRecord; +}; import migration from './replace-nguniversal-builders'; describe('replace-nguniversal-builders migration', () => { @@ -92,7 +99,7 @@ describe('replace-nguniversal-builders migration', () => { ])( `should replace "%s" with "%s" from nx.json targetDefaults keys`, async (fromExecutor, toExecutor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[fromExecutor] = { options: {}, @@ -103,14 +110,14 @@ describe('replace-nguniversal-builders migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults[fromExecutor]).toBeUndefined(); expect(nxJson.targetDefaults[toExecutor]).toBeDefined(); } ); it('should replace options from nx.json targetDefaults with executor "@nguniversal/builders:prerender" as the key', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults['@nguniversal/builders:prerender'] = { options: { @@ -125,7 +132,7 @@ describe('replace-nguniversal-builders migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); const { guessRoutes, numProcesses, discoverRoutes } = nxJson.targetDefaults['@angular-devkit/build-angular:prerender'].options; expect(guessRoutes).toBeUndefined(); @@ -147,7 +154,7 @@ describe('replace-nguniversal-builders migration', () => { ])( `should replace "%s" with "%s" from nx.json targetDefaults value executors`, async (fromExecutor, target, toExecutor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[target] = { executor: fromExecutor, @@ -159,13 +166,13 @@ describe('replace-nguniversal-builders migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults[target].executor).toBe(toExecutor); } ); it('should replace options from nx.json targetDefaults with executor "@nguniversal/builders:prerender"', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.prerender = { executor: '@nguniversal/builders:prerender', @@ -181,7 +188,7 @@ describe('replace-nguniversal-builders migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); const { guessRoutes, numProcesses, discoverRoutes } = nxJson.targetDefaults.prerender.options; expect(guessRoutes).toBeUndefined(); diff --git a/packages/angular/src/migrations/update-17-2-0/rename-webpack-dev-server.spec.ts b/packages/angular/src/migrations/update-17-2-0/rename-webpack-dev-server.spec.ts index 4832636d37ffe..72d535e743bc8 100644 --- a/packages/angular/src/migrations/update-17-2-0/rename-webpack-dev-server.spec.ts +++ b/packages/angular/src/migrations/update-17-2-0/rename-webpack-dev-server.spec.ts @@ -4,10 +4,17 @@ import { readProjectConfiguration, updateJson, type NxJsonConfiguration, + type TargetDefaultsRecord, type Tree, } from '@nx/devkit'; import * as devkit from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +type LegacyNxJson = Omit & { + targetDefaults?: TargetDefaultsRecord; +}; import migration from './rename-webpack-dev-server'; describe('rename-webpack-dev-server migration', () => { @@ -59,7 +66,7 @@ describe('rename-webpack-dev-server migration', () => { }); it('should replace @nx/angular:webpack-dev-server with @nx/angular:dev-server from nx.json targetDefaults keys', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults['@nx/angular:webpack-dev-server'] = { options: {}, @@ -70,7 +77,7 @@ describe('rename-webpack-dev-server migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect( nxJson.targetDefaults['@nx/angular:webpack-dev-server'] ).toBeUndefined(); @@ -78,7 +85,7 @@ describe('rename-webpack-dev-server migration', () => { }); it('should replace @nrwl/angular:webpack-dev-server with @nx/angular:dev-server from nx.json targetDefaults keys', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults['@nrwl/angular:webpack-dev-server'] = { options: {}, @@ -89,7 +96,7 @@ describe('rename-webpack-dev-server migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect( nxJson.targetDefaults['@nrwl/angular:webpack-dev-server'] ).toBeUndefined(); @@ -97,7 +104,7 @@ describe('rename-webpack-dev-server migration', () => { }); it('should replace @nx/angular:webpack-dev-server with @nx/angular:dev-server from nx.json targetDefaults value executors', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.serve = { executor: '@nx/angular:webpack-dev-server', @@ -109,12 +116,12 @@ describe('rename-webpack-dev-server migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.serve.executor).toBe('@nx/angular:dev-server'); }); it('should replace @nrwl/angular:webpack-dev-server with @nx/angular:dev-server from nx.json targetDefaults value executors', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.serve = { executor: '@nrwl/angular:webpack-dev-server', @@ -126,7 +133,7 @@ describe('rename-webpack-dev-server migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.serve.executor).toBe('@nx/angular:dev-server'); }); }); diff --git a/packages/angular/src/migrations/update-20-2-0/remove-tailwind-config-from-ng-packagr-executors.spec.ts b/packages/angular/src/migrations/update-20-2-0/remove-tailwind-config-from-ng-packagr-executors.spec.ts index 9219b0ba2867f..26f98b8c7efb8 100644 --- a/packages/angular/src/migrations/update-20-2-0/remove-tailwind-config-from-ng-packagr-executors.spec.ts +++ b/packages/angular/src/migrations/update-20-2-0/remove-tailwind-config-from-ng-packagr-executors.spec.ts @@ -4,10 +4,17 @@ import { readProjectConfiguration, updateJson, type NxJsonConfiguration, + type TargetDefaultsRecord, type Tree, } from '@nx/devkit'; import * as devkit from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +type LegacyNxJson = Omit & { + targetDefaults?: TargetDefaultsRecord; +}; import migration, { executors, } from './remove-tailwind-config-from-ng-packagr-executors'; @@ -130,7 +137,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { it.each(executors)( 'should delete "tailwindConfig" option in nx.json target defaults for a target with the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.build = { executor, @@ -154,7 +161,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect( nxJson.targetDefaults.build.options.tailwindConfig ).toBeUndefined(); @@ -170,7 +177,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { it.each(executors)( 'should delete empty target defaults for a target with the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.build = { executor, @@ -191,7 +198,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.build).toBeUndefined(); } ); @@ -199,7 +206,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { it.each(executors)( 'should delete "tailwindConfig" option in nx.json target defaults for the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[executor] = { options: { @@ -222,7 +229,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect( nxJson.targetDefaults[executor].options.tailwindConfig ).toBeUndefined(); @@ -239,7 +246,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { it.each(executors)( 'should delete empty target defaults for a target with the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[executor] = { options: { @@ -259,7 +266,7 @@ describe('remove-tailwind-config-from-ng-packagr-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); console.log(nxJson.targetDefaults); expect(nxJson.targetDefaults[executor]).toBeUndefined(); } diff --git a/packages/cypress/src/generators/init/init.spec.ts b/packages/cypress/src/generators/init/init.spec.ts index d75397a3a3395..338c4aa367c9e 100644 --- a/packages/cypress/src/generators/init/init.spec.ts +++ b/packages/cypress/src/generators/init/init.spec.ts @@ -1,12 +1,38 @@ import 'nx/src/internal-testing-utils/mock-project-graph'; -import { NxJsonConfiguration, readJson, Tree, updateJson } from '@nx/devkit'; +import { + NxJsonConfiguration, + readJson, + type TargetConfiguration, + type TargetDefaults, + Tree, + updateJson, +} from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { cypressVersion } from '../../utils/versions'; import { cypressInitGenerator } from './init'; import { Schema } from './schema'; +function getDefault( + td: TargetDefaults | undefined, + target: string +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + const found = td.find( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + if (!found) return undefined; + const { target: _t, projects: _p, source: _s, ...rest } = found; + return rest; + } + return td[target]; +} + describe('init', () => { let tree: Tree; @@ -49,7 +75,10 @@ describe('init', () => { await cypressInitGenerator(tree, { ...options, addPlugin: false }); expect( - readJson(tree, 'nx.json').targetDefaults.e2e + getDefault( + readJson(tree, 'nx.json').targetDefaults, + 'e2e' + ) ).toEqual({ cache: true, inputs: ['default', '^production'], diff --git a/packages/cypress/src/generators/init/init.ts b/packages/cypress/src/generators/init/init.ts index fb408aa190394..714ee5bc67509 100644 --- a/packages/cypress/src/generators/init/init.ts +++ b/packages/cypress/src/generators/init/init.ts @@ -7,9 +7,12 @@ import { readNxJson, removeDependenciesFromPackageJson, runTasksInSerial, + type TargetConfiguration, + type TargetDefaults, Tree, updateNxJson, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { addPlugin as _addPlugin } from '@nx/devkit/src/utils/add-plugin'; import { createNodesV2 } from '../../plugins/plugin'; import { @@ -27,17 +30,29 @@ function setupE2ETargetDefaults(tree: Tree) { } // E2e targets depend on all their project's sources + production sources of dependencies - nxJson.targetDefaults ??= {}; - const productionFileSet = !!nxJson.namedInputs?.production; - nxJson.targetDefaults.e2e ??= {}; - nxJson.targetDefaults.e2e.cache ??= true; - nxJson.targetDefaults.e2e.inputs ??= [ - 'default', - productionFileSet ? '^production' : '^default', - ]; + const existing = findExistingE2eDefault(nxJson.targetDefaults); + const patch: Partial = {}; + if (!existing?.cache) patch.cache = true; + if (!existing?.inputs) { + patch.inputs = ['default', productionFileSet ? '^production' : '^default']; + } + if (Object.keys(patch).length > 0) { + upsertTargetDefault(tree, { target: 'e2e', ...patch }); + } +} - updateNxJson(tree, nxJson); +function findExistingE2eDefault( + td: TargetDefaults | undefined +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === 'e2e' && e.projects === undefined && e.source === undefined + ); + } + return td['e2e']; } function updateDependencies(tree: Tree, options: Schema) { diff --git a/packages/cypress/src/migrations/update-21-0-0/remove-tsconfig-and-copy-files-options-from-cypress-executor.spec.ts b/packages/cypress/src/migrations/update-21-0-0/remove-tsconfig-and-copy-files-options-from-cypress-executor.spec.ts index 5c7f18fd7779d..1f20139a701a4 100644 --- a/packages/cypress/src/migrations/update-21-0-0/remove-tsconfig-and-copy-files-options-from-cypress-executor.spec.ts +++ b/packages/cypress/src/migrations/update-21-0-0/remove-tsconfig-and-copy-files-options-from-cypress-executor.spec.ts @@ -7,11 +7,18 @@ import { updateJson, type NxJsonConfiguration, type ProjectConfiguration, + type TargetDefaultsRecord, type Tree, } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import migration from './remove-tsconfig-and-copy-files-options-from-cypress-executor'; +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +type LegacyNxJson = Omit & { + targetDefaults?: TargetDefaultsRecord; +}; + describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { let tree: Tree; @@ -155,7 +162,7 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { }); it('should remove tsConfig and copyFiles options in nx.json target defaults for a target with the cypress executor', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.e2e = { executor: '@nx/cypress:cypress', @@ -178,7 +185,7 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.e2e.options).toStrictEqual({ cypressConfig: '{projectRoot}/cypress.config.ts', devServerTarget: '{projectName}:serve', @@ -197,7 +204,7 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { }); it('should remove tsConfig and copyFiles options in nx.json target defaults for the cypress executor', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults['@nx/cypress:cypress'] = { options: { @@ -219,7 +226,7 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults['@nx/cypress:cypress'].options).toStrictEqual({ cypressConfig: '{projectRoot}/cypress.config.ts', devServerTarget: '{projectName}:serve', @@ -276,7 +283,7 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { }); it('should remove empty targetDefault object from nx.json when using a target name as the key', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults = {}; json.targetDefaults.e2e = { executor: '@nx/cypress:cypress', @@ -296,12 +303,12 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults).toBeUndefined(); }); it('should remove empty targetDefault object from nx.json when using the cypress executor as the key', async () => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults = {}; json.targetDefaults['@nx/cypress:cypress'] = { options: { @@ -320,7 +327,7 @@ describe('remove-tsconfig-and-copy-files-options-from-cypress-executor', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults).toBeUndefined(); }); }); diff --git a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts index c3a1e8dec3003..37e06673a8380 100644 --- a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts +++ b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts @@ -617,7 +617,7 @@ async function getCreateNodesResultsForPlugin( pluginConfiguration ); projectConfigs = await retrieveProjectConfigurations( - [plugin], + { specifiedPlugins: [plugin], defaultPlugins: [] }, tree.root, nxJson ); diff --git a/packages/devkit/src/generators/target-defaults-utils.spec.ts b/packages/devkit/src/generators/target-defaults-utils.spec.ts index 9f81816dd427c..a58f191900834 100644 --- a/packages/devkit/src/generators/target-defaults-utils.spec.ts +++ b/packages/devkit/src/generators/target-defaults-utils.spec.ts @@ -1,8 +1,138 @@ import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; import { readNxJson, updateNxJson, type Tree } from 'nx/src/devkit-exports'; import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; -import { addE2eCiTargetDefaults } from './target-defaults-utils'; +import { + addBuildTargetDefaults, + addE2eCiTargetDefaults, + upsertTargetDefault, +} from './target-defaults-utils'; describe('target-defaults-utils', () => { + describe('upsertTargetDefault', () => { + let tree: Tree; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('appends a new entry when nx.json has no targetDefaults', () => { + const nxJson = readNxJson(tree); + delete nxJson.targetDefaults; + updateNxJson(tree, nxJson); + + upsertTargetDefault(tree, { target: 'test', cache: true }); + + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'test', cache: true }, + ]); + }); + + it('merges into an existing array entry with same filter tuple', () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = [{ target: 'test', cache: true }]; + updateNxJson(tree, nxJson); + + upsertTargetDefault(tree, { target: 'test', inputs: ['default'] }); + + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'test', cache: true, inputs: ['default'] }, + ]); + }); + + it('appends a new array entry when filter tuple differs', () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = [{ target: 'test', cache: true }]; + updateNxJson(tree, nxJson); + + upsertTargetDefault(tree, { + target: 'test', + source: '@nx/vite', + inputs: ['vite'], + }); + + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'test', cache: true }, + { target: 'test', source: '@nx/vite', inputs: ['vite'] }, + ]); + }); + + it('preserves legacy record shape when no filters are specified', () => { + const nxJson = readNxJson(tree); + (nxJson as any).targetDefaults = { + build: { cache: true }, + }; + updateNxJson(tree, nxJson); + + upsertTargetDefault(tree, { target: 'test', cache: true }); + + expect(readNxJson(tree).targetDefaults).toEqual({ + build: { cache: true }, + test: { cache: true }, + }); + }); + + it('upgrades a legacy record to array when a filter is requested', () => { + const nxJson = readNxJson(tree); + (nxJson as any).targetDefaults = { + build: { cache: true }, + }; + updateNxJson(tree, nxJson); + + upsertTargetDefault(tree, { + target: 'test', + projects: 'tag:dotnet', + inputs: ['x'], + }); + + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'build', cache: true }, + { target: 'test', projects: 'tag:dotnet', inputs: ['x'] }, + ]); + }); + }); + + describe('addBuildTargetDefaults', () => { + let tree: Tree; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('adds entry to legacy record shape preserving record', () => { + const nxJson = readNxJson(tree); + (nxJson as any).targetDefaults = {}; + updateNxJson(tree, nxJson); + + addBuildTargetDefaults(tree, '@nx/vite:build'); + + expect(readNxJson(tree).targetDefaults).toEqual({ + '@nx/vite:build': { + cache: true, + dependsOn: ['^build'], + inputs: ['default', '^default'], + }, + }); + }); + + it('appends to array shape and is idempotent', () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = [{ target: 'test', cache: true }]; + updateNxJson(tree, nxJson); + + addBuildTargetDefaults(tree, '@nx/vite:build'); + addBuildTargetDefaults(tree, '@nx/vite:build'); + + const td = readNxJson(tree).targetDefaults; + expect(Array.isArray(td)).toBe(true); + expect(td as any).toEqual([ + { target: 'test', cache: true }, + { + target: '@nx/vite:build', + cache: true, + dependsOn: ['^build'], + inputs: ['default', '^default'], + }, + ]); + }); + }); + describe('addE2eCiTargetDefaults', () => { let tree: Tree; let tempFs: TempFs; diff --git a/packages/devkit/src/generators/target-defaults-utils.ts b/packages/devkit/src/generators/target-defaults-utils.ts index 00f5bbb0fff0c..c5e76e44c937c 100644 --- a/packages/devkit/src/generators/target-defaults-utils.ts +++ b/packages/devkit/src/generators/target-defaults-utils.ts @@ -1,20 +1,100 @@ import { type CreateNodesV2, type PluginConfiguration, + type TargetConfiguration, + type TargetDefaultEntry, + type TargetDefaults, type Tree, readNxJson, updateNxJson, } from 'nx/src/devkit-exports'; import { findMatchingConfigFiles } from 'nx/src/devkit-internals'; +import { normalizeTargetDefaults } from '../utils/normalize-target-defaults'; + +export interface UpsertTargetDefaultOptions + extends Partial { + target: string; + projects?: string | string[]; + source?: string; +} + +/** + * Upsert a `targetDefaults` entry in nx.json. Reads the current shape + * (array or legacy record), finds a matching entry by the + * `(target, projects, source)` tuple, and merges the given config into + * it (or appends a new entry). + * + * If nx.json uses the legacy record shape AND the caller provides a + * `projects` or `source` filter, nx.json is upgraded to the array shape + * because the record shape cannot express filtered entries. + */ +export function upsertTargetDefault( + tree: Tree, + options: UpsertTargetDefaultOptions +): void { + const { target, projects, source, ...config } = options; + const nxJson = readNxJson(tree) ?? {}; + const existing: TargetDefaults | undefined = nxJson.targetDefaults; + const hasFilter = projects !== undefined || source !== undefined; + + // Legacy record shape, no filters → stay in record shape. + if (existing && !Array.isArray(existing) && !hasFilter) { + const record = { ...existing }; + record[target] = { ...(record[target] ?? {}), ...config }; + nxJson.targetDefaults = record; + updateNxJson(tree, nxJson); + return; + } + + const entries = normalizeTargetDefaults(existing); + const matchIndex = entries.findIndex( + (e) => + e.target === target && + projectsEqual(e.projects, projects) && + e.source === source + ); + + const newEntry: TargetDefaultEntry = { + target, + ...(projects !== undefined ? { projects } : {}), + ...(source !== undefined ? { source } : {}), + ...config, + }; + + if (matchIndex >= 0) { + const merged = { ...entries[matchIndex], ...config, target }; + if (projects !== undefined) merged.projects = projects; + if (source !== undefined) merged.source = source; + entries[matchIndex] = merged; + } else { + entries.push(newEntry); + } + + nxJson.targetDefaults = entries; + updateNxJson(tree, nxJson); +} + +function projectsEqual( + a: string | string[] | undefined, + b: string | string[] | undefined +): boolean { + if (a === b) return true; + const aArr = a === undefined ? undefined : Array.isArray(a) ? a : [a]; + const bArr = b === undefined ? undefined : Array.isArray(b) ? b : [b]; + if (!aArr || !bArr) return false; + if (aArr.length !== bArr.length) return false; + for (let i = 0; i < aArr.length; i++) if (aArr[i] !== bArr[i]) return false; + return true; +} export function addBuildTargetDefaults( tree: Tree, executorName: string, buildTargetName = 'build' ): void { - const nxJson = readNxJson(tree); - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults[executorName] ??= { + const nxJson = readNxJson(tree) ?? {}; + const existing = nxJson.targetDefaults; + const defaultConfig: Partial = { cache: true, dependsOn: [`^${buildTargetName}`], inputs: @@ -22,6 +102,23 @@ export function addBuildTargetDefaults( ? ['production', '^production'] : ['default', '^default'], }; + + // Preserve legacy record-shape behavior when nx.json is still in that + // shape: only set the entry if one does not already exist at the + // executor key, and do not upgrade to the array shape. + if (existing && !Array.isArray(existing)) { + if (existing[executorName]) return; + nxJson.targetDefaults = { ...existing, [executorName]: defaultConfig }; + updateNxJson(tree, nxJson); + return; + } + + const entries = normalizeTargetDefaults(existing); + if (entries.some((e) => e.target === executorName)) { + return; + } + entries.push({ target: executorName, ...defaultConfig }); + nxJson.targetDefaults = entries; updateNxJson(tree, nxJson); } @@ -32,7 +129,7 @@ export async function addE2eCiTargetDefaults( pathToE2EConfigFile: string ): Promise { const nxJson = readNxJson(tree); - if (!nxJson.plugins) { + if (!nxJson?.plugins) { return; } @@ -81,17 +178,40 @@ export async function addE2eCiTargetDefaults( : ((foundPluginForApplication.options as any)?.ciTargetName ?? 'e2e-ci'); const ciTargetNameGlob = `${ciTargetName}--**/**`; - nxJson.targetDefaults ??= {}; - const e2eCiTargetDefaults = nxJson.targetDefaults[ciTargetNameGlob]; - if (!e2eCiTargetDefaults) { - nxJson.targetDefaults[ciTargetNameGlob] = { - dependsOn: [buildTarget], - }; + const existing = nxJson.targetDefaults; + + // Legacy record-shape: preserve the existing mutate-in-place behavior. + if (existing && !Array.isArray(existing)) { + const current = existing[ciTargetNameGlob]; + if (!current) { + existing[ciTargetNameGlob] = { dependsOn: [buildTarget] }; + } else { + current.dependsOn ??= []; + if (!current.dependsOn.includes(buildTarget)) { + current.dependsOn.push(buildTarget); + } + } + nxJson.targetDefaults = existing; + updateNxJson(tree, nxJson); + return; + } + + const entries = normalizeTargetDefaults(existing); + const matchIndex = entries.findIndex( + (e) => + e.target === ciTargetNameGlob && + e.projects === undefined && + e.source === undefined + ); + if (matchIndex < 0) { + entries.push({ target: ciTargetNameGlob, dependsOn: [buildTarget] }); } else { - e2eCiTargetDefaults.dependsOn ??= []; - if (!e2eCiTargetDefaults.dependsOn.includes(buildTarget)) { - e2eCiTargetDefaults.dependsOn.push(buildTarget); + const entry = entries[matchIndex]; + entry.dependsOn ??= []; + if (!(entry.dependsOn as (string | unknown)[]).includes(buildTarget)) { + (entry.dependsOn as (string | unknown)[]).push(buildTarget); } } + nxJson.targetDefaults = entries; updateNxJson(tree, nxJson); } diff --git a/packages/devkit/src/utils/add-plugin.ts b/packages/devkit/src/utils/add-plugin.ts index 73bb767b4361d..1e41d2c9f0793 100644 --- a/packages/devkit/src/utils/add-plugin.ts +++ b/packages/devkit/src/utils/add-plugin.ts @@ -124,7 +124,10 @@ async function _addPluginInternal( global.NX_GRAPH_CREATION = true; try { projConfigs = await retrieveProjectConfigurations( - [pluginFactory(pluginOptions)], + { + specifiedPlugins: [pluginFactory(pluginOptions)], + defaultPlugins: [], + }, tree.root, nxJson ); @@ -169,7 +172,10 @@ async function _addPluginInternal( global.NX_GRAPH_CREATION = true; try { projConfigs = await retrieveProjectConfigurations( - [pluginFactory(pluginOptions)], + { + specifiedPlugins: [pluginFactory(pluginOptions)], + defaultPlugins: [], + }, tree.root, nxJson ); diff --git a/packages/devkit/src/utils/normalize-target-defaults.spec.ts b/packages/devkit/src/utils/normalize-target-defaults.spec.ts new file mode 100644 index 0000000000000..63b1c1500ce8b --- /dev/null +++ b/packages/devkit/src/utils/normalize-target-defaults.spec.ts @@ -0,0 +1,134 @@ +import { + findTargetDefaultEntry, + isTargetDefaultsArray, + normalizeTargetDefaults, +} from './normalize-target-defaults'; + +describe('normalizeTargetDefaults', () => { + it('returns [] for undefined', () => { + expect(normalizeTargetDefaults(undefined)).toEqual([]); + }); + + it('returns a shallow copy of an array shape', () => { + const input = [{ target: 'test' as const, cache: true }]; + const out = normalizeTargetDefaults(input); + expect(out).toEqual(input); + expect(out).not.toBe(input); + }); + + it('converts record shape preserving insertion order', () => { + expect( + normalizeTargetDefaults({ + build: { cache: true }, + 'e2e-ci--*': { dependsOn: ['build'] }, + '@nx/vite:test': { inputs: ['default'] }, + }) + ).toEqual([ + { target: 'build', cache: true }, + { target: 'e2e-ci--*', dependsOn: ['build'] }, + { target: '@nx/vite:test', inputs: ['default'] }, + ]); + }); + + it('tolerates empty record entries', () => { + expect( + normalizeTargetDefaults({ + build: undefined as any, + lint: null as any, + }) + ).toEqual([{ target: 'build' }, { target: 'lint' }]); + }); +}); + +describe('isTargetDefaultsArray', () => { + it('returns true for arrays', () => { + expect(isTargetDefaultsArray([])).toBe(true); + expect(isTargetDefaultsArray([{ target: 'a' }])).toBe(true); + }); + + it('returns false for record or undefined', () => { + expect(isTargetDefaultsArray({})).toBe(false); + expect(isTargetDefaultsArray({ build: { cache: true } })).toBe(false); + expect(isTargetDefaultsArray(undefined)).toBe(false); + }); +}); + +describe('findTargetDefaultEntry', () => { + it('returns undefined when target defaults absent', () => { + expect( + findTargetDefaultEntry(undefined, { target: 'test' }) + ).toBeUndefined(); + }); + + it('finds an entry in the array shape by target only', () => { + expect( + findTargetDefaultEntry( + [ + { target: 'build', cache: true }, + { target: 'test', cache: false }, + ], + { target: 'test' } + ) + ).toEqual({ target: 'test', cache: false }); + }); + + it('distinguishes array entries by projects filter', () => { + const entries = [ + { target: 'test' as const, cache: true }, + { target: 'test' as const, projects: 'web', inputs: ['x'] }, + ]; + expect(findTargetDefaultEntry(entries, { target: 'test' })).toEqual( + entries[0] + ); + expect( + findTargetDefaultEntry(entries, { target: 'test', projects: 'web' }) + ).toEqual(entries[1]); + }); + + it('distinguishes array entries by source filter', () => { + const entries = [ + { target: 'test' as const, cache: true }, + { target: 'test' as const, source: '@nx/vite', inputs: ['v'] }, + ]; + expect( + findTargetDefaultEntry(entries, { target: 'test', source: '@nx/vite' }) + ).toEqual(entries[1]); + }); + + it('returns the unfiltered record entry when no filters requested', () => { + expect( + findTargetDefaultEntry({ build: { cache: true } }, { target: 'build' }) + ).toEqual({ target: 'build', cache: true }); + }); + + it('returns undefined when record is consulted with filters', () => { + expect( + findTargetDefaultEntry( + { build: { cache: true } }, + { target: 'build', projects: 'web' } + ) + ).toBeUndefined(); + }); + + it('compares arrays of projects by shallow equality', () => { + const entries = [ + { + target: 'test' as const, + projects: ['a', 'b'], + inputs: ['x'], + }, + ]; + expect( + findTargetDefaultEntry(entries, { + target: 'test', + projects: ['a', 'b'], + }) + ).toEqual(entries[0]); + expect( + findTargetDefaultEntry(entries, { + target: 'test', + projects: ['b', 'a'], + }) + ).toBeUndefined(); + }); +}); diff --git a/packages/devkit/src/utils/normalize-target-defaults.ts b/packages/devkit/src/utils/normalize-target-defaults.ts new file mode 100644 index 0000000000000..5c26bc6d159b0 --- /dev/null +++ b/packages/devkit/src/utils/normalize-target-defaults.ts @@ -0,0 +1,78 @@ +import type { + TargetDefaultEntry, + TargetDefaults, + TargetDefaultsRecord, +} from 'nx/src/devkit-exports'; + +/** + * Convert an nx.json `targetDefaults` value (either the legacy record shape + * or the new array shape) into the normalized array shape. + * + * Record entries become `{ target: key, ...value }` preserving insertion + * order. Executor-looking record keys (e.g. `nx:run-commands`) keep the + * executor string in `target`; the nx-core matcher treats `target` as + * either a target name, a glob, or an executor, so legacy semantics are + * preserved. + */ +export function normalizeTargetDefaults( + raw: TargetDefaults | undefined +): TargetDefaultEntry[] { + if (!raw) return []; + if (Array.isArray(raw)) return [...raw]; + const out: TargetDefaultEntry[] = []; + const record = raw as TargetDefaultsRecord; + for (const key of Object.keys(record)) { + const value = record[key] ?? {}; + out.push({ ...value, target: key }); + } + return out; +} + +/** + * True when the given `targetDefaults` is already in the array shape. + */ +export function isTargetDefaultsArray( + raw: TargetDefaults | undefined +): raw is TargetDefaultEntry[] { + return Array.isArray(raw); +} + +/** + * Find an existing target defaults entry by the tuple + * `(target, projects, source)`. Returns `undefined` when no such entry + * exists. For the legacy record shape only the `target` tuple key is + * consulted; a record never carries `projects`/`source` filters. + */ +export function findTargetDefaultEntry( + raw: TargetDefaults | undefined, + match: { target: string; projects?: string | string[]; source?: string } +): TargetDefaultEntry | undefined { + if (!raw) return undefined; + if (Array.isArray(raw)) { + return raw.find( + (e) => + e.target === match.target && + sameProjects(e.projects, match.projects) && + e.source === match.source + ); + } + if (match.projects === undefined && match.source === undefined) { + const value = raw[match.target]; + if (!value) return undefined; + return { ...value, target: match.target }; + } + return undefined; +} + +function sameProjects( + a: string | string[] | undefined, + b: string | string[] | undefined +): boolean { + if (a === b) return true; + const aArr = a === undefined ? undefined : Array.isArray(a) ? a : [a]; + const bArr = b === undefined ? undefined : Array.isArray(b) ? b : [b]; + if (!aArr || !bArr) return false; + if (aArr.length !== bArr.length) return false; + for (let i = 0; i < aArr.length; i++) if (aArr[i] !== bArr[i]) return false; + return true; +} diff --git a/packages/eslint/src/generators/convert-to-flat-config/generator.ts b/packages/eslint/src/generators/convert-to-flat-config/generator.ts index 8d0805cd771b6..3ff3c7d2bffd5 100644 --- a/packages/eslint/src/generators/convert-to-flat-config/generator.ts +++ b/packages/eslint/src/generators/convert-to-flat-config/generator.ts @@ -10,6 +10,7 @@ import { ProjectConfiguration, readJson, readNxJson, + TargetConfiguration, Tree, updateJson, updateProjectConfiguration, @@ -183,15 +184,24 @@ function convertProjectToFlatConfig( function updateNxJsonConfig(tree: Tree, format: 'cjs' | 'mjs') { if (tree.exists('nx.json')) { updateJson(tree, 'nx.json', (json: NxJsonConfiguration) => { - if (json.targetDefaults?.lint?.inputs) { - const inputSet = new Set(json.targetDefaults.lint.inputs); + const addInputToEntry = (entry: Partial) => { + if (!entry?.inputs) return; + const inputSet = new Set(entry.inputs); inputSet.add(`{workspaceRoot}/eslint.config.${format}`); - json.targetDefaults.lint.inputs = Array.from(inputSet); - } - if (json.targetDefaults?.['@nx/eslint:lint']?.inputs) { - const inputSet = new Set(json.targetDefaults['@nx/eslint:lint'].inputs); - inputSet.add(`{workspaceRoot}/eslint.config.${format}`); - json.targetDefaults['@nx/eslint:lint'].inputs = Array.from(inputSet); + entry.inputs = Array.from(inputSet); + }; + const td = json.targetDefaults; + if (td) { + if (Array.isArray(td)) { + for (const entry of td) { + if (entry.target === 'lint' || entry.target === '@nx/eslint:lint') { + addInputToEntry(entry); + } + } + } else { + addInputToEntry(td['lint']); + addInputToEntry(td['@nx/eslint:lint']); + } } if (json.namedInputs?.production) { const inputSet = new Set(json.namedInputs.production); diff --git a/packages/eslint/src/generators/init/init.spec.ts b/packages/eslint/src/generators/init/init.spec.ts index 92deaaba89ec7..937d47d07d743 100644 --- a/packages/eslint/src/generators/init/init.spec.ts +++ b/packages/eslint/src/generators/init/init.spec.ts @@ -3,6 +3,8 @@ import 'nx/src/internal-testing-utils/mock-project-graph'; import { NxJsonConfiguration, readJson, + type TargetConfiguration, + type TargetDefaults, Tree, updateJson, writeJson, @@ -11,6 +13,25 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { LinterInitOptions, lintInitGenerator } from './init'; import { setWorkspaceRoot } from 'nx/src/utils/workspace-root'; +function getDefault( + td: TargetDefaults | undefined, + target: string +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + const found = td.find( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + if (!found) return undefined; + const { target: _t, projects: _p, source: _s, ...rest } = found; + return rest; + } + return td[target]; +} + describe('@nx/eslint:init', () => { let tree: Tree; let options: LinterInitOptions; @@ -41,9 +62,10 @@ describe('@nx/eslint:init', () => { await lintInitGenerator(tree, options); expect( - readJson(tree, 'nx.json').targetDefaults[ + getDefault( + readJson(tree, 'nx.json').targetDefaults, '@nx/eslint:lint' - ] + ) ).toBeUndefined(); expect(readJson(tree, 'nx.json').plugins) .toMatchInlineSnapshot(` @@ -111,7 +133,10 @@ describe('@nx/eslint:init', () => { }); expect( - readJson(tree, 'nx.json').targetDefaults['@nx/eslint:lint'] + getDefault( + readJson(tree, 'nx.json').targetDefaults, + '@nx/eslint:lint' + ) ).toEqual({ cache: true, inputs: [ @@ -139,9 +164,10 @@ describe('@nx/eslint:init', () => { }); expect( - readJson(tree, 'nx.json').targetDefaults[ + getDefault( + readJson(tree, 'nx.json').targetDefaults, '@nx/eslint:lint' - ] + ) ).toEqual({ cache: true, inputs: [ @@ -165,7 +191,10 @@ describe('@nx/eslint:init', () => { }); expect( - readJson(tree, 'nx.json').targetDefaults['@nx/eslint:lint'] + getDefault( + readJson(tree, 'nx.json').targetDefaults, + '@nx/eslint:lint' + ) ).toEqual({ cache: true, inputs: [ @@ -193,9 +222,10 @@ describe('@nx/eslint:init', () => { }); expect( - readJson(tree, 'nx.json').targetDefaults[ + getDefault( + readJson(tree, 'nx.json').targetDefaults, '@nx/eslint:lint' - ] + ) ).toEqual({ cache: true, inputs: [ diff --git a/packages/eslint/src/generators/init/init.ts b/packages/eslint/src/generators/init/init.ts index b75b98b313985..d19694bf88c8d 100644 --- a/packages/eslint/src/generators/init/init.ts +++ b/packages/eslint/src/generators/init/init.ts @@ -5,10 +5,13 @@ import { readNxJson, removeDependenciesFromPackageJson, runTasksInSerial, + type TargetConfiguration, + type TargetDefaults, Tree, updateJson, updateNxJson, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; import { eslintVersion, nxVersion } from '../../utils/versions'; import { @@ -43,19 +46,37 @@ function updateProductionFileset(tree: Tree, format: 'mjs' | 'cjs' = 'mjs') { function addTargetDefaults(tree: Tree, format: 'mjs' | 'cjs') { const nxJson = readNxJson(tree); + const existing = findExistingLintDefault(nxJson?.targetDefaults); + const patch: Partial = {}; + if (existing?.cache === undefined) patch.cache = true; + if (existing?.inputs === undefined) { + patch.inputs = [ + 'default', + '^default', + `{workspaceRoot}/.eslintrc.json`, + `{workspaceRoot}/.eslintignore`, + `{workspaceRoot}/eslint.config.${format}`, + '{workspaceRoot}/tools/eslint-rules/**/*', + ]; + } + if (Object.keys(patch).length > 0) { + upsertTargetDefault(tree, { target: '@nx/eslint:lint', ...patch }); + } +} - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults['@nx/eslint:lint'] ??= {}; - nxJson.targetDefaults['@nx/eslint:lint'].cache ??= true; - nxJson.targetDefaults['@nx/eslint:lint'].inputs ??= [ - 'default', - '^default', - `{workspaceRoot}/.eslintrc.json`, - `{workspaceRoot}/.eslintignore`, - `{workspaceRoot}/eslint.config.${format}`, - '{workspaceRoot}/tools/eslint-rules/**/*', - ]; - updateNxJson(tree, nxJson); +function findExistingLintDefault( + td: TargetDefaults | undefined +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === '@nx/eslint:lint' && + e.projects === undefined && + e.source === undefined + ); + } + return td['@nx/eslint:lint']; } function updateVsCodeRecommendedExtensions(host: Tree) { diff --git a/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.spec.ts b/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.spec.ts index 1a9661440b9bc..e43553eaaa99c 100644 --- a/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.spec.ts +++ b/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.spec.ts @@ -32,9 +32,11 @@ describe('@nx/eslint:workspace-rules-project', () => { }); await lintWorkspaceRulesProjectGenerator(tree); - expect( - readJson(tree, 'nx.json').targetDefaults.lint.inputs - ).toContain('{workspaceRoot}/tools/eslint-rules/**/*'); + const td = readJson(tree, 'nx.json').targetDefaults!; + const lint = Array.isArray(td) + ? td.find((e) => e.target === 'lint') + : td.lint; + expect(lint?.inputs).toContain('{workspaceRoot}/tools/eslint-rules/**/*'); }); it('should generate the required files', async () => { diff --git a/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts b/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts index 0a6d39d01afff..b64d63ce6ea8c 100644 --- a/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts +++ b/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts @@ -9,6 +9,8 @@ import { readNxJson, readProjectConfiguration, runTasksInSerial, + type TargetConfiguration, + type TargetDefaults, Tree, updateJson, updateNxJson, @@ -66,10 +68,9 @@ export async function lintWorkspaceRulesProjectGenerator( */ const nxJson = readNxJson(tree); - if (nxJson.targetDefaults?.lint?.inputs) { - nxJson.targetDefaults.lint.inputs.push( - `{workspaceRoot}/${WORKSPACE_PLUGIN_DIR}/**/*` - ); + const lintEntry = findLintTargetDefault(nxJson.targetDefaults); + if (lintEntry?.inputs) { + lintEntry.inputs.push(`{workspaceRoot}/${WORKSPACE_PLUGIN_DIR}/**/*`); updateNxJson(tree, nxJson); } @@ -135,3 +136,18 @@ export async function lintWorkspaceRulesProjectGenerator( return runTasksInSerial(...tasks); } + +function findLintTargetDefault( + td: TargetDefaults | undefined +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === 'lint' && + e.projects === undefined && + e.source === undefined + ); + } + return td['lint']; +} diff --git a/packages/jest/src/generators/configuration/configuration.spec.ts b/packages/jest/src/generators/configuration/configuration.spec.ts index 81f18a48a304b..3e072ec4787fe 100644 --- a/packages/jest/src/generators/configuration/configuration.spec.ts +++ b/packages/jest/src/generators/configuration/configuration.spec.ts @@ -5,6 +5,7 @@ import { readJson, readProjectConfiguration, Tree, + updateNxJson, updateProjectConfiguration, writeJson, updateJson, @@ -588,13 +589,21 @@ describe('jestProject', () => { }); it(`should setup a task pipeline for the test target to depend on the deps' build target`, async () => { + // seed nx.json with array-shape targetDefaults so the generator's + // upsert call preserves the array shape we assert against below + const seeded = readNxJson(tree); + updateNxJson(tree, { ...seeded, targetDefaults: [] }); + await configurationGenerator(tree, { ...defaultOptions, project: 'pkg1', }); const nxJson = readNxJson(tree); - expect(nxJson.targetDefaults.test.dependsOn).toStrictEqual(['^build']); + expect(nxJson.targetDefaults).toContainEqual({ + target: 'test', + dependsOn: ['^build'], + }); }); it('should generate files with swc compiler', async () => { diff --git a/packages/jest/src/generators/configuration/configuration.ts b/packages/jest/src/generators/configuration/configuration.ts index a324a6824cad4..3af415f179a73 100644 --- a/packages/jest/src/generators/configuration/configuration.ts +++ b/packages/jest/src/generators/configuration/configuration.ts @@ -5,9 +5,11 @@ import { readNxJson, readProjectConfiguration, runTasksInSerial, + type TargetConfiguration, + type TargetDefaults, Tree, - updateNxJson, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { initGenerator as jsInitGenerator } from '@nx/js'; import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup'; import { JestPluginOptions } from '../../plugins/plugin'; @@ -137,14 +139,14 @@ export async function configurationGeneratorInternal( // in the TS solution setup, the test target depends on the build outputs // so we need to setup the task pipeline accordingly const nxJson = readNxJson(tree); - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults[options.targetName] ??= {}; - nxJson.targetDefaults[options.targetName].dependsOn ??= []; - nxJson.targetDefaults[options.targetName].dependsOn.push('^build'); - nxJson.targetDefaults[options.targetName].dependsOn = Array.from( - new Set(nxJson.targetDefaults[options.targetName].dependsOn) + const existing = findExistingTestDefault( + nxJson?.targetDefaults, + options.targetName ); - updateNxJson(tree, nxJson); + const dependsOn = Array.from( + new Set([...(existing?.dependsOn ?? []), '^build']) + ); + upsertTargetDefault(tree, { target: options.targetName, dependsOn }); } if (!schema.skipFormat) { @@ -154,6 +156,22 @@ export async function configurationGeneratorInternal( return runTasksInSerial(...tasks); } +function findExistingTestDefault( + td: TargetDefaults | undefined, + targetName: string +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === targetName && + e.projects === undefined && + e.source === undefined + ); + } + return td[targetName]; +} + function ignoreTestOutput(tree: Tree): void { if (!tree.exists('.gitignore')) { logger.warn(`Couldn't find a root .gitignore file to update.`); diff --git a/packages/jest/src/generators/init/init.spec.ts b/packages/jest/src/generators/init/init.spec.ts index 6841ce4218131..0035d34b1bab0 100644 --- a/packages/jest/src/generators/init/init.spec.ts +++ b/packages/jest/src/generators/init/init.spec.ts @@ -3,6 +3,7 @@ import 'nx/src/internal-testing-utils/mock-project-graph'; import { type NxJsonConfiguration, readJson, + type TargetDefaultEntry, type Tree, updateJson, } from '@nx/devkit'; @@ -16,6 +17,11 @@ describe('jest', () => { beforeEach(() => { tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + // ensure targetDefaults starts as the array shape so assertions target it + updateJson(tree, 'nx.json', (json) => { + json.targetDefaults = []; + return json; + }); options = { addPlugin: true, }; @@ -32,8 +38,6 @@ describe('jest', () => { const productionFileSet = readJson(tree, 'nx.json') .namedInputs.production; - const jestDefaults = readJson(tree, 'nx.json') - .targetDefaults['@nx/jest:jest']; expect(productionFileSet).toContain( '!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)' ); @@ -58,14 +62,17 @@ describe('jest', () => { '!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)', '!{projectRoot}/**/*.md', ]; - json.targetDefaults.test = { + const entries = (json.targetDefaults ?? []) as TargetDefaultEntry[]; + entries.push({ + target: 'test', inputs: [ 'default', '^production', '{workspaceRoot}/jest.preset.js', '{workspaceRoot}/testSetup.ts', ], - }; + }); + json.targetDefaults = entries; nxJson = json; return json; }); diff --git a/packages/jest/src/generators/init/init.ts b/packages/jest/src/generators/init/init.ts index 2c60e05b5464c..b60e4479e22f3 100644 --- a/packages/jest/src/generators/init/init.ts +++ b/packages/jest/src/generators/init/init.ts @@ -7,8 +7,11 @@ import { runTasksInSerial, updateNxJson, type GeneratorCallback, + type TargetConfiguration, + type TargetDefaults, type Tree, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; import { createNodesV2 } from '../../plugins/plugin'; import { @@ -49,31 +52,49 @@ function updateProductionFileSet(tree: Tree) { function addJestTargetDefaults(tree: Tree, presetExt: JestPresetExtension) { const nxJson = readNxJson(tree); + const existing = findExistingJestDefault(nxJson?.targetDefaults); + const productionFileSet = nxJson?.namedInputs?.production; - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults['@nx/jest:jest'] ??= {}; - - const productionFileSet = nxJson.namedInputs?.production; - - nxJson.targetDefaults['@nx/jest:jest'].cache ??= true; + const patch: Partial = {}; + if (existing?.cache === undefined) patch.cache = true; // Test targets depend on all their project's sources + production sources of dependencies - nxJson.targetDefaults['@nx/jest:jest'].inputs ??= [ - 'default', - productionFileSet ? '^production' : '^default', - `{workspaceRoot}/jest.preset.${presetExt}`, - ]; - - nxJson.targetDefaults['@nx/jest:jest'].options ??= { - passWithNoTests: true, - }; - nxJson.targetDefaults['@nx/jest:jest'].configurations ??= { - ci: { - ci: true, - codeCoverage: true, - }, - }; + if (existing?.inputs === undefined) { + patch.inputs = [ + 'default', + productionFileSet ? '^production' : '^default', + `{workspaceRoot}/jest.preset.${presetExt}`, + ]; + } + if (existing?.options === undefined) { + patch.options = { passWithNoTests: true }; + } + if (existing?.configurations === undefined) { + patch.configurations = { + ci: { + ci: true, + codeCoverage: true, + }, + }; + } - updateNxJson(tree, nxJson); + if (Object.keys(patch).length > 0) { + upsertTargetDefault(tree, { target: '@nx/jest:jest', ...patch }); + } +} + +function findExistingJestDefault( + td: TargetDefaults | undefined +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === '@nx/jest:jest' && + e.projects === undefined && + e.source === undefined + ); + } + return td['@nx/jest:jest']; } function updateDependencies(tree: Tree, options: JestInitSchema) { diff --git a/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.spec.ts b/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.spec.ts index 038dcb23074a0..ac7b435919866 100644 --- a/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.spec.ts +++ b/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.spec.ts @@ -132,63 +132,68 @@ describe('remove-tsconfig-option-from-jest-executor', () => { it('should remove tsConfig option in nx.json target defaults for a target with the jest executor', async () => { updateJson(tree, 'nx.json', (json) => { - json.targetDefaults ??= {}; - json.targetDefaults.test = { - executor: '@nx/jest:jest', - options: { - jestConfig: '{projectRoot}/jest.config.ts', - tsConfig: '{projectRoot}/tsconfig.json', - }, - configurations: { - production: { - tsConfig: '{projectRoot}/tsconfig.prod.json', - codeCoverage: true, + json.targetDefaults = [ + { + target: 'test', + executor: '@nx/jest:jest', + options: { + jestConfig: '{projectRoot}/jest.config.ts', + tsConfig: '{projectRoot}/tsconfig.json', + }, + configurations: { + production: { + tsConfig: '{projectRoot}/tsconfig.prod.json', + codeCoverage: true, + }, }, }, - }; + ]; return json; }); await migration(tree); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults.test.options).toStrictEqual({ - jestConfig: '{projectRoot}/jest.config.ts', - }); - expect(nxJson.targetDefaults.test.configurations.production).toStrictEqual({ - codeCoverage: true, - }); + expect(nxJson.targetDefaults).toEqual([ + { + target: 'test', + executor: '@nx/jest:jest', + options: { jestConfig: '{projectRoot}/jest.config.ts' }, + configurations: { production: { codeCoverage: true } }, + }, + ]); }); it('should remove tsConfig option in nx.json target defaults for the jest executor', async () => { updateJson(tree, 'nx.json', (json) => { - json.targetDefaults ??= {}; - json.targetDefaults['@nx/jest:jest'] = { - options: { - jestConfig: '{projectRoot}/jest.config.ts', - tsConfig: '{projectRoot}/tsconfig.json', - }, - configurations: { - production: { - tsConfig: '{projectRoot}/tsconfig.prod.json', - codeCoverage: true, + json.targetDefaults = [ + { + target: '@nx/jest:jest', + options: { + jestConfig: '{projectRoot}/jest.config.ts', + tsConfig: '{projectRoot}/tsconfig.json', + }, + configurations: { + production: { + tsConfig: '{projectRoot}/tsconfig.prod.json', + codeCoverage: true, + }, }, }, - }; + ]; return json; }); await migration(tree); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults['@nx/jest:jest'].options).toStrictEqual({ - jestConfig: '{projectRoot}/jest.config.ts', - }); - expect( - nxJson.targetDefaults['@nx/jest:jest'].configurations.production - ).toStrictEqual({ - codeCoverage: true, - }); + expect(nxJson.targetDefaults).toEqual([ + { + target: '@nx/jest:jest', + options: { jestConfig: '{projectRoot}/jest.config.ts' }, + configurations: { production: { codeCoverage: true } }, + }, + ]); }); it('should remove empty options and configurations objects from project configuration', async () => { diff --git a/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.ts b/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.ts index dbe20d46e5460..157f4dea07f31 100644 --- a/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.ts +++ b/packages/jest/src/migrations/update-21-0-0/remove-tsconfig-option-from-jest-executor.ts @@ -3,6 +3,7 @@ import { readNxJson, readProjectConfiguration, type TargetConfiguration, + type TargetDefaultEntry, type Tree, updateNxJson, updateProjectConfiguration, @@ -10,6 +11,7 @@ import { import { forEachExecutorOptions } from '@nx/devkit/src/generators/executor-options-utils'; const EXECUTOR_TO_MIGRATE = '@nx/jest:jest'; +const ENTRY_META_KEYS = new Set(['target', 'projects', 'source']); export default async function (tree: Tree) { // update options from project configs @@ -38,34 +40,61 @@ export default async function (tree: Tree) { // update options from nx.json target defaults const nxJson = readNxJson(tree); if (nxJson.targetDefaults) { - for (const [targetOrExecutor, targetConfig] of Object.entries( - nxJson.targetDefaults - )) { - if ( - targetOrExecutor !== EXECUTOR_TO_MIGRATE && - targetConfig.executor !== EXECUTOR_TO_MIGRATE - ) { - continue; - } + if (Array.isArray(nxJson.targetDefaults)) { + const next: TargetDefaultEntry[] = []; + for (const entry of nxJson.targetDefaults) { + if ( + entry.target !== EXECUTOR_TO_MIGRATE && + entry.executor !== EXECUTOR_TO_MIGRATE + ) { + next.push(entry); + continue; + } + + if (entry.options) updateOptions(entry); + Object.keys(entry.configurations ?? {}).forEach((config) => { + updateConfiguration(entry, config); + }); - if (targetConfig.options) { - updateOptions(targetConfig); + if (!isEntryEmpty(entry)) { + next.push(entry); + } + } + if (next.length === 0) { + delete nxJson.targetDefaults; + } else { + nxJson.targetDefaults = next; } + } else { + for (const [targetOrExecutor, targetConfig] of Object.entries( + nxJson.targetDefaults + )) { + if ( + targetOrExecutor !== EXECUTOR_TO_MIGRATE && + targetConfig.executor !== EXECUTOR_TO_MIGRATE + ) { + continue; + } - Object.keys(targetConfig.configurations ?? {}).forEach((config) => { - updateConfiguration(targetConfig, config); - }); + if (targetConfig.options) { + updateOptions(targetConfig); + } - if ( - !Object.keys(targetConfig).length || - (Object.keys(targetConfig).length === 1 && - Object.keys(targetConfig)[0] === 'executor') - ) { - delete nxJson.targetDefaults[targetOrExecutor]; - } + Object.keys(targetConfig.configurations ?? {}).forEach((config) => { + updateConfiguration(targetConfig, config); + }); - if (!Object.keys(nxJson.targetDefaults).length) { - delete nxJson.targetDefaults; + if ( + !Object.keys(targetConfig).length || + (Object.keys(targetConfig).length === 1 && + Object.keys(targetConfig)[0] === 'executor') + ) { + delete nxJson.targetDefaults[targetOrExecutor]; + } + + if (!Object.keys(nxJson.targetDefaults).length) { + delete nxJson.targetDefaults; + } } } @@ -75,6 +104,15 @@ export default async function (tree: Tree) { await formatFiles(tree); } +// An entry is "empty" once only its filter/meta keys (target/projects/source) +// plus optionally `executor` remain — nothing else worth keeping around. +function isEntryEmpty(entry: TargetDefaultEntry): boolean { + const configKeys = Object.keys(entry).filter( + (k) => !ENTRY_META_KEYS.has(k) && k !== 'executor' + ); + return configKeys.length === 0; +} + function updateOptions(target: TargetConfiguration) { delete target.options.tsConfig; diff --git a/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.spec.ts b/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.spec.ts index 8da74b10fcd68..39b0ef2c3d3ef 100644 --- a/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.spec.ts +++ b/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.spec.ts @@ -79,76 +79,63 @@ describe('rename-test-path-pattern migration', () => { it('should rename "testPathPattern" option in nx.json target defaults for a target with the @nx/jest:jest executor', async () => { updateJson(tree, 'nx.json', (json) => { - json.targetDefaults ??= {}; - json.targetDefaults.test = { - executor: '@nx/jest:jest', - options: { testPathPattern: 'some-regex' }, - configurations: { - development: { testPathPattern: 'regex-dev' }, - production: { testPathPattern: 'regex-prod' }, + json.targetDefaults = [ + { + target: 'test', + executor: '@nx/jest:jest', + options: { testPathPattern: 'some-regex' }, + configurations: { + development: { testPathPattern: 'regex-dev' }, + production: { testPathPattern: 'regex-prod' }, + }, }, - }; + ]; return json; }); await migration(tree); const nxJson = readJson(tree, 'nx.json'); - expect(nxJson.targetDefaults!.test.options.testPathPattern).toBeUndefined(); - expect(nxJson.targetDefaults!.test.options.testPathPatterns).toBe( - 'some-regex' - ); - expect( - nxJson.targetDefaults!.test.configurations!.development.testPathPattern - ).toBeUndefined(); - expect( - nxJson.targetDefaults!.test.configurations!.development.testPathPatterns - ).toBe('regex-dev'); - expect( - nxJson.targetDefaults!.test.configurations!.production.testPathPattern - ).toBeUndefined(); - expect( - nxJson.targetDefaults!.test.configurations!.production.testPathPatterns - ).toBe('regex-prod'); + expect(nxJson.targetDefaults).toEqual([ + { + target: 'test', + executor: '@nx/jest:jest', + options: { testPathPatterns: 'some-regex' }, + configurations: { + development: { testPathPatterns: 'regex-dev' }, + production: { testPathPatterns: 'regex-prod' }, + }, + }, + ]); }); it('should rename "testPathPattern" option in nx.json target defaults for the @nx/jest:jest executor', async () => { updateJson(tree, 'nx.json', (json) => { - json.targetDefaults ??= {}; - json.targetDefaults['@nx/jest:jest'] = { - options: { testPathPattern: 'some-regex' }, - configurations: { - development: { testPathPattern: 'regex-dev' }, - production: { testPathPattern: 'regex-prod' }, + json.targetDefaults = [ + { + target: '@nx/jest:jest', + options: { testPathPattern: 'some-regex' }, + configurations: { + development: { testPathPattern: 'regex-dev' }, + production: { testPathPattern: 'regex-prod' }, + }, }, - }; + ]; return json; }); await migration(tree); const nxJson = readJson(tree, 'nx.json'); - expect( - nxJson.targetDefaults!['@nx/jest:jest'].options.testPathPattern - ).toBeUndefined(); - expect( - nxJson.targetDefaults!['@nx/jest:jest'].options.testPathPatterns - ).toBe('some-regex'); - expect( - nxJson.targetDefaults!['@nx/jest:jest'].configurations!.development - .testPathPattern - ).toBeUndefined(); - expect( - nxJson.targetDefaults!['@nx/jest:jest'].configurations!.development - .testPathPatterns - ).toBe('regex-dev'); - expect( - nxJson.targetDefaults!['@nx/jest:jest'].configurations!.production - .testPathPattern - ).toBeUndefined(); - expect( - nxJson.targetDefaults!['@nx/jest:jest'].configurations!.production - .testPathPatterns - ).toBe('regex-prod'); + expect(nxJson.targetDefaults).toEqual([ + { + target: '@nx/jest:jest', + options: { testPathPatterns: 'some-regex' }, + configurations: { + development: { testPathPatterns: 'regex-dev' }, + production: { testPathPatterns: 'regex-prod' }, + }, + }, + ]); }); }); diff --git a/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.ts b/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.ts index fe8dd4c88a3ac..a59ec575f631b 100644 --- a/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.ts +++ b/packages/jest/src/migrations/update-21-3-0/rename-test-path-pattern.ts @@ -33,23 +33,42 @@ export default async function (tree: Tree) { return; } - for (const [targetOrExecutor, targetConfig] of Object.entries( - nxJson.targetDefaults - )) { - if ( - targetOrExecutor !== '@nx/jest:jest' && - targetConfig.executor !== '@nx/jest:jest' - ) { - continue; - } + if (Array.isArray(nxJson.targetDefaults)) { + for (const entry of nxJson.targetDefaults) { + if ( + entry.target !== '@nx/jest:jest' && + entry.executor !== '@nx/jest:jest' + ) { + continue; + } + + if (entry.options) { + renameTestPathPattern(entry.options); + } - if (targetConfig.options) { - renameTestPathPattern(targetConfig.options); + Object.values(entry.configurations ?? {}).forEach((config) => { + renameTestPathPattern(config); + }); } + } else { + for (const [targetOrExecutor, targetConfig] of Object.entries( + nxJson.targetDefaults + )) { + if ( + targetOrExecutor !== '@nx/jest:jest' && + targetConfig.executor !== '@nx/jest:jest' + ) { + continue; + } - Object.values(targetConfig.configurations ?? {}).forEach((config) => { - renameTestPathPattern(config); - }); + if (targetConfig.options) { + renameTestPathPattern(targetConfig.options); + } + + Object.values(targetConfig.configurations ?? {}).forEach((config) => { + renameTestPathPattern(config); + }); + } } updateNxJson(tree, nxJson); diff --git a/packages/js/src/migrations/update-22-0-0/remove-external-options-from-js-executors.spec.ts b/packages/js/src/migrations/update-22-0-0/remove-external-options-from-js-executors.spec.ts index 213f124c4f572..6c334850e4e4a 100644 --- a/packages/js/src/migrations/update-22-0-0/remove-external-options-from-js-executors.spec.ts +++ b/packages/js/src/migrations/update-22-0-0/remove-external-options-from-js-executors.spec.ts @@ -4,6 +4,7 @@ import { readProjectConfiguration, updateJson, type NxJsonConfiguration, + type TargetDefaultsRecord, type Tree, } from '@nx/devkit'; import * as devkit from '@nx/devkit'; @@ -12,6 +13,12 @@ import migration, { executors, } from './remove-external-options-from-js-executors'; +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +type LegacyNxJson = Omit & { + targetDefaults?: TargetDefaultsRecord; +}; + describe('remove-external-options-from-js-executors migration', () => { let tree: Tree; @@ -161,7 +168,7 @@ describe('remove-external-options-from-js-executors migration', () => { it.each(executors)( 'should delete "external" and "externalBuildTargets" options in nx.json target defaults for a target with the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.build = { executor, @@ -190,7 +197,7 @@ describe('remove-external-options-from-js-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.build.options.external).toBeUndefined(); expect( nxJson.targetDefaults.build.options.externalBuildTargets @@ -215,7 +222,7 @@ describe('remove-external-options-from-js-executors migration', () => { it.each(executors)( 'should remove empty options but keep empty configurations for a target with the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults.build = { executor, @@ -239,7 +246,7 @@ describe('remove-external-options-from-js-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults.build.options).toBeUndefined(); // we keep them because users might rely on them, e.g. in scripts, CI, etc. // they might be applying them from target defaults @@ -253,7 +260,7 @@ describe('remove-external-options-from-js-executors migration', () => { it.each(executors)( 'should delete "external" and "externalBuildTargets" options in nx.json target defaults for the "%s" executor', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[executor] = { options: { @@ -281,7 +288,7 @@ describe('remove-external-options-from-js-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults[executor].options.external).toBeUndefined(); expect( nxJson.targetDefaults[executor].options.externalBuildTargets @@ -306,7 +313,7 @@ describe('remove-external-options-from-js-executors migration', () => { it.each(executors)( 'should only delete target defaults for the "%s" executor when nothing remains after migration', async (executor) => { - updateJson(tree, 'nx.json', (json) => { + updateJson(tree, 'nx.json', (json) => { json.targetDefaults ??= {}; json.targetDefaults[executor] = { options: { @@ -319,7 +326,7 @@ describe('remove-external-options-from-js-executors migration', () => { await migration(tree); - const nxJson = readJson(tree, 'nx.json'); + const nxJson = readJson(tree, 'nx.json'); expect(nxJson.targetDefaults[executor]).toBeUndefined(); } ); diff --git a/packages/next/src/generators/custom-server/custom-server.ts b/packages/next/src/generators/custom-server/custom-server.ts index 235c2ff6ea1b0..73391ef2ec383 100644 --- a/packages/next/src/generators/custom-server/custom-server.ts +++ b/packages/next/src/generators/custom-server/custom-server.ts @@ -1,4 +1,5 @@ import { joinPathFragments, Tree } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { updateJson, generateFiles, @@ -141,12 +142,11 @@ export async function customServerGenerator( 'build-custom-server' ); } - json.targetDefaults ??= {}; - json.targetDefaults['build-custom-server'] ??= {}; - json.targetDefaults['build-custom-server'].cache ??= true; return json; }); + upsertTargetDefault(host, { target: 'build-custom-server', cache: true }); + if (options.compiler === 'swc') { // Update app swc to exlude server files updateJson(host, join(project.root, '.swcrc'), (json) => { diff --git a/packages/nx/migrations.json b/packages/nx/migrations.json index 3886d60cc85c6..1aaf7b2e67e0c 100644 --- a/packages/nx/migrations.json +++ b/packages/nx/migrations.json @@ -160,6 +160,12 @@ "version": "22.7.0-beta.0", "description": "Adds .nx/self-healing to .gitignore", "implementation": "./dist/src/migrations/update-22-2-0/add-self-healing-to-gitignore" + }, + "23-0-0-convert-target-defaults-to-array": { + "cli": "nx", + "version": "23.0.0-beta.0", + "description": "Converts nx.json `targetDefaults` from the legacy record shape to the new filtered array shape.", + "implementation": "./dist/src/migrations/update-23-0-0/convert-target-defaults-to-array" } } } diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index ca3ec729d8b8d..d8a86b585be7d 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -43,11 +43,24 @@ } }, "targetDefaults": { - "type": "object", - "description": "Target defaults", - "additionalProperties": { - "$ref": "#/definitions/targetDefaultsConfig" - } + "description": "Target defaults. The recommended form is an array of entries, each filtered by target name (required), and optionally projects and source plugin. The legacy record/object form (keyed by target name or executor) is still read but deprecated; run `nx repair` to convert it.", + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/targetDefaultEntry" + } + }, + { + "type": "object", + "deprecated": true, + "deprecationMessage": "The record-shape `targetDefaults` is deprecated and will be removed in a future major. Run `nx repair` to convert this nx.json to the new array-shape `targetDefaults`, which also unlocks the `projects` and `source` filters.", + "description": "Deprecated: the record-shape `targetDefaults` is still read for backwards compatibility, but new workspaces should use the array form. Run `nx repair` to convert.", + "additionalProperties": { + "$ref": "#/definitions/targetDefaultsConfig" + } + } + ] }, "workspaceLayout": { "type": "object", @@ -866,6 +879,82 @@ }, "additionalProperties": false }, + "targetDefaultEntry": { + "type": "object", + "description": "A single target defaults entry.", + "required": ["target"], + "properties": { + "target": { + "type": "string", + "description": "The target name, target name glob, or executor (e.g. '@nx/vite:test') this default applies to." + }, + "projects": { + "oneOf": [ + { + "type": "string", + "description": "A project name, glob, or tag (e.g. 'tag:dotnet')." + }, + { + "type": "array", + "items": { "type": "string" }, + "description": "A list of project names, globs, or tags (e.g. ['apps/*', '!apps/legacy'])." + } + ], + "description": "Restrict the default to matching projects. Uses findMatchingProjects syntax." + }, + "source": { + "type": "string", + "description": "Restrict the default to targets originated by a specific plugin (e.g. '@nx/vite')." + }, + "executor": { + "description": "The function that Nx will invoke when you run this target", + "type": "string" + }, + "options": { + "type": "object" + }, + "outputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "defaultConfiguration": { + "type": "string", + "description": "The name of a configuration to use as the default if a configuration is not provided" + }, + "configurations": { + "type": "object", + "description": "provides extra sets of values that will be merged into the options map", + "additionalProperties": { + "type": "object" + } + }, + "continuous": { + "type": "boolean", + "default": false, + "description": "Whether this target runs continuously until stopped" + }, + "parallelism": { + "type": "boolean", + "default": true, + "description": "Whether this target can be run in parallel with other tasks" + }, + "inputs": { + "$ref": "#/definitions/inputs" + }, + "dependsOn": { + "$ref": "#/definitions/targetDefaultsConfig/properties/dependsOn" + }, + "cache": { + "$ref": "#/definitions/targetDefaultsConfig/properties/cache" + }, + "syncGenerators": { + "$ref": "#/definitions/targetDefaultsConfig/properties/syncGenerators" + } + }, + "additionalProperties": false + }, "targetDefaultsConfig": { "type": "object", "description": "Target defaults", diff --git a/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts b/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts index ae01a4763cd44..4b688e0891a56 100644 --- a/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts +++ b/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts @@ -1,7 +1,10 @@ import { unlinkSync } from 'fs'; import { dirname, join, posix, relative, resolve } from 'node:path'; import { toNewFormat } from '../../../../adapter/angular-json'; -import type { NxJsonConfiguration } from '../../../../config/nx-json'; +import type { + NxJsonConfiguration, + TargetDefaultEntry, +} from '../../../../config/nx-json'; import type { ProjectConfiguration } from '../../../../config/workspace-json-project-json'; import { fileExists, @@ -95,29 +98,34 @@ function createNxJson( : []), ].filter(Boolean), }; - nxJson.targetDefaults ??= {}; + const defaults: TargetDefaultEntry[] = Array.isArray(nxJson.targetDefaults) + ? [...nxJson.targetDefaults] + : []; + const upsert = (target: string, patch: Partial): void => { + const idx = defaults.findIndex( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + if (idx >= 0) defaults[idx] = { ...defaults[idx], ...patch, target }; + else defaults.push({ target, ...patch }); + }; if (workspaceTargets.includes('build')) { - nxJson.targetDefaults.build = { - ...nxJson.targetDefaults.build, + upsert('build', { dependsOn: ['^build'], inputs: ['production', '^production'], - }; + }); } if (workspaceTargets.includes('server')) { - nxJson.targetDefaults.server = { - ...nxJson.targetDefaults.server, - inputs: ['production', '^production'], - }; + upsert('server', { inputs: ['production', '^production'] }); } if (workspaceTargets.includes('test')) { const inputs = ['default', '^production']; if (fileExists(join(repoRoot, 'karma.conf.js'))) { inputs.push('{workspaceRoot}/karma.conf.js'); } - nxJson.targetDefaults.test = { - ...nxJson.targetDefaults.test, - inputs, - }; + upsert('test', { inputs }); } if (workspaceTargets.includes('lint')) { const inputs = ['default']; @@ -127,16 +135,13 @@ function createNxJson( if (fileExists(join(repoRoot, 'eslint.config.cjs'))) { inputs.push('{workspaceRoot}/eslint.config.cjs'); } - nxJson.targetDefaults.lint = { - ...nxJson.targetDefaults.lint, - inputs, - }; + upsert('lint', { inputs }); } if (workspaceTargets.includes('e2e')) { - nxJson.targetDefaults.e2e = { - ...nxJson.targetDefaults.e2e, - inputs: ['default', '^production'], - }; + upsert('e2e', { inputs: ['default', '^production'] }); + } + if (defaults.length > 0) { + nxJson.targetDefaults = defaults; } writeJsonFile(join(repoRoot, 'nx.json'), nxJson); } diff --git a/packages/nx/src/command-line/init/implementation/utils.spec.ts b/packages/nx/src/command-line/init/implementation/utils.spec.ts index 56713dedebce5..b67427bda38f0 100644 --- a/packages/nx/src/command-line/init/implementation/utils.spec.ts +++ b/packages/nx/src/command-line/init/implementation/utils.spec.ts @@ -56,12 +56,13 @@ describe('utils', () => { }, nx: { $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: { - build: { + targetDefaults: [ + { + target: 'build', dependsOn: ['^build'], cache: true, }, - }, + ], }, }, { @@ -75,12 +76,13 @@ describe('utils', () => { }, nx: { $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: { - build: { + targetDefaults: [ + { + target: 'build', outputs: ['{projectRoot}/dist/**', '{projectRoot}/.next/**'], cache: true, }, - }, + ], }, }, { @@ -94,15 +96,16 @@ describe('utils', () => { }, nx: { $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: { - build: { + targetDefaults: [ + { + target: 'build', inputs: [ '{projectRoot}/src/**/*.tsx', '{projectRoot}/test/**/*.tsx', ], cache: true, }, - }, + ], }, }, { @@ -119,14 +122,10 @@ describe('utils', () => { }, nx: { $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: { - build: { - cache: true, - }, - dev: { - cache: false, - }, - }, + targetDefaults: [ + { target: 'build', cache: true }, + { target: 'dev', cache: false }, + ], }, }, { @@ -154,12 +153,13 @@ describe('utils', () => { }, nx: { $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: { - build: { + targetDefaults: [ + { + target: 'build', dependsOn: ['^build'], cache: true, }, - }, + ], }, }, { @@ -195,22 +195,22 @@ describe('utils', () => { default: ['{projectRoot}/**/*', 'sharedGlobals'], }, cacheDirectory: '.nx/cache', - targetDefaults: { - build: { + targetDefaults: [ + { + target: 'build', dependsOn: ['^build'], outputs: ['{projectRoot}/dist/**'], inputs: ['{projectRoot}/src/**/*'], cache: true, }, - test: { + { + target: 'test', dependsOn: ['build'], outputs: ['{projectRoot}/coverage/**'], cache: true, }, - dev: { - cache: false, - }, - }, + { target: 'dev', cache: false }, + ], }, }, { @@ -238,8 +238,9 @@ describe('utils', () => { }, nx: { $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: { - build: { + targetDefaults: [ + { + target: 'build', dependsOn: ['^build'], inputs: ['{projectRoot}/**/*', '{projectRoot}/.env*'], outputs: [ @@ -248,18 +249,18 @@ describe('utils', () => { ], cache: true, }, - lint: { + { + target: 'lint', dependsOn: ['^lint'], cache: true, }, - 'check-types': { + { + target: 'check-types', dependsOn: ['^check-types'], cache: true, }, - dev: { - cache: false, - }, - }, + { target: 'dev', cache: false }, + ], }, }, ])('$description', ({ turbo, nx }) => { diff --git a/packages/nx/src/command-line/init/implementation/utils.ts b/packages/nx/src/command-line/init/implementation/utils.ts index e68f776f48bc4..eb067f7eb266d 100644 --- a/packages/nx/src/command-line/init/implementation/utils.ts +++ b/packages/nx/src/command-line/init/implementation/utils.ts @@ -1,7 +1,10 @@ import { execSync } from 'child_process'; import { join } from 'path'; -import { NxJsonConfiguration } from '../../../config/nx-json'; +import { + NxJsonConfiguration, + TargetDefaultEntry, +} from '../../../config/nx-json'; import { fileExists, readJsonFile, @@ -35,12 +38,23 @@ export function createNxJsonFile( } catch {} nxJson.$schema = './node_modules/nx/schemas/nx-schema.json'; - nxJson.targetDefaults ??= {}; + const entries: TargetDefaultEntry[] = Array.isArray(nxJson.targetDefaults) + ? [...nxJson.targetDefaults] + : []; + const upsert = (target: string, patch: Partial): void => { + const idx = entries.findIndex( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + if (idx >= 0) entries[idx] = { ...entries[idx], ...patch, target }; + else entries.push({ target, ...patch }); + }; if (topologicalTargets.length > 0) { for (const scriptName of topologicalTargets) { - nxJson.targetDefaults[scriptName] ??= {}; - nxJson.targetDefaults[scriptName] = { dependsOn: [`^${scriptName}`] }; + upsert(scriptName, { dependsOn: [`^${scriptName}`] }); } } for (const [scriptName, output] of Object.entries(scriptOutputs)) { @@ -48,17 +62,24 @@ export function createNxJsonFile( // eslint-disable-next-line no-continue continue; } - nxJson.targetDefaults[scriptName] ??= {}; - nxJson.targetDefaults[scriptName].outputs = [`{projectRoot}/${output}`]; + upsert(scriptName, { outputs: [`{projectRoot}/${output}`] }); } for (const target of cacheableOperations) { - nxJson.targetDefaults[target] ??= {}; - nxJson.targetDefaults[target].cache ??= true; + const idx = entries.findIndex( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + if (idx >= 0) entries[idx].cache ??= true; + else entries.push({ target, cache: true }); } - if (Object.keys(nxJson.targetDefaults).length === 0) { + if (entries.length === 0) { delete nxJson.targetDefaults; + } else { + nxJson.targetDefaults = entries; } const defaultBase = deduceDefaultBase(); @@ -104,23 +125,23 @@ export function createNxJsonFromTurboJson( // Handle task configurations if (turboJson.tasks) { - nxJson.targetDefaults = {}; + const entries: TargetDefaultEntry[] = []; for (const [taskName, taskConfig] of Object.entries(turboJson.tasks)) { // Skip project-specific tasks (containing #) if (taskName.includes('#')) continue; const config = taskConfig as any; - nxJson.targetDefaults[taskName] = {}; + const entry: TargetDefaultEntry = { target: taskName }; // Handle dependsOn if (config.dependsOn?.length > 0) { - nxJson.targetDefaults[taskName].dependsOn = config.dependsOn; + entry.dependsOn = config.dependsOn; } // Handle inputs if (config.inputs?.length > 0) { - nxJson.targetDefaults[taskName].inputs = config.inputs + entry.inputs = config.inputs .map((input) => { if (input === '$TURBO_DEFAULT$') { return '{projectRoot}/**/*'; @@ -148,21 +169,25 @@ export function createNxJsonFromTurboJson( // Handle outputs if (config.outputs?.length > 0) { - nxJson.targetDefaults[taskName].outputs = config.outputs.map( - (output) => { - // Don't add projectRoot if it's already there - if (output.startsWith('{projectRoot}/')) return output; - // Handle negated patterns by adding projectRoot after the ! - if (output.startsWith('!')) { - return `!{projectRoot}/${output.slice(1)}`; - } - return `{projectRoot}/${output}`; + entry.outputs = config.outputs.map((output) => { + // Don't add projectRoot if it's already there + if (output.startsWith('{projectRoot}/')) return output; + // Handle negated patterns by adding projectRoot after the ! + if (output.startsWith('!')) { + return `!{projectRoot}/${output.slice(1)}`; } - ); + return `{projectRoot}/${output}`; + }); } // Handle cache setting - true by default in Turbo - nxJson.targetDefaults[taskName].cache = config.cache !== false; + entry.cache = config.cache !== false; + + entries.push(entry); + } + + if (entries.length > 0) { + nxJson.targetDefaults = entries; } } diff --git a/packages/nx/src/command-line/show/show-target/info.ts b/packages/nx/src/command-line/show/show-target/info.ts index 1fe2c87f8bc2a..b41874b7484c1 100644 --- a/packages/nx/src/command-line/show/show-target/info.ts +++ b/packages/nx/src/command-line/show/show-target/info.ts @@ -2,6 +2,8 @@ import type { NxJsonConfiguration } from '../../../config/nx-json'; import type { ProjectGraph } from '../../../config/project-graph'; import type { InputDefinition } from '../../../config/workspace-json-project-json'; import type { ConfigurationSourceMaps } from '../../../project-graph/utils/project-configuration/source-maps'; +import { normalizeTargetDefaults } from '../../../project-graph/utils/project-configuration/target-defaults'; +import { findMatchingProjects } from '../../../utils/find-matching-projects'; import { getNamedInputs } from '../../../hasher/task-hasher'; import { getDependencyConfigs } from '../../../tasks-runner/utils'; import type { ShowTargetBaseOptions } from '../command-object'; @@ -51,11 +53,24 @@ function resolveTargetInfoData(t: ResolvedTarget) { } } - const extraTargetDeps = Object.fromEntries( - Object.entries(nxJson.targetDefaults ?? {}) - .filter(([, config]) => config.dependsOn) - .map(([name, config]) => [name, config.dependsOn]) - ); + const extraTargetDeps: Record = {}; + const projectNodeForMatch = graph.nodes[projectName]; + for (const entry of normalizeTargetDefaults(nxJson.targetDefaults)) { + if (!entry.target || !entry.dependsOn) continue; + if (entry.projects !== undefined) { + if (!projectNodeForMatch) continue; + const patterns = Array.isArray(entry.projects) + ? entry.projects + : [entry.projects]; + const matched = findMatchingProjects(patterns, { + [projectName]: projectNodeForMatch, + }); + if (!matched.includes(projectName)) continue; + } + // Record by target name; later entries overwrite earlier (later array + // index wins on ties, mirroring matcher semantics). + extraTargetDeps[entry.target] = entry.dependsOn; + } const depConfigs = getDependencyConfigs( { project: projectName, target: targetName }, diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 82d226e8f316d..34208d4db9d25 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -31,7 +31,42 @@ export interface NxAffectedConfig { defaultBase?: string; } -export type TargetDefaults = Record>; +/** + * A single entry in the array-shaped `targetDefaults` configuration. + * Supports filtering the default's applicability by project set and/or the + * plugin that originated the target. + */ +export type TargetDefaultEntry = { + /** + * Target name or glob pattern (e.g. `build`, `e2e-ci--*`). An + * executor-qualified key (e.g. `@nx/vite:test`) matches by executor. + */ + target: string; + /** + * Restrict the default to a subset of projects. Accepts any pattern + * supported by `findMatchingProjects` (project names, globs, `tag:foo`, + * directory globs, negation with `!`). + */ + projects?: string | string[]; + /** + * Restrict the default to targets originated by a specific plugin + * (e.g. `@nx/vite`). Matches against the plugin that wrote the target's + * `executor` or `command`. + */ + source?: string; +} & Partial; + +/** + * @deprecated Use the array-shaped {@link TargetDefaultEntry}[] form instead. + * Retained so devkit helpers can still read nx.json files that predate the + * migration. + */ +export type TargetDefaultsRecord = Record>; + +export type TargetDefaults = TargetDefaultEntry[] | TargetDefaultsRecord; + +/** Internal-only: the post-normalization shape consumed by the nx core matcher. */ +export type NormalizedTargetDefaults = TargetDefaultEntry[]; export type TargetDependencies = Record< string, diff --git a/packages/nx/src/config/to-project-name.spec.ts b/packages/nx/src/config/to-project-name.spec.ts index 9fbb07d1ce4e0..87368c1854b76 100644 --- a/packages/nx/src/config/to-project-name.spec.ts +++ b/packages/nx/src/config/to-project-name.spec.ts @@ -45,7 +45,7 @@ describe('Workspaces', () => { async () => { const plugins = await getPlugins(fs.tempDir); const res = await retrieveProjectConfigurations( - plugins, + { specifiedPlugins: [], defaultPlugins: plugins }, fs.tempDir, readNxJson(fs.tempDir) ); diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index 5c3ba8755f78c..39e93a429d2a7 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -294,4 +294,15 @@ export interface TargetConfiguration { * is up to date. */ syncGenerators?: string[]; + + /** + * Spread token used when merging target configurations. When set to `true`, + * base (inferred) values take priority over this target's values for any + * shared keys — effectively "only add new keys without overwriting inferred + * values". Keys that do not exist in the base target are still added. + * + * The position of `'...'` in the object's key order follows standard + * last-write-wins semantics with {@link https://nx.dev/reference/project-configuration#spread-token}. + */ + '...'?: true; } diff --git a/packages/nx/src/daemon/server/handle-hash-tasks.ts b/packages/nx/src/daemon/server/handle-hash-tasks.ts index 6ea46730053d3..ab4a672e3bed4 100644 --- a/packages/nx/src/daemon/server/handle-hash-tasks.ts +++ b/packages/nx/src/daemon/server/handle-hash-tasks.ts @@ -18,7 +18,7 @@ export async function handleHashTasks(payload: { cwd: string; collectInputs?: boolean; }) { - const { error, projectGraph, allWorkspaceFiles, fileMap, rustReferences } = + const { error, projectGraph, rustReferences } = await getCachedSerializedProjectGraphPromise(); if (error) { diff --git a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts index 77e399e9940b8..98661450d32b5 100644 --- a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts +++ b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts @@ -23,7 +23,11 @@ import { writeCache, writeCacheIfStale, } from '../../project-graph/nx-deps-cache'; -import { getPlugins } from '../../project-graph/plugins/get-plugins'; +import { + getPlugins, + getPluginsSeparated, + SeparatedPlugins, +} from '../../project-graph/plugins/get-plugins'; import type { LoadedNxPlugin } from '../../project-graph/plugins/loaded-nx-plugin'; import { ConfigurationResult } from '../../project-graph/utils/project-configuration-utils'; import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration/source-maps'; @@ -46,8 +50,6 @@ interface SerializedProjectGraph { error: Error | null; projectGraph: ProjectGraph | null; projectFileMapCache: FileMapCache | null; - fileMap: FileMap | null; - allWorkspaceFiles: FileData[] | null; serializedProjectGraph: string | null; serializedSourceMaps: string | null; sourceMaps: ConfigurationSourceMaps | null; @@ -58,7 +60,6 @@ let cachedSerializedProjectGraphPromise: Promise; export let fileMapWithFiles: | { fileMap: FileMap; - allWorkspaceFiles: FileData[]; rustReferences: NxWorkspaceFilesExternals; } | undefined; @@ -97,12 +98,12 @@ export async function getCachedSerializedProjectGraphPromise(): Promise { + const plugins = [ + ...separatedPlugins.specifiedPlugins, + ...separatedPlugins.defaultPlugins, + ]; const myGeneration = ++recomputationGeneration; // Helper to check if this recomputation is stale (a newer one has started) @@ -357,7 +362,7 @@ async function processFilesAndCreateAndSerializeProjectGraph( try { projectConfigurationsResult = await retrieveProjectConfigurations( - plugins, + separatedPlugins, workspaceRoot, nxJson ); @@ -415,9 +420,7 @@ async function processFilesAndCreateAndSerializeProjectGraph( error: g.error, projectGraph: null, projectFileMapCache: null, - fileMap: null, rustReferences: null, - allWorkspaceFiles: null, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, @@ -433,9 +436,7 @@ async function processFilesAndCreateAndSerializeProjectGraph( ), projectGraph: null, projectFileMapCache: null, - fileMap: null, rustReferences: null, - allWorkspaceFiles: null, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, @@ -448,9 +449,7 @@ async function processFilesAndCreateAndSerializeProjectGraph( error: err, projectGraph: null, projectFileMapCache: null, - fileMap: null, rustReferences: null, - allWorkspaceFiles: null, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, @@ -480,14 +479,12 @@ async function createAndSerializeProjectGraph({ try { performance.mark('create-project-graph-start'); const fileMap = copyFileMap(fileMapWithFiles.fileMap); - const allWorkspaceFiles = copyFileData(fileMapWithFiles.allWorkspaceFiles); const rustReferences = fileMapWithFiles.rustReferences; const { projectGraph, projectFileMapCache } = await buildProjectGraphUsingFileMap( projects, knownExternalNodes, fileMap, - allWorkspaceFiles, rustReferences, currentProjectFileMapCache || readFileMapCache(), await getPlugins(), @@ -519,8 +516,6 @@ async function createAndSerializeProjectGraph({ error: null, projectGraph, projectFileMapCache, - fileMap, - allWorkspaceFiles, serializedProjectGraph, serializedSourceMaps, sourceMaps, @@ -534,8 +529,6 @@ async function createAndSerializeProjectGraph({ error: e, projectGraph: null, projectFileMapCache: null, - fileMap: null, - allWorkspaceFiles: null, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, diff --git a/packages/nx/src/devkit-exports.ts b/packages/nx/src/devkit-exports.ts index c939e18790161..796ba5da7c5fa 100644 --- a/packages/nx/src/devkit-exports.ts +++ b/packages/nx/src/devkit-exports.ts @@ -88,6 +88,8 @@ export type { PluginConfiguration, ExpandedPluginConfiguration, TargetDefaults, + TargetDefaultEntry, + TargetDefaultsRecord, NxAffectedConfig, } from './config/nx-json'; diff --git a/packages/nx/src/executors/utils/convert-nx-executor.ts b/packages/nx/src/executors/utils/convert-nx-executor.ts index 4263ea9340454..fc5369b879e0e 100644 --- a/packages/nx/src/executors/utils/convert-nx-executor.ts +++ b/packages/nx/src/executors/utils/convert-nx-executor.ts @@ -8,7 +8,7 @@ import { Executor, ExecutorContext } from '../../config/misc-interfaces'; import { retrieveProjectConfigurations } from '../../project-graph/utils/retrieve-workspace-files'; import { readProjectConfigurationsFromRootMap } from '../../project-graph/utils/project-configuration/project-nodes-manager'; import { ProjectsConfigurations } from '../../config/workspace-json-project-json'; -import { getPlugins } from '../../project-graph/plugins/get-plugins'; +import { getPluginsSeparated } from '../../project-graph/plugins/get-plugins'; /** * Convert an Nx Executor into an Angular Devkit Builder @@ -20,13 +20,13 @@ export function convertNxExecutor(executor: Executor) { const promise = async () => { const nxJsonConfiguration = readNxJson(builderContext.workspaceRoot); - const plugins = await getPlugins(); + const separatedPlugins = await getPluginsSeparated(); const projectsConfigurations: ProjectsConfigurations = { version: 2, projects: readProjectConfigurationsFromRootMap( ( await retrieveProjectConfigurations( - plugins, + separatedPlugins, builderContext.workspaceRoot, nxJsonConfiguration ) diff --git a/packages/nx/src/hasher/create-task-hasher.ts b/packages/nx/src/hasher/create-task-hasher.ts index f0b6323d8b2b1..c01a64dfc3708 100644 --- a/packages/nx/src/hasher/create-task-hasher.ts +++ b/packages/nx/src/hasher/create-task-hasher.ts @@ -16,7 +16,7 @@ export function createTaskHasher( if (daemonClient.enabled()) { return new DaemonBasedTaskHasher(daemonClient, runnerOptions); } else { - const { fileMap, allWorkspaceFiles, rustReferences } = getFileMap(); + const { rustReferences } = getFileMap(); return new InProcessTaskHasher( projectGraph, nxJson, diff --git a/packages/nx/src/migrations/update-16-0-0/update-depends-on-to-tokens.spec.ts b/packages/nx/src/migrations/update-16-0-0/update-depends-on-to-tokens.spec.ts index 85d9957810819..494b5f3e4e440 100644 --- a/packages/nx/src/migrations/update-16-0-0/update-depends-on-to-tokens.spec.ts +++ b/packages/nx/src/migrations/update-16-0-0/update-depends-on-to-tokens.spec.ts @@ -39,13 +39,12 @@ describe('update-depends-on-to-tokens', () => { }); await update(tree); const nxJson = readNxJson(tree); - const buildDependencyConfiguration = nxJson.targetDefaults.build - .dependsOn[0] as any; - const testDependencyConfiguration = nxJson.targetDefaults.test - .dependsOn[0] as any; - const buildInputConfiguration = nxJson.targetDefaults.build - .inputs[0] as any; - const testInputConfiguration = nxJson.targetDefaults.test.inputs[0] as any; + // Migration ran before targetDefaults supported the array shape. + const td = nxJson.targetDefaults as Record; + const buildDependencyConfiguration = td.build.dependsOn[0] as any; + const testDependencyConfiguration = td.test.dependsOn[0] as any; + const buildInputConfiguration = td.build.inputs[0] as any; + const testInputConfiguration = td.test.inputs[0] as any; expect(buildDependencyConfiguration.projects).not.toBeDefined(); expect(buildDependencyConfiguration.dependencies).not.toBeDefined(); expect(buildInputConfiguration.projects).not.toBeDefined(); @@ -54,7 +53,7 @@ describe('update-depends-on-to-tokens', () => { expect(testInputConfiguration.dependencies).toEqual(true); expect(testDependencyConfiguration.projects).not.toBeDefined(); expect(testDependencyConfiguration.dependencies).toEqual(true); - expect(nxJson.targetDefaults.other.dependsOn).toEqual(['^deps']); + expect(td.other.dependsOn).toEqual(['^deps']); }); it('should update project configurations', async () => { diff --git a/packages/nx/src/migrations/update-23-0-0/convert-target-defaults-to-array.spec.ts b/packages/nx/src/migrations/update-23-0-0/convert-target-defaults-to-array.spec.ts new file mode 100644 index 0000000000000..ab4359525f3c5 --- /dev/null +++ b/packages/nx/src/migrations/update-23-0-0/convert-target-defaults-to-array.spec.ts @@ -0,0 +1,93 @@ +import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from '../../generators/tree'; +import { readNxJson, updateNxJson } from '../../generators/utils/nx-json'; +import convertTargetDefaultsToArray from './convert-target-defaults-to-array'; + +describe('convert-target-defaults-to-array migration', () => { + let tree: Tree; + const originalSkipFormat = process.env.NX_SKIP_FORMAT; + + beforeAll(() => { + process.env.NX_SKIP_FORMAT = 'true'; + }); + afterAll(() => { + if (originalSkipFormat === undefined) { + delete process.env.NX_SKIP_FORMAT; + } else { + process.env.NX_SKIP_FORMAT = originalSkipFormat; + } + }); + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('is a no-op when nx.json does not exist', async () => { + tree.delete('nx.json'); + await expect(convertTargetDefaultsToArray(tree)).resolves.toBeUndefined(); + }); + + it('is a no-op when targetDefaults is absent', async () => { + const nxJson = readNxJson(tree); + delete nxJson.targetDefaults; + updateNxJson(tree, nxJson); + await convertTargetDefaultsToArray(tree); + expect(readNxJson(tree).targetDefaults).toBeUndefined(); + }); + + it('is a no-op when targetDefaults is already an array', async () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = [ + { target: 'build', cache: true }, + { target: 'test', inputs: ['default', '^production'] }, + ]; + updateNxJson(tree, nxJson); + await convertTargetDefaultsToArray(tree); + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'build', cache: true }, + { target: 'test', inputs: ['default', '^production'] }, + ]); + }); + + it('converts record-shape to array preserving insertion order', async () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = { + build: { cache: true, dependsOn: ['^build'] }, + test: { inputs: ['default', '^production'] }, + }; + updateNxJson(tree, nxJson); + await convertTargetDefaultsToArray(tree); + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'build', cache: true, dependsOn: ['^build'] }, + { target: 'test', inputs: ['default', '^production'] }, + ]); + }); + + it('keeps executor-style keys as `target` strings', async () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = { + '@nx/vite:test': { inputs: ['default'] }, + 'nx:run-commands': { cache: true }, + }; + updateNxJson(tree, nxJson); + await convertTargetDefaultsToArray(tree); + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: '@nx/vite:test', inputs: ['default'] }, + { target: 'nx:run-commands', cache: true }, + ]); + }); + + it('keeps glob keys as `target` strings', async () => { + const nxJson = readNxJson(tree); + nxJson.targetDefaults = { + 'e2e-ci--*': { dependsOn: ['build'] }, + 'test:*': { cache: true }, + }; + updateNxJson(tree, nxJson); + await convertTargetDefaultsToArray(tree); + expect(readNxJson(tree).targetDefaults).toEqual([ + { target: 'e2e-ci--*', dependsOn: ['build'] }, + { target: 'test:*', cache: true }, + ]); + }); +}); diff --git a/packages/nx/src/migrations/update-23-0-0/convert-target-defaults-to-array.ts b/packages/nx/src/migrations/update-23-0-0/convert-target-defaults-to-array.ts new file mode 100644 index 0000000000000..755007286061b --- /dev/null +++ b/packages/nx/src/migrations/update-23-0-0/convert-target-defaults-to-array.ts @@ -0,0 +1,39 @@ +import { formatChangedFilesWithPrettierIfAvailable } from '../../generators/internal-utils/format-changed-files-with-prettier-if-available'; +import { readNxJson, updateNxJson } from '../../generators/utils/nx-json'; +import { Tree } from '../../generators/tree'; +import type { + TargetDefaultEntry, + TargetDefaultsRecord, +} from '../../config/nx-json'; + +/** + * Converts the legacy record-shape `targetDefaults` in nx.json to the new + * array shape introduced in Nx 23. No-op when `targetDefaults` is absent + * or already an array. + */ +export default async function convertTargetDefaultsToArray( + tree: Tree +): Promise { + if (!tree.exists('nx.json')) { + return; + } + + const nxJson = readNxJson(tree); + if (!nxJson) return; + + const { targetDefaults } = nxJson; + if (!targetDefaults) return; + if (Array.isArray(targetDefaults)) return; + + const legacy = targetDefaults as TargetDefaultsRecord; + const entries: TargetDefaultEntry[] = []; + for (const key of Object.keys(legacy)) { + const value = legacy[key] ?? {}; + entries.push({ ...value, target: key }); + } + + nxJson.targetDefaults = entries; + updateNxJson(tree, nxJson); + + await formatChangedFilesWithPrettierIfAvailable(tree); +} diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-project-dependencies.spec.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-project-dependencies.spec.ts index 95417535c7b94..47da62c349d60 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-project-dependencies.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/explicit-project-dependencies.spec.ts @@ -703,7 +703,7 @@ async function createContext( const plugins = await getOnlyDefaultPlugins(tempFs.tempDir); const { projects, projectRootMap } = await retrieveProjectConfigurations( - plugins, + { specifiedPlugins: [], defaultPlugins: plugins }, tempFs.tempDir, nxJson ); diff --git a/packages/nx/src/project-graph/build-project-graph.ts b/packages/nx/src/project-graph/build-project-graph.ts index 80bc305e11fda..4bb0cffb02b39 100644 --- a/packages/nx/src/project-graph/build-project-graph.ts +++ b/packages/nx/src/project-graph/build-project-graph.ts @@ -46,18 +46,15 @@ import { DelayedSpinner } from '../utils/delayed-spinner'; import { hashObject } from '../hasher/file-hasher'; let storedFileMap: FileMap | null = null; -let storedAllWorkspaceFiles: FileData[] | null = null; let storedRustReferences: NxWorkspaceFilesExternals | null = null; export function getFileMap(): { fileMap: FileMap; - allWorkspaceFiles: FileData[]; rustReferences: NxWorkspaceFilesExternals | null; } { if (!!storedFileMap) { return { fileMap: storedFileMap, - allWorkspaceFiles: storedAllWorkspaceFiles, rustReferences: storedRustReferences, }; } else { @@ -66,7 +63,6 @@ export function getFileMap(): { nonProjectFiles: [], projectFileMap: {}, }, - allWorkspaceFiles: [], rustReferences: null, }; } @@ -74,11 +70,9 @@ export function getFileMap(): { export function hydrateFileMap( fileMap: FileMap, - allWorkspaceFiles: FileData[], rustReferences: NxWorkspaceFilesExternals ) { storedFileMap = fileMap; - storedAllWorkspaceFiles = allWorkspaceFiles; storedRustReferences = rustReferences; } @@ -86,7 +80,6 @@ export async function buildProjectGraphUsingProjectFileMap( projectRootMap: Record, externalNodes: Record, fileMap: FileMap, - allWorkspaceFiles: FileData[], rustReferences: NxWorkspaceFilesExternals, fileMapCache: FileMapCache | null, plugins: LoadedNxPlugin[], @@ -96,7 +89,6 @@ export async function buildProjectGraphUsingProjectFileMap( projectFileMapCache: FileMapCache; }> { storedFileMap = fileMap; - storedAllWorkspaceFiles = allWorkspaceFiles; storedRustReferences = rustReferences; const projects: Record = {}; diff --git a/packages/nx/src/project-graph/file-map-utils.spec.ts b/packages/nx/src/project-graph/file-map-utils.spec.ts index cb3285430ecd3..44cb9fc90ba76 100644 --- a/packages/nx/src/project-graph/file-map-utils.spec.ts +++ b/packages/nx/src/project-graph/file-map-utils.spec.ts @@ -47,12 +47,6 @@ describe('fileMapUtils', () => { }, nonProjectFiles: [{ file: 'tools/myfile.txt', hash: 'some-hash' }], }, - allWorkspaceFiles: [ - { file: 'apps/demo/src/main.ts', hash: 'some-hash' }, - { file: 'apps/demo-e2e/src/main.ts', hash: 'some-hash' }, - { file: 'libs/ui/src/index.ts', hash: 'some-hash' }, - { file: 'tools/myfile.txt', hash: 'some-hash' }, - ], }); }); }); diff --git a/packages/nx/src/project-graph/file-map-utils.ts b/packages/nx/src/project-graph/file-map-utils.ts index 73d6eb9d795da..a4e7a73bcc9c3 100644 --- a/packages/nx/src/project-graph/file-map-utils.ts +++ b/packages/nx/src/project-graph/file-map-utils.ts @@ -1,14 +1,13 @@ -import { +import type { FileData, FileMap, ProjectFileMap, ProjectGraph, } from '../config/project-graph'; -import { +import type { ProjectConfiguration, ProjectsConfigurations, } from '../config/workspace-json-project-json'; -import { daemonClient } from '../daemon/client/client'; import { NxWorkspaceFilesExternals } from '../native'; import { getAllFileDataInContext, @@ -16,14 +15,12 @@ import { } from '../utils/workspace-context'; import { workspaceRoot } from '../utils/workspace-root'; import { readProjectsConfigurationFromProjectGraph } from './project-graph'; -import { buildAllWorkspaceFiles } from './utils/build-all-workspace-files'; import { createProjectRootMappingsFromProjectConfigurations, findProjectForPath, } from './utils/find-project-for-path'; export interface WorkspaceFileMap { - allWorkspaceFiles: FileData[]; fileMap: FileMap; } @@ -70,7 +67,6 @@ export function createFileMap( } } return { - allWorkspaceFiles, fileMap: { projectFileMap, nonProjectFiles, @@ -94,10 +90,6 @@ export function updateFileMap( ); return { fileMap: updates.fileMap, - allWorkspaceFiles: buildAllWorkspaceFiles( - updates.fileMap.projectFileMap, - updates.fileMap.nonProjectFiles - ), rustReferences: updates.externalReferences, }; } diff --git a/packages/nx/src/project-graph/plugins/get-plugins.ts b/packages/nx/src/project-graph/plugins/get-plugins.ts index 4a0d3d600297b..23ad6d3b5c5ab 100644 --- a/packages/nx/src/project-graph/plugins/get-plugins.ts +++ b/packages/nx/src/project-graph/plugins/get-plugins.ts @@ -19,9 +19,15 @@ import { */ let currentPluginsConfigurationHash: string; let loadedPlugins: LoadedNxPlugin[]; +let cachedSeparatedPlugins: SeparatedPlugins; let pendingPluginsPromise: Promise | undefined; let cleanupSpecifiedPlugins: () => void | undefined; +export interface SeparatedPlugins { + specifiedPlugins: LoadedNxPlugin[]; + defaultPlugins: LoadedNxPlugin[]; +} + const loadingMethod = ( plugin: PluginConfiguration, root: string, @@ -31,18 +37,35 @@ const loadingMethod = ( ? loadIsolatedNxPlugin(plugin, root, index) : loadNxPlugin(plugin, root, index); +/** + * Returns all plugins (specified + default) as a flat list. + * Specified plugins come first, followed by default plugins. + */ export async function getPlugins( root = workspaceRoot ): Promise { + const { specifiedPlugins, defaultPlugins } = await getPluginsSeparated(root); + return specifiedPlugins.concat(defaultPlugins); +} + +/** + * Returns specified plugins (from nx.json) and default plugins (project.json, + * package.json, etc.) as separate arrays. This separation is needed for + * two-phase project configuration processing where target defaults are + * applied between specified and default plugin results. + */ +export async function getPluginsSeparated( + root = workspaceRoot +): Promise { const pluginsConfiguration = readNxJson(root).plugins ?? []; const pluginsConfigurationHash = hashObject(pluginsConfiguration); // If the plugins configuration has not changed, reuse the current plugins if ( - loadedPlugins && + cachedSeparatedPlugins && pluginsConfigurationHash === currentPluginsConfigurationHash ) { - return loadedPlugins; + return cachedSeparatedPlugins; } currentPluginsConfigurationHash = pluginsConfigurationHash; @@ -71,9 +94,10 @@ export async function getPlugins( throw new AggregateError(errors, errors.map((e) => e.message).join('\n')); } + cachedSeparatedPlugins = { specifiedPlugins, defaultPlugins }; loadedPlugins = specifiedPlugins.concat(defaultPlugins); - return loadedPlugins; + return cachedSeparatedPlugins; } /** @@ -119,6 +143,7 @@ export function cleanupPlugins() { cleanupDefaultPlugins?.(); pendingPluginsPromise = undefined; pendingDefaultPluginPromise = undefined; + cachedSeparatedPlugins = undefined; } /** diff --git a/packages/nx/src/project-graph/project-graph.spec.ts b/packages/nx/src/project-graph/project-graph.spec.ts index bbeaec04176fb..a330356d48351 100644 --- a/packages/nx/src/project-graph/project-graph.spec.ts +++ b/packages/nx/src/project-graph/project-graph.spec.ts @@ -39,9 +39,10 @@ describe('buildProjectGraphAndSourceMapsWithoutDaemon', () => { ], } as any; - jest - .spyOn(plugins, 'getPlugins') - .mockImplementation(async () => [testPlugin]); + jest.spyOn(plugins, 'getPluginsSeparated').mockImplementation(async () => ({ + specifiedPlugins: [testPlugin], + defaultPlugins: [], + })); try { const p = await buildProjectGraphAndSourceMapsWithoutDaemon(); @@ -72,9 +73,10 @@ describe('buildProjectGraphAndSourceMapsWithoutDaemon', () => { }), ], } as any; - jest - .spyOn(plugins, 'getPlugins') - .mockImplementation(async () => [testPlugin]); + jest.spyOn(plugins, 'getPluginsSeparated').mockImplementation(async () => ({ + specifiedPlugins: [testPlugin], + defaultPlugins: [], + })); const p = await buildProjectGraphAndSourceMapsWithoutDaemon(); expect(testPlugin.createNodes[1]).toHaveBeenCalled(); @@ -90,9 +92,10 @@ describe('buildProjectGraphAndSourceMapsWithoutDaemon', () => { }), ], } as any; - jest - .spyOn(plugins, 'getPlugins') - .mockImplementation(async () => [testPlugin]); + jest.spyOn(plugins, 'getPluginsSeparated').mockImplementation(async () => ({ + specifiedPlugins: [testPlugin], + defaultPlugins: [], + })); return Promise.all([ buildProjectGraphAndSourceMapsWithoutDaemon(), diff --git a/packages/nx/src/project-graph/project-graph.ts b/packages/nx/src/project-graph/project-graph.ts index ab6b6678a0243..2ae5dd181177a 100644 --- a/packages/nx/src/project-graph/project-graph.ts +++ b/packages/nx/src/project-graph/project-graph.ts @@ -37,7 +37,7 @@ import { readSourceMapsCache, writeCache, } from './nx-deps-cache'; -import { getPlugins } from './plugins/get-plugins'; +import { getPlugins, getPluginsSeparated } from './plugins/get-plugins'; import { ConfigurationResult } from './utils/project-configuration-utils'; import { retrieveProjectConfigurations, @@ -116,10 +116,13 @@ export async function buildProjectGraphAndSourceMapsWithoutDaemon() { performance.mark('retrieve-project-configurations:start'); let configurationResult: ConfigurationResult; let projectConfigurationsError: ProjectConfigurationsError; - const plugins = await getPlugins(); + const separatedPlugins = await getPluginsSeparated(); + const plugins = separatedPlugins.specifiedPlugins.concat( + separatedPlugins.defaultPlugins + ); try { configurationResult = await retrieveProjectConfigurations( - plugins, + separatedPlugins, workspaceRoot, nxJson ); @@ -136,8 +139,10 @@ export async function buildProjectGraphAndSourceMapsWithoutDaemon() { performance.mark('retrieve-project-configurations:end'); performance.mark('retrieve-workspace-files:start'); - const { allWorkspaceFiles, fileMap, rustReferences } = - await retrieveWorkspaceFiles(workspaceRoot, projectRootMap); + const { fileMap, rustReferences } = await retrieveWorkspaceFiles( + workspaceRoot, + projectRootMap + ); performance.mark('retrieve-workspace-files:end'); const cacheEnabled = process.env.NX_CACHE_PROJECT_GRAPH !== 'false'; @@ -151,7 +156,6 @@ export async function buildProjectGraphAndSourceMapsWithoutDaemon() { projects, externalNodes, fileMap, - allWorkspaceFiles, rustReferences, cacheEnabled ? readFileMapCache() : null, plugins, @@ -230,9 +234,11 @@ async function readCachedGraphAndHydrateFileMap(minimumComputedAt?: number) { project, ]) ); - const { allWorkspaceFiles, fileMap, rustReferences } = - await retrieveWorkspaceFiles(workspaceRoot, projectRootMap); - hydrateFileMap(fileMap, allWorkspaceFiles, rustReferences); + const { fileMap, rustReferences } = await retrieveWorkspaceFiles( + workspaceRoot, + projectRootMap + ); + hydrateFileMap(fileMap, rustReferences); return graph; } diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts index 6688c5a79acbc..0b9bde9c1a057 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.spec.ts @@ -1,11 +1,26 @@ +import { + ProjectConfiguration, + TargetConfiguration, +} from '../../config/workspace-json-project-json'; import { dirname } from 'path'; import { isProjectConfigurationsError } from '../error-types'; import { createNodesFromFiles, NxPluginV2 } from '../plugins'; import { LoadedNxPlugin } from '../plugins/loaded-nx-plugin'; import { createProjectConfigurationsWithPlugins, + CreateNodesResultEntry, + MergeError, mergeCreateNodesResults, } from './project-configuration-utils'; +import { mergeProjectConfigurationIntoRootMap } from './project-configuration/project-nodes-manager'; +import { + mergeTargetConfigurations, + isCompatibleTarget, +} from './project-configuration/target-merging'; +import type { + ConfigurationSourceMaps, + SourceInformation, +} from './project-configuration/source-maps'; describe('project-configuration-utils', () => { describe('mergeCreateNodesResults', () => { @@ -16,8 +31,10 @@ describe('project-configuration-utils', () => { workspaceRoot: root, errors, } = require('./__fixtures__/merge-create-nodes-args.json'); + // results[0] = specified plugin (@acme/gradle), results[1] = default plugin (project.json) const result = mergeCreateNodesResults( - results, + [results[0]], + [results[1]], nxJsonConfiguration, root, errors @@ -36,234 +53,1178 @@ describe('project-configuration-utils', () => { ] `); }); - }); - describe('createProjectConfigurations', () => { - /* A fake plugin that sets `fake-lib` tag to libs. */ - const fakeTagPlugin: NxPluginV2 = { - name: 'fake-tag-plugin', - createNodesV2: [ - 'libs/*/project.json', - (vitestConfigPaths) => - createNodesFromFiles( - (vitestConfigPath) => { - const [_libs, name, _config] = vitestConfigPath.split('/'); - return { - projects: { - [name]: { - name: name, - root: `libs/${name}`, - tags: ['fake-lib'], - }, + // Regression: default-plugin batches merge into an intermediate + // rootmap, not the manager's main rootmap, so filtering substitutor + // registration to roots the manager already knows about used to drop + // every default-plugin project's own dependsOn/inputs. Any + // cross-project reference those arrays introduced therefore never + // received a sentinel and stayed stale through applySubstitutions. + // + // This test drives that gap by having a default plugin rename a + // specified-plugin project (libs/b 'b-old' → 'b-new') while a + // separate default-plugin project.json owns a dependsOn referencing + // the *old* name. Without sentinel registration on the default + // batch, the final dependsOn would still say 'b-old:build'. + it('should resolve dependsOn refs owned by default plugins when the referenced project is renamed during the default apply', () => { + const specifiedResults: CreateNodesResultEntry[][] = [ + [ + [ + '@acme/tool', + 'libs/b/tool.config.ts', + { + projects: { + 'libs/b': { + name: 'b-old', + targets: { build: {} }, }, - }; + }, }, - vitestConfigPaths, - null, - null - ), - ], - }; + ], + ], + ]; - const fakeTargetsPlugin: NxPluginV2 = { - name: 'fake-targets-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => - createNodesFromFiles( - (projectJsonPath) => { - const root = dirname(projectJsonPath); - return { - projects: { - [root]: { - root, - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo {projectName} @ {projectRoot}', - }, - }, + const defaultResults: CreateNodesResultEntry[][] = [ + [ + [ + 'nx/core/project-json', + 'libs/a/project.json', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + test: { + dependsOn: ['b-old:build'], }, }, }, - }; + }, }, - projectJsonPaths, - null, - null - ), - ], - }; - - const sameNamePlugin: NxPluginV2 = { - name: 'same-name-plugin', - createNodesV2: [ - 'libs/*/project.json', - (projectJsonPaths) => - createNodesFromFiles( - (projectJsonPath) => { - const root = dirname(projectJsonPath); - return { - projects: { - [root]: { - root, - name: 'same-name', - }, + ], + [ + 'nx/core/project-json', + 'libs/b/project.json', + { + projects: { + 'libs/b': { + name: 'b-new', + root: 'libs/b', }, - }; + }, }, - projectJsonPaths, - null, - null - ), - ], - }; + ], + ], + ]; - it('should create nodes for files matching included patterns only', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - }), - ] - ); + const errors: MergeError[] = []; + const result = mergeCreateNodesResults( + specifiedResults, + defaultResults, + {}, + '/tmp/test', + errors + ); - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - 'libs/b': { - name: 'b', - root: 'libs/b', - tags: ['fake-lib'], - }, - }); + expect(errors).toEqual([]); + const aTargets = result.projectRootMap['libs/a'].targets!; + expect(aTargets.test.dependsOn).toEqual(['b-new:build']); }); - it('should create nodes for files matching included patterns only', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], + // Mirror of the dependsOn P2 regression for the inputs path: + // processInputs and processDependsOn share writeReplacement / + // createRef plumbing, but the top-level walk is separate. Locks in + // that default-plugin inputs references get sentinel treatment too. + it('should resolve inputs refs owned by default plugins when the referenced project is renamed during the default apply', () => { + const specifiedResults: CreateNodesResultEntry[][] = [ + [ [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/a/**'], - }), - ] - ); - - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); - }); + '@acme/tool', + 'libs/b/tool.config.ts', + { + projects: { + 'libs/b': { + name: 'b-old', + targets: { build: {} }, + }, + }, + }, + ], + ], + ]; - it('should not create nodes for files matching excluded patterns', async () => { - const projectConfigurations = - await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json']], + const defaultResults: CreateNodesResultEntry[][] = [ + [ [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['libs/b/**'], - }), - ] - ); + 'nx/core/project-json', + 'libs/a/project.json', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + test: { + executor: 'nx:noop', + inputs: [{ input: 'default', projects: 'b-old' }], + }, + }, + }, + }, + }, + ], + [ + 'nx/core/project-json', + 'libs/b/project.json', + { + projects: { + 'libs/b': { + name: 'b-new', + root: 'libs/b', + }, + }, + }, + ], + ], + ]; - expect(projectConfigurations.projects).toEqual({ - 'libs/a': { - name: 'a', - root: 'libs/a', - tags: ['fake-lib'], - }, - }); + const errors: MergeError[] = []; + const result = mergeCreateNodesResults( + specifiedResults, + defaultResults, + {}, + '/tmp/test', + errors + ); + + expect(errors).toEqual([]); + const aTargets = result.projectRootMap['libs/a'].targets!; + expect(aTargets.test.inputs).toEqual([ + { input: 'default', projects: 'b-new' }, + ]); }); - it('should normalize targets', async () => { - const { projects } = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json'], ['libs/a/project.json']], + // Forward reference from one default-plugin batch to another: + // intermediate merges don't touch the manager's nameMap, so a + // default-plugin reference to a project that won't be created + // until a later default batch starts life as a usage (forward) + // ref. The intermediate apply loop later fires + // identifyProjectWithRoot for the new project; that promotion has + // to reach the earlier batch's pending sentinel or the final + // configuration will still hold a leftover NameRef object. + it('should promote forward refs introduced by one default plugin to a project created by another default plugin', () => { + const defaultResults: CreateNodesResultEntry[][] = [ [ - new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), - new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), - ] + [ + 'nx/core/package-json', + 'libs/a/package.json', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + test: { + executor: 'nx:noop', + inputs: [{ input: 'default', projects: 'b-final' }], + dependsOn: ['b-final:build'], + }, + }, + }, + }, + }, + ], + ], + [ + [ + 'nx/core/project-json', + 'libs/b/project.json', + { + projects: { + 'libs/b': { + name: 'b-final', + root: 'libs/b', + targets: { build: {} }, + }, + }, + }, + ], + ], + ]; + + const errors: MergeError[] = []; + const result = mergeCreateNodesResults( + [], + defaultResults, + {}, + '/tmp/test', + errors ); - expect(projects['libs/a'].targets.build).toMatchInlineSnapshot(` - { - "configurations": {}, - "executor": "nx:run-commands", - "options": { - "command": "echo a @ libs/a", - }, - "parallelism": true, - } - `); + + expect(errors).toEqual([]); + const aTargets = result.projectRootMap['libs/a'].targets!; + // Plain strings — no internal sentinel objects left behind. + expect(aTargets.test.inputs).toEqual([ + { input: 'default', projects: 'b-final' }, + ]); + expect(aTargets.test.dependsOn).toEqual(['b-final:build']); + expect(typeof (aTargets.test.dependsOn as unknown[])[0]).toBe('string'); }); - it('should validate that project names are unique', async () => { - const error = await createProjectConfigurationsWithPlugins( - undefined, - {}, - [['libs/a/project.json', 'libs/b/project.json', 'libs/c/project.json']], - [new LoadedNxPlugin(sameNamePlugin, 'same-name-plugin')] - ).catch((e) => e); - const isErrorType = isProjectConfigurationsError(error); - expect(isErrorType).toBe(true); - if (isErrorType) { - expect(error.errors).toMatchInlineSnapshot(` + // Rebinding-after-spread regression: when a specified plugin seeds + // a dependsOn array and a default plugin contributes a spread + // (`['...', ...]`), the intermediate apply runs merge logic on the + // owner and rebuilds the array. Sentinels inserted against the + // specified-plugin merge now live in an array that's been + // discarded — the follow-up + // nodesManager.registerNameRefs(intermediateDefaultRootMap) + // call after the intermediate apply rebinds them to the merged + // array. Without it, the later rename would leave an unresolved + // sentinel in the first slot (the one originating from specified). + it('should rebind sentinels inserted by specified plugins when the intermediate apply spread-merges their array', () => { + const specifiedResults: CreateNodesResultEntry[][] = [ + [ [ - [MultipleProjectsWithSameNameError: The following projects are defined in multiple locations: - - same-name: - - libs/a - - libs/b - - libs/c + '@acme/tool', + 'libs/a/tool.config.ts', + { + projects: { + 'libs/a': { + name: 'a', + targets: { + build: { + dependsOn: ['b-old:build'], + }, + }, + }, + 'libs/b': { + name: 'b-old', + targets: { build: {} }, + }, + }, + }, + ], + ], + ]; - To fix this, set a unique name for each project in a project.json inside the project's root. If the project does not currently have a project.json, you can create one that contains only a name.], - ] - `); - } - }); + const defaultResults: CreateNodesResultEntry[][] = [ + [ + [ + 'nx/core/project-json', + 'libs/a/project.json', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + build: { + dependsOn: ['...', '^compile'], + }, + }, + }, + }, + }, + ], + [ + 'nx/core/project-json', + 'libs/b/project.json', + { + projects: { + 'libs/b': { + name: 'b-new', + root: 'libs/b', + }, + }, + }, + ], + ], + ]; - it('should validate that projects have a name', async () => { - const error = await createProjectConfigurationsWithPlugins( - undefined, + const errors: MergeError[] = []; + const result = mergeCreateNodesResults( + specifiedResults, + defaultResults, {}, - [['libs/a/project.json', 'libs/b/project.json', 'libs/c/project.json']], - [new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin')] - ).catch((e) => e); - const isErrorType = isProjectConfigurationsError(error); - expect(isErrorType).toBe(true); - if (isErrorType) { - expect(error.errors).toMatchInlineSnapshot(` - [ - [ProjectsWithNoNameError: The projects in the following directories have no name provided: - - libs/a - - libs/b - - libs/c], - ] - `); + '/tmp/test', + errors + ); + + expect(errors).toEqual([]); + const aTargets = result.projectRootMap['libs/a'].targets!; + // The base dependsOn entry must have been rewritten to the new + // name *in the merged array* — if rebinding were missed, the + // replacement would have been written to the orphaned specified- + // plugin array and the merged slot would still hold the sentinel. + expect(aTargets.build.dependsOn).toEqual(['b-new:build', '^compile']); + // And every slot is a plain string, not a leftover NameRef. + for (const entry of aTargets.build.dependsOn as unknown[]) { + expect(typeof entry).toBe('string'); } }); - it('should provide helpful error if project has task containing cache and continuous', async () => { - const invalidCachePlugin: NxPluginV2 = { - name: 'invalid-cache-plugin', - createNodesV2: [ + it('should apply target defaults between specified and default plugin results', () => { + const specifiedResults = [ + [ + [ + '@nx/vite', + 'libs/my-lib/vite.config.ts', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + targets: { + build: { + executor: '@nx/vite:build', + inputs: ['inferred'], + options: { configFile: 'vite.config.ts' }, + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const defaultResults = [ + [ + [ + 'nx/core/project-json', + 'libs/my-lib/project.json', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + root: 'libs/my-lib', + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + specifiedResults as any, + defaultResults as any, + { + targetDefaults: { + '@nx/vite:build': { + cache: true, + inputs: ['production'], + }, + }, + }, + '/tmp/test', + errors + ); + + const buildTarget = + result.projectRootMap['libs/my-lib'].targets!['build']; + // Target defaults should be applied on top of specified plugin values + expect(buildTarget.cache).toEqual(true); + expect(buildTarget.inputs).toEqual(['production']); + expect(buildTarget.options).toEqual({ configFile: 'vite.config.ts' }); + expect(errors).toEqual([]); + }); + + it('should let default plugin values override target defaults', () => { + const specifiedResults = [ + [ + [ + '@nx/vite', + 'libs/my-lib/vite.config.ts', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + targets: { + build: { + executor: '@nx/vite:build', + inputs: ['inferred'], + options: { configFile: 'vite.config.ts' }, + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const defaultResults = [ + [ + [ + 'nx/core/project-json', + 'libs/my-lib/project.json', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + root: 'libs/my-lib', + targets: { + build: { + inputs: ['explicit'], + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + specifiedResults as any, + defaultResults as any, + { + targetDefaults: { + '@nx/vite:build': { + cache: true, + inputs: ['from-defaults'], + }, + }, + }, + '/tmp/test', + errors + ); + + const buildTarget = + result.projectRootMap['libs/my-lib'].targets!['build']; + // Default plugin (project.json) overrides target defaults for inputs + expect(buildTarget.inputs).toEqual(['explicit']); + // But cache from target defaults still applies (project.json didn't set it) + expect(buildTarget.cache).toEqual(true); + }); + + it('should resolve spread tokens in default plugin values against target defaults', () => { + const specifiedResults = [ + [ + [ + '@nx/vite', + 'libs/my-lib/vite.config.ts', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + targets: { + build: { + executor: '@nx/vite:build', + inputs: ['inferred'], + options: { configFile: 'vite.config.ts' }, + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const defaultResults = [ + [ + [ + 'nx/core/project-json', + 'libs/my-lib/project.json', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + root: 'libs/my-lib', + targets: { + build: { + inputs: ['explicit', '...'], + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + specifiedResults as any, + defaultResults as any, + { + targetDefaults: { + '@nx/vite:build': { + inputs: ['from-defaults'], + }, + }, + }, + '/tmp/test', + errors + ); + + const buildTarget = + result.projectRootMap['libs/my-lib'].targets!['build']; + // '...' in project.json expands against (specified + target defaults) + // The target defaults override specified, so base is ['from-defaults'] + // Then project.json's ['explicit', '...'] expands to ['explicit', 'from-defaults'] + expect(buildTarget.inputs).toEqual(['explicit', 'from-defaults']); + }); + + it('should handle empty specified results', () => { + const defaultResults = [ + [ + [ + 'nx/core/project-json', + 'libs/my-lib/project.json', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + root: 'libs/my-lib', + targets: { + build: { + executor: 'nx:run-commands', + options: { command: 'echo build' }, + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + [], + defaultResults as any, + { + targetDefaults: { + build: { cache: true }, + }, + }, + '/tmp/test', + errors + ); + + const buildTarget = + result.projectRootMap['libs/my-lib'].targets!['build']; + expect(buildTarget.executor).toEqual('nx:run-commands'); + expect(buildTarget.cache).toEqual(true); + expect(errors).toEqual([]); + }); + + it('should handle empty default results', () => { + const specifiedResults = [ + [ + [ + '@nx/vite', + 'libs/my-lib/vite.config.ts', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + targets: { + build: { + executor: '@nx/vite:build', + options: { configFile: 'vite.config.ts' }, + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + specifiedResults as any, + [], + { + targetDefaults: { + '@nx/vite:build': { cache: true }, + }, + }, + '/tmp/test', + errors + ); + + const buildTarget = + result.projectRootMap['libs/my-lib'].targets!['build']; + expect(buildTarget.executor).toEqual('@nx/vite:build'); + expect(buildTarget.cache).toEqual(true); + expect(errors).toEqual([]); + }); + + it('should handle no target defaults', () => { + const specifiedResults = [ + [ + [ + '@nx/vite', + 'libs/my-lib/vite.config.ts', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + targets: { + build: { + executor: '@nx/vite:build', + inputs: ['inferred'], + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const defaultResults = [ + [ + [ + 'nx/core/project-json', + 'libs/my-lib/project.json', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + root: 'libs/my-lib', + targets: { + build: { + inputs: ['explicit', '...'], + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + specifiedResults as any, + defaultResults as any, + {}, + '/tmp/test', + errors + ); + + const buildTarget = + result.projectRootMap['libs/my-lib'].targets!['build']; + // No target defaults, so '...' expands against specified plugin's inputs + expect(buildTarget.inputs).toEqual(['explicit', 'inferred']); + }); + + it('should merge multiple specified plugins contributing to the same project', () => { + const specifiedResults = [ + [ + [ + '@nx/vite', + 'libs/my-lib/vite.config.ts', + { + projects: { + 'libs/my-lib': { + name: 'my-lib', + targets: { + build: { + executor: '@nx/vite:build', + options: { configFile: 'vite.config.ts' }, + }, + }, + }, + }, + }, + ], + ], + [ + [ + '@nx/eslint', + 'libs/my-lib/.eslintrc.json', + { + projects: { + 'libs/my-lib': { + targets: { + lint: { + executor: '@nx/eslint:lint', + }, + }, + }, + }, + }, + ], + ], + ] as const; + + const errors = []; + const result = mergeCreateNodesResults( + specifiedResults as any, + [], + {}, + '/tmp/test', + errors + ); + + const project = result.projectRootMap['libs/my-lib']; + expect(project.targets!['build'].executor).toEqual('@nx/vite:build'); + expect(project.targets!['lint'].executor).toEqual('@nx/eslint:lint'); + expect(errors).toEqual([]); + }); + + // Regression guard for the `defaultConfigurationSourceMaps` overlay + // in project-configuration-utils.ts: when a default plugin target + // lists keys before `...`, those keys yield to the specified-plugin + // base during the final apply, but the intermediate merge already + // wrote their attribution into `defaultConfigurationSourceMaps`. + // The overlay uses "only fill missing" semantics so that stale + // default-plugin entries can't clobber the correct specified-plugin + // attribution already recorded in `configurationSourceMaps`. + it('should attribute target-level keys that yield to base via `...` to the base source, not the default plugin', () => { + const specifiedResults: CreateNodesResultEntry[][] = [ + [ + [ + '@acme/tool', + 'libs/a/tool.config.ts', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + build: { + executor: 'nx:run-commands', + cache: false, + }, + }, + }, + }, + }, + ], + ], + ]; + + const defaultResults: CreateNodesResultEntry[][] = [ + [ + [ + 'nx/core/project-json', + 'libs/a/project.json', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + build: { + cache: true, + '...': true, + }, + }, + }, + }, + }, + ], + ], + ]; + + const errors: MergeError[] = []; + const result = mergeCreateNodesResults( + specifiedResults, + defaultResults, + {}, + '/tmp/test', + errors + ); + + const build = result.projectRootMap['libs/a'].targets!.build; + // Sanity: `cache` before `...` means base (specified) wins. + expect(build.cache).toEqual(false); + + // But the overlay misattributes `cache` to the default plugin + // because the intermediate merge wrote it into + // defaultConfigurationSourceMaps before the final apply + // decided base won. + const sm = result.configurationSourceMaps['libs/a']; + expect(sm['targets.build.cache']).toEqual([ + 'libs/a/tool.config.ts', + '@acme/tool', + ]); + }); + + // Known gap in target-merging.ts#mergeConfigurations: the + // per-configuration `mergeOptions` call is passed an undefined + // source map, and a separate loop then unconditionally attributes + // every property of every new configuration to the new source — + // even when a spread inside the configuration made the base win + // for a given property. Properties that survive only because of + // the spread should keep base-plugin attribution. + it('should attribute spread-shadowed configuration properties to the base, not the new plugin', () => { + const specifiedResults: CreateNodesResultEntry[][] = [ + [ + [ + '@acme/base', + 'libs/a/base.config.ts', + { + projects: { + 'libs/a': { + name: 'a', + root: 'libs/a', + targets: { + build: { + executor: '@acme/build', + configurations: { + prod: { + minify: false, + sourceMap: true, + }, + }, + }, + }, + }, + }, + }, + ], + ], + [ + [ + '@acme/extend', + 'libs/a/extend.config.ts', + { + projects: { + 'libs/a': { + targets: { + build: { + configurations: { + prod: { + // `minify` is before `...` → base wins for it. + minify: true, + '...': true, + }, + }, + }, + }, + }, + }, + }, + ], + ], + ]; + + const errors: MergeError[] = []; + const result = mergeCreateNodesResults( + specifiedResults, + [], + {}, + '/tmp/test', + errors + ); + + const build = result.projectRootMap['libs/a'].targets!.build; + // Sanity: spread resolved correctly — base wins for `minify`, + // `sourceMap` survives via the `...` expansion. + expect(build.configurations!.prod).toEqual({ + minify: false, + sourceMap: true, + }); + + const sm = result.configurationSourceMaps['libs/a']; + expect(sm['targets.build.configurations.prod.minify']).toEqual([ + 'libs/a/base.config.ts', + '@acme/base', + ]); + expect(sm['targets.build.configurations.prod.sourceMap']).toEqual([ + 'libs/a/base.config.ts', + '@acme/base', + ]); + }); + }); + + describe('createProjectConfigurations', () => { + /* A fake plugin that sets `fake-lib` tag to libs. */ + const fakeTagPlugin: NxPluginV2 = { + name: 'fake-tag-plugin', + createNodesV2: [ + 'libs/*/project.json', + (vitestConfigPaths) => + createNodesFromFiles( + (vitestConfigPath) => { + const [_libs, name, _config] = vitestConfigPath.split('/'); + return { + projects: { + [name]: { + name: name, + root: `libs/${name}`, + tags: ['fake-lib'], + }, + }, + }; + }, + vitestConfigPaths, + null, + null + ), + ], + }; + + const fakeTargetsPlugin: NxPluginV2 = { + name: 'fake-targets-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => + createNodesFromFiles( + (projectJsonPath) => { + const root = dirname(projectJsonPath); + return { + projects: { + [root]: { + root, + targets: { + build: { + executor: 'nx:run-commands', + options: { + command: 'echo {projectName} @ {projectRoot}', + }, + }, + }, + }, + }, + }; + }, + projectJsonPaths, + null, + null + ), + ], + }; + + const sameNamePlugin: NxPluginV2 = { + name: 'same-name-plugin', + createNodesV2: [ + 'libs/*/project.json', + (projectJsonPaths) => + createNodesFromFiles( + (projectJsonPath) => { + const root = dirname(projectJsonPath); + return { + projects: { + [root]: { + root, + name: 'same-name', + }, + }, + }; + }, + projectJsonPaths, + null, + null + ), + ], + }; + + it('should create nodes for files matching included patterns only', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + ['libs/a/project.json', 'libs/b/project.json'], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + }), + ], + } + ); + + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], + }, + 'libs/b': { + name: 'b', + root: 'libs/b', + tags: ['fake-lib'], + }, + }); + }); + + it('should create nodes for files matching included patterns only', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + ['libs/a/project.json', 'libs/b/project.json'], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/a/**'], + }), + ], + } + ); + + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], + }, + }); + }); + + it('should not create nodes for files matching excluded patterns', async () => { + const projectConfigurations = + await createProjectConfigurationsWithPlugins( + undefined, + {}, + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + ['libs/a/project.json', 'libs/b/project.json'], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['libs/b/**'], + }), + ], + } + ); + + expect(projectConfigurations.projects).toEqual({ + 'libs/a': { + name: 'a', + root: 'libs/a', + tags: ['fake-lib'], + }, + }); + }); + + it('should normalize targets', async () => { + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + {}, + { + specifiedPluginFiles: [ + ['libs/a/project.json'], + ['libs/a/project.json'], + ], + defaultPluginFiles: [], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), + new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), + ], + defaultPlugins: [], + } + ); + expect(projects['libs/a'].targets.build).toMatchInlineSnapshot(` + { + "configurations": {}, + "executor": "nx:run-commands", + "options": { + "command": "echo a @ libs/a", + }, + "parallelism": true, + } + `); + }); + + it('should validate that project names are unique', async () => { + const error = await createProjectConfigurationsWithPlugins( + undefined, + {}, + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(sameNamePlugin, 'same-name-plugin'), + ], + } + ).catch((e) => e); + const isErrorType = isProjectConfigurationsError(error); + expect(isErrorType).toBe(true); + if (isErrorType) { + expect(error.errors).toMatchInlineSnapshot(` + [ + [MultipleProjectsWithSameNameError: The following projects are defined in multiple locations: + - same-name: + - libs/a + - libs/b + - libs/c + + To fix this, set a unique name for each project in a project.json inside the project's root. If the project does not currently have a project.json, you can create one that contains only a name.], + ] + `); + } + }); + + it('should validate that projects have a name', async () => { + const error = await createProjectConfigurationsWithPlugins( + undefined, + {}, + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), + ], + } + ).catch((e) => e); + const isErrorType = isProjectConfigurationsError(error); + expect(isErrorType).toBe(true); + if (isErrorType) { + expect(error.errors).toMatchInlineSnapshot(` + [ + [ProjectsWithNoNameError: The projects in the following directories have no name provided: + - libs/a + - libs/b + - libs/c], + ] + `); + } + }); + + it('should provide helpful error if project has task containing cache and continuous', async () => { + const invalidCachePlugin: NxPluginV2 = { + name: 'invalid-cache-plugin', + createNodesV2: [ 'libs/*/project.json', (projectJsonPaths) => { const results = []; @@ -300,8 +1261,16 @@ describe('project-configuration-utils', () => { const error = await createProjectConfigurationsWithPlugins( undefined, {}, - [['libs/my-lib/project.json']], - [new LoadedNxPlugin(invalidCachePlugin, 'invalid-cache-plugin')] + { + specifiedPluginFiles: [], + defaultPluginFiles: [['libs/my-lib/project.json']], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(invalidCachePlugin, 'invalid-cache-plugin'), + ], + } ).catch((e) => e); const isErrorType = isProjectConfigurationsError(error); @@ -321,11 +1290,20 @@ describe('project-configuration-utils', () => { const { sourceMaps } = await createProjectConfigurationsWithPlugins( undefined, {}, - [['libs/a/project.json'], ['libs/a/project.json']], - [ - new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), - new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), - ] + { + specifiedPluginFiles: [ + ['libs/a/project.json'], + ['libs/a/project.json'], + ], + defaultPluginFiles: [], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(fakeTargetsPlugin, 'fake-targets-plugin'), + new LoadedNxPlugin(fakeTagPlugin, 'fake-tag-plugin'), + ], + defaultPlugins: [], + } ); expect(sourceMaps).toMatchInlineSnapshot(` { @@ -408,8 +1386,16 @@ describe('project-configuration-utils', () => { const error = await createProjectConfigurationsWithPlugins( undefined, {}, - [['libs/my-app/project.json']], - [new LoadedNxPlugin(invalidTokenPlugin, 'invalid-token-plugin')] + { + specifiedPluginFiles: [], + defaultPluginFiles: [['libs/my-app/project.json']], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(invalidTokenPlugin, 'invalid-token-plugin'), + ], + } ).catch((e) => e); expect(error.message).toContain( @@ -465,14 +1451,22 @@ describe('project-configuration-utils', () => { const error = await createProjectConfigurationsWithPlugins( undefined, nxJsonWithInvalidDefaults, - [['libs/my-lib/project.json']], - [new LoadedNxPlugin(simplePlugin, 'simple-plugin')] + { + specifiedPluginFiles: [], + defaultPluginFiles: [['libs/my-lib/project.json']], + }, + { + specifiedPlugins: [], + defaultPlugins: [new LoadedNxPlugin(simplePlugin, 'simple-plugin')], + } ).catch((e) => e); expect(error.message).toContain( 'The {workspaceRoot} token is only valid at the beginning of an option' ); - expect(error.message).toContain('nx.json[targetDefaults]:test'); + // Token validation now happens during normalization on the merged + // rootMap, so the error is keyed by project root + target name. + expect(error.message).toContain('libs/my-lib:test'); }); describe('negation pattern support', () => { @@ -481,19 +1475,25 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a-e2e/project.json', - 'libs/b-e2e/project.json', - 'libs/toolkit-workspace-e2e/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a-e2e/project.json', + 'libs/b-e2e/project.json', + 'libs/toolkit-workspace-e2e/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['**/*-e2e/**', '!**/toolkit-workspace-e2e/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['**/*-e2e/**', '!**/toolkit-workspace-e2e/**'], + }), + ], + } ); expect(projectConfigurations.projects).toEqual({ @@ -510,19 +1510,25 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/**', '!libs/b/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/**', '!libs/b/**'], + }), + ], + } ); expect(projectConfigurations.projects).toEqual({ @@ -544,20 +1550,26 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - 'libs/d/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + 'libs/d/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['libs/**', '!libs/b/**', '!libs/c/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['libs/**', '!libs/b/**', '!libs/c/**'], + }), + ], + } ); expect(projectConfigurations.projects).toEqual({ @@ -579,19 +1591,25 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['!libs/a/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['!libs/a/**'], + }), + ], + } ); // Should exclude everything except libs/a (first pattern is negation) @@ -609,19 +1627,25 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['!libs/b/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['!libs/b/**'], + }), + ], + } ); // Should include everything except libs/b (first pattern is negation) @@ -644,14 +1668,22 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/a/**'], - exclude: ['libs/b/**'], - }), - ] + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + ['libs/a/project.json', 'libs/b/project.json'], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/a/**'], + exclude: ['libs/b/**'], + }), + ], + } ); expect(projectConfigurations.projects).toEqual({ @@ -668,19 +1700,25 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a/project.json', - 'libs/a/special/project.json', - 'libs/b/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/a/special/project.json', + 'libs/b/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - exclude: ['libs/**', '!libs/a/**', 'libs/a/special/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + exclude: ['libs/**', '!libs/a/**', 'libs/a/special/**'], + }), + ], + } ); // Exclude all libs, except a, but re-exclude a/special (last match wins) @@ -698,21 +1736,27 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [ - [ - 'libs/a/project.json', - 'libs/b/project.json', - 'libs/c/project.json', - 'libs/d/project.json', + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + [ + 'libs/a/project.json', + 'libs/b/project.json', + 'libs/c/project.json', + 'libs/d/project.json', + ], ], - ], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: ['libs/**', '!libs/d/**'], - exclude: ['libs/b/**', '!libs/c/**'], - }), - ] + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: ['libs/**', '!libs/d/**'], + exclude: ['libs/b/**', '!libs/c/**'], + }), + ], + } ); // Include: a, b, c (all except d) @@ -737,14 +1781,22 @@ describe('project-configuration-utils', () => { await createProjectConfigurationsWithPlugins( undefined, {}, - [['libs/a/project.json', 'libs/b/project.json']], - [ - new LoadedNxPlugin(fakeTagPlugin, { - plugin: fakeTagPlugin.name, - include: [], - exclude: [], - }), - ] + { + specifiedPluginFiles: [], + defaultPluginFiles: [ + ['libs/a/project.json', 'libs/b/project.json'], + ], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(fakeTagPlugin, { + plugin: fakeTagPlugin.name, + include: [], + exclude: [], + }), + ], + } ); // Empty arrays should not filter anything @@ -762,5 +1814,773 @@ describe('project-configuration-utils', () => { }); }); }); + + describe('mergeProjectConfigurationIntoRootMap spread syntax', () => { + it('should spread arrays in target options when merging projects', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + options: { + scripts: ['existing-script-1', 'existing-script-2'], + }, + }, + }, + }) + .getRootMap(); + + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + options: { + scripts: ['new-script', '...'], + }, + }, + }, + }); + + expect(rootMap['libs/lib-a'].targets?.build.options.scripts).toEqual([ + 'new-script', + 'existing-script-1', + 'existing-script-2', + ]); + }); + + it('should spread objects in target options when merging projects', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + options: { + env: { + EXISTING_VAR: 'existing', + SHARED_VAR: 'existing-shared', + }, + }, + }, + }, + }) + .getRootMap(); + + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + options: { + env: { + NEW_VAR: 'new', + '...': true, + SHARED_VAR: 'new-shared', + }, + }, + }, + }, + }); + + expect(rootMap['libs/lib-a'].targets?.build.options.env).toEqual({ + NEW_VAR: 'new', + EXISTING_VAR: 'existing', + SHARED_VAR: 'new-shared', + }); + }); + + it('should spread arrays in top-level target properties when merging projects', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + inputs: ['default', '{projectRoot}/**/*'], + outputs: ['{projectRoot}/dist'], + dependsOn: ['^build'], + }, + }, + }) + .getRootMap(); + + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + inputs: ['production', '...'], + outputs: ['...', '{projectRoot}/coverage'], + dependsOn: ['prebuild', '...'], + }, + }, + }); + + expect(rootMap['libs/lib-a'].targets?.build.inputs).toEqual([ + 'production', + 'default', + '{projectRoot}/**/*', + ]); + expect(rootMap['libs/lib-a'].targets?.build.outputs).toEqual([ + '{projectRoot}/dist', + '{projectRoot}/coverage', + ]); + expect(rootMap['libs/lib-a'].targets?.build.dependsOn).toEqual([ + 'prebuild', + '^build', + ]); + }); + + it('should spread arrays in configuration options when merging projects', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + configurations: { + prod: { + fileReplacements: [ + { replace: 'env.ts', with: 'env.prod.ts' }, + ], + }, + }, + }, + }, + }) + .getRootMap(); + + mergeProjectConfigurationIntoRootMap(rootMap, { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + configurations: { + prod: { + fileReplacements: [ + { replace: 'config.ts', with: 'config.prod.ts' }, + '...', + ], + }, + }, + }, + }, + }); + + expect( + rootMap['libs/lib-a'].targets?.build.configurations?.prod + ?.fileReplacements + ).toEqual([ + { replace: 'config.ts', with: 'config.prod.ts' }, + { replace: 'env.ts', with: 'env.prod.ts' }, + ]); + }); + + it('should handle spread with source maps correctly', () => { + const rootMap = new RootMapBuilder() + .addProject({ + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + executor: 'nx:run-commands', + options: { + scripts: ['base-script'], + }, + }, + }, + }) + .getRootMap(); + const sourceMap: ConfigurationSourceMaps = { + 'libs/lib-a': { + 'targets.build': ['base', 'base-plugin'], + 'targets.build.options': ['base', 'base-plugin'], + 'targets.build.options.scripts': ['base', 'base-plugin'], + }, + }; + + mergeProjectConfigurationIntoRootMap( + rootMap, + { + root: 'libs/lib-a', + name: 'lib-a', + targets: { + build: { + options: { + scripts: ['new-script', '...'], + }, + }, + }, + }, + sourceMap, + ['new', 'new-plugin'] + ); + + expect(rootMap['libs/lib-a'].targets?.build.options.scripts).toEqual([ + 'new-script', + 'base-script', + ]); + expect( + sourceMap['libs/lib-a']['targets.build.options.scripts'] + ).toEqual(['new', 'new-plugin']); + // Per-element source tracking + expect( + sourceMap['libs/lib-a']['targets.build.options.scripts.0'] + ).toEqual(['new', 'new-plugin']); + expect( + sourceMap['libs/lib-a']['targets.build.options.scripts.1'] + ).toEqual(['base', 'base-plugin']); + }); + }); + + describe('two-phase spread: project.json spread includes target defaults', () => { + const projectJsonPaths = ['libs/my-lib/project.json']; + + /** + * Creates a specified plugin that infers targets for a project. + * Simulates plugins like @nx/webpack, @nx/jest, etc. + */ + function makeSpecifiedPlugin( + targets: Record, + projectRoot = 'libs/my-lib' + ): NxPluginV2 { + return { + name: 'specified-plugin', + createNodesV2: [ + 'libs/*/project.json', + (configFiles) => + createNodesFromFiles( + (configFile) => { + const root = dirname(configFile); + if (root !== projectRoot) return {}; + return { + projects: { + [root]: { targets }, + }, + }; + }, + configFiles, + {} as any, + {} as any + ), + ], + }; + } + + /** + * Creates a default plugin (like project.json) that defines targets. + */ + function makeDefaultPlugin( + targets: Record, + projectRoot = 'libs/my-lib', + name = 'default-plugin' + ): NxPluginV2 { + return { + name, + createNodesV2: [ + 'libs/*/project.json', + (configFiles) => + createNodesFromFiles( + (configFile) => { + const root = dirname(configFile); + if (root !== projectRoot) return {}; + return { + projects: { + [root]: { + name: 'my-lib', + targets, + }, + }, + }; + }, + configFiles, + {} as any, + {} as any + ), + ], + }; + } + + it('Case C: spread in project.json target includes target defaults (specified + defaults)', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + inputs: ['inferred'], + options: { command: 'echo build' }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({ + build: { + inputs: ['explicit', '...'], + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + inputs: ['default'], + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // project.json spread expands with (specified + target defaults) + // Since target defaults override specified: base is ['default'] + // project.json merges ['explicit', '...'] on top → ['explicit', 'default'] + expect(projects['libs/my-lib'].targets!.build.inputs).toEqual([ + 'explicit', + 'default', + ]); + }); + + it('Case B: spread in project.json-only target includes target defaults', async () => { + const defaultPlugin = makeDefaultPlugin({ + deploy: { + executor: 'nx:run-commands', + inputs: ['explicit', '...'], + options: { command: 'echo deploy' }, + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + deploy: { + inputs: ['default'], + }, + }, + }, + { + specifiedPluginFiles: [], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // project.json spread expands with target defaults (no specified values) + // Base is ['default'], project.json merges ['explicit', '...'] on top + expect(projects['libs/my-lib'].targets!.deploy.inputs).toEqual([ + 'explicit', + 'default', + ]); + }); + + it('Case C without spread: project.json fully replaces (existing behavior)', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + inputs: ['inferred'], + options: { command: 'echo build' }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({ + build: { + inputs: ['explicit'], + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + inputs: ['default'], + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // No spread: project.json fully replaces + expect(projects['libs/my-lib'].targets!.build.inputs).toEqual([ + 'explicit', + ]); + }); + + it('Case A: target defaults override specified plugin (no project.json target)', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + inputs: ['inferred'], + options: { command: 'echo build' }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({}); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + inputs: ['default'], + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // Target defaults override specified plugin values + expect(projects['libs/my-lib'].targets!.build.inputs).toEqual([ + 'default', + ]); + }); + + it('Case A: target defaults with spread include specified plugin values', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + inputs: ['inferred'], + options: { command: 'echo build' }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({}); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + inputs: ['default', '...'], + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // Target defaults spread includes specified plugin values + expect(projects['libs/my-lib'].targets!.build.inputs).toEqual([ + 'default', + 'inferred', + ]); + }); + + it('Case B without spread: project.json fully replaces target defaults', async () => { + const defaultPlugin = makeDefaultPlugin({ + deploy: { + executor: 'nx:run-commands', + inputs: ['explicit'], + options: { command: 'echo deploy' }, + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + deploy: { + inputs: ['default'], + }, + }, + }, + { + specifiedPluginFiles: [], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // No spread: project.json fully replaces target defaults + expect(projects['libs/my-lib'].targets!.deploy.inputs).toEqual([ + 'explicit', + ]); + }); + + it('full three-layer spread chain', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + options: { + command: 'echo build', + assets: ['inferred'], + }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({ + build: { + options: { + assets: ['explicit', '...'], + }, + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + options: { + assets: ['default', '...'], + }, + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // Three-layer chain: + // 1. Specified: ['inferred'] + // 2. Target defaults: ['default', '...'] → ['default', 'inferred'] + // 3. project.json: ['explicit', '...'] → ['explicit', 'default', 'inferred'] + expect(projects['libs/my-lib'].targets!.build.options.assets).toEqual([ + 'explicit', + 'default', + 'inferred', + ]); + }); + + it('spread in project.json options includes target default options', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + options: { + command: 'echo build', + env: { SPECIFIED: 'true' }, + }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({ + build: { + options: { + env: { PROJECT: 'true', '...': true }, + }, + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + options: { + env: { DEFAULT: 'true', '...': true }, + }, + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + // Object spread through all three layers + expect(projects['libs/my-lib'].targets!.build.options.env).toEqual({ + PROJECT: 'true', + DEFAULT: 'true', + SPECIFIED: 'true', + }); + }); + + it('Case D: target defaults apply once when target is in default plugin results', async () => { + const specifiedPlugin = makeSpecifiedPlugin({ + build: { + executor: 'nx:run-commands', + inputs: ['from-specified'], + options: { command: 'echo build' }, + }, + }); + + const defaultPlugin = makeDefaultPlugin({ + build: { + inputs: ['from-default', '...'], + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + build: { + inputs: ['from-defaults', '...'], + }, + }, + }, + { + specifiedPluginFiles: [projectJsonPaths], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [ + new LoadedNxPlugin(specifiedPlugin, 'specified-plugin'), + ], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + expect(projects['libs/my-lib'].targets!.build.inputs).toEqual([ + 'from-default', + 'from-defaults', + 'from-specified', + ]); + }); + + it('Case E: target defaults provide cache/dependsOn when default plugin has executor but no cache', async () => { + const defaultPlugin = makeDefaultPlugin({ + build: { + executor: '@nx/esbuild:esbuild', + outputs: ['{options.outputPath}'], + options: { + outputPath: 'dist', + }, + }, + }); + + const { projects } = await createProjectConfigurationsWithPlugins( + undefined, + { + targetDefaults: { + '@nx/esbuild:esbuild': { + cache: true, + dependsOn: ['^build'], + inputs: ['production', '^production'], + }, + }, + }, + { + specifiedPluginFiles: [], + defaultPluginFiles: [projectJsonPaths], + }, + { + specifiedPlugins: [], + defaultPlugins: [ + new LoadedNxPlugin(defaultPlugin, 'default-plugin'), + ], + } + ); + + const buildTarget = projects['libs/my-lib'].targets!.build; + expect(buildTarget.executor).toEqual('@nx/esbuild:esbuild'); + expect(buildTarget.cache).toEqual(true); + expect(buildTarget.dependsOn).toEqual(['^build']); + expect(buildTarget.inputs).toEqual(['production', '^production']); + expect(buildTarget.outputs).toEqual(['{options.outputPath}']); + expect(buildTarget.options).toEqual({ outputPath: 'dist' }); + }); + }); }); }); + +class RootMapBuilder { + private rootMap: Record = {}; + + addProject(p: ProjectConfiguration) { + this.rootMap[p.root] = p; + return this; + } + + getRootMap() { + return this.rootMap; + } +} + +function assertCorrectKeysInSourceMap( + sourceMaps: ConfigurationSourceMaps, + root: string, + ...tuples: [string, string][] +) { + const sourceMap = sourceMaps[root]; + tuples.forEach(([key, value]) => { + if (!sourceMap[key]) { + throw new Error(`Expected sourceMap to contain key ${key}`); + } + try { + expect(sourceMap[key][0]).toEqual(value); + } catch (error) { + // Enhancing the error message with the problematic key + throw new Error( + `Assertion failed for key '${key}': \n ${(error as Error).message}` + ); + } + }); +} diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.ts index e25dbd854e701..8c1b73698148a 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -4,6 +4,7 @@ import { ProjectConfiguration } from '../../config/workspace-json-project-json'; import { workspaceRoot } from '../../utils/workspace-root'; import { createRootMap, + mergeProjectConfigurationIntoRootMap, ProjectNodesManager, } from './project-configuration/project-nodes-manager'; import { validateAndNormalizeProjectRootMap } from './project-configuration/target-normalization'; @@ -33,10 +34,10 @@ import type { SourceInformation, } from './project-configuration/source-maps'; -export { - mergeTargetConfigurations, - readTargetDefaultsForTarget, -} from './project-configuration/target-merging'; +import { createTargetDefaultsResults } from './project-configuration/target-defaults'; + +export { mergeTargetConfigurations } from './project-configuration/target-merging'; +export { readTargetDefaultsForTarget } from './project-configuration/target-defaults'; export type ConfigurationResult = { /** @@ -67,16 +68,29 @@ export type ConfigurationResult = { /** * Transforms a list of project paths into a map of project configurations. * + * Plugins are run in parallel, then results are merged in a single ordered pass: + * specified plugins → synthetic target defaults → default plugins + * + * This ordering ensures '...' spread tokens in default plugin configs + * (project.json, package.json) expand against accumulated values from + * specified plugins and target defaults. + * * @param root The workspace root * @param nxJson The NxJson configuration - * @param workspaceFiles A list of non-ignored workspace files - * @param plugins The plugins that should be used to infer project configuration + * @param projectFiles Plugin config files, separated by plugin set + * @param plugins The plugins separated into specified and default sets */ export async function createProjectConfigurationsWithPlugins( root: string = workspaceRoot, nxJson: NxJsonConfiguration, - projectFiles: string[][], // making this parameter allows devkit to pick up newly created projects - plugins: LoadedNxPlugin[] + projectFiles: { + specifiedPluginFiles: string[][]; + defaultPluginFiles: string[][]; + }, + plugins: { + specifiedPlugins: LoadedNxPlugin[]; + defaultPlugins: LoadedNxPlugin[]; + } ): Promise { performance.mark('build-project-configs:start'); @@ -108,11 +122,24 @@ export async function createProjectConfigurationsWithPlugins( } } - const createNodesPlugins = plugins.filter( + const specifiedCreateNodesPlugins = plugins.specifiedPlugins.filter( (plugin) => plugin.createNodes?.[0] ); + const defaultCreateNodesPlugins = plugins.defaultPlugins.filter( + (plugin) => plugin.createNodes?.[0] + ); + const allCreateNodesPlugins = [ + ...specifiedCreateNodesPlugins, + ...defaultCreateNodesPlugins, + ]; + const allProjectFiles = [ + ...projectFiles.specifiedPluginFiles, + ...projectFiles.defaultPluginFiles, + ]; + const specifiedCount = specifiedCreateNodesPlugins.length; + spinner = new DelayedSpinner( - `Creating project graph nodes with ${createNodesPlugins.length} plugins` + `Creating project graph nodes with ${allCreateNodesPlugins.length} plugins` ); const results: Promise< @@ -141,11 +168,11 @@ export async function createProjectConfigurationsWithPlugins( exclude, name: pluginName, }, - ] of createNodesPlugins.entries()) { + ] of allCreateNodesPlugins.entries()) { const [pattern, createNodes] = createNodesTuple; const matchingConfigFiles: string[] = findMatchingConfigFiles( - projectFiles[index], + allProjectFiles[index], pattern, include, exclude @@ -184,8 +211,18 @@ export async function createProjectConfigurationsWithPlugins( return Promise.all(results).then((results) => { spinner?.cleanup(); + // Split results into specified and default plugin sets + const specifiedResults = results.slice(0, specifiedCount); + const defaultResults = results.slice(specifiedCount); + const { projectRootMap, externalNodes, rootMap, configurationSourceMaps } = - mergeCreateNodesResults(results, nxJson, root, errors); + mergeCreateNodesResults( + specifiedResults, + defaultResults, + nxJson, + root, + errors + ); performance.mark('build-project-configs:end'); performance.measure( @@ -194,13 +231,18 @@ export async function createProjectConfigurationsWithPlugins( 'build-project-configs:end' ); + const allProjectFilesFlat = [ + ...projectFiles.specifiedPluginFiles.flat(), + ...projectFiles.defaultPluginFiles.flat(), + ]; + if (errors.length === 0) { return { projects: projectRootMap, externalNodes, projectRootMap: rootMap, sourceMaps: configurationSourceMaps, - matchingProjectFiles: projectFiles.flat(), + matchingProjectFiles: allProjectFilesFlat, }; } else { throw new ProjectConfigurationsError(errors, { @@ -208,105 +250,220 @@ export async function createProjectConfigurationsWithPlugins( externalNodes, projectRootMap: rootMap, sourceMaps: configurationSourceMaps, - matchingProjectFiles: projectFiles.flat(), + matchingProjectFiles: allProjectFilesFlat, }); } }); } +export type CreateNodesResultEntry = readonly [ + plugin: string, + file: string, + result: CreateNodesResult, + pluginIndex?: number, +]; + +export type MergeError = + | AggregateCreateNodesError + | MergeNodesError + | ProjectsWithNoNameError + | MultipleProjectsWithSameNameError + | WorkspaceValidityError; + +type MergeFn = ( + project: ProjectConfiguration, + sourceInfo: SourceInformation +) => void; + +/** + * Runs a single plugin batch through two passes: + * + * 1. Every project node in every plugin result is handed to `mergeFn`, + * which decides where it lands (the manager's rootMap, an + * intermediate rootMap, etc.). Any failure is collected into + * `errors`; processing keeps going. External nodes are accumulated + * onto the shared `externalNodes` record. + * 2. After every project in the batch has been merged, name-reference + * sentinels for the batch are registered against `nameRefRootMap` — + * the rootMap the batch was merged into — so sentinels point at the + * target objects that actually received the merges. + * + * The two passes can't be collapsed: a sentinel registered too early + * would point at the pre-merge object, and a later project in the same + * batch may still rename a project the sentinel refers to. Splitting + * the registration into a second pass also lets forward references + * inside the same batch resolve eagerly. + */ +function mergeCreateNodesResultsFromSinglePlugin( + pluginResults: CreateNodesResultEntry[], + mergeFn: MergeFn, + nodesManager: ProjectNodesManager, + nameRefRootMap: Record, + externalNodes: Record, + errors: MergeError[] +): void { + for (const result of pluginResults) { + const [pluginName, file, nodes, pluginIndex] = result; + const { projects: projectNodes, externalNodes: pluginExternalNodes } = + nodes; + const sourceInfo: SourceInformation = [file, pluginName]; + + for (const root in projectNodes) { + if (!projectNodes[root]) continue; + const project = { root, ...projectNodes[root] }; + + try { + mergeFn(project, sourceInfo); + } catch (error) { + errors.push( + new MergeNodesError({ file, pluginName, error, pluginIndex }) + ); + } + } + + Object.assign(externalNodes, pluginExternalNodes); + } + + for (const result of pluginResults) { + const [pluginName, file, nodes, pluginIndex] = result; + const { projects: projectNodes } = nodes; + + try { + nodesManager.registerNameRefs(projectNodes, nameRefRootMap); + } catch (error) { + errors.push( + new MergeNodesError({ file, pluginName, error, pluginIndex }) + ); + } + } +} + +/** + * Merges create nodes results into a single rootMap. + * + * Default plugin results are merged twice: first into an intermediate + * rootMap with unresolved spread sentinels preserved, so target + * defaults selection sees the real merged shape of defaults; then + * applied as a single layer onto the main rootMap where the preserved + * spreads resolve against the specified + target-defaults base. + */ export function mergeCreateNodesResults( - results: (readonly [ - plugin: string, - file: string, - result: CreateNodesResult, - pluginIndex?: number, - ])[][], + specifiedResults: CreateNodesResultEntry[][], + defaultResults: CreateNodesResultEntry[][], nxJsonConfiguration: NxJsonConfiguration, workspaceRoot: string, - errors: ( - | AggregateCreateNodesError - | MergeNodesError - | ProjectsWithNoNameError - | MultipleProjectsWithSameNameError - | WorkspaceValidityError - )[] + errors: MergeError[] ) { performance.mark('createNodes:merge - start'); + const nodesManager = new ProjectNodesManager(); const externalNodes: Record = {}; - const configurationSourceMaps: Record< - string, - Record - > = {}; - - // Process each plugin's results in two phases: - // Phase 1: Merge all projects from this plugin into rootMap/nameMap - // Phase 2: Register substitutors for this plugin's results - // - // Per-plugin batching ensures that: - // - All same-plugin projects are in the nameMap before substitutor - // registration (fixes cross-file references like kafka-stream) - // - Later-plugin renames haven't occurred yet, so dependsOn strings - // that reference old names can still be resolved via the nameMap - for (const pluginResults of results) { - // Phase 1: Merge all projects from this plugin batch - for (const result of pluginResults) { - const [pluginName, file, nodes, pluginIndex] = result; - - const { projects: projectNodes, externalNodes: pluginExternalNodes } = - nodes; - - const sourceInfo: SourceInformation = [file, pluginName]; - - for (const root in projectNodes) { - // Handles `{projects: {'libs/foo': undefined}}`. - if (!projectNodes[root]) { - continue; - } - const project = { - root: root, - ...projectNodes[root], - }; - - try { - nodesManager.mergeProjectNode( - project, - configurationSourceMaps, - sourceInfo - ); - } catch (error) { - errors.push( - new MergeNodesError({ - file, - pluginName, - error, - pluginIndex, - }) - ); - } - } + const configurationSourceMaps: ConfigurationSourceMaps = {}; + const intermediateDefaultRootMap: Record = {}; + // Kept separate so the intermediate merge doesn't clobber + // specified/TD attribution on fields the defaults don't touch. + const defaultConfigurationSourceMaps: ConfigurationSourceMaps = {}; + + const mergeToManager: MergeFn = (project, sourceInfo) => + nodesManager.mergeProjectNode(project, configurationSourceMaps, sourceInfo); + + const mergeToIntermediate: MergeFn = (project, sourceInfo) => { + mergeProjectConfigurationIntoRootMap( + intermediateDefaultRootMap, + project, + defaultConfigurationSourceMaps, + sourceInfo, + false, + true + ); + }; - Object.assign(externalNodes, pluginExternalNodes); - } + for (const pluginResults of specifiedResults) { + mergeCreateNodesResultsFromSinglePlugin( + pluginResults, + mergeToManager, + nodesManager, + nodesManager.getRootMap(), + externalNodes, + errors + ); + } - // Phase 2: Register substitutors for this plugin batch. The nameMap - // now contains all projects from this plugin (and all prior plugins) - // so splitTargetFromConfigurations can resolve colon-delimited strings. - for (const result of pluginResults) { - const [pluginName, file, nodes, pluginIndex] = result; - const { projects: projectNodes } = nodes; + for (const pluginResults of defaultResults) { + mergeCreateNodesResultsFromSinglePlugin( + pluginResults, + mergeToIntermediate, + nodesManager, + intermediateDefaultRootMap, + externalNodes, + errors + ); + } - try { - nodesManager.registerSubstitutors(projectNodes); - } catch (error) { - errors.push( - new MergeNodesError({ - file, - pluginName, - error, - pluginIndex, - }) - ); + const targetDefaultsResults = createTargetDefaultsResults( + nodesManager.getRootMap(), + intermediateDefaultRootMap, + nxJsonConfiguration, + configurationSourceMaps, + defaultConfigurationSourceMaps + ); + + if (targetDefaultsResults.length > 0) { + mergeCreateNodesResultsFromSinglePlugin( + targetDefaultsResults, + mergeToManager, + nodesManager, + nodesManager.getRootMap(), + externalNodes, + errors + ); + } + + // Apply the intermediate default rootMap as a single layer. Preserved + // spread sentinels resolve here against the real specified + TD base. + // Source maps are intentionally not written — TD attribution for + // fields that yield to the base (e.g. keys before `...`) stays intact. + for (const root in intermediateDefaultRootMap) { + const project = intermediateDefaultRootMap[root]; + try { + nodesManager.mergeProjectNode(project, undefined, undefined); + } catch (error) { + errors.push( + new MergeNodesError({ + file: 'nx.json', + pluginName: 'nx/default-plugins', + error, + pluginIndex: undefined, + }) + ); + } + } + + // The intermediate apply may have rebuilt dependsOn / inputs arrays + // via spread merges, leaving sentinels inserted against the + // intermediate rootMap pointing at now-orphaned arrays. Re-walking + // the final merged targets rebinds each encountered sentinel's + // `parent` to the current array (see + // ProjectNameInNodePropsManager#processInputs / processDependsOn). + nodesManager.registerNameRefs(intermediateDefaultRootMap); + + // Overlay default-plugin attribution onto the main source maps using + // "only fill missing" semantics. Any key already present in + // configurationSourceMaps was written by a specified plugin or by + // target defaults, and that attribution is strictly more correct: + // - For fields the default plugin never shadowed, the existing entry + // already matches what the default plugin would overlay. + // - For fields where a default plugin placed `...` after other keys, + // those keys yielded to the base during the single-layer apply + // above. The stale default-plugin entry in + // `defaultConfigurationSourceMaps` must NOT clobber the base + // attribution that the specified plugin / TD already recorded. + for (const root in defaultConfigurationSourceMaps) { + const existing = (configurationSourceMaps[root] ??= {}); + const incoming = defaultConfigurationSourceMaps[root]; + for (const key in incoming) { + if (existing[key] === undefined) { + existing[key] = incoming[key]; } } } diff --git a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts index ff4b34644d918..f881f809a35e5 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.spec.ts @@ -159,14 +159,14 @@ describe('ProjectNameInNodePropsManager', () => { it('should handle empty plugin results without errors', () => { const manager = createManager(); - manager.registerSubstitutorsForNodeResults({}); + manager.registerNameRefs({}); manager.applySubstitutions({}); // No error should be thrown }); it('should handle undefined plugin results', () => { const manager = createManager(); - manager.registerSubstitutorsForNodeResults(undefined); + manager.registerNameRefs(undefined); // No error should be thrown }); }); @@ -189,7 +189,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Simulate project B's name being changed renameProject(manager, 'libs/b', 'project-b-renamed'); @@ -227,7 +227,7 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Simulate both projects being renamed renameProject(manager, 'libs/b', 'new-b'); @@ -259,7 +259,7 @@ describe('ProjectNameInNodePropsManager', () => { const initialResult = createPluginResult([projectAInitial]); identifyProjects(manager, initialResult); - manager.registerSubstitutorsForNodeResults(initialResult); + manager.registerNameRefs(initialResult); const projectAUpdated = createProject('project-a', 'libs/a', { targets: { @@ -269,7 +269,7 @@ describe('ProjectNameInNodePropsManager', () => { const updatedResult = createPluginResult([projectAUpdated]); identifyProjects(manager, updatedResult); - manager.registerSubstitutorsForNodeResults(updatedResult); + manager.registerNameRefs(updatedResult); renameProject(manager, 'libs/c', 'renamed-c'); const rootMap = createRootMap([ @@ -300,7 +300,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Even if we mark this dirty, 'self' should not be substituted renameProject(manager, 'libs/a', 'renamed-a'); @@ -327,7 +327,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -358,7 +358,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'project-b-renamed'); const rootMap = createRootMap([ @@ -392,7 +392,7 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'new-b'); renameProject(manager, 'libs/c', 'new-c'); @@ -422,7 +422,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -446,7 +446,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -470,7 +470,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -496,7 +496,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'new-b'); const rootMap = createRootMap([ @@ -525,7 +525,7 @@ describe('ProjectNameInNodePropsManager', () => { const initialResult = createPluginResult([projectAInitial]); identifyProjects(manager, initialResult); - manager.registerSubstitutorsForNodeResults(initialResult); + manager.registerNameRefs(initialResult); const projectAUpdated = createProject('project-a', 'libs/a', { targets: { @@ -535,7 +535,7 @@ describe('ProjectNameInNodePropsManager', () => { const updatedResult = createPluginResult([projectAUpdated]); identifyProjects(manager, updatedResult); - manager.registerSubstitutorsForNodeResults(updatedResult); + manager.registerNameRefs(updatedResult); renameProject(manager, 'libs/c', 'renamed-c'); const rootMap = createRootMap([ @@ -557,20 +557,26 @@ describe('ProjectNameInNodePropsManager', () => { it('should substitute references for targets expanded from glob keys', () => { const manager = createManager(); + // The real merge expands glob target names into concrete keys while + // preserving element identity — the expanded target's dependsOn + // array shares element references with the glob target's source + // array. We simulate that here so the sentinel the manager inserts + // on the plugin result is visible through the expanded key too. + const globTarget = createTargetWithDependsOn('project-b'); const projectA = createProject('project-a', 'libs/a', { targets: { - 'build-*': createTargetWithDependsOn('project-b'), + 'build-*': globTarget, }, }); const projectB = createProject('project-b', 'libs/b'); const globResult = createPluginResult([projectA, projectB]); identifyProjects(manager, globResult); - manager.registerSubstitutorsForNodeResults(globResult); + manager.registerNameRefs(globResult); renameProject(manager, 'libs/b', 'renamed-b'); const expandedTargets = { - 'build-prod': createTargetWithDependsOn('project-b'), + 'build-prod': globTarget, }; const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: expandedTargets }, @@ -604,7 +610,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'project-b-renamed'); const rootMap = createRootMap([ @@ -633,7 +639,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -659,7 +665,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -696,7 +702,7 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'new-b'); renameProject(manager, 'libs/c', 'new-c'); @@ -729,9 +735,9 @@ describe('ProjectNameInNodePropsManager', () => { const projBResult = createPluginResult([projB]); identifyProjects(manager, projAResult); - manager.registerSubstitutorsForNodeResults(projAResult); + manager.registerNameRefs(projAResult); identifyProjects(manager, projBResult); - manager.registerSubstitutorsForNodeResults(projBResult); + manager.registerNameRefs(projBResult); renameProject(manager, 'proj-b', 'project-b-renamed'); const rootMap = createRootMap([ @@ -766,7 +772,7 @@ describe('ProjectNameInNodePropsManager', () => { const quotedResult = createPluginResult([scopedPkg, projectA]); identifyProjects(manager, quotedResult); - manager.registerSubstitutorsForNodeResults(quotedResult); + manager.registerNameRefs(quotedResult); renameProject(manager, 'libs/scoped', 'new-pkg'); const rootMap = createRootMap([ @@ -798,7 +804,7 @@ describe('ProjectNameInNodePropsManager', () => { const colonResult = createPluginResult([projectA, projectB]); identifyProjects(manager, colonResult); - manager.registerSubstitutorsForNodeResults(colonResult); + manager.registerNameRefs(colonResult); renameProject(manager, 'libs/b', '@scope:new-b'); const rootMap = createRootMap([ @@ -833,7 +839,7 @@ describe('ProjectNameInNodePropsManager', () => { const result = createPluginResult([projectAB, projectOwner]); identifyProjects(manager, result); - manager.registerSubstitutorsForNodeResults(result); + manager.registerNameRefs(result); renameProject(manager, 'libs/ab', 'new-ab'); const rootMap = createRootMap([ @@ -869,7 +875,7 @@ describe('ProjectNameInNodePropsManager', () => { const colonLeadingResult = createPluginResult([colonPkg, projectA]); identifyProjects(manager, colonLeadingResult); - manager.registerSubstitutorsForNodeResults(colonLeadingResult); + manager.registerNameRefs(colonLeadingResult); renameProject(manager, 'libs/pkg', 'renamed-pkg'); const rootMap = createRootMap([ @@ -889,23 +895,26 @@ describe('ProjectNameInNodePropsManager', () => { targets: { compile: {} }, }); + // Glob target expansion during merge preserves the underlying + // target object — the expanded key points at the same object so + // sentinels inserted on the glob target are visible through the + // expansion. + const globTarget: { dependsOn: unknown[] } = { + dependsOn: ['project-b:compile'], + }; const projectA = createProject('project-a', 'libs/a', { targets: { - 'build-*': { - dependsOn: ['project-b:compile'], - }, + 'build-*': globTarget, }, }); const globStringResult = createPluginResult([projectB, projectA]); identifyProjects(manager, globStringResult); - manager.registerSubstitutorsForNodeResults(globStringResult); + manager.registerNameRefs(globStringResult); renameProject(manager, 'libs/b', 'renamed-b'); const expandedTargets = { - 'build-prod': { - dependsOn: ['project-b:compile'], - }, + 'build-prod': globTarget, }; const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: expandedTargets }, @@ -954,9 +963,9 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, projAResult); - manager.registerSubstitutorsForNodeResults(projAResult); + manager.registerNameRefs(projAResult); identifyProjects(manager, projBResult); - manager.registerSubstitutorsForNodeResults(projBResult); + manager.registerNameRefs(projBResult); renameProject(manager, 'proj-b', 'project-b-renamed'); manager.applySubstitutions(projectRootMap); @@ -979,7 +988,7 @@ describe('ProjectNameInNodePropsManager', () => { const firstPluginResult = createPluginResult([projectA, projectB]); identifyProjects(manager, firstPluginResult); - manager.registerSubstitutorsForNodeResults(firstPluginResult); + manager.registerNameRefs(firstPluginResult); // Second plugin creates projects C and D, where C references D const projectC = createProject('project-c', 'libs/c', { @@ -992,7 +1001,7 @@ describe('ProjectNameInNodePropsManager', () => { const secondPluginResult = createPluginResult([projectC, projectD]); identifyProjects(manager, secondPluginResult); - manager.registerSubstitutorsForNodeResults(secondPluginResult); + manager.registerNameRefs(secondPluginResult); // Mark both B and D as dirty renameProject(manager, 'libs/b', 'renamed-b'); @@ -1019,7 +1028,7 @@ describe('ProjectNameInNodePropsManager', () => { const projectB = createProject('project-b', 'libs/b'); const firstPluginResult = createPluginResult([projectB]); identifyProjects(manager, firstPluginResult); - manager.registerSubstitutorsForNodeResults(firstPluginResult); + manager.registerNameRefs(firstPluginResult); // Second plugin creates project A that references B (which is in merged configs) const projectA = createProject('project-a', 'libs/a', { @@ -1030,7 +1039,7 @@ describe('ProjectNameInNodePropsManager', () => { const secondPluginResult = createPluginResult([projectA]); identifyProjects(manager, secondPluginResult); - manager.registerSubstitutorsForNodeResults(secondPluginResult); + manager.registerNameRefs(secondPluginResult); // Mark B as dirty renameProject(manager, 'libs/b', 'renamed-b'); @@ -1066,12 +1075,12 @@ describe('ProjectNameInNodePropsManager', () => { // First call const firstResult = createPluginResult([projectA, sharedLib]); identifyProjects(manager, firstResult); - manager.registerSubstitutorsForNodeResults(firstResult); + manager.registerNameRefs(firstResult); // Second call const secondResult = createPluginResult([projectB]); identifyProjects(manager, secondResult); - manager.registerSubstitutorsForNodeResults(secondResult); + manager.registerNameRefs(secondResult); // Rename shared-lib renameProject(manager, 'libs/shared', 'renamed-shared'); @@ -1127,7 +1136,7 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Rename all referenced projects renameProject(manager, 'libs/b', 'new-b'); @@ -1174,7 +1183,7 @@ describe('ProjectNameInNodePropsManager', () => { const projectB = createProject('project-b-original', 'libs/b'); const renameResult1 = createPluginResult([projectA, projectB]); identifyProjects(manager, renameResult1); - manager.registerSubstitutorsForNodeResults(renameResult1); + manager.registerNameRefs(renameResult1); // Plugin 2: renames project-b to intermediate and project-c references it const projectBIntermediate = createProject( @@ -1191,7 +1200,7 @@ describe('ProjectNameInNodePropsManager', () => { projectC, ]); identifyProjects(manager, renameResult2); - manager.registerSubstitutorsForNodeResults(renameResult2); + manager.registerNameRefs(renameResult2); // project-b-original -> project-b-intermediate -> project-b-final renameProject(manager, 'libs/b', 'project-b-intermediate'); @@ -1225,7 +1234,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Rename both projects renameProject(manager, 'libs/a', 'renamed-a'); @@ -1263,7 +1272,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult(projects); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Mark all as dirty for (let i = 0; i < projectCount; i++) { @@ -1306,7 +1315,7 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); // Only rename B — C stays the same renameProject(manager, 'libs/b', 'renamed-b'); @@ -1336,7 +1345,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([{ name: 'renamed-a', root: 'libs/a' }]); @@ -1360,7 +1369,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -1384,9 +1393,7 @@ describe('ProjectNameInNodePropsManager', () => { const malformedResult = createPluginResult([projectA, projectB]); identifyProjects(manager, malformedResult); - expect(() => - manager.registerSubstitutorsForNodeResults(malformedResult) - ).not.toThrow(); + expect(() => manager.registerNameRefs(malformedResult)).not.toThrow(); renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ @@ -1419,7 +1426,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ @@ -1459,7 +1466,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectB]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ @@ -1491,7 +1498,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -1517,7 +1524,7 @@ describe('ProjectNameInNodePropsManager', () => { // This should not throw, since the referenced project doesn't exist // No substitutor will be registered for it identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); const rootMap = createRootMap([ { name: 'project-a', root: 'libs/a', targets: projectA.targets }, @@ -1544,7 +1551,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/a', 'renamed-a'); const rootMap = createRootMap([ @@ -1569,7 +1576,7 @@ describe('ProjectNameInNodePropsManager', () => { ); const existingResult = createPluginResult([existingProject]); identifyProjects(manager, existingResult); - manager.registerSubstitutorsForNodeResults(existingResult); + manager.registerNameRefs(existingResult); // Plugin 2: Project A references existing-project (from a different plugin result) const projectA = createProject('project-a', 'libs/a', { @@ -1579,7 +1586,7 @@ describe('ProjectNameInNodePropsManager', () => { }); const projectAResult = createPluginResult([projectA]); identifyProjects(manager, projectAResult); - manager.registerSubstitutorsForNodeResults(projectAResult); + manager.registerNameRefs(projectAResult); renameProject(manager, 'libs/existing', 'renamed-existing'); @@ -1611,7 +1618,7 @@ describe('ProjectNameInNodePropsManager', () => { const pluginResultProjects = createPluginResult([projectA, projectBNew]); identifyProjects(manager, pluginResultProjects); - manager.registerSubstitutorsForNodeResults(pluginResultProjects); + manager.registerNameRefs(pluginResultProjects); renameProject(manager, 'libs/b-new', 'renamed-b'); const rootMap = createRootMap([ @@ -1660,9 +1667,9 @@ describe('ProjectNameInNodePropsManager', () => { const plugin2Result = createPluginResult([renamedA, newA, projectC]); identifyProjects(manager, plugin1Result); - manager.registerSubstitutorsForNodeResults(plugin1Result); + manager.registerNameRefs(plugin1Result); identifyProjects(manager, plugin2Result); - manager.registerSubstitutorsForNodeResults(plugin2Result); + manager.registerNameRefs(plugin2Result); // The merge phase detected that libs/a changed from "A" to "B" renameProject(manager, 'libs/a', 'B'); @@ -1699,9 +1706,9 @@ describe('ProjectNameInNodePropsManager', () => { const plugin2Result = createPluginResult([renamedA, newA, projectC]); identifyProjects(manager, plugin1Result); - manager.registerSubstitutorsForNodeResults(plugin1Result); + manager.registerNameRefs(plugin1Result); identifyProjects(manager, plugin2Result); - manager.registerSubstitutorsForNodeResults(plugin2Result); + manager.registerNameRefs(plugin2Result); renameProject(manager, 'libs/a', 'B'); @@ -1742,9 +1749,9 @@ describe('ProjectNameInNodePropsManager', () => { ]); identifyProjects(manager, plugin1Result); - manager.registerSubstitutorsForNodeResults(plugin1Result); + manager.registerNameRefs(plugin1Result); identifyProjects(manager, plugin2Result); - manager.registerSubstitutorsForNodeResults(plugin2Result); + manager.registerNameRefs(plugin2Result); renameProject(manager, 'libs/a', 'B'); @@ -1802,12 +1809,12 @@ describe('ProjectNameInNodePropsManager', () => { // First plugin result: root project with name "nx" const plugin1Result = createPluginResult([rootProject]); identifyProjects(manager, plugin1Result); - manager.registerSubstitutorsForNodeResults(plugin1Result); + manager.registerNameRefs(plugin1Result); // Second plugin result: devkit with the colon target const plugin2Result = createPluginResult([nxProject, devkit]); identifyProjects(manager, plugin2Result); - manager.registerSubstitutorsForNodeResults(plugin2Result); + manager.registerNameRefs(plugin2Result); // Root project renamed from "nx" to "@nx/nx-source" renameProject(manager, 'root-dir', '@nx/nx-source'); @@ -1913,13 +1920,13 @@ describe('ProjectNameInNodePropsManager', () => { 'libs/java/split-client', ':libs:java:split-client' ); - manager.registerSubstitutorsForNodeResults(plugin1Result); + manager.registerNameRefs(plugin1Result); // Plugin 2 renames all projects — identify new names, then register renameProject(manager, 'apps/ovm-compactor', 'ovm-compactor'); renameProject(manager, 'libs/java/kafka-stream', 'kafka-stream'); renameProject(manager, 'libs/java/split-client', 'split-client'); - manager.registerSubstitutorsForNodeResults(plugin2Result); + manager.registerNameRefs(plugin2Result); const rootMap = createRootMap([ { @@ -1964,7 +1971,7 @@ describe('ProjectNameInNodePropsManager', () => { const crossProjectResult = createPluginResult([projectA, projectB]); identifyProjects(manager, crossProjectResult); - manager.registerSubstitutorsForNodeResults(crossProjectResult); + manager.registerNameRefs(crossProjectResult); renameProject(manager, 'libs/b', 'renamed-b'); const rootMap = createRootMap([ @@ -1978,4 +1985,207 @@ describe('ProjectNameInNodePropsManager', () => { expect(projectA.targets.build.dependsOn[0]).toBe('renamed-b:compile'); }); }); + + describe('spread tokens in arrays with cross-project references', () => { + // The sentinel-based approach survives spread expansion because the + // element carrying the sentinel is the same *object* in both the raw + // plugin-result array and the post-merge array — spread builds the + // new array by pushing element references, not by cloning. The + // sentinel's back-reference points at that shared element, so the + // index it ends up at after expansion is immaterial. + it('should substitute a project reference that sits after an expanded spread in dependsOn', () => { + const manager = createManager(); + + // Base plugin seeds dependsOn with two entries. + const basePluginResult = createPluginResult([ + createProject('project-a', 'libs/a', { + targets: { + build: { + dependsOn: ['^build', '^test'], + }, + }, + }), + ]); + identifyProjects(manager, basePluginResult); + manager.registerNameRefs(basePluginResult); + + // Later plugin contributes a dependsOn with a leading spread followed + // by a cross-project ref. The cross-project ref is a shared element + // reference — the same object lives in both the raw plugin result + // and the simulated post-merge array below, matching what + // getMergeValueResult produces at runtime. + const projectBRef: { target: string; projects: unknown } = { + target: 'build', + projects: 'project-b', + }; + const projectA = createProject('project-a', 'libs/a', { + targets: { + build: { + dependsOn: ['...', projectBRef], + }, + }, + }); + const projectB = createProject('project-b', 'libs/b'); + + const spreadPluginResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, spreadPluginResult); + manager.registerNameRefs(spreadPluginResult); + + renameProject(manager, 'libs/b', 'project-b-renamed'); + + // Simulate the post-merge rootMap — dependsOn has the spread + // expanded, with the cross-project entry shifted to a later index. + // The element identity is preserved so the sentinel reaches it. + const mergedDependsOn: Array = ['^build', '^test', projectBRef]; + const rootMap = createRootMap([ + { + name: 'project-a', + root: 'libs/a', + targets: { build: { dependsOn: mergedDependsOn } }, + }, + { name: 'project-b-renamed', root: 'libs/b' }, + ]); + + manager.applySubstitutions(rootMap); + + // The ref now lives at index 2 post-expansion and its `projects` + // field is rewritten to the new name even though the substitutor + // was never told about the new index. + expect(mergedDependsOn[2]).toEqual({ + target: 'build', + projects: 'project-b-renamed', + }); + // And nothing should have been written to the string entries that + // now occupy the indices the sentinel was originally reachable at. + expect(mergedDependsOn[0]).toBe('^build'); + expect(mergedDependsOn[1]).toBe('^test'); + }); + + // Regression for string-form dependsOn sentinels specifically: + // because the sentinel replaces a primitive *slot*, it lives + // directly as an array element rather than inside a stable wrapper + // object. When a later plugin contributes a dependsOn with a spread + // token, getMergeValueResult produces a fresh array and pushes the + // sentinel reference into it. If `processDependsOn` doesn't rebind + // the sentinel's parent back-ref when it re-encounters the ref in + // the fresh array, applySubstitutions writes the replacement to the + // now-orphaned original array and the final dependsOn still holds + // the internal sentinel object. + it('should rebind a string-form dependsOn sentinel after a later spread merge copies it into a fresh array', () => { + const manager = createManager(); + + // First plugin provides a base dependsOn with one cross-project + // target-string entry. + const baseProjectA = createProject('project-a', 'libs/a', { + targets: { + build: { + dependsOn: ['project-b:build'], + }, + }, + }); + const projectB = createProject('project-b', 'libs/b'); + const baseResult = createPluginResult([baseProjectA, projectB]); + identifyProjects(manager, baseResult); + manager.registerNameRefs(baseResult); + + // The sentinel is now inline in baseProjectA's dependsOn array — + // element 0 is no longer a string; it is a NameRef whose parent + // points at that array. + const baseDependsOn = baseProjectA.targets.build.dependsOn as unknown[]; + + // Simulate what getMergeValueResult does for a spread merge: + // build a fresh array that pushes the base entry (the sentinel) + // followed by a new entry from a later plugin. Element identity + // is preserved. + const mergedDependsOn: unknown[] = [baseDependsOn[0], '^build']; + const mergedProjectA = { + name: 'project-a', + root: 'libs/a', + targets: { build: { dependsOn: mergedDependsOn } }, + } as unknown as Record; + const mergedResult = createPluginResult([mergedProjectA]); + + // Re-registering against the merged form must notice the existing + // sentinel and rebind its parent to `mergedDependsOn`. + identifyProjects(manager, mergedResult); + manager.registerNameRefs(mergedResult); + + renameProject(manager, 'libs/b', 'renamed-b'); + + const rootMap = createRootMap([ + { + name: 'project-a', + root: 'libs/a', + targets: mergedProjectA.targets, + }, + { name: 'renamed-b', root: 'libs/b' }, + ]); + + manager.applySubstitutions(rootMap); + + expect(mergedDependsOn[0]).toBe('renamed-b:build'); + expect(mergedDependsOn[1]).toBe('^build'); + // And nothing left behind that looks like an internal sentinel. + expect(typeof mergedDependsOn[0]).toBe('string'); + }); + + // Regression: getMergeValueResult pushes element *references* when it + // expands a spread token, so a later dependsOn of the form + // `['...', '...']` concatenates the base array twice and the same + // sentinel object ends up at multiple indices. applySubstitutions + // used to walk only the first `indexOf(ref)` match, leaving the + // duplicate slot holding the internal sentinel object in the final + // configuration. + it('should replace every occurrence when the same sentinel is copied into multiple array slots', () => { + const manager = createManager(); + + const projectA = createProject('project-a', 'libs/a', { + targets: { + build: { + dependsOn: ['project-b:build'], + }, + }, + }); + const projectB = createProject('project-b', 'libs/b'); + const baseResult = createPluginResult([projectA, projectB]); + identifyProjects(manager, baseResult); + manager.registerNameRefs(baseResult); + + // After registration, dependsOn[0] is a sentinel pointing at + // libs/b. Simulate `getMergeValueResult` for a later + // `['...', '...']` that doubles the base into a fresh array — + // the same sentinel reference lands at indices 0 and 1. + const baseDependsOn = projectA.targets.build.dependsOn as unknown[]; + const sentinel = baseDependsOn[0]; + const mergedDependsOn: unknown[] = [sentinel, sentinel]; + const mergedProjectA = { + name: 'project-a', + root: 'libs/a', + targets: { build: { dependsOn: mergedDependsOn } }, + } as unknown as Record; + + identifyProjects(manager, createPluginResult([mergedProjectA])); + manager.registerNameRefs(createPluginResult([mergedProjectA])); + + renameProject(manager, 'libs/b', 'renamed-b'); + + const rootMap = createRootMap([ + { + name: 'project-a', + root: 'libs/a', + targets: mergedProjectA.targets, + }, + { name: 'renamed-b', root: 'libs/b' }, + ]); + + manager.applySubstitutions(rootMap); + + // Both slots must be resolved — neither should still hold the + // sentinel object. + expect(mergedDependsOn[0]).toBe('renamed-b:build'); + expect(mergedDependsOn[1]).toBe('renamed-b:build'); + expect(typeof mergedDependsOn[0]).toBe('string'); + expect(typeof mergedDependsOn[1]).toBe('string'); + }); + }); }); diff --git a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts index 294535616aac1..24d8f70b3613c 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/name-substitution-manager.ts @@ -1,351 +1,93 @@ -import { ProjectConfiguration } from '../../../config/workspace-json-project-json'; +import { + InputDefinition, + ProjectConfiguration, + TargetConfiguration, + TargetDependencyConfig, +} from '../../../config/workspace-json-project-json'; import { isGlobPattern } from '../../../utils/globs'; import { splitTargetFromConfigurations } from '../../../utils/split-target'; -import { minimatch } from 'minimatch'; - -// A substitutor receives the final resolved name of the project that was -// renamed, and the final merged configuration of the project that *holds -// the stale reference* so it can update the appropriate field in-place. -type ProjectNameSubstitutor = ( - finalName: string, - ownerProjectConfig: ProjectConfiguration -) => void; - -// Pairs a substitutor with the root of the project whose configuration it -// will update. Stored together so applySubstitutions can look up -// rootMap[ownerRoot] without passing the full map into each substitutor. -type SubstitutorEntry = { - ownerRoot: string; - substitutor: ProjectNameSubstitutor; -}; - -// Tracking item stored in substitutorsByArrayKey. Associates a substitutor -// entry with the root (or pending name) key so it can be evicted when -// overwritten. When `keyedByRoot` is true the item lives in -// `substitutorsByReferencedRoot`; otherwise it lives in -// `pendingSubstitutorsByName`. -type TrackingItem = { - referencedRoot?: string; - referencedName?: string; - entry: SubstitutorEntry; -}; + +type InputEntry = string | InputDefinition | NameRef; +type DependsOnEntry = string | TargetDependencyConfig | NameRef; +type ProjectsEntry = string | NameRef; /** - * Manages deferred project name substitutions across the plugin result - * merge phase of project graph construction. - * - * ### Why this exists - * - * When plugins return `createNodes` results, a node `A` may declare a - * `dependsOn` or `inputs` entry that references another project `B` by - * name. A *later* plugin is allowed to rename project `B` to `C`. After - * all plugin results are merged into the root map, node `A` would still - * hold a stale reference to the now-nonexistent name `B`. - * - * This class solves that by: - * 1. Receiving a live nameMap accessor (maintained by ProjectNodesManager) - * for name → root resolution and colon-delimited string parsing. - * 2. Tracking dirty roots via {@link identifyProjectWithRoot} when a - * project name changes at a root. - * 3. Scanning each plugin's results for project-name references in - * `inputs` and `dependsOn` ({@link registerSubstitutorsForNodeResults}). - * 4. After all results are merged, applying the substitutors for every - * renamed project so that references are updated to the final name - * ({@link applySubstitutions}). + * Sentinel placed in `inputs` / `dependsOn` for a pending project-name + * reference. `RootRef` carries the referenced project's root (resolved + * via nameMap lookup); `UsageRef` carries the raw written name (for + * forward refs, promoted to `RootRef` in place when the name is + * identified). `parent` + `key` let the final pass write the resolved + * name back; `targetPart` preserves the `:target` suffix from + * `dependsOn` strings. */ -export class ProjectNameInNodePropsManager { - // Maps the *root of the referenced project* → set of substitutor entries - // that should run when that project is renamed. Keying by root (not name) - // ensures that when project "A" is renamed to "B" and a *new* project - // takes the name "A" at a different root, references to the new "A" are - // not incorrectly rewritten. - private substitutorsByReferencedRoot = new Map< - string, - Set - >(); - - // Tracks substitutor entries by (array path, index, subIndex). This - // serves two purposes: - // - // 1. Per-index deduplication: if the same array index is registered - // again (e.g. two plugin results contribute to the same position), - // the old substitutor is evicted before the new one is added. - // - // 2. Tail-clearing: when a later plugin provides a shorter array at the - // same path, splice removes all tail entries in one call. - // - // Outer key: array path (e.g. "proj-a:targets.build.inputs") - // Inner array: indexed by position; each slot holds an array so that a - // single `projects` array can hold multiple name references. - private substitutorsByArrayKey = new Map< - string, - Array | undefined> - >(); - - // Holds substitutors for project names that haven't been identified yet - // (forward references). When identifyProjectWithRoot is later called for - // a name in this map, the entries are promoted to substitutorsByReferencedRoot. - private pendingSubstitutorsByName = new Map>(); - - // Roots of projects whose names changed during the merge phase. - private dirtyRoots = new Set(); - - // Lazy accessor for the name-keyed project configuration map maintained - // by the owning ProjectNodesManager. Points to the same object references - // as the rootMap, so targets are always up-to-date without manual copying. - private getNameMap: () => Record; - - constructor(getNameMap?: () => Record) { - this.getNameMap = getNameMap ?? (() => ({})); - } - - private removeSubstitutorEntry(item: TrackingItem) { - if (item.referencedRoot !== undefined) { - const substitutors = this.substitutorsByReferencedRoot.get( - item.referencedRoot - ); - if (substitutors) { - substitutors.delete(item.entry); - if (substitutors.size === 0) { - this.substitutorsByReferencedRoot.delete(item.referencedRoot); - } - } - } - if (item.referencedName !== undefined) { - const substitutors = this.pendingSubstitutorsByName.get( - item.referencedName - ); - if (substitutors) { - substitutors.delete(item.entry); - if (substitutors.size === 0) { - this.pendingSubstitutorsByName.delete(item.referencedName); - } - } - } - } - - // Removes the substitutor registered at the given index (and optional - // subIndex) of an array, if any. Used when re-registering for the same - // position (overwritten by a later plugin). - private clearSubstitutorAtIndex( - arrayKey: string, - index: number, - subIndex?: number - ) { - const byIndex = this.substitutorsByArrayKey.get(arrayKey); - const atIndex = byIndex?.[index]; - if (!atIndex) { - return; - } - if (subIndex === undefined) { - // Clear the entire index entry (single project reference) - for (const item of atIndex) { - if (item) { - this.removeSubstitutorEntry(item); - } - } - byIndex[index] = undefined; - } else { - // Clear only the specific subIndex (within a projects array) - const existing = atIndex[subIndex]; - if (existing) { - this.removeSubstitutorEntry(existing); - atIndex[subIndex] = undefined; - } - } - } - - // Removes all substitutors at indices >= `fromIndex` for the given array - // path. Uses splice so the tail is dropped in one operation. - private clearSubstitutorsFromIndex(arrayKey: string, fromIndex: number) { - const byIndex = this.substitutorsByArrayKey.get(arrayKey); - if (!byIndex) { - return; - } - const removed = byIndex.splice(fromIndex); - for (const atIndex of removed) { - if (atIndex) { - for (const item of atIndex) { - if (item) { - this.removeSubstitutorEntry(item); - } - } - } - } - } - - // Removes all substitutors at sub-indices >= `fromSubIndex` for one - // specific array index of the given array key. - private clearSubstitutorsFromSubIndex( - arrayKey: string, - index: number, - fromSubIndex: number - ) { - const byIndex = this.substitutorsByArrayKey.get(arrayKey); - const atIndex = byIndex?.[index]; - if (!atIndex) { - return; - } - - const removed = atIndex.splice(fromSubIndex); - for (const item of removed) { - if (item) { - this.removeSubstitutorEntry(item); - } - } - - let hasAnyItem = false; - for (const item of atIndex) { - if (item) { - hasAnyItem = true; - break; - } - } - - if (!hasAnyItem) { - byIndex[index] = undefined; - } - } - - private forEachTargetConfig( - ownerConfig: ProjectConfiguration, - targetName: string, - callback: ( - targetConfig: NonNullable - ) => void - ) { - const ownerTargets = ownerConfig.targets; - if (!ownerTargets) { - return; - } - - const exactMatch = ownerTargets[targetName]; - if (exactMatch && typeof exactMatch === 'object') { - callback(exactMatch); - return; - } - - if (!isGlobPattern(targetName)) { - return; - } +export abstract class NameRef { + constructor( + public value: string, + public parent: unknown, + public key: string | undefined, + public targetPart: string | undefined + ) {} +} - for (const candidateTargetName in ownerTargets) { - if (!minimatch(candidateTargetName, targetName)) { - continue; - } +export class RootRef extends NameRef {} +export class UsageRef extends NameRef {} - const targetConfig = ownerTargets[candidateTargetName]; - if (!targetConfig || typeof targetConfig !== 'object') { - continue; - } +export function isNameRef(value: unknown): value is NameRef { + return value instanceof NameRef; +} - callback(targetConfig); - } - } +export function isRootRef(value: unknown): value is RootRef { + return value instanceof RootRef; +} - // Registers a new substitutor for `referencedName`, tracked at - // (arrayKey, index, subIndex) for deduplication and tail-clearing. - // The substitutor is keyed by root when the referenced project is - // already in the nameMap, otherwise parked in pendingSubstitutorsByName. - private registerProjectNameSubstitutor( - referencedName: string, - ownerRoot: string, - arrayKey: string, - index: number, - substitutor: ProjectNameSubstitutor, - subIndex?: number - ) { - // Evict any existing substitutor at this exact position first. - this.clearSubstitutorAtIndex(arrayKey, index, subIndex); - - const entry: SubstitutorEntry = { ownerRoot, substitutor }; - const nameMap = this.getNameMap(); - const referencedRoot = nameMap[referencedName]?.root; - - let trackingItem: TrackingItem; - if (referencedRoot !== undefined) { - // Project is already known — key directly by root. - let substitutorsForRoot = - this.substitutorsByReferencedRoot.get(referencedRoot); - if (!substitutorsForRoot) { - substitutorsForRoot = new Set(); - this.substitutorsByReferencedRoot.set( - referencedRoot, - substitutorsForRoot - ); - } - substitutorsForRoot.add(entry); - trackingItem = { referencedRoot, entry }; - } else { - // Forward reference — park in pending map keyed by name. - let pendingSet = this.pendingSubstitutorsByName.get(referencedName); - if (!pendingSet) { - pendingSet = new Set(); - this.pendingSubstitutorsByName.set(referencedName, pendingSet); - } - pendingSet.add(entry); - trackingItem = { referencedName, entry }; - } +export function isUsageRef(value: unknown): value is UsageRef { + return value instanceof UsageRef; +} - let byIndex = this.substitutorsByArrayKey.get(arrayKey); - if (!byIndex) { - byIndex = []; - this.substitutorsByArrayKey.set(arrayKey, byIndex); - } +/** + * Replaces project-name refs in plugin results with in-place sentinels, + * then resolves them after all merging is done. + * + * Tracking by array position breaks once `'...'` spreads shuffle indices, + * so each ref becomes a sentinel object. Arrays spread-merge by pushing + * element references, so sentinel identity survives any downstream + * merges — the final pass walks a flat registry and writes the resolved + * name back through each sentinel's `parent` back-reference. Orphaned + * sentinels (from arrays dropped by a full-replace) write harmlessly. + */ +export class ProjectNameInNodePropsManager { + private getNameMap: () => Record; + private allRefs = new Set(); + private pendingByName = new Map>(); - if (subIndex === undefined) { - byIndex[index] = [trackingItem]; - } else { - if (!byIndex[index]) { - byIndex[index] = []; - } - const subArray = byIndex[index] as Array; - subArray[subIndex] = trackingItem; - } + constructor(getNameMap?: () => Record) { + this.getNameMap = getNameMap ?? (() => ({})); } - /** - * Scans `pluginResultProjects` for `inputs` and `dependsOn` entries that - * reference another project by name, and registers substitutors so those - * references are updated if the target project is later renamed. - * - * **Important**: call {@link identifyProjectWithRoot} for all projects in - * this result (and all prior results) before calling this method, so that - * referenced project names can be resolved to roots. - * - * @param pluginResultProjects Projects from a single plugin's createNodes call. - */ - registerSubstitutorsForNodeResults( + // Replaces each project-name ref in `inputs`/`dependsOn` with a sentinel. + // Call after `identifyProjectWithRoot` for the batch so same-batch forward + // refs resolve straight to RootRefs. + registerNameRefs( pluginResultProjects?: Record< string, Omit & Partial > - ) { - if (!pluginResultProjects) { - return; - } + ): void { + if (!pluginResultProjects) return; for (const ownerRoot in pluginResultProjects) { const project = pluginResultProjects[ownerRoot]; - if (!project.targets) { - continue; - } + if (!project?.targets) continue; + for (const targetName in project.targets) { const targetConfig = project.targets[targetName]; - if (!targetConfig || typeof targetConfig !== 'object') { - continue; - } + if (!targetConfig || typeof targetConfig !== 'object') continue; + if (Array.isArray(targetConfig.inputs)) { - this.registerSubstitutorsForInputs( - ownerRoot, - targetName, - targetConfig.inputs - ); + this.processInputs(targetConfig.inputs); } if (Array.isArray(targetConfig.dependsOn)) { - this.registerSubstitutorsForDependsOn( - ownerRoot, - targetName, + this.processDependsOn( targetConfig.dependsOn, project.targets, project.name @@ -355,313 +97,192 @@ export class ProjectNameInNodePropsManager { } } - // Factory methods for creating substitutors. Using factory functions - // ensures that index variables (i, j) are captured as function parameters - // (always by value), preventing closure-over-loop-variable bugs. - - private createInputsStringSubstitutor( - targetName: string, - i: number - ): ProjectNameSubstitutor { - return (finalName, ownerConfig) => { - this.forEachTargetConfig(ownerConfig, targetName, (targetConfig) => { - const finalInput = targetConfig.inputs?.[i]; - if ( - finalInput && - typeof finalInput === 'object' && - 'projects' in finalInput - ) { - (finalInput as { projects: string }).projects = finalName; - } - }); - }; - } - - private createInputsArraySubstitutor( - targetName: string, - i: number, - j: number - ): ProjectNameSubstitutor { - return (finalName, ownerConfig) => { - this.forEachTargetConfig(ownerConfig, targetName, (targetConfig) => { - const finalInput = targetConfig.inputs?.[i]; - if ( - finalInput && - typeof finalInput === 'object' && - 'projects' in finalInput - ) { - (finalInput['projects'] as string[])[j] = finalName; - } - }); - }; - } - - private createDependsOnStringSubstitutor( - targetName: string, - i: number - ): ProjectNameSubstitutor { - return (finalName, ownerConfig) => { - this.forEachTargetConfig(ownerConfig, targetName, (targetConfig) => { - const finalDep = targetConfig.dependsOn?.[i]; - if ( - finalDep && - typeof finalDep === 'object' && - 'projects' in finalDep - ) { - (finalDep as { projects: string }).projects = finalName; - } - }); - }; - } - - private createDependsOnArraySubstitutor( - targetName: string, - i: number, - j: number - ): ProjectNameSubstitutor { - return (finalName, ownerConfig) => { - this.forEachTargetConfig(ownerConfig, targetName, (targetConfig) => { - const finalDep = targetConfig.dependsOn?.[i]; - if ( - finalDep && - typeof finalDep === 'object' && - 'projects' in finalDep - ) { - (finalDep['projects'] as string[])[j] = finalName; - } - }); - }; - } - - private createDependsOnTargetStringSubstitutor( - targetName: string, - i: number, - targetPart: string - ): ProjectNameSubstitutor { - return (finalName, ownerConfig) => { - this.forEachTargetConfig(ownerConfig, targetName, (targetConfig) => { - const finalDep = targetConfig.dependsOn?.[i]; - if (typeof finalDep === 'string') { - (targetConfig.dependsOn as (string | object)[])[i] = - `${finalName}:${targetPart}`; - } - }); - }; - } - - private registerSubstitutorsForInputs( - ownerRoot: string, - targetName: string, - inputs: NonNullable - ) { - const arrayKey = `${ownerRoot}:targets.${targetName}.inputs`; + private processInputs(inputs: InputEntry[]): void { for (let i = 0; i < inputs.length; i++) { - const input = inputs[i]; - if (typeof input !== 'object' || !('projects' in input)) { + const entry = inputs[i]; + // Existing sentinel: spread merges may have copied it out of its + // original array, so rebind parent to this one. + if (isNameRef(entry)) { + entry.parent = inputs; continue; } - const inputProjectNames = input['projects']; - if (typeof inputProjectNames === 'string') { - // `self` and `dependencies` are keywords, not project names. - if ( - inputProjectNames === 'self' || - inputProjectNames === 'dependencies' - ) { - continue; - } - this.registerProjectNameSubstitutor( - inputProjectNames, - ownerRoot, - arrayKey, - i, - this.createInputsStringSubstitutor(targetName, i) - ); - } else if (Array.isArray(inputProjectNames)) { - for (let j = 0; j < inputProjectNames.length; j++) { - const projectName = inputProjectNames[j]; - this.registerProjectNameSubstitutor( - projectName, - ownerRoot, - arrayKey, - i, - this.createInputsArraySubstitutor(targetName, i, j), - j // subIndex for array elements - ); - } - // Clear stale sub-indices if a later plugin shrinks the array. - this.clearSubstitutorsFromSubIndex( - arrayKey, - i, - inputProjectNames.length - ); + if (!entry || typeof entry !== 'object') continue; + if (!('projects' in entry)) continue; + const element = entry as { projects: unknown }; + const projects = element.projects; + + if (isNameRef(projects)) { + // Object-parent sentinel — element identity is stable across spread. + continue; + } + if (typeof projects === 'string') { + if (projects === 'self' || projects === 'dependencies') continue; + element.projects = this.createRef(projects, element, 'projects'); + } else if (Array.isArray(projects)) { + this.processProjectsArray(projects); } } - // Evict any dangling substitutors at indices beyond the new array length — - // the array may have shrunk compared to a previous plugin's contribution. - this.clearSubstitutorsFromIndex(arrayKey, inputs.length); } - private registerSubstitutorsForDependsOn( - ownerRoot: string, - targetName: string, - dependsOn: NonNullable< - ProjectConfiguration['targets'][string]['dependsOn'] - >, - ownerTargets?: Record, - ownerProjectName?: string - ) { - const arrayKey = `${ownerRoot}:targets.${targetName}.dependsOn`; + private processDependsOn( + dependsOn: DependsOnEntry[], + ownerTargets: Record | undefined, + ownerName: string | undefined + ): void { for (let i = 0; i < dependsOn.length; i++) { const dep = dependsOn[i]; + // Existing sentinel: rebind parent to this array in case a spread + // merge copied it out of its original. + if (isNameRef(dep)) { + dep.parent = dependsOn; + continue; + } + if (typeof dep === 'string') { - // String-form dependsOn entries like "project:target". Strings - // starting with '^' are dependency-mode references (no project - // name). Use splitTargetFromConfigurations with the nameMap to - // properly handle project / target names containing colons. - // - // However, if the string matches a target name in the owning - // project, it is a same-project target reference (e.g. a target - // literally named "nx:echo"), not a cross-project reference. - if (!dep.startsWith('^') && !(ownerTargets && dep in ownerTargets)) { - const [maybeProject, ...rest] = splitTargetFromConfigurations( - dep, - this.getNameMap(), - { silent: true, currentProject: ownerProjectName } - ); - if (rest.length > 0) { - const targetPart = rest.join(':'); - this.registerProjectNameSubstitutor( - maybeProject, - ownerRoot, - arrayKey, - i, - this.createDependsOnTargetStringSubstitutor( - targetName, - i, - targetPart - ) - ); - } + // `^target` and same-project targets aren't cross-project refs. + if (dep.startsWith('^') || (ownerTargets && dep in ownerTargets)) { + continue; } + const [maybeProject, ...rest] = splitTargetFromConfigurations( + dep, + this.getNameMap(), + { silent: true, currentProject: ownerName } + ); + if (rest.length === 0) continue; + const targetPart = rest.join(':'); + dependsOn[i] = this.createRef( + maybeProject, + dependsOn, + undefined, + targetPart + ); continue; } - if (typeof dep !== 'object' || !dep.projects) { + + if (!dep || typeof dep !== 'object' || !('projects' in dep)) continue; + const element = dep as { projects: unknown }; + const projects = element.projects; + + if (isNameRef(projects)) { continue; } - const depProjects = dep.projects; - if (typeof depProjects === 'string') { - // `*`, `self`, and `dependencies` are keywords, not project names. - if (['*', 'self', 'dependencies'].includes(depProjects)) { + if (typeof projects === 'string') { + if ( + projects === '*' || + projects === 'self' || + projects === 'dependencies' + ) { continue; } - this.registerProjectNameSubstitutor( - depProjects, - ownerRoot, - arrayKey, - i, - this.createDependsOnStringSubstitutor(targetName, i) - ); - } else if (Array.isArray(depProjects)) { - // Glob patterns can match multiple projects and can't be resolved - // to a single project name at this stage, so we skip them. - for (let j = 0; j < depProjects.length; j++) { - const projectName = depProjects[j]; - if (isGlobPattern(projectName)) { - continue; - } - this.registerProjectNameSubstitutor( - projectName, - ownerRoot, - arrayKey, - i, - this.createDependsOnArraySubstitutor(targetName, i, j), - j // subIndex for array elements - ); - } - // Clear stale sub-indices if a later plugin shrinks the array. - this.clearSubstitutorsFromSubIndex(arrayKey, i, depProjects.length); + element.projects = this.createRef(projects, element, 'projects'); + } else if (Array.isArray(projects)) { + this.processProjectsArray(projects); } } - // Evict any dangling substitutors at indices beyond the new array length — - // the array may have shrunk compared to a previous plugin's contribution. - this.clearSubstitutorsFromIndex(arrayKey, dependsOn.length); } - /** - * Records that a project with `name` exists at the given `root`. Call - * this during the merge phase whenever a project's name changes at a - * root — **before** calling - * {@link registerSubstitutorsForNodeResults} for that result. - * - * The nameMap (maintained externally by ProjectNodesManager) is always - * current — this method only needs to mark the root as dirty and - * promote any pending substitutors keyed by name. - */ - identifyProjectWithRoot(root: string, name: string) { - // Always mark dirty when called — the caller only invokes this when - // the name actually changed at this root (first identification or - // rename). If there are pending substitutors for this name, those - // forward refs need updating. If it's a rename, existing refs need - // updating. Either way, the root is dirty. - this.dirtyRoots.add(root); - - // Promote any pending substitutors that were waiting for this name. - const pending = this.pendingSubstitutorsByName.get(name); - if (pending) { - this.pendingSubstitutorsByName.delete(name); - - let substitutorsForRoot = this.substitutorsByReferencedRoot.get(root); - if (!substitutorsForRoot) { - substitutorsForRoot = new Set(); - this.substitutorsByReferencedRoot.set(root, substitutorsForRoot); + private processProjectsArray(projects: ProjectsEntry[]): void { + for (let j = 0; j < projects.length; j++) { + const name = projects[j]; + if (isNameRef(name)) { + name.parent = projects; + continue; } + if (typeof name !== 'string') continue; + if (isGlobPattern(name)) continue; + projects[j] = this.createRef(name, projects, undefined); + } + } - for (const entry of pending) { - substitutorsForRoot.add(entry); + // Builds a sentinel and registers it. + private createRef( + referencedName: string, + parent: unknown, + key: string | undefined, + targetPart?: string + ): RootRef | UsageRef { + const referencedRoot = this.getNameMap()[referencedName]?.root; + const ref: RootRef | UsageRef = + referencedRoot !== undefined + ? new RootRef(referencedRoot, parent, key, targetPart) + : new UsageRef(referencedName, parent, key, targetPart); + + this.allRefs.add(ref); + + if (ref instanceof UsageRef) { + let set = this.pendingByName.get(referencedName); + if (!set) { + set = new Set(); + this.pendingByName.set(referencedName, set); } + set.add(ref); + } - // Update tracking items to reflect the promotion from name → root. - for (const [, byIndex] of this.substitutorsByArrayKey) { - for (const atIndex of byIndex) { - if (!atIndex) continue; - for (const item of atIndex) { - if ( - item && - item.referencedName === name && - pending.has(item.entry) - ) { - item.referencedName = undefined; - item.referencedRoot = root; - } - } - } - } + return ref; + } + + // Records `name` → `root` and promotes any waiting UsageRef sentinels to + // RootRef by prototype swap. Sentinel identity across spread copies means + // one promotion updates every array the sentinel reached. + identifyProjectWithRoot(root: string, name: string): void { + const pending = this.pendingByName.get(name); + if (!pending) return; + this.pendingByName.delete(name); + + for (const ref of pending) { + if (!(ref instanceof UsageRef)) continue; + Object.setPrototypeOf(ref, RootRef.prototype); + ref.value = root; } } - /** - * Executes all registered substitutors for renamed projects, updating - * stale project name references in the final merged `rootMap`. Should be - * called once after all plugin results have been merged. - */ - applySubstitutions(rootMap: Record) { - for (const root of this.dirtyRoots) { - const finalName = rootMap[root]?.name; - if (!finalName) { - continue; - } + // Writes each sentinel's current resolved name back into its owning slot. + // Called once after all plugin results have been merged. + applySubstitutions(rootMap: Record): void { + const nameByRoot: Record = {}; + for (const root in rootMap) { + nameByRoot[root] = rootMap[root]?.name; + } - const substitutors = this.substitutorsByReferencedRoot.get(root); - if (!substitutors) { - continue; - } + for (const ref of this.allRefs) { + const finalName = this.resolveFinalName(ref, nameByRoot); + if (finalName === undefined) continue; - for (const { ownerRoot, substitutor } of substitutors) { - const ownerConfig = rootMap[ownerRoot]; - if (ownerConfig) { - substitutor(finalName, ownerConfig); - } + const replacement = + ref.targetPart !== undefined + ? `${finalName}:${ref.targetPart}` + : finalName; + + this.writeReplacement(ref, replacement); + } + + this.allRefs.clear(); + this.pendingByName.clear(); + } + + private resolveFinalName( + ref: RootRef | UsageRef, + nameByRoot: Record + ): string | undefined { + if (ref instanceof RootRef) { + return nameByRoot[ref.value]; + } + // Unpromoted forward ref — best effort, fall back to the written name. + return this.getNameMap()[ref.value]?.name ?? ref.value; + } + + private writeReplacement(ref: NameRef, replacement: string): void { + const parent = ref.parent; + if (Array.isArray(parent)) { + // One sentinel may appear at multiple indices (e.g. `[..., ...]` + // pushed the same reference twice via spread), so replace all. + for (let i = 0; i < parent.length; i++) { + if (parent[i] === ref) parent[i] = replacement; } + return; + } + if (parent && typeof parent === 'object' && ref.key !== undefined) { + (parent as Record)[ref.key] = replacement; } } } diff --git a/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts b/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts index 09de1d2872123..0c8bda8bc3803 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/project-nodes-manager.ts @@ -27,7 +27,8 @@ export function mergeProjectConfigurationIntoRootMap( sourceInformation?: SourceInformation, // This function is used when reading project configuration // in generators, where we don't want to do this. - skipTargetNormalization?: boolean + skipTargetNormalization?: boolean, + deferSpreadsWithoutBase?: boolean ): { nameChanged: boolean; } { @@ -197,7 +198,8 @@ export function mergeProjectConfigurationIntoRootMap( matchingProject.targets?.[matchingTargetName], sourceMap, sourceInformation, - `targets.${matchingTargetName}` + `targets.${matchingTargetName}`, + deferSpreadsWithoutBase ); } } @@ -284,8 +286,8 @@ export class ProjectNodesManager { private nameSubstitutionManager: ProjectNameInNodePropsManager; constructor() { - // Pass a lazy accessor so the substitution manager always sees - // the current nameMap without manual synchronization. + // Pass a lazy accessor so the substitution manager always sees the + // current nameMap without manual synchronization. this.nameSubstitutionManager = new ProjectNameInNodePropsManager( () => this.nameMap ); @@ -335,18 +337,30 @@ export class ProjectNodesManager { } /** - * Registers substitutors for a plugin result's project references - * in `inputs` and `dependsOn`. + * Inserts project-name sentinels into `inputs` and `dependsOn` on the + * merged objects from `mergedRootMap` (defaulting to this manager's + * rootMap). Walking the merged entries matters because a spread-produced + * array is a fresh instance. + * + * Pass a different `mergedRootMap` for the default-plugin intermediate + * pass, then call again with `this.rootMap` after it's applied so + * sentinel parents rebind onto the final arrays. */ - registerSubstitutors( + registerNameRefs( pluginResultProjects?: Record< string, Omit & Partial - > + >, + mergedRootMap: Record = this.rootMap ): void { - this.nameSubstitutionManager.registerSubstitutorsForNodeResults( - pluginResultProjects - ); + if (!pluginResultProjects) return; + const scoped: Record = {}; + for (const root in pluginResultProjects) { + if (mergedRootMap[root]) { + scoped[root] = mergedRootMap[root]; + } + } + this.nameSubstitutionManager.registerNameRefs(scoped); } /** diff --git a/packages/nx/src/project-graph/utils/project-configuration/source-maps.ts b/packages/nx/src/project-graph/utils/project-configuration/source-maps.ts index f259ba0f00b00..00ef44d8dec13 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/source-maps.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/source-maps.ts @@ -1,34 +1,16 @@ -/** - * Utilities for constructing source map keys used to track the origin - * of project configuration properties. Source map keys are dot-delimited - * paths into a ProjectConfiguration, e.g. `targets.build.inputs.0.projects`. - * - * Centralizing key construction here prevents typos and ensures consistent - * key shapes across the project graph build pipeline. - */ +// Source map keys are dot-delimited paths into a ProjectConfiguration, +// e.g. `targets.build.inputs.0.projects`. -/** Describes the file and plugin that contributed a given configuration property. */ +/** [file, plugin] that contributed a configuration property. */ export type SourceInformation = [file: string | null, plugin: string]; -/** Maps each project root to a source map for its configuration properties. */ +/** Source map per project root. */ export type ConfigurationSourceMaps = Record< string, Record >; -/** - * Calls `callback` with the source map key for each index of `array` under - * `prefixKey`, producing keys like `${prefixKey}.0`, `${prefixKey}.1`, etc. - * - * Use this when you need the keys themselves — e.g. to register or clear - * entries in a name substitution manager — without necessarily writing to a - * source map. - * - * @param prefixKey The dot-delimited path prefix for the array (e.g. `"targets.build.inputs"`). - * @param array The array whose indices should be iterated. - * @param callback Called with the key for each index. - * @param startIndex Index to start from. Useful when appending to an existing array. - */ +// Iterates `${prefixKey}.0`, `${prefixKey}.1`, ... for each index of `array`. export function forEachSourceMapKeyForArray( prefixKey: string, array: unknown[], @@ -40,10 +22,24 @@ export function forEachSourceMapKeyForArray( } } -/** - * Records a single source map entry. Prefer this over direct bracket - * assignment to keep writes consistent with the rest of the source map API. - */ +// Reads per-index source info, falling back to the array's top-level entry. +export function readArrayItemSourceInfo( + sourceMap: Record, + arrayKey: string, + itemIndex: number +): SourceInformation | undefined { + return sourceMap[`${arrayKey}.${itemIndex}`] ?? sourceMap[arrayKey]; +} + +// Reads per-property source info, falling back to the object's top-level entry. +export function readObjectPropertySourceInfo( + sourceMap: Record, + objectKey: string, + propertyKey: string +): SourceInformation | undefined { + return sourceMap[`${objectKey}.${propertyKey}`] ?? sourceMap[objectKey]; +} + export function recordSourceMapInfo( sourceMap: Record, key: string, @@ -52,17 +48,7 @@ export function recordSourceMapInfo( sourceMap[key] = sourceInfo; } -/** - * Convenience wrapper that records a source map entry for each index of - * `array` under `prefixKey`. Equivalent to calling {@link forEachSourceMapKeyForArray} - * and {@link recordSourceMapInfo} together. - * - * @param sourceMap The source map to write into. - * @param prefixKey The dot-delimited path prefix for the array (e.g. `"targets.build.inputs"`). - * @param array The array whose indices should be recorded. - * @param sourceInfo The source information to associate with each index key. - * @param startIndex Index to start writing from. Useful when appending to an existing array. - */ +// Records the same source info under every `${prefixKey}.${i}` entry. export function recordSourceMapKeysByIndex( sourceMap: Record, prefixKey: string, @@ -78,24 +64,10 @@ export function recordSourceMapKeysByIndex( ); } -/** - * Builds a source map key for a target entry. - * - * @example - * // Returns "targets.build" - * targetSourceMapKey('build') - */ export function targetSourceMapKey(targetName: string): string { return `targets.${targetName}`; } -/** - * Builds a source map key for a specific option within a target. - * - * @example - * // Returns "targets.build.options.outputPath" - * targetOptionSourceMapKey('build', 'outputPath') - */ export function targetOptionSourceMapKey( targetName: string, optionKey: string @@ -103,15 +75,6 @@ export function targetOptionSourceMapKey( return `targets.${targetName}.options.${optionKey}`; } -/** - * Builds a source map key for a target's configurations section, optionally - * scoped to a specific configuration name and key within it. - * - * @example - * targetConfigurationsSourceMapKey('build') // "targets.build.configurations" - * targetConfigurationsSourceMapKey('build', 'production') // "targets.build.configurations.production" - * targetConfigurationsSourceMapKey('build', 'production', 'outputHashing') // "targets.build.configurations.production.outputHashing" - */ export function targetConfigurationsSourceMapKey( targetName: string, configurationName?: string, diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-defaults.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-defaults.spec.ts new file mode 100644 index 0000000000000..e569f1655e9d0 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/target-defaults.spec.ts @@ -0,0 +1,514 @@ +import type { ProjectGraphProjectNode } from '../../../config/project-graph'; +import type { TargetDefaultEntry } from '../../../config/nx-json'; +import { + __resetTargetDefaultsLegacyWarning, + findBestTargetDefault, + normalizeTargetDefaults, + readTargetDefaultsForTarget, +} from './target-defaults'; + +// Silence the legacy record-shape warning everywhere except in the +// dedicated describe that asserts on it. The warning writes to stderr +// directly (so it cannot pollute `--json` stdout), so we stub +// `process.stderr.write` rather than any output helper. Restored per +// test so call history doesn't leak between `it` blocks. +let stderrWriteSpy: jest.SpyInstance; +beforeEach(() => { + __resetTargetDefaultsLegacyWarning(); + stderrWriteSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); +}); +afterEach(() => { + stderrWriteSpy.mockRestore(); +}); + +function node( + name: string, + opts: { root?: string; tags?: string[] } = {} +): ProjectGraphProjectNode { + return { + name, + type: 'lib', + data: { root: opts.root ?? name, tags: opts.tags ?? [] }, + } as ProjectGraphProjectNode; +} + +describe('findBestTargetDefault', () => { + it('returns null on empty entries', () => { + expect( + findBestTargetDefault( + 'test', + undefined, + undefined, + undefined, + undefined, + [] + ) + ).toBeNull(); + }); + + it('matches exact target name', () => { + const entries: TargetDefaultEntry[] = [{ target: 'test', cache: true }]; + expect( + findBestTargetDefault( + 'test', + undefined, + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ cache: true }); + }); + + it('returns null when no target matches', () => { + const entries: TargetDefaultEntry[] = [{ target: 'build', cache: true }]; + expect( + findBestTargetDefault( + 'test', + undefined, + undefined, + undefined, + undefined, + entries + ) + ).toBeNull(); + }); + + it('matches a target glob', () => { + const entries: TargetDefaultEntry[] = [{ target: 'test:*', cache: true }]; + expect( + findBestTargetDefault( + 'test:ci', + undefined, + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ cache: true }); + }); + + it('matches executor when target equals executor string', () => { + const entries: TargetDefaultEntry[] = [ + { target: '@nx/vite:test', inputs: ['x'] }, + ]; + expect( + findBestTargetDefault( + 'test', + '@nx/vite:test', + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ inputs: ['x'] }); + }); + + it('target+source beats target only', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', cache: true }, + { target: 'test', source: '@nx/vite', inputs: ['vite'] }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + 'web', + node('web'), + '@nx/vite', + entries + ) + ).toEqual({ inputs: ['vite'] }); + }); + + it('target+projects beats target+source', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', source: '@nx/vite', inputs: ['vite'] }, + { target: 'test', projects: 'web', inputs: ['byproject'] }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + 'web', + node('web'), + '@nx/vite', + entries + ) + ).toEqual({ inputs: ['byproject'] }); + }); + + it('target+projects+source beats all', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', cache: true }, + { target: 'test', source: '@nx/vite', inputs: ['vite'] }, + { target: 'test', projects: 'web', inputs: ['byproject'] }, + { + target: 'test', + projects: 'web', + source: '@nx/vite', + inputs: ['both'], + }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + 'web', + node('web'), + '@nx/vite', + entries + ) + ).toEqual({ inputs: ['both'] }); + }); + + it('tie in same tier is broken by later array index', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', inputs: ['first'] }, + { target: 'test', inputs: ['second'] }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ inputs: ['second'] }); + }); + + it('exact target match beats glob match in same tier', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test:*', inputs: ['glob'] }, + { target: 'test', inputs: ['exact'] }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ inputs: ['exact'] }); + }); + + it('matches by project tag pattern', () => { + const entries: TargetDefaultEntry[] = [ + { + target: 'test', + projects: 'tag:dotnet', + options: { configuration: 'Release' }, + }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + 'api', + node('api', { tags: ['dotnet'] }), + undefined, + entries + ) + ).toEqual({ options: { configuration: 'Release' } }); + }); + + it('does not match when project tag is missing', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', projects: 'tag:dotnet', options: { a: 1 } }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + 'web', + node('web', { tags: ['node'] }), + undefined, + entries + ) + ).toBeNull(); + }); + + it('supports array projects with glob + negation', () => { + const entries: TargetDefaultEntry[] = [ + { + target: 'test', + projects: ['apps/*', '!apps/legacy'], + inputs: ['ok'], + }, + ]; + const include = findBestTargetDefault( + 'test', + undefined, + 'apps/web', + node('apps/web', { root: 'apps/web' }), + undefined, + entries + ); + const exclude = findBestTargetDefault( + 'test', + undefined, + 'apps/legacy', + node('apps/legacy', { root: 'apps/legacy' }), + undefined, + entries + ); + expect(include).toEqual({ inputs: ['ok'] }); + expect(exclude).toBeNull(); + }); + + it('skips entries requiring source when sourcePlugin is unknown', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', source: '@nx/vite', inputs: ['vite'] }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + 'web', + node('web'), + undefined, + entries + ) + ).toBeNull(); + }); + + it('skips entries requiring projects when no projectNode is provided', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'test', projects: 'web', inputs: ['x'] }, + ]; + expect( + findBestTargetDefault( + 'test', + undefined, + undefined, + undefined, + undefined, + entries + ) + ).toBeNull(); + }); + + describe('executor body field (dual-role)', () => { + it('matches and bumps tier when body executor equals target executor', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', inputs: ['target-only'] }, + { target: 'build', executor: '@nx/js:tsc', inputs: ['executor-match'] }, + ]; + expect( + findBestTargetDefault( + 'build', + '@nx/js:tsc', + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ executor: '@nx/js:tsc', inputs: ['executor-match'] }); + }); + + it('matches as injection (no tier bump) when target has no executor and no command', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', executor: '@nx/js:tsc', inputs: ['inject'] }, + ]; + expect( + findBestTargetDefault( + 'build', + undefined, + undefined, + undefined, + undefined, + entries, + undefined + ) + ).toEqual({ executor: '@nx/js:tsc', inputs: ['inject'] }); + }); + + it('ties injection match with pure target-only; later index wins', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', inputs: ['plain'] }, + { target: 'build', executor: '@nx/js:tsc', inputs: ['inject'] }, + ]; + expect( + findBestTargetDefault( + 'build', + undefined, + undefined, + undefined, + undefined, + entries, + undefined + ) + ).toEqual({ executor: '@nx/js:tsc', inputs: ['inject'] }); + }); + + it('skips entry when target has a different executor', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', executor: '@nx/js:tsc', inputs: ['x'] }, + ]; + expect( + findBestTargetDefault( + 'build', + '@nx/esbuild:esbuild', + undefined, + undefined, + undefined, + entries + ) + ).toBeNull(); + }); + + it('skips entry when target has a command but no matching executor', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', executor: '@nx/js:tsc', inputs: ['x'] }, + ]; + expect( + findBestTargetDefault( + 'build', + undefined, + undefined, + undefined, + undefined, + entries, + 'some command' + ) + ).toBeNull(); + }); + + it('executor filter match beats target-only entry in tier', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', inputs: ['plain'] }, + { target: 'build', executor: '@nx/js:tsc', inputs: ['filter'] }, + ]; + expect( + findBestTargetDefault( + 'build', + '@nx/js:tsc', + undefined, + undefined, + undefined, + entries + ) + ).toEqual({ executor: '@nx/js:tsc', inputs: ['filter'] }); + }); + + it('target+source still beats target+executor-match', () => { + const entries: TargetDefaultEntry[] = [ + { target: 'build', executor: '@nx/js:tsc', inputs: ['executor-match'] }, + { target: 'build', source: '@nx/js', inputs: ['source-match'] }, + ]; + expect( + findBestTargetDefault( + 'build', + '@nx/js:tsc', + 'lib', + node('lib'), + '@nx/js', + entries + ) + ).toEqual({ inputs: ['source-match'] }); + }); + }); +}); + +describe('normalizeTargetDefaults', () => { + it('returns [] for undefined', () => { + expect(normalizeTargetDefaults(undefined)).toEqual([]); + }); + + it('passes array through unchanged', () => { + const input: TargetDefaultEntry[] = [{ target: 'test', cache: true }]; + expect(normalizeTargetDefaults(input)).toEqual(input); + }); + + it('converts record to array preserving insertion order', () => { + const result = normalizeTargetDefaults({ + build: { cache: true }, + 'e2e-ci--*': { cache: false }, + '@nx/vite:test': { inputs: ['x'] }, + }); + expect(result).toEqual([ + { target: 'build', cache: true }, + { target: 'e2e-ci--*', cache: false }, + { target: '@nx/vite:test', inputs: ['x'] }, + ]); + }); + + describe('legacy record-shape warning', () => { + it('warns once to stderr when record shape is normalized, mentioning nx repair', () => { + normalizeTargetDefaults({ build: { cache: true } }); + normalizeTargetDefaults({ test: { cache: true } }); + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + const message = stderrWriteSpy.mock.calls[0][0] as string; + expect(message).toMatch(/legacy record-shape/i); + expect(message).toMatch(/nx repair/); + }); + + it('never writes the warning to stdout', () => { + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + normalizeTargetDefaults({ build: { cache: true } }); + expect(stdoutSpy).not.toHaveBeenCalled(); + stdoutSpy.mockRestore(); + }); + + it('does not warn for array shape', () => { + normalizeTargetDefaults([{ target: 'build', cache: true }]); + expect(stderrWriteSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when targetDefaults is undefined', () => { + normalizeTargetDefaults(undefined); + expect(stderrWriteSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('readTargetDefaultsForTarget (backwards-compat wrapper)', () => { + it('still reads from the legacy record shape', () => { + expect( + readTargetDefaultsForTarget('build', { + build: { inputs: ['a'] }, + }) + ).toEqual({ inputs: ['a'] }); + }); + + it('reads from array shape', () => { + expect( + readTargetDefaultsForTarget('build', [{ target: 'build', inputs: ['a'] }]) + ).toEqual({ inputs: ['a'] }); + }); + + it('returns null when no defaults apply', () => { + expect(readTargetDefaultsForTarget('test', [])).toBeNull(); + expect(readTargetDefaultsForTarget('test', undefined)).toBeNull(); + }); + + it('record: prefers executor key over target key', () => { + expect( + readTargetDefaultsForTarget( + 'build', + { + build: { inputs: ['by-target'] }, + '@nx/vite:build': { inputs: ['by-executor'] }, + }, + '@nx/vite:build' + ) + ).toEqual({ inputs: ['by-executor'] }); + }); + + it('record: later key wins for overlapping globs', () => { + expect( + readTargetDefaultsForTarget('e2e-ci--file-foo', { + 'e2e-ci--*': { options: { key: 'short' } }, + 'e2e-ci--file-*': { options: { key: 'long' } }, + }) + ).toEqual({ options: { key: 'long' } }); + }); +}); diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-defaults.ts b/packages/nx/src/project-graph/utils/project-configuration/target-defaults.ts new file mode 100644 index 0000000000000..5221f8271a1f4 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/target-defaults.ts @@ -0,0 +1,566 @@ +import { minimatch } from 'minimatch'; +import { + NormalizedTargetDefaults, + NxJsonConfiguration, + TargetDefaultEntry, + TargetDefaults, + TargetDefaultsRecord, +} from '../../../config/nx-json'; +import { + ProjectConfiguration, + TargetConfiguration, +} from '../../../config/workspace-json-project-json'; +import type { ProjectGraphProjectNode } from '../../../config/project-graph'; +import { findMatchingProjects } from '../../../utils/find-matching-projects'; +import { isGlobPattern } from '../../../utils/globs'; +import type { CreateNodesResult } from '../../plugins/public-api'; +import { + SourceInformation, + ConfigurationSourceMaps, + targetOptionSourceMapKey, + targetSourceMapKey, +} from './source-maps'; +import { + deepClone, + isCompatibleTarget, + resolveCommandSyntacticSugar, +} from './target-merging'; +import { uniqueKeysInObjects } from './utils'; +import { EOL } from 'os'; +import * as pc from 'picocolors'; + +type CreateNodesResultEntry = readonly [ + plugin: string, + file: string, + result: CreateNodesResult, + pluginIndex?: number, +]; + +/** + * Builds a synthetic plugin result from nx.json's `targetDefaults`, layered + * between specified-plugin and default-plugin results during merging. + */ +export function createTargetDefaultsResults( + specifiedPluginRootMap: Record, + defaultPluginRootMap: Record, + nxJsonConfiguration: NxJsonConfiguration, + specifiedSourceMaps?: ConfigurationSourceMaps, + defaultSourceMaps?: ConfigurationSourceMaps +): CreateNodesResultEntry[] { + const targetDefaultsConfig = nxJsonConfiguration.targetDefaults; + if (!targetDefaultsConfig) { + return []; + } + + const entries = normalizeTargetDefaults(targetDefaultsConfig); + if (entries.length === 0) { + return []; + } + + const projectNodesByName = buildProjectNodesByName( + specifiedPluginRootMap, + defaultPluginRootMap + ); + const rootToName = new Map(); + for (const [name, node] of Object.entries(projectNodesByName)) { + rootToName.set(node.data.root, name); + } + + const syntheticProjects: Record = {}; + + const allRoots = new Set([ + ...Object.keys(specifiedPluginRootMap), + ...Object.keys(defaultPluginRootMap), + ]); + + for (const root of allRoots) { + const specifiedTargets = specifiedPluginRootMap[root]?.targets ?? {}; + const defaultTargets = defaultPluginRootMap[root]?.targets ?? {}; + const projectName = rootToName.get(root); + const projectNode = projectName + ? projectNodesByName[projectName] + : undefined; + + for (const targetName of uniqueKeysInObjects( + specifiedTargets, + defaultTargets + )) { + const sourcePlugin = resolveSourcePlugin( + root, + targetName, + specifiedSourceMaps, + defaultSourceMaps + ); + + const syntheticTarget = buildSyntheticTargetForRoot( + targetName, + root, + specifiedTargets[targetName], + defaultTargets[targetName], + entries, + projectName, + projectNode, + sourcePlugin + ); + + if (!syntheticTarget) continue; + + syntheticProjects[root] ??= { root, targets: {} }; + syntheticProjects[root].targets[targetName] = syntheticTarget; + } + } + + if (Object.keys(syntheticProjects).length === 0) { + return []; + } + + return [ + [ + 'nx/target-defaults', + 'nx.json', + { + projects: syntheticProjects, + }, + ], + ]; +} + +// Returns the synthetic defaults target to insert for `targetName` at +// `root`, or undefined if no defaults apply. +// Layering: specified plugins < target defaults < default plugins. +function buildSyntheticTargetForRoot( + targetName: string, + root: string, + specifiedTarget: TargetConfiguration | undefined, + defaultTarget: TargetConfiguration | undefined, + entries: NormalizedTargetDefaults, + projectName: string | undefined, + projectNode: ProjectGraphProjectNode | undefined, + sourcePlugin: string | undefined +): TargetConfiguration | undefined { + const resolvedSpecified = specifiedTarget + ? resolveCommandSyntacticSugar(specifiedTarget, root) + : undefined; + const resolvedDefault = defaultTarget + ? resolveCommandSyntacticSugar(defaultTarget, root) + : undefined; + + // Specified-only: layer defaults on top; the downstream merge handles + // executor mismatches by replacing. + if (resolvedSpecified && !resolvedDefault) { + return readAndPrepareTargetDefaults( + targetName, + resolvedSpecified.executor, + resolvedSpecified.command, + root, + entries, + projectName, + projectNode, + sourcePlugin + ); + } + + // Default-only. + if (resolvedDefault && !resolvedSpecified) { + return readAndPrepareTargetDefaults( + targetName, + resolvedDefault.executor, + resolvedDefault.command, + root, + entries, + projectName, + projectNode, + sourcePlugin + ); + } + + if (!resolvedSpecified || !resolvedDefault) return undefined; + + // Both compatible: use the default plugin's executor for the lookup. + if (isCompatibleTarget(resolvedSpecified, resolvedDefault)) { + return readAndPrepareTargetDefaults( + targetName, + resolvedDefault.executor || resolvedSpecified.executor, + resolvedDefault.command || resolvedSpecified.command, + root, + entries, + projectName, + projectNode, + sourcePlugin + ); + } + + // Incompatible: default plugin will replace specified; only defaults + // matching the default plugin's executor are useful. + const targetDefaults = readAndPrepareTargetDefaults( + targetName, + resolvedDefault.executor, + resolvedDefault.command, + root, + entries, + projectName, + projectNode, + sourcePlugin + ); + if (targetDefaults && isCompatibleTarget(resolvedDefault, targetDefaults)) { + // Stamp executor/command so the default layer merges cleanly on top. + return { + ...targetDefaults, + executor: resolvedDefault.executor, + command: resolvedDefault.command, + }; + } + + return undefined; +} + +function readAndPrepareTargetDefaults( + targetName: string, + executor: string | undefined, + command: string | undefined, + root: string, + entries: NormalizedTargetDefaults, + projectName: string | undefined, + projectNode: ProjectGraphProjectNode | undefined, + sourcePlugin: string | undefined +): TargetConfiguration | undefined { + const rawTargetDefaults = findBestTargetDefault( + targetName, + executor, + projectName, + projectNode, + sourcePlugin, + entries, + command + ); + if (!rawTargetDefaults) return undefined; + + return resolveCommandSyntacticSugar(deepClone(rawTargetDefaults), root); +} + +/** + * Public, backwards-compatible reader that looks up the most-specific + * target default for a given target. Accepts either the new array shape + * or the legacy record shape (devkit support). + * + * When called without project context, entries that require a `projects` + * filter or a `source` filter are skipped. + */ +export function readTargetDefaultsForTarget( + targetName: string, + targetDefaults: TargetDefaults | undefined, + executor?: string, + opts?: { + projectName?: string; + projectNode?: ProjectGraphProjectNode; + sourcePlugin?: string; + command?: string; + } +): Partial | null { + if (!targetDefaults) return null; + const entries = normalizeTargetDefaults(targetDefaults); + return findBestTargetDefault( + targetName, + executor, + opts?.projectName, + opts?.projectNode, + opts?.sourcePlugin, + entries, + opts?.command + ); +} + +type MatchKind = 'executor' | 'exactTarget' | 'globTarget'; + +interface Candidate { + config: Partial; + tier: number; // 1..5 + matchKind: MatchKind; + index: number; +} + +/** + * Find the highest-specificity `targetDefaults` entry that applies to the + * given (target, project, sourcePlugin) tuple. Ties are broken by later + * array index. Returns the config slice with filter keys (`target`, + * `projects`, `source`) stripped. + * + * Specificity tiers (highest wins): + * 5: target + projects + source + * 4: target + projects + * 3: target + source + * 2: target + executor (body-field) match + * 1: target (or target + executor injection-only match) alone + * + * `executor` in a defaults body acts dually: when the target already + * has an executor it is treated as a filter (matching bumps tier 1 → 2, + * mismatch drops the entry); when the target has no executor and no + * command, it still matches as an injector but does not bump the tier. + * + * Exact target / executor match beats glob target match within a tier. + */ +export function findBestTargetDefault( + targetName: string, + executor: string | undefined, + projectName: string | undefined, + projectNode: ProjectGraphProjectNode | undefined, + sourcePlugin: string | undefined, + entries: NormalizedTargetDefaults, + targetCommand?: string | undefined +): Partial | null { + if (!entries?.length) return null; + + let best: Candidate | null = null; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!entry || !entry.target) continue; + + const matchKind = matchTarget(entry.target, targetName, executor); + if (!matchKind) continue; + + if (entry.source && entry.source !== sourcePlugin) continue; + + if (entry.projects !== undefined) { + if (!projectName || !projectNode) continue; + const patterns = Array.isArray(entry.projects) + ? entry.projects + : [entry.projects]; + const matched = findMatchingProjects(patterns, { + [projectName]: projectNode, + }); + if (!matched.includes(projectName)) continue; + } + + const executorMatch = matchExecutorBody( + entry.executor, + executor, + targetCommand + ); + if (executorMatch === 'no-match') continue; + + let tier = 1; + if (entry.projects !== undefined && entry.source !== undefined) tier = 5; + else if (entry.projects !== undefined) tier = 4; + else if (entry.source !== undefined) tier = 3; + else if (executorMatch === 'filter') tier = 2; + + const candidate: Candidate = { + config: stripFilterKeys(entry), + tier, + matchKind, + index: i, + }; + + if (!best || beats(candidate, best)) { + best = candidate; + } + } + + return best ? best.config : null; +} + +/** + * Dual-role check for a defaults entry's `executor` body field. + * + * - `entry.executor` absent: not applicable, treated as neutral. + * - `entry.executor` matches target executor: filter match (specificity++). + * - target has no executor and no command: injection match (no bump). + * - target has a different executor or an unrelated command: skip. + */ +function matchExecutorBody( + entryExecutor: string | undefined, + targetExecutor: string | undefined, + targetCommand: string | undefined +): 'neutral' | 'filter' | 'inject' | 'no-match' { + if (!entryExecutor) return 'neutral'; + if (targetExecutor && targetExecutor === entryExecutor) return 'filter'; + if (!targetExecutor && !targetCommand) return 'inject'; + return 'no-match'; +} + +function beats(a: Candidate, b: Candidate): boolean { + if (a.tier !== b.tier) return a.tier > b.tier; + const aRank = matchKindRank(a.matchKind); + const bRank = matchKindRank(b.matchKind); + if (aRank !== bRank) return aRank > bRank; + // Tie: later array index wins. + return a.index > b.index; +} + +function matchKindRank(kind: MatchKind): number { + // Exact target and executor matches are equivalent in specificity and + // both beat a glob target match. + return kind === 'globTarget' ? 0 : 1; +} + +function matchTarget( + entryTarget: string, + targetName: string, + executor: string | undefined +): MatchKind | null { + if (executor && entryTarget === executor) return 'executor'; + if (entryTarget === targetName) return 'exactTarget'; + if (isGlobPattern(entryTarget) && minimatch(targetName, entryTarget)) { + return 'globTarget'; + } + return null; +} + +function stripFilterKeys( + entry: TargetDefaultEntry +): Partial { + const { target, projects, source, ...rest } = entry; + return rest; +} + +let hasWarnedAboutLegacyRecordShape = false; + +/** + * Accept either the new array shape or the legacy record shape and return + * a normalized array. Record entries become `{ target: key, ...value }` + * preserving insertion order. Legacy record executor keys (e.g. + * `nx:run-commands`) keep `target: key` — the matcher compares `target` + * against both target names and executors, so executor semantics are + * preserved. + * + * When the record shape is encountered we log a one-time warning + * recommending `nx repair`, which will re-run the migration that + * converts `targetDefaults` to the array shape. + */ +export function normalizeTargetDefaults( + raw: TargetDefaults | undefined +): NormalizedTargetDefaults { + if (!raw) return []; + if (Array.isArray(raw)) return raw; + warnAboutLegacyRecordShapeOnce(); + const out: TargetDefaultEntry[] = []; + for (const key of Object.keys(raw)) { + const value = (raw as TargetDefaultsRecord)[key] ?? {}; + out.push({ ...value, target: key }); + } + return out; +} + +function warnAboutLegacyRecordShapeOnce() { + if (hasWarnedAboutLegacyRecordShape) return; + hasWarnedAboutLegacyRecordShape = true; + // Written to stderr (not stdout) so commands with structured stdout — + // e.g. `nx show project --json` — remain parseable. + const title = pc.yellow( + 'NX nx.json uses the legacy record-shape `targetDefaults`' + ); + const bodyLines = [ + 'The object/record form of `targetDefaults` is deprecated. Nx still reads it for now, but the array form is required to use the new `projects` and `source` filters.', + 'Run `nx repair` to automatically convert `targetDefaults` to the array shape.', + ]; + process.stderr.write( + `${EOL}${title}${EOL}${EOL}${bodyLines + .map((l) => ` ${l}`) + .join(EOL)}${EOL}${EOL}` + ); +} + +/** Test-only: resets the module-level "warned once" flag. */ +export function __resetTargetDefaultsLegacyWarning() { + hasWarnedAboutLegacyRecordShape = false; +} + +function resolveSourcePlugin( + root: string, + targetName: string, + specifiedSourceMaps: ConfigurationSourceMaps | undefined, + defaultSourceMaps: ConfigurationSourceMaps | undefined +): string | undefined { + // Prefer the executor/command source map entries (which identify the + // plugin that originated the target) over the top-level `targets.` + // key (which tracks only the last writer). + const candidates: (string | undefined)[] = [ + pluginFromSourceMap( + specifiedSourceMaps, + root, + targetOptionSourceMapKey(targetName, 'executor') + ), + pluginFromSourceMap( + specifiedSourceMaps, + root, + targetOptionSourceMapKey(targetName, 'command') + ), + pluginFromSourceMap( + defaultSourceMaps, + root, + targetOptionSourceMapKey(targetName, 'executor') + ), + pluginFromSourceMap( + defaultSourceMaps, + root, + targetOptionSourceMapKey(targetName, 'command') + ), + // Last-resort fallback — less reliable because it records the last + // plugin to touch the target rather than the originator. + pluginFromSourceMap( + specifiedSourceMaps, + root, + targetSourceMapKey(targetName) + ), + pluginFromSourceMap( + defaultSourceMaps, + root, + targetSourceMapKey(targetName) + ), + ]; + + for (const candidate of candidates) { + if (candidate && candidate !== 'nx/target-defaults') return candidate; + } + return undefined; +} + +function pluginFromSourceMap( + maps: ConfigurationSourceMaps | undefined, + root: string, + key: string +): string | undefined { + const entry = maps?.[root]?.[key] as SourceInformation | undefined; + return entry?.[1]; +} + +function buildProjectNodesByName( + specifiedPluginRootMap: Record, + defaultPluginRootMap: Record +): Record { + const out: Record = {}; + const addFromMap = (map: Record) => { + for (const root of Object.keys(map)) { + const cfg = map[root]; + const name = cfg?.name ?? inferNameFromRoot(root); + if (!name) continue; + if (out[name]) { + // Merge tags (union) across layers so tag-based project filters + // see all tags, regardless of which plugin contributed them. + const existingTags = out[name].data.tags ?? []; + const newTags = cfg.tags ?? []; + const mergedTags = Array.from(new Set([...existingTags, ...newTags])); + out[name].data.tags = mergedTags; + } else { + out[name] = { + name, + type: 'lib', + data: { + root, + tags: cfg.tags ?? [], + }, + } as ProjectGraphProjectNode; + } + } + }; + addFromMap(specifiedPluginRootMap); + addFromMap(defaultPluginRootMap); + return out; +} + +function inferNameFromRoot(root: string): string | undefined { + if (!root) return undefined; + const parts = root.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1]; +} diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts index 59f51e60217d7..d3b5886f2f725 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts @@ -2,9 +2,8 @@ import { TargetConfiguration } from '../../../config/workspace-json-project-json import { isCompatibleTarget, mergeTargetConfigurations, - mergeTargetDefaultWithTargetDefinition, - readTargetDefaultsForTarget, } from './target-merging'; +import { readTargetDefaultsForTarget } from './target-defaults'; import type { SourceInformation } from './source-maps'; describe('target merging', () => { @@ -444,6 +443,462 @@ describe('target merging', () => { }); }); +describe('spread syntax in mergeTargetConfigurations', () => { + it('should spread arrays in top-level target properties', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + inputs: ['production', '...', '{workspaceRoot}/.eslintrc.json'], + }, + { + executor: 'nx:run-commands', + inputs: ['default', '{projectRoot}/**/*'], + } + ); + + expect(result.inputs).toEqual([ + 'production', + 'default', + '{projectRoot}/**/*', + '{workspaceRoot}/.eslintrc.json', + ]); + }); + + it('should spread arrays in options', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { + assets: ['new-asset', '...', 'trailing-asset'], + }, + }, + { + executor: 'nx:run-commands', + options: { + assets: ['base-asset-1', 'base-asset-2'], + }, + } + ); + + expect(result.options.assets).toEqual([ + 'new-asset', + 'base-asset-1', + 'base-asset-2', + 'trailing-asset', + ]); + }); + + it('should spread objects in options using "..." key', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { + env: { + NEW_VAR: 'new-value', + '...': true, + OVERRIDE_VAR: 'overridden', + }, + }, + }, + { + executor: 'nx:run-commands', + options: { + env: { + BASE_VAR: 'base-value', + OVERRIDE_VAR: 'original', + }, + }, + } + ); + + expect(result.options.env).toEqual({ + NEW_VAR: 'new-value', + BASE_VAR: 'base-value', + OVERRIDE_VAR: 'overridden', + }); + }); + + it('should spread arrays in configurations', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + configurations: { + prod: { + fileReplacements: [ + { replace: 'new.ts', with: 'new.prod.ts' }, + '...', + ], + }, + }, + }, + { + executor: 'nx:run-commands', + configurations: { + prod: { + fileReplacements: [{ replace: 'env.ts', with: 'env.prod.ts' }], + }, + }, + } + ); + + expect(result.configurations.prod.fileReplacements).toEqual([ + { replace: 'new.ts', with: 'new.prod.ts' }, + { replace: 'env.ts', with: 'env.prod.ts' }, + ]); + }); + + it('should track source map entries for spread array elements', () => { + const sourceMap: Record = { + 'targets.build': ['base.json', 'base-plugin'], + 'targets.build.inputs': ['base.json', 'base-plugin'], + }; + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + inputs: ['new-input', '...', 'trailing-input'], + }, + { + executor: 'nx:run-commands', + inputs: ['base-input'], + }, + sourceMap, + ['nx.json', 'override-plugin'], + 'targets.build' + ); + + expect(result.inputs).toEqual([ + 'new-input', + 'base-input', + 'trailing-input', + ]); + // Parent key attributed to the new source + expect(sourceMap['targets.build.inputs']).toEqual([ + 'nx.json', + 'override-plugin', + ]); + // Per-element tracking + expect(sourceMap['targets.build.inputs.0']).toEqual([ + 'nx.json', + 'override-plugin', + ]); + expect(sourceMap['targets.build.inputs.1']).toEqual([ + 'base.json', + 'base-plugin', + ]); + expect(sourceMap['targets.build.inputs.2']).toEqual([ + 'nx.json', + 'override-plugin', + ]); + }); + + it('should replace array without spread token', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + inputs: ['only-new-input'], + }, + { + executor: 'nx:run-commands', + inputs: ['base-input-1', 'base-input-2'], + } + ); + + expect(result.inputs).toEqual(['only-new-input']); + }); + + describe('options-level spread ("..." key in options object)', () => { + it('should use object spread semantics when options contains "..." key', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { + myNewOption: 'new-value', + '...': true, + sharedOption: 'overridden', + }, + }, + { + executor: 'nx:run-commands', + options: { + baseOption: 'base-value', + sharedOption: 'original', + }, + } + ); + + // Keys before '...' in new options can be overridden by base; + // keys after '...' override base. Last-write-wins. + expect(result.options).toEqual({ + myNewOption: 'new-value', + baseOption: 'base-value', + sharedOption: 'overridden', + }); + // The '...' key itself must not appear in the result + expect(result.options['...']).toBeUndefined(); + }); + + it('should let base win for options keys that appear before "..."', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { + outputPath: 'dist/my-override', + '...': true, + }, + }, + { + executor: 'nx:run-commands', + options: { + outputPath: 'dist/inferred', + tsConfig: 'tsconfig.app.json', + }, + } + ); + + // outputPath is before '...' so base wins; tsConfig comes from base via spread + expect(result.options).toEqual({ + outputPath: 'dist/inferred', + tsConfig: 'tsconfig.app.json', + }); + }); + + it('should let new options win for keys that appear after "..."', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { + '...': true, + outputPath: 'dist/my-override', + }, + }, + { + executor: 'nx:run-commands', + options: { + outputPath: 'dist/inferred', + tsConfig: 'tsconfig.app.json', + }, + } + ); + + // outputPath is after '...' so new options win; tsConfig comes from base + expect(result.options).toEqual({ + outputPath: 'dist/my-override', + tsConfig: 'tsconfig.app.json', + }); + }); + + it('should add new options keys that are not in base when "..." is at end', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { + brandNewOption: 'value', + '...': true, + }, + }, + { + executor: 'nx:run-commands', + options: { + existingOption: 'base-value', + }, + } + ); + + // brandNewOption is not in base so it survives even though it's before '...' + expect(result.options).toEqual({ + brandNewOption: 'value', + existingOption: 'base-value', + }); + }); + }); + + describe('target root spread ("..." key in target object)', () => { + it('should let base win for top-level keys before "..." (only add new keys)', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + dependsOn: ['lint'], + '...': true, + }, + { + executor: 'nx:run-commands', + dependsOn: ['typecheck'], + inputs: ['production'], + } + ); + + // dependsOn is before '...' and exists in base → base wins + expect(result.dependsOn).toEqual(['typecheck']); + // inputs is only in base → comes through via spread + expect(result.inputs).toEqual(['production']); + // '...' must not appear in the result + expect((result as any)['...']).toBeUndefined(); + }); + + it('should let target win for top-level keys after "..."', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + '...': true, + dependsOn: ['lint'], + }, + { + executor: 'nx:run-commands', + dependsOn: ['typecheck'], + inputs: ['production'], + } + ); + + // dependsOn is after '...' → target wins + expect(result.dependsOn).toEqual(['lint']); + // inputs is only in base → comes through via spread + expect(result.inputs).toEqual(['production']); + }); + + it('should add target-only keys that appear before "..."', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + cache: true, + '...': true, + }, + { + executor: 'nx:run-commands', + inputs: ['production'], + } + ); + + // cache is before '...' but NOT in base → it survives as a new addition + expect(result.cache).toBe(true); + // inputs comes from base via spread + expect(result.inputs).toEqual(['production']); + }); + + it('should not spread when targets are not compatible', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:webpack:webpack', + dependsOn: ['lint'], + '...': true, + }, + { + executor: 'nx:run-commands', + inputs: ['production'], + } + ); + + // Incompatible targets: target wins entirely, base is discarded + // so inputs should not appear (base is ignored) + expect(result.inputs).toBeUndefined(); + expect(result.dependsOn).toEqual(['lint']); + }); + + it('options are always merged with their own logic regardless of "..." position', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + options: { myOption: 'my-value' }, + '...': true, + }, + { + executor: 'nx:run-commands', + options: { baseOption: 'base-value', myOption: 'base-my-value' }, + inputs: ['production'], + } + ); + + // options use their own merge logic (not overridden by root spread): + // target's options wins by default for shared keys + expect(result.options).toEqual({ + myOption: 'my-value', + baseOption: 'base-value', + }); + // inputs: only in base → comes through via root spread + expect(result.inputs).toEqual(['production']); + }); + }); + + describe('configurations-level spread ("..." key in configurations object)', () => { + it('should let base win for named configurations before "..."', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + configurations: { + 'my-config': { outputPath: 'dist/custom' }, + '...': true, + }, + }, + { + executor: 'nx:run-commands', + configurations: { + production: { sourceMap: false }, + development: { sourceMap: true }, + }, + } + ); + + // 'my-config' is before '...' and not in base → survives + expect(result.configurations['my-config']).toEqual({ + outputPath: 'dist/custom', + }); + // base named configs come through via spread + expect(result.configurations['production']).toEqual({ sourceMap: false }); + expect(result.configurations['development']).toEqual({ sourceMap: true }); + expect((result.configurations as any)['...']).toBeUndefined(); + }); + + it('should let target win for named configurations after "..."', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + configurations: { + '...': true, + production: { outputPath: 'dist/my-override' }, + }, + }, + { + executor: 'nx:run-commands', + configurations: { + production: { sourceMap: false, outputPath: 'dist/base' }, + development: { sourceMap: true }, + }, + } + ); + + // 'production' is after '...' → target options merged with base options (target wins) + expect(result.configurations['production'].outputPath).toBe( + 'dist/my-override' + ); + // development comes from base via spread + expect(result.configurations['development']).toEqual({ sourceMap: true }); + }); + + it('should add new named configurations that do not exist in base', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + configurations: { + 'my-config': { outputPath: 'dist/custom' }, + '...': true, + }, + }, + { + executor: 'nx:run-commands', + configurations: { + production: { sourceMap: false }, + }, + } + ); + + // 'my-config' is before '...' but not in base → survives as a new addition + expect(result.configurations['my-config']).toEqual({ + outputPath: 'dist/custom', + }); + expect(result.configurations['production']).toEqual({ sourceMap: false }); + }); + }); +}); + describe('isCompatibleTarget', () => { it('should return true if only one target specifies an executor', () => { expect( @@ -538,95 +993,65 @@ describe('isCompatibleTarget', () => { ) ).toBe(false); }); -}); -describe('merge target default with target definition', () => { - it('should merge options', () => { - const sourceMap: Record = { - targets: ['dummy', 'dummy.ts'], - 'targets.build': ['dummy', 'dummy.ts'], - 'targets.build.options': ['dummy', 'dummy.ts'], - 'targets.build.options.command': ['dummy', 'dummy.ts'], - 'targets.build.options.cwd': ['project.json', 'nx/project-json'], - }; - const result = mergeTargetDefaultWithTargetDefinition( - 'build', - { - name: 'myapp', - root: 'apps/myapp', - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo', - cwd: '{workspaceRoot}', - }, - }, - }, - }, - { - options: { - command: 'tsc', - cwd: 'apps/myapp', - }, - }, - sourceMap - ); + describe('deferred spread preserves authored key position', () => { + const NX_SPREAD_TOKEN = '...'; - // Command was defined by a non-core plugin so it should be - // overwritten. - expect(result.options.command).toEqual('tsc'); - expect(sourceMap['targets.build.options.command']).toEqual([ - 'nx.json', - 'nx/target-defaults', - ]); - // Cwd was defined by a core plugin so it should be left unchanged. - expect(result.options.cwd).toEqual('{workspaceRoot}'); - expect(sourceMap['targets.build.options.cwd']).toEqual([ - 'project.json', - 'nx/project-json', - ]); - // other source map entries should be left unchanged - expect(sourceMap['targets']).toEqual(['dummy', 'dummy.ts']); - }); + it('should keep user override winning when the intermediate merge is later applied to a real base (target-level spread)', () => { + // Simulates the two-pass flow used by default-plugin batches: + // pass 1: merge the target against no base with deferSpreadsWithoutBase + // pass 2: apply that intermediate target against the real base + // The canonical idiom `{ '...': true, cache: true }` must keep + // `cache` *after* the spread in the intermediate object so that the + // second pass correctly classifies it as "after spread" → new wins. + const intermediate = mergeTargetConfigurations( + { [NX_SPREAD_TOKEN]: true, cache: true } as TargetConfiguration, + undefined, + undefined, + undefined, + 'targets.build', + true // deferSpreadsWithoutBase + ); - it('should not overwrite dependsOn', () => { - const sourceMap: Record = { - targets: ['dummy', 'dummy.ts'], - 'targets.build': ['dummy', 'dummy.ts'], - 'targets.build.options': ['dummy', 'dummy.ts'], - 'targets.build.options.command': ['dummy', 'dummy.ts'], - 'targets.build.options.cwd': ['project.json', 'nx/project-json'], - 'targets.build.dependsOn': ['project.json', 'nx/project-json'], - }; - const result = mergeTargetDefaultWithTargetDefinition( - 'build', - { - name: 'myapp', - root: 'apps/myapp', - targets: { - build: { - executor: 'nx:run-commands', - options: { - command: 'echo', - cwd: '{workspaceRoot}', - }, - dependsOn: [], + const final = mergeTargetConfigurations( + intermediate, + { cache: 'base-value' } as unknown as TargetConfiguration, + undefined, + undefined, + 'targets.build' + ); + + expect(final.cache).toBe(true); + }); + + it('should keep configuration override winning through the intermediate merge (configuration-level spread)', () => { + const intermediate = mergeTargetConfigurations( + { + configurations: { + [NX_SPREAD_TOKEN]: true, + prod: { optimization: false }, }, - }, - }, - { - options: { - command: 'tsc', - cwd: 'apps/myapp', - }, - dependsOn: ['^build'], - }, - sourceMap - ); + } as unknown as TargetConfiguration, + undefined, + undefined, + undefined, + 'targets.build', + true + ); + + const final = mergeTargetConfigurations( + intermediate, + { + configurations: { + prod: { optimization: true }, + }, + } as unknown as TargetConfiguration, + undefined, + undefined, + 'targets.build' + ); - // Command was defined by a core plugin so it should - // not be replaced by target default - expect(result.dependsOn).toEqual([]); + expect(final.configurations?.prod).toEqual({ optimization: false }); + }); }); }); diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts index d51108307dd8b..cfb4c0f9adc28 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts @@ -1,21 +1,20 @@ import { NX_PREFIX } from '../../../utils/logger'; -import { isGlobPattern } from '../../../utils/globs'; import { ProjectConfiguration, ProjectMetadata, TargetConfiguration, TargetMetadata, } from '../../../config/workspace-json-project-json'; -import { TargetDefaults } from '../../../config/nx-json'; -import { - recordSourceMapKeysByIndex, - targetConfigurationsSourceMapKey, - targetOptionSourceMapKey, -} from './source-maps'; +import { recordSourceMapKeysByIndex } from './source-maps'; import type { SourceInformation } from './source-maps'; - -import { minimatch } from 'minimatch'; +import { + getMergeValueResult, + INTEGER_LIKE_KEY_PATTERN, + IntegerLikeSpreadKeyError, + NX_SPREAD_TOKEN, + uniqueKeysInObjects, +} from './utils'; export function deepClone(obj: T): T { return structuredClone(obj); @@ -137,55 +136,222 @@ function mergeOptions( baseOptions: Record | undefined, projectConfigSourceMap?: Record, sourceInformation?: SourceInformation, - targetIdentifier?: string + targetIdentifier?: string, + deferSpreadsWithoutBase?: boolean ): Record | undefined { - const mergedOptions = { - ...(baseOptions ?? {}), - ...(newOptions ?? {}), - }; + // `'...'` at the options level uses object-spread semantics. + if (newOptions?.[NX_SPREAD_TOKEN] === true) { + return getMergeValueResult( + baseOptions, + newOptions, + projectConfigSourceMap + ? { + sourceMap: projectConfigSourceMap, + key: `${targetIdentifier}.options`, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ) as Record; + } - // record new options & option properties in source map - if (projectConfigSourceMap) { - for (const newOption in newOptions) { - projectConfigSourceMap[`${targetIdentifier}.options.${newOption}`] = - sourceInformation; - } + const mergedOptionKeys = uniqueKeysInObjects( + baseOptions ?? {}, + newOptions ?? {} + ); + const mergedOptions = {}; + + for (const optionKey of mergedOptionKeys) { + mergedOptions[optionKey] = getMergeValueResult( + baseOptions ? baseOptions[optionKey] : undefined, + newOptions ? newOptions[optionKey] : undefined, + projectConfigSourceMap + ? { + sourceMap: projectConfigSourceMap, + key: `${targetIdentifier}.options.${optionKey}`, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ); } return mergedOptions; } +// Merges a single named configuration, keyed under its own identifier +// (e.g. `targets.build.configurations.prod`) rather than under `.options`. +// Source-map correctness for the spread case is handled inside +// `getMergeValueResult`'s object-spread path — no post-merge fix-up needed. +function mergeConfigurationValue( + newConfig: Record | undefined, + baseConfig: Record | undefined, + projectConfigSourceMap?: Record, + sourceInformation?: SourceInformation, + configIdentifier?: string, + deferSpreadsWithoutBase?: boolean +): Record | undefined { + if (newConfig?.[NX_SPREAD_TOKEN] === true) { + return getMergeValueResult( + baseConfig, + newConfig, + projectConfigSourceMap && configIdentifier + ? { + sourceMap: projectConfigSourceMap, + key: configIdentifier, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ) as Record; + } + + const mergedKeys = uniqueKeysInObjects(baseConfig ?? {}, newConfig ?? {}); + const merged: Record = {}; + + for (const key of mergedKeys) { + merged[key] = getMergeValueResult( + baseConfig ? baseConfig[key] : undefined, + newConfig ? newConfig[key] : undefined, + projectConfigSourceMap && configIdentifier + ? { + sourceMap: projectConfigSourceMap, + key: `${configIdentifier}.${key}`, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ); + } + + return merged; +} + function mergeConfigurations( newConfigurations: Record | undefined, baseConfigurations: Record | undefined, projectConfigSourceMap?: Record, sourceInformation?: SourceInformation, - targetIdentifier?: string + targetIdentifier?: string, + deferSpreadsWithoutBase?: boolean ): Record | undefined { - const mergedConfigurations = {}; - - const configurations = new Set([ - ...Object.keys(baseConfigurations ?? {}), - ...Object.keys(newConfigurations ?? {}), - ]); - for (const configuration of configurations) { - mergedConfigurations[configuration] = { - ...(baseConfigurations?.[configuration] ?? {}), - ...(newConfigurations?.[configuration] ?? {}), - }; + const mergedConfigurations: Record = {}; + + // Keys before '...' let base win for shared names; keys after '...' + // (or when there's no spread) merge normally with new winning. + const newKeys = Object.keys(newConfigurations ?? {}); + const spreadPosInNew = newKeys.indexOf(NX_SPREAD_TOKEN); + const hasSpread = spreadPosInNew >= 0; + const keysBeforeSpread = hasSpread + ? new Set(newKeys.slice(0, spreadPosInNew)) + : new Set(); + + // Integer-like keys get hoisted to newKeys[0], making their position + // relative to '...' unrecoverable. + if (hasSpread && newKeys[0] && INTEGER_LIKE_KEY_PATTERN.test(newKeys[0])) { + throw new IntegerLikeSpreadKeyError( + newKeys[0], + targetIdentifier + ? `Configurations at "${targetIdentifier}.configurations"` + : 'Configurations' + ); } - // record new configurations & configuration properties in source map - if (projectConfigSourceMap) { - for (const newConfiguration in newConfigurations) { - projectConfigSourceMap[ - `${targetIdentifier}.configurations.${newConfiguration}` - ] = sourceInformation; - for (const configurationProperty in newConfigurations[newConfiguration]) { - projectConfigSourceMap[ - `${targetIdentifier}.configurations.${newConfiguration}.${configurationProperty}` - ] = sourceInformation; + // Preserving the unresolved `'...'` sentinel in authored position lets + // a later merge layer (which actually has a base) classify the keys as + // pre/post-spread correctly. + const preserveSpreadSentinel = + hasSpread && deferSpreadsWithoutBase && baseConfigurations === undefined; + + const processConfigName = (configName: string): void => { + const configIdentifier = targetIdentifier + ? `${targetIdentifier}.configurations.${configName}` + : undefined; + const baseHasConfig = configName in (baseConfigurations ?? {}); + const newHasConfig = !!newConfigurations && configName in newConfigurations; + + if (hasSpread && keysBeforeSpread.has(configName)) { + // Before '...': base wins for shared names. Keep base's source-map + // entries when it owns the config. + if (baseHasConfig) { + mergedConfigurations[configName] = baseConfigurations[configName]; + } else { + mergedConfigurations[configName] = mergeConfigurationValue( + newConfigurations?.[configName], + undefined, + projectConfigSourceMap, + sourceInformation, + configIdentifier, + deferSpreadsWithoutBase + ) as T; + if (projectConfigSourceMap && configIdentifier) { + projectConfigSourceMap[configIdentifier] = sourceInformation; + } + } + return; + } + + mergedConfigurations[configName] = mergeConfigurationValue( + newConfigurations?.[configName], + baseConfigurations?.[configName], + projectConfigSourceMap, + sourceInformation, + configIdentifier, + deferSpreadsWithoutBase + ) as T; + // Only reattribute the config name when the new plugin introduced it. + if ( + projectConfigSourceMap && + configIdentifier && + newHasConfig && + !baseHasConfig + ) { + projectConfigSourceMap[configIdentifier] = sourceInformation; + } + }; + + if (hasSpread) { + // Authored positions of new's own keys relative to `'...'` drive + // pre/post-spread classification, so those keys go in authored order. + // Base-only keys land right before `'...'` — they weren't authored by + // the new layer, so default semantics places them with the pre-spread + // keys (the "base layer" slot). + const baseOnlyKeys = baseConfigurations + ? Object.keys(baseConfigurations).filter( + (k) => k !== NX_SPREAD_TOKEN && !(k in (newConfigurations ?? {})) + ) + : []; + let baseOnlyInserted = false; + const insertBaseOnlyKeys = () => { + if (baseOnlyInserted) return; + baseOnlyInserted = true; + for (const configName of baseOnlyKeys) processConfigName(configName); + }; + for (const configName of newKeys) { + if (configName === NX_SPREAD_TOKEN) { + insertBaseOnlyKeys(); + if (preserveSpreadSentinel) { + (mergedConfigurations as Record)[NX_SPREAD_TOKEN] = + true; + } + continue; } + processConfigName(configName); + } + insertBaseOnlyKeys(); + } else { + // No spread — classic `{ ...base, ...new }` ordering: base keys + // first, new-only keys after. Shared configs stay at base's position. + if (baseConfigurations) { + for (const configName of Object.keys(baseConfigurations)) { + if (configName === NX_SPREAD_TOKEN) continue; + processConfigName(configName); + } + } + for (const configName of newKeys) { + if (configName === NX_SPREAD_TOKEN) continue; + if (configName in mergedConfigurations) continue; + processConfigName(configName); } } @@ -210,7 +376,8 @@ export function mergeTargetConfigurations( baseTarget?: TargetConfiguration, projectConfigSourceMap?: Record, sourceInformation?: SourceInformation, - targetIdentifier?: string + targetIdentifier?: string, + deferSpreadsWithoutBase?: boolean ): TargetConfiguration { const { configurations: defaultConfigurations, @@ -233,20 +400,136 @@ export function mergeTargetConfigurations( } // merge top level properties if they're compatible - const result = { - ...(isCompatible ? baseTargetProperties : {}), - ...target, + const result: Partial = {}; + const mergeBase = isCompatible ? baseTargetProperties : {}; + + // Keys before '...' let base win; keys after '...' (or when there's no + // spread) merge normally with target winning. + const targetKeys = Object.keys(target); + const spreadPosInTarget = targetKeys.indexOf(NX_SPREAD_TOKEN); + const hasSpread = isCompatible && spreadPosInTarget >= 0; + const keysBeforeSpread = hasSpread + ? new Set(targetKeys.slice(0, spreadPosInTarget)) + : new Set(); + + // Integer-like keys get hoisted to targetKeys[0], making their position + // relative to '...' unrecoverable. + if ( + hasSpread && + targetKeys[0] && + INTEGER_LIKE_KEY_PATTERN.test(targetKeys[0]) + ) { + throw new IntegerLikeSpreadKeyError( + targetKeys[0], + targetIdentifier ? `Target at "${targetIdentifier}"` : 'Target' + ); + } + + // Preserving the unresolved `'...'` sentinel in authored position lets a + // later merge layer (which actually has a base) classify sibling keys as + // pre/post-spread correctly. + const preserveSpreadSentinel = + spreadPosInTarget >= 0 && + deferSpreadsWithoutBase && + baseTarget === undefined; + + const skipForOwnMerge = new Set([ + 'options', + 'configurations', + NX_SPREAD_TOKEN, + ]); + + const processKey = (key: string): void => { + if (skipForOwnMerge.has(key)) return; + + if (hasSpread && keysBeforeSpread.has(key)) { + // Before '...': base wins; fall through to target only if base lacks it. + result[key] = + key in mergeBase + ? mergeBase[key] + : getMergeValueResult( + undefined, + target[key], + projectConfigSourceMap + ? { + sourceMap: projectConfigSourceMap, + key: `${targetIdentifier}.${key}`, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ); + return; + } + if (key in target) { + result[key] = getMergeValueResult( + mergeBase[key], + target[key], + projectConfigSourceMap + ? { + sourceMap: projectConfigSourceMap, + key: `${targetIdentifier}.${key}`, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ); + } else { + result[key] = mergeBase[key]; + } }; - // record top level properties in source map + if (isCompatible) { + if (hasSpread) { + // Authored positions of the target's own keys relative to `'...'` + // drive pre/post-spread classification, so those keys go in + // authored order. Base-only keys land right before `'...'` — they + // weren't authored, so default semantics ("base layer that yields + // to a higher-priority layer") places them with the rest of the + // pre-spread keys. + const baseOnlyKeys = Object.keys(baseTargetProperties).filter( + (k) => !skipForOwnMerge.has(k) && !(k in target) + ); + let baseOnlyInserted = false; + const insertBaseOnlyKeys = () => { + if (baseOnlyInserted) return; + baseOnlyInserted = true; + for (const key of baseOnlyKeys) processKey(key); + }; + for (const key of targetKeys) { + if (key === NX_SPREAD_TOKEN) { + insertBaseOnlyKeys(); + if (preserveSpreadSentinel) { + (result as Record)[NX_SPREAD_TOKEN] = true; + } + continue; + } + if (skipForOwnMerge.has(key)) continue; + processKey(key); + } + // Safety for a sentinel-less iteration (shouldn't happen when + // hasSpread is true, but keeps the base-only keys emitted). + insertBaseOnlyKeys(); + } else { + // No spread — classic `{ ...base, ...target }` ordering: base keys + // first (preserving their own-key order), target-only keys after. + // Shared keys stay at base's position with per-key merged value. + const mergedKeys = uniqueKeysInObjects(baseTargetProperties, target); + for (const key of mergedKeys) { + if (skipForOwnMerge.has(key)) continue; + processKey(key); + } + } + } else { + for (const key of targetKeys) { + if (skipForOwnMerge.has(key)) continue; + processKey(key); + } + } + + // Update source map once after loop if (projectConfigSourceMap) { projectConfigSourceMap[targetIdentifier] = sourceInformation; - - // record root level target properties to source map - for (const targetProperty in target) { - const targetPropertyId = `${targetIdentifier}.${targetProperty}`; - projectConfigSourceMap[targetPropertyId] = sourceInformation; - } } // merge options if there are any @@ -257,8 +540,12 @@ export function mergeTargetConfigurations( isCompatible ? defaultOptions : undefined, projectConfigSourceMap, sourceInformation, - targetIdentifier + targetIdentifier, + deferSpreadsWithoutBase ); + if (projectConfigSourceMap && target.options) { + projectConfigSourceMap[`${targetIdentifier}.options`] = sourceInformation; + } } // merge configurations if there are any @@ -269,8 +556,13 @@ export function mergeTargetConfigurations( isCompatible ? defaultConfigurations : undefined, projectConfigSourceMap, sourceInformation, - targetIdentifier + targetIdentifier, + deferSpreadsWithoutBase ); + if (projectConfigSourceMap && target.configurations) { + projectConfigSourceMap[`${targetIdentifier}.configurations`] = + sourceInformation; + } } if (target.metadata) { @@ -328,106 +620,6 @@ export function isCompatibleTarget( return true; } -function targetDefaultShouldBeApplied( - key: string, - sourceMap: Record -) { - const sourceInfo = sourceMap[key]; - if (!sourceInfo) { - return true; - } - // The defined value of the target is from a plugin that - // isn't part of Nx's core plugins, so target defaults are - // applied on top of it. - const [, plugin] = sourceInfo; - return !plugin?.startsWith('nx/'); -} - -export function mergeTargetDefaultWithTargetDefinition( - targetName: string, - project: ProjectConfiguration, - targetDefault: Partial, - sourceMap: Record -): TargetConfiguration { - const targetDefinition = project.targets[targetName] ?? {}; - const result = deepClone(targetDefinition); - - for (const key in targetDefault) { - switch (key) { - case 'options': { - const normalizedDefaults = resolveNxTokensInOptions( - targetDefault.options, - project, - targetName - ); - for (const optionKey in normalizedDefaults) { - const sourceMapKey = targetOptionSourceMapKey(targetName, optionKey); - if ( - targetDefinition.options[optionKey] === undefined || - targetDefaultShouldBeApplied(sourceMapKey, sourceMap) - ) { - result.options[optionKey] = targetDefault.options[optionKey]; - sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; - } - } - break; - } - case 'configurations': { - if (!result.configurations) { - result.configurations = {}; - sourceMap[targetConfigurationsSourceMapKey(targetName)] = [ - 'nx.json', - 'nx/target-defaults', - ]; - } - for (const configuration in targetDefault.configurations) { - if (!result.configurations[configuration]) { - result.configurations[configuration] = {}; - sourceMap[ - targetConfigurationsSourceMapKey(targetName, configuration) - ] = ['nx.json', 'nx/target-defaults']; - } - const normalizedConfigurationDefaults = resolveNxTokensInOptions( - targetDefault.configurations[configuration], - project, - targetName - ); - for (const configurationKey in normalizedConfigurationDefaults) { - const sourceMapKey = targetConfigurationsSourceMapKey( - targetName, - configuration, - configurationKey - ); - if ( - targetDefinition.configurations?.[configuration]?.[ - configurationKey - ] === undefined || - targetDefaultShouldBeApplied(sourceMapKey, sourceMap) - ) { - result.configurations[configuration][configurationKey] = - targetDefault.configurations[configuration][configurationKey]; - sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; - } - } - } - break; - } - default: { - const sourceMapKey = `targets.${targetName}.${key}`; - if ( - targetDefinition[key] === undefined || - targetDefaultShouldBeApplied(sourceMapKey, sourceMap) - ) { - result[key] = targetDefault[key]; - sourceMap[sourceMapKey] = ['nx.json', 'nx/target-defaults']; - } - break; - } - } - } - return result; -} - export function resolveNxTokensInOptions>( object: T, project: ProjectConfiguration, @@ -457,38 +649,3 @@ export function resolveNxTokensInOptions>( } return result; } - -export function readTargetDefaultsForTarget( - targetName: string, - targetDefaults: TargetDefaults, - executor?: string -): TargetDefaults[string] { - if (executor && targetDefaults?.[executor]) { - // If an executor is defined in project.json, defaults should be read - // from the most specific key that matches that executor. - // e.g. If executor === run-commands, and the target is named build: - // Use, use nx:run-commands if it is present - // If not, use build if it is present. - return targetDefaults?.[executor]; - } else if (targetDefaults?.[targetName]) { - // If the executor is not defined, the only key we have is the target name. - return targetDefaults?.[targetName]; - } - - let matchingTargetDefaultKey: string | null = null; - for (const key in targetDefaults ?? {}) { - if (isGlobPattern(key) && minimatch(targetName, key)) { - if ( - !matchingTargetDefaultKey || - matchingTargetDefaultKey.length < key.length - ) { - matchingTargetDefaultKey = key; - } - } - } - if (matchingTargetDefaultKey) { - return targetDefaults[matchingTargetDefaultKey]; - } - - return null; -} diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts index 9c58adaed738c..62615c4d3d036 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts @@ -21,10 +21,6 @@ import { import { resolveCommandSyntacticSugar, resolveNxTokensInOptions, - readTargetDefaultsForTarget, - isCompatibleTarget, - mergeTargetDefaultWithTargetDefinition, - deepClone, } from './target-merging'; import type { ConfigurationSourceMaps } from './source-maps'; @@ -138,33 +134,6 @@ function normalizeTargets( [project.root, targetName].join(':') ); - const projectSourceMaps = sourceMaps[project.root]; - - const targetConfig = project.targets[targetName]; - const targetDefaults = deepClone( - readTargetDefaultsForTarget( - targetName, - nxJsonConfiguration.targetDefaults, - targetConfig.executor - ) - ); - - // We only apply defaults if they exist - if (targetDefaults && isCompatibleTarget(targetConfig, targetDefaults)) { - project.targets[targetName] = mergeTargetDefaultWithTargetDefinition( - targetName, - project, - normalizeTarget( - targetDefaults, - project, - workspaceRoot, - projects, - ['nx.json[targetDefaults]', targetName].join(':') - ), - projectSourceMaps - ); - } - const target = project.targets[targetName]; if ( diff --git a/packages/nx/src/project-graph/utils/project-configuration/utils.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/utils.spec.ts new file mode 100644 index 0000000000000..974d47c0683fd --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/utils.spec.ts @@ -0,0 +1,467 @@ +import { getMergeValueResult } from './utils'; + +describe('getMergeValueResult - spread token behavior', () => { + const NX_SPREAD_TOKEN = '...'; + + describe('basic spread token merging', () => { + it('should merge array with spread token at beginning', () => { + const base = ['x', 'y']; + const newValue = [NX_SPREAD_TOKEN, 'a', 'b']; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['x', 'y', 'a', 'b']); + }); + + it('should merge array with spread token at end', () => { + const base = ['x', 'y']; + const newValue = ['a', 'b', NX_SPREAD_TOKEN]; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['a', 'b', 'x', 'y']); + }); + + it('should merge array with spread token in middle', () => { + const base = ['x', 'y']; + const newValue = ['a', NX_SPREAD_TOKEN, 'b']; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['a', 'x', 'y', 'b']); + }); + + it('should replace array when no spread token present', () => { + const base = ['x', 'y']; + const newValue = ['a', 'b']; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['a', 'b']); + }); + }); + + describe('nested spread token merging - base contains unexpanded spread token', () => { + it('should handle base array containing literal spread token when spreading', () => { + // Simulates: target defaults has ['a', '...'], then merged with package.json ['...', 'b'] + const base = ['a', NX_SPREAD_TOKEN]; // Result from first merge + const newValue = [NX_SPREAD_TOKEN, 'b']; + const result = getMergeValueResult(base, newValue); + + // The literal '...' from base gets included in the spread + expect(result).toEqual(['a', NX_SPREAD_TOKEN, 'b']); + }); + + it('should handle multiple levels of spread token nesting', () => { + // Level 1: target defaults + const targetDefaults = ['td1', NX_SPREAD_TOKEN]; + + // Level 2: merge with package.json + const packageJson = [NX_SPREAD_TOKEN, 'pkg1']; + const afterPackageJson = getMergeValueResult(targetDefaults, packageJson); + expect(afterPackageJson).toEqual(['td1', NX_SPREAD_TOKEN, 'pkg1']); + + // Level 3: merge with project.json + const projectJson = [NX_SPREAD_TOKEN, 'proj1']; + const final = getMergeValueResult(afterPackageJson, projectJson); + + // Now we have nested spread tokens + expect(final).toEqual(['td1', NX_SPREAD_TOKEN, 'pkg1', 'proj1']); + }); + + it('should handle spread token at different positions in multi-layer merge', () => { + // Layer 1: target defaults + const layer1 = [NX_SPREAD_TOKEN, 'a']; + + // Layer 2: merge with base ['x'] + const base = ['x']; + const afterLayer1 = getMergeValueResult(base, layer1); + expect(afterLayer1).toEqual(['x', 'a']); + + // Layer 3: merge with another spread token array + const layer3 = ['b', NX_SPREAD_TOKEN, 'c']; + const final = getMergeValueResult(afterLayer1, layer3); + expect(final).toEqual(['b', 'x', 'a', 'c']); + }); + + it('should handle case where all three layers have spread tokens', () => { + // This is the specific scenario the user is concerned about: + // target defaults, package.json, and project.json all have spread tokens + + // Base (from some earlier source) + const originalBase = ['original']; + + // Layer 1: target defaults with spread + const targetDefaults = [NX_SPREAD_TOKEN, 'td']; + const afterTargetDefaults = getMergeValueResult( + originalBase, + targetDefaults + ); + expect(afterTargetDefaults).toEqual(['original', 'td']); + + // Layer 2: package.json with spread + const packageJson = ['pkg', NX_SPREAD_TOKEN]; + const afterPackageJson = getMergeValueResult( + afterTargetDefaults, + packageJson + ); + expect(afterPackageJson).toEqual(['pkg', 'original', 'td']); + + // Layer 3: project.json with spread + const projectJson = [NX_SPREAD_TOKEN, 'proj']; + const final = getMergeValueResult(afterPackageJson, projectJson); + expect(final).toEqual(['pkg', 'original', 'td', 'proj']); + }); + + it('should handle empty base with spread token', () => { + const base = undefined; + const newValue = [NX_SPREAD_TOKEN, 'a']; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['a']); + }); + + it('should handle base with only spread token', () => { + const base = ['x']; + const newValue = [NX_SPREAD_TOKEN]; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['x']); + }); + }); + + describe('object spread token merging', () => { + it('should merge object with spread token', () => { + const base = { a: 1, b: 2 }; + const newValue = { [NX_SPREAD_TOKEN]: true, c: 3 }; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('should override base properties when defined after spread', () => { + const base = { a: 1, b: 2 }; + const newValue = { [NX_SPREAD_TOKEN]: true, b: 99, c: 3 }; + const result = getMergeValueResult(base, newValue); + // The order in object iteration matters, but '...' comes first, then b override + expect(result).toEqual({ a: 1, b: 99, c: 3 }); + }); + + it('should handle nested object spread merging', () => { + // Layer 1 + const base1 = { a: 1 }; + const layer1 = { [NX_SPREAD_TOKEN]: true, b: 2 }; + const afterLayer1 = getMergeValueResult(base1, layer1); + expect(afterLayer1).toEqual({ a: 1, b: 2 }); + + // Layer 2 + const layer2 = { [NX_SPREAD_TOKEN]: true, c: 3 }; + const final = getMergeValueResult(afterLayer1, layer2); + expect(final).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('should replace object when no spread token present', () => { + const base = { a: 1, b: 2 }; + const newValue = { c: 3 }; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual({ c: 3 }); + }); + }); + + describe('source map tracking with spread tokens', () => { + it('should track source information for spread elements', () => { + const base = ['a', 'b']; + const newValue = [NX_SPREAD_TOKEN, 'c']; + const sourceMap: Record = { + inputs: ['base.json', 'base-plugin'], + }; + + const result = getMergeValueResult(base, newValue, { + sourceMap, + key: 'inputs', + sourceInformation: ['new.json', 'new-plugin'], + }); + + expect(result).toEqual(['a', 'b', 'c']); + expect(sourceMap['inputs']).toEqual(['new.json', 'new-plugin']); + expect(sourceMap['inputs.0']).toEqual(['base.json', 'base-plugin']); + expect(sourceMap['inputs.1']).toEqual(['base.json', 'base-plugin']); + expect(sourceMap['inputs.2']).toEqual(['new.json', 'new-plugin']); + }); + + it('should track source information through nested spread merges', () => { + const sourceMap: Record = {}; + + // Layer 1: target defaults + const base1 = ['a']; + const layer1 = [NX_SPREAD_TOKEN, 'b']; + sourceMap['inputs'] = ['original', 'plugin']; + + const afterLayer1 = getMergeValueResult(base1, layer1, { + sourceMap, + key: 'inputs', + sourceInformation: ['target-defaults', 'nx/target-defaults'], + }); + expect(afterLayer1).toEqual(['a', 'b']); + expect(sourceMap['inputs.0']).toEqual(['original', 'plugin']); + expect(sourceMap['inputs.1']).toEqual([ + 'target-defaults', + 'nx/target-defaults', + ]); + + // Layer 2: package.json + const layer2 = ['c', NX_SPREAD_TOKEN]; + const afterLayer2 = getMergeValueResult(afterLayer1, layer2, { + sourceMap, + key: 'inputs', + sourceInformation: ['package.json', 'nx/package-json'], + }); + expect(afterLayer2).toEqual(['c', 'a', 'b']); + expect(sourceMap['inputs.0']).toEqual([ + 'package.json', + 'nx/package-json', + ]); + // Spread items keep their *original* per-index attribution: 'a' + // came in via layer 1's spread of the pre-existing 'inputs' + // (attributed to the original plugin), and 'b' was added by the + // target-defaults layer. Layer 2 only authors 'c'. + expect(sourceMap['inputs.1']).toEqual(['original', 'plugin']); + expect(sourceMap['inputs.2']).toEqual([ + 'target-defaults', + 'nx/target-defaults', + ]); + }); + + it('should preserve original per-index source info across chained array spreads', () => { + const sourceMap: Record = {}; + const sourceA: [string, string] = ['a.json', 'plugin-a']; + const sourceB: [string, string] = ['b.json', 'plugin-b']; + const sourceC: [string, string] = ['c.json', 'plugin-c']; + + // Layer A: seeds [1, 2, 3]; every index attributed to A. + const afterA = getMergeValueResult(undefined, [1, 2, 3], { + sourceMap, + key: 'arr', + sourceInformation: sourceA, + }); + expect(afterA).toEqual([1, 2, 3]); + expect(sourceMap['arr.0']).toEqual(sourceA); + expect(sourceMap['arr.1']).toEqual(sourceA); + expect(sourceMap['arr.2']).toEqual(sourceA); + + // Layer B: ['...', 4] → spread of A then 4; A's items keep A, + // 4 gets B. + const afterB = getMergeValueResult( + afterA, + [NX_SPREAD_TOKEN, 4], + { + sourceMap, + key: 'arr', + sourceInformation: sourceB, + } + ); + expect(afterB).toEqual([1, 2, 3, 4]); + expect(sourceMap['arr.0']).toEqual(sourceA); + expect(sourceMap['arr.1']).toEqual(sourceA); + expect(sourceMap['arr.2']).toEqual(sourceA); + expect(sourceMap['arr.3']).toEqual(sourceB); + + // Layer C: [0, '...'] → 0 then the full base spread. 0 gets C, + // the spread items keep their original sources (A for 1/2/3, + // B for 4) — *not* overwritten by C even though `arr.0=C` was + // just written before the spread loop reads base attribution. + const afterC = getMergeValueResult( + afterB, + [0, NX_SPREAD_TOKEN], + { + sourceMap, + key: 'arr', + sourceInformation: sourceC, + } + ); + expect(afterC).toEqual([0, 1, 2, 3, 4]); + expect(sourceMap['arr.0']).toEqual(sourceC); + expect(sourceMap['arr.1']).toEqual(sourceA); + expect(sourceMap['arr.2']).toEqual(sourceA); + expect(sourceMap['arr.3']).toEqual(sourceA); + expect(sourceMap['arr.4']).toEqual(sourceB); + expect(sourceMap['arr']).toEqual(sourceC); + }); + + it('should fall back to the parent-key source for base items without a per-index entry', () => { + const sourceMap: Record = { + // Only the parent key has attribution; per-index entries + // weren't recorded by whatever populated the base. + arr: ['legacy.json', 'legacy-plugin'], + }; + const result = getMergeValueResult( + ['x', 'y'], + [NX_SPREAD_TOKEN, 'z'], + { + sourceMap, + key: 'arr', + sourceInformation: ['new.json', 'new-plugin'], + } + ); + + expect(result).toEqual(['x', 'y', 'z']); + expect(sourceMap['arr.0']).toEqual(['legacy.json', 'legacy-plugin']); + expect(sourceMap['arr.1']).toEqual(['legacy.json', 'legacy-plugin']); + expect(sourceMap['arr.2']).toEqual(['new.json', 'new-plugin']); + }); + + it('should track source information for object spreads', () => { + const base = { a: 1, b: 2 }; + const newValue = { [NX_SPREAD_TOKEN]: true, c: 3 }; + const sourceMap: Record = { + options: ['base.json', 'base-plugin'], + }; + + const result = getMergeValueResult(base, newValue, { + sourceMap, + key: 'options', + sourceInformation: ['new.json', 'new-plugin'], + }); + + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + expect(sourceMap['options']).toEqual(['new.json', 'new-plugin']); + expect(sourceMap['options.a']).toEqual(['base.json', 'base-plugin']); + expect(sourceMap['options.b']).toEqual(['base.json', 'base-plugin']); + expect(sourceMap['options.c']).toEqual(['new.json', 'new-plugin']); + }); + }); + + describe('edge cases', () => { + it('should handle undefined base value', () => { + const result = getMergeValueResult(undefined, ['a', 'b']); + expect(result).toEqual(['a', 'b']); + }); + + it('should handle undefined new value', () => { + const result = getMergeValueResult(['a', 'b'], undefined); + expect(result).toEqual(['a', 'b']); + }); + + it('should handle null values', () => { + const result = getMergeValueResult(['a'], null); + expect(result).toEqual(null); + }); + + it('should handle primitive values', () => { + const result = getMergeValueResult('old', 'new'); + expect(result).toEqual('new'); + }); + + it('should handle type mismatches - object to array', () => { + const base = { a: 1 }; + const newValue = ['a', 'b']; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual(['a', 'b']); + }); + + it('should handle type mismatches - array to object', () => { + const base = ['a', 'b']; + const newValue = { a: 1 }; + const result = getMergeValueResult(base, newValue); + expect(result).toEqual({ a: 1 }); + }); + + it('should handle multiple spread tokens in same array', () => { + const base = ['x', 'y']; + const newValue = [NX_SPREAD_TOKEN, 'a', NX_SPREAD_TOKEN, 'b']; + const result = getMergeValueResult(base, newValue); + // Both spread tokens expand the base array + expect(result).toEqual(['x', 'y', 'a', 'x', 'y', 'b']); + }); + }); + + describe('object spread — per-key source provenance', () => { + it('should preserve base per-key attribution when a shared key appears before the spread token', () => { + // Layer A wrote `{ z: 1 }` with per-key attribution recorded at + // sourceMap['obj.z']. Layer B authors `{ z: 99, '...': true }` — + // `z` sits *before* the spread, so base wins for `z`. The surviving + // value came from A, so its attribution must stay with A. + const sourceMap: Record = { + obj: ['a.json', 'plugin-a'], + 'obj.z': ['a.json', 'plugin-a'], + }; + + const result = getMergeValueResult>( + { z: 1 }, + { z: 99, [NX_SPREAD_TOKEN]: true }, + { + sourceMap, + key: 'obj', + sourceInformation: ['b.json', 'plugin-b'], + } + ); + + expect(result).toEqual({ z: 1 }); + expect(sourceMap['obj.z']).toEqual(['a.json', 'plugin-a']); + }); + + it('should preserve base per-key attribution across three spread layers', () => { + const sourceMap: Record = {}; + const sourceA: [string, string] = ['a.json', 'plugin-a']; + const sourceB: [string, string] = ['b.json', 'plugin-b']; + const sourceC: [string, string] = ['c.json', 'plugin-c']; + + // Layer A: seed `{ x: 1 }` — plain object, no spread. + getMergeValueResult>( + undefined, + { x: 1 }, + { + sourceMap, + key: 'obj', + sourceInformation: sourceA, + } + ); + + // Layer B: `{ x: 99, '...': true }` — x before spread, base wins. + // After this merge, `obj.x` should still be attributed to A. + getMergeValueResult>( + { x: 1 }, + { x: 99, [NX_SPREAD_TOKEN]: true }, + { + sourceMap, + key: 'obj', + sourceInformation: sourceB, + } + ); + expect(sourceMap['obj.x']).toEqual(sourceA); + + // Layer C: `{ '...': true, y: 2 }` — x spread from base, y added. + // x's attribution must remain A, not get clobbered to C. + const final = getMergeValueResult>( + { x: 1 }, + { [NX_SPREAD_TOKEN]: true, y: 2 }, + { + sourceMap, + key: 'obj', + sourceInformation: sourceC, + } + ); + + expect(final).toEqual({ x: 1, y: 2 }); + expect(sourceMap['obj.x']).toEqual(sourceA); + expect(sourceMap['obj.y']).toEqual(sourceC); + }); + }); + + describe('integer-like keys with spread token', () => { + // ECMAScript enumerates integer-index string keys first in ascending + // numeric order regardless of insertion order, so there is no way to + // recover the authored position of an integer-like key relative to + // `'...'` from a plain object. Rather than silently misclassify such + // keys as before/after the spread, reject the ambiguous shape so the + // author is forced to rewrite it unambiguously. + it('should throw when an object mixes integer-like keys with the spread token', () => { + const newValue = { foo: 'a', [NX_SPREAD_TOKEN]: true, '1': 'x' }; + expect(() => + getMergeValueResult>( + { '1': 'base1', foo: 'basefoo' }, + newValue + ) + ).toThrow(/integer-like key/i); + }); + + it('should not throw when the object has no spread token', () => { + // Without `'...'` the key ordering doesn't affect merge semantics, + // so integer-like keys are fine. + const result = getMergeValueResult>( + { '1': 'base1' }, + { '1': 'x', foo: 'a' } + ); + expect(result).toEqual({ '1': 'x', foo: 'a' }); + }); + }); +}); diff --git a/packages/nx/src/project-graph/utils/project-configuration/utils.ts b/packages/nx/src/project-graph/utils/project-configuration/utils.ts new file mode 100644 index 0000000000000..e7fe5916d4b01 --- /dev/null +++ b/packages/nx/src/project-graph/utils/project-configuration/utils.ts @@ -0,0 +1,248 @@ +import { + readArrayItemSourceInfo, + readObjectPropertySourceInfo, + type SourceInformation, +} from './source-maps'; + +export const NX_SPREAD_TOKEN = '...'; + +/** + * Returns the union of keys across every provided object. + */ +export function uniqueKeysInObjects( + ...objs: Array +): Set { + const keys = new Set(); + for (const obj of objs) { + if (obj) { + for (const key of Object.keys(obj)) { + keys.add(key); + } + } + } + return keys; +} + +// Integer-like string keys (`"0"`, `"42"`) are enumerated before +// insertion-order keys, so we can't tell if they were authored before or +// after `'...'`. Spread sites reject them instead of guessing. +export const INTEGER_LIKE_KEY_PATTERN = /^(0|[1-9]\d*)$/; + +export class IntegerLikeSpreadKeyError extends Error { + constructor(key: string, context: string) { + super( + `${context} uses an integer-like key (${JSON.stringify( + key + )}) alongside the '...' spread token. Integer-like keys are enumerated before other keys regardless of authored order, so their position relative to '...' is ambiguous. Rename the key (e.g. add a non-numeric prefix) or restructure the object.` + ); + this.name = 'IntegerLikeSpreadKeyError'; + } +} + +type SourceMapContext = { + sourceMap: Record; + key: string; + sourceInformation: SourceInformation; +}; + +/** + * `"..."` in `newValue` (as an array element or a key set to `true`) + * expands the base at that position; otherwise `newValue` replaces + * `baseValue`. With `deferSpreadsWithoutBase`, an unresolvable spread is + * preserved so a later merge layer can expand it. + */ +export function getMergeValueResult( + baseValue: unknown, + newValue: T | undefined, + sourceMapContext?: SourceMapContext, + deferSpreadsWithoutBase?: boolean +): T | undefined { + if (newValue === undefined && baseValue !== undefined) { + return baseValue as T; + } + + if (Array.isArray(newValue)) { + return mergeArrayValue( + baseValue, + newValue, + sourceMapContext, + deferSpreadsWithoutBase + ) as T; + } + + if (isObject(newValue) && newValue[NX_SPREAD_TOKEN] === true) { + return mergeObjectWithSpread( + baseValue, + newValue, + sourceMapContext, + deferSpreadsWithoutBase + ) as T; + } + + // Scalar / null / plain object replace — newValue fully wins. + writeTopLevelSourceMap(sourceMapContext); + return newValue; +} + +function mergeArrayValue( + baseValue: unknown, + newValue: T[], + sourceMapContext: SourceMapContext | undefined, + deferSpreadsWithoutBase: boolean | undefined +): T[] { + const newSpreadIndex = newValue.findIndex((v) => v === NX_SPREAD_TOKEN); + + if (newSpreadIndex === -1) { + // No spread: newValue replaces baseValue entirely. + if (sourceMapContext) { + for (let i = 0; i < newValue.length; i++) { + sourceMapContext.sourceMap[`${sourceMapContext.key}.${i}`] = + sourceMapContext.sourceInformation; + } + } + writeTopLevelSourceMap(sourceMapContext); + return newValue; + } + + const baseArray = Array.isArray(baseValue) ? baseValue : []; + // Snapshot per-index base sources before we start writing — the loop + // writes into the same `${key}.${i}` entries it needs to read back when + // the spread expands. Unlike object spread, array spreads can overwrite + // indices during their own expansion (when new authors a prefix before + // `'...'`), so lazy capture isn't sufficient here. + const basePerIndexSources: Array = + sourceMapContext + ? baseArray.map((_, i) => + readArrayItemSourceInfo( + sourceMapContext.sourceMap, + sourceMapContext.key, + i + ) + ) + : []; + + const result: any[] = []; + const recordAt = (resultIdx: number, info: SourceInformation | undefined) => { + if (sourceMapContext && info) { + sourceMapContext.sourceMap[`${sourceMapContext.key}.${resultIdx}`] = info; + } + }; + + for ( + let newValueIndex = 0; + newValueIndex < newValue.length; + newValueIndex++ + ) { + const element = newValue[newValueIndex]; + + if (element === NX_SPREAD_TOKEN) { + if (deferSpreadsWithoutBase && baseValue === undefined) { + recordAt(result.length, sourceMapContext?.sourceInformation); + result.push(NX_SPREAD_TOKEN); + } else { + for (let baseIndex = 0; baseIndex < baseArray.length; baseIndex++) { + recordAt(result.length, basePerIndexSources[baseIndex]); + result.push(baseArray[baseIndex]); + } + } + continue; + } + + recordAt(result.length, sourceMapContext?.sourceInformation); + result.push(element); + } + + writeTopLevelSourceMap(sourceMapContext); + return result; +} + +function mergeObjectWithSpread( + baseValue: unknown, + newValue: Record, + sourceMapContext: SourceMapContext | undefined, + deferSpreadsWithoutBase: boolean | undefined +): Record { + const baseObj = isObject(baseValue) ? baseValue : {}; + const result: Record = {}; + const errorContext = sourceMapContext?.key + ? `Object at "${sourceMapContext.key}"` + : 'Object'; + + const newKeys = Object.keys(newValue); + + // Integer-like keys are hoisted to the front of enumeration, so if one + // exists alongside `'...'` it must be newKeys[0]. + if (newKeys[0] && INTEGER_LIKE_KEY_PATTERN.test(newKeys[0])) { + throw new IntegerLikeSpreadKeyError(newKeys[0], errorContext); + } + + // Base per-key sources captured lazily — only for shared keys the new + // object overwrites before `'...'`, since writing their new source + // clobbers the base entry the spread will need to restore. + const capturedBaseSources: Record = {}; + + for (const newKey of newKeys) { + if (newKey === NX_SPREAD_TOKEN) { + if (deferSpreadsWithoutBase && baseValue === undefined) { + // Keep the sentinel for a later merge layer to resolve. + result[NX_SPREAD_TOKEN] = true; + continue; + } + for (const baseKey of Object.keys(baseObj)) { + result[baseKey] = baseObj[baseKey]; + if (sourceMapContext) { + // If we captured a shared key pre-spread, use that; otherwise + // the source map still holds the base entry untouched. + const baseSource = Object.prototype.hasOwnProperty.call( + capturedBaseSources, + baseKey + ) + ? capturedBaseSources[baseKey] + : readObjectPropertySourceInfo( + sourceMapContext.sourceMap, + sourceMapContext.key, + baseKey + ); + if (baseSource) { + sourceMapContext.sourceMap[`${sourceMapContext.key}.${baseKey}`] = + baseSource; + } + } + } + continue; + } + + // About to overwrite a base key's source — capture it first so the + // spread can restore it. + if ( + sourceMapContext && + newKey in baseObj && + !Object.prototype.hasOwnProperty.call(capturedBaseSources, newKey) + ) { + capturedBaseSources[newKey] = readObjectPropertySourceInfo( + sourceMapContext.sourceMap, + sourceMapContext.key, + newKey + ); + } + + result[newKey] = newValue[newKey]; + if (sourceMapContext) { + sourceMapContext.sourceMap[`${sourceMapContext.key}.${newKey}`] = + sourceMapContext.sourceInformation; + } + } + + writeTopLevelSourceMap(sourceMapContext); + return result; +} + +function writeTopLevelSourceMap(ctx: SourceMapContext | undefined): void { + if (ctx) { + ctx.sourceMap[ctx.key] = ctx.sourceInformation; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/nx/src/project-graph/utils/retrieve-workspace-files.spec.ts b/packages/nx/src/project-graph/utils/retrieve-workspace-files.spec.ts index ade2bf876fb39..571e7436ba5c2 100644 --- a/packages/nx/src/project-graph/utils/retrieve-workspace-files.spec.ts +++ b/packages/nx/src/project-graph/utils/retrieve-workspace-files.spec.ts @@ -90,7 +90,7 @@ describe('retrieve-workspace-files', () => { const mockPlugin3 = createTestPlugin('test-plugin-3', '**/package.json'); const result = await retrieveProjectConfigurations( - [mockPlugin, mockPlugin3], + { specifiedPlugins: [], defaultPlugins: [mockPlugin, mockPlugin3] }, fs.tempDir, {} ); @@ -123,7 +123,7 @@ describe('retrieve-workspace-files', () => { const mockPlugin2 = createTestPlugin('test-plugin-2', '!**/*'); const result = await retrieveProjectConfigurations( - [mockPlugin1, mockPlugin2], + { specifiedPlugins: [], defaultPlugins: [mockPlugin1, mockPlugin2] }, fs.tempDir, {} ); diff --git a/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts b/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts index 6978c38f3bb03..895567b7a7edb 100644 --- a/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts +++ b/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts @@ -12,15 +12,17 @@ import { import type { LoadedNxPlugin } from '../plugins/loaded-nx-plugin'; import { getNxWorkspaceFilesFromContext, - globWithWorkspaceContext, multiGlobWithWorkspaceContext, } from '../../utils/workspace-context'; -import { buildAllWorkspaceFiles } from './build-all-workspace-files'; import { join } from 'path'; -import { getOnlyDefaultPlugins, getPlugins } from '../plugins/get-plugins'; +import { + getOnlyDefaultPlugins, + getPluginsSeparated, + SeparatedPlugins, +} from '../plugins/get-plugins'; /** - * Walks the workspace directory to create the `projectFileMap`, `ProjectConfigurations` and `allWorkspaceFiles` + * Walks the workspace directory to create the `projectFileMap` and `ProjectConfigurations` * @throws * @param workspaceRoot * @param nxJson @@ -49,7 +51,6 @@ export async function retrieveWorkspaceFiles( ); return { - allWorkspaceFiles: buildAllWorkspaceFiles(projectFileMap, globalFiles), fileMap: { projectFileMap, nonProjectFiles: globalFiles, @@ -60,25 +61,43 @@ export async function retrieveWorkspaceFiles( /** * Walk through the workspace and return `ProjectConfigurations`. Only use this if the projectFileMap is not needed. + * + * Accepts separated plugin sets so that target defaults can be applied + * between specified and default plugin processing phases. */ - export async function retrieveProjectConfigurations( - plugins: LoadedNxPlugin[], + separatedPlugins: SeparatedPlugins, workspaceRoot: string, nxJson: NxJsonConfiguration ): Promise { - const pluginsWithCreateNodes = plugins.filter((p) => !!p.createNodes); - const globPatterns = getGlobPatternsOfPlugins(pluginsWithCreateNodes); - const pluginConfigFiles = await multiGlobWithWorkspaceContext( - workspaceRoot, - globPatterns + const specifiedWithCreateNodes = separatedPlugins.specifiedPlugins.filter( + (p) => !!p.createNodes + ); + const defaultWithCreateNodes = separatedPlugins.defaultPlugins.filter( + (p) => !!p.createNodes ); + const specifiedGlobPatterns = getGlobPatternsOfPlugins( + specifiedWithCreateNodes + ); + const defaultGlobPatterns = getGlobPatternsOfPlugins(defaultWithCreateNodes); + + const [specifiedPluginFiles, defaultPluginFiles] = await Promise.all([ + multiGlobWithWorkspaceContext(workspaceRoot, specifiedGlobPatterns), + multiGlobWithWorkspaceContext(workspaceRoot, defaultGlobPatterns), + ]); + return createProjectConfigurationsWithPlugins( workspaceRoot, nxJson, - pluginConfigFiles, - pluginsWithCreateNodes + { + specifiedPluginFiles: specifiedPluginFiles ?? [], + defaultPluginFiles: defaultPluginFiles ?? [], + }, + { + specifiedPlugins: specifiedWithCreateNodes, + defaultPlugins: defaultWithCreateNodes, + } ); } @@ -99,10 +118,10 @@ export async function retrieveProjectConfigurationsWithAngularProjects( pluginsToLoad.push(join(__dirname, '../../adapter/angular-json')); } - const plugins = await getPlugins(workspaceRoot); + const separatedPlugins = await getPluginsSeparated(workspaceRoot); const res = await retrieveProjectConfigurations( - plugins, + separatedPlugins, workspaceRoot, nxJson ); @@ -131,8 +150,9 @@ export async function retrieveProjectConfigurationsWithoutPluginInference( root: string ): Promise> { const nxJson = readNxJson(root); - const plugins = await getOnlyDefaultPlugins(); // only load default plugins - const projectGlobPatterns = getGlobPatternsOfPlugins(plugins); + const defaultPlugins = await getOnlyDefaultPlugins(); // only load default plugins + const pluginsWithCreateNodes = defaultPlugins.filter((p) => !!p.createNodes); + const projectGlobPatterns = getGlobPatternsOfPlugins(pluginsWithCreateNodes); const cacheKey = root + ',' + projectGlobPatterns.join(','); if (projectsWithoutPluginCache.has(cacheKey)) { @@ -144,8 +164,14 @@ export async function retrieveProjectConfigurationsWithoutPluginInference( const { projects } = await createProjectConfigurationsWithPlugins( root, nxJson, - projectFiles, - plugins + { + specifiedPluginFiles: [], + defaultPluginFiles: projectFiles, + }, + { + specifiedPlugins: [], + defaultPlugins: pluginsWithCreateNodes, + } ); projectsWithoutPluginCache.set(cacheKey, projects); diff --git a/packages/nx/src/tasks-runner/run-command.ts b/packages/nx/src/tasks-runner/run-command.ts index 51e3467bd7520..409b8bd51b260 100644 --- a/packages/nx/src/tasks-runner/run-command.ts +++ b/packages/nx/src/tasks-runner/run-command.ts @@ -24,6 +24,7 @@ import { runPreTasksExecution, } from '../project-graph/plugins/tasks-execution-hooks'; import { createProjectGraphAsync } from '../project-graph/project-graph'; +import { normalizeTargetDefaults } from '../project-graph/utils/project-configuration/target-defaults'; import { NxArgs } from '../utils/command-line-utils'; import { handleErrors } from '../utils/handle-errors'; import { isCI } from '../utils/is-ci'; @@ -1233,11 +1234,11 @@ export function getRunnerOptions( nxArgs: NxArgs, isCloudDefault: boolean ): any { - const defaultCacheableOperations = []; + const defaultCacheableOperations: string[] = []; - for (const key in nxJson.targetDefaults) { - if (nxJson.targetDefaults[key].cache) { - defaultCacheableOperations.push(key); + for (const entry of normalizeTargetDefaults(nxJson.targetDefaults)) { + if (entry?.cache && entry.target) { + defaultCacheableOperations.push(entry.target); } } diff --git a/packages/playwright/src/generators/configuration/configuration.ts b/packages/playwright/src/generators/configuration/configuration.ts index a79f5e99f63ca..b1b3eb221cbf9 100644 --- a/packages/playwright/src/generators/configuration/configuration.ts +++ b/packages/playwright/src/generators/configuration/configuration.ts @@ -6,11 +6,13 @@ import { getPackageManagerCommand, joinPathFragments, logger, + type NxJsonConfiguration, offsetFromRoot, output, readNxJson, readProjectConfiguration, runTasksInSerial, + type TargetConfiguration, toJS, Tree, updateJson, @@ -19,6 +21,7 @@ import { workspaceRoot, writeJson, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { resolveImportPath } from '@nx/devkit/src/generators/project-name-and-root-utils'; import { promptWhenInteractive } from '@nx/devkit/src/generators/prompt'; import { getRelativePathToRootTsConfig } from '@nx/js'; @@ -357,17 +360,30 @@ function setupE2ETargetDefaults(tree: Tree) { } // E2e targets depend on all their project's sources + production sources of dependencies - nxJson.targetDefaults ??= {}; - const productionFileSet = !!nxJson.namedInputs?.production; - nxJson.targetDefaults.e2e ??= {}; - nxJson.targetDefaults.e2e.cache ??= true; - nxJson.targetDefaults.e2e.inputs ??= [ - 'default', - productionFileSet ? '^production' : '^default', - ]; - - updateNxJson(tree, nxJson); + const patch: Partial = {}; + if (!findExistingE2eDefault(nxJson.targetDefaults)?.cache) { + patch.cache = true; + } + if (!findExistingE2eDefault(nxJson.targetDefaults)?.inputs) { + patch.inputs = ['default', productionFileSet ? '^production' : '^default']; + } + if (Object.keys(patch).length > 0) { + upsertTargetDefault(tree, { target: 'e2e', ...patch }); + } +} + +function findExistingE2eDefault( + td: NxJsonConfiguration['targetDefaults'] +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === 'e2e' && e.projects === undefined && e.source === undefined + ); + } + return td['e2e']; } function addE2eTarget(tree: Tree, options: ConfigurationGeneratorSchema) { diff --git a/packages/react/src/generators/application/application.spec.ts b/packages/react/src/generators/application/application.spec.ts index 308f162f8ed67..fa8642c930eba 100644 --- a/packages/react/src/generators/application/application.spec.ts +++ b/packages/react/src/generators/application/application.spec.ts @@ -1351,7 +1351,22 @@ describe('app', () => { // ASSERT nxJson = readNxJson(tree); - expect(nxJson.targetDefaults.build).toMatchInlineSnapshot(` + const td = nxJson.targetDefaults!; + const buildEntry = Array.isArray(td) + ? td.find( + (e) => + e.target === 'build' && + e.projects === undefined && + e.source === undefined + ) + : td.build; + const { + target: _t, + projects: _p, + source: _s, + ...buildConfig + } = (buildEntry as any) ?? {}; + expect(buildConfig).toMatchInlineSnapshot(` { "cache": true, "dependsOn": [ diff --git a/packages/react/src/generators/application/application.ts b/packages/react/src/generators/application/application.ts index bf8087d4b25b9..bf3461d82ea24 100644 --- a/packages/react/src/generators/application/application.ts +++ b/packages/react/src/generators/application/application.ts @@ -4,10 +4,13 @@ import { joinPathFragments, readNxJson, runTasksInSerial, + type TargetConfiguration, + type TargetDefaults, Tree, updateJson, updateNxJson, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { initGenerator as jsInitGenerator } from '@nx/js'; import { logShowProjectCommand } from '@nx/devkit/src/utils/log-show-project-command'; import { @@ -121,16 +124,16 @@ export async function applicationGeneratorInternal( if (!options.addPlugin) { const nxJson = readNxJson(tree); - nxJson.targetDefaults ??= {}; - if (!Object.keys(nxJson.targetDefaults).includes('build')) { - nxJson.targetDefaults.build = { + const existing = findBuildDefault(nxJson.targetDefaults); + if (!existing) { + upsertTargetDefault(tree, { + target: 'build', cache: true, dependsOn: ['^build'], - }; - } else if (!nxJson.targetDefaults.build.dependsOn) { - nxJson.targetDefaults.build.dependsOn = ['^build']; + }); + } else if (!existing.dependsOn) { + upsertTargetDefault(tree, { target: 'build', dependsOn: ['^build'] }); } - updateNxJson(tree, nxJson); } if (options.bundler === 'webpack') { @@ -260,4 +263,19 @@ export async function applicationGeneratorInternal( return runTasksInSerial(...tasks); } +function findBuildDefault( + td: TargetDefaults | undefined +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === 'build' && + e.projects === undefined && + e.source === undefined + ); + } + return td['build']; +} + export default applicationGenerator; diff --git a/packages/react/src/generators/setup-ssr/setup-ssr.ts b/packages/react/src/generators/setup-ssr/setup-ssr.ts index eb71b37c00169..e7844d3b15953 100644 --- a/packages/react/src/generators/setup-ssr/setup-ssr.ts +++ b/packages/react/src/generators/setup-ssr/setup-ssr.ts @@ -12,6 +12,7 @@ import { updateNxJson, updateProjectConfiguration, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import type * as ts from 'typescript'; import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; @@ -229,9 +230,7 @@ export async function setupSsrGenerator(tree: Tree, options: Schema) { 'server', ]; } - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults['server'] ??= {}; - nxJson.targetDefaults.server.cache = true; + upsertTargetDefault(tree, { target: 'server', cache: true }); generateFiles(tree, join(__dirname, 'files'), projectRoot, { tmpl: '', diff --git a/packages/vite/src/generators/vitest/vitest.spec.ts b/packages/vite/src/generators/vitest/vitest.spec.ts index 2fa6cc58a69d4..50a559554ff4d 100644 --- a/packages/vite/src/generators/vitest/vitest.spec.ts +++ b/packages/vite/src/generators/vitest/vitest.spec.ts @@ -360,7 +360,16 @@ describe('vitest generator', () => { }); const nxJson = readNxJson(appTree); - expect(nxJson.targetDefaults.test.dependsOn).toStrictEqual(['^build']); + const td = nxJson.targetDefaults!; + const testEntry = Array.isArray(td) + ? td.find( + (e) => + e.target === 'test' && + e.projects === undefined && + e.source === undefined + ) + : td.test; + expect(testEntry?.dependsOn).toStrictEqual(['^build']); }); it(`should not duplicate the test target dependency on the deps' build target`, async () => { @@ -375,7 +384,16 @@ describe('vitest generator', () => { }); const nxJson = readNxJson(appTree); - expect(nxJson.targetDefaults.test.dependsOn).toStrictEqual(['^build']); + const td = nxJson.targetDefaults!; + const testEntry = Array.isArray(td) + ? td.find( + (e) => + e.target === 'test' && + e.projects === undefined && + e.source === undefined + ) + : td.test; + expect(testEntry?.dependsOn).toStrictEqual(['^build']); }); }); }); diff --git a/packages/vite/src/migrations/update-22-2-0/migrate-vitest-to-vitest-package.spec.ts b/packages/vite/src/migrations/update-22-2-0/migrate-vitest-to-vitest-package.spec.ts index c2d9e3eda6705..a5f13789cbabd 100644 --- a/packages/vite/src/migrations/update-22-2-0/migrate-vitest-to-vitest-package.spec.ts +++ b/packages/vite/src/migrations/update-22-2-0/migrate-vitest-to-vitest-package.spec.ts @@ -3,6 +3,7 @@ import { readJson, readNxJson, readProjectConfiguration, + type TargetDefaultsRecord, type Tree, updateNxJson, writeJson, @@ -10,6 +11,11 @@ import { import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import migrateVitestToVitestPackage from './migrate-vitest-to-vitest-package'; +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +const td = (n: { targetDefaults?: unknown }) => + n.targetDefaults as TargetDefaultsRecord; + describe('migrate-vitest-to-vitest-package', () => { let tree: Tree; @@ -422,8 +428,8 @@ describe('migrate-vitest-to-vitest-package', () => { await migrateVitestToVitestPackage(tree); const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults['@nx/vite:test']).toBeUndefined(); - expect(updatedNxJson.targetDefaults['@nx/vitest:test']).toEqual({ + expect(td(updatedNxJson)['@nx/vite:test']).toBeUndefined(); + expect(td(updatedNxJson)['@nx/vitest:test']).toEqual({ cache: true, inputs: ['default', '^production'], }); @@ -444,8 +450,8 @@ describe('migrate-vitest-to-vitest-package', () => { await migrateVitestToVitestPackage(tree); const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults['@nx/vite:test']).toBeUndefined(); - expect(updatedNxJson.targetDefaults['@nx/vitest:test']).toEqual({ + expect(td(updatedNxJson)['@nx/vite:test']).toBeUndefined(); + expect(td(updatedNxJson)['@nx/vitest:test']).toEqual({ cache: true, inputs: ['default'], }); @@ -463,7 +469,7 @@ describe('migrate-vitest-to-vitest-package', () => { await migrateVitestToVitestPackage(tree); const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults).toEqual({ + expect(td(updatedNxJson)).toEqual({ build: { cache: true, }, @@ -484,7 +490,7 @@ describe('migrate-vitest-to-vitest-package', () => { await migrateVitestToVitestPackage(tree); const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults.test).toEqual({ + expect(td(updatedNxJson).test).toEqual({ executor: '@nx/vitest:test', cache: true, inputs: ['default', '^production'], @@ -508,12 +514,12 @@ describe('migrate-vitest-to-vitest-package', () => { const updatedNxJson = readNxJson(tree); // Executor-keyed should be migrated to new key - expect(updatedNxJson.targetDefaults['@nx/vite:test']).toBeUndefined(); - expect(updatedNxJson.targetDefaults['@nx/vitest:test']).toEqual({ + expect(td(updatedNxJson)['@nx/vite:test']).toBeUndefined(); + expect(td(updatedNxJson)['@nx/vitest:test']).toEqual({ cache: true, }); // Target-name-keyed should have executor updated in place - expect(updatedNxJson.targetDefaults.test).toEqual({ + expect(td(updatedNxJson).test).toEqual({ executor: '@nx/vitest:test', inputs: ['default'], }); @@ -551,7 +557,7 @@ describe('migrate-vitest-to-vitest-package', () => { // Should not fail, just skip targetDefaults migration const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults).toBeUndefined(); + expect(td(updatedNxJson)).toBeUndefined(); }); }); }); diff --git a/packages/vitest/src/generators/configuration/configuration.ts b/packages/vitest/src/generators/configuration/configuration.ts index 6918f88757a07..5d0bbabdbe21d 100644 --- a/packages/vitest/src/generators/configuration/configuration.ts +++ b/packages/vitest/src/generators/configuration/configuration.ts @@ -11,10 +11,13 @@ import { readNxJson, readProjectConfiguration, runTasksInSerial, + type TargetConfiguration, + type TargetDefaults, Tree, updateJson, updateNxJson, } from '@nx/devkit'; +import { upsertTargetDefault } from '@nx/devkit/src/generators/target-defaults-utils'; import { initGenerator as jsInitGenerator } from '@nx/js'; import { getProjectType, @@ -245,13 +248,11 @@ getTestBed().initTestEnvironment( // so we need to setup the task pipeline accordingly const nxJson = readNxJson(tree); const testTarget = schema.testTarget ?? 'test'; - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults[testTarget] ??= {}; - nxJson.targetDefaults[testTarget].dependsOn ??= []; - nxJson.targetDefaults[testTarget].dependsOn = Array.from( - new Set([...nxJson.targetDefaults[testTarget].dependsOn, '^build']) + const existing = findTestDefault(nxJson?.targetDefaults, testTarget); + const dependsOn = Array.from( + new Set([...(existing?.dependsOn ?? []), '^build']) ); - updateNxJson(tree, nxJson); + upsertTargetDefault(tree, { target: testTarget, dependsOn }); } const devDependencies = await getCoverageProviderDependency( @@ -523,4 +524,20 @@ function findBuildTarget(project: { return project.targets?.build ?? null; } +function findTestDefault( + td: TargetDefaults | undefined, + target: string +): Partial | undefined { + if (!td) return undefined; + if (Array.isArray(td)) { + return td.find( + (e) => + e.target === target && + e.projects === undefined && + e.source === undefined + ); + } + return td[target]; +} + export default configurationGenerator; diff --git a/packages/vitest/src/migrations/update-22-6-0/prefix-reports-directory-with-project-root.spec.ts b/packages/vitest/src/migrations/update-22-6-0/prefix-reports-directory-with-project-root.spec.ts index 2611d4f7d2414..f2160b97c6a94 100644 --- a/packages/vitest/src/migrations/update-22-6-0/prefix-reports-directory-with-project-root.spec.ts +++ b/packages/vitest/src/migrations/update-22-6-0/prefix-reports-directory-with-project-root.spec.ts @@ -2,12 +2,18 @@ import { addProjectConfiguration, readNxJson, readProjectConfiguration, + type TargetDefaultsRecord, type Tree, updateNxJson, } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import prefixReportsDirectoryWithProjectRoot from './prefix-reports-directory-with-project-root'; +// This migration ran before targetDefaults supported the array shape, so +// the test fixtures all use the legacy record shape. +const td = (n: { targetDefaults?: unknown }) => + n.targetDefaults as TargetDefaultsRecord; + describe('prefix-reports-directory-with-project-root', () => { let tree: Tree; @@ -267,7 +273,7 @@ describe('prefix-reports-directory-with-project-root', () => { const updatedNxJson = readNxJson(tree); expect( - updatedNxJson.targetDefaults['@nx/vitest:test'].options.reportsDirectory + td(updatedNxJson)['@nx/vitest:test'].options.reportsDirectory ).toBe('{projectRoot}/coverage'); }); @@ -286,7 +292,7 @@ describe('prefix-reports-directory-with-project-root', () => { prefixReportsDirectoryWithProjectRoot(tree); const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults.test.options.reportsDirectory).toBe( + expect(td(updatedNxJson).test.options.reportsDirectory).toBe( '{projectRoot}/coverage' ); }); @@ -305,9 +311,9 @@ describe('prefix-reports-directory-with-project-root', () => { prefixReportsDirectoryWithProjectRoot(tree); const updatedNxJson = readNxJson(tree); - expect( - updatedNxJson.targetDefaults['@nx/jest:jest'].options.reportsDirectory - ).toBe('coverage'); + expect(td(updatedNxJson)['@nx/jest:jest'].options.reportsDirectory).toBe( + 'coverage' + ); }); it('should handle workspace without targetDefaults', () => { @@ -319,7 +325,7 @@ describe('prefix-reports-directory-with-project-root', () => { prefixReportsDirectoryWithProjectRoot(tree); const updatedNxJson = readNxJson(tree); - expect(updatedNxJson.targetDefaults).toBeUndefined(); + expect(td(updatedNxJson)).toBeUndefined(); }); }); diff --git a/packages/workspace/src/generators/new/generate-workspace-files.ts b/packages/workspace/src/generators/new/generate-workspace-files.ts index fa43c348c91d8..e4bc0e5a06c31 100644 --- a/packages/workspace/src/generators/new/generate-workspace-files.ts +++ b/packages/workspace/src/generators/new/generate-workspace-files.ts @@ -234,15 +234,10 @@ function createNxJson( defaultBase, targetDefaults: process.env.NX_ADD_PLUGINS === 'false' - ? { - build: { - cache: true, - dependsOn: ['^build'], - }, - lint: { - cache: true, - }, - } + ? [ + { target: 'build', cache: true, dependsOn: ['^build'] }, + { target: 'lint', cache: true }, + ] : undefined, analytics, }; @@ -257,7 +252,21 @@ function createNxJson( sharedGlobals: [], }; if (process.env.NX_ADD_PLUGINS === 'false') { - nxJson.targetDefaults.build.inputs = ['production', '^production']; + const td = nxJson.targetDefaults; + if (Array.isArray(td)) { + const buildIdx = td.findIndex( + (e) => + e.target === 'build' && + e.projects === undefined && + e.source === undefined + ); + if (buildIdx >= 0) { + td[buildIdx] = { + ...td[buildIdx], + inputs: ['production', '^production'], + }; + } + } nxJson.useInferencePlugins = false; } }