diff --git a/packages/kbn-dependency-usage/src/cli.ts b/packages/kbn-dependency-usage/src/cli.ts index 674150fa4d91..7e3231648fa3 100644 --- a/packages/kbn-dependency-usage/src/cli.ts +++ b/packages/kbn-dependency-usage/src/cli.ts @@ -47,8 +47,8 @@ export const configureYargs = () => { }) .option('group-by', { alias: 'g', - describe: chalk.magenta('Group results by either owner or source (package/plugin)'), - choices: ['owner', 'source'], + describe: chalk.magenta('Group results by owner, source, or package'), + choices: ['owner', 'source', 'package'], }) .option('summary', { alias: 's', diff --git a/packages/kbn-dependency-usage/src/dependency_graph/common/constants.ts b/packages/kbn-dependency-usage/src/dependency_graph/common/constants.ts index 0d7f48d92697..e45fb198085a 100644 --- a/packages/kbn-dependency-usage/src/dependency_graph/common/constants.ts +++ b/packages/kbn-dependency-usage/src/dependency_graph/common/constants.ts @@ -7,7 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { KIBANA_SOLUTIONS } from '@kbn/projects-solutions-groups'; +// TODO: This cannot be imported until Kibana supports ESM +// import { KIBANA_SOLUTIONS } from '@kbn/projects-solutions-groups'; +const KIBANA_SOLUTIONS = ['observability', 'security', 'search', 'chat'] as const; export const aggregationGroups: string[] = [ ...KIBANA_SOLUTIONS.flatMap((solution) => [ diff --git a/packages/kbn-dependency-usage/src/dependency_graph/providers/cruiser.ts b/packages/kbn-dependency-usage/src/dependency_graph/providers/cruiser.ts index 33817605cf11..49e42b1b977d 100644 --- a/packages/kbn-dependency-usage/src/dependency_graph/providers/cruiser.ts +++ b/packages/kbn-dependency-usage/src/dependency_graph/providers/cruiser.ts @@ -14,6 +14,7 @@ import nodePath from 'path'; import { groupFilesByOwners } from '../../lib/group_by_owners.ts'; import { groupBySource } from '../../lib/group_by_source.ts'; +import { groupByPackage } from '../../lib/group_by_package.ts'; import { createCollapseRegexWithDepth } from '../../lib/collapse_with_depth.ts'; import { aggregationGroups, excludePaths } from '../common/constants.ts'; @@ -116,6 +117,10 @@ export async function identifyDependencyUsageWithCruiser( return groupFilesByOwners(violations); } + if (groupBy === 'package') { + return groupByPackage(violations); + } + if (dependencyName) { const dependencyRegex = new RegExp(`node_modules/${dependencyName}`); diff --git a/packages/kbn-dependency-usage/src/lib/group_by_package.test.ts b/packages/kbn-dependency-usage/src/lib/group_by_package.test.ts new file mode 100644 index 000000000000..6908b84a0f6c --- /dev/null +++ b/packages/kbn-dependency-usage/src/lib/group_by_package.test.ts @@ -0,0 +1,231 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; +import path from 'path'; +import { groupByPackage } from './group_by_package'; + +// Mock fs and path modules +jest.mock('fs'); +jest.mock('path'); + +describe('groupByPackage', () => { + // Reset mocks before each test + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should group dependencies by their package directories', () => { + // Mock path.dirname to simulate directory structure + const mockDirname = jest.fn().mockImplementation((filePath) => { + if (filePath === 'src/package1/file1.js' || filePath === 'src/package1/sub/file2.js') { + // First call for the file's immediate directory + if (filePath === 'src/package1/file1.js') return 'src/package1'; + if (filePath === 'src/package1/sub/file2.js') return 'src/package1/sub'; + } else if (filePath === 'src/package1/sub') { + // When checking parent directory of 'src/package1/sub' + return 'src/package1'; + } else if (filePath === 'src/package2/file2.js') { + return 'src/package2'; + } + // Default implementation + return filePath; + }); + + // Mock path.join to handle path concatenation + const mockJoin = jest + .fn() + .mockImplementation((...args) => args.join('/').replace(/\/\//g, '/')); + + // Mock path.parse to handle root directory detection + const mockParse = jest.fn().mockReturnValue({ root: '/' }); + + // Mock fs.existsSync to simulate kibana.jsonc files + const mockExistsSync = jest.fn().mockImplementation((filePath) => { + if (filePath === 'src/package1/kibana.jsonc') return true; + if (filePath === 'src/package2/kibana.jsonc') return true; + // No kibana.jsonc in 'src/package1/sub' + if (filePath === 'src/package1/sub/kibana.jsonc') return false; + return false; + }); + + // Apply mocks + (path.dirname as jest.Mock).mockImplementation(mockDirname); + (path.join as jest.Mock).mockImplementation(mockJoin); + (path.parse as jest.Mock).mockImplementation(mockParse); + (fs.existsSync as jest.Mock).mockImplementation(mockExistsSync); + + const dependencies = [ + { from: 'src/package1/file1.js', to: 'node_modules/module1' }, + { from: 'src/package1/sub/file2.js', to: 'node_modules/module2' }, + { from: 'src/package2/file2.js', to: 'node_modules/module3' }, + ]; + + const result = groupByPackage(dependencies); + + expect(result).toEqual({ + 'src/package1': ['module1', 'module2'], + 'src/package2': ['module3'], + }); + }); + + it('should handle a dependency with no package directory', () => { + // Mock directoryname to return consistent paths + const mockDirname = jest.fn().mockImplementation((filePath) => { + if (filePath === 'src/no-package/file.js') return 'src/no-package'; + return filePath; + }); + + // Mock path.join for consistent behavior + const mockJoin = jest + .fn() + .mockImplementation((...args) => args.join('/').replace(/\/\//g, '/')); + + // Mock path.parse to handle root directory detection + const mockParse = jest.fn().mockReturnValue({ root: '/' }); + + // Mock fs.existsSync to return false (no kibana.jsonc exists) + const mockExistsSync = jest.fn().mockReturnValue(false); + + // Apply mocks + (path.dirname as jest.Mock).mockImplementation(mockDirname); + (path.join as jest.Mock).mockImplementation(mockJoin); + (path.parse as jest.Mock).mockImplementation(mockParse); + (fs.existsSync as jest.Mock).mockImplementation(mockExistsSync); + + const dependencies = [{ from: 'src/no-package/file.js', to: 'node_modules/module1' }]; + + const result = groupByPackage(dependencies); + + // Should fall back to the file's directory when no package found + expect(result).toEqual({ + 'src/no-package': ['module1'], + }); + }); + + it('should group multiple dependencies from files in the same package', () => { + // Mock path.dirname for consistent behavior + const mockDirname = jest.fn().mockImplementation((filePath) => { + if (filePath.startsWith('src/package1/')) return 'src/package1'; + return filePath; + }); + + // Mock path.join for path concatenation + const mockJoin = jest + .fn() + .mockImplementation((...args) => args.join('/').replace(/\/\//g, '/')); + + // Mock path.parse for root directory + const mockParse = jest.fn().mockReturnValue({ root: '/' }); + + // Mock fs.existsSync to simulate kibana.jsonc file + const mockExistsSync = jest.fn().mockImplementation((filePath) => { + return filePath === 'src/package1/kibana.jsonc'; + }); + + // Apply mocks + (path.dirname as jest.Mock).mockImplementation(mockDirname); + (path.join as jest.Mock).mockImplementation(mockJoin); + (path.parse as jest.Mock).mockImplementation(mockParse); + (fs.existsSync as jest.Mock).mockImplementation(mockExistsSync); + + const dependencies = [ + { from: 'src/package1/file1.js', to: 'node_modules/module1' }, + { from: 'src/package1/file2.js', to: 'node_modules/module2' }, + { from: 'src/package1/nested/file3.js', to: 'node_modules/module3' }, + ]; + + const result = groupByPackage(dependencies); + + expect(result).toEqual({ + 'src/package1': ['module1', 'module2', 'module3'], + }); + }); + + it('should remove "node_modules/" prefix from dependencies', () => { + // Mock path.dirname for consistent behavior + const mockDirname = jest.fn().mockReturnValue('src/package1'); + + // Mock path.join for path concatenation + const mockJoin = jest + .fn() + .mockImplementation((...args) => args.join('/').replace(/\/\//g, '/')); + + // Mock path.parse for root directory + const mockParse = jest.fn().mockReturnValue({ root: '/' }); + + // Mock fs.existsSync to simulate kibana.jsonc file + const mockExistsSync = jest.fn().mockReturnValue(true); + + // Apply mocks + (path.dirname as jest.Mock).mockImplementation(mockDirname); + (path.join as jest.Mock).mockImplementation(mockJoin); + (path.parse as jest.Mock).mockImplementation(mockParse); + (fs.existsSync as jest.Mock).mockImplementation(mockExistsSync); + + const dependencies = [ + { from: 'src/package1/file1.js', to: 'node_modules/module1' }, + { from: 'src/package1/file1.js', to: 'node_modules/module2' }, + ]; + + const result = groupByPackage(dependencies); + + expect(result).toEqual({ + 'src/package1': ['module1', 'module2'], + }); + }); + + it('should return an empty object if there are no dependencies', () => { + const result = groupByPackage([]); + + expect(result).toEqual({}); + }); + + it('should handle multiple packages in nested directory structure', () => { + // Mock path.dirname to simulate directory structure + const mockDirname = jest.fn().mockImplementation((filePath) => { + if (filePath === 'src/parent/package1/file1.js') return 'src/parent/package1'; + if (filePath === 'src/parent/package2/file2.js') return 'src/parent/package2'; + return path.dirname(filePath); + }); + + // Mock path.join to handle path concatenation + const mockJoin = jest + .fn() + .mockImplementation((...args) => args.join('/').replace(/\/\//g, '/')); + + // Mock path.parse to handle root directory detection + const mockParse = jest.fn().mockReturnValue({ root: '/' }); + + // Mock fs.existsSync to simulate kibana.jsonc files + const mockExistsSync = jest.fn().mockImplementation((filePath) => { + if (filePath === 'src/parent/package1/kibana.jsonc') return true; + if (filePath === 'src/parent/package2/kibana.jsonc') return true; + return false; + }); + + // Apply mocks + (path.dirname as jest.Mock).mockImplementation(mockDirname); + (path.join as jest.Mock).mockImplementation(mockJoin); + (path.parse as jest.Mock).mockImplementation(mockParse); + (fs.existsSync as jest.Mock).mockImplementation(mockExistsSync); + + const dependencies = [ + { from: 'src/parent/package1/file1.js', to: 'node_modules/module1' }, + { from: 'src/parent/package2/file2.js', to: 'node_modules/module2' }, + ]; + + const result = groupByPackage(dependencies); + + expect(result).toEqual({ + 'src/parent/package1': ['module1'], + 'src/parent/package2': ['module2'], + }); + }); +}); diff --git a/packages/kbn-dependency-usage/src/lib/group_by_package.ts b/packages/kbn-dependency-usage/src/lib/group_by_package.ts new file mode 100644 index 000000000000..1064c5292cbc --- /dev/null +++ b/packages/kbn-dependency-usage/src/lib/group_by_package.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; +import path from 'path'; + +/** + * Determines the package directory for a given file path + * A package is defined as a directory containing a kibana.jsonc file + * + * @param filePath The path of the file to find the package for + * @returns The package directory path or the original path if no package found + */ +function findPackageDirectory(filePath: string): string { + let currentDir = path.dirname(filePath); + const rootDir = path.parse(currentDir).root; + + // Traverse up the directory tree until we find a kibana.jsonc file or hit the root + while (true) { + // Check if current directory has kibana.jsonc + if (fs.existsSync(path.join(currentDir, 'kibana.jsonc'))) { + return currentDir; + } + + // If we've reached the root and haven't found kibana.jsonc, break the loop + if (currentDir === rootDir) { + break; + } + + // Move up to parent directory + const parentDir = path.dirname(currentDir); + + // Break if we can't go up any further (safety check) + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + } + + // If no package directory found, return the directory of the original path + return path.dirname(filePath); +} + +/** + * Groups dependencies by package (directories containing kibana.jsonc files) + * + * @param dependencies Array of from/to dependency pairs + * @returns Record mapping package paths to their dependencies + */ +export function groupByPackage(dependencies: Array<{ from: string; to: string }>) { + const packageMap = new Map>(); + + for (const dep of dependencies) { + const { from, to } = dep; + + // Find the package directory for the source file + const packageDir = findPackageDirectory(from); + + if (!packageMap.has(packageDir)) { + packageMap.set(packageDir, new Set()); + } + + packageMap.get(packageDir)!.add(to.replace(/^node_modules\//, '')); + } + + // Convert the map to a record + const result: Record = {}; + for (const [packageDir, deps] of packageMap.entries()) { + result[packageDir] = Array.from(deps); + } + + return result; +} diff --git a/scripts/dependency_usage.sh b/scripts/dependency_usage.sh index ccee69e62d34..894fea42f2f1 100755 --- a/scripts/dependency_usage.sh +++ b/scripts/dependency_usage.sh @@ -3,5 +3,5 @@ # Need to tun the script with ts-node/esm since dependency-cruiser is only available as an ESM module. # We specify the correct tsconfig.json file to ensure compatibility, as our current setup doesn’t fully support ESM modules. # Should be resolved after https://github.com/elastic/kibana/issues/198790 is done. -NODE_NO_WARNINGS=1 TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=packages/kbn-dependency-usage/tsconfig.json \ +NODE_OPTIONS="--max-old-space-size=8192" NODE_NO_WARNINGS=1 TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=packages/kbn-dependency-usage/tsconfig.json \ node --loader ts-node/esm packages/kbn-dependency-usage/src/cli.ts "$@"