feat(linter): migrate to create-nodes-v2 (#26302)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

## Current Behavior
No cache for eslint nodes

## Expected Behavior
v2 cache for eslint nodes

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #

---------

Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>
This commit is contained in:
Craigory Coppola 2024-06-03 20:01:03 -04:00 committed by GitHub
parent cf0142d711
commit 0594debfef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 198 additions and 107 deletions

View File

@ -5,8 +5,8 @@ import {
type TargetConfiguration, type TargetConfiguration,
type Tree, type Tree,
} from '@nx/devkit'; } from '@nx/devkit';
import { createNodes, EslintPluginOptions } from '../../plugins/plugin'; import { createNodesV2, EslintPluginOptions } from '../../plugins/plugin';
import { migrateExecutorToPluginV1 } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator';
import { targetOptionsToCliMap } from './lib/target-options-map'; import { targetOptionsToCliMap } from './lib/target-options-map';
import { interpolate } from 'nx/src/tasks-runner/utils'; import { interpolate } from 'nx/src/tasks-runner/utils';
@ -19,26 +19,26 @@ export async function convertToInferred(tree: Tree, options: Schema) {
const projectGraph = await createProjectGraphAsync(); const projectGraph = await createProjectGraphAsync();
const migratedProjectsModern = const migratedProjectsModern =
await migrateExecutorToPluginV1<EslintPluginOptions>( await migrateExecutorToPlugin<EslintPluginOptions>(
tree, tree,
projectGraph, projectGraph,
'@nx/eslint:lint', '@nx/eslint:lint',
'@nx/eslint/plugin', '@nx/eslint/plugin',
(targetName) => ({ targetName }), (targetName) => ({ targetName }),
postTargetTransformer, postTargetTransformer,
createNodes, createNodesV2,
options.project options.project
); );
const migratedProjectsLegacy = const migratedProjectsLegacy =
await migrateExecutorToPluginV1<EslintPluginOptions>( await migrateExecutorToPlugin<EslintPluginOptions>(
tree, tree,
projectGraph, projectGraph,
'@nrwl/linter:eslint', '@nrwl/linter:eslint',
'@nx/eslint/plugin', '@nx/eslint/plugin',
(targetName) => ({ targetName }), (targetName) => ({ targetName }),
postTargetTransformer, postTargetTransformer,
createNodes, createNodesV2,
options.project options.project
); );

View File

@ -8,10 +8,10 @@ import {
Tree, Tree,
updateNxJson, updateNxJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { addPluginV1 } from '@nx/devkit/src/utils/add-plugin'; import { addPlugin } from '@nx/devkit/src/utils/add-plugin';
import { eslintVersion, nxVersion } from '../../utils/versions'; import { eslintVersion, nxVersion } from '../../utils/versions';
import { findEslintFile } from '../utils/eslint-file'; import { findEslintFile } from '../utils/eslint-file';
import { createNodes } from '../../plugins/plugin'; import { createNodesV2 } from '../../plugins/plugin';
import { hasEslintPlugin } from '../utils/plugin'; import { hasEslintPlugin } from '../utils/plugin';
export interface LinterInitOptions { export interface LinterInitOptions {
@ -73,11 +73,11 @@ export async function initEsLint(
]; ];
if (rootEslintFile && options.addPlugin && !hasPlugin) { if (rootEslintFile && options.addPlugin && !hasPlugin) {
await addPluginV1( await addPlugin(
tree, tree,
graph, graph,
'@nx/eslint/plugin', '@nx/eslint/plugin',
createNodes, createNodesV2,
{ {
targetName: lintTargetNames, targetName: lintTargetNames,
}, },
@ -94,11 +94,11 @@ export async function initEsLint(
updateProductionFileset(tree); updateProductionFileset(tree);
if (options.addPlugin) { if (options.addPlugin) {
await addPluginV1( await addPlugin(
tree, tree,
graph, graph,
'@nx/eslint/plugin', '@nx/eslint/plugin',
createNodes, createNodesV2,
{ {
targetName: lintTargetNames, targetName: lintTargetNames,
}, },

View File

@ -1,13 +1,21 @@
import { CreateNodesContext } from '@nx/devkit'; import { CreateNodesContextV2 } from '@nx/devkit';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { createNodes } from './plugin'; import { createNodesV2 } from './plugin';
import { mkdirSync, rmdirSync } from 'fs';
jest.mock('nx/src/utils/cache-directory', () => ({
...jest.requireActual('nx/src/utils/cache-directory'),
projectGraphCacheDirectory: 'tmp/project-graph-cache',
}));
describe('@nx/eslint/plugin', () => { describe('@nx/eslint/plugin', () => {
let context: CreateNodesContext; let context: CreateNodesContextV2;
let tempFs: TempFs; let tempFs: TempFs;
let configFiles: string[] = [];
beforeEach(async () => { beforeEach(async () => {
mkdirSync('tmp/project-graph-cache', { recursive: true });
tempFs = new TempFs('eslint-plugin'); tempFs = new TempFs('eslint-plugin');
context = { context = {
nxJsonConfiguration: { nxJsonConfiguration: {
@ -24,7 +32,6 @@ describe('@nx/eslint/plugin', () => {
}, },
}, },
workspaceRoot: tempFs.tempDir, workspaceRoot: tempFs.tempDir,
configFiles: [],
}; };
}); });
@ -32,6 +39,7 @@ describe('@nx/eslint/plugin', () => {
jest.resetModules(); jest.resetModules();
tempFs.cleanup(); tempFs.cleanup();
tempFs = null; tempFs = null;
rmdirSync('tmp/project-graph-cache', { recursive: true });
}); });
it('should not create any nodes when there are no eslint configs', async () => { it('should not create any nodes when there are no eslint configs', async () => {
@ -617,27 +625,30 @@ describe('@nx/eslint/plugin', () => {
function createFiles(fileSys: Record<string, string>) { function createFiles(fileSys: Record<string, string>) {
tempFs.createFilesSync(fileSys); tempFs.createFilesSync(fileSys);
// @ts-expect-error update otherwise readonly property for testing configFiles = getMatchingFiles(Object.keys(fileSys));
context.configFiles = getMatchingFiles(Object.keys(fileSys)); }
function getMatchingFiles(allConfigFiles: string[]): string[] {
return allConfigFiles.filter((file) =>
minimatch(file, createNodesV2[0], { dot: true })
);
}
async function invokeCreateNodesOnMatchingFiles(
context: CreateNodesContextV2,
targetName: string
) {
const aggregateProjects: Record<string, any> = {};
const results = await createNodesV2[1](
configFiles,
{ targetName },
context
);
for (const [, nodes] of results) {
Object.assign(aggregateProjects, nodes.projects);
}
return {
projects: aggregateProjects,
};
} }
}); });
function getMatchingFiles(allConfigFiles: string[]): string[] {
return allConfigFiles.filter((file) =>
minimatch(file, createNodes[0], { dot: true })
);
}
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

@ -2,12 +2,20 @@ import {
CreateNodes, CreateNodes,
CreateNodesContext, CreateNodesContext,
CreateNodesResult, CreateNodesResult,
CreateNodesV2,
TargetConfiguration, TargetConfiguration,
createNodesFromFiles,
logger,
readJsonFile,
writeJsonFile,
} from '@nx/devkit'; } from '@nx/devkit';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, join, normalize, sep } from 'node:path'; import { dirname, join, normalize, sep } 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 {
globWithWorkspaceContext,
hashWithWorkspaceContext,
} from 'nx/src/utils/workspace-context';
import { import {
ESLINT_CONFIG_FILENAMES, ESLINT_CONFIG_FILENAMES,
baseEsLintConfigFile, baseEsLintConfigFile,
@ -16,6 +24,9 @@ import {
} from '../utils/config-file'; } from '../utils/config-file';
import { resolveESLintClass } from '../utils/resolve-eslint-class'; import { resolveESLintClass } from '../utils/resolve-eslint-class';
import { gte } from 'semver'; import { gte } from 'semver';
import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory';
import { hashObject } from 'nx/src/hasher/file-hasher';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
export interface EslintPluginOptions { export interface EslintPluginOptions {
targetName?: string; targetName?: string;
@ -23,91 +34,160 @@ export interface EslintPluginOptions {
} }
const DEFAULT_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'html', 'vue']; const DEFAULT_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'html', 'vue'];
const ESLINT_CONFIG_GLOB = combineGlobPatterns([
...ESLINT_CONFIG_FILENAMES.map((f) => `**/${f}`),
baseEsLintConfigFile,
baseEsLintFlatConfigFile,
]);
export const createNodes: CreateNodes<EslintPluginOptions> = [ type EslintProjects = Awaited<ReturnType<typeof getProjectsUsingESLintConfig>>;
combineGlobPatterns([
...ESLINT_CONFIG_FILENAMES.map((f) => `**/${f}`),
baseEsLintConfigFile,
baseEsLintFlatConfigFile,
]),
async (configFilePath, options, context) => {
options = normalizeOptions(options);
const configDir = dirname(configFilePath);
// Ensure that configFiles are set, e2e-run fails due to them being undefined in CI (does not occur locally) function readTargetsCache(cachePath: string): Record<string, EslintProjects> {
// TODO(JamesHenry): Further troubleshoot this in CI return existsSync(cachePath) ? readJsonFile(cachePath) : {};
(context as any).configFiles = context.configFiles ?? []; }
// Create a Set of all the directories containing eslint configs, and a function writeTargetsToCache(
// list of globs to exclude from child projects cachePath: string,
const eslintRoots = new Set(); results: Record<string, EslintProjects>
const nestedEslintRootPatterns: string[] = []; ) {
for (const configFile of context.configFiles) { writeJsonFile(cachePath, results);
const eslintRootDir = dirname(configFile); }
eslintRoots.add(eslintRootDir);
if (eslintRootDir !== configDir && isSubDir(configDir, eslintRootDir)) { const internalCreateNodes = async (
nestedEslintRootPatterns.push(`${eslintRootDir}/**/*`); configFilePath: string,
} options: EslintPluginOptions,
context: CreateNodesContext,
projectsCache: Record<string, CreateNodesResult['projects']>
): Promise<CreateNodesResult> => {
options = normalizeOptions(options);
const configDir = dirname(configFilePath);
// Ensure that configFiles are set, e2e-run fails due to them being undefined in CI (does not occur locally)
// TODO(JamesHenry): Further troubleshoot this in CI
(context as any).configFiles = context.configFiles ?? [];
// Create a Set of all the directories containing eslint configs, and a
// list of globs to exclude from child projects
const eslintRoots = new Set();
const nestedEslintRootPatterns: string[] = [];
for (const configFile of context.configFiles) {
const eslintRootDir = dirname(configFile);
eslintRoots.add(eslintRootDir);
if (eslintRootDir !== configDir && isSubDir(configDir, eslintRootDir)) {
nestedEslintRootPatterns.push(`${eslintRootDir}/**/*`);
} }
}
const projectFiles = await globWithWorkspaceContext( const projectFiles = await globWithWorkspaceContext(
context.workspaceRoot, context.workspaceRoot,
[ ['project.json', 'package.json', '**/project.json', '**/package.json'].map(
'project.json', (f) => join(configDir, f)
'package.json', ),
'**/project.json', nestedEslintRootPatterns.length ? nestedEslintRootPatterns : undefined
'**/package.json', );
].map((f) => join(configDir, f)), // dedupe and sort project roots by depth for more efficient traversal
nestedEslintRootPatterns.length ? nestedEslintRootPatterns : undefined const dedupedProjectRoots = Array.from(
); new Set(projectFiles.map((f) => dirname(f)))
// dedupe and sort project roots by depth for more efficient traversal ).sort((a, b) => (a !== b && isSubDir(a, b) ? -1 : 1));
const dedupedProjectRoots = Array.from( const excludePatterns = dedupedProjectRoots.map((root) => `${root}/**/*`);
new Set(projectFiles.map((f) => dirname(f)))
).sort((a, b) => (a !== b && isSubDir(a, b) ? -1 : 1));
const excludePatterns = dedupedProjectRoots.map((root) => `${root}/**/*`);
const ESLint = await resolveESLintClass(isFlatConfig(configFilePath)); const ESLint = await resolveESLintClass(isFlatConfig(configFilePath));
const eslintVersion = ESLint.version; const eslintVersion = ESLint.version;
const childProjectRoots = new Set<string>(); const childProjectRoots = new Set<string>();
await Promise.all( const projects: CreateNodesResult['projects'] = {};
dedupedProjectRoots.map(async (childProjectRoot, index) => { await Promise.all(
// anything after is either a nested project or a sibling project, can be excluded dedupedProjectRoots.map(async (childProjectRoot, index) => {
const nestedProjectRootPatterns = excludePatterns.slice(index + 1); // anything after is either a nested project or a sibling project, can be excluded
const nestedProjectRootPatterns = excludePatterns.slice(index + 1);
// Ignore project roots where the project does not contain any lintable files // Ignore project roots where the project does not contain any lintable files
const lintableFiles = await globWithWorkspaceContext( const lintableFiles = await globWithWorkspaceContext(
context.workspaceRoot, context.workspaceRoot,
[join(childProjectRoot, `**/*.{${options.extensions.join(',')}}`)], [join(childProjectRoot, `**/*.{${options.extensions.join(',')}}`)],
// exclude nested eslint roots and nested project roots // exclude nested eslint roots and nested project roots
[...nestedEslintRootPatterns, ...nestedProjectRootPatterns] [...nestedEslintRootPatterns, ...nestedProjectRootPatterns]
); );
const eslint = new ESLint({
cwd: join(context.workspaceRoot, childProjectRoot), const parentConfigs = context.configFiles.filter((eslintConfig) =>
}); isSubDir(childProjectRoot, dirname(eslintConfig))
for (const file of lintableFiles) { );
if ( const hash = await calculateHashForCreateNodes(
!(await eslint.isPathIgnored(join(context.workspaceRoot, file))) childProjectRoot,
) { options,
childProjectRoots.add(childProjectRoot); context,
break; [...parentConfigs, join(childProjectRoot, '.eslintignore')]
} );
if (projectsCache[hash]) {
// We can reuse the projects in the cache.
Object.assign(projects, projectsCache[hash]);
return;
}
const eslint = new ESLint({
cwd: join(context.workspaceRoot, childProjectRoot),
});
for (const file of lintableFiles) {
if (!(await eslint.isPathIgnored(join(context.workspaceRoot, file)))) {
childProjectRoots.add(childProjectRoot);
break;
} }
}) }
);
const uniqueChildProjectRoots = Array.from(childProjectRoots); const uniqueChildProjectRoots = Array.from(childProjectRoots);
return { const projectsForRoot = getProjectsUsingESLintConfig(
projects: getProjectsUsingESLintConfig(
configFilePath, configFilePath,
uniqueChildProjectRoots, uniqueChildProjectRoots,
eslintVersion, eslintVersion,
options, options,
context context
), );
};
if (Object.keys(projectsForRoot).length > 0) {
Object.assign(projects, projectsForRoot);
// Store those projects into the cache;
projectsCache[hash] = projectsForRoot;
}
})
);
return {
projects,
};
};
export const createNodesV2: CreateNodesV2<EslintPluginOptions> = [
ESLINT_CONFIG_GLOB,
async (configFiles, options, context) => {
const optionsHash = hashObject(options);
const cachePath = join(
projectGraphCacheDirectory,
`eslint-${optionsHash}.hash`
);
const targetsCache = readTargetsCache(cachePath);
try {
return await createNodesFromFiles(
(configFile, options, context) =>
internalCreateNodes(configFile, options, context, targetsCache),
configFiles,
options,
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
}
},
];
export const createNodes: CreateNodes<EslintPluginOptions> = [
ESLINT_CONFIG_GLOB,
(configFilePath, options, context) => {
logger.warn(
'`createNodes` is deprecated. Update your plugin to utilize createNodesV2 instead. In Nx 20, this will change to the createNodesV2 API.'
);
return internalCreateNodes(configFilePath, options, context, {});
}, },
]; ];