Skip to content

Commit e680f25

Browse files
peopleworksclaude
andcommitted
Read the framework however the project file spells it
Closes #50. ExtractProjectMetadata read <TargetFramework> and no other spelling. A project from before the SDK format declares <TargetFrameworkVersion>v4.8 and has no <TargetFramework> at all, so it reported an empty framework -- not a wrong one, none. Two of the six real applications came out of `xaflogic wiki` with no framework beside four reporting net7.0 and net9.0. DeclaredTargetFramework reads all three spellings and normalises the pre-SDK one to its moniker: v4.8 becomes net48 and v4.8.1 becomes net481, which are different frameworks and stay distinguishable. A multi-targeting <TargetFrameworks> list, which the old regex could not see either, is reported as the project declared it rather than reduced to one entry -- code has to compile on every framework named there. Null stays distinct from a value, because "no project file was read" and "it targets net48" must never render the same way. Verified on all six real applications: PWPresupuesto net48, pwControlVisita net481, and the four SDK-style ones unchanged. The wiki went from four framework pills to six. AGENTS.md now carries a ground rule for a .NET Framework application rather than only naming the framework in its summary, which 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 on net48 once LangVersion is set, and a rule forbidding all of it would be false -- which would cost the true half its credibility. It names only what no compiler switch supplies: the C# 7.3 default, the absent System.Text.Json, IAsyncEnumerable<T>, Index and Range, default interface methods being impossible at all, and the polyfill attributes record and required need. Written from a reading and never from a default, the same discipline the ORM rule keeps, so a modern application is never handed it. Three render sites printed the empty value rather than omitting it: the Markdown overview and detail page emitted a Framework label with nothing after it, and the MCP overview told an agent "Target framework: ." -- which is not a smaller answer than naming one, it is an unreadable one, and that string is what an agent reasons from. New fixture LegacyFrameworkSolution in the pre-SDK project format, with C# written the way that framework compiles: block namespaces and backing-field properties, no auto properties. A fixture that declared a legacy framework while writing modern C# would prove the project file was read and nothing else. It also reaches DevExpress through assembly references rather than PackageReference, so it pins both project-file readings against the same file. 548 tests, up from 525. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45tzJFX3NoSrk7svtQeKT
1 parent 954a24d commit e680f25

12 files changed

Lines changed: 477 additions & 9 deletions

File tree

CHANGELOG.md

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

4670
## [0.17.0] — 2026-08-24
4771

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ applications. The agent-facing surface is what is landing now, in the open.
473473
|| Pluggable publishing targets (`IDocumentationSink`) |
474474
|| **MCP server** — 12 tools, live against your source |
475475
|| **Installable Claude Code plugin** with skill and MCP server |
476-
|| **525 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
476+
|| **548 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
477477
|| **DevExpress ground-truth catalog**, generated locally by licensees |
478478

479479
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace XafLogicExplainer.Core.Analyzers;
4+
5+
/// <summary>
6+
/// Which framework an application says it targets, however its project file spells it.
7+
/// </summary>
8+
/// <remarks>
9+
/// Read from the project file as text, like everything else here, so it needs no build and no
10+
/// installed SDK.
11+
/// <para>
12+
/// <strong>Three spellings, because XAF applications outlive project formats.</strong> An
13+
/// SDK-style project writes <c>&lt;TargetFramework&gt;</c>, or <c>&lt;TargetFrameworks&gt;</c> when
14+
/// it multi-targets. A project from before the SDK format writes
15+
/// <c>&lt;TargetFrameworkVersion&gt;v4.8&lt;/TargetFrameworkVersion&gt;</c> and has no
16+
/// <c>&lt;TargetFramework&gt;</c> at all. Reading only the first returns nothing for exactly the
17+
/// applications where the constraint is tightest, because a .NET Framework project is the one
18+
/// place most modern C# does not compile.
19+
/// </para>
20+
/// <para>
21+
/// Silence is not a neutral outcome here. An agent handed a document that says nothing about the
22+
/// framework assumes a modern one and reaches for nullable reference types, <c>record</c>,
23+
/// file-scoped namespaces and collection expressions, none of which build on <c>net48</c>. That is
24+
/// the same failure as reporting no reports for an application that has forty: an absence read as
25+
/// information.
26+
/// </para>
27+
/// </remarks>
28+
public static class DeclaredTargetFramework
29+
{
30+
/// <summary><c>&lt;TargetFramework&gt;net9.0&lt;/TargetFramework&gt;</c>.</summary>
31+
private static readonly Regex SdkForm = new(
32+
@"<TargetFramework>\s*(?<tfm>[^<]+?)\s*</TargetFramework>",
33+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
34+
35+
/// <summary>
36+
/// <c>&lt;TargetFrameworks&gt;net8.0;net9.0&lt;/TargetFrameworks&gt;</c> — a project that
37+
/// multi-targets.
38+
/// </summary>
39+
private static readonly Regex MultiForm = new(
40+
@"<TargetFrameworks>\s*(?<tfms>[^<]+?)\s*</TargetFrameworks>",
41+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
42+
43+
/// <summary>
44+
/// <c>&lt;TargetFrameworkVersion&gt;v4.8&lt;/TargetFrameworkVersion&gt;</c> — the pre-SDK
45+
/// spelling, and the only one a .NET Framework project has.
46+
/// </summary>
47+
private static readonly Regex LegacyForm = new(
48+
@"<TargetFrameworkVersion>\s*v?(?<version>\d+(?:\.\d+)*)\s*</TargetFrameworkVersion>",
49+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
50+
51+
/// <summary>
52+
/// The framework a project file declares, or null when it declares none in any spelling.
53+
/// </summary>
54+
/// <remarks>
55+
/// Null is an ordinary outcome and stays distinct from a value: a module whose framework comes
56+
/// from a shared <c>Directory.Build.props</c> declares nothing here, and a project file that
57+
/// could not be found at all declares nothing either. "I did not read one" and "it targets
58+
/// net48" must never render the same way, which is the whole point of the fix.
59+
/// <para>
60+
/// A multi-targeting project is reported as it declared itself, semicolons and all. Picking
61+
/// one of the list would be inventing a fact, and the list is what an agent needs: code has to
62+
/// compile on every framework named there, so the oldest one is the real constraint.
63+
/// </para>
64+
/// </remarks>
65+
public static string? FromProjectFile(string? projectFileContent)
66+
{
67+
if (string.IsNullOrWhiteSpace(projectFileContent))
68+
return null;
69+
70+
if (SdkForm.Match(projectFileContent) is { Success: true } sdk)
71+
return sdk.Groups["tfm"].Value;
72+
73+
if (MultiForm.Match(projectFileContent) is { Success: true } multi)
74+
return multi.Groups["tfms"].Value;
75+
76+
if (LegacyForm.Match(projectFileContent) is { Success: true } legacy)
77+
return Moniker(legacy.Groups["version"].Value);
78+
79+
return null;
80+
}
81+
82+
/// <summary>
83+
/// Whether a moniker names .NET Framework, where most modern C# does not compile.
84+
/// </summary>
85+
/// <remarks>
86+
/// Matched on the moniker rather than remembered from the parse, so it is equally right about
87+
/// an SDK-style project that targets <c>net472</c> — which is legal, and is how a migrated
88+
/// application often looks halfway through. <c>net5.0</c> and everything after carry a dot;
89+
/// .NET Framework monikers never do.
90+
/// </remarks>
91+
public static bool IsDotNetFramework(string? moniker) =>
92+
!string.IsNullOrWhiteSpace(moniker)
93+
&& DotNetFrameworkMoniker.IsMatch(moniker);
94+
95+
private static readonly Regex DotNetFrameworkMoniker = new(
96+
@"^net[1-4]\d*$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
97+
98+
/// <summary>
99+
/// <c>4.8</c> becomes <c>net48</c>, <c>4.8.1</c> becomes <c>net481</c>.
100+
/// </summary>
101+
/// <remarks>
102+
/// Dots removed, which is the whole of the .NET Framework moniker rule and is why
103+
/// <c>net481</c> and <c>net48</c> are different frameworks rather than a typo of each other.
104+
/// </remarks>
105+
private static string Moniker(string version) =>
106+
"net" + version.Replace(".", string.Empty, StringComparison.Ordinal);
107+
}

src/XafLogicExplainer.Core/Analyzers/LogicExtractor.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,10 @@ private static void ExtractProjectMetadata(string projectPath, ExtractedProject
330330

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

339338
// Extract PackageReferences
340339
var packageMatches = System.Text.RegularExpressions.Regex.Matches(csprojContent,

src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,27 @@ private static void WriteGroundRules(
225225

226226
sb.AppendLine();
227227

228+
// The framework, but only where it forbids something. Written from a reading and never
229+
// from a default, the same discipline as the ORM rule above and for the same reason: an
230+
// agent cannot tell a guess from a fact, so a constraint stated on an assumption is worse
231+
// than no constraint at all. A project that declared no framework gets no rule.
232+
//
233+
// Narrow on purpose. Most modern C# syntax does compile here once `LangVersion` is set,
234+
// and a rule that forbade all of it would be false -- which would cost the true half its
235+
// credibility. What is listed is what no compiler switch can supply.
236+
if (Analyzers.DeclaredTargetFramework.IsDotNetFramework(project.TargetFramework))
237+
{
238+
sb.AppendLine($"**{rule++}. This is a .NET Framework application (`{project.TargetFramework}`).**");
239+
sb.AppendLine("The C# language version there defaults to **7.3** unless the project file sets");
240+
sb.AppendLine("`LangVersion`, and the class library is the .NET Framework one:");
241+
sb.AppendLine("`System.Text.Json`, `IAsyncEnumerable<T>`, `Index` and `Range` are absent without a");
242+
sb.AppendLine("package, and default interface methods cannot work at all. `record`, init-only");
243+
sb.AppendLine("setters and `required` members compile only where a polyfill attribute is supplied.");
244+
sb.AppendLine("Match the C# already in these files rather than what you would write by default, and");
245+
sb.AppendLine("read the project file before using anything newer.");
246+
sb.AppendLine();
247+
}
248+
228249
// Rule 2: the closed-world statement. This is the load-bearing one.
229250
sb.AppendLine($"**{rule++}. The inventories {inventoryLocation} are complete.**");
230251
sb.AppendLine("They were extracted from the whole source tree, not sampled. If an entity, controller or");

src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ public string GenerateMarkdown(ExtractedProject project)
4747
sb.AppendLine($"# {project.ProjectName} - {_l.FunctionalDocumentation}");
4848
sb.AppendLine();
4949
sb.AppendLine($"*Generated: {project.ExtractedAt}*");
50-
sb.AppendLine($"*Framework: {project.TargetFramework}*");
50+
// Only when one was read. `*Framework: *` is a field with nothing in it, which reads as
51+
// a broken document rather than as the honest "this was not declared".
52+
if (!string.IsNullOrWhiteSpace(project.TargetFramework))
53+
sb.AppendLine($"*Framework: {project.TargetFramework}*");
5154
sb.AppendLine();
5255

5356
foreach (var section in sections)
@@ -732,7 +735,8 @@ private DocumentSection GenerateOverviewSection(ExtractedProject project)
732735
sb.AppendLine($"## {_l.Summary}");
733736
sb.AppendLine();
734737
sb.AppendLine($"- **{_l.Project}:** {project.ProjectName}");
735-
sb.AppendLine($"- **{_l.Framework}:** {project.TargetFramework}");
738+
if (!string.IsNullOrWhiteSpace(project.TargetFramework))
739+
sb.AppendLine($"- **{_l.Framework}:** {project.TargetFramework}");
736740
sb.AppendLine($"- **ORM:** {project.OrmType}");
737741
sb.AppendLine($"- **{_l.BusinessEntitiesCount}:** {project.Entities.Count}");
738742
sb.AppendLine($"- **{_l.ControllersCount}:** {project.Controllers.Count}");

src/XafLogicExplainer.Mcp/Tools/XafDiscoveryTools.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ public async Task<string> OverviewAsync(
4141

4242
sb.AppendLine($"# {app.ProjectName}");
4343
sb.AppendLine();
44-
sb.AppendLine($"DevExpress XAF application. Persistence: **{orm}**. Target framework: {app.TargetFramework}.");
44+
sb.Append($"DevExpress XAF application. Persistence: **{orm}**.");
45+
46+
// Omitted rather than printed empty. "Target framework: ." is not a smaller answer than
47+
// naming one, it is an unreadable one, and this string is what an agent reasons from.
48+
if (!string.IsNullOrWhiteSpace(app.TargetFramework))
49+
sb.Append($" Target framework: {app.TargetFramework}.");
50+
51+
sb.AppendLine();
4552
sb.AppendLine();
4653
sb.AppendLine($"- Entities: **{app.Entities.Count}**");
4754
sb.AppendLine($"- Controllers: **{app.Controllers.Count}** exposing **{actionCount}** actions");
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using DevExpress.Persistent.Base;
2+
using DevExpress.Persistent.BaseImpl;
3+
using DevExpress.Xpo;
4+
5+
namespace SampleFx.Module.BusinessObjects
6+
{
7+
/// <summary>
8+
/// A contract, written the way a .NET Framework XAF project writes one.
9+
/// </summary>
10+
/// <remarks>
11+
/// Property-with-backing-field rather than an auto property, and a block namespace rather than
12+
/// a file-scoped one, because that is what compiles under the C# version this project gets by
13+
/// default. The extractor must read it as readily as it reads the modern shape.
14+
/// </remarks>
15+
[DefaultClassOptions]
16+
[NavigationItem("Operaciones")]
17+
public class Contrato : BaseObject
18+
{
19+
private string numero;
20+
private decimal monto;
21+
22+
public Contrato(Session session) : base(session) { }
23+
24+
[Size(20)]
25+
public string Numero
26+
{
27+
get { return numero; }
28+
set { SetPropertyValue(nameof(Numero), ref numero, value); }
29+
}
30+
31+
public decimal Monto
32+
{
33+
get { return monto; }
34+
set { SetPropertyValue(nameof(Monto), ref monto, value); }
35+
}
36+
}
37+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using DevExpress.ExpressApp;
2+
using DevExpress.ExpressApp.Actions;
3+
using SampleFx.Module.BusinessObjects;
4+
5+
namespace SampleFx.Module.Controllers
6+
{
7+
public class ContratoController : ViewController
8+
{
9+
private SimpleAction cerrarContrato;
10+
11+
public ContratoController()
12+
{
13+
TargetObjectType = typeof(Contrato);
14+
15+
cerrarContrato = new SimpleAction(this, "CerrarContrato", "Edit")
16+
{
17+
Caption = "Cerrar contrato"
18+
};
19+
20+
cerrarContrato.Execute += CerrarContrato_Execute;
21+
}
22+
23+
private void CerrarContrato_Execute(object sender, SimpleActionExecuteEventArgs e)
24+
{
25+
ObjectSpace.CommitChanges();
26+
}
27+
}
28+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
3+
<!--
4+
Fixture only. Never built.
5+
6+
The pre-SDK project format, which is what an XAF application written before .NET Core looks
7+
like and what a great many of them still are. Two things about it matter here: the framework
8+
is declared as `TargetFrameworkVersion`, an element the SDK format does not have, and there is
9+
no PackageReference anywhere -- DevExpress arrives as assembly references carrying the version
10+
in the file name.
11+
-->
12+
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
13+
<PropertyGroup>
14+
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
15+
<OutputType>Library</OutputType>
16+
<RootNamespace>SampleFx.Module</RootNamespace>
17+
<AssemblyName>SampleFx.Module</AssemblyName>
18+
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
19+
<FileAlignment>512</FileAlignment>
20+
</PropertyGroup>
21+
<ItemGroup>
22+
<Reference Include="DevExpress.ExpressApp.v22.1, Version=22.1.6.0, Culture=neutral, PublicKeyToken=b88d1754d700e49a" />
23+
<Reference Include="DevExpress.ExpressApp.Xpo.v22.1, Version=22.1.6.0, Culture=neutral, PublicKeyToken=b88d1754d700e49a" />
24+
<Reference Include="DevExpress.Persistent.BaseImpl.v22.1, Version=22.1.6.0, Culture=neutral, PublicKeyToken=b88d1754d700e49a" />
25+
<Reference Include="System" />
26+
<Reference Include="System.Core" />
27+
</ItemGroup>
28+
<ItemGroup>
29+
<Compile Include="BusinessObjects\Contrato.cs" />
30+
<Compile Include="Controllers\ContratoController.cs" />
31+
</ItemGroup>
32+
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
33+
</Project>

0 commit comments

Comments
 (0)