Skip to content

Commit 894c8b0

Browse files
peopleworksclaude
andcommitted
Restore three source files my own .gitignore was swallowing
A `catalog/` rule, meant for the generated DevExpress catalog, matches a directory of that name at ANY depth -- so it silently excluded src/XafLogicExplainer.Core/Catalog/. XafCatalog, XafCatalogStore and CatalogEnricher were never committed. Every clean checkout failed to compile while my local build kept passing, because the files were still on disk. That is the same shape as the bug I fixed in the analyzers a few commits ago: matching by name where the match should have been specific. The generated catalog is now excluded by its extension, and it lives outside any repository anyway. Verified by building a fresh clone rather than the working tree, which is the only check that could have caught this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2024d3d commit 894c8b0

4 files changed

Lines changed: 418 additions & 1 deletion

File tree

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ coverage*.xml
5252
# The DevExpress ground-truth catalog is generated locally by licensees from
5353
# their own installation (see tools/XafLogicExplainer.DxCatalog). Nothing
5454
# derived from DevExpress sources is ever committed to this repository.
55-
catalog/
55+
#
56+
# Matched by file extension, NOT by a `catalog/` directory rule. That rule matched
57+
# any directory of that name at any depth, so it silently swallowed
58+
# src/XafLogicExplainer.Core/Catalog/ -- three source files were never committed,
59+
# and every clean checkout failed to compile while the local build sailed on
60+
# because the files were still sitting on disk.
5661
*.dxcatalog.json
5762

5863
# Working notes and scratch. One `git add -A` from the root is all it takes to
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using XafLogicExplainer.Core.Models;
2+
3+
namespace XafLogicExplainer.Core.Catalog;
4+
5+
/// <summary>
6+
/// Annotates an extracted application with what the framework, rather than the team, provides.
7+
/// </summary>
8+
/// <remarks>
9+
/// Runs after extraction rather than inside the analyzers, so the catalog stays entirely optional:
10+
/// with none present nothing here executes and the result is what it always was.
11+
/// <para>
12+
/// It answers one question the source alone cannot: which of these names came with XAF? A
13+
/// controller deriving from <c>DeleteObjectsViewController</c> is extending shipped behavior; one
14+
/// deriving from a class in the same project is not, and an agent asked to change the first should
15+
/// know it is looking at framework code.
16+
/// </para>
17+
/// </remarks>
18+
public static class CatalogEnricher
19+
{
20+
/// <summary>
21+
/// Applies catalog knowledge to an extracted project.
22+
/// </summary>
23+
/// <param name="project">The project to annotate, modified in place.</param>
24+
/// <param name="catalog">The catalog, or null to do nothing.</param>
25+
public static void Enrich(ExtractedProject project, XafCatalog? catalog)
26+
{
27+
if (catalog is null)
28+
return;
29+
30+
project.CatalogVersion = catalog.DevExpressVersion;
31+
32+
EnrichControllers(project, catalog);
33+
EnrichAttributes(project, catalog);
34+
}
35+
36+
/// <summary>
37+
/// The generic entry points every XAF controller starts from.
38+
/// </summary>
39+
/// <remarks>
40+
/// Deriving from one of these carries no information: it says "this is a controller", which
41+
/// the reader already knows from the word Controller in its name. Reporting them turned the
42+
/// section into a list of every controller in the application, each annotated "A View
43+
/// Controller" — noise that buries the one case worth noticing.
44+
/// </remarks>
45+
private static readonly HashSet<string> GenericControllerBases = new(StringComparer.Ordinal)
46+
{
47+
"Controller",
48+
"ViewController",
49+
"ObjectViewController",
50+
"WindowController",
51+
};
52+
53+
private static void EnrichControllers(ExtractedProject project, XafCatalog catalog)
54+
{
55+
var ownControllers = project.Controllers
56+
.Select(c => c.ClassName)
57+
.ToHashSet(StringComparer.Ordinal);
58+
59+
foreach (var controller in project.Controllers)
60+
{
61+
// A base type defined in this same project is the team's own layering, not framework
62+
// behavior, even when the catalog happens to hold a type of that name.
63+
if (ownControllers.Contains(StripGenerics(controller.BaseControllerType)))
64+
continue;
65+
66+
var frameworkBase = catalog.FindController(controller.BaseControllerType);
67+
68+
if (frameworkBase is null || GenericControllerBases.Contains(frameworkBase.Name))
69+
continue;
70+
71+
// What remains is a controller extending a specific piece of shipped behavior --
72+
// DeleteObjectsViewController, ExportController -- which changes how an existing
73+
// feature works rather than adding a new one.
74+
controller.FrameworkBaseType = frameworkBase.Name;
75+
controller.FrameworkBaseSummary = frameworkBase.Summary;
76+
controller.FrameworkBaseDocumentationUrl = frameworkBase.DocumentationUrl;
77+
}
78+
}
79+
80+
/// <summary>
81+
/// Separates the attributes XAF defines from the ones this application defines itself.
82+
/// </summary>
83+
/// <remarks>
84+
/// A custom attribute is a signal worth surfacing: it usually marks a convention the team
85+
/// invented, which an agent has no other way to learn about and cannot look up in any
86+
/// documentation.
87+
/// </remarks>
88+
private static void EnrichAttributes(ExtractedProject project, XafCatalog catalog)
89+
{
90+
var seen = new HashSet<string>(StringComparer.Ordinal);
91+
var custom = new SortedSet<string>(StringComparer.Ordinal);
92+
93+
foreach (var entity in project.Entities)
94+
{
95+
foreach (var attributeName in entity.Properties.SelectMany(p => p.CustomAttributes))
96+
{
97+
var name = NormalizeAttributeName(attributeName);
98+
99+
if (name.Length == 0 || !seen.Add(name))
100+
continue;
101+
102+
if (catalog.FindAttribute(name) is null && !IsFrameworkNamespace(name))
103+
custom.Add(name);
104+
}
105+
}
106+
107+
project.CustomAttributes = [.. custom];
108+
}
109+
110+
/// <summary>
111+
/// Reduces an attribute as written in source to its bare name.
112+
/// </summary>
113+
private static string NormalizeAttributeName(string attribute)
114+
{
115+
var name = attribute.Trim();
116+
117+
// "[RuleRequiredField("id", DefaultContexts.Save)]" -> "RuleRequiredField"
118+
var parenthesis = name.IndexOf('(');
119+
if (parenthesis > 0)
120+
name = name[..parenthesis];
121+
122+
name = name.Trim('[', ']', ' ');
123+
124+
var lastDot = name.LastIndexOf('.');
125+
if (lastDot >= 0)
126+
name = name[(lastDot + 1)..];
127+
128+
return name.Trim();
129+
}
130+
131+
/// <summary>
132+
/// Whether a name is a .NET attribute that no XAF catalog would list.
133+
/// </summary>
134+
/// <remarks>
135+
/// EF Core applications annotate with <c>System.ComponentModel.DataAnnotations</c>, which is
136+
/// part of .NET rather than of XAF. Reporting those as "your own" would be wrong and would
137+
/// bury the handful that genuinely are.
138+
/// </remarks>
139+
private static bool IsFrameworkNamespace(string attributeName) =>
140+
attributeName is
141+
"Required" or "StringLength" or "MaxLength" or "MinLength" or "Range" or
142+
"Key" or "ForeignKey" or "NotMapped" or "Column" or "Table" or "InverseProperty" or
143+
"Description" or "DisplayName" or "Browsable" or "DefaultValue" or "Obsolete" or
144+
"DataType" or "Display" or "Editable" or "Compare" or "RegularExpression" or
145+
"EmailAddress" or "Phone" or "Url" or "CreditCard" or "Timestamp" or "ConcurrencyCheck";
146+
147+
private static string StripGenerics(string? typeName)
148+
{
149+
if (string.IsNullOrWhiteSpace(typeName))
150+
return string.Empty;
151+
152+
var generic = typeName.IndexOf('<');
153+
return generic > 0 ? typeName[..generic] : typeName;
154+
}
155+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace XafLogicExplainer.Core.Catalog;
5+
6+
/// <summary>
7+
/// What the XAF framework itself provides: its attributes, controllers, model interfaces and modules.
8+
/// </summary>
9+
/// <remarks>
10+
/// Extraction reads an application's source without knowing anything about the framework it is
11+
/// written against. That makes one question unanswerable: is <c>DeleteObjectsViewController</c>
12+
/// something this team wrote, or something DevExpress ships? Without an answer, generated
13+
/// documentation presents framework behavior and the application's own logic as though they were
14+
/// the same thing — and an agent asked to change the former will happily try.
15+
/// <para>
16+
/// The catalog supplies that ground truth. It is generated locally by a licensee from their own
17+
/// DevExpress installation (see <c>xaflogic catalog build</c>) and written outside the repository.
18+
/// <strong>Nothing derived from DevExpress is distributed with this project.</strong>
19+
/// </para>
20+
/// <para>
21+
/// It is entirely optional. With no catalog present, extraction behaves exactly as it did before
22+
/// one existed.
23+
/// </para>
24+
/// </remarks>
25+
public sealed class XafCatalog
26+
{
27+
/// <summary>Version of the DevExpress installation this was generated from, e.g. "26.1".</summary>
28+
public string DevExpressVersion { get; init; } = string.Empty;
29+
30+
/// <summary>When it was generated, in round-trip format.</summary>
31+
public string GeneratedAt { get; init; } = string.Empty;
32+
33+
/// <summary>Assemblies that were read.</summary>
34+
public List<string> Assemblies { get; init; } = [];
35+
36+
/// <summary>XAF attributes, keyed by simple type name.</summary>
37+
public Dictionary<string, XafCatalogType> Attributes { get; init; } = [];
38+
39+
/// <summary>Framework controllers, keyed by simple type name.</summary>
40+
public Dictionary<string, XafCatalogType> Controllers { get; init; } = [];
41+
42+
/// <summary>Application Model interfaces, keyed by simple type name.</summary>
43+
public Dictionary<string, XafCatalogType> ModelInterfaces { get; init; } = [];
44+
45+
/// <summary>Framework modules, keyed by simple type name.</summary>
46+
public Dictionary<string, XafCatalogType> Modules { get; init; } = [];
47+
48+
/// <summary>Total number of framework types recorded.</summary>
49+
[JsonIgnore]
50+
public int TypeCount =>
51+
Attributes.Count + Controllers.Count + ModelInterfaces.Count + Modules.Count;
52+
53+
/// <summary>
54+
/// Looks up an attribute by the name as written in source, with or without the suffix.
55+
/// </summary>
56+
/// <remarks>
57+
/// C# lets <c>[Description]</c> stand for <c>DescriptionAttribute</c>, and source is written
58+
/// both ways, so both must resolve.
59+
/// </remarks>
60+
/// <param name="attributeName">Name as it appears in the attribute list.</param>
61+
public XafCatalogType? FindAttribute(string? attributeName)
62+
{
63+
if (string.IsNullOrWhiteSpace(attributeName))
64+
return null;
65+
66+
var name = attributeName.Trim();
67+
68+
if (Attributes.TryGetValue(name, out var exact))
69+
return exact;
70+
71+
return Attributes.TryGetValue(name + "Attribute", out var suffixed) ? suffixed : null;
72+
}
73+
74+
/// <summary>
75+
/// Looks up a controller by name, ignoring any generic arguments.
76+
/// </summary>
77+
/// <param name="typeName">A base type as written in source, e.g. <c>ViewController&lt;DetailView&gt;</c>.</param>
78+
public XafCatalogType? FindController(string? typeName)
79+
{
80+
if (string.IsNullOrWhiteSpace(typeName))
81+
return null;
82+
83+
var name = typeName.Trim();
84+
85+
var generic = name.IndexOf('<');
86+
if (generic > 0)
87+
name = name[..generic];
88+
89+
var lastDot = name.LastIndexOf('.');
90+
if (lastDot >= 0)
91+
name = name[(lastDot + 1)..];
92+
93+
return Controllers.TryGetValue(name, out var controller) ? controller : null;
94+
}
95+
96+
/// <summary>Serialization settings shared by the generator and the loader.</summary>
97+
public static JsonSerializerOptions JsonOptions { get; } = new()
98+
{
99+
WriteIndented = true,
100+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
101+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
102+
PropertyNameCaseInsensitive = true,
103+
};
104+
105+
/// <summary>Serializes the catalog.</summary>
106+
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
107+
108+
/// <summary>Deserializes a catalog, returning null when the text is not one.</summary>
109+
/// <param name="json">Catalog JSON.</param>
110+
public static XafCatalog? FromJson(string json)
111+
{
112+
try
113+
{
114+
return JsonSerializer.Deserialize<XafCatalog>(json, JsonOptions);
115+
}
116+
catch (JsonException)
117+
{
118+
return null;
119+
}
120+
}
121+
}
122+
123+
/// <summary>
124+
/// One type the XAF framework provides.
125+
/// </summary>
126+
public sealed class XafCatalogType
127+
{
128+
/// <summary>Simple type name, e.g. <c>DeleteObjectsViewController</c>.</summary>
129+
public string Name { get; init; } = string.Empty;
130+
131+
/// <summary>Namespace it lives in.</summary>
132+
public string Namespace { get; init; } = string.Empty;
133+
134+
/// <summary>Assembly it ships in, without the version suffix.</summary>
135+
public string Assembly { get; init; } = string.Empty;
136+
137+
/// <summary>Immediate base type, for controllers.</summary>
138+
public string? BaseType { get; init; }
139+
140+
/// <summary>First sentence of the official documentation, when the XML docs supplied one.</summary>
141+
public string? Summary { get; init; }
142+
143+
/// <summary>Official documentation URL, when the XML docs linked one.</summary>
144+
public string? DocumentationUrl { get; init; }
145+
146+
/// <summary>Whether the type is abstract, which for a controller means it is meant to be derived from.</summary>
147+
public bool IsAbstract { get; init; }
148+
149+
/// <summary>Full name, for display.</summary>
150+
[JsonIgnore]
151+
public string FullName => string.IsNullOrEmpty(Namespace) ? Name : $"{Namespace}.{Name}";
152+
}

0 commit comments

Comments
 (0)