Skip to content

Commit 1d590b2

Browse files
authored
feat(workspace): add Gradle multi-project workspace discovery (#494)
1 parent 5348b9e commit 1d590b2

18 files changed

Lines changed: 235 additions & 2 deletions

File tree

src/index.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import fs from 'node:fs'
88
import { getCustom } from "./tools.js";
99
import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js'
1010
import { discoverMavenModules } from './providers/java_maven.js'
11+
import { discoverGradleSubprojects } from './providers/java_gradle.js'
1112
import {
1213
discoverWorkspaceCrates,
1314
discoverWorkspacePackages,
@@ -25,6 +26,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta
2526
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom }
2627
export {
2728
discoverMavenModules,
29+
discoverGradleSubprojects,
2830
discoverWorkspacePackages,
2931
discoverWorkspaceCrates,
3032
validatePackageJson,
@@ -321,7 +323,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) {
321323
*
322324
* @param {string} root - Resolved workspace root
323325
* @param {Options} opts
324-
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'unknown', manifestPaths: string[] }>}
326+
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'unknown', manifestPaths: string[] }>}
325327
* @private
326328
*/
327329
async function detectWorkspaceManifests(root, opts) {
@@ -341,6 +343,15 @@ async function detectWorkspaceManifests(root, opts) {
341343
}
342344
}
343345

346+
const hasGradleSettings = fs.existsSync(path.join(root, 'settings.gradle'))
347+
|| fs.existsSync(path.join(root, 'settings.gradle.kts'))
348+
if (hasGradleSettings) {
349+
const manifestPaths = await discoverGradleSubprojects(root, opts)
350+
if (manifestPaths.length > 0) {
351+
return { ecosystem: 'gradle', manifestPaths }
352+
}
353+
}
354+
344355
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
345356
|| fs.existsSync(path.join(root, 'yarn.lock'))
346357
|| fs.existsSync(path.join(root, 'package-lock.json'))

src/providers/java_gradle.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import crypto from 'node:crypto'
12
import fs from 'node:fs'
3+
import os from 'node:os'
24
import path from 'node:path'
35
import { EOL } from 'os'
46

57
import TOML from 'fast-toml'
68

79
import { readLicenseFile } from '../license/license_utils.js'
810
import Sbom from '../sbom.js'
11+
import { invokeCommand } from '../tools.js'
12+
import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js'
913

1014
import Base_java, { ecosystem_gradle } from "./base_java.js";
1115

@@ -466,3 +470,117 @@ export default class Java_gradle extends Base_java {
466470
return undefined
467471
}
468472
}
473+
474+
const DEFAULT_GRADLE_DISCOVERY_IGNORE = [
475+
'**/build/**',
476+
'**/.gradle/**',
477+
]
478+
479+
/** Gradle init script that emits structured project listing. */
480+
const GRADLE_INIT_SCRIPT = `allprojects {
481+
task daListProjects {
482+
doLast {
483+
println "::DA_PROJECT::\${project.path}::\${project.projectDir}"
484+
}
485+
}
486+
}
487+
`
488+
489+
/**
490+
* Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build.
491+
* Uses a custom init script to get structured project listing.
492+
*
493+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts])
494+
* @param {import('../index.js').Options} [opts={}]
495+
* @returns {Promise<string[]>} Paths to build.gradle[.kts] files (absolute)
496+
*/
497+
export async function discoverGradleSubprojects(workspaceRoot, opts = {}) {
498+
const root = path.resolve(workspaceRoot)
499+
const hasSettings = fs.existsSync(path.join(root, 'settings.gradle'))
500+
|| fs.existsSync(path.join(root, 'settings.gradle.kts'))
501+
502+
if (!hasSettings) {
503+
return []
504+
}
505+
506+
const manifestPaths = []
507+
508+
const rootBuildKts = path.join(root, 'build.gradle.kts')
509+
const rootBuild = path.join(root, 'build.gradle')
510+
const rootManifest = fs.existsSync(rootBuildKts) ? rootBuildKts : fs.existsSync(rootBuild) ? rootBuild : null
511+
if (rootManifest) {
512+
manifestPaths.push(rootManifest)
513+
}
514+
515+
let gradleBin
516+
try {
517+
gradleBin = new Java_gradle().selectToolBinary(rootManifest || rootBuild, opts)
518+
} catch {
519+
const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE]
520+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
521+
}
522+
523+
const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${crypto.randomUUID()}.gradle`)
524+
try {
525+
fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT)
526+
let output
527+
try {
528+
output = invokeCommand(gradleBin, [
529+
'-q', '--no-daemon',
530+
'--init-script', initScriptPath,
531+
'daListProjects',
532+
], { cwd: root })
533+
} catch {
534+
const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE]
535+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
536+
}
537+
538+
const projects = parseGradleInitScriptOutput(output.toString())
539+
for (const proj of projects) {
540+
if (proj.path === ':') {
541+
continue
542+
}
543+
const projDir = path.resolve(proj.dir)
544+
const buildKts = path.join(projDir, 'build.gradle.kts')
545+
const buildGroovy = path.join(projDir, 'build.gradle')
546+
if (fs.existsSync(buildKts)) {
547+
manifestPaths.push(buildKts)
548+
} else if (fs.existsSync(buildGroovy)) {
549+
manifestPaths.push(buildGroovy)
550+
}
551+
}
552+
} finally {
553+
try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ }
554+
}
555+
556+
const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE]
557+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
558+
}
559+
560+
/**
561+
* Parse the structured output from the Gradle init script.
562+
*
563+
* @param {string} raw - Raw stdout from gradle
564+
* @returns {{ path: string, dir: string }[]}
565+
*/
566+
export function parseGradleInitScriptOutput(raw) {
567+
const projects = []
568+
for (const rawLine of raw.split('\n')) {
569+
const line = rawLine.trimEnd()
570+
if (!line.startsWith('::DA_PROJECT::')) {
571+
continue
572+
}
573+
const prefix = '::DA_PROJECT::'
574+
const remainder = line.substring(prefix.length)
575+
const lastSep = remainder.lastIndexOf('::')
576+
if (lastSep < 0) {
577+
continue
578+
}
579+
const projPath = remainder.substring(0, lastSep)
580+
const dir = remainder.substring(lastSep + 2)
581+
if (projPath && dir) {
582+
projects.push({ path: projPath, dir })
583+
}
584+
}
585+
return projects
586+
}

src/tools.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,25 @@ export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined)
178178
}
179179
}
180180

181+
/**
182+
* Resolve a build-tool binary, preferring a wrapper when configured.
183+
*
184+
* @param {string} globalBinary - Global binary name (e.g. `mvn`, `gradle`)
185+
* @param {string} localWrapper - Wrapper filename (e.g. `mvnw`, `gradlew.bat`)
186+
* @param {string} startDir - Directory from which to start the wrapper search
187+
* @param {import('./index.js').Options} [opts={}]
188+
* @returns {string} Path to the resolved binary
189+
*/
190+
export function resolveBinary(globalBinary, localWrapper, startDir, opts = {}) {
191+
if (getWrapperPreference(globalBinary, opts)) {
192+
const wrapper = traverseForWrapper(startDir, localWrapper)
193+
if (wrapper !== undefined) {
194+
return wrapper
195+
}
196+
}
197+
return getCustomPath(globalBinary, opts)
198+
}
199+
181200
/** this method invokes command string in a process in a synchronous way.
182201
* @param {string} bin - the command to be invoked
183202
* @param {Array<string>} args - the args to pass to the binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// app build groovy
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// root build kts
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// lib build kts
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include(":app", ":lib")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// app build
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// root build
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// lib build

0 commit comments

Comments
 (0)