R-AL: restore ReferenceMembers' IsReference guard and fix D10's blind spot

CultNetSelectionEvaluator.ReferenceMembers dropped the `!member.IsReference`
guard on the premise that a data member's role string could never coincide
with a reference member's alias - false. CultCache's D10 only rejects a
collision between two *aliased* members; an unaliased data member's bare
name was never checked against another member's alias. A data member named
`Owner` plus a reference member aliased `Owner` registered fine, then faulted
every hop through it: ReferenceMembers yielded the data member, ReferencesOf
matched it to the reference member's edges by role string, and
`member.TargetType!` was null.

Two independent fixes, both regression-tested:
- Restore the guard, delete the false comment. Pinned directly by hand-
  building the colliding descriptor via reflection (bypassing the registry,
  since D10b below now refuses that shape at the only production door).
- D10b: registration now refuses a plain member whose bare name collides
  with a reference member's alias in the reference-role namespace
  (IndexAlias ?? MemberName), not just alias-vs-alias.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 07:15:33 +02:00
commit 375b6177ae
2 changed files with 46 additions and 6 deletions

View file

@ -1096,6 +1096,25 @@ namespace GameCult.Caching
rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));
}
// D10b: the reference-role namespace is (IndexAlias ?? MemberName) for every member, not
// only the aliased ones D10 groups above. A member with no [CultIndex] still contributes
// its bare name to that namespace, and a reference member declaring the same string as its
// alias collides with it - the collision D10 cannot see because an unaliased member's Alias
// is null.
foreach (var group in keyed
.Select(entry => (
Entry: entry,
Role: ResolveIndexAlias(entry.Member) ?? entry.Member.Name,
IsReference: PersistedMember.IsReferenceCandidate(entry.Member, entry.MemberType)))
.GroupBy(pair => pair.Role, StringComparer.Ordinal)
.Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1
&& group.Any(pair => pair.IsReference))
.OrderBy(group => group.Key, StringComparer.Ordinal))
{
var pair = group.Take(2).ToArray();
rejections.Add(DuplicateReferenceRoleMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key));
}
// D11: a member declared as a reference (by [CultReference] or a bare CultRecordRef<T> type)
// whose CLR shape the cache cannot walk. many:false needs CultRecordRef<T> itself; many:true
// needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>. A declaration the
@ -1355,6 +1374,14 @@ namespace GameCult.Caching
private static string DuplicateIndexAliasMessage(string documentTypeName, string first, string second, string alias) =>
$"Cult document {documentTypeName} members {first} and {second} both declare index alias \"{alias}\"; an index alias must name one member.";
// D10b (R-AL): a plain member's bare name colliding with a reference member's index alias.
// CultDocumentRegistry.ReferencesOf and CultNetSelectionEvaluator.ReferenceMembers both key
// reference lookups off (IndexAlias ?? MemberName); D10 above only groups members that
// declare an alias, so an unaliased member's bare name sharing that string with a reference
// member's alias registered silently and faulted every hop through it at resolution time.
private static string DuplicateReferenceRoleMessage(string documentTypeName, string first, string second, string role) =>
$"Cult document {documentTypeName} members {first} and {second} both resolve to reference role \"{role}\" (index alias, or bare member name when unaliased); a reference role must name one member.";
private static string UnsupportedReferenceShapeMessage(string documentTypeName, string member) =>
$"Cult document {documentTypeName} member {member} declares a reference the cache cannot walk; many:false needs CultRecordRef<T>, many:true needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>.";
@ -1525,6 +1552,15 @@ namespace GameCult.Caching
};
}
// Mirrors the isReference computation FromMember uses below, callable from DiscoverMembers
// (D10b) before a slot is turned into a full PersistedMember.
internal static bool IsReferenceCandidate(MemberInfo member, Type memberType)
{
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
return targetType != null || referenceAttribute != null;
}
private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget)
{
if (explicitTarget != null)

View file

@ -448,16 +448,20 @@ namespace GameCult.Networking
return incoming;
}
// R-AK: no `!member.IsReference` guard here - CultCache.ReferencesOf (CultCache.cs:262-278)
// already searches only its own RichMembers' reference members for a role match and
// returns empty for anything else, including a data member's own alias (index aliases are
// unique per descriptor, so a data member's role string can never coincide with a
// reference member's). A second copy of the same exclusion here was duplicate authority,
// not a second decision - CultCache owns "is this member a reference" once.
// R-AL: the `!member.IsReference` guard is restored. R-AK's removal argued a data member's
// role string could never coincide with a reference member's because index aliases are
// unique per descriptor - false. CultCache's D10 only rejects a collision between two
// *aliased* members (CultCache.cs:1085-1097); a plain data member with no [CultIndex]
// contributes its bare MemberName to the same role namespace unchecked (fixed for new
// registrations by D10b, CultCache.cs ~1099, but this guard is the caller-side backstop for
// whatever is already registered). Without it, a data member sharing a reference member's
// role is yielded here, ReferencesOf then matches it to the *reference* member's edges by
// role string, and `member.TargetType!` is null - ArgumentNullException on every hop.
private static IEnumerable<CultDocumentMemberView> ReferenceMembers(CultDocumentDescriptor descriptor, string? role)
{
foreach (var member in descriptor.DeclaredMembers)
{
if (!member.IsReference) continue;
if (role != null && (member.IndexAlias ?? member.MemberName) != role) continue;
yield return member;
}