diff --git a/S1API.Tests/Coverage/CoverageAnalyzerTests.cs b/S1API.Tests/Coverage/CoverageAnalyzerTests.cs new file mode 100644 index 00000000..54c944bd --- /dev/null +++ b/S1API.Tests/Coverage/CoverageAnalyzerTests.cs @@ -0,0 +1,186 @@ +using System.Text.Json; +using S1APICoverageAnalyzer.Analysis; +using S1APICoverageAnalyzer.Models; +using S1APICoverageAnalyzer.Output; +using Xunit; + +namespace S1API.Tests.Coverage; + +public sealed class CoverageAnalyzerTests +{ + [Fact] + public void ApiAnalyzer_RecordsExplicitMappingsAndUnwrapsElementTypes() + { + var apiAssembly = typeof(global::S1API.Temperature.TemperatureUtility).Assembly; + var analyzer = new ApiAssemblyAnalyzer(apiAssembly, apiAssembly.Location); + + analyzer.Analyze(); + + IReadOnlyDictionary explicitMappings = + analyzer.GetExplicitCoverageMappings(); + Assert.Equal( + "S1API.Temperature.TemperatureEmitterInfo", + explicitMappings["ScheduleOne.Temperature.TemperatureEmitterInfo"]); + Assert.Equal( + "S1API.Temperature.TemperatureUtility", + explicitMappings["ScheduleOne.Temperature.TemperatureUtility"]); + + Assert.Contains( + "ScheduleOne.Temperature.TemperatureEmitterInfo", + analyzer.GetWrappedGameTypes()); + Assert.DoesNotContain( + "ScheduleOne.Temperature.TemperatureEmitterInfo[]", + analyzer.GetWrappedGameTypes()); + } + + [Fact] + public void Calculate_ReportsProvenanceAndMatchStrategyForEveryCoveredType() + { + var gameTypes = new List + { + GameType("ScheduleOne.Temperature.TemperatureUtility"), + GameType("ScheduleOne.Items.ItemDefinition"), + GameType("ScheduleOne.Casino.BlackjackGameController+EStage"), + GameType("ScheduleOne.Dialogue.DialogueController.Node"), + GameType("ScheduleOne.Vehicles.Modification.EVehicleColor") + }; + var apiTypes = new List + { + ApiType( + "S1API.Casino.BlackjackGame", + "BlackjackGame", + "ScheduleOne.Casino.BlackjackGameController"), + ApiType( + "S1API.Dialogue.DialogueNode", + "DialogueNode", + "ScheduleOne.Dialogue.DialogueController+Node"), + ApiType( + "S1API.Items.ItemDefinition", + "ItemDefinition", + "ScheduleOne.Items.ItemDefinition"), + ApiType( + "S1API.Temperature.TemperatureUtility", + "TemperatureUtility", + "ScheduleOne.Temperature.TemperatureUtility"), + ApiType( + "S1API.Vehicles.VehicleColor", + "VehicleColor", + "ScheduleOne.Vehicles.Modification.VehicleColors") + }; + var explicitMappings = new Dictionary(StringComparer.Ordinal) + { + ["ScheduleOne.Temperature.TemperatureUtility"] = + "S1API.Temperature.TemperatureUtility" + }; + + CoverageResult result = Calculate(gameTypes, apiTypes, explicitMappings); + + Assert.Collection( + result.CoveredTypes.OrderBy(type => type.FullName, StringComparer.Ordinal), + type => AssertMatch(type, "S1API.Casino.BlackjackGame", CoverageMatchStrategy.Nested), + type => AssertMatch(type, "S1API.Dialogue.DialogueNode", CoverageMatchStrategy.Normalized), + type => AssertMatch(type, "S1API.Items.ItemDefinition", CoverageMatchStrategy.Exact), + type => AssertMatch(type, "S1API.Temperature.TemperatureUtility", CoverageMatchStrategy.Explicit), + type => AssertMatch(type, "S1API.Vehicles.VehicleColor", CoverageMatchStrategy.Fuzzy)); + Assert.Empty(result.UncoveredTypes); + + using JsonDocument report = JsonDocument.Parse(ReportGenerator.GenerateJsonReport(result)); + foreach (JsonElement coveredType in report.RootElement.GetProperty("coveredTypes").EnumerateArray()) + { + Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("coveredBy").GetString())); + Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("matchStrategy").GetString())); + } + } + + [Fact] + public void Calculate_DoesNotFuzzyMatchSimilarUnrelatedType() + { + GameType unrelatedGameType = + GameType("ScheduleOne.Vehicles.VehicleSeatSnapshot"); + ApiTypeInfo similarlyNamedApiType = ApiType( + "S1API.Items.VehicleSeat", + "VehicleSeat", + "ScheduleOne.ItemFramework.ItemSlot"); + + CoverageResult result = Calculate( + new List { unrelatedGameType }, + new List { similarlyNamedApiType }, + new Dictionary()); + + Assert.Empty(result.CoveredTypes); + Assert.Same(unrelatedGameType, Assert.Single(result.UncoveredTypes)); + } + + [Fact] + public void Calculate_UsesDeterministicApiTypeForEquivalentMatches() + { + GameType gameType = GameType("ScheduleOne.Items.ItemDefinition"); + var apiTypes = new List + { + ApiType( + "S1API.Zeta.ItemDefinition", + "ItemDefinition", + gameType.FullName), + ApiType( + "S1API.Alpha.ItemDefinition", + "ItemDefinition", + gameType.FullName) + }; + + CoverageResult result = Calculate( + new List { gameType }, + apiTypes, + new Dictionary()); + + AssertMatch( + Assert.Single(result.CoveredTypes), + "S1API.Alpha.ItemDefinition", + CoverageMatchStrategy.Exact); + } + + private static CoverageResult Calculate( + List gameTypes, + List apiTypes, + IReadOnlyDictionary explicitMappings) + { + var calculator = new CoverageCalculator( + gameTypes, + new Dictionary>(StringComparer.Ordinal), + apiTypes, + explicitMappings, + excludedTypeCount: 0); + return calculator.Calculate(); + } + + private static GameType GameType(string fullName) + { + int separatorIndex = fullName.LastIndexOfAny(['.', '+']); + return new GameType + { + FullName = fullName, + Namespace = separatorIndex < 0 ? string.Empty : fullName[..separatorIndex], + Name = separatorIndex < 0 ? fullName : fullName[(separatorIndex + 1)..], + Kind = GameTypeKind.Class + }; + } + + private static ApiTypeInfo ApiType( + string fullName, + string name, + params string[] wrappedGameTypes) => + new() + { + FullName = fullName, + Name = name, + WrappedGameTypes = wrappedGameTypes.ToList() + }; + + private static void AssertMatch( + GameType gameType, + string expectedApiType, + CoverageMatchStrategy expectedStrategy) + { + Assert.Equal(expectedApiType, gameType.CoveredByApiType); + Assert.Equal(expectedStrategy, gameType.MatchStrategy); + } +} diff --git a/S1API.Tests/S1API.Tests.csproj b/S1API.Tests/S1API.Tests.csproj index 9723afa5..87670586 100644 --- a/S1API.Tests/S1API.Tests.csproj +++ b/S1API.Tests/S1API.Tests.csproj @@ -28,6 +28,7 @@ all + diff --git a/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs b/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs index c16a1b3e..c0830095 100644 --- a/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs +++ b/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs @@ -12,6 +12,7 @@ public sealed class ApiAssemblyAnalyzer : AssemblyAnalyzer { private readonly HashSet _wrappedGameTypes = new(); private readonly Dictionary> _typeToAccessedMembers = new(); + private readonly Dictionary _explicitCoverageMappings = new(StringComparer.Ordinal); private readonly List _apiTypes = new(); public ApiAssemblyAnalyzer(Assembly assembly, string assemblyPath) @@ -67,12 +68,20 @@ public void Analyze() // Strategy 9: Attributes that reference game types AnalyzeAttributes(type, apiTypeInfo); + + // Strategy 10: Analyzer-owned declarations for runtime-agnostic mirrors. + AnalyzeExplicitCoverage(type, apiTypeInfo); if (apiTypeInfo.WrappedGameTypes.Count > 0) { _apiTypes.Add(apiTypeInfo); } } + + _apiTypes.Sort((left, right) => + StringComparer.Ordinal.Compare(left.FullName, right.FullName)); + + ValidateExplicitCoverageMappings(); } /// @@ -89,6 +98,12 @@ public void Analyze() /// Get information about all API types that wrap game types. /// public List GetApiTypes() => _apiTypes; + + /// + /// Get semantic coverage declarations keyed by game type name. + /// + public IReadOnlyDictionary GetExplicitCoverageMappings() => + _explicitCoverageMappings; /// /// Analyze fields that are primary wrappers (S1*, Inner*, etc.). @@ -230,14 +245,20 @@ private void AnalyzeSameNameWrapping(Type apiType, ApiTypeInfo apiTypeInfo) private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiTypeInfo) { + if (type.HasElementType) + { + var elementType = type.GetElementType(); + if (elementType != null) + RegisterGameTypeReference(elementType, apiType, apiTypeInfo); + return; + } + if (IsGameType(type)) { var normalizedName = NormalizeScheduleOneTypeName(type.FullName); if (!string.IsNullOrEmpty(normalizedName)) { - _wrappedGameTypes.Add(normalizedName); - apiTypeInfo.WrappedGameTypes.Add(normalizedName); - TrackTypeAccess(normalizedName, apiType); + RegisterGameTypeName(normalizedName, apiType, apiTypeInfo); } if (type.DeclaringType != null && IsGameType(type.DeclaringType)) @@ -256,14 +277,42 @@ private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiT var normalizedName = NormalizeScheduleOneTypeName(arg.FullName); if (!string.IsNullOrEmpty(normalizedName)) { - _wrappedGameTypes.Add(normalizedName); - apiTypeInfo.WrappedGameTypes.Add(normalizedName); - TrackTypeAccess(normalizedName, apiType); + RegisterGameTypeName(normalizedName, apiType, apiTypeInfo); } } } } } + + private void AnalyzeExplicitCoverage(Type apiType, ApiTypeInfo apiTypeInfo) + { + foreach (var gameTypeName in ExplicitCoverageConfig.GetGameTypesCoveredBy(apiTypeInfo.FullName)) + { + RegisterGameTypeName(gameTypeName, apiType, apiTypeInfo); + _explicitCoverageMappings.Add(gameTypeName, apiTypeInfo.FullName); + } + } + + private void ValidateExplicitCoverageMappings() + { + foreach (var mapping in ExplicitCoverageConfig.GetMappings()) + { + if (_explicitCoverageMappings.ContainsKey(mapping.Key)) + continue; + + throw new InvalidOperationException( + $"Explicit coverage mapping for '{mapping.Key}' references " + + $"missing API type '{mapping.Value}'."); + } + } + + private void RegisterGameTypeName(string gameTypeName, Type apiType, ApiTypeInfo apiTypeInfo) + { + _wrappedGameTypes.Add(gameTypeName); + if (!apiTypeInfo.WrappedGameTypes.Contains(gameTypeName, StringComparer.Ordinal)) + apiTypeInfo.WrappedGameTypes.Add(gameTypeName); + TrackTypeAccess(gameTypeName, apiType); + } private bool IsGameType(Type type) { diff --git a/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs b/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs index 47d8f139..7face3f9 100644 --- a/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs +++ b/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs @@ -9,26 +9,27 @@ namespace S1APICoverageAnalyzer.Analysis; public sealed class CoverageCalculator { private readonly List _gameTypes; - private readonly HashSet _wrappedGameTypes; private readonly Dictionary> _accessedMembers; private readonly List _apiTypes; + private readonly IReadOnlyDictionary _explicitCoverageMappings; private readonly int _excludedTypeCount; - private readonly Dictionary _fuzzyMatchCache = new(); - + public CoverageCalculator( List gameTypes, - HashSet wrappedGameTypes, Dictionary> accessedMembers, List apiTypes, + IReadOnlyDictionary explicitCoverageMappings, int excludedTypeCount) { _gameTypes = gameTypes; - _wrappedGameTypes = wrappedGameTypes; _accessedMembers = accessedMembers; - _apiTypes = apiTypes; + _apiTypes = apiTypes + .OrderBy(apiType => apiType.FullName, StringComparer.Ordinal) + .ToList(); + _explicitCoverageMappings = explicitCoverageMappings; _excludedTypeCount = excludedTypeCount; } - + /// /// Calculate coverage and return the result. /// @@ -42,33 +43,31 @@ public CoverageResult Calculate() .ToList(), ApiTypes = _apiTypes }; - + int totalMembers = 0; int coveredMembers = 0; - + foreach (var gameType in _gameTypes) { - // Check if this game type is covered by S1API - bool isCovered = IsTypeCovered(gameType.FullName); - gameType.IsCovered = isCovered; - - if (isCovered) + CoverageMatch? match = FindCoverageMatch(gameType.FullName); + gameType.IsCovered = match != null; + gameType.CoveredByApiType = match?.ApiTypeName; + gameType.MatchStrategy = match?.Strategy; + + if (match != null) { - // Find which API type covers this game type - gameType.CoveredByApiType = FindCoveringApiType(gameType.FullName); result.CoveredTypes.Add(gameType); - - // Check member coverage + if (_accessedMembers.TryGetValue(gameType.FullName, out var accessedMemberNames)) { foreach (var member in gameType.Members) { - if (accessedMemberNames.Contains(member.Name)) - { - member.IsCovered = true; - member.CoveredByApiType = gameType.CoveredByApiType; - coveredMembers++; - } + if (!accessedMemberNames.Contains(member.Name)) + continue; + + member.IsCovered = true; + member.CoveredByApiType = match.ApiTypeName; + coveredMembers++; } } } @@ -76,154 +75,158 @@ public CoverageResult Calculate() { result.UncoveredTypes.Add(gameType); } - + totalMembers += gameType.Members.Count; } - + result.TotalGameClasses = _gameTypes.Count; result.CoveredGameClasses = result.CoveredTypes.Count; result.TotalGameMembers = totalMembers; result.CoveredGameMembers = coveredMembers; - + return result; } - - private bool IsTypeCovered(string gameTypeFullName) + + private CoverageMatch? FindCoverageMatch(string gameTypeFullName) { - // Strategy 1: Direct exact match - if (_wrappedGameTypes.Contains(gameTypeFullName)) - return true; - - // Strategy 2: Normalized nested class separator (+ to . and vice versa) - var normalizedName = gameTypeFullName.Replace('+', '.'); - if (_wrappedGameTypes.Contains(normalizedName)) - return true; - - // Also try converting wrapped types from . to + - foreach (var wrapped in _wrappedGameTypes) + if (_explicitCoverageMappings.TryGetValue(gameTypeFullName, out var explicitApiType) && + _apiTypes.Any(apiType => apiType.FullName.Equals(explicitApiType, StringComparison.Ordinal))) { - var wrappedNormalized = wrapped.Replace('+', '.'); - if (wrappedNormalized == normalizedName || wrappedNormalized == gameTypeFullName) - return true; + return new CoverageMatch(explicitApiType, CoverageMatchStrategy.Explicit); } - - // Strategy 3: Check if any wrapped type starts with this (for nested types) - // Handle both + and . separators - foreach (var wrapped in _wrappedGameTypes) - { - // Check if wrapped type is a parent of this nested type - if (wrapped.StartsWith(gameTypeFullName + ".", StringComparison.Ordinal) || - wrapped.StartsWith(gameTypeFullName + "+", StringComparison.Ordinal)) - { - return true; - } - - // Check if this type is a nested type within wrapped - // e.g., gameTypeFullName = "ScheduleOne.Console+PackageProduct" - // wrapped = "ScheduleOne.Console" - if (gameTypeFullName.StartsWith(wrapped + "+", StringComparison.Ordinal) || - gameTypeFullName.StartsWith(wrapped + ".", StringComparison.Ordinal)) - { - return true; - } - - // Also check normalized versions - var wrappedNormalized = wrapped.Replace('+', '.'); - var gameNormalized = gameTypeFullName.Replace('+', '.'); - if (wrappedNormalized.StartsWith(gameNormalized + ".", StringComparison.Ordinal) || - gameNormalized.StartsWith(wrappedNormalized + ".", StringComparison.Ordinal)) - { - return true; - } - } - - // Strategy 4: Fuzzy matching based on type names - // This handles cases like: - // - Game: "ScheduleOne.Vehicles.Modification.EVehicleColor" vs S1API: "S1API.Vehicles.VehicleColor" - // - Game: "ScheduleOne.Vehicles.Modification.VehicleColors" vs S1API: "S1API.Vehicles.VehicleColor" - // - Nested types: "ScheduleOne.Console+PackageProduct" vs wrapper that uses Console - var fuzzyMatch = FindFuzzyMatch(gameTypeFullName); - if (fuzzyMatch != null) - { - _fuzzyMatchCache[gameTypeFullName] = fuzzyMatch; - return true; - } - - return false; + + string? exactApiType = FindApiTypeForWrappedName(gameTypeFullName, normalize: false); + if (exactApiType != null) + return new CoverageMatch(exactApiType, CoverageMatchStrategy.Exact); + + string normalizedGameTypeName = NormalizeNestedTypeName(gameTypeFullName); + string? normalizedApiType = FindApiTypeForWrappedName(normalizedGameTypeName, normalize: true); + if (normalizedApiType != null) + return new CoverageMatch(normalizedApiType, CoverageMatchStrategy.Normalized); + + string? nestedApiType = FindNestedMatch(gameTypeFullName); + if (nestedApiType != null) + return new CoverageMatch(nestedApiType, CoverageMatchStrategy.Nested); + + string? fuzzyApiType = FindFuzzyMatch(gameTypeFullName); + return fuzzyApiType == null + ? null + : new CoverageMatch(fuzzyApiType, CoverageMatchStrategy.Fuzzy); } - - /// - /// Find a fuzzy match for a game type among API types. - /// Uses similarity scoring to find the best match above a threshold. - /// + + private string? FindApiTypeForWrappedName(string gameTypeName, bool normalize) + { + return _apiTypes + .Where(apiType => apiType.WrappedGameTypes.Any(wrappedType => + (normalize ? NormalizeNestedTypeName(wrappedType) : wrappedType) + .Equals(gameTypeName, StringComparison.Ordinal))) + .Select(apiType => apiType.FullName) + .FirstOrDefault(); + } + + private string? FindNestedMatch(string gameTypeFullName) + { + string normalizedGameTypeName = NormalizeNestedTypeName(gameTypeFullName); + + return _apiTypes + .SelectMany(apiType => apiType.WrappedGameTypes + .Distinct(StringComparer.Ordinal) + .Select(wrappedType => new + { + ApiTypeName = apiType.FullName, + WrappedTypeName = NormalizeNestedTypeName(wrappedType) + })) + .Where(candidate => + IsNestedRelation(normalizedGameTypeName, candidate.WrappedTypeName)) + .OrderBy(candidate => + Math.Abs(normalizedGameTypeName.Length - candidate.WrappedTypeName.Length)) + .ThenBy(candidate => candidate.ApiTypeName, StringComparer.Ordinal) + .ThenBy(candidate => candidate.WrappedTypeName, StringComparer.Ordinal) + .Select(candidate => candidate.ApiTypeName) + .FirstOrDefault(); + } + private string? FindFuzzyMatch(string gameTypeFullName) { - // Check if fuzzy matching is enabled - if (!Configuration.MatchingConfig.EnableFuzzyMatching) + if (!MatchingConfig.EnableFuzzyMatching) return null; - - double similarityThreshold = Configuration.MatchingConfig.FuzzySimilarityThreshold; + + double similarityThreshold = MatchingConfig.FuzzySimilarityThreshold; double bestScore = 0.0; - string? bestMatch = null; - + string? bestApiType = null; + foreach (var apiType in _apiTypes) { - // Calculate similarity between game type and API type - var score = TypeNameMatcher.CalculateSimilarity( + double apiTypeScore = TypeNameMatcher.CalculateSimilarity( gameTypeFullName, apiType.FullName, apiType.Name); - - if (score > bestScore && score >= similarityThreshold) - { - bestScore = score; - bestMatch = apiType.FullName; - } - } - - // Also check against wrapped game types directly - // (in case the API wraps a game type with a different name) - foreach (var wrappedType in _wrappedGameTypes) - { - var wrappedSimpleName = wrappedType.Split('.', '+').Last(); - var score = TypeNameMatcher.CalculateSimilarity( - gameTypeFullName, - wrappedType, - wrappedSimpleName); - - if (score > bestScore && score >= similarityThreshold) + SelectBetterFuzzyMatch( + apiTypeScore, + similarityThreshold, + apiType.FullName, + ref bestScore, + ref bestApiType); + + foreach (string wrappedType in apiType.WrappedGameTypes + .Distinct(StringComparer.Ordinal) + .OrderBy(typeName => typeName, StringComparer.Ordinal)) { - bestScore = score; - bestMatch = wrappedType; + string wrappedSimpleName = wrappedType.Split('.', '+').Last(); + double wrappedTypeScore = TypeNameMatcher.CalculateSimilarity( + gameTypeFullName, + wrappedType, + wrappedSimpleName); + SelectBetterFuzzyMatch( + wrappedTypeScore, + similarityThreshold, + apiType.FullName, + ref bestScore, + ref bestApiType); } } - - if (Configuration.MatchingConfig.VerboseFuzzyMatching && bestMatch != null) + + if (MatchingConfig.VerboseFuzzyMatching && bestApiType != null) { - Console.WriteLine($"[Fuzzy Match] {gameTypeFullName} -> {bestMatch} (score: {bestScore:F2})"); + Console.WriteLine( + $"[Fuzzy Match] {gameTypeFullName} -> {bestApiType} (score: {bestScore:F2})"); } - - return bestMatch; + + return bestApiType; } - - private string? FindCoveringApiType(string gameTypeFullName) + + private static void SelectBetterFuzzyMatch( + double score, + double threshold, + string apiTypeName, + ref double bestScore, + ref string? bestApiType) { - // First try exact matches - foreach (var apiType in _apiTypes) - { - if (apiType.WrappedGameTypes.Contains(gameTypeFullName)) - return apiType.FullName; - - // Check normalized name - var normalizedName = gameTypeFullName.Replace('+', '.'); - if (apiType.WrappedGameTypes.Contains(normalizedName)) - return apiType.FullName; - } - - // If we found a fuzzy match earlier, return it - if (_fuzzyMatchCache.TryGetValue(gameTypeFullName, out var cachedMatch)) - return cachedMatch; - - return null; + if (score < threshold) + return; + + bool isBetterScore = score > bestScore; + bool isDeterministicTieBreak = + Math.Abs(score - bestScore) < double.Epsilon && + (bestApiType == null || + StringComparer.Ordinal.Compare(apiTypeName, bestApiType) < 0); + + if (!isBetterScore && !isDeterministicTieBreak) + return; + + bestScore = score; + bestApiType = apiTypeName; } + + private static bool IsNestedRelation(string left, string right) => + !left.Equals(right, StringComparison.Ordinal) && + (left.StartsWith(right + ".", StringComparison.Ordinal) || + right.StartsWith(left + ".", StringComparison.Ordinal)); + + private static string NormalizeNestedTypeName(string typeName) => + typeName.Replace('+', '.'); + + private sealed record CoverageMatch( + string ApiTypeName, + CoverageMatchStrategy Strategy); } diff --git a/tools/S1APICoverageAnalyzer/Configuration/ExplicitCoverageConfig.cs b/tools/S1APICoverageAnalyzer/Configuration/ExplicitCoverageConfig.cs new file mode 100644 index 00000000..21e45e85 --- /dev/null +++ b/tools/S1APICoverageAnalyzer/Configuration/ExplicitCoverageConfig.cs @@ -0,0 +1,25 @@ +namespace S1APICoverageAnalyzer.Configuration; + +/// +/// Declares semantic coverage that cannot be inferred from native type references. +/// +internal static class ExplicitCoverageConfig +{ + private static readonly IReadOnlyDictionary Mappings = + new Dictionary(StringComparer.Ordinal) + { + ["ScheduleOne.Temperature.TemperatureEmitterInfo"] = + "S1API.Temperature.TemperatureEmitterInfo", + ["ScheduleOne.Temperature.TemperatureUtility"] = + "S1API.Temperature.TemperatureUtility" + }; + + public static IReadOnlyDictionary GetMappings() => + Mappings; + + public static IEnumerable GetGameTypesCoveredBy(string apiTypeName) => + Mappings + .Where(mapping => mapping.Value.Equals(apiTypeName, StringComparison.Ordinal)) + .Select(mapping => mapping.Key) + .OrderBy(gameTypeName => gameTypeName, StringComparer.Ordinal); +} diff --git a/tools/S1APICoverageAnalyzer/Models/GameType.cs b/tools/S1APICoverageAnalyzer/Models/GameType.cs index 63902596..f70abb8b 100644 --- a/tools/S1APICoverageAnalyzer/Models/GameType.cs +++ b/tools/S1APICoverageAnalyzer/Models/GameType.cs @@ -20,6 +20,11 @@ public sealed class GameType /// The S1API wrapper type that provides coverage for this game type. /// public string? CoveredByApiType { get; set; } + + /// + /// How the analyzer matched this game type to its covering S1API type. + /// + public CoverageMatchStrategy? MatchStrategy { get; set; } /// /// Number of members that are covered. @@ -38,6 +43,15 @@ public sealed class GameType TotalMemberCount == 0 ? 0 : (double)CoveredMemberCount / TotalMemberCount * 100; } +public enum CoverageMatchStrategy +{ + Explicit, + Exact, + Normalized, + Nested, + Fuzzy +} + public enum GameTypeKind { Class, diff --git a/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs b/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs index 3d0052ec..9bb54eef 100644 --- a/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs +++ b/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs @@ -45,6 +45,7 @@ public static string GenerateJsonReport(CoverageResult result) { FullName = t.FullName, CoveredBy = t.CoveredByApiType, + MatchStrategy = t.MatchStrategy?.ToString(), MembersCovered = t.CoveredMemberCount, MembersTotal = t.TotalMemberCount }) @@ -148,7 +149,8 @@ public static string GenerateTextReport(CoverageResult result) if (!string.IsNullOrEmpty(type.CoveredByApiType)) { sb.AppendLine($" [✓] {type.Name}"); - sb.AppendLine($" -> {type.CoveredByApiType}"); + sb.AppendLine( + $" -> {type.CoveredByApiType} ({type.MatchStrategy})"); } else { @@ -235,6 +237,7 @@ internal sealed class TypeCoverageInfo { public required string FullName { get; init; } public string? CoveredBy { get; init; } + public string? MatchStrategy { get; init; } public int MembersCovered { get; init; } public int MembersTotal { get; init; } } diff --git a/tools/S1APICoverageAnalyzer/Program.cs b/tools/S1APICoverageAnalyzer/Program.cs index 4c98e8be..3eac389a 100644 --- a/tools/S1APICoverageAnalyzer/Program.cs +++ b/tools/S1APICoverageAnalyzer/Program.cs @@ -228,15 +228,16 @@ private static async Task RunAnalysis(AnalysisOptions options) var wrappedTypes = apiAnalyzer.GetWrappedGameTypes(); var accessedMembers = apiAnalyzer.GetAccessedMembers(); var apiTypes = apiAnalyzer.GetApiTypes(); + var explicitCoverageMappings = apiAnalyzer.GetExplicitCoverageMappings(); Console.WriteLine($" Found {wrappedTypes.Count} wrapped game types across {apiTypes.Count} API types"); // Calculate coverage Console.WriteLine("Calculating coverage..."); var calculator = new CoverageCalculator( - gameTypes, - wrappedTypes, - accessedMembers, + gameTypes, + accessedMembers, apiTypes, + explicitCoverageMappings, excludedTypeCount); var result = calculator.Calculate(); @@ -321,7 +322,10 @@ private static async Task RunAnalysis(AnalysisOptions options) { Console.WriteLine($" [covered] {type.FullName}"); if (!string.IsNullOrEmpty(type.CoveredByApiType)) - Console.WriteLine($" -> Wrapped by: {type.CoveredByApiType}"); + { + Console.WriteLine( + $" -> Wrapped by: {type.CoveredByApiType} ({type.MatchStrategy})"); + } } Console.WriteLine(); diff --git a/tools/S1APICoverageAnalyzer/README.md b/tools/S1APICoverageAnalyzer/README.md index 7391ed9f..c26b668d 100644 --- a/tools/S1APICoverageAnalyzer/README.md +++ b/tools/S1APICoverageAnalyzer/README.md @@ -9,8 +9,10 @@ If the game splits public `ScheduleOne.*` types across multiple assemblies, pass - **Type Coverage Analysis**: Identifies which game types are wrapped by S1API - **Member Coverage Analysis**: Tracks which members (fields, properties, methods) are exposed - **Smart Type Matching**: Uses multiple strategies to match game types to S1API types + - Explicit matching for reviewed semantic mirrors - Exact matching - Normalized matching (handles nested type separators) + - Nested matching (attributes declaring and nested types) - Fuzzy matching (handles naming variations) - **Configurable Exclusions**: Excludes internal/infrastructure types from analysis - **Multiple Output Formats**: JSON, plain text, and badge markdown @@ -39,17 +41,25 @@ dotnet run --project S1APICoverageAnalyzer.csproj \ ## Type Matching Strategies -The analyzer uses multiple strategies to match game types to S1API types, in order of priority: +The analyzer uses multiple strategies to match game types to S1API types, in order of priority. Every covered type records both the responsible S1API type and the selected strategy. -### 1. Exact Match +### 1. Explicit Match + +`Configuration/ExplicitCoverageConfig.cs` declares runtime-agnostic mirrors that intentionally avoid retaining native game types in their public or compiled shape. Keep this list limited to reviewed semantic equivalents. + +### 2. Exact Match Direct full name match: `ScheduleOne.NPCs.NPC` == `ScheduleOne.NPCs.NPC` -### 2. Normalized Match +### 3. Normalized Match Handles nested type separator differences: - Game: `ScheduleOne.Casino.SlotMachine+ESymbol` - Matches: `ScheduleOne.Casino.SlotMachine.ESymbol` -### 3. Fuzzy Match +### 4. Nested Match + +Attributes a nested game type to the S1API type that wraps its declaring type, or vice versa. + +### 5. Fuzzy Match Handles common naming variations: #### Enum Prefix Differences @@ -101,7 +111,7 @@ Edit `Configuration/ExclusionConfig.cs` to adjust which types are excluded from ### JSON Report Detailed coverage data including: - Class and member coverage percentages -- List of covered types with their covering API types +- List of covered types with their covering API types and match strategies - List of uncovered types - Excluded namespace information diff --git a/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj b/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj index 4d1a6ae0..545aaf2a 100644 --- a/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj +++ b/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj @@ -15,4 +15,8 @@ + + + +