Skip to content

Narrow the DbSet roster to what the application actually registers - #4

Merged
peopleworks merged 2 commits into
mainfrom
fix/dbset-roster-precision
Aug 13, 2026
Merged

Narrow the DbSet roster to what the application actually registers#4
peopleworks merged 2 commits into
mainfrom
fix/dbset-roster-precision

Conversation

@peopleworks

Copy link
Copy Markdown
Owner

Follow-up to #3, which I merged before this was fixed. @MBrekhof — asking you to review this one, since it is your feature and you will spot faster than anyone whether I have narrowed it too far.

The roster idea is right and it stays. What it needed was an identity: it landed as a set of bare simple names matched globally, and a bare name is not an identity.

The three

1. Every part of a partial class matched. Matching by base class could only ever match once — one part declares the base list. Matching by name matches all of them, and the scaffolded split that produces two parts is exactly the population the roster is for. The class came out twice, each copy holding half its columns:

SampleLegacy.Module.BusinessObjects.Shipment  props=2  file=Shipment.cs
SampleLegacy.Module.BusinessObjects.Shipment  props=2  file=Shipment.Generated.cs

Two incomplete truths, with nothing to tell a reader they are the same class.

2. A DTO sharing a simple name became a table. BusinessObjects.Contracts.Warehouse is a wire shape; BusinessObjects.Warehouse is the entity. The roster could not tell them apart.

3. DbSet<T> as a local counted as a registration. CollectDbSetTypeNames walked every GenericNameSyntax named DbSet anywhere in the source, so DbSet<AuditEntry> entries = database.Set<AuditEntry>() inside a method body registered AuditEntry.

What changed

Registrations now carry the namespaces they could have been naming — the registering file's usings, its own namespace, and the namespaces enclosing it. That is ordinary C# lookup, the part of it syntax can see. Aliases, using static and extern aliases are not modelled: a registration needing one of those finds no class and is dropped, which is the safe direction, and it is written down in the remark rather than left for someone to discover.

They are read only from the properties a DbContext declares. Contexts are found through their base chain too, because an application that puts auditing in a shared AuditedDbContext has no context that names DbContext in its own declaration — reading only classes that say : DbContext would have found nothing in one, which is the same silent-empty failure the roster exists to fix. That base walk is by simple name; the remark says so, and says why the cost is small here: a class only contributes if it also declares DbSet<T> properties.

The parts of a partial class are folded into one entity. That also recovers members XPO extraction has always dropped, where a hand-written part carries : BaseObject and a generated part carries the mapping — a pre-existing gap, silent until now because nothing ever reported the second part at all.

Verification

Passed!  Failed: 0, Passed: 279, Skipped: 0     0 warnings, 0 errors

Three of the four new tests fail on main:

Failed  ReportsAPartialClassOnceWithAllOfItsProperties
Failed  DoesNotPersistADtoThatMerelySharesAnEntityName
Failed  DoesNotRegisterADbSetWrittenAsALocal

The fourth, FindsEntitiesRegisteredThroughASharedContextBase, passes on main — it has to, since the old code matched that name from anywhere. It is a regression guard on this change, not a demonstration of the bug, and I would rather say that than let a green tick imply otherwise.

One thing worth knowing about the blast radius

My first draft of these fixtures put the DTO in Module/Contracts/ and the repository in Module/Services/, and two of the three tests passed without the fix. Entity extraction only reads **/BusinessObjects/**, so neither file was ever parsed.

So (2) and (3) only bite when the colliding class or the stray DbSet<T> sits inside a BusinessObjects folder — which happens, but is narrower than I first wrote in the review. The fixtures were moved there so the tests are load-bearing. Correcting my own overstatement, since the rule cuts both ways.

Still open

#2 is yours if you want it. Everything in this PR is downstream of the same idea: where the tool cannot tell, it must not pick a side and state it.

The roster landed in #3 as a set of bare simple names matched globally, which
reports more than the application declares in three ways: every part of a
partial class matches, so a scaffolded split becomes two entities each holding
half its columns; a DTO sharing a simple name joins the business inventory; and
a DbSet<T> written as a local counts the same as a registration.

Registrations now carry the namespaces they could have been naming, are read
only from the properties of a DbContext -- found through its base chain -- and
the parts of a partial class are folded into the one entity they describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +536 to +540
foreach (var directive in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
if (directive.Alias is null && directive.Name is not null)
scopes.Add(directive.Name.ToString());
}
Comment on lines +553 to +562
foreach (var property in context.Members.OfType<PropertyDeclarationSyntax>())
{
if (property.Type is not GenericNameSyntax generic) continue;
if (generic.Identifier.Text != "DbSet") continue;
if (generic.TypeArgumentList.Arguments.Count != 1) continue;

foreach (var root in roots)
var argument = generic.TypeArgumentList.Arguments[0].ToString();
if (argument.Length > 0)
roster._registrations.Add((argument, scopes));
}
Comment on lines +624 to +631
foreach (var candidate in declared)
{
if (contextNames.Contains(candidate.Identifier.Text)) continue;
if (!GetBaseTypeNames(candidate).Any(contextNames.Contains)) continue;

contextNames.Add(candidate.Identifier.Text);
grew = true;
}
CI caught this on Linux while Windows passed: which half of a partial class
becomes the primary depended on the file system doing the listing. NTFS
compares names without case and ext4 by byte, so Shipment.Generated.cs sorts
after Shipment.cs on one and before it on the other -- and with it went the
entity's reported file and the order of its columns.

Files are now read in ordinal path order, and the part declaring the base list
is the primary regardless. A document that cannot be regenerated identically is
one nobody can diff, which is most of what regenerating it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +681 to +721
foreach (var key in order)
{
var group = parts[key];

// The part declaring the base list is the hand-written one, and it is where the class
// attributes live. Taking whichever half came first instead would make the reported
// file, and the order of the columns, depend on the file system doing the listing.
var primary = group.Find(part => part.BaseTypes.Count > 0) ?? group[0];
merged.Add(primary);

foreach (var entity in group)
{
if (ReferenceEquals(entity, primary)) continue;

primary.Description ??= entity.Description;
primary.NavigationGroup ??= entity.NavigationGroup;
primary.DefaultProperty ??= entity.DefaultProperty;
primary.ModelCaption ??= entity.ModelCaption;
primary.SourceProject ??= entity.SourceProject;
primary.IsDefaultClassOptions |= entity.IsDefaultClassOptions;
primary.IsCloneable |= entity.IsCloneable;

// Non-persistent anywhere means non-persistent: one part saying so is the class.
primary.IsPersistent &= entity.IsPersistent;

if (primary.BaseType is "object" or "" && entity.BaseType is not ("object" or ""))
primary.BaseType = entity.BaseType;

foreach (var baseType in entity.BaseTypes.Where(name => !primary.BaseTypes.Contains(name)))
primary.BaseTypes.Add(baseType);

var known = primary.Properties.Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
primary.Properties.AddRange(entity.Properties.Where(property => known.Add(property.Name)));

primary.Relationships.AddRange(entity.Relationships);
primary.ValidationRules.AddRange(entity.ValidationRules);
primary.AppearanceRules.AddRange(entity.AppearanceRules);
primary.InferredBusinessRules.AddRange(entity.InferredBusinessRules);
primary.SourceComments.AddRange(entity.SourceComments);
}
}
Comment on lines +691 to +720
foreach (var entity in group)
{
if (ReferenceEquals(entity, primary)) continue;

primary.Description ??= entity.Description;
primary.NavigationGroup ??= entity.NavigationGroup;
primary.DefaultProperty ??= entity.DefaultProperty;
primary.ModelCaption ??= entity.ModelCaption;
primary.SourceProject ??= entity.SourceProject;
primary.IsDefaultClassOptions |= entity.IsDefaultClassOptions;
primary.IsCloneable |= entity.IsCloneable;

// Non-persistent anywhere means non-persistent: one part saying so is the class.
primary.IsPersistent &= entity.IsPersistent;

if (primary.BaseType is "object" or "" && entity.BaseType is not ("object" or ""))
primary.BaseType = entity.BaseType;

foreach (var baseType in entity.BaseTypes.Where(name => !primary.BaseTypes.Contains(name)))
primary.BaseTypes.Add(baseType);

var known = primary.Properties.Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
primary.Properties.AddRange(entity.Properties.Where(property => known.Add(property.Name)));

primary.Relationships.AddRange(entity.Relationships);
primary.ValidationRules.AddRange(entity.ValidationRules);
primary.AppearanceRules.AddRange(entity.AppearanceRules);
primary.InferredBusinessRules.AddRange(entity.InferredBusinessRules);
primary.SourceComments.AddRange(entity.SourceComments);
}
@peopleworks
peopleworks merged commit ae15039 into main Aug 13, 2026
6 checks passed
@peopleworks peopleworks mentioned this pull request Aug 13, 2026
@MBrekhof

Copy link
Copy Markdown

Reviewed against the application that motivated #1 — 221 entities, legacy LIMS schema, partial classes throughout, which is the corpus that would show over-narrowing if it were there.

You did not narrow it too far. Nothing was lost.

#3 as merged 0.12.1
entity headings emitted 210 196
unique entity names 196 196
present before, absent now none

The set difference is empty in both directions. All 14 that disappeared were the second declaration of a partial: Analysis, Batch, Catalogue, Component, COosDistrItem, COosDistrList, CPackage, Customer, Result, Sample, SamplingPoint, TDistributionList, Test, TestList. So my 210 was never 210 entities — it was 196 entities and 14 half-truths, exactly as you described.

The merge is also exact rather than lossy, which is the part I most wanted to check:

Analysis   0.12.0+#3: 76 props + 1 props (two blocks)   ->  0.12.1: 77 props (one)
Sample     0.12.0+#3: 179 props + 1 props (two blocks)  ->  0.12.1: 180 props (one)

Every property survived the fold; none was counted twice. 279 passed, 0 failed, 0 warnings locally on Windows, .NET 10.

Your three findings were all mine and all correct. The one I want to record for anyone reading later is why my guard test didn't catch them: DoesNotReportFrameworkTypesRegisteredInTheDbContext asserts on names, and all three defects were name-collision defects. A test written in the same currency as the bug cannot see it. Yours are keyed on identity instead, which is the right currency.


One thing I noticed while reading, unrelated to this PR and possibly already known.

ExtractionOptions.BaseTypeNames defaults to ["XPCustomObject", "BaseObject", "XPObject", "XPLiteObject"], but SampleProjects.Extract adds "PermissionPolicyUser" before extracting. So the suite exercises a five-name list the CLI never uses. In the application above, ApplicationUser : PermissionPolicyUser is currently found only because it is also a DbSet — under XPO, or under EF Core if the security tables are registered somewhere the roster does not reach, it would still be missed.

That is the same one-hop shape as the transitive base walk we talked about, so I will fold it into that issue rather than open a second one.

@peopleworks

Copy link
Copy Markdown
Owner Author

This is the review I could not have done. I had 279 synthetic tests and one claim about a corpus I have never seen; you had the corpus. Running the merged change against 221 entities and reporting the set difference in both directions is the only thing that could have told me whether I narrowed it too far, and it is worth saying that a green suite on my side was never evidence of that.

The property counts are the part I care about most:

Analysis   76 + 1  ->  77
Sample    179 + 1  -> 180

Exact, not lossy. That was the risk in folding the parts rather than just dropping the duplicate, and it is now measured rather than argued.

A test written in the same currency as the bug cannot see it.

I am going to quote you on that, with attribution, in the test file's remark. DoesNotReportFrameworkTypesRegisteredInTheDbContext was a good guard aimed at the right property — it just asserted on names while the defects were about identity, so it was structurally blind to all three. That is a sharper statement of the project's rule than the rule itself, and it generalises well past this repository.


On BaseTypeNames — you found something real, and the shape is worse than you described. Filed as #7 rather than folded into #6, since it is a different defect.

The CLI does use the five-name list. So do the sync service and the MCP context; all three write it out by hand, and the tests agree with them:

Cli/Program.cs:1841                        5 names
CopilotSync/IncrementalSyncService.cs:71   5 names
Mcp/XafProjectContext.cs:162               5 names
tests/SampleProjects.cs:140                5 names
ExtractionOptions.cs:57  (the default)     4 names   <- the odd one

It is the default that agrees with nobody. Which means the population that loses ApplicationUser is not our test suite — it is anyone consuming XafLogicExplainer.Core as a library from NuGet, who gets four names and no indication that a fifth was ever expected. Our own tools are correct precisely because they all remember to override, and that is what kept it invisible: every code path we exercise patches around the value a stranger gets.

Which also means no test could have caught it, since the tests override it too. #7 asks for one that reads new ExtractionOptions() on purpose.

@MBrekhof

Copy link
Copy Markdown

Correction to my comment above: I claimed ExtractionOptions.BaseTypeNames defaults to four names "the CLI never uses", and that ApplicationUser : PermissionPolicyUser is found in tests but missed in the field. That is wrong — both shipping callers pass the five-name list explicitly (Cli/Program.cs:1841, Mcp/XafProjectContext.cs:162), so the harness matches the real callers and ApplicationUser is found. Only the unused class default has four names. I found it by running MainDemo.NET.XPO, where ApplicationUser came out fine and four other classes did not — details in #6. The verification numbers in the comment above are unaffected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants