diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentComponent.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentComponent.cs index d9124a7b2..77676e2fb 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentComponent.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentComponent.cs @@ -19,6 +19,7 @@ public ExperimentComponent(ScannedComponent detectedComponent) this.Id = detectedComponent.Component.Id; this.DevelopmentDependency = detectedComponent.IsDevelopmentDependency ?? false; this.RootIds = detectedComponent.TopLevelReferrers?.Select(x => x.Id).ToHashSet() ?? []; + this.Locations = detectedComponent.LocationsFoundAt?.ToHashSet() ?? []; } /// @@ -35,4 +36,9 @@ public ExperimentComponent(ScannedComponent detectedComponent) /// The set of root component IDs for this component. /// public HashSet RootIds { get; } + + /// + /// The set of file paths where this component was found. + /// + public HashSet Locations { get; } } diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs index ad9ddf229..ed82015e3 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs @@ -37,6 +37,7 @@ public ExperimentDiff( var developmentDependencyChanges = new List(); var addedRootIds = new Dictionary>(); var removedRootIds = new Dictionary>(); + var locationChanges = new Dictionary(); var controlDetectorList = new List(); var experimentDetectorList = new List(); @@ -56,6 +57,16 @@ public ExperimentDiff( newValue: newComponent.DevelopmentDependency)); } + // Track locations for newly added components + if (newComponent.Locations.Count > 0) + { + // Pass HashSet directly - no conversion needed + locationChanges[id] = new LocationChange( + id, + controlLocations: [], + experimentLocations: newComponent.Locations); + } + continue; } @@ -79,6 +90,32 @@ public ExperimentDiff( { removedRootIds[id] = removedRoots; } + + // Track location changes if counts differ or either set has locations + // Note: We don't check SetEquals() here as it's O(n) and expensive for large sets. + // The LocationChange uses lazy evaluation, so no diff is computed unless accessed. + if (oldComponent.Locations.Count != newComponent.Locations.Count) + { + // Pass HashSets directly - LocationChange will compute diffs lazily if needed + locationChanges[id] = new LocationChange( + id, + controlLocations: oldComponent.Locations, + experimentLocations: newComponent.Locations); + } + } + + // Track locations for removed components + foreach (var oldComponentPair in oldComponentDictionary) + { + var id = oldComponentPair.Key; + if (!newComponentDictionary.ContainsKey(id) && oldComponentPair.Value.Locations.Count > 0) + { + // Pass HashSet directly - no conversion needed + locationChanges[id] = new LocationChange( + id, + controlLocations: oldComponentPair.Value.Locations, + experimentLocations: []); + } } if (controlDetectors != null) @@ -104,6 +141,7 @@ public ExperimentDiff( this.DevelopmentDependencyChanges = developmentDependencyChanges.AsReadOnly(); this.AddedRootIds = addedRootIds.ToImmutableDictionary(); this.RemovedRootIds = removedRootIds.ToImmutableDictionary(); + this.LocationChanges = locationChanges.ToImmutableDictionary(); } /// @@ -150,6 +188,11 @@ public ExperimentDiff() /// public IReadOnlyDictionary> RemovedRootIds { get; init; } + /// + /// Gets a dictionary of component IDs to the location changes for that component. + /// + public IReadOnlyDictionary LocationChanges { get; init; } + /// /// Any additional metrics that were captured for the experiment. /// @@ -189,6 +232,95 @@ public DevelopmentDependencyChange(string id, bool oldValue, bool newValue) public bool NewValue { get; } } + /// + /// Stores information about changes to the file path locations where a component was found. + /// + public class LocationChange + { + private IReadOnlySet addedLocations; + private IReadOnlySet removedLocations; + + /// + /// Initializes a new instance of the class. + /// + /// The component ID. + /// The locations found by the control detector. + /// The locations found by the experimental detector. + public LocationChange( + string componentId, + IEnumerable controlLocations, + IEnumerable experimentLocations) + { + this.ComponentId = componentId; + + // Store as HashSet if not already, or keep the reference if it's already a HashSet + this.ControlLocations = controlLocations as IReadOnlySet ?? controlLocations.ToHashSet(); + this.ExperimentLocations = experimentLocations as IReadOnlySet ?? experimentLocations.ToHashSet(); + + this.ControlLocationCount = this.ControlLocations.Count; + this.ExperimentLocationCount = this.ExperimentLocations.Count; + this.LocationCountDelta = this.ExperimentLocationCount - this.ControlLocationCount; + } + + /// + /// Gets the component ID. + /// + public string ComponentId { get; } + + /// + /// Gets the locations found by the control detector. + /// + public IReadOnlySet ControlLocations { get; } + + /// + /// Gets the locations found by the experimental detector. + /// + public IReadOnlySet ExperimentLocations { get; } + + /// + /// Gets the locations found by the experimental detector but not the control detector. + /// Computed lazily to avoid allocations if not accessed. + /// + public IReadOnlySet AddedLocations + { + get + { + this.addedLocations ??= this.ExperimentLocations.Except(this.ControlLocations).ToHashSet(); + return this.addedLocations; + } + } + + /// + /// Gets the locations found by the control detector but not the experimental detector. + /// Computed lazily to avoid allocations if not accessed. + /// + public IReadOnlySet RemovedLocations + { + get + { + this.removedLocations ??= this.ControlLocations.Except(this.ExperimentLocations).ToHashSet(); + return this.removedLocations; + } + } + + /// + /// Gets the number of locations found by the control detector. + /// + public int ControlLocationCount { get; } + + /// + /// Gets the number of locations found by the experimental detector. + /// + public int ExperimentLocationCount { get; } + + /// + /// Gets the difference in location count (experiment - control). + /// A positive value means the experiment found more locations. + /// A negative value means the experiment found fewer locations. + /// + public int LocationCountDelta { get; } + } + /// /// Stores information about a detector run. /// diff --git a/test/Microsoft.ComponentDetection.Orchestrator.Tests/Experiments/Models/ExperimentDiffLocationTests.cs b/test/Microsoft.ComponentDetection.Orchestrator.Tests/Experiments/Models/ExperimentDiffLocationTests.cs new file mode 100644 index 000000000..10324fc49 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Orchestrator.Tests/Experiments/Models/ExperimentDiffLocationTests.cs @@ -0,0 +1,221 @@ +namespace Microsoft.ComponentDetection.Orchestrator.Tests.Experiments.Models; + +using AwesomeAssertions; +using Microsoft.ComponentDetection.Contracts.BcdeModels; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Orchestrator.Experiments.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class ExperimentDiffLocationTests +{ + [TestMethod] + public void ExperimentDiff_TracksLocationChanges_WhenLocationsDiffer() + { + // Arrange - Control detector finds component at 3 locations + var controlComponent = new ScannedComponent + { + Component = new NuGetComponent("Newtonsoft.Json", "13.0.1"), + LocationsFoundAt = + [ + "src/Project1/packages.config", + "src/Project2/packages.config", + "src/Project3/packages.config", + ], + }; + + // Arrange - Experiment detector finds same component at only 2 locations (from central management) + var experimentComponent = new ScannedComponent + { + Component = new NuGetComponent("Newtonsoft.Json", "13.0.1"), + LocationsFoundAt = + [ + "Directory.Packages.props", + "src/packages.props", + ], + }; + + var controlComponents = new[] { new ExperimentComponent(controlComponent) }; + var experimentComponents = new[] { new ExperimentComponent(experimentComponent) }; + + // Act + var diff = new ExperimentDiff(controlComponents, experimentComponents); + + // Assert + diff.LocationChanges.Should().ContainKey("Newtonsoft.Json 13.0.1 - NuGet"); + var locationChange = diff.LocationChanges["Newtonsoft.Json 13.0.1 - NuGet"]; + + locationChange.ControlLocationCount.Should().Be(3); + locationChange.ExperimentLocationCount.Should().Be(2); + locationChange.LocationCountDelta.Should().Be(-1); // Experiment found 1 fewer location + + locationChange.ControlLocations.Should().Contain("src/Project1/packages.config"); + locationChange.ControlLocations.Should().Contain("src/Project2/packages.config"); + locationChange.ControlLocations.Should().Contain("src/Project3/packages.config"); + + locationChange.ExperimentLocations.Should().Contain("Directory.Packages.props"); + locationChange.ExperimentLocations.Should().Contain("src/packages.props"); + + locationChange.AddedLocations.Should().Contain("Directory.Packages.props"); + locationChange.AddedLocations.Should().Contain("src/packages.props"); + + locationChange.RemovedLocations.Should().Contain("src/Project1/packages.config"); + locationChange.RemovedLocations.Should().Contain("src/Project2/packages.config"); + locationChange.RemovedLocations.Should().Contain("src/Project3/packages.config"); + } + + [TestMethod] + public void ExperimentDiff_TracksLocationChanges_WhenExperimentFindsMoreLocations() + { + // Arrange - Control detector finds component at 2 locations + var controlComponent = new ScannedComponent + { + Component = new NuGetComponent("Microsoft.Extensions.Logging", "7.0.0"), + LocationsFoundAt = + [ + "src/Project1/Project1.csproj", + "src/Project2/Project2.csproj", + ], + }; + + // Arrange - Experiment detector finds same component at 4 locations + var experimentComponent = new ScannedComponent + { + Component = new NuGetComponent("Microsoft.Extensions.Logging", "7.0.0"), + LocationsFoundAt = + [ + "src/Project1/Project1.csproj", + "src/Project2/Project2.csproj", + "src/Project3/Project3.csproj", + "src/Project4/Project4.csproj", + ], + }; + + var controlComponents = new[] { new ExperimentComponent(controlComponent) }; + var experimentComponents = new[] { new ExperimentComponent(experimentComponent) }; + + // Act + var diff = new ExperimentDiff(controlComponents, experimentComponents); + + // Assert + diff.LocationChanges.Should().ContainKey("Microsoft.Extensions.Logging 7.0.0 - NuGet"); + var locationChange = diff.LocationChanges["Microsoft.Extensions.Logging 7.0.0 - NuGet"]; + + locationChange.ControlLocationCount.Should().Be(2); + locationChange.ExperimentLocationCount.Should().Be(4); + locationChange.LocationCountDelta.Should().Be(2); // Experiment found 2 more locations + + locationChange.AddedLocations.Should().Contain("src/Project3/Project3.csproj"); + locationChange.AddedLocations.Should().Contain("src/Project4/Project4.csproj"); + locationChange.AddedLocations.Should().HaveCount(2); + + locationChange.RemovedLocations.Should().BeEmpty(); + } + + [TestMethod] + public void ExperimentDiff_TracksLocationChanges_ForNewlyDetectedComponents() + { + // Arrange - Control detector doesn't find the component + var controlComponents = System.Array.Empty(); + + // Arrange - Experiment detector finds the component at 2 locations + var experimentComponent = new ScannedComponent + { + Component = new NuGetComponent("NewPackage", "1.0.0"), + LocationsFoundAt = + [ + "Directory.Packages.props", + "src/packages.props", + ], + }; + + var experimentComponents = new[] { new ExperimentComponent(experimentComponent) }; + + // Act + var diff = new ExperimentDiff(controlComponents, experimentComponents); + + // Assert + diff.LocationChanges.Should().ContainKey("NewPackage 1.0.0 - NuGet"); + var locationChange = diff.LocationChanges["NewPackage 1.0.0 - NuGet"]; + + locationChange.ControlLocationCount.Should().Be(0); + locationChange.ExperimentLocationCount.Should().Be(2); + locationChange.LocationCountDelta.Should().Be(2); + + locationChange.ControlLocations.Should().BeEmpty(); + locationChange.ExperimentLocations.Should().HaveCount(2); + locationChange.AddedLocations.Should().HaveCount(2); + locationChange.RemovedLocations.Should().BeEmpty(); + } + + [TestMethod] + public void ExperimentDiff_TracksLocationChanges_ForRemovedComponents() + { + // Arrange - Control detector finds the component at 3 locations + var controlComponent = new ScannedComponent + { + Component = new NuGetComponent("OldPackage", "1.0.0"), + LocationsFoundAt = + [ + "src/Project1/packages.config", + "src/Project2/packages.config", + "src/Project3/packages.config", + ], + }; + + var controlComponents = new[] { new ExperimentComponent(controlComponent) }; + + // Arrange - Experiment detector doesn't find the component + var experimentComponents = System.Array.Empty(); + + // Act + var diff = new ExperimentDiff(controlComponents, experimentComponents); + + // Assert + diff.LocationChanges.Should().ContainKey("OldPackage 1.0.0 - NuGet"); + var locationChange = diff.LocationChanges["OldPackage 1.0.0 - NuGet"]; + + locationChange.ControlLocationCount.Should().Be(3); + locationChange.ExperimentLocationCount.Should().Be(0); + locationChange.LocationCountDelta.Should().Be(-3); + + locationChange.ControlLocations.Should().HaveCount(3); + locationChange.ExperimentLocations.Should().BeEmpty(); + locationChange.AddedLocations.Should().BeEmpty(); + locationChange.RemovedLocations.Should().HaveCount(3); + } + + [TestMethod] + public void ExperimentDiff_DoesNotTrackLocationChanges_WhenLocationsAreIdentical() + { + // Arrange - Both detectors find component at same locations + var controlComponent = new ScannedComponent + { + Component = new NuGetComponent("SamePackage", "1.0.0"), + LocationsFoundAt = + [ + "src/Project1/packages.config", + "src/Project2/packages.config", + ], + }; + + var experimentComponent = new ScannedComponent + { + Component = new NuGetComponent("SamePackage", "1.0.0"), + LocationsFoundAt = + [ + "src/Project1/packages.config", + "src/Project2/packages.config", + ], + }; + + var controlComponents = new[] { new ExperimentComponent(controlComponent) }; + var experimentComponents = new[] { new ExperimentComponent(experimentComponent) }; + + // Act + var diff = new ExperimentDiff(controlComponents, experimentComponents); + + // Assert - No location changes should be tracked when locations are identical + diff.LocationChanges.Should().BeEmpty(); + } +}