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();
+ }
+}