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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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 |
| ✅ | **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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using DevExpress.Persistent.BaseImpl;
using DevExpress.Xpo;

namespace SampleAudited.Module.BusinessObjects;

/// <summary>
/// The audit base an application writes once and derives every entity from.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class AuditedObject : BaseObject
{
public AuditedObject(Session session) : base(session) { }

public string CreatedBy
{
get => GetPropertyValue<string>(nameof(CreatedBy));
set => SetPropertyValue(nameof(CreatedBy), value);
}

public DateTime CreatedOn
{
get => GetPropertyValue<DateTime>(nameof(CreatedOn));
set => SetPropertyValue(nameof(CreatedOn), value);
}

public string ChangedBy
{
get => GetPropertyValue<string>(nameof(ChangedBy));
set => SetPropertyValue(nameof(ChangedBy), value);
}

public DateTime ChangedOn
{
get => GetPropertyValue<DateTime>(nameof(ChangedOn));
set => SetPropertyValue(nameof(ChangedOn), value);
}

public int RowVersion
{
get => GetPropertyValue<int>(nameof(RowVersion));
set => SetPropertyValue(nameof(RowVersion), value);
}

public string AuditNotes
{
get => GetPropertyValue<string>(nameof(AuditNotes));
set => SetPropertyValue(nameof(AuditNotes), value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using DevExpress.Persistent.Base;
using DevExpress.Persistent.Validation;
using DevExpress.Xpo;

namespace SampleAudited.Module.BusinessObjects;

/// <summary>An entity whose own columns are outnumbered by the ones it inherits.</summary>
[DefaultClassOptions]
[NavigationItem("Billing")]
public class Invoice : AuditedObject
{
public Invoice(Session session) : base(session) { }

[RuleRequiredField("Invoice_Number_Required", DefaultContexts.Save)]
public string Number
{
get => GetPropertyValue<string>(nameof(Number));
set => SetPropertyValue(nameof(Number), value);
}

public DateTime IssuedOn
{
get => GetPropertyValue<DateTime>(nameof(IssuedOn));
set => SetPropertyValue(nameof(IssuedOn), value);
}

public decimal Total
{
get => GetPropertyValue<decimal>(nameof(Total));
set => SetPropertyValue(nameof(Total), value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using DevExpress.Persistent.Base;
using DevExpress.Xpo;

namespace SampleAudited.Module.BusinessObjects;

/// <summary>
/// The same shape without a required column.
/// </summary>
/// <remarks>
/// <c>Invoice</c> 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.
/// </remarks>
[DefaultClassOptions]
[NavigationItem("Billing")]
public class Receipt : AuditedObject
{
public Receipt(Session session) : base(session) { }

public string Reference
{
get => GetPropertyValue<string>(nameof(Reference));
set => SetPropertyValue(nameof(Reference), value);
}

public decimal Amount
{
get => GetPropertyValue<decimal>(nameof(Amount));
set => SetPropertyValue(nameof(Amount), value);
}
}
77 changes: 77 additions & 0 deletions tests/XafLogicExplainer.Tests/InheritedPropertySummaryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Text.RegularExpressions;
using XafLogicExplainer.Core.Generators;

namespace XafLogicExplainer.Tests;

/// <summary>
/// What a summary of fixed width names once an entity carries its base's columns.
/// </summary>
/// <remarks>
/// Folding inherited properties (<see cref="InheritedPropertyFoldTests"/>) 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.
/// <para>
/// The full listings are unaffected and still read root down. This is only about the summaries,
/// where the width is the whole constraint.
/// </para>
/// </remarks>
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);
}

/// <summary>The property names in one row of the agent context's entity table.</summary>
private static List<string> 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)];
}
}
15 changes: 15 additions & 0 deletions tests/XafLogicExplainer.Tests/SampleProjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ internal static class SampleProjects
/// <summary>Path to the XPO fixture whose entities derive through a shared base.</summary>
public static string DeepXpoPath => Path.Combine(FixturesRoot, "DeepXpoSolution", "SampleDeep.Module");

/// <summary>Path to the XPO fixture whose audit base is wider than its entities.</summary>
public static string AuditedXpoPath =>
Path.Combine(FixturesRoot, "AuditedXpoSolution", "SampleAudited.Module");

/// <summary>Path to the fourteen-entity demo module.</summary>
public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module");

Expand Down Expand Up @@ -96,6 +100,7 @@ private static string FixturesRoot
private static readonly Lazy<ExtractedProject> LazyNoOrm = new(() => Extract(NoOrmPath));

private static readonly Lazy<ExtractedProject> LazyDeepXpo = new(() => Extract(DeepXpoPath));
private static readonly Lazy<ExtractedProject> LazyAuditedXpo = new(() => Extract(AuditedXpoPath));

/// <summary>The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml.</summary>
public static ExtractedProject Xpo => LazyXpo.Value;
Expand All @@ -122,6 +127,16 @@ private static string FixturesRoot
/// <summary>An XPO application whose entities reach BaseObject through a shared base.</summary>
public static ExtractedProject DeepXpo => LazyDeepXpo.Value;

/// <summary>
/// An XPO application on an audit base wider than the entities that derive from it.
/// </summary>
/// <remarks>
/// Kept apart from <see cref="DeepXpo"/>, 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.
/// </remarks>
public static ExtractedProject AuditedXpo => LazyAuditedXpo.Value;

/// <summary>
/// The fourteen-entity demo, with a custom editor in a sibling platform project.
/// </summary>
Expand Down
Loading