diff --git a/docs/rules/NE0003.md b/docs/rules/NE0003.md
new file mode 100644
index 0000000..095ee41
--- /dev/null
+++ b/docs/rules/NE0003.md
@@ -0,0 +1,55 @@
+# NE0003: Declare a single namespace per file
+
+| Property | Value |
+|------------|------------------|
+| Rule ID | NE0003 |
+| Category | Maintainability |
+| Severity | Warning |
+| Code fix | Yes |
+
+## Cause
+
+A file declares more than one namespace. This covers both shapes: sibling namespaces (two or more
+declarations at the top level) and nested namespaces (one declared inside another), whether written with block
+(`namespace X { ... }`) or file-scoped (`namespace X;`) syntax.
+
+## Rule description
+
+Keeping one namespace per file keeps types findable: the folder-to-namespace and name-to-file conventions the
+other organization rules establish only hold when a file maps to a single namespace.
+
+- All namespace declarations in the file are collected in document order.
+- When there is more than one, every declaration except the first (the outermost, which is kept) is flagged.
+- Generated code is skipped.
+
+## How to fix violations
+
+Split the file so each namespace lives in its own file, or collapse a nested namespace into a single one.
+A code fix is provided for the nested shape:
+
+- **Flatten to a single namespace** — offered when the flagged namespace is nested inside another. It rewrites
+ the whole file to a single file-scoped namespace containing every top-level type. The target namespace is
+ the folder-derived namespace (`RootNamespace` joined with the file's folder path relative to `ProjectDir`);
+ when that anchor is unavailable it falls back to the literal concatenation of the nested namespace chain
+ (for example `Outer.Inner`).
+
+Multiple **sibling** namespaces are not flattened here — that is resolved via NE0001's *move type* fix, which
+relocates the extra types into their own correctly named files.
+
+## Configuration
+
+```xml
+
+
+ true
+
+```
+
+The rules are also disabled automatically for single-file deployments (`PublishSingleFile=true`).
+
+## Suppress a warning
+
+```csharp
+#pragma warning disable NE0003
+#pragma warning restore NE0003
+```
diff --git a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md
index 726b5f5..2b1d74d 100644
--- a/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md
+++ b/src/NetEvolve.Analyzer/AnalyzerReleases.Unshipped.md
@@ -7,3 +7,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)
+NE0003 | Maintainability | Warning | SingleNamespacePerFileAnalyzer, [Documentation](https://github.com/dailydevops/analyzer/blob/main/docs/rules/NE0003.md)
diff --git a/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs b/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs
index 055b673..5eef74a 100644
--- a/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs
+++ b/src/NetEvolve.Analyzer/DiagnosticDescriptors.cs
@@ -35,4 +35,18 @@ internal static class DiagnosticDescriptors
+ "physical and logical layout stay aligned.",
helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0002)
);
+
+ /// NE0003 — a file should declare exactly one namespace.
+ public static readonly DiagnosticDescriptor SingleNamespacePerFile = new(
+ id: DiagnosticIds.NE0003,
+ title: "Declare a single namespace per file",
+ messageFormat: "Declare exactly one namespace per file",
+ category: DiagnosticCategories.Maintainability,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "A file that declares more than one namespace (sibling or nested) hides types from the "
+ + "name-to-location mapping the other organization rules establish. Declare exactly one namespace "
+ + "per file.",
+ helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.NE0003)
+ );
}
diff --git a/src/NetEvolve.Analyzer/DiagnosticIds.cs b/src/NetEvolve.Analyzer/DiagnosticIds.cs
index b813802..ea579b1 100644
--- a/src/NetEvolve.Analyzer/DiagnosticIds.cs
+++ b/src/NetEvolve.Analyzer/DiagnosticIds.cs
@@ -29,6 +29,11 @@ internal static class DiagnosticIds
///
public const string NE0002 = Prefix + "0002";
+ ///
+ /// NE0003 — a file should declare exactly one namespace.
+ ///
+ public const string NE0003 = Prefix + "0003";
+
/// 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/SingleNamespacePerFileAnalyzer.cs b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileAnalyzer.cs
new file mode 100644
index 0000000..46cb62a
--- /dev/null
+++ b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileAnalyzer.cs
@@ -0,0 +1,81 @@
+namespace NetEvolve.Analyzer.Maintainability;
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+///
+/// NE0003 — reports when a file declares more than one namespace. All namespace declarations (block- and
+/// file-scoped, including nested) are collected in document order; when there is more than one, every
+/// declaration except the first is flagged, so a file always narrows to a single namespace.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class SingleNamespacePerFileAnalyzer : DiagnosticAnalyzer
+{
+ /// Diagnostic property key: "true" when the flagged namespace is nested inside another.
+ internal const string NestedProperty = "Nested";
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DiagnosticDescriptors.SingleNamespacePerFile);
+
+ ///
+ 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 globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions;
+ if (
+ GetBoolean(globalOptions, BuildProperty.DisableFileOrganizationRules)
+ || GetBoolean(globalOptions, BuildProperty.PublishSingleFile)
+ )
+ {
+ return;
+ }
+
+ var root = context.Tree.GetRoot(context.CancellationToken);
+
+ // Document order (pre-order) puts a parent namespace before the child it contains, so the first
+ // declaration is always the outermost one and is the one we keep.
+ var namespaces = root.DescendantNodes().OfType().ToList();
+ if (namespaces.Count <= 1)
+ {
+ return;
+ }
+
+ for (var index = 1; index < namespaces.Count; index++)
+ {
+ var declaration = namespaces[index];
+
+ // Surface whether the flagged declaration is nested so the code fix offers flatten only for the
+ // nested shape; the sibling shape is left to NE0001's move-type fix.
+ var nested = declaration.Ancestors().OfType().Any();
+ var value = nested ? "true" : "false";
+ var properties = ImmutableDictionary.Empty.Add(NestedProperty, value);
+
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ DiagnosticDescriptors.SingleNamespacePerFile,
+ declaration.Name.GetLocation(),
+ properties
+ )
+ );
+ }
+ }
+
+ 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/SingleNamespacePerFileCodeFixProvider.cs b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs
new file mode 100644
index 0000000..9eb30db
--- /dev/null
+++ b/src/NetEvolve.Analyzer/Maintainability/SingleNamespacePerFileCodeFixProvider.cs
@@ -0,0 +1,161 @@
+namespace NetEvolve.Analyzer.Maintainability;
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Composition;
+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.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+///
+/// Code fix for NE0003. Offered only for the nested shape
+/// (Nested == "true"): flattens the whole file to a single file-scoped namespace holding every top-level
+/// type. The sibling shape is intentionally left to NE0001's move-type fix, so no action is offered there.
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SingleNamespacePerFileCodeFixProvider))]
+[Shared]
+public sealed class SingleNamespacePerFileCodeFixProvider : CodeFixProvider
+{
+ ///
+ public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0003);
+
+ ///
+ // A whole-file rewrite does not compose safely across many diagnostics, so no batch fix-all.
+ public override FixAllProvider? GetFixAllProvider() => null;
+
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var diagnostic = context.Diagnostics[0];
+
+ // Only the nested shape is flattened here; the sibling shape is resolved via NE0001's move-type fix.
+ var nested = string.Equals(
+ diagnostic.Properties[SingleNamespacePerFileAnalyzer.NestedProperty],
+ "true",
+ StringComparison.Ordinal
+ );
+ if (!nested)
+ {
+ return;
+ }
+
+ var root = (CompilationUnitSyntax)
+ (await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false))!;
+ var declaration = (BaseNamespaceDeclarationSyntax)
+ root.FindNode(diagnostic.Location.SourceSpan)
+ .AncestorsAndSelf()
+ .First(node => node is BaseNamespaceDeclarationSyntax);
+
+ var target = ResolveTargetNamespace(context.Document, declaration);
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ "Flatten to a single namespace",
+ cancellationToken => FlattenAsync(context.Document, target, cancellationToken),
+ equivalenceKey: "NE0003.Flatten"
+ ),
+ diagnostic
+ );
+ }
+
+ private static async Task FlattenAsync(
+ Document document,
+ string target,
+ CancellationToken cancellationToken
+ )
+ {
+ var root = (CompilationUnitSyntax)(await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false))!;
+
+ // Preserve the original file's final-newline style: trim trailing blank lines left by the rewrite, then
+ // re-add a single newline only if the source had one.
+ var endsWithNewline = root.ToFullString().EndsWith("\n", StringComparison.Ordinal);
+ var newText = WithTrailingNewline(BuildNewFileText(root, target), endsWithNewline);
+
+ return document.WithText(SourceText.From(newText));
+ }
+
+ private static string ResolveTargetNamespace(Document document, BaseNamespaceDeclarationSyntax declaration)
+ {
+ // Prefer the folder-derived namespace so the flattened file lands where the folder layout implies; fall
+ // back to the literal dotted concatenation of the nested namespace chain when no mapping is available.
+ var options = document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions;
+ var filePath = document.FilePath ?? string.Empty;
+
+ return FolderNamespace.TryResolve(options, filePath, out var expected) ? expected : NamespaceChain(declaration);
+ }
+
+ private static string BuildNewFileText(CompilationUnitSyntax root, string namespaceName)
+ {
+ // Assemble the new file as text: the file-level usings, a single file-scoped namespace, then every
+ // top-level type rendered at column 0 so its (possibly nested) indentation is dropped and leading doc
+ // comments travel with it.
+ var builder = new StringBuilder();
+
+ foreach (var directive in root.Usings)
+ {
+ _ = builder.Append(directive.ToString()).Append('\n');
+ }
+
+ if (root.Usings.Count != 0)
+ {
+ _ = builder.Append('\n');
+ }
+
+ _ = builder.Append("namespace ").Append(namespaceName).Append(";\n\n");
+
+ var members = root.DescendantNodes().Where(IsTopLevelTypeDeclaration).Cast();
+ return builder.Append(string.Join("\n\n", members.Select(RenderMember))).ToString();
+ }
+
+ private static string WithTrailingNewline(string text, bool trailingNewline) =>
+ trailingNewline ? text.TrimEnd() + "\n" : text.TrimEnd();
+
+ // Renders a top-level member at column 0, keeping its leading doc comments/comments and inner blank lines but
+ // dropping the surrounding blank lines and the indentation it had in its original (nested) context.
+ private static string RenderMember(MemberDeclarationSyntax member)
+ {
+ var lines = member.ToFullString().Replace("\r\n", "\n").Split('\n').ToList();
+
+ while (lines.Count != 0 && lines[0].Trim().Length == 0)
+ {
+ lines.RemoveAt(0);
+ }
+
+ while (lines.Count != 0 && lines[lines.Count - 1].Trim().Length == 0)
+ {
+ lines.RemoveAt(lines.Count - 1);
+ }
+
+ var indent = lines[0].Length - lines[0].TrimStart().Length;
+ return string.Join(
+ "\n",
+ lines.Select(line => line.Length >= indent ? line.Substring(indent) : line.TrimStart())
+ );
+ }
+
+ private static bool IsTopLevelTypeDeclaration(SyntaxNode node) =>
+ node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax
+ && node.Parent is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax;
+
+ private static string NamespaceChain(SyntaxNode node)
+ {
+ var segments = new List();
+ for (var current = node; current is not null; current = current.Parent)
+ {
+ if (current is BaseNamespaceDeclarationSyntax namespaceDeclaration)
+ {
+ segments.Add(namespaceDeclaration.Name.ToString());
+ }
+ }
+
+ segments.Reverse();
+ return string.Join(".", segments);
+ }
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs
new file mode 100644
index 0000000..ad6d8d7
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespaceCodeFixRunner.cs
@@ -0,0 +1,136 @@
+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 a single document (with an explicit file path so the folder-derived namespace resolves),
+/// runs the analyzer to obtain the NE0003 diagnostic, registers the fix, applies the resulting
+/// , and returns the final set of documents.
+///
+internal static class SingleNamespaceCodeFixRunner
+{
+ private static readonly ImmutableArray _references = ResolveFrameworkReferences();
+
+ public static async Task> ApplyAsync(
+ string name,
+ string content,
+ string filePath,
+ (string Key, string Value)[]? properties = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var workspace = new AdhocWorkspace();
+ var projectId = ProjectId.CreateNewId();
+ var solution = BuildSolution(workspace, projectId, name, content, filePath, 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.Name] = (await document.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString();
+ }
+
+ return result;
+ }
+
+ private static Solution BuildSolution(
+ AdhocWorkspace workspace,
+ ProjectId projectId,
+ string name,
+ string content,
+ string filePath,
+ (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)
+ .AddDocument(DocumentId.CreateNewId(projectId), name, SourceText.From(content), filePath: filePath);
+
+ 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 SingleNamespacePerFileAnalyzer()),
+ project.AnalyzerOptions
+ );
+#pragma warning restore S8949
+
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false);
+ var diagnostic = diagnostics.First(d => string.Equals(d.Id, DiagnosticIds.NE0003, 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 SingleNamespacePerFileCodeFixProvider().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/SingleNamespacePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs
new file mode 100644
index 0000000..e7776b3
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileAnalyzerTests.cs
@@ -0,0 +1,154 @@
+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 NE0003 through the real
+/// pipeline. The rule is count-based and does not depend on the file path, so these hold with or without one.
+///
+public sealed class SingleNamespacePerFileAnalyzerTests
+{
+ private static bool IsNe0003(Microsoft.CodeAnalysis.Diagnostic diagnostic) =>
+ string.Equals(diagnostic.Id, DiagnosticIds.NE0003, StringComparison.Ordinal);
+
+ [Test]
+ public async Task SiblingNamespaces_ReportsNe0003()
+ {
+ const string source = """
+ namespace First
+ {
+ public sealed class One { }
+ }
+
+ namespace Second
+ {
+ public sealed class Two { }
+ }
+ """;
+
+ var diagnostics = await AnalyzerCompiler
+ .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer(), path: "Types.cs")
+ .ConfigureAwait(false);
+
+ await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task NestedNamespaces_ReportsNe0003()
+ {
+ const string source = """
+ namespace Outer
+ {
+ namespace Inner
+ {
+ public sealed class Circle { }
+ }
+ }
+ """;
+
+ var diagnostics = await AnalyzerCompiler
+ .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer(), path: "Circle.cs")
+ .ConfigureAwait(false);
+
+ await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task SingleNamespace_ReportsNothing()
+ {
+ const string source = """
+ namespace Geometry;
+
+ public sealed class Circle { }
+ """;
+
+ var diagnostics = await AnalyzerCompiler
+ .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer(), path: "Circle.cs")
+ .ConfigureAwait(false);
+
+ await Assert.That(diagnostics.Any(IsNe0003)).IsFalse();
+ }
+
+ [Test]
+ public async Task WithoutFilePath_StillReportsNe0003()
+ {
+ const string source = """
+ namespace First { }
+
+ namespace Second { }
+ """;
+
+ var diagnostics = await AnalyzerCompiler
+ .GetAnalyzerDiagnosticsAsync(source, new SingleNamespacePerFileAnalyzer())
+ .ConfigureAwait(false);
+
+ await Assert.That(diagnostics.Count(IsNe0003)).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Disabled_ViaBuildProperty_ReportsNothing()
+ {
+ const string source = """
+ namespace First { }
+
+ namespace Second { }
+ """;
+
+ var diagnostics = await AnalyzerCompiler
+ .GetAnalyzerDiagnosticsAsync(
+ source,
+ new SingleNamespacePerFileAnalyzer(),
+ path: "Types.cs",
+ properties: [("NetEvolveAnalyzerDisableFileOrganizationRules", "true")]
+ )
+ .ConfigureAwait(false);
+
+ await Assert.That(diagnostics.Any(IsNe0003)).IsFalse();
+ }
+
+ [Test]
+ public async Task Disabled_ForSingleFilePublish_ReportsNothing()
+ {
+ const string source = """
+ namespace First { }
+
+ namespace Second { }
+ """;
+
+ var diagnostics = await AnalyzerCompiler
+ .GetAnalyzerDiagnosticsAsync(
+ source,
+ new SingleNamespacePerFileAnalyzer(),
+ path: "Types.cs",
+ properties: [("PublishSingleFile", "true")]
+ )
+ .ConfigureAwait(false);
+
+ await Assert.That(diagnostics.Any(IsNe0003)).IsFalse();
+ }
+
+ [Test]
+ public async Task Initialize_NullContext_ThrowsArgumentNullException()
+ {
+ var analyzer = new SingleNamespacePerFileAnalyzer();
+ ArgumentNullException? caught = null;
+
+ try
+ {
+ analyzer.Initialize(null!);
+ }
+ catch (ArgumentNullException exception)
+ {
+ caught = exception;
+ }
+
+ await Assert.That(caught).IsNotNull();
+ }
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs
new file mode 100644
index 0000000..0d64ceb
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/SingleNamespacePerFileCodeFixTests.cs
@@ -0,0 +1,172 @@
+namespace NetEvolve.Analyzer.Tests.Integration.Maintainability;
+
+using System;
+using System.Threading.Tasks;
+using NetEvolve.Analyzer.Maintainability;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.Core;
+
+///
+/// End-to-end tests for the NE0003 code fix through a real AdhocWorkspace (see
+/// ): flattening a nested namespace either to the folder-derived
+/// namespace or, when no anchor is available, to the concatenated nested chain.
+///
+public sealed class SingleNamespacePerFileCodeFixTests
+{
+ private const string Nested = """
+ namespace Outer
+ {
+ namespace Inner
+ {
+ public sealed class Circle { }
+ }
+ }
+ """;
+
+ [Test]
+ public async Task Nested_NoProperties_UsesConcatenatedChainFallback()
+ {
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync("Circle.cs", Nested, filePath: "Circle.cs")
+ .ConfigureAwait(false);
+
+ await Assert
+ .That(
+ result.TryGetValue("Circle.cs", out var text)
+ && text.Contains("namespace Outer.Inner;", StringComparison.Ordinal)
+ )
+ .IsTrue();
+ }
+
+ [Test]
+ public async Task Nested_WithRootAndProjectDir_UsesFolderDerivedNamespace()
+ {
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync(
+ "Circle.cs",
+ Nested,
+ filePath: "/proj/Shapes/Circle.cs",
+ properties: [("RootNamespace", "Geometry"), ("ProjectDir", "/proj")]
+ )
+ .ConfigureAwait(false);
+
+ await Assert
+ .That(
+ result.TryGetValue("Circle.cs", out var text)
+ && text.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal)
+ )
+ .IsTrue();
+ }
+
+ [Test]
+ public async Task Nested_WithUsings_CarriesUsingsIntoFlattenedFile()
+ {
+ const string source = """
+ using System;
+
+ namespace Outer
+ {
+ namespace Inner
+ {
+ public sealed class Circle
+ {
+ public DateTime Now { get; }
+ }
+ }
+ }
+ """;
+
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync("Circle.cs", source, filePath: "Circle.cs")
+ .ConfigureAwait(false);
+
+ await Assert
+ .That(
+ result.TryGetValue("Circle.cs", out var text)
+ && text.Contains("using System;", StringComparison.Ordinal)
+ && text.Contains("namespace Outer.Inner;", StringComparison.Ordinal)
+ )
+ .IsTrue();
+ }
+
+ [Test]
+ public async Task SiblingNamespaces_OfferNoFix_LeavesFileUnchanged()
+ {
+ const string source = """
+ namespace Alpha
+ {
+ public sealed class One { }
+ }
+
+ namespace Beta
+ {
+ public sealed class Two { }
+ }
+ """;
+
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync("Types.cs", source, filePath: "Types.cs")
+ .ConfigureAwait(false);
+
+ // The sibling case is left to NE0001's move fix, so NE0003 offers no action and the file is untouched.
+ await Assert
+ .That(
+ result.TryGetValue("Types.cs", out var text)
+ && text.Contains("namespace Alpha", StringComparison.Ordinal)
+ && text.Contains("namespace Beta", StringComparison.Ordinal)
+ )
+ .IsTrue();
+ }
+
+ [Test]
+ public async Task Nested_Rich_RendersEveryMemberKindAtColumnZero()
+ {
+ // Usings, a doc comment, a leading blank line, an interior blank line, plus enum and delegate members
+ // exercise the full member-rendering path. No anchor properties, so the target is the concatenated chain.
+ const string source =
+ "using System;\n\nnamespace Outer\n{\n namespace Inner\n {\n\n"
+ + " /// A circle.\n public sealed class Circle\n {\n"
+ + " public int X { get; }\n\n public int Y { get; }\n }\n\n"
+ + " public enum Kind { A }\n\n public delegate void Handler();\n }\n}\n";
+
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync("Types.cs", source, filePath: "Types.cs")
+ .ConfigureAwait(false);
+
+ var text = result["Types.cs"];
+ await Assert.That(text.Contains("namespace Outer.Inner;", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("using System;", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("public enum Kind { A }", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("public delegate void Handler();", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("\npublic sealed class Circle", StringComparison.Ordinal)).IsTrue();
+ }
+
+ [Test]
+ public async Task Nested_NoTrailingNewline_PreservesStyleAndUsesFolderNamespace()
+ {
+ const string source =
+ "namespace Outer\n{\n namespace Inner\n {\n public sealed class Circle { }\n }\n}";
+
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync(
+ "Circle.cs",
+ source,
+ filePath: "/proj/Shapes/Circle.cs",
+ properties: [("RootNamespace", "Geometry"), ("ProjectDir", "/proj")]
+ )
+ .ConfigureAwait(false);
+
+ var text = result["Circle.cs"];
+ await Assert.That(text.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.EndsWith('\n')).IsFalse();
+ }
+
+ [Test]
+ public async Task GetFixAllProvider_ReturnsNull()
+ {
+ var provider = new SingleNamespacePerFileCodeFixProvider();
+
+ await Assert.That(provider.GetFixAllProvider()).IsNull();
+ }
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs
new file mode 100644
index 0000000..17e0b63
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespaceCodeFixRunner.cs
@@ -0,0 +1,136 @@
+namespace NetEvolve.Analyzer.Tests.Unit.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;
+using NetEvolve.Analyzer.Maintainability;
+
+///
+/// Drives end-to-end through a real
+/// from the unit suite as well, so the NE0003 flatten fix is exercised by both the unit and integration flags
+/// (a line covered by only one flag counts as a partial against the patch coverage gate).
+///
+internal static class SingleNamespaceCodeFixRunner
+{
+ private static readonly ImmutableArray _references = ResolveFrameworkReferences();
+
+ public static async Task> ApplyAsync(
+ string name,
+ string content,
+ string filePath,
+ (string Key, string Value)[]? properties = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var workspace = new AdhocWorkspace();
+ var projectId = ProjectId.CreateNewId();
+ var solution = BuildSolution(workspace, projectId, name, content, filePath, 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.Name] = (await document.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString();
+ }
+
+ return result;
+ }
+
+ private static Solution BuildSolution(
+ AdhocWorkspace workspace,
+ ProjectId projectId,
+ string name,
+ string content,
+ string filePath,
+ (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)
+ .AddDocument(DocumentId.CreateNewId(projectId), name, SourceText.From(content), filePath: filePath);
+
+ 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 SingleNamespacePerFileAnalyzer()),
+ project.AnalyzerOptions
+ );
+#pragma warning restore S8949
+
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false);
+ var diagnostic = diagnostics.First(d => string.Equals(d.Id, DiagnosticIds.NE0003, 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 SingleNamespacePerFileCodeFixProvider().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.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs
new file mode 100644
index 0000000..aac3d15
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileAnalyzerTests.cs
@@ -0,0 +1,123 @@
+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 SingleNamespacePerFileAnalyzer (NE0003), driven through the verifier harness.
+public sealed class SingleNamespacePerFileAnalyzerTests
+{
+ [Test]
+ public async Task Initialize_NullContext_ThrowsArgumentNullException()
+ {
+ var analyzer = new SingleNamespacePerFileAnalyzer();
+ ArgumentNullException? caught = null;
+
+ try
+ {
+ analyzer.Initialize(null!);
+ }
+ catch (ArgumentNullException exception)
+ {
+ caught = exception;
+ }
+
+ await Assert.That(caught).IsNotNull();
+ }
+
+ // ---- Compliant: exactly one namespace ---------------------------------------------------------------
+
+ [Test]
+ public Task SingleFileScopedNamespace_NoDiagnostic() =>
+ SingleNamespacePerFileVerifier.VerifyAsync(
+ "Circle.cs",
+ """
+ namespace Geometry;
+
+ public sealed class Circle { }
+ """
+ );
+
+ [Test]
+ public Task SingleBlockNamespace_NoDiagnostic() =>
+ SingleNamespacePerFileVerifier.VerifyAsync(
+ "Circle.cs",
+ """
+ namespace Geometry
+ {
+ public sealed class Circle { }
+ }
+ """
+ );
+
+ // ---- Non-compliant: more than one namespace ---------------------------------------------------------
+
+ [Test]
+ public Task TwoSiblingNamespaces_SecondFlagged() =>
+ SingleNamespacePerFileVerifier.VerifyAsync(
+ "Types.cs",
+ """
+ namespace First
+ {
+ public sealed class One { }
+ }
+
+ namespace {|NE0003:Second|}
+ {
+ public sealed class Two { }
+ }
+ """
+ );
+
+ [Test]
+ public Task NestedNamespaces_InnerFlagged() =>
+ SingleNamespacePerFileVerifier.VerifyAsync(
+ "Types.cs",
+ """
+ namespace A
+ {
+ namespace {|NE0003:B|}
+ {
+ public sealed class Circle { }
+ }
+ }
+ """
+ );
+
+ // ---- Opt-outs ---------------------------------------------------------------------------------------
+
+ [Test]
+ public Task Disabled_ViaBuildProperty_NoDiagnostic() =>
+ SingleNamespacePerFileVerifier.VerifyAsync(
+ [
+ (
+ "Types.cs",
+ """
+ namespace First { }
+
+ namespace Second { }
+ """
+ ),
+ ],
+ ("NetEvolveAnalyzerDisableFileOrganizationRules", "true")
+ );
+
+ [Test]
+ public Task Disabled_ForSingleFilePublish_NoDiagnostic() =>
+ SingleNamespacePerFileVerifier.VerifyAsync(
+ [
+ (
+ "Types.cs",
+ """
+ namespace First { }
+
+ namespace Second { }
+ """
+ ),
+ ],
+ ("PublishSingleFile", "true")
+ );
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs
new file mode 100644
index 0000000..6a68755
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixRunnerTests.cs
@@ -0,0 +1,77 @@
+namespace NetEvolve.Analyzer.Tests.Unit.Maintainability;
+
+using System;
+using System.Threading.Tasks;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.Core;
+
+///
+/// End-to-end unit tests for the NE0003 flatten fix through a real AdhocWorkspace (see
+/// ). These mirror the integration coverage so the fix is exercised
+/// by both test flags, and cover the rendering branches (leading/interior blank lines, usings, enum/delegate
+/// members, trailing-newline style, folder-derived vs concatenated target).
+///
+public sealed class SingleNamespacePerFileCodeFixRunnerTests
+{
+ // Nested file with usings, a doc comment, a leading blank line, an interior blank line, and enum/delegate
+ // members — exercises the full member-rendering path. No anchor properties, so the target is the
+ // concatenated nesting chain.
+ private const string Rich =
+ "using System;\n\nnamespace Outer\n{\n namespace Inner\n {\n\n"
+ + " /// A circle.\n public sealed class Circle\n {\n"
+ + " public int X { get; }\n\n public int Y { get; }\n }\n\n"
+ + " public enum Kind { A }\n\n public delegate void Handler();\n }\n}\n";
+
+ [Test]
+ public async Task Nested_Rich_FlattensToConcatenatedChain()
+ {
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync("Types.cs", Rich, filePath: "Types.cs")
+ .ConfigureAwait(false);
+
+ var text = result["Types.cs"];
+ await Assert.That(text.Contains("namespace Outer.Inner;", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("using System;", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("/// A circle.", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("public enum Kind { A }", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("public delegate void Handler();", StringComparison.Ordinal)).IsTrue();
+ // De-indented to column 0.
+ await Assert.That(text.Contains("\npublic sealed class Circle", StringComparison.Ordinal)).IsTrue();
+ }
+
+ [Test]
+ public async Task Nested_NoTrailingNewline_WithAnchor_UsesFolderNamespace()
+ {
+ const string source =
+ "namespace Outer\n{\n namespace Inner\n {\n public sealed class Circle { }\n }\n}";
+
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync(
+ "Circle.cs",
+ source,
+ filePath: "/proj/Shapes/Circle.cs",
+ properties: [("RootNamespace", "Geometry"), ("ProjectDir", "/proj")]
+ )
+ .ConfigureAwait(false);
+
+ var text = result["Circle.cs"];
+ await Assert.That(text.Contains("namespace Geometry.Shapes;", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.EndsWith('\n')).IsFalse();
+ }
+
+ [Test]
+ public async Task SiblingNamespaces_OfferNoFix_LeavesFileUnchanged()
+ {
+ const string source =
+ "namespace Alpha\n{\n public sealed class One { }\n}\n\nnamespace Beta\n{\n public sealed class Two { }\n}\n";
+
+ var result = await SingleNamespaceCodeFixRunner
+ .ApplyAsync("Types.cs", source, filePath: "Types.cs")
+ .ConfigureAwait(false);
+
+ var text = result["Types.cs"];
+ await Assert.That(text.Contains("namespace Alpha", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(text.Contains("namespace Beta", StringComparison.Ordinal)).IsTrue();
+ }
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs
new file mode 100644
index 0000000..cb66982
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixTests.cs
@@ -0,0 +1,41 @@
+namespace NetEvolve.Analyzer.Tests.Unit.Maintainability;
+
+using System.Threading.Tasks;
+using TUnit.Core;
+
+///
+/// Tests for SingleNamespacePerFileCodeFixProvider (NE0003): flattening a nested namespace to a single
+/// file-scoped namespace. Without RootNamespace/ProjectDir the target is the literal dotted
+/// concatenation of the nested chain, so the outcome is deterministic in the unit harness.
+///
+public sealed class SingleNamespacePerFileCodeFixTests
+{
+ [Test]
+ public Task Nested_Flatten_UsesConcatenatedChainFallback() =>
+ SingleNamespacePerFileCodeFixVerifier.VerifyAsync(
+ [
+ (
+ "Circle.cs",
+ """
+ namespace Outer
+ {
+ namespace {|NE0003:Inner|}
+ {
+ public sealed class Circle { }
+ }
+ }
+ """
+ ),
+ ],
+ [
+ (
+ "Circle.cs",
+ """
+ namespace Outer.Inner;
+
+ public sealed class Circle { }
+ """
+ ),
+ ]
+ );
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.cs
new file mode 100644
index 0000000..b1c7eb2
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileCodeFixVerifier.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 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 SingleNamespacePerFileCodeFixVerifier
+{
+ 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<
+ SingleNamespacePerFileAnalyzer,
+ SingleNamespacePerFileCodeFixProvider,
+ 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/SingleNamespacePerFileVerifier.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileVerifier.cs
new file mode 100644
index 0000000..efd8aba
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/SingleNamespacePerFileVerifier.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 and,
+/// optionally, a set of MSBuild build properties injected through a global analyzer config. Diagnostics are
+/// declared inline with {|NE0003:Identifier|} markup.
+///
+internal static class SingleNamespacePerFileVerifier
+{
+ 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);
+}