Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
<DisableTransitiveProjectReferences Condition="'$(DisableTransitiveProjectReferences)' == ''">true</DisableTransitiveProjectReferences>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.28" />
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The following warnings are generated by this package:
| RP0003 | No dependency rules matched the current project |
| RP0004 | Project reference 'x' ==> 'y' violates dependency rule or one of its exceptions |
| RP0005 | Package reference 'x' ==> 'y' violates dependency rule or one of its exceptions |
| RP0006 | Tech debt exception 'x' ==> 'y' no longer matches any declared reference and can be removed |

## How to use
Add a package reference to the [ReferenceProtector](https://www.nuget.org/packages/ReferenceProtector) package in your projects, or as a common package reference in the repo's [`Directory.Packages.props`](./Directory.Build.props).
Expand Down Expand Up @@ -72,6 +73,8 @@ Schema of the rules file is as follows:
}
```

Note: for stability - use the version in the schema url, instead of `main`. For example for version `v1.2.3` - use `"$schema": https://raw.githubusercontent.com/olstakh/ReferenceProtector/v1.2.3/src/Build/DependencyRules.schema.json`

Top `ProjectDependencies` object will contain a list of rules to validate against. Each rule has the following schema:

- `From` / `To` - Full path regex for source and target projects to be matched.
Expand All @@ -82,6 +85,8 @@ Top `ProjectDependencies` object will contain a list of rules to validate agains

Top `PackageDependences` object will have the same format as `ProjectDependencies` with `LinkType` omitted, since only direct package references will be considered. Also, `Description` section will be part of `RP0005` warning (as opposed to `RP0004`)

Adding `IsTechDebt: true` to an exception (if the `Policy: "Forbidden"`) will mark the exception to the rule as undesireable and analyzer will produce a warning when such an exception no longer holds and can be removed from the rules.

## Matching logic
Each reference between the projects / packages during the build is evaluated against provided list of policies. First each pair of dependencies is evaluated against `From` and `To` patterns, based on their full path. For project dependencies - if the match is successful - their link type is evaluated: if current pair has a direct dependency on each other and `LinkType` value is `Direct` or `DirectOrTransient` - the match is successful, otherwise (the dependency is transient) - `LinkType` should be `Transient` or `DirectOrTransient` for the match to be successful. Package dependencies are only viewed as direct references. Then the exceptions are evaluated using the same pattern matching logic with `From` and `To` fields.
The decision logic is as follows
Expand Down
8 changes: 7 additions & 1 deletion samples/DependencyRules.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
"From": "*\\ClassA.csproj",
"To": "*\\ClassB.csproj",
"Justification": "This is an exception for testing purposes"
},
{
"From": "*\\ClassA.csproj",
"To": "*\\OldClass.csproj",
"IsTechDebt": true,
"Justification": "This is a tech debt exception for testing purposes. [Waning is expected]"
}
]
},
{
"From": "*\\ClassA.csproj",
"To": "*\\ClassC.csproj",
"Description": "Can't reference this project transitively",
"Description": "Can't reference this project transitively [Warning is expected]",
"Policy": "Forbidden",
"LinkType": "Transitive",
"Exceptions": [
Expand Down
1 change: 1 addition & 0 deletions samples/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DependencyRulesFile>$(MSBuildThisFileDirectory)DependencyRules.json</DependencyRulesFile>
<ErrorLog>buildResult.sarif,version=2.1</ErrorLog>
<DisableTransitiveProjectReferences>false</DisableTransitiveProjectReferences>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,229 @@ private AnalyzerTest<DefaultVerifier> GetAnalyzer() =>
},
ReferenceAssemblies = ReferenceAssemblies.Net.Net90
};

/// <summary>
/// Verifies that the analyzer reports RP0006 when a tech debt exception no longer matches any declared project reference.
/// </summary>
[Fact]
public async Task TechDebtException_NoLongerNeeded_ShouldReportDiagnostic_Async()
{
var test = GetAnalyzer();
test.TestState.AdditionalFiles.Add(
("DependencyRules.json", """
{
"ProjectDependencies": [
{
"From": "*",
"To": "*",
"Description": "No direct project references allowed",
"Policy": "Forbidden",
"LinkType": "Direct",
"Exceptions": [
{
"From": "TestProject.csproj",
"To": "OldProject.csproj",
"Justification": "Legacy dependency to be removed",
"IsTechDebt": true
}
]
}
]
}
"""));

// OldProject.csproj is NOT in the declared references
test.TestState.AdditionalFiles.Add(
(ReferenceProtectorAnalyzer.DeclaredReferencesFile, """
TestProject.csproj ProjectReferenceDirect SomeOtherProject.csproj
"""));

test.ExpectedDiagnostics.Add(DiagnosticResult.CompilerWarning("RP0004")
.WithNoLocation()
.WithMessage("Project reference 'TestProject.csproj' ==> 'SomeOtherProject.csproj' violates dependency rule 'No direct project references allowed' or one of its exceptions. Please remove the dependency or update 'DependencyRules.json' file to allow it."));

test.ExpectedDiagnostics.Add(DiagnosticResult.CompilerWarning("RP0006")
.WithNoLocation()
.WithMessage("Tech debt exception 'TestProject.csproj' ==> 'OldProject.csproj' in rule 'No direct project references allowed' no longer matches any declared reference and can be removed from 'DependencyRules.json'"));

await test.RunAsync(TestContext.Current.CancellationToken);
}

/// <summary>
/// Verifies that the analyzer does NOT report RP0006 when a tech debt exception still matches a declared project reference.
/// </summary>
[Fact]
public async Task TechDebtException_StillNeeded_ShouldNotReportDiagnostic_Async()
{
var test = GetAnalyzer();
test.TestState.AdditionalFiles.Add(
("DependencyRules.json", """
{
"ProjectDependencies": [
{
"From": "*",
"To": "*",
"Description": "No direct project references allowed",
"Policy": "Forbidden",
"LinkType": "Direct",
"Exceptions": [
{
"From": "TestProject.csproj",
"To": "ReferencedProject.csproj",
"Justification": "Legacy dependency to be removed",
"IsTechDebt": true
}
]
}
]
}
"""));

test.TestState.AdditionalFiles.Add(
(ReferenceProtectorAnalyzer.DeclaredReferencesFile, """
TestProject.csproj ProjectReferenceDirect ReferencedProject.csproj
"""));

// No diagnostics expected - the tech debt exception is still needed
await test.RunAsync(TestContext.Current.CancellationToken);
}

/// <summary>
/// Verifies that a non-tech-debt exception that no longer matches does NOT trigger RP0006.
/// </summary>
[Fact]
public async Task NonTechDebtException_NoLongerNeeded_ShouldNotReportDiagnostic_Async()
{
var test = GetAnalyzer();
test.TestState.AdditionalFiles.Add(
("DependencyRules.json", """
{
"ProjectDependencies": [
{
"From": "*",
"To": "*",
"Description": "No direct project references allowed",
"Policy": "Forbidden",
"LinkType": "Direct",
"Exceptions": [
{
"From": "TestProject.csproj",
"To": "OldProject.csproj",
"Justification": "Legitimate exception"
}
]
}
]
}
"""));

test.TestState.AdditionalFiles.Add(
(ReferenceProtectorAnalyzer.DeclaredReferencesFile, """
TestProject.csproj ProjectReferenceDirect SomeOtherProject.csproj
"""));

// RP0004 for the violating reference, but NO RP0006 since the exception is not tech debt
test.ExpectedDiagnostics.Add(DiagnosticResult.CompilerWarning("RP0004")
.WithNoLocation()
.WithMessage("Project reference 'TestProject.csproj' ==> 'SomeOtherProject.csproj' violates dependency rule 'No direct project references allowed' or one of its exceptions. Please remove the dependency or update 'DependencyRules.json' file to allow it."));

await test.RunAsync(TestContext.Current.CancellationToken);
}

/// <summary>
/// Verifies that the analyzer reports RP0006 when a tech debt exception for a package reference no longer matches.
/// </summary>
[Fact]
public async Task TechDebtPackageException_NoLongerNeeded_ShouldReportDiagnostic_Async()
{
var test = GetAnalyzer();
test.TestState.AdditionalFiles.Add(
("DependencyRules.json", """
{
"PackageDependencies": [
{
"From": "TestProject.csproj",
"To": "*",
"Description": "No packages allowed",
"Policy": "Forbidden",
"Exceptions": [
{
"From": "TestProject.csproj",
"To": "OldPackage",
"Justification": "Legacy package to be removed",
"IsTechDebt": true
}
]
}
],
"ProjectDependencies": [
{
"From": "TestProject.csproj",
"To": "*",
"Description": "Allowed",
"Policy": "Allowed",
"LinkType": "DirectOrTransitive"
}
]
}
"""));

test.TestState.AdditionalFiles.Add(
(ReferenceProtectorAnalyzer.DeclaredReferencesFile, """
TestProject.csproj PackageReferenceDirect SomeOtherPackage
"""));

test.ExpectedDiagnostics.Add(DiagnosticResult.CompilerWarning("RP0005")
.WithNoLocation()
.WithMessage("Package reference 'TestProject.csproj' ==> 'SomeOtherPackage' violates dependency rule 'No packages allowed' or one of its exceptions. Please remove the dependency or update 'DependencyRules.json' file to allow it."));

test.ExpectedDiagnostics.Add(DiagnosticResult.CompilerWarning("RP0006")
.WithNoLocation()
.WithMessage("Tech debt exception 'TestProject.csproj' ==> 'OldPackage' in rule 'No packages allowed' no longer matches any declared reference and can be removed from 'DependencyRules.json'"));

await test.RunAsync(TestContext.Current.CancellationToken);
}

/// <summary>
/// Verifies IsTechDebt defaults to false when not specified in JSON.
/// </summary>
[Fact]
public async Task TechDebtException_DefaultFalse_NoFlagSpecified_ShouldNotReportDiagnostic_Async()
{
var test = GetAnalyzer();
test.TestState.AdditionalFiles.Add(
("DependencyRules.json", """
{
"ProjectDependencies": [
{
"From": "*",
"To": "*",
"Description": "No direct project references allowed",
"Policy": "Forbidden",
"LinkType": "Direct",
"Exceptions": [
{
"From": "TestProject.csproj",
"To": "OldProject.csproj",
"Justification": "Legitimate exception",
"IsTechDebt": false
}
]
}
]
}
"""));

test.TestState.AdditionalFiles.Add(
(ReferenceProtectorAnalyzer.DeclaredReferencesFile, """
TestProject.csproj ProjectReferenceDirect SomeOtherProject.csproj
"""));

// Only RP0004 for SomeOtherProject, no RP0006 since IsTechDebt is explicitly false
test.ExpectedDiagnostics.Add(DiagnosticResult.CompilerWarning("RP0004")
.WithNoLocation()
.WithMessage("Project reference 'TestProject.csproj' ==> 'SomeOtherProject.csproj' violates dependency rule 'No direct project references allowed' or one of its exceptions. Please remove the dependency or update 'DependencyRules.json' file to allow it."));

await test.RunAsync(TestContext.Current.CancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ RP0002 | Usage | Warning | Make sure the dependency rules file '{0}' is in the c
RP0003 | Usage | Info | No dependency rules matched the current project '{0}'
RP0004 | Usage | Warning | Project reference '{0}' ==> '{1}' violates dependency rule '{2}' or one of its exceptions
RP0005 | Usage | Warning | Package reference '{0}' ==> '{1}' violates dependency rule '{2}' or one of its exceptions

## Release 1.3.7

### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
RP0006 | Usage | Warning | Technical debt dependency can be removed from the rules exception
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,12 @@ internal static class Descriptors
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor TechDebtExceptionNoLongerNeeded = new(
id: "RP0006",
title: "Tech debt exception is no longer needed",
messageFormat: "Tech debt exception '{0}' ==> '{1}' in rule '{2}' no longer matches any declared reference and can be removed from '{3}'",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ internal record PackageDependency(
internal record Exceptions(
string From,
string To,
string Justification);
string Justification,
bool IsTechDebt = false);

internal enum Policy
{
Expand Down
Loading