diff --git a/analyzers/src/SonarAnalyzer.Common/Common/DisjointSets.cs b/analyzers/src/SonarAnalyzer.Common/Common/DisjointSets.cs
new file mode 100644
index 00000000000..c2724019060
--- /dev/null
+++ b/analyzers/src/SonarAnalyzer.Common/Common/DisjointSets.cs
@@ -0,0 +1,52 @@
+/*
+ * SonarAnalyzer for .NET
+ * Copyright (C) 2015-2024 SonarSource SA
+ * mailto: contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+namespace SonarAnalyzer.Common;
+
+///
+/// Data structure for working with disjoint sets of strings, to perform union-find operations with equality semantics:
+/// i.e. reflexivity, symmetry and transitivity.
+///
+/// See https://en.wikipedia.org/wiki/Disjoint-set_data_structure for an introduction to the data structure and
+/// https://www.geeksforgeeks.org/introduction-to-disjoint-set-data-structure-or-union-find-algorithm/ for examples of
+/// its use.
+///
+/// An example of use is to build undirected connected components of dependencies, where each node is the identifier.
+///
+/// Uses a dictionary of strings as a backing store. The dictionary represents a forest of trees, where each node is
+/// a string and each tree is a set of nodes.
+///
+public class DisjointSets
+{
+ private readonly Dictionary parents;
+
+ public DisjointSets(IEnumerable elements) =>
+ parents = elements.ToDictionary(x => x, x => x);
+
+ public void Union(string from, string to) =>
+ parents[FindRoot(from)] = FindRoot(to);
+
+ public string FindRoot(string element) =>
+ parents[element] is var root && root != element ? FindRoot(root) : root;
+
+ // Set elements are sorted in ascending order. Sets are sorted in ascending order by their first element.
+ public List> GetAllSets() =>
+ [.. parents.GroupBy(x => FindRoot(x.Key), x => x.Key).Select(x => x.OrderBy(x => x).ToList()).OrderBy(x => x[0])];
+}
diff --git a/analyzers/tests/SonarAnalyzer.Test/Common/DisjointSetsTest.cs b/analyzers/tests/SonarAnalyzer.Test/Common/DisjointSetsTest.cs
new file mode 100644
index 00000000000..3c09b54b734
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.Test/Common/DisjointSetsTest.cs
@@ -0,0 +1,82 @@
+namespace SonarAnalyzer.Test.Common;
+
+[TestClass]
+public class DisjointSetsTest
+{
+ private static readonly string[] FirstSixPositiveInts = Enumerable.Range(1, 6).Select(x => x.ToString()).ToArray();
+
+ [TestMethod]
+ public void FindRootAndUnion_AreConsistent()
+ {
+ var sets = new DisjointSets(FirstSixPositiveInts);
+ foreach (var element in FirstSixPositiveInts)
+ {
+ sets.FindRoot(element).Should().Be(element);
+ }
+
+ sets.Union("1", "1");
+ sets.FindRoot("1").Should().Be("1"); // Reflexivity
+ sets.Union("1", "2");
+ sets.FindRoot("1").Should().Be(sets.FindRoot("2")); // Correctness
+ sets.Union("1", "2");
+ sets.FindRoot("1").Should().Be(sets.FindRoot("2")); // Idempotency
+ sets.Union("2", "1");
+ sets.FindRoot("1").Should().Be(sets.FindRoot("2")); // Symmetry
+
+ sets.FindRoot("3").Should().Be("3");
+ sets.Union("2", "3");
+ sets.FindRoot("2").Should().Be(sets.FindRoot("3"));
+ sets.FindRoot("1").Should().Be(sets.FindRoot("3")); // Transitivity
+ sets.Union("3", "4");
+ sets.FindRoot("1").Should().Be(sets.FindRoot("4")); // Double transitivity
+ sets.Union("4", "1");
+ sets.FindRoot("4").Should().Be(sets.FindRoot("1")); // Idempotency after transitivity
+ }
+
+ [TestMethod]
+ public void GetAllSetsAndUnion_AreConsistent()
+ {
+ var sets = new DisjointSets(FirstSixPositiveInts);
+ AssertSets([["1"], ["2"], ["3"], ["4"], ["5"], ["6"]], sets); // Initial state
+ sets.Union("1", "2");
+ AssertSets([["1", "2"], ["3"], ["4"], ["5"], ["6"]], sets); // Correctness
+ sets.Union("1", "2");
+ AssertSets([["1", "2"], ["3"], ["4"], ["5"], ["6"]], sets); // Idempotency
+
+ sets.Union("2", "3");
+ AssertSets([["1", "2", "3"], ["4"], ["5"], ["6"]], sets); // Transitivity
+ sets.Union("1", "3");
+ AssertSets([["1", "2", "3"], ["4"], ["5"], ["6"]], sets); // Idempotency after transitivity
+
+ sets.Union("4", "5");
+ AssertSets([["1", "2", "3"], ["4", "5"], ["6"]], sets); // Separated trees
+ sets.Union("3", "4");
+ AssertSets([["1", "2", "3", "4", "5"], ["6"]], sets); // Merged trees
+ }
+
+ [TestMethod]
+ public void GetAllSetsAndUnion_OfNestedTrees()
+ {
+ var sets = new DisjointSets(FirstSixPositiveInts);
+ sets.Union("1", "2");
+ sets.Union("3", "4");
+ sets.Union("5", "6");
+ AssertSets([["1", "2"], ["3", "4"], ["5", "6"]], sets); // Merge of 1-height trees
+ sets.Union("2", "3");
+ AssertSets([["1", "2", "3", "4"], ["5", "6"]], sets); // Merge of 2-height trees
+ sets.Union("4", "5");
+ AssertSets([["1", "2", "3", "4", "5", "6"]], sets); // Merge of 1-height tree and 2-height tree
+ }
+
+ [TestMethod]
+ public void GetAllSets_ReturnsSortedSets()
+ {
+ var sets = new DisjointSets(["3", "2", "1"]);
+ AssertSets([["1"], ["2"], ["3"]], sets);
+ sets.Union("3", "1");
+ AssertSets([["1", "3"], ["2"]], sets);
+ }
+
+ private static void AssertSets(List> expected, DisjointSets sets) =>
+ sets.GetAllSets().Should().BeEquivalentTo(expected, options => options.WithStrictOrdering());
+}