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

## [Unreleased]

### Fixed

- **A context deriving from a base in a package registered no entities at all**
([#53](https://github.com/peopleworks/XAFLogicExplainer/issues/53), reported by
[@MBrekhof](https://github.com/MBrekhof)). The DbSet roster reached a context by walking base
names from a set seeded with `DbContext` and grown only by classes declared in the analyzed
source, so a base that lives in a package was never reached. The everyday case is
`IdentityDbContext<TUser>`, the base every ASP.NET Core Identity template writes: an application
with 56 `DbSet<T>` properties reported **zero entities**, silently, and reported itself as EF Core
while doing it. A class that declares `DbSet<T>` properties is now a context, whatever it derives
from — the stronger signal, and the only one that does not depend on having the base class to
read. A `DbSet<T>` written as a local inside a method body is still not a registration.
- **The wizard scaffold was counted as classes you had modelled twice**
([#54](https://github.com/peopleworks/XAFLogicExplainer/issues/54), reported by
[@MBrekhof](https://github.com/MBrekhof)). The XAF Project Wizard writes `ApplicationUser` and
`ApplicationUserLoginInfo` into every solution created with v21.1 or later, so their source is
read and the rule that makes a class *yours* — its own source was read here — let them through.
Any two XAF applications made since 2021 appeared to share them, and on a corpus of small
applications the scaffold can be most of the first number on the page. A class is now shown as
**carried by the framework** when every application declares it with a DevExpress security
contract *and* with the same properties, and it is left out of the count, the map and the overlap
grid. Both halves matter: extend `ApplicationUser` in two applications and it is a finding again,
which on the six real applications is exactly what happens — `ApplicationUser` still counts,
because one of them added `Email`, `Photo` and `Tasks`, and only `ApplicationUserLoginInfo` drops
out.
- The same scaffold was also supplying "names you keep". Two applications sharing nothing but the
wizard output were told that `LoginProviderName` and `ProviderUserKey` were house vocabulary. A
class that does not count as modelled twice no longer supplies words that do — which on the real
corpus promoted three actual house names into a list that is capped at sixty.
- The `wiki` summary printed in the terminal now reads the same number as the page it just wrote.
- A site holding one of something reads `1 property`, not `1 properties`. The page already had a
helper that pluralises and says in its own comment why it exists; four call sites went around it.
- `EntityAnalyzer.cs` carried a literal NUL byte inside a string interpolation, used as a key
separator. It compiled, and it made the largest file in the project binary to `grep` and
`ripgrep`, which silently refuse to search it. Written as the `\0` escape instead.

## [0.17.0] — 2026-08-24

The question one application cannot answer.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ applications. The agent-facing surface is what is landing now, in the open.
| ✅ | Pluggable publishing targets (`IDocumentationSink`) |
| ✅ | **MCP server** — 12 tools, live against your source |
| ✅ | **Installable Claude Code plugin** with skill and MCP server |
| ✅ | **514 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **525 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
4 changes: 3 additions & 1 deletion src/XafLogicExplainer.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1818,7 +1818,9 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a

var wikiTable = new Table().Border(TableBorder.Rounded).AddColumn("In common").AddColumn("");
wikiTable.AddRow("Applications read", wikiApplications.Count.ToString());
wikiTable.AddRow("Classes modelled more than once", wikiCorpus.RecurringEntities.Count.ToString());
// The same number the page leads with, and it has to agree with it. Framework scaffold two
// applications were both given is not something their author modelled twice.
wikiTable.AddRow("Classes modelled more than once", wikiCorpus.ModelledTwiceCount.ToString());
wikiTable.AddRow("Base classes you reused", wikiCorpus.RecurringBaseTypes.Count.ToString());
wikiTable.AddRow("Actions written more than once", wikiCorpus.RecurringActions.Count.ToString());
wikiTable.AddRow("Names used across applications", wikiCorpus.Conventions.Count.ToString());
Expand Down
62 changes: 32 additions & 30 deletions src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using XafLogicExplainer.Core.Interfaces;
Expand Down Expand Up @@ -969,45 +969,47 @@ public bool Registers(string @namespace, string className)
}

/// <summary>
/// The classes that are a <c>DbContext</c>, following base classes declared in the source.
/// The classes that register entities: the ones declaring <c>DbSet&lt;T&gt;</c> properties.
/// </summary>
/// <remarks>
/// Following the chain matters because an application that writes its own
/// <c>AuditedDbContext : DbContext</c> and derives every real context from it would
/// otherwise register nothing -- the same silent-empty failure this roster is here to fix.
/// Read from the declaration rather than from the base class, because the base is very
/// often not there to read. <c>IdentityDbContext&lt;TUser&gt;</c> is the base every
/// ASP.NET Core Identity template writes, and it lives in a package -- so a walk that can
/// only grow its set from classes declared in the analyzed source never reaches it, and an
/// application registers nothing at all however many tables it has.
/// <para>
/// The chain is walked by simple name, which cannot distinguish two same-named classes in
/// different namespaces. The cost of being wrong is small in this direction: a class only
/// contributes to the roster if it also declares <c>DbSet&lt;T&gt;</c> properties, which
/// something that is not a context does not do.
/// Declaring a <c>DbSet&lt;T&gt;</c> property is the stronger signal in any case. It is
/// the application stating that this is one of its tables, it has to be right for the
/// application to run, and it is the only signal that does not depend on having the base
/// class in hand. A hand-written <c>AuditedDbContext : DbContext</c> and a context on a
/// package base become the same case, rather than one that works and one that does not.
/// </para>
/// <para>
/// It is the property that counts, never a mention of the generic: a <c>DbSet&lt;T&gt;</c>
/// declared as a local inside a method body is a type name in a helper, not an
/// application saying it owns a table.
/// </para>
/// </remarks>
private static List<ClassDeclarationSyntax> FindContextClasses(List<SyntaxNode> trees)
{
var declared = trees
return trees
.SelectMany(root => root.DescendantNodes().OfType<ClassDeclarationSyntax>())
.Where(DeclaresAnyDbSet)
.ToList();
}

var contextNames = new HashSet<string>(StringComparer.Ordinal) { "DbContext" };

// A fixed point, because a base class may be read after the class deriving from it.
bool grew;
do
{
grew = false;
foreach (var candidate in declared)
/// <summary>
/// Whether the class declares at least one <c>DbSet&lt;T&gt;</c> property of its own.
/// </summary>
private static bool DeclaresAnyDbSet(ClassDeclarationSyntax candidate)
{
return candidate.Members
.OfType<PropertyDeclarationSyntax>()
.Any(property => property.Type is GenericNameSyntax
{
if (contextNames.Contains(candidate.Identifier.Text)) continue;
if (!GetBaseTypeNames(candidate).Any(contextNames.Contains)) continue;

contextNames.Add(candidate.Identifier.Text);
grew = true;
}
} while (grew);

return declared
.Where(candidate => GetBaseTypeNames(candidate).Any(contextNames.Contains))
.ToList();
Identifier.Text: "DbSet",
TypeArgumentList.Arguments.Count: 1,
});
}
}

Expand Down Expand Up @@ -1218,7 +1220,7 @@ private static void FoldInto<T>(
/// 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}";
=> rule.Id is { Length: > 0 } id ? id : $"{rule.RuleType}\0{rule.TargetProperty}";

/// <summary>
/// What makes two appearance rules the same rule.
Expand Down
51 changes: 46 additions & 5 deletions src/XafLogicExplainer.Core/Wiki/CorpusAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,22 @@
{
ArgumentNullException.ThrowIfNull(applications);

var conventions = Conventions(applications);
var recurring = RecurringEntities(applications);

// The scaffold is settled once, here, and every other finding reads that answer. Deciding
// it twice is how the classes card and the vocabulary card end up disagreeing about the
// same two classes.
var templates = recurring
.Where(r => r.IsTemplate)
.Select(r => r.ClassName)
.ToHashSet(StringComparer.Ordinal);

var conventions = Conventions(applications, templates);

return new WikiCorpus
{
Applications = applications,
RecurringEntities = RecurringEntities(applications),
RecurringEntities = recurring,
RecurringBaseTypes = RecurringBaseTypes(applications),
RecurringActions = RecurringActions(applications),
Conventions = conventions.Take(ConventionLimit).ToList(),
Expand Down Expand Up @@ -173,16 +183,32 @@
.ThenBy(s => s.Application, StringComparer.OrdinalIgnoreCase)
.ToList();

var properties = CompareProperties(uses);

results.Add(new RecurringEntity
{
ClassName = className,
In = sites,
Properties = CompareProperties(uses),
Properties = properties,

// Every application has to carry the contract, and they all have to declare the
// same properties. One application that extended it is enough to make the class a
// real finding, because then the applications disagree -- and the disagreement is
// the answer to "have I built this before".
IsTemplate = uses.TrueForAll(u => SecurityContract.IsCarriedBy(u.Entity))
&& properties.All(p => p.Applications.Count == uses.Count),

Contracts = [.. uses
.SelectMany(u => SecurityContract.CarriedBy(u.Entity))
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.Ordinal)],
});
}

return results
.OrderByDescending(r => r.In.Count)
// Templates last: they are context, not findings, and the findings have to lead.
.OrderBy(r => r.IsTemplate)
.ThenByDescending(r => r.In.Count)
.ThenByDescending(r => r.Properties.Count)
.ThenBy(r => r.ClassName, StringComparer.OrdinalIgnoreCase)
.ToList();
Expand Down Expand Up @@ -409,25 +435,40 @@
// Names that keep coming back
// ------------------------------------------------------------------

private static List<RecurringProperty> Conventions(IReadOnlyList<WikiApplication> apps)
/// <param name="apps">The applications, in the order they should be shown.</param>
/// <param name="templates">
/// Classes the framework supplied rather than the author, whose properties are its vocabulary
/// and not the author own.
/// </param>
private static List<RecurringProperty> Conventions(
IReadOnlyList<WikiApplication> apps,
HashSet<string> templates)
{
var byName = new Dictionary<string, List<(WikiApplication App, ExtractedProperty Property)>>(StringComparer.Ordinal);

foreach (var app in apps)
{
foreach (var entity in app.Project.Entities)
{
// A class that was not counted as modelled twice cannot supply words that were.
// Without this, two applications sharing nothing but the wizard scaffold are told
// that LoginProviderName and ProviderUserKey are house vocabulary -- the same
// false claim as the classes card, two headings further down. A scaffold somebody
// extended is not in this set, so the properties they added still count.
if (templates.Contains(entity.ClassName))
continue;

foreach (var property in Declared(entity))
{
if (string.IsNullOrWhiteSpace(property.Name))
continue;

if (!byName.TryGetValue(property.Name, out var list))
byName[property.Name] = list = [];

list.Add((app, property));
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.
}

var results = new List<RecurringProperty>();
Expand Down
12 changes: 9 additions & 3 deletions src/XafLogicExplainer.Core/Wiki/CorpusGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ public static OverlapGrid Overlap(WikiCorpus corpus)
{
ArgumentNullException.ThrowIfNull(corpus);

// Excluded from the diagonal as well as from the pairs. A grid whose totals counted the
// scaffold and whose overlaps did not would let a pair read as less alike than it is.
var templates = corpus.Templates
.Select(r => r.ClassName)
.ToHashSet(StringComparer.Ordinal);

var names = corpus.Applications
.Select(a => a.Project.Entities
.Select(e => e.ClassName)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Where(n => !string.IsNullOrWhiteSpace(n) && !templates.Contains(n))
.ToHashSet(StringComparer.Ordinal))
.ToList();

Expand Down Expand Up @@ -89,7 +95,7 @@ public static CorpusMap Map(WikiCorpus corpus, double width = 940, double height

var count = corpus.Applications.Count;

if (count < 2 || corpus.RecurringEntities.Count == 0)
if (count < 2 || corpus.ModelledTwiceCount == 0)
return new CorpusMap { Width = width, Height = height };

var centreX = width / 2;
Expand Down Expand Up @@ -125,7 +131,7 @@ public static CorpusMap Map(WikiCorpus corpus, double width = 940, double height
var classes = new List<MapClass>();
var links = new List<MapLink>();

foreach (var recurring in corpus.RecurringEntities)
foreach (var recurring in corpus.ModelledTwice)
{
var slugs = recurring.In.Select(s => s.Slug).Distinct(StringComparer.Ordinal).ToList();

Expand Down
67 changes: 67 additions & 0 deletions src/XafLogicExplainer.Core/Wiki/SecurityContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using XafLogicExplainer.Core.Models;

namespace XafLogicExplainer.Core.Wiki;

/// <summary>
/// The DevExpress security contracts a class can carry, and why carrying one matters to a corpus.
/// </summary>
/// <remarks>
/// The rule for what counts as <em>yours</em> is deliberately not a list of DevExpress class
/// names: a class is yours when its own source was read in one of the projects. That rule is
/// right, and it is exactly why the XAF Project Wizard scaffold slips through it. The wizard
/// writes <c>ApplicationUser</c> and <c>ApplicationUserLoginInfo</c> into
/// <c>SolutionName.Module\BusinessObjects\</c> of every solution created with v21.1 or later, so
/// their source <em>is</em> read -- and any two XAF applications built since 2021 look like they
/// share them. On a corpus of small applications that scaffold can be most of what the page says
/// was reused, and the first number a reader sees is the one it inflates.
/// </remarks>
internal static class SecurityContract
{
/// <summary>
/// The interfaces that make a class the security system user, or its login information.
/// </summary>
/// <remarks>
/// This is a list of names, and there is no honest way to say otherwise. It is a different
/// kind of list from the one the wiki refuses to keep: framework API surface that DevExpress
/// documents and cannot rename without breaking every application implementing it, rather
/// than the class names one template happened to emit this year. A developer who renames
/// <c>ApplicationUser</c> to <c>Usuario</c> is still caught; a future template that picks new
/// class names is still caught.
/// </remarks>
private static readonly HashSet<string> Names = new(StringComparer.Ordinal)
{
"ISecurityUser",
"ISecurityUserWithLoginInfo",
"ISecurityUserLoginInfo",
"ISecurityUserLockout",
"IOAuthSecurityUser",
"IAuthenticationStandardUser",
"IAuthenticationActiveDirectoryUser",
"IPermissionPolicyUser",
};

/// <summary>
/// Whether the class names one of those contracts in its base list.
/// </summary>
/// <remarks>
/// Carrying a contract is never enough on its own to call something template code, which is
/// why this only answers half the question. A developer who added <c>Department</c> and
/// <c>Photo</c> to <c>ApplicationUser</c> in three applications really has built something,
/// and the documented <c>Employee : Person, ISecurityUser, ...</c> shape is a business object
/// that happens to log in. The shapes have to agree as well -- see
/// <see cref="RecurringEntity.IsTemplate"/>, and the wording it earns on the page, which
/// claims only what was read and never that a wizard typed it.
/// </remarks>
public static bool IsCarriedBy(ExtractedEntity entity) => CarriedBy(entity).Count > 0;

/// <summary>
/// The contracts the class names, in the order it names them.
/// </summary>
/// <remarks>
/// Returned rather than merely counted so the page can print the evidence beside the claim.
/// A card that says "framework" and nothing else asks to be trusted; one that says which
/// contract the class carries can be checked against the file it cites.
/// </remarks>
public static IReadOnlyList<string> CarriedBy(ExtractedEntity entity) =>
entity is null ? [] : [.. entity.BaseTypes.Where(Names.Contains)];
}
Loading
Loading