From 375b6177aecc94ea442a1568e625b152168c4e72 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 07:15:33 +0200 Subject: [PATCH] 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 --- src/GameCult.Caching/CultCache.cs | 36 +++++++++++++++++++ .../CultNetSelectionEvaluator.cs | 16 +++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index b75d9a45..c0b733e7 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -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 type) // whose CLR shape the cache cannot walk. many:false needs CultRecordRef itself; many:true // needs IEnumerable> or IDictionary, 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, many:true needs IEnumerable> or IDictionary, 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(); + var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType); + return targetType != null || referenceAttribute != null; + } + private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget) { if (explicitTarget != null) diff --git a/src/GameCult.Networking/CultNetSelectionEvaluator.cs b/src/GameCult.Networking/CultNetSelectionEvaluator.cs index 84f8631a..07156fad 100644 --- a/src/GameCult.Networking/CultNetSelectionEvaluator.cs +++ b/src/GameCult.Networking/CultNetSelectionEvaluator.cs @@ -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 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; }