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 |
| ✅ | **318 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **326 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
102 changes: 74 additions & 28 deletions src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -476,48 +476,81 @@ private static bool IsCriteriaRule(ExtractedValidationRule rule)
: null;

/// <summary>
/// Extracts appearance rules from class attributes.
/// Extracts appearance rules written on the class and on its properties.
/// </summary>
/// <remarks>
/// <c>AppearanceAttribute</c> is usable on a class, a property, a method or an interface, and
/// the documentation teaches the property form first: a rule on <c>UnitPrice</c> and a rule on
/// the class naming <c>TargetItems = "UnitPrice"</c> are two spellings of one rule. Only the
/// class spelling was read here, while <see cref="ExtractValidationRules"/> had walked the
/// properties from the start.
/// </remarks>
private static List<ExtractedAppearanceRule> ExtractAppearanceRules(ClassDeclarationSyntax classDecl)
{
var rules = new List<ExtractedAppearanceRule>();

var allAttributes = classDecl.AttributeLists
rules.AddRange(AppearanceAttributesOf(classDecl.AttributeLists)
.Select(attr => ReadAppearanceRule(attr))
.OfType<ExtractedAppearanceRule>());

foreach (var prop in classDecl.Members.OfType<PropertyDeclarationSyntax>())
{
rules.AddRange(AppearanceAttributesOf(prop.AttributeLists)
.Select(attr => ReadAppearanceRule(attr, prop.Identifier.Text))
.OfType<ExtractedAppearanceRule>());
}

return rules;
}

private static IEnumerable<AttributeSyntax> AppearanceAttributesOf(SyntaxList<AttributeListSyntax> attributeLists)
=> attributeLists
.SelectMany(al => al.Attributes)
.Where(a => a.Name.ToString().Contains("Appearance"));

foreach (var attr in allAttributes)
{
var rule = new ExtractedAppearanceRule();
if (attr.ArgumentList == null) continue;
/// <summary>
/// Reads one <c>[Appearance]</c> attribute.
/// </summary>
/// <param name="attr">The attribute to read.</param>
/// <param name="targetProperty">
/// The property the attribute was written on, or <see langword="null"/> for a class-level rule.
/// A property rule that does not name its own <c>TargetItems</c> affects that property, which is
/// what the equivalent class-level spelling states outright; filling it in keeps the two forms
/// from documenting differently. An explicit <c>TargetItems</c> is left alone — overwriting it
/// would silently narrow a rule that names several targets.
/// </param>
private static ExtractedAppearanceRule? ReadAppearanceRule(AttributeSyntax attr, string? targetProperty = null)
{
if (attr.ArgumentList == null) return null;

var args = attr.ArgumentList.Arguments.ToList();
var rule = new ExtractedAppearanceRule();
var args = attr.ArgumentList.Arguments.ToList();

// First positional argument is typically the ID
if (args.Count > 0)
rule.Id = SyntaxLiteral.ValueOf(args[0].Expression);
// First positional argument is typically the ID
if (args.Count > 0)
rule.Id = SyntaxLiteral.ValueOf(args[0].Expression);

foreach (var arg in args)
{
var name = arg.NameEquals?.Name.ToString();
var value = SyntaxLiteral.ValueOf(arg.Expression);
foreach (var arg in args)
{
var name = arg.NameEquals?.Name.ToString();
var value = SyntaxLiteral.ValueOf(arg.Expression);

switch (name)
{
case "TargetItems": rule.TargetItems = value; break;
case "Criteria": rule.Criteria = value; break;
case "Context": rule.Context = value; break;
case "Visibility": rule.Visibility = value; break;
case "Enabled": rule.Enabled = value; break;
case "BackColor": rule.BackColor = value; break;
case "FontColor": rule.FontColor = value; break;
}
switch (name)
{
case "TargetItems": rule.TargetItems = value; break;
case "Criteria": rule.Criteria = value; break;
case "Context": rule.Context = value; break;
case "Visibility": rule.Visibility = value; break;
case "Enabled": rule.Enabled = value; break;
case "BackColor": rule.BackColor = value; break;
case "FontColor": rule.FontColor = value; break;
}

rules.Add(rule);
}

return rules;
if (targetProperty is { Length: > 0 } && string.IsNullOrEmpty(rule.TargetItems))
rule.TargetItems = targetProperty;

return rule;
}

#region Helper Methods
Expand Down Expand Up @@ -1082,7 +1115,7 @@ private static void Fold(
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,
FoldInto(entity.AppearanceRules, parent.AppearanceRules, parent.ClassName, AppearanceRuleKey,
rule => rule.Clone(), (rule, declarer) => rule.InheritedFrom ??= declarer);

FoldInto(entity.Relationships, parent.Relationships, parent.ClassName, rel => rel.PropertyName,
Expand Down Expand Up @@ -1133,6 +1166,19 @@ private static void FoldInto<T>(
private static string ValidationRuleKey(ExtractedValidationRule rule)
=> rule.Id is { Length: > 0 } id ? id : $"{rule.RuleType}{rule.TargetProperty}";

/// <summary>
/// What makes two appearance rules the same rule.
/// </summary>
/// <remarks>
/// Its identifier when it was given one, on the same terms as <see cref="ValidationRuleKey"/>.
/// An empty id is ordinary rather than an omission — a rule written on a property already says
/// what it governs, and the DevExpress non-persistent-objects demo writes
/// <c>[Appearance("", Enabled = false, TargetItems = "*")]</c> — so the targets stand in for a
/// name, and two unnamed rules over different properties stay two rules through the fold.
/// </remarks>
private static string AppearanceRuleKey(ExtractedAppearanceRule rule)
=> rule.Id is { Length: > 0 } id ? id : $"Appearance {rule.TargetItems}";

private static bool IsXafBusinessObject(ClassDeclarationSyntax classDecl, string[] baseTypeNames)
{
if (classDecl.BaseList == null) return false;
Expand Down
14 changes: 10 additions & 4 deletions tests/XafLogicExplainer.Tests/DeclaredRatherThanFoldedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,17 @@ public void CountsMeasureTheApplicationAndNotItsInheritanceDepth()
{
var html = new HtmlExplainerGenerator("0.13.0").Generate(SampleProjects.AuditedXpo);

// Five validation rules and one appearance rule are written in this application. Counting
// the folded copies reports twelve and four, on six entities -- a number that grows when
// somebody adds a subclass and changes nothing about the validation.
// Five validation rules and three appearance rules are written in this application.
// Counting the folded copies reports twelve of each, on six entities -- a number that grows
// when somebody adds a subclass and changes nothing about the validation.
Assert.Contains("<b>5</b><span>validation rules", Compact(html), StringComparison.Ordinal);
Assert.Contains("<b>1</b><span>appearance rules", Compact(html), StringComparison.Ordinal);
Assert.Contains("<b>3</b><span>appearance rules", Compact(html), StringComparison.Ordinal);

// The folded totals the paragraph above names, enforced rather than asserted in prose --
// they are the whole reason the page reports the declared numbers instead.
var entities = SampleProjects.AuditedXpo.Entities;
Assert.Equal(12, entities.Sum(e => e.ValidationRules.Count));
Assert.Equal(12, entities.Sum(e => e.AppearanceRules.Count));
}

/// <summary>The page with its line breaks removed, so a stat can be matched across them.</summary>
Expand Down
2 changes: 1 addition & 1 deletion tests/XafLogicExplainer.Tests/ExtractionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,6 @@ public void TheDemoApplicationKeepsItsShape()

Assert.Equal(14, demo.Entities.Count);
Assert.Equal(24, demo.Entities.Sum(e => e.Relationships.Count));
Assert.Equal(9, demo.Entities.Sum(e => e.ValidationRules.Count + e.AppearanceRules.Count));
Assert.Equal(10, demo.Entities.Sum(e => e.ValidationRules.Count + e.AppearanceRules.Count));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public DateTime CreatedOn
set => SetPropertyValue(nameof(CreatedOn), value);
}

// Two rules written the way the DevExpress non-persistent-objects demo writes them: the id
// left empty, because a rule on a property already says what it governs.
[Appearance("", Enabled = false)]
public string ChangedBy
{
get => GetPropertyValue<string>(nameof(ChangedBy));
Expand All @@ -62,6 +65,7 @@ public int RowVersion
set => SetPropertyValue(nameof(RowVersion), value);
}

[Appearance("", Visibility = "Hide")]
public string AuditNotes
{
get => GetPropertyValue<string>(nameof(AuditNotes));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public Product(Session session) : base(session) { }
public string Barcode { get => _barcode; set => SetPropertyValue(nameof(Barcode), ref _barcode, value); }

private decimal _unitPrice;
[Appearance("PriceLockedOnPrescriptionItems", Criteria = "RequiresPrescription", Enabled = false)]
public decimal UnitPrice { get => _unitPrice; set => SetPropertyValue(nameof(UnitPrice), ref _unitPrice, value); }

private bool _requiresPrescription;
Expand Down
10 changes: 7 additions & 3 deletions tests/XafLogicExplainer.Tests/InheritedRuleFoldTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ public void AnEntityCarriesTheRulesEnforcedWhenItIsSaved()
[Fact]
public void AnEntityCarriesTheAppearanceRulesThatStyleIt()
{
var appearance = Assert.Single(Receipt.AppearanceRules);
var appearance = Assert.Single(
Receipt.AppearanceRules, rule => rule.Id == "Audit_ReadOnlyOnceVersioned");

Assert.Equal("Audit_ReadOnlyOnceVersioned", appearance.Id);
Assert.Equal("RowVersion > 0", appearance.Criteria);
}

Expand All @@ -56,8 +56,12 @@ public void AnEntityCarriesTheAssociationsItInherits()
public void EachFoldedDeclarationNamesTheClassThatWroteIt()
{
Assert.Equal("AuditedObject", Receipt.ValidationRules.First().InheritedFrom);
Assert.Equal("AuditedObject", Assert.Single(Receipt.AppearanceRules).InheritedFrom);
Assert.Equal("AuditedObject", Assert.Single(Receipt.Relationships).InheritedFrom);

// Every one of them, not just the first: Receipt declares no appearance rule of its own, so
// an unmarked one would be a rule the reader is told Receipt wrote.
Assert.All(Receipt.AppearanceRules,
rule => Assert.Equal("AuditedObject", rule.InheritedFrom));
}

[Fact]
Expand Down
74 changes: 74 additions & 0 deletions tests/XafLogicExplainer.Tests/PropertyLevelAppearanceRuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using XafLogicExplainer.Core.Models;

namespace XafLogicExplainer.Tests;

/// <summary>
/// An <c>[Appearance]</c> rule written on a property, which is the first form the documentation
/// teaches.
/// </summary>
/// <remarks>
/// <c>AppearanceAttribute</c> is declared
/// <c>[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property
/// | AttributeTargets.Interface)]</c>, and "Declare Conditional Appearance Rules in Code" lists
/// applying it to a property as Approach 1 and applying it to the class with the property named in
/// <c>TargetItems</c> as Approach 2 — two spellings of one rule.
/// <para>
/// Only the class-level spelling was read. Every <c>[Appearance]</c> in every fixture happened to
/// be class-level, so the whole suite agreed that reading
/// <see cref="Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax.AttributeLists"/> alone
/// was enough — the same shape as the <c>CustomMessageTemplate</c> blind spot in
/// <see cref="ValidationRuleArgumentTests"/>, where a form no fixture used could not be seen to be
/// missing. <c>ExtractValidationRules</c> had walked the properties as well as the class since the
/// beginning; <c>ExtractAppearanceRules</c> never did.
/// </para>
/// <para>
/// A rule that governs a property and is reported nowhere is worse than an unread one: the entity's
/// section is presented as its complete inventory, so the reader concludes the property is
/// unconditionally editable.
/// </para>
/// </remarks>
public class PropertyLevelAppearanceRuleTests
{
private static ExtractedEntity Product => SampleProjects.Demo.Entity("Product");

private static ExtractedAppearanceRule? PriceRule => Product.AppearanceRules
.SingleOrDefault(rule => rule.Id == "PriceLockedOnPrescriptionItems");

[Fact]
public void ARuleWrittenOnAPropertyIsFound()
{
Assert.NotNull(PriceRule);
}

[Fact]
public void ItKeepsTheCriteriaThatDecidesWhenItApplies()
{
Assert.Equal("RequiresPrescription", PriceRule!.Criteria);
}

[Fact]
public void ItTargetsThePropertyItWasWrittenOn()
{
// Approach 2 spells this rule on the class with TargetItems = "UnitPrice". The two
// approaches are equivalent, so they must extract alike -- otherwise which spelling the
// author happened to choose changes what the documentation says the rule affects.
Assert.Equal("UnitPrice", PriceRule!.TargetItems);
}

[Fact]
public void ItDoesNotDisplaceTheRuleWrittenOnTheClass()
{
Assert.Contains(Product.AppearanceRules, rule => rule.Id == "ProductOutOfStock");
}

[Fact]
public void AnExplicitTargetItemsOnAPropertyRuleIsLeftAlone()
{
// Only an unset TargetItems is filled in from the property name. StockBatch's rule names
// its own targets, and inferring over the top of that would silently narrow the rule.
var batchRule = SampleProjects.Demo.Entity("StockBatch").AppearanceRules
.Single(rule => rule.Id == "BatchExpired");

Assert.Equal("*", batchRule.TargetItems);
}
}
57 changes: 57 additions & 0 deletions tests/XafLogicExplainer.Tests/UnnamedAppearanceRuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using XafLogicExplainer.Core.Models;

namespace XafLogicExplainer.Tests;

/// <summary>
/// Appearance rules that were not given an identifier.
/// </summary>
/// <remarks>
/// A rule written on a property does not need a name — the property is what it governs — and the
/// DevExpress non-persistent-objects demo writes exactly that: <c>[Appearance("", Enabled = false,
/// TargetItems = "*")]</c>. So an empty id is ordinary, not a mistake.
/// <para>
/// The fold that carries a base class's rules down to its descendants keys appearance rules on
/// <c>rule.Id</c> alone, and <see cref="FoldInto"/> skips a key it has already seen. Two unnamed
/// rules therefore look like one rule to it, and the second is dropped without a word.
/// <c>ValidationRuleKey</c> already guards against this — it falls back to the attribute and the
/// property when a rule has no id, "because two unnamed rules of the same kind on the same property
/// cannot be told apart anyway". Appearance rules had no such fallback.
/// </para>
/// <para>
/// Reading rules off properties is what made this reachable: before, appearance rules came only
/// from the class, where one unnamed rule per class is the most anyone writes.
/// </para>
/// </remarks>
public class UnnamedAppearanceRuleTests
{
private static List<ExtractedAppearanceRule> InheritedByInvoice => SampleProjects.AuditedXpo
.Entity("Invoice").AppearanceRules
.Where(rule => string.IsNullOrEmpty(rule.Id))
.ToList();

[Fact]
public void BothUnnamedRulesSurviveTheFoldIntoADescendant()
{
Assert.Equal(2, InheritedByInvoice.Count);
}

[Fact]
public void EachKeepsThePropertyItGoverns()
{
Assert.Equal(
new[] { "AuditNotes", "ChangedBy" },
InheritedByInvoice.Select(rule => rule.TargetItems).OrderBy(name => name).ToArray());
}

[Fact]
public void TheyAreStillDistinguishableByWhatTheyDo()
{
// Dropping one of the two would leave the survivor looking like the whole truth, so the
// reader would be told the audit notes are hidden but not that ChangedBy is read-only, or
// the reverse -- with nothing to indicate a rule had gone missing.
var byProperty = InheritedByInvoice.ToDictionary(rule => rule.TargetItems!);

Assert.Equal("false", byProperty["ChangedBy"].Enabled);
Assert.Equal("Hide", byProperty["AuditNotes"].Visibility);
}
}
Loading