From f351d3107145826dd0dc153d6dea3b7a7ce56832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 12:20:04 +0200 Subject: [PATCH 1/2] feat: add NE0001 analyzer (one top-level type per file, matching file name) Implements #3. Reports files that declare more than one top-level type or whose single type does not match the file name. Generic overloads are arity-encoded by default (Result{T}.cs) or may share a base-named file when NetEvolveAnalyzerGroupGenericOverloads is set. The rule disables itself for single-file publish (PublishSingleFile) and via NetEvolveAnalyzerDisableFileOrganizationRules. - Analyzer + NE0001 registration + release-tracking entry + docs - Consumer build props expose the CompilerVisibleProperty values it reads - Namespace-scoped type identity, so distinct same-named types in different namespaces are each evaluated (adversarial-review finding) - File-name-aware unit verifier (named sources + build-property injection) and an integration harness with tree path + AnalyzerConfigOptions - 29 unit + 8 integration tests; src coverage: unit 98.9%, integration 88.2%, project 100% Also removes a stray line in AnalyzerReleases.Shipped.md that failed RS2007. Refs #3 #6 Co-Authored-By: Claude Opus 4.8 --- docs/rules/NE0001.md | 54 +++ .../AnalyzerReleases.Shipped.md | 1 - .../AnalyzerReleases.Unshipped.md | 1 + src/NetEvolve.Analyzer/DiagnosticIds.cs | 7 + .../Maintainability/.gitkeep | 0 .../Maintainability/OneTypePerFileAnalyzer.cs | 215 +++++++++++ .../NetEvolve.Analyzer.csproj | 5 + .../build/NetEvolve.Analyzer.props | 11 + .../AnalyzerCompiler.cs | 66 +++- .../OneTypePerFileAnalyzerTests.cs | 116 ++++++ .../OneTypePerFileAnalyzerTests.cs | 350 ++++++++++++++++++ .../Maintainability/OneTypePerFileVerifier.cs | 60 +++ 12 files changed, 880 insertions(+), 6 deletions(-) create mode 100644 docs/rules/NE0001.md delete mode 100644 src/NetEvolve.Analyzer/Maintainability/.gitkeep create mode 100644 src/NetEvolve.Analyzer/Maintainability/OneTypePerFileAnalyzer.cs create mode 100644 src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileAnalyzerTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileAnalyzerTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileVerifier.cs diff --git a/docs/rules/NE0001.md b/docs/rules/NE0001.md new file mode 100644 index 0000000..325f6ef --- /dev/null +++ b/docs/rules/NE0001.md @@ -0,0 +1,54 @@ +# NE0001: Declare one type per file with a matching file name + +| Property | Value | +|------------|------------------| +| Rule ID | NE0001 | +| Category | Maintainability | +| Severity | Warning | +| Code fix | Not yet (planned)| + +## Cause + +A file declares more than one top-level type, or its single top-level type has a name that does not match +the file name. Top-level types are `class`, `struct`, `record`, `record struct`, `interface`, `enum` and +`delegate` declarations that are not nested inside another type. + +## Rule description + +Keeping one type per file — with the file named after the type — makes types predictable to locate and +keeps diffs small. + +- One file declares exactly one top-level type. +- The file name equals the type name: `TypeName` ⇒ `TypeName.cs`. +- **Generic overloads.** By default, overloads that share a base name (`Result`, `Result`, + `Result`) are distinct types, each in its own arity-encoded file (`Result.cs`, `Result{T}.cs`, + `Result{T1,T2}.cs`). Set `NetEvolveAnalyzerGroupGenericOverloads` to `true` to let them share one file + named after the base identifier (`Result.cs`). +- `partial` parts of the same type in one file count as a single type. +- Nested types are ignored; only top-level declarations are considered. +- Generated code is skipped. + +## How to fix violations + +Move each extra type into its own file, and rename files so the name matches the contained type. + +## Configuration + +```xml + + + true + + + true + +``` + +The rules are also disabled automatically for single-file deployments (`PublishSingleFile=true`). + +## Suppress a warning + +```csharp +#pragma warning disable NE0001 +#pragma warning restore NE0001 +``` diff --git a/src/NetEvolve.Analyzer/AnalyzerReleases.Shipped.md b/src/NetEvolve.Analyzer/AnalyzerReleases.Shipped.md index dc99f47..f50bb1f 100644 --- a/src/NetEvolve.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/NetEvolve.Analyzer/AnalyzerReleases.Shipped.md @@ -1,3 +1,2 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md -re \ No newline at end of file diff --git a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md index 7b845f4..06e8b6c 100644 --- a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md +++ b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md @@ -5,3 +5,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +NE0001 | Maintainability | Warning | OneTypePerFileAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0001.md) diff --git a/src/NetEvolve.Analyzer/DiagnosticIds.cs b/src/NetEvolve.Analyzer/DiagnosticIds.cs index 3aad3b8..9067843 100644 --- a/src/NetEvolve.Analyzer/DiagnosticIds.cs +++ b/src/NetEvolve.Analyzer/DiagnosticIds.cs @@ -17,6 +17,13 @@ internal static class DiagnosticIds /// private const string HelpLinkBase = "https://github.com/dailydevops/analyzer/blob/main/docs/rules/"; + // Maintainability + + /// + /// NE0001 — each file should declare a single top-level type whose name matches the file name. + /// + public const string NE0001 = Prefix + "0001"; + /// Builds the documentation help link for a diagnostic identifier. /// The diagnostic identifier, e.g. NE0001. /// An absolute URI pointing at the rule's documentation. diff --git a/src/NetEvolve.Analyzer/Maintainability/.gitkeep b/src/NetEvolve.Analyzer/Maintainability/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileAnalyzer.cs b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileAnalyzer.cs new file mode 100644 index 0000000..8ef2ffa --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileAnalyzer.cs @@ -0,0 +1,215 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// NE0001 — reports when a file declares more than one top-level type, or when its single top-level type +/// does not match the file name. Generic overloads that share a base name (Result, Result<T>) +/// are, by default, treated as distinct types encoded by arity (Result{T}.cs); enabling +/// NetEvolveAnalyzerGroupGenericOverloads lets them share a single file named after the base identifier. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class OneTypePerFileAnalyzer : DiagnosticAnalyzer +{ + private const string GroupGenericOverloadsProperty = "build_property.NetEvolveAnalyzerGroupGenericOverloads"; + private const string DisableProperty = "build_property.NetEvolveAnalyzerDisableFileOrganizationRules"; + private const string PublishSingleFileProperty = "build_property.PublishSingleFile"; + + /// The descriptor for NE0001. + internal static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticIds.NE0001, + title: "Declare one type per file with a matching file name", + messageFormat: "Type '{0}' should be declared in its own file named '{1}.cs'", + category: DiagnosticCategories.Maintainability, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Each top-level type should live in its own file whose name matches the type. Generic " + + "overloads are encoded by arity unless overload grouping is enabled.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0001) + ); + + /// + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxTreeAction(AnalyzeTree); + } + + private static void AnalyzeTree(SyntaxTreeAnalysisContext context) + { + var filePath = context.Tree.FilePath; + if (string.IsNullOrEmpty(filePath)) + { + return; + } + + var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + if (GetBoolean(globalOptions, DisableProperty) || GetBoolean(globalOptions, PublishSingleFileProperty)) + { + return; + } + + var groupGenericOverloads = GetBoolean(globalOptions, GroupGenericOverloadsProperty); + var root = context.Tree.GetRoot(context.CancellationToken); + + var groups = GroupTopLevelTypes(root, groupGenericOverloads); + if (groups.Count == 0) + { + return; + } + + var fileName = Path.GetFileNameWithoutExtension(filePath); + var primary = groups.FirstOrDefault(group => + string.Equals(group.ExpectedFileName, fileName, StringComparison.Ordinal) + ); + + foreach (var group in groups) + { + if (ReferenceEquals(group, primary)) + { + continue; + } + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + group.First.Identifier.GetLocation(), + group.First.Display, + group.ExpectedFileName + ) + ); + } + } + + private static List GroupTopLevelTypes(SyntaxNode root, bool groupGenericOverloads) + { + var groups = new List(); + var index = new Dictionary(StringComparer.Ordinal); + + foreach (var node in root.DescendantNodes().Where(IsTopLevelTypeDeclaration)) + { + var type = TypeDescriptor.From(node); + + // The identity key is scoped by namespace so that only genuine partial parts (same namespace, + // name and arity) collapse into one group; two distinct same-named types in different namespaces + // remain separate types and are each evaluated. + var identity = groupGenericOverloads ? type.Name : type.MetadataName; + var key = GetNamespaceName(node) + "::" + identity; + if (!index.TryGetValue(key, out var group)) + { + group = new TypeGroup(type, groupGenericOverloads); + index.Add(key, group); + groups.Add(group); + } + } + + return groups; + } + + private static string GetNamespaceName(SyntaxNode node) + { + var segments = new List(); + for (var current = node.Parent; current is not null; current = current.Parent) + { + if (current is BaseNamespaceDeclarationSyntax namespaceDeclaration) + { + segments.Add(namespaceDeclaration.Name.ToString()); + } + } + + segments.Reverse(); + return string.Join(".", segments); + } + + private static bool IsTopLevelTypeDeclaration(SyntaxNode node) => + node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax + && node.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax; + + private static bool GetBoolean(AnalyzerConfigOptions options, string key) => + options.TryGetValue(key, out var value) && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + + /// A single top-level type declaration reduced to the facts NE0001 needs. + private readonly struct TypeDescriptor + { + private TypeDescriptor(SyntaxToken identifier, string name, ImmutableArray typeParameters) + { + Identifier = identifier; + Name = name; + TypeParameters = typeParameters; + } + + public SyntaxToken Identifier { get; } + + public string Name { get; } + + public ImmutableArray TypeParameters { get; } + + public string MetadataName => + TypeParameters.IsEmpty ? Name : Name + "`" + TypeParameters.Length.ToString(CultureInfo.InvariantCulture); + + public string Display => TypeParameters.IsEmpty ? Name : Name + "<" + string.Join(", ", TypeParameters) + ">"; + + public string ArityEncodedFileName => + TypeParameters.IsEmpty ? Name : Name + "{" + string.Join(",", TypeParameters) + "}"; + + public static TypeDescriptor From(SyntaxNode node) + { + if (node is TypeDeclarationSyntax type) + { + return new TypeDescriptor( + type.Identifier, + type.Identifier.ValueText, + GetTypeParameters(type.TypeParameterList) + ); + } + + if (node is DelegateDeclarationSyntax @delegate) + { + return new TypeDescriptor( + @delegate.Identifier, + @delegate.Identifier.ValueText, + GetTypeParameters(@delegate.TypeParameterList) + ); + } + + var @enum = (EnumDeclarationSyntax)node; + return new TypeDescriptor(@enum.Identifier, @enum.Identifier.ValueText, ImmutableArray.Empty); + } + + private static ImmutableArray GetTypeParameters(TypeParameterListSyntax? list) => + list is null + ? ImmutableArray.Empty + : list.Parameters.Select(parameter => parameter.Identifier.ValueText).ToImmutableArray(); + } + + /// All declarations that share one type identity (partial parts, or grouped generic overloads). + private sealed class TypeGroup + { + public TypeGroup(TypeDescriptor first, bool groupGenericOverloads) + { + First = first; + ExpectedFileName = groupGenericOverloads ? first.Name : first.ArityEncodedFileName; + } + + public TypeDescriptor First { get; } + + public string ExpectedFileName { get; } + } +} diff --git a/src/NetEvolve.Analyzer/NetEvolve.Analyzer.csproj b/src/NetEvolve.Analyzer/NetEvolve.Analyzer.csproj index d0f5fe4..b63787c 100644 --- a/src/NetEvolve.Analyzer/NetEvolve.Analyzer.csproj +++ b/src/NetEvolve.Analyzer/NetEvolve.Analyzer.csproj @@ -42,6 +42,11 @@ + + + + + diff --git a/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props b/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props new file mode 100644 index 0000000..d4394c6 --- /dev/null +++ b/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/test/NetEvolve.Analyzer.Tests.Integration/AnalyzerCompiler.cs b/test/NetEvolve.Analyzer.Tests.Integration/AnalyzerCompiler.cs index b90e30d..d82ee62 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/AnalyzerCompiler.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/AnalyzerCompiler.cs @@ -2,6 +2,7 @@ namespace NetEvolve.Analyzer.Tests.Integration; using System; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; @@ -20,10 +21,17 @@ internal static class AnalyzerCompiler private static readonly ImmutableArray _references = ResolveFrameworkReferences(); /// Creates a compilation for against the running framework's assemblies. - public static CSharpCompilation CreateCompilation(string source, CancellationToken cancellationToken = default) => + public static CSharpCompilation CreateCompilation( + string source, + string? path = null, + CancellationToken cancellationToken = default + ) => CSharpCompilation.Create( assemblyName: "NetEvolve.Analyzer.Integration.Sample", - syntaxTrees: [CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken)], + syntaxTrees: + [ + CSharpSyntaxTree.ParseText(source, path: path ?? string.Empty, cancellationToken: cancellationToken), + ], references: _references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); @@ -32,16 +40,31 @@ public static CSharpCompilation CreateCompilation(string source, CancellationTok public static ImmutableArray GetCompilerDiagnostics( string source, CancellationToken cancellationToken = default - ) => CreateCompilation(source, cancellationToken).GetDiagnostics(cancellationToken); + ) => CreateCompilation(source, cancellationToken: cancellationToken).GetDiagnostics(cancellationToken); - /// Runs over a compilation of . + /// + /// Runs over a compilation of , giving the tree the + /// supplied and exposing as MSBuild build properties. + /// public static async Task> GetAnalyzerDiagnosticsAsync( string source, DiagnosticAnalyzer analyzer, + string? path = null, + (string Key, string Value)[]? properties = null, CancellationToken cancellationToken = default ) { - var withAnalyzers = CreateCompilation(source, cancellationToken).WithAnalyzers(ImmutableArray.Create(analyzer)); + var options = new AnalyzerOptions( + ImmutableArray.Empty, + new BuildPropertyOptionsProvider(properties) + ); + + // S8949: the cancellation-token WithAnalyzers overload is obsolete; cancellation is honored by the + // GetAnalyzerDiagnosticsAsync call below, which is the only place work actually happens. +#pragma warning disable S8949 + var withAnalyzers = CreateCompilation(source, path, cancellationToken) + .WithAnalyzers(ImmutableArray.Create(analyzer), options); +#pragma warning restore S8949 return await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); } @@ -58,4 +81,37 @@ .. trustedAssemblies .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)), ]; } + + /// Surfaces the given build_property.* pairs through . + private sealed class BuildPropertyOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly BuildPropertyOptions _options; + + public BuildPropertyOptionsProvider((string Key, string Value)[]? properties) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in properties ?? []) + { + builder["build_property." + key] = value; + } + + _options = new BuildPropertyOptions(builder.ToImmutable()); + } + + public override AnalyzerConfigOptions GlobalOptions => _options; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _options; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _options; + + private sealed class BuildPropertyOptions : AnalyzerConfigOptions + { + private readonly ImmutableDictionary _values; + + public BuildPropertyOptions(ImmutableDictionary values) => _values = values; + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) => + _values.TryGetValue(key, out value); + } + } } diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileAnalyzerTests.cs new file mode 100644 index 0000000..cd60e71 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileAnalyzerTests.cs @@ -0,0 +1,116 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Linq; +using System.Threading.Tasks; +using NetEvolve.Analyzer; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end tests for NE0001 through the real +/// pipeline, covering the parts the unit verifier cannot reach: an absent file path and generated-code skipping. +/// +public sealed class OneTypePerFileAnalyzerTests +{ + private const string TwoTypes = """ + namespace Geometry; + + public sealed class Circle { } + + public sealed class Square { } + """; + + private static bool IsNe0001(Microsoft.CodeAnalysis.Diagnostic diagnostic) => + string.Equals(diagnostic.Id, DiagnosticIds.NE0001, StringComparison.Ordinal); + + [Test] + public async Task Violation_WithFilePath_ReportsNe0001() + { + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(TwoTypes, new OneTypePerFileAnalyzer(), path: "Shapes.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Count(IsNe0001)).IsEqualTo(2); + } + + [Test] + public async Task Compliant_SingleMatchingType_ReportsNothing() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new OneTypePerFileAnalyzer(), path: "Circle.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0001)).IsFalse(); + } + + [Test] + public async Task WithoutFilePath_ReportsNothing() + { + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(TwoTypes, new OneTypePerFileAnalyzer()) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0001)).IsFalse(); + } + + [Test] + public async Task GeneratedFileByName_IsSkipped() + { + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(TwoTypes, new OneTypePerFileAnalyzer(), path: "Shapes.designer.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0001)).IsFalse(); + } + + [Test] + public async Task GeneratedFileByHeader_IsSkipped() + { + const string source = """ + // + namespace Geometry; + + public sealed class Circle { } + + public sealed class Square { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new OneTypePerFileAnalyzer(), path: "Shapes.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0001)).IsFalse(); + } + + [Test] + public async Task GroupGenericOverloads_Property_SuppressesArityViolation() + { + const string source = """ + namespace Geometry; + + public readonly struct Result { } + + public readonly struct Result { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new OneTypePerFileAnalyzer(), + path: "Result.cs", + properties: [("NetEvolveAnalyzerGroupGenericOverloads", "true")] + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0001)).IsFalse(); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileAnalyzerTests.cs new file mode 100644 index 0000000..8454ebc --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileAnalyzerTests.cs @@ -0,0 +1,350 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System; +using System.Threading.Tasks; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// Unit tests for OneTypePerFileAnalyzer (NE0001), driven through the verifier harness. +public sealed class OneTypePerFileAnalyzerTests +{ + [Test] + public async Task Initialize_NullContext_ThrowsArgumentNullException() + { + var analyzer = new OneTypePerFileAnalyzer(); + ArgumentNullException? caught = null; + + try + { + analyzer.Initialize(null!); + } + catch (ArgumentNullException exception) + { + caught = exception; + } + + await Assert.That(caught).IsNotNull(); + } + + // ---- Compliant: single type matching the file name -------------------------------------------------- + + [Test] + public Task SingleType_FileScopedNamespace_MatchingName_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Geometry; + + public sealed class Circle { } + """ + ); + + [Test] + public Task SingleType_BlockNamespace_MatchingName_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Geometry + { + public sealed class Circle { } + } + """ + ); + + [Test] + public Task SingleType_NoNamespace_MatchingName_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync("Circle.cs", "public sealed class Circle { }"); + + [Test] + public Task NestedTypes_AreIgnored_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Outer.cs", + """ + namespace Geometry; + + public sealed class Outer + { + private sealed class Inner { } + + private enum Kind { One } + } + """ + ); + + [Test] + public Task PartialType_MultipleParts_SameFile_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Geometry; + + public sealed partial class Circle { } + + public sealed partial class Circle { } + """ + ); + + [Test] + public Task FileWithoutTopLevelType_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync("Empty.cs", "// intentionally without a top-level type"); + + // ---- Non-compliant: name mismatch and multiple types ------------------------------------------------ + + [Test] + public Task SingleType_NameMismatch_Diagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Shapes.cs", + """ + namespace Geometry; + + public sealed class {|NE0001:Circle|} { } + """ + ); + + [Test] + public Task SingleType_NoNamespace_NameMismatch_Diagnostic() => + OneTypePerFileVerifier.VerifyAsync("Wrong.cs", "public sealed class {|NE0001:Circle|} { }"); + + [Test] + public Task MultipleTypes_NoMatchingName_AllFlagged() => + OneTypePerFileVerifier.VerifyAsync( + "Shapes.cs", + """ + namespace Geometry; + + public sealed class {|NE0001:Circle|} { } + + public sealed class {|NE0001:Square|} { } + """ + ); + + [Test] + public Task MultipleTypes_OneMatchesFileName_OnlyOthersFlagged() => + OneTypePerFileVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Geometry; + + public sealed class Circle { } + + public sealed class {|NE0001:Square|} { } + """ + ); + + [Test] + public Task SameName_DifferentNamespaces_AreDistinctTypes_AllFlagged() => + OneTypePerFileVerifier.VerifyAsync( + "Types.cs", + """ + namespace Models + { + public sealed class {|NE0001:Item|} { } + } + + namespace Dtos + { + public sealed class {|NE0001:Item|} { } + } + """ + ); + + [Test] + public Task SameName_DifferentNamespaces_OneMatchesFileName_OnlyOtherFlagged() => + OneTypePerFileVerifier.VerifyAsync( + "Item.cs", + """ + namespace Models + { + public sealed class Item { } + } + + namespace Dtos + { + public sealed class {|NE0001:Item|} { } + } + """ + ); + + [Test] + public Task PartialType_NameMismatch_ReportedOnce() => + OneTypePerFileVerifier.VerifyAsync( + "Wrong.cs", + """ + namespace Geometry; + + public sealed partial class {|NE0001:Circle|} { } + + public sealed partial class Circle { } + """ + ); + + // ---- Type kinds ------------------------------------------------------------------------------------- + + [Test] + public Task Enum_NameMismatch_Diagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Wrong.cs", + """ + namespace Geometry; + + public enum {|NE0001:Color|} { Red } + """ + ); + + [Test] + public Task Delegate_NameMismatch_Diagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Wrong.cs", + """ + namespace Geometry; + + public delegate void {|NE0001:Handler|}(); + """ + ); + + [Test] + public Task GenericDelegate_NameMismatch_Diagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Wrong.cs", + """ + namespace Geometry; + + public delegate T {|NE0001:Factory|}(); + """ + ); + + [Test] + public Task Record_NameMismatch_Diagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Wrong.cs", + """ + namespace Geometry; + + public sealed record {|NE0001:Point|}(int X, int Y); + """ + ); + + // ---- Generic overloads: strict (default) ------------------------------------------------------------ + + [Test] + public Task Generic_Strict_NonGenericInBaseNamedFile_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Result.cs", + """ + namespace Geometry; + + public readonly struct Result { } + """ + ); + + [Test] + public Task Generic_Strict_ArityEncodedFileName_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Result{T}.cs", + """ + namespace Geometry; + + public readonly struct Result { } + """ + ); + + [Test] + public Task Generic_Strict_TwoArgArityEncodedFileName_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + "Result{T1,T2}.cs", + """ + namespace Geometry; + + public readonly struct Result { } + """ + ); + + [Test] + public Task Generic_Strict_OverloadsGroupedInBaseFile_ExtraFlagged() => + OneTypePerFileVerifier.VerifyAsync( + "Result.cs", + """ + namespace Geometry; + + public readonly struct Result { } + + public readonly struct {|NE0001:Result|} { } + """ + ); + + // ---- Generic overloads: grouping enabled ------------------------------------------------------------ + + [Test] + public Task Generic_Grouped_AllOverloadsInBaseFile_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + [ + ( + "Result.cs", + """ + namespace Geometry; + + public readonly struct Result { } + + public readonly struct Result { } + + public readonly struct Result { } + """ + ), + ], + ("NetEvolveAnalyzerGroupGenericOverloads", "true") + ); + + [Test] + public Task Generic_Grouped_FileNameMismatch_ReportedOnce() => + OneTypePerFileVerifier.VerifyAsync( + [ + ( + "Shapes.cs", + """ + namespace Geometry; + + public readonly struct {|NE0001:Result|} { } + + public readonly struct Result { } + """ + ), + ], + ("NetEvolveAnalyzerGroupGenericOverloads", "true") + ); + + // ---- Opt-outs --------------------------------------------------------------------------------------- + + [Test] + public Task Disabled_ViaBuildProperty_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + [ + ( + "Shapes.cs", + """ + namespace Geometry; + + public sealed class Circle { } + """ + ), + ], + ("NetEvolveAnalyzerDisableFileOrganizationRules", "true") + ); + + [Test] + public Task Disabled_ForSingleFilePublish_NoDiagnostic() => + OneTypePerFileVerifier.VerifyAsync( + [ + ( + "Shapes.cs", + """ + namespace Geometry; + + public sealed class Circle { } + """ + ), + ], + ("PublishSingleFile", "true") + ); +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileVerifier.cs new file mode 100644 index 0000000..74b0246 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileVerifier.cs @@ -0,0 +1,60 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Runs against one or more named source files (the analyzer is +/// file-name sensitive) and, optionally, a set of MSBuild build properties injected through a global +/// analyzer config. Diagnostics are declared inline with {|NE0001:Identifier|} markup. +/// +internal static class OneTypePerFileVerifier +{ + public static async Task VerifyAsync( + (string Name, string Content)[] sources, + params (string Key, string Value)[] properties + ) + { + var test = new CSharpAnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }; + + foreach (var (name, content) in sources) + { + test.TestState.Sources.Add((name, content)); + } + + if (properties.Length > 0) + { + var builder = new StringBuilder("is_global = true\n"); + foreach (var (key, value) in properties) + { + _ = builder.Append("build_property.").Append(key).Append(" = ").Append(value).Append('\n'); + } + + var config = builder.ToString(); + test.SolutionTransforms.Add( + (solution, projectId) => + solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(projectId), + ".globalconfig", + SourceText.From(config), + filePath: "/.globalconfig" + ) + ); + } + + await test.RunAsync(CancellationToken.None).ConfigureAwait(false); + } + + /// Convenience overload for a single named source file. + public static Task VerifyAsync(string name, string content, params (string Key, string Value)[] properties) => + VerifyAsync([(name, content)], properties); +} From 8381a08b1ae60aff77a049e483b563a22eeb0f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 13:06:18 +0200 Subject: [PATCH 2/2] fix(test): suppress IDE0058 in CI via test/Directory.Build.props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test-tree .editorconfig that disabled IDE0058 for TUnit's awaited assertions is git-ignored (.gitignore rule **/.editorconfig, keeping only the root .editorconfig), so it never reached CI. There, code-style enforcement failed on every `await Assert.That(...)` expression statement — including the pre-existing seed tests. The root .editorconfig is template-managed ("DO NOT CHANGE SETTINGS IN THIS FILE"). Add a committed test/Directory.Build.props that chains to the root props and sets NoWarn=IDE0058 for all test projects (current and future). Co-Authored-By: Claude Opus 4.8 --- test/Directory.Build.props | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 test/Directory.Build.props diff --git a/test/Directory.Build.props b/test/Directory.Build.props new file mode 100644 index 0000000..c8a7802 --- /dev/null +++ b/test/Directory.Build.props @@ -0,0 +1,16 @@ + + + + + + + $(NoWarn);IDE0058 + +