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); +}