diff --git a/src/ProjGraph.Cli/Commands/StatsCommand.cs b/src/ProjGraph.Cli/Commands/StatsCommand.cs index 1a4ac73..30fd82b 100644 --- a/src/ProjGraph.Cli/Commands/StatsCommand.cs +++ b/src/ProjGraph.Cli/Commands/StatsCommand.cs @@ -1,4 +1,5 @@ using ProjGraph.Lib.Core.Abstractions; +using ProjGraph.Lib.Core.Infrastructure; using ProjGraph.Lib.ProjectGraph.Application; using Spectre.Console; using Spectre.Console.Cli; @@ -84,7 +85,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se sw.Stop(); // ── Header ──────────────────────────────────────────────────────── - console.Write(new Rule($"[bold blue]{stats.SolutionName}[/]").LeftJustified()); + SpectreOutputConsole.Write(new Rule($"[bold blue]{stats.SolutionName}[/]").LeftJustified()); // ── Metrics table ───────────────────────────────────────────────── var table = new Table @@ -117,7 +118,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se .AddRow("[grey]Cycles detected[/]", stats.HasCycles ? "[red]Yes[/]" : "[green]No[/]") .AddRow("[grey]Analysis time[/]", $"{sw.ElapsedMilliseconds} ms"); - console.Write(table); + SpectreOutputConsole.Write(table); // ── Hotspot projects ────────────────────────────────────────────── if (stats.HotspotProjects.Count > 0) diff --git a/src/ProjGraph.Core/Models/SolutionStats.cs b/src/ProjGraph.Core/Models/SolutionStats.cs index 38ecac6..5a339d0 100644 --- a/src/ProjGraph.Core/Models/SolutionStats.cs +++ b/src/ProjGraph.Core/Models/SolutionStats.cs @@ -34,15 +34,16 @@ bool HasCycles /// /// Aggregate dependency depth statistics across all projects in the graph. +/// When cycles are detected, all values are null. /// -/// Mean longest-path depth across all projects; -1.0 if cycles are detected. -/// Minimum depth (0 for leaf projects with no dependencies); -1 if cycles are detected. -/// Maximum depth (the longest dependency chain in the graph); -1 if cycles are detected. +/// Mean longest-path depth across all projects; null if cycles are detected. +/// Minimum depth (0 for leaf projects with no dependencies); null if cycles are detected. +/// Maximum depth (the longest dependency chain in the graph); null if cycles are detected. public record DependencyDepthStats( [property: JsonPropertyName("average")] - double Average, - [property: JsonPropertyName("min")] int Min, - [property: JsonPropertyName("max")] int Max + double? Average, + [property: JsonPropertyName("min")] int? Min, + [property: JsonPropertyName("max")] int? Max ); /// diff --git a/src/ProjGraph.Lib.ClassDiagram/Infrastructure/WorkspaceTypeDiscovery.cs b/src/ProjGraph.Lib.ClassDiagram/Infrastructure/WorkspaceTypeDiscovery.cs index 71c57c9..1f3a810 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Infrastructure/WorkspaceTypeDiscovery.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Infrastructure/WorkspaceTypeDiscovery.cs @@ -64,19 +64,27 @@ internal sealed class WorkspaceTypeDiscovery(IFileSystem fileSystem) : IWorkspac /// private async Task SearchDirectoryForTypeAsync(string directory, string typeName) { - return await SearchDirectoryRecursiveAsync(directory, typeName); + var matches = new List(); + await CollectTypeMatchesAsync(directory, typeName, matches); + + if (matches.Count <= 1) + { + return matches.FirstOrDefault(); + } + + // Multiple files define the same type name — sort by path for deterministic results + matches.Sort(StringComparer.OrdinalIgnoreCase); + return matches[0]; } /// - /// Recursively searches a directory and its subdirectories for a C# file containing a specific type definition. - /// This method manually handles recursion to avoid descending into common non-source directories for better performance. + /// Recursively searches a directory and its subdirectories for C# files containing a specific type definition, + /// collecting all matches for deterministic resolution. /// /// The path of the directory to search. /// The name of the type to search for. - /// - /// The full path of the file containing the type definition if found; otherwise, null. - /// - private async Task SearchDirectoryRecursiveAsync(string directory, string typeName) + /// The list to collect matching file paths into. + private async Task CollectTypeMatchesAsync(string directory, string typeName, List matches) { var enumerationOptions = new EnumerationOptions { @@ -107,7 +115,7 @@ internal sealed class WorkspaceTypeDiscovery(IFileSystem fileSystem) : IWorkspac if (hasType) { - return file; + matches.Add(file); } } @@ -120,13 +128,7 @@ internal sealed class WorkspaceTypeDiscovery(IFileSystem fileSystem) : IWorkspac continue; } - var result = await SearchDirectoryRecursiveAsync(subDir, typeName); - if (result != null) - { - return result; - } + await CollectTypeMatchesAsync(subDir, typeName, matches); } - - return null; } } diff --git a/src/ProjGraph.Lib.Core/Abstractions/IOutputConsole.cs b/src/ProjGraph.Lib.Core/Abstractions/IOutputConsole.cs index 7a861f6..3bad30b 100644 --- a/src/ProjGraph.Lib.Core/Abstractions/IOutputConsole.cs +++ b/src/ProjGraph.Lib.Core/Abstractions/IOutputConsole.cs @@ -1,5 +1,3 @@ -using Spectre.Console.Rendering; - namespace ProjGraph.Lib.Core.Abstractions; /// @@ -13,12 +11,6 @@ public interface IOutputConsole /// The message to write. void Write(string message); - /// - /// Writes a object (e.g. a Spectre.Console Table or Rule) to the console. - /// - /// The renderable to write. - void Write(IRenderable renderable); - /// /// Writes a message to the console followed by a newline. /// diff --git a/src/ProjGraph.Lib.Core/Abstractions/NullOutputConsole.cs b/src/ProjGraph.Lib.Core/Abstractions/NullOutputConsole.cs index cbe8f89..2c6b0d7 100644 --- a/src/ProjGraph.Lib.Core/Abstractions/NullOutputConsole.cs +++ b/src/ProjGraph.Lib.Core/Abstractions/NullOutputConsole.cs @@ -1,5 +1,3 @@ -using Spectre.Console.Rendering; - namespace ProjGraph.Lib.Core.Abstractions; /// @@ -12,9 +10,6 @@ public sealed class NullOutputConsole : IOutputConsole /// public void Write(string message) { } - /// - public void Write(IRenderable renderable) { } - /// public void WriteLine(string message) { } @@ -34,11 +29,13 @@ public void WriteSuccess(string message) { } public void WriteMarkup(string markup) { } /// - /// Returns the first available choice without prompting. + /// Returns the first available choice without prompting, or throws if no choices are available. public Task PromptSelectionAsync(string title, IEnumerable choices, CancellationToken cancellationToken = default) { - return Task.FromResult(choices.First()); + var first = choices.FirstOrDefault() + ?? throw new InvalidOperationException($"No choices available for prompt '{title}'."); + return Task.FromResult(first); } /// diff --git a/src/ProjGraph.Lib.Core/Domain/Algorithms/TarjanSccAlgorithm.cs b/src/ProjGraph.Lib.Core/Domain/Algorithms/TarjanSccAlgorithm.cs index 6f92234..e3f9495 100644 --- a/src/ProjGraph.Lib.Core/Domain/Algorithms/TarjanSccAlgorithm.cs +++ b/src/ProjGraph.Lib.Core/Domain/Algorithms/TarjanSccAlgorithm.cs @@ -71,14 +71,22 @@ public static IReadOnlyList> FindStronglyConnectedComponents } /// - /// Recursively explores the graph to find strongly connected components using Tarjan's algorithm. + /// Represents a single frame on the explicit DFS stack used by the iterative Tarjan implementation. /// - /// - /// This method uses unbounded recursion. Graphs with more than ~1,000 nodes in a single dependency - /// chain may cause a . In practice, .NET solution graphs - /// rarely approach this depth. - /// - /// The current node being visited. + /// The graph node this frame represents. + /// The index into the node's neighbor list to resume iteration from. + private readonly struct StackFrame(Guid node, int neighborIndex) + { + public Guid Node { get; } = node; + public int NeighborIndex { get; } = neighborIndex; + } + + /// + /// Iteratively explores the graph to find strongly connected components using Tarjan's algorithm. + /// Uses an explicit stack instead of recursion to avoid + /// on deep dependency chains. + /// + /// The starting node. /// The adjacency list representing the graph. /// The context of the Tarjan's algorithm execution. private static void StrongConnect( @@ -86,41 +94,78 @@ private static void StrongConnect( Dictionary> adjacencyList, TarjanContext context) { + var dfsStack = new Stack(); + + // Initialize the starting node context.Indices[v] = context.Index; context.Lowlink[v] = context.Index; context.Index++; context.Stack.Push(v); context.OnStack.Add(v); - if (adjacencyList.TryGetValue(v, out var neighbors)) + dfsStack.Push(new StackFrame(v, 0)); + + while (dfsStack.Count > 0) { - foreach (var w in neighbors) + var frame = dfsStack.Pop(); + var node = frame.Node; + var neighborIdx = frame.NeighborIndex; + + var neighbors = adjacencyList.TryGetValue(node, out var list) ? list : []; + + var pushed = false; + for (var i = neighborIdx; i < neighbors.Count; i++) { - if (!context.Indices.TryGetValue(w, out var index1)) + var w = neighbors[i]; + if (!context.Indices.TryGetValue(w, out var wIndex)) { - StrongConnect(w, adjacencyList, context); - context.Lowlink[v] = Math.Min(context.Lowlink[v], context.Lowlink[w]); + // Save current frame (will resume at neighbor i+1 after w completes) + dfsStack.Push(new StackFrame(node, i + 1)); + + // Initialize w and push it + context.Indices[w] = context.Index; + context.Lowlink[w] = context.Index; + context.Index++; + context.Stack.Push(w); + context.OnStack.Add(w); + + dfsStack.Push(new StackFrame(w, 0)); + pushed = true; + break; } - else if (context.OnStack.Contains(w)) + + if (context.OnStack.Contains(w)) { - context.Lowlink[v] = Math.Min(context.Lowlink[v], index1); + context.Lowlink[node] = Math.Min(context.Lowlink[node], wIndex); } } - } - // ReSharper disable once InvertIf - if (context.Lowlink[v] == context.Indices[v]) - { - var component = new List(); - Guid w; - do + if (pushed) { - w = context.Stack.Pop(); - context.OnStack.Remove(w); - component.Add(w); - } while (w != v); + continue; + } - context.Sccs.Add(component); + // All neighbors processed — check for SCC root + if (context.Lowlink[node] == context.Indices[node]) + { + var component = new List(); + Guid w; + do + { + w = context.Stack.Pop(); + context.OnStack.Remove(w); + component.Add(w); + } while (w != node); + + context.Sccs.Add(component); + } + + // Update parent's lowlink + if (dfsStack.Count > 0) + { + var parent = dfsStack.Peek(); + context.Lowlink[parent.Node] = Math.Min(context.Lowlink[parent.Node], context.Lowlink[node]); + } } } } diff --git a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs index 95ba6e7..2283c19 100644 --- a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs +++ b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs @@ -61,8 +61,11 @@ public void Write(string message) AnsiConsole.Write(message); } - /// - public void Write(IRenderable renderable) + /// + /// Writes a Spectre.Console object (e.g., a Table or Rule) to the console. + /// + /// The renderable to write. + public static void Write(IRenderable renderable) { AnsiConsole.Write(renderable); } diff --git a/src/ProjGraph.Lib.Core/Parsers/SlnxParser.cs b/src/ProjGraph.Lib.Core/Parsers/SlnxParser.cs index 6861b2f..9f44073 100644 --- a/src/ProjGraph.Lib.Core/Parsers/SlnxParser.cs +++ b/src/ProjGraph.Lib.Core/Parsers/SlnxParser.cs @@ -1,4 +1,6 @@ +using ProjGraph.Core.Exceptions; using ProjGraph.Lib.Core.Abstractions; +using System.Xml; using System.Xml.Linq; namespace ProjGraph.Lib.Core.Parsers; @@ -17,6 +19,7 @@ public sealed class SlnxParser(IFileSystem fileSystem) : ISlnxParser /// An enumerable collection of project file paths contained in the `.slnx` file. /// If the `.slnx` file does not exist, an empty collection is returned. /// + /// Thrown when the `.slnx` file contains malformed XML. public IEnumerable GetProjectPaths(string path) { if (!fileSystem.FileExists(path)) @@ -24,7 +27,16 @@ public IEnumerable GetProjectPaths(string path) return []; } - var doc = XDocument.Load(path); + XDocument doc; + try + { + doc = XDocument.Load(path); + } + catch (XmlException ex) + { + throw new ParsingException($"Malformed .slnx file: {path}", ex); + } + var solutionDir = fileSystem.GetDirectoryName(path) ?? ""; return doc.Descendants("Project") diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentApiParsingUtilities.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentApiParsingUtilities.cs index 1399063..e0d22d8 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentApiParsingUtilities.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentApiParsingUtilities.cs @@ -1,14 +1,17 @@ using ProjGraph.Core.Models; using ProjGraph.Lib.EntityFramework.Infrastructure.Constants; using ProjGraph.Lib.EntityFramework.Infrastructure.Patterns; +using System.Text.RegularExpressions; namespace ProjGraph.Lib.EntityFramework.Infrastructure; /// /// Shared utility methods used by relationship and property configuration parsers. /// -internal static class FluentApiParsingUtilities +internal static partial class FluentApiParsingUtilities { + [GeneratedRegex(@"""[^""]*""")] + private static partial Regex StringLiteralStripRegex(); /// /// Extracts the generic type from a method name like "Property<T>". /// @@ -177,8 +180,12 @@ public static bool IsInsideUsingEntityBlock(string configSection, int matchIndex } var textBetween = configSection[lastUsingEntity..matchIndex]; - var openParens = textBetween.Count(c => c == '('); - var closeParens = textBetween.Count(c => c == ')'); + + // Strip string literals to avoid counting parentheses inside strings like .ToTable("Name()") + var stripped = StringLiteralStripRegex().Replace(textBetween, ""); + + var openParens = stripped.Count(c => c == '('); + var closeParens = stripped.Count(c => c == ')'); return openParens > closeParens; } diff --git a/src/ProjGraph.Lib.ProjectGraph/Application/UseCases/ComputeStatsUseCase.cs b/src/ProjGraph.Lib.ProjectGraph/Application/UseCases/ComputeStatsUseCase.cs index e3883cd..f58fb78 100644 --- a/src/ProjGraph.Lib.ProjectGraph/Application/UseCases/ComputeStatsUseCase.cs +++ b/src/ProjGraph.Lib.ProjectGraph/Application/UseCases/ComputeStatsUseCase.cs @@ -46,7 +46,7 @@ public static SolutionStats Execute(SolutionGraph graph, int topN = 5) // 3. Dependency depth stats var depthStats = hasCycles - ? new DependencyDepthStats(-1.0, -1, -1) + ? new DependencyDepthStats(null, null, null) : ComputeDepths(projects, projectRefs); // 4. Hotspot ranking — direct in-degree (how many projects reference each project) diff --git a/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs index 72ab009..775c6f7 100644 --- a/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs +++ b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs @@ -23,15 +23,15 @@ public static IServiceCollection AddProjGraphProjectGraph(this IServiceCollectio services.AddSingleton(); services.AddSingleton(); - // Renderers - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + // Renderers (all stateless — registered as Singleton) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Register as interface for collection injection - services.AddTransient>(sp => sp.GetRequiredService()); - services.AddTransient>(sp => sp.GetRequiredService()); - services.AddTransient>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); return services; } diff --git a/src/ProjGraph.Mcp/ProjGraphTools.cs b/src/ProjGraph.Mcp/ProjGraphTools.cs index 656c639..d8dab70 100644 --- a/src/ProjGraph.Mcp/ProjGraphTools.cs +++ b/src/ProjGraph.Mcp/ProjGraphTools.cs @@ -22,6 +22,11 @@ internal sealed class ProjGraphTools( McpServer server, WorkspaceRootService rootService) { + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + [McpServerTool(Name = "get_class_diagram")] [Description( "Generates a Mermaid class diagram for the types defined in a specific C# file or directory, with options to discover inheritance and related types in the workspace.")] @@ -189,7 +194,7 @@ public async Task GetProjectStatsAsync( Message = "Summarizing results" }); - var json = JsonSerializer.Serialize(stats); + var json = JsonSerializer.Serialize(stats, JsonSerializerOptions); var filename = Path.GetFileName(path); await cache.StoreAsync("stats", path, "application/json", json, diff --git a/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs b/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs index 5b901b3..58cf177 100644 --- a/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs +++ b/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs @@ -265,4 +265,125 @@ public class Empty { } result.Members.Should().BeEmpty(); } + + [Fact] + public void AnalyzeType_NestedClass_ShouldAnalyzeInnerType() + { + const string code = """ + namespace Test; + public class Outer + { + public class Inner + { + public string Value { get; set; } + } + } + """; + var compilation = RoslynTestHelper.CreateCompilation(code); + var outerSymbol = RoslynTestHelper.GetTypeSymbol(compilation, "Outer")!; + var innerSymbol = outerSymbol.GetTypeMembers("Inner").First(); + + var result = TypeAnalyzer.AnalyzeType(innerSymbol); + + result.Name.Should().Contain("Inner"); + result.Kind.Should().Be(ModelTypeKind.Class); + result.Members.Should().Contain(m => m.Name == "Value"); + } + + [Fact] + public void AnalyzeType_GenericClassWithConstraints_ShouldIncludeTypeParameters() + { + const string code = """ + namespace Test; + public class Repository where T : System.IComparable + { + public T Get(int id) => default!; + } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Repository")!; + + var result = TypeAnalyzer.AnalyzeType(symbol); + + result.Name.Should().Contain("Repository"); + result.Name.Should().Contain("T"); + result.Members.Should().Contain(m => m.Name == "Get" && m.Kind == MemberKind.Method); + } + + [Fact] + public void AnalyzeType_StaticClass_ShouldReturnClassKind() + { + const string code = """ + namespace Test; + public static class Helpers + { + public static void DoWork() { } + public static int Value => 42; + } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Helpers")!; + + var result = TypeAnalyzer.AnalyzeType(symbol); + + result.Kind.Should().Be(ModelTypeKind.Class); + result.Members.Should().Contain(m => m.Name == "DoWork" && m.Kind == MemberKind.Method); + result.Members.Should().Contain(m => m.Name == "Value" && m.Kind == MemberKind.Property); + } + + [Fact] + public void AnalyzeType_ClassWithEvent_ShouldNotIncludeEventsAsMembers() + { + const string code = """ + namespace Test; + public class Publisher + { + public event System.EventHandler? Changed; + public string Name { get; set; } + } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Publisher")!; + + var result = TypeAnalyzer.AnalyzeType(symbol); + + // Events are not handled by TypeAnalyzer's switch, so only properties/fields/methods are extracted + result.Members.Should().Contain(m => m.Name == "Name" && m.Kind == MemberKind.Property); + } + + [Fact] + public void AnalyzeType_InterfaceWithDefaultImplementation_ShouldExtractMethod() + { + const string code = """ + namespace Test; + public interface ILogger + { + void Log(string message); + void LogError(string message) => Log("ERROR: " + message); + } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "ILogger")!; + + var result = TypeAnalyzer.AnalyzeType(symbol); + + result.Kind.Should().Be(ModelTypeKind.Interface); + result.Members.Should().Contain(m => m.Name == "Log" && m.Kind == MemberKind.Method); + result.Members.Should().Contain(m => m.Name == "LogError" && m.Kind == MemberKind.Method); + } + + [Fact] + public void AnalyzeType_RecordStruct_ShouldReturnRecordKind() + { + const string code = """ + namespace Test; + public record struct Point(int X, int Y); + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Point")!; + + var result = TypeAnalyzer.AnalyzeType(symbol); + + result.Kind.Should().Be(ModelTypeKind.Record); + } } diff --git a/tests/ProjGraph.Tests.Unit.Core/Algorithms/TarjanSccAlgorithmTests.cs b/tests/ProjGraph.Tests.Unit.Core/Algorithms/TarjanSccAlgorithmTests.cs index 1e860b2..83274db 100644 --- a/tests/ProjGraph.Tests.Unit.Core/Algorithms/TarjanSccAlgorithmTests.cs +++ b/tests/ProjGraph.Tests.Unit.Core/Algorithms/TarjanSccAlgorithmTests.cs @@ -204,4 +204,31 @@ public void FindStronglyConnectedComponents_ShouldHandleDisconnectedNodes() sccs.Should().HaveCount(3); sccs.Should().OnlyContain(scc => scc.Count == 1); } + + [Fact] + public void FindStronglyConnectedComponents_DeepChain_ShouldNotStackOverflow() + { + // Arrange – 2000-node linear chain, would overflow with recursive implementation + const int depth = 2000; + var guids = Enumerable.Range(0, depth).Select(_ => Guid.NewGuid()).ToList(); + + var projects = guids + .Select((g, i) => new Project(g, $"P{i}", $"P{i}.csproj", $"P{i}.csproj", "net10.0", ProjectType.Library)) + .ToList(); + + var dependencies = new List(); + for (var i = 0; i < depth - 1; i++) + { + dependencies.Add(new Dependency(guids[i], guids[i + 1], DependencyType.ProjectReference)); + } + + var graph = new SolutionGraph("Deep", "Deep.sln", projects, dependencies); + + // Act — should complete without StackOverflowException + var sccs = TarjanSccAlgorithm.FindStronglyConnectedComponents(graph); + + // Assert — all nodes are individual SCCs (no cycles) + sccs.Should().HaveCount(depth); + sccs.Should().OnlyContain(scc => scc.Count == 1); + } } diff --git a/tests/ProjGraph.Tests.Unit.Core/NullOutputConsoleTests.cs b/tests/ProjGraph.Tests.Unit.Core/NullOutputConsoleTests.cs index f7baac2..b2e5ea3 100644 --- a/tests/ProjGraph.Tests.Unit.Core/NullOutputConsoleTests.cs +++ b/tests/ProjGraph.Tests.Unit.Core/NullOutputConsoleTests.cs @@ -79,6 +79,15 @@ public async Task PromptSelectionAsync_ShouldReturnFirstChoice() result.Should().Be("option1"); } + [Fact] + public async Task PromptSelectionAsync_EmptyChoices_ShouldThrowInvalidOperationException() + { + var act = () => _sut.PromptSelectionAsync("Pick one", Array.Empty()); + + await act.Should().ThrowAsync() + .WithMessage("*No choices available*"); + } + [Fact] public async Task RunWithStatusAsync_ShouldExecuteAction() { diff --git a/tests/ProjGraph.Tests.Unit.Core/Parsers/ProjectParserTests.cs b/tests/ProjGraph.Tests.Unit.Core/Parsers/ProjectParserTests.cs index 4da3165..834d495 100644 --- a/tests/ProjGraph.Tests.Unit.Core/Parsers/ProjectParserTests.cs +++ b/tests/ProjGraph.Tests.Unit.Core/Parsers/ProjectParserTests.cs @@ -2,6 +2,7 @@ using ProjGraph.Lib.Core.Infrastructure; using ProjGraph.Lib.Core.Parsers; using ProjGraph.Tests.Shared.Helpers; +using System.Text; namespace ProjGraph.Tests.Unit.Core.Parsers; @@ -315,4 +316,92 @@ public void Parse_ShouldSetCorrectPaths() project.FullPath.Should().Be(tempFile); project.RelativePath.Should().NotBeNullOrWhiteSpace(); } + + [Fact] + public void Parse_ShouldHandleFileWithBom() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + + + net10.0 + + + + + + """; + + // Write file with UTF-8 BOM + var filePath = Path.Combine(temp.DirectoryPath, "bom.csproj"); + File.WriteAllText(filePath, content, new UTF8Encoding(true)); + + // Act + var (project, references, _) = _parser.Parse(filePath); + + // Assert + project.Name.Should().Be("bom"); + project.Framework.Should().Be("net10.0"); + references.Should().ContainSingle(); + } + + [Fact] + public void Parse_ShouldHandleConditionalItemGroup() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + + + net10.0 + + + + + + + + + """; + + var tempFile = temp.CreateFile("conditional.csproj", content); + + // Act + var (_, references, _) = _parser.Parse(tempFile); + var refList = references.ToList(); + + // Assert - both conditional and unconditional references are extracted + refList.Should().HaveCount(2); + refList.Should().Contain(r => r.Contains("DebugOnly")); + refList.Should().Contain(r => r.Contains("Always")); + } + + [Fact] + public void Parse_ShouldHandlePackageVersionRangeSyntax() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + + + net10.0 + + + + + + """; + + var tempFile = temp.CreateFile("range.csproj", content); + + // Act + var (_, _, packages) = _parser.Parse(tempFile); + var pkgList = packages.ToList(); + + // Assert + pkgList.Should().ContainSingle(); + pkgList[0].Name.Should().Be("SomePackage"); + pkgList[0].Version.Should().Be("[1.0,2.0)"); + } } diff --git a/tests/ProjGraph.Tests.Unit.Core/Parsers/SlnxParserTests.cs b/tests/ProjGraph.Tests.Unit.Core/Parsers/SlnxParserTests.cs index 1fa3008..aec4f5a 100644 --- a/tests/ProjGraph.Tests.Unit.Core/Parsers/SlnxParserTests.cs +++ b/tests/ProjGraph.Tests.Unit.Core/Parsers/SlnxParserTests.cs @@ -1,6 +1,8 @@ +using ProjGraph.Core.Exceptions; using ProjGraph.Lib.Core.Infrastructure; using ProjGraph.Lib.Core.Parsers; using ProjGraph.Tests.Shared.Helpers; +using System.Text; namespace ProjGraph.Tests.Unit.Core.Parsers; @@ -126,7 +128,8 @@ public void GetProjectPaths_ShouldHandleInvalidXml() // Act & Assert var act = () => _parser.GetProjectPaths(tempSlnx).ToList(); - act.Should().Throw(); + act.Should().Throw() + .WithMessage("*Malformed .slnx file*"); } [Fact] @@ -175,4 +178,27 @@ public void GetProjectPaths_ShouldHandleMultipleDifferentProjects() paths.Should().HaveCount(5); paths.Should().OnlyHaveUniqueItems(); } + + [Fact] + public void GetProjectPaths_ShouldHandleFileWithBom() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + + + + """; + + // Write file with UTF-8 BOM + var filePath = Path.Combine(temp.DirectoryPath, "bom.slnx"); + File.WriteAllText(filePath, content, new UTF8Encoding(true)); + + // Act + var paths = _parser.GetProjectPaths(filePath).ToList(); + + // Assert + paths.Should().ContainSingle(); + paths[0].Should().EndWith("ProjA.csproj"); + } } diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalysisAdvancedTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalysisAdvancedTests.cs index 9d62d1e..62c0da8 100644 --- a/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalysisAdvancedTests.cs +++ b/tests/ProjGraph.Tests.Unit.EntityFramework/EfAnalysisAdvancedTests.cs @@ -533,4 +533,158 @@ public class PermissionEntry { rel.TargetEntity.Should().Be("PermissionEntry"); rel.Type.Should().Be(EfRelationshipType.OneToMany); } + + [Fact] + public async Task AnalyzeContextAsync_KeylessEntity_ShouldExtractEntity() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + namespace Test; + public class AppDbContext : DbContext + { + public DbSet BlogPostViews { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasNoKey().ToView("vw_BlogPosts"); + } + } + public class BlogPostView + { + public string Title { get; set; } + public int PostCount { get; set; } + } + """; + var filePath = temp.CreateFile("ContextKeyless.cs", content); + + // Act + var model = await _service.AnalyzeContextAsync(filePath, "AppDbContext"); + + // Assert + model.Entities.Should().ContainSingle(e => e.Name == "BlogPostView"); + var entity = model.Entities.First(e => e.Name == "BlogPostView"); + entity.Properties.Should().Contain(p => p.Name == "Title"); + entity.Properties.Should().Contain(p => p.Name == "PostCount"); + // No primary key should be marked + entity.Properties.Should().NotContain(p => p.IsPrimaryKey); + } + + [Fact] + public async Task AnalyzeContextAsync_ValueConverter_ShouldExtractProperty() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + namespace Test; + public class AppDbContext : DbContext + { + public DbSet Orders { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(o => o.Status) + .HasConversion(); + } + } + public enum OrderStatus { Pending, Shipped, Delivered } + public class Order + { + public int Id { get; set; } + public OrderStatus Status { get; set; } + } + """; + var filePath = temp.CreateFile("ContextConverter.cs", content); + + // Act + var model = await _service.AnalyzeContextAsync(filePath, "AppDbContext"); + + // Assert + var order = model.Entities.First(e => e.Name == "Order"); + order.Properties.Should().Contain(p => p.Name == "Status"); + } + + [Fact] + public async Task AnalyzeContextAsync_OwnedType_ShouldExtractOwnedEntity() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + namespace Test; + public class AppDbContext : DbContext + { + public DbSet Customers { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .OwnsOne(c => c.Address); + } + } + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } + public Address Address { get; set; } + } + public class Address + { + public string Street { get; set; } + public string City { get; set; } + } + """; + var filePath = temp.CreateFile("ContextOwned.cs", content); + + // Act + var model = await _service.AnalyzeContextAsync(filePath, "AppDbContext"); + + // Assert + model.Entities.Should().Contain(e => e.Name == "Customer"); + var customer = model.Entities.First(e => e.Name == "Customer"); + customer.Properties.Should().Contain(p => p.Name == "Name"); + } + + [Fact] + public async Task AnalyzeContextAsync_TptInheritance_ShouldExtractBothEntities() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + namespace Test; + public class AppDbContext : DbContext + { + public DbSet Animals { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToTable("Animals"); + modelBuilder.Entity().ToTable("Cats"); + } + } + public class Animal + { + public int Id { get; set; } + public string Name { get; set; } + } + public class Cat : Animal + { + public string Color { get; set; } + } + """; + var filePath = temp.CreateFile("ContextTpt.cs", content); + + // Act + var model = await _service.AnalyzeContextAsync(filePath, "AppDbContext"); + + // Assert + model.Entities.Should().Contain(e => e.Name == "Animal"); + var animal = model.Entities.First(e => e.Name == "Animal"); + animal.Properties.Should().Contain(p => p.Name == "Id"); + animal.TableName.Should().Be("Animals"); + } } diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/FluentApiParsingUtilitiesTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/FluentApiParsingUtilitiesTests.cs index fe9f1da..c15a65d 100644 --- a/tests/ProjGraph.Tests.Unit.EntityFramework/FluentApiParsingUtilitiesTests.cs +++ b/tests/ProjGraph.Tests.Unit.EntityFramework/FluentApiParsingUtilitiesTests.cs @@ -261,4 +261,26 @@ public void IsInsideUsingEntityBlock_NoUsingEntity_ShouldReturnFalse() FluentApiParsingUtilities.IsInsideUsingEntityBlock(configSection, 0).Should().BeFalse(); } + + [Fact] + public void IsInsideUsingEntityBlock_StringLiteralWithParens_ShouldNotBeConfused() + { + // The string literal ".ToTable("Name()")" contains parentheses that should be ignored + const string configSection = + """.HasMany().WithMany().UsingEntity(j => { j.ToTable("Name()"); j.HasKey(x => x.Id); })"""; + var matchIndex = configSection.IndexOf("HasKey", StringComparison.Ordinal); + + FluentApiParsingUtilities.IsInsideUsingEntityBlock(configSection, matchIndex).Should().BeTrue(); + } + + [Fact] + public void IsInsideUsingEntityBlock_ClosedBlockWithStringParens_ShouldReturnFalse() + { + // UsingEntity block is closed despite string literal containing parens + const string configSection = + """.HasMany().WithMany().UsingEntity(j => { j.ToTable("T()"); }).HasOne()"""; + var matchIndex = configSection.IndexOf("HasOne", StringComparison.Ordinal); + + FluentApiParsingUtilities.IsInsideUsingEntityBlock(configSection, matchIndex).Should().BeFalse(); + } } diff --git a/tests/ProjGraph.Tests.Unit.ProjectGraph/BuildGraphUseCaseTests.cs b/tests/ProjGraph.Tests.Unit.ProjectGraph/BuildGraphUseCaseTests.cs index c3d80c7..2abb1a3 100644 --- a/tests/ProjGraph.Tests.Unit.ProjectGraph/BuildGraphUseCaseTests.cs +++ b/tests/ProjGraph.Tests.Unit.ProjectGraph/BuildGraphUseCaseTests.cs @@ -303,4 +303,30 @@ public void Execute_WithPackagesNotRequested_ShouldExcludePackages() result.Projects.Should().NotContain(p => p.Type == ProjectType.Package); result.Dependencies.Should().BeEmpty(); } + + [Fact] + public void Execute_SelfReferencingProject_ShouldCreateDependencyToSelf() + { + const string slnPath = "/test/solution.sln"; + const string pathA = "/test/a.csproj"; + + _fileSystem.FileExists(slnPath).Returns(true); + _slnParser.GetProjectPaths(slnPath).Returns([pathA]); + + _fileSystem.GetFullPath(pathA).Returns(pathA); + _fileSystem.FileExists(pathA).Returns(true); + _discoveryService.NormalizePath(pathA).Returns(pathA); + _discoveryService.ResolveProjectReferencePath(pathA, "../a.csproj").Returns(pathA); + + var idA = Guid.NewGuid(); + var projectA = new Project(idA, "A", pathA, "a.csproj", "net10.0", ProjectType.Library); + _projectParser.Parse(pathA).Returns((projectA, (IEnumerable)["../a.csproj"], + Enumerable.Empty())); + + var result = _sut.Execute(slnPath); + + result.Dependencies.Should().HaveCount(1); + result.Dependencies[0].SourceId.Should().Be(idA); + result.Dependencies[0].TargetId.Should().Be(idA); + } } diff --git a/tests/ProjGraph.Tests.Unit.ProjectGraph/ComputeStatsUseCaseTests.cs b/tests/ProjGraph.Tests.Unit.ProjectGraph/ComputeStatsUseCaseTests.cs index 0178973..49d421f 100644 --- a/tests/ProjGraph.Tests.Unit.ProjectGraph/ComputeStatsUseCaseTests.cs +++ b/tests/ProjGraph.Tests.Unit.ProjectGraph/ComputeStatsUseCaseTests.cs @@ -211,9 +211,9 @@ public void Execute_CyclicGraph_HasCyclesTrue_DepthNegative() var stats = ComputeStatsUseCase.Execute(graph); stats.HasCycles.Should().BeTrue(); - stats.DepthStats.Average.Should().Be(-1.0); - stats.DepthStats.Min.Should().Be(-1); - stats.DepthStats.Max.Should().Be(-1); + stats.DepthStats.Average.Should().BeNull(); + stats.DepthStats.Min.Should().BeNull(); + stats.DepthStats.Max.Should().BeNull(); } [Fact] @@ -231,4 +231,53 @@ public void Execute_NoCycle_HasCyclesFalse() stats.HasCycles.Should().BeFalse(); } + + [Fact] + public void Execute_SelfReferencingProject_ShouldNotCrash() + { + // A self-referencing project is a degenerate edge case. + // The algorithm should handle it without crashing. + var id = Guid.NewGuid(); + var graph = new SolutionGraph("SelfRef", "/SelfRef.slnx", + [MakeProject(id, "SelfRef", ProjectType.Library)], + [ProjectRef(id, id)]); + + var stats = ComputeStatsUseCase.Execute(graph); + + stats.TotalProjectCount.Should().Be(1); + } + + [Fact] + public void Execute_DeepChain100Levels_ShouldComputeCorrectDepth() + { + const int depth = 100; + var ids = Enumerable.Range(0, depth + 1).Select(_ => Guid.NewGuid()).ToArray(); + var projects = ids.Select((id, i) => MakeProject(id, $"P{i}", ProjectType.Library)).ToList(); + var deps = Enumerable.Range(0, depth).Select(i => ProjectRef(ids[i], ids[i + 1])).ToList(); + var graph = new SolutionGraph("Deep", "/Deep.slnx", projects, deps); + + var stats = ComputeStatsUseCase.Execute(graph); + + stats.HasCycles.Should().BeFalse(); + stats.DepthStats.Max.Should().Be(depth); + stats.DepthStats.Min.Should().Be(0); + } + + [Fact] + public void Execute_UnicodeProjectNames_ShouldHandleCorrectly() + { + var (a, b) = (Guid.NewGuid(), Guid.NewGuid()); + var graph = new SolutionGraph("Unicode", "/Unicode.slnx", + [ + MakeProject(a, "Ünïcödé.Lîb", ProjectType.Library), + MakeProject(b, "日本語プロジェクト", ProjectType.Library) + ], + [ProjectRef(a, b)]); + + var stats = ComputeStatsUseCase.Execute(graph); + + stats.TotalProjectCount.Should().Be(2); + stats.HasCycles.Should().BeFalse(); + stats.DepthStats.Max.Should().Be(1); + } }