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
7 changes: 7 additions & 0 deletions src/ProjGraph.Core/Models/EfModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ public class EfProperty
/// </summary>
public bool IsExplicitlyRequired { get; init; }

/// <summary>
/// Gets or initializes a value indicating whether <see cref="Type"/> was guessed from the property's
/// name rather than read from a CLR declaration or an explicit column type. Only a guessed type may be
/// corrected by later evidence, such as a maximum length that no value type could carry.
/// </summary>
public bool IsTypeInferred { get; init; }

/// <summary>
/// Gets or initializes the maximum length of the property value, if applicable.
/// </summary>
Expand Down
22 changes: 12 additions & 10 deletions src/ProjGraph.Lib.ClassDiagram/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,21 @@ services.AddProjGraphCore();
services.AddProjGraphClassDiagram();
```

### Generate a class diagram from a project
### Generate a class diagram from a directory

```csharp
using ProjGraph.Core.Models;
using ProjGraph.Lib.ClassDiagram.Application;
using ProjGraph.Lib.ClassDiagram.Application.UseCases;
using ProjGraph.Lib.Core.Abstractions;

var analysisService = provider.GetRequiredService<IClassAnalysisService>();

var result = await analysisService.AnalyzeAsync("src/MyProject/MyProject.csproj", new AnalysisOptions
{
IncludePrivateMembers = false,
IncludeInternalTypes = false
});
var result = await analysisService.AnalyzeDirectoryAsync("src/MyProject", new AnalysisOptions(
MaxDepth: 1,
IncludeInheritance: true,
IncludeDependencies: true));

var renderer = provider.GetRequiredService<MermaidClassDiagramRenderer>();
var renderer = provider.GetRequiredService<IDiagramRenderer<ClassModel>>();
string diagram = renderer.Render(result);
```

Expand All @@ -70,11 +70,13 @@ classDiagram
### Analyze a single file or directory

```csharp
using ProjGraph.Lib.ClassDiagram.Application.UseCases;

var analyzeFile = provider.GetRequiredService<AnalyzeFileUseCase>();
var result = await analyzeFile.ExecuteAsync("src/Domain/Order.cs");
var fileModel = await analyzeFile.ExecuteAsync("src/Domain/Order.cs");

var analyzeDirectory = provider.GetRequiredService<AnalyzeDirectoryUseCase>();
var result = await analyzeDirectory.ExecuteAsync("src/Domain/");
var directoryModel = await analyzeDirectory.ExecuteAsync("src/Domain/");
```

## Key Services
Expand Down
59 changes: 59 additions & 0 deletions src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,65 @@ public static DirectoryInfo FindSolutionRoot(string startDirectory, int maxLevel
return solutionRoot;
}

/// <summary>
/// Traverses up from the start directory looking for the enclosing *solution* root — a directory
/// holding a .sln/.slnx file or a .git directory. Unlike <see cref="FindWorkspaceRoot"/> this ignores
/// .csproj markers, which would stop the walk inside the starting project and never reach the sibling
/// projects of a layered solution. Never walks above the system temp directory, so an isolated tree
/// created under temp is bounded by its own marker rather than by the shared temp root.
/// </summary>
/// <param name="startDirectory">The directory path from which to begin searching upward.</param>
/// <param name="maxLevels">The maximum number of directory levels to traverse upward.</param>
/// <returns>The full path to the enclosing solution root if one is found; otherwise, null.</returns>
public static string? FindEnclosingSolutionRoot(string startDirectory, int maxLevels)
{
var current = new DirectoryInfo(startDirectory);
var tempPath = GetNormalizedTempPath();

for (var level = 0; current != null && level <= maxLevels; level++)
{
if (IsSolutionRoot(current))
{
return current.FullName;
}

// Stop *at* the temp root rather than anywhere beneath it: an isolated tree created under temp
// still gets to be bounded by its own marker, but the shared temp directory is never scanned.
if (IsSameDirectory(current, tempPath))
{
break;
}

current = current.Parent;
}

return null;
}

/// <summary>
/// Determines whether the specified directory is exactly the given path (ignoring trailing separators).
/// </summary>
/// <param name="directory">The directory to check.</param>
/// <param name="path">The normalized path to compare against.</param>
/// <returns>True if the directory is that same directory; otherwise, false.</returns>
private static bool IsSameDirectory(DirectoryInfo directory, string path)
{
return directory.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Equals(path, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Determines whether the specified directory holds a solution-level marker (.sln, .slnx or .git).
/// </summary>
/// <param name="directory">The directory to check.</param>
/// <returns>True if the directory contains a solution-level marker; otherwise, false.</returns>
private static bool IsSolutionRoot(DirectoryInfo directory)
{
return directory.GetFiles("*.sln").Length > 0 ||
directory.GetFiles("*.slnx").Length > 0 ||
directory.GetDirectories(DirectoryFilters.Git).Length > 0;
}

/// <summary>
/// Determines whether the specified directory is a workspace root by checking for
/// workspace marker files (.sln, .slnx, .csproj) or directories (.git).
Expand Down
13 changes: 7 additions & 6 deletions src/ProjGraph.Lib.Core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,24 @@ services.AddProjGraphCore();
```csharp
using Microsoft.Extensions.DependencyInjection;
using ProjGraph.Lib.Core;
using ProjGraph.Core.Models;
using ProjGraph.Lib.Core.Abstractions;

var services = new ServiceCollection();
services.AddProjGraphCore();
var provider = services.BuildServiceProvider();

var parser = provider.GetRequiredService<ISolutionParser>();
IReadOnlyList<Project> projects = await parser.ParseAsync("MySolution.slnx");
// Resolve the parser matching the solution format: ISlnxParser for .slnx, ISlnParser for .sln.
var parser = provider.GetRequiredService<ISlnxParser>();
IEnumerable<string> projectPaths = parser.GetProjectPaths("MySolution.slnx");
```

## Key Abstractions

| Interface | Description |
|----------------------------|---------------------------------------------------------------------|
| `ISolutionParser` | Parses `.sln` / `.slnx` into a list of `Project` models |
| `IProjectParser` | Parses a single `.csproj` file |
| `IProjectDiscoveryService` | Discovers all projects under a directory |
| `ISolutionParser` | Extracts project file paths from a solution (`ISlnParser` / `ISlnxParser`) |
| `IProjectParser` | Parses a single `.csproj` into a `Project` plus its references |
| `IProjectDiscoveryService` | Discovers referenced projects recursively and resolves their paths |
| `ICompilationFactory` | Creates Roslyn `Compilation` objects from project files |
| `IDiagramRenderer<T>` | Generic renderer producing a string diagram from a model |
| `IFileSystem` | Abstraction over `System.IO` for testability |
Expand Down
12 changes: 9 additions & 3 deletions src/ProjGraph.Lib.Dependencies/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ graph TD
var statsService = provider.GetRequiredService<IStatsService>();
var stats = await statsService.ComputeStatsAsync("MySolution.slnx");

Console.WriteLine($"Projects : {stats.ProjectCount}");
Console.WriteLine($"Packages : {stats.PackageCount}");
Console.WriteLine($"Dependencies: {stats.DependencyCount}");
Console.WriteLine($"Solution : {stats.SolutionName}");
Console.WriteLine($"Projects : {stats.TotalProjectCount}");
Console.WriteLine($"Max depth: {stats.DepthStats.Max}");
Console.WriteLine($"Cycles : {stats.HasCycles}");

foreach (var hotspot in stats.HotspotProjects)
{
Console.WriteLine($" {hotspot.Name} ← {hotspot.InDegree} projects");
}
```

## Key Services
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ Task<Dictionary<string, string>> DiscoverBaseClassFilesAsync(Dictionary<string,
/// <param name="contextDirectory">The directory containing the context file.</param>
IReadOnlyList<string> BuildSearchDirectories(string contextDirectory);

/// <summary>
/// Builds the narrow search list — the context directory and its immediate parent — for discovery that
/// collects by shape rather than by name and would over-collect over a wider radius.
/// </summary>
/// <param name="contextDirectory">The directory containing the context file.</param>
IReadOnlyList<string> BuildLocalSearchDirectories(string contextDirectory);

/// <summary>
/// Extracts entity type names from a DbContext class declaration.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ internal static class CommonNames
public const string ModelSnapshot = "ModelSnapshot";
public const string System = "System";
public const string Nullable = "Nullable";
public const string Migration = "Migration";
public const string MigrationAttribute = "MigrationAttribute";
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ private async Task<List<SyntaxTree>> BuildSyntaxTreesAsync(

// Slice 4: pull separate IEntityTypeConfiguration<T> files into the compilation so config classes
// that live in their own files are visible to EntityConfigurationWalker.
var configFiles = await entityFileDiscovery.DiscoverConfigurationFilesAsync(searchDirectories, contextPath);
// Deliberately the narrow radius: config classes are collected by shape, not by name, so a wider
// one pulls in entities from unrelated solutions nested in the same repository.
var configFiles = await entityFileDiscovery.DiscoverConfigurationFilesAsync(
entityFileDiscovery.BuildLocalSearchDirectories(contextDirectory), contextPath);
MergeFileDictionaries(entityFiles, configFiles);

// An OwnsOne/OwnsMany navigation's CLR type is never named by a DbSet<T>, so nothing above finds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static EfProperty CopyWith(EfProperty source, EfPropertyOverrides overrid
IsRequired = overrides.IsRequired ?? source.IsRequired,
IsValueType = overrides.IsValueType ?? source.IsValueType,
IsExplicitlyRequired = overrides.IsExplicitlyRequired ?? source.IsExplicitlyRequired,
IsTypeInferred = overrides.IsTypeInferred ?? source.IsTypeInferred,
MaxLength = overrides.MaxLength ?? source.MaxLength,
Precision = overrides.Precision ?? source.Precision,
Scale = overrides.Scale ?? source.Scale,
Expand All @@ -50,6 +51,7 @@ public static EfProperty Rename(EfProperty source, string name)
IsRequired = source.IsRequired,
IsValueType = source.IsValueType,
IsExplicitlyRequired = source.IsExplicitlyRequired,
IsTypeInferred = source.IsTypeInferred,
MaxLength = source.MaxLength,
Precision = source.Precision,
Scale = source.Scale,
Expand All @@ -70,7 +72,8 @@ public static EfProperty GetOrCreateProperty(EfEntity entity, string propName, s
if (property is null)
{
var detectedType = type;
if (string.IsNullOrEmpty(detectedType))
var isTypeInferred = string.IsNullOrEmpty(detectedType);
if (isTypeInferred)
{
detectedType =
propName.EndsWith(EfAnalysisConstants.Suffixes.IdSuffix, StringComparison.OrdinalIgnoreCase)
Expand All @@ -82,7 +85,8 @@ public static EfProperty GetOrCreateProperty(EfEntity entity, string propName, s
{
Name = propName,
Type = detectedType,
IsValueType = IsValueTypeString(detectedType)
IsValueType = IsValueTypeString(detectedType),
IsTypeInferred = isTypeInferred
};
entity.Properties.Add(property);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ internal sealed record EfPropertyOverrides
/// <summary>Override for the IsExplicitlyRequired property.</summary>
public bool? IsExplicitlyRequired { get; init; }

/// <summary>Override for the IsTypeInferred property.</summary>
public bool? IsTypeInferred { get; init; }

/// <summary>Override for the MaxLength property.</summary>
public int? MaxLength { get; init; }

Expand Down
Loading
Loading