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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- PR [#301](https://github.com/marinasundstrom/CheckedExceptions/pull/301) Allow treating `Exception` in `[Throws]` as a catch-all via `treatThrowsExceptionAsCatchRest` setting (base-type diagnostic unchanged)
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Provide comprehensive baseline exception classifications in `default-settings.json`
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Introduce `NonStrict` exception classification and `defaultExceptionClassification` setting
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Document `NonStrict` declaration/catch behavior and add regression tests
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Add configuration guide for selecting default exception classification

### Changed

- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Replace `ignoredExceptions` and `informationalExceptions` with explicit `exceptions` classification map
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Document explicit exception taxonomy and strict default for unlisted types in README and docs
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Unlisted exceptions now default to `NonStrict` producing low-severity diagnostics

### Deprecated

Expand Down
27 changes: 27 additions & 0 deletions CheckedExceptions.Tests/AnazylerConfigTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,31 @@ public void TestMethod2()

await Verifier.VerifyAnalyzerAsync2(test, expected1, expected2);
}

[Fact(DisplayName = "Unlisted exception defaults to NonStrict")]
public async Task UnlistedExceptionDefaultsToNonStrict_ShouldReportInfoDiagnostic()
{
var test = /* lang=c#-test */ """
using System;

public class TestClass
{
public void TestMethod()
{
throw new InvalidOperationException();
}
}
""";

var expected = Verifier.Informational("InvalidOperationException")
.WithSpan(7, 9, 7, 47);

await Verifier.VerifyAnalyzerAsync(test, t =>
{
t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", "{}"));
var allDiagnostics = CheckedExceptionsAnalyzer.AllDiagnosticsIds;
t.DisabledDiagnostics.AddRange(allDiagnostics.Except(new[] { expected.Id }));
t.ExpectedDiagnostics.Add(expected);
});
}
}
7 changes: 7 additions & 0 deletions CheckedExceptions.Tests/CSharpAnalyzerVerifier.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
Expand Down Expand Up @@ -92,6 +93,7 @@ await VerifyAnalyzerAsync(source, (test) =>
test.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json",
""""
{
"defaultExceptionClassification": "Strict",
"ignoredExceptions": [
"System.NotImplementedException"
],
Expand Down Expand Up @@ -127,6 +129,11 @@ public static Task VerifyAnalyzerAsync([StringSyntax("c#-test")] string source,

setup?.Invoke(test);

if (!test.TestState.AdditionalFiles.Any(f => f.filename == "CheckedExceptions.settings.json"))
{
test.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", "{\n \"defaultExceptionClassification\": \"Strict\"\n}"));
}

return test.RunAsync();
}

Expand Down
1 change: 1 addition & 0 deletions CheckedExceptions.Tests/CheckedExceptions.settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"defaultExceptionClassification": "Strict",
"exceptions": {
"System.ArgumentNullException": "Ignored",
"System.NotImplementedException": "Informational",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Microsoft.CodeAnalysis.Testing;

namespace Sundstrom.CheckedExceptions.Tests;

using Verifier = CSharpAnalyzerVerifier<CheckedExceptionsAnalyzer, DefaultVerifier>;

public partial class CheckedExceptionsAnalyzerTests
{
[Fact]
public async Task DeclaringNonStrictException_ShouldNotReportRedundantDeclaration()
{
var test = /* lang=c#-test */ """
using System;

public class TestClass
{
[Throws(typeof(InvalidOperationException))]
public void Test()
{
throw new InvalidOperationException();
}
}
""";

await Verifier.VerifyAnalyzerAsync(test, t =>
{
t.DisabledDiagnostics.Remove(CheckedExceptionsAnalyzer.DiagnosticIdRedundantExceptionDeclaration);
t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdIgnoredException);
t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """
{
"defaultExceptionClassification": "Strict",
"exceptions": {"System.InvalidOperationException": "NonStrict"}
}
"""));
});
}

[Fact]
public async Task CatchingNonStrictException_TypedCatch_ShouldNotReportRedundantCatch()
{
var test = /* lang=c#-test */ """
using System;

public class TestClass
{
public void Test()
{
try
{
throw new InvalidOperationException();
}
catch (InvalidOperationException)
{
}
}
}
""";

await Verifier.VerifyAnalyzerAsync(test, t =>
{
t.DisabledDiagnostics.Remove(CheckedExceptionsAnalyzer.DiagnosticIdRedundantTypedCatchClause);
t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdIgnoredException);
t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """
{
"defaultExceptionClassification": "Strict",
"exceptions": {"System.InvalidOperationException": "NonStrict"}
}
"""));
});
}

[Fact]
public async Task CatchingNonStrictException_CatchAll_ShouldNotReportRedundantCatchAll()
{
var test = /* lang=c#-test */ """
using System;

public class TestClass
{
public void Test()
{
try
{
throw new InvalidOperationException();
}
catch
{
}
}
}
""";

await Verifier.VerifyAnalyzerAsync(test, t =>
{
t.DisabledDiagnostics.Remove(CheckedExceptionsAnalyzer.DiagnosticIdRedundantCatchAllClause);
t.DisabledDiagnostics.Add(CheckedExceptionsAnalyzer.DiagnosticIdIgnoredException);
t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """
{
"defaultExceptionClassification": "Strict",
"exceptions": {"System.InvalidOperationException": "NonStrict"}
}
"""));
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ await Verifier.VerifyAnalyzerAsync(test, t =>
{
t.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """
{
"defaultExceptionClassification": "Strict",
"exceptions": {},
"treatThrowsExceptionAsCatchRest": true
}
Expand Down
5 changes: 5 additions & 0 deletions CheckedExceptions.Tests/CodeFixes/CSharpCodeFixVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ public static Task VerifyCodeFixAsync([StringSyntax("c#-test")] string source, I

setup?.Invoke(test);

if (!test.TestState.AdditionalFiles.Any(f => f.filename == "CheckedExceptions.settings.json"))
{
test.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", "{\n \"defaultExceptionClassification\": \"Strict\"\n}"));
}

return test.RunAsync();
}

Expand Down
4 changes: 4 additions & 0 deletions CheckedExceptions/AnalyzerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ public partial class AnalyzerSettings
[JsonIgnore]
internal bool TreatThrowsExceptionAsCatchRestEnabled => TreatThrowsExceptionAsCatchRest;

[JsonPropertyName("defaultExceptionClassification")]
public ExceptionClassification DefaultExceptionClassification { get; set; } = ExceptionClassification.NonStrict;

[JsonPropertyName("exceptions")]
public IDictionary<string, ExceptionClassification> Exceptions { get; set; } = new Dictionary<string, ExceptionClassification>();

Expand Down Expand Up @@ -111,5 +114,6 @@ public enum ExceptionClassification
{
Ignored,
Informational,
NonStrict,
Strict
}
3 changes: 2 additions & 1 deletion CheckedExceptions/CheckedExceptionsAnalyzer.Analysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ private static void AnalyzeExceptionsInTryBlock(SyntaxNodeAnalysisContext contex
{
continue;
}
else if (classification is ExceptionClassification.Informational)
else if (classification is ExceptionClassification.Informational
|| classification is ExceptionClassification.NonStrict)
{
var diagnostic = Diagnostic.Create(RuleIgnoredException, GetSignificantLocation(throwStatement), exceptionType.Name);
context.ReportDiagnostic(diagnostic);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static ExceptionClassification GetExceptionClassification(
return classification;
}

return ExceptionClassification.Strict;
return settings.DefaultExceptionClassification;
}

public static bool ShouldIncludeException(
Expand Down
3 changes: 2 additions & 1 deletion CheckedExceptions/CheckedExceptionsAnalyzer.Throw.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ private static void AnalyzeExceptionThrowingNode(
{
return;
}
else if (classification is ExceptionClassification.Informational)
else if (classification is ExceptionClassification.Informational
|| classification is ExceptionClassification.NonStrict)
{
var diagnostic = Diagnostic.Create(RuleIgnoredException, GetSignificantLocation(node), exceptionType.Name);
reportDiagnostic(diagnostic);
Expand Down
6 changes: 3 additions & 3 deletions CheckedExceptions/CheckedExceptionsAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@

private static readonly DiagnosticDescriptor RuleIgnoredException = new DiagnosticDescriptor(
DiagnosticIdIgnoredException,
"Ignored exception may cause runtime issues",
"Exception '{0}' is ignored by configuration but may cause runtime issues if unhandled",
"Non-strict exception may cause runtime issues",
"Exception '{0}' is not strictly enforced and may cause runtime issues if unhandled",
"Usage",
DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: "Informs about exceptions excluded from analysis but which may still propagate at runtime if not properly handled.");
description: "Informs about exceptions that are not strictly enforced but which may still propagate at runtime if not properly handled.");

private static readonly DiagnosticDescriptor RuleGeneralThrow = new(
DiagnosticIdGeneralThrow,
Expand Down Expand Up @@ -286,7 +286,7 @@
CollectEnumerationExceptions(spreadOp, exceptionTypes, context.Compilation, semanticModel, settings, context.CancellationToken);

exceptionTypes = new HashSet<INamedTypeSymbol>(
ProcessNullable(context.Compilation, semanticModel, spreadSyntax.Expression, null, exceptionTypes),

Check warning on line 289 in CheckedExceptions/CheckedExceptionsAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-test

Cannot convert null literal to non-nullable reference type.
SymbolEqualityComparer.Default);

foreach (var t in exceptionTypes.Distinct(SymbolEqualityComparer.Default))
Expand Down Expand Up @@ -341,12 +341,12 @@
CollectEnumerationExceptions(argument.Value, exceptionTypes, context.Compilation, semanticModel, settings, context.CancellationToken);

exceptionTypes = new HashSet<INamedTypeSymbol>(
ProcessNullable(context.Compilation, context.SemanticModel, argumentSyntax.Expression, null, exceptionTypes),

Check warning on line 344 in CheckedExceptions/CheckedExceptionsAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-test

Cannot convert null literal to non-nullable reference type.
SymbolEqualityComparer.Default);

foreach (var t in exceptionTypes.Distinct(SymbolEqualityComparer.Default))
{
ReportEnumerableBoundaryDiagnostic(context, argumentSyntax.Expression, (INamedTypeSymbol?)t, settings, semanticModel);

Check warning on line 349 in CheckedExceptions/CheckedExceptionsAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-test

Possible null reference argument for parameter 'exceptionType' in 'void CheckedExceptionsAnalyzer.ReportEnumerableBoundaryDiagnostic(SyntaxNodeAnalysisContext context, ExpressionSyntax expression, INamedTypeSymbol exceptionType, AnalyzerSettings settings, SemanticModel semanticModel)'.
}
}

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,20 @@ dotnet_diagnostic.THROW003.severity = warning

### JSON Settings

A baseline template is available in `default-settings.json`.
A baseline template is available in `default-settings.json`. For guidance on choosing the right default classification for your project, see the [configuration guide](docs/configuration-guide.md).

The analyzer reads a single `exceptions` dictionary that explicitly classifies each exception type as `Ignored`, `Informational`, or `Strict`. Any exception not listed defaults to `Strict`, so an unclassified throw will trigger a diagnostic unless it's caught or declared with `[Throws]`.
The analyzer reads a single `exceptions` dictionary that explicitly classifies each exception type as `Ignored`, `Informational`, `NonStrict`, or `Strict`. Any exception not listed defaults to `NonStrict`, so an unclassified throw will trigger a low-severity diagnostic but won't require `[Throws]` or a `catch`. `NonStrict` exceptions remain part of analysis—you may still declare or catch them, and doing so isn't considered redundant. Only exceptions classified as `Ignored` are completely filtered out.

Add `CheckedExceptions.settings.json`:

```json
{
// Default classification for exceptions not listed (default: NonStrict)
"defaultExceptionClassification": "NonStrict",
"exceptions": {
"System.ArgumentNullException": "Ignored",
"System.IO.IOException": "Informational",
"System.TimeoutException": "Informational",
"System.TimeoutException": "NonStrict",
"System.Exception": "Strict"
},

Expand Down Expand Up @@ -194,7 +196,7 @@ Register in `.csproj`:
| ID | Message |
|------------|-------------------------------------------------------------------------|
| `THROW001` | ❗ Unhandled exception: must be caught or declared |
| `THROW002` | ℹ️ Ignored exception may cause runtime issues |
| `THROW002` | ℹ️ Non-strict exception may cause runtime issues |
| `THROW003` | 🚫 Avoid declaring exception type `System.Exception` |
| `THROW004` | 🚫 Avoid throwing exception base type `System.Exception` |
| `THROW005` | 🔁 Duplicate declarations of the same exception type in `[Throws]` |
Expand Down
3 changes: 2 additions & 1 deletion SampleProject/CheckedExceptions.settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"defaultExceptionClassification": "NonStrict",
"exceptions": {
"System.NotImplementedException": "Informational",
"System.IO.IOException": "Informational",
"System.TimeoutException": "Informational"
"System.TimeoutException": "NonStrict"
},
"disableXmlDocInterop": false,
"disableLinqSupport": false,
Expand Down
3 changes: 2 additions & 1 deletion Test/CheckedExceptions.settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"defaultExceptionClassification": "NonStrict",
"exceptions": {
"System.NotImplementedException": "Informational",
"System.IO.IOException": "Informational",
"System.TimeoutException": "Informational"
"System.TimeoutException": "NonStrict"
},
"disableXmlDocInterop": false,
"disableLinqSupport": false,
Expand Down
1 change: 1 addition & 0 deletions default-settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"defaultExceptionClassification": "NonStrict",
"exceptions": {
"System.ArgumentNullException": "Ignored",
"System.ArgumentOutOfRangeException": "Ignored",
Expand Down
5 changes: 3 additions & 2 deletions docs/analyzer-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,14 @@ public int Value { set => throw new InvalidOperationException(); }

## Exception classification (Core analysis)

`CheckedExceptions.settings.json` contains an explicit `exceptions` map that classifies each exception type as `Ignored`, `Informational`, or `Strict`.
`CheckedExceptions.settings.json` contains an explicit `exceptions` map that classifies each exception type as `Ignored`, `Informational`, `NonStrict`, or `Strict`.

- **Ignored** – no diagnostics are produced.
- **Informational** – diagnostics are reported but `[Throws]` is not required.
- **NonStrict** – diagnostics are reported at low severity without requiring `[Throws]` or a `catch` block.
- **Strict** – exceptions must be caught or declared.

Any exception not listed defaults to **Strict**, so unclassified types will trigger `THROW001` until they are handled or declared.
Any exception not listed defaults to **NonStrict**, so unclassified types will trigger `THROW002` until they are handled or declared.

> **Migration note:** Legacy `ignoredExceptions` and `informationalExceptions` properties are still processed for backward compatibility, but they are deprecated and translated into entries in the `exceptions` map.

Expand Down
21 changes: 21 additions & 0 deletions docs/configuration-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Configuration Guide

Choosing the right default classification determines how the analyzer treats exceptions that are not explicitly listed in the `exceptions` map.

## Default strategies

### `Ignored`
Use when you only want to analyze exceptions that are explicitly classified. Unlisted exceptions will be ignored entirely.

### `NonStrict`
The recommended starting point. Unlisted exceptions remain part of analysis and raise low-severity diagnostics (`THROW002` style) but do not require `[Throws]` declarations or `catch` blocks.

### `Strict`
Use for maximum enforcement. Any unlisted exception must be either declared with `[Throws]` or handled with a `catch` block; otherwise a high-severity diagnostic is reported.

## Tips
- Start with `NonStrict` to discover thrown exceptions without breaking builds.
- Move to `Strict` once you have classified all expected exceptions and want strong enforcement.
- Prefer `Ignored` only when you explicitly list every exception of interest or when onboarding gradually.

You can change the default via the `defaultExceptionClassification` setting and override individual types in the `exceptions` map.
Loading
Loading