diff --git a/README.md b/README.md index ba0cde3..c4afe9a 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 | -| ✅ | **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 diff --git a/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs b/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs index 7e48de3..a6fc27f 100644 --- a/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs +++ b/src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs @@ -45,7 +45,7 @@ public List AnalyzeEntities(string sourceDirectory, ExtractionO : 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) { @@ -67,6 +67,11 @@ public List AnalyzeEntities(string sourceDirectory, ExtractionO 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; } @@ -90,6 +95,7 @@ private static ExtractedEntity ExtractEntity(ClassDeclarationSyntax classDecl, s IsPersistent = !HasAttribute(classDecl, "NonPersistent") && !HasAttribute(classDecl, "DomainComponent") && !HasAttribute(classDecl, "NotMapped"), + IsAbstract = classDecl.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.AbstractKeyword)), }; // Extract properties @@ -500,8 +506,9 @@ private static List ExtractAppearanceRules(ClassDeclara /// generated part that carries the mapping are the same class. /// /// - private static HashSet<(string Namespace, string Name)> SelectPersistentClasses( - IEnumerable 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 roots, DbSetRoster roster, ExtractionOptions options) { var trees = roots.ToList(); var globalUsings = GlobalUsings(trees); @@ -559,7 +566,7 @@ private static List ExtractAppearanceRules(ClassDeclara 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)); @@ -568,18 +575,34 @@ private static List ExtractAppearanceRules(ClassDeclara } 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); + } + + return (accepted, parents); } /// - /// 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. /// /// /// 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. /// - private static bool DerivesFromAccepted( + private static (string Namespace, string Name)? ResolveBase( ClassDeclarationSyntax classDecl, HashSet scopes, HashSet<(string Namespace, string Name)> accepted) @@ -601,7 +624,7 @@ private static bool DerivesFromAccepted( 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; } @@ -609,11 +632,11 @@ private static bool DerivesFromAccepted( var qualifier = written[..dot]; if (@namespace.Equals(qualifier, StringComparison.Ordinal) || @namespace.EndsWith("." + qualifier, StringComparison.Ordinal)) - return true; + return (@namespace, name); } } - return false; + return null; } /// @@ -913,6 +936,8 @@ private static List MergePartialDeclarations(List MergePartialDeclarations(List + /// Folds each ancestor's properties into the entities that inherit them. + /// + /// + /// 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. + /// + /// 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. + /// + /// + private static void FoldInheritedProperties( + List 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); + } + + /// + /// Folds one entity's ancestry into it, folding the parent first so a chain of any depth + /// arrives complete. + /// + 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(); + + 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); + } + + entity.Properties.InsertRange(0, inherited); + } + private static bool IsXafBusinessObject(ClassDeclarationSyntax classDecl, string[] baseTypeNames) { if (classDecl.BaseList == null) return false; diff --git a/src/XafLogicExplainer.Core/Models/ExtractedEntity.cs b/src/XafLogicExplainer.Core/Models/ExtractedEntity.cs index bd422dd..cfc9784 100644 --- a/src/XafLogicExplainer.Core/Models/ExtractedEntity.cs +++ b/src/XafLogicExplainer.Core/Models/ExtractedEntity.cs @@ -71,6 +71,16 @@ public class ExtractedEntity /// public bool IsPersistent { get; set; } = true; + /// + /// Whether the class is declared abstract. + /// + /// + /// 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. + /// + public bool IsAbstract { get; set; } + /// /// Scalar and collection properties discovered in source. /// @@ -122,6 +132,31 @@ public class ExtractedProperty /// public string Name { get; set; } = string.Empty; + /// + /// The class that declared this property, when it is not the entity listing it. + /// + /// + /// null for a property the entity declares itself. Knowing ChangedOn 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. + /// + public string? InheritedFrom { get; set; } + + /// + /// A copy this property's declarer does not share. + /// + /// + /// Folding lists the same declared property under every descendant, and each listing carries + /// its own . Stamping that on a shared instance would rewrite the + /// declaring entity's own listing. + /// + public ExtractedProperty Clone() + { + var copy = (ExtractedProperty)MemberwiseClone(); + copy.CustomAttributes = [.. CustomAttributes]; + return copy; + } + /// /// Declared CLR type text. /// diff --git a/tests/XafLogicExplainer.Tests/Fixtures/DeepXpoSolution/SampleDeep.Module/BusinessObjects/LabeledOrder.cs b/tests/XafLogicExplainer.Tests/Fixtures/DeepXpoSolution/SampleDeep.Module/BusinessObjects/LabeledOrder.cs new file mode 100644 index 0000000..f39482b --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/DeepXpoSolution/SampleDeep.Module/BusinessObjects/LabeledOrder.cs @@ -0,0 +1,21 @@ +using DevExpress.Persistent.Base; +using DevExpress.Xpo; + +namespace SampleDeep.Module.BusinessObjects; + +/// Redeclares a property its base already declares. +/// +/// Here so folding has to answer what a redeclaration means: the class's own Number is the +/// property, and the inherited one must not appear beside it as a duplicate row. +/// +[DefaultClassOptions] +public class LabeledOrder : Order +{ + public LabeledOrder(Session session) : base(session) { } + + public new string Number + { + get => GetPropertyValue(nameof(Number)); + set => SetPropertyValue(nameof(Number), value); + } +} diff --git a/tests/XafLogicExplainer.Tests/InheritedPropertyFoldTests.cs b/tests/XafLogicExplainer.Tests/InheritedPropertyFoldTests.cs new file mode 100644 index 0000000..e3991a7 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/InheritedPropertyFoldTests.cs @@ -0,0 +1,61 @@ +namespace XafLogicExplainer.Tests; + +/// +/// Properties an entity inherits from a class declared in the same project. +/// +/// +/// Finding a class through its base () made the class visible +/// but left two thirds of its columns out: PriorityOrder was reported with Rank +/// alone, while the Name and Number it persists lived under other headings. An +/// inventory that promises to be complete has to carry them on the entity itself. +/// +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); + } +}