From 6b90f56eb3db058466a3f3ecab821fd272846874 Mon Sep 17 00:00:00 2001 From: MBrekhof Date: Thu, 13 Aug 2026 23:23:12 +0200 Subject: [PATCH] Read the ORM from syntax, and say Unknown when nothing says Detection scanned raw file text for DevExpress.Persistent.BaseImpl.EF and otherwise fell through to XPO. An EF Core application whose entities do not use the DevExpress EF base implementation -- mapped onto a legacy schema, security tables in another project -- was therefore reported as XPO. That is not an incomplete document. Ground rule 1 goes on to tell the agent that DbContext, DbSet, OnModelCreating and EF migrations "do not exist in this application and must never be suggested", so the guess does not leave the agent uninformed, it forbids the only correct answer. Signals are now ranked by what it costs to be wrong about each. A DbSet registered on a context is the application declaring a table and cannot be incidental; using directives and base classes are weaker but still deliberate. Namespaces are compared exactly or as a prefix with a dot, because DevExpress.Persistent.BaseImpl is XPO and a prefix of the EF Core one. Where neither ORM leaves a trace, the answer is Unknown and the rule is omitted rather than defaulted, since stating it on a guess is what made this severe. Reading text rather than syntax also counted a mention as evidence: a comment naming the namespace was enough. The fixture for this change passed against the old code until its own doc comment stopped naming what it was there to avoid. Fixes #2 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++ README.md | 2 +- .../Analyzers/EntityAnalyzer.cs | 84 ++++++++++++++++--- .../Analyzers/LogicExtractor.cs | 7 +- .../Generators/AgentContextGenerator.cs | 26 +++++- .../Models/ExtractionOptions.cs | 12 ++- .../AgentContextGeneratorTests.cs | 15 ++++ .../Controllers/PingController.cs | 20 +++++ .../BusinessObjects/Coupon.cs | 19 +++++ .../BusinessObjects/ShopDbContext.cs | 23 +++++ .../OrmDetectionTests.cs | 8 ++ .../XafLogicExplainer.Tests/SampleProjects.cs | 14 ++++ 12 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 tests/XafLogicExplainer.Tests/Fixtures/NoOrmSolution/SampleNoOrm.Module/Controllers/PingController.cs create mode 100644 tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/Coupon.cs create mode 100644 tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/ShopDbContext.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f194e..9cb75e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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` 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. diff --git a/README.md b/README.md index a45d404..abe6c33 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs b/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs index f9ea499..287e20a 100644 --- a/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs +++ b/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs @@ -22,12 +22,6 @@ public List AnalyzeEntities(string sourceDirectory, ExtractionO var entities = new List(); 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. // @@ -44,6 +38,13 @@ public List AnalyzeEntities(string sourceDirectory, ExtractionO 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(); @@ -475,17 +476,73 @@ private static List ExtractAppearanceRules(ClassDeclara /// /// Detects ORM mode by scanning file contents for EF-specific namespaces. /// - private static OrmType DetectOrmType(IEnumerable csFiles) + /// + /// Decides which ORM an application persists with, from what its source declares. + /// + /// + /// 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 #if-disabled block as evidence — and a project + /// that merely discusses EF Core is not one that uses it. + /// + /// The signals are ranked by what each one costs to be wrong about. A DbSet<T> + /// registered on a context is the application declaring a table, and it cannot be mistaken; + /// a using directive is weaker but still deliberate. Where neither ORM leaves any + /// trace the answer is , because the alternative is to state a + /// default in the same voice as everything that was actually read. + /// + /// + private static OrmType DetectOrmType(IEnumerable 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()) + { + 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; + } + + // 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()) + { + 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); + /// /// The types an application registers as DbSet<T> on a DbContext, and the /// namespaces each registration could have been naming. @@ -516,6 +573,9 @@ private sealed class DbSetRoster { private readonly List<(string Argument, HashSet Scopes)> _registrations = []; + /// Whether any context in the source registers anything at all. + public bool RegistersAnything => _registrations.Count > 0; + /// /// Reads every DbSet<T> property declared on a context in the parsed source. /// diff --git a/src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs b/src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs index 0050e15..302b4b3 100644 --- a/src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs +++ b/src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs @@ -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); diff --git a/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs b/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs index 2dcbd66..f2c4622 100644 --- a/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs +++ b/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs @@ -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` 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`, 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"); @@ -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) { diff --git a/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs b/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs index a10040a..c713f97 100644 --- a/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs +++ b/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs @@ -18,7 +18,17 @@ public enum OrmType /// /// Treat project as Entity Framework Core-based. /// - EfCore + EfCore, + /// + /// No evidence of either ORM was found in the analyzed source. + /// + /// + /// Distinct from , 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. + /// + Unknown } /// diff --git a/tests/XafLogicExplainer.Tests/AgentContextGeneratorTests.cs b/tests/XafLogicExplainer.Tests/AgentContextGeneratorTests.cs index 07094a1..2f81f82 100644 --- a/tests/XafLogicExplainer.Tests/AgentContextGeneratorTests.cs +++ b/tests/XafLogicExplainer.Tests/AgentContextGeneratorTests.cs @@ -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() { diff --git a/tests/XafLogicExplainer.Tests/Fixtures/NoOrmSolution/SampleNoOrm.Module/Controllers/PingController.cs b/tests/XafLogicExplainer.Tests/Fixtures/NoOrmSolution/SampleNoOrm.Module/Controllers/PingController.cs new file mode 100644 index 0000000..e5652b0 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/NoOrmSolution/SampleNoOrm.Module/Controllers/PingController.cs @@ -0,0 +1,20 @@ +using DevExpress.ExpressApp; +using DevExpress.ExpressApp.Actions; + +namespace SampleNoOrm.Module.Controllers; + +/// +/// A module that persists nothing: no entity, no DbContext, no XPO type. +/// +/// +/// 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. +/// +public class PingController : ViewController +{ + public PingController() + { + var ping = new SimpleAction(this, "Ping", "Tools"); + ping.Execute += (_, _) => Application.ShowViewStrategy.ShowMessage("pong"); + } +} diff --git a/tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/Coupon.cs b/tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/Coupon.cs new file mode 100644 index 0000000..9624e67 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/Coupon.cs @@ -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; } +} diff --git a/tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/ShopDbContext.cs b/tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/ShopDbContext.cs new file mode 100644 index 0000000..12fe692 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/PocoEfSolution/SamplePoco.Module/BusinessObjects/ShopDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; + +namespace SamplePoco.Module.BusinessObjects; + +/// +/// An EF Core context that names no DevExpress type at all. +/// +/// +/// An application on an existing schema has no reason to reference the DevExpress EF base +/// implementation: it does not use BaseObject, 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. +/// +/// 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. +/// +/// +public class ShopDbContext : DbContext +{ + public ShopDbContext(DbContextOptions options) : base(options) { } + + public DbSet Coupons { get; set; } +} diff --git a/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs b/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs index 600ea21..4b8f246 100644 --- a/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs +++ b/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs @@ -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() { diff --git a/tests/XafLogicExplainer.Tests/SampleProjects.cs b/tests/XafLogicExplainer.Tests/SampleProjects.cs index 7e1bfb4..5f9fae2 100644 --- a/tests/XafLogicExplainer.Tests/SampleProjects.cs +++ b/tests/XafLogicExplainer.Tests/SampleProjects.cs @@ -31,6 +31,12 @@ internal static class SampleProjects /// Path to the legacy EF Core fixture module. public static string LegacyEfPath => Path.Combine(FixturesRoot, "LegacyEfSolution", "SampleLegacy.Module"); + /// Path to the EF Core fixture that names no DevExpress persistence type. + public static string PocoEfPath => Path.Combine(FixturesRoot, "PocoEfSolution", "SamplePoco.Module"); + + /// Path to the fixture that persists nothing at all. + public static string NoOrmPath => Path.Combine(FixturesRoot, "NoOrmSolution", "SampleNoOrm.Module"); + /// Path to the fourteen-entity demo module. public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module"); @@ -83,6 +89,8 @@ private static string FixturesRoot private static readonly Lazy LazyDemo = new(() => Extract(DemoPath)); private static readonly Lazy LazyEfCore = new(() => Extract(EfCorePath)); private static readonly Lazy LazyLegacyEf = new(() => Extract(LegacyEfPath)); + private static readonly Lazy LazyPocoEf = new(() => Extract(PocoEfPath)); + private static readonly Lazy LazyNoOrm = new(() => Extract(NoOrmPath)); /// The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml. public static ExtractedProject Xpo => LazyXpo.Value; @@ -100,6 +108,12 @@ private static string FixturesRoot /// public static ExtractedProject LegacyEf => LazyLegacyEf.Value; + /// An EF Core application that names no DevExpress persistence type anywhere. + public static ExtractedProject PocoEf => LazyPocoEf.Value; + + /// A module that persists nothing, and is evidence for neither ORM. + public static ExtractedProject NoOrm => LazyNoOrm.Value; + /// /// The fourteen-entity demo, with a custom editor in a sibling platform project. ///