@nx/eslint-plugin
Version:
388 lines (387 loc) • 19.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RULE_NAME = void 0;
const devkit_1 = require("@nx/devkit");
const internal_1 = require("@nx/devkit/internal");
const internal_2 = require("@nx/js/internal");
const utils_1 = require("@typescript-eslint/utils");
const path_1 = require("path");
const semver_1 = require("semver");
const package_json_utils_1 = require("../utils/package-json-utils");
const project_graph_utils_1 = require("../utils/project-graph-utils");
const runtime_lint_utils_1 = require("../utils/runtime-lint-utils");
const WORKSPACE_VERSION_WILDCARD = 'workspace:*';
exports.RULE_NAME = 'dependency-checks';
exports.default = utils_1.ESLintUtils.RuleCreator(() => `https://github.com/nrwl/nx/blob/${devkit_1.NX_VERSION}/docs/generated/packages/eslint-plugin/documents/dependency-checks.md`)({
name: exports.RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description: `Checks dependencies in project's package.json for version mismatches`,
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
buildTargets: { type: 'array', items: { type: 'string' } },
ignoredDependencies: { type: 'array', items: { type: 'string' } },
ignoredFiles: { type: 'array', items: { type: 'string' } },
checkMissingDependencies: { type: 'boolean' },
checkObsoleteDependencies: { type: 'boolean' },
checkVersionMismatches: { type: 'boolean' },
includeTransitiveDependencies: { type: 'boolean' },
useLocalPathsForWorkspaceDependencies: { type: 'boolean' },
runtimeHelpers: { type: 'array', items: { type: 'string' } },
peerDepsVersionStrategy: {
type: 'string',
enum: ['installed', 'workspace'],
description: 'Strategy for peer dependency versions. "installed" uses versions from root package.json (default). "workspace" uses workspace:* for all peer dependencies to ensure version synchronization in integrated monorepos.',
},
},
additionalProperties: false,
},
],
messages: {
missingDependency: `The "{{projectName}}" project uses the following packages, but they are missing from "{{section}}":{{packageNames}}`,
obsoleteDependency: `The "{{packageName}}" package is not used by "{{projectName}}" project.`,
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}}`,
invalidCatalogReference: `Invalid catalog reference for "{{packageName}}": {{error}}`,
},
},
defaultOptions: [
{
buildTargets: ['build'],
checkMissingDependencies: true,
checkObsoleteDependencies: true,
checkVersionMismatches: true,
ignoredDependencies: [],
ignoredFiles: [],
includeTransitiveDependencies: false,
useLocalPathsForWorkspaceDependencies: false,
runtimeHelpers: [],
peerDepsVersionStrategy: 'installed',
},
],
create(context, [{ buildTargets, ignoredDependencies, ignoredFiles, checkMissingDependencies, checkObsoleteDependencies, checkVersionMismatches, includeTransitiveDependencies, useLocalPathsForWorkspaceDependencies, runtimeHelpers, peerDepsVersionStrategy = 'installed', },]) {
if (!(0, runtime_lint_utils_1.getParserServices)(context).isJSON) {
return {};
}
const fileName = (0, devkit_1.normalizePath)(context.filename ?? context.getFilename());
// support only package.json
if (!fileName.endsWith('/package.json')) {
return {};
}
const sourceFilePath = (0, runtime_lint_utils_1.getSourceFilePath)(fileName, devkit_1.workspaceRoot);
const { projectGraph, projectRootMappings, projectFileMap } = (0, project_graph_utils_1.readProjectGraph)(exports.RULE_NAME);
if (!projectGraph) {
return {};
}
const sourceProject = (0, runtime_lint_utils_1.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 {};
}
const rootPackageJson = (0, package_json_utils_1.getPackageJson)((0, path_1.join)(devkit_1.workspaceRoot, 'package.json'));
const npmDependencies = (0, internal_2.findNpmDependencies)(devkit_1.workspaceRoot, sourceProject, projectGraph, projectFileMap, buildTarget, // TODO: What if child library has a build target different from the parent?
{
includeTransitiveDependencies,
ignoredFiles,
useLocalPathsForWorkspaceDependencies,
runtimeHelpers,
});
const expectedDependencyNames = Object.keys(npmDependencies);
// Packages eligible for `workspace:*` rewrites under
// `peerDepsVersionStrategy: 'workspace'`. Must be both a workspace project
// and registered in the package manager's workspaces — otherwise
// `workspace:*` won't resolve at install time.
const workspacePackageNames = new Set();
for (const node of Object.values(projectGraph.nodes)) {
const js = node.data?.metadata?.js;
if (js?.packageName && js.isInPackageManagerWorkspaces) {
workspacePackageNames.add(js.packageName);
}
}
const packageJson = JSON.parse(context.sourceCode.getText());
const projPackageJsonDeps = (0, package_json_utils_1.getProductionDependencies)(packageJson);
const rootPackageJsonDeps = (0, package_json_utils_1.getAllDependencies)(rootPackageJson);
const catalogManager = (0, internal_1.getCatalogManager)(devkit_1.workspaceRoot);
const catalogDefs = catalogManager?.getCatalogDefinitions(devkit_1.workspaceRoot);
function catalogEntryMatchesInstalled(catalogVersionSpec, installedVersion) {
if (installedVersion === '*') {
return true;
}
// For non-semver values (file:, link:, etc.), use exact comparison
if (installedVersion.includes(':') || catalogVersionSpec.includes(':')) {
return installedVersion === catalogVersionSpec;
}
return (0, semver_1.satisfies)(installedVersion, catalogVersionSpec, {
includePrerelease: true,
});
}
function getCatalogVersionForPackage(packageName) {
if (!catalogDefs) {
return null;
}
const matches = [];
// Check default catalog — `catalog` takes precedence over `catalogs.default`.
// Both existing simultaneously is a pnpm error caught by validateCatalogReference.
const defaultEntry = catalogDefs.catalog?.[packageName] ??
catalogDefs.catalogs?.default?.[packageName];
if (defaultEntry) {
matches.push({ catalogRef: 'catalog:', versionSpec: defaultEntry });
}
// Check named catalogs (skip "default" — handled above)
if (catalogDefs.catalogs) {
for (const [name, entries] of Object.entries(catalogDefs.catalogs)) {
if (name === 'default' || !entries?.[packageName]) {
continue;
}
matches.push({
catalogRef: `catalog:${name}`,
versionSpec: entries[packageName],
});
}
}
if (!matches.length) {
return null;
}
// Filter by installed version compatibility when available
const installedVersion = npmDependencies[packageName];
const valid = installedVersion
? matches.filter((m) => catalogEntryMatchesInstalled(m.versionSpec, installedVersion))
: matches;
if (valid.length !== 1) {
return null;
}
return valid[0].catalogRef;
}
function getVersionForMissingDependency(packageName) {
if (rootPackageJsonDeps[packageName]) {
return rootPackageJsonDeps[packageName];
}
const catalogVersion = getCatalogVersionForPackage(packageName);
if (catalogVersion) {
return catalogVersion;
}
return npmDependencies[packageName];
}
function getDependencySection(node) {
// Check if this node is a dependency section itself
const directSection = node.key?.value;
if (['dependencies', 'peerDependencies', 'optionalDependencies'].includes(directSection)) {
return directSection;
}
// Otherwise, traverse up to find the parent section
const sectionProp = node.parent?.parent;
return sectionProp?.key?.value;
}
function validateMissingDependencies(node) {
if (!checkMissingDependencies) {
return;
}
const missingDeps = expectedDependencyNames.filter((d) => !projPackageJsonDeps[d] && !ignoredDependencies.includes(d));
if (missingDeps.length) {
const dependencySection = getDependencySection(node);
context.report({
node: node,
messageId: 'missingDependency',
data: {
packageNames: missingDeps.map((d) => `\n - ${d}`).join(''),
section: node.key.value,
projectName: sourceProject.name,
},
fix(fixer) {
missingDeps.forEach((d) => {
if (dependencySection === 'peerDependencies' &&
peerDepsVersionStrategy === 'workspace' &&
workspacePackageNames.has(d)) {
projPackageJsonDeps[d] = WORKSPACE_VERSION_WILDCARD;
}
else {
projPackageJsonDeps[d] = getVersionForMissingDependency(d);
}
});
const deps = node.value.properties;
const mappedDeps = missingDeps
.map((d) => `\n "${d}": "${projPackageJsonDeps[d]}"`)
.join(',');
if (deps.length) {
return fixer.insertTextAfter(deps[deps.length - 1], `,${mappedDeps}`);
}
else {
return fixer.insertTextAfterRange([node.value.range[0] + 1, node.value.range[1] - 1], `${mappedDeps}\n `);
}
},
});
}
}
function validateCatalogReferenceForPackage(node, packageName, packageRange) {
if (!catalogManager) {
return;
}
if (!catalogManager.isCatalogReference(packageRange)) {
return;
}
try {
catalogManager.validateCatalogReference(devkit_1.workspaceRoot, packageName, packageRange);
}
catch (error) {
context.report({
node: node,
messageId: 'invalidCatalogReference',
data: {
packageName: packageName,
error: error.message,
},
});
}
}
function validateVersionMatchesInstalled(node, packageName, packageRange) {
if (!checkVersionMismatches)
return;
const dependencySection = getDependencySection(node);
if (dependencySection === 'peerDependencies' &&
peerDepsVersionStrategy === 'workspace' &&
!packageRange.startsWith('workspace:') &&
workspacePackageNames.has(packageName)) {
context.report({
node: node,
messageId: 'versionMismatch',
data: { packageName, version: WORKSPACE_VERSION_WILDCARD },
fix: (fixer) => fixer.replaceText(node, `"${packageName}": "${WORKSPACE_VERSION_WILDCARD}"`),
});
return;
}
// Resolve catalog references before validation
let resolvedPackageRange = packageRange;
if (catalogManager?.isCatalogReference(packageRange)) {
const resolved = catalogManager.resolveCatalogReference(devkit_1.workspaceRoot, packageName, packageRange);
if (!resolved) {
// Catalog resolution failed - this shouldn't happen because
// validateCatalogReferenceForPackage should have caught it earlier
// But if it does, skip validation gracefully
return;
}
resolvedPackageRange = resolved;
}
if (npmDependencies[packageName].startsWith('file:') ||
resolvedPackageRange.startsWith('file:') ||
npmDependencies[packageName] === '*' ||
resolvedPackageRange === '*' ||
resolvedPackageRange.startsWith('workspace:') ||
(0, semver_1.satisfies)(npmDependencies[packageName], resolvedPackageRange, {
includePrerelease: true,
})) {
return;
}
context.report({
node: node,
messageId: 'versionMismatch',
data: {
packageName: packageName,
version: npmDependencies[packageName],
},
fix: (fixer) => fixer.replaceText(node, `"${packageName}": "${rootPackageJsonDeps[packageName] || npmDependencies[packageName]}"`),
});
}
function reportObsoleteDependency(node, packageName) {
if (!checkObsoleteDependencies) {
return;
}
context.report({
node: node,
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] + (isLastProperty ? 0 : 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,
]);
}
}
},
});
}
function validateDependenciesSectionExistance(node) {
if (!expectedDependencyNames.length ||
!expectedDependencyNames.some((d) => !ignoredDependencies.includes(d))) {
return;
}
if (!node.properties ||
!node.properties.some((p) => ['dependencies', 'peerDependencies', 'optionalDependencies'].includes(p.key.value))) {
context.report({
node: node,
messageId: 'missingDependencySection',
data: {
dependencies: expectedDependencyNames
.map((d) => `\n- "${d}"`)
.join(),
},
fix: (fixer) => {
expectedDependencyNames.sort().reduce((acc, d) => {
acc[d] = getVersionForMissingDependency(d);
return acc;
}, projPackageJsonDeps);
const dependencies = Object.keys(projPackageJsonDeps)
.map((d) => `\n "${d}": "${projPackageJsonDeps[d]}"`)
.join(',');
if (!node.properties.length) {
return fixer.replaceText(node, `{\n "dependencies": {${dependencies}\n }\n}`);
}
else {
return fixer.insertTextAfter(node.properties[node.properties.length - 1], `,\n "dependencies": {${dependencies}\n }`);
}
},
});
}
}
return {
['JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=/^(peer|optional)?dependencies$/i]'](node) {
validateMissingDependencies(node);
},
['JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=/^(peer|optional)?dependencies$/i] > JSONObjectExpression > JSONProperty'](node) {
const packageName = node.key.value;
const packageRange = node.value.value;
if (ignoredDependencies.includes(packageName)) {
return;
}
validateCatalogReferenceForPackage(node, packageName, packageRange);
if (expectedDependencyNames.includes(packageName)) {
validateVersionMatchesInstalled(node, packageName, packageRange);
}
else {
reportObsoleteDependency(node, packageName);
}
},
['JSONExpressionStatement > JSONObjectExpression'](node) {
validateDependenciesSectionExistance(node);
},
};
},
});