Skip to content

Commit 84e4163

Browse files
committed
feat(workspace): add Gradle multi-project workspace discovery
1 parent 58200d2 commit 84e4163

17 files changed

Lines changed: 327 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 {
11+
discoverGradleSubprojects,
1112
discoverWorkspaceCrates,
1213
discoverWorkspacePackages,
1314
filterManifestPathsByDiscoveryIgnore,
@@ -23,6 +24,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta
2324

2425
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom }
2526
export {
27+
discoverGradleSubprojects,
2628
discoverWorkspacePackages,
2729
discoverWorkspaceCrates,
2830
validatePackageJson,
@@ -319,7 +321,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) {
319321
*
320322
* @param {string} root - Resolved workspace root
321323
* @param {Options} opts
322-
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'unknown', manifestPaths: string[] }>}
324+
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'gradle' | 'unknown', manifestPaths: string[] }>}
323325
* @private
324326
*/
325327
async function detectWorkspaceManifests(root, opts) {
@@ -331,6 +333,15 @@ async function detectWorkspaceManifests(root, opts) {
331333
return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) }
332334
}
333335

336+
const hasGradleSettings = fs.existsSync(path.join(root, 'settings.gradle'))
337+
|| fs.existsSync(path.join(root, 'settings.gradle.kts'))
338+
if (hasGradleSettings) {
339+
const manifestPaths = await discoverGradleSubprojects(root, opts)
340+
if (manifestPaths.length > 0) {
341+
return { ecosystem: 'gradle', manifestPaths }
342+
}
343+
}
344+
334345
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
335346
|| fs.existsSync(path.join(root, 'yarn.lock'))
336347
|| fs.existsSync(path.join(root, 'package-lock.json'))

src/workspace.js

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
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'
56
import { load as yamlLoad } from 'js-yaml'
67
import micromatch from 'micromatch'
78

8-
import { getCustom, getCustomPath, invokeCommand } from './tools.js'
9+
import { getCustom, getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from './tools.js'
910

1011
/** Default paths skipped during JS workspace discovery (merged with user patterns). */
1112
const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [
1213
'**/node_modules/**',
1314
'**/.git/**',
15+
'**/build/**',
16+
'**/.gradle/**',
1417
]
1518

1619
/**
@@ -224,6 +227,159 @@ async function discoverFromPackageJsonWorkspaces(root, packageJsonPath, globOpts
224227
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
225228
}
226229

230+
/**
231+
* Walk up from `startDir` to `repoRoot` looking for an executable wrapper script.
232+
*
233+
* @param {string} startDir - Absolute directory to start from
234+
* @param {string} wrapperName - Wrapper filename (e.g. `mvnw`)
235+
* @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root)
236+
* @returns {string | undefined}
237+
*/
238+
function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) {
239+
const currentDir = path.resolve(startDir)
240+
repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root
241+
const wrapperPath = path.join(currentDir, wrapperName)
242+
243+
try {
244+
fs.accessSync(wrapperPath, fs.constants.X_OK)
245+
return wrapperPath
246+
} catch (err) {
247+
if (err.code === 'ENOENT') {
248+
const rootDir = path.parse(currentDir).root
249+
if (currentDir === repoRoot || currentDir === rootDir) {
250+
return undefined
251+
}
252+
const parentDir = path.dirname(currentDir)
253+
if (parentDir === currentDir || parentDir === rootDir) {
254+
return undefined
255+
}
256+
return traverseForWrapper(parentDir, wrapperName, repoRoot)
257+
}
258+
throw new Error(`failure searching for ${wrapperName}`, { cause: err })
259+
}
260+
}
261+
262+
/** Gradle init script that emits structured project listing. */
263+
const GRADLE_INIT_SCRIPT = `allprojects {
264+
task daListProjects {
265+
doLast {
266+
println "::DA_PROJECT::\${project.path}::\${project.projectDir}"
267+
}
268+
}
269+
}
270+
`
271+
272+
/**
273+
* Resolve the Gradle binary, respecting wrapper preference.
274+
*
275+
* @param {string} startDir - Directory from which to start the wrapper search
276+
* @param {import('./index.js').Options} [opts={}]
277+
* @returns {string} Path to the Gradle binary
278+
*/
279+
function resolveGradleBinary(startDir, opts = {}) {
280+
const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '')
281+
const useWrapper = getWrapperPreference('gradle', opts)
282+
if (useWrapper) {
283+
const wrapper = traverseForWrapper(startDir, localWrapper)
284+
if (wrapper !== undefined) {
285+
return wrapper
286+
}
287+
}
288+
return getCustomPath('gradle', opts)
289+
}
290+
291+
/**
292+
* Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build.
293+
* Uses a custom init script to get structured project listing.
294+
*
295+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts])
296+
* @param {import('./index.js').Options} [opts={}]
297+
* @returns {Promise<string[]>} Paths to build.gradle[.kts] files (absolute)
298+
*/
299+
export async function discoverGradleSubprojects(workspaceRoot, opts = {}) {
300+
const root = path.resolve(workspaceRoot)
301+
const hasSettings = fs.existsSync(path.join(root, 'settings.gradle'))
302+
|| fs.existsSync(path.join(root, 'settings.gradle.kts'))
303+
304+
if (!hasSettings) {
305+
return []
306+
}
307+
308+
const gradleBin = resolveGradleBinary(root, opts)
309+
const manifestPaths = []
310+
311+
const rootBuildKts = path.join(root, 'build.gradle.kts')
312+
const rootBuild = path.join(root, 'build.gradle')
313+
if (fs.existsSync(rootBuildKts)) {
314+
manifestPaths.push(rootBuildKts)
315+
} else if (fs.existsSync(rootBuild)) {
316+
manifestPaths.push(rootBuild)
317+
}
318+
319+
const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
320+
try {
321+
fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT)
322+
let output
323+
try {
324+
output = invokeCommand(gradleBin, [
325+
'-q', '--no-daemon',
326+
'--init-script', initScriptPath,
327+
'daListProjects',
328+
], { cwd: root })
329+
} catch {
330+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
331+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
332+
}
333+
334+
const projects = parseGradleInitScriptOutput(output.toString())
335+
for (const proj of projects) {
336+
if (proj.path === ':') {
337+
continue
338+
}
339+
const projDir = path.resolve(proj.dir)
340+
const buildKts = path.join(projDir, 'build.gradle.kts')
341+
const buildGroovy = path.join(projDir, 'build.gradle')
342+
if (fs.existsSync(buildKts)) {
343+
manifestPaths.push(buildKts)
344+
} else if (fs.existsSync(buildGroovy)) {
345+
manifestPaths.push(buildGroovy)
346+
}
347+
}
348+
} finally {
349+
try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ }
350+
}
351+
352+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
353+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
354+
}
355+
356+
/**
357+
* Parse the structured output from the Gradle init script.
358+
*
359+
* @param {string} raw - Raw stdout from gradle
360+
* @returns {{ path: string, dir: string }[]}
361+
*/
362+
function parseGradleInitScriptOutput(raw) {
363+
const projects = []
364+
for (const line of raw.split('\n')) {
365+
if (!line.startsWith('::DA_PROJECT::')) {
366+
continue
367+
}
368+
const prefix = '::DA_PROJECT::'
369+
const remainder = line.substring(prefix.length)
370+
const lastSep = remainder.lastIndexOf('::')
371+
if (lastSep < 0) {
372+
continue
373+
}
374+
const projPath = remainder.substring(0, lastSep)
375+
const dir = remainder.substring(lastSep + 2)
376+
if (projPath && dir) {
377+
projects.push({ path: projPath, dir })
378+
}
379+
}
380+
return projects
381+
}
382+
227383
/**
228384
* Discover all Cargo.toml manifest paths in a Cargo workspace.
229385
* 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)