Skip to content

Commit 543aef4

Browse files
Strum355claude
andcommitted
feat(workspace): add Gradle multi-project workspace discovery
Discover subprojects in Gradle multi-project builds using a custom init script that emits structured project listings. Supports Groovy and Kotlin DSL variants, wrapper detection via gradlew traversal, and workspaceDiscoveryIgnore filtering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fcd428a commit 543aef4

17 files changed

Lines changed: 287 additions & 1 deletion

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 {
11+
discoverGradleSubprojects,
1112
discoverMavenModules,
1213
discoverWorkspaceCrates,
1314
discoverWorkspacePackages,
@@ -24,6 +25,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta
2425

2526
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom }
2627
export {
28+
discoverGradleSubprojects,
2729
discoverMavenModules,
2830
discoverWorkspacePackages,
2931
discoverWorkspaceCrates,
@@ -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/workspace.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs'
2+
import os from 'node:os'
23
import path from 'node:path'
34

45
import fg from 'fast-glob'
@@ -12,6 +13,8 @@ const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [
1213
'**/node_modules/**',
1314
'**/.git/**',
1415
'**/target/**',
16+
'**/build/**',
17+
'**/.gradle/**',
1518
]
1619

1720
/**
@@ -376,6 +379,120 @@ function parseMavenModuleList(raw) {
376379
return match[1].split(',').map(s => s.trim()).filter(Boolean)
377380
}
378381

382+
/** Gradle init script that emits structured project listing. */
383+
const GRADLE_INIT_SCRIPT = `allprojects {
384+
task daListProjects {
385+
doLast {
386+
println "::DA_PROJECT::\${project.path}::\${project.projectDir}"
387+
}
388+
}
389+
}
390+
`
391+
392+
/**
393+
* Resolve the Gradle binary, respecting wrapper preference.
394+
*
395+
* @param {string} startDir - Directory from which to start the wrapper search
396+
* @param {import('./index.js').Options} [opts={}]
397+
* @returns {string} Path to the Gradle binary
398+
*/
399+
function resolveGradleBinary(startDir, opts = {}) {
400+
const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '')
401+
const useWrapper = getWrapperPreference('gradle', opts)
402+
if (useWrapper) {
403+
const wrapper = traverseForWrapper(startDir, localWrapper)
404+
if (wrapper !== undefined) {
405+
return wrapper
406+
}
407+
}
408+
return getCustomPath('gradle', opts)
409+
}
410+
411+
/**
412+
* Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build.
413+
* Uses a custom init script to get structured project listing.
414+
*
415+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts])
416+
* @param {import('./index.js').Options} [opts={}]
417+
* @returns {Promise<string[]>} Paths to build.gradle[.kts] files (absolute)
418+
*/
419+
export async function discoverGradleSubprojects(workspaceRoot, opts = {}) {
420+
const root = path.resolve(workspaceRoot)
421+
const hasSettings = fs.existsSync(path.join(root, 'settings.gradle'))
422+
|| fs.existsSync(path.join(root, 'settings.gradle.kts'))
423+
424+
if (!hasSettings) {
425+
return []
426+
}
427+
428+
const gradleBin = resolveGradleBinary(root, opts)
429+
const manifestPaths = []
430+
431+
const rootBuildKts = path.join(root, 'build.gradle.kts')
432+
const rootBuild = path.join(root, 'build.gradle')
433+
if (fs.existsSync(rootBuildKts)) {
434+
manifestPaths.push(rootBuildKts)
435+
} else if (fs.existsSync(rootBuild)) {
436+
manifestPaths.push(rootBuild)
437+
}
438+
439+
const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
440+
try {
441+
fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT)
442+
let output
443+
try {
444+
output = invokeCommand(gradleBin, [
445+
'-q', '--no-daemon',
446+
'--init-script', initScriptPath,
447+
'daListProjects',
448+
], { cwd: root })
449+
} catch {
450+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
451+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
452+
}
453+
454+
const projects = parseGradleInitScriptOutput(output.toString())
455+
for (const proj of projects) {
456+
if (proj.path === ':') {
457+
continue
458+
}
459+
const projDir = path.resolve(proj.dir)
460+
const buildKts = path.join(projDir, 'build.gradle.kts')
461+
const buildGroovy = path.join(projDir, 'build.gradle')
462+
if (fs.existsSync(buildKts)) {
463+
manifestPaths.push(buildKts)
464+
} else if (fs.existsSync(buildGroovy)) {
465+
manifestPaths.push(buildGroovy)
466+
}
467+
}
468+
} finally {
469+
try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ }
470+
}
471+
472+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
473+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
474+
}
475+
476+
/**
477+
* Parse the structured output from the Gradle init script.
478+
*
479+
* @param {string} raw - Raw stdout from gradle
480+
* @returns {{ path: string, dir: string }[]}
481+
*/
482+
function parseGradleInitScriptOutput(raw) {
483+
const projects = []
484+
for (const line of raw.split('\n')) {
485+
if (!line.startsWith('::DA_PROJECT::')) {
486+
continue
487+
}
488+
const parts = line.split('::').filter(Boolean)
489+
if (parts.length >= 3) {
490+
projects.push({ path: parts[1], dir: parts[2] })
491+
}
492+
}
493+
return projects
494+
}
495+
379496
/**
380497
* Discover all Cargo.toml manifest paths in a Cargo workspace.
381498
* Uses `cargo metadata` to get workspace members.
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
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include ':app', ':lib'

0 commit comments

Comments
 (0)