diff --git a/CHANGELOG.md b/CHANGELOG.md index 5160a14..d2f28b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). was added for. `FeatureCenter.NET.XPO` gains `OidGenerator`, `NoKeyPropertyNamedBaseObject` and `LayoutDemoObject`. +- **An entity carries the properties it inherits.** A class found through a base declared in the + same project was reported with only the columns it declares itself: `PriorityOrder` listed + `Rank` and omitted the `Name` and `Number` it persists. Finding those classes at all is what + 0.12.1 was about, and it converted a silent omission into a stated one — the entity now appeared + under a heading presenting the application's tables, with two thirds of its columns absent, in a + document that tells an agent its inventories are complete. At scale it is the shared base that + hurts: an application on an `AuditedObject` lost whatever that base holds from *every* entity, + which is normally the audit fields an agent most needs to know it must not set by hand. Each + entity now folds in its ancestors' properties in declaration order from the root down, each + marked with the class that declared it; a property the class redeclares stays its own, and the + abstract base is marked as abstract. `FeatureCenter.NET.XPO` folds 151 properties over 146 + entities, `MainDemo.NET.XPO` 26 over 17 — where `Employee` reaches `Photo` through `Person` and + is correctly told it comes from `Party`. + + Summaries of fixed width name an entity's **own** columns first. The full listings read root + down, the way the class does, but a five-slot table sharing its width with a six-column audit + base spends every slot on the base — and then every row of the entity table names the same + columns and none of the ones that tell one entity from another. Rules and associations an entity + inherits are still listed only under the class that declares them (#14). + ## [0.12.1] — 2026-08-13 Entities the application declares, rather than the ones that inherit from the right class. diff --git a/README.md b/README.md index c4afe9a..f3a8620 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 | -| ✅ | **295 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed | +| ✅ | **299 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/Generators/AgentContextGenerator.cs b/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs index 93f6080..95014af 100644 --- a/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs +++ b/src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs @@ -278,9 +278,15 @@ private static void WriteEntityInventory(StringBuilder sb, ExtractedProject proj foreach (var entity in project.Entities.OrderBy(e => e.ClassName, StringComparer.Ordinal)) { + // What the entity declares comes first. The full list keeps the order the class reads + // in, root down, but five slots shared with a shared base spend them all on the base: + // every row of an audited application then names the same audit columns and none of + // the columns that tell one entity from another. A row that cannot discriminate is + // what this table exists to do. var notable = entity.Properties .Where(p => !p.IsCollection) - .OrderByDescending(p => p.IsKey) + .OrderByDescending(p => p.InheritedFrom is null) + .ThenByDescending(p => p.IsKey) .ThenByDescending(p => p.IsRequired) .ThenByDescending(p => p.IsComputed) .Take(5) diff --git a/src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs b/src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs index 3b3273b..8db90f8 100644 --- a/src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs +++ b/src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs @@ -782,7 +782,16 @@ private DocumentSection GenerateNavigationSection(ExtractedProject project) if (!string.IsNullOrEmpty(entity.Description)) sb.AppendLine($"*{entity.Description}*"); sb.AppendLine(); - sb.AppendLine($"- **{_l.MainProperties}:** {string.Join(", ", entity.Properties.Where(p => !p.IsCollection && p.VisibleInListView).Take(8).Select(p => p.Name))}"); + + // Own columns first: eight slots shared with a base the whole application derives + // from would otherwise name the base's columns under every entity in the menu. + var main = entity.Properties + .Where(p => !p.IsCollection && p.VisibleInListView) + .OrderByDescending(p => p.InheritedFrom is null) + .Take(8) + .Select(p => p.Name); + + sb.AppendLine($"- **{_l.MainProperties}:** {string.Join(", ", main)}"); if (entity.Relationships.Count > 0) sb.AppendLine($"- **{_l.Relationships}:** {string.Join(", ", entity.Relationships.Select(r => $"{r.PropertyName}→{r.RelatedEntity}"))}"); sb.AppendLine(); diff --git a/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/AuditedObject.cs b/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/AuditedObject.cs new file mode 100644 index 0000000..dbd1102 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/AuditedObject.cs @@ -0,0 +1,52 @@ +using DevExpress.Persistent.BaseImpl; +using DevExpress.Xpo; + +namespace SampleAudited.Module.BusinessObjects; + +/// +/// The audit base an application writes once and derives every entity from. +/// +/// +/// Wider than the columns of the entities below it, which is the ordinary shape: audit bases +/// accumulate. It is what makes a fixed-size summary a decision about whose columns get named. +/// +public abstract class AuditedObject : BaseObject +{ + public AuditedObject(Session session) : base(session) { } + + public string CreatedBy + { + get => GetPropertyValue(nameof(CreatedBy)); + set => SetPropertyValue(nameof(CreatedBy), value); + } + + public DateTime CreatedOn + { + get => GetPropertyValue(nameof(CreatedOn)); + set => SetPropertyValue(nameof(CreatedOn), value); + } + + public string ChangedBy + { + get => GetPropertyValue(nameof(ChangedBy)); + set => SetPropertyValue(nameof(ChangedBy), value); + } + + public DateTime ChangedOn + { + get => GetPropertyValue(nameof(ChangedOn)); + set => SetPropertyValue(nameof(ChangedOn), value); + } + + public int RowVersion + { + get => GetPropertyValue(nameof(RowVersion)); + set => SetPropertyValue(nameof(RowVersion), value); + } + + public string AuditNotes + { + get => GetPropertyValue(nameof(AuditNotes)); + set => SetPropertyValue(nameof(AuditNotes), value); + } +} diff --git a/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/Invoice.cs b/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/Invoice.cs new file mode 100644 index 0000000..9337a35 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/Invoice.cs @@ -0,0 +1,32 @@ +using DevExpress.Persistent.Base; +using DevExpress.Persistent.Validation; +using DevExpress.Xpo; + +namespace SampleAudited.Module.BusinessObjects; + +/// An entity whose own columns are outnumbered by the ones it inherits. +[DefaultClassOptions] +[NavigationItem("Billing")] +public class Invoice : AuditedObject +{ + public Invoice(Session session) : base(session) { } + + [RuleRequiredField("Invoice_Number_Required", DefaultContexts.Save)] + public string Number + { + get => GetPropertyValue(nameof(Number)); + set => SetPropertyValue(nameof(Number), value); + } + + public DateTime IssuedOn + { + get => GetPropertyValue(nameof(IssuedOn)); + set => SetPropertyValue(nameof(IssuedOn), value); + } + + public decimal Total + { + get => GetPropertyValue(nameof(Total)); + set => SetPropertyValue(nameof(Total), value); + } +} diff --git a/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/Receipt.cs b/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/Receipt.cs new file mode 100644 index 0000000..6d91220 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/AuditedXpoSolution/SampleAudited.Module/BusinessObjects/Receipt.cs @@ -0,0 +1,31 @@ +using DevExpress.Persistent.Base; +using DevExpress.Xpo; + +namespace SampleAudited.Module.BusinessObjects; + +/// +/// The same shape without a required column. +/// +/// +/// Invoice keeps one of its own columns in a five-slot summary by accident, because a +/// required property outranks the rest. An entity with no such column keeps none — which is the +/// case that shows the ranking was never the thing holding the row together. +/// +[DefaultClassOptions] +[NavigationItem("Billing")] +public class Receipt : AuditedObject +{ + public Receipt(Session session) : base(session) { } + + public string Reference + { + get => GetPropertyValue(nameof(Reference)); + set => SetPropertyValue(nameof(Reference), value); + } + + public decimal Amount + { + get => GetPropertyValue(nameof(Amount)); + set => SetPropertyValue(nameof(Amount), value); + } +} diff --git a/tests/XafLogicExplainer.Tests/InheritedPropertySummaryTests.cs b/tests/XafLogicExplainer.Tests/InheritedPropertySummaryTests.cs new file mode 100644 index 0000000..33309ae --- /dev/null +++ b/tests/XafLogicExplainer.Tests/InheritedPropertySummaryTests.cs @@ -0,0 +1,77 @@ +using System.Text.RegularExpressions; +using XafLogicExplainer.Core.Generators; + +namespace XafLogicExplainer.Tests; + +/// +/// What a summary of fixed width names once an entity carries its base's columns. +/// +/// +/// Folding inherited properties () gives every entity the +/// columns it persists, in the order the class reads them — root down. Two summaries then take the +/// first few of that list, and on an application with a shared audit base the first few are the +/// base's for every entity at once: the tables that exist to tell entities apart end up naming the +/// same columns in every row, and none of the ones that differ. +/// +/// The full listings are unaffected and still read root down. This is only about the summaries, +/// where the width is the whole constraint. +/// +/// +public class InheritedPropertySummaryTests +{ + private static string Index => + new AgentContextGenerator("0.9.0").GenerateIndex(SampleProjects.AuditedXpo, []); + + [Fact] + public void AnEntitysOwnColumnsSurviveAFixedWidthSummary() + { + // Receipt declares two columns and inherits six. Ranked by nothing but position, the six + // fill all five slots and the reader learns that a receipt has a CreatedBy. + Assert.Equal(["Reference", "Amount", "CreatedBy", "CreatedOn", "ChangedBy"], Notable("Receipt")); + } + + [Fact] + public void RequiredColumnsStillOutrankTheRestOfTheEntitysOwn() + { + // Own-before-inherited is the outer sort; the existing ranking still orders each group. + Assert.Equal(["Number", "IssuedOn", "Total", "CreatedBy", "CreatedOn"], Notable("Invoice")); + } + + [Fact] + public void TheBaseItselfStillNamesItsOwnColumns() + { + // The base declares all six, so the rule must not be "hide inherited" -- it is "declare + // first". A base whose row went blank would be the same defect facing the other way. + Assert.Equal(["CreatedBy", "CreatedOn", "ChangedBy", "ChangedOn", "RowVersion"], Notable("AuditedObject")); + } + + [Fact] + public void TheNavigationSummaryNamesTheEntitysOwnColumnsFirstToo() + { + // The same eight-slot summary, in the documents published to a Copilot rather than to a + // coding agent -- one fix per reader, or the reader who gets missed keeps the defect. + // Generated in the default language, which is Spanish; the ordering is not translated. + var sections = new MarkdownDocumentationGenerator().GenerateSections(SampleProjects.AuditedXpo); + var navigation = sections.Single(section => section.Content.Contains("#### Receipt")); + + var line = navigation.Content + .Split('\n') + .SkipWhile(text => !text.StartsWith("#### Receipt", StringComparison.Ordinal)) + .First(text => text.Contains("Propiedades principales", StringComparison.Ordinal)); + + Assert.StartsWith( + "- **Propiedades principales:** Reference, Amount, CreatedBy", + line.Trim(), + StringComparison.Ordinal); + } + + /// The property names in one row of the agent context's entity table. + private static List Notable(string className) + { + var row = Index + .Split('\n') + .Single(line => line.StartsWith($"| **{className}** ", StringComparison.Ordinal)); + + return [.. Regex.Matches(row.Split('|')[3], "`([^`]+)`").Select(match => match.Groups[1].Value)]; + } +} diff --git a/tests/XafLogicExplainer.Tests/SampleProjects.cs b/tests/XafLogicExplainer.Tests/SampleProjects.cs index ae4b7cd..ac4ef26 100644 --- a/tests/XafLogicExplainer.Tests/SampleProjects.cs +++ b/tests/XafLogicExplainer.Tests/SampleProjects.cs @@ -40,6 +40,10 @@ internal static class SampleProjects /// Path to the XPO fixture whose entities derive through a shared base. public static string DeepXpoPath => Path.Combine(FixturesRoot, "DeepXpoSolution", "SampleDeep.Module"); + /// Path to the XPO fixture whose audit base is wider than its entities. + public static string AuditedXpoPath => + Path.Combine(FixturesRoot, "AuditedXpoSolution", "SampleAudited.Module"); + /// Path to the fourteen-entity demo module. public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module"); @@ -96,6 +100,7 @@ private static string FixturesRoot private static readonly Lazy LazyNoOrm = new(() => Extract(NoOrmPath)); private static readonly Lazy LazyDeepXpo = new(() => Extract(DeepXpoPath)); + private static readonly Lazy LazyAuditedXpo = new(() => Extract(AuditedXpoPath)); /// The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml. public static ExtractedProject Xpo => LazyXpo.Value; @@ -122,6 +127,16 @@ private static string FixturesRoot /// An XPO application whose entities reach BaseObject through a shared base. public static ExtractedProject DeepXpo => LazyDeepXpo.Value; + /// + /// An XPO application on an audit base wider than the entities that derive from it. + /// + /// + /// Kept apart from , which asserts exact property lists on a base holding + /// a single column. What this fixture is for is the opposite proportion — the one where a + /// summary of fixed width has to choose between an entity's columns and its base's. + /// + public static ExtractedProject AuditedXpo => LazyAuditedXpo.Value; + /// /// The fourteen-entity demo, with a custom editor in a sibling platform project. ///