diff --git a/.eslintrc.json b/.eslintrc.json index 77eea25993..13d266de37 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -30,8 +30,12 @@ }, "overrides": [ { - "files": ["**/executors/**/schema.json", "**/generators/**/schema.json"], + "files": ["*.json"], "parser": "jsonc-eslint-parser", + "rules": {} + }, + { + "files": ["**/executors/**/schema.json", "**/generators/**/schema.json"], "rules": { "@nx/workspace/valid-schema-description": "error" } diff --git a/e2e/linter/src/linter.test.ts b/e2e/linter/src/linter.test.ts index 304f8eafe9..54d90f741f 100644 --- a/e2e/linter/src/linter.test.ts +++ b/e2e/linter/src/linter.test.ts @@ -9,6 +9,7 @@ import { runCLI, uniq, updateFile, + updateJson, } from '@nx/e2e/utils'; 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', () => { diff --git a/nx.json b/nx.json index f1baefa38b..60dd1e244a 100644 --- a/nx.json +++ b/nx.json @@ -105,6 +105,7 @@ "{projectRoot}/generators.json", "{projectRoot}/executors.json", "{projectRoot}/package.json", + "{projectRoot}/project.json", "{projectRoot}/migrations.json" ] }, diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 79866a53bd..94cf60fee6 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -39,6 +39,7 @@ "@typescript-eslint/utils": "^5.58.0", "chalk": "^4.1.0", "confusing-browser-globals": "^1.0.9", + "jsonc-eslint-parser": "^2.1.0", "semver": "7.3.4" }, "publishConfig": { diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 052a053a43..4037dfeada 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -15,6 +15,10 @@ import nxPluginChecksRule, { RULE_NAME as nxPluginChecksRuleName, } 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 import { workspaceRules } from './resolve-workspace-rules'; @@ -32,6 +36,7 @@ module.exports = { rules: { [enforceModuleBoundariesRuleName]: enforceModuleBoundaries, [nxPluginChecksRuleName]: nxPluginChecksRule, + [dependencyChecksRuleName]: dependencyChecks, ...workspaceRules, }, }; diff --git a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts new file mode 100644 index 0000000000..e856a7834d --- /dev/null +++ b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts @@ -0,0 +1,1458 @@ +import 'nx/src/utils/testing/mock-fs'; + +import dependencyChecks, { + Options, + RULE_NAME as dependencyChecksRuleName, +} from './dependency-checks'; +import * as jsoncParser from 'jsonc-eslint-parser'; +import { createProjectRootMappings } from 'nx/src/project-graph/utils/find-project-for-path'; + +import { vol } from 'memfs'; +import { + FileData, + ProjectFileMap, + ProjectGraph, + ProjectGraphExternalNode, +} from '@nx/devkit'; +import { Linter } from 'eslint'; + +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + workspaceRoot: '/root', +})); + +jest.mock('nx/src/utils/workspace-root', () => ({ + workspaceRoot: '/root', +})); + +const rootPackageJson = { + dependencies: { + external1: '~16.1.2', + external2: '^5.2.0', + }, + devDependencies: { + tslib: '^2.1.0', + }, +}; + +const externalNodes: Record = { + 'npm:external1': { + name: 'npm:external1', + type: 'npm', + data: { + packageName: 'external1', + version: '16.1.8', + }, + }, + 'npm:external2': { + name: 'npm:external2', + type: 'npm', + data: { + packageName: 'external2', + version: '5.5.6', + }, + }, + 'npm:random-external': { + name: 'npm:random-external', + type: 'npm', + data: { + packageName: 'random-external', + version: '1.2.3', + }, + }, + 'npm:tslib': { + name: 'npm:tslib', + type: 'npm', + data: { + packageName: 'tslib', + version: '2.1.0', + }, + }, + 'npm:@swc/helpers': { + name: 'npm:@swc/helpers', + type: 'npm', + data: { + packageName: '@swc/helpers', + version: '1.2.3', + }, + }, +}; + +describe('Dependency checks (eslint)', () => { + beforeEach(() => { + globalThis.projPackageJsonDeps = undefined; + }); + + afterEach(() => { + vol.reset(); + }); + + it('should not error when everything is in order', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should report missing dependencies section and fix it', () => { + const packageJson = { + name: '@mycompany/liba', + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot(` + "Dependency sections are missing from the "package.json" but following dependencies were detected: + - "external1"" + `); + + // apply fix + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "dependencies": { + "external1": "~16.1.2" + } + }" + `); + }); + + it('should not report missing dependencies section if all dependencies are ignored', () => { + const packageJson = { + name: '@mycompany/liba', + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { ignoredDependencies: ['external1'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not report missing dependencies section if no buildable target', () => { + const packageJson = { + name: '@mycompany/liba', + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { ignoredDependencies: ['external1'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + nonbuildable: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not report missing dependencies section if no dependencies', () => { + const packageJson = { + name: '@mycompany/liba', + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { ignoredDependencies: ['external1'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: {}, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`), + createFile(`libs/liba/package.json`), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should error if package is missing and apply fixer', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot( + `"The "liba" uses the package "external2", but it is missing from the project's "package.json"."` + ); + expect(failures[0].line).toEqual(3); + + // apply fix + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "dependencies": { + "external1": "^16.0.0", + "external2": "^5.2.0" + } + }" + `); + }); + + it('should add missing dependency when none are provided', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: {}, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['npm:external1'])], + } + ); + expect(failures.length).toEqual(1); + + // apply fix + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "dependencies": { + "external1": "~16.1.2" + } + }" + `); + }); + + it('should take version from lockfile for fixer if not provided in the root package.json', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: {}, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:random-external', type: 'static' }, + ], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['npm:random-external'])], + } + ); + expect(failures.length).toEqual(1); + + // apply fix + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "dependencies": { + "random-external": "1.2.3" + } + }" + `); + }); + + it('should not error if there are no build targets', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { buildTargets: ['notbuild'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not check missing deps if checkMissingDependencies=false', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { checkMissingDependencies: false }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not report missing deps if in ignoredDependencies', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { ignoredDependencies: ['external2'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should error if package is obsolete and fix it', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + peerDependencies: { + unneeded: '>= 16 < 18', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:unneeded', + ]), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot( + `"The "unneeded" package is not used by "liba"."` + ); + expect(failures[0].line).toEqual(7); + + // should apply fixer + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "dependencies": { + "external1": "^16.0.0" + }, + "peerDependencies": {} + }" + `); + }); + + it('should remove obsolete package in the middle with fix', () => { + const packageJson = { + name: '@mycompany/liba', + peerDependencies: { + external1: '^16.0.0', + unneeded: '>= 16 < 18', + external2: '^5.2.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + 'npm:unneeded', + ]), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot( + `"The "unneeded" package is not used by "liba"."` + ); + expect(failures[0].line).toEqual(5); + + // should apply fixer + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "peerDependencies": { + "external1": "^16.0.0", + "external2": "^5.2.0" + } + }" + `); + }); + + it('should remove obsolete package in the beginning with fix', () => { + const packageJson = { + name: '@mycompany/liba', + peerDependencies: { + unneeded: '>= 16 < 18', + external1: '^16.0.0', + external2: '^5.2.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + 'npm:unneeded', + ]), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot( + `"The "unneeded" package is not used by "liba"."` + ); + expect(failures[0].line).toEqual(4); + + // should apply fixer + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "peerDependencies": { + "external1": "^16.0.0", + "external2": "^5.2.0" + } + }" + `); + }); + + it('should not check obsolete deps if checkObsoleteDependencies=false', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + peerDependencies: { + unneeded: '>= 16 < 18', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { checkObsoleteDependencies: false }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:unneeded', + ]), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not report obsolete deps if in ignoredDependencies', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + peerDependencies: { + unneeded: '>= 16 < 18', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { ignoredDependencies: ['unneeded'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:unneeded', + ]), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should error if package version is mismatch and fix it', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '~16.0.0', + external2: '^1.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + expect(failures.length).toEqual(2); + expect(failures[0].message).toMatchInlineSnapshot( + `"The version specifier does not contain the installed version of "external1" package: 16.1.8."` + ); + expect(failures[0].line).toEqual(4); + expect(failures[1].message).toMatchInlineSnapshot( + `"The version specifier does not contain the installed version of "external2" package: 5.5.6."` + ); + expect(failures[1].line).toEqual(5); + + // should apply fixer + const content = JSON.stringify(packageJson, null, 2); + let result = + content.slice(0, failures[0].fix.range[0]) + + failures[0].fix.text + + content.slice(failures[0].fix.range[1]); + result = + result.slice(0, failures[1].fix.range[0]) + + failures[1].fix.text + + result.slice(failures[1].fix.range[1]); + expect(result).toMatchInlineSnapshot(` + "{ + "name": "@mycompany/liba", + "dependencies": { + "external1": "~16.1.2", + "external2": "^5.2.0" + } + }" + `); + }); + + it('should not error if mismatch when checkVersionMismatches is false', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '~16.0.0', + external2: '^1.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { checkVersionMismatches: false }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + expect(failures.length).toEqual(0); + }); + + it('should not report mismatch if in ignoredDependencies', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '~16.0.0', + external2: '^1.0.0', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + { ignoredDependencies: ['external1'] }, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot( + `"The version specifier does not contain the installed version of "external2" package: 5.5.6."` + ); + expect(failures[0].line).toEqual(5); + }); + + it('should require tslib if @nx/js:tsc executor', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const tsConfigJson = { + extends: '../../tsconfig.base.json', + compilerOptions: { + module: 'commonjs', + outDir: '../../dist/out-tsc', + declaration: true, + types: ['node'], + }, + exclude: [ + '**/*.spec.ts', + '**/*.test.ts', + '**/*_spec.ts', + '**/*_test.ts', + 'jest.config.ts', + ], + include: ['**/*.ts'], + }; + + const tsConfiogBaseJson = { + compilerOptions: { + target: 'es2015', + importHelpers: true, + module: 'commonjs', + moduleResolution: 'node', + outDir: 'build', + experimentalDecorators: true, + emitDecoratorMetadata: true, + skipLibCheck: true, + types: ['node'], + lib: ['es2019'], + declaration: true, + resolveJsonModule: true, + baseUrl: '.', + rootDir: '.', + allowJs: true, + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './libs/libb/tsconfig.json': JSON.stringify(tsConfigJson, null, 2), + './package.json': JSON.stringify(rootPackageJson, null, 2), + './tsconfig.base.json': JSON.stringify(tsConfiogBaseJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + libb: { + name: 'libb', + type: 'lib', + data: { + root: 'libs/libb', + targets: { + build: { + executor: '@nx/js:tsc', + options: { + tsConfig: 'libs/libb/tsconfig.json', + }, + }, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'libb', type: 'static' }, + ], + libb: [{ source: 'libb', target: 'npm:external2', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + createFile(`libs/libb/src/main.ts`, ['npm:external2']), + ], + } + ); + expect(failures.length).toEqual(2); + expect(failures[0].message).toMatchInlineSnapshot( + `"The "liba" uses the package "tslib", but it is missing from the project's "package.json"."` + ); + expect(failures[0].line).toEqual(3); + expect(failures[1].message).toMatchInlineSnapshot( + `"The "liba" uses the package "external2", but it is missing from the project's "package.json"."` + ); + expect(failures[1].line).toEqual(3); + }); +}); + +it('should require swc if @nx/js:swc executor', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const swcrc = { + jsc: { + externalHelpers: true, + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './libs/libb/.swcrc': JSON.stringify(swcrc, null, 2), + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + libb: { + name: 'libb', + type: 'lib', + data: { + root: 'libs/libb', + targets: { + build: { + executor: '@nx/js:swc', + options: { + tsConfig: 'libs/libb/tsconfig.json', + }, + }, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'libb', type: 'static' }, + ], + libb: [], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + createFile(`libs/libb/src/main.ts`), + ], + } + ); + expect(failures.length).toEqual(1); + expect(failures[0].message).toMatchInlineSnapshot( + `"The "liba" uses the package "@swc/helpers", but it is missing from the project's "package.json"."` + ); + expect(failures[0].line).toEqual(3); +}); + +function createFile(f: string, deps?: (string | [string, string])[]): FileData { + return { file: f, hash: '', deps }; +} + +const linter = new Linter(); +const baseConfig = { + parser: 'jsonc-eslint-parser', + rules: { + [dependencyChecksRuleName]: 'error', + }, +}; +linter.defineParser('jsonc-eslint-parser', jsoncParser as any); +linter.defineRule(dependencyChecksRuleName, dependencyChecks as any); + +function runRule( + ruleArguments: Options[0], + filePath: string, + content: string, + projectGraph: ProjectGraph, + projectFileMap: ProjectFileMap +): Linter.LintMessage[] { + globalThis.projectPath = `${process.cwd()}/proj`; + globalThis.projectGraph = projectGraph; + globalThis.projectFileMap = projectFileMap; + globalThis.projectRootMappings = createProjectRootMappings( + projectGraph.nodes + ); + + const config = { + ...baseConfig, + rules: { + [dependencyChecksRuleName]: ['error', ruleArguments], + }, + }; + + return linter.verify(content, config as any, filePath); +} diff --git a/packages/eslint-plugin/src/rules/dependency-checks.ts b/packages/eslint-plugin/src/rules/dependency-checks.ts new file mode 100644 index 0000000000..937f63e24c --- /dev/null +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -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({ + 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 = + 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); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts b/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts index 9ed7e57182..2af05c1d99 100644 --- a/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts +++ b/packages/eslint-plugin/src/rules/enforce-module-boundaries.spec.ts @@ -1,11 +1,6 @@ import 'nx/src/utils/testing/mock-fs'; -import type { - FileData, - ProjectFileMap, - ProjectGraph, - ProjectGraphDependency, -} from '@nx/devkit'; +import type { FileData, ProjectFileMap, ProjectGraph } from '@nx/devkit'; import { DependencyType } from '@nx/devkit'; import * as parser from '@typescript-eslint/parser'; import { TSESLint } from '@typescript-eslint/utils'; diff --git a/packages/eslint-plugin/src/utils/package-json-utils.ts b/packages/eslint-plugin/src/utils/package-json-utils.ts new file mode 100644 index 0000000000..4423ab7128 --- /dev/null +++ b/packages/eslint-plugin/src/utils/package-json-utils.ts @@ -0,0 +1,26 @@ +import { ProjectFileMap, readJsonFile } from '@nx/devkit'; +import { existsSync } from 'fs'; + +export function getAllDependencies(path: string): Record { + 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; +} diff --git a/packages/js/src/internal.ts b/packages/js/src/internal.ts index 75893c8a57..f22d39eaa4 100644 --- a/packages/js/src/internal.ts +++ b/packages/js/src/internal.ts @@ -6,3 +6,5 @@ export { } from 'nx/src/plugins/js/utils/register'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports 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'; diff --git a/packages/js/src/utils/compiler-helper-dependency.ts b/packages/js/src/utils/compiler-helper-dependency.ts index c22a167e08..85f685096f 100644 --- a/packages/js/src/utils/compiler-helper-dependency.ts +++ b/packages/js/src/utils/compiler-helper-dependency.ts @@ -123,8 +123,7 @@ export function getHelperDependenciesFromProjectGraph( // 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) if ( - !dependency.target.startsWith('npm:') && - !!projectGraph.nodes[dependency.target] && + projectGraph.nodes[dependency.target] && projectGraph.nodes[dependency.target].type === 'lib' ) { const targetData = projectGraph.nodes[dependency.target].data; diff --git a/packages/linter/src/generators/init/global-eslint-config.ts b/packages/linter/src/generators/init/global-eslint-config.ts index cde3d9ae6d..b3d0d3bb9e 100644 --- a/packages/linter/src/generators/init/global-eslint-config.ts +++ b/packages/linter/src/generators/init/global-eslint-config.ts @@ -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. @@ -28,6 +28,20 @@ export const globalJavaScriptOverrides = { 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 * markup like HTML, or other custom file types like GraphQL) diff --git a/packages/nx/src/plugins/js/package-json/create-package-json.ts b/packages/nx/src/plugins/js/package-json/create-package-json.ts index 7e699249ba..7d844b4ef6 100644 --- a/packages/nx/src/plugins/js/package-json/create-package-json.ts +++ b/packages/nx/src/plugins/js/package-json/create-package-json.ts @@ -40,40 +40,15 @@ export function createPackageJson( } = {}, fileMap: ProjectFileMap = null ): PackageJson { - if (fileMap == null) { - fileMap = readProjectFileMapCache()?.projectFileMap || {}; - } - const projectNode = graph.nodes[projectName]; const isLibrary = projectNode.type === 'lib'; - const { selfInputs, dependencyInputs } = options.target - ? getTargetInputs(readNxJson(), projectNode, options.target) - : { selfInputs: [], dependencyInputs: [] }; - - const npmDeps: NpmDeps = { - dependencies: {}, - peerDependencies: {}, - peerDependenciesMeta: {}, - }; - - const seen = new Set(); - - 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, + const npmDeps = findProjectsNpmDependencies( projectNode, graph, - npmDeps, - seen, - dependencyInputs, - selfInputs + options.target, + { helperDependencies: options.helperDependencies }, + fileMap ); // default package.json if one does not exist @@ -200,6 +175,51 @@ export function createPackageJson( 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(); + + 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( projectFileMap: ProjectFileMap, projectNode: ProjectGraphProjectNode, diff --git a/packages/plugin/src/generators/lint-checks/generator.ts b/packages/plugin/src/generators/lint-checks/generator.ts index baf89c8f39..c60018e856 100644 --- a/packages/plugin/src/generators/lint-checks/generator.ts +++ b/packages/plugin/src/generators/lint-checks/generator.ts @@ -1,5 +1,4 @@ import { - addDependenciesToPackageJson, formatFiles, joinPathFragments, logger, @@ -16,9 +15,8 @@ import { 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 { NX_PREFIX } from 'nx/src/utils/logger'; 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` ); } - const installTask = addDependenciesToPackageJson( - host, - {}, - { 'jsonc-eslint-parser': jsoncEslintParserVersion } - ); if (!options.skipFormat) { await formatFiles(host); } - - return () => installTask; } export function addMigrationJsonChecks(