All notable changes to this project are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
0.17.0 — 2026-08-24
The question one application cannot answer.
Everything here so far reads one XAF application and explains it. That is the right unit for inheriting a codebase, and it is the wrong unit for the person who wrote forty of them. Someone who has delivered XAF to clients for ten years has a question no single-application tool can be asked: have I built this before? Somewhere back there is the class about to be modelled again — not a similar one, the same one, thought through properly, with the two properties this time will forget.
xaflogic wiki reads every configured project into one page and computes what they have in common.
There is nowhere in that page to type a sentence about the collection, which is deliberate: a
hand-written summary of nine applications is wrong the day the tenth is added, and nobody notices.
Run over six real applications — 405 entities, 111 controllers — it produced two things worth the
release on their own. First, the same property name meaning two different scalar shapes: Total a
decimal in one application and a double in another, and the same for UnitPrice, Cantidad,
Descuento and Latitude. Nothing is broken and everything compiles; it is how a total ends up two
cents out. Second, zero shared base classes across all six — every application rebuilt from XPO
primitives. That zero is not an empty result, it is the finding, and the page says so rather than
showing a heading over nothing.
Two of the quality decisions in it came from running against those applications rather than against
fixtures, because fixtures agree with whatever the code already does. Double and double are one
type, and reporting them as a disagreement is a false accusation — a tool that makes one stops being
believed about the true ones. And a name holding a different collection in each entity is vocabulary
rather than a conflict; leaving those in buried decimal against double under seven rows of
XPCollection<T>. A third came from opening the page instead of reading the diff: the comparison
table silently cropped its last column, which is the failure that matters, because a reader would
have believed the columns they could see.
It is also the first release with pictures that are arithmetic rather than decoration. The map places each shared class at the average direction of the applications that model it, at a distance set by how much they agree, so the middle of the picture means everybody models this and the rim means this belongs to one client — and that reads before the caption does. A diagram is believed faster than a sentence and checked less, which is why the placement rules are asserted rather than eyeballed, and why the same corpus draws the same picture every run.
The release closes with the same correction turned on the tool itself. Four generators carried a
version number as a default — the explainer stamped 0.10.1 six releases after 0.10.1 shipped, and
the writer of AGENTS.md stamped 0.9.0 eight releases on. Nothing failed, because a default every
caller overrides is a default nobody rereads. They now say of unknown version, which cannot go
stale, and a test refuses any generator default that looks like a version.
xaflogic wiki— reads every configured project into one self-contained HTML page and computes what they have in common. A single-application explainer answers "how does this work"; this answers a question that cannot be asked of one application at a time: have I built this before?- Classes modelled more than once, with a property-by-property comparison and a column per
application, so the richest version of
Clienteis the one you open before writing it again. - The layer you wrote yourself — base classes carried between applications. A base type is listed only when its own source was read in one of the projects, so no list of DevExpress type names is involved and nothing here goes stale when DevExpress renames something.
- The same name, two shapes — where a name means one scalar type in one application and
another elsewhere.
Doubleanddoubleare one type and are never reported as a disagreement; neither is a nullable annotation on a reference type. A name holding a different collection per entity is vocabulary rather than a conflict, and is listed as such. - Names you keep, modules more than one application requires, per-application detail, and a filter that shows only what touches one project.
- Search across every application, one file, no network requests.
- Classes modelled more than once, with a property-by-property comparison and a column per
application, so the richest version of
- Three pictures of the corpus, computed rather than drawn, in the same one file.
- A map of the applications on a ring with every shared class placed between the ones that model it — at the average direction of those applications, at a distance set by how much they agree, so a class every application has falls to the centre. Hover an application to isolate what it shares; hover a class to see who has it; click a class to land on its comparison.
- An overlap grid: how many class names each pair of applications both model, with the diagonal held out of the colour scale so one large project cannot wash out every real overlap. Click a cell to hold the whole page to just those two applications.
- A version strip: the DevExpress releases the estate is spread across, spaced ordinally because nine years between two releases draws as one dot and a gap, with the release the framework catalog actually describes marked on it.
- Layout is deterministic: the same corpus draws the same picture every run, because a diagram that moves between two runs cannot be used to compare them.
- Three sample client modules (
ClinicaSolution,TallerSolution,FerreteriaSolution) that disagree the way client work disagrees — each with its own copy of the same audit base, all three modellingClienteandFactura, andFactura.Totaladecimalin two of them and adoublein the third. They exercise extraction, analysis and rendering end to end, and they are what the map in the README is drawn from, so nothing in the documentation is a mock-up. - Source citations on every entity and controller in a wiki, so an entry can always tell you which file to open.
xaflogic projects addno longer requires--resource-name. It names a resource in PeopleWorks Copilot, which is one publishing target among several and irrelevant towiki,explain,agentsandmcp— all of which read the configured project list and write locally. Requiring it made the multi-project list unreachable without an account somewhere. It now defaults to the profile name.
- Generators no longer default to a version number.
HtmlExplainerGeneratordefaulted to0.10.1six releases after 0.10.1 shipped, andAgentContextGeneratorandAgentFilesSinkto0.9.0eight releases on. Every caller passes the real version, which is exactly why nobody reread the default — and the one page it would ever stamp is a page whose footer then names a release that did not generate it. They now sayof unknown version, and a test rejects any generator default shaped like a version.
0.16.0 — 2026-08-23
What we cannot see, said out loud.
Every release so far made the same kind of promise: here is something in your application you could not see from the code. This one keeps that promise — reports are read now, the largest gap left in the extraction — and then does the opposite thing, which turns out to matter more.
An XAF application's reports are frequently not in its repository at all. With ReportsModuleV2
registered, users design reports at run time and they are stored as rows in a database, out of reach
of anything that reads source. An application with forty reports and none in its code is not an edge
case; it is what a reporting setup looks like once people use it. So the answer had to stop being a
list and start being three different sentences: the list is all of them, the list is a lower
bound, or the number is unknown rather than zero — and that last one is the common case, the
one where a tool that answers confidently does real damage. An agent told an application has no
reports designs as though none can exist.
The same correction landed on the framework catalog, where it had been wrong for three releases. The newest catalog on the machine was used for every application whatever version it targeted, and the result said "these controllers load onto this screen" in one confident sentence either way. On a machine holding a single 26.1 catalog that sentence was produced for a 23.2 application and for a 17.1 one, unqualified. It now asks for the release the application declares, still falls back when that catalog is absent, and says which wherever a framework fact is reported.
Both halves of the reports work are a collaboration: @MBrekhof extracted them, this side rendered
them and wrote the bound. Two things only the generated document could show were caught that way —
a citation printing an absolute path from the machine that ran the extraction, and one layout's
filter and GetCriteria() printed twice when two registrations shared it.
Five spellings of a DevExpress version are read now, because seven real applications were checked
instead of fixtures: PackageReference, a floating 25.2.*-*, an MSBuild property (what
DevExpress's current template generates), and a pre-NuGet assembly reference from 17.1. Three of
those seven module folders hold more than one .csproj, which had been resolved by whatever order
the file system returned.
457 tests, zero warnings.
-
The reports are written down, and the list says what it is (#37, the rendering half). They now appear in the Markdown pages, in
AGENTS.md, and through a newxaf_reportsMCP tool — each with what it is over, the filter inside its layout, its calculated fields and bound expressions, and the parameters dialog it opens with, including theGetCriteria()that turns the answers into a filter.The sentence under the heading carries more than the list. With
ReportsModuleV2registered, users design reports at run time and those are stored as database rows, so an application with forty of them and none in its repository is the ordinary case: checked against a production application that registers the module, setsReportStoreMode.XML, and contains no report in source at all. Printing "no reports" there is not an incomplete answer but a wrong one, and the more use an application makes of reports the wronger it gets. Three states are told apart — the module absent and the list complete, the module present and the list a lower bound, and the module present with nothing in source, where the number is reported as unknown rather than zero. An application that registers nothing and ships no layout gets no section, because there "no reports" is the default rather than a finding.Two things the generated document showed that reading the code would not have. A layout kept beside the module rather than inside it was cited by its absolute path — the drive of whichever machine ran the extraction, in a file meant to be committed; citations now fall back to the solution root and then to the file name, never to a path that is wrong everywhere but here. And two registrations sharing one
.repxprinted the filter, the bindings and the whole ofGetCriteria()twice, burying the only thing that differs between them; the second now points at the first. -
The reports an application declares are read (#37, phases 1–3 — the extraction; the Markdown and MCP rendering follow separately). Reports V2 leaves four kinds of trace in a repository, all syntax, and none of them was read: the registration (
PredefinedReportsUpdater.AddPredefinedReport<T>, in the sameGetModuleUpdatersthe updater already lives in), the layout (designer code, a.repx, or a constructor), and the parameters dialog (ReportParametersObjectBase, whoseGetCriteria()is business logic in the plainest sense).ExtractedProject.Reportsnow carries each registration with what the call says and nothing it does not —IsInplaceReportis null when the overload is silent — plus the layout's filter, bound expressions, grouping, calculated fields and parameters, and the dialog's fields and criteria source.ReferencesReportsModuleis the flag the rendering needs for the sentence that makes the list safe to believe: withReportsModuleV2in, users build reports at run time that live only in the database, and the list is a lower bound. Three things the real files taught, each pinned by the newReportsSolutionfixture: a.repxparameter's type resolves through<ObjectStorage>; a layout exported from the running application keeps its data source there too; and a shop that designs reports outside Visual Studio keeps the exports beside the module, registered by nothing — so every.repxin the solution is read, and the unclaimed ones are listed as unregistered rather than skipped. Checked against three licensed applications, one of which registers through reflection over a catalog: a syntactic reader correctly sees zero registrations there, the module referenced, and the fourteen layouts and two parameters objects it would otherwise have hidden.
-
The framework catalog is chosen by the version the application declares, not by file date.
LoadLatest()took the newest catalog on the machine whatever the application targeted, and the output then reported which framework controllers load onto a screen in the same confident sentence either way. On a machine holding a single 26.1 catalog, that sentence was produced for a 23.2 application and for a 17.1 one — three releases and nine years out — with nothing to tell the reader. Reported by the external review at 0.12.0 and live until now.Extraction now reads the DevExpress
major.minorfrom the project file and asks for that release's catalog. When it is not on the machine the newest is still used — most of the framework is stable across releases, and withholding it would trade real information for a small error — but the difference is now stated wherever a framework fact is reported: the Markdown and HTML pages, theAGENTS.mdheader, and the MCP view detail.Both project-file spellings are read. A pre-NuGet XAF project has no
PackageReferenceat all and names its version only inside the assembly reference (DevExpress.ExpressApp.Xpo.v17.1) — which is exactly the case where the mismatch is widest, so reading only the modern spelling would have missed the applications that need this most.Two further spellings were found by running this against seven real applications rather than against fixtures, and both returned "declares nothing" until they were handled. A version written as an MSBuild property —
<DevExpressVersion>25.2.7</DevExpressVersion>withVersion="$(DevExpressVersion)"— is what DevExpress's current template generates, so it is not an exotic case to tolerate but what a project created today looks like; properties a project file sets are now resolved before anything reads a version, which also stops$(DevExpressVersion)reaching the rendered package list. A floating25.2.*-*already worked and is now pinned. -
The project file a module folder is named for is the one that is read. With several
.csprojside by side — a.Net10.csprojfrom a framework migration, a hand-made" - Backup.Module.csproj"— extraction took whichever the file system returned first, so the target framework, the package list and the DevExpress version could come from a backup, and two machines could describe one repository differently with nothing in the output to say why. Three of the seven applications checked have more than one.Verified end to end against applications declaring 17.1, 23.2, 25.1, 25.2 and 26.1 — including every case above, and one whose version matches the installed catalog, where the new sentence correctly says nothing at all.
0.15.0 — 2026-08-23
How it works, not only what exists.
Every release until now answered the same shape of question. What entities does this application have. What does this controller do. What does that rule forbid. All of it true, all of it declarations — and a business process is not a declaration. It is a path across several of them, and nothing in the extractor walked a path.
xaflogic walkthrough --from ApproveOrder walks it. The scope is computed, by a bounded
breadth-first traversal, and not chosen by a language model — because a model asked what belongs in
"the approval process" answers authoritatively, in a form nobody can check, and is wrong in the
places that look exactly like the places it is right. The Mermaid diagram is emitted from that
traversal arrow for arrow, for the same reason and more sharply: a diagram is believed at a glance,
so an invented edge in one is worth less than no diagram at all.
The two halves that make it honest are the ones that took the most care. A call the walk cannot
follow is printed rather than dropped — a virtual method is followed to the declaration written
beside the call and reported with every override that may replace it, and the document says outright
that the bodies it never entered mean entities missing from the account. And --since reports what
changed in that one process against a stored snapshot, which is the question no conversational
agent can answer, because none of them has a yesterday.
--narrate is opt-in and, deliberately, the least load-bearing thing here. The model receives the
numbered steps and the code behind them; the only prose that reaches a reader is a paragraph it
managed to key to a step that exists. A document generated with an empty narration is
byte-for-byte the one generated with none.
Also in this release: every declaration now says where it is, file:line, in the extraction and in
the MCP tools — the foundation the walkthrough needed and worth having on its own, since the tools
used to hand an agent a name and leave it to search for it. Four appearance-rule defects, two of
them found by @MBrekhof. Any model can now answer, and a key is enough.
And the Markdown we generate is Markdown, which took two goes. A seed method's source was wrapped in
a <details> fold that collapses on GitHub and nowhere else (#28). Then, running the output
through a real Word converter, @MBrekhof found the second half of the same defect: a generic base
type was written bare, and <DetailView> is an inline HTML tag rather than a block, so an export
drops it and github.com's sanitizer strips it — every controller read as deriving from plain
ViewController (#36, fixed in #39). Type names are written as code now, and the guard was
widened from lines that open with < to a tag anywhere on a line. The route from an extraction to
a Word document is written down in the README, and there is deliberately no exporter here: reading
an XAF application needs no DevExpress, and that stays true.
402 tests, zero warnings.
-
Entities, controllers, actions and methods say where they are declared (#23, first step). The extraction knew which file a class was in and nothing narrower, so
xaf_entityandxaf_controllerhanded an agent a name and left it to search the file for it. Each now carries a one-based line, taken from the identifier token rather than from the declaration's span — a span begins at the first attribute, andCustomersits behind a doc comment and four of them, so the two answers are four lines apart and only one of them is the line anybody means. Actions and methods carry their own file as well as their own line, because a partial controller's members need not be declared in the file the controller is cited at. The MCP tools name the file once and then cite members by line alone; a member in a different file is given in full, which is exactly the case where a reader would otherwise open the wrong one. Prerequisite for the walkthrough, where every claim is supposed to carry afile:linea reader can check. -
The walk that decides what belongs to one process (#23, phase 1).
ProcessSlice.Fromtakes an action, a controller method, a controller or an entity by name and walks outward from it — breadth-first, bounded by depth, syntax-only like everything else here. It follows an action to the handler it runs, a method to the methods it calls and the entities it names, a controller to the entities it is activated for, and an entity to the rules that govern it. Every node carries the file and line a reader can open. The scope is computed rather than asked for, and that is the governing decision: a model asked what belongs in "the approval process" answers authoritatively, in a form nobody can check, and is wrong in the places that look exactly like the places it is right. A call it cannot follow is printed, not dropped. A virtual declaration is followed to what is written beside the call, and reported with every override that may replace it — which is honest about the consequence, because the bodies that were not entered mean entities missing from the slice. So is the depth bound: a walk that ran out of things to reach is a whole process, and a walk that stopped at its limit is a view of one, and rendering them identically is how a document claims completeness it does not have. Methods now recordvirtual/abstractandoverride, which is what makes that distinction possible at all. Nothing consumes the slice yet — the Mermaid diagram, the document, the CLI command and the MCP tool are phase 2. -
xaflogic walkthroughandxaf_walkthrough(#23, phase 2). The slice becomes a document, and the feature is now usable: a Mermaid diagram, everything that takes part with the place it is declared, the ordered steps each citing afile:line, the calls the walk could not follow, and what the walk deliberately is not. In both languages, offline, with no API key and no network. The diagram is emitted from the walk's own edge set — node for node, arrow for arrow. Nothing decides what to draw. Ask a model for a Mermaid diagram of a process and it will produce one, including edges that do not exist, drawn with a confidence indistinguishable from the true ones, in a format whose whole value is that a reader believes it at a glance. A test counts the arrows against the edges, because an invented one is exactly what a spot check of a diagram that looks right would miss.xaf_walkthroughis the first MCP tool that answers a question about a process. The other ten return atoms, so an agent asked how something works has to guess which atoms to fetch and then guess whether it has them all — and the guess that stops one atom early produces a confident answer with a step missing from it. -
xaflogic walkthrough --narrate(#23, phase 3). Opt-in prose over a walk that has already been computed: one paragraph on what the process is for, and a sentence or two under each step, each sitting directly beneath the citation it belongs to. The model narrates; it does not discover. It receives the numbered steps and the code behind them, and the only thing that reaches a reader is a paragraph it managed to key to a step that exists — a paragraph keyed to step 99 of a nine-step process is dropped before rendering, and so is fluent prose attached to no step at all. The point is not that such a sentence would probably be wrong; it is that nobody could check it, and an ordinary reader cannot tell a fluent sentence about real code from a fluent sentence about code that is not there. The model is also told what the walk could not follow, so it does not narrate its way over the one gap the analysis already knows about. Failure costs prose and not the document: no key, or a provider that does not answer, prints why and writes the walkthrough anyway. Phases 1 and 2 stand entirely on their own, which is what makes the model optional rather than load-bearing — and a test pins that a document generated with an empty narration is byte-for-byte the one generated with none.XafLogicExplainer.Corestill references nothing but Roslyn: narration arrives at the generator as plain text keyed to steps that already exist. -
xaflogic walkthrough --since(#23, phase 4 — the last one). Re-walks the same process over a stored snapshot and reports what is different about this process: a step added, a rule now governing it, a branch gone, a body rewritten, and a call the trace can no longer follow. Reads the same_Previous.jsonthatxaflogic diffdoes, or any snapshot given by path. This is the part no conversational agent can imitate, because none of them has a yesterday. Asked what changed in the commission calculation since the last release, a model can only re-read today's code and describe it fluently. Comparing two walks by their node sets alone would have missed the most ordinary change there is — somebody edits a method body and leaves every call in it alone — so each node now carries a fingerprint of its own substance: a method's body, a rule's condition and effect, an action's caption and criteria. Whitespace is collapsed first, so reformatting is not reported as a change of behaviour. A controller and an entity have no fingerprint; what matters about them is elsewhere in the slice, and giving them one would report the same edit twice. Three states that had to stay distinct: the process is unchanged, the process did not exist at the snapshot, and no snapshot could be read — the last one stops the run rather than writing a document with the section missing, because an absent section reads exactly like "nothing changed".
-
An appearance rule keeps its condition however the attribute was written (#22, reported by @MBrekhof).
AppearanceAttributehas three constructors and two pass the criteria by position —(id, criteria)and(id, appearanceItemType, criteria). Only the named form was read, so a rule written either of the other two ways was extracted with no condition and then documented as applying always, which is the strongest claim a rule can make, printed about rules whose whole purpose is a condition. The criteria is the last positional argument in both overloads that carry one, so both are read without knowing which constructor was called; a named argument still wins. The expression index — the page that introduces itself as every distinct expression in the application — heals on its own, because it already gathered appearance criteria and there were none to gather. -
A rule over an Action is no longer called a field (#22).
AppearanceItemTypewas never read, so a rule disabling theDeleteaction was documented as governing a column called Delete — a confident sentence about something that does not exist. It is written two ways in real code: positionally as the enum, and by name as a string, which is the form the DevExpress examples use; both are read and normalised to one value. Absent means the XAF default,ViewItem, and is recorded as absent so a rule that said nothing can be told from one that saidViewItem. The Markdown now says actions or layout items where it applies, the MCP tool saysDelete (actions), and both diffs treat the item type as part of a rule's identity — a rule repointed from a column to an action of the same name changes nothing else. -
New fixture,
AppearanceSolution, whose rules are written every way the attribute allows. Every existing fixture wroteCriteria =named, which is the whole reason the suite agreed. 402 tests. -
An appearance rule written on a property is read (#21, thanks @MBrekhof).
AppearanceAttributeis usable on a class or on a property, and the documentation teaches the property form first: a rule onUnitPriceand a rule on the class namingTargetItems = "UnitPrice"are two spellings of one rule. Only the class spelling was read, so the other produced nothing at all — and an entity's section is presented as its complete inventory, so a rule governing a property and reported nowhere left the reader concluding the property was unconditionally editable. A property rule that names noTargetItemsof its own now records the property it was written on, which is what the class spelling states outright; an explicitTargetItemsis left alone. Measured on a 196-entity application: 25 rules to 33. -
Two unnamed appearance rules stay two rules through the fold (#21, thanks @MBrekhof). The fold keyed them on
Idalone, so an empty identifier made them one rule and the second was dropped in silence. An empty identifier is ordinary rather than an omission — a rule written on a property already says what it governs. -
An appearance rule with no name, or no criteria, is rendered as what it is. The Markdown and the MCP tool printed
- **** — when ``:— an empty bold span where the identifier goes, and a condition that reads as though it failed to load. In XAF a rule that declares no criteria is permanently active, which is the stronger of the two claims; the HTML explainer had already settled onalwaysand the other two never received it. Reachable before rules were read off properties — one unnamed rule per class is enough — and made ordinary by reading them. -
The diff reports appearance rules that were added, removed, edited or repurposed. It keyed them on
Idalone and collected them into a set, which failed three ways from the one key: every unnamed rule in an application collapsed into a single entry, so adding or removing one reported no change; a rewritten criteria kept its identifier, so the edit reported nothing; and a rule changed from disabling a field to hiding it reported nothing either, which is the whole of what an appearance rule does. Probed at the time: an application went from two declared rules to three and the diff reported zero changes.
-
Any model will do, and a key is enough (#24). Every AI feature reached PeopleWorks Copilot for its credentials — not for a key the user had configured, but for the model's key, fetched from an account. On a public MIT project that meant no outside user could run any of them:
--enrichrefused without an API URL and token and told the reader to configure credentials for a service they had never heard of, and the Description Annotator asked forCOPILOT_API_TOKEN. There is now one resolver shared by both, taking the first route that is configured:--api-keyon the command line, thenOPENAI_API_KEYorANTHROPIC_API_KEYin the environment, then a PeopleWorks Copilot account — which still works untouched, as one option among several rather than the gate.--ai-base-urlreaches any OpenAI-compatible endpoint, including a local one, and--ai-modelnames the model. Someone with none of them configured is now told all four. A key is never read from or written to the configuration file: the endpoint and the model name are settings, a key is a secret, and that file lives in a home directory that gets copied around. -
The Markdown we generate is Markdown (#28). A seed method's source was wrapped in a
<details>fold. That collapses on GitHub and nowhere else: in a Word or PDF export, in a plain Markdown viewer, and to a model reading the file, the wrapper is literal text and the fold's label — "Source code of PopulateStatuses" — stops being a label and becomes a line of markup. It is now a heading, which survives the trip and takes its place in the document outline. The fold is lost on GitHub; these files are read far more often than they are scrolled. Found by walking the output against the Markdig converter in mcpOffice, whose documented behaviour for an HTML block is to emit it as plain text — every other construct we write already maps to a real Word equivalent, so this one call site was the whole distance between an extraction and a document somebody can hand over.
-
First tests over
ProjectDiffEngine, which is why the key above survived. -
Every sample project's Markdown is now checked, in both languages, for a line that opens raw HTML outside a code fence.
-
Citations are checked by reading the fixture back off disk: the cited line must really contain the declaration, across every sample project. It is the only assertion that catches an off-by-one or a span that starts at an attribute.
-
New fixture,
WalkthroughSolution, whose proportions are its point: one action, one handler, and aRecalculatethat two controllers override — so a walk reporting only what it can resolve produces a confident, complete-looking account of a process whose body it never saw. No existing fixture could reach that case. 391 tests.
0.14.0 — 2026-08-16
Everything that governs an entity, under the entity.
0.13.0 gave each entity the columns it persists and stopped one door short. What is written on a
property travelled with the property — a folded Number row correctly said required — and what is
written on the class did not. So an entity's section could say a column was required and, three
headings later, document no rule requiring it: the two halves of the same page disagreeing, with
the property half telling the truth.
The rules were never missing from the application, only from the place a reader looks. A
RuleCriteria on an audit base is enforced every time anything in the application is saved. An
[Appearance] greys a field on every screen below it. An association gives every descendant a
collection that really is populated. All three were documented under the base alone, which on a
real application means documented nowhere anybody reads.
The other half of #14 was filed as genuinely debatable, and it turned out to be a question about scale rather than about relationships: an index, a count, a diagram and a search are answering what does this application declare, while an entity's section is answering what governs this entity. Following the fold everywhere would have made every total a measurement of the class hierarchy — one rule on a base shared by two hundred entities reported two hundred times. So each folded declaration carries the class that wrote it, and each rendering chooses.
A minor rather than a patch: ExtractedValidationRule gains Id and Contexts, all three
declaration types gain InheritedFrom and Clone(). Additive, and invisible to the CLI and the
MCP server.
-
A rule a class inherits is now listed under the class that inherits it (#14). Folding carried what lives on a property — the folded
Numberrow correctly said required — and left everything recorded on the class behind. ARuleCriteriaon 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 asCustomMessageTemplate =, so 299 tests agreed with the wrong answer. A rule now also carries its identifier and its validation contexts, which were read asarg0andarg1and printed to the published documentation that way.
- 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.
-
The workflows run current actions.
actions/checkoutv4 → v7,actions/setup-dotnetv4 → v6,github/codeql-actionv3 → v4. Every run had been annotating that the first two target Node 20 and were being forced onto Node 24; the third carries a deprecation dated December 2026. The jobs that would have broken first arenuget.ymlandmcp-registry.yml, which nothing exercises until a release is being published. -
docs/RELEASING.mdrecords how a publish is verified. Install with--tool-path, so verifying cannot leave you on a version you did not choose to run, and read the nuget.org index twice: it is cached per CDN edge, and during the 0.13.0 verification two requests seconds apart returned0.12.1and0.13.0for the same package.
0.13.0 — 2026-08-14
The entities an application actually has, and all of the columns they actually persist.
Every fix here came from outside. @MBrekhof read the code before filing, separated the reports by cause rather than by symptom, and kept finding the next one in the review of the last — three issues and five pull requests, each of which turned out to be a different way of asking the same question: what is this tool entitled to call an entity, and what is it entitled to leave out.
The number that measures it, against the demos DevExpress ships with 26.1 rather than against our
own fixtures: FeatureCenter.NET.XPO 43 → 140 entities, MainDemo.NET.XPO 14 → 17,
OutlookInspiredDemo.NET.EFCore 23 → 24. Anyone evaluating this tool by pointing it at
FeatureCenter was seeing under a third of it — under an AGENTS.md telling their agent the
inventory was complete. That shape is the one thing this project exists to prevent, and it was
happening in the place a newcomer was most likely to look.
A minor rather than a patch: OrmType.Unknown is a new member on a public enum and
ExtractionOptions.BaseTypeNames changed its default. Neither affects the CLI or the MCP server;
both are breaking for code calling XafLogicExplainer.Core directly.
-
OrmType.Unknownis a new member of a public enum, returned whenever no evidence names an ORM. Anyone consumingXafLogicExplainer.Coredirectly and switching exhaustively overOrmTypehas a new case to handle; anyone using the CLI or the MCP server has nothing to do. The same noteIControllerAnalyzer.AnalyzeControllerFilegot in 0.12.0, for the same reason — on 0.x this is what a minor is for. -
ExtractionOptions.BaseTypeNamesis the single source of the list. The CLI, the MCP server and the test harness each passed their own copy, so the default inCorewas four names while every caller passed five — four copies with three chances to disagree about what an entity is. The callers now use the default.
-
Unknownnow reaches every place the ORM is reported. The agent files learned it; the HTML explainer and the MCPxaf_overviewkept deciding in a binary with no third answer, so a project whose ORM could not be determined was reported as XPO by both. The MCP one was the worse of the two: it prints the ORM two lines above "These lists are complete, not sampled", from a tool whose description tells the agent that anything absent does not exist in the application. All three now go through oneOrmhelper — the defect was never the wrong answer, it was that three places were each entitled to one. -
The ORM is read as syntax, and is
Unknownwhen nothing says. Detection scanned raw file text forDevExpress.Persistent.BaseImpl.EFand fell through to XPO, so an EF Core application whose entities do not use the DevExpress EF base implementation — a legacy schema, its security tables in another project — was reported as XPO. That is not a hole in the document: ground rule 1 then tells the agent thatDbContext,DbSet<T>and EF migrations "do not exist in this application and must never be suggested", which forbids the only correct answer. Signals are now ranked by what it costs to be wrong about them — aDbSet<T>registered on a context first, thenusingdirectives and base classes — and where neither ORM leaves a trace, the rule is omitted rather than guessed. Reading text also counted a mention: a comment naming the namespace was enough, which is how the fixture for this fix first passed against the old code. -
Entities are found through a base class the project wrote itself. Classification matched a class's own base list against the root names and stopped there, so an application with a shared base — auditing, a key convention, a display-name property — lost every business object below it. The inversion is what makes it severe: the abstract base is matched, so the inventory reported the one class that is not a table and omitted the ones that are. Selection now repeats until a round changes nothing, exactly as
SelectControllersdoes, and resolves a base name through the deriving file's own scope rather than by simple name, so aContracts.Orderbeside aBusinessObjects.Orderstill resolves to the base it actually named. On the demos shipped with 26.1:FeatureCenter.NET.XPO43 → 140 entities,MainDemo.NET.XPO14 → 17, andOutlookInspiredDemo.NET.EFCore23 → 24 — the last of which is an EF Core application, where an entity that is not registered as aDbSet<T>had no fallback either. -
PersistentBaseandXPBaseObjectare recognised as persistent bases. The XPO hierarchy isPersistentBase→XPBaseObject→XPCustomObject→XPObject, withXPLiteObjectalso underXPBaseObject. The list held the three leaves and neither of the classes above them, so a hole sat in the middle of a documented API — DevExpress names all five as bases a persistent class may derive from, and recommendsPersistentBase. Deriving from the higher bases is what you do when the table brings its own key, which is the same population as the legacy schemas the DbSet roster was added for.FeatureCenter.NET.XPOgainsOidGenerator,NoKeyPropertyNamedBaseObjectandLayoutDemoObject. -
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:
PriorityOrderlistedRankand omitted theNameandNumberit 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 anAuditedObjectlost 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.XPOfolds 151 properties over 146 entities,MainDemo.NET.XPO26 over 17 — whereEmployeereachesPhotothroughPersonand is correctly told it comes fromParty.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.
The first release that came from outside. @MBrekhof reported an XAF
application of 221 entities over a legacy LIMS schema, of which this tool found three — and
filed it against the argument the project is built on: a class that is never seen cannot be
reported as missing, and AGENTS.md goes on to tell the agent its inventory is complete. That is
not a gap in a document, it is an agent confidently wrong about which tables exist.
The fix and the three defects found reviewing it are all downstream of one rule, which is worth
saying plainly because it cuts both ways: a name is not an identity. Reading entities from a
roster of bare names finds the classes a base list misses, and then also finds a DTO that merely
shares a name, every half of a partial class, and a type mentioned in a method body.
-
EF Core entities are found by their
DbSet<T>registration, not only by their base class. An application mapped onto an existing schema rarely derives fromBaseObject— the tables bring their own keys, so the project writes its own base class or maps a plain POCO — and every one of those was dropped, silently, whileAGENTS.mdwent on describing its inventory as complete. Only classes declared in the analyzed source qualify, so the framework tables a DbContext also registers (ModuleInfo,FileData,ModelDifference) stay out. On a 221-entity application over a legacy LIMS schema this moves extraction from 3 entities to 210. Thanks to @MBrekhof. -
A
partialclass is one entity, not one per file. Matching by base class could only ever match once, because one part declares the base list; matching by theDbSetroster matches on the name, so every part matched — and the scaffolded split that produces two parts is exactly what the roster is for. The class came out twice, each copy holding half its columns: two incomplete truths with nothing to say they were the same class. The parts are now folded into one entity, which also recovers the members XPO extraction had always dropped where a hand-written part carries: BaseObjectand a generated part carries the mapping. -
The
DbSetroster no longer matches on a bare name. A name is not an identity: an application may keep aContracts.InvoiceDTO beside itsBusinessObjects.Invoiceentity, and the roster turned the DTO into a table. Registrations now carry the namespaces they could have been naming — the registering file's usings, its own namespace, and the namespaces enclosing it — which is ordinary C# lookup, the part of it syntax can see. -
Business object files are read in a fixed order. The directory hands them over in whatever order the file system keeps them, and that is not the same order on two machines: NTFS compares names without case, ext4 by byte, so
Shipment.Generated.cssorts afterShipment.cson one and before it on the other. Extraction is now ordered by path, so a document regenerated on a laptop and in CI can be compared — which is most of what regenerating it is for. -
Only a
DbContext's own properties count as registrations.DbSet<T>written as a local or a parameter is a type name in a method body, not the application declaring a table. Contexts are found through their base chain as well, so an application whose contexts derive from a sharedAuditedDbContextstill registers everything.
0.12.0 — 2026-08-11
What runs when you open this screen.
Most of this release is corrections, and they came from an audit rather than from a bug report: three reviewers on disjoint axes — one against the DevExpress sources, one against the new code, one reading only the generated output and never the generator. The third found what the other two structurally could not, which is the argument for keeping all three.
- The README and the landing page lead with the screens, and every raster figure was retaken
from a report generated by the current code.
site/capture-screenshots.pynow lives in the repository and regenerates the report before shooting it — the previous figures came from an uncommitted script in a scratch directory, and three commits later they showed headings that no longer existed. A figure nothing can regenerate is a claim nothing keeps true. IControllerAnalyzer.AnalyzeControllerFilenow returns a list rather than a single controller, because a file can declare more than one and returning the first silently lost the rest. Breaking for anyone callingCoredirectly;xaflogicand the MCP server are unaffected.
-
The screen inventory. Every view the application has, and the logic XAF loads onto each one. Neither half of that can be read from the repository: the Model Editor stores only the views somebody changed, and the rest are generated at startup from the business classes — so the fourteen-entity demo has 54 views, 54 of which appear in no file. The id rules are the framework's own generators:
{Class}_ListView,{Class}_DetailView,{Class}_LookupListView, and{Class}_{Collection}_ListViewfor every collection.- Activation is a transcription of
ViewController.IsFitToView, condition by condition, and each match records why — so the answer can be checked instead of trusted. - Actions are filtered by their own targeting, which can be narrower than their controller's.
- Immediately found the thing it was built to find: the demo's
CustomizeExpiryEditorControlleris aViewController<DetailView>that names no object type, so it runs on the detail view of all fourteen classes. Its own comment says it customizes "every expiry field". xaf_view(MCP) — call it with no argument for the inventory, or with a view id for the whole picture. AScreenssection in the HTML explainer, and a_Screens.mddetail file beside the other agent documentation.- What it deliberately does not claim:
Active["reason"] = …is set at run time from data, so a controller listed on a view can still switch itself off. This is what XAF loads, and it says so wherever it is reported. - A controller restricted to a
TargetViewIdthat is not a literal is listed apart rather than against every screen — that would invent an appearance on all of them.
- Activation is a transcription of
-
The framework's controllers on each screen, when a catalog is present. Two layers, kept apart everywhere they are reported: what this team wrote gets the full treatment, what XAF provides is named compactly with its official one-line description. On the demo,
Prescription_DetailViewruns 2 of the team's controllers and 32 of the framework's.- Scoped to the modules the application registers, so a WinForms controller never appears on a Blazor screen. The platform module is registered by the application builder and named in no source — the platform project beside the module is the evidence that it is there.
- Only what XAF would actually instantiate: abstract types, generic definitions and
[Obsolete]types are excluded, mirroring the framework's ownIsValidControllerType.WindowControllerdescendants are excluded too — they belong to a window, not a view. - The 164 framework controllers that restrict nothing are recorded once rather than under all 54 screens, where they would bury the ones a reader came for.
- A controller whose targeting the catalog could not determine is left out of both lists. Unknown is not unrestricted.
-
Controller targeting is now read in full. XAF decides where a controller activates with four conditions ANDed together — nesting, view type, object type and view id — and only two of them were being read. All four are now extracted, normalized to the way XAF evaluates them, and reported per controller.
TargetViewIdis split on;, because XAF accepts a list in that one string and compares against each. An id it cannot resolve to a literal is kept as the expression that produced it rather than dropped, since a controller restricted to an unnamed view is still restricted.- Targeting assigned to an action (
someAction.TargetViewId = …) is deliberately not read as the controller's. XAF evaluates the action's own copy separately, to narrow it further inside an already-active controller.
-
The ground-truth catalog now records where every framework controller activates, given the DevExpress source component. Reflection over the assemblies cannot see it: four out of five built-in controllers assign their targeting inside a constructor, which assembly metadata does not carry. The catalog builder reads those constructors with the same syntax analysis the rest of the project uses. On DevExpress 26.1 that is 386 of 386 controllers, up from 5.
xaflogic catalog build --dx-sources <Components/Sources>when they are not beside the assemblies, and the command now states how many controllers it could answer for.- Each entry records whether its targeting came from
sources(complete) orreflection(a lower bound), so nothing downstream can mistake a partial answer for a whole one.
- Inventories that promised to be complete and were not. The worst shape of error this project
can make: a list headed "every expression in this application" reads as authoritative, and a
reader has no way to see what is missing.
- The criteria index drew from four of the six places criteria occur, and called the result
every expression while deduplicating by expression. It now reads all six — including the two
it never touched: the expression a
RuleCriteriaenforces (its own criteria is a different field from the one saying when the rule applies, and only the second was read) and an action'sTargetObjectsCriteria, which on the demo isNot IsDispensed, the condition governing its single operation. On the demo the index went from six expressions to nine. [Indexed(Unique = true)]was captured nowhere. A constraint the user meets as a save that fails, enforced below the application, absent from documents promising every rule.- Classes with
[DefaultClassOptions]and no[NavigationItem]were missing from navigation. They go into XAF'sDefaultgroup — still in the menu, and dropped from an inventory headed "what a user sees in the menu". - Interfaces were not ancestors. XAF's object-type test is
IsAssignableFrom, which an interface satisfies, and DevExpress targets interfaces —ChangePasswordControllertargetsIAuthenticationStandardUser. Following the base class alone made every interface-targeted controller match no view at all, silently. - Collections were filed as calculated properties. An XPO collection is getter-only, so it
satisfied
IsComputedand appeared under derived logic, inviting a reader to treat a persistent relationship as a formula. - Appearance rules were printed without their effect or their screen — "when
OnHand = 0()", a condition with no consequence. Font colour, back colour and the context are now shown. - "9 rules" counted two of the six kinds of rule the page documents. It now names them.
- The criteria index drew from four of the six places criteria occur, and called the result
every expression while deduplicating by expression. It now reads all six — including the two
it never touched: the expression a
- Controllers were being dropped from extraction entirely — the worst shape of failure this
project has, because a controller that is never seen cannot be reported as missing. Two causes,
both silent:
- Only the first controller class in a file was read. Grouping small controllers in one file is ordinary C#; every one after the first vanished.
- A class counted as a controller only if it derived directly from
ViewController,ObjectViewControllerorWindowController. Real XAF code does not look like that: it extends shipped controllers and its own base classes.ArchiveController : DeleteObjectsViewController— the example the README advertises as what the catalog makes possible — was never extracted, soFrameworkBaseTypecould never be set and the feature could not fire. - Discovery now follows base classes to a fixed point, through the application's own classes and the catalog, falling back to the naming convention only when the file imports XAF. A probe with five controller classes across three files reported one; it now reports all of them, and the derived ones inherit their base's targeting.
- Every unit test passed throughout, because they all built their
ExtractedControllerlists by hand. The new tests go through the real pipeline against a fixture written in that shape.
- A controller extending a class this analysis cannot see was reported as unrestricted.
Targeting is inherited, so an unresolvable base means the targeting is unknown — and unknown is
not "runs on every screen". Those controllers are now listed apart, with the base that could not
be resolved, exactly as an unreadable
TargetViewIdalready was. - Abstract controllers were reported as running on screens. XAF registers only what it can instantiate; an abstract base hands its targeting down and activates on nothing itself.
TargetViewIdwas trimmed and XAF does not trim it."A; B"never activates onB, because the entry XAF compares is" B". The untrimmed id is now what gets reported, which is also what makes the typo visible to whoever wrote it.- A controller a registered descendant replaces was still listed on screens. XAF activates only
the most derived controller of an inheritance chain — registering a descendant evicts its base
(
SharedControllersManager.RegisterController). Every screen of a Blazor application therefore listedModificationsControllerandBlazorModificationsController, duplicating every Save action, and credited shipped behaviour to the framework in the one application that replaced it. The survivor now says what it replaces, which is the sentence worth reading. - Targeting was only read from constructor blocks, so three ordinary shapes came out as
"restricts nothing, runs on every screen": an expression-bodied constructor, an assignment through
base., andInitializeComponent— where the XAF designer puts it, which is every migrated application. - An action's targeting written in an object initializer was attributed to its controller. Only
the
action.TargetViewId = …form was guarded, and the initializer form is the one DevExpress documentation uses. - A condition the reader could not understand was treated as no restriction.
TargetViewType = isList ? ViewType.ListView : ViewType.DetailViewwas reported as a confident restriction toDetailView— the last word in the expression — andTargetObjectType = FindTypeInfo(name).Typeas no restriction at all. Both are now recorded as unreadable, which keeps the controller out of the per-screen lists and into the one that says why. - Window controllers were listed on every screen. They belong to a window and have none of the four view conditions, so "unrestricted" put them everywhere.
- The platform project was matched by substring, so
Winery.ModuleorDarwin.Corepulled every WinForms framework controller onto a Blazor application's screens. Whole dotted segments now. - Nested list views were invented for collections that never get one. XAF generates one only
when the collection holds a business class, so a
List<string>produced a fabricated view id — and framework controllers were then reported on a screen that does not exist. ViewController<DetailView>reported no view type at all. Only generic bases with two arguments were read, so the single-argument form — which is how DevExpress documentation writes controllers, and how the demo fixture's ownDispenseControlleris written — lost half its targeting.- Targeting inherited from a base controller was ignored, which reported any controller whose base class does the targeting as running on every view in the application. It is set in a constructor and constructors run base-first, so it is inherited; reading each class in isolation understated 33 controllers in the DevExpress framework alone. Both an application's own base classes and framework ones are now followed.
- The packed MCP server README advertised seven of nine tools, omitting
xaf_editorsandxaf_migrations— and that is the README nuget.org renders and the MCP directories import. The test that keeps the front page honest now covers it too. - Catalog entries sharing a name at two arities overwrote each other. The sources pass matched
declarations to catalog types by bare name, so
ObjectViewControllerandObjectViewController<TView, TObject>were the same key and whichever file was read last won. The concreteObjectViewControllerwas reported as running on every screen instead of on object views; ten controllers were affected. - View identifiers were printed in upper case in the explainer, because they sit in a row
header and row headers are uppercased. XAF view ids are case-sensitive, and they are printed
precisely so someone can go and find one —
PATIENT_PRESCRIPTIONS_LISTVIEWis an id that does not exist. Caught by looking at a screenshot, which is the only place it was visible. - Generated prose that said more than the source proves. Found by reading the output rather than
the code, which is the only way any of these surface:
AGENTS.mdannounced a custom editor as "requested with[EditorAlias(…)]" while the explainer, from the same extraction, said nothing requested it. The alias is what an editor offers, not evidence anything asks for it — and the index is the file read on every request.- "Register the type in
X, as the other 4 are" presentedAdditionalExportedTypesas an obligation. XAF finds business classes declared in a module by itself; that collection is for types it cannot find. - "Controllers live in
…/" named one folder chosen by a coin flip between two. Both are listed now, with which belongs in which. - "existing databases only, from 0.0.0.0" — the guard is
> 0.0.0.0, so the one version named as the lower bound is the one version excluded. - "These ran once … and never again" stated execution history. The source proves the guard, not that any particular database ever passed through it: each runs at most once per database.
- "on any view" for a controller labelled from
TargetObjectTypealone, ignoring the view type it also restricts. - A heading asserting "screens that do not follow their type" over a section whose own rows said nothing requests the editor.
ObjectViewwas read as deriving directly fromView. It derives fromCompositeView, so a controller targetingCompositeViewreaches dashboards as well as list and detail views — checked against the 26.1 sources rather than assumed.
0.11.0 — 2026-08-10
Everything that lives outside the business classes.
-
Custom property and list editors. A property rendered by one does not show the control its type implies, and the business class says nothing about it — the same category of hidden behaviour as the Model Editor. They also live in the platform project (
*.Blazor.Server,*.Win) beside the module, so nobody reading the business objects ever meets them.- Detected from
[PropertyEditor],[ListEditor]and[ViewItem], and from editor base types for the abstract editors a team writes once and never decorates. - Alias constants are resolved across the solution. The attribute reads
CustomEditorAliases.BarcodeScannerPropertyEditor; the reader needs the value XAF matches on, and the constant is declared in the module while the editor sits in the platform project, so a project read on its own resolves nothing. - Client assets are recorded — the JavaScript an editor cannot work without. Behaviour in neither C# nor XML, and the reason a control breaks when somebody renames a file.
- Also finds built-in editors reconfigured at run time through
View.CustomizeViewItemControl<T>(). There is no custom editor class to find: a controller reaches into a built-in editor's component model, leaving no trace on the entity or in the Model Editor. - Surfaced in
AGENTS.mdas a ground rule, in the explainer, and through a newxaf_editorsMCP tool. - Registration is read the way the DevExpress documentation defines it:
isDefault: truemeans the editor replaces the default for that type everywhere, whilefalsemeans it is merely selectable in the Model Editor. Only the first is reported as being used by an entity — listing every string property in an application as "uses the barcode scanner" would be plainly false.
- Detected from
-
Version-gated data migrations from the module updater — the blocks guarded by
CurrentDBVersion < new Version("1.1.0.0"). Each ran once, on somebody's production database, and never again. Reading the code that runs today cannot recover what they did, which is why an agent asked "why does this column contain that?" reasons from current code and invents a cause.- Records the version being upgraded to, the "existing databases only" lower bound, which schema phase it ran in — a block running before the schema changed could not use the new columns — the methods it calls, and the code itself.
- Captures the comment above the block, which is usually the only surviving record of why, and the question anyone reading a migration actually has.
- Kept separate from seed data throughout: seed data says what a fresh database contains, migrations say what happened to every database that was not fresh.
- Surfaced in
AGENTS.md, the explainer, and a newxaf_migrationsMCP tool.
-
A fourteen-entity demo application (
Fixtures/DemoSolution) with a platform project, a custom editor and a version-gated updater, so the diagrams and screenshots show a realistic application that belongs to nobody. -
xaflogic explain— a single self-contained HTML page explaining the application to a person. The same extraction already serves agents; this is the reader who has just inherited a ten-year-old XAF application, or has to hand one over.- A map of the domain model, drawn from the association attributes scattered across the codebase. Most teams have never seen theirs: it exists in one person's head, which is exactly the knowledge that leaves when they do. Hovering an entity isolates what it touches.
- Every entity with its properties and what each one is; every action with the code it runs; validation with the message the user will actually see; and the Model Editor settings that exist in no C# file.
- An index of every criteria expression in the application, gathered from attributes spread across the source. XAF's criteria language is neither SQL nor C#, and it is nowhere collected.
- Client-side search across everything, light and dark, and no request to the network — it has to open from an email attachment on a machine with no internet.
- The layout is computed at generation time, not in the browser, so the same source always draws the same diagram and a regenerated page produces a readable diff.
- Explicit interface implementations were extracted as separate properties, so
object ISecurityUserLoginInfo.User => User;put a secondUserrow, typedobject, into every rendering — reading as a modelling mistake the team had not made. - Code shown in the explainer kept the indentation it had in its source file. Roslyn hands back text starting where the node starts, so a method body opened flush left and then jumped eight columns.
- The demo application attributed its backing fields rather than its properties, which is not how XPO is written — DevExpress's own persistent classes attribute the property. Half its relationships and rules were therefore invisible, and the map, the README and the site all rendered an application simpler than the one in the fixture. Its shape is now pinned by a test, since the previous suite passed either way.
- Cross-references between entities in the explainer were rendered in the browser's default link blue, a colour the rest of the page never uses.
0.10.1 — 2026-08-10
-
XafLogicExplainer.Mcppacked the repository README, which does not carry themcp-name: io.github.peopleworks/xaf-logic-explainerline. The MCP registry reads that line out of the published package to confirm that whoever submits the registry entry also owns the NuGet package, so 0.10.0 could never have been registered. The package now carries its own README.The line looks like decoration and is not. Removing or rewording it breaks registry publishing quietly — the next release simply stops being accepted, with nothing in the build to say why.
0.10.0 — 2026-08-10
First release published to NuGet. Version 0.9.0 was the point the repository went public; nothing was ever pushed to a package feed under it, so everything since is gathered here.
Three packages: XafLogicExplainer.Core (the extraction engine),
XafLogicExplainer.Cli (the xaflogic tool) and XafLogicExplainer.Mcp
(an MCP server, installable on its own with dnx).
Still 0.x deliberately. The extraction engine is production-proven, but this release changed its behaviour in six places and has been verified against one real application. 1.0.0 is earned once the extractor has read codebases we did not write.
-
xaflogic agents— writesAGENTS.md,CLAUDE.mdand.github/copilot-instructions.mdso any AI coding agent understands the analyzed application. No account, key or server.- Output is tiered: a compact index (~11 KB) that agents load on every request, and detail
files in
.xaflogic/(~70 KB) they open only when a question needs them. AnAGENTS.mdis prepended to every conversation, so putting the full documentation there would consume the context the user's actual question needs. - The index leads with ground rules: which ORM this application uses and which APIs therefore do not exist in it, that the inventories are complete so an absent entity is genuinely absent, and that some behavior lives in the Model Editor rather than in C#.
- Conventions are inferred from the codebase — namespaces, folder layout, base classes, how associations and validation are written — so generated code matches the surrounding style instead of a generic tutorial.
- Includes real criteria expressions taken from the source. XAF's criteria language is neither SQL nor C#, and worked examples teach the dialect better than a description of it.
- Generated text is written between markers: anything you wrote by hand is preserved, and regenerating produces byte-identical output when nothing has changed.
- Output is tiered: a compact index (~11 KB) that agents load on every request, and detail
files in
-
IDocumentationSink, making a publishing target something the caller chooses. PeopleWorks Copilot is now one implementation of it rather than the destination the tool is built around. -
xaflogic mcp— a Model Context Protocol server (ModelContextProtocol 2.1.0) exposing seven tools:xaf_overview,xaf_search,xaf_entity,xaf_controller,xaf_rules,xaf_modelandxaf_refresh. Unlike generated files it reads live source, so it cannot go stale.- Asking for something absent returns the complete inventory and states plainly that it does not exist, rather than a bare "not found" that invites an agent to invent it anyway.
- Extractions are cached per project and invalidated by a cheap size-and-timestamp fingerprint, so a conversation's worth of questions costs one parse but an edit is still noticed.
- Finds the XAF module by itself when started from a solution directory, which is what lets the
plugin declare
xaflogic mcpwith no arguments.
-
Installable Claude Code plugin at
plugins/xaf-logic-explainer, carrying the skill and the MCP server:/plugin marketplace add peopleworks/XAFLogicExplainer. -
DevExpress ground-truth catalog (
xaflogic catalog build, or the standalonexafcatalog). Reads a locally licensed DevExpress installation and records what the framework itself provides: attributes, controllers, model interfaces and modules, with the official summaries and documentation links DevExpress ships. On 26.1 that is ~850 types across 50 assemblies.- Extraction can then distinguish your logic from the framework's: a controller extending
DeleteObjectsViewControlleris changing how deletion works application-wide, and an attribute in neither XAF nor .NET is one your team invented — its meaning exists in your codebase and in no documentation. - Read with
MetadataLoadContext, so DevExpress code is never executed. The catalog is written to~/.xaflogic/catalog/, never into a repository, because it is derived from licensed software. Extraction behaves exactly as before when no catalog is present. - Generic bases every controller shares (
ViewController,ObjectViewController,WindowController) are deliberately not reported: listing them annotated the entire application with "A View Controller" and buried the one case worth noticing.
- Extraction can then distinguish your logic from the framework's: a controller extending
-
Test suite (
tests/XafLogicExplainer.Tests, xUnit v3): 129 tests over synthetic XAF fixtures in both XPO and EF Core. The fixtures are XAF source that is never compiled — extraction parses it as text — so the suite needs no DevExpress licence and no private feed, and CI verifies the whole engine on a public runner. It runs in under a second.
- Target framework is now .NET 10.
Writing the suite surfaced six extraction bugs, all of which had been producing confidently wrong documentation:
- A project living under a directory named
binextracted as empty. Five analyzers testedpath.Contains("bin"), matching the substring anywhere in an absolute path — soC:\bin\Sales\, or any folder whose name merely contains those letters, had every source file silently skipped. Matching is now on whole path segments, and only below the directory being analyzed, since build output is always beneath the project root. - Property-level validation rules lost their message. Class-level and property-level rules were
read by two code paths that had drifted apart; only the class-level one populated
MessageTemplate. Property-level rules are the ordinary way to write XAF validation, and the message is the most useful part of a rule, so it was missing from exactly the rules people write most. Both paths now share one reader. ViewController<DetailView>was reported as targetingDetailView. The single generic argument ofViewController<T>constrains the view, not the business type; onlyObjectViewController<TView, TObject>names an object. An explicitTargetObjectType = typeof(X)in the constructor is now read first, since it states the intent outright and was previously unreachable.- Seed data was only found in a file named
Updater.cs. Any other name —SeedDataUpdater,DemoDataUpdater, an updater split per area — meant the application was reported as having no seed data at all, silently. The fallback now looks for a class that actually derives fromModuleUpdater. ObjectSpace.CreateObject<T>()seed records were invisible. Onlynew Customer(session)was recognized, which is the older Session-based style; a modern updater works againstIObjectSpace. On a real 19-entity application this raised the seed methods found from 4 to 9.- Seed methods were counted twice. Each one is reached both by following calls out of
UpdateDatabaseAfterUpdateSchemaand by the sweep over every method in the class, and the duplicate was a perfect copy — so it read as two genuinely separate operations.
nameof(...)in attribute arguments was recorded as the literal textnameof(Numero)rather thanNumero. Attribute values were read withexpression.ToString().Trim('"'), which is right for a string literal and wrong for everything else — and[XafDefaultProperty(nameof(X))]is how current C# is written. The bad value reached generated documentation and MCP responses as though it were a real property name. A sharedSyntaxLiteralreader now resolves string literals,nameof, and concatenated strings across all three analyzers.
ModuleAnalyzerreported business entities as required XAF modules. It accepted any invocation whose expression containedAdd, soAdditionalExportedTypes.Add(typeof(Customer))was read as a module dependency. On a real project this listed nine entities and six framework base types among twelve genuine modules. It now matches the target collection, andAdditionalExportedTypesfeeds the registered-types list where it belongs.
- AI provider abstraction (OpenAI, Azure OpenAI, Anthropic, Ollama) for
--enrich - Splitting the 1,500-line
Program.csinto one file per command
0.9.0 — 2026-08-10
First public release. The extraction engine has been running in production against real XAF applications; this is the point where it becomes a community project.
- Roslyn-based extraction of entities, properties, associations and XAF attributes
- Controller and action extraction, including target criteria and handler code
- Business rule extraction from validation attributes and code rules
ModuleUpdaterseed data and module configuration extraction- Navigation group and item extraction
- Model Editor (
.xafml) extraction, merging module and platform files the way XAF merges them - XPO and EF Core support, auto-detected from
usingstatements, overridable with--orm - Incremental extraction via a SHA-256 hash over
.csand.xafmlsources diffcommand reporting what changed between extractionswatchcommand with debounced re-extraction- Multi-project configuration and
--allbatch processing --enrich, generating AI business-logic summaries per controller and per action- Bilingual output (English and Spanish)
- PeopleWorks Copilot publishing target
DescriptionAnnotator, which writes missing[Description]attributes back into source- Blazor in-app help panel for XAF Blazor applications
- MSBuild
.targetsfor extraction on build
- The packaged MSBuild integration never worked. Three problems, none of which could surface
before the package was installed from a feed: the file was named
XafLogicExplainer.targetswhen NuGet only auto-importsbuild/<PackageId>.targets; it resolved the CLI through a path into this repository's ownbin/directory, which cannot exist on a consumer's machine; and it passed a--configflag the CLI has never had. It now invokes the installedxaflogictool, and CI verifies packaging on every push. - The MSBuild integration defaulted to
sync, uploading documentation on every Release build. It now defaults toextract, which makes no network call, and does nothing at all unlessXafLogicExplainerRunOnBuildis set. Publishing from a build step should be a decision. DescriptionAnnotatordefaulted its resource name to a specific client project, so an unconfigured run targeted someone else's resource. The default is removed.
- Versioned 0.9.0 rather than 1.0.0 on purpose: the extraction engine is mature, but the agent-facing surface is still landing. 1.0.0 follows the MCP server.
XafLogicExplainer.Corereferences no DevExpress assemblies and needs no DevExpress license. Only the Blazor widget does.