diff --git a/src/ProjGraph.Core/Models/EfModel.cs b/src/ProjGraph.Core/Models/EfModel.cs
index 1c0f538..5099917 100644
--- a/src/ProjGraph.Core/Models/EfModel.cs
+++ b/src/ProjGraph.Core/Models/EfModel.cs
@@ -136,6 +136,13 @@ public class EfProperty
///
public bool IsExplicitlyRequired { get; init; }
+ ///
+ /// Gets or initializes a value indicating whether 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.
+ ///
+ public bool IsTypeInferred { get; init; }
+
///
/// Gets or initializes the maximum length of the property value, if applicable.
///
diff --git a/src/ProjGraph.Lib.ClassDiagram/README.md b/src/ProjGraph.Lib.ClassDiagram/README.md
index 81ad62a..ca2d9a7 100644
--- a/src/ProjGraph.Lib.ClassDiagram/README.md
+++ b/src/ProjGraph.Lib.ClassDiagram/README.md
@@ -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();
-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();
+var renderer = provider.GetRequiredService>();
string diagram = renderer.Render(result);
```
@@ -70,11 +70,13 @@ classDiagram
### Analyze a single file or directory
```csharp
+using ProjGraph.Lib.ClassDiagram.Application.UseCases;
+
var analyzeFile = provider.GetRequiredService();
-var result = await analyzeFile.ExecuteAsync("src/Domain/Order.cs");
+var fileModel = await analyzeFile.ExecuteAsync("src/Domain/Order.cs");
var analyzeDirectory = provider.GetRequiredService();
-var result = await analyzeDirectory.ExecuteAsync("src/Domain/");
+var directoryModel = await analyzeDirectory.ExecuteAsync("src/Domain/");
```
## Key Services
diff --git a/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs b/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs
index f325131..68a4906 100644
--- a/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs
+++ b/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs
@@ -61,6 +61,65 @@ public static DirectoryInfo FindSolutionRoot(string startDirectory, int maxLevel
return solutionRoot;
}
+ ///
+ /// Traverses up from the start directory looking for the enclosing *solution* root — a directory
+ /// holding a .sln/.slnx file or a .git directory. Unlike 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.
+ ///
+ /// The directory path from which to begin searching upward.
+ /// The maximum number of directory levels to traverse upward.
+ /// The full path to the enclosing solution root if one is found; otherwise, null.
+ 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;
+ }
+
+ ///
+ /// Determines whether the specified directory is exactly the given path (ignoring trailing separators).
+ ///
+ /// The directory to check.
+ /// The normalized path to compare against.
+ /// True if the directory is that same directory; otherwise, false.
+ private static bool IsSameDirectory(DirectoryInfo directory, string path)
+ {
+ return directory.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ .Equals(path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Determines whether the specified directory holds a solution-level marker (.sln, .slnx or .git).
+ ///
+ /// The directory to check.
+ /// True if the directory contains a solution-level marker; otherwise, false.
+ private static bool IsSolutionRoot(DirectoryInfo directory)
+ {
+ return directory.GetFiles("*.sln").Length > 0 ||
+ directory.GetFiles("*.slnx").Length > 0 ||
+ directory.GetDirectories(DirectoryFilters.Git).Length > 0;
+ }
+
///
/// Determines whether the specified directory is a workspace root by checking for
/// workspace marker files (.sln, .slnx, .csproj) or directories (.git).
diff --git a/src/ProjGraph.Lib.Core/README.md b/src/ProjGraph.Lib.Core/README.md
index c74ac4a..5cb2803 100644
--- a/src/ProjGraph.Lib.Core/README.md
+++ b/src/ProjGraph.Lib.Core/README.md
@@ -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();
-IReadOnlyList projects = await parser.ParseAsync("MySolution.slnx");
+// Resolve the parser matching the solution format: ISlnxParser for .slnx, ISlnParser for .sln.
+var parser = provider.GetRequiredService();
+IEnumerable 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` | Generic renderer producing a string diagram from a model |
| `IFileSystem` | Abstraction over `System.IO` for testability |
diff --git a/src/ProjGraph.Lib.Dependencies/README.md b/src/ProjGraph.Lib.Dependencies/README.md
index 79149a2..c69d112 100644
--- a/src/ProjGraph.Lib.Dependencies/README.md
+++ b/src/ProjGraph.Lib.Dependencies/README.md
@@ -62,9 +62,15 @@ graph TD
var statsService = provider.GetRequiredService();
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
diff --git a/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs b/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs
index 4ece298..f1da87a 100644
--- a/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs
@@ -43,6 +43,13 @@ Task> DiscoverBaseClassFilesAsync(DictionaryThe directory containing the context file.
IReadOnlyList BuildSearchDirectories(string contextDirectory);
+ ///
+ /// 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.
+ ///
+ /// The directory containing the context file.
+ IReadOnlyList BuildLocalSearchDirectories(string contextDirectory);
+
///
/// Extracts entity type names from a DbContext class declaration.
///
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs
index fa87352..f76584f 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs
@@ -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";
}
///
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
index 603bf1f..eb6277c 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
@@ -217,7 +217,10 @@ private async Task> BuildSyntaxTreesAsync(
// Slice 4: pull separate IEntityTypeConfiguration 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, so nothing above finds
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs
index b7af4b4..a79bd59 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs
@@ -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,
@@ -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,
@@ -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)
@@ -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);
}
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs
index 405bb0b..7d09229 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs
@@ -24,6 +24,9 @@ internal sealed record EfPropertyOverrides
/// Override for the IsExplicitlyRequired property.
public bool? IsExplicitlyRequired { get; init; }
+ /// Override for the IsTypeInferred property.
+ public bool? IsTypeInferred { get; init; }
+
/// Override for the MaxLength property.
public int? MaxLength { get; init; }
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs
index 7d76a50..75b5c26 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs
@@ -26,6 +26,13 @@ internal sealed class EntityFileDiscovery(IFileSystem fileSystem) : IEntityFileD
///
private const int MaxSearchDepth = 10;
+ ///
+ /// Maximum number of directory levels walked upward when looking for the enclosing solution root.
+ /// Deep enough for the conventional <root>/src/<Project>/<Folder> layouts, bounded so a
+ /// context outside any solution never triggers a walk toward the filesystem root.
+ ///
+ private const int MaxWorkspaceWalkLevels = 6;
+
///
/// Discovers the file paths of entity files within the specified search directories.
///
@@ -126,9 +133,13 @@ public async Task> DiscoverBaseClassFilesAsync(
/// A list of directories to search for entity files, including the context directory and its parent directory.
///
///
- /// This method starts with the context directory and then its parent directory.
- /// Since the parent directory scan is recursive, it will naturally include the context directory
- /// and all siblings through the recursive search.
+ /// The context directory comes first so that a type declared next to the DbContext always wins over a
+ /// same-named type elsewhere (matches are recorded with TryAdd, so the first hit sticks).
+ /// The enclosing solution root is added next: in a layered solution the entities live in a sibling
+ /// project (src/Core) while the context sits deeper in its own (src/Infrastructure/Data),
+ /// so a one-parent radius never reaches them. When no solution marker is found the search falls back
+ /// to the context directory plus its immediate parent, and never escapes into the shared system temp
+ /// directory, where it could pick up files from parallel test runs.
///
public IReadOnlyList BuildSearchDirectories(
string contextDirectory)
@@ -137,6 +148,55 @@ public IReadOnlyList BuildSearchDirectories(
{
contextDirectory
};
+
+ var solutionRoot = WorkspaceRootResolver.FindEnclosingSolutionRoot(contextDirectory, MaxWorkspaceWalkLevels);
+ if (solutionRoot is not null)
+ {
+ // Walk outward one level at a time instead of jumping straight to the root: each ancestor's scan
+ // is recursive, so the nearest declaration of a type name is recorded first and a same-named type
+ // in an unrelated project further out can never displace it.
+ for (var current = Directory.GetParent(contextDirectory);
+ current is not null;
+ current = current.Parent)
+ {
+ searchDirectories.Add(current.FullName);
+
+ if (current.FullName.Equals(solutionRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ break;
+ }
+ }
+
+ return [.. searchDirectories.Distinct()];
+ }
+
+ return BuildLocalSearchDirectories(contextDirectory);
+ }
+
+ ///
+ /// Builds the narrow search list — the context directory and its immediate parent — used where a wider
+ /// radius would over-collect rather than fill gaps.
+ ///
+ /// The directory containing the context file.
+ /// The context directory, plus its parent when one exists outside the system temp directory.
+ ///
+ /// Entity discovery is driven by names the context actually declares, so widening it can only fill in
+ /// missing types. Configuration-class discovery has no such guard: every
+ /// IEntityTypeConfiguration<T> in range contributes its entity to the model once the context
+ /// calls ApplyConfigurationsFromAssembly. Scanning a whole repository therefore pulls in entities
+ /// from unrelated solutions nested inside it — on ardalis/CleanArchitecture the six config classes of the
+ /// nested MinimalClean solution added Cart, CartItem, GuestUser, Order, OrderItem and Product to an ERD
+ /// of a context whose only DbSet is Contributor. Configuration classes live beside their DbContext by
+ /// convention (Data/Config/), so this narrower radius still finds the ones that belong.
+ ///
+ public IReadOnlyList BuildLocalSearchDirectories(
+ string contextDirectory)
+ {
+ var searchDirectories = new List
+ {
+ contextDirectory
+ };
+
var parentDir = Directory.GetParent(contextDirectory);
if (parentDir is null)
@@ -185,12 +245,40 @@ public HashSet ExtractEntityTypeNames(ClassDeclarationSyntax contextClas
];
}
- /// Reduces a type-argument syntax to its simple identifier (last segment of a qualified name).
- /// The type-argument syntax from a DbSet<T> property.
+ ///
+ /// Determines whether a type declaration is an EF Core migration rather than an entity.
+ ///
+ /// The type declaration to classify.
+ /// when the declaration is a migration; otherwise, .
+ ///
+ /// dotnet ef migrations add PhoneNumber emits public partial class PhoneNumber : Migration
+ /// beside the DbContext, so a migration named after the change it makes collides with the entity or value
+ /// object of that name — and sits nearer to the context than the real declaration does. Detection is by
+ /// shape (a Migration base type or the [Migration] attribute EF generates on the designer
+ /// half of the partial class) rather than by folder name, which is configurable and often renamed.
+ /// Both are matched on the right-most identifier: migrations are generated code, so neither the base
+ /// type nor the attribute is guaranteed to be written unqualified.
+ ///
+ private static bool IsMigrationClass(TypeDeclarationSyntax typeDecl)
+ {
+ var derivesFromMigration = typeDecl.BaseList?.Types
+ .Any(baseType => SimpleTypeName(baseType.Type) is EfAnalysisConstants.CommonNames.Migration) == true;
+
+ var hasMigrationAttribute = typeDecl.AttributeLists
+ .SelectMany(list => list.Attributes)
+ .Any(attribute => SimpleTypeName(attribute.Name) is EfAnalysisConstants.CommonNames.Migration
+ or EfAnalysisConstants.CommonNames.MigrationAttribute);
+
+ return derivesFromMigration || hasMigrationAttribute;
+ }
+
+ /// Reduces a type syntax to its simple identifier (the right-most segment of a qualified name).
+ /// The type syntax to reduce — a DbSet<T> type argument, a base type or an attribute name.
/// The simple type name.
private static string SimpleTypeName(TypeSyntax type) => type switch
{
- QualifiedNameSyntax qualified => qualified.Right.Identifier.Text,
+ QualifiedNameSyntax qualified => SimpleTypeName(qualified.Right),
+ AliasQualifiedNameSyntax alias => SimpleTypeName(alias.Name),
SimpleNameSyntax simple => simple.Identifier.Text,
_ => type.ToString()
};
@@ -322,7 +410,8 @@ private async Task ProcessSourceFileAsync(
foreach (var typeDecl in root.DescendantNodes()
.OfType()
- .Where(typeDecl => entityTypeNames.Contains(typeDecl.Identifier.Text)))
+ .Where(typeDecl => entityTypeNames.Contains(typeDecl.Identifier.Text)
+ && !IsMigrationClass(typeDecl)))
{
entityFiles.TryAdd(typeDecl.Identifier.Text, filePath);
}
diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs
index 289ea3d..5cf47a5 100644
--- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs
+++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs
@@ -232,15 +232,25 @@ private static EfProperty ApplyIsRequiredConfiguration(EfProperty property, stri
private static EfProperty ApplyMaxLengthConfiguration(EfProperty property, string configArg)
{
- if (int.TryParse(configArg, out var maxLen))
+ if (!int.TryParse(configArg, out var maxLen))
{
- return EfPropertyFactory.CopyWith(property, new EfPropertyOverrides
- {
- MaxLength = maxLen
- });
+ return property;
}
- return property;
+ // A maximum length only applies to string (and byte[]) columns, so it is proof that a guessed type
+ // is wrong: an unresolvable property whose name ends in "Id" is guessed as a Guid, which would
+ // otherwise render as the self-contradictory `Guid BuyerId "max:256"`. Only a guessed type is
+ // corrected — a CLR-declared or column-type-derived type stays exactly as declared.
+ var correctsGuessedType = property.IsTypeInferred &&
+ !property.Type.Equals(EfAnalysisConstants.DataTypes.StringTypeName,
+ StringComparison.OrdinalIgnoreCase);
+
+ return EfPropertyFactory.CopyWith(property, new EfPropertyOverrides
+ {
+ MaxLength = maxLen,
+ Type = correctsGuessedType ? EfAnalysisConstants.DataTypes.StringTypeName : null,
+ IsValueType = correctsGuessedType ? false : null
+ });
}
///
@@ -256,14 +266,20 @@ private static EfProperty ApplyColumnTypeConfiguration(EfProperty property, stri
// The SQL column type is authoritative. When the CLR type is only the guessed string fallback,
// recover a more accurate value type (e.g. decimal, Guid, bool) from the column type.
+ // Applies to any guessed type, not just the string fallback: a property named `*Id` is guessed as a
+ // Guid, and the column type is the better evidence for it too.
var inferredType = SqlColumnTypeMapper.ToClrType(configArg);
if (inferredType is not null &&
- updated.Type.Equals(EfAnalysisConstants.DataTypes.StringTypeName, StringComparison.OrdinalIgnoreCase))
+ (updated.IsTypeInferred ||
+ updated.Type.Equals(EfAnalysisConstants.DataTypes.StringTypeName, StringComparison.OrdinalIgnoreCase)))
{
updated = EfPropertyFactory.CopyWith(updated, new EfPropertyOverrides
{
Type = inferredType,
- IsValueType = EfPropertyFactory.IsValueTypeString(inferredType)
+ IsValueType = EfPropertyFactory.IsValueTypeString(inferredType),
+ // The column type is authoritative: the type is no longer a name-based guess, so a max
+ // length chained after it must not overwrite it the way it corrects a guessed type.
+ IsTypeInferred = false
});
}
diff --git a/src/ProjGraph.Lib.EntityFramework/README.md b/src/ProjGraph.Lib.EntityFramework/README.md
index 7c37692..c308823 100644
--- a/src/ProjGraph.Lib.EntityFramework/README.md
+++ b/src/ProjGraph.Lib.EntityFramework/README.md
@@ -38,14 +38,15 @@ services.AddProjGraphEntityFramework();
### Generate an ERD from a DbContext file
```csharp
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.Core.Abstractions;
using ProjGraph.Lib.EntityFramework.Application;
-using ProjGraph.Lib.EntityFramework.Application.UseCases;
var efAnalysisService = provider.GetRequiredService();
var model = await efAnalysisService.AnalyzeContextAsync("src/Data/AppDbContext.cs");
-var renderer = provider.GetRequiredService();
+var renderer = provider.GetRequiredService>();
string diagram = renderer.Render(model);
```
@@ -70,6 +71,8 @@ erDiagram
### Generate an ERD from a ModelSnapshot
```csharp
+using ProjGraph.Lib.EntityFramework.Application.UseCases;
+
var analyzeSnapshot = provider.GetRequiredService();
var model = await analyzeSnapshot.ExecuteAsync("src/Migrations/AppDbContextModelSnapshot.cs");
```
diff --git a/src/ProjGraph.Lib/README.md b/src/ProjGraph.Lib/README.md
index 880841a..e8f36c9 100644
--- a/src/ProjGraph.Lib/README.md
+++ b/src/ProjGraph.Lib/README.md
@@ -46,7 +46,7 @@ using ProjGraph.Lib.Dependencies.Application;
using ProjGraph.Lib.Dependencies.Rendering;
var graphService = provider.GetRequiredService();
-var graph = await graphService.GetGraphAsync("MySolution.slnx", includePackages: false);
+var graph = await graphService.BuildGraphAsync("MySolution.slnx", includePackages: false);
var renderer = provider.GetRequiredService();
Console.WriteLine(renderer.Render(graph));
@@ -55,26 +55,28 @@ Console.WriteLine(renderer.Render(graph));
### Class diagram
```csharp
+using ProjGraph.Core.Models;
using ProjGraph.Lib.ClassDiagram.Application;
-using ProjGraph.Lib.ClassDiagram.Rendering;
+using ProjGraph.Lib.Core.Abstractions;
var classAnalysis = provider.GetRequiredService();
-var result = await classAnalysis.AnalyzeAsync("src/MyProject/MyProject.csproj");
+var result = await classAnalysis.AnalyzeDirectoryAsync("src/MyProject");
-var renderer = provider.GetRequiredService();
+var renderer = provider.GetRequiredService>();
Console.WriteLine(renderer.Render(result));
```
### Entity Relationship Diagram
```csharp
+using ProjGraph.Core.Models;
+using ProjGraph.Lib.Core.Abstractions;
using ProjGraph.Lib.EntityFramework.Application;
-using ProjGraph.Lib.EntityFramework.Rendering;
var efAnalysis = provider.GetRequiredService();
var model = await efAnalysis.AnalyzeContextAsync("src/Data/AppDbContext.cs");
-var renderer = provider.GetRequiredService();
+var renderer = provider.GetRequiredService>();
Console.WriteLine(renderer.Render(model));
```
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs
index bb7d025..60de97d 100644
--- a/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs
@@ -83,6 +83,96 @@ public async Task HasColumnType_StringColumn_KeepsStringAndMaxLength()
property.MaxLength.Should().Be(200);
}
+ [Fact]
+ public async Task HasMaxLength_OnUnresolvedIdNamedProperty_InfersStringNotGuid()
+ {
+ // A property whose name ends in "Id" and whose CLR type is unresolvable is guessed as a Guid.
+ // A max length is meaningless for a Guid column, so the constraint is proof the column is a
+ // string: eShopOnWeb configures `BuyerId` with HasMaxLength(256) and it is a string, but the
+ // ERD rendered the self-contradictory `Guid BuyerId "required, max:256"`.
+ using var temp = new TestDirectory();
+ const string content = """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+ public class AppContext : DbContext
+ {
+ public DbSet Orders { get; set; } = null!;
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ builder.Entity().Property(o => o.BuyerId)
+ .IsRequired()
+ .HasMaxLength(256);
+ }
+ }
+ """;
+ var filePath = temp.CreateFile("Context.cs", content);
+
+ var model = await _service.AnalyzeContextAsync(filePath, "AppContext");
+ var property = model.Entities.Single(e => e.Name == "Order").Properties.Single(p => p.Name == "BuyerId");
+
+ property.Type.Should().Be("string");
+ property.MaxLength.Should().Be(256);
+ }
+
+ [Fact]
+ public async Task HasMaxLength_AfterExplicitColumnType_KeepsColumnTypesClrType()
+ {
+ // HasColumnType is authoritative, so a max length chained after it must not undo the recovered
+ // CLR type the way it corrects a name-guessed one.
+ using var temp = new TestDirectory();
+ const string content = """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+ public class AppContext : DbContext
+ {
+ public DbSet Orders { get; set; } = null!;
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ builder.Entity().Property(o => o.BuyerId)
+ .HasColumnType("uniqueidentifier")
+ .HasMaxLength(36);
+ }
+ }
+ """;
+ var filePath = temp.CreateFile("Context.cs", content);
+
+ var model = await _service.AnalyzeContextAsync(filePath, "AppContext");
+ var property = model.Entities.Single(e => e.Name == "Order").Properties.Single(p => p.Name == "BuyerId");
+
+ property.Type.Should().Be("Guid");
+ }
+
+ [Fact]
+ public async Task HasMaxLength_OnResolvedGuidProperty_KeepsDeclaredType()
+ {
+ // The inference must not rewrite a type the CLR actually declares.
+ using var temp = new TestDirectory();
+ const string content = """
+ using System;
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+ public class Order
+ {
+ public int Id { get; set; }
+ public Guid BuyerId { get; set; }
+ }
+ public class AppContext : DbContext
+ {
+ public DbSet Orders { get; set; } = null!;
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ builder.Entity().Property(o => o.BuyerId).HasMaxLength(256);
+ }
+ }
+ """;
+ var filePath = temp.CreateFile("Context.cs", content);
+
+ var model = await _service.AnalyzeContextAsync(filePath, "AppContext");
+ var property = model.Entities.Single(e => e.Name == "Order").Properties.Single(p => p.Name == "BuyerId");
+
+ property.Type.Should().Be("Guid");
+ }
+
[Theory]
[InlineData("decimal(18)")]
[InlineData("numeric(18)")]
diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/CrossProjectEntityDiscoveryTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/CrossProjectEntityDiscoveryTests.cs
new file mode 100644
index 0000000..1eff0e6
--- /dev/null
+++ b/tests/ProjGraph.Tests.Unit.EntityFramework/CrossProjectEntityDiscoveryTests.cs
@@ -0,0 +1,296 @@
+using ProjGraph.Lib.Core.Infrastructure;
+using ProjGraph.Lib.EntityFramework.Application;
+using ProjGraph.Lib.EntityFramework.Application.UseCases;
+using ProjGraph.Lib.EntityFramework.Infrastructure;
+using ProjGraph.Tests.Shared.Helpers;
+
+namespace ProjGraph.Tests.Unit.EntityFramework;
+
+///
+/// Regression tests for a live-found gap: entity-file discovery searched only the context directory and
+/// its immediate parent, while base-class discovery already walked up to the workspace root. In the
+/// standard layered layout — entities in src/Core, the DbContext in src/Infrastructure/Data/ —
+/// the entity CLR files sit two or more levels above the context directory, so none of them were ever read
+/// and every column had to come from an explicit fluent Property() call. Found by validating against
+/// ardalis/CleanArchitecture, where Contributor.PhoneNumber (a bare OwnsOne whose CLR type
+/// lives in the Core project) rendered as an empty PhoneNumber {} box, and against eShopOnWeb, where
+/// Order.OrderDate and CatalogItem.Description were missing entirely.
+///
+[Trait("Category", "EntityFramework")]
+public sealed class CrossProjectEntityDiscoveryTests
+{
+ private static EfAnalysisService CreateService()
+ {
+ var fs = new PhysicalFileSystem();
+ var analyzer = new EfModelAnalyzer(new CompilationFactory(), fs, new EntityFileDiscovery(fs));
+ return new EfAnalysisService(
+ new AnalyzeContextUseCase(analyzer),
+ new DiscoverContextsUseCase(analyzer, fs),
+ new AnalyzeSnapshotUseCase(analyzer),
+ new DiscoverSnapshotsUseCase(analyzer, fs));
+ }
+
+ [Fact]
+ public async Task AnalyzeContextAsync_EntityInSiblingProject_ClrPropertiesCaptured()
+ {
+ using var temp = new TestDirectory();
+
+ // Layered layout: the solution marker sits at the root, the entity lives in one project and the
+ // DbContext in another, nested a directory deeper (src/Infrastructure/Data) exactly as EF templates
+ // scaffold it. Customer carries no fluent configuration at all, so its columns can only reach the
+ // model by reading Customer.cs — which the old one-parent search radius never reached.
+ temp.CreateFile("App.slnx", "");
+ temp.CreateFile("src/Core/Customer.cs", """
+ namespace Test;
+
+ public class Customer
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public string Email { get; set; } = "";
+ }
+ """);
+ var contextPath = temp.CreateFile("src/Infrastructure/Data/ShopContext.cs", """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+
+ public class ShopContext : DbContext
+ {
+ public DbSet Customers { get; set; } = null!;
+ }
+ """);
+
+ var model = await CreateService().AnalyzeContextAsync(contextPath, "ShopContext");
+
+ var customer = model.Entities.SingleOrDefault(e => e.Name == "Customer");
+ customer.Should().NotBeNull("the DbSet entity must be discovered across the project boundary");
+ customer.Properties.Select(p => p.Name).Should().BeEquivalentTo(["Id", "Name", "Email"],
+ "Customer has no fluent configuration, so these columns prove Customer.cs was read");
+ }
+
+ [Fact]
+ public async Task AnalyzeContextAsync_OwnedTypeInSiblingProject_ColumnsCaptured()
+ {
+ using var temp = new TestDirectory();
+
+ // The ardalis/CleanArchitecture shape: a bare OwnsOne with no property configuration, whose CLR
+ // type lives in another project. Without the widened radius the owned entity is captured with zero
+ // properties and renders as an empty box.
+ temp.CreateFile("App.slnx", "");
+ // The navigation is declared nullable (`PhoneNumber?`), exactly as ardalis/CleanArchitecture
+ // declares it: the owned CLR type must be resolved through the nullable annotation rather than
+ // being read as the type `Nullable`.
+ temp.CreateFile("src/Core/Contributor.cs", """
+ namespace Test;
+
+ public class Contributor
+ {
+ public int Id { get; set; }
+ public PhoneNumber? PhoneNumber { get; private set; }
+ }
+ """);
+ temp.CreateFile("src/Core/PhoneNumber.cs", """
+ namespace Test;
+
+ public class PhoneNumber(string countryCode, string number)
+ {
+ public string CountryCode { get; private set; } = countryCode;
+ public string Number { get; private set; } = number;
+ }
+ """);
+ var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+
+ public class AppDbContext : DbContext
+ {
+ public DbSet Contributors { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().OwnsOne(c => c.PhoneNumber);
+ }
+ }
+ """);
+
+ var model = await CreateService().AnalyzeContextAsync(contextPath, "AppDbContext");
+
+ var owned = model.Entities.SingleOrDefault(e => e.Key == "Contributor.PhoneNumber");
+ owned.Should().NotBeNull("the owned navigation must be captured");
+ owned.Properties.Select(p => p.Name).Should().BeEquivalentTo(["CountryCode", "Number"],
+ "the owned type's columns can only come from CLR seeding of PhoneNumber.cs in the sibling project");
+ }
+
+ [Fact]
+ public async Task AnalyzeContextAsync_ConfigurationInUnrelatedSolution_DoesNotLeakEntities()
+ {
+ using var temp = new TestDirectory();
+
+ // A repository holding more than one solution — the shape of ardalis/CleanArchitecture, which
+ // carries `sample/` and `MinimalClean/` trees alongside the main one. Widening entity discovery to
+ // the repository root must not drag in IEntityTypeConfiguration classes from a sibling solution:
+ // those configure entities this DbContext never declares.
+ temp.CreateFile("App.slnx", "");
+ temp.CreateFile("src/Core/Customer.cs", """
+ namespace Test;
+
+ public class Customer
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ }
+ """);
+ var contextPath = temp.CreateFile("src/Infrastructure/Data/ShopContext.cs", """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+
+ public class ShopContext : DbContext
+ {
+ public DbSet Customers { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopContext).Assembly);
+ }
+ }
+ """);
+
+ // A second, self-contained solution nested inside the same repository, declaring a context class
+ // of the SAME name. This is what leaked on ardalis/CleanArchitecture: the nested MinimalClean
+ // solution's AppDbContext contributed its DbSets (Cart, GuestUser, Order, …) to an ERD of the main
+ // solution's AppDbContext, which declares only DbSet.
+ temp.CreateFile("sample/Sample.slnx", "");
+ temp.CreateFile("sample/src/Widget.cs", """
+ namespace Other;
+
+ public class Widget
+ {
+ public int Id { get; set; }
+ public string Label { get; set; } = "";
+ }
+ """);
+ temp.CreateFile("sample/src/Web/Infrastructure/Data/Config/WidgetConfiguration.cs", """
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
+ namespace Other;
+
+ public class WidgetConfiguration : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.Property(w => w.Label).IsRequired().HasMaxLength(50);
+ }
+ }
+ """);
+
+ var model = await CreateService().AnalyzeContextAsync(contextPath, "ShopContext");
+
+ model.Entities.Select(e => e.Name).Should().NotContain("Widget",
+ "Widget belongs to a different solution nested in the same repository and is not declared by this DbContext");
+ model.Entities.Select(e => e.Name).Should().Contain("Customer",
+ "the analysed solution's own entity must still be found");
+ }
+
+ [Fact]
+ public async Task AnalyzeContextAsync_MigrationClassSharesEntityName_ResolvesTheRealType()
+ {
+ using var temp = new TestDirectory();
+
+ // EF names a migration class after the change it makes, so `dotnet ef migrations add PhoneNumber`
+ // produces `public partial class PhoneNumber : Migration` right beside the DbContext — nearer than
+ // the value object it is named for. Matching it made the owned navigation resolve to a type with no
+ // columns (rendered as an empty box on ardalis/CleanArchitecture). Migration classes are never
+ // entities and must be skipped regardless of how close they sit.
+ temp.CreateFile("App.slnx", "");
+ temp.CreateFile("src/Core/Contributor.cs", """
+ namespace Test;
+
+ public class Contributor
+ {
+ public int Id { get; set; }
+ public PhoneNumber? PhoneNumber { get; private set; }
+ }
+ """);
+ temp.CreateFile("src/Core/PhoneNumber.cs", """
+ namespace Test;
+
+ public class PhoneNumber
+ {
+ public string CountryCode { get; private set; } = "";
+ public string Number { get; private set; } = "";
+ }
+ """);
+ temp.CreateFile("src/Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs", """
+ using Microsoft.EntityFrameworkCore.Migrations;
+
+ namespace Test.Migrations;
+
+ public partial class PhoneNumber : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder) { }
+ protected override void Down(MigrationBuilder migrationBuilder) { }
+ }
+ """);
+ // The designer half of the same partial class, written with fully qualified names: migrations are
+ // generated code, so neither the base type nor the attribute is guaranteed to be unqualified.
+ temp.CreateFile("src/Infrastructure/Data/Migrations/20231218143922_PhoneNumber.Designer.cs", """
+ namespace Test.Migrations;
+
+ [Microsoft.EntityFrameworkCore.Migrations.Migration("20231218143922_PhoneNumber")]
+ public partial class PhoneNumber : Microsoft.EntityFrameworkCore.Migrations.Migration
+ {
+ }
+ """);
+ var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", """
+ using Microsoft.EntityFrameworkCore;
+ namespace Test;
+
+ public class AppDbContext : DbContext
+ {
+ public DbSet Contributors { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().OwnsOne(c => c.PhoneNumber);
+ }
+ }
+ """);
+
+ var model = await CreateService().AnalyzeContextAsync(contextPath, "AppDbContext");
+
+ var owned = model.Entities.SingleOrDefault(e => e.Key == "Contributor.PhoneNumber");
+ owned.Should().NotBeNull();
+ owned.Properties.Select(p => p.Name).Should().BeEquivalentTo(["CountryCode", "Number"],
+ "the value object must win over the identically named migration class");
+ }
+
+ [Fact]
+ public void BuildSearchDirectories_ContextNestedUnderSolutionRoot_IncludesThatRoot()
+ {
+ using var temp = new TestDirectory();
+
+ temp.CreateFile("App.slnx", "");
+ var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", "// context");
+ var contextDirectory = Path.GetDirectoryName(contextPath)!;
+
+ var result = new EntityFileDiscovery(new PhysicalFileSystem()).BuildSearchDirectories(contextDirectory);
+
+ result.Should().Contain(contextDirectory, "the context directory is always searched first");
+ result.Should().Contain(temp.DirectoryPath,
+ "the enclosing solution root bounds the search, so sibling projects are reachable");
+ }
+
+ [Fact]
+ public void BuildSearchDirectories_NoWorkspaceMarkerAnywhere_DoesNotEscapeToTemp()
+ {
+ using var temp = new TestDirectory();
+
+ var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", "// context");
+ var contextDirectory = Path.GetDirectoryName(contextPath)!;
+
+ var result = new EntityFileDiscovery(new PhysicalFileSystem()).BuildSearchDirectories(contextDirectory);
+
+ result.Should().NotContain(Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar),
+ "with no marker to bound it the walk must stop rather than scan the whole temp directory");
+ }
+}