From 3556df74b1ebde9114b1d2a07f47b5cb32417787 Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:17:43 +0200 Subject: [PATCH 1/4] Fix cache hit in CodeAnalysisRuleSetPredictor Fixes #136 --- .../CodeAnalysisRuleSetPredictor.cs | 68 +++++++++++++++++-- .../CodeAnalysisRuleSetPredictorTests.cs | 29 +++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs index e2b32db..923de1f 100644 --- a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs +++ b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Xml; using System.Xml.Linq; @@ -29,7 +30,7 @@ public sealed class CodeAnalysisRuleSetPredictor : IProjectPredictor private readonly List _emptyList = new List(0); // Often rulesets are reused across projects, so keep a cache to avoid parsing the same ruleset over and over. - private readonly ConcurrentDictionary> _cachedInputs = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _cachedInputs = new ConcurrentDictionary>(); /// public void PredictInputsAndOutputs( @@ -60,7 +61,7 @@ public void PredictInputsAndOutputs( } } - // Based on resolution logic from: https://github.com/Microsoft/msbuild/blob/master/src/Tasks/ResolveCodeAnalysisRuleSet.cs + // Based on resolution logic from: https://github.com/Microsoft/msbuild/blob/main/src/Tasks/ResolveCodeAnalysisRuleSet.cs private static string GetResolvedRuleSetPath( string ruleSet, List ruleSetDirectories, @@ -148,7 +149,8 @@ private HashSet ParseRuleset( return _emptySet; } - if (_cachedInputs.TryGetValue(ruleSetPath, out HashSet cachedResults)) + RuleSetCacheKey cacheKey = new RuleSetCacheKey(ruleSetPath, ruleSetDirectories); + if (_cachedInputs.TryGetValue(cacheKey, out HashSet cachedResults)) { return cachedResults; } @@ -241,7 +243,7 @@ private HashSet ParseRuleset( // As described above, do not cache intermediate results inside a cycle. if (!isInCycle) { - _cachedInputs.TryAdd(ruleSetPath, results); + _cachedInputs.TryAdd(cacheKey, results); } return results; @@ -252,5 +254,63 @@ private HashSet ParseRuleset( return _emptySet; } } + + private sealed record RuleSetCacheKey + { + public RuleSetCacheKey(string ruleSetPath, IReadOnlyList ruleSetDirectories) + { + RuleSetPath = ruleSetPath; + RuleSetDirectories = ruleSetDirectories.Count == 0 + ? [] + : ruleSetDirectories.ToImmutableArray(); + } + + public string RuleSetPath { get; } + + public IReadOnlyList RuleSetDirectories { get; } + + public bool Equals(RuleSetCacheKey other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other is null || !PathComparer.Instance.Equals(RuleSetPath, other.RuleSetPath)) + { + return false; + } + + if (RuleSetDirectories.Count != other.RuleSetDirectories.Count) + { + return false; + } + + for (int i = 0; i < RuleSetDirectories.Count; i++) + { + if (!PathComparer.Instance.Equals(RuleSetDirectories[i], other.RuleSetDirectories[i])) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + int hash = PathComparer.Instance.GetHashCode(RuleSetPath); + + foreach (string directory in RuleSetDirectories) + { + unchecked + { + hash = (hash * 397) ^ PathComparer.Instance.GetHashCode(directory); + } + } + + return hash; + } + } } } \ No newline at end of file diff --git a/src/BuildPredictionTests/Predictors/CodeAnalysisRuleSetPredictorTests.cs b/src/BuildPredictionTests/Predictors/CodeAnalysisRuleSetPredictorTests.cs index 7c7a585..0c1643b 100644 --- a/src/BuildPredictionTests/Predictors/CodeAnalysisRuleSetPredictorTests.cs +++ b/src/BuildPredictionTests/Predictors/CodeAnalysisRuleSetPredictorTests.cs @@ -84,6 +84,28 @@ public void RuleSetIsCached() AssertInputs("a.ruleset", expectedInputs); } + [Fact] + public void RuleSetIsCacheMissOnDifferentCodeAnalysisDirectories() + { + var rulesetA = CreateRuleSetFile("a.ruleset") + .WithInclude("b.ruleset") + .WriteToDisk(); + var rulesetB = CreateRuleSetFile("foo\\b.ruleset") + .WriteToDisk(); + + var expectedInputs = new[] + { + rulesetA.FullPath, + }; + AssertInputs("a.ruleset", expectedInputs); + + expectedInputs = [ + rulesetA.FullPath, + rulesetB.FullPath, + ]; + AssertInputs("a.ruleset", expectedInputs, [Path.GetDirectoryName(rulesetB.FullPath)]); + } + [Fact] public void RuleSetWithRuleHintPaths() { @@ -296,7 +318,7 @@ public void RuleSetWithComplexCycle() AssertInputs("a2.ruleset", expectedResultsInCycleBCD.Union(new[] { rulesetA2.FullPath }).ToList()); } - private void AssertInputs(string ruleSetPath, IList expectedInputs) + private void AssertInputs(string ruleSetPath, IList expectedInputs, IList codeAnalysisRuleSetDirectories = null) { ProjectRootElement projectRootElement = ProjectRootElement.Create(Path.Combine(_rootDir, "project.proj")); if (ruleSetPath != null) @@ -305,6 +327,11 @@ private void AssertInputs(string ruleSetPath, IList expectedInputs) projectRootElement.AddProperty(CodeAnalysisRuleSetPredictor.CodeAnalysisRuleSetPropertyName, ruleSetPath); } + if (codeAnalysisRuleSetDirectories != null) + { + projectRootElement.AddProperty(CodeAnalysisRuleSetPredictor.CodeAnalysisRuleSetDirectoriesPropertyName, string.Join(";", codeAnalysisRuleSetDirectories)); + } + var projectInstance = TestHelpers.CreateProjectInstanceFromRootElement(projectRootElement); var expectedInputFiles = expectedInputs? From 719c75d17425ba67c669412a335d70b6e1e0e29c Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:47:52 +0200 Subject: [PATCH 2/4] PR feedback --- .../Predictors/CodeAnalysisRuleSetPredictor.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs index 923de1f..7537020 100644 --- a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs +++ b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Immutable; using System.IO; using System.Xml; using System.Xml.Linq; @@ -260,14 +259,12 @@ private sealed record RuleSetCacheKey public RuleSetCacheKey(string ruleSetPath, IReadOnlyList ruleSetDirectories) { RuleSetPath = ruleSetPath; - RuleSetDirectories = ruleSetDirectories.Count == 0 - ? [] - : ruleSetDirectories.ToImmutableArray(); + RuleSetDirectories = new List(ruleSetDirectories); } public string RuleSetPath { get; } - public IReadOnlyList RuleSetDirectories { get; } + public List RuleSetDirectories { get; } public bool Equals(RuleSetCacheKey other) { From 2767305f1e047f078948377bdf4ee4b01c9230a1 Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:12:09 +0200 Subject: [PATCH 3/4] Record struct --- .../Predictors/CodeAnalysisRuleSetPredictor.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs index 7537020..c323e82 100644 --- a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs +++ b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs @@ -254,7 +254,7 @@ private HashSet ParseRuleset( } } - private sealed record RuleSetCacheKey + private readonly record struct RuleSetCacheKey { public RuleSetCacheKey(string ruleSetPath, IReadOnlyList ruleSetDirectories) { @@ -268,12 +268,7 @@ public RuleSetCacheKey(string ruleSetPath, IReadOnlyList ruleSetDirector public bool Equals(RuleSetCacheKey other) { - if (ReferenceEquals(this, other)) - { - return true; - } - - if (other is null || !PathComparer.Instance.Equals(RuleSetPath, other.RuleSetPath)) + if (!PathComparer.Instance.Equals(RuleSetPath, other.RuleSetPath)) { return false; } From cdd2368faa56e4f19ea7cc4dfe447a63ad1ebe22 Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:27:06 +0200 Subject: [PATCH 4/4] PR feedback --- Directory.Packages.props | 1 + src/BuildPrediction/Microsoft.Build.Prediction.csproj | 1 + .../Predictors/CodeAnalysisRuleSetPredictor.cs | 5 +---- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d7a4c1..d498e8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ 17.11.4 + diff --git a/src/BuildPrediction/Microsoft.Build.Prediction.csproj b/src/BuildPrediction/Microsoft.Build.Prediction.csproj index 2d9ae00..16cd9ec 100644 --- a/src/BuildPrediction/Microsoft.Build.Prediction.csproj +++ b/src/BuildPrediction/Microsoft.Build.Prediction.csproj @@ -9,6 +9,7 @@ A library to predict inputs and outputs of MSBuild projects + diff --git a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs index c323e82..7d4dcd8 100644 --- a/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs +++ b/src/BuildPrediction/Predictors/CodeAnalysisRuleSetPredictor.cs @@ -295,10 +295,7 @@ public override int GetHashCode() foreach (string directory in RuleSetDirectories) { - unchecked - { - hash = (hash * 397) ^ PathComparer.Instance.GetHashCode(directory); - } + hash = HashCode.Combine(hash, PathComparer.Instance.GetHashCode(directory)); } return hash;