From ae773d547e91e3ffcd015a8afbef0c32ff7df137 Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Wed, 12 Jul 2023 20:59:36 -0400 Subject: [PATCH] fix(linter): skip verifying deps of deps by default in package.json (#18058) --- e2e/linter/src/linter.test.ts | 12 +- .../src/rules/dependency-checks.spec.ts | 171 +++++--- .../src/rules/dependency-checks.ts | 85 ++-- .../src/utils/package-json-utils.ts | 13 +- .../src/utils/find-npm-dependencies.spec.ts | 400 ++++++++++++++++++ .../js/src/utils/find-npm-dependencies.ts | 191 +++++++++ 6 files changed, 739 insertions(+), 133 deletions(-) create mode 100644 packages/js/src/utils/find-npm-dependencies.spec.ts create mode 100644 packages/js/src/utils/find-npm-dependencies.ts diff --git a/e2e/linter/src/linter.test.ts b/e2e/linter/src/linter.test.ts index 54d90f741fbd9..8049632344cf4 100644 --- a/e2e/linter/src/linter.test.ts +++ b/e2e/linter/src/linter.test.ts @@ -452,6 +452,12 @@ describe('Linter', () => { ]; return json; }); + // Set this to false for now until the `@nx/js:lib` generator is updated to include ts/swc helpers by default. + // TODO(jack): Remove this once the above is addressed in another PR. + updateJson(`libs/${mylib}/tsconfig.lib.json`, (json) => { + json.compilerOptions.importHelpers = false; + return json; + }); updateJson(`libs/${mylib}/project.json`, (json) => { json.targets.lint.options.lintFilePatterns = [ `libs/${mylib}/**/*.ts`, @@ -465,8 +471,7 @@ describe('Linter', () => { 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']; + const tslibVersion = rootPackageJson.devDependencies['tslib']; let out = runCLI(`lint ${mylib}`, { silenceError: true }); expect(out).toContain('All files pass linting'); @@ -495,9 +500,6 @@ describe('Linter', () => { { "dependencies": { "@nx/devkit": "${nxVersion}", - "@swc/core": "${swcCoreVersion}", - "@swc/helpers": "${swcHelpersVersion}", - "nx": "${nxVersion}", }, "name": "@proj/${mylib}", "type": "commonjs", diff --git a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts index a0b40558615e3..c0fa7ade83621 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts @@ -104,7 +104,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -134,6 +134,74 @@ describe('Dependency checks (eslint)', () => { expect(failures.length).toEqual(0); }); + it('should exclude files not matching input of the build target', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: {}, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './libs/liba/project.json': JSON.stringify( + { + name: 'liba', + targets: { + build: { + command: 'tsc -p tsconfig.lib.json', + }, + }, + }, + null, + 2 + ), + './nx.json': JSON.stringify({ + targetDefaults: { + build: { + inputs: [ + '{projectRoot}/**/*', + '!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)', + ], + }, + }, + }), + './package.json': JSON.stringify(rootPackageJson, null, 2), + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/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`, []), + createFile(`libs/liba/src/main.spec.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, []), + ], + } + ); + expect(failures.length).toEqual(0); + }); + it('should report missing dependencies section and fix it', () => { const packageJson = { name: '@mycompany/liba', @@ -148,7 +216,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -191,7 +259,6 @@ describe('Dependency checks (eslint)', () => { "{ "name": "@mycompany/liba", "dependencies": { - "external1": "~16.1.2" } }" `); @@ -211,7 +278,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { ignoredDependencies: ['external1'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -255,7 +322,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { ignoredDependencies: ['external1'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -299,7 +366,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { ignoredDependencies: ['external1'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -344,7 +411,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -416,7 +483,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -473,7 +540,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -534,7 +601,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { buildTargets: ['notbuild'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -590,7 +657,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { checkMissingDependencies: false }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -646,7 +713,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { ignoredDependencies: ['external2'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -706,7 +773,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -778,7 +845,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -857,7 +924,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -936,7 +1003,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1015,7 +1082,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { checkObsoleteDependencies: false }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1068,7 +1135,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { ignoredDependencies: ['unneeded'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1119,7 +1186,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1204,7 +1271,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { checkVersionMismatches: false }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1261,7 +1328,7 @@ describe('Dependency checks (eslint)', () => { const failures = runRule( { ignoredDependencies: ['external1'] }, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1330,7 +1397,7 @@ describe('Dependency checks (eslint)', () => { include: ['**/*.ts'], }; - const tsConfiogBaseJson = { + const tsConfigBaseJson = { compilerOptions: { target: 'es2015', importHelpers: true, @@ -1353,15 +1420,15 @@ describe('Dependency checks (eslint)', () => { 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), + './libs/liba/tsconfig.json': JSON.stringify(tsConfigJson, null, 2), './package.json': JSON.stringify(rootPackageJson, null, 2), - './tsconfig.base.json': JSON.stringify(tsConfiogBaseJson, null, 2), + './tsconfig.base.json': JSON.stringify(tsConfigBaseJson, null, 2), }; vol.fromJSON(fileSys, '/root'); const failures = runRule( {}, - `${process.cwd()}/proj/libs/liba/package.json`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1370,21 +1437,11 @@ describe('Dependency checks (eslint)', () => { 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', + tsConfig: 'libs/liba/tsconfig.json', }, }, }, @@ -1393,26 +1450,20 @@ describe('Dependency checks (eslint)', () => { }, 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: [{ source: 'liba', target: 'npm:external1', 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(1); expect(failures[0].message).toMatchInlineSnapshot(` "The "liba" uses the following packages, but they are missing from the "dependencies": - - tslib - - external2" + - tslib" `); expect(failures[0].line).toEqual(3); }); @@ -1435,14 +1486,14 @@ it('should require swc if @nx/js:swc executor', () => { const fileSys = { './libs/liba/package.json': JSON.stringify(packageJson, null, 2), './libs/liba/src/index.ts': '', - './libs/libb/.swcrc': JSON.stringify(swcrc, null, 2), + './libs/liba/.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`, + `/root/libs/liba/package.json`, JSON.stringify(packageJson, null, 2), { nodes: { @@ -1451,22 +1502,10 @@ it('should require swc if @nx/js:swc executor', () => { 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', - }, + options: {}, }, }, }, @@ -1474,26 +1513,21 @@ it('should require swc if @nx/js:swc executor', () => { }, externalNodes, dependencies: { - liba: [ - { source: 'liba', target: 'npm:external1', type: 'static' }, - { source: 'liba', target: 'libb', type: 'static' }, - ], - libb: [], + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], }, }, { 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 following packages, but they are missing from the "dependencies": - - @swc/helpers" -`); + "The "liba" uses the following packages, but they are missing from the "dependencies": + - @swc/helpers" + `); expect(failures[0].line).toEqual(3); }); @@ -1518,7 +1552,6 @@ function runRule( projectGraph: ProjectGraph, projectFileMap: ProjectFileMap ): Linter.LintMessage[] { - globalThis.projectPath = `${process.cwd()}/proj`; globalThis.projectGraph = projectGraph; globalThis.projectFileMap = projectFileMap; globalThis.projectRootMappings = createProjectRootMappings( diff --git a/packages/eslint-plugin/src/rules/dependency-checks.ts b/packages/eslint-plugin/src/rules/dependency-checks.ts index 25c4ae9c400e0..b539ebd27d6e2 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -1,18 +1,22 @@ +import { join } from 'path'; +import { satisfies } from 'semver'; import { AST } from 'jsonc-eslint-parser'; -import { normalizePath, workspaceRoot } from '@nx/devkit'; +import { type JSONLiteral } from 'jsonc-eslint-parser/lib/parser/ast'; +import { + normalizePath, + ProjectGraphProjectNode, + FileData, + workspaceRoot, +} from '@nx/devkit'; +import { findNpmDependencies } from '@nx/js/src/utils/find-npm-dependencies'; + 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, getPackageJson, - removePackageJsonFromFileMap, } from '../utils/package-json-utils'; -import { JSONLiteral } from 'jsonc-eslint-parser/lib/parser/ast'; export type Options = [ { @@ -22,6 +26,7 @@ export type Options = [ checkVersionMismatches?: boolean; checkMissingPackageJson?: boolean; ignoredDependencies?: string[]; + includeTransitiveDependencies?: boolean; } ]; @@ -51,6 +56,7 @@ export default createESLintRule({ checkMissingDependencies: { type: 'boolean' }, checkObsoleteDependencies: { type: 'boolean' }, checkVersionMismatches: { type: 'boolean' }, + includeTransitiveDependencies: { type: 'boolean' }, }, additionalProperties: false, }, @@ -69,6 +75,7 @@ export default createESLintRule({ checkObsoleteDependencies: true, checkVersionMismatches: true, ignoredDependencies: [], + includeTransitiveDependencies: false, }, ], create( @@ -80,6 +87,7 @@ export default createESLintRule({ checkMissingDependencies, checkObsoleteDependencies, checkVersionMismatches, + includeTransitiveDependencies, }, ] ) { @@ -92,8 +100,7 @@ export default createESLintRule({ return {}; } - const projectPath = normalizePath(globalThis.projectPath || workspaceRoot); - const sourceFilePath = getSourceFilePath(fileName, projectPath); + const sourceFilePath = getSourceFilePath(fileName, workspaceRoot); const { projectGraph, projectRootMappings, projectFileMap } = readProjectGraph(RULE_NAME); @@ -120,32 +127,19 @@ export default createESLintRule({ return {}; } - // gather helper dependencies for @nx/js executors - const helperDependencies = getHelperDependenciesFromProjectGraph( - workspaceRoot, - sourceProject.name, - projectGraph - ); - const rootPackageJson = getPackageJson(join(workspaceRoot, 'package.json')); - // find all dependencies for the project - const npmDeps = findProjectsNpmDependencies( + const npmDependencies = findNpmDependencies( + workspaceRoot, sourceProject, projectGraph, - buildTarget, - rootPackageJson, + projectFileMap, + buildTarget, // TODO: What if child library has a build target different from the parent? { - helperDependencies: helperDependencies.map((dep) => dep.target), - isProduction: true, - }, - removePackageJsonFromFileMap(projectFileMap) + includeTransitiveDependencies, + } ); - const projDependencies = { - ...npmDeps.dependencies, - ...npmDeps.peerDependencies, - }; - const expectedDependencyNames = Object.keys(projDependencies); + const expectedDependencyNames = Object.keys(npmDependencies); const projPackageJsonPath = join( workspaceRoot, @@ -180,7 +174,7 @@ export default createESLintRule({ fix(fixer) { missingDeps.forEach((d) => { projPackageJsonDeps[d] = - rootPackageJsonDeps[d] || projDependencies[d]; + rootPackageJsonDeps[d] || npmDependencies[d]; }); const deps = (node.value as AST.JSONObjectExpression).properties; @@ -213,8 +207,9 @@ export default createESLintRule({ return; } if ( - projDependencies[packageName] === '*' || - satisfies(projDependencies[packageName], packageRange) + npmDependencies[packageName] === '*' || + packageRange === '*' || + satisfies(npmDependencies[packageName], packageRange) ) { return; } @@ -224,13 +219,13 @@ export default createESLintRule({ messageId: 'versionMismatch', data: { packageName: packageName, - version: projDependencies[packageName], + version: npmDependencies[packageName], }, fix: (fixer) => fixer.replaceText( node as any, `"${packageName}": "${ - rootPackageJsonDeps[packageName] || projDependencies[packageName] + rootPackageJsonDeps[packageName] || npmDependencies[packageName] }"` ), }); @@ -308,15 +303,15 @@ export default createESLintRule({ .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(','); + expectedDependencyNames.sort().reduce((acc, d) => { + acc[d] = rootPackageJsonDeps[d] || dependencies[d]; + return acc; + }, projPackageJsonDeps); + if (!node.properties.length) { return fixer.replaceText( node as any, @@ -337,7 +332,7 @@ export default createESLintRule({ ['JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=/^(dev|peer|optional)?dependencies$/i]']( node: AST.JSONProperty ) { - return validateMissingDependencies(node); + validateMissingDependencies(node); }, ['JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=/^(dev|peer|optional)?dependencies$/i] > JSONObjectExpression > JSONProperty']( node: AST.JSONProperty @@ -350,19 +345,15 @@ export default createESLintRule({ } if (expectedDependencyNames.includes(packageName)) { - return validateVersionMatchesInstalled( - node, - packageName, - packageRange - ); + validateVersionMatchesInstalled(node, packageName, packageRange); } else { - return reportObsoleteDependency(node, packageName); + reportObsoleteDependency(node, packageName); } }, ['JSONExpressionStatement > JSONObjectExpression']( node: AST.JSONObjectExpression ) { - return validateDependenciesSectionExistance(node); + validateDependenciesSectionExistance(node); }, }; }, diff --git a/packages/eslint-plugin/src/utils/package-json-utils.ts b/packages/eslint-plugin/src/utils/package-json-utils.ts index bf1759d37f32e..178e9d09e83c0 100644 --- a/packages/eslint-plugin/src/utils/package-json-utils.ts +++ b/packages/eslint-plugin/src/utils/package-json-utils.ts @@ -9,6 +9,7 @@ export function getAllDependencies( ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies, + ...packageJson.optionalDependencies, }; } @@ -18,15 +19,3 @@ export function getPackageJson(path: string): PackageJson { } return {} as PackageJson; } - -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/utils/find-npm-dependencies.spec.ts b/packages/js/src/utils/find-npm-dependencies.spec.ts new file mode 100644 index 0000000000000..d5f0f4cd16cff --- /dev/null +++ b/packages/js/src/utils/find-npm-dependencies.spec.ts @@ -0,0 +1,400 @@ +import 'nx/src/utils/testing/mock-fs'; +import { vol } from 'memfs'; +import { findNpmDependencies } from './find-npm-dependencies'; + +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + workspaceRoot: '/root', +})); + +jest.mock('nx/src/utils/workspace-root', () => ({ + workspaceRoot: '/root', +})); + +describe('findNpmDependencies', () => { + const nxJson = { + targetDefaults: { + build: { + inputs: [ + '{projectRoot}/**/*', + '!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)', + ], + }, + }, + }; + + afterEach(() => { + vol.reset(); + }); + + it('should pick up external npm dependencies and their versions', () => { + vol.fromJSON( + { + './nx.json': JSON.stringify(nxJson), + }, + '/root' + ); + const libWithExternalDeps = { + name: 'my-lib', + type: 'lib' as const, + data: { + root: 'libs/my-lib', + targets: { build: {} }, + }, + }; + const projectGraph = { + nodes: { + 'my-lib': libWithExternalDeps, + }, + externalNodes: { + 'npm:foo': { + name: 'npm:foo' as const, + type: 'npm' as const, + data: { + packageName: 'foo', + version: '1.0.0', + }, + }, + }, + dependencies: {}, + }; + const projectFileMap = { + 'my-lib': [ + { + file: 'libs/my-lib/index.ts', + hash: '123', + deps: ['npm:foo'], + }, + ], + }; + + const results = findNpmDependencies( + '/root', + libWithExternalDeps, + projectGraph, + projectFileMap, + 'build' + ); + + expect(results).toEqual({ + foo: '1.0.0', + }); + }); + + it('should pick up helper npm dependencies if required', () => { + vol.fromJSON( + { + './nx.json': JSON.stringify(nxJson), + './libs/my-lib/tsconfig.json': JSON.stringify({ + compilerOptions: { + importHelpers: true, + }, + }), + './libs/my-lib/.swcrc': JSON.stringify({ + jsc: { + externalHelpers: true, + }, + }), + }, + '/root' + ); + const libWithHelpers = { + name: 'my-lib', + type: 'lib' as const, + data: { + root: 'libs/my-lib', + targets: { + build1: { + executor: '@nx/js:tsc', + options: { + tsConfig: 'libs/my-lib/tsconfig.json', + }, + }, + build2: { + executor: '@nx/js:swc', + options: {}, + }, + }, + }, + }; + const projectGraph = { + nodes: { + 'my-lib': libWithHelpers, + }, + externalNodes: { + 'npm:tslib': { + name: 'npm:tslib' as const, + type: 'npm' as const, + data: { + packageName: 'tslib', + version: '2.6.0', + }, + }, + 'npm:@swc/helpers': { + name: 'npm:@swc/helpers' as const, + type: 'npm' as const, + data: { + packageName: '@swc/helpers', + version: '0.5.0', + }, + }, + }, + dependencies: {}, + }; + const projectFileMap = { + 'my-lib': [], + }; + + expect( + findNpmDependencies( + '/root', + libWithHelpers, + projectGraph, + projectFileMap, + 'build1' + ) + ).toEqual({ + tslib: '2.6.0', + }); + expect( + findNpmDependencies( + '/root', + libWithHelpers, + projectGraph, + projectFileMap, + 'build2' + ) + ).toEqual({ + '@swc/helpers': '0.5.0', + }); + }); + + it('should not pick up helper npm dependencies if not required', () => { + vol.fromJSON( + { + './libs/my-lib/tsconfig.json': JSON.stringify({ + compilerOptions: { + importHelpers: false, + }, + }), + './libs/my-lib/.swcrc': JSON.stringify({ + jsc: { + externalHelpers: false, + }, + }), + }, + '/root' + ); + const libWithInlinedHelpers = { + name: 'my-lib', + type: 'lib' as const, + data: { + root: 'libs/my-lib', + targets: { + build1: { + executor: '@nx/js:tsc', + options: { + tsConfig: 'libs/my-lib/tsconfig.json', + }, + }, + build2: { + executor: '@nx/js:swc', + options: {}, + }, + }, + }, + }; + const projectGraph = { + nodes: { + 'my-lib': libWithInlinedHelpers, + }, + externalNodes: { + 'npm:tslib': { + name: 'npm:tslib' as const, + type: 'npm' as const, + data: { + packageName: 'tslib', + version: '2.6.0', + }, + }, + 'npm:@swc/helpers': { + name: 'npm:@swc/helpers' as const, + type: 'npm' as const, + data: { + packageName: '@swc/helpers', + version: '0.5.0', + }, + }, + }, + dependencies: {}, + }; + const projectFileMap = { + 'my-lib': [], + }; + + const results = findNpmDependencies( + '/root', + libWithInlinedHelpers, + projectGraph, + projectFileMap, + 'build' + ); + + expect(results).toEqual({}); + }); + + it('should support recursive collection of dependencies', () => { + vol.fromJSON( + { + './nx.json': JSON.stringify(nxJson), + }, + '/root' + ); + const parentLib = { + name: 'parent', + type: 'lib' as const, + data: { + root: 'libs/parent', + targets: { build: {} }, + }, + }; + const projectGraph = { + nodes: { + parent: parentLib, + child1: { + name: 'child1', + type: 'lib' as const, + data: { + root: 'libs/child1', + targets: { build: {} }, + }, + }, + child2: { + name: 'child2', + type: 'lib' as const, + data: { + root: 'libs/child2', + targets: { build: {} }, + }, + }, + }, + externalNodes: { + 'npm:foo': { + name: 'npm:foo' as const, + type: 'npm' as const, + data: { + packageName: 'foo', + version: '1.0.0', + }, + }, + }, + dependencies: { + parent: [ + { + type: 'static', + source: 'parent', + target: 'child1', + }, + ], + child1: [ + { + type: 'static', + source: 'child1', + target: 'child2', + }, + ], + child2: [ + { + type: 'static', + source: 'child2', + target: 'npm:foo', + }, + ], + }, + }; + const projectFileMap = { + parent: [{ file: 'libs/parent/index.ts', hash: '123', deps: ['child1'] }], + child1: [{ file: 'libs/child1/index.ts', hash: '123', deps: ['child2'] }], + child2: [ + { file: 'libs/child2/index.ts', hash: '123', deps: ['npm:foo'] }, + ], + }; + + const results = findNpmDependencies( + '/root', + parentLib, + projectGraph, + projectFileMap, + 'build', + { + includeTransitiveDependencies: true, + } + ); + + expect(results).toEqual({ + foo: '1.0.0', + }); + }); + + it('should find workspace dependencies', () => { + vol.fromJSON( + { + './libs/lib3/package.json': JSON.stringify({ + name: '@acme/lib3', + version: '0.0.1', + }), + './nx.json': JSON.stringify(nxJson), + }, + '/root' + ); + const lib1 = { + name: 'lib1', + type: 'lib' as const, + data: { + root: 'libs/lib1', + targets: { build: {} }, + }, + }; + const lib2 = { + name: 'lib2', + type: 'lib' as const, + data: { + root: 'libs/lib2', + targets: { build: {} }, + }, + }; + const lib3 = { + name: 'lib3', + type: 'lib' as const, + data: { + root: 'libs/lib3', + targets: { build: {} }, + }, + }; + const projectGraph = { + nodes: { + lib1: lib1, + lib2: lib2, + lib3: lib3, + }, + externalNodes: {}, + dependencies: {}, + }; + const projectFileMap = { + lib1: [{ file: 'libs/lib1/index.ts', hash: '123', deps: ['lib3'] }], + lib2: [{ file: 'libs/lib1/index.ts', hash: '123', deps: ['lib3'] }], + lib3: [], + }; + + expect( + findNpmDependencies('/root', lib1, projectGraph, projectFileMap, 'build') + ).toEqual({ + '@acme/lib3': '*', + }); + expect( + findNpmDependencies('/root', lib2, projectGraph, projectFileMap, 'build') + ).toEqual({ + '@acme/lib3': '*', + }); + }); +}); diff --git a/packages/js/src/utils/find-npm-dependencies.ts b/packages/js/src/utils/find-npm-dependencies.ts new file mode 100644 index 0000000000000..7a14fd70fcd62 --- /dev/null +++ b/packages/js/src/utils/find-npm-dependencies.ts @@ -0,0 +1,191 @@ +import { join } from 'path'; +import { readNxJson } from 'nx/src/project-graph/file-utils'; +import { + getTargetInputs, + filterUsingGlobPatterns, +} from 'nx/src/hasher/task-hasher'; +import { + type ProjectGraph, + type ProjectGraphProjectNode, + type ProjectFileMap, + readJsonFile, + FileData, + joinPathFragments, +} from '@nx/devkit'; +import { fileExists } from 'nx/src/utils/fileutils'; +import { fileDataDepTarget } from 'nx/src/config/project-graph'; +import { readTsConfig } from './typescript/ts-config'; + +/** + * Finds all npm dependencies and their expected versions for a given project. + */ +export function findNpmDependencies( + workspaceRoot: string, + sourceProject: ProjectGraphProjectNode, + projectGraph: ProjectGraph, + projectFileMap: ProjectFileMap, + buildTarget: string, + options: { + includeTransitiveDependencies?: boolean; + } = {} +): Record { + let seen: null | Set = null; + if (options.includeTransitiveDependencies) { + seen = new Set(); + } + + const results: Record = {}; + + function collectAll( + currentProject: ProjectGraphProjectNode, + collectedDeps: Record + ): void { + if (seen?.has(currentProject.name)) return; + + collectDependenciesFromFileMap( + workspaceRoot, + currentProject, + projectGraph, + projectFileMap, + buildTarget, + collectedDeps + ); + + collectHelperDependencies( + workspaceRoot, + currentProject, + projectGraph, + buildTarget, + collectedDeps + ); + + if (options.includeTransitiveDependencies) { + const projectDeps = projectGraph.dependencies[currentProject.name]; + for (const dep of projectDeps) { + const projectDep = projectGraph.nodes[dep.target]; + if (projectDep) collectAll(projectDep, collectedDeps); + } + } + } + + collectAll(sourceProject, results); + + return results; +} + +// Keep track of workspace libs we already read package.json for so we don't read from disk again. +const seenWorkspaceDeps: Record = {}; + +function collectDependenciesFromFileMap( + workspaceRoot: string, + sourceProject: ProjectGraphProjectNode, + projectGraph: ProjectGraph, + projectFileMap: ProjectFileMap, + buildTarget: string, + npmDeps: Record +): void { + const rawFiles = projectFileMap[sourceProject.name]; + if (!rawFiles) return; + + // Cannot read inputs if the target does not exist on the project. + if (!sourceProject.data.targets[buildTarget]) return; + + const inputs = getTargetInputs( + readNxJson(), + sourceProject, + buildTarget + ).selfInputs; + const files = filterUsingGlobPatterns( + sourceProject.data.root, + projectFileMap[sourceProject.name] || [], + inputs + ); + + for (const fileData of files) { + if ( + !fileData.deps || + fileData.file === + joinPathFragments(sourceProject.data.root, 'package.json') + ) { + continue; + } + + for (const dep of fileData.deps) { + const target = fileDataDepTarget(dep); + + // If the node is external, then read package info from `data`. + const externalDep = projectGraph.externalNodes[target]; + if (externalDep?.type === 'npm') { + npmDeps[externalDep.data.packageName] = externalDep.data.version; + continue; + } + + // If node is internal, then try reading package info from `package.json` (which must exist for this to work). + const workspaceDep = projectGraph.nodes[target]; + if (!workspaceDep) continue; + const cached = seenWorkspaceDeps[workspaceDep.name]; + if (cached) { + npmDeps[cached.name] = cached.version; + } else { + const packageJson = readPackageJson(workspaceDep, workspaceRoot); + if (packageJson) { + // This is a workspace lib so we can't reliably read in a specific version since it depends on how the workspace is set up. + // ASSUMPTION: Most users will use '*' for workspace lib versions. Otherwise, they can manually update it. + npmDeps[packageJson.name] = '*'; + seenWorkspaceDeps[workspaceDep.name] = { + name: packageJson.name, + version: '*', + }; + } + } + } + } +} + +function readPackageJson( + project: ProjectGraphProjectNode, + workspaceRoot: string +): null | { + name: string; + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +} { + const packageJsonPath = join( + workspaceRoot, + project.data.root, + 'package.json' + ); + if (fileExists(packageJsonPath)) return readJsonFile(packageJsonPath); + return null; +} + +function collectHelperDependencies( + workspaceRoot: string, + sourceProject: ProjectGraphProjectNode, + projectGraph: ProjectGraph, + buildTarget: string, + npmDeps: Record +): void { + const target = sourceProject.data.targets[buildTarget]; + if (!target) return; + + if (target.executor === '@nx/js:tsc' && target.options?.tsConfig) { + const tsConfig = readTsConfig(join(workspaceRoot, target.options.tsConfig)); + if (tsConfig?.options['importHelpers']) { + npmDeps['tslib'] = projectGraph.externalNodes['npm:tslib']?.data.version; + } + } + if (target.executor === '@nx/js:swc') { + const swcConfigPath = target.options.swcrc + ? join(workspaceRoot, target.options.swcrc) + : join(workspaceRoot, sourceProject.data.root, '.swcrc'); + const swcConfig = fileExists(swcConfigPath) + ? readJsonFile(swcConfigPath) + : {}; + if (swcConfig?.jsc?.externalHelpers) { + npmDeps['@swc/helpers'] = + projectGraph.externalNodes['npm:@swc/helpers']?.data.version; + } + } +}