From 9e64ca9782be278a7197f71d4f8e9f3207794cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 16:25:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20NE0001=20Fix-All=20=E2=80=94=20sequ?= =?UTF-8?q?ential=20provider=20for=20batch=20rename/move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a custom FixAllProvider for NE0001 that resolves every diagnostic in scope by applying the existing rename/move fixes sequentially on the accumulating solution, re-resolving diagnostics between each step. The default WellKnownFixAllProviders.BatchFixer cannot be used because the fix relies on solution-level operations (WithDocumentName, AddDocument) rather than text edits, and because multiple types in one file would produce overlapping edits that fail to merge. Iterating to a fixed point instead lets a multi-type file's remaining type flip from "move" to "rename" between rounds, and skips the unfixable collision case without failing the batch. - Custom FixAllProvider wired via OneTypePerFileCodeFixProvider.GetFixAllProvider() - Supports Document, Project and Solution scope with deterministic ordering - Integration tests: multi-type files, move-then-rename convergence, project scope, collision skip, no-op, and null-argument guard Closes #10 --- .../OneTypePerFileCodeFixProvider.cs | 5 +- .../OneTypePerFileFixAllProvider.cs | 244 ++++++++++++++++++ .../Maintainability/FixAllRunner.cs | 230 +++++++++++++++++ .../OneTypePerFileFixAllTests.cs | 164 ++++++++++++ 4 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs diff --git a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs index e5d5901..47dc2e4 100644 --- a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs +++ b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileCodeFixProvider.cs @@ -29,8 +29,9 @@ public sealed class OneTypePerFileCodeFixProvider : CodeFixProvider public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.NE0001); /// - // Renaming and adding documents does not compose safely across many diagnostics, so no batch fix-all. - public override FixAllProvider? GetFixAllProvider() => null; + // Renaming and adding documents cannot compose through the default batch fixer, so a custom provider + // applies the rename/move fixes sequentially and re-resolves diagnostics between each step. + public override FixAllProvider? GetFixAllProvider() => OneTypePerFileFixAllProvider.Instance; /// public override async Task RegisterCodeFixesAsync(CodeFixContext context) diff --git a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs new file mode 100644 index 0000000..b152dc3 --- /dev/null +++ b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs @@ -0,0 +1,244 @@ +namespace NetEvolve.Analyzer.Maintainability; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// Custom fix-all for NE0001. The single-diagnostic fix uses +/// solution-level operations (WithDocumentName, AddDocument) and overlapping edits to a single +/// multi-type file, none of which the default batch fixer can compose. Instead this provider applies the +/// rename/move fixes one at a time and re-resolves diagnostics between each step, iterating to a fixed point. +/// +internal sealed class OneTypePerFileFixAllProvider : FixAllProvider +{ + /// The shared instance returned by . + public static OneTypePerFileFixAllProvider Instance { get; } = new OneTypePerFileFixAllProvider(); + + /// + public override IEnumerable GetSupportedFixAllScopes() => + new[] { FixAllScope.Document, FixAllScope.Project, FixAllScope.Solution }; + + /// + public override async Task GetFixAsync(FixAllContext fixAllContext) + { + if (fixAllContext is null) + { + throw new ArgumentNullException(nameof(fixAllContext)); + } + + if (!await HasFixableDiagnosticsAsync(fixAllContext).ConfigureAwait(false)) + { + return null; + } + + return CodeAction.Create( + $"Fix all '{DiagnosticIds.NE0001}' occurrences", + cancellationToken => FixAllAsync(fixAllContext, cancellationToken), + equivalenceKey: nameof(OneTypePerFileFixAllProvider) + ); + } + + private static async Task HasFixableDiagnosticsAsync(FixAllContext fixAllContext) + { + if (fixAllContext.Scope == FixAllScope.Document && fixAllContext.Document is not null) + { + var diagnostics = await fixAllContext + .GetDocumentDiagnosticsAsync(fixAllContext.Document) + .ConfigureAwait(false); + return !diagnostics.IsEmpty; + } + + if (fixAllContext.Scope == FixAllScope.Project) + { + var diagnostics = await fixAllContext.GetAllDiagnosticsAsync(fixAllContext.Project).ConfigureAwait(false); + return !diagnostics.IsEmpty; + } + + if (fixAllContext.Scope == FixAllScope.Solution) + { + foreach (var project in fixAllContext.Solution.Projects) + { + var diagnostics = await fixAllContext.GetAllDiagnosticsAsync(project).ConfigureAwait(false); + if (!diagnostics.IsEmpty) + { + return true; + } + } + } + + return false; + } + + // Applies one rename/move at a time, re-resolving diagnostics from the accumulating solution after each + // step. Convergence and the move->rename flip fall out of the re-resolution; the collision case (target + // name equals the current file) registers no action and is therefore passed over without failing. + private static async Task FixAllAsync(FixAllContext fixAllContext, CancellationToken cancellationToken) + { + var solution = fixAllContext.Solution; + var scope = fixAllContext.Scope; + var documentId = fixAllContext.Document?.Id; + var projectId = fixAllContext.Project?.Id; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var next = await TryApplyOneAsync(solution, scope, documentId, projectId, cancellationToken) + .ConfigureAwait(false); + if (next is null) + { + return solution; + } + + solution = next; + } + } + + private static async Task TryApplyOneAsync( + Solution solution, + FixAllScope scope, + DocumentId? documentId, + ProjectId? projectId, + CancellationToken cancellationToken + ) + { + foreach (var id in TargetDocumentIds(solution, scope, documentId, projectId)) + { + var changed = await TryFixDocumentAsync(solution, id, cancellationToken).ConfigureAwait(false); + if (changed is not null) + { + return changed; + } + } + + return null; + } + + // Runs the analyzer over the document's project, then applies the first NE0001 diagnostic in the document + // (by source order) that yields an action. Returns null when the document has no applicable fix. + private static async Task TryFixDocumentAsync( + Solution solution, + DocumentId id, + CancellationToken cancellationToken + ) + { + var document = solution.GetDocument(id); + if (document is null) + { + return null; + } + + var diagnostics = await ResolveDiagnosticsAsync(solution, document, id, cancellationToken) + .ConfigureAwait(false); + var fixProvider = new OneTypePerFileCodeFixProvider(); + + foreach (var diagnostic in diagnostics) + { + var changed = await TryApplyFixAsync(fixProvider, document, diagnostic, cancellationToken) + .ConfigureAwait(false); + if (changed is not null) + { + return changed; + } + } + + return null; + } + + // The NE0001 diagnostics located in the document, ordered by source position, resolved from a fresh run of + // the analyzer over the current project so each pass sees the accumulated edits. + private static async Task> ResolveDiagnosticsAsync( + Solution solution, + Document document, + DocumentId id, + CancellationToken cancellationToken + ) + { + var project = document.Project; + 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 OneTypePerFileAnalyzer()), + project.AnalyzerOptions + ); +#pragma warning restore S8949 + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + + return diagnostics + .Where(diagnostic => string.Equals(diagnostic.Id, DiagnosticIds.NE0001, StringComparison.Ordinal)) + .Where(diagnostic => + diagnostic.Location.SourceTree is not null + && solution.GetDocument(diagnostic.Location.SourceTree)?.Id == id + ) + .OrderBy(diagnostic => diagnostic.Location.SourceSpan.Start) + .ToList(); + } + + // Registers the single-diagnostic fix and applies its first change. Returns null when no action is offered + // (the collision case, where the target file equals the current file), skipping without failing the batch. + private static async Task TryApplyFixAsync( + OneTypePerFileCodeFixProvider fixProvider, + Document document, + Diagnostic diagnostic, + CancellationToken cancellationToken + ) + { + var actions = new List(); + var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); + await fixProvider.RegisterCodeFixesAsync(context).ConfigureAwait(false); + + if (actions.Count == 0) + { + return null; + } + + var operations = await actions[0].GetOperationsAsync(cancellationToken).ConfigureAwait(false); + return operations.OfType().FirstOrDefault()?.ChangedSolution; + } + + // Re-enumerates the target document set from the CURRENT solution each pass (a move adds a new, already + // compliant file), ordered deterministically by file path then name so the sequence is stable. + private static List TargetDocumentIds( + Solution solution, + FixAllScope scope, + DocumentId? documentId, + ProjectId? projectId + ) + { + IEnumerable documents; + switch (scope) + { + case FixAllScope.Document: + var single = solution.GetDocument(documentId); + documents = single is null ? Enumerable.Empty() : new[] { single }; + break; + case FixAllScope.Project: + documents = solution.GetProject(projectId)?.Documents ?? Enumerable.Empty(); + break; + case FixAllScope.Solution: + documents = solution.Projects.SelectMany(project => project.Documents); + break; + default: + documents = Enumerable.Empty(); + break; + } + + return documents + .OrderBy(document => document.FilePath ?? document.Name, StringComparer.Ordinal) + .ThenBy(document => document.Name, StringComparer.Ordinal) + .Select(document => document.Id) + .ToList(); + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs new file mode 100644 index 0000000..b51f1ae --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/FixAllRunner.cs @@ -0,0 +1,230 @@ +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 , +/// mirroring but invoking the fix-all pipeline the IDE uses: it builds a project +/// from named documents, constructs a for the requested +/// backed by a diagnostic provider that runs , obtains the fix-all +/// , applies its , and returns the final documents. +/// +internal static class FixAllRunner +{ + private static readonly ImmutableArray _references = ResolveFrameworkReferences(); + + public static async Task> FixAllAsync( + (string Name, string Content)[] sources, + FixAllScope scope, + (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 ApplyFixAllAsync(solution, projectId, scope, 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)[] 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 (name, content) in sources) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(projectId), + name, + SourceText.From(content), + filePath: name + ); + } + + 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 ApplyFixAllAsync( + Solution solution, + ProjectId projectId, + FixAllScope scope, + CancellationToken cancellationToken + ) + { + var project = solution.GetProject(projectId)!; + var context = await CreateContextAsync(project, scope, cancellationToken).ConfigureAwait(false); + + var action = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(context).ConfigureAwait(false); + if (action is null) + { + return solution; + } + + var operations = await action.GetOperationsAsync(cancellationToken).ConfigureAwait(false); + return operations.OfType().First().ChangedSolution; + } + + // Builds a FixAllContext exactly as the IDE would: a Document trigger for Document scope, a Project trigger + // for Project/Solution scope, carrying the diagnostic id the provider fixes and a live diagnostic provider. + private static async Task CreateContextAsync( + Project project, + FixAllScope scope, + CancellationToken cancellationToken + ) + { + var fixProvider = new OneTypePerFileCodeFixProvider(); + var diagnosticProvider = new AnalyzerDiagnosticProvider(); + var diagnosticIds = new[] { DiagnosticIds.NE0001 }; + + if (scope != FixAllScope.Document) + { + return new FixAllContext( + project, + fixProvider, + scope, + nameof(OneTypePerFileFixAllProvider), + diagnosticIds, + diagnosticProvider, + cancellationToken + ); + } + + var trigger = await FindTriggerDocumentAsync(project, cancellationToken).ConfigureAwait(false); + return new FixAllContext( + trigger, + fixProvider, + scope, + nameof(OneTypePerFileFixAllProvider), + diagnosticIds, + diagnosticProvider, + cancellationToken + ); + } + + // The first document (ordered by name) carrying an NE0001 diagnostic drives Document-scope fix-all. + private static async Task FindTriggerDocumentAsync(Project project, CancellationToken cancellationToken) + { + var diagnostics = await AnalyzeAsync(project, cancellationToken).ConfigureAwait(false); + + foreach (var document in project.Documents.OrderBy(document => document.Name, StringComparer.Ordinal)) + { + var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + if (diagnostics.Any(diagnostic => diagnostic.Location.SourceTree == tree)) + { + return document; + } + } + + return project.Documents.First(); + } + + private static async Task> AnalyzeAsync( + Project project, + CancellationToken cancellationToken + ) + { + 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 OneTypePerFileAnalyzer()), + project.AnalyzerOptions + ); +#pragma warning restore S8949 + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + return diagnostics + .Where(diagnostic => string.Equals(diagnostic.Id, DiagnosticIds.NE0001, StringComparison.Ordinal)) + .ToImmutableArray(); + } + + 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)), + ]; + } + + /// Supplies NE0001 diagnostics to by running the analyzer live. + private sealed class AnalyzerDiagnosticProvider : FixAllContext.DiagnosticProvider + { + public override async Task> GetAllDiagnosticsAsync( + Project project, + CancellationToken cancellationToken + ) => await AnalyzeAsync(project, cancellationToken).ConfigureAwait(false); + + public override async Task> GetProjectDiagnosticsAsync( + Project project, + CancellationToken cancellationToken + ) + { + var diagnostics = await AnalyzeAsync(project, cancellationToken).ConfigureAwait(false); + return diagnostics.Where(diagnostic => diagnostic.Location.SourceTree is null); + } + + public override async Task> GetDocumentDiagnosticsAsync( + Document document, + CancellationToken cancellationToken + ) + { + var diagnostics = await AnalyzeAsync(document.Project, cancellationToken).ConfigureAwait(false); + var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + return diagnostics.Where(diagnostic => diagnostic.Location.SourceTree == tree); + } + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs new file mode 100644 index 0000000..05ba2f1 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs @@ -0,0 +1,164 @@ +namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; + +using System; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CodeFixes; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end tests for the NE0001 fix-all (see ): the custom provider must resolve +/// every occurrence in scope by applying rename/move fixes sequentially and re-resolving between steps, +/// including the move-then-rename flip and skipping the unfixable collision case. +/// +public sealed class OneTypePerFileFixAllTests +{ + [Test] + public async Task Document_MultiTypeFile_MovesAndRenamesEveryType() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + + public sealed class Square { } + + public sealed class Triangle { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Shapes.cs", source)], FixAllScope.Document) + .ConfigureAwait(false); + + await Assert.That(result.ContainsKey("Shapes.cs")).IsFalse(); + await Assert + .That( + result.TryGetValue("Circle.cs", out var circle) + && circle.Contains("class Circle", StringComparison.Ordinal) + ) + .IsTrue(); + await Assert + .That( + result.TryGetValue("Square.cs", out var square) + && square.Contains("class Square", StringComparison.Ordinal) + ) + .IsTrue(); + await Assert + .That( + result.TryGetValue("Triangle.cs", out var triangle) + && triangle.Contains("class Triangle", StringComparison.Ordinal) + ) + .IsTrue(); + } + + [Test] + public async Task Solution_TwoTypeFileNamedAfterNeither_ConvergesToOwnFiles() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + + public sealed class Square { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Shapes.cs", source)], FixAllScope.Solution) + .ConfigureAwait(false); + + await Assert.That(result.ContainsKey("Shapes.cs")).IsFalse(); + await Assert.That(result.ContainsKey("Circle.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Square.cs")).IsTrue(); + } + + [Test] + public async Task Project_MultipleViolatingFiles_AllResolved() + { + const string alpha = """ + namespace Sample; + + public sealed class Alpha { } + """; + + const string others = """ + namespace Sample; + + public sealed class Beta { } + + public sealed class Gamma { } + """; + + var result = await FixAllRunner + .FixAllAsync([("A.cs", alpha), ("B.cs", others)], FixAllScope.Project) + .ConfigureAwait(false); + + await Assert.That(result.ContainsKey("A.cs")).IsFalse(); + await Assert.That(result.ContainsKey("B.cs")).IsFalse(); + await Assert.That(result.ContainsKey("Alpha.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Beta.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Gamma.cs")).IsTrue(); + } + + [Test] + public async Task Project_CollisionCase_CompletesAndLeavesFileIntact() + { + const string source = """ + namespace Models + { + public sealed class Item { } + } + + namespace Dtos + { + public sealed class Item { } + } + """; + + var result = await FixAllRunner.FixAllAsync([("Item.cs", source)], FixAllScope.Project).ConfigureAwait(false); + + await Assert.That(result.Count).IsEqualTo(1); + await Assert + .That( + result.TryGetValue("Item.cs", out var item) + && item.Contains("namespace Models", StringComparison.Ordinal) + && item.Contains("namespace Dtos", StringComparison.Ordinal) + ) + .IsTrue(); + } + + [Test] + public async Task Document_NoViolations_ReturnsUnchanged() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Circle.cs", source)], FixAllScope.Document) + .ConfigureAwait(false); + + await Assert.That(result.Count).IsEqualTo(1); + await Assert.That(result.ContainsKey("Circle.cs")).IsTrue(); + } + + [Test] + public async Task GetFixAsync_NullContext_ThrowsArgumentNullException() + { + ArgumentNullException? caught = null; + + try + { + _ = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(null!).ConfigureAwait(false); + } + catch (ArgumentNullException exception) + { + caught = exception; + } + + await Assert.That(caught).IsNotNull(); + } +} From c343b08501006aa734d5cd7271a47b4677869486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Mon, 3 Aug 2026 17:15:44 +0200 Subject: [PATCH 2/2] test: raise NE0001 Fix-All patch coverage above the gate Exercise the custom fix-all provider from both the unit and integration flags so its lines are not counted as partials against the patch coverage gate: - Add an AdhocWorkspace-based fix-all runner to the unit suite and mirror the Document/Project scope, convergence, collision-skip, no-op and null-argument scenarios there - Cover the supported-scope enumeration and GetFixAllProvider() wiring in both suites, and the solution-scope no-op path - Drop the unreachable document-null guard in TryFixDocumentAsync (the id is always taken from the current solution's documents) --- .../OneTypePerFileFixAllProvider.cs | 7 +- .../OneTypePerFileFixAllTests.cs | 36 +++ .../Maintainability/FixAllRunner.cs | 211 ++++++++++++++++++ .../OneTypePerFileFixAllTests.cs | 157 +++++++++++++ 4 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs diff --git a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs index b152dc3..5f20928 100644 --- a/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs +++ b/src/NetEvolve.Analyzer/Maintainability/OneTypePerFileFixAllProvider.cs @@ -130,11 +130,8 @@ CancellationToken cancellationToken CancellationToken cancellationToken ) { - var document = solution.GetDocument(id); - if (document is null) - { - return null; - } + // The id always comes from TargetDocumentIds enumerating the current solution, so the document exists. + var document = solution.GetDocument(id)!; var diagnostics = await ResolveDiagnosticsAsync(solution, document, id, cancellationToken) .ConfigureAwait(false); diff --git a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs index 05ba2f1..9ace509 100644 --- a/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Integration/Maintainability/OneTypePerFileFixAllTests.cs @@ -1,6 +1,7 @@ namespace NetEvolve.Analyzer.Tests.Integration.Maintainability; using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CodeFixes; using NetEvolve.Analyzer.Maintainability; @@ -161,4 +162,39 @@ public async Task GetFixAsync_NullContext_ThrowsArgumentNullException() await Assert.That(caught).IsNotNull(); } + + [Test] + public async Task Solution_NoViolations_ReturnsUnchanged() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Circle.cs", source)], FixAllScope.Solution) + .ConfigureAwait(false); + + await Assert.That(result.Count).IsEqualTo(1); + await Assert.That(result.ContainsKey("Circle.cs")).IsTrue(); + } + + [Test] + public async Task GetSupportedFixAllScopes_AreDocumentProjectSolution() + { + var scopes = OneTypePerFileFixAllProvider.Instance.GetSupportedFixAllScopes().ToList(); + + await Assert.That(scopes).Contains(FixAllScope.Document); + await Assert.That(scopes).Contains(FixAllScope.Project); + await Assert.That(scopes).Contains(FixAllScope.Solution); + } + + [Test] + public async Task GetFixAllProvider_ReturnsCustomProvider() + { + var provider = new OneTypePerFileCodeFixProvider().GetFixAllProvider(); + + await Assert.That(provider).IsSameReferenceAs(OneTypePerFileFixAllProvider.Instance); + } } diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs new file mode 100644 index 0000000..8ee1932 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs @@ -0,0 +1,211 @@ +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 fix-all pipeline 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). It builds a project from named +/// documents, constructs a for the requested backed by a +/// diagnostic provider that runs , obtains the fix-all +/// , applies its , and returns the final documents. +/// +internal static class FixAllRunner +{ + private static readonly ImmutableArray _references = ResolveFrameworkReferences(); + + public static async Task> FixAllAsync( + (string Name, string Content)[] sources, + FixAllScope scope, + CancellationToken cancellationToken = default + ) + { + using var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var solution = BuildSolution(workspace, projectId, sources); + + var changed = await ApplyFixAllAsync(solution, projectId, scope, 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)[] sources + ) + { + 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 (name, content) in sources) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(projectId), + name, + SourceText.From(content), + filePath: name + ); + } + + return solution; + } + + private static async Task ApplyFixAllAsync( + Solution solution, + ProjectId projectId, + FixAllScope scope, + CancellationToken cancellationToken + ) + { + var project = solution.GetProject(projectId)!; + var context = await CreateContextAsync(project, scope, cancellationToken).ConfigureAwait(false); + + var action = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(context).ConfigureAwait(false); + if (action is null) + { + return solution; + } + + var operations = await action.GetOperationsAsync(cancellationToken).ConfigureAwait(false); + return operations.OfType().First().ChangedSolution; + } + + private static async Task CreateContextAsync( + Project project, + FixAllScope scope, + CancellationToken cancellationToken + ) + { + var fixProvider = new OneTypePerFileCodeFixProvider(); + var diagnosticProvider = new AnalyzerDiagnosticProvider(); + var diagnosticIds = new[] { DiagnosticIds.NE0001 }; + + if (scope != FixAllScope.Document) + { + return new FixAllContext( + project, + fixProvider, + scope, + nameof(OneTypePerFileFixAllProvider), + diagnosticIds, + diagnosticProvider, + cancellationToken + ); + } + + var trigger = await FindTriggerDocumentAsync(project, cancellationToken).ConfigureAwait(false); + return new FixAllContext( + trigger, + fixProvider, + scope, + nameof(OneTypePerFileFixAllProvider), + diagnosticIds, + diagnosticProvider, + cancellationToken + ); + } + + private static async Task FindTriggerDocumentAsync(Project project, CancellationToken cancellationToken) + { + var diagnostics = await AnalyzeAsync(project, cancellationToken).ConfigureAwait(false); + + foreach (var document in project.Documents.OrderBy(document => document.Name, StringComparer.Ordinal)) + { + var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + if (diagnostics.Any(diagnostic => diagnostic.Location.SourceTree == tree)) + { + return document; + } + } + + return project.Documents.First(); + } + + private static async Task> AnalyzeAsync( + Project project, + CancellationToken cancellationToken + ) + { + 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 OneTypePerFileAnalyzer()), + project.AnalyzerOptions + ); +#pragma warning restore S8949 + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + return diagnostics + .Where(diagnostic => string.Equals(diagnostic.Id, DiagnosticIds.NE0001, StringComparison.Ordinal)) + .ToImmutableArray(); + } + + 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)), + ]; + } + + /// Supplies NE0001 diagnostics to by running the analyzer live. + private sealed class AnalyzerDiagnosticProvider : FixAllContext.DiagnosticProvider + { + public override async Task> GetAllDiagnosticsAsync( + Project project, + CancellationToken cancellationToken + ) => await AnalyzeAsync(project, cancellationToken).ConfigureAwait(false); + + public override async Task> GetProjectDiagnosticsAsync( + Project project, + CancellationToken cancellationToken + ) + { + var diagnostics = await AnalyzeAsync(project, cancellationToken).ConfigureAwait(false); + return diagnostics.Where(diagnostic => diagnostic.Location.SourceTree is null); + } + + public override async Task> GetDocumentDiagnosticsAsync( + Document document, + CancellationToken cancellationToken + ) + { + var diagnostics = await AnalyzeAsync(document.Project, cancellationToken).ConfigureAwait(false); + var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + return diagnostics.Where(diagnostic => diagnostic.Location.SourceTree == tree); + } + } +} diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs new file mode 100644 index 0000000..465ad37 --- /dev/null +++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs @@ -0,0 +1,157 @@ +namespace NetEvolve.Analyzer.Tests.Unit.Maintainability; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CodeFixes; +using NetEvolve.Analyzer.Maintainability; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// End-to-end unit tests for the NE0001 fix-all through a real AdhocWorkspace (see +/// ). These mirror the integration coverage so the custom provider is exercised by +/// both test flags, and cover the scope enumeration, the null-argument guard, and the no-op paths. +/// +public sealed class OneTypePerFileFixAllTests +{ + [Test] + public async Task Document_MultiTypeFile_MovesAndRenamesEveryType() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + + public sealed class Square { } + + public sealed class Triangle { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Shapes.cs", source)], FixAllScope.Document) + .ConfigureAwait(false); + + await Assert.That(result.ContainsKey("Shapes.cs")).IsFalse(); + await Assert.That(result.ContainsKey("Circle.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Square.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Triangle.cs")).IsTrue(); + } + + [Test] + public async Task Project_MultipleViolatingFiles_AllResolved() + { + const string alpha = """ + namespace Sample; + + public sealed class Alpha { } + """; + + const string others = """ + namespace Sample; + + public sealed class Beta { } + + public sealed class Gamma { } + """; + + var result = await FixAllRunner + .FixAllAsync([("A.cs", alpha), ("B.cs", others)], FixAllScope.Project) + .ConfigureAwait(false); + + await Assert.That(result.ContainsKey("Alpha.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Beta.cs")).IsTrue(); + await Assert.That(result.ContainsKey("Gamma.cs")).IsTrue(); + } + + [Test] + public async Task Document_NoViolations_ReturnsUnchanged() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Circle.cs", source)], FixAllScope.Document) + .ConfigureAwait(false); + + await Assert.That(result.Count).IsEqualTo(1); + await Assert.That(result.ContainsKey("Circle.cs")).IsTrue(); + } + + [Test] + public async Task Solution_NoViolations_ReturnsUnchanged() + { + const string source = """ + namespace Geometry; + + public sealed class Circle { } + """; + + var result = await FixAllRunner + .FixAllAsync([("Circle.cs", source)], FixAllScope.Solution) + .ConfigureAwait(false); + + await Assert.That(result.Count).IsEqualTo(1); + await Assert.That(result.ContainsKey("Circle.cs")).IsTrue(); + } + + [Test] + public async Task Project_CollisionCase_CompletesAndLeavesFileIntact() + { + const string source = """ + namespace Models + { + public sealed class Item { } + } + + namespace Dtos + { + public sealed class Item { } + } + """; + + var result = await FixAllRunner.FixAllAsync([("Item.cs", source)], FixAllScope.Project).ConfigureAwait(false); + + await Assert.That(result.Count).IsEqualTo(1); + await Assert.That(result.ContainsKey("Item.cs")).IsTrue(); + } + + [Test] + public async Task GetSupportedFixAllScopes_AreDocumentProjectSolution() + { + var scopes = OneTypePerFileFixAllProvider.Instance.GetSupportedFixAllScopes().ToList(); + + await Assert.That(scopes).Contains(FixAllScope.Document); + await Assert.That(scopes).Contains(FixAllScope.Project); + await Assert.That(scopes).Contains(FixAllScope.Solution); + } + + [Test] + public async Task GetFixAllProvider_ReturnsCustomProvider() + { + var provider = new OneTypePerFileCodeFixProvider().GetFixAllProvider(); + + await Assert.That(provider).IsSameReferenceAs(OneTypePerFileFixAllProvider.Instance); + } + + [Test] + public async Task GetFixAsync_NullContext_ThrowsArgumentNullException() + { + ArgumentNullException? caught = null; + + try + { + _ = await OneTypePerFileFixAllProvider.Instance.GetFixAsync(null!).ConfigureAwait(false); + } + catch (ArgumentNullException exception) + { + caught = exception; + } + + await Assert.That(caught).IsNotNull(); + } +}