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
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 |
| ✅ | **290 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **295 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
111 changes: 101 additions & 10 deletions src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
: options.Orm;
options.ResolvedOrm = ormType;

var persistent = SelectPersistentClasses(parsedFiles.Select(parsed => parsed.Root), roster, options);
var (persistent, parents) = SelectPersistentClasses(parsedFiles.Select(parsed => parsed.Root), roster, options);

foreach (var (file, root) in parsedFiles)
{
Expand All @@ -67,6 +67,11 @@
if (ormType == OrmType.EfCore)
InferEfCoreRelationships(entities);

// After the merge, so a parent's property set is complete before it is folded into anyone;
// after relationship inference, so inherited navigation properties do not repeat the
// parent's relationships under every descendant.
FoldInheritedProperties(entities, parents);

return entities;
}

Expand All @@ -90,6 +95,7 @@
IsPersistent = !HasAttribute(classDecl, "NonPersistent")
&& !HasAttribute(classDecl, "DomainComponent")
&& !HasAttribute(classDecl, "NotMapped"),
IsAbstract = classDecl.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.AbstractKeyword)),
};

// Extract properties
Expand Down Expand Up @@ -500,8 +506,9 @@
/// generated part that carries the mapping are the same class.
/// </para>
/// </remarks>
private static HashSet<(string Namespace, string Name)> SelectPersistentClasses(
IEnumerable<SyntaxNode> roots, DbSetRoster roster, ExtractionOptions options)
private static (HashSet<(string Namespace, string Name)> Accepted,
Dictionary<(string Namespace, string Name), (string Namespace, string Name)> Parents)
SelectPersistentClasses(IEnumerable<SyntaxNode> roots, DbSetRoster roster, ExtractionOptions options)
{
var trees = roots.ToList();
var globalUsings = GlobalUsings(trees);
Expand Down Expand Up @@ -559,7 +566,7 @@
if (accepted.Contains((candidate.Namespace, candidate.Name)))
continue;

if (!DerivesFromAccepted(candidate.Declaration, candidate.Scopes, accepted))
if (ResolveBase(candidate.Declaration, candidate.Scopes, accepted) is null)
continue;

accepted.Add((candidate.Namespace, candidate.Name));
Expand All @@ -568,18 +575,34 @@
}
while (changed);

return accepted;
// The walk just resolved every class's ancestry; keeping the edge is what lets inherited
// properties be folded later without resolving anything a second time. Computed after the
// fixed point, because a parent may be accepted rounds after the class deriving from it.
var parents = new Dictionary<(string Namespace, string Name), (string Namespace, string Name)>();

foreach (var candidate in candidates)
{
if (!accepted.Contains((candidate.Namespace, candidate.Name)))
continue;

// Only one part of a partial class declares the base list; the parts that declare
// none must not erase the edge that part resolved.
if (ResolveBase(candidate.Declaration, candidate.Scopes, accepted) is { } parent)
parents.TryAdd((candidate.Namespace, candidate.Name), parent);
}
Comment on lines +583 to +592

return (accepted, parents);
}

/// <summary>
/// Whether any name in this class's base list resolves to a class already accepted.
/// The accepted class a name in this class's base list resolves to, if any.
/// </summary>
/// <remarks>
/// Every entry is tried rather than the first, because syntax cannot tell a base class from an
/// interface. An interface name only matches if a class of that name was itself accepted, which
/// an interface never is.
/// </remarks>
private static bool DerivesFromAccepted(
private static (string Namespace, string Name)? ResolveBase(
ClassDeclarationSyntax classDecl,
HashSet<string> scopes,
HashSet<(string Namespace, string Name)> accepted)
Expand All @@ -601,19 +624,19 @@
if (dot < 0)
{
// Unqualified: it named something this file can actually see.
if (scopes.Contains(@namespace)) return true;
if (scopes.Contains(@namespace)) return (@namespace, name);
continue;
}

// Qualified: the tail it wrote is more specific than any using directive.
var qualifier = written[..dot];
if (@namespace.Equals(qualifier, StringComparison.Ordinal)
|| @namespace.EndsWith("." + qualifier, StringComparison.Ordinal))
return true;
return (@namespace, name);
}
}

return false;
return null;
}

/// <summary>
Expand Down Expand Up @@ -913,6 +936,8 @@
primary.SourceProject ??= entity.SourceProject;
primary.IsDefaultClassOptions |= entity.IsDefaultClassOptions;
primary.IsCloneable |= entity.IsCloneable;
// `abstract` need only be written on one part to be true of the class.
primary.IsAbstract |= entity.IsAbstract;

// Non-persistent anywhere means non-persistent: one part saying so is the class.
primary.IsPersistent &= entity.IsPersistent;
Expand All @@ -937,6 +962,72 @@
return merged;
}

/// <summary>
/// Folds each ancestor's properties into the entities that inherit them.
/// </summary>
/// <remarks>
/// An entity holding only what it declares itself is an inventory with most of its columns
/// under other headings — or, for a shared audit base, missing from every entity at once. The
/// inherited properties are persisted, appear in views, and are readable from any code an
/// agent writes; a document that promises completeness has to carry them where the reader
/// looks.
/// <para>
/// Ancestors first, so the properties read in declaration order from the root down, the way
/// they do in the class. A property the class redeclares is its own: the inherited one is not
/// added beside it. Each fold works on a copy, because the same declared property is listed
/// under every descendant and each listing names its own declarer.
/// </para>
/// </remarks>
private static void FoldInheritedProperties(
List<ExtractedEntity> entities,
Dictionary<(string Namespace, string Name), (string Namespace, string Name)> parents)
{
var byClass = entities.ToDictionary(entity => (entity.Namespace, entity.ClassName));
var folded = new HashSet<(string Namespace, string Name)>();

foreach (var entity in entities)
Fold(entity, byClass, parents, folded);
}

/// <summary>
/// Folds one entity's ancestry into it, folding the parent first so a chain of any depth
/// arrives complete.
/// </summary>
private static void Fold(
ExtractedEntity entity,
Dictionary<(string Namespace, string Name), ExtractedEntity> byClass,
Dictionary<(string Namespace, string Name), (string Namespace, string Name)> parents,
HashSet<(string Namespace, string Name)> folded)
{
var key = (entity.Namespace, entity.ClassName);

// Marked before recursing, which both memoizes the walk and stops it if malformed source
// ever declares a circular base list.
if (!folded.Add(key))
return;

if (!parents.TryGetValue(key, out var parentKey) || !byClass.TryGetValue(parentKey, out var parent))
return;

Fold(parent, byClass, parents, folded);

var own = entity.Properties.Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
var inherited = new List<ExtractedProperty>();

foreach (var property in parent.Properties)
{
if (own.Contains(property.Name))
continue;

var copy = property.Clone();
// The declarer, not the parent: what the parent itself inherited keeps its origin.
copy.InheritedFrom ??= parent.ClassName;
inherited.Add(copy);
}
Comment on lines +1017 to +1026

entity.Properties.InsertRange(0, inherited);
}

private static bool IsXafBusinessObject(ClassDeclarationSyntax classDecl, string[] baseTypeNames)
{
if (classDecl.BaseList == null) return false;
Expand Down
35 changes: 35 additions & 0 deletions src/XafLogicExplainer.Core/Models/ExtractedEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ public class ExtractedEntity
/// </summary>
public bool IsPersistent { get; set; } = true;

/// <summary>
/// Whether the class is declared <c>abstract</c>.
/// </summary>
/// <remarks>
/// An abstract base appears in the inventory because its descendants are found through it, but
/// it is not itself something a user opens. Once its properties are folded into every
/// descendant, a renderer needs this to say which heading is a table and which is a convention.
/// </remarks>
public bool IsAbstract { get; set; }

/// <summary>
/// Scalar and collection properties discovered in source.
/// </summary>
Expand Down Expand Up @@ -122,6 +132,31 @@ public class ExtractedProperty
/// </summary>
public string Name { get; set; } = string.Empty;

/// <summary>
/// The class that declared this property, when it is not the entity listing it.
/// </summary>
/// <remarks>
/// <c>null</c> for a property the entity declares itself. Knowing <c>ChangedOn</c> comes from
/// a shared audit base and not from the entity is worth something to a reader — it is usually
/// how they learn the property is not theirs to set.
/// </remarks>
public string? InheritedFrom { get; set; }

/// <summary>
/// A copy this property's declarer does not share.
/// </summary>
/// <remarks>
/// Folding lists the same declared property under every descendant, and each listing carries
/// its own <see cref="InheritedFrom"/>. Stamping that on a shared instance would rewrite the
/// declaring entity's own listing.
/// </remarks>
public ExtractedProperty Clone()
{
var copy = (ExtractedProperty)MemberwiseClone();
copy.CustomAttributes = [.. CustomAttributes];
return copy;
}

/// <summary>
/// Declared CLR type text.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using DevExpress.Persistent.Base;
using DevExpress.Xpo;

namespace SampleDeep.Module.BusinessObjects;

/// <summary>Redeclares a property its base already declares.</summary>
/// <remarks>
/// Here so folding has to answer what a redeclaration means: the class's own <c>Number</c> is the
/// property, and the inherited one must not appear beside it as a duplicate row.
/// </remarks>
[DefaultClassOptions]
public class LabeledOrder : Order
{
public LabeledOrder(Session session) : base(session) { }

public new string Number
{
get => GetPropertyValue<string>(nameof(Number));
set => SetPropertyValue(nameof(Number), value);
}
}
61 changes: 61 additions & 0 deletions tests/XafLogicExplainer.Tests/InheritedPropertyFoldTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace XafLogicExplainer.Tests;

/// <summary>
/// Properties an entity inherits from a class declared in the same project.
/// </summary>
/// <remarks>
/// Finding a class through its base (<see cref="DeepInheritanceTests"/>) made the class visible
/// but left two thirds of its columns out: <c>PriorityOrder</c> was reported with <c>Rank</c>
/// alone, while the <c>Name</c> and <c>Number</c> it persists lived under other headings. An
/// inventory that promises to be complete has to carry them on the entity itself.
/// </remarks>
public class InheritedPropertyFoldTests
{
[Fact]
public void FoldsInheritedPropertiesIntoTheEntityThatInheritsThem()
{
var priorityOrder = SampleProjects.DeepXpo.Entity("PriorityOrder");

// Declaration order from the root down, the way the columns read in the class.
Assert.Equal(["Name", "Number", "Rank"], priorityOrder.Properties.Select(property => property.Name));
}

[Fact]
public void NamesTheClassThatDeclaredAnInheritedProperty()
{
var priorityOrder = SampleProjects.DeepXpo.Entity("PriorityOrder");

// The declarer, not the parent: Name comes from NamedBaseObject even though PriorityOrder
// reaches it through Order.
Assert.Equal(
["NamedBaseObject", "Order", null],
priorityOrder.Properties.Select(property => property.InheritedFrom));
}

[Fact]
public void APropertyTheClassRedeclaresIsItsOwn()
{
var labeledOrder = SampleProjects.DeepXpo.Entity("LabeledOrder");

// One Number, not two -- and it is the redeclaration, not the inherited one.
Assert.Equal(["Name", "Number"], labeledOrder.Properties.Select(property => property.Name));
Assert.Null(labeledOrder.Properties.Single(property => property.Name == "Number").InheritedFrom);
}

[Fact]
public void TheBaseItselfKeepsOnlyWhatItDeclares()
{
var namedBase = SampleProjects.DeepXpo.Entity("NamedBaseObject");

Assert.Equal(["Name"], namedBase.Properties.Select(property => property.Name));
}

[Fact]
public void MarksTheAbstractBaseAsAbstract()
{
// With every descendant carrying the base's columns, a renderer needs to know which
// heading is a table and which is a convention.
Assert.True(SampleProjects.DeepXpo.Entity("NamedBaseObject").IsAbstract);
Assert.False(SampleProjects.DeepXpo.Entity("Order").IsAbstract);
}
}
Loading