diff --git a/src/index.js b/src/index.js index b3b915c8..24d2ed26 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import fs from 'node:fs' import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' import { discoverMavenModules } from './providers/java_maven.js' +import { discoverGradleSubprojects } from './providers/java_gradle.js' import { discoverWorkspaceCrates, discoverWorkspacePackages, @@ -25,6 +26,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom } export { discoverMavenModules, + discoverGradleSubprojects, discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, @@ -321,7 +323,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) { * * @param {string} root - Resolved workspace root * @param {Options} opts - * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'unknown', manifestPaths: string[] }>} + * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'unknown', manifestPaths: string[] }>} * @private */ async function detectWorkspaceManifests(root, opts) { @@ -341,6 +343,15 @@ async function detectWorkspaceManifests(root, opts) { } } + const hasGradleSettings = fs.existsSync(path.join(root, 'settings.gradle')) + || fs.existsSync(path.join(root, 'settings.gradle.kts')) + if (hasGradleSettings) { + const manifestPaths = await discoverGradleSubprojects(root, opts) + if (manifestPaths.length > 0) { + return { ecosystem: 'gradle', manifestPaths } + } + } + const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml')) || fs.existsSync(path.join(root, 'yarn.lock')) || fs.existsSync(path.join(root, 'package-lock.json')) diff --git a/src/providers/java_gradle.js b/src/providers/java_gradle.js index f7095dac..88b1505b 100644 --- a/src/providers/java_gradle.js +++ b/src/providers/java_gradle.js @@ -1,4 +1,6 @@ +import crypto from 'node:crypto' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { EOL } from 'os' @@ -6,6 +8,8 @@ import TOML from 'fast-toml' import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' +import { invokeCommand } from '../tools.js' +import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_gradle } from "./base_java.js"; @@ -466,3 +470,117 @@ export default class Java_gradle extends Base_java { return undefined } } + +const DEFAULT_GRADLE_DISCOVERY_IGNORE = [ + '**/build/**', + '**/.gradle/**', +] + +/** Gradle init script that emits structured project listing. */ +const GRADLE_INIT_SCRIPT = `allprojects { + task daListProjects { + doLast { + println "::DA_PROJECT::\${project.path}::\${project.projectDir}" + } + } +} +` + +/** + * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. + * Uses a custom init script to get structured project listing. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts]) + * @param {import('../index.js').Options} [opts={}] + * @returns {Promise} Paths to build.gradle[.kts] files (absolute) + */ +export async function discoverGradleSubprojects(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const hasSettings = fs.existsSync(path.join(root, 'settings.gradle')) + || fs.existsSync(path.join(root, 'settings.gradle.kts')) + + if (!hasSettings) { + return [] + } + + const manifestPaths = [] + + const rootBuildKts = path.join(root, 'build.gradle.kts') + const rootBuild = path.join(root, 'build.gradle') + const rootManifest = fs.existsSync(rootBuildKts) ? rootBuildKts : fs.existsSync(rootBuild) ? rootBuild : null + if (rootManifest) { + manifestPaths.push(rootManifest) + } + + let gradleBin + try { + gradleBin = new Java_gradle().selectToolBinary(rootManifest || rootBuild, opts) + } catch { + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) + } + + const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${crypto.randomUUID()}.gradle`) + try { + fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT) + let output + try { + output = invokeCommand(gradleBin, [ + '-q', '--no-daemon', + '--init-script', initScriptPath, + 'daListProjects', + ], { cwd: root }) + } catch { + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) + } + + const projects = parseGradleInitScriptOutput(output.toString()) + for (const proj of projects) { + if (proj.path === ':') { + continue + } + const projDir = path.resolve(proj.dir) + const buildKts = path.join(projDir, 'build.gradle.kts') + const buildGroovy = path.join(projDir, 'build.gradle') + if (fs.existsSync(buildKts)) { + manifestPaths.push(buildKts) + } else if (fs.existsSync(buildGroovy)) { + manifestPaths.push(buildGroovy) + } + } + } finally { + try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ } + } + + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} + +/** + * Parse the structured output from the Gradle init script. + * + * @param {string} raw - Raw stdout from gradle + * @returns {{ path: string, dir: string }[]} + */ +export function parseGradleInitScriptOutput(raw) { + const projects = [] + for (const rawLine of raw.split('\n')) { + const line = rawLine.trimEnd() + if (!line.startsWith('::DA_PROJECT::')) { + continue + } + const prefix = '::DA_PROJECT::' + const remainder = line.substring(prefix.length) + const lastSep = remainder.lastIndexOf('::') + if (lastSep < 0) { + continue + } + const projPath = remainder.substring(0, lastSep) + const dir = remainder.substring(lastSep + 2) + if (projPath && dir) { + projects.push({ path: projPath, dir }) + } + } + return projects +} diff --git a/src/tools.js b/src/tools.js index c4d5801d..941c2103 100644 --- a/src/tools.js +++ b/src/tools.js @@ -178,6 +178,25 @@ export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) } } +/** + * Resolve a build-tool binary, preferring a wrapper when configured. + * + * @param {string} globalBinary - Global binary name (e.g. `mvn`, `gradle`) + * @param {string} localWrapper - Wrapper filename (e.g. `mvnw`, `gradlew.bat`) + * @param {string} startDir - Directory from which to start the wrapper search + * @param {import('./index.js').Options} [opts={}] + * @returns {string} Path to the resolved binary + */ +export function resolveBinary(globalBinary, localWrapper, startDir, opts = {}) { + if (getWrapperPreference(globalBinary, opts)) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath(globalBinary, opts) +} + /** this method invokes command string in a process in a synchronous way. * @param {string} bin - the command to be invoked * @param {Array} args - the args to pass to the binary diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle b/test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle new file mode 100644 index 00000000..ad38b260 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle @@ -0,0 +1 @@ +// app build groovy diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts new file mode 100644 index 00000000..d78283f2 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts @@ -0,0 +1 @@ +// root build kts diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts new file mode 100644 index 00000000..342a08ea --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts @@ -0,0 +1 @@ +// lib build kts diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts new file mode 100644 index 00000000..d7baa552 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts @@ -0,0 +1 @@ +include(":app", ":lib") diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle new file mode 100644 index 00000000..c6325b8f --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle @@ -0,0 +1 @@ +// app build diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle new file mode 100644 index 00000000..82766848 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle @@ -0,0 +1 @@ +// root build diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle new file mode 100644 index 00000000..eac0e240 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle @@ -0,0 +1 @@ +// lib build diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle new file mode 100644 index 00000000..3cbe2493 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle @@ -0,0 +1 @@ +include ':app', ':lib' diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle new file mode 100644 index 00000000..82766848 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle @@ -0,0 +1 @@ +// root build diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle new file mode 100644 index 00000000..3cc4bb73 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle @@ -0,0 +1 @@ +// core build diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle new file mode 100644 index 00000000..693cd196 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle @@ -0,0 +1 @@ +// util build diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle new file mode 100644 index 00000000..ded9291c --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle @@ -0,0 +1 @@ +include ':libs:core', ':libs:util' diff --git a/test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle b/test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle new file mode 100644 index 00000000..82766848 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle @@ -0,0 +1 @@ +// root build diff --git a/test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle b/test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle new file mode 100644 index 00000000..07b8ff07 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle @@ -0,0 +1 @@ +// no includes diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index b2b7a38c..a75b20e5 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -4,6 +4,7 @@ import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock' +import { discoverGradleSubprojects } from '../../src/providers/java_gradle.js' import { discoverMavenModules } from '../../src/providers/java_maven.js' import { discoverWorkspaceCrates, @@ -189,13 +190,83 @@ suite('discoverWorkspaceCrates', () => { }) }) + +suite('discoverGradleSubprojects', () => { + test('returns empty when no settings.gradle at root', async () => { + const result = await discoverGradleSubprojects('test/providers/tst_manifests/npm') + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(0) + }) + + test('discovers multi-project build', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + expect(result.some(p => p.includes(path.join('app', 'build.gradle')))).to.be.true + expect(result.some(p => p.includes(path.join('lib', 'build.gradle')))).to.be.true + }).timeout(40000) + + test('discovers nested subprojects', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_nested_subprojects') + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + expect(result.some(p => p.includes(path.join('libs', 'core', 'build.gradle')))).to.be.true + expect(result.some(p => p.includes(path.join('libs', 'util', 'build.gradle')))).to.be.true + }).timeout(40000) + + test('handles mixed Groovy and Kotlin build files', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_mixed_variants') + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'build.gradle.kts')) + expect(result.some(p => p.endsWith(path.join('app', 'build.gradle')))).to.be.true + expect(result.some(p => p.endsWith(path.join('lib', 'build.gradle.kts')))).to.be.true + }).timeout(40000) + + test('returns root only when no subprojects', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_no_subprojects') + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + }).timeout(40000) + + test('returns root build file when gradle is not available', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') + const { discoverGradleSubprojects: discoverMocked } = await esmock('../../src/providers/java_gradle.js', { + '../../src/tools.js': { + getCustomPath: () => '/nonexistent/gradle', + getWrapperPreference: () => false, + invokeCommand: () => { throw Object.assign(new Error('gradle not found'), { code: 'ENOENT' }) }, + }, + }) + const result = await discoverMocked(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + }) + + test('excludes paths matching workspaceDiscoveryIgnore', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') + const result = await discoverGradleSubprojects(root, { + workspaceDiscoveryIgnore: ['**/lib/**'], + }) + expect(result.some(p => p.includes(path.join('app', 'build.gradle')))).to.be.true + expect(result.some(p => p.includes(path.join('lib', 'build.gradle')))).to.be.false + }).timeout(40000) +}) + suite('discoverMavenModules', () => { test('returns empty when no pom.xml at root', async () => { const result = await discoverMavenModules('test/providers/tst_manifests/npm') expect(result).to.be.an('array') expect(result).to.have.lengthOf(0) }) - test('returns root pom only when mvn reports no modules', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') const result = await discoverMavenModules(root)