Skip to content
16 changes: 16 additions & 0 deletions .github/workflows/nuget-release-projectsruler.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: 📦 Release ProjectsRuler NuGets
run-name: 📦 Releasing NuGets of ProjectsRuler

on:
workflow_dispatch:
release:
types:
- published

jobs:
release_ProjectsRuler:
name: Build and release ProjectsRuler package
uses: DigitecGalaxus/actions/.github/workflows/reusable-release-nuget.yml@v4
secrets: inherit
with:
proj-or-sln: dotnet/src/ProjectsRuler/ProjectsRuler.csproj
40 changes: 40 additions & 0 deletions ProjectReferencesRuler.Tests/ReferencesRulerRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public void GetComplaintsForProjectReferences_GivenTestProjectFilesDirectory_Ext
{
var extractorMock = new Mock<IReferencesExtractor>();
var rulerMock = new Mock<IReferencesRuler>();
rulerMock.Setup(r => r.GiveMeUnusedRulesComplaints()).Returns(string.Empty);
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
Expand All @@ -37,6 +38,9 @@ public void GetComplaintsForProjectReferences_RulerHasNoComplaints_ReturnsNoComp
rulerMock
.Setup(r => r.GiveMeComplaints(It.IsAny<Reference>()))
.Returns(string.Empty);
rulerMock
.Setup(r => r.GiveMeUnusedRulesComplaints())
.Returns(string.Empty);
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
Expand All @@ -61,6 +65,9 @@ public void GetComplaintsForProjectReferences_RulerHasComplaints_RunnerCollectsT
rulerMock
.Setup(r => r.GiveMeComplaints(It.IsAny<Reference>()))
.Returns("Aarrr!");
rulerMock
.Setup(r => r.GiveMeUnusedRulesComplaints())
.Returns(string.Empty);
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
Expand All @@ -72,11 +79,38 @@ public void GetComplaintsForProjectReferences_RulerHasComplaints_RunnerCollectsT

Assert.Equal("Aarrr!\nAarrr!\nAarrr!\nAarrr!\nAarrr!\nAarrr!\nAarrr!", complaints);
}

[Fact]
public void GetComplaintsForProjectReferences_RulerHasUnusedRuleComplaints_AppendsThemAtTheEnd()
{
var extractorMock = new Mock<IReferencesExtractor>();
extractorMock
.Setup(e => e.GetProjectReferences(It.IsAny<string>()))
.Returns(new[] { new Reference(from: "source", to: "someReference", isPrivateAssetsAllSet: true, versionOrNull: null), });
var rulerMock = new Mock<IReferencesRuler>();
rulerMock
.Setup(r => r.GiveMeComplaints(It.IsAny<Reference>()))
.Returns("Aarrr!");
rulerMock
.Setup(r => r.GiveMeUnusedRulesComplaints())
.Returns("Unused rules:\nrule x");
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
filesRunner: new ProjectFilesRunner(
solutionPath: @"../../../TestProjectFiles/",
filesExtension: "*.xml"));

var complaints = runner.GetComplaintsForProjectReferences();

Assert.Equal("Aarrr!\nAarrr!\nAarrr!\nAarrr!\nAarrr!\nAarrr!\nAarrr!\nUnused rules:\nrule x", complaints);
}
[Fact]
public void GetComplaintsForPackageReferences_GivenTestProjectFilesDirectory_ExtractsReferencesFromAllGivenFiles()
{
var extractorMock = new Mock<IReferencesExtractor>();
var rulerMock = new Mock<IReferencesRuler>();
rulerMock.Setup(r => r.GiveMeUnusedRulesComplaints()).Returns(string.Empty);
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
Expand All @@ -101,6 +135,9 @@ public void GetComplaintsForPackageReferences_RulerHasNoComplaints_ReturnsNoComp
rulerMock
.Setup(r => r.GiveMeComplaints(It.IsAny<Reference>()))
.Returns(string.Empty);
rulerMock
.Setup(r => r.GiveMeUnusedRulesComplaints())
.Returns(string.Empty);
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
Expand All @@ -125,6 +162,9 @@ public void GetComplaintsForPackageReferences_RulerHasComplaints_RunnerCollectsT
rulerMock
.Setup(r => r.GiveMeComplaints(It.IsAny<Reference>()))
.Returns("Aarrr!");
rulerMock
.Setup(r => r.GiveMeUnusedRulesComplaints())
.Returns(string.Empty);
var runner = new ReferencesRulerRunner(
extractor: extractorMock.Object,
referencesRuler: rulerMock.Object,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,36 @@ public void GiveMeComplaints_ForbiddenExplicitRuleWithVersionAndException_Matche

Assert.True(string.IsNullOrEmpty(complaints));
}

[Fact]
public void GiveMeUnusedRulesComplaints_OptedOut_ReturnsNoComplaints()
{
var ruler = new ReferencesRuler(
patternParser: new RegexPatternParser(),
rules: new[] { new ReferenceRule("a", "b", RuleKind.Forbidden, description: "no no") });

ruler.GiveMeComplaints(new Reference(from: "x", to: "y", isPrivateAssetsAllSet: true, versionOrNull: null));
var complaints = ruler.GiveMeUnusedRulesComplaints();

Assert.True(string.IsNullOrEmpty(complaints));
}

[Fact]
public void GiveMeUnusedRulesComplaints_OptedIn_ReturnsOnlyUnusedRules()
{
var ruler = new ReferencesRuler(
patternParser: new RegexPatternParser(),
rules: new[]
{
new ReferenceRule("a", "b", RuleKind.Forbidden, description: "used"),
new ReferenceRule("x", "y", RuleKind.Forbidden, description: "unused")
},
complainAboutUnusedRules: true);

ruler.GiveMeComplaints(new Reference(from: "a", to: "b", isPrivateAssetsAllSet: true, versionOrNull: null));
var complaints = ruler.GiveMeUnusedRulesComplaints();

Assert.Equal("Unused rules:\nunused", complaints);
}
}
}
15 changes: 14 additions & 1 deletion ProjectReferencesRuler/ProjectRunners/ReferencesRulerRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,20 @@ public string GetComplaintsForPackageReferences()

private string GetComplaintsForReferences(Func<string, IEnumerable<Reference>> getReferences)
{
return _filesRunner.CollectComplaintsForFiles(fileName => GetReferencesComplaints(getReferences, fileName));
var referencesComplaints = _filesRunner.CollectComplaintsForFiles(fileName => GetReferencesComplaints(getReferences, fileName));
var unusedRulesComplaints = referencesRuler.GiveMeUnusedRulesComplaints();

if (string.IsNullOrEmpty(unusedRulesComplaints))
{
return referencesComplaints;
}

if (string.IsNullOrEmpty(referencesComplaints))
{
return unusedRulesComplaints;
}

return $"{referencesComplaints}\n{unusedRulesComplaints}";
}

private IEnumerable<string> GetReferencesComplaints(
Expand Down
48 changes: 42 additions & 6 deletions ProjectReferencesRuler/ProjectsRuler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,76 @@ public static class ProjectsRuler
{
public static string GetProjectReferencesComplaints(string solutionDir, params ReferenceRule[] rules)
{
return GetProjectReferencesComplaints(solutionDir, null, rules);
return GetProjectReferencesComplaints(solutionDir, null, complainAboutUnusedRules: false, rules);
}

public static string GetProjectReferencesComplaints(
string solutionDir,
bool complainAboutUnusedRules,
params ReferenceRule[] rules)
{
return GetProjectReferencesComplaints(solutionDir, null, complainAboutUnusedRules, rules);
}

public static string GetProjectReferencesComplaints(
string solutionDir,
string excludedProjects = null,
params ReferenceRule[] rules)
{
return GetRunner(solutionDir, excludedProjects, rules).GetComplaintsForProjectReferences();
return GetProjectReferencesComplaints(solutionDir, excludedProjects, complainAboutUnusedRules: false, rules);
}

public static string GetProjectReferencesComplaints(
string solutionDir,
string excludedProjects,
bool complainAboutUnusedRules,
params ReferenceRule[] rules)
{
return GetRunner(solutionDir, excludedProjects, rules, complainAboutUnusedRules).GetComplaintsForProjectReferences();
}

public static string GetPackageReferencesComplaints(string solutionDir, params ReferenceRule[] rules)
{
return GetPackageReferencesComplaints(solutionDir, null, rules);
return GetPackageReferencesComplaints(solutionDir, null, complainAboutUnusedRules: false, rules);
}

public static string GetPackageReferencesComplaints(
string solutionDir,
bool shouldComplainAboutUnusedRules,
params ReferenceRule[] rules)
{
return GetPackageReferencesComplaints(solutionDir, null, shouldComplainAboutUnusedRules, rules);
}

public static string GetPackageReferencesComplaints(
string solutionDir,
string excludedProjects = null,
params ReferenceRule[] rules)
{
return GetRunner(solutionDir, excludedProjects, rules).GetComplaintsForPackageReferences();
return GetPackageReferencesComplaints(solutionDir, excludedProjects, complainAboutUnusedRules: false, rules);
}

public static string GetPackageReferencesComplaints(
string solutionDir,
string excludedProjects,
bool complainAboutUnusedRules,
params ReferenceRule[] rules)
{
return GetRunner(solutionDir, excludedProjects, rules, complainAboutUnusedRules).GetComplaintsForPackageReferences();
}

private static ReferencesRulerRunner GetRunner(
string solutionDir,
string excludedProjectsRegex,
IReadOnlyList<ReferenceRule> rules)
IReadOnlyList<ReferenceRule> rules,
bool complainAboutUnusedRules)
{
return new ReferencesRulerRunner(
extractor: new CsprojReferencesExtractor(),
referencesRuler: new ReferencesRuler(
patternParser: new WildcardPatternParser(),
rules: rules),
rules: rules,
complainAboutUnusedRules: complainAboutUnusedRules),
filesRunner: new ProjectFilesRunner(
solutionPath: solutionDir,
filesExtension: "*.csproj",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ namespace ProjectReferencesRuler.Rules.References
public interface IReferencesRuler
{
string GiveMeComplaints(Reference reference);
string GiveMeUnusedRulesComplaints();
}
}
27 changes: 26 additions & 1 deletion ProjectReferencesRuler/Rules/References/ReferencesRuler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,21 @@ public class ReferencesRuler : IReferencesRuler
private readonly IReadOnlyList<ReferenceRule> _forbiddingRules;
private readonly IReadOnlyList<ReferenceRule> _allowingRules;
private readonly IReadOnlyList<ReferenceRule> _explicitlyForbiddenRules;
private readonly IReadOnlyList<ReferenceRule> _allRules;
private readonly HashSet<ReferenceRule> _usedRules = new();
private readonly bool _complainAboutUnusedRules;

public ReferencesRuler(IPatternParser patternParser, IReadOnlyList<ReferenceRule> rules)
public ReferencesRuler(
IPatternParser patternParser,
IReadOnlyList<ReferenceRule> rules,
bool complainAboutUnusedRules = false)
{
_patternParser = patternParser;
_forbiddingRules = rules.Where(r => r.Kind == RuleKind.Forbidden).ToList();
_allowingRules = rules.Where(r => r.Kind == RuleKind.Allowed).ToList();
_explicitlyForbiddenRules = rules.Where(r => r.Kind == RuleKind.ExplicitlyForbidden).ToList();
_allRules = rules.ToList();
_complainAboutUnusedRules = complainAboutUnusedRules;
}

public string GiveMeComplaints(Reference reference)
Expand All @@ -48,6 +56,22 @@ public string GiveMeComplaints(Reference reference)
return $"Reference from {reference.From} to {reference.To} broke:\n{complaints}";
}

public string GiveMeUnusedRulesComplaints()
{
if (!_complainAboutUnusedRules)
{
return string.Empty;
}

var complaints = string.Join("\n", _allRules.Where(r => !_usedRules.Contains(r)).Select(r => r.Description));
if (string.IsNullOrEmpty(complaints))
{
return string.Empty;
}

return $"Unused rules:\n{complaints}";
}

private IEnumerable<ReferenceRule> GetGenerallyForbiddenRules(Reference reference)
{
return GetMatchingRules(rules: _forbiddingRules, reference: reference, kind: RuleKind.Forbidden);
Expand All @@ -73,6 +97,7 @@ private IEnumerable<ReferenceRule> GetMatchingRules(IReadOnlyList<ReferenceRule>
&& DoesVersionRuleMatch(reference, rule)
&& rule.Kind == kind)
{
_usedRules.Add(rule);
yield return rule;
}
}
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,39 @@ private void AssertReferenceRules(params ReferenceRule[] rules)
}
```

## Reporting unused rules

By default, the ruler only reports complaints when a rule is violated. You can opt-in to also report rules that were never matched by any reference. This is useful to detect stale or overly specific rules in your ruleset.

```C#
[Test]
public void CheckForUnusedRules()
{
var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
var solutionDir = Path.Combine(dir, @"..\..\..\");

var complaints = ProjectsRuler.GetProjectReferencesComplaints(
solutionDir,
shouldComplainAboutUnusedRules: true,
rules);
// or var complaints = ProjectsRuler.GetPackageReferencesComplaints(
// solutionDir,
// shouldComplainAboutUnusedRules: true,
// rules);

Assert.IsEmpty(complaints);
}
```

When enabled, unused rules will be reported in the output as:
```
Unused rules:
Rule description 1
Rule description 2
```

This feature is **opt-in** and backward-compatible. Existing code will continue to work without any changes.

# Project references exitence check

There is a dedicated checker for that. It uses the same csproj parser as all the other tools in the ruler: **CsprojReferencesExtractor**.
Expand Down
Loading