Skip to content

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

Merged
Strum355 merged 3 commits into
guacsec:mainfrom
Strum355:TC-4265
May 7, 2026
Merged

feat(workspace): add uv workspace (pyproject.toml) discovery#496
Strum355 merged 3 commits into
guacsec:mainfrom
Strum355:TC-4265

Conversation

@Strum355

@Strum355 Strum355 commented Apr 28, 2026

Copy link
Copy Markdown
Member

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.

@sourcery-ai

sourcery-ai Bot commented Apr 28, 2026

Copy link
Copy Markdown

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

@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 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 Outdated
@Strum355

Strum355 commented May 5, 2026

Copy link
Copy Markdown
Member Author

Verification Report -- TC-4265

Scope Check

Item Status
PR title follows conventional commits PASS -- feat(workspace): add uv workspace (pyproject.toml) discovery
Branch matches Jira task PASS -- TC-4265
Commits are well-structured PASS -- 2 commits: feat + refactor
No sensitive patterns (secrets, tokens, keys) PASS
CI status (all checks) PASS -- 5/5 checks green (Lint+Test Node 22, Lint+Test Node 24, Validate PR title, Validate commit messages, Sourcery review)

Changed Files (19 files, +238/-6)

File Purpose
src/providers/python_uv.js New discoverUvWorkspaceMembers() + hasProjectMetadata() + toManifestGlobPatterns()
src/index.js uv ecosystem detection in detectWorkspaceManifests(), export, type updates
src/workspace.js Trailing newline only
test/providers/workspace.test.js 8 new test cases for uv workspace discovery
15 test fixture files pyproject.toml + uv.lock for test scenarios

Acceptance Criteria Verification

Criterion Status Evidence
stackAnalysisBatch("/path/to/uv-workspace") discovers all member pyproject.toml files PASS detectWorkspaceManifests() at src/index.js:336-341 detects pyproject.toml+uv.lock and calls discoverUvWorkspaceMembers()
Discovery parses [tool.uv.workspace].members globs (no uv CLI needed) PASS Uses smol-toml to parse TOML and fast-glob for glob expansion (python_uv.js:212,241-245). No CLI invocation
[tool.uv.workspace].exclude patterns are respected PASS Exclude patterns converted to ignore globs at python_uv.js:227-230, test "respects exclude patterns" passes
Virtual workspaces (no [project] in root) correctly exclude root from manifests PASS hasProjectMetadata() at python_uv.js:258-263 checks for [project].name; test "discovers members from virtual workspace (excludes root)" passes
Root-package workspaces (has [project]) include root in manifests PASS Line 247-249: if root not already in results and has project metadata, prepend it; test "discovers members from root-package workspace (includes root)" passes
**/__pycache__/** and **/.venv/** added to default ignore PASS DEFAULT_UV_DISCOVERY_IGNORE at python_uv.js:171-174
Missing uv.lock -> not detected as workspace (returns []) PASS Guard at python_uv.js:206-208; test "returns empty when pyproject.toml exists but no uv.lock" passes

Test Results

  discoverUvWorkspaceMembers
    ✔ returns empty when no pyproject.toml at root
    ✔ returns empty when pyproject.toml exists but no uv.lock
    ✔ returns empty when pyproject.toml has no [tool.uv.workspace]
    ✔ discovers members from root-package workspace (includes root)
    ✔ discovers members from virtual workspace (excludes root)
    ✔ respects exclude patterns
    ✔ discovers members from multiple glob patterns
    ✔ applies workspaceDiscoveryIgnore patterns

  8 passing (31ms)
  Full workspace suite: 20 passing (39ms) — no regressions

Test Coverage vs. Jira Test Requirements

Requirement Covered
Simple workspace: 3 members in packages/ -> discovers 3 pyproject.toml files YES -- "discovers members from root-package workspace (includes root)"
Virtual workspace: root has no [project] -> root not included YES -- "discovers members from virtual workspace (excludes root)"
Root package workspace: root has [project] -> root included YES -- "discovers members from root-package workspace (includes root)"
Exclude pattern: exclude = ["packages/internal"] -> excluded YES -- "respects exclude patterns"
Wildcard members: members = ["packages/*"] -> all subdirs matched YES -- multiple tests use packages/*
Missing uv.lock: not detected as workspace YES -- "returns empty when pyproject.toml exists but no uv.lock"

Review Comment Classification

# Author Type Status
1 sourcery-ai (review) Overall: Gradle tmpfile collision + hasProjectMetadata re-parse Out-of-scope (Gradle issue belongs to TC-4263); hasProjectMetadata re-parse was fixed in commit 5939d5b by passing parsed object directly
2 sourcery-ai (inline) Suggestion: Gradle initScriptPath PID collision Out-of-scope for this PR

Code Quality Notes

  • Refactoring: The second commit (5939d5b) properly moved discoverUvWorkspaceMembers and hasProjectMetadata from workspace.js into src/providers/python_uv.js, co-locating discovery logic with the provider. This also addressed the Sourcery feedback about hasProjectMetadata re-parsing the TOML file -- it now receives the already-parsed object.
  • Reuse: Correctly reuses resolveWorkspaceDiscoveryIgnore, filterManifestPathsByDiscoveryIgnore from workspace.js, and toManifestGlobPatterns (local copy with same pattern as the workspace.js version).
  • Detection order: Cargo -> uv -> JavaScript (correct for this PR scope; Maven/Gradle/Go are handled by other PRs).
  • No new dependencies: Uses existing smol-toml and fast-glob imports already present in the codebase.

Verdict: PASS

All acceptance criteria are met. 8/8 tests pass with no regressions. CI is green. Code is clean and well-structured.

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 a few minor comments

Comment thread src/providers/python_uv.js Outdated
Comment thread src/index.js Outdated
Comment thread src/index.js Outdated
@Strum355
Strum355 force-pushed the TC-4265 branch 2 times, most recently from 4bb3fa6 to b6434f1 Compare May 7, 2026 10:58
@Strum355
Strum355 requested a review from ruromero May 7, 2026 10:58
Strum355 and others added 3 commits May 7, 2026 12:15
Move discoverUvWorkspaceMembers and hasProjectMetadata to their provider
file so workspace.js only retains generic scaffolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Export the existing toManifestGlobPatterns function from workspace.js
instead of duplicating it in python_uv.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Strum355
Strum355 merged commit 76e1fef into guacsec:main May 7, 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