Skip to content

Commit d264ad2

Browse files
feat: add yanked version substitution, MODULE.tools support, and Bazel compatibility
This commit adds several enhancements to improve Bazel compatibility and module resolution accuracy: 1. Yanked Version Substitution - Added SubstituteYanked option to automatically replace yanked versions with non-yanked alternatives in the same compatibility level - Matches Bazel's default behavior for handling yanked versions - Added findNonYankedVersion() to locate suitable replacements - Fixed map iteration bug in substituteYankedVersionsInGraph() 2. MODULE.tools Dependencies (bazeltools package) - New bazeltools package provides Bazel version-specific MODULE.tools deps - Supports Bazel versions: 6.6.0, 7.0.0, 7.1.0, 7.2.0, 8.0.0, 9.0.0 - Added BazelVersion option to inject implicit tool dependencies - Improved ClosestVersion() to correctly parse version strings - Ensures resolution matches Bazel's behavior for a given version 3. Selection API Enhancements - Added SelectionResolver for full Bazel-compatible resolution - New ResolveWithSelection() API with compatibility level enforcement - Fixed buildDepGraph() concurrency issues with proper synchronization - Added SelectionResult with resolved, unpruned, and BFS order views 4. Type System Extensions - Added ResolutionOptions.SubstituteYanked for yanked substitution - Added ResolutionOptions.BazelVersion for MODULE.tools injection - Added SelectionResult type with resolved/unpruned graph views - Added NodepDeps support in selection.Module for Bazel 7.6+ 5. Registry & Metadata - Added NonYankedVersions() method to registry.Metadata - Enhanced GetModuleMetadata() with caching support 6. Tests & Documentation - Added yanked_test.go with comprehensive yanked version tests - Added selection_api_test.go for selection resolver tests - Added max_compatibility_level tests in selection package - Added nodep_deps tests for Bazel 7.6+ behavior - Updated e2e tests to use MODULE.tools dependencies Bug Fixes: - Fixed map modification during iteration in substituteYankedVersionsInGraph - Fixed buildDepGraph queue handling to prevent premature exit - Fixed ClosestVersion to properly parse major.minor versions
1 parent fbf679c commit d264ad2

File tree

15 files changed

+2278
-50
lines changed

15 files changed

+2278
-50
lines changed

api.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//
1212
// - Parser: Parses MODULE.bazel files to extract module information
1313
// - Registry: Fetches module metadata from Bazel Central Registry (BCR)
14-
// - Resolver: Resolves transitive dependencies using MVS
14+
// - Resolver: Resolves transitive dependencies using MVS or Bazel's selection algorithm
1515
//
1616
// # Quick Start
1717
//
@@ -29,16 +29,39 @@
2929
// fmt.Printf("%s@%s\n", mod.Name, mod.Version)
3030
// }
3131
//
32+
// # Selection API (Full Bazel Compatibility)
33+
//
34+
// For full Bazel compatibility including compatibility levels and proper pruning:
35+
//
36+
// opts := gobzlmod.ResolutionOptions{
37+
// IncludeDevDeps: false,
38+
// CheckYanked: true,
39+
// YankedBehavior: gobzlmod.YankedVersionWarn,
40+
// }
41+
// result, err := gobzlmod.ResolveWithSelection(ctx, moduleContent, registryURL, opts)
42+
//
43+
// # Yanked Version Handling
44+
//
45+
// The library supports detecting and handling yanked versions from the registry:
46+
//
47+
// opts := gobzlmod.ResolutionOptions{
48+
// CheckYanked: true, // Enable yanked version checking
49+
// YankedBehavior: gobzlmod.YankedVersionError, // Fail if yanked versions selected
50+
// }
51+
//
52+
// YankedBehavior options:
53+
// - YankedVersionAllow: Populate yanked info but don't warn or error
54+
// - YankedVersionWarn: Include warnings in result for yanked versions
55+
// - YankedVersionError: Return error if any yanked version is selected
56+
//
3257
// # Differences from Bazel's Algorithm
3358
//
34-
// Bazel's actual resolution algorithm includes additional features not implemented here:
59+
// The simple MVS resolver differs from Bazel's algorithm in:
3560
// - Compatibility level checking and automatic upgrades
36-
// - Yanked version handling
3761
// - Multiple version override support
3862
// - Module extension resolution
3963
//
40-
// For production use requiring full Bazel compatibility, see the selection package
41-
// which implements Bazel's complete algorithm.
64+
// Use ResolveWithSelection for compatibility level enforcement and proper pruning.
4265
//
4366
// # Thread Safety
4467
//
@@ -80,3 +103,46 @@ func ResolveDependenciesWithContext(ctx context.Context, moduleContent, registry
80103
resolver := NewDependencyResolver(registry, includeDevDeps)
81104
return resolver.ResolveDependencies(ctx, moduleInfo)
82105
}
106+
107+
// ResolveDependenciesWithOptions resolves dependencies with full configuration control.
108+
// This allows enabling yanked version checking and other advanced options.
109+
func ResolveDependenciesWithOptions(ctx context.Context, moduleContent, registryURL string, opts ResolutionOptions) (*ResolutionList, error) {
110+
moduleInfo, err := ParseModuleContent(moduleContent)
111+
if err != nil {
112+
return nil, fmt.Errorf("parse module content: %w", err)
113+
}
114+
115+
registry := NewRegistryClient(registryURL)
116+
resolver := NewDependencyResolverWithOptions(registry, opts)
117+
return resolver.ResolveDependencies(ctx, moduleInfo)
118+
}
119+
120+
// ResolveWithSelection resolves dependencies using Bazel's complete selection algorithm.
121+
// This provides full compatibility with Bazel including:
122+
// - Compatibility level enforcement
123+
// - Multiple version override support (when available in Override type)
124+
// - Proper pruning of unreachable modules
125+
//
126+
// Returns a SelectionResult with both resolved and unpruned views.
127+
func ResolveWithSelection(ctx context.Context, moduleContent, registryURL string, opts ResolutionOptions) (*SelectionResult, error) {
128+
moduleInfo, err := ParseModuleContent(moduleContent)
129+
if err != nil {
130+
return nil, fmt.Errorf("parse module content: %w", err)
131+
}
132+
133+
registry := NewRegistryClient(registryURL)
134+
resolver := NewSelectionResolver(registry, opts)
135+
return resolver.Resolve(ctx, moduleInfo)
136+
}
137+
138+
// ResolveWithSelectionFromFile loads a MODULE.bazel file and resolves using the selection algorithm.
139+
func ResolveWithSelectionFromFile(moduleFilePath, registryURL string, opts ResolutionOptions) (*SelectionResult, error) {
140+
moduleInfo, err := ParseModuleFile(moduleFilePath)
141+
if err != nil {
142+
return nil, fmt.Errorf("parse module file: %w", err)
143+
}
144+
145+
registry := NewRegistryClient(registryURL)
146+
resolver := NewSelectionResolver(registry, opts)
147+
return resolver.Resolve(context.Background(), moduleInfo)
148+
}

bazeltools/tools.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Package bazeltools provides Bazel version-specific MODULE.tools dependencies.
2+
// These are the implicit dependencies that Bazel adds to every resolution.
3+
package bazeltools
4+
5+
// ToolDep represents a dependency from Bazel's MODULE.tools file.
6+
type ToolDep struct {
7+
Name string
8+
Version string
9+
}
10+
11+
// VersionConfig contains the MODULE.tools dependencies for a specific Bazel version.
12+
type VersionConfig struct {
13+
// BazelVersion is the Bazel version (e.g., "7.0.0").
14+
BazelVersion string
15+
// Deps are the dependencies declared in MODULE.tools.
16+
Deps []ToolDep
17+
}
18+
19+
// bazelConfigs maps Bazel versions to their MODULE.tools dependencies.
20+
var bazelConfigs = map[string]VersionConfig{
21+
"6.6.0": {
22+
BazelVersion: "6.6.0",
23+
Deps: []ToolDep{
24+
{"rules_cc", "0.0.9"},
25+
{"rules_java", "5.5.1"},
26+
{"rules_license", "0.0.3"},
27+
{"rules_proto", "4.0.0"},
28+
{"rules_python", "0.4.0"},
29+
{"platforms", "0.0.7"},
30+
{"protobuf", "3.19.6"},
31+
{"zlib", "1.2.13"},
32+
},
33+
},
34+
"7.0.0": {
35+
BazelVersion: "7.0.0",
36+
Deps: []ToolDep{
37+
{"rules_cc", "0.0.9"},
38+
{"rules_java", "7.1.0"},
39+
{"rules_license", "0.0.3"},
40+
{"rules_proto", "4.0.0"},
41+
{"rules_python", "0.4.0"},
42+
{"platforms", "0.0.7"},
43+
{"protobuf", "3.19.6"},
44+
{"zlib", "1.3"},
45+
{"apple_support", "1.5.0"},
46+
},
47+
},
48+
"7.1.0": {
49+
BazelVersion: "7.1.0",
50+
Deps: []ToolDep{
51+
{"rules_cc", "0.0.9"},
52+
{"rules_java", "7.4.0"},
53+
{"rules_license", "0.0.7"},
54+
{"rules_proto", "5.3.0-21.7"},
55+
{"rules_python", "0.31.0"},
56+
{"platforms", "0.0.8"},
57+
{"protobuf", "21.7"},
58+
{"zlib", "1.3.1"},
59+
{"apple_support", "1.11.1"},
60+
},
61+
},
62+
"7.2.0": {
63+
BazelVersion: "7.2.0",
64+
Deps: []ToolDep{
65+
{"rules_cc", "0.0.9"},
66+
{"rules_java", "7.6.1"},
67+
{"rules_license", "0.0.7"},
68+
{"rules_proto", "6.0.0"},
69+
{"rules_python", "0.32.2"},
70+
{"platforms", "0.0.9"},
71+
{"protobuf", "27.0"},
72+
{"zlib", "1.3.1.bcr.1"},
73+
{"apple_support", "1.15.1"},
74+
},
75+
},
76+
"8.0.0": {
77+
BazelVersion: "8.0.0",
78+
Deps: []ToolDep{
79+
{"rules_license", "1.0.0"},
80+
{"buildozer", "7.1.2"},
81+
{"platforms", "0.0.10"},
82+
{"zlib", "1.3.1.bcr.3"},
83+
{"rules_proto", "7.0.2"},
84+
{"bazel_features", "1.21.0"},
85+
{"protobuf", "29.0"},
86+
{"rules_java", "8.6.1"},
87+
{"rules_cc", "0.0.16"},
88+
{"rules_python", "0.40.0"},
89+
{"rules_shell", "0.2.0"},
90+
},
91+
},
92+
"9.0.0": {
93+
BazelVersion: "9.0.0",
94+
Deps: []ToolDep{
95+
{"rules_license", "1.0.0"},
96+
{"buildozer", "8.2.1"},
97+
{"platforms", "1.0.0"},
98+
{"zlib", "1.3.1.bcr.5"},
99+
{"bazel_features", "1.30.0"},
100+
{"protobuf", "33.4"},
101+
{"rules_java", "9.0.3"},
102+
{"rules_cc", "0.2.14"},
103+
{"rules_python", "1.7.0"},
104+
{"rules_shell", "0.6.1"},
105+
{"apple_support", "1.24.2"},
106+
{"rules_apple", "4.1.0"},
107+
{"rules_swift", "3.1.2"},
108+
{"abseil-cpp", "20250814.1"},
109+
},
110+
},
111+
}
112+
113+
// GetConfig returns the MODULE.tools configuration for a Bazel version.
114+
// Returns nil if the version is not supported.
115+
// Use ClosestVersion to find the closest matching version.
116+
func GetConfig(version string) *VersionConfig {
117+
if cfg, ok := bazelConfigs[version]; ok {
118+
return &cfg
119+
}
120+
return nil
121+
}
122+
123+
// GetDeps returns the MODULE.tools dependencies for a Bazel version.
124+
// Returns nil if the version is not supported.
125+
func GetDeps(version string) []ToolDep {
126+
if cfg := GetConfig(version); cfg != nil {
127+
return cfg.Deps
128+
}
129+
return nil
130+
}
131+
132+
// SupportedVersions returns all supported Bazel versions.
133+
func SupportedVersions() []string {
134+
versions := make([]string, 0, len(bazelConfigs))
135+
for v := range bazelConfigs {
136+
versions = append(versions, v)
137+
}
138+
return versions
139+
}
140+
141+
const (
142+
// versionMinLenMajorMinor is the minimum length for major.minor pattern (e.g., "7.0.x").
143+
versionMinLenMajorMinor = 5
144+
)
145+
146+
// ClosestVersion finds the closest supported version for a given Bazel version.
147+
// For example, "7.0.1" would return "7.0.0", "7.1.2" would return "7.1.0".
148+
// Returns empty string if no suitable version is found.
149+
func ClosestVersion(version string) string {
150+
// Exact match
151+
if _, ok := bazelConfigs[version]; ok {
152+
return version
153+
}
154+
155+
// Try major.minor.0 pattern - find the first two dots
156+
// For "7.0.1" -> "7.0.0", for "7.1.2" -> "7.1.0"
157+
if len(version) >= versionMinLenMajorMinor {
158+
firstDot := -1
159+
secondDot := -1
160+
for i, c := range version {
161+
if c == '.' {
162+
if firstDot == -1 {
163+
firstDot = i
164+
} else {
165+
secondDot = i
166+
break
167+
}
168+
}
169+
}
170+
if firstDot > 0 && secondDot > firstDot {
171+
majorMinor := version[:secondDot] + ".0" // e.g., "7.0.1" -> "7.0.0"
172+
if _, ok := bazelConfigs[majorMinor]; ok {
173+
return majorMinor
174+
}
175+
}
176+
}
177+
178+
// Try major.0.0 pattern
179+
if len(version) >= 1 {
180+
// Find first dot to get major version
181+
firstDot := -1
182+
for i, c := range version {
183+
if c == '.' {
184+
firstDot = i
185+
break
186+
}
187+
}
188+
if firstDot > 0 {
189+
major := version[:firstDot] + ".0.0" // e.g., "7.x.x" -> "7.0.0"
190+
if _, ok := bazelConfigs[major]; ok {
191+
return major
192+
}
193+
}
194+
}
195+
196+
return ""
197+
}

e2e/e2e_test.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,18 @@ type BazelModGraph = graph.BazelModGraph
2626

2727
// resolveDependencies uses our library API to resolve dependencies
2828
func resolveDependencies(content, registry string, includeDevDeps bool) (*ResolutionList, error) {
29-
// Use the library API directly instead of building a binary
30-
resolutionList, err := gobzlmod.ResolveDependenciesFromContent(content, registry, includeDevDeps)
29+
return resolveDependenciesWithBazelVersion(content, registry, includeDevDeps, "7.0.0")
30+
}
31+
32+
// resolveDependenciesWithBazelVersion resolves with a specific Bazel version for MODULE.tools compat
33+
func resolveDependenciesWithBazelVersion(content, registry string, includeDevDeps bool, bazelVersion string) (*ResolutionList, error) {
34+
opts := gobzlmod.ResolutionOptions{
35+
IncludeDevDeps: includeDevDeps,
36+
SubstituteYanked: true,
37+
BazelVersion: bazelVersion,
38+
}
39+
ctx := context.Background()
40+
resolutionList, err := gobzlmod.ResolveDependenciesWithOptions(ctx, content, registry, opts)
3141
if err != nil {
3242
return nil, fmt.Errorf("failed to resolve dependencies: %v", err)
3343
}
@@ -128,18 +138,8 @@ func runBazelModGraph(t *testing.T, workspaceDir string) (*BazelModGraph, error)
128138
func flattenBazelGraph(graph *BazelModGraph) []BazelModuleInfo {
129139
modules := make(map[string]BazelModuleInfo)
130140

131-
// Add root module if it has name and version
132-
if graph.Name != "" && graph.Version != "" {
133-
key := graph.Key
134-
if key == "" {
135-
key = graph.Name + "@" + graph.Version
136-
}
137-
modules[key] = BazelModuleInfo{
138-
Key: key,
139-
Name: graph.Name,
140-
Version: graph.Version,
141-
}
142-
}
141+
// Skip root module - we only compare dependencies
142+
// The root module has Key="<root>" and is not a dependency
143143

144144
// Recursively process dependencies
145145
var processDeps func(deps []BazelDependency)
@@ -195,7 +195,7 @@ func compareModuleLists(t *testing.T, ourModules []ModuleToResolve, bazelModules
195195
bazelModuleMap := make(map[string]BazelModuleInfo)
196196
for _, module := range bazelModules {
197197
key := normalizeModuleName(module.Name)
198-
// Skip root module and empty names
198+
// Skip empty names and internal references
199199
if key == "" || strings.Contains(key, "<root>") || strings.Contains(key, "@@") {
200200
continue
201201
}

parser.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ func extractModuleInfo(f *build.File) *ModuleInfo {
5555

5656
case "bazel_dep":
5757
dep := Dependency{
58-
Name: buildutil.String(call, "name"),
59-
Version: buildutil.String(call, "version"),
60-
RepoName: buildutil.String(call, "repo_name"),
61-
DevDependency: buildutil.Bool(call, "dev_dependency"),
58+
Name: buildutil.String(call, "name"),
59+
Version: buildutil.String(call, "version"),
60+
MaxCompatibilityLevel: buildutil.Int(call, "max_compatibility_level"),
61+
RepoName: buildutil.String(call, "repo_name"),
62+
DevDependency: buildutil.Bool(call, "dev_dependency"),
6263
}
6364
if dep.Name != "" && dep.Version != "" {
6465
info.Dependencies = append(info.Dependencies, dep)

0 commit comments

Comments
 (0)