From 6757e2d1f795cb7336469676ca639c2d9c53a3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 16:27:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20NE0002=20=E2=80=94=20namespace=20must?= =?UTF-8?q?=20match=20folder=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the NE0002 analyzer and code fix: anchored at the RootNamespace MSBuild property, the declared namespace should equal RootNamespace joined with the file's folder path relative to the project directory, so the physical and logical layout stay aligned (comparable to Roslyn IDE0130, made configurable and RootNamespace-aware). - NamespaceMatchesFolderAnalyzer evaluates each top-level namespace declaration; nested namespaces are left to NE0003 and global-namespace files are out of scope - NamespaceMatchesFolderCodeFixProvider rewrites the namespace to the expected value (batch fix-all supported) - Shared FolderNamespace helper maps a file's folder path to its namespace; reused by the NE0003 flatten fix - RootNamespace and ProjectDir exposed via CompilerVisibleProperty - Honors the file-organization opt-outs (PublishSingleFile, NetEvolveAnalyzerDisableFileOrganizationRules) and skips generated code - Unit + integration tests, docs/rules/NE0002.md Closes #4 --- docs/rules/NE0002.md | 67 ++++++ .../AnalyzerReleases.Unshipped.md | 1 + src/NetEvolve.Analyzer/BuildProperty.cs | 10 + .../DiagnosticDescriptors.cs | 14 ++ src/NetEvolve.Analyzer/DiagnosticIds.cs | 5 + .../Maintainability/FolderNamespace.cs | 113 +++++++++ .../NamespaceMatchesFolderAnalyzer.cs | 94 ++++++++ .../NamespaceMatchesFolderCodeFixProvider.cs | 73 ++++++ .../build/NetEvolve.Analyzer.props | 2 + .../Maintainability/NamespaceCodeFixRunner.cs | 141 +++++++++++ .../NamespaceMatchesFolderAnalyzerTests.cs | 218 ++++++++++++++++++ .../NamespaceMatchesFolderCodeFixTests.cs | 55 +++++ .../Maintainability/FolderNamespaceTests.cs | 123 ++++++++++ .../NamespaceMatchesFolderAnalyzerTests.cs | 97 ++++++++ .../NamespaceMatchesFolderCodeFixTests.cs | 38 +++ .../NamespaceMatchesFolderCodeFixVerifier.cs | 59 +++++ .../NamespaceMatchesFolderVerifier.cs | 60 +++++ 17 files changed, 1170 insertions(+) create mode 100644 docs/rules/NE0002.md create mode 100644 src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs create mode 100644 src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderAnalyzer.cs create mode 100644 src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderCodeFixProvider.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceCodeFixRunner.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderCodeFixTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixVerifier.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderVerifier.cs diff --git a/docs/rules/NE0002.md b/docs/rules/NE0002.md new file mode 100644 index 0000000..961e3bd --- /dev/null +++ b/docs/rules/NE0002.md @@ -0,0 +1,67 @@ +# NE0002: Namespace should match the folder structure + +| Property | Value | +|------------|------------------| +| Rule ID | NE0002 | +| Category | Maintainability | +| Severity | Warning | +| Code fix | Yes | + +## Cause + +A file's top-level namespace declaration does not match the namespace derived from the file's folder path, +anchored at the `RootNamespace` MSBuild property. The expected namespace is `RootNamespace` joined with the +file's folder path relative to the project directory (`ProjectDir`). + +## Rule description + +Keeping the declared namespace aligned with the folder layout makes types predictable to locate and keeps the +physical and logical structure in lockstep. + +- The expected namespace is `RootNamespace` + the folder path (relative to `ProjectDir`), dot-joined. A file + at `/Shapes/Primitives/Circle.cs` with `RootNamespace` `Geometry` maps to + `Geometry.Shapes.Primitives`. +- A file directly in the project directory maps to `RootNamespace` exactly. +- Only the **top-level** namespace declaration is considered; nested namespaces are the concern of + [NE0003](NE0003.md). +- Files with no namespace declaration (the global namespace) are out of scope and never flagged. +- Generated code is skipped. + +### Open questions from the issue + +- **`RootNamespace` is required.** Without both `RootNamespace` and `ProjectDir` no reliable mapping exists, + so the rule stays silent (they are supplied by the SDK for normal builds). +- **Non-identifier folder segments are skipped.** When a folder segment is not a valid C# identifier + (for example, it starts with a digit), no namespace can be derived from it and the file is not flagged. +- **Files outside the project directory** have no folder-to-namespace mapping and are not flagged. + +## How to fix violations + +Rename the declared namespace to the folder-derived value, or move the file into the folder that matches its +namespace. A code fix is provided: + +- **Change namespace to '`Expected.Namespace`'** — rewrites the top-level namespace declaration's name to the + folder-derived namespace. The file is not moved. + +## Configuration + +The rule anchors on the standard MSBuild properties, which are compiler-visible by default: + +```xml + + + Geometry + + + true + +``` + +The rules are also disabled automatically for single-file deployments (`PublishSingleFile=true`). + +## Suppress a warning + +```csharp +#pragma warning disable NE0002 +#pragma warning restore NE0002 +``` diff --git a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md index 06e8b6c..726b5f5 100644 --- a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md +++ b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md @@ -6,3 +6,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- NE0001 | Maintainability | Warning | OneTypePerFileAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0001.md) +NE0002 | Maintainability | Warning | NamespaceMatchesFolderAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0002.md) diff --git a/src/NetEvolve.Analyzer/BuildProperty.cs b/src/NetEvolve.Analyzer/BuildProperty.cs index 6c4fcc9..be08a4f 100644 --- a/src/NetEvolve.Analyzer/BuildProperty.cs +++ b/src/NetEvolve.Analyzer/BuildProperty.cs @@ -27,4 +27,14 @@ internal static class BuildProperty /// a single file named after the base identifier. /// public const string GroupGenericOverloads = Prefix + "NetEvolveAnalyzerGroupGenericOverloads"; + + /// + /// RootNamespace — the namespace anchor NE0002 uses as the root of the folder-derived namespace. + /// + public const string RootNamespace = Prefix + "RootNamespace"; + + /// + /// ProjectDir — the project directory NE0002 measures a file's folder path against. + /// + public const string ProjectDir = Prefix + "ProjectDir"; } diff --git a/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs b/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs index 0bb778d..055b673 100644 --- a/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs +++ b/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs @@ -21,4 +21,18 @@ internal static class DiagnosticDescriptors + "overloads are encoded by arity unless overload grouping is enabled.", helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0001) ); + + /// NE0002 — the declared namespace should match the folder structure relative to RootNamespace. + public static readonly DiagnosticDescriptor NamespaceMatchesFolder = new( + id: DiagnosticIds.NE0002, + title: "Namespace should match the folder structure", + messageFormat: "Namespace '{0}' should be '{1}' to match the folder structure", + category: DiagnosticCategories.Maintainability, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Anchored at the RootNamespace MSBuild property, the declared namespace should equal " + + "RootNamespace joined with the file's folder path relative to the project directory, so the " + + "physical and logical layout stay aligned.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0002) + ); } diff --git a/src/NetEvolve.Analyzer/DiagnosticIds.cs b/src/NetEvolve.Analyzer/DiagnosticIds.cs index 9067843..b813802 100644 --- a/src/NetEvolve.Analyzer/DiagnosticIds.cs +++ b/src/NetEvolve.Analyzer/DiagnosticIds.cs @@ -24,6 +24,11 @@ internal static class DiagnosticIds /// public const string NE0001 = Prefix + "0001"; + /// + /// NE0002 — the declared namespace should match the folder structure, anchored at RootNamespace. + /// + public const string NE0002 = Prefix + "0002"; + /// 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/FolderNamespace.cs b/src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs new file mode 100644 index 0000000..6ad7f84 --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/FolderNamespace.cs @@ -0,0 +1,113 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// Computes the namespace a file should declare from its location relative to the project directory, anchored +/// at the RootNamespace MSBuild property. Shared by NamespaceMatchesFolderAnalyzer (NE0002) and +/// the NE0003 nested-namespace flatten fix, so both derive the same folder-anchored value. +/// +internal static class FolderNamespace +{ + private static readonly char[] PathSeparators = { '/' }; + + /// + /// Resolves the folder-derived namespace for . Returns + /// when the anchor properties (RootNamespace, ProjectDir) are missing, the file lives outside + /// the project directory, or a folder segment is not a valid C# identifier — in all of which cases no + /// reliable mapping exists and the caller should stay silent. + /// + /// The global analyzer-config options exposing the build properties. + /// The absolute (or project-relative) path of the source file. + /// The folder-derived namespace when the method returns . + public static bool TryResolve(AnalyzerConfigOptions globalOptions, string filePath, out string expected) + { + expected = string.Empty; + + if (string.IsNullOrEmpty(filePath)) + { + return false; + } + + if ( + !TryGetNonEmpty(globalOptions, BuildProperty.RootNamespace, out var rootNamespace) + || !TryGetNonEmpty(globalOptions, BuildProperty.ProjectDir, out var projectDir) + ) + { + return false; + } + + var directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory)) + { + // The file has no directory component, so it maps to the root namespace exactly. + expected = rootNamespace; + return true; + } + + if (!TryGetRelativeSegments(projectDir, directory!, out var segments)) + { + return false; + } + + if (segments.Count == 0) + { + // The file sits directly in the project directory: it maps to the root namespace exactly. + expected = rootNamespace; + return true; + } + + if (segments.Any(segment => !SyntaxFacts.IsValidIdentifier(segment))) + { + return false; + } + + expected = rootNamespace + "." + string.Join(".", segments); + return true; + } + + private static bool TryGetNonEmpty(AnalyzerConfigOptions options, string key, out string value) + { + if (options.TryGetValue(key, out var raw) && !string.IsNullOrEmpty(raw)) + { + value = raw; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryGetRelativeSegments(string projectDir, string directory, out List segments) + { + segments = new List(); + + var root = Normalize(projectDir); + var target = Normalize(directory); + + if (string.Equals(root, target, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var prefix = root + "/"; + if (!target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + // The file lives outside the project directory: no reliable folder-to-namespace mapping. + return false; + } + + segments = target + .Substring(prefix.Length) + .Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + return true; + } + + private static string Normalize(string path) => path.Replace('\\', '/').TrimEnd('/'); +} diff --git a/src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderAnalyzer.cs b/src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderAnalyzer.cs new file mode 100644 index 0000000..e2febcc --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderAnalyzer.cs @@ -0,0 +1,94 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// NE0002 — reports when a file's top-level namespace declaration does not match the folder-derived namespace, +/// anchored at the RootNamespace MSBuild property. The expected value is RootNamespace joined with +/// the file's folder path relative to the project directory (see ). Files without a +/// namespace (the global namespace) and nested namespace declarations are out of scope. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class NamespaceMatchesFolderAnalyzer : DiagnosticAnalyzer +{ + /// Diagnostic property key carrying the expected folder-derived namespace. + internal const string ExpectedNamespaceProperty = "ExpectedNamespace"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DiagnosticDescriptors.NamespaceMatchesFolder); + + /// + 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, BuildProperty.DisableFileOrganizationRules) + || GetBoolean(globalOptions, BuildProperty.PublishSingleFile) + ) + { + return; + } + + if (!FolderNamespace.TryResolve(globalOptions, filePath, out var expected)) + { + return; + } + + var root = context.Tree.GetRoot(context.CancellationToken); + + // NE0002 evaluates only namespaces declared directly under the compilation unit. Nested namespaces + // are left to NE0003, and source in the global namespace is out of scope. + foreach (var node in root.ChildNodes()) + { + if (node is not BaseNamespaceDeclarationSyntax declaration) + { + continue; + } + + var actual = declaration.Name.ToString(); + if (string.Equals(actual, expected, StringComparison.Ordinal)) + { + continue; + } + + // Surface the expected namespace so the code fix can rewrite the name without re-deriving it. + var properties = ImmutableDictionary.Empty.Add(ExpectedNamespaceProperty, expected); + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.NamespaceMatchesFolder, + declaration.Name.GetLocation(), + properties, + actual, + expected + ) + ); + } + } + + private static bool GetBoolean(AnalyzerConfigOptions options, string key) => + options.TryGetValue(key, out var value) && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderCodeFixProvider.cs b/src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderCodeFixProvider.cs new file mode 100644 index 0000000..25f3fc6 --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/NamespaceMatchesFolderCodeFixProvider.cs @@ -0,0 +1,73 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// Code fix for NE0002. Rewrites the flagged namespace +/// declaration's name to the folder-derived namespace carried on the diagnostic, keeping the change a local +/// document text edit (the file is not moved). Independent per-namespace edits merge, so batch fix-all applies. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NamespaceMatchesFolderCodeFixProvider))] +[Shared] +public sealed class NamespaceMatchesFolderCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0002); + + /// + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + // The fix only handles NE0002 diagnostics from NamespaceMatchesFolderAnalyzer, which always report at a + // top-level namespace name and always carry the ExpectedNamespace property. + var root = (await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false))!; + var diagnostic = context.Diagnostics[0]; + var declaration = root.FindNode(diagnostic.Location.SourceSpan) + .AncestorsAndSelf() + .OfType() + .First(); + + var expected = diagnostic.Properties[NamespaceMatchesFolderAnalyzer.ExpectedNamespaceProperty]!; + + context.RegisterCodeFix( + CodeAction.Create( + $"Change namespace to '{expected}'", + cancellationToken => ChangeNamespaceAsync(context.Document, declaration, expected, cancellationToken), + equivalenceKey: "NE0002.ChangeNamespace" + ), + diagnostic + ); + } + + private static async Task ChangeNamespaceAsync( + Document document, + BaseNamespaceDeclarationSyntax declaration, + string expected, + CancellationToken cancellationToken + ) + { + var root = (await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false))!; + + // Re-find the declaration in the freshly fetched root so the replaced node belongs to that same tree. + var current = root.FindNode(declaration.Span) + .AncestorsAndSelf() + .OfType() + .First(); + + var oldName = current.Name; + var newName = SyntaxFactory.ParseName(expected).WithTriviaFrom(oldName); + + return document.WithSyntaxRoot(root.ReplaceNode(oldName, newName)); + } +} diff --git a/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props b/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props index d4394c6..84f58aa 100644 --- a/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props +++ b/src/NetEvolve.Analyzer/build/NetEvolve.Analyzer.props @@ -7,5 +7,7 @@ + + diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceCodeFixRunner.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceCodeFixRunner.cs new file mode 100644 index 0000000..31fe44a --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceCodeFixRunner.cs @@ -0,0 +1,141 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Drives end-to-end through a real : +/// builds a project from documents added with an explicit file path (so can resolve +/// against ProjectDir), runs the analyzer to obtain the NE0002 diagnostic, registers the fix, applies the +/// resulting , and returns the final document text keyed by file path. +/// +internal static class NamespaceCodeFixRunner +{ + private static readonly ImmutableArray _references = ResolveFrameworkReferences(); + + public static async Task> ApplyAsync( + (string Path, string Content)[] sources, + (string Key, string Value)[]? properties = null, + CancellationToken cancellationToken = default + ) + { + using var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var solution = BuildSolution(workspace, projectId, sources, properties); + + var changed = await ApplyFixAsync(solution, projectId, cancellationToken).ConfigureAwait(false); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var document in changed.GetProject(projectId)!.Documents) + { + result[document.FilePath!] = ( + await document.GetTextAsync(cancellationToken).ConfigureAwait(false) + ).ToString(); + } + + return result; + } + + private static Solution BuildSolution( + AdhocWorkspace workspace, + ProjectId projectId, + (string Path, string Content)[] sources, + (string Key, string Value)[]? properties + ) + { + var projectInfo = ProjectInfo + .Create(projectId, VersionStamp.Default, "Sample", "Sample", LanguageNames.CSharp) + .WithMetadataReferences(_references) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var solution = workspace.CurrentSolution.AddProject(projectInfo); + foreach (var (path, content) in sources) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(projectId), + Path.GetFileName(path), + SourceText.From(content), + filePath: path + ); + } + + if (properties is not { Length: > 0 }) + { + return solution; + } + + 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'); + } + + return solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(projectId), + ".globalconfig", + SourceText.From(builder.ToString()), + filePath: "/.globalconfig" + ); + } + + private static async Task ApplyFixAsync( + Solution solution, + ProjectId projectId, + CancellationToken cancellationToken + ) + { + var project = solution.GetProject(projectId)!; + var compilation = (await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false))!; + + // S8949: the cancellation-token WithAnalyzers overload is obsolete; cancellation is honored by + // GetAnalyzerDiagnosticsAsync below. +#pragma warning disable S8949 + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new NamespaceMatchesFolderAnalyzer()), + project.AnalyzerOptions + ); +#pragma warning restore S8949 + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + var diagnostic = diagnostics.First(d => string.Equals(d.Id, DiagnosticIds.NE0002, StringComparison.Ordinal)); + var document = solution.GetDocument(diagnostic.Location.SourceTree)!; + + var actions = new List(); + var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); + await new NamespaceMatchesFolderCodeFixProvider().RegisterCodeFixesAsync(context).ConfigureAwait(false); + + if (actions.Count == 0) + { + return solution; + } + + var operations = await actions[0].GetOperationsAsync(cancellationToken).ConfigureAwait(false); + return operations.OfType().First().ChangedSolution; + } + + private static ImmutableArray ResolveFrameworkReferences() + { + var trustedAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!; + + return + [ + .. trustedAssemblies + .Split(Path.PathSeparator) + .Where(path => path.Length != 0) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)), + ]; + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs new file mode 100644 index 0000000..327206f --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs @@ -0,0 +1,218 @@ +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 NE0002 through the real +/// pipeline, where the tree path and the RootNamespace/ProjectDir build properties are fully +/// deterministic — the folder-to-namespace mapping the unit verifier cannot reliably reproduce. +/// +public sealed class NamespaceMatchesFolderAnalyzerTests +{ + private const string ExpectedNamespaceKey = NamespaceMatchesFolderAnalyzer.ExpectedNamespaceProperty; + + private static readonly (string Key, string Value)[] Anchor = + [ + ("RootNamespace", "Geometry"), + ("ProjectDir", "/proj"), + ]; + + private static bool IsNe0002(Microsoft.CodeAnalysis.Diagnostic diagnostic) => + string.Equals(diagnostic.Id, DiagnosticIds.NE0002, StringComparison.Ordinal); + + [Test] + public async Task Mismatch_ReportsNe0002() + { + const string source = """ + namespace Geometry.Shapes; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/proj/Shapes/Primitives/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Count(IsNe0002)).IsEqualTo(1); + await Assert + .That(diagnostics.Single(IsNe0002).Properties[ExpectedNamespaceKey]) + .IsEqualTo("Geometry.Shapes.Primitives"); + } + + [Test] + public async Task ExactMatch_ReportsNothing() + { + const string source = """ + namespace Geometry.Shapes.Primitives; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/proj/Shapes/Primitives/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } + + [Test] + public async Task FileInProjectRoot_MapsToRootNamespace_MismatchReported() + { + const string source = """ + namespace Wrong; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/proj/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Count(IsNe0002)).IsEqualTo(1); + await Assert.That(diagnostics.Single(IsNe0002).Properties[ExpectedNamespaceKey]).IsEqualTo("Geometry"); + } + + [Test] + public async Task FileInProjectRoot_MatchesRootNamespace_ReportsNothing() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/proj/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } + + [Test] + public async Task MissingBuildProperties_ReportsNothing() + { + const string source = """ + namespace Wrong; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new NamespaceMatchesFolderAnalyzer(), path: "/proj/Shapes/Circle.cs") + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } + + [Test] + public async Task FileOutsideProjectDir_ReportsNothing() + { + const string source = """ + namespace Wrong; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/other/Shapes/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } + + [Test] + public async Task NonIdentifierFolderSegment_ReportsNothing() + { + const string source = """ + namespace Wrong; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/proj/1Shapes/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } + + [Test] + public async Task NestedNamespace_TopLevelChecked_InnerIgnored() + { + // Only the top-level namespace is NE0002's concern; the nested declaration is NE0003's, so the + // top-level match here yields no NE0002 regardless of the inner name. + const string source = """ + namespace Geometry.Shapes + { + namespace Inner + { + public sealed class Circle { } + } + } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync( + source, + new NamespaceMatchesFolderAnalyzer(), + path: "/proj/Shapes/Circle.cs", + properties: Anchor + ) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } + + [Test] + public async Task WithoutFilePath_ReportsNothing() + { + const string source = """ + namespace Wrong; + + public sealed class Circle { } + """; + + var diagnostics = await AnalyzerCompiler + .GetAnalyzerDiagnosticsAsync(source, new NamespaceMatchesFolderAnalyzer(), properties: Anchor) + .ConfigureAwait(false); + + await Assert.That(diagnostics.Any(IsNe0002)).IsFalse(); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderCodeFixTests.cs new file mode 100644 index 0000000..280e1d5 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/NamespaceMatchesFolderCodeFixTests.cs @@ -0,0 +1,55 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Threading.Tasks; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end tests for the NE0002 code fix through a real AdhocWorkspace (see ), +/// where the file path and RootNamespace/ProjectDir are deterministic so the folder-derived +/// namespace resolves and the fix's actual apply pipeline is exercised. +/// +public sealed class NamespaceMatchesFolderCodeFixTests +{ + private static readonly (string Key, string Value)[] Anchor = + [ + ("RootNamespace", "Geometry"), + ("ProjectDir", "/proj"), + ]; + + [Test] + public async Task Mismatch_RewritesNamespaceToFolderDerived() + { + const string source = "namespace Geometry;\n\npublic sealed class Circle { }\n"; + + var result = await NamespaceCodeFixRunner + .ApplyAsync([("/proj/Shapes/Circle.cs", source)], properties: Anchor) + .ConfigureAwait(false); + + await Assert + .That( + result.TryGetValue("/proj/Shapes/Circle.cs", out var fixedText) + && fixedText.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal) + ) + .IsTrue(); + } + + [Test] + public async Task Mismatch_NestedFolder_RewritesToFullDottedNamespace() + { + const string source = "namespace Geometry.Shapes;\n\npublic sealed class Circle { }\n"; + + var result = await NamespaceCodeFixRunner + .ApplyAsync([("/proj/Shapes/Primitives/Circle.cs", source)], properties: Anchor) + .ConfigureAwait(false); + + await Assert + .That( + result.TryGetValue("/proj/Shapes/Primitives/Circle.cs", out var fixedText) + && fixedText.Contains("namespace Geometry.Shapes.Primitives;", StringComparison.Ordinal) + ) + .IsTrue(); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs new file mode 100644 index 0000000..672e581 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FolderNamespaceTests.cs @@ -0,0 +1,123 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Diagnostics; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Unit tests for the shared helper — the folder-to-namespace mapping that both +/// NE0002 and the NE0003 flatten fix rely on. Exercised directly so every branch (missing anchors, root-level +/// files, files outside the project, invalid folder segments) is covered without the analyzer harness. +/// +public sealed class FolderNamespaceTests +{ + [Test] + public async Task TryResolve_EmptyFilePath_ReturnsFalse() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", "Geometry"), ("ProjectDir", "/proj")), + "", + out _ + ); + + await Assert.That(resolved).IsFalse(); + } + + [Test] + public async Task TryResolve_MissingRootNamespaceOrProjectDir_ReturnsFalse() + { + var resolved = FolderNamespace.TryResolve(Options(), "/proj/Shapes/Circle.cs", out _); + + await Assert.That(resolved).IsFalse(); + } + + [Test] + public async Task TryResolve_NoDirectoryComponent_MapsToRootNamespace() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", "Geometry"), ("ProjectDir", "/proj")), + "Circle.cs", + out var expected + ); + + await Assert.That(resolved).IsTrue(); + await Assert.That(expected).IsEqualTo("Geometry"); + } + + [Test] + public async Task TryResolve_FileInProjectRoot_MapsToRootNamespace() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", "Geometry"), ("ProjectDir", "/proj")), + "/proj/Circle.cs", + out var expected + ); + + await Assert.That(resolved).IsTrue(); + await Assert.That(expected).IsEqualTo("Geometry"); + } + + [Test] + public async Task TryResolve_SubFolders_JoinsSegments() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", "Geometry"), ("ProjectDir", "/proj")), + "/proj/Shapes/Primitives/Circle.cs", + out var expected + ); + + await Assert.That(resolved).IsTrue(); + await Assert.That(expected).IsEqualTo("Geometry.Shapes.Primitives"); + } + + [Test] + public async Task TryResolve_OutsideProjectDir_ReturnsFalse() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", "Geometry"), ("ProjectDir", "/proj")), + "/other/Circle.cs", + out _ + ); + + await Assert.That(resolved).IsFalse(); + } + + [Test] + public async Task TryResolve_InvalidIdentifierSegment_ReturnsFalse() + { + var resolved = FolderNamespace.TryResolve( + Options(("RootNamespace", "Geometry"), ("ProjectDir", "/proj")), + "/proj/my-folder/Circle.cs", + out _ + ); + + await Assert.That(resolved).IsFalse(); + } + + private static FakeOptions Options(params (string Key, string Value)[] properties) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in properties) + { + builder["build_property." + key] = value; + } + + return new FakeOptions(builder.ToImmutable()); + } + + private sealed class FakeOptions : AnalyzerConfigOptions + { + private readonly ImmutableDictionary _values; + + public FakeOptions(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.Unit/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs new file mode 100644 index 0000000..83774f7 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderAnalyzerTests.cs @@ -0,0 +1,97 @@ +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 NamespaceMatchesFolderAnalyzer (NE0002), driven through the verifier harness. +public sealed class NamespaceMatchesFolderAnalyzerTests +{ + [Test] + public async Task Initialize_NullContext_ThrowsArgumentNullException() + { + var analyzer = new NamespaceMatchesFolderAnalyzer(); + ArgumentNullException? caught = null; + + try + { + analyzer.Initialize(null!); + } + catch (ArgumentNullException exception) + { + caught = exception; + } + + await Assert.That(caught).IsNotNull(); + } + + // ---- No mapping available: without RootNamespace/ProjectDir the analyzer stays silent ----------------- + + [Test] + public Task NoBuildProperties_NoDiagnostic() => + NamespaceMatchesFolderVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Whatever; + + public sealed class Circle { } + """ + ); + + [Test] + public Task RootNamespaceWithoutProjectDir_NoDiagnostic() => + NamespaceMatchesFolderVerifier.VerifyAsync( + "Circle.cs", + """ + namespace Whatever; + + public sealed class Circle { } + """, + ("RootNamespace", "Geometry") + ); + + [Test] + public Task GlobalNamespaceFile_OutOfScope_NoDiagnostic() => + NamespaceMatchesFolderVerifier.VerifyAsync("Circle.cs", "public sealed class Circle { }"); + + // ---- Opt-outs --------------------------------------------------------------------------------------- + + [Test] + public Task Disabled_ViaBuildProperty_NoDiagnostic() => + NamespaceMatchesFolderVerifier.VerifyAsync( + [ + ( + "Circle.cs", + """ + namespace Whatever; + + public sealed class Circle { } + """ + ), + ], + ("RootNamespace", "Geometry"), + ("ProjectDir", "/proj"), + ("NetEvolveAnalyzerDisableFileOrganizationRules", "true") + ); + + [Test] + public Task Disabled_ForSingleFilePublish_NoDiagnostic() => + NamespaceMatchesFolderVerifier.VerifyAsync( + [ + ( + "Circle.cs", + """ + namespace Whatever; + + public sealed class Circle { } + """ + ), + ], + ("RootNamespace", "Geometry"), + ("ProjectDir", "/proj"), + ("PublishSingleFile", "true") + ); +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixTests.cs new file mode 100644 index 0000000..8e0ad3f --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixTests.cs @@ -0,0 +1,38 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System.Threading.Tasks; +using TUnit.Core; + +/// +/// Tests for NamespaceMatchesFolderCodeFixProvider (NE0002). The rule fires only when a file's folder +/// path resolves against RootNamespace/ProjectDir; the unit verifier's project-directory +/// convention is opaque, so the substantive fix coverage lives in the deterministic integration runner. The +/// case kept here confirms that without an anchor mapping nothing is flagged and no fix is applied. +/// +public sealed class NamespaceMatchesFolderCodeFixTests +{ + [Test] + public Task NoBuildProperties_NoDiagnostic_NoFix() => + NamespaceMatchesFolderCodeFixVerifier.VerifyAsync( + [ + ( + "Circle.cs", + """ + namespace Whatever; + + public sealed class Circle { } + """ + ), + ], + [ + ( + "Circle.cs", + """ + namespace Whatever; + + public sealed class Circle { } + """ + ), + ] + ); +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixVerifier.cs new file mode 100644 index 0000000..842bd1e --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderCodeFixVerifier.cs @@ -0,0 +1,59 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using NetEvolve.Analyzer.Maintainability; + +/// +/// Applies to named source files (the rule is file-path +/// sensitive) and asserts the resulting set of files equals the expected fixed sources. Build properties are +/// injected through a global analyzer config, mirroring . +/// +internal static class NamespaceMatchesFolderCodeFixVerifier +{ + public static async Task VerifyAsync( + (string Name, string Content)[] sources, + (string Name, string Content)[] fixedSources, + params (string Key, string Value)[] properties + ) + { + var test = new CSharpCodeFixTest< + NamespaceMatchesFolderAnalyzer, + NamespaceMatchesFolderCodeFixProvider, + DefaultVerifier + > + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }; + + foreach (var (name, content) in sources) + { + test.TestState.Sources.Add((name, content)); + } + + foreach (var (name, content) in fixedSources) + { + test.FixedState.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'); + } + + // Declare the global config in both states: the fix carries it into the fixed solution, so the + // expected FixedState must contain it too, otherwise the analyzer-config comparison fails. + var config = builder.ToString(); + test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", config)); + test.FixedState.AnalyzerConfigFiles.Add(("/.globalconfig", config)); + } + + await test.RunAsync(CancellationToken.None).ConfigureAwait(false); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderVerifier.cs new file mode 100644 index 0000000..7c9af45 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/NamespaceMatchesFolderVerifier.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-path sensitive) and, optionally, a set of MSBuild build properties injected through a global analyzer +/// config. Diagnostics are declared inline with {|NE0002:Namespace|} markup. +/// +internal static class NamespaceMatchesFolderVerifier +{ + 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); +}