Skip to content

feat(workspace): add uv workspace (pyproject.toml) discovery#496

Open
Strum355 wants to merge 4 commits intoguacsec:mainfrom
Strum355:TC-4265
Open

feat(workspace): add uv workspace (pyproject.toml) discovery#496
Strum355 wants to merge 4 commits intoguacsec:mainfrom
Strum355:TC-4265

Conversation

@Strum355
Copy link
Copy Markdown
Member

@Strum355 Strum355 commented Apr 28, 2026

Summary

  • Add discoverUvWorkspaceMembers() for discovering workspace members in uv/Python monorepos
  • Parses pyproject.toml for [tool.uv.workspace] member globs and resolves via fast-glob — no uv CLI needed
  • Handles virtual workspaces (no [project] in root → exclude root) vs root-package workspaces (include root)
  • Respects uv's exclude patterns and TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE
  • Adds __pycache__ and .venv to default ignore patterns
  • Detection order: Cargo → Maven → Gradle → Go → uv → JavaScript

Test plan

  • 8 new tests covering: no pyproject.toml, missing uv.lock, missing workspace config, root-package workspace, virtual workspace, exclude patterns, multiple glob patterns, ignore pattern filtering
  • All 42 workspace tests passing
  • ESLint clean

Jira

TC-4265

🤖 Generated with Claude Code

Summary by Sourcery

Extend workspace manifest discovery to support additional ecosystems and uv-based Python monorepos.

New Features:

  • Add Maven multi-module workspace discovery based on pom.xml and mvn project.modules.
  • Add Gradle multi-project workspace discovery using an init script to list subprojects and their build files.
  • Add Go workspace discovery using go.work metadata to locate module go.mod files.
  • Add uv-based Python workspace discovery by parsing pyproject.toml [tool.uv.workspace] configuration and respecting uv.lock presence.

Enhancements:

  • Broaden workspace detection to classify workspaces as Maven, Gradle, Go modules, or uv/pyproject in addition to Cargo and JavaScript.
  • Extend default workspace discovery ignore patterns to cover common build and cache directories across ecosystems.
  • Export new workspace discovery helpers from the public index for external use.
  • Improve the batch analysis error message to reference all supported workspace types.

Tests:

  • Add comprehensive unit tests and fixture projects for Maven, Gradle, Go, and uv workspace discovery, including edge cases, nested structures, wrapper preference, and ignore/exclude handling.

Strum355 and others added 4 commits April 28, 2026 11:40
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
Discover subprojects in Gradle multi-project builds using a custom init
script that emits structured project listings. Supports Groovy and Kotlin
DSL variants, wrapper detection via gradlew traversal, and
workspaceDiscoveryIgnore filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add discoverGoWorkspaceModules() for discovering go.mod manifest paths
in Go multi-module workspaces via `go work edit -json`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add discoverUvWorkspaceMembers() for discovering workspace members in
uv/Python monorepos. Parses pyproject.toml for [tool.uv.workspace]
member globs and resolves them via fast-glob. Requires both
pyproject.toml with workspace config and uv.lock to be present.

Handles virtual workspaces (no [project] section in root) by excluding
the root pyproject.toml from discovered manifests. Respects uv's
exclude patterns and the standard workspace discovery ignore mechanism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 28, 2026

Reviewer's Guide

Adds first-class multi-ecosystem workspace discovery (Maven, Gradle, Go, uv/pyproject) to the workspace analysis pipeline, including CLI wrapper resolution, TOML parsing via smol-toml, enhanced default ignore patterns, and comprehensive tests/fixtures for the new discovery paths and error/ignore handling.

Class diagram for new workspace discovery utilities and exports

classDiagram

  class WorkspaceModule {
    <<module>>
    +discoverMavenModules(workspaceRoot, opts) Promise~string[]~
    +discoverGradleSubprojects(workspaceRoot, opts) Promise~string[]~
    +discoverGoWorkspaceModules(workspaceRoot, opts) Promise~string[]~
    +discoverUvWorkspaceMembers(workspaceRoot, opts) Promise~string[]~
    +discoverWorkspaceCrates(workspaceRoot, opts) Promise~string[]~
    +discoverWorkspacePackages(workspaceRoot, opts) Promise~string[]~
    +resolveMavenBinary(startDir, opts) string
    +resolveGradleBinary(startDir, opts) string
    +traverseForWrapper(startDir, wrapperName, repoRoot) string
    +listMavenModules(dir, mvnBin) string[]
    +collectMavenModules(dir, mvnBin, visited, manifestPaths) void
    +parseMavenModuleList(raw) string[]
    +parseGradleInitScriptOutput(raw) ProjectInfo[]
    +hasProjectMetadata(pyprojectPath) boolean
    +resolveWorkspaceDiscoveryIgnore(opts) string[]
    +buildWorkspaceDiscoveryGlobOptions(root, ignorePatterns) GlobOptions
    +filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) string[]
    +toManifestGlobPatterns(memberPatterns, manifestFileName) string[]
  }

  class ToolsModule {
    <<module>>
    +getCustom(toolName, opts) string
    +getCustomPath(toolName, opts) string
    +getGitRootDir(startDir) string
    +getWrapperPreference(toolName, opts) boolean
    +invokeCommand(binary, args, options) Buffer
  }

  class IndexModule {
    <<module>>
    +detectWorkspaceManifests(root, opts) DetectionResult
    +stackAnalysisBatch(workspaceRoot, html, opts) Promise~AnalysisResult[]~
    +discoverMavenModules(workspaceRoot, opts) Promise~string[]~
    +discoverGradleSubprojects(workspaceRoot, opts) Promise~string[]~
    +discoverGoWorkspaceModules(workspaceRoot, opts) Promise~string[]~
    +discoverUvWorkspaceMembers(workspaceRoot, opts) Promise~string[]~
  }

  class DetectionResult {
    +ecosystem : string
    +manifestPaths : string[]
  }

  class ProjectInfo {
    +path : string
    +dir : string
  }

  class GlobOptions {
    +cwd : string
    +ignore : string[]
    +absolute : boolean
  }

  class AnalysisResult {
    +workspaceRoot : string
    +ecosystem : string
    +total : number
    +successful : number
    +failed : number
  }

  WorkspaceModule ..> ToolsModule : uses
  IndexModule ..> WorkspaceModule : calls
  IndexModule ..> DetectionResult : returns
  WorkspaceModule ..> GlobOptions : constructs
  WorkspaceModule ..> ProjectInfo : returns
  IndexModule ..> AnalysisResult : returns
Loading

File-Level Changes

Change Details Files
Introduce Maven multi-module workspace discovery with wrapper support and ignore handling
  • Add resolveMavenBinary() and traverseForWrapper() helpers to prefer mvnw wrappers when configured, falling back to mvn or custom paths
  • Implement discoverMavenModules() that starts from the root pom.xml, recursively collects module pom.xml files using mvn help:evaluate, and filters results through workspaceDiscoveryIgnore
  • Add listMavenModules() and parseMavenModuleList() utilities to safely invoke Maven and parse project.modules output, handling failures/null responses gracefully
  • Extend tests to cover no root pom, single-module roots, multi-module projects, nested aggregators, mvn failures, and workspaceDiscoveryIgnore filtering
src/workspace.js
test/providers/workspace.test.js
test/providers/tst_manifests/maven/maven_no_modules/pom.xml
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_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
Introduce Gradle multi-project workspace discovery using an init script and wrapper support
  • Define a GRADLE_INIT_SCRIPT that prints project path and projectDir in a machine-readable ::DA_PROJECT:: format
  • Add resolveGradleBinary() using traverseForWrapper() and getWrapperPreference() to pick gradlew vs gradle
  • Implement discoverGradleSubprojects() to detect settings.gradle[.kts], run Gradle with the init script, collect root and subproject build.gradle[.kts] files, and filter with workspaceDiscoveryIgnore
  • Implement parseGradleInitScriptOutput() to parse the init script output, and ensure temporary init script files are cleaned up
  • Add tests for missing settings, multi-project builds, nested subprojects, mixed Groovy/Kotlin build files, Gradle failures, and workspaceDiscoveryIgnore filtering
src/workspace.js
test/providers/workspace.test.js
test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle
test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle
test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle
test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle
test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle
test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle
test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts
test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts
test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle
test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts
Introduce Go workspace (go.work) discovery via go CLI JSON and ignore filtering
  • Implement discoverGoWorkspaceModules() which checks for go.work, runs go work edit -json, parses JSON, and collects existing go.mod files for each Use entry
  • Handle command failures, invalid JSON, empty Use arrays, and missing module directories by returning an empty or reduced set of manifests
  • Apply resolveWorkspaceDiscoveryIgnore() and filterManifestPathsByDiscoveryIgnore() so workspaceDiscoveryIgnore and env-based ignores affect Go modules
  • Add tests for no go.work, multiple/nested modules, single module, missing module directories, go command failures, invalid JSON, empty Use arrays, and ignore filtering
src/workspace.js
test/providers/workspace.test.js
test/providers/tst_manifests/golang/go_workspace/go.work
test/providers/tst_manifests/golang/go_workspace/module-a/go.mod
test/providers/tst_manifests/golang/go_workspace/module-b/go.mod
test/providers/tst_manifests/golang/go_workspace_missing_module/go.work
test/providers/tst_manifests/golang/go_workspace_missing_module/existing/go.mod
test/providers/tst_manifests/golang/go_workspace_nested/go.work
test/providers/tst_manifests/golang/go_workspace_nested/libs/core/go.mod
test/providers/tst_manifests/golang/go_workspace_nested/libs/util/go.mod
test/providers/tst_manifests/golang/go_workspace_single/go.work
test/providers/tst_manifests/golang/go_workspace_single/mymod/go.mod
Introduce uv/pyproject workspace discovery based on pyproject.toml parsing and fast-glob
  • Add smol-toml dependency usage (parseToml) and import it in workspace.js
  • Implement discoverUvWorkspaceMembers() which requires pyproject.toml and uv.lock at the root, parses [tool.uv.workspace], aggregates member glob patterns, and glob-resolves them to pyproject.toml files using fast-glob and existing glob options
  • Support uv workspace exclude patterns by converting them into additional ignore globs, and combine them with workspaceDiscoveryIgnore/TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE
  • Implement hasProjectMetadata() to decide whether the root pyproject.toml should be included (root-package workspace) based on presence of [project] metadata; otherwise treat it as a virtual workspace and exclude root
  • Add tests for missing pyproject or uv.lock, missing workspace config, root-package vs virtual workspaces, exclude patterns, multiple member patterns, and workspaceDiscoveryIgnore filtering, with corresponding pyproject/uv.lock fixtures
src/workspace.js
src/index.js
test/providers/workspace.test.js
test/providers/tst_manifests/pyproject/uv_workspace_exclude/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/core/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/internal/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_exclude/uv.lock
test/providers/tst_manifests/pyproject/uv_workspace_nested/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_nested/apps/backend/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_nested/libs/core/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_nested/uv.lock
test/providers/tst_manifests/pyproject/uv_workspace_no_lock/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_no_config/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_no_config/uv.lock
test/providers/tst_manifests/pyproject/uv_workspace_virtual/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-a/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-b/pyproject.toml
test/providers/tst_manifests/pyproject/uv_workspace_virtual/uv.lock
Extend core workspace detection pipeline and defaults to support new ecosystems
  • Export discoverMavenModules, discoverGradleSubprojects, discoverGoWorkspaceModules, and discoverUvWorkspaceMembers from src/index.js
  • Extend WorkspaceSbomResult.ecosystem type and detectWorkspaceManifests() to recognize Maven, Gradle, Go (go.work), and uv/pyproject roots in priority order: Cargo → Maven → Gradle → Go → uv → JavaScript
  • Update the error message in stackAnalysisBatch() when no manifests are found to describe all supported workspace root types
  • Expand DEFAULT_WORKSPACE_DISCOVERY_IGNORE to also skip Rust target, Gradle build/.gradle, Python pycache, and .venv directories so they never appear in discovered manifests
src/index.js
src/workspace.js

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

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In discoverGradleSubprojects, the init script is written to a fixed path in os.tmpdir() keyed only by process.pid, which can collide if multiple calls run in the same process (or overlap across threads); consider using fs.mkdtemp or a random suffix per call and cleaning up that directory.
  • In discoverUvWorkspaceMembers, hasProjectMetadata reparses the root pyproject.toml that was already parsed earlier; you could pass the parsed object into this check (or inline the logic) to avoid a second read/parse and keep behavior centralized.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `discoverGradleSubprojects`, the init script is written to a fixed path in `os.tmpdir()` keyed only by `process.pid`, which can collide if multiple calls run in the same process (or overlap across threads); consider using `fs.mkdtemp` or a random suffix per call and cleaning up that directory.
- In `discoverUvWorkspaceMembers`, `hasProjectMetadata` reparses the root `pyproject.toml` that was already parsed earlier; you could pass the parsed object into this check (or inline the logic) to avoid a second read/parse and keep behavior centralized.

## Individual Comments

### Comment 1
<location path="src/workspace.js" line_range="442" />
<code_context>
+		manifestPaths.push(rootBuild)
+	}
+
+	const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
+	try {
+		fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Temp init script name based only on PID can cause collisions within the same process.

Because the filename only varies by PID, all Gradle detections in the same Node.js process will share this temp file. Concurrent or overlapping calls can clobber each other’s init scripts or delete the file while another call still needs it. Consider adding extra uniqueness (e.g. a counter, timestamp, `crypto.randomUUID()`) or using a temp-file helper to avoid intra-process races.

```suggestion
	const initScriptPath = path.join(
		os.tmpdir(),
		`da-list-projects-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.gradle`
	)
```
</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
manifestPaths.push(rootBuild)
}

const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Temp init script name based only on PID can cause collisions within the same process.

Because the filename only varies by PID, all Gradle detections in the same Node.js process will share this temp file. Concurrent or overlapping calls can clobber each other’s init scripts or delete the file while another call still needs it. Consider adding extra uniqueness (e.g. a counter, timestamp, crypto.randomUUID()) or using a temp-file helper to avoid intra-process races.

Suggested change
const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
const initScriptPath = path.join(
os.tmpdir(),
`da-list-projects-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.gradle`
)

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.

1 participant