Skip to content

Commit f5b5066

Browse files
authored
Merge pull request peopleworks#8 from MBrekhof/fix/orm-detection-from-syntax
Read the ORM from syntax, and say Unknown when nothing says
2 parents 67b0f14 + 6b90f56 commit f5b5066

12 files changed

Lines changed: 225 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

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

1225
Entities the application declares, rather than the ones that inherit from the right class.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ applications. The agent-facing surface is what is landing now, in the open.
317317
|| Pluggable publishing targets (`IDocumentationSink`) |
318318
|| **MCP server** — 10 tools, live against your source |
319319
|| **Installable Claude Code plugin** with skill and MCP server |
320-
|| **279 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
320+
|| **282 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
321321
|| **DevExpress ground-truth catalog**, generated locally by licensees |
322322

323323
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the

src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,6 @@ public List<ExtractedEntity> AnalyzeEntities(string sourceDirectory, ExtractionO
2222
var entities = new List<ExtractedEntity>();
2323
var csFiles = FindFiles(sourceDirectory, options.BusinessObjectPatterns, options.ExcludePatterns).ToList();
2424

25-
// Resolve ORM type
26-
var ormType = options.Orm == OrmType.Auto
27-
? DetectOrmType(csFiles)
28-
: options.Orm;
29-
options.ResolvedOrm = ormType;
30-
3125
// Parsed once and kept, because the DbSet roster has to be known before the first class is
3226
// classified and re-parsing every file to build it costs more than holding the trees.
3327
//
@@ -44,6 +38,13 @@ public List<ExtractedEntity> AnalyzeEntities(string sourceDirectory, ExtractionO
4438

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

41+
// Resolved after the parse, because the roster is the best evidence there is and it only
42+
// exists once the trees do.
43+
var ormType = options.Orm == OrmType.Auto
44+
? DetectOrmType(parsedFiles.Select(parsed => parsed.Root), roster)
45+
: options.Orm;
46+
options.ResolvedOrm = ormType;
47+
4748
foreach (var (file, root) in parsedFiles)
4849
{
4950
var classDeclarations = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
@@ -475,17 +476,73 @@ private static List<ExtractedAppearanceRule> ExtractAppearanceRules(ClassDeclara
475476
/// <summary>
476477
/// Detects ORM mode by scanning file contents for EF-specific namespaces.
477478
/// </summary>
478-
private static OrmType DetectOrmType(IEnumerable<string> csFiles)
479+
/// <summary>
480+
/// Decides which ORM an application persists with, from what its source declares.
481+
/// </summary>
482+
/// <remarks>
483+
/// Read as syntax rather than as text. Scanning file contents for a namespace counts a mention
484+
/// of it in a comment, a string or an <c>#if</c>-disabled block as evidence — and a project
485+
/// that merely discusses EF Core is not one that uses it.
486+
/// <para>
487+
/// The signals are ranked by what each one costs to be wrong about. A <c>DbSet&lt;T&gt;</c>
488+
/// registered on a context is the application declaring a table, and it cannot be mistaken;
489+
/// a <c>using</c> directive is weaker but still deliberate. Where neither ORM leaves any
490+
/// trace the answer is <see cref="OrmType.Unknown"/>, because the alternative is to state a
491+
/// default in the same voice as everything that was actually read.
492+
/// </para>
493+
/// </remarks>
494+
private static OrmType DetectOrmType(IEnumerable<SyntaxNode> roots, DbSetRoster roster)
479495
{
480-
foreach (var file in csFiles)
496+
// The application cannot run without its registrations being right, which makes them the
497+
// one signal that is never incidental.
498+
if (roster.RegistersAnything)
499+
return OrmType.EfCore;
500+
501+
var efCore = false;
502+
var xpo = false;
503+
504+
foreach (var root in roots)
481505
{
482-
var source = File.ReadAllText(file);
483-
if (source.Contains("DevExpress.Persistent.BaseImpl.EF"))
484-
return OrmType.EfCore;
506+
foreach (var directive in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
507+
{
508+
if (directive.Alias is not null || directive.Name is null)
509+
continue;
510+
511+
var name = directive.Name.ToString();
512+
513+
// Exact or namespace-prefixed, never StartsWith on its own:
514+
// `DevExpress.Persistent.BaseImpl` is XPO and a prefix of the EF Core one.
515+
if (IsOrDescends(name, "Microsoft.EntityFrameworkCore")
516+
|| IsOrDescends(name, "DevExpress.Persistent.BaseImpl.EF")
517+
|| IsOrDescends(name, "DevExpress.ExpressApp.EFCore"))
518+
efCore = true;
519+
else if (IsOrDescends(name, "DevExpress.Xpo"))
520+
xpo = true;
521+
}
522+
523+
// A base class is as deliberate as a using directive and survives file-scoped
524+
// namespaces that name nothing.
525+
foreach (var classDecl in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
526+
{
527+
foreach (var baseTypeName in GetBaseTypeNames(classDecl))
528+
{
529+
if (baseTypeName is "XPObject" or "XPCustomObject" or "XPLiteObject" or "XPBaseObject")
530+
xpo = true;
531+
else if (baseTypeName is "DbContext")
532+
efCore = true;
533+
}
534+
}
485535
}
486-
return OrmType.Xpo;
536+
537+
if (efCore) return OrmType.EfCore;
538+
if (xpo) return OrmType.Xpo;
539+
540+
return OrmType.Unknown;
487541
}
488542

543+
private static bool IsOrDescends(string name, string ns) =>
544+
name.Equals(ns, StringComparison.Ordinal) || name.StartsWith($"{ns}.", StringComparison.Ordinal);
545+
489546
/// <summary>
490547
/// The types an application registers as <c>DbSet&lt;T&gt;</c> on a <c>DbContext</c>, and the
491548
/// namespaces each registration could have been naming.
@@ -516,6 +573,9 @@ private sealed class DbSetRoster
516573
{
517574
private readonly List<(string Argument, HashSet<string> Scopes)> _registrations = [];
518575

576+
/// <summary>Whether any context in the source registers anything at all.</summary>
577+
public bool RegistersAnything => _registrations.Count > 0;
578+
519579
/// <summary>
520580
/// Reads every <c>DbSet&lt;T&gt;</c> property declared on a context in the parsed source.
521581
/// </summary>

src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ public ExtractedProject ExtractFromSourceDirectory(string projectPath, Extractio
9090
e.SourceProject ??= new DirectoryInfo(projectPath).Name;
9191

9292
// Set detected ORM type
93-
project.OrmType = options.ResolvedOrm == OrmType.EfCore ? "EF Core" : "XPO";
93+
project.OrmType = options.ResolvedOrm switch
94+
{
95+
OrmType.EfCore => "EF Core",
96+
OrmType.Unknown => "Unknown",
97+
_ => "XPO",
98+
};
9499

95100
// 2. Extract controllers from Module
96101
project.Controllers = _controllerAnalyzer.AnalyzeControllers(projectPath, options);

src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,20 @@ private static void WriteGroundRules(
183183
// Rule 1: the ORM. Mixing XPO and EF Core idioms is the most frequent way generated XAF
184184
// code turns out wrong, because both are legitimate XAF and the class names collide --
185185
// BaseObject exists in both, in different namespaces.
186-
if (isEfCore)
186+
//
187+
// Which is why it is not written at all when the source never said. The rule forbids one
188+
// ORM outright, so stating it on a default would forbid whichever one the application
189+
// actually uses -- worse than silence, because the agent cannot tell a guess from a
190+
// reading.
191+
if (IsOrmUnknown(project.OrmType))
192+
{
193+
sb.AppendLine($"**{rule++}. The ORM this application uses could not be determined.**");
194+
sb.AppendLine("No `DbSet<T>` registration, `DbContext`, XPO base class or ORM `using` directive was");
195+
sb.AppendLine("found in the analyzed source — so neither persistence style is ruled out here. Check");
196+
sb.AppendLine("what the project actually references before suggesting `Session`/`XPCollection` or");
197+
sb.AppendLine("`DbContext`/`DbSet<T>`, and do not infer one from the other files in this document.");
198+
}
199+
else if (isEfCore)
187200
{
188201
sb.AppendLine($"**{rule++}. Persistence is Entity Framework Core, not XPO.**");
189202
sb.AppendLine("`Session`, `XPObject`, `XPCollection`, `UnitOfWork` and `XPO` criteria APIs do not exist");
@@ -728,8 +741,15 @@ private static void WriteFooter(StringBuilder sb)
728741
private static bool IsEfCore(string? ormType) =>
729742
ormType is not null && ormType.Contains("EF", StringComparison.OrdinalIgnoreCase);
730743

731-
private static string OrmDisplayName(string? ormType) =>
732-
IsEfCore(ormType) ? "Entity Framework Core" : "XPO";
744+
private static bool IsOrmUnknown(string? ormType) =>
745+
ormType is null || ormType.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
746+
747+
private static string OrmDisplayName(string? ormType) => ormType switch
748+
{
749+
_ when IsOrmUnknown(ormType) => "an undetermined ORM",
750+
_ when IsEfCore(ormType) => "Entity Framework Core",
751+
_ => "XPO",
752+
};
733753

734754
private static string PropertyMarkers(ExtractedProperty property)
735755
{

src/XafLogicExplainer.Core/Models/ExtractionOptions.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ public enum OrmType
1818
/// <summary>
1919
/// Treat project as Entity Framework Core-based.
2020
/// </summary>
21-
EfCore
21+
EfCore,
22+
/// <summary>
23+
/// No evidence of either ORM was found in the analyzed source.
24+
/// </summary>
25+
/// <remarks>
26+
/// Distinct from <see cref="Auto"/>, which is a request to look. This is the answer: a module
27+
/// that persists nothing, or whose context lives in a project that was not scanned, is
28+
/// evidence for neither — and naming one anyway puts a guess in front of the agent in the
29+
/// same voice as everything the extractor actually read.
30+
/// </remarks>
31+
Unknown
2232
}
2333

2434
/// <summary>

tests/XafLogicExplainer.Tests/AgentContextGeneratorTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ public class AgentContextGeneratorTests
1717
private static string EfCoreIndex =>
1818
new AgentContextGenerator("0.9.0").GenerateIndex(SampleProjects.EfCore, []);
1919

20+
private static string NoOrmIndex =>
21+
new AgentContextGenerator("0.9.0").GenerateIndex(SampleProjects.NoOrm, []);
22+
23+
[Fact]
24+
public void DoesNotRuleOutAnOrmItNeverFoundEvidenceFor()
25+
{
26+
// The ground rule is emphatic in both directions on purpose -- which is exactly why it must
27+
// not fire on a guess. Telling an agent that DbContext "does not exist in this application"
28+
// is a harder failure than saying nothing, because it forbids the correct answer.
29+
var index = NoOrmIndex;
30+
31+
Assert.DoesNotContain("Persistence is DevExpress XPO", index);
32+
Assert.DoesNotContain("Persistence is Entity Framework Core", index);
33+
}
34+
2035
[Fact]
2136
public void StatesThatTheInventoriesAreComplete()
2237
{
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using DevExpress.ExpressApp;
2+
using DevExpress.ExpressApp.Actions;
3+
4+
namespace SampleNoOrm.Module.Controllers;
5+
6+
/// <summary>
7+
/// A module that persists nothing: no entity, no DbContext, no XPO type.
8+
/// </summary>
9+
/// <remarks>
10+
/// A UI-only or utility module is a real thing to point the extractor at — and there is no
11+
/// evidence in it for either ORM. That is a fact about the project, not a reason to pick one.
12+
/// </remarks>
13+
public class PingController : ViewController
14+
{
15+
public PingController()
16+
{
17+
var ping = new SimpleAction(this, "Ping", "Tools");
18+
ping.Execute += (_, _) => Application.ShowViewStrategy.ShowMessage("pong");
19+
}
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using DevExpress.Persistent.Base;
2+
using System.ComponentModel.DataAnnotations;
3+
using System.ComponentModel.DataAnnotations.Schema;
4+
5+
namespace SamplePoco.Module.BusinessObjects;
6+
7+
[Table("coupon")]
8+
[DefaultClassOptions]
9+
[NavigationItem("Promotions")]
10+
public class Coupon
11+
{
12+
[Key]
13+
[Column("Code")]
14+
[StringLength(12)]
15+
public virtual string Code { get; set; }
16+
17+
[Column("Percentage")]
18+
public virtual decimal Percentage { get; set; }
19+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SamplePoco.Module.BusinessObjects;
4+
5+
/// <summary>
6+
/// An EF Core context that names no DevExpress type at all.
7+
/// </summary>
8+
/// <remarks>
9+
/// An application on an existing schema has no reason to reference the DevExpress EF base
10+
/// implementation: it does not use <c>BaseObject</c>, and the security tables may well live in
11+
/// another project. Nothing here names DevExpress at all — and it is still an EF Core
12+
/// application, which the DbContext states plainly.
13+
/// <para>
14+
/// The namespace this fixture must not name is deliberately absent from the comments too: the
15+
/// detector it exercises reads raw file text, so writing it here would be enough to pass.
16+
/// </para>
17+
/// </remarks>
18+
public class ShopDbContext : DbContext
19+
{
20+
public ShopDbContext(DbContextOptions<ShopDbContext> options) : base(options) { }
21+
22+
public DbSet<Coupon> Coupons { get; set; }
23+
}

0 commit comments

Comments
 (0)