Skip to content

Commit fcd428a

Browse files
Strum355claude
andcommitted
feat(workspace): add Maven multi-module workspace discovery
Add `discoverMavenModules()` to discover all pom.xml manifest paths in Maven multi-module projects. Uses `mvn help:evaluate -Dexpression=project.modules` to list declared modules, with recursive traversal for nested aggregators. Supports Maven wrapper (mvnw) via `TRUSTIFY_DA_PREFER_MVNW` preference, reusing the same traversal pattern as provider-level wrapper detection. Adds Maven detection (`pom.xml` presence) to `detectWorkspaceManifests()` between Cargo and JavaScript in the ecosystem detection order. Also adds `**/target/**` to the default workspace discovery ignore patterns. Implements TC-4259 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Assisted-by: Claude Code
1 parent d9c1ae4 commit fcd428a

10 files changed

Lines changed: 352 additions & 2 deletions

File tree

src/index.js

Lines changed: 11 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+
discoverMavenModules,
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+
discoverMavenModules,
2628
discoverWorkspacePackages,
2729
discoverWorkspaceCrates,
2830
validatePackageJson,
@@ -319,18 +321,26 @@ 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' | 'maven' | 'unknown', manifestPaths: string[] }>}
323325
* @private
324326
*/
325327
async function detectWorkspaceManifests(root, opts) {
326328
const cargoToml = path.join(root, 'Cargo.toml')
327329
const cargoLock = path.join(root, 'Cargo.lock')
328330
const packageJson = path.join(root, 'package.json')
331+
const pomXml = path.join(root, 'pom.xml')
329332

330333
if (fs.existsSync(cargoToml) && fs.existsSync(cargoLock)) {
331334
return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) }
332335
}
333336

337+
if (fs.existsSync(pomXml)) {
338+
const manifestPaths = await discoverMavenModules(root, opts)
339+
if (manifestPaths.length > 0) {
340+
return { ecosystem: 'maven', manifestPaths }
341+
}
342+
}
343+
334344
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
335345
|| fs.existsSync(path.join(root, 'yarn.lock'))
336346
|| fs.existsSync(path.join(root, 'package-lock.json'))

src/workspace.js

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import fg from 'fast-glob'
55
import { load as yamlLoad } from 'js-yaml'
66
import micromatch from 'micromatch'
77

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

1010
/** Default paths skipped during JS workspace discovery (merged with user patterns). */
1111
const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [
1212
'**/node_modules/**',
1313
'**/.git/**',
14+
'**/target/**',
1415
]
1516

1617
/**
@@ -224,6 +225,157 @@ async function discoverFromPackageJsonWorkspaces(root, packageJsonPath, globOpts
224225
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
225226
}
226227

228+
/**
229+
* Resolve the Maven binary, respecting wrapper preference.
230+
*
231+
* When `TRUSTIFY_DA_PREFER_MVNW` is truthy, traverses from `startDir` up to the
232+
* git root (or filesystem root) looking for an executable `mvnw` wrapper.
233+
* Falls back to the global `mvn` binary (or `TRUSTIFY_DA_MVN_PATH`).
234+
*
235+
* @param {string} startDir - Directory from which to start the wrapper search
236+
* @param {import('./index.js').Options} [opts={}]
237+
* @returns {string} Path to the Maven binary
238+
*/
239+
function resolveMavenBinary(startDir, opts = {}) {
240+
const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '')
241+
const useWrapper = getWrapperPreference('mvn', opts)
242+
if (useWrapper) {
243+
const wrapper = traverseForWrapper(startDir, localWrapper)
244+
if (wrapper !== undefined) {
245+
return wrapper
246+
}
247+
}
248+
return getCustomPath('mvn', opts)
249+
}
250+
251+
/**
252+
* Walk up from `startDir` to `repoRoot` looking for an executable wrapper script.
253+
*
254+
* @param {string} startDir - Absolute directory to start from
255+
* @param {string} wrapperName - Wrapper filename (e.g. `mvnw`)
256+
* @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root)
257+
* @returns {string | undefined}
258+
*/
259+
function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) {
260+
const currentDir = path.resolve(startDir)
261+
repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root
262+
const wrapperPath = path.join(currentDir, wrapperName)
263+
264+
try {
265+
fs.accessSync(wrapperPath, fs.constants.X_OK)
266+
return wrapperPath
267+
} catch (err) {
268+
if (err.code === 'ENOENT') {
269+
const rootDir = path.parse(currentDir).root
270+
if (currentDir === repoRoot || currentDir === rootDir) {
271+
return undefined
272+
}
273+
const parentDir = path.dirname(currentDir)
274+
if (parentDir === currentDir || parentDir === rootDir) {
275+
return undefined
276+
}
277+
return traverseForWrapper(parentDir, wrapperName, repoRoot)
278+
}
279+
throw new Error(`failure searching for ${wrapperName}`, { cause: err })
280+
}
281+
}
282+
283+
/**
284+
* Discover all pom.xml manifest paths in a Maven multi-module project.
285+
* Uses `mvn help:evaluate` to read `project.modules`, then recurses into
286+
* nested aggregator modules.
287+
*
288+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pom.xml)
289+
* @param {import('./index.js').Options} [opts={}]
290+
* @returns {Promise<string[]>} Paths to pom.xml files (absolute)
291+
*/
292+
export async function discoverMavenModules(workspaceRoot, opts = {}) {
293+
const root = path.resolve(workspaceRoot)
294+
const rootPom = path.join(root, 'pom.xml')
295+
296+
if (!fs.existsSync(rootPom)) {
297+
return []
298+
}
299+
300+
const mvnBin = resolveMavenBinary(root, opts)
301+
const visited = new Set()
302+
const manifestPaths = [rootPom]
303+
304+
collectMavenModules(root, mvnBin, visited, manifestPaths)
305+
306+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
307+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
308+
}
309+
310+
/**
311+
* Recursively collect Maven module pom.xml paths starting from a given directory.
312+
*
313+
* @param {string} dir - Absolute path to directory containing pom.xml
314+
* @param {string} mvnBin - Maven binary path
315+
* @param {Set<string>} visited - Already-visited directories (cycle guard)
316+
* @param {string[]} manifestPaths - Accumulator for discovered pom.xml paths
317+
*/
318+
function collectMavenModules(dir, mvnBin, visited, manifestPaths) {
319+
const resolvedDir = path.resolve(dir)
320+
if (visited.has(resolvedDir)) {
321+
return
322+
}
323+
visited.add(resolvedDir)
324+
325+
const modules = listMavenModules(resolvedDir, mvnBin)
326+
for (const mod of modules) {
327+
const moduleDir = path.resolve(resolvedDir, mod)
328+
const modulePom = path.join(moduleDir, 'pom.xml')
329+
if (fs.existsSync(modulePom)) {
330+
manifestPaths.push(modulePom)
331+
collectMavenModules(moduleDir, mvnBin, visited, manifestPaths)
332+
}
333+
}
334+
}
335+
336+
/**
337+
* Invoke `mvn help:evaluate` to list the `<modules>` declared in a pom.xml.
338+
*
339+
* @param {string} dir - Directory containing pom.xml
340+
* @param {string} mvnBin - Maven binary path
341+
* @returns {string[]} Module directory names (relative to `dir`)
342+
*/
343+
function listMavenModules(dir, mvnBin) {
344+
let output
345+
try {
346+
output = invokeCommand(mvnBin, [
347+
'help:evaluate',
348+
'-Dexpression=project.modules',
349+
'-q',
350+
'-DforceStdout',
351+
'-f', path.join(dir, 'pom.xml'),
352+
'--batch-mode',
353+
], { cwd: dir })
354+
} catch {
355+
return []
356+
}
357+
358+
const raw = output.toString().trim()
359+
if (!raw || raw === 'null') {
360+
return []
361+
}
362+
return parseMavenModuleList(raw)
363+
}
364+
365+
/**
366+
* Parse the `[module-a, module-b]` output from `mvn help:evaluate -Dexpression=project.modules`.
367+
*
368+
* @param {string} raw - Raw stdout from mvn (e.g. `[module-a, module-b]`)
369+
* @returns {string[]}
370+
*/
371+
function parseMavenModuleList(raw) {
372+
const match = raw.match(/^\[(.+)]$/)
373+
if (!match) {
374+
return []
375+
}
376+
return match[1].split(',').map(s => s.trim()).filter(Boolean)
377+
}
378+
227379
/**
228380
* Discover all Cargo.toml manifest paths in a Cargo workspace.
229381
* Uses `cargo metadata` to get workspace members.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<parent>
4+
<groupId>com.example</groupId>
5+
<artifactId>parent</artifactId>
6+
<version>1.0.0</version>
7+
</parent>
8+
<artifactId>module-a</artifactId>
9+
</project>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<parent>
4+
<groupId>com.example</groupId>
5+
<artifactId>parent</artifactId>
6+
<version>1.0.0</version>
7+
</parent>
8+
<artifactId>module-b</artifactId>
9+
</project>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<groupId>com.example</groupId>
4+
<artifactId>parent</artifactId>
5+
<version>1.0.0</version>
6+
<packaging>pom</packaging>
7+
<modules>
8+
<module>module-a</module>
9+
<module>module-b</module>
10+
</modules>
11+
</project>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<parent>
4+
<groupId>com.example</groupId>
5+
<artifactId>parent</artifactId>
6+
<version>1.0.0</version>
7+
</parent>
8+
<artifactId>child</artifactId>
9+
</project>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<groupId>com.example</groupId>
4+
<artifactId>parent</artifactId>
5+
<version>1.0.0</version>
6+
<packaging>pom</packaging>
7+
<modules>
8+
<module>child</module>
9+
</modules>
10+
</project>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<groupId>com.example</groupId>
4+
<artifactId>root</artifactId>
5+
<version>1.0.0</version>
6+
<packaging>pom</packaging>
7+
<modules>
8+
<module>parent</module>
9+
</modules>
10+
</project>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<project>
2+
<modelVersion>4.0.0</modelVersion>
3+
<groupId>com.example</groupId>
4+
<artifactId>single</artifactId>
5+
<version>1.0.0</version>
6+
</project>

0 commit comments

Comments
 (0)