Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `EntityAnalyzer.cs` carried a literal NUL byte inside a string interpolation, used as a key
separator. It compiled, and it made the largest file in the project binary to `grep` and
`ripgrep`, which silently refuse to search it. Written as the `\0` escape instead.
- **A .NET Framework application told an agent nothing about its framework**
([#50](https://github.com/peopleworks/XAFLogicExplainer/issues/50)). `ExtractProjectMetadata`
read `<TargetFramework>` and no other spelling, so a project from before the SDK format —
which declares `<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>` and has no
`<TargetFramework>` at all — reported an **empty** framework. Not a wrong one: none. Two of the
six real applications came out of `xaflogic wiki` with no framework at all beside four that
reported `net7.0` and `net9.0`. The framework is now read through every spelling a project file
uses, and the pre-SDK one is normalised to its moniker: `v4.8` becomes `net48`, `v4.8.1` becomes
`net481` — different frameworks, and kept distinguishable. A multi-targeting
`<TargetFrameworks>` list, which the old regex also could not see, is reported as the project
declared it.
- `AGENTS.md` now carries a **ground rule** for a .NET Framework application rather than only
naming the framework in its summary. This is what the issue is actually about: an agent told
nothing assumes a modern framework and reaches for APIs that are not there. The rule is
deliberately narrow — most modern C# *syntax* does compile there once `LangVersion` is set, and
a rule that forbade all of it would be false and would cost the true half its credibility. It
names only what no compiler switch supplies: the C# 7.3 default, the missing
`System.Text.Json`/`IAsyncEnumerable<T>`/`Index`/`Range`, default interface methods being
impossible, and the polyfill attributes `record` and `required` need. Written from a reading and
never from a default, the same discipline the ORM rule keeps.
- A project whose framework was never declared no longer renders an empty field. The Markdown
overview printed a `Framework:` label with nothing after it, the detail page did the same, and
the MCP overview told an agent `Target framework: .` — all three now omit it, because an empty
field reads as a document that lost a value rather than as a fact that was never declared.

## [0.17.0] — 2026-08-24

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ applications. The agent-facing surface is what is landing now, in the open.
| ✅ | Pluggable publishing targets (`IDocumentationSink`) |
| ✅ | **MCP server** — 12 tools, live against your source |
| ✅ | **Installable Claude Code plugin** with skill and MCP server |
| ✅ | **525 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **548 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
| ✅ | **DevExpress ground-truth catalog**, generated locally by licensees |

PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the
Expand Down
107 changes: 107 additions & 0 deletions src/XafLogicExplainer.Core/Analyzers/DeclaredTargetFramework.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Text.RegularExpressions;

namespace XafLogicExplainer.Core.Analyzers;

/// <summary>
/// Which framework an application says it targets, however its project file spells it.
/// </summary>
/// <remarks>
/// Read from the project file as text, like everything else here, so it needs no build and no
/// installed SDK.
/// <para>
/// <strong>Three spellings, because XAF applications outlive project formats.</strong> An
/// SDK-style project writes <c>&lt;TargetFramework&gt;</c>, or <c>&lt;TargetFrameworks&gt;</c> when
/// it multi-targets. A project from before the SDK format writes
/// <c>&lt;TargetFrameworkVersion&gt;v4.8&lt;/TargetFrameworkVersion&gt;</c> and has no
/// <c>&lt;TargetFramework&gt;</c> at all. Reading only the first returns nothing for exactly the
/// applications where the constraint is tightest, because a .NET Framework project is the one
/// place most modern C# does not compile.
/// </para>
/// <para>
/// Silence is not a neutral outcome here. An agent handed a document that says nothing about the
/// framework assumes a modern one and reaches for nullable reference types, <c>record</c>,
/// file-scoped namespaces and collection expressions, none of which build on <c>net48</c>. That is
/// the same failure as reporting no reports for an application that has forty: an absence read as
/// information.
/// </para>
/// </remarks>
public static class DeclaredTargetFramework
{
/// <summary><c>&lt;TargetFramework&gt;net9.0&lt;/TargetFramework&gt;</c>.</summary>
private static readonly Regex SdkForm = new(
@"<TargetFramework>\s*(?<tfm>[^<]+?)\s*</TargetFramework>",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>
/// <c>&lt;TargetFrameworks&gt;net8.0;net9.0&lt;/TargetFrameworks&gt;</c> — a project that
/// multi-targets.
/// </summary>
private static readonly Regex MultiForm = new(
@"<TargetFrameworks>\s*(?<tfms>[^<]+?)\s*</TargetFrameworks>",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>
/// <c>&lt;TargetFrameworkVersion&gt;v4.8&lt;/TargetFrameworkVersion&gt;</c> — the pre-SDK
/// spelling, and the only one a .NET Framework project has.
/// </summary>
private static readonly Regex LegacyForm = new(
@"<TargetFrameworkVersion>\s*v?(?<version>\d+(?:\.\d+)*)\s*</TargetFrameworkVersion>",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>
/// The framework a project file declares, or null when it declares none in any spelling.
/// </summary>
/// <remarks>
/// Null is an ordinary outcome and stays distinct from a value: a module whose framework comes
/// from a shared <c>Directory.Build.props</c> declares nothing here, and a project file that
/// could not be found at all declares nothing either. "I did not read one" and "it targets
/// net48" must never render the same way, which is the whole point of the fix.
/// <para>
/// A multi-targeting project is reported as it declared itself, semicolons and all. Picking
/// one of the list would be inventing a fact, and the list is what an agent needs: code has to
/// compile on every framework named there, so the oldest one is the real constraint.
/// </para>
/// </remarks>
public static string? FromProjectFile(string? projectFileContent)
{
if (string.IsNullOrWhiteSpace(projectFileContent))
return null;

if (SdkForm.Match(projectFileContent) is { Success: true } sdk)
return sdk.Groups["tfm"].Value;

if (MultiForm.Match(projectFileContent) is { Success: true } multi)
return multi.Groups["tfms"].Value;

if (LegacyForm.Match(projectFileContent) is { Success: true } legacy)
return Moniker(legacy.Groups["version"].Value);

return null;
}

/// <summary>
/// Whether a moniker names .NET Framework, where most modern C# does not compile.
/// </summary>
/// <remarks>
/// Matched on the moniker rather than remembered from the parse, so it is equally right about
/// an SDK-style project that targets <c>net472</c> — which is legal, and is how a migrated
/// application often looks halfway through. <c>net5.0</c> and everything after carry a dot;
/// .NET Framework monikers never do.
/// </remarks>
public static bool IsDotNetFramework(string? moniker) =>
!string.IsNullOrWhiteSpace(moniker)
&& DotNetFrameworkMoniker.IsMatch(moniker);

private static readonly Regex DotNetFrameworkMoniker = new(
@"^net[1-4]\d*$", RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>
/// <c>4.8</c> becomes <c>net48</c>, <c>4.8.1</c> becomes <c>net481</c>.
/// </summary>
/// <remarks>
/// Dots removed, which is the whole of the .NET Framework moniker rule and is why
/// <c>net481</c> and <c>net48</c> are different frameworks rather than a typo of each other.
/// </remarks>
private static string Moniker(string version) =>
"net" + version.Replace(".", string.Empty, StringComparison.Ordinal);
}
9 changes: 4 additions & 5 deletions src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,10 @@ private static void ExtractProjectMetadata(string projectPath, ExtractedProject

// HACK: Metadata parsing currently relies on simple regex patterns against raw XML text.
// A future refactor should use XDocument to reliably handle multiline attributes and property groups.
// Extract TargetFramework
var tfmMatch = System.Text.RegularExpressions.Regex.Match(csprojContent,
@"<TargetFramework>(.*?)</TargetFramework>");
if (tfmMatch.Success)
project.TargetFramework = tfmMatch.Groups[1].Value;
// Read through every spelling, because a project from before the SDK format writes
// `<TargetFrameworkVersion>v4.8` and no `<TargetFramework>` at all -- and reporting nothing
// for it tells an agent it may use modern C# in the one place that will not compile.
project.TargetFramework = DeclaredTargetFramework.FromProjectFile(csprojContent) ?? string.Empty;

// Extract PackageReferences
var packageMatches = System.Text.RegularExpressions.Regex.Matches(csprojContent,
Expand Down
21 changes: 21 additions & 0 deletions src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@ private static void WriteGroundRules(

sb.AppendLine();

// The framework, but only where it forbids something. Written from a reading and never
// from a default, the same discipline as the ORM rule above and for the same reason: an
// agent cannot tell a guess from a fact, so a constraint stated on an assumption is worse
// than no constraint at all. A project that declared no framework gets no rule.
//
// Narrow on purpose. Most modern C# syntax does compile here once `LangVersion` is set,
// and a rule that forbade all of it would be false -- which would cost the true half its
// credibility. What is listed is what no compiler switch can supply.
if (Analyzers.DeclaredTargetFramework.IsDotNetFramework(project.TargetFramework))
{
sb.AppendLine($"**{rule++}. This is a .NET Framework application (`{project.TargetFramework}`).**");
sb.AppendLine("The C# language version there defaults to **7.3** unless the project file sets");
sb.AppendLine("`LangVersion`, and the class library is the .NET Framework one:");
sb.AppendLine("`System.Text.Json`, `IAsyncEnumerable<T>`, `Index` and `Range` are absent without a");
sb.AppendLine("package, and default interface methods cannot work at all. `record`, init-only");
sb.AppendLine("setters and `required` members compile only where a polyfill attribute is supplied.");
sb.AppendLine("Match the C# already in these files rather than what you would write by default, and");
sb.AppendLine("read the project file before using anything newer.");
sb.AppendLine();
}

// Rule 2: the closed-world statement. This is the load-bearing one.
sb.AppendLine($"**{rule++}. The inventories {inventoryLocation} are complete.**");
sb.AppendLine("They were extracted from the whole source tree, not sampled. If an entity, controller or");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ public string GenerateMarkdown(ExtractedProject project)
sb.AppendLine($"# {project.ProjectName} - {_l.FunctionalDocumentation}");
sb.AppendLine();
sb.AppendLine($"*Generated: {project.ExtractedAt}*");
sb.AppendLine($"*Framework: {project.TargetFramework}*");
// Only when one was read. `*Framework: *` is a field with nothing in it, which reads as
// a broken document rather than as the honest "this was not declared".
if (!string.IsNullOrWhiteSpace(project.TargetFramework))
sb.AppendLine($"*Framework: {project.TargetFramework}*");
sb.AppendLine();

foreach (var section in sections)
Expand Down Expand Up @@ -732,7 +735,8 @@ private DocumentSection GenerateOverviewSection(ExtractedProject project)
sb.AppendLine($"## {_l.Summary}");
sb.AppendLine();
sb.AppendLine($"- **{_l.Project}:** {project.ProjectName}");
sb.AppendLine($"- **{_l.Framework}:** {project.TargetFramework}");
if (!string.IsNullOrWhiteSpace(project.TargetFramework))
sb.AppendLine($"- **{_l.Framework}:** {project.TargetFramework}");
sb.AppendLine($"- **ORM:** {project.OrmType}");
sb.AppendLine($"- **{_l.BusinessEntitiesCount}:** {project.Entities.Count}");
sb.AppendLine($"- **{_l.ControllersCount}:** {project.Controllers.Count}");
Expand Down
9 changes: 8 additions & 1 deletion src/XafLogicExplainer.Mcp/Tools/XafDiscoveryTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ public async Task<string> OverviewAsync(

sb.AppendLine($"# {app.ProjectName}");
sb.AppendLine();
sb.AppendLine($"DevExpress XAF application. Persistence: **{orm}**. Target framework: {app.TargetFramework}.");
sb.Append($"DevExpress XAF application. Persistence: **{orm}**.");

// Omitted rather than printed empty. "Target framework: ." is not a smaller answer than
// naming one, it is an unreadable one, and this string is what an agent reasons from.
if (!string.IsNullOrWhiteSpace(app.TargetFramework))
sb.Append($" Target framework: {app.TargetFramework}.");

sb.AppendLine();
sb.AppendLine();
sb.AppendLine($"- Entities: **{app.Entities.Count}**");
sb.AppendLine($"- Controllers: **{app.Controllers.Count}** exposing **{actionCount}** actions");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using DevExpress.Persistent.Base;
using DevExpress.Persistent.BaseImpl;
using DevExpress.Xpo;

namespace SampleFx.Module.BusinessObjects
{
/// <summary>
/// A contract, written the way a .NET Framework XAF project writes one.
/// </summary>
/// <remarks>
/// Property-with-backing-field rather than an auto property, and a block namespace rather than
/// a file-scoped one, because that is what compiles under the C# version this project gets by
/// default. The extractor must read it as readily as it reads the modern shape.
/// </remarks>
[DefaultClassOptions]
[NavigationItem("Operaciones")]
public class Contrato : BaseObject
{
private string numero;
private decimal monto;

public Contrato(Session session) : base(session) { }

[Size(20)]
public string Numero
{
get { return numero; }
set { SetPropertyValue(nameof(Numero), ref numero, value); }
}

public decimal Monto
{
get { return monto; }
set { SetPropertyValue(nameof(Monto), ref monto, value); }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Actions;
using SampleFx.Module.BusinessObjects;

namespace SampleFx.Module.Controllers
{
public class ContratoController : ViewController
{
private SimpleAction cerrarContrato;

public ContratoController()
{
TargetObjectType = typeof(Contrato);

cerrarContrato = new SimpleAction(this, "CerrarContrato", "Edit")
{
Caption = "Cerrar contrato"
};

cerrarContrato.Execute += CerrarContrato_Execute;
}

private void CerrarContrato_Execute(object sender, SimpleActionExecuteEventArgs e)
{
ObjectSpace.CommitChanges();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!--
Fixture only. Never built.

The pre-SDK project format, which is what an XAF application written before .NET Core looks
like and what a great many of them still are. Two things about it matter here: the framework
is declared as `TargetFrameworkVersion`, an element the SDK format does not have, and there is
no PackageReference anywhere -- DevExpress arrives as assembly references carrying the version
in the file name.
-->
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<OutputType>Library</OutputType>
<RootNamespace>SampleFx.Module</RootNamespace>
<AssemblyName>SampleFx.Module</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
</PropertyGroup>
<ItemGroup>
<Reference Include="DevExpress.ExpressApp.v22.1, Version=22.1.6.0, Culture=neutral, PublicKeyToken=b88d1754d700e49a" />
<Reference Include="DevExpress.ExpressApp.Xpo.v22.1, Version=22.1.6.0, Culture=neutral, PublicKeyToken=b88d1754d700e49a" />
<Reference Include="DevExpress.Persistent.BaseImpl.v22.1, Version=22.1.6.0, Culture=neutral, PublicKeyToken=b88d1754d700e49a" />
<Reference Include="System" />
<Reference Include="System.Core" />
</ItemGroup>
<ItemGroup>
<Compile Include="BusinessObjects\Contrato.cs" />
<Compile Include="Controllers\ContratoController.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
16 changes: 16 additions & 0 deletions tests/XafLogicExplainer.Tests/SampleProjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ internal static class SampleProjects
/// <summary>Path to the fixture whose module registers predefined reports.</summary>
public static string ReportsPath => Path.Combine(FixturesRoot, "ReportsSolution", "Invoicing.Module");

/// <summary>Path to the fixture written in the pre-SDK project format.</summary>
public static string LegacyFrameworkPath =>
Path.Combine(FixturesRoot, "LegacyFrameworkSolution", "SampleFx.Module");

/// <summary>
/// Three modules standing in for the same developer's work for three different clients.
/// </summary>
Expand Down Expand Up @@ -132,6 +136,7 @@ private static string FixturesRoot
private static readonly Lazy<ExtractedProject> LazyDeepXpo = new(() => Extract(DeepXpoPath));
private static readonly Lazy<ExtractedProject> LazyAuditedXpo = new(() => Extract(AuditedXpoPath));
private static readonly Lazy<ExtractedProject> LazyReports = new(() => Extract(ReportsPath));
private static readonly Lazy<ExtractedProject> LazyLegacyFramework = new(() => Extract(LegacyFrameworkPath));

/// <summary>The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml.</summary>
public static ExtractedProject Xpo => LazyXpo.Value;
Expand Down Expand Up @@ -165,6 +170,17 @@ private static string FixturesRoot
/// </summary>
public static ExtractedProject Reports => LazyReports.Value;

/// <summary>
/// An XPO application in the pre-SDK project format, targeting .NET Framework 4.8.
/// </summary>
/// <remarks>
/// It declares its framework in <c>TargetFrameworkVersion</c> and its DevExpress version in
/// assembly references, neither of which the SDK format uses — so it is the fixture that
/// fails whenever project-file reading quietly assumes the modern spelling. Its C# is written
/// in the old shape too: block namespaces, backing fields, no auto properties.
/// </remarks>
public static ExtractedProject LegacyFramework => LazyLegacyFramework.Value;

/// <summary>
/// An XPO application on an audit base wider than the entities that derive from it.
/// </summary>
Expand Down
Loading
Loading