Skip to content

Commit fe5c5c3

Browse files
Brekhofclaude
authored andcommitted
Find entities through a base class the project wrote itself
Classification matched a class's own base list against the root names and stopped there. An application with a shared base -- auditing, a key convention, a display-name property -- lost every business object below it. The inversion is what makes it severe rather than incomplete. The abstract base does match, so the inventory reports the one class that is not a table and omits the ones that are, while AGENTS.md goes on to say the inventory is complete and that anything absent does not exist. Selection now repeats until a round changes nothing, which is what SelectControllers already does on the controller side and for the same reasons: a base may be read after the class deriving from it, and a chain can be any depth. A base name is resolved through the deriving file's own scope rather than by simple name. A name is not an identity -- the reason the DbSet roster carries scopes -- so a Contracts.Order beside a BusinessObjects.Order still resolves to the base it actually named, not to any accepted class wearing that name. Acceptance is keyed on (namespace, name), so every part of a partial class is selected once any part of it is. On the demos shipped with 26.1: FeatureCenter.NET.XPO 43 -> 140 MainDemo.NET.XPO 14 -> 17 OutlookInspiredDemo.NET.EFCore 23 -> 24 MainDemo.NET.EFCore 14 -> 14 (unchanged) The OutlookInspired one is an EF Core application: TaxRate derives through a shared base and is not registered as a DbSet, so neither the roster nor the base match saw it. Fixes #6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 935c194 commit fe5c5c3

10 files changed

Lines changed: 323 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2828
omitted rather than guessed. Reading text also counted a *mention*: a comment naming the
2929
namespace was enough, which is how the fixture for this fix first passed against the old code.
3030

31+
- **Entities are found through a base class the project wrote itself.** Classification matched a
32+
class's own base list against the root names and stopped there, so an application with a shared
33+
base — auditing, a key convention, a display-name property — lost every business object below it.
34+
The inversion is what makes it severe: the abstract base *is* matched, so the inventory reported
35+
the one class that is not a table and omitted the ones that are. Selection now repeats until a
36+
round changes nothing, exactly as `SelectControllers` does, and resolves a base name through the
37+
deriving file's own scope rather than by simple name, so a `Contracts.Order` beside a
38+
`BusinessObjects.Order` still resolves to the base it actually named. On the demos shipped with
39+
26.1: `FeatureCenter.NET.XPO` 43 → 140 entities, `MainDemo.NET.XPO` 14 → 17, and
40+
`OutlookInspiredDemo.NET.EFCore` 23 → 24 — the last of which is an EF Core application, where an
41+
entity that is not registered as a `DbSet<T>` had no fallback either.
42+
3143
## [0.12.1] — 2026-08-13
3244

3345
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-
|| **285 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
320+
|| **289 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: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,14 @@ public List<ExtractedEntity> AnalyzeEntities(string sourceDirectory, ExtractionO
4545
: options.Orm;
4646
options.ResolvedOrm = ormType;
4747

48+
var persistent = SelectPersistentClasses(parsedFiles.Select(parsed => parsed.Root), roster, options);
49+
4850
foreach (var (file, root) in parsedFiles)
4951
{
5052
var classDeclarations = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
5153
foreach (var classDecl in classDeclarations)
5254
{
53-
if (IsXafBusinessObject(classDecl, options.BaseTypeNames)
54-
|| roster.Registers(GetNamespace(classDecl), classDecl.Identifier.Text))
55+
if (persistent.Contains((GetNamespace(classDecl), classDecl.Identifier.Text)))
5556
{
5657
var entity = ExtractEntity(classDecl, file, options);
5758
entities.Add(entity);
@@ -473,6 +474,159 @@ private static List<ExtractedAppearanceRule> ExtractAppearanceRules(ClassDeclara
473474

474475
#region Helper Methods
475476

477+
/// <summary>
478+
/// Decides which classes are persistent, following base classes to a fixed point.
479+
/// </summary>
480+
/// <remarks>
481+
/// Matching a class's own base list against a list of root names stops one hop short. An
482+
/// application that writes a shared base — auditing, a key convention, a display name — puts
483+
/// every business object below it out of reach, and the loss is silent in the worst way: the
484+
/// abstract base is extracted in their place, so the inventory reports the one class that is
485+
/// not a table and omits the ones that are.
486+
/// <para>
487+
/// Repeating until a round changes nothing is what the controller side already does in
488+
/// <c>SelectControllers</c>, and for the same reason: a base class may be read after the class
489+
/// deriving from it, and a chain can be any depth.
490+
/// </para>
491+
/// <para>
492+
/// A base name is resolved through the deriving file's own scope rather than by simple name,
493+
/// because a name is not an identity — the reason the DbSet roster carries scopes. An
494+
/// application may keep a <c>Contracts.Order</c> beside its <c>BusinessObjects.Order</c>, each
495+
/// deriving from a different <c>NamedBaseObject</c>, and only one of those is a table.
496+
/// </para>
497+
/// <para>
498+
/// Acceptance is keyed on <c>(namespace, name)</c>, so every part of a <c>partial</c> class is
499+
/// selected once any part of it is — the hand-written part that carries the base list and the
500+
/// generated part that carries the mapping are the same class.
501+
/// </para>
502+
/// </remarks>
503+
private static HashSet<(string Namespace, string Name)> SelectPersistentClasses(
504+
IEnumerable<SyntaxNode> roots, DbSetRoster roster, ExtractionOptions options)
505+
{
506+
var trees = roots.ToList();
507+
var globalUsings = GlobalUsings(trees);
508+
509+
var candidates = new List<(ClassDeclarationSyntax Declaration, string Namespace, string Name, HashSet<string> Scopes)>();
510+
511+
foreach (var root in trees)
512+
{
513+
var fileScopes = new HashSet<string>(globalUsings, StringComparer.Ordinal);
514+
515+
foreach (var directive in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
516+
{
517+
if (directive.Alias is null && directive.Name is not null)
518+
fileScopes.Add(directive.Name.ToString());
519+
}
520+
521+
foreach (var classDecl in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
522+
{
523+
var @namespace = GetNamespace(classDecl);
524+
var scopes = new HashSet<string>(fileScopes, StringComparer.Ordinal);
525+
526+
// Its own namespace and every one enclosing it: C# resolves an unqualified name
527+
// outwards, so a base one level up needs no using directive.
528+
for (var scope = @namespace; ; )
529+
{
530+
scopes.Add(scope);
531+
var dot = scope.LastIndexOf('.');
532+
if (dot < 0) break;
533+
scope = scope[..dot];
534+
}
535+
536+
candidates.Add((classDecl, @namespace, classDecl.Identifier.Text, scopes));
537+
}
538+
}
539+
540+
var accepted = new HashSet<(string Namespace, string Name)>();
541+
542+
foreach (var candidate in candidates)
543+
{
544+
if (IsXafBusinessObject(candidate.Declaration, options.BaseTypeNames)
545+
|| roster.Registers(candidate.Namespace, candidate.Name))
546+
accepted.Add((candidate.Namespace, candidate.Name));
547+
}
548+
549+
// Each round can accept a class whose base was accepted in the previous one, so it repeats
550+
// until a round changes nothing. Bounded by the number of classes.
551+
bool changed;
552+
553+
do
554+
{
555+
changed = false;
556+
557+
foreach (var candidate in candidates)
558+
{
559+
if (accepted.Contains((candidate.Namespace, candidate.Name)))
560+
continue;
561+
562+
if (!DerivesFromAccepted(candidate.Declaration, candidate.Scopes, accepted))
563+
continue;
564+
565+
accepted.Add((candidate.Namespace, candidate.Name));
566+
changed = true;
567+
}
568+
}
569+
while (changed);
570+
571+
return accepted;
572+
}
573+
574+
/// <summary>
575+
/// Whether any name in this class's base list resolves to a class already accepted.
576+
/// </summary>
577+
/// <remarks>
578+
/// Every entry is tried rather than the first, because syntax cannot tell a base class from an
579+
/// interface. An interface name only matches if a class of that name was itself accepted, which
580+
/// an interface never is.
581+
/// </remarks>
582+
private static bool DerivesFromAccepted(
583+
ClassDeclarationSyntax classDecl,
584+
HashSet<string> scopes,
585+
HashSet<(string Namespace, string Name)> accepted)
586+
{
587+
foreach (var baseType in classDecl.BaseList?.Types ?? default)
588+
{
589+
var written = baseType.Type.ToString();
590+
591+
var generic = written.IndexOf('<');
592+
if (generic > 0) written = written[..generic];
593+
594+
var dot = written.LastIndexOf('.');
595+
var simpleName = dot < 0 ? written : written[(dot + 1)..];
596+
597+
foreach (var (@namespace, name) in accepted)
598+
{
599+
if (!string.Equals(name, simpleName, StringComparison.Ordinal)) continue;
600+
601+
if (dot < 0)
602+
{
603+
// Unqualified: it named something this file can actually see.
604+
if (scopes.Contains(@namespace)) return true;
605+
continue;
606+
}
607+
608+
// Qualified: the tail it wrote is more specific than any using directive.
609+
var qualifier = written[..dot];
610+
if (@namespace.Equals(qualifier, StringComparison.Ordinal)
611+
|| @namespace.EndsWith("." + qualifier, StringComparison.Ordinal))
612+
return true;
613+
}
614+
}
615+
616+
return false;
617+
}
618+
619+
/// <summary>
620+
/// The <c>global using</c> namespaces, which reach every file however they are declared.
621+
/// </summary>
622+
private static HashSet<string> GlobalUsings(IEnumerable<SyntaxNode> trees) => trees
623+
.SelectMany(root => root.DescendantNodes().OfType<UsingDirectiveSyntax>())
624+
.Where(directive => !directive.GlobalKeyword.IsKind(SyntaxKind.None))
625+
.Select(directive => directive.Name?.ToString())
626+
.Where(name => name is not null)
627+
.Select(name => name!)
628+
.ToHashSet(StringComparer.Ordinal);
629+
476630
/// <summary>
477631
/// Detects ORM mode by scanning file contents for EF-specific namespaces.
478632
/// </summary>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace XafLogicExplainer.Tests;
2+
3+
/// <summary>
4+
/// Entities that reach a persistent base through another class in the same project.
5+
/// </summary>
6+
/// <remarks>
7+
/// Matching a class's own base list against a list of root names stops one hop short, and an
8+
/// application that writes a shared base — auditing, a key convention, a display-name property —
9+
/// puts every one of its business objects out of reach. It is the same defect the controller side
10+
/// fixed by repeating until a round changes nothing.
11+
/// <para>
12+
/// Under EF Core the DbSet roster hides this: a registered class is found whatever it derives
13+
/// from. XPO has no such fallback, and an EF Core class that is not registered has none either.
14+
/// </para>
15+
/// </remarks>
16+
public class DeepInheritanceTests
17+
{
18+
[Fact]
19+
public void FindsAnEntityTwoHopsFromAPersistentRoot()
20+
{
21+
var names = SampleProjects.DeepXpo.Entities.Select(entity => entity.ClassName);
22+
23+
Assert.Contains("Order", names);
24+
}
25+
26+
[Fact]
27+
public void FindsAnEntityThreeHopsFromAPersistentRoot()
28+
{
29+
// Only resolvable after Order has been accepted, which is what makes this a fixed point
30+
// rather than one more hop.
31+
var names = SampleProjects.DeepXpo.Entities.Select(entity => entity.ClassName);
32+
33+
Assert.Contains("PriorityOrder", names);
34+
}
35+
36+
[Fact]
37+
public void ResolvesTheBaseThatWasActuallyNamedRatherThanAnyClassOfThatName()
38+
{
39+
// Contracts.Order derives from Contracts.NamedBaseObject, which is persistent in no sense.
40+
// Both namespaces declare a NamedBaseObject, and only one of them is an entity.
41+
var orders = SampleProjects.DeepXpo.Entities.Where(entity => entity.ClassName == "Order").ToList();
42+
43+
Assert.Equal(["SampleDeep.Module.BusinessObjects"], orders.Select(entity => entity.Namespace));
44+
}
45+
46+
[Fact]
47+
public void ReadsTheRulesOfAnEntityFoundThroughItsBase()
48+
{
49+
// The class was invisible, so everything declared on it was too.
50+
var order = SampleProjects.DeepXpo.Entity("Order");
51+
52+
Assert.Equal("Sales", order.NavigationGroup);
53+
Assert.Contains(order.Properties, property => property.Name == "Number");
54+
}
55+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using DevExpress.Persistent.BaseImpl;
2+
using DevExpress.Xpo;
3+
4+
namespace SampleDeep.Module.BusinessObjects;
5+
6+
/// <summary>
7+
/// The shared base an application writes once and derives everything from.
8+
/// </summary>
9+
/// <remarks>
10+
/// This is the shape FeatureCenter uses throughout: one abstract class holding the convention,
11+
/// and the real business objects two or three hops below it.
12+
/// </remarks>
13+
public abstract class NamedBaseObject : BaseObject
14+
{
15+
public NamedBaseObject(Session session) : base(session) { }
16+
17+
public string Name
18+
{
19+
get => GetPropertyValue<string>(nameof(Name));
20+
set => SetPropertyValue(nameof(Name), value);
21+
}
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using DevExpress.Persistent.Base;
2+
using DevExpress.Persistent.Validation;
3+
using DevExpress.Xpo;
4+
5+
namespace SampleDeep.Module.BusinessObjects;
6+
7+
/// <summary>Two hops from a persistent root: Order -> NamedBaseObject -> BaseObject.</summary>
8+
[DefaultClassOptions]
9+
[NavigationItem("Sales")]
10+
public class Order : NamedBaseObject
11+
{
12+
public Order(Session session) : base(session) { }
13+
14+
[RuleRequiredField("Order_Number_Required", DefaultContexts.Save)]
15+
public string Number
16+
{
17+
get => GetPropertyValue<string>(nameof(Number));
18+
set => SetPropertyValue(nameof(Number), value);
19+
}
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using DevExpress.Persistent.Base;
2+
using DevExpress.Xpo;
3+
4+
namespace SampleDeep.Module.BusinessObjects;
5+
6+
/// <summary>Three hops: PriorityOrder -> Order -> NamedBaseObject -> BaseObject.</summary>
7+
/// <remarks>
8+
/// Here so the walk is a fixed point rather than one extra hop. It only resolves once
9+
/// <see cref="Order"/> has been accepted, which may be in a later round.
10+
/// </remarks>
11+
[DefaultClassOptions]
12+
public class PriorityOrder : Order
13+
{
14+
public PriorityOrder(Session session) : base(session) { }
15+
16+
public int Rank
17+
{
18+
get => GetPropertyValue<int>(nameof(Rank));
19+
set => SetPropertyValue(nameof(Rank), value);
20+
}
21+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace SampleDeep.Module.Contracts;
2+
3+
/// <summary>
4+
/// A wire shape that happens to share a name with the persistent base.
5+
/// </summary>
6+
/// <remarks>
7+
/// Nothing here is persistent. It exists so the walk has to answer *which* NamedBaseObject a
8+
/// class derives from rather than whether some class of that name was accepted.
9+
/// </remarks>
10+
public abstract class NamedBaseObject
11+
{
12+
public string Name { get; set; }
13+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace SampleDeep.Module.Contracts;
2+
3+
/// <summary>
4+
/// The DTO an integration posts. Same simple name as the entity, same base name as the entity's
5+
/// base, and persistent in neither sense.
6+
/// </summary>
7+
/// <remarks>
8+
/// There is no using directive for the BusinessObjects namespace here, so
9+
/// <c>NamedBaseObject</c> resolves to the one beside it. A walk that asked only "was a class
10+
/// called NamedBaseObject accepted" would answer yes and turn this into a table.
11+
/// </remarks>
12+
public class Order : NamedBaseObject
13+
{
14+
public string Number { get; set; }
15+
}

tests/XafLogicExplainer.Tests/SampleProjects.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ internal static class SampleProjects
3737
/// <summary>Path to the fixture that persists nothing at all.</summary>
3838
public static string NoOrmPath => Path.Combine(FixturesRoot, "NoOrmSolution", "SampleNoOrm.Module");
3939

40+
/// <summary>Path to the XPO fixture whose entities derive through a shared base.</summary>
41+
public static string DeepXpoPath => Path.Combine(FixturesRoot, "DeepXpoSolution", "SampleDeep.Module");
42+
4043
/// <summary>Path to the fourteen-entity demo module.</summary>
4144
public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module");
4245

@@ -92,6 +95,8 @@ private static string FixturesRoot
9295
private static readonly Lazy<ExtractedProject> LazyPocoEf = new(() => Extract(PocoEfPath));
9396
private static readonly Lazy<ExtractedProject> LazyNoOrm = new(() => Extract(NoOrmPath));
9497

98+
private static readonly Lazy<ExtractedProject> LazyDeepXpo = new(() => Extract(DeepXpoPath));
99+
95100
/// <summary>The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml.</summary>
96101
public static ExtractedProject Xpo => LazyXpo.Value;
97102

@@ -114,6 +119,9 @@ private static string FixturesRoot
114119
/// <summary>A module that persists nothing, and is evidence for neither ORM.</summary>
115120
public static ExtractedProject NoOrm => LazyNoOrm.Value;
116121

122+
/// <summary>An XPO application whose entities reach BaseObject through a shared base.</summary>
123+
public static ExtractedProject DeepXpo => LazyDeepXpo.Value;
124+
117125
/// <summary>
118126
/// The fourteen-entity demo, with a custom editor in a sibling platform project.
119127
/// </summary>

0 commit comments

Comments
 (0)