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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **A rule a class inherits is now listed under the class that inherits it** ([#14]). Folding
carried what lives on a property — the folded `Number` row correctly said required — and left
everything recorded on the class behind. A `RuleCriteria` on an audit base is enforced every
time any entity in the application is saved, an `[Appearance]` greys a field on every screen
below it, and an association gives every descendant a collection that really is populated; all
three appeared under the base alone. A reader told the inventories were complete read an
entity's section and was told of no rule. Each folded declaration now names the class that wrote
it, in the entity's properties as well, where it had been recorded since 0.13.0 and shown
nowhere.

- **A validation rule's positional arguments are read into the fields they name.** The four-
argument form — `[RuleCriteria("id", DefaultContexts.Save, "Total >= 0", "A sale total cannot be
negative.")]` — put the message in the field that holds what the rule enforces, and left the
message field empty. Every fixture in the suite passed its message as `CustomMessageTemplate =`,
so 299 tests agreed with the wrong answer. A rule now also carries its identifier and its
validation contexts, which were read as `arg0` and `arg1` and printed to the published
documentation that way.

### Changed

- **Counts, indexes, diagrams and searches report what the application declares**, while an
entity's own section reports everything that governs it. One rule on a base shared by two
hundred entities is one rule; following the fold everywhere would have made every total, map and
search result a measurement of the class hierarchy instead. This is the half of [#14] filed as
debatable, and the answer is that the two readings are answering different questions.

[#14]: https://github.com/peopleworks/XAFLogicExplainer/issues/14

## [0.13.0] — 2026-08-14

The entities an application actually has, and all of the columns they actually persist.
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 |
| ✅ | **299 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **318 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
10 changes: 8 additions & 2 deletions src/XafLogicExplainer.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,8 +1517,14 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a
explainTable.AddRow("Entities", explained.Entities.Count.ToString());
explainTable.AddRow("Controllers", explained.Controllers.Count.ToString());
explainTable.AddRow("Actions", explainActions.ToString());
explainTable.AddRow("Relationships", explained.Entities.Sum(e => e.Relationships.Count).ToString());
explainTable.AddRow("Rules", explained.Entities.Sum(e => e.ValidationRules.Count + e.AppearanceRules.Count).ToString());
// Counted where they are declared. Entities carry what they inherit so that a reader of one
// is told the whole truth about it, but a count that follows the fold measures the depth of
// the class hierarchy: one rule on an audit base would report as one rule per entity.
explainTable.AddRow("Relationships",
explained.Entities.Sum(e => e.Relationships.Count(r => r.InheritedFrom is null)).ToString());
explainTable.AddRow("Rules",
explained.Entities.Sum(e => e.ValidationRules.Count(r => r.InheritedFrom is null)
+ e.AppearanceRules.Count(r => r.InheritedFrom is null)).ToString());
AnsiConsole.Write(explainTable);

AnsiConsole.WriteLine();
Expand Down
155 changes: 130 additions & 25 deletions src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@
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);
// after relationship inference, so an inherited navigation property is not inferred a
// second time under the descendant that received a copy of it — the fold carries the
// parent's relationship down itself, marked with the class that declared it.
FoldInheritance(entities, parents);

return entities;
}
Expand Down Expand Up @@ -400,39 +401,80 @@
rule.Expression = argValue;
else if (argName.Equals("TargetPropertyName", StringComparison.OrdinalIgnoreCase))
rule.TargetProperty = argValue;
else if (argName.Equals("Id", StringComparison.OrdinalIgnoreCase))
rule.Id = argValue;
else if (argName.Equals("TargetContextIDs", StringComparison.OrdinalIgnoreCase))
rule.Contexts = argValue;
}

rule.Expression ??= PositionalCriteria(rule, args);
// After the named ones, which win: every assignment below is a fallback.
ApplyPositionalArguments(rule, args);
}

/// <summary>
/// The expression a <c>RuleCriteria</c> enforces, when it was passed positionally.
/// Reads the arguments a rule attribute was given by position into the fields they name.
/// </summary>
/// <remarks>
/// Its overloads put the criteria in different positions — <c>("Total &gt;= 0")</c>,
/// <c>("id", DefaultContexts.Save, "Total &gt;= 0")</c> — so the position cannot be hardcoded.
/// The last positional string <em>literal</em> is the criteria in every overload: the id comes
/// before it, and the contexts argument is an enum in the form people write.
/// Every <c>Rule*</c> overload that takes an identifier takes it first and the validation
/// contexts second, so those two slots can be read without knowing which attribute this is.
/// What follows them belongs to the rule itself.
/// <para>
/// Taking the last positional literal as the criteria — which is what this did — is right for
/// <c>("id", DefaultContexts.Save, "Total &gt;= 0")</c> and wrong for
/// <c>("id", DefaultContexts.Save, "Total &gt;= 0", "A sale total cannot be negative.")</c>:
/// the trailing literal there is the message shown to the user, so the field holding what the
/// rule enforces held the sentence explaining it instead, and the message field stayed empty.
/// Every fixture passed its message as <c>CustomMessageTemplate =</c>, so the whole suite
/// agreed with the wrong answer.
/// </para>
/// </remarks>
private static string? PositionalCriteria(ExtractedValidationRule rule, SeparatedSyntaxList<AttributeArgumentSyntax> args)
private static void ApplyPositionalArguments(
ExtractedValidationRule rule, SeparatedSyntaxList<AttributeArgumentSyntax> args)
{
if (!rule.RuleType.Contains("Criteria", StringComparison.Ordinal))
return null;
var positional = args.Where(arg => arg.NameEquals is null).ToList();

string? found = null;

foreach (var arg in args)
// One argument leaves no room for an identifier before it, so it is the rule's own.
if (positional.Count < 2)
{
if (arg.NameEquals is not null)
break;
if (positional.Count == 1 && IsCriteriaRule(rule))
rule.Expression ??= StringLiteral(positional[0]);

return;
}

rule.Id ??= StringLiteral(positional[0]);

// Slot 1 is the contexts, written either as the enum or as a context name. Recorded as
// written: `DefaultContexts.Save` is not a string in the source and resolving it would
// mean compiling, which extraction deliberately never does.
rule.Contexts ??= SyntaxLiteral.ValueOf(positional[1].Expression);

if (arg.Expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression))
found = literal.Token.ValueText;
var rest = positional.Skip(2).Select(StringLiteral).OfType<string>().ToList();

if (IsCriteriaRule(rule))
{
if (rest.Count > 0) rule.Expression ??= rest[0];
if (rest.Count > 1) rule.MessageTemplate ??= rest[1];
return;
}

return found;
// A rule with no criteria of its own takes only a message here — but only when the tail
// holds one literal. The attributes that put several there put values in them, the way
// RuleRange puts its bounds, and a confidently wrong message is worse than none.
if (rest.Count == 1)
rule.MessageTemplate ??= rest[0];
}

private static bool IsCriteriaRule(ExtractedValidationRule rule)
=> rule.RuleType.Contains("Criteria", StringComparison.Ordinal);

/// <summary>The argument's text when it was written as a string literal, else null.</summary>
private static string? StringLiteral(AttributeArgumentSyntax arg)
=> arg.Expression is LiteralExpressionSyntax literal
&& literal.IsKind(SyntaxKind.StringLiteralExpression)
? literal.Token.ValueText
: null;

/// <summary>
/// Extracts appearance rules from class attributes.
/// </summary>
Expand Down Expand Up @@ -963,7 +1005,7 @@
}

/// <summary>
/// Folds each ancestor's properties into the entities that inherit them.
/// Folds what each ancestor declares into the entities that inherit it.
/// </summary>
/// <remarks>
/// An entity holding only what it declares itself is an inventory with most of its columns
Expand All @@ -972,13 +1014,20 @@
/// agent writes; a document that promises completeness has to carry them where the reader
/// looks.
/// <para>
/// The same holds for everything else an ancestor declares. A <c>RuleCriteria</c> on an audit
/// base is enforced every time any entity in the application is saved; an <c>[Appearance]</c>
/// greys a field on every screen below it; an association gives every descendant a collection
/// that really is populated. Carrying only the properties down fixed the inventory and left
/// the rules one door away, which is what issue #14 is about.
/// </para>
/// <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.
/// they do in the class. What the class redeclares is its own: the inherited one is not added
/// beside it. Each fold works on a copy, because the same declaration is listed under every
/// descendant and each listing names its own declarer.
/// </para>
/// </remarks>
private static void FoldInheritedProperties(
private static void FoldInheritance(
List<ExtractedEntity> entities,
Dictionary<(string Namespace, string Name), (string Namespace, string Name)> parents)
{
Expand Down Expand Up @@ -1026,8 +1075,64 @@
}

entity.Properties.InsertRange(0, inherited);

// Everything else the entity inherits, on the same terms. What is written on a property
// travels with the property; what is written on the class does not, and stayed under the
// heading of a class the reader was not reading.
FoldInto(entity.ValidationRules, parent.ValidationRules, parent.ClassName, ValidationRuleKey,
rule => rule.Clone(), (rule, declarer) => rule.InheritedFrom ??= declarer);

FoldInto(entity.AppearanceRules, parent.AppearanceRules, parent.ClassName, rule => rule.Id,
rule => rule.Clone(), (rule, declarer) => rule.InheritedFrom ??= declarer);

FoldInto(entity.Relationships, parent.Relationships, parent.ClassName, rel => rel.PropertyName,
rel => rel.Clone(), (rel, declarer) => rel.InheritedFrom ??= declarer);
}

/// <summary>
/// Adds what the parent declared to the descendant, keeping the descendant's own where the two
/// name the same thing.
/// </summary>
/// <remarks>
/// Appended rather than inserted first, unlike properties: a class's properties read in
/// declaration order from the root down, but a rule or an association has no such order to
/// preserve, and what the entity declares itself is what a reader came for.
/// </remarks>
private static void FoldInto<T>(
List<T> target,
List<T> fromParent,
string parentClassName,
Func<T, string> keyOf,
Func<T, T> clone,
Action<T, string> markDeclarer)
{
var own = target.Select(keyOf).ToHashSet(StringComparer.Ordinal);

foreach (var item in fromParent)
{
// Redeclaring wins: a descendant that reuses an identifier is replacing the rule, and
// listing both would show a reader two rules that contradict each other.
if (!own.Add(keyOf(item)))
continue;

var copy = clone(item);
markDeclarer(copy, parentClassName);
target.Add(copy);
}
Comment on lines +1111 to +1121
}

/// <summary>
/// What makes two validation rules the same rule.
/// </summary>
/// <remarks>
/// Its identifier when it was given one, because that is what XAF itself keys on. Without one,
/// the attribute and the property it targets: a descendant that writes its own
/// <c>[RuleRequiredField]</c> over an inherited column has replaced the inherited one, and two
/// unnamed rules of the same kind on the same property cannot be told apart anyway.
/// </remarks>
private static string ValidationRuleKey(ExtractedValidationRule rule)
=> rule.Id is { Length: > 0 } id ? id : $"{rule.RuleType}{rule.TargetProperty}";

private static bool IsXafBusinessObject(ClassDeclarationSyntax classDecl, string[] baseTypeNames)
{
if (classDecl.BaseList == null) return false;
Expand Down
21 changes: 12 additions & 9 deletions src/XafLogicExplainer.Core/Diff/ProjectDiffEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,28 +106,31 @@ private EntityDiff DiffEntity(ExtractedEntity prev, ExtractedEntity curr)
diff.ModifiedProperties.Add(propDiff);
}

// Relationships
var prevRels = ToSafeDictionary(prev.Relationships, r => r.PropertyName);
var currRels = ToSafeDictionary(curr.Relationships, r => r.PropertyName);
// Relationships, as declared, for the same reason.
var prevRels = ToSafeDictionary(prev.Relationships.Where(r => r.InheritedFrom is null), r => r.PropertyName);
var currRels = ToSafeDictionary(curr.Relationships.Where(r => r.InheritedFrom is null), r => r.PropertyName);

foreach (var name in currRels.Keys.Except(prevRels.Keys))
diff.AddedRelationships.Add($"{name} -> {currRels[name].RelatedEntity} ({currRels[name].Type})");

foreach (var name in prevRels.Keys.Except(currRels.Keys))
diff.RemovedRelationships.Add($"{name} -> {prevRels[name].RelatedEntity} ({prevRels[name].Type})");

// Validation rules
var prevRules = prev.ValidationRules.Select(FormatValidationRule).ToHashSet();
var currRules = curr.ValidationRules.Select(FormatValidationRule).ToHashSet();
// Validation rules, as declared. An entity carries the ones it inherits so that reading it
// tells the whole truth, but a change has one author: editing a rule on an audit base
// would otherwise be reported again under every entity in the application, burying the one
// line that says where it was actually changed.
var prevRules = prev.ValidationRules.Where(r => r.InheritedFrom is null).Select(FormatValidationRule).ToHashSet();
var currRules = curr.ValidationRules.Where(r => r.InheritedFrom is null).Select(FormatValidationRule).ToHashSet();

foreach (var rule in currRules.Except(prevRules))
diff.AddedValidationRules.Add(rule);
foreach (var rule in prevRules.Except(currRules))
diff.RemovedValidationRules.Add(rule);

// Appearance rules
var prevAppRules = prev.AppearanceRules.Select(r => r.Id).ToHashSet();
var currAppRules = curr.AppearanceRules.Select(r => r.Id).ToHashSet();
// Appearance rules, on the same terms.
var prevAppRules = prev.AppearanceRules.Where(r => r.InheritedFrom is null).Select(r => r.Id).ToHashSet();
var currAppRules = curr.AppearanceRules.Where(r => r.InheritedFrom is null).Select(r => r.Id).ToHashSet();

foreach (var id in currAppRules.Except(prevAppRules))
diff.AddedAppearanceRules.Add(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,11 @@ private static void WriteEntityInventory(StringBuilder sb, ExtractedProject proj
.Take(5)
.Select(p => $"`{p.Name}`{PropertyMarkers(p)}");

// Own associations first, for the reason the properties beside them are ordered that
// way: four slots shared with a base every entity derives from spend them on the
// base's associations, and the row stops telling this entity apart from any other.
var relationships = entity.Relationships
.OrderByDescending(r => r.InheritedFrom is null)
.Take(4)
.Select(r => $"{RelationshipArrow(r.Type)} `{r.RelatedEntity}`{(r.IsAggregated ? " (owned)" : "")}");

Expand Down
Loading
Loading