From d42af752adc11f68e4c2752c53f5431857dd22f1 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 28 Apr 2026 11:40:12 +0100 Subject: [PATCH 01/10] 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 Assisted-by: Claude Code --- src/index.js | 12 +- src/workspace.js | 154 +++++++++++++++++- .../maven/maven_multi_module/module-a/pom.xml | 9 + .../maven/maven_multi_module/module-b/pom.xml | 9 + .../maven/maven_multi_module/pom.xml | 11 ++ .../parent/child/pom.xml | 9 + .../maven_nested_aggregator/parent/pom.xml | 10 ++ .../maven/maven_nested_aggregator/pom.xml | 10 ++ .../maven/maven_no_modules/pom.xml | 6 + test/providers/workspace.test.js | 124 ++++++++++++++ 10 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml create mode 100644 test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml create mode 100644 test/providers/tst_manifests/maven/maven_multi_module/pom.xml create mode 100644 test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml create mode 100644 test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml create mode 100644 test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml create mode 100644 test/providers/tst_manifests/maven/maven_no_modules/pom.xml diff --git a/src/index.js b/src/index.js index f4ed4946..2793a88b 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import fs from 'node:fs' import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' import { + discoverMavenModules, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -23,6 +24,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom } export { + discoverMavenModules, discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, @@ -319,18 +321,26 @@ async function generateOneSbom(manifestPath, workspaceOpts) { * * @param {string} root - Resolved workspace root * @param {Options} opts - * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'unknown', manifestPaths: string[] }>} + * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'unknown', manifestPaths: string[] }>} * @private */ async function detectWorkspaceManifests(root, opts) { const cargoToml = path.join(root, 'Cargo.toml') const cargoLock = path.join(root, 'Cargo.lock') const packageJson = path.join(root, 'package.json') + const pomXml = path.join(root, 'pom.xml') if (fs.existsSync(cargoToml) && fs.existsSync(cargoLock)) { return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) } } + if (fs.existsSync(pomXml)) { + const manifestPaths = await discoverMavenModules(root, opts) + if (manifestPaths.length > 0) { + return { ecosystem: 'maven', manifestPaths } + } + } + const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml')) || fs.existsSync(path.join(root, 'yarn.lock')) || fs.existsSync(path.join(root, 'package-lock.json')) diff --git a/src/workspace.js b/src/workspace.js index f71d5e18..2df09cb5 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -5,12 +5,13 @@ import fg from 'fast-glob' import { load as yamlLoad } from 'js-yaml' import micromatch from 'micromatch' -import { getCustom, getCustomPath, invokeCommand } from './tools.js' +import { getCustom, getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from './tools.js' /** Default paths skipped during JS workspace discovery (merged with user patterns). */ const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [ '**/node_modules/**', '**/.git/**', + '**/target/**', ] /** @@ -224,6 +225,157 @@ async function discoverFromPackageJsonWorkspaces(root, packageJsonPath, globOpts return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) } +/** + * Resolve the Maven binary, respecting wrapper preference. + * + * When `TRUSTIFY_DA_PREFER_MVNW` is truthy, traverses from `startDir` up to the + * git root (or filesystem root) looking for an executable `mvnw` wrapper. + * Falls back to the global `mvn` binary (or `TRUSTIFY_DA_MVN_PATH`). + * + * @param {string} startDir - Directory from which to start the wrapper search + * @param {import('./index.js').Options} [opts={}] + * @returns {string} Path to the Maven binary + */ +function resolveMavenBinary(startDir, opts = {}) { + const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '') + const useWrapper = getWrapperPreference('mvn', opts) + if (useWrapper) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath('mvn', opts) +} + +/** + * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. + * + * @param {string} startDir - Absolute directory to start from + * @param {string} wrapperName - Wrapper filename (e.g. `mvnw`) + * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) + * @returns {string | undefined} + */ +function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { + const currentDir = path.resolve(startDir) + repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root + const wrapperPath = path.join(currentDir, wrapperName) + + try { + fs.accessSync(wrapperPath, fs.constants.X_OK) + return wrapperPath + } catch (err) { + if (err.code === 'ENOENT') { + const rootDir = path.parse(currentDir).root + if (currentDir === repoRoot || currentDir === rootDir) { + return undefined + } + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir || parentDir === rootDir) { + return undefined + } + return traverseForWrapper(parentDir, wrapperName, repoRoot) + } + throw new Error(`failure searching for ${wrapperName}`, { cause: err }) + } +} + +/** + * Discover all pom.xml manifest paths in a Maven multi-module project. + * Uses `mvn help:evaluate` to read `project.modules`, then recurses into + * nested aggregator modules. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pom.xml) + * @param {import('./index.js').Options} [opts={}] + * @returns {Promise} Paths to pom.xml files (absolute) + */ +export async function discoverMavenModules(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const rootPom = path.join(root, 'pom.xml') + + if (!fs.existsSync(rootPom)) { + return [] + } + + const mvnBin = resolveMavenBinary(root, opts) + const visited = new Set() + const manifestPaths = [rootPom] + + collectMavenModules(root, mvnBin, visited, manifestPaths) + + const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} + +/** + * Recursively collect Maven module pom.xml paths starting from a given directory. + * + * @param {string} dir - Absolute path to directory containing pom.xml + * @param {string} mvnBin - Maven binary path + * @param {Set} visited - Already-visited directories (cycle guard) + * @param {string[]} manifestPaths - Accumulator for discovered pom.xml paths + */ +function collectMavenModules(dir, mvnBin, visited, manifestPaths) { + const resolvedDir = path.resolve(dir) + if (visited.has(resolvedDir)) { + return + } + visited.add(resolvedDir) + + const modules = listMavenModules(resolvedDir, mvnBin) + for (const mod of modules) { + const moduleDir = path.resolve(resolvedDir, mod) + const modulePom = path.join(moduleDir, 'pom.xml') + if (fs.existsSync(modulePom)) { + manifestPaths.push(modulePom) + collectMavenModules(moduleDir, mvnBin, visited, manifestPaths) + } + } +} + +/** + * Invoke `mvn help:evaluate` to list the `` declared in a pom.xml. + * + * @param {string} dir - Directory containing pom.xml + * @param {string} mvnBin - Maven binary path + * @returns {string[]} Module directory names (relative to `dir`) + */ +function listMavenModules(dir, mvnBin) { + let output + try { + output = invokeCommand(mvnBin, [ + 'help:evaluate', + '-Dexpression=project.modules', + '-q', + '-DforceStdout', + '-f', path.join(dir, 'pom.xml'), + '--batch-mode', + ], { cwd: dir }) + } catch { + return [] + } + + const raw = output.toString().trim() + if (!raw || raw === 'null') { + return [] + } + return parseMavenModuleList(raw) +} + +/** + * Parse the `[module-a, module-b]` output from `mvn help:evaluate -Dexpression=project.modules`. + * + * @param {string} raw - Raw stdout from mvn (e.g. `[module-a, module-b]`) + * @returns {string[]} + */ +function parseMavenModuleList(raw) { + const match = raw.match(/^\[(.+)]$/) + if (!match) { + return [] + } + return match[1].split(',').map(s => s.trim()).filter(Boolean) +} + /** * Discover all Cargo.toml manifest paths in a Cargo workspace. * Uses `cargo metadata` to get workspace members. diff --git a/test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml b/test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml new file mode 100644 index 00000000..6740fbdd --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-a + diff --git a/test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml b/test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml new file mode 100644 index 00000000..03684ec4 --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-b + diff --git a/test/providers/tst_manifests/maven/maven_multi_module/pom.xml b/test/providers/tst_manifests/maven/maven_multi_module/pom.xml new file mode 100644 index 00000000..0566726e --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_multi_module/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + module-a + module-b + + diff --git a/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml new file mode 100644 index 00000000..b29df9e8 --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + child + diff --git a/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml new file mode 100644 index 00000000..b95c8a95 --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + child + + diff --git a/test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml b/test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml new file mode 100644 index 00000000..144babcf --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.example + root + 1.0.0 + pom + + parent + + diff --git a/test/providers/tst_manifests/maven/maven_no_modules/pom.xml b/test/providers/tst_manifests/maven/maven_no_modules/pom.xml new file mode 100644 index 00000000..770a783f --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_no_modules/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + single + 1.0.0 + diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 5c06b68a..1f72982f 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -5,6 +5,7 @@ import { expect } from 'chai' import esmock from 'esmock' import { + discoverMavenModules, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -187,3 +188,126 @@ suite('discoverWorkspaceCrates', () => { expect(result.some(p => p.includes('crate-b'))).to.be.true }) }) + +suite('discoverMavenModules', () => { + test('returns empty when no pom.xml at root', async () => { + const result = await discoverMavenModules('test/providers/tst_manifests/npm') + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(0) + }) + + test('returns root pom only when mvn reports no modules', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') + const { discoverMavenModules } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'mvn', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => Buffer.from('null'), + }, + }) + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + }) + + test('discovers multi-module project', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') + const { discoverMavenModules } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'mvn', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: (bin, args) => { + const pomArg = args.find((a, i) => args[i - 1] === '-f') + if (pomArg && pomArg.includes('module-a')) { + return Buffer.from('null') + } + if (pomArg && pomArg.includes('module-b')) { + return Buffer.from('null') + } + return Buffer.from('[module-a, module-b]') + }, + }, + }) + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result.every(p => p.endsWith('pom.xml'))).to.be.true + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + expect(result.some(p => p.includes('module-a'))).to.be.true + expect(result.some(p => p.includes('module-b'))).to.be.true + }) + + test('discovers nested aggregator modules recursively', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_nested_aggregator') + const { discoverMavenModules } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'mvn', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: (bin, args) => { + const pomArg = args.find((a, i) => args[i - 1] === '-f') + if (pomArg && pomArg.endsWith(path.join('parent', 'child', 'pom.xml'))) { + return Buffer.from('null') + } + if (pomArg && pomArg.endsWith(path.join('parent', 'pom.xml'))) { + return Buffer.from('[child]') + } + return Buffer.from('[parent]') + }, + }, + }) + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + expect(result.some(p => p.includes(path.join('parent', 'pom.xml')))).to.be.true + expect(result.some(p => p.includes(path.join('parent', 'child', 'pom.xml')))).to.be.true + }) + + test('returns root pom when mvn command fails', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') + const { discoverMavenModules } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'mvn', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => { throw new Error('mvn not found') }, + }, + }) + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + }) + + test('excludes paths matching workspaceDiscoveryIgnore', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') + const { discoverMavenModules } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'mvn', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: (bin, args) => { + const pomArg = args.find((a, i) => args[i - 1] === '-f') + if (pomArg && (pomArg.includes('module-a') || pomArg.includes('module-b'))) { + return Buffer.from('null') + } + return Buffer.from('[module-a, module-b]') + }, + }, + }) + const result = await discoverMavenModules(root, { + workspaceDiscoveryIgnore: ['**/module-b/**'], + }) + expect(result.some(p => p.includes('module-a'))).to.be.true + expect(result.some(p => p.includes('module-b'))).to.be.false + }) +}) From 37de69fa3ca59bd12403073ea24692b3bbbb02e1 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 1 May 2026 13:04:17 +0100 Subject: [PATCH 02/10] refactor: move Maven discovery logic from workspace.js to java_maven.js Address review feedback: provider-specific discovery functions don't belong in workspace.js. Extract traverseForWrapper to tools.js as shared utility, update base_java.js to delegate to it, and move all Maven workspace discovery functions (discoverMavenModules, resolveMavenBinary, collectMavenModules, listMavenModules, parseMavenModuleList) to their provider file. Co-Authored-By: Claude Opus 4.6 --- src/index.js | 8 +- src/providers/base_java.js | 39 +------- src/providers/java_maven.js | 114 ++++++++++++++++++++++- src/tools.js | 30 ++++++ src/workspace.js | 154 +------------------------------ test/providers/workspace.test.js | 12 +-- 6 files changed, 157 insertions(+), 200 deletions(-) diff --git a/src/index.js b/src/index.js index 2793a88b..b3b915c8 100644 --- a/src/index.js +++ b/src/index.js @@ -7,8 +7,8 @@ import analysis from './analysis.js' import fs from 'node:fs' import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' +import { discoverMavenModules } from './providers/java_maven.js' import { - discoverMavenModules, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -100,7 +100,7 @@ export { * @param {any} valueToBePrinted - The value to log. * @private */ -function logOptionsAndEnvironmentsVariables(alongsideText,valueToBePrinted) { +function logOptionsAndEnvironmentsVariables(alongsideText, valueToBePrinted) { if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { console.log(`${alongsideText}: ${valueToBePrinted} ${EOL}`) } @@ -112,9 +112,9 @@ function logOptionsAndEnvironmentsVariables(alongsideText,valueToBePrinted) { */ function readAndPrintVersionFromPackageJson() { let dirName -// new ESM way in nodeJS ( since node version 22 ) to bring module directory. + // new ESM way in nodeJS ( since node version 22 ) to bring module directory. dirName = import.meta.dirname -// old ESM way in nodeJS ( before node versions 22.00 to bring module directory) + // old ESM way in nodeJS ( before node versions 22.00 to bring module directory) if (!dirName) { dirName = url.fileURLToPath(new URL('.', import.meta.url)); } diff --git a/src/providers/base_java.js b/src/providers/base_java.js index e80a17a7..d3024b85 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -1,9 +1,8 @@ -import fs from 'node:fs' import path from 'node:path' import { PackageURL } from 'packageurl-js' -import { getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from "../tools.js" +import { getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from "../tools.js" /** @typedef {import('../provider').Provider} */ @@ -145,7 +144,8 @@ export default class Base_Java { const useWrapper = getWrapperPreference(this.globalBinary, opts) if (useWrapper) { - const wrapper = this.traverseForWrapper(manifestPath) + const manifestDir = path.dirname(this.normalizePath(manifestPath)) + const wrapper = traverseForWrapper(manifestDir) if (wrapper !== undefined) { try { this._invokeCommand(wrapper, ['--version'], {cwd: manifestDir}) @@ -168,39 +168,6 @@ export default class Base_Java { return toolPath } - /** - * - * @param {string} startingManifest - the path of the manifest from which to start searching for the wrapper - * @param {string} repoRoot - the root of the repository at which point to stop searching for mvnw, derived via git if unset and then fallsback - * to the root of the drive the manifest is on (assumes absolute path is given) - * @returns {string|undefined} - */ - traverseForWrapper(startingManifest, repoRoot = undefined) { - const normalizedManifest = this.normalizePath(startingManifest); - const currentDir = this.normalizePath(path.dirname(normalizedManifest)); - repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(normalizedManifest).root; - const wrapperPath = path.join(currentDir, this.localWrapper); - - try { - fs.accessSync(wrapperPath, fs.constants.X_OK); - return wrapperPath; - } - catch (error) { - if (error.code === 'ENOENT') { - const rootDir = path.parse(currentDir).root; - if (currentDir === repoRoot || currentDir === rootDir) { - return undefined; - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir || parentDir === rootDir) { - return undefined; - } - return this.traverseForWrapper(path.join(parentDir, path.basename(normalizedManifest)), repoRoot); - } - throw new Error(`failure searching for ${this.localWrapper}`, { cause: error }); - } - } - normalizePath(thePath) { const normalized = path.resolve(thePath).normalize(); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 0e827172..e3530273 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -7,7 +7,8 @@ import { XMLParser } from 'fast-xml-parser' import { getLicense } from '../license/license_utils.js' import Sbom from '../sbom.js' -import { getCustom } from '../tools.js' +import { getCustom, getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from '../tools.js' +import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_maven } from "./base_java.js"; @@ -309,3 +310,114 @@ export default class Java_maven extends Base_java { return deps.filter(d => dep.artifactId === d.artifactId && dep.groupId === d.groupId && dep.scope === d.scope).length > 0 } } + +const DEFAULT_MAVEN_DISCOVERY_IGNORE = [ + '**/target/**', +] + +/** + * Resolve the Maven binary, respecting wrapper preference. + * + * @param {string} startDir - Directory from which to start the wrapper search + * @param {object} [opts={}] + * @returns {string} Path to the Maven binary + */ +function resolveMavenBinary(startDir, opts = {}) { + const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '') + const useWrapper = getWrapperPreference('mvn', opts) + if (useWrapper) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath('mvn', opts) +} + +/** + * Discover all pom.xml manifest paths in a Maven multi-module project. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pom.xml) + * @param {object} [opts={}] + * @returns {Promise} Paths to pom.xml files (absolute) + */ +export async function discoverMavenModules(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const rootPom = path.join(root, 'pom.xml') + + if (!fs.existsSync(rootPom)) { + return [] + } + + const mvnBin = resolveMavenBinary(root, opts) + const visited = new Set() + const manifestPaths = [rootPom] + + collectMavenModules(root, mvnBin, visited, manifestPaths) + + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_MAVEN_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} + +/** + * @param {string} dir - Absolute path to directory containing pom.xml + * @param {string} mvnBin - Maven binary path + * @param {Set} visited - Already-visited directories (cycle guard) + * @param {string[]} manifestPaths - Accumulator for discovered pom.xml paths + */ +function collectMavenModules(dir, mvnBin, visited, manifestPaths) { + const resolvedDir = path.resolve(dir) + if (visited.has(resolvedDir)) { + return + } + visited.add(resolvedDir) + + const modules = listMavenModules(resolvedDir, mvnBin) + for (const mod of modules) { + const moduleDir = path.resolve(resolvedDir, mod) + const modulePom = path.join(moduleDir, 'pom.xml') + if (fs.existsSync(modulePom)) { + manifestPaths.push(modulePom) + collectMavenModules(moduleDir, mvnBin, visited, manifestPaths) + } + } +} + +/** + * @param {string} dir - Directory containing pom.xml + * @param {string} mvnBin - Maven binary path + * @returns {string[]} Module directory names (relative to `dir`) + */ +function listMavenModules(dir, mvnBin) { + let output + try { + output = invokeCommand(mvnBin, [ + 'help:evaluate', + '-Dexpression=project.modules', + '-q', + '-DforceStdout', + '-f', path.join(dir, 'pom.xml'), + '--batch-mode', + ], { cwd: dir }) + } catch { + return [] + } + + const raw = output.toString().trim() + if (!raw || raw === 'null') { + return [] + } + return parseMavenModuleList(raw) +} + +/** + * @param {string} raw - Raw stdout from mvn (e.g. `[module-a, module-b]`) + * @returns {string[]} + */ +function parseMavenModuleList(raw) { + const match = raw.match(/^\[(.+)]$/) + if (!match) { + return [] + } + return match[1].split(',').map(s => s.trim()).filter(Boolean) +} diff --git a/src/tools.js b/src/tools.js index 756968e5..5901076a 100644 --- a/src/tools.js +++ b/src/tools.js @@ -1,4 +1,6 @@ import { execFileSync } from "child_process"; +import fs from 'node:fs' +import path from 'node:path' import { EOL } from "os"; import { HttpsProxyAgent } from "https-proxy-agent"; @@ -137,6 +139,34 @@ export function getGitRootDir(cwd) { } } +/** + * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. + * + * @param {string} startDir - Absolute directory to start from + * @param {string} wrapperName - Wrapper filename (e.g. `mvnw`, `gradlew`) + * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) + * @returns {string | undefined} + */ +export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { + const currentDir = path.resolve(startDir) + repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root + const wrapperPath = path.join(currentDir, wrapperName) + try { + fs.accessSync(wrapperPath, fs.constants.X_OK) + return wrapperPath + } catch { + const rootDir = path.parse(currentDir).root + if (currentDir === repoRoot || currentDir === rootDir) { + return undefined + } + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir || parentDir === rootDir) { + return undefined + } + return traverseForWrapper(parentDir, wrapperName, repoRoot) + } +} + /** this method invokes command string in a process in a synchronous way. * @param {string} bin - the command to be invoked * @param {Array} args - the args to pass to the binary diff --git a/src/workspace.js b/src/workspace.js index 2df09cb5..f71d5e18 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -5,13 +5,12 @@ import fg from 'fast-glob' import { load as yamlLoad } from 'js-yaml' import micromatch from 'micromatch' -import { getCustom, getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from './tools.js' +import { getCustom, getCustomPath, invokeCommand } from './tools.js' /** Default paths skipped during JS workspace discovery (merged with user patterns). */ const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [ '**/node_modules/**', '**/.git/**', - '**/target/**', ] /** @@ -225,157 +224,6 @@ async function discoverFromPackageJsonWorkspaces(root, packageJsonPath, globOpts return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) } -/** - * Resolve the Maven binary, respecting wrapper preference. - * - * When `TRUSTIFY_DA_PREFER_MVNW` is truthy, traverses from `startDir` up to the - * git root (or filesystem root) looking for an executable `mvnw` wrapper. - * Falls back to the global `mvn` binary (or `TRUSTIFY_DA_MVN_PATH`). - * - * @param {string} startDir - Directory from which to start the wrapper search - * @param {import('./index.js').Options} [opts={}] - * @returns {string} Path to the Maven binary - */ -function resolveMavenBinary(startDir, opts = {}) { - const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '') - const useWrapper = getWrapperPreference('mvn', opts) - if (useWrapper) { - const wrapper = traverseForWrapper(startDir, localWrapper) - if (wrapper !== undefined) { - return wrapper - } - } - return getCustomPath('mvn', opts) -} - -/** - * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. - * - * @param {string} startDir - Absolute directory to start from - * @param {string} wrapperName - Wrapper filename (e.g. `mvnw`) - * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) - * @returns {string | undefined} - */ -function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { - const currentDir = path.resolve(startDir) - repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root - const wrapperPath = path.join(currentDir, wrapperName) - - try { - fs.accessSync(wrapperPath, fs.constants.X_OK) - return wrapperPath - } catch (err) { - if (err.code === 'ENOENT') { - const rootDir = path.parse(currentDir).root - if (currentDir === repoRoot || currentDir === rootDir) { - return undefined - } - const parentDir = path.dirname(currentDir) - if (parentDir === currentDir || parentDir === rootDir) { - return undefined - } - return traverseForWrapper(parentDir, wrapperName, repoRoot) - } - throw new Error(`failure searching for ${wrapperName}`, { cause: err }) - } -} - -/** - * Discover all pom.xml manifest paths in a Maven multi-module project. - * Uses `mvn help:evaluate` to read `project.modules`, then recurses into - * nested aggregator modules. - * - * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pom.xml) - * @param {import('./index.js').Options} [opts={}] - * @returns {Promise} Paths to pom.xml files (absolute) - */ -export async function discoverMavenModules(workspaceRoot, opts = {}) { - const root = path.resolve(workspaceRoot) - const rootPom = path.join(root, 'pom.xml') - - if (!fs.existsSync(rootPom)) { - return [] - } - - const mvnBin = resolveMavenBinary(root, opts) - const visited = new Set() - const manifestPaths = [rootPom] - - collectMavenModules(root, mvnBin, visited, manifestPaths) - - const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) - return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) -} - -/** - * Recursively collect Maven module pom.xml paths starting from a given directory. - * - * @param {string} dir - Absolute path to directory containing pom.xml - * @param {string} mvnBin - Maven binary path - * @param {Set} visited - Already-visited directories (cycle guard) - * @param {string[]} manifestPaths - Accumulator for discovered pom.xml paths - */ -function collectMavenModules(dir, mvnBin, visited, manifestPaths) { - const resolvedDir = path.resolve(dir) - if (visited.has(resolvedDir)) { - return - } - visited.add(resolvedDir) - - const modules = listMavenModules(resolvedDir, mvnBin) - for (const mod of modules) { - const moduleDir = path.resolve(resolvedDir, mod) - const modulePom = path.join(moduleDir, 'pom.xml') - if (fs.existsSync(modulePom)) { - manifestPaths.push(modulePom) - collectMavenModules(moduleDir, mvnBin, visited, manifestPaths) - } - } -} - -/** - * Invoke `mvn help:evaluate` to list the `` declared in a pom.xml. - * - * @param {string} dir - Directory containing pom.xml - * @param {string} mvnBin - Maven binary path - * @returns {string[]} Module directory names (relative to `dir`) - */ -function listMavenModules(dir, mvnBin) { - let output - try { - output = invokeCommand(mvnBin, [ - 'help:evaluate', - '-Dexpression=project.modules', - '-q', - '-DforceStdout', - '-f', path.join(dir, 'pom.xml'), - '--batch-mode', - ], { cwd: dir }) - } catch { - return [] - } - - const raw = output.toString().trim() - if (!raw || raw === 'null') { - return [] - } - return parseMavenModuleList(raw) -} - -/** - * Parse the `[module-a, module-b]` output from `mvn help:evaluate -Dexpression=project.modules`. - * - * @param {string} raw - Raw stdout from mvn (e.g. `[module-a, module-b]`) - * @returns {string[]} - */ -function parseMavenModuleList(raw) { - const match = raw.match(/^\[(.+)]$/) - if (!match) { - return [] - } - return match[1].split(',').map(s => s.trim()).filter(Boolean) -} - /** * Discover all Cargo.toml manifest paths in a Cargo workspace. * Uses `cargo metadata` to get workspace members. diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 1f72982f..fb19451f 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -4,8 +4,8 @@ import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock' +import { discoverMavenModules } from '../../src/providers/java_maven.js' import { - discoverMavenModules, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -198,7 +198,7 @@ suite('discoverMavenModules', () => { test('returns root pom only when mvn reports no modules', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') - const { discoverMavenModules } = await esmock('../../src/workspace.js', { + const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'mvn', @@ -215,7 +215,7 @@ suite('discoverMavenModules', () => { test('discovers multi-module project', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const { discoverMavenModules } = await esmock('../../src/workspace.js', { + const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'mvn', @@ -244,7 +244,7 @@ suite('discoverMavenModules', () => { test('discovers nested aggregator modules recursively', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_nested_aggregator') - const { discoverMavenModules } = await esmock('../../src/workspace.js', { + const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'mvn', @@ -272,7 +272,7 @@ suite('discoverMavenModules', () => { test('returns root pom when mvn command fails', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const { discoverMavenModules } = await esmock('../../src/workspace.js', { + const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'mvn', @@ -289,7 +289,7 @@ suite('discoverMavenModules', () => { test('excludes paths matching workspaceDiscoveryIgnore', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const { discoverMavenModules } = await esmock('../../src/workspace.js', { + const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'mvn', From c208ccfa4ccc18877655c777bbb197faf20e58c2 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 1 May 2026 13:49:43 +0100 Subject: [PATCH 03/10] fix: pass localWrapper to traverseForWrapper in selectToolBinary The refactored selectToolBinary call was missing the wrapperName argument, causing path.join to receive undefined. Co-Authored-By: Claude Opus 4.6 --- src/providers/base_java.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/base_java.js b/src/providers/base_java.js index d3024b85..0fc0d9a3 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -145,7 +145,7 @@ export default class Base_Java { const useWrapper = getWrapperPreference(this.globalBinary, opts) if (useWrapper) { const manifestDir = path.dirname(this.normalizePath(manifestPath)) - const wrapper = traverseForWrapper(manifestDir) + const wrapper = traverseForWrapper(manifestDir, this.localWrapper) if (wrapper !== undefined) { try { this._invokeCommand(wrapper, ['--version'], {cwd: manifestDir}) From dd9e8117fe84baf047583f7aa6c51bdbb39eafb1 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 11:43:10 +0100 Subject: [PATCH 04/10] fix: add normalizePath to traverseForWrapper for Windows compatibility traverseForWrapper was extracted from base_java.js but the normalizePath call (lowercases paths on Windows for case-insensitive comparison) was left behind. Move normalizePath into tools.js and call it inside traverseForWrapper so all callers get normalization automatically. Co-Authored-By: Claude Opus 4.6 --- src/providers/base_java.js | 5 ----- src/tools.js | 13 ++++++++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/providers/base_java.js b/src/providers/base_java.js index 0fc0d9a3..9d328967 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -144,7 +144,6 @@ export default class Base_Java { const useWrapper = getWrapperPreference(this.globalBinary, opts) if (useWrapper) { - const manifestDir = path.dirname(this.normalizePath(manifestPath)) const wrapper = traverseForWrapper(manifestDir, this.localWrapper) if (wrapper !== undefined) { try { @@ -168,8 +167,4 @@ export default class Base_Java { return toolPath } - normalizePath(thePath) { - const normalized = path.resolve(thePath).normalize(); - return process.platform === 'win32' ? normalized.toLowerCase() : normalized; - } } diff --git a/src/tools.js b/src/tools.js index 5901076a..c4d5801d 100644 --- a/src/tools.js +++ b/src/tools.js @@ -139,6 +139,17 @@ export function getGitRootDir(cwd) { } } +/** + * Normalize a filesystem path, lowercasing on Windows for case-insensitive comparison. + * + * @param {string} thePath + * @returns {string} + */ +export function normalizePath(thePath) { + const normalized = path.resolve(thePath).normalize() + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + /** * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. * @@ -148,7 +159,7 @@ export function getGitRootDir(cwd) { * @returns {string | undefined} */ export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { - const currentDir = path.resolve(startDir) + const currentDir = normalizePath(startDir) repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root const wrapperPath = path.join(currentDir, wrapperName) try { From 53ebe0b6e8ecfed5670a720c799524925b36dc02 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 11:45:07 +0100 Subject: [PATCH 05/10] refactor: replace resolveMavenBinary with shared resolveBinary Extract a reusable resolveBinary(globalBinary, localWrapper, startDir, opts) into tools.js and use it from discoverMavenModules, eliminating the one-off resolveMavenBinary function that duplicated selectToolBinary logic. Co-Authored-By: Claude Opus 4.6 --- src/providers/java_maven.js | 24 +++--------------------- src/tools.js | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index e3530273..b6a89d0b 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -7,7 +7,7 @@ import { XMLParser } from 'fast-xml-parser' import { getLicense } from '../license/license_utils.js' import Sbom from '../sbom.js' -import { getCustom, getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from '../tools.js' +import { getCustom, invokeCommand, resolveBinary } from '../tools.js' import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_maven } from "./base_java.js"; @@ -315,25 +315,6 @@ const DEFAULT_MAVEN_DISCOVERY_IGNORE = [ '**/target/**', ] -/** - * Resolve the Maven binary, respecting wrapper preference. - * - * @param {string} startDir - Directory from which to start the wrapper search - * @param {object} [opts={}] - * @returns {string} Path to the Maven binary - */ -function resolveMavenBinary(startDir, opts = {}) { - const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '') - const useWrapper = getWrapperPreference('mvn', opts) - if (useWrapper) { - const wrapper = traverseForWrapper(startDir, localWrapper) - if (wrapper !== undefined) { - return wrapper - } - } - return getCustomPath('mvn', opts) -} - /** * Discover all pom.xml manifest paths in a Maven multi-module project. * @@ -349,7 +330,8 @@ export async function discoverMavenModules(workspaceRoot, opts = {}) { return [] } - const mvnBin = resolveMavenBinary(root, opts) + const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '') + const mvnBin = resolveBinary('mvn', localWrapper, root, opts) const visited = new Set() const manifestPaths = [rootPom] diff --git a/src/tools.js b/src/tools.js index c4d5801d..941c2103 100644 --- a/src/tools.js +++ b/src/tools.js @@ -178,6 +178,25 @@ export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) } } +/** + * Resolve a build-tool binary, preferring a wrapper when configured. + * + * @param {string} globalBinary - Global binary name (e.g. `mvn`, `gradle`) + * @param {string} localWrapper - Wrapper filename (e.g. `mvnw`, `gradlew.bat`) + * @param {string} startDir - Directory from which to start the wrapper search + * @param {import('./index.js').Options} [opts={}] + * @returns {string} Path to the resolved binary + */ +export function resolveBinary(globalBinary, localWrapper, startDir, opts = {}) { + if (getWrapperPreference(globalBinary, opts)) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath(globalBinary, opts) +} + /** this method invokes command string in a process in a synchronous way. * @param {string} bin - the command to be invoked * @param {Array} args - the args to pass to the binary From 1e07b4f49238e61566ddf3afa188f511cf104027 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 14:26:32 +0100 Subject: [PATCH 06/10] refactor: use selectToolBinary instead of resolveBinary for Maven discovery Replace the standalone resolveBinary function with Java_maven's selectToolBinary, which also runs a --version smoke test to verify the binary is executable. Gracefully fall back to returning only the root pom.xml if Maven is not available. Co-Authored-By: Claude Opus 4.6 --- src/providers/java_maven.js | 10 +++++++--- src/tools.js | 19 ------------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index b6a89d0b..80967800 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -7,7 +7,7 @@ import { XMLParser } from 'fast-xml-parser' import { getLicense } from '../license/license_utils.js' import Sbom from '../sbom.js' -import { getCustom, invokeCommand, resolveBinary } from '../tools.js' +import { getCustom, invokeCommand } from '../tools.js' import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_maven } from "./base_java.js"; @@ -330,8 +330,12 @@ export async function discoverMavenModules(workspaceRoot, opts = {}) { return [] } - const localWrapper = 'mvnw' + (process.platform === 'win32' ? '.cmd' : '') - const mvnBin = resolveBinary('mvn', localWrapper, root, opts) + let mvnBin + try { + mvnBin = new Java_maven().selectToolBinary(rootPom, opts) + } catch { + return [rootPom] + } const visited = new Set() const manifestPaths = [rootPom] diff --git a/src/tools.js b/src/tools.js index 941c2103..c4d5801d 100644 --- a/src/tools.js +++ b/src/tools.js @@ -178,25 +178,6 @@ export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) } } -/** - * Resolve a build-tool binary, preferring a wrapper when configured. - * - * @param {string} globalBinary - Global binary name (e.g. `mvn`, `gradle`) - * @param {string} localWrapper - Wrapper filename (e.g. `mvnw`, `gradlew.bat`) - * @param {string} startDir - Directory from which to start the wrapper search - * @param {import('./index.js').Options} [opts={}] - * @returns {string} Path to the resolved binary - */ -export function resolveBinary(globalBinary, localWrapper, startDir, opts = {}) { - if (getWrapperPreference(globalBinary, opts)) { - const wrapper = traverseForWrapper(startDir, localWrapper) - if (wrapper !== undefined) { - return wrapper - } - } - return getCustomPath(globalBinary, opts) -} - /** this method invokes command string in a process in a synchronous way. * @param {string} bin - the command to be invoked * @param {Array} args - the args to pass to the binary From 230339f703b18e7c35fd6ddf2f30cecb46e50ccb Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 14:27:07 +0100 Subject: [PATCH 07/10] fix: parse real Maven XML output and remove invokeCommand mocks parseMavenModuleList was expecting Java List.toString() format ([module-a, module-b]) but Maven 3.9.x with -DforceStdout produces XML (module-a...). The empty- modules check also expected 'null' but real Maven returns . Replace invokeCommand mocks in workspace tests with real Maven invocations so the parser is exercised against actual tool output. Co-Authored-By: Claude Opus 4.6 --- src/providers/java_maven.js | 15 +++-- test/providers/workspace.test.js | 95 +++++++------------------------- 2 files changed, 29 insertions(+), 81 deletions(-) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 80967800..9a366af7 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -390,20 +390,23 @@ function listMavenModules(dir, mvnBin) { } const raw = output.toString().trim() - if (!raw || raw === 'null') { + if (!raw || raw.startsWith('(.+?)<\/string>/g + let m + while ((m = re.exec(raw)) !== null) { + const val = m[1].trim() + if (val) modules.push(val) } - return match[1].split(',').map(s => s.trim()).filter(Boolean) + return modules } diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index fb19451f..0bf88a37 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -189,7 +189,9 @@ suite('discoverWorkspaceCrates', () => { }) }) -suite('discoverMavenModules', () => { +suite('discoverMavenModules', function () { + this.timeout(60_000) + test('returns empty when no pom.xml at root', async () => { const result = await discoverMavenModules('test/providers/tst_manifests/npm') expect(result).to.be.an('array') @@ -198,15 +200,6 @@ suite('discoverMavenModules', () => { test('returns root pom only when mvn reports no modules', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') - const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { - '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'mvn', - getGitRootDir: () => null, - getWrapperPreference: () => false, - invokeCommand: () => Buffer.from('null'), - }, - }) const result = await discoverMavenModules(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(1) @@ -215,24 +208,6 @@ suite('discoverMavenModules', () => { test('discovers multi-module project', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { - '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'mvn', - getGitRootDir: () => null, - getWrapperPreference: () => false, - invokeCommand: (bin, args) => { - const pomArg = args.find((a, i) => args[i - 1] === '-f') - if (pomArg && pomArg.includes('module-a')) { - return Buffer.from('null') - } - if (pomArg && pomArg.includes('module-b')) { - return Buffer.from('null') - } - return Buffer.from('[module-a, module-b]') - }, - }, - }) const result = await discoverMavenModules(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(3) @@ -244,24 +219,6 @@ suite('discoverMavenModules', () => { test('discovers nested aggregator modules recursively', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_nested_aggregator') - const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { - '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'mvn', - getGitRootDir: () => null, - getWrapperPreference: () => false, - invokeCommand: (bin, args) => { - const pomArg = args.find((a, i) => args[i - 1] === '-f') - if (pomArg && pomArg.endsWith(path.join('parent', 'child', 'pom.xml'))) { - return Buffer.from('null') - } - if (pomArg && pomArg.endsWith(path.join('parent', 'pom.xml'))) { - return Buffer.from('[child]') - } - return Buffer.from('[parent]') - }, - }, - }) const result = await discoverMavenModules(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(3) @@ -270,40 +227,28 @@ suite('discoverMavenModules', () => { expect(result.some(p => p.includes(path.join('parent', 'child', 'pom.xml')))).to.be.true }) - test('returns root pom when mvn command fails', async () => { + test('returns root pom when mvn is not available', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { - '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'mvn', - getGitRootDir: () => null, - getWrapperPreference: () => false, - invokeCommand: () => { throw new Error('mvn not found') }, - }, - }) - const result = await discoverMavenModules(root) - expect(result).to.be.an('array') - expect(result).to.have.lengthOf(1) - expect(result[0]).to.equal(path.join(root, 'pom.xml')) + const saved = process.env.TRUSTIFY_DA_MVN_PATH + try { + process.env.TRUSTIFY_DA_MVN_PATH = '/nonexistent/mvn' + process.env.TRUSTIFY_DA_PREFER_MVNW = 'false' + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + } finally { + if (saved !== undefined) { + process.env.TRUSTIFY_DA_MVN_PATH = saved + } else { + delete process.env.TRUSTIFY_DA_MVN_PATH + } + delete process.env.TRUSTIFY_DA_PREFER_MVNW + } }) test('excludes paths matching workspaceDiscoveryIgnore', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const { discoverMavenModules } = await esmock('../../src/providers/java_maven.js', { - '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'mvn', - getGitRootDir: () => null, - getWrapperPreference: () => false, - invokeCommand: (bin, args) => { - const pomArg = args.find((a, i) => args[i - 1] === '-f') - if (pomArg && (pomArg.includes('module-a') || pomArg.includes('module-b'))) { - return Buffer.from('null') - } - return Buffer.from('[module-a, module-b]') - }, - }, - }) const result = await discoverMavenModules(root, { workspaceDiscoveryIgnore: ['**/module-b/**'], }) From 933dfc1cfb549d83ab97f589386f8c6f8d4b0b02 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 15:02:16 +0100 Subject: [PATCH 08/10] test: use esmock instead of env var manipulation for mvn-not-available test Replace process.env.TRUSTIFY_DA_MVN_PATH save/restore pattern with esmock mocking of invokeCommand to simulate missing mvn binary. Co-Authored-By: Claude Opus 4.6 --- test/providers/workspace.test.js | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 0bf88a37..ab002060 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -229,22 +229,17 @@ suite('discoverMavenModules', function () { test('returns root pom when mvn is not available', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') - const saved = process.env.TRUSTIFY_DA_MVN_PATH - try { - process.env.TRUSTIFY_DA_MVN_PATH = '/nonexistent/mvn' - process.env.TRUSTIFY_DA_PREFER_MVNW = 'false' - const result = await discoverMavenModules(root) - expect(result).to.be.an('array') - expect(result).to.have.lengthOf(1) - expect(result[0]).to.equal(path.join(root, 'pom.xml')) - } finally { - if (saved !== undefined) { - process.env.TRUSTIFY_DA_MVN_PATH = saved - } else { - delete process.env.TRUSTIFY_DA_MVN_PATH - } - delete process.env.TRUSTIFY_DA_PREFER_MVNW - } + const { discoverMavenModules: discoverMocked } = await esmock('../../src/providers/java_maven.js', { + '../../src/tools.js': { + getCustomPath: () => '/nonexistent/mvn', + getWrapperPreference: () => false, + invokeCommand: () => { throw Object.assign(new Error('mvn not found'), { code: 'ENOENT' }) }, + }, + }) + const result = await discoverMocked(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) }) test('excludes paths matching workspaceDiscoveryIgnore', async () => { From 097cefa0b57f6916df920a5ffdddb92a69045770 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 15:15:38 +0100 Subject: [PATCH 09/10] refactor: replace regex with XMLParser in parseMavenModuleList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMLParser is already imported in the file — reuse it instead of a hand-rolled regex to parse Maven's output. Co-Authored-By: Claude Opus 4.6 --- src/providers/java_maven.js | 14 ++++++-------- test/providers/workspace.test.js | 4 +--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 9a366af7..7a03947c 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -401,12 +401,10 @@ function listMavenModules(dir, mvnBin) { * @returns {string[]} */ function parseMavenModuleList(raw) { - const modules = [] - const re = /(.+?)<\/string>/g - let m - while ((m = re.exec(raw)) !== null) { - const val = m[1].trim() - if (val) modules.push(val) - } - return modules + const parser = new XMLParser() + const parsed = parser.parse(raw) + const entries = parsed?.strings?.string + if (!entries) { return [] } + const list = Array.isArray(entries) ? entries : [entries] + return list.map(s => String(s).trim()).filter(Boolean) } diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index ab002060..a72e53e8 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -189,9 +189,7 @@ suite('discoverWorkspaceCrates', () => { }) }) -suite('discoverMavenModules', function () { - this.timeout(60_000) - +suite('discoverMavenModules', () => { test('returns empty when no pom.xml at root', async () => { const result = await discoverMavenModules('test/providers/tst_manifests/npm') expect(result).to.be.an('array') From a6a7425b42b992fa3ca05821b24d5e3648a12f81 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 15:33:38 +0100 Subject: [PATCH 10/10] test: add timeouts to Maven workspace tests that invoke real mvn Co-Authored-By: Claude Opus 4.6 --- test/providers/workspace.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index a72e53e8..b2b7a38c 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -202,7 +202,7 @@ suite('discoverMavenModules', () => { expect(result).to.be.an('array') expect(result).to.have.lengthOf(1) expect(result[0]).to.equal(path.join(root, 'pom.xml')) - }) + }).timeout(40000) test('discovers multi-module project', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') @@ -213,7 +213,7 @@ suite('discoverMavenModules', () => { expect(result[0]).to.equal(path.join(root, 'pom.xml')) expect(result.some(p => p.includes('module-a'))).to.be.true expect(result.some(p => p.includes('module-b'))).to.be.true - }) + }).timeout(40000) test('discovers nested aggregator modules recursively', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_nested_aggregator') @@ -223,7 +223,7 @@ suite('discoverMavenModules', () => { expect(result[0]).to.equal(path.join(root, 'pom.xml')) expect(result.some(p => p.includes(path.join('parent', 'pom.xml')))).to.be.true expect(result.some(p => p.includes(path.join('parent', 'child', 'pom.xml')))).to.be.true - }) + }).timeout(40000) test('returns root pom when mvn is not available', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') @@ -247,5 +247,5 @@ suite('discoverMavenModules', () => { }) expect(result.some(p => p.includes('module-a'))).to.be.true expect(result.some(p => p.includes('module-b'))).to.be.false - }) + }).timeout(40000) })