Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? [];
}

/// <summary>
Expand All @@ -35,4 +36,9 @@ public ExperimentComponent(ScannedComponent detectedComponent)
/// The set of root component IDs for this component.
/// </summary>
public HashSet<string> RootIds { get; }

/// <summary>
/// The set of file paths where this component was found.
/// </summary>
public HashSet<string> Locations { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public ExperimentDiff(
var developmentDependencyChanges = new List<DevelopmentDependencyChange>();
var addedRootIds = new Dictionary<string, IReadOnlySet<string>>();
var removedRootIds = new Dictionary<string, IReadOnlySet<string>>();
var locationChanges = new Dictionary<string, LocationChange>();
var controlDetectorList = new List<ExperimentDetector>();
var experimentDetectorList = new List<ExperimentDetector>();

Expand All @@ -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;
}

Expand All @@ -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.
Comment on lines +94 to +95
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Track location changes if counts differ or either set has locations" but the implementation only checks if counts differ (line 97). This creates a mismatch between the documented intent and actual behavior.

The comment should be updated to accurately reflect the implementation:

// Track location changes only if counts differ.
// Note: We don't check SetEquals() here as it's O(n) and expensive for large sets.
// This means identical locations with different counts will be tracked, but 
// different locations with the same count will NOT be tracked.
// The LocationChange uses lazy evaluation, so no diff is computed unless accessed.

Alternatively, if the behavior should match the comment, the implementation needs to be fixed (see related bug comment).

Suggested change
// 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.
// Track location changes only if counts differ.
// Note: We don't check SetEquals() here as it's O(n) and expensive for large sets.
// This means identical locations with different counts will be tracked, but
// different locations with the same count will NOT be tracked.

Copilot uses AI. Check for mistakes.
// 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);
}
Comment on lines +94 to +104
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation only checks if location counts differ (oldComponent.Locations.Count != newComponent.Locations.Count), which means it will miss cases where the locations have the same count but different actual values.

For example, if control finds ["file1.txt", "file2.txt"] and experiment finds ["file3.txt", "file4.txt"], both have count=2 but completely different locations. This would not be tracked as a location change.

The comment at line 94 mentions "Track location changes if counts differ or either set has locations", but the implementation doesn't match this intent.

Consider using SetEquals() to properly detect when locations are different, or updating the comment to accurately reflect that only count differences are tracked. If the performance concern about SetEquals() is valid, you could check counts first and then use SetEquals() as a fallback.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grvillic It will be expensive if we do a SetEquals check

}

// 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)
Expand All @@ -104,6 +141,7 @@ public ExperimentDiff(
this.DevelopmentDependencyChanges = developmentDependencyChanges.AsReadOnly();
this.AddedRootIds = addedRootIds.ToImmutableDictionary();
this.RemovedRootIds = removedRootIds.ToImmutableDictionary();
this.LocationChanges = locationChanges.ToImmutableDictionary();
}

/// <summary>
Expand Down Expand Up @@ -150,6 +188,11 @@ public ExperimentDiff()
/// </summary>
public IReadOnlyDictionary<string, IReadOnlySet<string>> RemovedRootIds { get; init; }

/// <summary>
/// Gets a dictionary of component IDs to the location changes for that component.
/// </summary>
public IReadOnlyDictionary<string, LocationChange> LocationChanges { get; init; }

/// <summary>
/// Any additional metrics that were captured for the experiment.
/// </summary>
Expand Down Expand Up @@ -189,6 +232,95 @@ public DevelopmentDependencyChange(string id, bool oldValue, bool newValue)
public bool NewValue { get; }
}

/// <summary>
/// Stores information about changes to the file path locations where a component was found.
/// </summary>
public class LocationChange
{
private IReadOnlySet<string> addedLocations;
private IReadOnlySet<string> removedLocations;

/// <summary>
/// Initializes a new instance of the <see cref="LocationChange"/> class.
/// </summary>
/// <param name="componentId">The component ID.</param>
/// <param name="controlLocations">The locations found by the control detector.</param>
/// <param name="experimentLocations">The locations found by the experimental detector.</param>
public LocationChange(
string componentId,
IEnumerable<string> controlLocations,
IEnumerable<string> 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<string> ?? controlLocations.ToHashSet();
this.ExperimentLocations = experimentLocations as IReadOnlySet<string> ?? experimentLocations.ToHashSet();

this.ControlLocationCount = this.ControlLocations.Count;
this.ExperimentLocationCount = this.ExperimentLocations.Count;
this.LocationCountDelta = this.ExperimentLocationCount - this.ControlLocationCount;
}

/// <summary>
/// Gets the component ID.
/// </summary>
public string ComponentId { get; }

/// <summary>
/// Gets the locations found by the control detector.
/// </summary>
public IReadOnlySet<string> ControlLocations { get; }

/// <summary>
/// Gets the locations found by the experimental detector.
/// </summary>
public IReadOnlySet<string> ExperimentLocations { get; }

/// <summary>
/// Gets the locations found by the experimental detector but not the control detector.
/// Computed lazily to avoid allocations if not accessed.
/// </summary>
public IReadOnlySet<string> AddedLocations
{
get
{
this.addedLocations ??= this.ExperimentLocations.Except(this.ControlLocations).ToHashSet();
return this.addedLocations;
}
}

/// <summary>
/// Gets the locations found by the control detector but not the experimental detector.
/// Computed lazily to avoid allocations if not accessed.
/// </summary>
public IReadOnlySet<string> RemovedLocations
{
get
{
this.removedLocations ??= this.ControlLocations.Except(this.ExperimentLocations).ToHashSet();
return this.removedLocations;
}
}

/// <summary>
/// Gets the number of locations found by the control detector.
/// </summary>
public int ControlLocationCount { get; }

/// <summary>
/// Gets the number of locations found by the experimental detector.
/// </summary>
public int ExperimentLocationCount { get; }

/// <summary>
/// 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.
/// </summary>
public int LocationCountDelta { get; }
}

/// <summary>
/// Stores information about a detector run.
/// </summary>
Expand Down
Loading
Loading