Skip to content

Commit 4558b63

Browse files
authored
feat(workspace): add Maven multi-module workspace discovery (#493)
1 parent 2e5fe72 commit 4558b63

12 files changed

Lines changed: 283 additions & 45 deletions

File tree

src/index.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import analysis from './analysis.js'
77
import fs from 'node:fs'
88
import { getCustom } from "./tools.js";
99
import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js'
10+
import { discoverMavenModules } from './providers/java_maven.js'
1011
import {
1112
discoverWorkspaceCrates,
1213
discoverWorkspacePackages,
@@ -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,
@@ -98,7 +100,7 @@ export {
98100
* @param {any} valueToBePrinted - The value to log.
99101
* @private
100102
*/
101-
function logOptionsAndEnvironmentsVariables(alongsideText,valueToBePrinted) {
103+
function logOptionsAndEnvironmentsVariables(alongsideText, valueToBePrinted) {
102104
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
103105
console.log(`${alongsideText}: ${valueToBePrinted} ${EOL}`)
104106
}
@@ -110,9 +112,9 @@ function logOptionsAndEnvironmentsVariables(alongsideText,valueToBePrinted) {
110112
*/
111113
function readAndPrintVersionFromPackageJson() {
112114
let dirName
113-
// new ESM way in nodeJS ( since node version 22 ) to bring module directory.
115+
// new ESM way in nodeJS ( since node version 22 ) to bring module directory.
114116
dirName = import.meta.dirname
115-
// old ESM way in nodeJS ( before node versions 22.00 to bring module directory)
117+
// old ESM way in nodeJS ( before node versions 22.00 to bring module directory)
116118
if (!dirName) {
117119
dirName = url.fileURLToPath(new URL('.', import.meta.url));
118120
}
@@ -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/providers/base_java.js

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import fs from 'node:fs'
21
import path from 'node:path'
32

43
import { PackageURL } from 'packageurl-js'
54

6-
import { getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from "../tools.js"
5+
import { getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from "../tools.js"
76

87
/** @typedef {import('../provider').Provider} */
98

@@ -145,7 +144,7 @@ export default class Base_Java {
145144

146145
const useWrapper = getWrapperPreference(this.globalBinary, opts)
147146
if (useWrapper) {
148-
const wrapper = this.traverseForWrapper(manifestPath)
147+
const wrapper = traverseForWrapper(manifestDir, this.localWrapper)
149148
if (wrapper !== undefined) {
150149
try {
151150
this._invokeCommand(wrapper, ['--version'], {cwd: manifestDir})
@@ -168,41 +167,4 @@ export default class Base_Java {
168167
return toolPath
169168
}
170169

171-
/**
172-
*
173-
* @param {string} startingManifest - the path of the manifest from which to start searching for the wrapper
174-
* @param {string} repoRoot - the root of the repository at which point to stop searching for mvnw, derived via git if unset and then fallsback
175-
* to the root of the drive the manifest is on (assumes absolute path is given)
176-
* @returns {string|undefined}
177-
*/
178-
traverseForWrapper(startingManifest, repoRoot = undefined) {
179-
const normalizedManifest = this.normalizePath(startingManifest);
180-
const currentDir = this.normalizePath(path.dirname(normalizedManifest));
181-
repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(normalizedManifest).root;
182-
const wrapperPath = path.join(currentDir, this.localWrapper);
183-
184-
try {
185-
fs.accessSync(wrapperPath, fs.constants.X_OK);
186-
return wrapperPath;
187-
}
188-
catch (error) {
189-
if (error.code === 'ENOENT') {
190-
const rootDir = path.parse(currentDir).root;
191-
if (currentDir === repoRoot || currentDir === rootDir) {
192-
return undefined;
193-
}
194-
const parentDir = path.dirname(currentDir);
195-
if (parentDir === currentDir || parentDir === rootDir) {
196-
return undefined;
197-
}
198-
return this.traverseForWrapper(path.join(parentDir, path.basename(normalizedManifest)), repoRoot);
199-
}
200-
throw new Error(`failure searching for ${this.localWrapper}`, { cause: error });
201-
}
202-
}
203-
204-
normalizePath(thePath) {
205-
const normalized = path.resolve(thePath).normalize();
206-
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
207-
}
208170
}

src/providers/java_maven.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { XMLParser } from 'fast-xml-parser'
77

88
import { getLicense } from '../license/license_utils.js'
99
import Sbom from '../sbom.js'
10-
import { getCustom } from '../tools.js'
10+
import { getCustom, invokeCommand } from '../tools.js'
11+
import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js'
1112

1213
import Base_java, { ecosystem_maven } from "./base_java.js";
1314

@@ -309,3 +310,101 @@ export default class Java_maven extends Base_java {
309310
return deps.filter(d => dep.artifactId === d.artifactId && dep.groupId === d.groupId && dep.scope === d.scope).length > 0
310311
}
311312
}
313+
314+
const DEFAULT_MAVEN_DISCOVERY_IGNORE = [
315+
'**/target/**',
316+
]
317+
318+
/**
319+
* Discover all pom.xml manifest paths in a Maven multi-module project.
320+
*
321+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pom.xml)
322+
* @param {object} [opts={}]
323+
* @returns {Promise<string[]>} Paths to pom.xml files (absolute)
324+
*/
325+
export async function discoverMavenModules(workspaceRoot, opts = {}) {
326+
const root = path.resolve(workspaceRoot)
327+
const rootPom = path.join(root, 'pom.xml')
328+
329+
if (!fs.existsSync(rootPom)) {
330+
return []
331+
}
332+
333+
let mvnBin
334+
try {
335+
mvnBin = new Java_maven().selectToolBinary(rootPom, opts)
336+
} catch {
337+
return [rootPom]
338+
}
339+
const visited = new Set()
340+
const manifestPaths = [rootPom]
341+
342+
collectMavenModules(root, mvnBin, visited, manifestPaths)
343+
344+
const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_MAVEN_DISCOVERY_IGNORE]
345+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
346+
}
347+
348+
/**
349+
* @param {string} dir - Absolute path to directory containing pom.xml
350+
* @param {string} mvnBin - Maven binary path
351+
* @param {Set<string>} visited - Already-visited directories (cycle guard)
352+
* @param {string[]} manifestPaths - Accumulator for discovered pom.xml paths
353+
*/
354+
function collectMavenModules(dir, mvnBin, visited, manifestPaths) {
355+
const resolvedDir = path.resolve(dir)
356+
if (visited.has(resolvedDir)) {
357+
return
358+
}
359+
visited.add(resolvedDir)
360+
361+
const modules = listMavenModules(resolvedDir, mvnBin)
362+
for (const mod of modules) {
363+
const moduleDir = path.resolve(resolvedDir, mod)
364+
const modulePom = path.join(moduleDir, 'pom.xml')
365+
if (fs.existsSync(modulePom)) {
366+
manifestPaths.push(modulePom)
367+
collectMavenModules(moduleDir, mvnBin, visited, manifestPaths)
368+
}
369+
}
370+
}
371+
372+
/**
373+
* @param {string} dir - Directory containing pom.xml
374+
* @param {string} mvnBin - Maven binary path
375+
* @returns {string[]} Module directory names (relative to `dir`)
376+
*/
377+
function listMavenModules(dir, mvnBin) {
378+
let output
379+
try {
380+
output = invokeCommand(mvnBin, [
381+
'help:evaluate',
382+
'-Dexpression=project.modules',
383+
'-q',
384+
'-DforceStdout',
385+
'-f', path.join(dir, 'pom.xml'),
386+
'--batch-mode',
387+
], { cwd: dir })
388+
} catch {
389+
return []
390+
}
391+
392+
const raw = output.toString().trim()
393+
if (!raw || raw.startsWith('<modules')) {
394+
return []
395+
}
396+
return parseMavenModuleList(raw)
397+
}
398+
399+
/**
400+
* @param {string} raw - Raw stdout from mvn help:evaluate -DforceStdout
401+
* @returns {string[]}
402+
*/
403+
function parseMavenModuleList(raw) {
404+
const parser = new XMLParser()
405+
const parsed = parser.parse(raw)
406+
const entries = parsed?.strings?.string
407+
if (!entries) { return [] }
408+
const list = Array.isArray(entries) ? entries : [entries]
409+
return list.map(s => String(s).trim()).filter(Boolean)
410+
}

src/tools.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { execFileSync } from "child_process";
2+
import fs from 'node:fs'
3+
import path from 'node:path'
24
import { EOL } from "os";
35

46
import { HttpsProxyAgent } from "https-proxy-agent";
@@ -137,6 +139,45 @@ export function getGitRootDir(cwd) {
137139
}
138140
}
139141

142+
/**
143+
* Normalize a filesystem path, lowercasing on Windows for case-insensitive comparison.
144+
*
145+
* @param {string} thePath
146+
* @returns {string}
147+
*/
148+
export function normalizePath(thePath) {
149+
const normalized = path.resolve(thePath).normalize()
150+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
151+
}
152+
153+
/**
154+
* Walk up from `startDir` to `repoRoot` looking for an executable wrapper script.
155+
*
156+
* @param {string} startDir - Absolute directory to start from
157+
* @param {string} wrapperName - Wrapper filename (e.g. `mvnw`, `gradlew`)
158+
* @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root)
159+
* @returns {string | undefined}
160+
*/
161+
export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) {
162+
const currentDir = normalizePath(startDir)
163+
repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root
164+
const wrapperPath = path.join(currentDir, wrapperName)
165+
try {
166+
fs.accessSync(wrapperPath, fs.constants.X_OK)
167+
return wrapperPath
168+
} catch {
169+
const rootDir = path.parse(currentDir).root
170+
if (currentDir === repoRoot || currentDir === rootDir) {
171+
return undefined
172+
}
173+
const parentDir = path.dirname(currentDir)
174+
if (parentDir === currentDir || parentDir === rootDir) {
175+
return undefined
176+
}
177+
return traverseForWrapper(parentDir, wrapperName, repoRoot)
178+
}
179+
}
180+
140181
/** this method invokes command string in a process in a synchronous way.
141182
* @param {string} bin - the command to be invoked
142183
* @param {Array<string>} args - the args to pass to the binary
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>

0 commit comments

Comments
 (0)