fix(linter): refactor pcv3 plugin, expose configFiles on context (#21677)

This commit is contained in:
James Henry 2024-03-16 00:29:13 +04:00 committed by GitHub
parent cea7e93c86
commit 1fe5b98f45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 668 additions and 248 deletions

View File

@ -1,6 +1,6 @@
# Type alias: CreateNodes\<T\> # Type alias: CreateNodes\<T\>
Ƭ **CreateNodes**\<`T`\>: readonly [projectFilePattern: string, createNodesFunction: CreateNodesFunction\<T\>] Ƭ **CreateNodes**\<`T`\>: readonly [configFilePattern: string, createNodesFunction: CreateNodesFunction\<T\>]
A pair of file patterns and [CreateNodesFunction](../../devkit/documents/CreateNodesFunction) A pair of file patterns and [CreateNodesFunction](../../devkit/documents/CreateNodesFunction)

View File

@ -6,11 +6,20 @@ Context for [CreateNodesFunction](../../devkit/documents/CreateNodesFunction)
### Properties ### Properties
- [configFiles](../../devkit/documents/CreateNodesContext#configfiles): string[]
- [nxJsonConfiguration](../../devkit/documents/CreateNodesContext#nxjsonconfiguration): NxJsonConfiguration<string[] | "\*"> - [nxJsonConfiguration](../../devkit/documents/CreateNodesContext#nxjsonconfiguration): NxJsonConfiguration<string[] | "\*">
- [workspaceRoot](../../devkit/documents/CreateNodesContext#workspaceroot): string - [workspaceRoot](../../devkit/documents/CreateNodesContext#workspaceroot): string
## Properties ## Properties
### configFiles
`Readonly` **configFiles**: `string`[]
The subset of configuration files which match the createNodes pattern
---
### nxJsonConfiguration ### nxJsonConfiguration
`Readonly` **nxJsonConfiguration**: [`NxJsonConfiguration`](../../devkit/documents/NxJsonConfiguration)\<`string`[] \| `"*"`\> `Readonly` **nxJsonConfiguration**: [`NxJsonConfiguration`](../../devkit/documents/NxJsonConfiguration)\<`string`[] \| `"*"`\>

View File

@ -34,6 +34,7 @@ describe('@nx/cypress/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
}); });

View File

@ -48,6 +48,7 @@ export async function replaceProjectConfigurationsWithPlugin<T = unknown>(
const nodes = await createNodesFunction(configFile, pluginOptions, { const nodes = await createNodesFunction(configFile, pluginOptions, {
workspaceRoot: tree.root, workspaceRoot: tree.root,
nxJsonConfiguration: readNxJson(tree), nxJsonConfiguration: readNxJson(tree),
configFiles,
}); });
const node = nodes.projects[Object.keys(nodes.projects)[0]]; const node = nodes.projects[Object.keys(nodes.projects)[0]];

View File

@ -25,11 +25,18 @@ export async function updatePackageScripts(
const nxJson = readNxJson(tree); const nxJson = readNxJson(tree);
const [pattern, createNodes] = createNodesTuple; const [pattern, createNodes] = createNodesTuple;
const files = glob(tree, [pattern]); const matchingFiles = glob(tree, [pattern]);
for (const file of files) { for (const file of matchingFiles) {
const projectRoot = getProjectRootFromConfigFile(file); const projectRoot = getProjectRootFromConfigFile(file);
await processProject(tree, projectRoot, file, createNodes, nxJson); await processProject(
tree,
projectRoot,
file,
createNodes,
nxJson,
matchingFiles
);
} }
} }
@ -38,7 +45,8 @@ async function processProject(
projectRoot: string, projectRoot: string,
projectConfigurationFile: string, projectConfigurationFile: string,
createNodesFunction: CreateNodesFunction, createNodesFunction: CreateNodesFunction,
nxJsonConfiguration: NxJsonConfiguration nxJsonConfiguration: NxJsonConfiguration,
configFiles: string[]
) { ) {
const packageJsonPath = `${projectRoot}/package.json`; const packageJsonPath = `${projectRoot}/package.json`;
if (!tree.exists(packageJsonPath)) { if (!tree.exists(packageJsonPath)) {
@ -52,7 +60,11 @@ async function processProject(
const result = await createNodesFunction( const result = await createNodesFunction(
projectConfigurationFile, projectConfigurationFile,
{}, {},
{ nxJsonConfiguration, workspaceRoot } {
nxJsonConfiguration,
workspaceRoot,
configFiles,
}
); );
const targetCommands = getInferredTargetCommands(result); const targetCommands = getInferredTargetCommands(result);

View File

@ -1,23 +1,30 @@
import { CreateNodesContext } from '@nx/devkit'; import 'nx/src/internal-testing-utils/mock-fs';
import { createNodes } from './plugin';
import { vol } from 'memfs';
jest.mock('fs', () => { jest.mock(
const memFs = require('memfs').fs; 'nx/src/utils/workspace-context',
return { (): Partial<typeof import('nx/src/utils/workspace-context')> => {
...memFs, const glob = require('fast-glob');
existsSync: (p) => (p.endsWith('.node') ? true : memFs.existsSync(p)), return {
}; globWithWorkspaceContext(workspaceRoot: string, patterns: string[]) {
}); // This glob will operate on memfs thanks to 'nx/src/internal-testing-utils/mock-fs'
return glob.sync(patterns, { cwd: workspaceRoot });
},
};
}
);
import { CreateNodesContext } from '@nx/devkit';
import { vol } from 'memfs';
import { minimatch } from 'minimatch';
import { createNodes } from './plugin';
describe('@nx/eslint/plugin', () => { describe('@nx/eslint/plugin', () => {
let createNodesFunction = createNodes[1];
let context: CreateNodesContext; let context: CreateNodesContext;
beforeEach(async () => { beforeEach(async () => {
context = { context = {
nxJsonConfiguration: { nxJsonConfiguration: {
// These defaults should be overridden by plugin // These defaults should be overridden by the plugin
targetDefaults: { targetDefaults: {
lint: { lint: {
cache: false, cache: false,
@ -30,6 +37,7 @@ describe('@nx/eslint/plugin', () => {
}, },
}, },
workspaceRoot: '', workspaceRoot: '',
configFiles: [],
}; };
}); });
@ -38,117 +46,433 @@ describe('@nx/eslint/plugin', () => {
jest.resetModules(); jest.resetModules();
}); });
it('should create nodes with default configuration for nested project', () => { it('should not create any nodes when there are no eslint configs', async () => {
const fileSys = { applyFilesToVolAndContext(
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'.eslintrc.json': `{}`,
'package.json': `{}`,
};
vol.fromJSON(fileSys, '');
const nodes = createNodesFunction(
'apps/my-app/project.json',
{ {
targetName: 'lint', 'package.json': `{}`,
'project.json': `{}`,
}, },
context context
); );
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
expect(nodes).toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"projects": { "projects": {},
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/apps/my-app/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/my-app",
},
},
},
},
},
} }
`); `);
}); });
it('should create nodes with default configuration for standalone project', () => { describe('root eslint config only', () => {
const fileSys = { it('should not create any nodes for just a package.json and root level eslint config', async () => {
'apps/my-app/eslint.config.js': `module.exports = []`, applyFilesToVolAndContext(
'apps/my-app/project.json': `{}`, {
'eslint.config.js': `module.exports = []`, '.eslintrc.json': `{}`,
'src/index.ts': `console.log('hello world')`, 'package.json': `{}`,
'package.json': `{}`, },
}; context
vol.fromJSON(fileSys, ''); );
const nodes = createNodesFunction( expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
'package.json', .toMatchInlineSnapshot(`
{ {
targetName: 'lint', "projects": {},
}, }
context `);
); });
expect(nodes).toMatchInlineSnapshot(` it('should not create a node for a root level eslint config when accompanied by a project.json, if no src directory is present', async () => {
{ applyFilesToVolAndContext(
"projects": { {
".": { 'eslint.config.js': `module.exports = {};`,
"targets": { 'project.json': `{}`,
"lint": { },
"cache": true, context
"command": "eslint ./src", );
"inputs": [ // NOTE: It should set ESLINT_USE_FLAT_CONFIG to true because of the use of eslint.config.js
"default", expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
"{workspaceRoot}/eslint.config.js", .toMatchInlineSnapshot(`
"{workspaceRoot}/tools/eslint-rules/**/*", {
{ "projects": {},
"externalDependencies": [ }
"eslint", `);
], });
},
], // Standalone Nx workspace style setup
"options": { it('should create a node for just a package.json and root level eslint config if accompanied by a src directory', async () => {
"cwd": ".", applyFilesToVolAndContext(
"env": { {
"ESLINT_USE_FLAT_CONFIG": "true", '.eslintrc.json': `{}`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
},
context
);
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
".": {
"targets": {
"lint": {
"cache": true,
"command": "eslint ./src",
"inputs": [
"default",
"^default",
"{projectRoot}/eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": ".",
}, },
}, },
}, },
}, },
}, },
}
`);
});
it('should create a node for a nested project (with a project.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
}, },
} context
`); );
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/my-app",
},
},
},
},
},
}
`);
});
it('should create a node for a nested project (with a package.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
},
context
);
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/my-app",
},
},
},
},
},
}
`);
});
it('should not create a node for a nested project (with a package.json and no lintable files) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
},
context
);
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create a node for a nested project (with a project.json and no lintable files) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
},
context
);
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
}); });
it('should not create nodes if no src folder for root', () => { describe('nested eslint configs only', () => {
const fileSys = { it('should create appropriate nodes for nested projects without a root level eslint config', async () => {
'apps/my-app/eslint.config.js': `module.exports = []`, applyFilesToVolAndContext(
'apps/my-app/project.json': `{}`, {
'eslint.config.js': `module.exports = []`, 'apps/my-app/.eslintrc.json': `{}`,
'package.json': `{}`, 'apps/my-app/project.json': `{}`,
}; 'apps/my-app/index.ts': `console.log('hello world')`,
vol.fromJSON(fileSys, ''); 'libs/my-lib/.eslintrc.json': `{}`,
const nodes = createNodesFunction( 'libs/my-lib/project.json': `{}`,
'package.json', 'libs/my-lib/index.ts': `console.log('hello world')`,
{ },
targetName: 'lint', context
}, );
context expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
); .toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/my-app",
},
},
},
},
"libs/my-lib": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "libs/my-lib",
},
},
},
},
},
}
`);
});
});
expect(nodes).toMatchInlineSnapshot(`{}`); describe('root eslint config and nested eslint configs', () => {
it('should create appropriate nodes for just a package.json and root level eslint config combined with nested eslint configs', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'package.json': `{}`,
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
},
context
);
// NOTE: The nested projects have the root level config as an input to their lint targets
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/my-app",
},
},
},
},
"libs/my-lib": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "libs/my-lib",
},
},
},
},
},
}
`);
});
it('should create appropriate nodes for a nested project without its own eslint config but with an orphaned eslint config in its parent hierarchy', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': '{}',
'apps/.eslintrc.json': '{}',
'apps/myapp/project.json': '{}',
'apps/myapp/index.ts': 'console.log("hello world")',
},
context
);
// NOTE: The nested projects have the root level config as an input to their lint targets
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/myapp": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/apps/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/myapp",
},
},
},
},
},
}
`);
});
}); });
}); });
function getMatchingFiles(allConfigFiles: string[]): string[] {
return allConfigFiles.filter((file) =>
minimatch(file, createNodes[0], { dot: true })
);
}
function applyFilesToVolAndContext(
fileSys: Record<string, string>,
context: CreateNodesContext
) {
vol.fromJSON(fileSys, '');
// @ts-expect-error update otherwise readonly property for testing
context.configFiles = getMatchingFiles(Object.keys(fileSys));
}
async function invokeCreateNodesOnMatchingFiles(
context: CreateNodesContext,
targetName: string
) {
const aggregateProjects: Record<string, any> = {};
for (const file of context.configFiles) {
const nodes = await createNodes[1](file, { targetName }, context);
Object.assign(aggregateProjects, nodes.projects);
}
return {
projects: aggregateProjects,
};
}

View File

@ -1,110 +1,158 @@
import { CreateNodes, TargetConfiguration } from '@nx/devkit'; import {
import { dirname, join } from 'path'; CreateNodes,
import { readdirSync } from 'fs'; CreateNodesContext,
CreateNodesResult,
TargetConfiguration,
} from '@nx/devkit';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { combineGlobPatterns } from 'nx/src/utils/globs'; import { combineGlobPatterns } from 'nx/src/utils/globs';
import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context';
import { import {
ESLINT_CONFIG_FILENAMES, ESLINT_CONFIG_FILENAMES,
findBaseEslintFile, baseEsLintConfigFile,
baseEsLintFlatConfigFile,
isFlatConfig, isFlatConfig,
} from '../utils/config-file'; } from '../utils/config-file';
export interface EslintPluginOptions { export interface EslintPluginOptions {
targetName?: string; targetName?: string;
extensions?: string[];
} }
export const createNodes: CreateNodes<EslintPluginOptions> = [ const DEFAULT_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'html', 'vue'];
combineGlobPatterns(['**/project.json', '**/package.json']),
(configFilePath, options, context) => {
const projectRoot = dirname(configFilePath);
export const createNodes: CreateNodes<EslintPluginOptions> = [
combineGlobPatterns([
...ESLINT_CONFIG_FILENAMES.map((f) => `**/${f}`),
baseEsLintConfigFile,
baseEsLintFlatConfigFile,
]),
(configFilePath, options, context) => {
options = normalizeOptions(options); options = normalizeOptions(options);
const eslintConfigs = getEslintConfigsForProject( // Ensure that configFiles are set, e2e-run fails due to them being undefined in CI (does not occur locally)
projectRoot, // TODO(JamesHenry): Further troubleshoot this in CI
context.workspaceRoot (context as any).configFiles = context.configFiles ?? [];
);
if (!eslintConfigs.length) { // Create a Set of all the directories containing eslint configs
return {}; const eslintRoots = new Set(context.configFiles.map(dirname));
} const configDir = dirname(configFilePath);
const childProjectRoots = globWithWorkspaceContext(
context.workspaceRoot,
[
'project.json',
'package.json',
'**/project.json',
'**/package.json',
].map((f) => join(configDir, f))
)
.map((f) => dirname(f))
.filter((childProjectRoot) => {
// Filter out projects under other eslint configs
let root = childProjectRoot;
// Traverse up from the childProjectRoot to either the workspaceRoot or the dir of this config file
while (root !== dirname(root) && root !== dirname(configFilePath)) {
if (eslintRoots.has(root)) {
return false;
}
root = dirname(root);
}
return true;
})
.filter((dir) => {
// Ignore project roots where the project does not contain any lintable files
const lintableFiles = globWithWorkspaceContext(context.workspaceRoot, [
join(dir, `**/*.{${options.extensions.join(',')}}`),
]);
return lintableFiles.length > 0;
});
const uniqueChildProjectRoots = Array.from(new Set(childProjectRoots));
return { return {
projects: { projects: getProjectsUsingESLintConfig(
[projectRoot]: { configFilePath,
targets: buildEslintTargets(eslintConfigs, projectRoot, options), uniqueChildProjectRoots,
}, options,
}, context
),
}; };
}, },
]; ];
function getEslintConfigsForProject( function getProjectsUsingESLintConfig(
projectRoot: string, configFilePath: string,
workspaceRoot: string childProjectRoots: string[],
): string[] { options: EslintPluginOptions,
const detectedConfigs = new Set<string>(); context: CreateNodesContext
const baseConfig = findBaseEslintFile(workspaceRoot); ): CreateNodesResult['projects'] {
if (baseConfig) { const projects: CreateNodesResult['projects'] = {};
detectedConfigs.add(baseConfig);
}
let siblingFiles = readdirSync(join(workspaceRoot, projectRoot)); const rootEslintConfig = context.configFiles.find(
(f) =>
if (projectRoot === '.') { f === baseEsLintConfigFile ||
// If there's no src folder, it's not a standalone project f === baseEsLintFlatConfigFile ||
if (!siblingFiles.includes('src')) {
return [];
}
// If it's standalone but doesn't have eslint config, it's not a lintable
const config = siblingFiles.find((f) =>
ESLINT_CONFIG_FILENAMES.includes(f) ESLINT_CONFIG_FILENAMES.includes(f)
);
if (!config) {
return [];
}
detectedConfigs.add(config);
return Array.from(detectedConfigs);
}
while (projectRoot !== '.') {
// if it has an eslint config it's lintable
const config = siblingFiles.find((f) =>
ESLINT_CONFIG_FILENAMES.includes(f)
);
if (config) {
detectedConfigs.add(`${projectRoot}/${config}`);
return Array.from(detectedConfigs);
}
projectRoot = dirname(projectRoot);
siblingFiles = readdirSync(join(workspaceRoot, projectRoot));
}
// check whether the root has an eslint config
const config = readdirSync(workspaceRoot).find((f) =>
ESLINT_CONFIG_FILENAMES.includes(f)
); );
if (config) {
detectedConfigs.add(config); // Add a lint target for each child project without an eslint config, with the root level config as an input
return Array.from(detectedConfigs); for (const projectRoot of childProjectRoots) {
// If there's no src folder, it's not a standalone project, do not add the target at all
const isStandaloneWorkspace =
projectRoot === '.' &&
existsSync(join(context.workspaceRoot, projectRoot, 'src')) &&
existsSync(join(context.workspaceRoot, projectRoot, 'package.json'));
if (projectRoot === '.' && !isStandaloneWorkspace) {
continue;
}
const eslintConfigs = [configFilePath];
if (rootEslintConfig && !eslintConfigs.includes(rootEslintConfig)) {
eslintConfigs.unshift(rootEslintConfig);
}
projects[projectRoot] = {
targets: buildEslintTargets(
eslintConfigs,
projectRoot,
options,
isStandaloneWorkspace
),
};
} }
return [];
return projects;
} }
function buildEslintTargets( function buildEslintTargets(
eslintConfigs: string[], eslintConfigs: string[],
projectRoot: string, projectRoot: string,
options: EslintPluginOptions options: EslintPluginOptions,
isStandaloneWorkspace = false
) { ) {
const isRootProject = projectRoot === '.'; const isRootProject = projectRoot === '.';
const targets: Record<string, TargetConfiguration> = {}; const targets: Record<string, TargetConfiguration> = {};
const targetConfig: TargetConfiguration = { const targetConfig: TargetConfiguration = {
command: `eslint ${isRootProject ? './src' : '.'}`, command: `eslint ${isRootProject && isStandaloneWorkspace ? './src' : '.'}`,
cache: true, cache: true,
options: { options: {
cwd: projectRoot, cwd: projectRoot,
}, },
inputs: [ inputs: [
'default', 'default',
...eslintConfigs.map((config) => `{workspaceRoot}/${config}`), // Certain lint rules can be impacted by changes to dependencies
'^default',
...eslintConfigs.map((config) =>
`{workspaceRoot}/${config}`.replace(
`{workspaceRoot}/${projectRoot}`,
isRootProject ? '{projectRoot}/' : '{projectRoot}'
)
),
'{workspaceRoot}/tools/eslint-rules/**/*', '{workspaceRoot}/tools/eslint-rules/**/*',
{ externalDependencies: ['eslint'] }, { externalDependencies: ['eslint'] },
], ],
@ -123,5 +171,13 @@ function buildEslintTargets(
function normalizeOptions(options: EslintPluginOptions): EslintPluginOptions { function normalizeOptions(options: EslintPluginOptions): EslintPluginOptions {
options ??= {}; options ??= {};
options.targetName ??= 'lint'; options.targetName ??= 'lint';
// Normalize user input for extensions (strip leading . characters)
if (Array.isArray(options.extensions)) {
options.extensions = options.extensions.map((f) => f.replace(/^\.+/, ''));
} else {
options.extensions = DEFAULT_EXTENSIONS;
}
return options; return options;
} }

View File

@ -14,22 +14,6 @@ export const ESLINT_CONFIG_FILENAMES = [
export const baseEsLintConfigFile = '.eslintrc.base.json'; export const baseEsLintConfigFile = '.eslintrc.base.json';
export const baseEsLintFlatConfigFile = 'eslint.base.config.js'; export const baseEsLintFlatConfigFile = 'eslint.base.config.js';
export function findBaseEslintFile(workspaceRoot = ''): string | null {
if (existsSync(joinPathFragments(workspaceRoot, baseEsLintConfigFile))) {
return baseEsLintConfigFile;
}
if (existsSync(joinPathFragments(workspaceRoot, baseEsLintFlatConfigFile))) {
return baseEsLintFlatConfigFile;
}
for (const file of ESLINT_CONFIG_FILENAMES) {
if (existsSync(joinPathFragments(workspaceRoot, file))) {
return file;
}
}
return null;
}
export function isFlatConfig(configFilePath: string): boolean { export function isFlatConfig(configFilePath: string): boolean {
return configFilePath.endsWith('.config.js'); return configFilePath.endsWith('.config.js');
} }

View File

@ -20,6 +20,7 @@ describe('@nx/jest/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
await tempFs.createFiles({ await tempFs.createFiles({

View File

@ -18,6 +18,7 @@ describe('@nx/next/plugin', () => {
}, },
}, },
workspaceRoot: '', workspaceRoot: '',
configFiles: [],
}; };
}); });
@ -53,6 +54,7 @@ describe('@nx/next/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(

View File

@ -38,6 +38,7 @@ describe('@nx/nuxt/plugin', () => {
}, },
}, },
workspaceRoot: '', workspaceRoot: '',
configFiles: [],
}; };
}); });
@ -72,6 +73,7 @@ describe('@nx/nuxt/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(

View File

@ -14,6 +14,7 @@ describe('nx project.json plugin', () => {
context = { context = {
nxJsonConfiguration: {}, nxJsonConfiguration: {},
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}; };
}); });

View File

@ -12,6 +12,7 @@ describe('nx project.json plugin', () => {
context = { context = {
nxJsonConfiguration: {}, nxJsonConfiguration: {},
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}; };
}); });

View File

@ -20,6 +20,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}; };
}); });
@ -109,6 +110,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}) })
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
@ -156,6 +158,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}) })
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
@ -200,6 +203,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}) })
).toMatchInlineSnapshot(`{}`); ).toMatchInlineSnapshot(`{}`);
}); });
@ -230,6 +234,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}) })
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
@ -278,6 +283,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}); });
const { targets } = result.projects['.']; const { targets } = result.projects['.'];
@ -321,6 +327,7 @@ describe('target-defaults plugin', () => {
}, },
}, },
workspaceRoot: '/root', workspaceRoot: '/root',
configFiles: [],
}); });
const { targets } = result.projects['.']; const { targets } = result.projects['.'];

View File

@ -182,7 +182,7 @@ export { readNxJson, workspaceLayout } from '../config/configuration';
* TODO(v19): Remove this function. * TODO(v19): Remove this function.
*/ */
function getProjectsSyncNoInference(root: string, nxJson: NxJsonConfiguration) { function getProjectsSyncNoInference(root: string, nxJson: NxJsonConfiguration) {
const projectFiles = retrieveProjectConfigurationPaths( const allConfigFiles = retrieveProjectConfigurationPaths(
root, root,
getDefaultPluginsSync(root) getDefaultPluginsSync(root)
); );
@ -199,11 +199,15 @@ function getProjectsSyncNoInference(root: string, nxJson: NxJsonConfiguration) {
if (!pattern) { if (!pattern) {
continue; continue;
} }
for (const file of projectFiles) { const matchingConfigFiles = allConfigFiles.filter((file) =>
minimatch(file, pattern, { dot: true })
);
for (const file of matchingConfigFiles) {
if (minimatch(file, pattern, { dot: true })) { if (minimatch(file, pattern, { dot: true })) {
let r = createNodes(file, options, { let r = createNodes(file, options, {
nxJsonConfiguration: nxJson, nxJsonConfiguration: nxJson,
workspaceRoot: root, workspaceRoot: root,
configFiles: matchingConfigFiles,
}) as CreateNodesResult; }) as CreateNodesResult;
for (const node in r.projects) { for (const node in r.projects) {
const project = { const project = {

View File

@ -193,13 +193,13 @@ export type ConfigurationResult = {
* Transforms a list of project paths into a map of project configurations. * Transforms a list of project paths into a map of project configurations.
* *
* @param nxJson The NxJson configuration * @param nxJson The NxJson configuration
* @param projectFiles A list of files identified as projects * @param workspaceFiles A list of non-ignored workspace files
* @param plugins The plugins that should be used to infer project configuration * @param plugins The plugins that should be used to infer project configuration
* @param root The workspace root * @param root The workspace root
*/ */
export function buildProjectsConfigurationsFromProjectPathsAndPlugins( export function buildProjectsConfigurationsFromProjectPathsAndPlugins(
nxJson: NxJsonConfiguration, nxJson: NxJsonConfiguration,
projectFiles: string[], // making this parameter allows devkit to pick up newly created projects workspaceFiles: string[], // making this parameter allows devkit to pick up newly created projects
plugins: LoadedNxPlugin[], plugins: LoadedNxPlugin[],
root: string = workspaceRoot root: string = workspaceRoot
): Promise<ConfigurationResult> { ): Promise<ConfigurationResult> {
@ -222,54 +222,57 @@ export function buildProjectsConfigurationsFromProjectPathsAndPlugins(
continue; continue;
} }
for (const file of projectFiles) { const matchingConfigFiles: string[] = workspaceFiles.filter(
performance.mark(`${plugin.name}:createNodes:${file} - start`); minimatch.filter(pattern, { dot: true })
if (minimatch(file, pattern, { dot: true })) { );
try {
let r = createNodes(file, options, {
nxJsonConfiguration: nxJson,
workspaceRoot: root,
});
if (r instanceof Promise) { for (const file of matchingConfigFiles) {
pluginResults.push( performance.mark(`${plugin.name}:createNodes:${file} - start`);
r try {
.catch((e) => { let r = createNodes(file, options, {
performance.mark(`${plugin.name}:createNodes:${file} - end`); nxJsonConfiguration: nxJson,
throw new CreateNodesError( workspaceRoot: root,
`Unable to create nodes for ${file} using plugin ${plugin.name}.`, configFiles: matchingConfigFiles,
e });
);
}) if (r instanceof Promise) {
.then((r) => { pluginResults.push(
performance.mark(`${plugin.name}:createNodes:${file} - end`); r
performance.measure( .catch((e) => {
`${plugin.name}:createNodes:${file}`, performance.mark(`${plugin.name}:createNodes:${file} - end`);
`${plugin.name}:createNodes:${file} - start`, throw new CreateNodesError(
`${plugin.name}:createNodes:${file} - end` `Unable to create nodes for ${file} using plugin ${plugin.name}.`,
); e
return { ...r, file, pluginName: plugin.name }; );
}) })
); .then((r) => {
} else { performance.mark(`${plugin.name}:createNodes:${file} - end`);
performance.mark(`${plugin.name}:createNodes:${file} - end`); performance.measure(
performance.measure( `${plugin.name}:createNodes:${file}`,
`${plugin.name}:createNodes:${file}`, `${plugin.name}:createNodes:${file} - start`,
`${plugin.name}:createNodes:${file} - start`, `${plugin.name}:createNodes:${file} - end`
`${plugin.name}:createNodes:${file} - end` );
); return { ...r, file, pluginName: plugin.name };
pluginResults.push({ })
...r,
file,
pluginName: plugin.name,
});
}
} catch (e) {
throw new CreateNodesError(
`Unable to create nodes for ${file} using plugin ${plugin.name}.`,
e
); );
} else {
performance.mark(`${plugin.name}:createNodes:${file} - end`);
performance.measure(
`${plugin.name}:createNodes:${file}`,
`${plugin.name}:createNodes:${file} - start`,
`${plugin.name}:createNodes:${file} - end`
);
pluginResults.push({
...r,
file,
pluginName: plugin.name,
});
} }
} catch (e) {
throw new CreateNodesError(
`Unable to create nodes for ${file} using plugin ${plugin.name}.`,
e
);
} }
} }
// If there are no promises (counter undefined) or all promises have resolved (counter === 0) // If there are no promises (counter undefined) or all promises have resolved (counter === 0)

View File

@ -116,12 +116,12 @@ function _retrieveProjectConfigurations(
plugins: LoadedNxPlugin[] plugins: LoadedNxPlugin[]
): Promise<RetrievedGraphNodes> { ): Promise<RetrievedGraphNodes> {
const globPatterns = configurationGlobs(plugins); const globPatterns = configurationGlobs(plugins);
const projectFiles = globWithWorkspaceContext(workspaceRoot, globPatterns); const workspaceFiles = globWithWorkspaceContext(workspaceRoot, globPatterns);
return createProjectConfigurations( return createProjectConfigurations(
workspaceRoot, workspaceRoot,
nxJson, nxJson,
projectFiles, workspaceFiles,
plugins plugins
); );
} }
@ -152,7 +152,8 @@ export async function retrieveProjectConfigurationsWithoutPluginInference(
return projectsWithoutPluginCache.get(cacheKey); return projectsWithoutPluginCache.get(cacheKey);
} }
const projectFiles = globWithWorkspaceContext(root, projectGlobPatterns); const projectFiles =
globWithWorkspaceContext(root, projectGlobPatterns) ?? [];
const { projects } = await createProjectConfigurations( const { projects } = await createProjectConfigurations(
root, root,
nxJson, nxJson,

View File

@ -32,7 +32,7 @@ export function getIgnoredGlobs(
} }
export function getAlwaysIgnore(root?: string) { export function getAlwaysIgnore(root?: string) {
const paths = ['node_modules', '**/node_modules', '.git']; const paths = ['node_modules', '**/node_modules', '.git', '.nx', '.vscode'];
return root ? paths.map((x) => joinPathFragments(root, x)) : paths; return root ? paths.map((x) => joinPathFragments(root, x)) : paths;
} }

View File

@ -50,6 +50,10 @@ import { TargetDefaultsPlugin } from '../plugins/target-defaults/target-defaults
export interface CreateNodesContext { export interface CreateNodesContext {
readonly nxJsonConfiguration: NxJsonConfiguration; readonly nxJsonConfiguration: NxJsonConfiguration;
readonly workspaceRoot: string; readonly workspaceRoot: string;
/**
* The subset of configuration files which match the createNodes pattern
*/
readonly configFiles: string[];
} }
/** /**
@ -78,7 +82,7 @@ export interface CreateNodesResult {
* A pair of file patterns and {@link CreateNodesFunction} * A pair of file patterns and {@link CreateNodesFunction}
*/ */
export type CreateNodes<T = unknown> = readonly [ export type CreateNodes<T = unknown> = readonly [
projectFilePattern: string, configFilePattern: string,
createNodesFunction: CreateNodesFunction<T> createNodesFunction: CreateNodesFunction<T>
]; ];

View File

@ -24,6 +24,7 @@ describe('@nx/playwright/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
}); });

View File

@ -34,6 +34,7 @@ describe('@nx/remix/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(
'package.json', 'package.json',
@ -89,6 +90,7 @@ module.exports = {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(

View File

@ -25,6 +25,7 @@ describe('@nx/rollup/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync('package.json', JSON.stringify({ name: 'mylib' })); tempFs.createFileSync('package.json', JSON.stringify({ name: 'mylib' }));
@ -93,6 +94,7 @@ module.exports = config;
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(

View File

@ -17,6 +17,7 @@ describe('@nx/storybook/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(
'my-app/project.json', 'my-app/project.json',

View File

@ -19,6 +19,7 @@ describe('@nx/webpack/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
tempFs.createFileSync( tempFs.createFileSync(