Skip to content

Commit 020d6c5

Browse files
authored
Fix the request by overriding of direct dependencies (#349)
1 parent ebe86db commit 020d6c5

File tree

4 files changed

+379
-21
lines changed

4 files changed

+379
-21
lines changed

build/utils/dotnet/dependencies/assetsjson.go

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7-
buildinfo "github.com/jfrog/build-info-go/entities"
8-
"github.com/jfrog/build-info-go/utils"
9-
"github.com/jfrog/gofrog/crypto"
107
"os"
118
"path/filepath"
9+
"sort"
1210
"strings"
11+
12+
buildinfo "github.com/jfrog/build-info-go/entities"
13+
"github.com/jfrog/build-info-go/utils"
14+
"github.com/jfrog/gofrog/crypto"
1315
)
1416

1517
const (
@@ -65,28 +67,46 @@ func (extractor *assetsExtractor) new(dependenciesSource string, log utils.Log)
6567
}
6668

6769
func (assets *assets) getChildrenMap() map[string][]string {
68-
dependenciesRelations := map[string][]string{}
70+
// Use a set to deduplicate children across multiple target frameworks
71+
dependenciesRelations := map[string]map[string]struct{}{}
6972
for _, dependencies := range assets.Targets {
7073
for dependencyId, targetDependencies := range dependencies {
71-
var transitive []string
74+
dependencyName := getDependencyName(dependencyId)
75+
if _, ok := dependenciesRelations[dependencyName]; !ok {
76+
dependenciesRelations[dependencyName] = map[string]struct{}{}
77+
}
7278
for transitiveName := range targetDependencies.Dependencies {
73-
transitive = append(transitive, strings.ToLower(transitiveName))
79+
dependenciesRelations[dependencyName][strings.ToLower(transitiveName)] = struct{}{}
7480
}
75-
dependencyName := getDependencyName(dependencyId)
76-
dependenciesRelations[dependencyName] = transitive
7781
}
7882
}
79-
return dependenciesRelations
83+
// Convert sets to sorted slices for deterministic output
84+
result := make(map[string][]string, len(dependenciesRelations))
85+
for dependencyName, transitiveSet := range dependenciesRelations {
86+
result[dependencyName] = setToSortedSlice(transitiveSet)
87+
}
88+
return result
89+
}
90+
91+
func setToSortedSlice(values map[string]struct{}) []string {
92+
sortedValues := make([]string, 0, len(values))
93+
for value := range values {
94+
sortedValues = append(sortedValues, value)
95+
}
96+
sort.Strings(sortedValues)
97+
return sortedValues
8098
}
8199

82100
func (assets *assets) getDirectDependencies() []string {
83-
var directDependencies []string
101+
// Use a set to deduplicate across multiple target frameworks
102+
directDependencies := map[string]struct{}{}
84103
for _, framework := range assets.Project.Frameworks {
85104
for dependencyName := range framework.Dependencies {
86-
directDependencies = append(directDependencies, strings.ToLower(dependencyName))
105+
directDependencies[strings.ToLower(dependencyName)] = struct{}{}
87106
}
88107
}
89-
return directDependencies
108+
// Return sorted slice for deterministic output
109+
return setToSortedSlice(directDependencies)
90110
}
91111

92112
func (assets *assets) getAllDependencies(log utils.Log) (map[string]*buildinfo.Dependency, error) {

build/utils/dotnet/dependencies/assetsjson_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,108 @@ func TestGetDependencyIdForBuildInfo(t *testing.T) {
128128
assert.Equal(t, expected[index], actualId)
129129
}
130130
}
131+
132+
func TestGetDirectDependenciesDeterministic(t *testing.T) {
133+
// Test that direct dependencies are returned in sorted order
134+
content := []byte(`{
135+
"version": 3,
136+
"targets": {},
137+
"project": {
138+
"restore": {"packagesPath": "unused"},
139+
"frameworks": {
140+
"net8.0": {
141+
"dependencies": {
142+
"Zebra": {"target": "Package", "version": "1.0.0"},
143+
"Alpha": {"target": "Package", "version": "1.0.0"},
144+
"Middle": {"target": "Package", "version": "1.0.0"}
145+
}
146+
}
147+
}
148+
}
149+
}`)
150+
151+
var assetsObj assets
152+
assert.NoError(t, json.Unmarshal(content, &assetsObj))
153+
154+
// Run multiple times to verify consistency
155+
expected := []string{"alpha", "middle", "zebra"}
156+
for i := 0; i < 10; i++ {
157+
result := assetsObj.getDirectDependencies()
158+
assert.Equal(t, expected, result, "Run %d produced different order", i)
159+
}
160+
}
161+
162+
func TestGetChildrenMapDeterministic(t *testing.T) {
163+
// Test that children map returns sorted children across multiple target frameworks
164+
content := []byte(`{
165+
"version": 3,
166+
"targets": {
167+
".NETCoreApp,Version=v8.0": {
168+
"Parent/1.0.0": {
169+
"dependencies": {
170+
"Zebra": "1.0.0",
171+
"Alpha": "1.0.0"
172+
}
173+
}
174+
},
175+
".NETCoreApp,Version=v7.0": {
176+
"Parent/1.0.0": {
177+
"dependencies": {
178+
"Middle": "1.0.0",
179+
"Alpha": "1.0.0"
180+
}
181+
}
182+
}
183+
},
184+
"project": {
185+
"restore": {"packagesPath": "unused"},
186+
"frameworks": {}
187+
}
188+
}`)
189+
190+
var assetsObj assets
191+
assert.NoError(t, json.Unmarshal(content, &assetsObj))
192+
193+
// Run multiple times to verify consistency
194+
// Alpha appears in both frameworks (should be deduplicated)
195+
expected := []string{"alpha", "middle", "zebra"}
196+
for i := 0; i < 10; i++ {
197+
result := assetsObj.getChildrenMap()
198+
assert.Equal(t, expected, result["parent"], "Run %d produced different order", i)
199+
}
200+
}
201+
202+
func TestSetToSortedSlice(t *testing.T) {
203+
tests := []struct {
204+
name string
205+
input map[string]struct{}
206+
expected []string
207+
}{
208+
{
209+
name: "empty map",
210+
input: map[string]struct{}{},
211+
expected: []string{},
212+
},
213+
{
214+
name: "single element",
215+
input: map[string]struct{}{"alpha": {}},
216+
expected: []string{"alpha"},
217+
},
218+
{
219+
name: "multiple elements sorted",
220+
input: map[string]struct{}{
221+
"zebra": {},
222+
"alpha": {},
223+
"middle": {},
224+
},
225+
expected: []string{"alpha", "middle", "zebra"},
226+
},
227+
}
228+
229+
for _, tt := range tests {
230+
t.Run(tt.name, func(t *testing.T) {
231+
result := setToSortedSlice(tt.input)
232+
assert.Equal(t, tt.expected, result)
233+
})
234+
}
235+
}

build/utils/dotnet/solution/solution.go

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"regexp"
10+
"sort"
1011
"strings"
1112

1213
"github.com/jfrog/build-info-go/build/utils/dotnet/dependencies"
@@ -72,20 +73,35 @@ func (solution *solution) BuildInfo(moduleName string, log utils.Log) (*buildinf
7273
module := buildinfo.Module{Id: getModuleId(moduleName, currProject.Name()), Type: buildinfo.Nuget}
7374

7475
// Populate requestedBy field
76+
// Seed all direct dependencies with the module path
77+
for _, directDepName := range directDeps {
78+
if directDep, exist := projectDependencies[directDepName]; exist {
79+
// Add the direct path (don't overwrite - merge with existing)
80+
directDep.RequestedBy = append(directDep.RequestedBy, []string{module.Id})
81+
}
82+
}
83+
// Propagate paths to transitive dependencies
7584
for _, directDepName := range directDeps {
76-
// Populate the direct dependency requested by only if the dependency exist in the cache
7785
if directDep, exist := projectDependencies[directDepName]; exist {
78-
directDep.RequestedBy = [][]string{{module.Id}}
7986
populateRequestedBy(*directDep, projectDependencies, childrenMap)
8087
}
8188
}
8289

8390
// Populate module dependencies
84-
for _, dep := range projectDependencies {
91+
// Sort dependency keys for deterministic output
92+
depKeys := make([]string, 0, len(projectDependencies))
93+
for key := range projectDependencies {
94+
depKeys = append(depKeys, key)
95+
}
96+
sort.Strings(depKeys)
97+
98+
for _, key := range depKeys {
99+
dep := projectDependencies[key]
85100
// If dependency has no RequestedBy field, it means that the dependency not accessible in the current project.
86101
// In that case, the dependency is assumed to be under a project which is referenced by this project.
87102
// We therefore don't include the dependency in the build-info.
88103
if len(dep.RequestedBy) > 0 {
104+
sortRequestedByPaths(dep.RequestedBy)
89105
module.Dependencies = append(module.Dependencies, *dep)
90106
}
91107
}
@@ -128,6 +144,40 @@ func getDependencyName(dependencyKey string) string {
128144
return strings.ToLower(dependencyName)
129145
}
130146

147+
// sortRequestedByPaths sorts RequestedBy paths for deterministic output.
148+
// Shorter paths come first (direct deps before transitive), then lexicographic order.
149+
func sortRequestedByPaths(paths [][]string) {
150+
sort.Slice(paths, func(i, j int) bool {
151+
// Shorter paths come first
152+
if len(paths[i]) != len(paths[j]) {
153+
return len(paths[i]) < len(paths[j])
154+
}
155+
// Same length: compare lexicographically
156+
for k := 0; k < len(paths[i]); k++ {
157+
if paths[i][k] != paths[j][k] {
158+
return paths[i][k] < paths[j][k]
159+
}
160+
}
161+
return false
162+
})
163+
}
164+
165+
// isMatchingDependencySource checks if a dependency source file belongs to a project.
166+
// It matches if the source is:
167+
// - directly in the project root directory
168+
// - under the project's obj directory (for project.assets.json)
169+
// - in a subdirectory named after the project
170+
func isMatchingDependencySource(source, projectRootPath, projectObjPattern, projectNamePattern string) bool {
171+
sourceLower := strings.ToLower(source)
172+
// Check if source is directly in project root
173+
isInRoot := projectRootPath == strings.ToLower(filepath.Dir(source))
174+
// Check if source is under the project's obj directory
175+
isUnderObjDir := strings.Contains(sourceLower, projectObjPattern)
176+
// Check if source path contains the project name directory (handles subdirs like /projectname/obj/)
177+
isUnderSubDirWithName := strings.Contains(sourceLower, projectNamePattern)
178+
return isInRoot || isUnderObjDir || isUnderSubDirWithName
179+
}
180+
131181
func (solution *solution) Marshal() ([]byte, error) {
132182
return json.Marshal(&struct {
133183
Projects []project.Project `json:"projects,omitempty"`
@@ -233,14 +283,12 @@ func (solution *solution) loadSingleProject(project project.Project, log utils.L
233283
// It can be located directly in the project's root directory or in a directory with the project name under the solution root
234284
// or under obj directory (in case of assets.json file)
235285
projectRootPath := strings.ToLower(project.RootPath())
236-
projectPathPattern := strings.ToLower(filepath.Join(projectRootPath, dependencies.AssetDirName) + string(filepath.Separator))
237-
projectNamePattern := strings.ToLower(string(filepath.Separator) + project.Name())
286+
projectObjPattern := strings.ToLower(filepath.Join(projectRootPath, dependencies.AssetDirName) + string(filepath.Separator))
287+
// Pattern includes trailing separator to avoid partial matches (e.g., "project" matching "projectname")
288+
projectNamePattern := strings.ToLower(string(filepath.Separator) + project.Name() + string(filepath.Separator))
238289
var dependenciesSource string
239290
for _, source := range solution.dependenciesSources {
240-
isInRoot := projectRootPath == strings.ToLower(filepath.Dir(source))
241-
isUnderObjDir := strings.Contains(strings.ToLower(source), projectPathPattern)
242-
isUnderSubDirWithName := strings.HasSuffix(strings.ToLower(filepath.Dir(source)), projectNamePattern)
243-
if isInRoot || isUnderObjDir || isUnderSubDirWithName {
291+
if isMatchingDependencySource(source, projectRootPath, projectObjPattern, projectNamePattern) {
244292
dependenciesSource = source
245293
break
246294
}

0 commit comments

Comments
 (0)