Skip to content

Commit 9d6669b

Browse files
authored
Merge pull request #49 from peopleworks/feat/cross-project-wiki
Add `xaflogic wiki` — every project in one page, and what they share
2 parents 0a7bc0e + 565fea3 commit 9d6669b

10 files changed

Lines changed: 3047 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`xaflogic wiki`** — reads every configured project into one self-contained HTML page and computes
13+
what they have in common. A single-application explainer answers "how does this work"; this answers
14+
a question that cannot be asked of one application at a time: *have I built this before?*
15+
- **Classes modelled more than once**, with a property-by-property comparison and a column per
16+
application, so the richest version of `Cliente` is the one you open before writing it again.
17+
- **The layer you wrote yourself** — base classes carried between applications. A base type is
18+
listed only when its own source was read in one of the projects, so no list of DevExpress type
19+
names is involved and nothing here goes stale when DevExpress renames something.
20+
- **The same name, two shapes** — where a name means one scalar type in one application and
21+
another elsewhere. `Double` and `double` are one type and are never reported as a disagreement;
22+
neither is a nullable annotation on a reference type. A name holding a different collection per
23+
entity is vocabulary rather than a conflict, and is listed as such.
24+
- **Names you keep**, **modules more than one application requires**, per-application detail, and
25+
a filter that shows only what touches one project.
26+
- Search across every application, one file, no network requests.
27+
- Source citations on every entity and controller in a wiki, so an entry can always tell you which
28+
file to open.
29+
30+
### Changed
31+
32+
- `xaflogic projects add` no longer requires `--resource-name`. It names a resource in PeopleWorks
33+
Copilot, which is one publishing target among several and irrelevant to `wiki`, `explain`, `agents`
34+
and `mcp` — all of which read the configured project list and write locally. Requiring it made the
35+
multi-project list unreachable without an account somewhere. It now defaults to the profile name.
36+
1037
## [0.16.0] — 2026-08-23
1138

1239
What we cannot see, said out loud.

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,38 @@ Try it on the sample without touching your own code:
273273
xaflogic explain --project tests/XafLogicExplainer.Tests/Fixtures/DemoSolution/PharmacyDemo.Module --open
274274
```
275275

276+
## The question one application cannot answer
277+
278+
You have delivered XAF applications to clients for ten years. Somewhere in there you already
279+
modelled the class you are about to write again, and you cannot remember which project it was in.
280+
281+
```bash
282+
xaflogic projects add --name pwLegalOffice --project "C:\Clients\Legal\pwLegalOffice.Module"
283+
xaflogic projects add --name Presupuesto --project "C:\Clients\Budget\PWPresupuesto.Module"
284+
xaflogic wiki --open
285+
```
286+
287+
One HTML file over **all of them**, and a section that exists in no single-project tool:
288+
289+
- **Classes you modelled more than once** — with a property-by-property comparison, so you can see
290+
which application models `Cliente` in the most detail before writing it a fourth time.
291+
- **The layer you wrote yourself** — base classes carried from one application into another. Your
292+
own framework, the one that was never written down. A base class is listed only when its own
293+
source was read in one of the projects, so nothing is matched against a list of DevExpress type
294+
names that would rot at the next release.
295+
- **The same name, two shapes** — where `Total` is a `decimal` in one application and a `double`
296+
in another. Nothing is broken; each one compiles. It is how a total ends up two cents out.
297+
- **Names you keep** — the vocabulary of your applications, which nobody wrote down and everybody
298+
who joins has to learn by reading code.
299+
300+
Everything in it is computed from what was read. There is no place in the page to put a sentence
301+
somebody typed about the corpus, because a hand-written summary of nine applications is wrong the
302+
day the tenth is added and nobody notices.
303+
304+
Filter by any project to see only what it has in common with the rest. And the honest part is on
305+
the page too: two classes are matched **by name**, so the wiki tells you they share a name — the
306+
comparison is what tells you whether they share an idea.
307+
276308
## The same knowledge, as a document
277309

278310
You arrive at an XAF project you have never seen and, half a day later, hand someone a document
@@ -350,6 +382,7 @@ licensed software. Everything works without it — it only sharpens the output.
350382
| `agents` | **Write `AGENTS.md` / `CLAUDE.md` / Copilot instructions for your agent** |
351383
| `mcp` | **Run as an MCP server so agents can query the app live** |
352384
| `explain` | **Write a self-contained HTML page explaining the app to a person** |
385+
| `wiki` | **Read every project together and say what they have in common** |
353386
| `catalog` | Build the DevExpress ground-truth catalog (`build`, `status`) |
354387
| `extract` | Read the project, write Markdown + JSON locally |
355388
| `walkthrough` | **Trace one business process** — what runs, in what order, and what governs it |
@@ -422,10 +455,11 @@ applications. The agent-facing surface is what is landing now, in the open.
422455
|| Blazor in-app help panel |
423456
|| **`AGENTS.md` / `CLAUDE.md` / Copilot instructions** — zero infrastructure, works for everyone |
424457
|| **`xaflogic explain`** — one self-contained HTML page, for a person rather than an agent |
458+
|| **`xaflogic wiki`** — every project in one page, and what they have in common |
425459
|| Pluggable publishing targets (`IDocumentationSink`) |
426460
|| **MCP server** — 12 tools, live against your source |
427461
|| **Installable Claude Code plugin** with skill and MCP server |
428-
|| **457 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
462+
|| **498 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
429463
|| **DevExpress ground-truth catalog**, generated locally by licensees |
430464

431465
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the

site/index.html

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@
600600
<a href="#output" class="nav__hideable">Output</a>
601601
<a href="#mcp" class="nav__hideable">MCP</a>
602602
<a href="#explainer" class="nav__hideable">Explainer</a>
603+
<a href="#wiki" class="nav__hideable">Wiki</a>
603604
<a href="#install" class="is-cta">Install</a>
604605
</nav>
605606

@@ -1224,6 +1225,52 @@ <h2>Most teams have never seen<br>their own domain model.</h2>
12241225
</figure>
12251226
</section>
12261227

1228+
<!-- --------------------------------------------------------- wiki -->
1229+
<section id="wiki" class="reveal">
1230+
<p class="eyebrow">And for the person who has built forty of them</p>
1231+
<h2>You already solved this.<br>In a project you cannot remember.</h2>
1232+
<p>
1233+
Ten years of XAF work for different clients, and somewhere in there is the class you are about
1234+
to model again. Not a similar one — the same one, thought through properly, with the two
1235+
properties you are going to forget this time.
1236+
</p>
1237+
<p>
1238+
<code>xaflogic wiki</code> reads <em>every</em> project you have configured into one page and
1239+
computes what they have in common. There is nowhere in that page to type a sentence about the
1240+
collection, which is the point: a hand-written summary of nine applications is wrong the day
1241+
the tenth arrives, and nobody notices.
1242+
</p>
1243+
1244+
<div class="grid grid--2">
1245+
<div class="card card--lit">
1246+
<h3>Classes you modelled more than once</h3>
1247+
<p>Property by property, side by side, so you can see which application models
1248+
<code>Cliente</code> in the most detail before writing it a fourth time.</p>
1249+
</div>
1250+
<div class="card card--lit">
1251+
<h3>The layer you wrote yourself</h3>
1252+
<p>Base classes carried from one application into another: your own framework, the one that
1253+
was never written down.</p>
1254+
</div>
1255+
<div class="card card--lit">
1256+
<h3>The same name, two shapes</h3>
1257+
<p><code>Total</code> is a <code>decimal</code> in one application and a <code>double</code>
1258+
in another. Nothing is broken. It is how a total ends up two cents out.</p>
1259+
</div>
1260+
<div class="card card--lit">
1261+
<h3>Names you keep</h3>
1262+
<p>The vocabulary of your applications, which nobody wrote down and everybody who joins has
1263+
to learn by reading code.</p>
1264+
</div>
1265+
</div>
1266+
1267+
<p style="margin-top:2.2rem">
1268+
Two classes are matched <strong>by name</strong>. So the wiki tells you they share a name — the
1269+
comparison beside it is what tells you whether they share an idea. It says which of those two
1270+
things it knows, on the page, next to the finding.
1271+
</p>
1272+
</section>
1273+
12271274
<!-- --------------------------------------------------------- catalog -->
12281275
<section id="catalog" class="reveal">
12291276
<p class="eyebrow">Optional, for licensees</p>

src/XafLogicExplainer.Cli/Program.cs

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using OpenAI;
2020
using XafLogicExplainer.Core.Models;
2121
using XafLogicExplainer.Core.Walkthrough;
22+
using XafLogicExplainer.Core.Wiki;
2223

2324
// ============================================================
2425
// xaflogic - XAF Logic Explainer CLI
@@ -211,7 +212,12 @@
211212
var addNameOption = new Option<string>("--name", "Project friendly name") { IsRequired = true };
212213
var addProjectPathOption = new Option<string>("--project", "XAF module directory path") { IsRequired = true };
213214
addProjectPathOption.AddAlias("-p");
214-
var addResourceOption = new Option<string>("--resource-name", "Copilot resource name") { IsRequired = true };
215+
// Not required. It names a resource in PeopleWorks Copilot, which is one publishing target among
216+
// several and irrelevant to anyone using `wiki`, `explain`, `agents` or `mcp` — every one of which
217+
// reads the configured projects and writes locally. Demanding it made the multi-project list
218+
// unreachable unless you had an account somewhere. Defaults to the profile name so `sync` still
219+
// has something to publish to.
220+
var addResourceOption = new Option<string?>("--resource-name", "Copilot resource name (defaults to the profile name)");
215221
var addLangOption = new Option<string?>("--lang", "Language override (es/en)");
216222
var addOrmOption = new Option<string?>("--orm", "ORM override (auto/xpo/efcore)");
217223

@@ -241,7 +247,7 @@
241247
{
242248
Name = name,
243249
ProjectPath = fullPath,
244-
ResourceName = resource,
250+
ResourceName = string.IsNullOrWhiteSpace(resource) ? name : resource,
245251
Language = lang,
246252
Orm = orm
247253
});
@@ -1689,6 +1695,166 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a
16891695

16901696
rootCommand.AddCommand(explainCommand);
16911697

1698+
// ============================================================
1699+
// COMMAND: wiki
1700+
// ============================================================
1701+
var wikiCommand = new Command(
1702+
"wiki",
1703+
"Read every configured XAF application into one page, and say what they have in common");
1704+
1705+
var wikiProjectsOption = new Option<string[]>(
1706+
"--project",
1707+
"An XAF project to include. Repeat for several. Defaults to every configured project.")
1708+
{
1709+
AllowMultipleArgumentsPerToken = true,
1710+
};
1711+
wikiProjectsOption.AddAlias("-p");
1712+
1713+
var wikiOutputOption = new Option<string?>(
1714+
"--output",
1715+
"File to write (default: xaf-wiki.html in the current directory)");
1716+
var wikiTitleOption = new Option<string?>(
1717+
"--title",
1718+
"A name for the collection (default: \"Your XAF applications\")");
1719+
var wikiOpenOption = new Option<bool>(
1720+
"--open",
1721+
"Open the page in the default browser when it is written");
1722+
1723+
wikiCommand.AddOption(wikiProjectsOption);
1724+
wikiCommand.AddOption(languageOption);
1725+
wikiCommand.AddOption(ormOption);
1726+
wikiCommand.AddOption(wikiOutputOption);
1727+
wikiCommand.AddOption(wikiTitleOption);
1728+
wikiCommand.AddOption(wikiOpenOption);
1729+
1730+
wikiCommand.SetHandler((wikiPaths, wikiLanguage, wikiOrm, wikiOutput, wikiTitle, wikiOpen) =>
1731+
{
1732+
var wikiConfig = ConfigHelper.Load();
1733+
1734+
// Named profiles first: a wiki is the multi-project command, so the configured list is what it
1735+
// is for. Explicit --project wins, and a single default project still produces a page — one
1736+
// that says outright that a corpus of one has nothing to compare itself against.
1737+
var wikiSources = new List<(string Name, string Path, string? Language, string? Orm)>();
1738+
1739+
if (wikiPaths.Length > 0)
1740+
{
1741+
// The folder is called MyApp.Module; the application is called MyApp. Repeating ".Module"
1742+
// beside every heading, chip and nav entry costs width and says nothing, since every
1743+
// project in a wiki is a module.
1744+
wikiSources.AddRange(wikiPaths.Select(p =>
1745+
(Name: WikiApplicationName(p),
1746+
Path: p,
1747+
Language: wikiLanguage,
1748+
Orm: wikiOrm)));
1749+
}
1750+
else if (wikiConfig.Projects.Count > 0)
1751+
{
1752+
wikiSources.AddRange(wikiConfig.Projects.Select(p =>
1753+
(p.Name, Path: p.ProjectPath, Language: p.Language ?? wikiLanguage, Orm: p.Orm ?? wikiOrm)));
1754+
}
1755+
else if (!string.IsNullOrEmpty(wikiConfig.ProjectPath))
1756+
{
1757+
wikiSources.Add((new DirectoryInfo(wikiConfig.ProjectPath).Name, wikiConfig.ProjectPath, wikiLanguage, wikiOrm));
1758+
}
1759+
1760+
if (wikiSources.Count == 0)
1761+
{
1762+
AnsiConsole.MarkupLine("[red]✗[/] Nothing to read. Add projects with [cyan]xaflogic projects add[/], "
1763+
+ "or pass [cyan]--project <path>[/] once per application.");
1764+
return;
1765+
}
1766+
1767+
var wikiApplications = new List<WikiApplication>();
1768+
var wikiTaken = new HashSet<string>(StringComparer.Ordinal);
1769+
var wikiSkipped = new List<(string Name, string Why)>();
1770+
1771+
AnsiConsole.Status().Spinner(Spinner.Known.Dots).Start("Reading the applications...", ctx =>
1772+
{
1773+
foreach (var source in wikiSources)
1774+
{
1775+
ctx.Status($"Reading {Markup.Escape(source.Name)}...");
1776+
1777+
if (string.IsNullOrWhiteSpace(source.Path) || !Directory.Exists(source.Path))
1778+
{
1779+
wikiSkipped.Add((source.Name, "path not found"));
1780+
continue;
1781+
}
1782+
1783+
try
1784+
{
1785+
// One project that moved, or that no longer parses, must not cost the other eleven.
1786+
var extracted = new LogicExtractor().ExtractFromSourceDirectory(
1787+
source.Path,
1788+
BuildExtractionOptions(source.Language ?? wikiConfig.Language ?? "en", source.Orm ?? wikiConfig.Orm));
1789+
1790+
wikiApplications.Add(new WikiApplication
1791+
{
1792+
Name = source.Name,
1793+
Slug = CorpusAnalyzer.Slug(source.Name, wikiTaken),
1794+
Project = extracted,
1795+
});
1796+
}
1797+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
1798+
{
1799+
wikiSkipped.Add((source.Name, ex.Message));
1800+
}
1801+
}
1802+
});
1803+
1804+
if (wikiApplications.Count == 0)
1805+
{
1806+
AnsiConsole.MarkupLine("[red]✗[/] None of the projects could be read.");
1807+
foreach (var (name, why) in wikiSkipped)
1808+
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(name)}: {Markup.Escape(why)}[/]");
1809+
return;
1810+
}
1811+
1812+
var wikiCorpus = CorpusAnalyzer.Analyze(wikiApplications);
1813+
var wikiHtml = new WikiGenerator(ThisAssemblyVersion()).Generate(wikiCorpus, wikiTitle);
1814+
var wikiFile = wikiOutput ?? Path.Combine(Directory.GetCurrentDirectory(), "xaf-wiki.html");
1815+
1816+
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(wikiFile))!);
1817+
File.WriteAllText(wikiFile, wikiHtml);
1818+
1819+
var wikiTable = new Table().Border(TableBorder.Rounded).AddColumn("In common").AddColumn("");
1820+
wikiTable.AddRow("Applications read", wikiApplications.Count.ToString());
1821+
wikiTable.AddRow("Classes modelled more than once", wikiCorpus.RecurringEntities.Count.ToString());
1822+
wikiTable.AddRow("Base classes you reused", wikiCorpus.RecurringBaseTypes.Count.ToString());
1823+
wikiTable.AddRow("Actions written more than once", wikiCorpus.RecurringActions.Count.ToString());
1824+
wikiTable.AddRow("Names used across applications", wikiCorpus.Conventions.Count.ToString());
1825+
AnsiConsole.Write(wikiTable);
1826+
1827+
foreach (var (name, why) in wikiSkipped)
1828+
AnsiConsole.MarkupLine($"[yellow]![/] Skipped {Markup.Escape(name)}: {Markup.Escape(why)}");
1829+
1830+
// A corpus of one is a legitimate result, not an error, but it is worth saying out loud: the
1831+
// page will be a single-application index and none of the comparisons will have anything to do.
1832+
if (wikiApplications.Count == 1)
1833+
{
1834+
AnsiConsole.MarkupLine("[grey]One application. Add more with [/][cyan]xaflogic projects add[/]"
1835+
+ "[grey] to get the comparisons.[/]");
1836+
}
1837+
1838+
AnsiConsole.WriteLine();
1839+
AnsiConsole.MarkupLine($"[green]✓[/] {Markup.Escape(Path.GetFullPath(wikiFile))}");
1840+
AnsiConsole.MarkupLine($"[grey]{wikiHtml.Length / 1024:N0} KB · one file, no dependencies.[/]");
1841+
1842+
if (wikiOpen)
1843+
{
1844+
try
1845+
{
1846+
Process.Start(new ProcessStartInfo(Path.GetFullPath(wikiFile)) { UseShellExecute = true });
1847+
}
1848+
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
1849+
{
1850+
// Headless, or no handler registered. The path is already printed above.
1851+
AnsiConsole.MarkupLine("[grey]Could not open a browser; the path is above.[/]");
1852+
}
1853+
}
1854+
}, wikiProjectsOption, languageOption, ormOption, wikiOutputOption, wikiTitleOption, wikiOpenOption);
1855+
1856+
rootCommand.AddCommand(wikiCommand);
1857+
16921858
// ============================================================
16931859
// COMMAND: mcp
16941860
// ============================================================
@@ -1977,6 +2143,18 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a
19772143
AnsiConsole.MarkupLine("[grey]Ask your agent something only this codebase could answer.[/]");
19782144
}
19792145

2146+
// The name to show for a project the wiki was pointed at directly, rather than one configured
2147+
// under a profile name of its own.
2148+
static string WikiApplicationName(string path)
2149+
{
2150+
var folder = new DirectoryInfo(
2151+
path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)).Name;
2152+
2153+
return folder.EndsWith(".Module", StringComparison.OrdinalIgnoreCase) && folder.Length > ".Module".Length
2154+
? folder[..^".Module".Length]
2155+
: folder;
2156+
}
2157+
19802158
// The tool version, stamped into generated files so a stale one can be identified.
19812159
static string ThisAssemblyVersion() =>
19822160
System.Reflection.Assembly.GetExecutingAssembly().GetName().Version is { } v

0 commit comments

Comments
 (0)