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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **The ORM is read as syntax, and is `Unknown` when nothing says.** Detection scanned raw file
text for `DevExpress.Persistent.BaseImpl.EF` and fell through to XPO, so an EF Core application
whose entities do not use the DevExpress EF base implementation — a legacy schema, its security
tables in another project — was reported as XPO. That is not a hole in the document: ground rule
1 then tells the agent that `DbContext`, `DbSet<T>` and EF migrations "do not exist in this
application and must never be suggested", which forbids the only correct answer. Signals are now
ranked by what it costs to be wrong about them — a `DbSet<T>` registered on a context first,
then `using` directives and base classes — and where neither ORM leaves a trace, the rule is
omitted rather than guessed. Reading text also counted a *mention*: a comment naming the
namespace was enough, which is how the fixture for this fix first passed against the old code.

## [0.12.1] — 2026-08-13

Entities the application declares, rather than the ones that inherit from the right class.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ applications. The agent-facing surface is what is landing now, in the open.
| ✅ | Pluggable publishing targets (`IDocumentationSink`) |
| ✅ | **MCP server** — 10 tools, live against your source |
| ✅ | **Installable Claude Code plugin** with skill and MCP server |
| ✅ | **279 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **282 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **DevExpress ground-truth catalog**, generated locally by licensees |

PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the
Expand Down
84 changes: 72 additions & 12 deletions src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@
var entities = new List<ExtractedEntity>();
var csFiles = FindFiles(sourceDirectory, options.BusinessObjectPatterns, options.ExcludePatterns).ToList();

// Resolve ORM type
var ormType = options.Orm == OrmType.Auto
? DetectOrmType(csFiles)
: options.Orm;
options.ResolvedOrm = ormType;

// Parsed once and kept, because the DbSet roster has to be known before the first class is
// classified and re-parsing every file to build it costs more than holding the trees.
//
Expand All @@ -44,6 +38,13 @@

var roster = DbSetRoster.Read(parsedFiles.Select(parsed => parsed.Root));

// Resolved after the parse, because the roster is the best evidence there is and it only
// exists once the trees do.
var ormType = options.Orm == OrmType.Auto
? DetectOrmType(parsedFiles.Select(parsed => parsed.Root), roster)
: options.Orm;
options.ResolvedOrm = ormType;

foreach (var (file, root) in parsedFiles)
{
var classDeclarations = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
Expand Down Expand Up @@ -475,17 +476,73 @@
/// <summary>
/// Detects ORM mode by scanning file contents for EF-specific namespaces.
/// </summary>
private static OrmType DetectOrmType(IEnumerable<string> csFiles)
/// <summary>
/// Decides which ORM an application persists with, from what its source declares.
/// </summary>
/// <remarks>
/// Read as syntax rather than as text. Scanning file contents for a namespace counts a mention
/// of it in a comment, a string or an <c>#if</c>-disabled block as evidence — and a project
/// that merely discusses EF Core is not one that uses it.
/// <para>
/// The signals are ranked by what each one costs to be wrong about. A <c>DbSet&lt;T&gt;</c>
/// registered on a context is the application declaring a table, and it cannot be mistaken;
/// a <c>using</c> directive is weaker but still deliberate. Where neither ORM leaves any
/// trace the answer is <see cref="OrmType.Unknown"/>, because the alternative is to state a
/// default in the same voice as everything that was actually read.
/// </para>
/// </remarks>
private static OrmType DetectOrmType(IEnumerable<SyntaxNode> roots, DbSetRoster roster)
{
foreach (var file in csFiles)
// The application cannot run without its registrations being right, which makes them the
// one signal that is never incidental.
if (roster.RegistersAnything)
return OrmType.EfCore;

var efCore = false;
var xpo = false;

foreach (var root in roots)
{
var source = File.ReadAllText(file);
if (source.Contains("DevExpress.Persistent.BaseImpl.EF"))
return OrmType.EfCore;
foreach (var directive in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
if (directive.Alias is not null || directive.Name is null)
continue;

var name = directive.Name.ToString();

// Exact or namespace-prefixed, never StartsWith on its own:
// `DevExpress.Persistent.BaseImpl` is XPO and a prefix of the EF Core one.
if (IsOrDescends(name, "Microsoft.EntityFrameworkCore")
|| IsOrDescends(name, "DevExpress.Persistent.BaseImpl.EF")
|| IsOrDescends(name, "DevExpress.ExpressApp.EFCore"))
efCore = true;
else if (IsOrDescends(name, "DevExpress.Xpo"))
xpo = true;
}
Comment on lines +506 to +521

// A base class is as deliberate as a using directive and survives file-scoped
// namespaces that name nothing.
foreach (var classDecl in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
{
foreach (var baseTypeName in GetBaseTypeNames(classDecl))
{
if (baseTypeName is "XPObject" or "XPCustomObject" or "XPLiteObject" or "XPBaseObject")
xpo = true;
else if (baseTypeName is "DbContext")
efCore = true;
}
}
}
return OrmType.Xpo;

if (efCore) return OrmType.EfCore;
if (xpo) return OrmType.Xpo;

return OrmType.Unknown;
}

private static bool IsOrDescends(string name, string ns) =>
name.Equals(ns, StringComparison.Ordinal) || name.StartsWith($"{ns}.", StringComparison.Ordinal);

/// <summary>
/// The types an application registers as <c>DbSet&lt;T&gt;</c> on a <c>DbContext</c>, and the
/// namespaces each registration could have been naming.
Expand Down Expand Up @@ -516,6 +573,9 @@
{
private readonly List<(string Argument, HashSet<string> Scopes)> _registrations = [];

/// <summary>Whether any context in the source registers anything at all.</summary>
public bool RegistersAnything => _registrations.Count > 0;

/// <summary>
/// Reads every <c>DbSet&lt;T&gt;</c> property declared on a context in the parsed source.
/// </summary>
Expand Down
7 changes: 6 additions & 1 deletion src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ public ExtractedProject ExtractFromSourceDirectory(string projectPath, Extractio
e.SourceProject ??= new DirectoryInfo(projectPath).Name;

// Set detected ORM type
project.OrmType = options.ResolvedOrm == OrmType.EfCore ? "EF Core" : "XPO";
project.OrmType = options.ResolvedOrm switch
{
OrmType.EfCore => "EF Core",
OrmType.Unknown => "Unknown",
_ => "XPO",
};

// 2. Extract controllers from Module
project.Controllers = _controllerAnalyzer.AnalyzeControllers(projectPath, options);
Expand Down
26 changes: 23 additions & 3 deletions src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,20 @@ private static void WriteGroundRules(
// Rule 1: the ORM. Mixing XPO and EF Core idioms is the most frequent way generated XAF
// code turns out wrong, because both are legitimate XAF and the class names collide --
// BaseObject exists in both, in different namespaces.
if (isEfCore)
//
// Which is why it is not written at all when the source never said. The rule forbids one
// ORM outright, so stating it on a default would forbid whichever one the application
// actually uses -- worse than silence, because the agent cannot tell a guess from a
// reading.
if (IsOrmUnknown(project.OrmType))
{
sb.AppendLine($"**{rule++}. The ORM this application uses could not be determined.**");
sb.AppendLine("No `DbSet<T>` registration, `DbContext`, XPO base class or ORM `using` directive was");
sb.AppendLine("found in the analyzed source — so neither persistence style is ruled out here. Check");
sb.AppendLine("what the project actually references before suggesting `Session`/`XPCollection` or");
sb.AppendLine("`DbContext`/`DbSet<T>`, and do not infer one from the other files in this document.");
}
else if (isEfCore)
{
sb.AppendLine($"**{rule++}. Persistence is Entity Framework Core, not XPO.**");
sb.AppendLine("`Session`, `XPObject`, `XPCollection`, `UnitOfWork` and `XPO` criteria APIs do not exist");
Expand Down Expand Up @@ -728,8 +741,15 @@ private static void WriteFooter(StringBuilder sb)
private static bool IsEfCore(string? ormType) =>
ormType is not null && ormType.Contains("EF", StringComparison.OrdinalIgnoreCase);

private static string OrmDisplayName(string? ormType) =>
IsEfCore(ormType) ? "Entity Framework Core" : "XPO";
private static bool IsOrmUnknown(string? ormType) =>
ormType is null || ormType.Equals("Unknown", StringComparison.OrdinalIgnoreCase);

private static string OrmDisplayName(string? ormType) => ormType switch
{
_ when IsOrmUnknown(ormType) => "an undetermined ORM",
_ when IsEfCore(ormType) => "Entity Framework Core",
_ => "XPO",
};

private static string PropertyMarkers(ExtractedProperty property)
{
Expand Down
12 changes: 11 additions & 1 deletion src/XafLogicExplainer.Core/Models/ExtractionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ public enum OrmType
/// <summary>
/// Treat project as Entity Framework Core-based.
/// </summary>
EfCore
EfCore,
/// <summary>
/// No evidence of either ORM was found in the analyzed source.
/// </summary>
/// <remarks>
/// Distinct from <see cref="Auto"/>, which is a request to look. This is the answer: a module
/// that persists nothing, or whose context lives in a project that was not scanned, is
/// evidence for neither — and naming one anyway puts a guess in front of the agent in the
/// same voice as everything the extractor actually read.
/// </remarks>
Unknown
}

/// <summary>
Expand Down
15 changes: 15 additions & 0 deletions tests/XafLogicExplainer.Tests/AgentContextGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ public class AgentContextGeneratorTests
private static string EfCoreIndex =>
new AgentContextGenerator("0.9.0").GenerateIndex(SampleProjects.EfCore, []);

private static string NoOrmIndex =>
new AgentContextGenerator("0.9.0").GenerateIndex(SampleProjects.NoOrm, []);

[Fact]
public void DoesNotRuleOutAnOrmItNeverFoundEvidenceFor()
{
// The ground rule is emphatic in both directions on purpose -- which is exactly why it must
// not fire on a guess. Telling an agent that DbContext "does not exist in this application"
// is a harder failure than saying nothing, because it forbids the correct answer.
var index = NoOrmIndex;

Assert.DoesNotContain("Persistence is DevExpress XPO", index);
Assert.DoesNotContain("Persistence is Entity Framework Core", index);
}

[Fact]
public void StatesThatTheInventoriesAreComplete()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Actions;

namespace SampleNoOrm.Module.Controllers;

/// <summary>
/// A module that persists nothing: no entity, no DbContext, no XPO type.
/// </summary>
/// <remarks>
/// A UI-only or utility module is a real thing to point the extractor at — and there is no
/// evidence in it for either ORM. That is a fact about the project, not a reason to pick one.
/// </remarks>
public class PingController : ViewController
{
public PingController()
{
var ping = new SimpleAction(this, "Ping", "Tools");
ping.Execute += (_, _) => Application.ShowViewStrategy.ShowMessage("pong");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using DevExpress.Persistent.Base;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace SamplePoco.Module.BusinessObjects;

[Table("coupon")]
[DefaultClassOptions]
[NavigationItem("Promotions")]
public class Coupon
{
[Key]
[Column("Code")]
[StringLength(12)]
public virtual string Code { get; set; }

[Column("Percentage")]
public virtual decimal Percentage { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;

namespace SamplePoco.Module.BusinessObjects;

/// <summary>
/// An EF Core context that names no DevExpress type at all.
/// </summary>
/// <remarks>
/// An application on an existing schema has no reason to reference the DevExpress EF base
/// implementation: it does not use <c>BaseObject</c>, and the security tables may well live in
/// another project. Nothing here names DevExpress at all — and it is still an EF Core
/// application, which the DbContext states plainly.
/// <para>
/// The namespace this fixture must not name is deliberately absent from the comments too: the
/// detector it exercises reads raw file text, so writing it here would be enough to pass.
/// </para>
/// </remarks>
public class ShopDbContext : DbContext
{
public ShopDbContext(DbContextOptions<ShopDbContext> options) : base(options) { }

public DbSet<Coupon> Coupons { get; set; }
}
8 changes: 8 additions & 0 deletions tests/XafLogicExplainer.Tests/OrmDetectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public void DetectsXpo() =>
public void DetectsEfCoreFromItsNamespace() =>
Assert.Contains("EF", SampleProjects.EfCore.OrmType, StringComparison.OrdinalIgnoreCase);

[Fact]
public void DetectsEfCoreFromTheDbContextWhenNoDevExpressEfTypeIsNamed() =>
Assert.Contains("EF", SampleProjects.PocoEf.OrmType, StringComparison.OrdinalIgnoreCase);

[Fact]
public void ReportsUnknownRatherThanGuessingWhenNothingPersists() =>
Assert.Equal("Unknown", SampleProjects.NoOrm.OrmType, ignoreCase: true);

[Fact]
public void FindsEfCoreEntitiesDespiteTheSharedBaseClassName()
{
Expand Down
14 changes: 14 additions & 0 deletions tests/XafLogicExplainer.Tests/SampleProjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ internal static class SampleProjects
/// <summary>Path to the legacy EF Core fixture module.</summary>
public static string LegacyEfPath => Path.Combine(FixturesRoot, "LegacyEfSolution", "SampleLegacy.Module");

/// <summary>Path to the EF Core fixture that names no DevExpress persistence type.</summary>
public static string PocoEfPath => Path.Combine(FixturesRoot, "PocoEfSolution", "SamplePoco.Module");

/// <summary>Path to the fixture that persists nothing at all.</summary>
public static string NoOrmPath => Path.Combine(FixturesRoot, "NoOrmSolution", "SampleNoOrm.Module");

/// <summary>Path to the fourteen-entity demo module.</summary>
public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module");

Expand Down Expand Up @@ -83,6 +89,8 @@ private static string FixturesRoot
private static readonly Lazy<ExtractedProject> LazyDemo = new(() => Extract(DemoPath));
private static readonly Lazy<ExtractedProject> LazyEfCore = new(() => Extract(EfCorePath));
private static readonly Lazy<ExtractedProject> LazyLegacyEf = new(() => Extract(LegacyEfPath));
private static readonly Lazy<ExtractedProject> LazyPocoEf = new(() => Extract(PocoEfPath));
private static readonly Lazy<ExtractedProject> LazyNoOrm = new(() => Extract(NoOrmPath));

/// <summary>The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml.</summary>
public static ExtractedProject Xpo => LazyXpo.Value;
Expand All @@ -100,6 +108,12 @@ private static string FixturesRoot
/// </remarks>
public static ExtractedProject LegacyEf => LazyLegacyEf.Value;

/// <summary>An EF Core application that names no DevExpress persistence type anywhere.</summary>
public static ExtractedProject PocoEf => LazyPocoEf.Value;

/// <summary>A module that persists nothing, and is evidence for neither ORM.</summary>
public static ExtractedProject NoOrm => LazyNoOrm.Value;

/// <summary>
/// The fourteen-entity demo, with a custom editor in a sibling platform project.
/// </summary>
Expand Down
Loading