feat(linter): add rule for dependency checking (#17581)

This commit is contained in:
Miroslav Jonaš 2023-06-23 16:26:44 +02:00 committed by GitHub
parent 1891addc09
commit ef8c4ed095
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2016 additions and 49 deletions

View File

@ -30,8 +30,12 @@
}, },
"overrides": [ "overrides": [
{ {
"files": ["**/executors/**/schema.json", "**/generators/**/schema.json"], "files": ["*.json"],
"parser": "jsonc-eslint-parser", "parser": "jsonc-eslint-parser",
"rules": {}
},
{
"files": ["**/executors/**/schema.json", "**/generators/**/schema.json"],
"rules": { "rules": {
"@nx/workspace/valid-schema-description": "error" "@nx/workspace/valid-schema-description": "error"
} }

View File

@ -9,6 +9,7 @@ import {
runCLI, runCLI,
uniq, uniq,
updateFile, updateFile,
updateJson,
} from '@nx/e2e/utils'; } from '@nx/e2e/utils';
import * as ts from 'typescript'; import * as ts from 'typescript';
@ -435,6 +436,92 @@ describe('Linter', () => {
); );
}); });
}); });
describe('dependency checks', () => {
beforeAll(() => {
updateJson(`libs/${mylib}/.eslintrc.json`, (json) => {
json.overrides = [
...json.overrides,
{
files: ['*.json'],
parser: 'jsonc-eslint-parser',
rules: {
'@nx/dependency-checks': 'error',
},
},
];
return json;
});
updateJson(`libs/${mylib}/project.json`, (json) => {
json.targets.lint.options.lintFilePatterns = [
`libs/${mylib}/**/*.ts`,
`libs/${mylib}/project.json`,
`libs/${mylib}/package.json`,
];
return json;
});
});
it('should report dependency check issues', () => {
const rootPackageJson = readJson('package.json');
const nxVersion = rootPackageJson.devDependencies.nx;
const swcCoreVersion = rootPackageJson.devDependencies['@swc/core'];
const swcHelpersVersion = rootPackageJson.dependencies['@swc/helpers'];
let out = runCLI(`lint ${mylib}`, { silenceError: true });
expect(out).toContain('All files pass linting');
// make an explict dependency to nx
updateFile(
`libs/${mylib}/src/lib/${mylib}.ts`,
(content) =>
`import { names } from '@nx/devkit';\n\n` +
content.replace(/return .*;/, `return names(${mylib}).className;`)
);
// output should now report missing dependencies section
out = runCLI(`lint ${mylib}`, { silenceError: true });
expect(out).toContain(
'Dependency sections are missing from the "package.json"'
);
// should fix the missing section issue
out = runCLI(`lint ${mylib} --fix`, { silenceError: true });
expect(out).toContain(
`Successfully ran target lint for project ${mylib}`
);
const packageJson = readJson(`libs/${mylib}/package.json`);
expect(packageJson).toMatchInlineSnapshot(`
{
"dependencies": {
"@nx/devkit": "${nxVersion}",
"@swc/core": "${swcCoreVersion}",
"@swc/helpers": "${swcHelpersVersion}",
"nx": "${nxVersion}",
},
"name": "@proj/${mylib}",
"type": "commonjs",
"version": "0.0.1",
}
`);
// intentionally set the invalid version
updateJson(`libs/${mylib}/package.json`, (json) => {
json.dependencies['@nx/devkit'] = '100.0.0';
return json;
});
out = runCLI(`lint ${mylib}`, { silenceError: true });
expect(out).toContain(
`The version specifier does not contain the installed version of "@nx/devkit" package: ${nxVersion}`
);
// should fix the version mismatch issue
out = runCLI(`lint ${mylib} --fix`, { silenceError: true });
expect(out).toContain(
`Successfully ran target lint for project ${mylib}`
);
});
});
}); });
describe('Root projects migration', () => { describe('Root projects migration', () => {

View File

@ -105,6 +105,7 @@
"{projectRoot}/generators.json", "{projectRoot}/generators.json",
"{projectRoot}/executors.json", "{projectRoot}/executors.json",
"{projectRoot}/package.json", "{projectRoot}/package.json",
"{projectRoot}/project.json",
"{projectRoot}/migrations.json" "{projectRoot}/migrations.json"
] ]
}, },

View File

@ -39,6 +39,7 @@
"@typescript-eslint/utils": "^5.58.0", "@typescript-eslint/utils": "^5.58.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"confusing-browser-globals": "^1.0.9", "confusing-browser-globals": "^1.0.9",
"jsonc-eslint-parser": "^2.1.0",
"semver": "7.3.4" "semver": "7.3.4"
}, },
"publishConfig": { "publishConfig": {

View File

@ -15,6 +15,10 @@ import nxPluginChecksRule, {
RULE_NAME as nxPluginChecksRuleName, RULE_NAME as nxPluginChecksRuleName,
} from './rules/nx-plugin-checks'; } from './rules/nx-plugin-checks';
import dependencyChecks, {
RULE_NAME as dependencyChecksRuleName,
} from './rules/dependency-checks';
// Resolve any custom rules that might exist in the current workspace // Resolve any custom rules that might exist in the current workspace
import { workspaceRules } from './resolve-workspace-rules'; import { workspaceRules } from './resolve-workspace-rules';
@ -32,6 +36,7 @@ module.exports = {
rules: { rules: {
[enforceModuleBoundariesRuleName]: enforceModuleBoundaries, [enforceModuleBoundariesRuleName]: enforceModuleBoundaries,
[nxPluginChecksRuleName]: nxPluginChecksRule, [nxPluginChecksRuleName]: nxPluginChecksRule,
[dependencyChecksRuleName]: dependencyChecks,
...workspaceRules, ...workspaceRules,
}, },
}; };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,364 @@
import { AST } from 'jsonc-eslint-parser';
import { normalizePath, workspaceRoot } from '@nx/devkit';
import { createESLintRule } from '../utils/create-eslint-rule';
import { readProjectGraph } from '../utils/project-graph-utils';
import { findProject, getSourceFilePath } from '../utils/runtime-lint-utils';
import { join } from 'path';
import { findProjectsNpmDependencies } from '@nx/js/src/internal';
import { satisfies } from 'semver';
import { getHelperDependenciesFromProjectGraph } from '@nx/js';
import {
getAllDependencies,
removePackageJsonFromFileMap,
} from '../utils/package-json-utils';
export type Options = [
{
buildTargets?: string[];
checkMissingDependencies?: boolean;
checkObsoleteDependencies?: boolean;
checkVersionMismatches?: boolean;
checkMissingPackageJson?: boolean;
ignoredDependencies?: string[];
}
];
export type MessageIds =
| 'missingDependency'
| 'obsoleteDependency'
| 'versionMismatch'
| 'missingDependencySection';
export const RULE_NAME = 'dependency-checks';
export default createESLintRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description: `Checks dependencies in project's package.json for version mismatches`,
recommended: 'error',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
buildTargets: [{ type: 'string' }],
ignoreDependencies: [{ type: 'string' }],
checkMissingDependencies: { type: 'boolean' },
checkObsoleteDependencies: { type: 'boolean' },
checkVersionMismatches: { type: 'boolean' },
},
additionalProperties: false,
},
],
messages: {
missingDependency: `The "{{projectName}}" uses the package "{{packageName}}", but it is missing from the project's "package.json".`,
obsoleteDependency: `The "{{packageName}}" package is not used by "{{projectName}}".`,
versionMismatch: `The version specifier does not contain the installed version of "{{packageName}}" package: {{version}}.`,
missingDependencySection: `Dependency sections are missing from the "package.json" but following dependencies were detected:{{dependencies}}`,
},
},
defaultOptions: [
{
buildTargets: ['build'],
checkMissingDependencies: true,
checkObsoleteDependencies: true,
checkVersionMismatches: true,
ignoredDependencies: [],
},
],
create(
context,
[
{
buildTargets,
ignoredDependencies,
checkMissingDependencies,
checkObsoleteDependencies,
checkVersionMismatches,
},
]
) {
if (!(context.parserServices as any).isJSON) {
return {};
}
const fileName = normalizePath(context.getFilename());
// support only package.json
if (!fileName.endsWith('/package.json')) {
return {};
}
const projectPath = normalizePath(globalThis.projectPath || workspaceRoot);
const sourceFilePath = getSourceFilePath(fileName, projectPath);
const { projectGraph, projectRootMappings, projectFileMap } =
readProjectGraph(RULE_NAME);
if (!projectGraph) {
return {};
}
const sourceProject = findProject(
projectGraph,
projectRootMappings,
sourceFilePath
);
// check if source project exists
if (!sourceProject) {
return {};
}
// check if library has a build target
const buildTarget = buildTargets.find(
(t) => sourceProject.data.targets?.[t]
);
if (!buildTarget) {
return {};
}
// gather helper dependencies for @nx/js executors
const helperDependencies = getHelperDependenciesFromProjectGraph(
workspaceRoot,
sourceProject.name,
projectGraph
);
// find all dependencies for the project
const npmDeps = findProjectsNpmDependencies(
sourceProject,
projectGraph,
buildTarget,
{
helperDependencies: helperDependencies.map((dep) => dep.target),
},
removePackageJsonFromFileMap(projectFileMap)
);
const projDependencies = {
...npmDeps.dependencies,
...npmDeps.peerDependencies,
};
const expectedDependencyNames = Object.keys(projDependencies);
const projPackageJsonPath = join(
workspaceRoot,
sourceProject.data.root,
'package.json'
);
globalThis.projPackageJsonDeps ??= getAllDependencies(projPackageJsonPath);
const projPackageJsonDeps: Record<string, string> =
globalThis.projPackageJsonDeps;
const rootPackageJsonDeps = getAllDependencies(
join(workspaceRoot, 'package.json')
);
function validateMissingDependencies(node: AST.JSONProperty) {
if (!checkMissingDependencies) {
return;
}
const missingDeps = expectedDependencyNames.filter(
(d) => !projPackageJsonDeps[d]
);
missingDeps.forEach((d) => {
if (!ignoredDependencies.includes(d)) {
context.report({
node: node as any,
messageId: 'missingDependency',
data: { packageName: d, projectName: sourceProject.name },
fix(fixer) {
projPackageJsonDeps[d] =
rootPackageJsonDeps[d] || projDependencies[d];
const deps = (node.value as AST.JSONObjectExpression).properties;
if (deps.length) {
return fixer.insertTextAfter(
deps[deps.length - 1] as any,
`,\n "${d}": "${projPackageJsonDeps[d]}"`
);
} else {
return fixer.replaceTextRange(
[node.value.range[0] + 1, node.value.range[1] - 1],
`\n "${d}": "${projPackageJsonDeps[d]}"\n `
);
}
},
});
}
});
}
function validateVersionMatchesInstalled(
node: AST.JSONProperty,
packageName: string,
packageRange: string
) {
if (!checkVersionMismatches) {
return;
}
if (
projDependencies[packageName] === '*' ||
satisfies(projDependencies[packageName], packageRange)
) {
return;
}
context.report({
node: node as any,
messageId: 'versionMismatch',
data: {
packageName: packageName,
version: projDependencies[packageName],
},
fix: (fixer) =>
fixer.replaceText(
node as any,
`"${packageName}": "${
rootPackageJsonDeps[packageName] || projDependencies[packageName]
}"`
),
});
}
function reportObsoleteDependency(
node: AST.JSONProperty,
packageName: string
) {
if (!checkObsoleteDependencies) {
return;
}
context.report({
node: node as any,
messageId: 'obsoleteDependency',
data: { packageName: packageName, projectName: sourceProject.name },
fix: (fixer) => {
const isLastProperty =
node.parent.properties[node.parent.properties.length - 1] === node;
const index = node.parent.properties.findIndex((n) => n === node);
if (index > 0) {
const previousNode = node.parent.properties[index - 1];
return fixer.removeRange([
previousNode.range[1] + 1,
node.range[1] + (isLastProperty ? 0 : 1),
]);
} else {
const parent = node.parent;
// it's the only property
if (isLastProperty) {
return fixer.removeRange([
parent.range[0] + 1,
parent.range[1] - 1,
]);
} else {
return fixer.removeRange([
parent.range[0] + 1,
node.range[1] + 1,
]);
}
}
// remove 4 spaces, new line and potential comma from previous line
const shouldRemoveSiblingComma =
isLastProperty && node.parent.properties.length > 1;
const leadingChars = 5 + (shouldRemoveSiblingComma ? 1 : 0);
return fixer.removeRange([
node.range[0] - leadingChars,
node.range[1] + (isLastProperty ? 0 : 1),
]);
},
});
}
function validateDependenciesSectionExistance(
node: AST.JSONObjectExpression
) {
if (
!expectedDependencyNames.length ||
!expectedDependencyNames.some((d) => !ignoredDependencies.includes(d))
) {
return;
}
if (
!node.properties ||
!node.properties.some((p) =>
[
'dependencies',
'peerDependencies',
'devDependencies',
'optionalDependencies',
].includes((p.key as any).value)
)
) {
context.report({
node: node as any,
messageId: 'missingDependencySection',
data: {
dependencies: expectedDependencyNames
.map((d) => `\n- "${d}"`)
.join(),
},
fix: (fixer) => {
expectedDependencyNames.sort().reduce((acc, d) => {
acc[d] = rootPackageJsonDeps[d] || projDependencies[d];
return acc;
}, projPackageJsonDeps);
const dependencies = Object.keys(projPackageJsonDeps)
.map((d) => `\n "${d}": "${projPackageJsonDeps[d]}"`)
.join(',');
if (!node.properties.length) {
return fixer.replaceText(
node as any,
`{\n "dependencies": {${dependencies}\n }\n}`
);
} else {
return fixer.insertTextAfter(
node.properties[node.properties.length - 1] as any,
`,\n "dependencies": {${dependencies}\n }`
);
}
},
});
}
}
return {
['JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=/^(dev|peer|optional)?dependencies$/i]'](
node: AST.JSONProperty
) {
return validateMissingDependencies(node);
},
['JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=/^(dev|peer|optional)?dependencies$/i] > JSONObjectExpression > JSONProperty'](
node: AST.JSONProperty
) {
const packageName = (node.key as any).value;
const packageRange = (node.value as any).value;
if (ignoredDependencies.includes(packageName)) {
return;
}
if (expectedDependencyNames.includes(packageName)) {
return validateVersionMatchesInstalled(
node,
packageName,
packageRange
);
} else {
return reportObsoleteDependency(node, packageName);
}
},
['JSONExpressionStatement > JSONObjectExpression'](
node: AST.JSONObjectExpression
) {
return validateDependenciesSectionExistance(node);
},
};
},
});

View File

@ -1,11 +1,6 @@
import 'nx/src/utils/testing/mock-fs'; import 'nx/src/utils/testing/mock-fs';
import type { import type { FileData, ProjectFileMap, ProjectGraph } from '@nx/devkit';
FileData,
ProjectFileMap,
ProjectGraph,
ProjectGraphDependency,
} from '@nx/devkit';
import { DependencyType } from '@nx/devkit'; import { DependencyType } from '@nx/devkit';
import * as parser from '@typescript-eslint/parser'; import * as parser from '@typescript-eslint/parser';
import { TSESLint } from '@typescript-eslint/utils'; import { TSESLint } from '@typescript-eslint/utils';

View File

@ -0,0 +1,26 @@
import { ProjectFileMap, readJsonFile } from '@nx/devkit';
import { existsSync } from 'fs';
export function getAllDependencies(path: string): Record<string, string> {
if (existsSync(path)) {
const packageJson = readJsonFile(path);
return {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies,
};
}
return {};
}
export function removePackageJsonFromFileMap(
projectFileMap: ProjectFileMap
): ProjectFileMap {
const newFileMap = {};
Object.keys(projectFileMap).forEach((key) => {
newFileMap[key] = projectFileMap[key].filter(
(f) => !f.file.endsWith('/package.json')
);
});
return newFileMap;
}

View File

@ -6,3 +6,5 @@ export {
} from 'nx/src/plugins/js/utils/register'; } from 'nx/src/plugins/js/utils/register';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
export { TargetProjectLocator } from 'nx/src/plugins/js/project-graph/build-dependencies/target-project-locator'; export { TargetProjectLocator } from 'nx/src/plugins/js/project-graph/build-dependencies/target-project-locator';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
export { findProjectsNpmDependencies } from 'nx/src/plugins/js/package-json/create-package-json';

View File

@ -123,8 +123,7 @@ export function getHelperDependenciesFromProjectGraph(
// we check if a dependency is part of the workspace and if it's a library // we check if a dependency is part of the workspace and if it's a library
// because we wouldn't want to include external dependencies (npm packages) // because we wouldn't want to include external dependencies (npm packages)
if ( if (
!dependency.target.startsWith('npm:') && projectGraph.nodes[dependency.target] &&
!!projectGraph.nodes[dependency.target] &&
projectGraph.nodes[dependency.target].type === 'lib' projectGraph.nodes[dependency.target].type === 'lib'
) { ) {
const targetData = projectGraph.nodes[dependency.target].data; const targetData = projectGraph.nodes[dependency.target].data;

View File

@ -1,4 +1,4 @@
import { ESLint, Linter as LinterType } from 'eslint'; import { Linter as LinterType } from 'eslint';
/** /**
* This configuration is intended to apply to all TypeScript source files. * This configuration is intended to apply to all TypeScript source files.
@ -28,6 +28,20 @@ export const globalJavaScriptOverrides = {
rules: {}, rules: {},
}; };
/**
* This configuration is intended to apply to all JSON source files.
* See the eslint-plugin package for what is in the referenced shareable config.
*/
export const globalJsonOverrides = {
files: ['*.json'],
parser: 'jsonc-eslint-parser',
/**
* Having an empty rules object present makes it more obvious to the user where they would
* extend things from if they needed to
*/
rules: {},
};
/** /**
* This configuration is intended to apply to all "source code" (but not * This configuration is intended to apply to all "source code" (but not
* markup like HTML, or other custom file types like GraphQL) * markup like HTML, or other custom file types like GraphQL)

View File

@ -40,40 +40,15 @@ export function createPackageJson(
} = {}, } = {},
fileMap: ProjectFileMap = null fileMap: ProjectFileMap = null
): PackageJson { ): PackageJson {
if (fileMap == null) {
fileMap = readProjectFileMapCache()?.projectFileMap || {};
}
const projectNode = graph.nodes[projectName]; const projectNode = graph.nodes[projectName];
const isLibrary = projectNode.type === 'lib'; const isLibrary = projectNode.type === 'lib';
const { selfInputs, dependencyInputs } = options.target const npmDeps = findProjectsNpmDependencies(
? getTargetInputs(readNxJson(), projectNode, options.target)
: { selfInputs: [], dependencyInputs: [] };
const npmDeps: NpmDeps = {
dependencies: {},
peerDependencies: {},
peerDependenciesMeta: {},
};
const seen = new Set<string>();
options.helperDependencies?.forEach((dep) => {
seen.add(dep);
npmDeps.dependencies[graph.externalNodes[dep].data.packageName] =
graph.externalNodes[dep].data.version;
recursivelyCollectPeerDependencies(dep, graph, npmDeps, seen);
});
findAllNpmDeps(
fileMap,
projectNode, projectNode,
graph, graph,
npmDeps, options.target,
seen, { helperDependencies: options.helperDependencies },
dependencyInputs, fileMap
selfInputs
); );
// default package.json if one does not exist // default package.json if one does not exist
@ -200,6 +175,51 @@ export function createPackageJson(
return packageJson; return packageJson;
} }
export function findProjectsNpmDependencies(
projectNode: ProjectGraphProjectNode,
graph: ProjectGraph,
target: string,
options: {
helperDependencies?: string[];
},
fileMap?: ProjectFileMap
): NpmDeps {
if (fileMap == null) {
fileMap = readProjectFileMapCache()?.projectFileMap || {};
}
const { selfInputs, dependencyInputs } = target
? getTargetInputs(readNxJson(), projectNode, target)
: { selfInputs: [], dependencyInputs: [] };
const npmDeps: NpmDeps = {
dependencies: {},
peerDependencies: {},
peerDependenciesMeta: {},
};
const seen = new Set<string>();
options.helperDependencies?.forEach((dep) => {
seen.add(dep);
npmDeps.dependencies[graph.externalNodes[dep].data.packageName] =
graph.externalNodes[dep].data.version;
recursivelyCollectPeerDependencies(dep, graph, npmDeps, seen);
});
findAllNpmDeps(
fileMap,
projectNode,
graph,
npmDeps,
seen,
dependencyInputs,
selfInputs
);
return npmDeps;
}
function findAllNpmDeps( function findAllNpmDeps(
projectFileMap: ProjectFileMap, projectFileMap: ProjectFileMap,
projectNode: ProjectGraphProjectNode, projectNode: ProjectGraphProjectNode,

View File

@ -1,5 +1,4 @@
import { import {
addDependenciesToPackageJson,
formatFiles, formatFiles,
joinPathFragments, joinPathFragments,
logger, logger,
@ -16,9 +15,8 @@ import {
import type { Linter as ESLint } from 'eslint'; import type { Linter as ESLint } from 'eslint';
import { Schema as EsLintExecutorOptions } from '@nx/linter/src/executors/eslint/schema'; import type { Schema as EsLintExecutorOptions } from '@nx/linter/src/executors/eslint/schema';
import { jsoncEslintParserVersion } from '../../utils/versions';
import { PluginLintChecksGeneratorSchema } from './schema'; import { PluginLintChecksGeneratorSchema } from './schema';
import { NX_PREFIX } from 'nx/src/utils/logger'; import { NX_PREFIX } from 'nx/src/utils/logger';
import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json'; import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json';
@ -57,17 +55,10 @@ export default async function pluginLintCheckGenerator(
`${NX_PREFIX} plugin lint checks can only be added to plugins which use eslint for linting` `${NX_PREFIX} plugin lint checks can only be added to plugins which use eslint for linting`
); );
} }
const installTask = addDependenciesToPackageJson(
host,
{},
{ 'jsonc-eslint-parser': jsoncEslintParserVersion }
);
if (!options.skipFormat) { if (!options.skipFormat) {
await formatFiles(host); await formatFiles(host);
} }
return () => installTask;
} }
export function addMigrationJsonChecks( export function addMigrationJsonChecks(