Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/ProjGraph.Cli/Commands/StatsCommand.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -84,7 +85,7 @@ public override async Task<int> 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
Expand Down Expand Up @@ -117,7 +118,7 @@ public override async Task<int> 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)
Expand Down
13 changes: 7 additions & 6 deletions src/ProjGraph.Core/Models/SolutionStats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@ bool HasCycles

/// <summary>
/// Aggregate dependency depth statistics across all projects in the graph.
/// When cycles are detected, all values are <c>null</c>.
/// </summary>
/// <param name="Average">Mean longest-path depth across all projects; -1.0 if cycles are detected.</param>
/// <param name="Min">Minimum depth (0 for leaf projects with no dependencies); -1 if cycles are detected.</param>
/// <param name="Max">Maximum depth (the longest dependency chain in the graph); -1 if cycles are detected.</param>
/// <param name="Average">Mean longest-path depth across all projects; <c>null</c> if cycles are detected.</param>
/// <param name="Min">Minimum depth (0 for leaf projects with no dependencies); <c>null</c> if cycles are detected.</param>
/// <param name="Max">Maximum depth (the longest dependency chain in the graph); <c>null</c> if cycles are detected.</param>
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
);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,27 @@ internal sealed class WorkspaceTypeDiscovery(IFileSystem fileSystem) : IWorkspac
/// </returns>
private async Task<string?> SearchDirectoryForTypeAsync(string directory, string typeName)
{
return await SearchDirectoryRecursiveAsync(directory, typeName);
var matches = new List<string>();
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];
}

/// <summary>
/// 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.
/// </summary>
/// <param name="directory">The path of the directory to search.</param>
/// <param name="typeName">The name of the type to search for.</param>
/// <returns>
/// The full path of the file containing the type definition if found; otherwise, null.
/// </returns>
private async Task<string?> SearchDirectoryRecursiveAsync(string directory, string typeName)
/// <param name="matches">The list to collect matching file paths into.</param>
private async Task CollectTypeMatchesAsync(string directory, string typeName, List<string> matches)
{
var enumerationOptions = new EnumerationOptions
{
Expand Down Expand Up @@ -107,7 +115,7 @@ internal sealed class WorkspaceTypeDiscovery(IFileSystem fileSystem) : IWorkspac

if (hasType)
{
return file;
matches.Add(file);
}
}

Expand All @@ -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;
}
}
8 changes: 0 additions & 8 deletions src/ProjGraph.Lib.Core/Abstractions/IOutputConsole.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using Spectre.Console.Rendering;

namespace ProjGraph.Lib.Core.Abstractions;

/// <summary>
Expand All @@ -13,12 +11,6 @@ public interface IOutputConsole
/// <param name="message">The message to write.</param>
void Write(string message);

/// <summary>
/// Writes a <see cref="IRenderable"/> object (e.g. a Spectre.Console <c>Table</c> or <c>Rule</c>) to the console.
/// </summary>
/// <param name="renderable">The renderable to write.</param>
void Write(IRenderable renderable);

/// <summary>
/// Writes a message to the console followed by a newline.
/// </summary>
Expand Down
11 changes: 4 additions & 7 deletions src/ProjGraph.Lib.Core/Abstractions/NullOutputConsole.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using Spectre.Console.Rendering;

namespace ProjGraph.Lib.Core.Abstractions;

/// <summary>
Expand All @@ -12,9 +10,6 @@ public sealed class NullOutputConsole : IOutputConsole
/// <inheritdoc />
public void Write(string message) { }

/// <inheritdoc />
public void Write(IRenderable renderable) { }

/// <inheritdoc />
public void WriteLine(string message) { }

Expand All @@ -34,11 +29,13 @@ public void WriteSuccess(string message) { }
public void WriteMarkup(string markup) { }

/// <inheritdoc />
/// <remarks>Returns the first available choice without prompting.</remarks>
/// <remarks>Returns the first available choice without prompting, or throws if no choices are available.</remarks>
public Task<string> PromptSelectionAsync(string title, IEnumerable<string> 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);
}

/// <inheritdoc />
Expand Down
97 changes: 71 additions & 26 deletions src/ProjGraph.Lib.Core/Domain/Algorithms/TarjanSccAlgorithm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,56 +71,101 @@ public static IReadOnlyList<IReadOnlyList<Guid>> FindStronglyConnectedComponents
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This method uses unbounded recursion. Graphs with more than ~1,000 nodes in a single dependency
/// chain may cause a <see cref="StackOverflowException"/>. In practice, .NET solution graphs
/// rarely approach this depth.
/// </remarks>
/// <param name="v">The current node being visited.</param>
/// <param name="node">The graph node this frame represents.</param>
/// <param name="neighborIndex">The index into the node's neighbor list to resume iteration from.</param>
private readonly struct StackFrame(Guid node, int neighborIndex)
{
public Guid Node { get; } = node;
public int NeighborIndex { get; } = neighborIndex;
}

/// <summary>
/// Iteratively explores the graph to find strongly connected components using Tarjan's algorithm.
/// Uses an explicit stack instead of recursion to avoid <see cref="StackOverflowException"/>
/// on deep dependency chains.
/// </summary>
/// <param name="v">The starting node.</param>
/// <param name="adjacencyList">The adjacency list representing the graph.</param>
/// <param name="context">The context of the Tarjan's algorithm execution.</param>
private static void StrongConnect(
Guid v,
Dictionary<Guid, List<Guid>> adjacencyList,
TarjanContext context)
{
var dfsStack = new Stack<StackFrame>();

// 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>();
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>();
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]);
}
}
}
}
7 changes: 5 additions & 2 deletions src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ public void Write(string message)
AnsiConsole.Write(message);
}

/// <inheritdoc />
public void Write(IRenderable renderable)
/// <summary>
/// Writes a Spectre.Console <see cref="IRenderable"/> object (e.g., a <c>Table</c> or <c>Rule</c>) to the console.
/// </summary>
/// <param name="renderable">The renderable to write.</param>
public static void Write(IRenderable renderable)
{
AnsiConsole.Write(renderable);
}
Expand Down
14 changes: 13 additions & 1 deletion src/ProjGraph.Lib.Core/Parsers/SlnxParser.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,14 +19,24 @@ 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.
/// </returns>
/// <exception cref="ParsingException">Thrown when the `.slnx` file contains malformed XML.</exception>
public IEnumerable<string> GetProjectPaths(string path)
{
if (!fileSystem.FileExists(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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared utility methods used by relationship and property configuration parsers.
/// </summary>
internal static class FluentApiParsingUtilities
internal static partial class FluentApiParsingUtilities
{
[GeneratedRegex(@"""[^""]*""")]
private static partial Regex StringLiteralStripRegex();
/// <summary>
/// Extracts the generic type from a method name like "Property&lt;T&gt;".
/// </summary>
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ public static IServiceCollection AddProjGraphProjectGraph(this IServiceCollectio
services.AddSingleton<IGraphService, GraphService>();
services.AddSingleton<IStatsService, StatsService>();

// Renderers
services.AddTransient<TreeGraphRenderer>();
services.AddTransient<FlatGraphRenderer>();
services.AddTransient<MermaidGraphRenderer>();
// Renderers (all stateless — registered as Singleton)
services.AddSingleton<TreeGraphRenderer>();
services.AddSingleton<FlatGraphRenderer>();
services.AddSingleton<MermaidGraphRenderer>();

// Register as interface for collection injection
services.AddTransient<IDiagramRenderer<SolutionGraph>>(sp => sp.GetRequiredService<TreeGraphRenderer>());
services.AddTransient<IDiagramRenderer<SolutionGraph>>(sp => sp.GetRequiredService<FlatGraphRenderer>());
services.AddTransient<IDiagramRenderer<SolutionGraph>>(sp => sp.GetRequiredService<MermaidGraphRenderer>());
services.AddSingleton<IDiagramRenderer<SolutionGraph>>(sp => sp.GetRequiredService<TreeGraphRenderer>());
services.AddSingleton<IDiagramRenderer<SolutionGraph>>(sp => sp.GetRequiredService<FlatGraphRenderer>());
services.AddSingleton<IDiagramRenderer<SolutionGraph>>(sp => sp.GetRequiredService<MermaidGraphRenderer>());

return services;
}
Expand Down
Loading
Loading