nx/packages/eslint-plugin/src/rules/dependency-checks.ts

365 lines
11 KiB
TypeScript

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' }],
ignoredDependencies: [{ 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);
},
};
},
});