Skip to content

Commit

Permalink
fix(core): name collisions during project inference should not error …
Browse files Browse the repository at this point in the history
…out if corrected by a project.json's name (#18665)
  • Loading branch information
AgentEnder authored Aug 17, 2023
1 parent 47f8b7a commit 94cc716
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 60 deletions.
7 changes: 0 additions & 7 deletions docs/generated/devkit/CreateNodesContext.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Context for [CreateNodesFunction](../../devkit/documents/CreateNodesFunction)
### Properties

- [nxJsonConfiguration](../../devkit/documents/CreateNodesContext#nxjsonconfiguration)
- [projectsConfigurations](../../devkit/documents/CreateNodesContext#projectsconfigurations)
- [workspaceRoot](../../devkit/documents/CreateNodesContext#workspaceroot)

## Properties
Expand All @@ -18,12 +17,6 @@ Context for [CreateNodesFunction](../../devkit/documents/CreateNodesFunction)

---

### projectsConfigurations

`Readonly` **projectsConfigurations**: `Record`<`string`, [`ProjectConfiguration`](../../devkit/documents/ProjectConfiguration)\>

---

### workspaceRoot

`Readonly` **workspaceRoot**: `string`
24 changes: 11 additions & 13 deletions packages/nx/src/generators/utils/project-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
ProjectConfiguration,
ProjectsConfigurations,
} from '../../config/workspace-json-project-json';
import { mergeProjectConfigurationIntoProjectsConfigurations } from '../../project-graph/utils/project-configuration-utils';
import {
mergeProjectConfigurationIntoRootMap,
readProjectConfigurationsFromRootMap,
} from '../../project-graph/utils/project-configuration-utils';
import { retrieveProjectConfigurationPathsWithoutPluginInference } from '../../project-graph/utils/retrieve-workspace-files';
import { output } from '../../utils/output';
import { PackageJson } from '../../utils/package-json';
Expand Down Expand Up @@ -207,26 +210,20 @@ function readAndCombineAllProjectConfigurations(tree: Tree): {
(r) => deletedFiles.indexOf(r) === -1
);

const rootMap: Map<string, string> = new Map();
return projectFiles.reduce((projects, projectFile) => {
const rootMap: Map<string, ProjectConfiguration> = new Map();
for (const projectFile of projectFiles) {
if (basename(projectFile) === 'project.json') {
const json = readJson(tree, projectFile);
const config = buildProjectFromProjectJson(json, projectFile);
mergeProjectConfigurationIntoProjectsConfigurations(
projects,
rootMap,
config,
projectFile
);
mergeProjectConfigurationIntoRootMap(rootMap, config, projectFile);
} else {
const packageJson = readJson<PackageJson>(tree, projectFile);
const config = buildProjectConfigurationFromPackageJson(
packageJson,
projectFile,
readNxJson(tree)
);
mergeProjectConfigurationIntoProjectsConfigurations(
projects,
mergeProjectConfigurationIntoRootMap(
rootMap,
// Inferred targets, tags, etc don't show up when running generators
// This is to help avoid running into issues when trying to update the workspace
Expand All @@ -237,8 +234,9 @@ function readAndCombineAllProjectConfigurations(tree: Tree): {
projectFile
);
}
return projects;
}, {});
}

return readProjectConfigurationsFromRootMap(rootMap);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { TargetConfiguration } from '../../config/workspace-json-project-json';
import {
ProjectConfiguration,
TargetConfiguration,
} from '../../config/workspace-json-project-json';
import {
mergeProjectConfigurationIntoRootMap,
mergeTargetConfigurations,
readProjectConfigurationsFromRootMap,
readTargetDefaultsForTarget,
} from './project-configuration-utils';

Expand Down Expand Up @@ -352,3 +357,146 @@ describe('target defaults', () => {
});
});
});

describe('mergeProjectConfigurationIntoRootMap', () => {
it('should merge targets from different configurations', () => {
const rootMap = new RootMapBuilder()
.addProject({
root: 'libs/lib-a',
name: 'lib-a',
targets: {
echo: {
command: 'echo lib-a',
},
},
})
.getRootMap();
mergeProjectConfigurationIntoRootMap(
rootMap,
{
root: 'libs/lib-a',
name: 'lib-a',
targets: {
build: {
command: 'tsc',
},
},
},
'inferred-project-config-file.ts'
);
expect(rootMap.get('libs/lib-a')).toMatchInlineSnapshot(`
{
"name": "lib-a",
"root": "libs/lib-a",
"targets": {
"build": {
"command": "tsc",
},
"echo": {
"command": "echo lib-a",
},
},
}
`);
});

it("shouldn't overwrite project name, unless merging project from project.json", () => {
const rootMap = new RootMapBuilder()
.addProject({
name: 'bad-name',
root: 'libs/lib-a',
})
.getRootMap();
mergeProjectConfigurationIntoRootMap(
rootMap,
{
name: 'other-bad-name',
root: 'libs/lib-a',
},
'libs/lib-a/package.json'
);
expect(rootMap.get('libs/lib-a').name).toEqual('bad-name');
mergeProjectConfigurationIntoRootMap(
rootMap,
{
name: 'lib-a',
root: 'libs/lib-a',
},
'libs/lib-a/project.json'
);
expect(rootMap.get('libs/lib-a').name).toEqual('lib-a');
});
});

describe('readProjectsConfigurationsFromRootMap', () => {
it('should error if multiple roots point to the same project', () => {
const rootMap = new RootMapBuilder()
.addProject({
name: 'lib',
root: 'apps/lib-a',
})
.addProject({
name: 'lib',
root: 'apps/lib-b',
})
.getRootMap();

expect(() => {
readProjectConfigurationsFromRootMap(rootMap);
}).toThrowErrorMatchingInlineSnapshot(`
"The following projects are defined in multiple locations:
- lib:
- apps/lib-a
- apps/lib-b
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 read root map into standard projects configurations form', () => {
const rootMap = new RootMapBuilder()
.addProject({
name: 'lib-a',
root: 'libs/a',
})
.addProject({
name: 'lib-b',
root: 'libs/b',
})
.addProject({
name: 'lib-shared-b',
root: 'libs/shared/b',
})
.getRootMap();
expect(readProjectConfigurationsFromRootMap(rootMap))
.toMatchInlineSnapshot(`
{
"lib-a": {
"name": "lib-a",
"root": "libs/a",
},
"lib-b": {
"name": "lib-b",
"root": "libs/b",
},
"lib-shared-b": {
"name": "lib-shared-b",
"root": "libs/shared/b",
},
}
`);
});
});

class RootMapBuilder {
private rootMap: Map<string, ProjectConfiguration> = new Map();

addProject(p: ProjectConfiguration) {
this.rootMap.set(p.root, p);
return this;
}

getRootMap() {
return this.rootMap;
}
}
102 changes: 64 additions & 38 deletions packages/nx/src/project-graph/utils/project-configuration-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,42 @@ import {
ProjectConfiguration,
TargetConfiguration,
} from '../../config/workspace-json-project-json';
import { readJsonFile } from '../../utils/fileutils';
import { NX_PREFIX } from '../../utils/logger';
import { NxPluginV2 } from '../../utils/nx-plugin';
import { workspaceRoot } from '../../utils/workspace-root';

import minimatch = require('minimatch');
export function mergeProjectConfigurationIntoProjectsConfigurations(
// projectName -> ProjectConfiguration
existingProjects: Record<string, ProjectConfiguration>,
// projectRoot -> projectName
existingProjectRootMap: Map<string, string>,

export function mergeProjectConfigurationIntoRootMap(
projectRootMap: Map<string, ProjectConfiguration>,
project: ProjectConfiguration,
// project.json is a special case, so we need to detect it.
file: string
): void {
let matchingProjectName = existingProjectRootMap.get(project.root);
const matchingProject = projectRootMap.get(project.root);

if (!matchingProjectName) {
existingProjects[project.name] = project;
existingProjectRootMap.set(project.root, project.name);
if (!matchingProject) {
projectRootMap.set(project.root, project);
return;
// There are some special cases for handling project.json - mainly
// that it should override any name the project already has.
} else if (
project.name &&
project.name !== matchingProjectName &&
project.name !== matchingProject.name &&
basename(file) === 'project.json'
) {
// Copy config to new name
existingProjects[project.name] = existingProjects[matchingProjectName];
// Update name in project config
existingProjects[project.name].name = project.name;
// Update root map to point to new name
existingProjectRootMap[project.root] = project.name;
// Remove entry for old name
delete existingProjects[matchingProjectName];
// Update name that config should be merged to
matchingProjectName = project.name;
// `name` inside project.json overrides any names from
// inference plugins
matchingProject.name = project.name;
}

const matchingProject = existingProjects[matchingProjectName];

// This handles top level properties that are overwritten. `srcRoot`, `projectType`, or fields that Nx doesn't know about.
// This handles top level properties that are overwritten.
// e.g. `srcRoot`, `projectType`, or other fields that shouldn't be extended
// Note: `name` is set specifically here to keep it from changing. The name is
// always determined by the first inference plugin to ID a project, unless it has
// a project.json in which case it was already updated above.
const updatedProjectConfiguration = {
...matchingProject,
...project,
name: matchingProjectName,
name: matchingProject.name,
};

// The next blocks handle properties that should be themselves merged (e.g. targets, tags, and implicit dependencies)
Expand Down Expand Up @@ -83,11 +72,10 @@ export function mergeProjectConfigurationIntoProjectsConfigurations(
};
}

if (updatedProjectConfiguration.name !== matchingProject.name) {
delete existingProjects[matchingProject.name];
}
existingProjects[updatedProjectConfiguration.name] =
updatedProjectConfiguration;
projectRootMap.set(
updatedProjectConfiguration.root,
updatedProjectConfiguration
);
}

export function buildProjectsConfigurationsFromProjectPathsAndPlugins(
Expand All @@ -99,8 +87,7 @@ export function buildProjectsConfigurationsFromProjectPathsAndPlugins(
projects: Record<string, ProjectConfiguration>;
externalNodes: Record<string, ProjectGraphExternalNode>;
} {
const projectRootMap: Map<string, string> = new Map();
const projects: Record<string, ProjectConfiguration> = {};
const projectRootMap: Map<string, ProjectConfiguration> = new Map();
const externalNodes: Record<string, ProjectGraphExternalNode> = {};

// We push the nx core node builder onto the end, s.t. it overwrites any user specified behavior
Expand All @@ -119,13 +106,11 @@ export function buildProjectsConfigurationsFromProjectPathsAndPlugins(
if (minimatch(file, pattern)) {
const { projects: projectNodes, externalNodes: pluginExternalNodes } =
configurationConstructor(file, {
projectsConfigurations: projects,
nxJsonConfiguration: nxJson,
workspaceRoot: root,
});
for (const node in projectNodes) {
mergeProjectConfigurationIntoProjectsConfigurations(
projects,
mergeProjectConfigurationIntoRootMap(
projectRootMap,
projectNodes[node],
file
Expand All @@ -136,7 +121,48 @@ export function buildProjectsConfigurationsFromProjectPathsAndPlugins(
}
}

return { projects, externalNodes };
return {
projects: readProjectConfigurationsFromRootMap(projectRootMap),
externalNodes,
};
}

export function readProjectConfigurationsFromRootMap(
projectRootMap: Map<string, ProjectConfiguration>
) {
const projects: Record<string, ProjectConfiguration> = {};
// If there are projects that have the same name, that is an error.
// This object tracks name -> (all roots of projects with that name)
// to provide better error messaging.
const errors: Map<string, string[]> = new Map();

for (const [root, configuration] of projectRootMap.entries()) {
if (!configuration.name) {
throw new Error(`Project at ${root} has no name provided.`);
} else if (configuration.name in projects) {
let rootErrors = errors.get(configuration.name) ?? [
projects[configuration.name].root,
];
rootErrors.push(root);
errors.set(configuration.name, rootErrors);
} else {
projects[configuration.name] = configuration;
}
}

if (errors.size > 0) {
throw new Error(
[
`The following projects are defined in multiple locations:`,
...Array.from(errors.entries()).map(([project, roots]) =>
[`- ${project}: `, ...roots.map((r) => ` - ${r}`)].join('\n')
),
'',
"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.",
].join('\n')
);
}
return projects;
}

export function mergeTargetConfigurations(
Expand Down
Loading

1 comment on commit 94cc716

@vercel
Copy link

@vercel vercel bot commented on 94cc716 Aug 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

nx-dev – ./

nx-five.vercel.app
nx.dev
nx-dev-nrwl.vercel.app
nx-dev-git-master-nrwl.vercel.app

Please sign in to comment.