diff --git a/extractors/cds/tools/index-files.ts b/extractors/cds/tools/index-files.ts index 7fb876252..847041c21 100644 --- a/extractors/cds/tools/index-files.ts +++ b/extractors/cds/tools/index-files.ts @@ -42,7 +42,7 @@ if (!envSetupSuccess) { process.exit(1); } -// Validate response file and get the fully paths of CDS files to process. +// Validate response file and get the full paths of CDS files to process. const filePathsResult = getCdsFilePathsToProcess(responseFile, platformInfo); if (!filePathsResult.success) { console.warn(filePathsResult.errorMessage); @@ -54,7 +54,8 @@ if (!filePathsResult.success) { const cdsFilePathsToProcess = filePathsResult.cdsFilePaths; // Find all package.json directories that have a `@sap/cds` node dependency. -const packageJsonDirs = findPackageJsonDirs(cdsFilePathsToProcess, codeqlExePath); +// Pass the source root to prevent searching above it +const packageJsonDirs = findPackageJsonDirs(cdsFilePathsToProcess, codeqlExePath, sourceRoot); // Install node dependencies in each directory. console.log('Pre-installing required CDS compiler versions ...'); diff --git a/extractors/cds/tools/src/packageManager.ts b/extractors/cds/tools/src/packageManager.ts index 786f6df27..5573d04d7 100644 --- a/extractors/cds/tools/src/packageManager.ts +++ b/extractors/cds/tools/src/packageManager.ts @@ -14,20 +14,32 @@ export interface PackageJson { } /** - * Find directories containing package.json with @sap/cds dependency - * @param filePaths List of CDS file paths to check - * @param codeqlExePath Path to the CodeQL executable (optional) - * @returns Set of directories containing relevant package.json files + * Find directories containing package.json with a `@sap/cds` dependency. + * @param filePaths List of CDS file paths to check. + * @param codeqlExePath Path to the CodeQL executable (optional). + * @param sourceRoot The source root directory (optional) - Limits the search to + * never go above this directory. + * @returns Set of directories containing relevant package.json files. */ -export function findPackageJsonDirs(filePaths: string[], codeqlExePath?: string): Set { +export function findPackageJsonDirs( + filePaths: string[], + codeqlExePath?: string, + sourceRoot?: string, +): Set { const packageJsonDirs = new Set(); + const absoluteSourceRoot = sourceRoot ? resolve(sourceRoot) : undefined; filePaths.forEach(file => { let dir = dirname(resolve(file)); - const rootDir = dirname(dir); // Keep track of the root to avoid infinite loop - while (dir !== rootDir && rootDir !== dir) { - // Check until we reach the root directory + // Check current directory and parent directories for package.json with a + // dependency on `@sap/cds`. Never look above the source root directory. + while (true) { + // Stop if we've reached or gone above the source root directory. + if (absoluteSourceRoot && !dir.startsWith(absoluteSourceRoot)) { + break; + } + const packageJsonPath = join(dir, 'package.json'); if (existsSync(packageJsonPath)) { try { diff --git a/extractors/cds/tools/test/src/packageManager.test.ts b/extractors/cds/tools/test/src/packageManager.test.ts index 34b51c8fa..6e7629cdc 100644 --- a/extractors/cds/tools/test/src/packageManager.test.ts +++ b/extractors/cds/tools/test/src/packageManager.test.ts @@ -33,103 +33,143 @@ describe('packageManager', () => { }); describe('findPackageJsonDirs', () => { - // Set up mocks for dirname to simulate directory structure - let dirLevel = 2; + // Helper functions to create cleaner tests + const setupMockDirectoryStructure = (dirStructure: Record) => { + // Mock implementation for dirname to simulate specific directory structure + (path.dirname as jest.Mock).mockImplementation((p: string) => { + return dirStructure[p] || p; // Return mapped parent or same path + }); - beforeEach(() => { - // Reset dirLevel before each test - dirLevel = 2; - - // Mock implementation for dirname to simulate directory traversal - (path.dirname as jest.Mock).mockImplementation(p => { - // Return parent directory based on level - if (dirLevel > 0) { - dirLevel--; - return `${p}_parent`; + // Mock join to concatenate paths + (path.join as jest.Mock).mockImplementation((dir: string, file: string) => `${dir}/${file}`); + + // Default resolve implementation + (path.resolve as jest.Mock).mockImplementation((p: string) => p); + }; + + const setupMockPackageJson = (pathsAndContents: Record) => { + // Mock existsSync to check specific paths + (fs.existsSync as jest.Mock).mockImplementation((path: string) => { + return Object.prototype.hasOwnProperty.call(pathsAndContents, path); + }); + + // Mock readFileSync to return specified content + (fs.readFileSync as jest.Mock).mockImplementation((path: string) => { + if (pathsAndContents[path] === 'invalid-json') { + return 'invalid-json'; } - return p; // Stop traversal by returning same path + return JSON.stringify(pathsAndContents[path]); }); + }; - // Mock join to concatenate paths - (path.join as jest.Mock).mockImplementation((dir, file) => `${dir}/${file}`); + beforeEach(() => { + // Mock join to concatenate paths by default + (path.join as jest.Mock).mockImplementation((dir: string, file: string) => `${dir}/${file}`); }); it('should find directories with package.json containing @sap/cds dependency', () => { - // Sample CDS file paths - const filePaths = ['/project/src/file1.cds', '/project/src/file2.cds']; - - // Mock existsSync to return true for package.json - (fs.existsSync as jest.Mock).mockImplementation(() => true); - - // Mock readFileSync to return valid package.json content with @sap/cds dependency - (fs.readFileSync as jest.Mock).mockReturnValue( - JSON.stringify({ + // Setup directory structure + const dirStructure = { + '/project/src/file1.cds': '/project/src', + '/project/src': '/project', + '/project/src/file2.cds': '/project/src', + '/project': '/', + }; + setupMockDirectoryStructure(dirStructure); + + // Setup package.json files + const packageJsonFiles = { + '/project/package.json': { name: 'test-project', dependencies: { '@sap/cds': '4.0.0', }, - }), - ); - - // We need to reset dirLevel between file paths to simulate correct directory traversal - (path.dirname as jest.Mock).mockImplementation(p => { - if (p === '/project/src/file1.cds') { - return '/project/src/file1.cds_parent'; - } else if (p === '/project/src/file1.cds_parent') { - return '/project/src/file1.cds_parent_parent'; - } else if (p === '/project/src/file2.cds') { - return '/project/src/file2.cds_parent'; - } else if (p === '/project/src/file2.cds_parent') { - return '/project/src/file2.cds_parent_parent'; - } - return p; // Return the same path to stop traversal - }); + }, + }; + setupMockPackageJson(packageJsonFiles); + const filePaths = ['/project/src/file1.cds', '/project/src/file2.cds']; const result = findPackageJsonDirs(filePaths, '/mock/codeql'); - expect(result.size).toBe(2); // Should find package.json for both files - expect(Array.from(result).sort()).toEqual( - ['/project/src/file1.cds_parent', '/project/src/file2.cds_parent'].sort(), - ); + expect(result.size).toBe(1); + expect(Array.from(result)[0]).toBe('/project'); }); it('should not include directories without @sap/cds dependency', () => { - const filePaths = ['/project/src/file.cds']; - - (fs.existsSync as jest.Mock).mockReturnValue(true); - (fs.readFileSync as jest.Mock).mockReturnValue( - JSON.stringify({ - name: 'test-project', + // Setup directory structure for various non-CDS package.json scenarios + const dirStructure = { + '/project-no-deps/file.cds': '/project-no-deps', + '/project-empty-deps/file.cds': '/project-empty-deps', + '/project-other-deps/file.cds': '/project-other-deps', + '/project-dev-deps/file.cds': '/project-dev-deps', + '/project-no-deps': '/', + '/project-empty-deps': '/', + '/project-other-deps': '/', + '/project-dev-deps': '/', + }; + setupMockDirectoryStructure(dirStructure); + + // Different package.json files without @sap/cds dependency + const packageJsonFiles = { + '/project-no-deps/package.json': { + name: 'test-project-no-deps', + // No dependencies at all + }, + '/project-empty-deps/package.json': { + name: 'test-project-empty-deps', + dependencies: {}, + }, + '/project-other-deps/package.json': { + name: 'test-project-other-deps', dependencies: { 'other-package': '1.0.0', }, - }), - ); - + }, + '/project-dev-deps/package.json': { + name: 'test-project-dev-deps', + // @sap/cds in devDependencies shouldn't count + devDependencies: { + '@sap/cds': '4.0.0', + }, + }, + }; + setupMockPackageJson(packageJsonFiles); + + const filePaths = [ + '/project-no-deps/file.cds', + '/project-empty-deps/file.cds', + '/project-other-deps/file.cds', + '/project-dev-deps/file.cds', + ]; const result = findPackageJsonDirs(filePaths, '/mock/codeql'); expect(result.size).toBe(0); }); it('should handle JSON parse errors gracefully', () => { - const filePaths = ['/project/src/file.cds']; + const dirStructure = { + '/project/file.cds': '/project', + '/project': '/', + }; + setupMockDirectoryStructure(dirStructure); - (fs.existsSync as jest.Mock).mockReturnValue(true); - (fs.readFileSync as jest.Mock).mockImplementation(() => { - return 'invalid-json'; - }); + const packageJsonFiles = { + '/project/package.json': 'invalid-json', + }; + setupMockPackageJson(packageJsonFiles); - // Mock console.warn to avoid polluting test output + // Mock console.warn const originalConsoleWarn = console.warn; console.warn = jest.fn(); + const filePaths = ['/project/file.cds']; const mockCodeqlPath = '/mock/codeql'; const result = findPackageJsonDirs(filePaths, mockCodeqlPath); expect(result.size).toBe(0); expect(console.warn).toHaveBeenCalled(); expect(addPackageJsonParsingDiagnostic).toHaveBeenCalledWith( - expect.stringContaining('/package.json'), + '/project/package.json', expect.any(String), mockCodeqlPath, ); @@ -137,6 +177,189 @@ describe('packageManager', () => { // Restore console.warn console.warn = originalConsoleWarn; }); + + it('should detect package.json in the source root directory', () => { + // Setup directory structure with file in source root + const dirStructure = { + '/source-root/file.cds': '/source-root', + '/source-root': '/', + }; + setupMockDirectoryStructure(dirStructure); + + const packageJsonFiles = { + '/source-root/package.json': { + name: 'root-project', + dependencies: { + '@sap/cds': '5.0.0', + }, + }, + }; + setupMockPackageJson(packageJsonFiles); + + const filePaths = ['/source-root/file.cds']; + const result = findPackageJsonDirs(filePaths, '/mock/codeql'); + + expect(result.size).toBe(1); + expect(Array.from(result)[0]).toBe('/source-root'); + }); + + it('should not look for package.json above the source root directory', () => { + // Setup directory structure with parent above source root + const dirStructure = { + '/source-root/subdir/file.cds': '/source-root/subdir', + '/source-root/subdir': '/source-root', + '/source-root': '/', + }; + setupMockDirectoryStructure(dirStructure); + + // Package.json in source root and above it + const packageJsonFiles = { + '/source-root/package.json': { + name: 'root-project', + dependencies: { + '@sap/cds': '5.0.0', + }, + }, + '/package.json': { + name: 'parent-project', + dependencies: { + '@sap/cds': '5.0.0', + }, + }, + }; + setupMockPackageJson(packageJsonFiles); + + const filePaths = ['/source-root/subdir/file.cds']; + const sourceRoot = '/source-root'; + const result = findPackageJsonDirs(filePaths, '/mock/codeql', sourceRoot); + + // Should only find the package.json in source root, not the one above it + expect(result.size).toBe(1); + expect(Array.from(result)[0]).toBe('/source-root'); + }); + + it('should find multiple sub-projects under a common source root', () => { + // Setup complex directory structure with multiple projects + const dirStructure = { + '/source-root/project1/src/file1.cds': '/source-root/project1/src', + '/source-root/project1/src': '/source-root/project1', + '/source-root/project1': '/source-root', + + '/source-root/project2/src/file2.cds': '/source-root/project2/src', + '/source-root/project2/src': '/source-root/project2', + '/source-root/project2': '/source-root', + + '/source-root/non-cds-project/file3.cds': '/source-root/non-cds-project', + '/source-root/non-cds-project': '/source-root', + + '/source-root': '/', + }; + setupMockDirectoryStructure(dirStructure); + + // Multiple package.json files with different contents + const packageJsonFiles = { + // Root project with @sap/cds + '/source-root/package.json': { + name: 'root-project', + dependencies: { + '@sap/cds': '5.0.0', + }, + }, + // Subproject 1 with @sap/cds + '/source-root/project1/package.json': { + name: 'project1', + dependencies: { + '@sap/cds': '4.5.0', + }, + }, + // Subproject 2 with @sap/cds + '/source-root/project2/package.json': { + name: 'project2', + dependencies: { + '@sap/cds': '4.7.0', + }, + }, + // Non-CDS project + '/source-root/non-cds-project/package.json': { + name: 'non-cds-project', + dependencies: { + 'other-dep': '1.0.0', + }, + }, + }; + setupMockPackageJson(packageJsonFiles); + + // Test with files from all projects + const filePaths = [ + '/source-root/project1/src/file1.cds', + '/source-root/project2/src/file2.cds', + '/source-root/non-cds-project/file3.cds', + ]; + const sourceRoot = '/source-root'; + const result = findPackageJsonDirs(filePaths, '/mock/codeql', sourceRoot); + + // Should find three CDS projects (root + two subprojects) + expect(result.size).toBe(3); + expect(Array.from(result).sort()).toEqual( + ['/source-root', '/source-root/project1', '/source-root/project2'].sort(), + ); + }); + + it('should detect a source root that is not a CDS project itself', () => { + // Setup directory structure where source root doesn't have @sap/cds + const dirStructure = { + '/source-root/project1/src/file1.cds': '/source-root/project1/src', + '/source-root/project1/src': '/source-root/project1', + '/source-root/project1': '/source-root', + '/source-root': '/', + }; + setupMockDirectoryStructure(dirStructure); + + const packageJsonFiles = { + // Root project WITHOUT @sap/cds + '/source-root/package.json': { + name: 'root-project', + dependencies: { + 'other-dep': '1.0.0', + }, + }, + // Subproject with @sap/cds + '/source-root/project1/package.json': { + name: 'project1', + dependencies: { + '@sap/cds': '4.5.0', + }, + }, + }; + setupMockPackageJson(packageJsonFiles); + + const filePaths = ['/source-root/project1/src/file1.cds']; + const sourceRoot = '/source-root'; + const result = findPackageJsonDirs(filePaths, '/mock/codeql', sourceRoot); + + // Should only find the subproject, not the root + expect(result.size).toBe(1); + expect(Array.from(result)[0]).toBe('/source-root/project1'); + }); + + it('should handle edge case with no existing package.json files', () => { + // Setup directory structure with no package.json + const dirStructure = { + '/source-root/file.cds': '/source-root', + '/source-root': '/', + }; + setupMockDirectoryStructure(dirStructure); + + // No package.json files exist + setupMockPackageJson({}); + + const filePaths = ['/source-root/file.cds']; + const sourceRoot = '/source-root'; + const result = findPackageJsonDirs(filePaths, '/mock/codeql', sourceRoot); + + // Should find no projects + expect(result.size).toBe(0); + }); }); describe('installDependencies', () => {