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 FirstClassErrors.Analyzers/Descriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal static class Descriptors {
category: DiagnosticCategories.ErrorCodes,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "ErrorCode.Create registers each code in a process-wide set and throws when the same code is created twice. Detection is per-compilation and limited to literal codes.",
description: "An ErrorCode is compared by value, so the same literal code created more than once yields equal instances that documentation extraction and lookups collapse into a single identity. Detection is per-compilation and limited to literal codes.",
helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateErrorCode),
customTags: new[] { WellKnownDiagnosticTags.CompilationEnd });

Expand Down
7 changes: 4 additions & 3 deletions FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ namespace FirstClassErrors.Analyzers;

/// <summary>
/// FCE001 — reports the same error code created by more than one <c>ErrorCode.Create("X")</c> literal in the
/// compilation. <c>ErrorCode.Create</c> registers each code in a process-wide set and throws when a code is created
/// twice. Detection is per-compilation (cross-assembly duplicates still surface only at runtime) and limited to
/// literal codes — a non-literal code is FCE003's concern.
/// compilation. <c>ErrorCode</c> is compared by value, so a duplicated code yields equal instances that silently
/// collapse two distinct errors into one identity (documentation extraction and dictionary lookups keep a single
/// entry). Detection is per-compilation (cross-assembly duplicates are not seen here — they surface only as a
/// documentation-pipeline warning) and limited to literal codes — a non-literal code is FCE003's concern.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DuplicateErrorCodeAnalyzer : DiagnosticAnalyzer {
Expand Down
69 changes: 22 additions & 47 deletions FirstClassErrors.UnitTests/ErrorCodeTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
#region Usings declarations

using System.Diagnostics.CodeAnalysis;

using JetBrains.Annotations;

using NFluent;
Expand All @@ -11,15 +9,7 @@
namespace FirstClassErrors.UnitTests;

[TestSubject(typeof(ErrorCode))]
public class ErrorCodeTests : IDisposable {

#region Constructors & Destructor

public ErrorCodeTests() {
ErrorCode.ResetForTests();
}

#endregion
public class ErrorCodeTests {

[Theory(DisplayName = "Creating an error code with a valid code succeeds.")]
[InlineData("ERROR_001")]
Expand All @@ -45,25 +35,25 @@ public void CreatingErrorCodeWithBlankCodeIsRejected(string? code) {
.WithMessage("Error code cannot be null or whitespace. (Parameter 'code')");
}

[Fact(DisplayName = "Creating an error code with a duplicate code is rejected.")]
public void CreatingErrorCodeWithDuplicateCodeIsRejected() {
[Fact(DisplayName = "Creating the same code twice is allowed and produces equal instances.")]
public void CreatingTheSameCodeTwiceIsAllowedAndProducesEqualInstances() {
// Setup
const string code = "DUPLICATE_ERROR";
ErrorCode.Create(code);

// Exercise & verify
Check.ThatCode(() => ErrorCode.Create(code))
.Throws<InvalidOperationException>()
.WithMessage("Error code 'DUPLICATE_ERROR' has already been registered.");
// Exercise
ErrorCode first = ErrorCode.Create(code);
ErrorCode second = ErrorCode.Create(code);

// Verify
Check.That(first).IsEqualTo(second);
}

[Fact(DisplayName = "Error codes with the same code are equal.")]
public void ErrorCodesWithSameCodeAreEqual() {
// Setup
const string code = "EQUALS_TEST";
ErrorCode errorCode1 = ErrorCode.Create(code);
ErrorCode.ResetForTests();
ErrorCode errorCode2 = ErrorCode.Create(code);
ErrorCode errorCode2 = ErrorCode.Create(code);

// Exercise & verify
Check.That(errorCode1).IsEqualTo(errorCode2);
Expand All @@ -73,7 +63,6 @@ public void ErrorCodesWithSameCodeAreEqual() {
public void ErrorCodesWithDifferentCodesAreNotEqual() {
// Setup
ErrorCode errorCode1 = ErrorCode.Create("ERROR_1");
ErrorCode.ResetForTests();
ErrorCode errorCode2 = ErrorCode.Create("ERROR_2");

// Exercise & verify
Expand All @@ -97,7 +86,6 @@ public void EqualityOperatorReturnsTrueForErrorCodesWithSameCode() {
// Setup
string code = "OPERATOR_EQUALS";
ErrorCode errorCode1 = ErrorCode.Create(code);
ErrorCode.ResetForTests();
ErrorCode errorCode2 = ErrorCode.Create(code);

// Exercise
Expand All @@ -111,7 +99,6 @@ public void EqualityOperatorReturnsTrueForErrorCodesWithSameCode() {
public void InequalityOperatorReturnsTrueForErrorCodesWithDifferentCodes() {
// Setup
ErrorCode errorCode1 = ErrorCode.Create("ERROR_1");
ErrorCode.ResetForTests();
ErrorCode errorCode2 = ErrorCode.Create("ERROR_2");

// Exercise
Expand Down Expand Up @@ -139,8 +126,7 @@ public void ErrorCodesWithSameCodeProduceTheSameHashCode() {
// Setup
const string code = "HASH_TEST";
ErrorCode errorCode1 = ErrorCode.Create(code);
ErrorCode.ResetForTests();
ErrorCode errorCode2 = ErrorCode.Create(code);
ErrorCode errorCode2 = ErrorCode.Create(code);

// Exercise
int hash1 = errorCode1.GetHashCode();
Expand All @@ -154,7 +140,6 @@ public void ErrorCodesWithSameCodeProduceTheSameHashCode() {
public void ErrorCodesWithDifferentCodesMayProduceDifferentHashCodes() {
// Setup
ErrorCode errorCode1 = ErrorCode.Create("HASH_1");
ErrorCode.ResetForTests();
ErrorCode errorCode2 = ErrorCode.Create("HASH_2");

// Exercise
Expand All @@ -178,29 +163,19 @@ public void ErrorCodeComparedToObjectOfDifferentTypeIsNotEqual() {
Check.That(result).IsFalse();
}

[Fact(DisplayName = "Concurrent creation of the same error code results in a single registration.")]
public void ConcurrentCreationOfSameErrorCodeResultsInSingleRegistration() {
const string code = "CONCURRENT";

int successes = 0;
int failures = 0;
[Fact(DisplayName = "Concurrent creation of the same code always succeeds and yields equal instances.")]
public void ConcurrentCreationOfSameCodeAlwaysSucceedsAndYieldsEqualInstances() {
// Setup
const string code = "CONCURRENT";
ErrorCode reference = ErrorCode.Create(code);

Parallel.For(0, 20, _ => {
try {
ErrorCode.Create(code);
Interlocked.Increment(ref successes);
} catch (InvalidOperationException) {
Interlocked.Increment(ref failures);
}
});
ErrorCode[] created = new ErrorCode[20];

Check.That(successes).IsEqualTo(1);
Check.That(failures).IsEqualTo(19);
}
// Exercise
Parallel.For(0, 20, index => created[index] = ErrorCode.Create(code));

[SuppressMessage("Usage", "CA1816", Justification = "IDisposable is used as an xUnit teardown hook. The class has no finalizer and does not own unmanaged resources.")]
public void Dispose() {
ErrorCode.ResetForTests();
// Verify
Check.That(created.All(errorCode => errorCode == reference)).IsTrue();
}

}
}
2 changes: 0 additions & 2 deletions FirstClassErrors.UnitTests/ErrorContextImmutabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ public sealed class ErrorContextImmutabilityTests : IDisposable {

public ErrorContextImmutabilityTests() {
ErrorContextKey.ResetForTests();
ErrorCode.ResetForTests();
}

#endregion

[SuppressMessage("Usage", "CA1816", Justification = "xUnit teardown hook.")]
public void Dispose() {
ErrorContextKey.ResetForTests();
ErrorCode.ResetForTests();
}

[Fact(DisplayName = "The context values are read-only and cannot be mutated.")]
Expand Down
50 changes: 29 additions & 21 deletions FirstClassErrors.UnitTests/ErrorContextKeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,31 @@ public void RegisteringKeyWithBlankNameIsRejected(string? name) {
.WithMessage("Value cannot be null or whitespace. (Parameter 'name')");
}

[Fact(DisplayName = "Registering a key with a duplicate name is rejected.")]
public void RegisteringKeyWithDuplicateNameIsRejected() {
[Fact(DisplayName = "Re-declaring a key with the same name and type returns the registered instance.")]
public void RedeclaringKeyWithSameNameAndTypeReturnsTheRegisteredInstance() {
// Setup
const string name = "Duplicate";
ErrorContextKey.Create<string>(name);
const string name = "Duplicate";
ErrorContextKey<string> first = ErrorContextKey.Create<string>(name);

// Exercise & verify
Check.ThatCode(() => ErrorContextKey.Create<int>(name))
.Throws<InvalidOperationException>()
.WithMessage("An error context key 'Duplicate' has already been registered.");
// Exercise
ErrorContextKey<string> second = ErrorContextKey.Create<string>(name);

// Verify
Check.That(second).IsSameReferenceAs(first);
Check.That(ErrorContextKey.GetRegisteredKeys().Count).IsEqualTo(1);
}

[Fact(DisplayName = "Re-declaring a key with a different description keeps the first registered description.")]
public void RedeclaringKeyWithDifferentDescriptionKeepsTheFirstRegisteredDescription() {
// Setup
const string name = "DescribedTwice";
ErrorContextKey.Create<string>(name, "first");

// Exercise
ErrorContextKey<string> second = ErrorContextKey.Create<string>(name, "second");

// Verify
Check.That(second.Description).IsEqualTo("first");
}

[Fact(DisplayName = "Registering a key with the same name but a different type is rejected.")]
Expand All @@ -101,7 +116,8 @@ public void RegisteringKeyWithSameNameButDifferentTypeIsRejected() {

// Exercise & verify
Check.ThatCode(() => ErrorContextKey.Create<Guid>(name))
.Throws<InvalidOperationException>();
.Throws<InvalidOperationException>()
.WithMessage("An error context key 'SameName' is already registered with value type 'System.String'; it cannot be re-registered with value type 'System.Guid'.");
}

[Fact(DisplayName = "The registry returns all registered keys.")]
Expand Down Expand Up @@ -245,22 +261,14 @@ public void ConcurrentRegistrationOfSameKeyNameResultsInSingleRegistration() {
// Setup
const string name = "Concurrent";

int successes = 0;
int failures = 0;
ErrorContextKey<string>[] created = new ErrorContextKey<string>[20];

// Exercise
Parallel.For(0, 20, _ => {
try {
ErrorContextKey.Create<string>(name);
Interlocked.Increment(ref successes);
} catch (InvalidOperationException) {
Interlocked.Increment(ref failures);
}
});
Parallel.For(0, 20, index => created[index] = ErrorContextKey.Create<string>(name));

// Verify
Check.That(successes).IsEqualTo(1);
Check.That(failures).IsEqualTo(19);
Check.That(created.All(key => ReferenceEquals(key, created[0]))).IsTrue();
Check.That(ErrorContextKey.GetRegisteredKeys().Count).IsEqualTo(1);
}

[SuppressMessage("Usage", "CA1816",
Expand Down
2 changes: 0 additions & 2 deletions FirstClassErrors.UnitTests/ErrorDocumentationBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@ public sealed class ErrorDocumentationBuilderTests : IDisposable {

public ErrorDocumentationBuilderTests() {
ErrorContextKey.ResetForTests();
ErrorCode.ResetForTests();
}

#endregion

[SuppressMessage("Usage", "CA1816", Justification = "IDisposable is used as an xUnit teardown hook. The class has no finalizer and does not own unmanaged resources.")]
public void Dispose() {
ErrorContextKey.ResetForTests();
ErrorCode.ResetForTests();
}

[Fact(DisplayName = "An error documentation title cannot be null.")]
Expand Down
2 changes: 0 additions & 2 deletions FirstClassErrors.UnitTests/ErrorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ public sealed class ErrorTests : IDisposable {

public ErrorTests() {
ErrorContextKey.ResetForTests();
ErrorCode.ResetForTests();
}

#endregion

[SuppressMessage("Usage", "CA1816", Justification = "IDisposable is used as an xUnit teardown hook. The class has no finalizer and does not own unmanaged resources.")]
public void Dispose() {
ErrorContextKey.ResetForTests();
ErrorCode.ResetForTests();
}

[Fact(DisplayName = "An error has a unique instance identifier.")]
Expand Down
29 changes: 7 additions & 22 deletions FirstClassErrors/ErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ public sealed class ErrorCode : IEquatable<ErrorCode> {

#region Statics members declarations

private static readonly HashSet<string> Registered = new(StringComparer.Ordinal);
private static readonly object Lock = new();

/// <summary>
/// Represents an unspecified error condition. This is used as a default value when no specific error code is provided.
/// </summary>
Expand All @@ -18,36 +15,24 @@ public sealed class ErrorCode : IEquatable<ErrorCode> {
/// <summary>
/// Creates a new instance of the <see cref="ErrorCode" /> class with the specified code.
/// </summary>
/// <param name="code">The unique string identifier for the error condition.</param>
/// <param name="code">The string identifier for the error condition.</param>
/// <returns>A new <see cref="ErrorCode" /> instance representing the specified error condition.</returns>
/// <remarks>
/// An error code is a value: two <see cref="ErrorCode" /> instances built from the same <paramref name="code" />
/// compare equal, so creating the same code more than once is allowed and never throws. A code is an identity, not a
/// runtime registry entry — a duplicated code silently merges two distinct errors into one, which the <c>FCE001</c>
/// analyzer flags at build time.
/// </remarks>
/// <exception cref="ArgumentException">
/// Thrown when the <paramref name="code" /> is null, empty, or consists only of
/// whitespace.
/// </exception>
/// <exception cref="InvalidOperationException">Thrown when the <paramref name="code" /> has already been registered.</exception>
public static ErrorCode Create(string code) {
if (string.IsNullOrWhiteSpace(code)) { throw new ArgumentException("Error code cannot be null or whitespace.", nameof(code)); }

lock (Lock) {
if (!Registered.Add(code)) { throw new InvalidOperationException($"Error code '{code}' has already been registered."); }
}

return new ErrorCode(code);
}

/// <summary>
/// Resets the internal state of registered <see cref="ErrorCode" /> values.
/// </summary>
/// <remarks>
/// This method is intended for use in testing scenarios only. It clears all registered error codes,
/// allowing a clean slate for subsequent tests that rely on <see cref="ErrorCode" /> registration.
/// </remarks>
internal static void ResetForTests() {
lock (Lock) {
Registered.Clear();
}
}

#endregion

/// <summary>
Expand Down
Loading
Loading