Skip to content

Commit

Permalink
feat(core): Handle when an existing plugin begins to fail with the ne…
Browse files Browse the repository at this point in the history
…w imported project
  • Loading branch information
xiongemi committed Dec 20, 2024
1 parent d7ffba8 commit e6561ed
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 29 deletions.
10 changes: 9 additions & 1 deletion docs/generated/devkit/AggregateCreateNodesError.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ It allows Nx to recieve partial results and continue processing for better UX.
- [message](../../devkit/documents/AggregateCreateNodesError#message): string
- [name](../../devkit/documents/AggregateCreateNodesError#name): string
- [partialResults](../../devkit/documents/AggregateCreateNodesError#partialresults): CreateNodesResultV2
- [pluginIndex](../../devkit/documents/AggregateCreateNodesError#pluginindex): number
- [stack](../../devkit/documents/AggregateCreateNodesError#stack): string
- [prepareStackTrace](../../devkit/documents/AggregateCreateNodesError#preparestacktrace): Function
- [stackTraceLimit](../../devkit/documents/AggregateCreateNodesError#stacktracelimit): number
Expand All @@ -34,7 +35,7 @@ It allows Nx to recieve partial results and continue processing for better UX.

### constructor

**new AggregateCreateNodesError**(`errors`, `partialResults`): [`AggregateCreateNodesError`](../../devkit/documents/AggregateCreateNodesError)
**new AggregateCreateNodesError**(`errors`, `partialResults`, `pluginIndex?`): [`AggregateCreateNodesError`](../../devkit/documents/AggregateCreateNodesError)

Throwing this error from a `createNodesV2` function will allow Nx to continue processing and recieve partial results from your plugin.

Expand All @@ -44,6 +45,7 @@ Throwing this error from a `createNodesV2` function will allow Nx to continue pr
| :--------------- | :------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `errors` | [file: string, error: Error][] | An array of tuples that represent errors encountered when processing a given file. An example entry might look like ['path/to/project.json', [Error: 'Invalid JSON. Unexpected token 'a' in JSON at position 0]] |
| `partialResults` | [`CreateNodesResultV2`](../../devkit/documents/CreateNodesResultV2) | The partial results of the `createNodesV2` function. This should be the results for each file that didn't encounter an issue. |
| `pluginIndex?` | `number` | - |

#### Returns

Expand Down Expand Up @@ -124,6 +126,12 @@ The partial results of the `createNodesV2` function. This should be the results

---

### pluginIndex

`Optional` **pluginIndex**: `number`

---

### stack

`Optional` **stack**: `string`
Expand Down
37 changes: 25 additions & 12 deletions packages/nx/src/command-line/import/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
configurePlugins,
runPackageManagerInstallPlugins,
} from '../init/configure-plugins';
import {
checkCompatibleWithPlugins,
updatePluginsInNxJson,
} from '../init/implementation/check-compatible-with-plugins';

const importRemoteName = '__tmp_nx_import__';

Expand Down Expand Up @@ -286,21 +290,30 @@ export async function importHandler(options: ImportOptions) {
packageManager,
destinationGitClient
);

if (installed && plugins.length > 0) {
installed = await runPluginsInstall(plugins, pmc, destinationGitClient);
if (installed) {
const { succeededPlugins } = await configurePlugins(
plugins,
updatePackageScripts,
pmc,
workspaceRoot,
verbose
);
if (succeededPlugins.length > 0) {
if (installed) {
// Check compatibility with existing plugins for the workspace included new imported projects
if (nxJson.plugins?.length > 0) {
const incompatiblePlugins = await checkCompatibleWithPlugins();
if (Object.keys(incompatiblePlugins).length > 0) {
updatePluginsInNxJson(workspaceRoot, incompatiblePlugins);
await destinationGitClient.amendCommit();
}
}
if (plugins.length > 0) {
installed = await runPluginsInstall(plugins, pmc, destinationGitClient);
if (installed) {
const { succeededPlugins } = await configurePlugins(
plugins,
updatePackageScripts,
pmc,
workspaceRoot,
verbose
);
if (succeededPlugins.length > 0) {
await destinationGitClient.amendCommit();
}
}
}
}

console.log(await destinationGitClient.showStat());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
AggregateCreateNodesError,
MergeNodesError,
ProjectGraphError,
ProjectsWithNoNameError,
} from '../../../project-graph/error-types';
import { checkCompatibleWithPlugins } from './check-compatible-with-plugins';
import { retrieveProjectConfigurations } from '../../../project-graph/utils/retrieve-workspace-files';

jest.mock('../../../project-graph/plugins/internal-api', () => ({
loadNxPlugins: jest.fn().mockReturnValue([[], []]),
}));
jest.mock('../../../project-graph/utils/retrieve-workspace-files', () => ({
retrieveProjectConfigurations: jest.fn(),
}));

describe('checkCompatibleWithPlugins', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return empty object if no errors are thrown', async () => {
(retrieveProjectConfigurations as any).mockReturnValueOnce(
Promise.resolve({})
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({});
});

it('should return empty object if error is not ProjectConfigurationsError', async () => {
(retrieveProjectConfigurations as any).mockReturnValueOnce(
Promise.reject(new Error('random error'))
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({});
});

it('should return empty object if error is ProjectsWithNoNameError', async () => {
(retrieveProjectConfigurations as any).mockReturnValueOnce(
Promise.reject(
new ProjectGraphError(
[
new ProjectsWithNoNameError([], {
project1: { root: 'root1' },
}),
],
undefined,
undefined
)
)
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({});
});

it('should return incompatible plugin with excluded files if error is AggregateCreateNodesError', async () => {
(retrieveProjectConfigurations as any).mockReturnValueOnce(
Promise.reject(
new ProjectGraphError(
[
new AggregateCreateNodesError(
[
['file1', undefined],
['file2', undefined],
],
[],
0
),
],
undefined,
undefined
)
)
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({ 0: new Set(['file1', 'file2']) });
});

it('should return true if error is MergeNodesError', async () => {
(retrieveProjectConfigurations as any).mockReturnValueOnce(
Promise.reject(
new ProjectGraphError(
[
new MergeNodesError({
file: 'file2',
pluginName: 'plugin2',
error: new Error(),
pluginIndex: 1,
}),
],
undefined,
undefined
)
)
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({ 1: new Set(['file2']) });
});

it('should handle multiple errors', async () => {
(retrieveProjectConfigurations as any).mockReturnValueOnce(
Promise.reject(
new ProjectGraphError(
[
new ProjectsWithNoNameError([], {
project1: { root: 'root1' },
}),
new AggregateCreateNodesError([], [], 0),
new AggregateCreateNodesError(
[
['file1', undefined],
['file2', undefined],
],
[],
0
),
new MergeNodesError({
file: 'file2',
pluginName: 'plugin2',
error: new Error(),
pluginIndex: 2,
}),
new AggregateCreateNodesError(
[
['file3', undefined],
['file4', undefined],
],
[],
2
),
],
undefined,
undefined
)
)
);
const result = await checkCompatibleWithPlugins();
expect(result).toEqual({
0: new Set(['file1', 'file2']),
2: new Set(['file2', 'file3', 'file4']),
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { bold } from 'chalk';

import { NxJsonConfiguration } from '../../../config/nx-json';
import {
isAggregateCreateNodesError,
isMergeNodesError,
ProjectGraphError,
} from '../../../project-graph/error-types';
import { workspaceRoot } from '../../../utils/workspace-root';
import { readJsonFile, writeJsonFile } from '../../../utils/fileutils';
import { output } from '../../../utils/output';
import { createProjectGraphAsync } from '../../../project-graph/project-graph';

/**
* This function checks if the imported project is compatible with the plugins.
* @returns a map of plugin names to files that are incompatible with the plugins
*/
export async function checkCompatibleWithPlugins(): Promise<{
[pluginName: string]: Set<string>;
}> {
let pluginToExcludeFiles: {
[pluginIndex: number]: Set<string>;
} = {};
try {
await createProjectGraphAsync();
} catch (projectGraphError) {
if (projectGraphError instanceof ProjectGraphError) {
projectGraphError.getErrors()?.forEach((error) => {
const { pluginIndex, excludeFiles } =
findPluginAndFilesWithError(error) ?? {};
if (pluginIndex === undefined || !excludeFiles?.length) {
return;
}
pluginToExcludeFiles[pluginIndex] ??= new Set();
excludeFiles.forEach((file) =>
pluginToExcludeFiles[pluginIndex].add(file)
);
});
}
}
return pluginToExcludeFiles;
}

/**
* This function finds the plugin name and files that caused the error.
* @param error the error to find the plugin name and files for
* @returns pluginName and excludeFiles if found, otherwise undefined
*/
function findPluginAndFilesWithError(
error: any
): { pluginIndex: number; excludeFiles: string[] } | undefined {
let pluginIndex: number | undefined;
let excludeFiles: string[] = [];
if (isAggregateCreateNodesError(error)) {
pluginIndex = error.pluginIndex;
excludeFiles = error.errors?.map((error) => error?.[0]) ?? [];
} else if (isMergeNodesError(error)) {
pluginIndex = error.pluginIndex;
excludeFiles = [error.file];
}
excludeFiles = excludeFiles.filter(Boolean);
return {
pluginIndex,
excludeFiles,
};
}

/**
* This function updates the plugins in the nx.json file with the given plugin names and files to exclude.
*/
export function updatePluginsInNxJson(
root: string = workspaceRoot,
pluginToExcludeFiles: {
[pluginIndex: number]: Set<string>;
}
): void {
const nxJsonPath = join(root, 'nx.json');
if (!existsSync(nxJsonPath)) {
return;
}
let nxJson: NxJsonConfiguration;
try {
nxJson = readJsonFile<NxJsonConfiguration>(nxJsonPath);
} catch {
// If there is an error reading the nx.json file, no need to update it
return;
}
if (!Object.keys(pluginToExcludeFiles)?.length || !nxJson?.plugins?.length) {
return;
}
Object.entries(pluginToExcludeFiles).forEach(
([pluginIndex, excludeFiles]) => {
let plugin = nxJson.plugins[pluginIndex];
if (!plugin || excludeFiles.size === 0) {
return;
}
if (typeof plugin === 'string') {
plugin = { plugin };
}
output.warn({
title: `Incompatible files found for no. ${
parseInt(pluginIndex) + 1
} plugin in nx.json`,
bodyLines: [
`Added the following files to the exclude list for ${plugin.plugin}:`,
...Array.from(excludeFiles).map((file) => ` - ${bold(file)}`),
],
});

(plugin.exclude ?? []).forEach((e) => excludeFiles.add(e));
plugin.exclude = Array.from(excludeFiles);
nxJson.plugins[pluginIndex] = plugin;
}
);
writeJsonFile(nxJsonPath, nxJson);
}
7 changes: 6 additions & 1 deletion packages/nx/src/project-graph/error-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ export class AggregateCreateNodesError extends Error {
*/
constructor(
public readonly errors: Array<[file: string | null, error: Error]>,
public readonly partialResults: Awaited<ReturnType<CreateNodesFunctionV2>>
public readonly partialResults: Awaited<ReturnType<CreateNodesFunctionV2>>,
public pluginIndex?: number
) {
super('Failed to create nodes');
this.name = this.constructor.name;
Expand All @@ -245,22 +246,26 @@ export class AggregateCreateNodesError extends Error {
export class MergeNodesError extends Error {
file: string;
pluginName: string;
pluginIndex: number;

constructor({
file,
pluginName,
error,
pluginIndex,
}: {
file: string;
pluginName: string;
error: Error;
pluginIndex?: number;
}) {
const msg = `The nodes created from ${file} by the "${pluginName}" could not be merged into the project graph:`;

super(msg, { cause: error });
this.name = this.constructor.name;
this.file = file;
this.pluginName = pluginName;
this.pluginIndex = pluginIndex;
this.stack = `${this.message}\n${indentString(
formatErrorStackAndCause(error),
2
Expand Down
Loading

0 comments on commit e6561ed

Please # to comment.