From e420410fe9cedb83365329e2799ae4da6b4a5985 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 01:57:35 +0200 Subject: [PATCH] CultNet selection cut1, commit 0: the cache's public read surface docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at CultNetDocumentRegistry.cs:325-342 exists only because nothing outside GameCult.Caching can read a declared index value, reference, or numeric member (Members/IndexAccessors are internal, no InternalsVisibleTo to Networking). This adds the one public door instead: CultDocumentMemberView, CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/ ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live against the registry, never cached or persisted). Two registration refusals land beside DiscoverMembers' existing DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException ToDictionary already threw for two differently-named members sharing one index alias into a rejection that names the schema, both members and the alias; D11 refuses a reference member whose CLR shape the cache cannot walk (many:false needs CultRecordRef, many:true needs IEnumerable> or IDictionary, V>) instead of silently answering it as unreadable later. GameCult.Networking is untouched; the evaluator that will read this surface does not exist yet (commit 1). Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176 existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary reference, including two dictionary entries with different payload bytes (S19); ResolveTargetLeaves against an abstract target with no [CultDocument] of its own (D9), including a leaf registered after an earlier descriptor was built; IsNumeric agreement for a nullable numeric and a decimal member (S22); the two new registration refusals, dynamically emitted so registry discovery by attribute scan cannot see the rejected shapes. scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this campaign does not edit it, per the map's section 10). Byte-exact restore verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and loosening each) all KILLED; final restore verified green. Co-Authored-By: Claude Sonnet 5 --- scripts/mutate-cultnet-selection-caching.ps1 | 198 +++++++++++ src/GameCult.Caching/CultCache.cs | 323 +++++++++++++++++- .../CultDocumentSelectionSurfaceTests.cs | 274 +++++++++++++++ 3 files changed, 782 insertions(+), 13 deletions(-) create mode 100644 scripts/mutate-cultnet-selection-caching.ps1 create mode 100644 tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs diff --git a/scripts/mutate-cultnet-selection-caching.ps1 b/scripts/mutate-cultnet-selection-caching.ps1 new file mode 100644 index 00000000..7e7e29ed --- /dev/null +++ b/scripts/mutate-cultnet-selection-caching.ps1 @@ -0,0 +1,198 @@ +# Mutation check for CultNet typed selection, Cut 1, commit 0: the cache's +# public read surface (docs/cultnet-selection-cut.md, section 6, D8/D10/D11). +# +# This is a dedicated runner for src/GameCult.Caching, not the shared +# tools/eureka-mutations.ps1 harness: that harness's Invoke-Cargo parses +# `cargo test` output and appends `-- --exact `, and is not usable +# against `dotnet test`/NUnit. It is also not scripts/mutate-cultmesh.mjs - +# that file is the QUIC campaign's, live and dirty in this tree, and this +# campaign does not edit it (docs/cultnet-selection-cut.md, section 10: "this +# campaign adds its entries in its own file ... and does not edit the QUIC +# campaign's targets - Self decides the exact split at landing"). +# +# Same discipline as both: every entry mutates CultCache.cs, is applied as a +# byte-exact splice against one anchor that must match exactly once, restored +# from the original bytes (never from git) with a SHA-256 check, and a +# sidecar (.mutation-original) protects a hard kill mid-mutation. M0 +# is a no-op control: the target round-trips through the read/write path +# unchanged and the unmutated suite is green, before any entry runs. +# +# powershell -File scripts/mutate-cultnet-selection-caching.ps1 +# +# Exit code: 0 when every entry's revert and loosening (where one is named) +# killed the test named in Killer, non-zero otherwise. Output for a run that +# did not go green names which entry and which mutant survived. + +$ErrorActionPreference = 'Stop' + +$repo = Resolve-Path (Join-Path $PSScriptRoot '..') +$target = Join-Path $repo 'src\GameCult.Caching\CultCache.cs' +$sidecar = "$target.mutation-original" +$testProject = Join-Path $repo 'tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj' +$utf8 = [System.Text.UTF8Encoding]::new($false) + +function Read-Bytes([string]$path) { [System.IO.File]::ReadAllBytes($path) } +function Get-BytesHash([byte[]]$bytes) { + ([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' +} +function Write-Text([string]$path, [string]$text) { + [System.IO.File]::WriteAllBytes($path, [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($text))) +} + +function Run-Tests([string]$filter) { + $args = @('test', $testProject, '-c', 'Debug', '--filter', $filter, '--nologo') + $output = & dotnet @args 2>&1 | Out-String + Write-Host $output + $passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)') + $failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)') + $compiled = -not ($output -match 'error CS\d+') + $passedLine = $output -match 'Passed!' + $failedLine = $output -match 'Failed!' + [pscustomobject]@{ + Compiled = $compiled + Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1 + Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 } + Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 } + } +} + +# --- Repair a run that died mid-mutation ------------------------------------ + +if (Test-Path -LiteralPath $sidecar) { + $original = Read-Bytes $sidecar + [System.IO.File]::WriteAllBytes($target, $original) + Remove-Item -LiteralPath $sidecar -Force + Write-Host "Repaired: a previous run left $sidecar; $target restored from it and the sidecar removed." +} + +# --- M0: no-op control ------------------------------------------------------- + +Write-Host '--- M0: no-op control' +$originalBytes = Read-Bytes $target +$originalHash = Get-BytesHash $originalBytes +$originalText = $utf8.GetString($originalBytes) +$reencoded = [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($originalText)) +if ((Get-BytesHash $reencoded) -ne $originalHash) { + throw "harness broken: $target does not round-trip through the UTF-8 read/write path byte for byte. Nothing was written and no entry ran." +} +[System.IO.File]::WriteAllBytes($sidecar, $originalBytes) +try { + Write-Text $target $originalText + if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) { + throw "harness broken: writing $target back through the harness path changed its bytes." + } +} +finally { + [System.IO.File]::WriteAllBytes($target, $originalBytes) + if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) { throw "RESTORE FAILED after M0; original SHA-256 $originalHash is kept in $sidecar." } + Remove-Item -LiteralPath $sidecar -Force +} +$control = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests' +if (-not $control.Green) { + throw "harness broken: the caching suite is not green unmutated (compiled=$($control.Compiled) passed=$($control.Passed) failed=$($control.Failed)). No entry ran." +} +Write-Host "M0: green, $($control.Passed) passed" + +# --- Entries ----------------------------------------------------------------- + +$entries = @( + [pscustomobject]@{ + Id = 'CACHE-D8-Revert' + Rule = 'D8/S22: IsNumeric is derived from the closed CLR numeric set; a decimal-typed member must be numeric.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericAgreesForANullableNumericMemberAndADecimalMember' + Old = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double), typeof(decimal)" + New = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double)" + }, + [pscustomobject]@{ + Id = 'CACHE-D8-Loosening' + Rule = 'D8/S22: a string-typed member must not be treated as numeric.' + Mutant = 'loosening' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericIsFalseForAStringAlias' + Old = "var underlying = Nullable.GetUnderlyingType(type) ?? type;`n return NumericClrTypes.Contains(underlying);" + New = "return true;" + }, + [pscustomobject]@{ + Id = 'CACHE-D10-Revert' + Rule = 'D10: two differently-named members sharing one index alias are refused by name at registration.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName' + Old = " // D10: two differently-named members that independently declare the same index alias. A`n // hiding member sharing one slot is already caught above; this is two distinct members whose`n // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.`n foreach (var group in keyed`n .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))`n .Where(pair => pair.Alias != null)`n .GroupBy(pair => pair.Alias, StringComparer.Ordinal)`n .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)`n .OrderBy(group => group.Key, StringComparer.Ordinal))`n {`n var pair = group.Take(2).ToArray();`n rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));`n }`n" + New = "" + }, + [pscustomobject]@{ + Id = 'CACHE-D10-Loosening' + Rule = 'D10: the collision groups by IndexAlias, not by Member.Name (which would refuse nothing HiddenMemberMessage does not already refuse).' + Mutant = 'loosening' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName' + Old = '.GroupBy(pair => pair.Alias, StringComparer.Ordinal)' + New = '.GroupBy(pair => pair.Entry.Member.Name, StringComparer.Ordinal)' + }, + [pscustomobject]@{ + Id = 'CACHE-D11-Revert' + Rule = 'D11: a dictionary reference is walked as edges keyed by CultRecordRef, not skipped as an unrecognised many-shape.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues' + Old = 'if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))' + New = 'if (false && TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))' + }, + [pscustomobject]@{ + Id = 'CACHE-D11-Loosening' + Rule = 'D11/S19: a dictionary reference edge carries its value as payload, not null (two entries with different payload bytes must compare unequal).' + Mutant = 'loosening' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues' + Old = 'results.Add((reference.Key, valueProperty.GetValue(entry)));' + New = 'results.Add((reference.Key, (object?)null));' + } +) + +$exitCode = 0 +foreach ($entry in $entries) { + Write-Host "--- $($entry.Id) ($($entry.Mutant)): $($entry.Rule)" + $text = $utf8.GetString((Read-Bytes $target)) + $old = $entry.Old -replace "`r`n", "`n" + $normalized = $text -replace "`r`n", "`n" + $count = 0 + $at = $normalized.IndexOf($old, [System.StringComparison]::Ordinal) + while ($at -ge 0) { $count++; $at = $normalized.IndexOf($old, $at + 1, [System.StringComparison]::Ordinal) } + if ($count -ne 1) { + throw "$($entry.Id): anchor matches $count times, expected exactly 1." + } + $mutatedNormalized = $normalized.Replace($old, ($entry.New -replace "`r`n", "`n")) + $eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" } + $mutatedText = $mutatedNormalized -replace "`n", $eol + + $originalBytes = Read-Bytes $target + [System.IO.File]::WriteAllBytes($sidecar, $originalBytes) + $verdict = 'NO VERDICT' + try { + Write-Text $target $mutatedText + $run = Run-Tests $entry.Killer + if (-not $run.Compiled) { + $verdict = 'NO VERDICT (did not compile)' + } + elseif ($run.Failed -ge 1) { + $verdict = 'KILLED' + } + else { + $verdict = 'SURVIVED' + } + } + finally { + [System.IO.File]::WriteAllBytes($target, $originalBytes) + if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) { + Write-Host "RESTORE FAILED: $target not restored; original SHA-256 $originalHash kept in $sidecar." + throw "restore failed for $($entry.Id)" + } + Remove-Item -LiteralPath $sidecar -Force + } + Write-Host "$($entry.Id): $verdict" + if ($verdict -ne 'KILLED') { $exitCode = 1 } +} + +# Restore the suite to green once more as a final sanity check. +$final = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests' +if (-not $final.Green) { throw "post-run sanity check: the caching suite is not green after restore (passed=$($final.Passed) failed=$($final.Failed))." } +Write-Host "Final: green, $($final.Passed) passed" + +exit $exitCode diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 5e8c8f6d..978c8822 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -109,6 +109,46 @@ namespace GameCult.Caching public CultSchemaMigrationReport Report { get; } } + // A declared member's addressing shape, read-only: what a caller outside GameCult.Caching + // (the CultNet selection evaluator, chiefly) is allowed to know about a member without + // reflecting over the document itself. Built from the same declarations CultIndexAttribute + // and CultReferenceAttribute already recorded; carries no value. + public readonly struct CultDocumentMemberView + { + internal CultDocumentMemberView( + string memberName, + int slot, + string? indexAlias, + bool isName, + bool isReference, + bool isMany, + bool isNumeric, + Type? targetType) + { + MemberName = memberName; + Slot = slot; + IndexAlias = indexAlias; + IsName = isName; + IsReference = isReference; + IsMany = isMany; + IsNumeric = isNumeric; + TargetType = targetType; + } + + public string MemberName { get; } + public int Slot { get; } + public string? IndexAlias { get; } + public bool IsName { get; } + public bool IsReference { get; } + public bool IsMany { get; } + public bool IsNumeric { get; } + + // The reference's declared target: the type CultReferenceAttribute names, or CultRecordRef's T. + // Null when the member is not a reference. May be abstract (D9): the leaf set is resolved at + // evaluation by CultDocumentRegistry.ResolveTargetLeaves, never persisted here or in the catalog. + public Type? TargetType { get; } + } + public sealed class CultDocumentDescriptor { internal CultDocumentDescriptor( @@ -122,7 +162,8 @@ namespace GameCult.Caching string? nameMember, Func? nameAccessor, IReadOnlyDictionary> indexAccessors, - IReadOnlyList members) + IReadOnlyList members, + IReadOnlyList richMembers) { DocumentType = documentType; SchemaName = schemaName; @@ -135,6 +176,18 @@ namespace GameCult.Caching NameAccessor = nameAccessor; IndexAccessors = indexAccessors; Members = members; + RichMembers = richMembers; + DeclaredMembers = richMembers + .Select(member => new CultDocumentMemberView( + member.Member.Name, + member.Slot, + member.IndexAlias, + member.IsName, + member.IsReference, + member.IsMany, + member.IsNumeric, + member.TargetType)) + .ToArray(); } public Type DocumentType { get; } @@ -148,6 +201,80 @@ namespace GameCult.Caching internal Func? NameAccessor { get; } internal IReadOnlyDictionary> IndexAccessors { get; } internal IReadOnlyList Members { get; } + internal IReadOnlyList RichMembers { get; } + + // The public read surface (CultNet selection cut 1, section 6): declared addressing, and the + // three ways to read a value through it. GameCult.Networking has no InternalsVisibleTo here on + // purpose - this is the only door, so nothing outside the cache decides what a member means. + public IReadOnlyList DeclaredMembers { get; } + + public bool TryGetIndexValue(object document, string alias, out string? value) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (string.IsNullOrEmpty(alias)) + { + value = null; + return false; + } + + foreach (var member in RichMembers) + { + if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal)) + { + value = member.GetterNullable(document); + return true; + } + } + + value = null; + return false; + } + + public bool TryGetIndexNumber(object document, string alias, out double value) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (!string.IsNullOrEmpty(alias)) + { + foreach (var member in RichMembers) + { + if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal) && member.IsNumeric) + { + var number = member.NumberOf(document); + if (number.HasValue) + { + value = number.Value; + return true; + } + + break; + } + } + } + + value = default; + return false; + } + + // The edges a declared reference member carries: one per CultRecordRef, one per element of + // an IEnumerable>, one per key of an IDictionary, V> with the + // value as the payload (D11). role is the member's index alias, or its name when it has none. + public IEnumerable<(CultRecordKey Target, object? Payload)> ReferencesOf(object document, string role) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (string.IsNullOrEmpty(role)) return Array.Empty<(CultRecordKey, object?)>(); + + foreach (var member in RichMembers) + { + if (!member.IsReference) continue; + var memberRole = member.IndexAlias ?? member.Member.Name; + if (string.Equals(memberRole, role, StringComparison.Ordinal)) + { + return member.ReferenceEnumerator!(document); + } + } + + return Array.Empty<(CultRecordKey, object?)>(); + } public CultSchemaCatalogEntry ToCatalogEntry() { @@ -254,6 +381,15 @@ namespace GameCult.Caching public IEnumerable AllDescriptors => _indexes.ByType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal); + // D9: a reference's target set is every registered leaf assignable to its declared target type, + // computed against the registry as it stands right now - never cached, never persisted. A leaf + // registered after some other descriptor was built must still show up here. + public IReadOnlyList ResolveTargetLeaves(Type targetType) + { + if (targetType == null) throw new ArgumentNullException(nameof(targetType)); + return AllDescriptors.Where(d => targetType.IsAssignableFrom(d.DocumentType)).ToArray(); + } + public void Refresh() { lock (_registrationGate) @@ -495,7 +631,8 @@ namespace GameCult.Caching nameMember?.Member.Name, nameMember?.GetterNullable, indexAccessors, - descriptorMembers); + descriptorMembers, + members); } private static string BuildSemanticFingerprint( @@ -944,6 +1081,38 @@ namespace GameCult.Caching rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member))); } + // D10: two differently-named members that independently declare the same index alias. A + // hiding member sharing one slot is already caught above; this is two distinct members whose + // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently. + foreach (var group in keyed + .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member))) + .Where(pair => pair.Alias != null) + .GroupBy(pair => pair.Alias, StringComparer.Ordinal) + .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1) + .OrderBy(group => group.Key, StringComparer.Ordinal)) + { + var pair = group.Take(2).ToArray(); + rejections.Add(DuplicateIndexAliasMessage(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 + // cache cannot read is refused here, not answered as an unreadable no-op reference later. + foreach (var candidate in keyed) + { + var referenceAttribute = candidate.Member.GetCustomAttribute(); + var isBareRecordRef = IsCultRecordRefType(candidate.MemberType); + if (referenceAttribute == null && !isBareRecordRef) + continue; + var many = referenceAttribute?.Many ?? false; + var supported = many + ? IsEnumerableOfRecordRef(candidate.MemberType) || TryGetDictionaryRefTypes(candidate.MemberType, out _, out _) + : isBareRecordRef; + if (!supported) + rejections.Add(UnsupportedReferenceShapeMessage(type.Name, Qualified(candidate.Member))); + } + if (rejections.Count > 0) throw new InvalidOperationException(rejections[0]); @@ -992,6 +1161,74 @@ namespace GameCult.Caching private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name; + // Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration + // refuses exactly the shapes the accessor it builds afterward could not have walked anyway. + private static string? ResolveIndexAlias(MemberInfo member) + { + var indexAttribute = member.GetCustomAttribute(); + if (indexAttribute == null) + return null; + return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias; + } + + private static bool IsCultRecordRefType(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>); + + private static bool IsEnumerableOfRecordRef(Type type) + { + foreach (var candidate in type.GetInterfaces().Append(type)) + { + if (candidate.IsGenericType + && candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>) + && IsCultRecordRefType(candidate.GetGenericArguments()[0])) + return true; + } + + return false; + } + + private static bool TryGetDictionaryRefTypes(Type type, out PropertyInfo keyProperty, out PropertyInfo valueProperty) + { + foreach (var candidate in type.GetInterfaces().Append(type)) + { + if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IDictionary<,>)) + continue; + var arguments = candidate.GetGenericArguments(); + if (!IsCultRecordRefType(arguments[0])) + continue; + var pairType = typeof(KeyValuePair<,>).MakeGenericType(arguments[0], arguments[1]); + keyProperty = pairType.GetProperty("Key")!; + valueProperty = pairType.GetProperty("Value")!; + return true; + } + + keyProperty = null!; + valueProperty = null!; + return false; + } + + private static readonly HashSet NumericClrTypes = new() + { + typeof(sbyte), typeof(byte), typeof(short), typeof(ushort), + typeof(int), typeof(uint), typeof(long), typeof(ulong), + typeof(float), typeof(double), typeof(decimal) + }; + + // D8's closed CLR numeric set: the sole authority for whether a comparison predicate may reach + // a member. The TypeName string persisted in the catalog is generated from this set (via + // CultSchemaTypeNames.FromType) and must never be hand-checked against separately - S22 pins it. + private static bool IsNumericClrType(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + return NumericClrTypes.Contains(underlying); + } + + 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."; + + 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>."; + private static string NotMessagePackObjectMessage(string documentTypeName) => $"Cult document {documentTypeName} is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members."; @@ -1047,7 +1284,9 @@ namespace GameCult.Caching return "sha256:" + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); } - private sealed class PersistedMember + // internal, not private: CultDocumentDescriptor (a separate top-level class) reads RichMembers + // through this type to build the public DeclaredMembers view and the three value accessors. + internal sealed class PersistedMember { public MemberInfo Member { get; set; } = default!; public Type MemberType { get; set; } = default!; @@ -1057,9 +1296,25 @@ namespace GameCult.Caching public bool IsReference { get; set; } public bool IsMany { get; set; } public string? TargetSchemaName { get; set; } + + // The reference's declared target Type, held alongside TargetSchemaName (which is null for + // an abstract target - D9). CultDocumentRegistry.ResolveTargetLeaves needs the Type, not the + // name, to compute the leaf set by assignability. + public Type? TargetType { get; set; } + public bool IsNumeric { get; set; } public Func Getter { get; set; } = default!; public Func GetterNullable { get; set; } = default!; + // D8: non-null exactly for the closed CLR numeric set (Nullable unwrapped). Convert.ToDouble + // never loses a value CultCache's own supported numeric types can hold. + public Func NumberOf { get; set; } = default!; + + // D11: null for a non-reference member; otherwise yields (target key, payload) per declared + // shape - one entry for a bare reference, one per element for a many-enumerable, one per key + // with its value as payload for a many-dictionary. Registration already refused every other + // shape (DiscoverMembers), so this is never called against something it cannot walk. + public Func>? ReferenceEnumerator { get; set; } + public static PersistedMember FromMember( MemberInfo member, Type memberType, @@ -1069,6 +1324,8 @@ namespace GameCult.Caching var referenceAttribute = member.GetCustomAttribute(); var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType); var targetSchemaName = targetType?.GetCustomAttribute()?.SchemaName; + var isReference = targetType != null || referenceAttribute != null; + var many = referenceAttribute?.Many ?? false; return new PersistedMember { Member = member, @@ -1076,25 +1333,65 @@ namespace GameCult.Caching Slot = slot, IsName = member.GetCustomAttribute() != null, IndexAlias = ResolveIndexAlias(member), - IsReference = targetType != null || referenceAttribute != null, - IsMany = referenceAttribute?.Many ?? false, + IsReference = isReference, + IsMany = many, TargetSchemaName = targetSchemaName, + TargetType = targetType, + IsNumeric = IsNumericClrType(memberType), Getter = document => getValue(document)?.ToString() ?? string.Empty, - GetterNullable = document => getValue(document)?.ToString() + GetterNullable = document => getValue(document)?.ToString(), + NumberOf = document => + { + var raw = getValue(document); + return raw == null ? null : (double?)Convert.ToDouble(raw, CultureInfo.InvariantCulture); + }, + ReferenceEnumerator = isReference ? BuildReferenceEnumerator(memberType, many, getValue) : null }; } - private static string? ResolveIndexAlias(MemberInfo member) + // Registration (DiscoverMembers' D11 check) already refused every shape but the three this + // builds an enumerator for; this does not re-refuse, it only classifies which of the three. + private static Func> BuildReferenceEnumerator( + Type memberType, bool many, Func getValue) { - var indexAttribute = member.GetCustomAttribute(); - if (indexAttribute == null) + if (!many) { - return null; + return document => + getValue(document) is ICultRecordRef reference + ? new[] { (reference.Key, (object?)null) } + : Array.Empty<(CultRecordKey, object?)>(); } - return string.IsNullOrWhiteSpace(indexAttribute.Alias) - ? member.Name - : indexAttribute.Alias; + if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty)) + { + return document => + { + if (getValue(document) is not System.Collections.IEnumerable sequence) + return Array.Empty<(CultRecordKey, object?)>(); + var results = new List<(CultRecordKey, object?)>(); + foreach (var entry in sequence) + { + if (keyProperty.GetValue(entry) is ICultRecordRef reference) + results.Add((reference.Key, valueProperty.GetValue(entry))); + } + + return results; + }; + } + + return document => + { + if (getValue(document) is not System.Collections.IEnumerable sequence) + return Array.Empty<(CultRecordKey, object?)>(); + var results = new List<(CultRecordKey, object?)>(); + foreach (var entry in sequence) + { + if (entry is ICultRecordRef reference) + results.Add((reference.Key, (object?)null)); + } + + return results; + }; } private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget) diff --git a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs new file mode 100644 index 00000000..7d5bb563 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs @@ -0,0 +1,274 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // CultNet typed selection, Cut 1, commit 0: the cache's public read surface over declared members + // (docs/cultnet-selection-cut.md, section 6). These tests are independent of GameCult.Networking - + // the evaluator that will consume this surface does not exist yet in this commit. + public sealed class CultDocumentSelectionSurfaceTests + { + // S19: a dictionary reference is enumerated as edges carrying its values, not merely its keys. + [Test] + public void ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var a1 = new CultRecordRef(new CultRecordKey("leaf-a-1")); + var a2 = new CultRecordRef(new CultRecordKey("leaf-a-2")); + var document = new FixtureCiter + { + Components = new Dictionary, float> { [a1] = 1.5f, [a2] = 2.5f } + }; + + var edges = citer.ReferencesOf(document, "Components").ToArray(); + + Assert.That(edges.Select(edge => edge.Target.Value).OrderBy(v => v, StringComparer.Ordinal), + Is.EqualTo(new[] { "leaf-a-1", "leaf-a-2" })); + Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-1").Payload, Is.EqualTo(1.5f)); + Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-2").Payload, Is.EqualTo(2.5f)); + // Two entries with different payload bytes: a shape-only assertion (keys enumerated, values + // dropped) would pass without this distinguishing the payloads (spec section 10, S19). + Assert.That(edges.Select(edge => edge.Payload).Distinct().Count(), Is.EqualTo(2)); + } + + [Test] + public void ReferencesOfEnumeratesAManyListReferenceWithNoPayload() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var document = new FixtureCiter + { + Related = new List> + { + new(new CultRecordKey("leaf-a-3")), + new(new CultRecordKey("leaf-a-4")) + } + }; + + var edges = citer.ReferencesOf(document, "Related").ToArray(); + + Assert.That(edges.Select(edge => edge.Target.Value), Is.EqualTo(new[] { "leaf-a-3", "leaf-a-4" })); + Assert.That(edges.All(edge => edge.Payload == null), Is.True); + } + + [Test] + public void ReferencesOfEnumeratesASingleReferenceToAnAbstractTarget() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var document = new FixtureCiter { Design = new CultRecordRef(new CultRecordKey("leaf-b-1")) }; + + var edges = citer.ReferencesOf(document, "Design").ToArray(); + + Assert.That(edges.Single().Target.Value, Is.EqualTo("leaf-b-1")); + Assert.That(edges.Single().Payload, Is.Null); + } + + // D9: the abstract middle carries no [CultDocument] and no catalog entry (TargetSchemaName is + // null for exactly this reference), so the leaf set must be resolved by assignability against + // the live registry, not read off a persisted name. + [Test] + public void ResolveTargetLeavesFindsBothLeavesUnderAnAbstractMiddleWithNoSchemaOfItsOwn() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design)); + + Assert.That(designMember.TargetType, Is.EqualTo(typeof(FixtureMiddle))); + Assert.That(typeof(FixtureMiddle).GetCustomAttribute(), Is.Null); + + var leaves = registry.ResolveTargetLeaves(designMember.TargetType!); + + Assert.That(leaves.Select(d => d.DocumentType).OrderBy(t => t.Name), + Is.EqualTo(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }.OrderBy(t => t.Name))); + } + + [Test] + public void ResolveTargetLeavesSeesALeafRegisteredAfterAnEarlierDescriptorWasBuilt() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureCiter), typeof(FixtureLeafA) }); + var citer = registry.GetRequired(); + var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design)); + + var before = registry.ResolveTargetLeaves(designMember.TargetType!); + Assert.That(before.Select(d => d.DocumentType), Does.Not.Contain(typeof(FixtureLeafB))); + + registry.GetRequired(); + var after = registry.ResolveTargetLeaves(designMember.TargetType!); + Assert.That(after.Select(d => d.DocumentType), Does.Contain(typeof(FixtureLeafB))); + } + + // D8's inherited-alias case, read through the public surface: mass is declared once on the + // abstract middle and reachable on both leaves. + [Test] + public void TryGetIndexValueAndNumberReadAnInheritedAliasOnEachLeaf() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }); + var leafA = registry.GetRequired(); + var documentA = new FixtureLeafA { Name = "sword", Mass = 3.5f }; + + Assert.That(leafA.TryGetIndexValue(documentA, "mass", out var text), Is.True); + Assert.That(text, Is.EqualTo("3.5")); + Assert.That(leafA.TryGetIndexNumber(documentA, "mass", out var number), Is.True); + Assert.That(number, Is.EqualTo(3.5d)); + } + + [Test] + public void TryGetIndexValueRefusesAnUndeclaredAlias() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) }); + var leafA = registry.GetRequired(); + + Assert.That(leafA.TryGetIndexValue(new FixtureLeafA(), "not_declared", out _), Is.False); + } + + // S22: IsNumeric is derived from the CLR set (sbyte..decimal, Nullable unwrapped) and must + // agree for a nullable numeric member and a decimal member - the two authorities D8 names. + [Test] + public void IsNumericAgreesForANullableNumericMemberAndADecimalMember() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafB) }); + var leafB = registry.GetRequired(); + var weight = leafB.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafB.Weight)); + + Assert.That(weight.IsNumeric, Is.True); + var document = new FixtureLeafB { Weight = 4.25m }; + Assert.That(leafB.TryGetIndexNumber(document, "weight", out var number), Is.True); + Assert.That(number, Is.EqualTo(4.25d)); + } + + [Test] + public void IsNumericIsFalseForAStringAlias() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) }); + var leafA = registry.GetRequired(); + var name = leafA.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafA.Name)); + + Assert.That(name.IsNumeric, Is.False); + } + + // D10 and D11 registration refusals: emitted into a dynamic assembly, invisible to registry + // discovery by attribute scan, exactly as DocumentShapeTests does for every other shape refusal. + private static readonly ModuleBuilder Rejected = AssemblyBuilder + .DefineDynamicAssembly(new AssemblyName("RejectedSelectionShapes"), AssemblyBuilderAccess.Run) + .DefineDynamicModule("RejectedSelectionShapes"); + + [Test] + public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName() + { + var type = Document("DupAlias"); + Field(type, "First", typeof(string), Key(0), IndexAttr("shared")); + Field(type, "Second", typeof(string), Key(1), IndexAttr("shared")); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("DupAlias")); + Assert.That(message, Does.Contain("First")); + Assert.That(message, Does.Contain("Second")); + Assert.That(message, Does.Contain("shared")); + } + + [Test] + public void ManyReferenceOfAnElementTypeThatIsNotACultRecordRefIsRefused() + { + var type = Document("BadMany"); + Field(type, "Values", typeof(List), Key(0), ReferenceAttr(null, many: true)); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("BadMany")); + Assert.That(message, Does.Contain("Values")); + } + + [Test] + public void SingleReferenceWhoseMemberTypeIsNotACultRecordRefIsRefused() + { + var type = Document("BadSingle"); + Field(type, "Target", typeof(int), Key(0), ReferenceAttr(typeof(FixtureLeafA), many: false)); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("BadSingle")); + Assert.That(message, Does.Contain("Target")); + } + + private static TypeBuilder Document(string name) + { + var type = Rejected.DefineType(name, TypeAttributes.Public); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, + new object[] { "selection.reject." + name, "selection.reject." + name + ".v1" })); + type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + return type; + } + + private static void Field(TypeBuilder type, string name, Type fieldType, params CustomAttributeBuilder[] attributes) + { + var field = type.DefineField(name, fieldType, FieldAttributes.Public); + foreach (var attribute in attributes) field.SetCustomAttribute(attribute); + } + + private static CustomAttributeBuilder Key(int slot) => + new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot }); + + private static CustomAttributeBuilder IndexAttr(string alias) => + new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias }); + + private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) => + new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many }); + + public abstract class FixtureMiddle + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("mass")] + public float Mass; + } + + [CultDocument("selection.fixture.leaf_a", "selection.fixture.leaf_a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureLeafA : FixtureMiddle + { + [Key(2)] + public int Extra; + } + + [CultDocument("selection.fixture.leaf_b", "selection.fixture.leaf_b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureLeafB : FixtureMiddle + { + [Key(2)] + [CultIndex("weight")] + public decimal? Weight; + } + + [CultDocument("selection.fixture.citer", "selection.fixture.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(FixtureMiddle))] + public CultRecordRef Design; + + [Key(2)] + [CultReference(typeof(FixtureLeafA), many: true)] + public Dictionary, float> Components = new(); + + [Key(3)] + [CultReference(typeof(FixtureLeafA), many: true)] + public List> Related = new(); + } + } +}