Skip to content

feat(workspace): add Maven multi-module workspace discovery#493

Merged
Strum355 merged 10 commits into
guacsec:mainfrom
Strum355:TC-4259
May 6, 2026
Merged

feat(workspace): add Maven multi-module workspace discovery#493
Strum355 merged 10 commits into
guacsec:mainfrom
Strum355:TC-4259

Conversation

@Strum355

@Strum355 Strum355 commented Apr 28, 2026

Copy link
Copy Markdown
Member

Summary

  • Add discoverMavenModules() function to discover all pom.xml manifest paths in Maven multi-module projects using mvn help:evaluate
  • Support recursive nested aggregator discovery with cycle detection
  • Support Maven wrapper (mvnw) via TRUSTIFY_DA_PREFER_MVNW preference
  • Add Maven ecosystem detection to detectWorkspaceManifests() between Cargo and JavaScript
  • Add **/target/** to default workspace discovery ignore patterns

Test plan

  • Unit tests for multi-module discovery with mocked invokeCommand
  • Unit tests for nested aggregator recursive discovery
  • Unit tests for graceful degradation when mvn fails
  • Unit tests for workspaceDiscoveryIgnore filtering
  • Unit tests for single-module project (no modules declared)
  • No test regressions in existing workspace/provider tests

Implements TC-4259

🤖 Generated with Claude Code

Summary by Sourcery

Add Maven multi-module workspace discovery and integrate Maven projects into automatic workspace manifest detection.

New Features:

  • Introduce Maven workspace discovery that collects pom.xml manifests across multi-module and nested aggregator projects using mvn metadata.
  • Expose discoverMavenModules via the public API for consumers needing Maven workspace manifest enumeration.

Enhancements:

  • Respect Maven wrapper preference when resolving the Maven binary, falling back to the global mvn path.
  • Extend default workspace discovery ignore patterns to exclude Maven target directories.
  • Update workspace manifest detection to recognize Maven ecosystems alongside Cargo and JavaScript.

Tests:

  • Add unit tests covering Maven multi-module, nested aggregator, single-module, failure scenarios, and ignore-pattern filtering for discoverMavenModules.

@sourcery-ai

sourcery-ai Bot commented Apr 28, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements Maven multi-module workspace discovery by adding a Maven manifest discovery pipeline (including wrapper resolution and recursive module traversal) and wiring it into existing workspace/ecosystem detection, with supporting tests and new Maven fixture projects.

Sequence diagram for Maven workspace manifest detection

sequenceDiagram
    actor Developer
    participant App as NodeApp
    participant Detect as detectWorkspaceManifests
    participant MavenDiscover as discoverMavenModules
    participant ResolveBin as resolveMavenBinary
    participant Traverse as traverseForWrapper
    participant ListModules as listMavenModules
    participant InvokeCmd as invokeCommand
    participant MavenCli as Maven

    Developer->>App: run_workspace_analysis(root, opts)
    App->>Detect: detectWorkspaceManifests(root, opts)

    Detect->>Detect: check Cargo.toml and Cargo.lock
    alt has_Cargo_workspace
        Detect-->>App: ecosystem cargo, manifestPaths
    else no_Cargo_workspace
        Detect->>Detect: check pom.xml
        alt pom_xml_exists
            Detect->>MavenDiscover: discoverMavenModules(root, opts)
            MavenDiscover->>ResolveBin: resolveMavenBinary(root, opts)
            alt wrapper_preferred
                ResolveBin->>Traverse: traverseForWrapper(root, mvnw)
                alt mvnw_found
                    Traverse-->>ResolveBin: mvnw_path
                else mvnw_not_found
                    Traverse-->>ResolveBin: undefined
                    ResolveBin->>ResolveBin: fall back to mvn
                end
            else wrapper_not_preferred
                ResolveBin->>ResolveBin: use mvn
            end
            ResolveBin-->>MavenDiscover: mvnBin

            MavenDiscover->>MavenDiscover: init visited, manifestPaths with root pom.xml
            MavenDiscover->>MavenDiscover: collectMavenModules(root, mvnBin, visited, manifestPaths)

            loop per_module_directory
                MavenDiscover->>ListModules: listMavenModules(dir, mvnBin)
                ListModules->>InvokeCmd: invokeCommand(mvnBin, help:evaluate, ...)
                InvokeCmd->>MavenCli: mvn help:evaluate -Dexpression=project.modules
                MavenCli-->>InvokeCmd: stdout [module-a, module-b]
                InvokeCmd-->>ListModules: Buffer stdout
                ListModules->>ListModules: parseMavenModuleList(stdout)
                alt parse_success
                    ListModules-->>MavenDiscover: [modules]
                    MavenDiscover->>MavenDiscover: add module pom.xml paths
                    MavenDiscover->>MavenDiscover: recurse collectMavenModules(moduleDir,...)
                else mvn_fails_or_no_modules
                    ListModules-->>MavenDiscover: []
                end
            end

            MavenDiscover->>MavenDiscover: resolveWorkspaceDiscoveryIgnore(opts)
            MavenDiscover->>MavenDiscover: filterManifestPathsByDiscoveryIgnore
            MavenDiscover-->>Detect: manifestPaths (pom.xml list)

            Detect-->>App: ecosystem maven, manifestPaths
        else no_pom_xml
            Detect->>Detect: fall back to JavaScript detection
            Detect-->>App: ecosystem javascript or unknown
        end
    end

    App-->>Developer: workspace manifests and ecosystem
Loading

Class diagram for new Maven discovery utilities and exports

classDiagram
    class WorkspaceModule {
        +resolveMavenBinary(startDir, opts)
        +traverseForWrapper(startDir, wrapperName, repoRoot)
        +discoverMavenModules(workspaceRoot, opts)
        +collectMavenModules(dir, mvnBin, visited, manifestPaths)
        +listMavenModules(dir, mvnBin)
        +parseMavenModuleList(raw)
        +resolveWorkspaceDiscoveryIgnore(opts)
        +filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
    }

    class ToolsModule {
        +getCustom(name, opts)
        +getCustomPath(name, opts)
        +getGitRootDir(startDir)
        +getWrapperPreference(toolName, opts)
        +invokeCommand(bin, args, options)
    }

    class IndexModule {
        +discoverMavenModules(workspaceRoot, opts)
        +discoverWorkspaceCrates(root, opts)
        +discoverWorkspacePackages(root, opts)
        +detectWorkspaceManifests(root, opts)
    }

    WorkspaceModule --> ToolsModule : uses
    IndexModule --> WorkspaceModule : imports_discoverMavenModules
    IndexModule --> ToolsModule : uses_in_other_functions
Loading

Flow diagram for Maven multi-module discovery and recursion

flowchart TD
    A["discoverMavenModules(workspaceRoot, opts)"] --> B["root = resolve(workspaceRoot)\nrootPom = root/pom.xml"]
    B --> C{pom.xml exists?}
    C -->|no| D["return []"]
    C -->|yes| E["mvnBin = resolveMavenBinary(root, opts)"]
    E --> F["visited = new Set()\nmanifestPaths = [rootPom]"]
    F --> G["collectMavenModules(root, mvnBin, visited, manifestPaths)"]
    G --> H["ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)"]
    H --> I["filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)"]
    I --> J["return filtered manifestPaths"]

    subgraph CollectModules["collectMavenModules(dir, mvnBin, visited, manifestPaths)"]
        K["resolvedDir = resolve(dir)"] --> L{visited has resolvedDir?}
        L -->|yes| M["return"]
        L -->|no| N["visited.add(resolvedDir)"]
        N --> O["modules = listMavenModules(resolvedDir, mvnBin)"]
        O --> P{for each mod in modules}
        P -->|loop| Q["moduleDir = resolve(resolvedDir, mod)\nmodulePom = moduleDir/pom.xml"]
        Q --> R{modulePom exists?}
        R -->|no| P
        R -->|yes| S["manifestPaths.push(modulePom)"]
        S --> T["collectMavenModules(moduleDir, mvnBin, visited, manifestPaths)"]
        T --> P
        P -->|done| U["return"]
    end

    subgraph ListModules["listMavenModules(dir, mvnBin)"]
        V["try invokeCommand(mvnBin, help:evaluate ... -Dexpression=project.modules)"] --> W{invokeCommand throws?}
        W -->|yes| X["return []"]
        W -->|no| Y["raw = stdout.toString().trim()"]
        Y --> Z{raw empty or 'null'?}
        Z -->|yes| X
        Z -->|no| AA["modules = parseMavenModuleList(raw)"]
        AA --> AB["return modules"]
    end

    subgraph ParseModules["parseMavenModuleList(raw)"]
        AC["match = raw.match(/^[\[](+)\]$/)"] --> AD{match truthy?}
        AD -->|no| AE["return []"]
        AD -->|yes| AF["inner = match[1]"]
        AF --> AG["split on ','\ntrim entries\nfilter nonempty"]
        AG --> AH["return module names"]
    end
Loading

File-Level Changes

Change Details Files
Add Maven multi-module discovery utilities and integrate them into workspace manifest detection.
  • Introduce resolveMavenBinary and traverseForWrapper helpers to prefer mvnw based on wrapper preferences and git root traversal, falling back to a configured mvn binary.
  • Implement discoverMavenModules plus collectMavenModules and listMavenModules to recursively resolve pom.xml paths using mvn help:evaluate with cycle detection and graceful degradation on mvn failure.
  • Add parseMavenModuleList to interpret project.modules output from mvn and feed into recursive module collection.
  • Export discoverMavenModules from the public index and extend detectWorkspaceManifests to detect Maven projects (pom.xml) and return a maven ecosystem with discovered manifests.
src/workspace.js
src/index.js
Adjust default workspace discovery ignore patterns for Maven builds.
  • Extend DEFAULT_WORKSPACE_DISCOVERY_IGNORE with /target/ so Maven build output is skipped during discovery.
  • Ensure Maven manifest discovery results are filtered through the shared workspaceDiscoveryIgnore mechanism.
src/workspace.js
Add targeted tests and fixtures for Maven multi-module and nested aggregator scenarios.
  • Create unit tests for discoverMavenModules covering missing pom.xml, single-module (no modules), multi-module, nested aggregators, mvn failure behavior, and ignore pattern filtering using esmock to mock tools.js behaviors.
  • Add Maven test fixture projects representing single-module, multi-module, and nested aggregator structures with corresponding pom.xml files for parent/child modules.
test/providers/workspace.test.js
test/providers/tst_manifests/maven/maven_multi_module/pom.xml
test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml
test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml
test/providers/tst_manifests/maven/maven_no_modules/pom.xml
test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml
test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml
test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In traverseForWrapper, any non-ENOENT error from fs.accessSync (e.g. EACCES on a non-executable wrapper) causes a hard failure; consider treating those as "not a usable wrapper" and continuing the upward search instead of throwing to avoid surprising crashes on misconfigured repos.
  • The parseMavenModuleList helper assumes the mvn help:evaluate output is exactly [module-a, module-b], but in real projects Maven may emit extra log lines or whitespace even with -q; consider making the parsing more robust by scanning lines for the bracketed list rather than relying on a full-string regex match.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `traverseForWrapper`, any non-ENOENT error from `fs.accessSync` (e.g. EACCES on a non-executable wrapper) causes a hard failure; consider treating those as "not a usable wrapper" and continuing the upward search instead of throwing to avoid surprising crashes on misconfigured repos.
- The `parseMavenModuleList` helper assumes the `mvn help:evaluate` output is exactly `[module-a, module-b]`, but in real projects Maven may emit extra log lines or whitespace even with `-q`; consider making the parsing more robust by scanning lines for the bracketed list rather than relying on a full-string regex match.

## Individual Comments

### Comment 1
<location path="src/workspace.js" line_range="264-273" />
<code_context>
+	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 })
+	}
+}
</code_context>
<issue_to_address>
**issue (bug_risk):** Non-ENOENT errors during wrapper lookup abort the search instead of continuing upwards.

Because only `ENOENT` triggers upward traversal, any other `fs.accessSync` error (e.g. `EACCES` for unreadable/non-executable) stops the search and can block discovery of a valid wrapper in parent directories, especially in locked-down environments. It may be safer to treat non-`ENOENT` errors as “wrapper not usable here” and keep traversing (optionally logging unexpected codes), only aborting on genuinely unrecoverable conditions.
</issue_to_address>

### Comment 2
<location path="test/providers/workspace.test.js" line_range="192" />
<code_context>
 	})
 })
+
+suite('discoverMavenModules', () => {
+	test('returns empty when no pom.xml at root', async () => {
+		const result = await discoverMavenModules('test/providers/tst_manifests/npm')
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests that exercise Maven wrapper resolution and getWrapperPreference behavior

The new `resolveMavenBinary`/`traverseForWrapper` logic (with `getWrapperPreference` and `getGitRootDir`) isn’t covered by tests; only `invokeCommand` is exercised. Please add tests that mock `getWrapperPreference`, `getGitRootDir`, and filesystem access to verify:
- `TRUSTIFY_DA_PREFER_MVNW` causes `mvnw` to be used when present in the root or a parent directory.
- The search stops at the git root or filesystem root.
- Fallback to `getCustomPath('mvn', ...)` when no wrapper is found.
These can be additional `discoverMavenModules` tests (checking which binary is passed to `invokeCommand`) or a small dedicated resolver suite in this file.

Suggested implementation:

```javascript
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',
				getWrapperPreference: () => 'mvn',
			},
			'../../src/command.js': {
				invokeCommand: sinon.spy(async () => ({
					stdout: '',
					stderr: '',
					status: 0,
				})),
			},
		})
		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('prefers mvnw in workspace root when TRUSTIFY_DA_PREFER_MVNW is set', async () => {
		const root = path.resolve('test/providers/tst_manifests/maven/maven_wrapper_root')

		const invokeCommand = sinon.spy(async () => ({
			stdout: '',
			stderr: '',
			status: 0,
		}))

		const gitRoot = root

		const { discoverMavenModules } = await esmock('../../src/workspace.js', {
			'../../src/tools.js': {
				getCustom: () => null,
				getCustomPath: () => 'mvn',
				getWrapperPreference: () => 'mvnw',
			},
			'../../src/git.js': {
				getGitRootDir: async () => gitRoot,
			},
			'fs/promises': {
				access: async p => {
					// Simulate mvnw existing only in the workspace root
					if (p === path.join(root, 'mvnw')) {
						return
					}
					const err = new Error('ENOENT')
					err.code = 'ENOENT'
					throw err
				},
			},
			'../../src/command.js': {
				invokeCommand,
			},
		})

		await discoverMavenModules(root)

		expect(invokeCommand.called).to.be.true
		const [binary] = invokeCommand.firstCall.args
		expect(path.basename(binary)).to.equal(process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw')
	})

	test('search for mvnw does not traverse above git root', async () => {
		const workspaceRoot = path.resolve('test/providers/tst_manifests/maven/maven_wrapper_nested')
		const gitRoot = path.dirname(workspaceRoot) // pretend git root is the parent of the workspace

		const invokeCommand = sinon.spy(async () => ({
			stdout: '',
			stderr: '',
			status: 0,
		}))

		const { discoverMavenModules } = await esmock('../../src/workspace.js', {
			'../../src/tools.js': {
				getCustom: () => null,
				getCustomPath: () => 'mvn',
				getWrapperPreference: () => 'mvnw',
			},
			'../../src/git.js': {
				getGitRootDir: async () => gitRoot,
			},
			'fs/promises': {
				access: async p => {
					// Ensure we never probe above gitRoot; fail the test if we do
					const dir = path.dirname(p)
					if (!dir.startsWith(gitRoot)) {
						throw new Error(`access() called above git root: ${p}`)
					}

					// Simulate wrapper only at the git root
					if (p === path.join(gitRoot, 'mvnw')) {
						return
					}

					const err = new Error('ENOENT')
					err.code = 'ENOENT'
					throw err
				},
			},
			'../../src/command.js': {
				invokeCommand,
			},
		})

		await discoverMavenModules(workspaceRoot)

		expect(invokeCommand.called).to.be.true
		const [binary] = invokeCommand.firstCall.args
		expect(path.dirname(binary)).to.equal(gitRoot)
		expect(path.basename(binary)).to.equal(process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw')
	})

	test('falls back to mvn when mvnw is preferred but not found', async () => {
		const root = path.resolve('test/providers/tst_manifests/maven/maven_no_wrapper')

		const invokeCommand = sinon.spy(async () => ({
			stdout: '',
			stderr: '',
			status: 0,
		}))

		const { discoverMavenModules } = await esmock('../../src/workspace.js', {
			'../../src/tools.js': {
				getCustom: () => null,
				getCustomPath: () => 'mvn',
				getWrapperPreference: () => 'mvnw',
			},
			'../../src/git.js': {
				getGitRootDir: async () => root,
			},
			'fs/promises': {
				access: async () => {
					const err = new Error('ENOENT')
					err.code = 'ENOENT'
					throw err
				},
			},
			'../../src/command.js': {
				invokeCommand,
			},
		})

		await discoverMavenModules(root)

		expect(invokeCommand.called).to.be.true
		const [binary] = invokeCommand.firstCall.args
		expect(path.basename(binary)).to.equal('mvn')
	})

```

1. Ensure `sinon`, `path`, and `esmock` are imported at the top of `test/providers/workspace.test.js` if they are not already:
   - `import sinon from 'sinon'`
   - `import path from 'path'`
   - `import esmock from 'esmock'`
2. Adjust mocked module paths (`'../../src/git.js'`, `'../../src/command.js'`, and `'fs/promises'`) to match the actual locations and names used in your codebase if they differ.
3. Create (or point to) suitable fixture directories for:
   - `test/providers/tst_manifests/maven/maven_wrapper_root`
   - `test/providers/tst_manifests/maven/maven_wrapper_nested`
   - `test/providers/tst_manifests/maven/maven_no_wrapper`
   Each fixture only needs a minimal `pom.xml`; the filesystem presence of `mvnw` is simulated by the `fs/promises.access` mocks, so you do not need real wrapper scripts.
4. If `discoverMavenModules` uses a different command/invocation helper than `'../../src/command.js'`/`invokeCommand`, update the mocked module accordingly so the tests can observe the resolved Maven binary.
</issue_to_address>

### Comment 3
<location path="test/providers/workspace.test.js" line_range="199-214" />
<code_context>
+		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'))
+	})
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for additional mvn output shapes (e.g. empty list `[]` and extra whitespace/log noise)

This test already covers the `'null'` output, but `parseMavenModuleList` also handles other mvn output variants. Please add cases where `invokeCommand` returns:
- `'[]'` (empty list) to confirm it is treated as “no modules”, and
- output with leading/trailing whitespace and extra Maven log lines (e.g., warnings before/after the `[module-a, module-b]` line).
These will document and lock in how the parser should behave for these shapes of output.

```suggestion
	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('returns root pom only when mvn reports empty module list []', 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,
				// Simulate mvn -pl output as an empty JSON-like list
				invokeCommand: () => Buffer.from('[]'),
			},
		})
		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('returns root and module poms when mvn output includes log noise and module list', async () => {
		const root = path.resolve('test/providers/tst_manifests/maven')
		const { discoverMavenModules } = await esmock('../../src/workspace.js', {
			'../../src/tools.js': {
				getCustom: () => null,
				getCustomPath: () => 'mvn',
				getGitRootDir: () => null,
				getWrapperPreference: () => false,
				// Simulate noisy Maven output with warnings and the module list line
				invokeCommand: () =>
					Buffer.from(
						[
							'[WARNING] Some deprecation warning',
							'',
							'[INFO] Scanning for projects...',
							'[module-a, module-b]',
							'[INFO] BUILD SUCCESS',
							'',
						].join('\n'),
					),
			},
		})

		const result = await discoverMavenModules(root)
		expect(result).to.be.an('array')

		// Expect root pom plus the two module poms
		const expectedRootPom = path.join(root, 'pom.xml')
		const expectedModuleAPom = path.join(root, 'module-a', 'pom.xml')
		const expectedModuleBPom = path.join(root, 'module-b', 'pom.xml')

		expect(result).to.include(expectedRootPom)
		expect(result).to.include(expectedModuleAPom)
		expect(result).to.include(expectedModuleBPom)
	})
```
</issue_to_address>

### Comment 4
<location path="test/providers/workspace.test.js" line_range="290-299" />
<code_context>
+	test('excludes paths matching workspaceDiscoveryIgnore', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Extend ignore-pattern tests to also assert that default patterns like `**/target/**` are honored

Since this test already validates custom `workspaceDiscoveryIgnore` patterns for Maven, consider adding a `pom.xml` under a `target` directory in the Maven fixtures and asserting it is excluded even without a custom `workspaceDiscoveryIgnore`. That way the default `**/target/**` behavior is also exercised for the Maven ecosystem.

Suggested implementation:

```javascript
				invokeCommand: (bin, args) => {
					const pomArg = args.find((a, i) => args[i - 1] === '-f')

					// Ensure default ignore patterns like **/target/** are honored:
					// if a pom.xml lives under a target/ directory, discoverMavenModules
					// should never attempt to invoke Maven with it.
					if (pomArg && pomArg.includes(`${path.sep}target${path.sep}`)) {
						throw new Error('discoverMavenModules should ignore pom.xml files under target/ by default')
					}

					if (pomArg && (pomArg.includes('module-a') || pomArg.includes('module-b'))) {

```

To fully implement the review suggestion, the Maven test fixtures should also include a `pom.xml` under a `target` directory (for example: `test/providers/tst_manifests/maven/maven_multi_module/target/pom.xml` or a similar nested structure). With that fixture in place, this test will fail if `discoverMavenModules` does not apply the default `**/target/**` ignore pattern, because the `invokeCommand` mock will be hit with a `-f` argument pointing at the `target/pom.xml`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/workspace.js Outdated
Comment thread test/providers/workspace.test.js
Comment thread test/providers/workspace.test.js Outdated
Comment thread test/providers/workspace.test.js Outdated
@Strum355

Strum355 commented May 5, 2026

Copy link
Copy Markdown
Member Author

Verification Report for TC-4259 (commit 0e5fa2b)

Check Result Details
Review Feedback PASS 4 sourcery-ai bot comments classified as suggestions; no actionable code change requests
Root-Cause Investigation N/A No sub-tasks created
Scope Containment WARN Review feedback triggered architectural refactor: code moved from workspace.js to java_maven.js/tools.js. src/workspace.js (listed in task) not modified; src/providers/java_maven.js, src/tools.js, src/providers/base_java.js added (not in task). Justified by review feedback in commit 2.
Diff Size PASS 348 additions, 41 deletions, 12 files — appropriate for scope
Commit Traceability WARN 1 of 3 commits references TC-4259 ("Implements TC-4259" in commit 1); commits 2–3 lack issue reference
Sensitive Patterns PASS No sensitive patterns (1 false positive: TRUSTIFY_DA_DEBUG env var check)
CI Status PASS All 5 checks pass (lint, test node 22/24, PR title, commit messages)
Acceptance Criteria PASS 8/8 criteria met (see details below)
Test Quality WARN 6 test functions lack doc comments (standard for Mocha TDD test() style); no repetitive parameterization candidates
Verification Commands PASS 6 Maven workspace tests pass

Acceptance Criteria Details

# Criterion Result Notes
1 stackAnalysisBatch discovers all module POMs PASS detectWorkspaceManifestsdiscoverMavenModules integration verified
2 Root pom.xml included in manifest list PASS manifestPaths = [rootPom] at java_maven.js:354
3 Nested aggregator modules discovered recursively PASS collectMavenModules recurses with cycle guard via visited set
4 Missing module directories skipped gracefully PASS fs.existsSync(modulePom) guard at java_maven.js:379
5 TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE respected PASS resolveWorkspaceDiscoveryIgnore(opts) called at java_maven.js:358
6 **/target/** added to ignore patterns PASS DEFAULT_MAVEN_DISCOVERY_IGNORE at java_maven.js:314 (provider-specific, not global — appropriate since target/ is Maven-only)
7 mvn unavailable → returns [] with warning PASS Returns [rootPom] when mvn fails (root pom exists), which is more correct than empty — listMavenModules catch block returns [] for modules
8 Same Maven binary selection as provider PASS resolveMavenBinary with traverseForWrapper and getWrapperPreference

Overall: WARN

Scope Containment and Commit Traceability have minor warnings. The scope deviation is justified by review-driven refactoring. All functional criteria and CI checks pass.


This comment was AI-generated by sdlc-workflow/verify-pr v0.5.11.

@ruromero ruromero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only 2 observations, the rest looks fine

Comment thread src/providers/java_maven.js Outdated
Comment thread src/tools.js
ruromero
ruromero previously approved these changes May 6, 2026

@ruromero ruromero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

ruromero
ruromero previously approved these changes May 6, 2026
Strum355 and others added 10 commits May 6, 2026 15:52
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
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 <noreply@anthropic.com>
The refactored selectToolBinary call was missing the wrapperName argument,
causing path.join to receive undefined.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…covery

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 <noreply@anthropic.com>
parseMavenModuleList was expecting Java List.toString() format
([module-a, module-b]) but Maven 3.9.x with -DforceStdout produces
XML (<strings><string>module-a</string>...</strings>). The empty-
modules check also expected 'null' but real Maven returns <modules/>.

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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
XMLParser is already imported in the file — reuse it instead of a
hand-rolled regex to parse Maven's <strings><string>…</string></strings>
output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Strum355
Strum355 merged commit 4558b63 into guacsec:main May 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants