fix(linter): infer lint tasks with inputs using {workspaceRoot} to support nested projects (#31488)

## Current Behavior

When running the `lint` task on a project that contains nested projects,
the task runs over all the files, including the ones inside the nested
projects, but the task cache status is not affected by changes to the
files in nested projects. This only happens when the inputs are defined
with `{projectRoot}/...` (what the `@nx/eslint/plugin` infers). The
`{projectRoot}` token scopes the files inside the project without files
in other nested projects. While the `{workspaceRoot}` token would
include every file and wouldn't scope them to any particular project.

## Expected Behavior

The `@nx/eslint/plugin` should infer `lint` tasks with their inputs
using the `{workspaceRoot}` token to support nested projects. This would
be more aligned with what the tool itself does, which runs over all the
files inside the project root regardless of them being inside nested Nx
projects.

Additionally, the difference in behavior between `{workspaceRoot}` and
`{projectRoot}` should be documented.

## Related Issue(s)

Fixes #31264
This commit is contained in:
Leosvel Pérez Espinosa 2025-06-06 18:52:15 +02:00 committed by GitHub
parent e73a1411a0
commit 6613dd29ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 63 additions and 29 deletions

View File

@ -39,6 +39,15 @@ Source file inputs are defined like this:
Source file inputs must be prefixed with either `{projectRoot}` or `{workspaceRoot}` to distinguish where the paths should be resolved from. `{workspaceRoot}` should only appear in the beginning of an input but `{projectRoot}` and `{projectName}` can be specified later in the input to interpolate the root or name of the project into the input location. Source file inputs must be prefixed with either `{projectRoot}` or `{workspaceRoot}` to distinguish where the paths should be resolved from. `{workspaceRoot}` should only appear in the beginning of an input but `{projectRoot}` and `{projectName}` can be specified later in the input to interpolate the root or name of the project into the input location.
{% callout type="info" title="Token Behavior with Nested Projects" %}
These tokens behave differently when dealing with nested projects:
- `{projectRoot}/**/*` only includes files that are assigned to the specific project. Files in nested projects are excluded.
- `{workspaceRoot}/path/**/*` includes all files matching the pattern in the entire workspace, including files from nested projects.
For example, in a structure like `packages/parent/nested-child/`, using `{projectRoot}/**/*` for the `parent` project will exclude files from `nested-child`, while `{workspaceRoot}/packages/parent/**/*` will include them.
{% /callout %}
Prefixing a source file input with `!` will exclude the files matching the pattern from the set of files used to calculate the hash. Prefixing a source file input with `!` will exclude the files matching the pattern from the set of files used to calculate the hash.
Prefixing a source file input with `^` means this entry applies to the project dependencies of the project, not the project itself. Prefixing a source file input with `^` means this entry applies to the project dependencies of the project, not the project itself.

View File

@ -58,17 +58,37 @@ function postTargetTransformer(
inferredTargetConfiguration: TargetConfiguration inferredTargetConfiguration: TargetConfiguration
): TargetConfiguration { ): TargetConfiguration {
if (target.inputs) { if (target.inputs) {
const inputs = target.inputs.filter( const normalizeInput = (input: string) => {
(input) => return input
typeof input === 'string' && .replace('{workspaceRoot}', '')
![ .replace('{projectRoot}', projectDetails.root)
'default', .replace('{projectName}', projectDetails.projectName)
'{workspaceRoot}/.eslintrc.json', .replace(/^\//, '');
'{workspaceRoot}/.eslintignore', };
'{workspaceRoot}/eslint.config.cjs',
'{workspaceRoot}/eslint.config.mjs', const inputs = target.inputs.filter((input) => {
].includes(input) if (typeof input === 'string') {
); // if the input is a string, check if it is inferred by the plugin
// if it is, filter it out
return !inferredTargetConfiguration.inputs.some(
(inferredInput) =>
typeof inferredInput === 'string' &&
normalizeInput(inferredInput) === normalizeInput(input)
);
} else if ('externalDependencies' in input) {
// if the input is an object with an externalDependencies property,
// check if all the external dependencies are inferred by the plugin
// if they are, filter it out
return !input.externalDependencies.every((externalDependency) =>
inferredTargetConfiguration.inputs.some(
(inferredInput) =>
typeof inferredInput === 'object' &&
'externalDependencies' in inferredInput &&
inferredInput.externalDependencies.includes(externalDependency)
)
);
}
});
if (inputs.length === 0) { if (inputs.length === 0) {
delete target.inputs; delete target.inputs;
} }

View File

@ -110,7 +110,7 @@ describe('@nx/eslint/plugin', () => {
"inputs": [ "inputs": [
"default", "default",
"^default", "^default",
"{projectRoot}/eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -166,7 +166,7 @@ describe('@nx/eslint/plugin', () => {
"inputs": [ "inputs": [
"default", "default",
"^default", "^default",
"{projectRoot}/eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -443,7 +443,7 @@ describe('@nx/eslint/plugin', () => {
"inputs": [ "inputs": [
"default", "default",
"^default", "^default",
"{projectRoot}/.eslintrc.json", "{workspaceRoot}/apps/my-app/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -482,7 +482,7 @@ describe('@nx/eslint/plugin', () => {
"inputs": [ "inputs": [
"default", "default",
"^default", "^default",
"{projectRoot}/.eslintrc.json", "{workspaceRoot}/libs/my-lib/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -586,7 +586,7 @@ describe('@nx/eslint/plugin', () => {
"default", "default",
"^default", "^default",
"{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json", "{workspaceRoot}/apps/my-app/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -626,7 +626,7 @@ describe('@nx/eslint/plugin', () => {
"default", "default",
"^default", "^default",
"{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json", "{workspaceRoot}/libs/my-lib/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -746,7 +746,7 @@ describe('@nx/eslint/plugin', () => {
"default", "default",
"^default", "^default",
"{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json", "{workspaceRoot}/apps/myapp/nested/mylib/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -802,7 +802,7 @@ describe('@nx/eslint/plugin', () => {
"inputs": [ "inputs": [
"default", "default",
"^default", "^default",
"{projectRoot}/eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [
@ -859,7 +859,7 @@ describe('@nx/eslint/plugin', () => {
"inputs": [ "inputs": [
"default", "default",
"^default", "^default",
"{projectRoot}/eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint-rules/**/*",
{ {
"externalDependencies": [ "externalDependencies": [

View File

@ -135,7 +135,12 @@ const internalCreateNodes = async (
); );
const hash = await calculateHashForCreateNodes( const hash = await calculateHashForCreateNodes(
childProjectRoot, childProjectRoot,
options, {
...options,
// change this to bust the cache when making changes that would yield
// different results for the same hash
bust: 1,
},
context, context,
[...parentConfigs, join(childProjectRoot, '.eslintignore')] [...parentConfigs, join(childProjectRoot, '.eslintignore')]
); );
@ -277,7 +282,12 @@ export const createNodesV2: CreateNodesV2<EslintPluginOptions> = [
); );
const hashes = await calculateHashesForCreateNodes( const hashes = await calculateHashesForCreateNodes(
projectRoots, projectRoots,
options, {
...options,
// change this to bust the cache when making changes that would yield
// different results for the same hash
bust: 1,
},
context, context,
projectRoots.map((root) => { projectRoots.map((root) => {
const parentConfigs = eslintConfigFiles.filter((eslintConfig) => const parentConfigs = eslintConfigFiles.filter((eslintConfig) =>
@ -493,14 +503,9 @@ function buildEslintTargets(
'default', 'default',
// Certain lint rules can be impacted by changes to dependencies // Certain lint rules can be impacted by changes to dependencies
'^default', '^default',
...eslintConfigs.map((config) => ...eslintConfigs.map((config) => `{workspaceRoot}/${config}`),
`{workspaceRoot}/${config}`.replace(
`{workspaceRoot}/${projectRoot}`,
isRootProject ? '{projectRoot}/' : '{projectRoot}'
)
),
...(existsSync(join(workspaceRoot, projectRoot, '.eslintignore')) ...(existsSync(join(workspaceRoot, projectRoot, '.eslintignore'))
? ['{projectRoot}/.eslintignore'] ? [join('{workspaceRoot}', projectRoot, '.eslintignore')]
: []), : []),
'{workspaceRoot}/tools/eslint-rules/**/*', '{workspaceRoot}/tools/eslint-rules/**/*',
{ externalDependencies: ['eslint'] }, { externalDependencies: ['eslint'] },