Skip to content

Commit c8e4643

Browse files
Brekhofclaude
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 67b0f14 commit c8e4643

10 files changed

Lines changed: 324 additions & 3 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

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

1226
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+
|| **283 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
@@ -44,13 +44,14 @@ public List<ExtractedEntity> AnalyzeEntities(string sourceDirectory, ExtractionO
4444

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

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

473474
#region Helper Methods
474475

476+
/// <summary>
477+
/// Decides which classes are persistent, following base classes to a fixed point.
478+
/// </summary>
479+
/// <remarks>
480+
/// Matching a class's own base list against a list of root names stops one hop short. An
481+
/// application that writes a shared base — auditing, a key convention, a display name — puts
482+
/// every business object below it out of reach, and the loss is silent in the worst way: the
483+
/// abstract base is extracted in their place, so the inventory reports the one class that is
484+
/// not a table and omits the ones that are.
485+
/// <para>
486+
/// Repeating until a round changes nothing is what the controller side already does in
487+
/// <c>SelectControllers</c>, and for the same reason: a base class may be read after the class
488+
/// deriving from it, and a chain can be any depth.
489+
/// </para>
490+
/// <para>
491+
/// A base name is resolved through the deriving file's own scope rather than by simple name,
492+
/// because a name is not an identity — the reason the DbSet roster carries scopes. An
493+
/// application may keep a <c>Contracts.Order</c> beside its <c>BusinessObjects.Order</c>, each
494+
/// deriving from a different <c>NamedBaseObject</c>, and only one of those is a table.
495+
/// </para>
496+
/// <para>
497+
/// Acceptance is keyed on <c>(namespace, name)</c>, so every part of a <c>partial</c> class is
498+
/// selected once any part of it is — the hand-written part that carries the base list and the
499+
/// generated part that carries the mapping are the same class.
500+
/// </para>
501+
/// </remarks>
502+
private static HashSet<(string Namespace, string Name)> SelectPersistentClasses(
503+
IEnumerable<SyntaxNode> roots, DbSetRoster roster, ExtractionOptions options)
504+
{
505+
var trees = roots.ToList();
506+
var globalUsings = GlobalUsings(trees);
507+
508+
var candidates = new List<(ClassDeclarationSyntax Declaration, string Namespace, string Name, HashSet<string> Scopes)>();
509+
510+
foreach (var root in trees)
511+
{
512+
var fileScopes = new HashSet<string>(globalUsings, StringComparer.Ordinal);
513+
514+
foreach (var directive in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
515+
{
516+
if (directive.Alias is null && directive.Name is not null)
517+
fileScopes.Add(directive.Name.ToString());
518+
}
519+
520+
foreach (var classDecl in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
521+
{
522+
var @namespace = GetNamespace(classDecl);
523+
var scopes = new HashSet<string>(fileScopes, StringComparer.Ordinal);
524+
525+
// Its own namespace and every one enclosing it: C# resolves an unqualified name
526+
// outwards, so a base one level up needs no using directive.
527+
for (var scope = @namespace; ; )
528+
{
529+
scopes.Add(scope);
530+
var dot = scope.LastIndexOf('.');
531+
if (dot < 0) break;
532+
scope = scope[..dot];
533+
}
534+
535+
candidates.Add((classDecl, @namespace, classDecl.Identifier.Text, scopes));
536+
}
537+
}
538+
539+
var accepted = new HashSet<(string Namespace, string Name)>();
540+
541+
foreach (var candidate in candidates)
542+
{
543+
if (IsXafBusinessObject(candidate.Declaration, options.BaseTypeNames)
544+
|| roster.Registers(candidate.Namespace, candidate.Name))
545+
accepted.Add((candidate.Namespace, candidate.Name));
546+
}
547+
548+
// Each round can accept a class whose base was accepted in the previous one, so it repeats
549+
// until a round changes nothing. Bounded by the number of classes.
550+
bool changed;
551+
552+
do
553+
{
554+
changed = false;
555+
556+
foreach (var candidate in candidates)
557+
{
558+
if (accepted.Contains((candidate.Namespace, candidate.Name)))
559+
continue;
560+
561+
if (!DerivesFromAccepted(candidate.Declaration, candidate.Scopes, accepted))
562+
continue;
563+
564+
accepted.Add((candidate.Namespace, candidate.Name));
565+
changed = true;
566+
}
567+
}
568+
while (changed);
569+
570+
return accepted;
571+
}
572+
573+
/// <summary>
574+
/// Whether any name in this class's base list resolves to a class already accepted.
575+
/// </summary>
576+
/// <remarks>
577+
/// Every entry is tried rather than the first, because syntax cannot tell a base class from an
578+
/// interface. An interface name only matches if a class of that name was itself accepted, which
579+
/// an interface never is.
580+
/// </remarks>
581+
private static bool DerivesFromAccepted(
582+
ClassDeclarationSyntax classDecl,
583+
HashSet<string> scopes,
584+
HashSet<(string Namespace, string Name)> accepted)
585+
{
586+
foreach (var baseType in classDecl.BaseList?.Types ?? default)
587+
{
588+
var written = baseType.Type.ToString();
589+
590+
var generic = written.IndexOf('<');
591+
if (generic > 0) written = written[..generic];
592+
593+
var dot = written.LastIndexOf('.');
594+
var simpleName = dot < 0 ? written : written[(dot + 1)..];
595+
596+
foreach (var (@namespace, name) in accepted)
597+
{
598+
if (!string.Equals(name, simpleName, StringComparison.Ordinal)) continue;
599+
600+
if (dot < 0)
601+
{
602+
// Unqualified: it named something this file can actually see.
603+
if (scopes.Contains(@namespace)) return true;
604+
continue;
605+
}
606+
607+
// Qualified: the tail it wrote is more specific than any using directive.
608+
var qualifier = written[..dot];
609+
if (@namespace.Equals(qualifier, StringComparison.Ordinal)
610+
|| @namespace.EndsWith("." + qualifier, StringComparison.Ordinal))
611+
return true;
612+
}
613+
}
614+
615+
return false;
616+
}
617+
618+
/// <summary>
619+
/// The <c>global using</c> namespaces, which reach every file however they are declared.
620+
/// </summary>
621+
private static HashSet<string> GlobalUsings(IEnumerable<SyntaxNode> trees) => trees
622+
.SelectMany(root => root.DescendantNodes().OfType<UsingDirectiveSyntax>())
623+
.Where(directive => !directive.GlobalKeyword.IsKind(SyntaxKind.None))
624+
.Select(directive => directive.Name?.ToString())
625+
.Where(name => name is not null)
626+
.Select(name => name!)
627+
.ToHashSet(StringComparer.Ordinal);
628+
475629
/// <summary>
476630
/// Detects ORM mode by scanning file contents for EF-specific namespaces.
477631
/// </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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ internal static class SampleProjects
3131
/// <summary>Path to the legacy EF Core fixture module.</summary>
3232
public static string LegacyEfPath => Path.Combine(FixturesRoot, "LegacyEfSolution", "SampleLegacy.Module");
3333

34+
/// <summary>Path to the XPO fixture whose entities derive through a shared base.</summary>
35+
public static string DeepXpoPath => Path.Combine(FixturesRoot, "DeepXpoSolution", "SampleDeep.Module");
36+
3437
/// <summary>Path to the fourteen-entity demo module.</summary>
3538
public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module");
3639

@@ -83,6 +86,7 @@ private static string FixturesRoot
8386
private static readonly Lazy<ExtractedProject> LazyDemo = new(() => Extract(DemoPath));
8487
private static readonly Lazy<ExtractedProject> LazyEfCore = new(() => Extract(EfCorePath));
8588
private static readonly Lazy<ExtractedProject> LazyLegacyEf = new(() => Extract(LegacyEfPath));
89+
private static readonly Lazy<ExtractedProject> LazyDeepXpo = new(() => Extract(DeepXpoPath));
8690

8791
/// <summary>The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml.</summary>
8892
public static ExtractedProject Xpo => LazyXpo.Value;
@@ -100,6 +104,9 @@ private static string FixturesRoot
100104
/// </remarks>
101105
public static ExtractedProject LegacyEf => LazyLegacyEf.Value;
102106

107+
/// <summary>An XPO application whose entities reach BaseObject through a shared base.</summary>
108+
public static ExtractedProject DeepXpo => LazyDeepXpo.Value;
109+
103110
/// <summary>
104111
/// The fourteen-entity demo, with a custom editor in a sibling platform project.
105112
/// </summary>

0 commit comments

Comments
 (0)