From 0742533c1bea6e77dfbf975d2d3d4e02a2cbe25a Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 02:41:38 -0400 Subject: [PATCH 01/23] Add Firebase binding audit tooling --- docs/firebase-binding-api-shape-policy.md | 27 + .../BindingComparerNormalizationTests.cs | 355 +++ .../FirebaseBindingAudit.Tests.csproj | 21 + .../SuppressionEngineTests.cs | 270 +++ scripts/FirebaseBindingAudit/AuditRunner.cs | 1436 +++++++++++ scripts/FirebaseBindingAudit/BindingModel.cs | 2116 +++++++++++++++++ scripts/FirebaseBindingAudit/Configuration.cs | 94 + .../FirebaseBindingAudit.csproj | 13 + .../InternalsVisibleTo.cs | 3 + scripts/FirebaseBindingAudit/Program.cs | 154 ++ scripts/FirebaseBindingAudit/ReportWriter.cs | 364 +++ scripts/FirebaseBindingAudit/Suppressions.cs | 327 +++ scripts/bootstrap-sharpie.sh | 39 + scripts/compare-firebase-bindings.sh | 103 + .../firebase-binding-audit-suppressions.json | 236 ++ scripts/firebase-binding-audit.json | 208 ++ 16 files changed, 5766 insertions(+) create mode 100644 docs/firebase-binding-api-shape-policy.md create mode 100644 scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs create mode 100644 scripts/FirebaseBindingAudit.Tests/FirebaseBindingAudit.Tests.csproj create mode 100644 scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs create mode 100644 scripts/FirebaseBindingAudit/AuditRunner.cs create mode 100644 scripts/FirebaseBindingAudit/BindingModel.cs create mode 100644 scripts/FirebaseBindingAudit/Configuration.cs create mode 100644 scripts/FirebaseBindingAudit/FirebaseBindingAudit.csproj create mode 100644 scripts/FirebaseBindingAudit/InternalsVisibleTo.cs create mode 100644 scripts/FirebaseBindingAudit/Program.cs create mode 100644 scripts/FirebaseBindingAudit/ReportWriter.cs create mode 100644 scripts/FirebaseBindingAudit/Suppressions.cs create mode 100755 scripts/bootstrap-sharpie.sh create mode 100755 scripts/compare-firebase-bindings.sh create mode 100644 scripts/firebase-binding-audit-suppressions.json create mode 100644 scripts/firebase-binding-audit.json diff --git a/docs/firebase-binding-api-shape-policy.md b/docs/firebase-binding-api-shape-policy.md new file mode 100644 index 00000000..51618ea5 --- /dev/null +++ b/docs/firebase-binding-api-shape-policy.md @@ -0,0 +1,27 @@ +# Firebase Binding API Shape Policy + +This note records the comparison policy used when triaging Firebase binding drift. The goal is to keep bindings thin and runtime-correct while avoiding unnecessary breaking changes for APIs that are already in use. + +## Preferred Shape + +- Selector correctness wins. If a binding has the wrong selector, wrong native argument type, or another mismatch that can throw before Firebase business logic runs, fix the binding directly. +- Prefer `Action<>` callback smoothing for new or additive APIs that bind Objective-C block callbacks. +- Do not replace existing named-delegate callback APIs just because `Action<>` is preferred. Keep the existing API for compatibility, then add the preferred `Action<>` shape case-by-case when it is useful and can be validated. +- Obsolete non-preferred callback shapes only after the preferred shape exists and the migration path is clear. +- Keep bindings thin. Avoid adding managed behavior in `ApiDefinition.cs` just to paper over native API shape differences. + +## Audit Treatment + +- Treat read-only properties and zero-argument getter methods as equivalent when they bind the same native selector and have equivalent return types. +- Treat raw and typed Foundation collection wrappers, such as `NSDictionary` and `NSDictionary`, as equivalent for binding-surface comparison when the selector is otherwise the same. +- Treat named-delegate callbacks and equivalent `Action<>` callbacks as compatible, but report them as informational preferred-shape candidates so they can be reviewed case-by-case. +- Do not normalize semantic delegate aliases that are not mechanically equivalent. Those should stay visible until header/source review proves the mapping. +- Do not churn enum underlying types or `nint`/`long`-style numeric differences without a concrete runtime or interoperability reason. + +## Case-By-Case Obsoletion Flow + +1. Confirm the native header shape and the current managed shape. +2. Add the preferred binding shape only when it improves runtime correctness or developer experience without hiding native semantics. +3. Add or update a targeted E2E case when the API can be exercised locally. +4. Keep the existing non-preferred API unless it was already runtime-broken. +5. Mark the older API obsolete only when the preferred replacement is present and the compatibility tradeoff is intentional. diff --git a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs new file mode 100644 index 00000000..d624e8fb --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs @@ -0,0 +1,355 @@ +using Xunit; + +namespace FirebaseBindingAudit.Tests; + +public sealed class BindingComparerNormalizationTests +{ + private static readonly string[] ManualAttributes = ["Wrap", "Advice", "Async", "Internal"]; + private static readonly string[] BindingAttributes = ["Export", "Field", "Notification"]; + + [Fact] + public void Compare_MatchesFirPrefixedEnumType() + { + var result = Compare( + """ + public enum RemoteConfigFetchStatus + { + Success + } + """, + """ + public enum FIRRemoteConfigFetchStatus + { + Success + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesAbtPrefixedEnumType() + { + var result = Compare( + """ + public enum ExperimentPayloadExperimentOverflowPolicy + { + DiscardOldest + } + """, + """ + public enum ABTExperimentPayloadExperimentOverflowPolicy + { + DiscardOldest + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesDelegateSuffixAndAcronymShape() + { + var result = Compare( + """ + public delegate void MessagingFcmTokenFetchCompletionHandler(string token, NSError error); + """, + """ + public delegate void FIRMessagingFCMTokenFetchCompletion(string token, NSError error); + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesFirPrefixedProtocolInterfaceShape() + { + var result = Compare( + """ + [Protocol(Name = "FIRPerformanceAttributable")] + public interface IPerformanceAttributable + { + [Export("setValue:forAttribute:")] + void SetValue(string value, string attribute); + } + """, + """ + [Protocol] + public interface IFIRPerformanceAttributable + { + [Export("setValue:forAttribute:")] + void SetValue(string value, string attribute); + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesEnumMemberWithContainingEnumPrefix() + { + var result = Compare( + """ + public enum AggregateSource + { + Server + } + """, + """ + public enum FIRAggregateSource + { + FIRAggregateSourceServer + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesNormalizedTypeReferencesInExportedMembers() + { + var result = Compare( + """ + public enum RemoteConfigFetchStatus + { + Success + } + + [BaseType(typeof(NSObject), Name = "FIRRemoteConfig")] + public interface RemoteConfig + { + [Export("lastFetchStatus")] + RemoteConfigFetchStatus LastFetchStatus { get; } + } + """, + """ + public enum FIRRemoteConfigFetchStatus + { + Success + } + + [BaseType(typeof(NSObject))] + public interface FIRRemoteConfig + { + [Export("lastFetchStatus")] + FIRRemoteConfigFetchStatus LastFetchStatus { get; } + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesNormalizedTypeReferencesInMethodParameters() + { + var result = Compare( + """ + [BaseType(typeof(NSObject), Name = "FIRLifecycleEvents")] + public interface LifecycleEvents + { + } + + public enum ExperimentPayloadExperimentOverflowPolicy + { + DiscardOldest + } + + [BaseType(typeof(NSObject), Name = "FIRExperimentController")] + public interface ExperimentController + { + [Export("updateExperimentsWithServiceOrigin:events:policy:lastStartTime:payloads:completionHandler:")] + void UpdateExperiments(string origin, LifecycleEvents events, ExperimentPayloadExperimentOverflowPolicy policy, double lastStartTime, NSData [] payloads, Action completionHandler); + } + """, + """ + [BaseType(typeof(NSObject))] + public interface FIRLifecycleEvents + { + } + + public enum ABTExperimentPayloadExperimentOverflowPolicy + { + DiscardOldest + } + + [BaseType(typeof(NSObject))] + public interface FIRExperimentController + { + [Export("updateExperimentsWithServiceOrigin:events:policy:lastStartTime:payloads:completionHandler:")] + void UpdateExperimentsWithServiceOrigin(string origin, FIRLifecycleEvents events, ABTExperimentPayloadExperimentOverflowPolicy policy, double lastStartTime, NSData[] payloads, Action completionHandler); + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesBooleanGetterAndPropertySelectorShape() + { + var result = Compare( + """ + [BaseType(typeof(NSObject), Name = "FIRMessaging")] + public interface Messaging + { + [Export("autoInitEnabled")] + bool AutoInitEnabled { [Bind("isAutoInitEnabled")] get; set; } + } + """, + """ + [BaseType(typeof(NSObject))] + public interface FIRMessaging + { + [Export("isAutoInitEnabled")] + bool AutoInitEnabled { get; set; } + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesReadOnlyPropertyAndZeroArgumentGetterMethodShape() + { + var result = Compare( + """ + [BaseType(typeof(NSObject), Name = "FIRApp")] + public interface App + { + [Static] + [Export("defaultApp")] + App DefaultInstance { get; } + } + """, + """ + [BaseType(typeof(NSObject))] + public interface FIRApp + { + [Static] + [Export("defaultApp")] + FIRApp DefaultApp(); + } + """); + + AssertNoFailures(result); + Assert.Empty(result.Infos); + } + + [Fact] + public void Compare_MatchesRawAndTypedFoundationCollections() + { + var result = Compare( + """ + [BaseType(typeof(NSObject), Name = "FIRAnalytics")] + public interface Analytics + { + [Static] + [Export("logEventWithName:parameters:")] + void LogEvent(string name, NSDictionary parameters); + } + """, + """ + [BaseType(typeof(NSObject))] + public interface FIRAnalytics + { + [Static] + [Export("logEventWithName:parameters:")] + void LogEventWithName(string name, NSDictionary parameters); + } + """); + + AssertNoFailures(result); + Assert.Empty(result.Infos); + } + + [Fact] + public void Compare_DemotesNamedDelegateAndActionSmoothingToInfo() + { + var result = Compare( + """ + public delegate void DatabaseReferenceCompletionHandler(NSError error, DatabaseReference reference); + + [BaseType(typeof(NSObject), Name = "FIRDatabaseReference")] + public interface DatabaseReference + { + [Export("setValue:withCompletionBlock:")] + void SetValue(NSObject value, DatabaseReferenceCompletionHandler completionBlock); + } + """, + """ + [BaseType(typeof(NSObject))] + public interface FIRDatabaseReference + { + [Export("setValue:withCompletionBlock:")] + void SetValue(NSObject value, Action completionBlock); + } + """); + + AssertNoFailures(result); + var info = Assert.Single(result.Infos); + Assert.Equal("preferred-api-shape", info.Category); + Assert.Contains("Action<> smoothing", info.Message); + } + + [Fact] + public void Compare_KeepsNonEquivalentActionCallbackAsSignatureDrift() + { + var result = Compare( + """ + public delegate void DatabaseReferenceCompletionHandler(NSError error, DatabaseReference reference); + + [BaseType(typeof(NSObject), Name = "FIRDatabaseReference")] + public interface DatabaseReference + { + [Export("setValue:withCompletionBlock:")] + void SetValue(NSObject value, DatabaseReferenceCompletionHandler completionBlock); + } + """, + """ + [BaseType(typeof(NSObject))] + public interface FIRDatabaseReference + { + [Export("setValue:withCompletionBlock:")] + void SetValue(NSObject value, Action completionBlock); + } + """); + + Assert.Contains(result.Failures, static failure => failure.Category == "signature-drift"); + } + + private static TargetComparisonResult Compare(string baselineSource, string generatedSource) + { + var baselineFile = Path.Combine(Path.GetTempPath(), $"firebase-binding-audit-baseline-{Guid.NewGuid():N}.cs"); + var generatedFile = Path.Combine(Path.GetTempPath(), $"firebase-binding-audit-generated-{Guid.NewGuid():N}.cs"); + + try + { + File.WriteAllText(baselineFile, baselineSource); + File.WriteAllText(generatedFile, generatedSource); + + var parser = new BindingSyntaxParser(ManualAttributes, BindingAttributes); + var baseline = parser.Parse([baselineFile], []); + var generated = parser.Parse([generatedFile], []); + + return new BindingComparer().Compare(baseline, generated); + } + finally + { + if (File.Exists(baselineFile)) + { + File.Delete(baselineFile); + } + + if (File.Exists(generatedFile)) + { + File.Delete(generatedFile); + } + } + } + + private static void AssertNoFailures(TargetComparisonResult result) + { + Assert.True( + result.Failures.Count == 0, + string.Join(Environment.NewLine, result.Failures.Select(static failure => $"{failure.Category}: {failure.Message}"))); + } +} diff --git a/scripts/FirebaseBindingAudit.Tests/FirebaseBindingAudit.Tests.csproj b/scripts/FirebaseBindingAudit.Tests/FirebaseBindingAudit.Tests.csproj new file mode 100644 index 00000000..f232f28c --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/FirebaseBindingAudit.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs b/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs new file mode 100644 index 00000000..e888d9ce --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs @@ -0,0 +1,270 @@ +using Xunit; + +namespace FirebaseBindingAudit.Tests; + +public sealed class SuppressionEngineTests +{ + [Fact] + public void Apply_UsesComparisonKeysForExactMemberLevelMatch() + { + var result = CreateResult( + "CloudMessaging", + CreateFailureFinding( + typeName: "Firebase.CloudMessaging.Messaging", + memberName: "ApnsToken", + selector: "APNSToken", + comparisonTypeKey: "firmessaging", + comparisonMemberKey: "property:APNSToken")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-1", + Target = "CloudMessaging", + Category = "attribute-drift", + ComparisonTypeKey = "firmessaging", + ComparisonMemberKey = "property:APNSToken", + Reason = "known false positive", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: false); + var target = application.Results.Single(); + + Assert.Equal("passed", target.Status); + Assert.Equal(0, target.FailureCount); + Assert.Empty(target.Findings); + Assert.Equal(1, target.SuppressedCount); + Assert.Equal("rule-1", target.SuppressedFindings.Single().SuppressionId); + Assert.Equal(1, application.Summary.MatchedRuleCount); + } + + [Fact] + public void Apply_FallsBackToTypeMemberAndSelectorMatch() + { + var result = CreateResult( + "RemoteConfig", + CreateFailureFinding( + typeName: "Firebase.RemoteConfig.RemoteConfigValue", + memberName: "JsonValue", + selector: "JSONValue")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-2", + Target = "RemoteConfig", + Category = "attribute-drift", + TypeName = "Firebase.RemoteConfig.RemoteConfigValue", + MemberName = "JsonValue", + Selector = "JSONValue", + Reason = "known false positive", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: false); + var target = application.Results.Single(); + + Assert.Equal(1, target.SuppressedCount); + Assert.Empty(target.Findings); + Assert.Equal("rule-2", target.SuppressedFindings.Single().SuppressionId); + } + + [Fact] + public void Apply_SuppressedFindingsDoNotCountTowardFailureCountOrStatus() + { + var result = CreateResult( + "Core", + CreateFailureFinding( + typeName: "Firebase.Core.Options", + memberName: "ApiKey", + selector: "APIKey")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-3", + Target = "Core", + Category = "attribute-drift", + TypeName = "Firebase.Core.Options", + MemberName = "ApiKey", + Selector = "APIKey", + Reason = "known false positive", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: false); + var target = application.Results.Single(); + + Assert.Equal("passed", target.Status); + Assert.Equal(0, target.FailureCount); + Assert.Equal(0, target.InfoCount); + Assert.Equal(1, target.SuppressedCount); + } + + [Fact] + public void Apply_ReportsOverBroadRuleAsInvalidAndLeavesFindingsActive() + { + var result = CreateResult( + "CloudMessaging", + CreateFailureFinding( + typeName: "Firebase.CloudMessaging.Messaging", + memberName: "ApnsToken", + selector: "APNSToken"), + CreateFailureFinding( + typeName: "Firebase.CloudMessaging.Messaging", + memberName: "FcmToken", + selector: "FCMToken")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-4", + Target = "CloudMessaging", + Category = "attribute-drift", + TypeName = "Firebase.CloudMessaging.Messaging", + Reason = "too broad", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: false); + var target = application.Results.Single(); + + Assert.Equal(1, application.Summary.InvalidRuleCount); + Assert.Equal(0, target.SuppressedCount); + Assert.Equal(2, target.FailureCount); + Assert.Equal("failed", target.Status); + } + + [Fact] + public void Apply_ReportsUnusedRuleAsStaleInfoOnly() + { + var result = CreateResult( + "Core", + CreateFailureFinding( + typeName: "Firebase.Core.App", + memberName: "DefaultInstance", + selector: "defaultApp")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-5", + Target = "Core", + Category = "attribute-drift", + TypeName = "Firebase.Core.Options", + MemberName = "ApiKey", + Selector = "APIKey", + Reason = "unused", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: false); + + Assert.Equal(1, application.Summary.StaleRuleCount); + Assert.Equal(0, application.Summary.InvalidRuleCount); + Assert.Equal(0, application.Summary.MatchedRuleCount); + Assert.Single(application.Results.Single().Findings); + } + + [Fact] + public void Apply_DisableSuppressionsLeavesFindingsUnsuppressed() + { + var result = CreateResult( + "Core", + CreateFailureFinding( + typeName: "Firebase.Core.Options", + memberName: "ApiKey", + selector: "APIKey")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-6", + Target = "Core", + Category = "attribute-drift", + TypeName = "Firebase.Core.Options", + MemberName = "ApiKey", + Selector = "APIKey", + Reason = "known false positive", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: true); + var target = application.Results.Single(); + + Assert.False(application.Summary.Enabled); + Assert.Equal(1, target.FailureCount); + Assert.Equal(0, target.SuppressedCount); + Assert.Single(target.Findings); + } + + private static TargetAuditResult CreateResult(string target, params AuditFinding[] findings) + { + return new TargetAuditResult( + Target: target, + Xcframework: $"{target}.xcframework", + Status: "failed", + GenerationStatus: "succeeded", + ComparisonSource: "primary", + FailureCount: findings.Count(finding => finding.Severity == "failure"), + InfoCount: findings.Count(finding => finding.Severity == "info"), + SuppressedCount: 0, + SharpieStatus: "not-requested", + ConfidenceSummary: AuditFindingSummary.BuildConfidenceSummary(findings), + Findings: findings, + SuppressedFindings: []); + } + + private static AuditFinding CreateFailureFinding( + string typeName, + string memberName, + string selector, + string? comparisonTypeKey = null, + string? comparisonMemberKey = null) + { + return new AuditFinding( + Category: "attribute-drift", + Severity: "failure", + Message: $"{typeName}.{memberName}", + TypeName: typeName, + MemberName: memberName, + Selector: selector, + BaselineFile: "baseline.cs", + GeneratedFile: "generated.cs", + ComparisonTypeKey: comparisonTypeKey, + ComparisonMemberKey: comparisonMemberKey, + Confidence: "not-reviewed", + ConfidenceSource: "test"); + } +} diff --git a/scripts/FirebaseBindingAudit/AuditRunner.cs b/scripts/FirebaseBindingAudit/AuditRunner.cs new file mode 100644 index 00000000..f8669abb --- /dev/null +++ b/scripts/FirebaseBindingAudit/AuditRunner.cs @@ -0,0 +1,1436 @@ +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace FirebaseBindingAudit; + +internal sealed record AuditOptions( + string RepoRoot, + string OutputDirectory, + string GeneratorVersion, + IReadOnlyList? SelectedTargets, + bool KeepTemp, + string? SharpiePath, + string SharpieVersion, + bool DisableSharpie, + bool DisableSuppressions); + +internal sealed record AuditFinding( + string Category, + string Severity, + string Message, + string TypeName, + string? MemberName, + string? Selector, + string? BaselineFile, + string? GeneratedFile, + string? ComparisonTypeKey = null, + string? ComparisonMemberKey = null, + string? Confidence = null, + string? ConfidenceSource = null, + string? SuppressionId = null, + string? SuppressionReason = null, + IReadOnlyList? SuppressionEvidence = null); + +internal sealed record ConfidenceSummary( + int Confirmed, + int Disputed, + int LowConfidence, + int NotReviewed); + +internal sealed record TargetAuditResult( + string Target, + string Xcframework, + string Status, + string GenerationStatus, + string ComparisonSource, + int FailureCount, + int InfoCount, + int SuppressedCount, + string SharpieStatus, + ConfidenceSummary ConfidenceSummary, + IReadOnlyList Findings, + IReadOnlyList SuppressedFindings); + +internal sealed record AuditRunResult( + int ExitCode, + string OutputDirectory, + IReadOnlyList Results, + string? TempDirectory, + SharpieToolResolution SharpieResolution, + SuppressionSummary SuppressionSummary); + +internal sealed record SharpieToolResolution( + string Status, + string ExpectedVersion, + string? CommandPath, + string? ResolvedPath, + string? Version, + string? Message) +{ + public bool IsReady => string.Equals(Status, "resolved", StringComparison.Ordinal); +} + +internal sealed record SharpieRunResult( + string Status, + string? FrameworkPath, + ProcessResult? ProcessResult, + TargetComparisonResult? Comparison, + BindingSnapshot? Snapshot, + IReadOnlyList GeneratedFiles, + IReadOnlyList WarningFindings, + string? Message); + +internal sealed class AuditRunner +{ + private static readonly Regex SwiftFrameworkDependencyRegex = new( + "", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly AuditConfiguration configuration; + private readonly SuppressionConfiguration suppressionConfiguration; + private readonly BindingSyntaxParser parser; + private readonly BindingComparer comparer = new(); + + public AuditRunner(AuditConfiguration configuration, SuppressionConfiguration suppressionConfiguration) + { + this.configuration = configuration; + this.suppressionConfiguration = suppressionConfiguration; + parser = new BindingSyntaxParser(configuration.ManualAttributes, configuration.BindingAttributes); + } + + public async Task RunAsync(AuditOptions options, CancellationToken cancellationToken = default) + { + var outputDirectory = Path.GetFullPath(options.OutputDirectory); + var detailsDirectory = Path.Combine(outputDirectory, "details"); + var logsDirectory = Path.Combine(outputDirectory, "logs"); + PrepareOutputDirectory(outputDirectory, detailsDirectory, logsDirectory); + + var selectedTargets = SelectTargets(options.SelectedTargets); + var tempRoot = Path.Combine(Path.GetTempPath(), "firebase-binding-audit", DateTime.UtcNow.ToString("yyyyMMddHHmmss")); + Directory.CreateDirectory(tempRoot); + var sharedNugetPackages = Path.Combine(Path.GetTempPath(), "firebase-binding-audit", "nuget-packages"); + var dotnetEnvironment = new Dictionary(StringComparer.Ordinal) + { + ["NUGET_PACKAGES"] = sharedNugetPackages, + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + }; + Directory.CreateDirectory(sharedNugetPackages); + + var sharpieResolution = await ResolveSharpieAsync(options, cancellationToken); + + try + { + var stagedFrameworksDirectory = Path.Combine(tempRoot, "frameworks"); + StageXcframeworks(options.RepoRoot, stagedFrameworksDirectory); + var sharpieFrameworkSearchDirectory = Path.Combine(tempRoot, "sharpie-frameworks"); + StageSharpieFrameworkSlices(stagedFrameworksDirectory, sharpieFrameworkSearchDirectory); + + var sharedAliases = BuildSharedAliases(options.RepoRoot); + var results = new List(); + foreach (var target in selectedTargets) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await AuditTargetAsync(target, options, tempRoot, logsDirectory, dotnetEnvironment, sharedAliases, sharpieResolution, sharpieFrameworkSearchDirectory, cancellationToken); + results.Add(result); + } + + var suppressionApplication = SuppressionEngine.Apply(results, suppressionConfiguration, options.DisableSuppressions); + var finalResults = suppressionApplication.Results; + var exitCode = finalResults.Any(static result => result.Status == "failed") || + suppressionApplication.Summary.InvalidRuleCount > 0 + ? 1 + : 0; + + await ReportWriter.WriteAsync( + outputDirectory, + detailsDirectory, + options.GeneratorVersion, + finalResults, + tempRoot, + sharpieResolution, + suppressionApplication.Summary, + cancellationToken); + + return new AuditRunResult( + exitCode, + outputDirectory, + finalResults, + options.KeepTemp ? tempRoot : null, + sharpieResolution, + suppressionApplication.Summary); + } + finally + { + if (!options.KeepTemp && Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + private IReadOnlyDictionary BuildSharedAliases(string repoRoot) + { + var comparableFiles = configuration.Targets + .SelectMany(target => target.BaselineFiles.Select(file => Path.Combine(target.BaselineDirectoryPath(repoRoot), file))) + .ToList(); + return parser.Parse(comparableFiles, []).TypeAliases; + } + + private async Task AuditTargetAsync( + AuditTargetDefinition target, + AuditOptions options, + string tempRoot, + string logsDirectory, + IReadOnlyDictionary dotnetEnvironment, + IReadOnlyDictionary sharedAliases, + SharpieToolResolution sharpieResolution, + string sharpieFrameworkSearchDirectory, + CancellationToken cancellationToken) + { + var diagnosticFindings = new List(); + var baselineComparableFiles = target.BaselineFiles + .Select(file => Path.Combine(target.BaselineDirectoryPath(options.RepoRoot), file)) + .ToList(); + var baselineHelperFiles = target.HelperFiles + .Select(file => Path.Combine(target.BaselineDirectoryPath(options.RepoRoot), file)) + .ToList(); + var baselineSnapshot = parser.Parse(baselineComparableFiles, baselineHelperFiles); + + var generationStatus = "succeeded"; + var primaryFailed = false; + var primaryComparableSurfaceEmpty = false; + var sharpieStatus = options.DisableSharpie ? "disabled" : "not-requested"; + TargetComparisonResult? primaryComparison = null; + TargetComparisonResult? sharpieComparison = null; + SharpieRunResult? sharpieRun = null; + var comparisonSource = "primary"; + + var targetProjectsDirectory = Path.Combine(tempRoot, "projects"); + var projectName = $"FirebaseBindingAudit.{target.Id}"; + Directory.CreateDirectory(targetProjectsDirectory); + var projectDirectory = Path.Combine(targetProjectsDirectory, projectName); + Directory.CreateDirectory(projectDirectory); + var stagedFrameworkPath = Path.Combine(tempRoot, "frameworks", $"{target.Xcframework}.xcframework"); + + try + { + var projectFile = CreateSwiftBindingProject(projectDirectory, projectName, stagedFrameworkPath, options.GeneratorVersion); + var buildResult = await BuildTargetProjectAsync(projectFile, projectDirectory, dotnetEnvironment, cancellationToken); + WriteLogs(logsDirectory, $"{target.Id}-build", buildResult); + + var primaryWarnings = BuildProcessWarningFindings(buildResult, target, "dotnet build", "generation-warning"); + diagnosticFindings.AddRange(primaryWarnings); + if (primaryWarnings.Count > 0) + { + generationStatus = "succeeded_with_warnings"; + } + + if (buildResult.ExitCode != 0) + { + primaryFailed = true; + generationStatus = "failed"; + diagnosticFindings.AddRange(BuildGenerationFailureFindings(buildResult, $"Binding generation failed for {target.Id}.")); + } + else + { + var generatedBindingFiles = FindGeneratedBindingFiles(projectDirectory); + if (generatedBindingFiles.Count == 0) + { + primaryFailed = true; + generationStatus = "failed"; + diagnosticFindings.Add(BuildPrimaryGenerationFinding( + category: "generation-failure", + severity: "failure", + target.PackageId, + $"Expected generated binding files were not found for {target.Id}.")); + } + else + { + var generatedSnapshot = parser.Parse(generatedBindingFiles, []); + primaryComparableSurfaceEmpty = CountComparableSurface(generatedSnapshot) == 0; + if (primaryComparableSurfaceEmpty) + { + diagnosticFindings.Add(BuildPrimaryGenerationFinding( + category: "generation-warning", + severity: "info", + target.PackageId, + $"Fresh output for {target.Id} contained zero comparable bound types, delegates, or enums.")); + if (!string.Equals(generationStatus, "failed", StringComparison.Ordinal)) + { + generationStatus = "succeeded_with_warnings"; + } + } + + primaryComparison = comparer.Compare(baselineSnapshot, generatedSnapshot, sharedAliases); + } + } + } + catch (Exception exception) + { + primaryFailed = true; + generationStatus = "failed"; + diagnosticFindings.Add(BuildPrimaryGenerationFinding( + category: "generation-failure", + severity: "failure", + target.PackageId, + $"Failed to create or build the temp binding project for {target.Id}: {exception.Message}")); + } + + var sharpieRequested = ShouldRequestSharpie(target, options, primaryFailed, primaryComparableSurfaceEmpty); + if (sharpieRequested) + { + if (!sharpieResolution.IsReady) + { + sharpieStatus = sharpieResolution.Status; + diagnosticFindings.Add(BuildSharpieStatusFinding( + target, + $"Sharpie fallback was requested for {target.Id}, but Sharpie is {sharpieResolution.Status}: {sharpieResolution.Message ?? "no additional details"}")); + } + else + { + try + { + sharpieRun = await RunSharpieAsync(target, stagedFrameworkPath, tempRoot, logsDirectory, sharedAliases, baselineSnapshot, sharpieResolution, sharpieFrameworkSearchDirectory, cancellationToken); + } + catch (Exception exception) + { + sharpieRun = new SharpieRunResult( + Status: "failed", + FrameworkPath: null, + ProcessResult: null, + Comparison: null, + Snapshot: null, + GeneratedFiles: [], + WarningFindings: [], + Message: $"Sharpie bind failed for {target.Id}: {exception.Message}"); + } + + sharpieStatus = sharpieRun.Status; + diagnosticFindings.AddRange(sharpieRun.WarningFindings); + sharpieComparison = sharpieRun.Comparison; + + if (!string.Equals(sharpieRun.Status, "succeeded", StringComparison.Ordinal)) + { + diagnosticFindings.Add(BuildSharpieStatusFinding( + target, + sharpieRun.Message ?? $"Sharpie completed with status '{sharpieRun.Status}' for {target.Id}.")); + } + } + } + + var effectiveFindings = new List(diagnosticFindings); + if (ShouldUseSharpieComparisonFallback(target, primaryFailed, primaryComparableSurfaceEmpty, sharpieRun)) + { + comparisonSource = "sharpie-fallback"; + generationStatus = GetFallbackGenerationStatus(generationStatus, primaryFailed, primaryComparableSurfaceEmpty); + effectiveFindings = DemotePrimaryGenerationFailuresForFallback(effectiveFindings); + effectiveFindings.Add(BuildComparisonFallbackFinding(target, primaryFailed, primaryComparableSurfaceEmpty, sharpieRun!.FrameworkPath)); + effectiveFindings.AddRange(sharpieComparison!.Failures); + effectiveFindings.AddRange(sharpieComparison.Infos); + } + else + { + if (primaryComparison is not null) + { + effectiveFindings.AddRange(primaryComparison.Failures); + effectiveFindings.AddRange(primaryComparison.Infos); + } + } + + var annotatedFindings = AnnotateFindings(effectiveFindings, sharpieRequested, sharpieStatus, sharpieComparison, comparisonSource); + var status = annotatedFindings.Any(static finding => finding.Severity == "failure") ? "failed" : "passed"; + return BuildTargetResult(target, status, generationStatus, comparisonSource, sharpieStatus, BuildConfidenceSummary(annotatedFindings), annotatedFindings); + } + + private bool ShouldRequestSharpie(AuditTargetDefinition target, AuditOptions options, bool primaryFailed, bool primaryComparableSurfaceEmpty) + { + if (options.DisableSharpie) + { + return false; + } + + var mode = target.EffectiveSharpieMode(configuration.Sharpie); + if (string.Equals(mode, "off", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(mode, "forceFallback", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return primaryFailed || primaryComparableSurfaceEmpty; + } + + private static bool ShouldUseSharpieComparisonFallback( + AuditTargetDefinition target, + bool primaryFailed, + bool primaryComparableSurfaceEmpty, + SharpieRunResult? sharpieRun) + { + return target.UseSharpieComparisonFallback && + (primaryFailed || primaryComparableSurfaceEmpty) && + sharpieRun is not null && + string.Equals(sharpieRun.Status, "succeeded", StringComparison.Ordinal) && + sharpieRun.Comparison is not null; + } + + private static ConfidenceSummary BuildConfidenceSummary(IReadOnlyList findings) + { + return AuditFindingSummary.BuildConfidenceSummary(findings.ToList()); + } + + private static List AnnotateFindings( + IReadOnlyList findings, + bool sharpieRequested, + string sharpieStatus, + TargetComparisonResult? sharpieComparison, + string comparisonSource) + { + if (string.Equals(comparisonSource, "sharpie-fallback", StringComparison.Ordinal)) + { + return findings.Select(finding => + { + if (IsComparisonFailureFinding(finding)) + { + return finding with { Confidence = "confirmed", ConfidenceSource = "sharpie-fallback-comparison-source" }; + } + + return finding with { Confidence = "not-reviewed", ConfidenceSource = "sharpie-fallback-note" }; + }).ToList(); + } + + if (!sharpieRequested) + { + return findings.Select(finding => finding with + { + Confidence = "not-reviewed", + ConfidenceSource = "sharpie-not-requested" + }).ToList(); + } + + if (!string.Equals(sharpieStatus, "succeeded", StringComparison.Ordinal) || sharpieComparison is null) + { + var (confidence, source) = sharpieStatus switch + { + "disabled" => ("not-reviewed", "sharpie-disabled"), + "not-requested" => ("not-reviewed", "sharpie-not-requested"), + _ => ("low-confidence", $"sharpie-{sharpieStatus}") + }; + + return findings.Select(finding => ApplyBaseConfidence(finding, confidence, source)).ToList(); + } + + var exactMatches = sharpieComparison.Failures + .Select(CreateComparisonMatchKey) + .Where(static key => !string.IsNullOrWhiteSpace(key)) + .ToHashSet(StringComparer.Ordinal); + var entityMatches = sharpieComparison.Failures + .Select(CreateComparisonEntityKey) + .Where(static key => !string.IsNullOrWhiteSpace(key)) + .ToHashSet(StringComparer.Ordinal); + + return findings.Select(finding => + { + if (IsComparisonFailureFinding(finding)) + { + var exactKey = CreateComparisonMatchKey(finding); + if (!string.IsNullOrWhiteSpace(exactKey) && exactMatches.Contains(exactKey)) + { + return finding with { Confidence = "confirmed", ConfidenceSource = "sharpie-exact-match" }; + } + + var entityKey = CreateComparisonEntityKey(finding); + if (!string.IsNullOrWhiteSpace(entityKey) && entityMatches.Contains(entityKey)) + { + return finding with { Confidence = "confirmed", ConfidenceSource = "sharpie-entity-match" }; + } + + return finding with { Confidence = "disputed", ConfidenceSource = "sharpie-no-match" }; + } + + if (IsPrimaryGenerationFinding(finding)) + { + return finding with { Confidence = "disputed", ConfidenceSource = "sharpie-generated-comparable-surface" }; + } + + return finding with { Confidence = "not-reviewed", ConfidenceSource = "sharpie-not-applicable" }; + }).ToList(); + } + + private static AuditFinding ApplyBaseConfidence(AuditFinding finding, string confidence, string source) + { + if (IsComparisonFailureFinding(finding) || IsPrimaryGenerationFinding(finding)) + { + return finding with { Confidence = confidence, ConfidenceSource = source }; + } + + return finding with { Confidence = "not-reviewed", ConfidenceSource = "sharpie-not-applicable" }; + } + + private static bool IsComparisonFailureFinding(AuditFinding finding) + { + return string.Equals(finding.Category, "stale-baseline-binding", StringComparison.Ordinal) || + string.Equals(finding.Category, "missing-baseline-binding", StringComparison.Ordinal) || + string.Equals(finding.Category, "signature-drift", StringComparison.Ordinal) || + string.Equals(finding.Category, "attribute-drift", StringComparison.Ordinal); + } + + private static bool IsPrimaryGenerationFinding(AuditFinding finding) + { + return string.Equals(finding.ComparisonTypeKey, "generation", StringComparison.Ordinal); + } + + private static string? CreateComparisonMatchKey(AuditFinding finding) + { + if (string.IsNullOrWhiteSpace(finding.ComparisonTypeKey)) + { + return null; + } + + return $"{finding.Category}|{finding.ComparisonTypeKey}|{finding.ComparisonMemberKey ?? "-"}"; + } + + private static string? CreateComparisonEntityKey(AuditFinding finding) + { + if (string.IsNullOrWhiteSpace(finding.ComparisonTypeKey)) + { + return null; + } + + return $"{finding.ComparisonTypeKey}|{finding.ComparisonMemberKey ?? "-"}"; + } + + private static AuditFinding BuildPrimaryGenerationFinding(string category, string severity, string typeName, string message) + { + return new AuditFinding( + Category: category, + Severity: severity, + Message: message, + TypeName: typeName, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: null, + ComparisonTypeKey: "generation", + ComparisonMemberKey: category); + } + + private static AuditFinding BuildSharpieStatusFinding(AuditTargetDefinition target, string message) + { + return new AuditFinding( + Category: "sharpie-warning", + Severity: "info", + Message: message, + TypeName: target.PackageId, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: null); + } + + private static List DemotePrimaryGenerationFailuresForFallback(IReadOnlyList findings) + { + return findings.Select(finding => + { + if (!IsPrimaryGenerationFinding(finding)) + { + return finding; + } + + return finding with + { + Category = "generation-warning", + Severity = "info" + }; + }).ToList(); + } + + private static string GetFallbackGenerationStatus(string generationStatus, bool primaryFailed, bool primaryComparableSurfaceEmpty) + { + if (primaryFailed) + { + return "primary-failed-used-sharpie-fallback"; + } + + if (primaryComparableSurfaceEmpty) + { + return "primary-inconclusive-used-sharpie-fallback"; + } + + return generationStatus; + } + + private static AuditFinding BuildComparisonFallbackFinding( + AuditTargetDefinition target, + bool primaryFailed, + bool primaryComparableSurfaceEmpty, + string? sharpieFrameworkPath) + { + var reason = primaryFailed + ? "Primary swift-dotnet-bindings generation was not usable" + : primaryComparableSurfaceEmpty + ? "Primary swift-dotnet-bindings output had zero comparable surface" + : "Configured to use Sharpie fallback output"; + var frameworkName = string.IsNullOrWhiteSpace(sharpieFrameworkPath) ? null : Path.GetFileName(sharpieFrameworkPath); + var suffix = frameworkName is null ? "." : $" via '{frameworkName}'."; + + return new AuditFinding( + Category: "comparison-fallback", + Severity: "info", + Message: $"{reason} for {target.Id}; using Sharpie output as the comparison source{suffix}", + TypeName: target.PackageId, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: null); + } + + private static int CountComparableSurface(BindingSnapshot snapshot) + { + return snapshot.BoundTypes.Count + snapshot.Delegates.Count + snapshot.Enums.Count; + } + + private async Task RunSharpieAsync( + AuditTargetDefinition target, + string stagedXcframeworkPath, + string tempRoot, + string logsDirectory, + IReadOnlyDictionary sharedAliases, + BindingSnapshot baselineSnapshot, + SharpieToolResolution sharpieResolution, + string sharpieFrameworkSearchDirectory, + CancellationToken cancellationToken) + { + var sharpieDirectory = Path.Combine(tempRoot, "sharpie", target.Id); + ResetDirectory(sharpieDirectory); + var warningFindings = new List(); + var attemptedFrameworkPath = ResolveSharpieFrameworkPath(stagedXcframeworkPath); + + var firstAttempt = await ExecuteSharpieBindAsync( + target, + sharpieResolution, + attemptedFrameworkPath, + sharpieDirectory, + logsDirectory, + $"{target.Id}-sharpie", + sharpieFrameworkSearchDirectory, + includeFrameworkSearchPath: true, + cancellationToken); + + warningFindings.AddRange(firstAttempt.WarningFindings); + if (firstAttempt.ProcessResult.ExitCode != 0) + { + return new SharpieRunResult( + Status: "failed", + FrameworkPath: attemptedFrameworkPath, + ProcessResult: firstAttempt.ProcessResult, + Comparison: null, + Snapshot: null, + GeneratedFiles: [], + WarningFindings: warningFindings, + Message: $"Sharpie bind failed for {target.Id}. Exit code: {firstAttempt.ProcessResult.ExitCode}."); + } + + var generatedFiles = FindSharpieBindingFiles(sharpieDirectory); + if (generatedFiles.Count == 0 && + TryResolveSharpieCompanionFrameworkPath(attemptedFrameworkPath, Path.GetDirectoryName(stagedXcframeworkPath) ?? string.Empty, out var companionFrameworkPath)) + { + warningFindings.Add(BuildSharpieStatusFinding( + target, + $"Sharpie generated no files for '{Path.GetFileName(attemptedFrameworkPath)}'; retrying with companion framework '{Path.GetFileName(companionFrameworkPath)}'.")); + + ResetDirectory(sharpieDirectory); + attemptedFrameworkPath = companionFrameworkPath; + var companionAttempt = await ExecuteSharpieBindAsync( + target, + sharpieResolution, + attemptedFrameworkPath, + sharpieDirectory, + logsDirectory, + $"{target.Id}-sharpie-companion", + sharpieFrameworkSearchDirectory, + includeFrameworkSearchPath: false, + cancellationToken); + + warningFindings.AddRange(companionAttempt.WarningFindings); + if (companionAttempt.ProcessResult.ExitCode != 0) + { + return new SharpieRunResult( + Status: "failed", + FrameworkPath: attemptedFrameworkPath, + ProcessResult: companionAttempt.ProcessResult, + Comparison: null, + Snapshot: null, + GeneratedFiles: [], + WarningFindings: warningFindings, + Message: $"Sharpie bind failed for {target.Id} when retrying companion framework '{Path.GetFileName(attemptedFrameworkPath)}'. Exit code: {companionAttempt.ProcessResult.ExitCode}."); + } + + generatedFiles = FindSharpieBindingFiles(sharpieDirectory); + } + + if (generatedFiles.Count == 0) + { + return new SharpieRunResult( + Status: "succeeded_empty", + FrameworkPath: attemptedFrameworkPath, + ProcessResult: firstAttempt.ProcessResult, + Comparison: null, + Snapshot: null, + GeneratedFiles: [], + WarningFindings: warningFindings, + Message: $"Sharpie completed for {target.Id}, but no binding files were generated."); + } + + var snapshot = parser.Parse(generatedFiles, []); + if (CountComparableSurface(snapshot) == 0) + { + return new SharpieRunResult( + Status: "succeeded_empty", + FrameworkPath: attemptedFrameworkPath, + ProcessResult: firstAttempt.ProcessResult, + Comparison: null, + Snapshot: snapshot, + GeneratedFiles: generatedFiles, + WarningFindings: warningFindings, + Message: $"Sharpie completed for {target.Id}, but its output contained zero comparable bound types, delegates, or enums."); + } + + var comparison = comparer.Compare(baselineSnapshot, snapshot, sharedAliases); + return new SharpieRunResult( + Status: "succeeded", + FrameworkPath: attemptedFrameworkPath, + ProcessResult: firstAttempt.ProcessResult, + Comparison: comparison, + Snapshot: snapshot, + GeneratedFiles: generatedFiles, + WarningFindings: warningFindings, + Message: null); + } + + private static async Task<(ProcessResult ProcessResult, IReadOnlyList WarningFindings)> ExecuteSharpieBindAsync( + AuditTargetDefinition target, + SharpieToolResolution sharpieResolution, + string frameworkPath, + string outputDirectory, + string logsDirectory, + string logPrefix, + string sharpieFrameworkSearchDirectory, + bool includeFrameworkSearchPath, + CancellationToken cancellationToken) + { + var workingDirectory = Directory.GetParent(outputDirectory)?.FullName ?? Path.GetTempPath(); + var arguments = new List { "bind", "--framework", frameworkPath, "--output", outputDirectory }; + if (includeFrameworkSearchPath) + { + arguments.AddRange(["-c", "-F", sharpieFrameworkSearchDirectory]); + } + + var processResult = await RunProcessAsync( + sharpieResolution.CommandPath ?? "sharpie", + arguments, + workingDirectory, + null, + cancellationToken); + WriteLogs(logsDirectory, logPrefix, processResult); + return (processResult, BuildProcessWarningFindings(processResult, target, "sharpie bind", "sharpie-warning")); + } + + private static bool TryResolveSharpieCompanionFrameworkPath(string frameworkPath, string stagedFrameworksDirectory, out string companionFrameworkPath) + { + var frameworkName = Path.GetFileNameWithoutExtension(frameworkPath); + var rootHeaderPath = Path.Combine(frameworkPath, "Headers", $"{frameworkName}.h"); + if (File.Exists(rootHeaderPath)) + { + var importMatch = Regex.Match( + File.ReadAllText(rootHeaderPath), + "#import\\s+<([^/]+)/[^>]+>", + RegexOptions.CultureInvariant); + + if (importMatch.Success) + { + var companionModuleName = importMatch.Groups[1].Value; + if (!string.Equals(companionModuleName, frameworkName, StringComparison.Ordinal)) + { + var companionXcframeworkPath = Path.Combine(stagedFrameworksDirectory, $"{companionModuleName}.xcframework"); + if (Directory.Exists(companionXcframeworkPath)) + { + companionFrameworkPath = ResolveSharpieFrameworkPath(companionXcframeworkPath); + return true; + } + } + } + } + + companionFrameworkPath = string.Empty; + return false; + } + + private static string ResolveSharpieFrameworkPath(string xcframeworkPath) + { + var infoPlistPath = Path.Combine(xcframeworkPath, "Info.plist"); + if (File.Exists(infoPlistPath)) + { + var libraries = ParseAvailableLibraries(infoPlistPath); + var preferredLibrary = libraries + .OrderBy(static library => RankSharpieLibraryCandidate(library)) + .FirstOrDefault(static library => string.Equals(GetLibraryValue(library, "SupportedPlatform"), "ios", StringComparison.OrdinalIgnoreCase)); + + if (preferredLibrary is not null) + { + var libraryIdentifier = GetLibraryValue(preferredLibrary, "LibraryIdentifier"); + var libraryPath = GetLibraryValue(preferredLibrary, "LibraryPath"); + if (!string.IsNullOrWhiteSpace(libraryIdentifier) && !string.IsNullOrWhiteSpace(libraryPath)) + { + var frameworkPath = Path.Combine(xcframeworkPath, libraryIdentifier, libraryPath); + if (Directory.Exists(frameworkPath)) + { + return frameworkPath; + } + } + } + } + + var fallbackFramework = Directory.EnumerateDirectories(xcframeworkPath, "*.framework", SearchOption.AllDirectories) + .OrderBy(path => RankSharpieFrameworkPath(path)) + .FirstOrDefault(); + + if (fallbackFramework is not null) + { + return fallbackFramework; + } + + throw new InvalidOperationException($"Unable to resolve a concrete framework slice for '{xcframeworkPath}'."); + } + + private static List> ParseAvailableLibraries(string plistPath) + { + var document = XDocument.Load(plistPath); + var rootDictionary = document.Root?.Element("dict"); + if (rootDictionary is null) + { + return []; + } + + foreach (var (key, value) in EnumerateDictionaryEntries(rootDictionary)) + { + if (!string.Equals(key, "AvailableLibraries", StringComparison.Ordinal) || !string.Equals(value.Name.LocalName, "array", StringComparison.Ordinal)) + { + continue; + } + + return value.Elements("dict") + .Select(ParseStringDictionary) + .ToList(); + } + + return []; + } + + private static IEnumerable<(string Key, XElement Value)> EnumerateDictionaryEntries(XElement dictionaryElement) + { + string? currentKey = null; + foreach (var child in dictionaryElement.Elements()) + { + if (string.Equals(child.Name.LocalName, "key", StringComparison.Ordinal)) + { + currentKey = child.Value; + continue; + } + + if (!string.IsNullOrWhiteSpace(currentKey)) + { + yield return (currentKey, child); + currentKey = null; + } + } + } + + private static Dictionary ParseStringDictionary(XElement dictionaryElement) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (var (key, value) in EnumerateDictionaryEntries(dictionaryElement)) + { + values[key] = value.Name.LocalName switch + { + "true" => "true", + "false" => "false", + _ => value.Value + }; + } + + return values; + } + + private static int RankSharpieLibraryCandidate(IReadOnlyDictionary library) + { + var identifier = GetLibraryValue(library, "LibraryIdentifier"); + var variant = GetLibraryValue(library, "SupportedPlatformVariant"); + if (string.Equals(identifier, "ios-arm64", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(variant)) + { + return 0; + } + + if (!string.IsNullOrWhiteSpace(identifier) && + identifier.StartsWith("ios-", StringComparison.OrdinalIgnoreCase) && + !identifier.Contains("simulator", StringComparison.OrdinalIgnoreCase) && + !identifier.Contains("maccatalyst", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + if (string.IsNullOrWhiteSpace(variant)) + { + return 2; + } + + return 3; + } + + private static string? GetLibraryValue(IReadOnlyDictionary library, string key) + { + return library.TryGetValue(key, out var value) ? value : null; + } + + private static int RankSharpieFrameworkPath(string frameworkPath) + { + var normalizedPath = frameworkPath.Replace('\\', '/'); + if (normalizedPath.Contains("/ios-arm64/", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + if (normalizedPath.Contains("/ios-", StringComparison.OrdinalIgnoreCase) && + !normalizedPath.Contains("simulator", StringComparison.OrdinalIgnoreCase) && + !normalizedPath.Contains("maccatalyst", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + return 2; + } + + private static List FindSharpieBindingFiles(string outputDirectory) + { + var generatedFiles = new List(); + var apiDefinitionPath = Directory.EnumerateFiles(outputDirectory, "ApiDefinition.cs", SearchOption.AllDirectories) + .OrderBy(path => path.Length) + .FirstOrDefault(); + var structsAndEnumsPath = Directory.EnumerateFiles(outputDirectory, "StructsAndEnums.cs", SearchOption.AllDirectories) + .OrderBy(path => path.Length) + .FirstOrDefault(); + + if (apiDefinitionPath is not null) + { + generatedFiles.Add(apiDefinitionPath); + } + + if (structsAndEnumsPath is not null) + { + generatedFiles.Add(structsAndEnumsPath); + } + + return generatedFiles; + } + + private async Task ResolveSharpieAsync(AuditOptions options, CancellationToken cancellationToken) + { + if (options.DisableSharpie) + { + return new SharpieToolResolution( + Status: "disabled", + ExpectedVersion: options.SharpieVersion, + CommandPath: options.SharpiePath, + ResolvedPath: options.SharpiePath, + Version: null, + Message: "Sharpie was disabled via --disable-sharpie."); + } + + var commandPath = options.SharpiePath ?? "sharpie"; + string? resolvedPath = options.SharpiePath; + try + { + if (string.IsNullOrWhiteSpace(resolvedPath)) + { + resolvedPath = await ResolveCommandPathAsync(commandPath, options.RepoRoot, cancellationToken); + } + + var versionResult = await RunProcessAsync(commandPath, ["--version"], options.RepoRoot, null, cancellationToken); + var version = versionResult.AllLines().FirstOrDefault(static line => !string.IsNullOrWhiteSpace(line))?.Trim(); + if (versionResult.ExitCode != 0 || string.IsNullOrWhiteSpace(version)) + { + return new SharpieToolResolution( + Status: "error", + ExpectedVersion: options.SharpieVersion, + CommandPath: commandPath, + ResolvedPath: resolvedPath, + Version: version, + Message: $"Unable to determine Sharpie version. Exit code: {versionResult.ExitCode}."); + } + + if (version.Contains(options.SharpieVersion, StringComparison.Ordinal)) + { + return new SharpieToolResolution( + Status: "resolved", + ExpectedVersion: options.SharpieVersion, + CommandPath: commandPath, + ResolvedPath: resolvedPath, + Version: version, + Message: null); + } + + return new SharpieToolResolution( + Status: "incompatible", + ExpectedVersion: options.SharpieVersion, + CommandPath: commandPath, + ResolvedPath: resolvedPath, + Version: version, + Message: $"Expected Sharpie {options.SharpieVersion}, but found '{version}'."); + } + catch (Exception exception) + { + return new SharpieToolResolution( + Status: "missing", + ExpectedVersion: options.SharpieVersion, + CommandPath: commandPath, + ResolvedPath: resolvedPath, + Version: null, + Message: exception.Message); + } + } + + private static async Task ResolveCommandPathAsync(string commandName, string workingDirectory, CancellationToken cancellationToken) + { + var whichResult = await RunProcessAsync("/usr/bin/which", [commandName], workingDirectory, null, cancellationToken); + return whichResult.ExitCode == 0 + ? whichResult.StandardOutput.Trim() + : null; + } + + private static async Task BuildTargetProjectAsync( + string projectFile, + string projectDirectory, + IReadOnlyDictionary dotnetEnvironment, + CancellationToken cancellationToken) + { + var attempt = 0; + var knownDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); + ProcessResult? lastResult = null; + + while (attempt < 4) + { + lastResult = await RunProcessAsync( + "dotnet", + ["build", "-nologo", "-v", "minimal", "-p:RestoreSources=https://api.nuget.org/v3/index.json", "-p:NuGetAudit=false"], + projectDirectory, + dotnetEnvironment, + cancellationToken); + + var addedDependencies = ExtractSuggestedDependencies(lastResult) + .Where(dependency => knownDependencies.Add(dependency.Include)) + .ToList(); + + if (addedDependencies.Count == 0) + { + return lastResult; + } + + UpsertSwiftFrameworkDependencies(projectFile, addedDependencies); + attempt++; + } + + return lastResult ?? throw new InvalidOperationException("Build process did not produce a result."); + } + + private static IEnumerable ExtractSuggestedDependencies(ProcessResult result) + { + var content = $"{result.StandardOutput}{Environment.NewLine}{result.StandardError}"; + foreach (Match match in SwiftFrameworkDependencyRegex.Matches(content)) + { + yield return new SwiftFrameworkDependencySpec( + Include: match.Groups[1].Value, + PackageId: match.Groups[2].Value, + PackageVersion: match.Groups[3].Value); + } + } + + private static void UpsertSwiftFrameworkDependencies(string projectFile, IEnumerable dependencies) + { + var document = XDocument.Load(projectFile, LoadOptions.PreserveWhitespace); + var projectElement = document.Root ?? throw new InvalidOperationException($"The project file '{projectFile}' is invalid."); + var existingDependencies = new HashSet( + projectElement + .Descendants("SwiftFrameworkDependency") + .Select(element => element.Attribute("Include")?.Value) + .Where(static value => !string.IsNullOrWhiteSpace(value))!, + StringComparer.OrdinalIgnoreCase); + + var newDependencies = dependencies + .Where(dependency => existingDependencies.Add(dependency.Include)) + .ToList(); + + if (newDependencies.Count == 0) + { + return; + } + + var itemGroup = new XElement("ItemGroup"); + foreach (var dependency in newDependencies) + { + itemGroup.Add(new XElement( + "SwiftFrameworkDependency", + new XAttribute("Include", dependency.Include), + new XAttribute("PackageId", dependency.PackageId), + new XAttribute("PackageVersion", dependency.PackageVersion))); + } + + projectElement.Add(itemGroup); + document.Save(projectFile); + } + + private TargetAuditResult BuildTargetResult( + AuditTargetDefinition target, + string status, + string generationStatus, + string comparisonSource, + string sharpieStatus, + ConfidenceSummary confidenceSummary, + IReadOnlyList findings) + { + return new TargetAuditResult( + Target: target.Id, + Xcframework: target.Xcframework, + Status: status, + GenerationStatus: generationStatus, + ComparisonSource: comparisonSource, + FailureCount: findings.Count(static finding => finding.Severity == "failure"), + InfoCount: findings.Count(static finding => finding.Severity == "info"), + SuppressedCount: 0, + SharpieStatus: sharpieStatus, + ConfidenceSummary: confidenceSummary, + Findings: findings, + SuppressedFindings: []); + } + + private List SelectTargets(IReadOnlyList? selectedTargets) + { + if (selectedTargets is null || selectedTargets.Count == 0) + { + return configuration.Targets; + } + + var lookup = new HashSet(selectedTargets, StringComparer.OrdinalIgnoreCase); + var matches = configuration.Targets + .Where(target => lookup.Contains(target.Id)) + .ToList(); + + var missing = selectedTargets + .Where(selection => matches.All(match => !string.Equals(match.Id, selection, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (missing.Count > 0) + { + throw new InvalidOperationException($"Unknown target(s): {string.Join(", ", missing)}"); + } + + return matches; + } + + private static void PrepareOutputDirectory(string outputDirectory, string detailsDirectory, string logsDirectory) + { + Directory.CreateDirectory(outputDirectory); + + if (Directory.Exists(detailsDirectory)) + { + Directory.Delete(detailsDirectory, recursive: true); + } + + if (Directory.Exists(logsDirectory)) + { + Directory.Delete(logsDirectory, recursive: true); + } + + Directory.CreateDirectory(detailsDirectory); + Directory.CreateDirectory(logsDirectory); + } + + private static void StageXcframeworks(string repoRoot, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + var externalsDirectory = Path.Combine(repoRoot, "externals"); + foreach (var xcframeworkDirectory in Directory.EnumerateDirectories(externalsDirectory, "*.xcframework", SearchOption.TopDirectoryOnly)) + { + var destinationPath = Path.Combine(destinationDirectory, Path.GetFileName(xcframeworkDirectory)); + TryCreateDirectorySymlink(destinationPath, xcframeworkDirectory); + } + } + + private static void StageSharpieFrameworkSlices(string stagedFrameworksDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var xcframeworkDirectory in Directory.EnumerateDirectories(stagedFrameworksDirectory, "*.xcframework", SearchOption.TopDirectoryOnly)) + { + var frameworkPath = ResolveSharpieFrameworkPath(xcframeworkDirectory); + var destinationPath = Path.Combine(destinationDirectory, Path.GetFileName(frameworkPath)); + TryCreateDirectorySymlink(destinationPath, frameworkPath); + } + } + + private static void TryCreateDirectorySymlink(string destinationPath, string sourcePath) + { + if (Directory.Exists(destinationPath)) + { + return; + } + + try + { + Directory.CreateSymbolicLink(destinationPath, sourcePath); + } + catch + { + CopyDirectory(sourcePath, destinationPath); + } + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + + foreach (var file in Directory.EnumerateFiles(sourceDirectory)) + { + File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true); + } + + foreach (var directory in Directory.EnumerateDirectories(sourceDirectory)) + { + CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); + } + } + + private static void ResetDirectory(string directoryPath) + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive: true); + } + + Directory.CreateDirectory(directoryPath); + } + + private static string CreateSwiftBindingProject(string projectDirectory, string projectName, string xcframeworkPath, string generatorVersion) + { + var projectFile = Path.Combine(projectDirectory, $"{projectName}.csproj"); + var settings = new XmlWriterSettings + { + Indent = true, + OmitXmlDeclaration = true + }; + + using var stringWriter = new StringWriter(); + using (var xmlWriter = XmlWriter.Create(stringWriter, settings)) + { + xmlWriter.WriteStartElement("Project"); + xmlWriter.WriteAttributeString("Sdk", $"SwiftBindings.Sdk/{generatorVersion}"); + + xmlWriter.WriteStartElement("PropertyGroup"); + xmlWriter.WriteElementString("TargetFramework", "net10.0-ios"); + xmlWriter.WriteElementString("SwiftFrameworkType", "ObjC"); + xmlWriter.WriteElementString("SwiftWrapperRequired", "false"); + xmlWriter.WriteEndElement(); + + xmlWriter.WriteStartElement("ItemGroup"); + xmlWriter.WriteStartElement("SwiftFramework"); + xmlWriter.WriteAttributeString("Include", xcframeworkPath); + xmlWriter.WriteEndElement(); + xmlWriter.WriteEndElement(); + + xmlWriter.WriteEndElement(); + } + + File.WriteAllText(projectFile, stringWriter.ToString()); + return projectFile; + } + + private static string? FindGeneratedFile(string projectDirectory, string fileName) + { + return Directory.EnumerateFiles(projectDirectory, fileName, SearchOption.AllDirectories) + .Where(path => path.Contains($"{Path.DirectorySeparatorChar}swift-binding{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .OrderBy(path => path.Length) + .FirstOrDefault(); + } + + private static List FindGeneratedBindingFiles(string projectDirectory) + { + var generatedFiles = new List(); + var apiDefinitionPath = FindGeneratedFile(projectDirectory, "ApiDefinition.cs"); + var structsAndEnumsPath = FindGeneratedFile(projectDirectory, "StructsAndEnums.cs"); + + if (apiDefinitionPath is not null) + { + generatedFiles.Add(apiDefinitionPath); + } + + if (structsAndEnumsPath is not null) + { + generatedFiles.Add(structsAndEnumsPath); + } + + return generatedFiles; + } + + private static List BuildGenerationFailureFindings(ProcessResult result, string summary) + { + var findings = new List + { + new( + Category: "generation-failure", + Severity: "failure", + Message: $"{summary} Exit code: {result.ExitCode}.", + TypeName: "generation", + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: null, + ComparisonTypeKey: "generation", + ComparisonMemberKey: "generation-failure") + }; + + var firstDiagnosticLine = result.AllLines().FirstOrDefault(IsActionableWarningLine); + if (!string.IsNullOrWhiteSpace(firstDiagnosticLine)) + { + findings.Add(new AuditFinding( + Category: "generation-warning", + Severity: "info", + Message: $"dotnet build: {firstDiagnosticLine}", + TypeName: "generation", + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: null)); + } + + return findings; + } + + private static List BuildProcessWarningFindings(ProcessResult processResult, AuditTargetDefinition target, string commandName, string category) + { + return processResult.AllLines() + .Where(IsActionableWarningLine) + .Distinct(StringComparer.Ordinal) + .Take(20) + .Select(line => new AuditFinding( + Category: category, + Severity: "info", + Message: $"{commandName}: {line}", + TypeName: target.PackageId, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: null)) + .ToList(); + } + + private static bool IsActionableWarningLine(string line) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return false; + } + + if (trimmed.EndsWith("Warning(s)", StringComparison.Ordinal)) + { + return false; + } + + return trimmed.Contains(": warning ", StringComparison.OrdinalIgnoreCase); + } + + private static void WriteLogs(string logsDirectory, string prefix, ProcessResult result) + { + var builder = new StringBuilder(); + builder.AppendLine($"Command: {result.Command}"); + builder.AppendLine($"Working directory: {result.WorkingDirectory}"); + builder.AppendLine($"Exit code: {result.ExitCode}"); + builder.AppendLine(); + builder.AppendLine("STDOUT:"); + builder.AppendLine(result.StandardOutput); + builder.AppendLine(); + builder.AppendLine("STDERR:"); + builder.AppendLine(result.StandardError); + + File.WriteAllText(Path.Combine(logsDirectory, $"{prefix}.log"), builder.ToString()); + } + + private static async Task RunProcessAsync( + string fileName, + IEnumerable arguments, + string workingDirectory, + IReadOnlyDictionary? environmentVariables, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo(fileName) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + var commandBuilder = new StringBuilder(fileName); + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + commandBuilder.Append(' '); + commandBuilder.Append(argument.Contains(' ') ? $"\"{argument}\"" : argument); + } + + if (environmentVariables is not null) + { + foreach (var environmentVariable in environmentVariables) + { + startInfo.Environment[environmentVariable.Key] = environmentVariable.Value; + } + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(cancellationToken); + + return new ProcessResult( + Command: commandBuilder.ToString(), + WorkingDirectory: workingDirectory, + ExitCode: process.ExitCode, + StandardOutput: await standardOutputTask, + StandardError: await standardErrorTask); + } +} + +internal sealed record SwiftFrameworkDependencySpec( + string Include, + string PackageId, + string PackageVersion); + +internal sealed record ProcessResult( + string Command, + string WorkingDirectory, + int ExitCode, + string StandardOutput, + string StandardError) +{ + public IEnumerable AllLines() + { + return StandardOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Concat(StandardError.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs new file mode 100644 index 00000000..b4cf005c --- /dev/null +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -0,0 +1,2116 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace FirebaseBindingAudit; + +internal sealed record BindingSnapshot( + IReadOnlyDictionary BoundTypes, + IReadOnlyDictionary Delegates, + IReadOnlyDictionary Enums, + IReadOnlyList ManualItems, + IReadOnlyDictionary TypeAliases); + +internal sealed record BoundTypeSurface( + string ComparisonKey, + string DisplayName, + string Namespace, + string Name, + string ObjectiveCName, + string ContainerKind, + string? BaseType, + bool IsProtocol, + bool IsStatic, + string SourceFile, + IReadOnlyDictionary Members); + +internal sealed record BindingMemberSurface( + string Key, + string Kind, + string Name, + string? BindingAttribute, + string? BindingValue, + bool IsNotification, + bool IsStatic, + string ReturnType, + bool IsReturnNullAllowed, + bool HasGetter, + bool HasSetter, + string? GetterBind, + string? SetterBind, + IReadOnlyList Parameters, + string SourceFile, + string Signature); + +internal sealed record BindingParameterSurface( + string Type, + bool IsNullAllowed); + +internal sealed record DelegateSurface( + string ComparisonKey, + string DisplayName, + string Namespace, + string Name, + string ReturnType, + bool IsReturnNullAllowed, + IReadOnlyList Parameters, + string SourceFile, + string Signature); + +internal sealed record EnumSurface( + string ComparisonKey, + string DisplayName, + string Namespace, + string Name, + string? UnderlyingType, + bool IsNative, + string SourceFile, + IReadOnlyDictionary Members); + +internal sealed record EnumMemberSurface( + string Name, + string? FieldValue, + string SourceFile); + +internal sealed record ManualSurfaceItem( + string Category, + string TypeName, + string MatchTypeKey, + string? MatchMemberKey, + string? MemberName, + string Signature, + string SourceFile); + +internal sealed class BindingSyntaxParser +{ + private readonly HashSet manualAttributes; + + public BindingSyntaxParser(IEnumerable manualAttributes, IEnumerable bindingAttributes) + { + this.manualAttributes = new HashSet(manualAttributes, StringComparer.Ordinal); + } + + public BindingSnapshot Parse(IEnumerable comparableFiles, IEnumerable helperFiles) + { + var boundTypes = new Dictionary(StringComparer.Ordinal); + var delegates = new Dictionary(StringComparer.Ordinal); + var enums = new Dictionary(StringComparer.Ordinal); + var manualItems = new List(); + + foreach (var file in comparableFiles.Distinct(StringComparer.Ordinal)) + { + ParseFile(file, isHelperFile: false, boundTypes, delegates, enums, manualItems); + } + + foreach (var file in helperFiles.Distinct(StringComparer.Ordinal)) + { + ParseFile(file, isHelperFile: true, boundTypes, delegates, enums, manualItems); + } + + return new BindingSnapshot( + BoundTypes: boundTypes, + Delegates: delegates, + Enums: enums, + ManualItems: manualItems, + TypeAliases: BuildTypeAliases(boundTypes.Values, delegates.Values, enums.Values)); + } + + private void ParseFile( + string filePath, + bool isHelperFile, + Dictionary boundTypes, + Dictionary delegates, + Dictionary enums, + List manualItems) + { + if (!File.Exists(filePath)) + { + return; + } + + var syntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText(filePath), path: filePath); + var root = syntaxTree.GetCompilationUnitRoot(); + VisitMembers(root.Members, string.Empty, filePath, isHelperFile, boundTypes, delegates, enums, manualItems); + } + + private void VisitMembers( + SyntaxList members, + string currentNamespace, + string filePath, + bool isHelperFile, + Dictionary boundTypes, + Dictionary delegates, + Dictionary enums, + List manualItems) + { + foreach (var member in members) + { + switch (member) + { + case NamespaceDeclarationSyntax namespaceDeclaration: + VisitMembers( + namespaceDeclaration.Members, + CombineNamespace(currentNamespace, namespaceDeclaration.Name.ToString()), + filePath, + isHelperFile, + boundTypes, + delegates, + enums, + manualItems); + break; + case FileScopedNamespaceDeclarationSyntax fileScopedNamespaceDeclaration: + VisitMembers( + fileScopedNamespaceDeclaration.Members, + CombineNamespace(currentNamespace, fileScopedNamespaceDeclaration.Name.ToString()), + filePath, + isHelperFile, + boundTypes, + delegates, + enums, + manualItems); + break; + case InterfaceDeclarationSyntax interfaceDeclaration: + ParseType( + interfaceDeclaration.Identifier.Text, + "interface", + interfaceDeclaration.Modifiers, + interfaceDeclaration.AttributeLists, + interfaceDeclaration.Members, + currentNamespace, + filePath, + isHelperFile, + boundTypes, + manualItems); + break; + case ClassDeclarationSyntax classDeclaration: + ParseType( + classDeclaration.Identifier.Text, + "class", + classDeclaration.Modifiers, + classDeclaration.AttributeLists, + classDeclaration.Members, + currentNamespace, + filePath, + isHelperFile, + boundTypes, + manualItems); + break; + case DelegateDeclarationSyntax delegateDeclaration: + ParseDelegate(delegateDeclaration, currentNamespace, filePath, isHelperFile, delegates, manualItems); + break; + case EnumDeclarationSyntax enumDeclaration: + ParseEnum(enumDeclaration, currentNamespace, filePath, isHelperFile, enums, manualItems); + break; + } + } + } + + private void ParseType( + string typeName, + string containerKind, + SyntaxTokenList modifiers, + SyntaxList attributeLists, + SyntaxList members, + string currentNamespace, + string filePath, + bool isHelperFile, + Dictionary boundTypes, + List manualItems) + { + var isProtocol = HasAttribute(attributeLists, "Protocol"); + var baseTypeName = GetBaseTypeName(attributeLists); + var objectiveCName = GetTypeBindingName(typeName, attributeLists); + var isStatic = modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || HasAttribute(attributeLists, "Static"); + var bindingMembers = new Dictionary(StringComparer.Ordinal); + + foreach (var member in members) + { + switch (member) + { + case MethodDeclarationSyntax methodDeclaration: + ParseMethod(methodDeclaration, typeName, objectiveCName, currentNamespace, filePath, isHelperFile, bindingMembers, manualItems); + break; + case PropertyDeclarationSyntax propertyDeclaration: + ParseProperty(propertyDeclaration, typeName, objectiveCName, currentNamespace, filePath, isHelperFile, bindingMembers, manualItems); + break; + } + } + + if (isHelperFile) + { + AddManualTypeItems(typeName, objectiveCName, containerKind, currentNamespace, filePath, bindingMembers.Values, manualItems); + return; + } + + if (baseTypeName is null && !isProtocol) + { + AddManualTypeItems(typeName, objectiveCName, containerKind, currentNamespace, filePath, bindingMembers.Values, manualItems); + return; + } + + boundTypes[objectiveCName] = new BoundTypeSurface( + ComparisonKey: objectiveCName, + DisplayName: QualifyType(currentNamespace, typeName), + Namespace: currentNamespace, + Name: typeName, + ObjectiveCName: objectiveCName, + ContainerKind: containerKind, + BaseType: baseTypeName, + IsProtocol: isProtocol, + IsStatic: isStatic, + SourceFile: filePath, + Members: bindingMembers); + } + + private static void AddManualTypeItems( + string typeName, + string typeMatchKey, + string containerKind, + string currentNamespace, + string filePath, + IEnumerable bindingMembers, + List manualItems) + { + var typeDisplayName = QualifyType(currentNamespace, typeName); + var comparableMembers = bindingMembers.ToList(); + if (comparableMembers.Count == 0) + { + manualItems.Add(new ManualSurfaceItem( + Category: "manual-surface", + TypeName: typeDisplayName, + MatchTypeKey: typeMatchKey, + MatchMemberKey: null, + MemberName: null, + Signature: $"{containerKind} {typeName}", + SourceFile: filePath)); + return; + } + + foreach (var member in comparableMembers) + { + manualItems.Add(new ManualSurfaceItem( + Category: "manual-surface", + TypeName: typeDisplayName, + MatchTypeKey: typeMatchKey, + MatchMemberKey: member.Key, + MemberName: member.Name, + Signature: member.Signature, + SourceFile: member.SourceFile)); + } + } + + private void ParseMethod( + MethodDeclarationSyntax methodDeclaration, + string containingType, + string containingTypeMatchKey, + string currentNamespace, + string filePath, + bool isHelperFile, + Dictionary bindingMembers, + List manualItems) + { + var bindingAttribute = GetPrimaryBindingAttribute(methodDeclaration.AttributeLists); + var bindingValue = bindingAttribute is null ? null : GetBindingValue(methodDeclaration.AttributeLists, bindingAttribute); + var isManual = isHelperFile || HasAnyAttribute(methodDeclaration.AttributeLists, manualAttributes); + var signature = BuildMethodSignature(methodDeclaration); + + if (isManual) + { + manualItems.Add(new ManualSurfaceItem( + Category: "manual-surface", + TypeName: QualifyType(currentNamespace, containingType), + MatchTypeKey: containingTypeMatchKey, + MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.Parameters.Count), + MemberName: methodDeclaration.Identifier.Text, + Signature: signature, + SourceFile: filePath)); + return; + } + + if (bindingAttribute is null) + { + return; + } + + var parameters = methodDeclaration.ParameterList.Parameters + .Select(CreateParameter) + .ToList(); + var memberKey = CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, parameters.Count); + + bindingMembers[memberKey] = new BindingMemberSurface( + Key: memberKey, + Kind: methodDeclaration.Identifier.Text == "Constructor" ? "constructor" : "method", + Name: methodDeclaration.Identifier.Text, + BindingAttribute: bindingAttribute, + BindingValue: bindingValue, + IsNotification: HasAttribute(methodDeclaration.AttributeLists, "Notification"), + IsStatic: methodDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || HasAttribute(methodDeclaration.AttributeLists, "Static"), + ReturnType: NormalizeType(methodDeclaration.ReturnType), + IsReturnNullAllowed: HasNullAllowed(methodDeclaration.AttributeLists, "return") || HasNullAllowed(methodDeclaration.AttributeLists, null), + HasGetter: false, + HasSetter: false, + GetterBind: null, + SetterBind: null, + Parameters: parameters, + SourceFile: filePath, + Signature: signature); + } + + private void ParseProperty( + PropertyDeclarationSyntax propertyDeclaration, + string containingType, + string containingTypeMatchKey, + string currentNamespace, + string filePath, + bool isHelperFile, + Dictionary bindingMembers, + List manualItems) + { + var bindingAttribute = GetPrimaryBindingAttribute(propertyDeclaration.AttributeLists); + var bindingValue = bindingAttribute is null ? null : GetBindingValue(propertyDeclaration.AttributeLists, bindingAttribute); + var isManual = isHelperFile || HasAnyAttribute(propertyDeclaration.AttributeLists, manualAttributes); + var signature = BuildPropertySignature(propertyDeclaration); + + if (isManual) + { + manualItems.Add(new ManualSurfaceItem( + Category: "manual-surface", + TypeName: QualifyType(currentNamespace, containingType), + MatchTypeKey: containingTypeMatchKey, + MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0), + MemberName: propertyDeclaration.Identifier.Text, + Signature: signature, + SourceFile: filePath)); + return; + } + + if (bindingAttribute is null) + { + return; + } + + var getter = propertyDeclaration.AccessorList?.Accessors.FirstOrDefault(static accessor => accessor.IsKind(SyntaxKind.GetAccessorDeclaration)); + var setter = propertyDeclaration.AccessorList?.Accessors.FirstOrDefault(static accessor => accessor.IsKind(SyntaxKind.SetAccessorDeclaration)); + var memberKey = CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0); + + bindingMembers[memberKey] = new BindingMemberSurface( + Key: memberKey, + Kind: "property", + Name: propertyDeclaration.Identifier.Text, + BindingAttribute: bindingAttribute, + BindingValue: bindingValue, + IsNotification: HasAttribute(propertyDeclaration.AttributeLists, "Notification"), + IsStatic: propertyDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || HasAttribute(propertyDeclaration.AttributeLists, "Static"), + ReturnType: NormalizeType(propertyDeclaration.Type), + IsReturnNullAllowed: HasNullAllowed(propertyDeclaration.AttributeLists, "return") || HasNullAllowed(propertyDeclaration.AttributeLists, null), + HasGetter: getter is not null, + HasSetter: setter is not null, + GetterBind: getter is null ? null : GetAttributeStringValue(getter.AttributeLists, "Bind"), + SetterBind: setter is null ? null : GetAttributeStringValue(setter.AttributeLists, "Bind"), + Parameters: Array.Empty(), + SourceFile: filePath, + Signature: signature); + } + + private void ParseDelegate( + DelegateDeclarationSyntax delegateDeclaration, + string currentNamespace, + string filePath, + bool isHelperFile, + Dictionary delegates, + List manualItems) + { + if (isHelperFile) + { + manualItems.Add(new ManualSurfaceItem( + Category: "manual-surface", + TypeName: QualifyType(currentNamespace, delegateDeclaration.Identifier.Text), + MatchTypeKey: delegateDeclaration.Identifier.Text, + MatchMemberKey: null, + MemberName: null, + Signature: BuildDelegateSignature(delegateDeclaration), + SourceFile: filePath)); + return; + } + + var comparisonKey = delegateDeclaration.Identifier.Text; + delegates[comparisonKey] = new DelegateSurface( + ComparisonKey: comparisonKey, + DisplayName: QualifyType(currentNamespace, delegateDeclaration.Identifier.Text), + Namespace: currentNamespace, + Name: delegateDeclaration.Identifier.Text, + ReturnType: NormalizeType(delegateDeclaration.ReturnType), + IsReturnNullAllowed: HasNullAllowed(delegateDeclaration.AttributeLists, "return") || HasNullAllowed(delegateDeclaration.AttributeLists, null), + Parameters: delegateDeclaration.ParameterList.Parameters.Select(CreateParameter).ToList(), + SourceFile: filePath, + Signature: BuildDelegateSignature(delegateDeclaration)); + } + + private void ParseEnum( + EnumDeclarationSyntax enumDeclaration, + string currentNamespace, + string filePath, + bool isHelperFile, + Dictionary enums, + List manualItems) + { + if (isHelperFile) + { + manualItems.Add(new ManualSurfaceItem( + Category: "manual-surface", + TypeName: QualifyType(currentNamespace, enumDeclaration.Identifier.Text), + MatchTypeKey: enumDeclaration.Identifier.Text, + MatchMemberKey: null, + MemberName: null, + Signature: $"enum {enumDeclaration.Identifier.Text}", + SourceFile: filePath)); + return; + } + + var members = new Dictionary(StringComparer.Ordinal); + foreach (var member in enumDeclaration.Members) + { + members[member.Identifier.Text] = new EnumMemberSurface( + Name: member.Identifier.Text, + FieldValue: GetAttributeStringValue(member.AttributeLists, "Field"), + SourceFile: filePath); + } + + var comparisonKey = enumDeclaration.Identifier.Text; + enums[comparisonKey] = new EnumSurface( + ComparisonKey: comparisonKey, + DisplayName: QualifyType(currentNamespace, enumDeclaration.Identifier.Text), + Namespace: currentNamespace, + Name: enumDeclaration.Identifier.Text, + UnderlyingType: enumDeclaration.BaseList?.Types.FirstOrDefault()?.Type.WithoutTrivia().ToString(), + IsNative: HasAttribute(enumDeclaration.AttributeLists, "Native"), + SourceFile: filePath, + Members: members); + } + + private static string CreateMemberKey(string bindingAttribute, string? bindingValue, string memberName, int parameterCount) + { + if (!string.IsNullOrWhiteSpace(bindingValue)) + { + return $"{bindingAttribute.ToLowerInvariant()}|{bindingValue}"; + } + + return $"{bindingAttribute.ToLowerInvariant()}|{memberName}|{parameterCount}"; + } + + private static string GetTypeBindingName(string typeName, SyntaxList attributeLists) + { + var protocolName = GetAttributeNamedStringValue(attributeLists, "Protocol", "Name"); + if (!string.IsNullOrWhiteSpace(protocolName)) + { + return protocolName; + } + + var baseTypeName = GetAttributeNamedStringValue(attributeLists, "BaseType", "Name"); + return string.IsNullOrWhiteSpace(baseTypeName) ? typeName : baseTypeName; + } + + private static string? GetPrimaryBindingAttribute(SyntaxList attributeLists) + { + if (HasAttribute(attributeLists, "Export")) + { + return "Export"; + } + + if (HasAttribute(attributeLists, "Field")) + { + return "Field"; + } + + if (HasAttribute(attributeLists, "Notification")) + { + return "Notification"; + } + + return null; + } + + private static string? GetBindingValue(SyntaxList attributeLists, string attributeName) + { + return attributeName switch + { + "Export" => GetAttributeStringValue(attributeLists, "Export"), + "Field" => GetAttributeStringValue(attributeLists, "Field"), + "Notification" => GetAttributeStringValue(attributeLists, "Notification"), + _ => null + }; + } + + private static BindingParameterSurface CreateParameter(ParameterSyntax parameter) + { + return new BindingParameterSurface( + Type: NormalizeType(parameter.Type), + IsNullAllowed: HasNullAllowed(parameter.AttributeLists, null)); + } + + private static Dictionary BuildTypeAliases( + IEnumerable boundTypes, + IEnumerable delegates, + IEnumerable enums) + { + var aliases = new Dictionary(StringComparer.Ordinal); + + foreach (var boundType in boundTypes) + { + AddExpandedAliases(aliases, boundType.ComparisonKey, boundType.ComparisonKey); + AddExpandedAliases(aliases, boundType.DisplayName, boundType.ComparisonKey); + AddExpandedAliases(aliases, boundType.Name, boundType.ComparisonKey); + AddExpandedAliases(aliases, boundType.ObjectiveCName, boundType.ComparisonKey); + } + + foreach (var delegateSurface in delegates) + { + AddExpandedAliases(aliases, delegateSurface.ComparisonKey, delegateSurface.ComparisonKey); + AddExpandedAliases(aliases, delegateSurface.DisplayName, delegateSurface.ComparisonKey); + AddExpandedAliases(aliases, delegateSurface.Name, delegateSurface.ComparisonKey); + } + + foreach (var enumSurface in enums) + { + AddExpandedAliases(aliases, enumSurface.ComparisonKey, enumSurface.ComparisonKey); + AddExpandedAliases(aliases, enumSurface.DisplayName, enumSurface.ComparisonKey); + AddExpandedAliases(aliases, enumSurface.Name, enumSurface.ComparisonKey); + } + + return aliases; + } + + private static void AddExpandedAliases(IDictionary aliases, string? alias, string comparisonKey) + { + AddAlias(aliases, alias, comparisonKey); + if (string.IsNullOrWhiteSpace(alias)) + { + return; + } + + foreach (var expandedAlias in ExpandAliasVariants(alias)) + { + AddAlias(aliases, expandedAlias, comparisonKey); + } + } + + private static IEnumerable ExpandAliasVariants(string alias) + { + var simpleName = alias.Split('.').Last(); + foreach (var simpleVariant in ExpandSimpleAliasVariants(simpleName)) + { + yield return simpleVariant; + if (alias.Contains('.', StringComparison.Ordinal)) + { + yield return $"{string.Join(".", alias.Split('.').SkipLast(1))}.{simpleVariant}"; + } + } + } + + private static IEnumerable ExpandSimpleAliasVariants(string simpleName) + { + var variants = new HashSet(StringComparer.Ordinal); + AddSimpleAliasVariant(variants, simpleName); + + if (TryStripObjectiveCPrefix(simpleName, out var withoutObjectiveCPrefix)) + { + AddSimpleAliasVariant(variants, withoutObjectiveCPrefix); + } + + if (TryStripInterfaceObjectiveCPrefix(simpleName, out var withoutInterfaceObjectiveCPrefix)) + { + AddSimpleAliasVariant(variants, withoutInterfaceObjectiveCPrefix); + } + + return variants; + } + + private static void AddSimpleAliasVariant(HashSet variants, string simpleName) + { + if (!variants.Add(simpleName)) + { + return; + } + + if (simpleName.EndsWith("Block", StringComparison.Ordinal)) + { + variants.Add($"{simpleName[..^"Block".Length]}Handler"); + } + + if (simpleName.EndsWith("Callback", StringComparison.Ordinal)) + { + variants.Add($"{simpleName[..^"Callback".Length]}Handler"); + } + + if (simpleName.EndsWith("Completion", StringComparison.Ordinal)) + { + variants.Add($"{simpleName}Handler"); + } + } + + private static bool TryStripObjectiveCPrefix(string alias, out string result) + { + foreach (var prefix in new[] { "FIR", "ABT" }) + { + if (alias.Length > prefix.Length && + alias.StartsWith(prefix, StringComparison.Ordinal) && + char.IsUpper(alias[prefix.Length])) + { + result = alias[prefix.Length..]; + return true; + } + } + + result = string.Empty; + return false; + } + + private static bool TryStripInterfaceObjectiveCPrefix(string alias, out string result) + { + if (alias.Length > 4 && + alias.StartsWith("IFIR", StringComparison.Ordinal) && + char.IsUpper(alias[4])) + { + result = $"I{alias[4..]}"; + return true; + } + + if (alias.Length > 4 && + alias.StartsWith("IABT", StringComparison.Ordinal) && + char.IsUpper(alias[4])) + { + result = $"I{alias[4..]}"; + return true; + } + + result = string.Empty; + return false; + } + + private static void AddAlias(IDictionary aliases, string? alias, string comparisonKey) + { + if (string.IsNullOrWhiteSpace(alias)) + { + return; + } + + var normalizedAlias = NormalizeAlias(alias); + if (!aliases.ContainsKey(normalizedAlias)) + { + aliases[normalizedAlias] = comparisonKey; + } + } + + private static string BuildMethodSignature(MethodDeclarationSyntax methodDeclaration) + { + var builder = new StringBuilder(); + builder.Append(methodDeclaration.Identifier.Text); + builder.Append('('); + builder.Append(string.Join(", ", methodDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)))); + builder.Append(')'); + builder.Append(" -> "); + builder.Append(NormalizeType(methodDeclaration.ReturnType)); + return builder.ToString(); + } + + private static string BuildPropertySignature(PropertyDeclarationSyntax propertyDeclaration) + { + var builder = new StringBuilder(); + builder.Append(NormalizeType(propertyDeclaration.Type)); + builder.Append(' '); + builder.Append(propertyDeclaration.Identifier.Text); + builder.Append(" { "); + if (propertyDeclaration.AccessorList?.Accessors.Any(static accessor => accessor.IsKind(SyntaxKind.GetAccessorDeclaration)) == true) + { + builder.Append("get; "); + } + + if (propertyDeclaration.AccessorList?.Accessors.Any(static accessor => accessor.IsKind(SyntaxKind.SetAccessorDeclaration)) == true) + { + builder.Append("set; "); + } + + builder.Append('}'); + return builder.ToString(); + } + + private static string BuildDelegateSignature(DelegateDeclarationSyntax delegateDeclaration) + { + var builder = new StringBuilder(); + builder.Append(delegateDeclaration.Identifier.Text); + builder.Append('('); + builder.Append(string.Join(", ", delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)))); + builder.Append(')'); + builder.Append(" -> "); + builder.Append(NormalizeType(delegateDeclaration.ReturnType)); + return builder.ToString(); + } + + private static string NormalizeType(TypeSyntax? typeSyntax) + { + return typeSyntax?.WithoutTrivia().ToString().Replace("global::", string.Empty, StringComparison.Ordinal) ?? "void"; + } + + private static string QualifyType(string currentNamespace, string typeName) + { + return string.IsNullOrWhiteSpace(currentNamespace) ? typeName : $"{currentNamespace}.{typeName}"; + } + + private static string CombineNamespace(string currentNamespace, string childNamespace) + { + return string.IsNullOrWhiteSpace(currentNamespace) ? childNamespace : $"{currentNamespace}.{childNamespace}"; + } + + private bool HasAnyAttribute(SyntaxList attributeLists, HashSet attributeNames) + { + foreach (var attributeList in attributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + if (attributeNames.Contains(NormalizeAttributeName(attribute.Name))) + { + return true; + } + } + } + + return false; + } + + private static bool HasAttribute(SyntaxList attributeLists, string attributeName) + { + foreach (var attributeList in attributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + if (string.Equals(NormalizeAttributeName(attribute.Name), attributeName, StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; + } + + private static bool HasNullAllowed(SyntaxList attributeLists, string? attributeTarget) + { + foreach (var attributeList in attributeLists) + { + var target = attributeList.Target?.Identifier.Text; + if (attributeTarget is not null && !string.Equals(attributeTarget, target, StringComparison.Ordinal)) + { + continue; + } + + if (attributeTarget is null && target is not null) + { + continue; + } + + foreach (var attribute in attributeList.Attributes) + { + if (string.Equals(NormalizeAttributeName(attribute.Name), "NullAllowed", StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; + } + + private static string? GetBaseTypeName(SyntaxList attributeLists) + { + foreach (var attributeList in attributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + if (!string.Equals(NormalizeAttributeName(attribute.Name), "BaseType", StringComparison.Ordinal)) + { + continue; + } + + var firstArgument = attribute.ArgumentList?.Arguments.FirstOrDefault(); + if (firstArgument?.Expression is TypeOfExpressionSyntax typeOfExpression) + { + return NormalizeType(typeOfExpression.Type); + } + } + } + + return null; + } + + private static string? GetAttributeStringValue(SyntaxList attributeLists, string attributeName) + { + foreach (var attributeList in attributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + if (!string.Equals(NormalizeAttributeName(attribute.Name), attributeName, StringComparison.Ordinal)) + { + continue; + } + + var firstArgument = attribute.ArgumentList?.Arguments.FirstOrDefault(static argument => argument.NameEquals is null); + if (firstArgument is null) + { + return null; + } + + return Unquote(firstArgument.Expression.WithoutTrivia().ToString()); + } + } + + return null; + } + + private static string? GetAttributeNamedStringValue( + SyntaxList attributeLists, + string attributeName, + string argumentName) + { + foreach (var attributeList in attributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + if (!string.Equals(NormalizeAttributeName(attribute.Name), attributeName, StringComparison.Ordinal)) + { + continue; + } + + var namedArgument = attribute.ArgumentList?.Arguments.FirstOrDefault(argument => + string.Equals(argument.NameEquals?.Name.Identifier.Text, argumentName, StringComparison.Ordinal)); + + if (namedArgument is null) + { + continue; + } + + return Unquote(namedArgument.Expression.WithoutTrivia().ToString()); + } + } + + return null; + } + + private static string NormalizeAttributeName(NameSyntax nameSyntax) + { + var rawName = nameSyntax.WithoutTrivia().ToString(); + var shortName = rawName.Split('.').Last(); + return shortName.EndsWith("Attribute", StringComparison.Ordinal) + ? shortName[..^"Attribute".Length] + : shortName; + } + + private static string NormalizeAlias(string alias) + { + return alias.Replace("global::", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + } + + private static string Unquote(string text) + { + return text.Length >= 2 && text.StartsWith('"') && text.EndsWith('"') + ? text[1..^1] + : text; + } +} + +internal sealed class SymbolAliasLookup +{ + private readonly Dictionary> comparisonKeysByAlias = new(StringComparer.Ordinal); + + public void AddAliases(IReadOnlyDictionary? aliases) + { + if (aliases is null) + { + return; + } + + foreach (var alias in aliases) + { + AddAlias(alias.Key, alias.Value); + AddAlias(alias.Value, alias.Value); + } + } + + public IEnumerable GetEquivalentComparisonKeys(string comparisonKey) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var lookupKey in GetLookupKeys(comparisonKey)) + { + if (!comparisonKeysByAlias.TryGetValue(lookupKey, out var comparisonKeys)) + { + continue; + } + + foreach (var equivalentComparisonKey in comparisonKeys) + { + if (seen.Add(equivalentComparisonKey)) + { + yield return equivalentComparisonKey; + } + } + } + } + + public bool TryGetComparableEntry( + IReadOnlyDictionary entries, + string comparisonKey, + out T entry) + { + if (entries.TryGetValue(comparisonKey, out entry!)) + { + return true; + } + + foreach (var equivalentKey in GetEquivalentComparisonKeys(comparisonKey)) + { + if (entries.TryGetValue(equivalentKey, out entry!)) + { + return true; + } + + var simplifiedEquivalentKey = equivalentKey.Split('.').Last(); + if (entries.TryGetValue(simplifiedEquivalentKey, out entry!)) + { + return true; + } + } + + var simplifiedKey = comparisonKey.Split('.').Last(); + if (entries.TryGetValue(simplifiedKey, out entry!)) + { + return true; + } + + foreach (var equivalentKey in GetEquivalentComparisonKeys(simplifiedKey)) + { + if (entries.TryGetValue(equivalentKey, out entry!)) + { + return true; + } + } + + return false; + } + + public string NormalizeSimpleType(string typeName) + { + var matchingComparisonKeys = new HashSet(StringComparer.Ordinal); + foreach (var lookupKey in GetLookupKeys(typeName)) + { + if (comparisonKeysByAlias.TryGetValue(lookupKey, out var comparisonKeys)) + { + foreach (var comparisonKey in comparisonKeys) + { + matchingComparisonKeys.Add(comparisonKey); + } + } + } + + if (matchingComparisonKeys.Count > 0) + { + return matchingComparisonKeys + .OrderBy(static comparisonKey => GetComparisonKeyRank(comparisonKey)) + .ThenBy(static comparisonKey => comparisonKey.Length) + .ThenBy(static comparisonKey => comparisonKey, StringComparer.Ordinal) + .First(); + } + + return NormalizeLookupKey(typeName.Split('.').Last()); + } + + public static string NormalizeLookupKey(string alias) + { + return alias.Replace("global::", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .ToLowerInvariant(); + } + + private void AddAlias(string alias, string comparisonKey) + { + if (string.IsNullOrWhiteSpace(alias) || string.IsNullOrWhiteSpace(comparisonKey)) + { + return; + } + + foreach (var lookupKey in GetLookupKeys(alias)) + { + if (!comparisonKeysByAlias.TryGetValue(lookupKey, out var comparisonKeys)) + { + comparisonKeys = []; + comparisonKeysByAlias[lookupKey] = comparisonKeys; + } + + if (!comparisonKeys.Contains(comparisonKey, StringComparer.Ordinal)) + { + comparisonKeys.Add(comparisonKey); + } + } + } + + private static IEnumerable GetLookupKeys(string alias) + { + if (string.IsNullOrWhiteSpace(alias)) + { + yield break; + } + + var lookupKeys = new HashSet(StringComparer.Ordinal); + AddLookupKeyVariants(lookupKeys, alias); + var simpleName = alias.Split('.').Last(); + AddLookupKeyVariants(lookupKeys, simpleName); + + foreach (var lookupKey in lookupKeys) + { + yield return NormalizeLookupKey(lookupKey); + } + } + + private static int GetComparisonKeyRank(string comparisonKey) + { + var simpleName = comparisonKey.Split('.').Last(); + return HasObjectiveCPrefixShape(simpleName) ? 1 : 0; + } + + private static bool HasObjectiveCPrefixShape(string alias) + { + return (alias.Length > 3 && alias.StartsWith("FIR", StringComparison.Ordinal) && char.IsUpper(alias[3])) || + (alias.Length > 3 && alias.StartsWith("ABT", StringComparison.Ordinal) && char.IsUpper(alias[3])) || + (alias.Length > 4 && alias.StartsWith("IFIR", StringComparison.Ordinal) && char.IsUpper(alias[4])) || + (alias.Length > 4 && alias.StartsWith("IABT", StringComparison.Ordinal) && char.IsUpper(alias[4])); + } + + private static void AddLookupKeyVariants(HashSet lookupKeys, string alias) + { + if (string.IsNullOrWhiteSpace(alias) || !lookupKeys.Add(alias)) + { + return; + } + + if (alias.EndsWith("Block", StringComparison.Ordinal)) + { + AddLookupKeyVariants(lookupKeys, $"{alias[..^"Block".Length]}Handler"); + } + + if (alias.EndsWith("Callback", StringComparison.Ordinal)) + { + AddLookupKeyVariants(lookupKeys, $"{alias[..^"Callback".Length]}Handler"); + } + + if (alias.EndsWith("Completion", StringComparison.Ordinal)) + { + AddLookupKeyVariants(lookupKeys, $"{alias}Handler"); + } + + if (TryStripObjectiveCPrefix(alias, out var withoutObjectiveCPrefix)) + { + AddLookupKeyVariants(lookupKeys, withoutObjectiveCPrefix); + } + + if (TryStripInterfaceObjectiveCPrefix(alias, out var withoutInterfaceObjectiveCPrefix)) + { + AddLookupKeyVariants(lookupKeys, withoutInterfaceObjectiveCPrefix); + AddLookupKeyVariants(lookupKeys, withoutInterfaceObjectiveCPrefix[1..]); + } + } + + private static bool TryStripObjectiveCPrefix(string alias, out string result) + { + foreach (var prefix in new[] { "FIR", "ABT" }) + { + if (alias.Length > prefix.Length && + alias.StartsWith(prefix, StringComparison.Ordinal) && + char.IsUpper(alias[prefix.Length])) + { + result = alias[prefix.Length..]; + return true; + } + } + + result = string.Empty; + return false; + } + + private static bool TryStripInterfaceObjectiveCPrefix(string alias, out string result) + { + if (alias.Length > 4 && + alias.StartsWith("IFIR", StringComparison.Ordinal) && + char.IsUpper(alias[4])) + { + result = $"I{alias[4..]}"; + return true; + } + + if (alias.Length > 4 && + alias.StartsWith("IABT", StringComparison.Ordinal) && + char.IsUpper(alias[4])) + { + result = $"I{alias[4..]}"; + return true; + } + + result = string.Empty; + return false; + } +} + +internal sealed class BindingComparer +{ + public TargetComparisonResult Compare( + BindingSnapshot baseline, + BindingSnapshot generated, + IReadOnlyDictionary? sharedAliases = null) + { + var failures = new List(); + var infos = new List(); + var aliases = BuildAliasLookup(baseline.TypeAliases, generated.TypeAliases, sharedAliases); + var baselineManualLookup = new HashSet( + baseline.ManualItems + .Where(static item => !string.IsNullOrWhiteSpace(item.MatchMemberKey)) + .Select(static item => CreateManualLookupKey(item.MatchTypeKey, item.MatchMemberKey!)), + StringComparer.Ordinal); + + var smoothedDelegateKeys = new HashSet(StringComparer.Ordinal); + + CompareBoundTypes( + baseline.BoundTypes, + generated.BoundTypes, + baseline.Delegates, + generated.Delegates, + aliases, + baselineManualLookup, + failures, + infos, + smoothedDelegateKeys); + CompareDelegates(baseline.Delegates, generated.Delegates, aliases, failures, smoothedDelegateKeys); + CompareEnums(baseline.Enums, generated.Enums, aliases, failures); + + foreach (var manualItem in baseline.ManualItems) + { + infos.Add(new AuditFinding( + Category: "manual-surface", + Severity: "info", + Message: $"Baseline-only manual surface: {manualItem.Signature}", + TypeName: manualItem.TypeName, + MemberName: manualItem.MemberName, + Selector: null, + BaselineFile: manualItem.SourceFile, + GeneratedFile: null)); + } + + return new TargetComparisonResult(failures, infos); + } + + private static SymbolAliasLookup BuildAliasLookup( + IReadOnlyDictionary baselineAliases, + IReadOnlyDictionary generatedAliases, + IReadOnlyDictionary? sharedAliases) + { + var aliases = new SymbolAliasLookup(); + + aliases.AddAliases(sharedAliases); + aliases.AddAliases(baselineAliases); + aliases.AddAliases(generatedAliases); + return aliases; + } + + private static void CompareBoundTypes( + IReadOnlyDictionary baseline, + IReadOnlyDictionary generated, + IReadOnlyDictionary baselineDelegates, + IReadOnlyDictionary generatedDelegates, + SymbolAliasLookup aliases, + IReadOnlySet baselineManualLookup, + List failures, + List infos, + ISet smoothedDelegateKeys) + { + foreach (var baselineEntry in baseline.Values) + { + if (!TryGetComparableEntry(generated, baselineEntry.ComparisonKey, aliases, out var generatedType)) + { + failures.Add(new AuditFinding( + Category: "stale-baseline-binding", + Severity: "failure", + Message: $"Type '{baselineEntry.DisplayName}' exists in the baseline but not in fresh output.", + TypeName: baselineEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: baselineEntry.SourceFile, + GeneratedFile: null, + ComparisonTypeKey: baselineEntry.ComparisonKey)); + continue; + } + + CompareTypeMetadata(baselineEntry, generatedType, aliases, failures); + CompareMembers( + baselineEntry, + generatedType, + baselineDelegates, + generatedDelegates, + aliases, + baselineManualLookup, + failures, + infos, + smoothedDelegateKeys); + } + + foreach (var generatedEntry in generated.Values) + { + if (ContainsEquivalentEntry(baseline, generatedEntry.ComparisonKey, aliases)) + { + continue; + } + + failures.Add(new AuditFinding( + Category: "missing-baseline-binding", + Severity: "failure", + Message: $"Type '{generatedEntry.DisplayName}' exists in fresh output but not in the baseline.", + TypeName: generatedEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: generatedEntry.SourceFile, + ComparisonTypeKey: generatedEntry.ComparisonKey)); + } + } + + private static void CompareTypeMetadata( + BoundTypeSurface baseline, + BoundTypeSurface generated, + SymbolAliasLookup aliases, + List failures) + { + if (!string.Equals(baseline.ContainerKind, generated.ContainerKind, StringComparison.Ordinal) || + !string.Equals(NormalizeTypeReference(baseline.BaseType, aliases), NormalizeTypeReference(generated.BaseType, aliases), StringComparison.Ordinal) || + baseline.IsProtocol != generated.IsProtocol || + baseline.IsStatic != generated.IsStatic) + { + failures.Add(new AuditFinding( + Category: "attribute-drift", + Severity: "failure", + Message: $"Type metadata drift for '{baseline.DisplayName}'. Baseline: base='{baseline.BaseType}', protocol={baseline.IsProtocol}, static={baseline.IsStatic}. Fresh: base='{generated.BaseType}', protocol={generated.IsProtocol}, static={generated.IsStatic}.", + TypeName: baseline.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: baseline.SourceFile, + GeneratedFile: generated.SourceFile, + ComparisonTypeKey: baseline.ComparisonKey)); + } + } + + private static void CompareMembers( + BoundTypeSurface baseline, + BoundTypeSurface generated, + IReadOnlyDictionary baselineDelegates, + IReadOnlyDictionary generatedDelegates, + SymbolAliasLookup aliases, + IReadOnlySet baselineManualLookup, + List failures, + List infos, + ISet smoothedDelegateKeys) + { + foreach (var baselineMemberEntry in baseline.Members.Values) + { + if (!TryGetComparableMember(generated.Members, baselineMemberEntry, out var generatedMember)) + { + failures.Add(new AuditFinding( + Category: "stale-baseline-binding", + Severity: "failure", + Message: $"Member '{baselineMemberEntry.Name}' on '{baseline.DisplayName}' exists in the baseline but not in fresh output.", + TypeName: baseline.DisplayName, + MemberName: baselineMemberEntry.Name, + Selector: baselineMemberEntry.BindingValue, + BaselineFile: baselineMemberEntry.SourceFile, + GeneratedFile: null, + ComparisonTypeKey: baseline.ComparisonKey, + ComparisonMemberKey: baselineMemberEntry.Key)); + continue; + } + + CompareMemberMetadata( + baseline.DisplayName, + baseline.ComparisonKey, + baselineMemberEntry, + generatedMember, + baselineDelegates, + generatedDelegates, + aliases, + failures, + infos, + smoothedDelegateKeys); + } + + foreach (var generatedMemberEntry in generated.Members.Values) + { + if (TryGetComparableMember(baseline.Members, generatedMemberEntry, out _)) + { + continue; + } + + if (baselineManualLookup.Contains(CreateManualLookupKey(baseline.ComparisonKey, generatedMemberEntry.Key))) + { + continue; + } + + failures.Add(new AuditFinding( + Category: "missing-baseline-binding", + Severity: "failure", + Message: $"Member '{generatedMemberEntry.Name}' on '{generated.DisplayName}' exists in fresh output but not in the baseline.", + TypeName: generated.DisplayName, + MemberName: generatedMemberEntry.Name, + Selector: generatedMemberEntry.BindingValue, + BaselineFile: null, + GeneratedFile: generatedMemberEntry.SourceFile, + ComparisonTypeKey: generated.ComparisonKey, + ComparisonMemberKey: generatedMemberEntry.Key)); + } + } + + private static bool TryGetComparableMember( + IReadOnlyDictionary members, + BindingMemberSurface requestedMember, + out BindingMemberSurface member) + { + if (members.TryGetValue(requestedMember.Key, out member!)) + { + return true; + } + + foreach (var candidate in members.Values) + { + if (AreComparableMembers(requestedMember, candidate)) + { + member = candidate; + return true; + } + } + + return false; + } + + private static bool AreComparableMembers(BindingMemberSurface left, BindingMemberSurface right) + { + return HaveEquivalentMemberKinds(left, right) && + string.Equals(left.BindingAttribute, right.BindingAttribute, StringComparison.Ordinal) && + HaveEquivalentBindingSelectors(left, right); + } + + private static bool HaveEquivalentMemberKinds(BindingMemberSurface left, BindingMemberSurface right) + { + return string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) || + IsReadOnlyPropertyGetterMethodPair(left, right); + } + + private static bool HaveEquivalentAccessors(BindingMemberSurface left, BindingMemberSurface right) + { + if (IsReadOnlyPropertyGetterMethodPair(left, right)) + { + return true; + } + + return left.HasGetter == right.HasGetter && + left.HasSetter == right.HasSetter; + } + + private static bool IsReadOnlyPropertyGetterMethodPair(BindingMemberSurface left, BindingMemberSurface right) + { + return IsReadOnlyPropertyGetter(left, right) || + IsReadOnlyPropertyGetter(right, left); + } + + private static bool IsReadOnlyPropertyGetter(BindingMemberSurface property, BindingMemberSurface method) + { + return string.Equals(property.Kind, "property", StringComparison.Ordinal) && + string.Equals(method.Kind, "method", StringComparison.Ordinal) && + property.HasGetter && + !property.HasSetter && + property.Parameters.Count == 0 && + method.Parameters.Count == 0; + } + + private static bool HaveEquivalentBindingSelectors(BindingMemberSurface left, BindingMemberSurface right) + { + if (string.Equals(left.BindingValue, right.BindingValue, StringComparison.Ordinal) && + string.Equals(left.GetterBind, right.GetterBind, StringComparison.Ordinal) && + string.Equals(left.SetterBind, right.SetterBind, StringComparison.Ordinal)) + { + return true; + } + + var leftSelectors = GetBindingSelectorAliases(left); + var rightSelectors = GetBindingSelectorAliases(right); + return leftSelectors.Overlaps(rightSelectors); + } + + private static HashSet GetBindingSelectorAliases(BindingMemberSurface member) + { + var selectors = new HashSet(StringComparer.Ordinal); + AddSelectorAlias(selectors, member.BindingValue); + AddSelectorAlias(selectors, member.GetterBind); + AddSelectorAlias(selectors, member.SetterBind); + + if (string.Equals(member.Kind, "property", StringComparison.Ordinal)) + { + AddBooleanPropertySelectorAlias(selectors, member.Name); + AddBooleanPropertySelectorAlias(selectors, member.BindingValue); + AddBooleanPropertySelectorAlias(selectors, member.GetterBind); + } + + return selectors; + } + + private static void AddSelectorAlias(HashSet selectors, string? selector) + { + if (!string.IsNullOrWhiteSpace(selector)) + { + selectors.Add(SymbolAliasLookup.NormalizeLookupKey(selector)); + } + } + + private static void AddBooleanPropertySelectorAlias(HashSet selectors, string? selector) + { + if (string.IsNullOrWhiteSpace(selector)) + { + return; + } + + var trimmed = selector.Trim(); + if (TryStripBooleanGetterPrefix(trimmed, "is", out var propertyName) || + TryStripBooleanGetterPrefix(trimmed, "Is", out propertyName) || + TryStripBooleanGetterPrefix(trimmed, "has", out propertyName) || + TryStripBooleanGetterPrefix(trimmed, "Has", out propertyName)) + { + selectors.Add(SymbolAliasLookup.NormalizeLookupKey(ToLowerCamelCase(propertyName))); + } + } + + private static bool TryStripBooleanGetterPrefix(string selector, string prefix, out string propertyName) + { + if (selector.Length > prefix.Length && + selector.StartsWith(prefix, StringComparison.Ordinal) && + char.IsUpper(selector[prefix.Length])) + { + propertyName = selector[prefix.Length..]; + return true; + } + + propertyName = string.Empty; + return false; + } + + private static string ToLowerCamelCase(string value) + { + return string.IsNullOrEmpty(value) + ? value + : $"{char.ToLowerInvariant(value[0])}{value[1..]}"; + } + + private static void CompareMemberMetadata( + string typeName, + string typeKey, + BindingMemberSurface baseline, + BindingMemberSurface generated, + IReadOnlyDictionary baselineDelegates, + IReadOnlyDictionary generatedDelegates, + SymbolAliasLookup aliases, + List failures, + List infos, + ISet smoothedDelegateKeys) + { + var baselineParameterTypes = baseline.Parameters.Select(parameter => NormalizeTypeReference(parameter.Type, aliases)).ToList(); + var generatedParameterTypes = generated.Parameters.Select(parameter => NormalizeTypeReference(parameter.Type, aliases)).ToList(); + var signatureHasDrift = false; + var preferredShapeNotes = new List(); + + if (!HaveEquivalentMemberKinds(baseline, generated) || + !string.Equals(NormalizeTypeReference(baseline.ReturnType, aliases), NormalizeTypeReference(generated.ReturnType, aliases), StringComparison.Ordinal) || + baseline.IsStatic != generated.IsStatic || + !HaveEquivalentAccessors(baseline, generated) || + baseline.Parameters.Count != generated.Parameters.Count) + { + signatureHasDrift = true; + } + + if (!signatureHasDrift) + { + for (var index = 0; index < baselineParameterTypes.Count; index++) + { + if (string.Equals(baselineParameterTypes[index], generatedParameterTypes[index], StringComparison.Ordinal)) + { + continue; + } + + if (TryMatchActionSmoothing( + baseline.Parameters[index].Type, + generated.Parameters[index].Type, + baselineDelegates, + generatedDelegates, + aliases, + smoothedDelegateKeys, + out var preferredShapeNote)) + { + preferredShapeNotes.Add(preferredShapeNote); + continue; + } + + signatureHasDrift = true; + break; + } + } + + if (signatureHasDrift) + { + failures.Add(new AuditFinding( + Category: "signature-drift", + Severity: "failure", + Message: $"Signature drift for '{typeName}.{baseline.Name}'. Baseline: {baseline.Signature}. Fresh: {generated.Signature}.", + TypeName: typeName, + MemberName: baseline.Name, + Selector: baseline.BindingValue ?? generated.BindingValue, + BaselineFile: baseline.SourceFile, + GeneratedFile: generated.SourceFile, + ComparisonTypeKey: typeKey, + ComparisonMemberKey: baseline.Key)); + } + else + { + foreach (var preferredShapeNote in preferredShapeNotes.Distinct(StringComparer.Ordinal)) + { + infos.Add(new AuditFinding( + Category: "preferred-api-shape", + Severity: "info", + Message: $"Compatible callback shape difference for '{typeName}.{baseline.Name}': {preferredShapeNote}. Keep existing public surface for compatibility; prefer Action<> smoothing for new or additive APIs case-by-case.", + TypeName: typeName, + MemberName: baseline.Name, + Selector: baseline.BindingValue ?? generated.BindingValue, + BaselineFile: baseline.SourceFile, + GeneratedFile: generated.SourceFile, + ComparisonTypeKey: typeKey, + ComparisonMemberKey: baseline.Key)); + } + } + + var hasParameterNullabilityDrift = baseline.Parameters.Count == generated.Parameters.Count && + baseline.Parameters.Where((parameter, index) => parameter.IsNullAllowed != generated.Parameters[index].IsNullAllowed).Any(); + + if (!string.Equals(baseline.BindingAttribute, generated.BindingAttribute, StringComparison.Ordinal) || + !HaveEquivalentBindingSelectors(baseline, generated) || + baseline.IsReturnNullAllowed != generated.IsReturnNullAllowed || + hasParameterNullabilityDrift) + { + failures.Add(new AuditFinding( + Category: "attribute-drift", + Severity: "failure", + Message: $"Binding attribute drift for '{typeName}.{baseline.Name}'. Baseline selector='{baseline.BindingValue}', return nullable={baseline.IsReturnNullAllowed}. Fresh selector='{generated.BindingValue}', return nullable={generated.IsReturnNullAllowed}.", + TypeName: typeName, + MemberName: baseline.Name, + Selector: baseline.BindingValue ?? generated.BindingValue, + BaselineFile: baseline.SourceFile, + GeneratedFile: generated.SourceFile, + ComparisonTypeKey: typeKey, + ComparisonMemberKey: baseline.Key)); + } + } + + private static void CompareDelegates( + IReadOnlyDictionary baseline, + IReadOnlyDictionary generated, + SymbolAliasLookup aliases, + List failures, + IReadOnlySet smoothedDelegateKeys) + { + foreach (var baselineEntry in baseline.Values) + { + if (!TryGetComparableEntry(generated, baselineEntry.ComparisonKey, aliases, out var generatedDelegate)) + { + if (smoothedDelegateKeys.Contains(baselineEntry.ComparisonKey)) + { + continue; + } + + failures.Add(new AuditFinding( + Category: "stale-baseline-binding", + Severity: "failure", + Message: $"Delegate '{baselineEntry.DisplayName}' exists in the baseline but not in fresh output.", + TypeName: baselineEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: baselineEntry.SourceFile, + GeneratedFile: null, + ComparisonTypeKey: baselineEntry.ComparisonKey)); + continue; + } + + if (!string.Equals(NormalizeTypeReference(baselineEntry.ReturnType, aliases), NormalizeTypeReference(generatedDelegate.ReturnType, aliases), StringComparison.Ordinal) || + baselineEntry.IsReturnNullAllowed != generatedDelegate.IsReturnNullAllowed || + baselineEntry.Parameters.Count != generatedDelegate.Parameters.Count || + baselineEntry.Parameters.Where((parameter, index) => + !string.Equals(NormalizeTypeReference(parameter.Type, aliases), NormalizeTypeReference(generatedDelegate.Parameters[index].Type, aliases), StringComparison.Ordinal) || + parameter.IsNullAllowed != generatedDelegate.Parameters[index].IsNullAllowed).Any()) + { + failures.Add(new AuditFinding( + Category: "signature-drift", + Severity: "failure", + Message: $"Delegate drift for '{baselineEntry.DisplayName}'. Baseline: {baselineEntry.Signature}. Fresh: {generatedDelegate.Signature}.", + TypeName: baselineEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: baselineEntry.SourceFile, + GeneratedFile: generatedDelegate.SourceFile, + ComparisonTypeKey: baselineEntry.ComparisonKey)); + } + } + + foreach (var generatedEntry in generated.Values) + { + if (ContainsEquivalentEntry(baseline, generatedEntry.ComparisonKey, aliases)) + { + continue; + } + + if (smoothedDelegateKeys.Contains(generatedEntry.ComparisonKey)) + { + continue; + } + + failures.Add(new AuditFinding( + Category: "missing-baseline-binding", + Severity: "failure", + Message: $"Delegate '{generatedEntry.DisplayName}' exists in fresh output but not in the baseline.", + TypeName: generatedEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: generatedEntry.SourceFile, + ComparisonTypeKey: generatedEntry.ComparisonKey)); + } + } + + private static void CompareEnums( + IReadOnlyDictionary baseline, + IReadOnlyDictionary generated, + SymbolAliasLookup aliases, + List failures) + { + foreach (var baselineEntry in baseline.Values) + { + if (!TryGetComparableEntry(generated, baselineEntry.ComparisonKey, aliases, out var generatedEnum)) + { + failures.Add(new AuditFinding( + Category: "stale-baseline-binding", + Severity: "failure", + Message: $"Enum '{baselineEntry.DisplayName}' exists in the baseline but not in fresh output.", + TypeName: baselineEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: baselineEntry.SourceFile, + GeneratedFile: null, + ComparisonTypeKey: baselineEntry.ComparisonKey)); + continue; + } + + if (!string.Equals(NormalizeTypeReference(baselineEntry.UnderlyingType, aliases), NormalizeTypeReference(generatedEnum.UnderlyingType, aliases), StringComparison.Ordinal) || + baselineEntry.IsNative != generatedEnum.IsNative) + { + failures.Add(new AuditFinding( + Category: "attribute-drift", + Severity: "failure", + Message: $"Enum metadata drift for '{baselineEntry.DisplayName}'. Baseline underlying type='{baselineEntry.UnderlyingType}', native={baselineEntry.IsNative}. Fresh underlying type='{generatedEnum.UnderlyingType}', native={generatedEnum.IsNative}.", + TypeName: baselineEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: baselineEntry.SourceFile, + GeneratedFile: generatedEnum.SourceFile, + ComparisonTypeKey: baselineEntry.ComparisonKey)); + } + + foreach (var baselineMemberEntry in baselineEntry.Members) + { + if (!TryGetComparableEnumMember(generatedEnum, baselineMemberEntry.Key, aliases, out var generatedMember)) + { + failures.Add(new AuditFinding( + Category: "stale-baseline-binding", + Severity: "failure", + Message: $"Enum member '{baselineMemberEntry.Key}' on '{baselineEntry.DisplayName}' exists in the baseline but not in fresh output.", + TypeName: baselineEntry.DisplayName, + MemberName: baselineMemberEntry.Key, + Selector: baselineMemberEntry.Value.FieldValue, + BaselineFile: baselineMemberEntry.Value.SourceFile, + GeneratedFile: null, + ComparisonTypeKey: baselineEntry.ComparisonKey, + ComparisonMemberKey: baselineMemberEntry.Key)); + continue; + } + + if (!string.Equals(baselineMemberEntry.Value.FieldValue, generatedMember.FieldValue, StringComparison.Ordinal)) + { + failures.Add(new AuditFinding( + Category: "attribute-drift", + Severity: "failure", + Message: $"Enum member drift for '{baselineEntry.DisplayName}.{baselineMemberEntry.Key}'. Baseline field='{baselineMemberEntry.Value.FieldValue}'. Fresh field='{generatedMember.FieldValue}'.", + TypeName: baselineEntry.DisplayName, + MemberName: baselineMemberEntry.Key, + Selector: baselineMemberEntry.Value.FieldValue ?? generatedMember.FieldValue, + BaselineFile: baselineMemberEntry.Value.SourceFile, + GeneratedFile: generatedMember.SourceFile, + ComparisonTypeKey: baselineEntry.ComparisonKey, + ComparisonMemberKey: baselineMemberEntry.Key)); + } + } + + foreach (var generatedMemberEntry in generatedEnum.Members) + { + if (ContainsEquivalentEnumMember(baselineEntry, generatedMemberEntry.Key, aliases)) + { + continue; + } + + failures.Add(new AuditFinding( + Category: "missing-baseline-binding", + Severity: "failure", + Message: $"Enum member '{generatedMemberEntry.Key}' on '{generatedEnum.DisplayName}' exists in fresh output but not in the baseline.", + TypeName: generatedEnum.DisplayName, + MemberName: generatedMemberEntry.Key, + Selector: generatedMemberEntry.Value.FieldValue, + BaselineFile: null, + GeneratedFile: generatedMemberEntry.Value.SourceFile, + ComparisonTypeKey: generatedEnum.ComparisonKey, + ComparisonMemberKey: generatedMemberEntry.Key)); + } + } + + foreach (var generatedEntry in generated.Values) + { + if (ContainsEquivalentEntry(baseline, generatedEntry.ComparisonKey, aliases)) + { + continue; + } + + failures.Add(new AuditFinding( + Category: "missing-baseline-binding", + Severity: "failure", + Message: $"Enum '{generatedEntry.DisplayName}' exists in fresh output but not in the baseline.", + TypeName: generatedEntry.DisplayName, + MemberName: null, + Selector: null, + BaselineFile: null, + GeneratedFile: generatedEntry.SourceFile, + ComparisonTypeKey: generatedEntry.ComparisonKey)); + } + } + + private static bool TryGetComparableEnumMember( + EnumSurface enumSurface, + string memberKey, + SymbolAliasLookup aliases, + out EnumMemberSurface member) + { + if (enumSurface.Members.TryGetValue(memberKey, out member!)) + { + return true; + } + + var normalizedMemberKey = NormalizeEnumMemberKey(memberKey, enumSurface, aliases); + foreach (var candidate in enumSurface.Members) + { + if (string.Equals(NormalizeEnumMemberKey(candidate.Key, enumSurface, aliases), normalizedMemberKey, StringComparison.Ordinal)) + { + member = candidate.Value; + return true; + } + } + + return false; + } + + private static bool ContainsEquivalentEnumMember( + EnumSurface enumSurface, + string memberKey, + SymbolAliasLookup aliases) + { + return TryGetComparableEnumMember(enumSurface, memberKey, aliases, out _); + } + + private static string NormalizeEnumMemberKey(string memberKey, EnumSurface enumSurface, SymbolAliasLookup aliases) + { + var normalizedMember = SymbolAliasLookup.NormalizeLookupKey(StripObjectiveCPrefix(memberKey)); + foreach (var enumName in GetEnumNameVariants(enumSurface, aliases).OrderByDescending(static name => name.Length)) + { + var normalizedEnumName = SymbolAliasLookup.NormalizeLookupKey(StripObjectiveCPrefix(enumName)); + if (normalizedMember.Length > normalizedEnumName.Length && + normalizedMember.StartsWith(normalizedEnumName, StringComparison.Ordinal)) + { + return normalizedMember[normalizedEnumName.Length..]; + } + } + + return normalizedMember; + } + + private static IEnumerable GetEnumNameVariants(EnumSurface enumSurface, SymbolAliasLookup aliases) + { + yield return enumSurface.ComparisonKey; + yield return enumSurface.Name; + yield return enumSurface.DisplayName.Split('.').Last(); + + foreach (var equivalentKey in aliases.GetEquivalentComparisonKeys(enumSurface.ComparisonKey)) + { + yield return equivalentKey; + yield return equivalentKey.Split('.').Last(); + } + } + + private static string StripObjectiveCPrefix(string symbol) + { + foreach (var prefix in new[] { "FIR", "ABT" }) + { + if (symbol.Length > prefix.Length && + symbol.StartsWith(prefix, StringComparison.Ordinal) && + char.IsUpper(symbol[prefix.Length])) + { + return symbol[prefix.Length..]; + } + } + + return symbol; + } + + private static string NormalizeTypeReference(string? typeName, SymbolAliasLookup aliases) + { + if (string.IsNullOrWhiteSpace(typeName)) + { + return string.Empty; + } + + var compact = typeName.Replace("global::", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + + if (compact.EndsWith("[]", StringComparison.Ordinal)) + { + return $"{NormalizeTypeReference(compact[..^2], aliases)}[]"; + } + + if (compact.EndsWith("?", StringComparison.Ordinal)) + { + return $"{NormalizeTypeReference(compact[..^1], aliases)}?"; + } + + var genericStart = compact.IndexOf('<'); + if (genericStart >= 0 && compact.EndsWith(">", StringComparison.Ordinal)) + { + var outer = compact[..genericStart]; + var arguments = compact[(genericStart + 1)..^1]; + var normalizedOuter = aliases.NormalizeSimpleType(outer); + if (IsFoundationCollectionType(normalizedOuter)) + { + return normalizedOuter; + } + + return $"{normalizedOuter}<{string.Join(",", SplitGenericArguments(arguments).Select(argument => NormalizeTypeReference(argument, aliases)))}>"; + } + + return aliases.NormalizeSimpleType(compact); + } + + private static bool IsFoundationCollectionType(string normalizedTypeName) + { + return string.Equals(normalizedTypeName, "nsarray", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsmutablearray", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsdictionary", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsmutabledictionary", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsset", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsmutableset", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsorderedset", StringComparison.Ordinal) || + string.Equals(normalizedTypeName, "nsmutableorderedset", StringComparison.Ordinal); + } + + private static bool TryMatchActionSmoothing( + string baselineType, + string generatedType, + IReadOnlyDictionary baselineDelegates, + IReadOnlyDictionary generatedDelegates, + SymbolAliasLookup aliases, + ISet smoothedDelegateKeys, + out string note) + { + if (TryMatchActionSmoothingDirection(baselineType, generatedType, baselineDelegates, generatedDelegates, aliases, out var baselineNote, out var baselineDelegate)) + { + smoothedDelegateKeys.Add(baselineDelegate.ComparisonKey); + note = baselineNote; + return true; + } + + if (TryMatchActionSmoothingDirection(generatedType, baselineType, baselineDelegates, generatedDelegates, aliases, out var generatedNote, out var generatedDelegate)) + { + smoothedDelegateKeys.Add(generatedDelegate.ComparisonKey); + note = generatedNote; + return true; + } + + note = string.Empty; + return false; + } + + private static bool TryMatchActionSmoothingDirection( + string delegateType, + string actionType, + IReadOnlyDictionary baselineDelegates, + IReadOnlyDictionary generatedDelegates, + SymbolAliasLookup aliases, + out string note, + out DelegateSurface delegateSurface) + { + if (!TryGetDelegateSurface(delegateType, baselineDelegates, generatedDelegates, aliases, out delegateSurface) || + !TryGetActionParameterTypes(actionType, out var actionParameterTypes)) + { + note = string.Empty; + return false; + } + + if (!string.Equals(NormalizeTypeReference(delegateSurface.ReturnType, aliases), "void", StringComparison.Ordinal) || + delegateSurface.Parameters.Count != actionParameterTypes.Count) + { + note = string.Empty; + return false; + } + + for (var index = 0; index < delegateSurface.Parameters.Count; index++) + { + if (!string.Equals( + NormalizeTypeReference(delegateSurface.Parameters[index].Type, aliases), + NormalizeTypeReference(actionParameterTypes[index], aliases), + StringComparison.Ordinal)) + { + note = string.Empty; + return false; + } + } + + note = $"named delegate '{delegateSurface.Name}' is equivalent to '{actionType}'"; + return true; + } + + private static bool TryGetDelegateSurface( + string typeName, + IReadOnlyDictionary baselineDelegates, + IReadOnlyDictionary generatedDelegates, + SymbolAliasLookup aliases, + out DelegateSurface delegateSurface) + { + var compact = typeName.Replace("global::", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + + if (compact.EndsWith("?", StringComparison.Ordinal)) + { + compact = compact[..^1]; + } + + return TryGetComparableEntry(baselineDelegates, compact, aliases, out delegateSurface) || + TryGetComparableEntry(generatedDelegates, compact, aliases, out delegateSurface); + } + + private static bool TryGetActionParameterTypes(string typeName, out IReadOnlyList parameterTypes) + { + var compact = typeName.Replace("global::", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + + if (compact.EndsWith("?", StringComparison.Ordinal)) + { + compact = compact[..^1]; + } + + if (string.Equals(compact, "Action", StringComparison.Ordinal) || + string.Equals(compact, "System.Action", StringComparison.Ordinal)) + { + parameterTypes = []; + return true; + } + + var genericStart = compact.IndexOf('<'); + if (genericStart < 0 || !compact.EndsWith(">", StringComparison.Ordinal)) + { + parameterTypes = []; + return false; + } + + var outer = compact[..genericStart].Split('.').Last(); + if (!string.Equals(outer, "Action", StringComparison.Ordinal)) + { + parameterTypes = []; + return false; + } + + parameterTypes = SplitGenericArguments(compact[(genericStart + 1)..^1]).ToList(); + return true; + } + + private static IEnumerable SplitGenericArguments(string arguments) + { + if (string.IsNullOrWhiteSpace(arguments)) + { + yield break; + } + + var depth = 0; + var builder = new StringBuilder(); + + foreach (var character in arguments) + { + switch (character) + { + case '<': + depth++; + builder.Append(character); + break; + case '>': + depth--; + builder.Append(character); + break; + case ',' when depth == 0: + yield return builder.ToString(); + builder.Clear(); + break; + default: + builder.Append(character); + break; + } + } + + if (builder.Length > 0) + { + yield return builder.ToString(); + } + } + + private static string CreateManualLookupKey(string typeKey, string memberKey) + { + return $"{typeKey}|{memberKey}"; + } + + private static bool TryGetComparableEntry( + IReadOnlyDictionary entries, + string comparisonKey, + SymbolAliasLookup aliases, + out T entry) + { + return aliases.TryGetComparableEntry(entries, comparisonKey, out entry); + } + + private static bool ContainsEquivalentEntry( + IReadOnlyDictionary entries, + string comparisonKey, + SymbolAliasLookup aliases) + { + return TryGetComparableEntry(entries, comparisonKey, aliases, out _); + } +} + +internal sealed record TargetComparisonResult( + IReadOnlyList Failures, + IReadOnlyList Infos); diff --git a/scripts/FirebaseBindingAudit/Configuration.cs b/scripts/FirebaseBindingAudit/Configuration.cs new file mode 100644 index 00000000..fb6704bf --- /dev/null +++ b/scripts/FirebaseBindingAudit/Configuration.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace FirebaseBindingAudit; + +internal sealed class AuditConfiguration +{ + public string[] ManualAttributes { get; set; } = []; + + public string[] BindingAttributes { get; set; } = []; + + public SharpieConfiguration Sharpie { get; set; } = new(); + + public List Targets { get; set; } = []; +} + +internal sealed class SharpieConfiguration +{ + public string ExpectedVersion { get; set; } = "26.3.0.11"; + + public string DefaultMode { get; set; } = "auto"; +} + +internal sealed class AuditTargetDefinition +{ + public string Id { get; set; } = string.Empty; + + public string PackageId { get; set; } = string.Empty; + + public string Xcframework { get; set; } = string.Empty; + + public string BaselineDirectory { get; set; } = string.Empty; + + public string[] BaselineFiles { get; set; } = []; + + public string[] HelperFiles { get; set; } = []; + + public string? SharpieMode { get; set; } + + public bool UseSharpieComparisonFallback { get; set; } + + public string BaselineDirectoryPath(string repoRoot) => Path.Combine(repoRoot, BaselineDirectory); + + public string EffectiveSharpieMode(SharpieConfiguration sharpieConfiguration) => + string.IsNullOrWhiteSpace(SharpieMode) ? sharpieConfiguration.DefaultMode : SharpieMode!; +} + +internal static class ConfigurationLoader +{ + public static AuditConfiguration Load(string configPath) + { + if (!File.Exists(configPath)) + { + throw new FileNotFoundException("Configuration file not found.", configPath); + } + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var config = JsonSerializer.Deserialize(File.ReadAllText(configPath), options); + if (config is null) + { + throw new InvalidOperationException($"Unable to deserialize configuration at '{configPath}'."); + } + + if (config.Targets.Count == 0) + { + throw new InvalidOperationException("The audit configuration does not define any targets."); + } + + var validModes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "auto", + "forceFallback", + "off" + }; + + if (!validModes.Contains(config.Sharpie.DefaultMode)) + { + throw new InvalidOperationException($"Unsupported sharpie.defaultMode '{config.Sharpie.DefaultMode}'."); + } + + foreach (var target in config.Targets) + { + if (!string.IsNullOrWhiteSpace(target.SharpieMode) && !validModes.Contains(target.SharpieMode)) + { + throw new InvalidOperationException($"Unsupported sharpieMode '{target.SharpieMode}' for target '{target.Id}'."); + } + } + + return config; + } +} diff --git a/scripts/FirebaseBindingAudit/FirebaseBindingAudit.csproj b/scripts/FirebaseBindingAudit/FirebaseBindingAudit.csproj new file mode 100644 index 00000000..a8297ddc --- /dev/null +++ b/scripts/FirebaseBindingAudit/FirebaseBindingAudit.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + enable + false + + + + + + diff --git a/scripts/FirebaseBindingAudit/InternalsVisibleTo.cs b/scripts/FirebaseBindingAudit/InternalsVisibleTo.cs new file mode 100644 index 00000000..34653f92 --- /dev/null +++ b/scripts/FirebaseBindingAudit/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FirebaseBindingAudit.Tests")] diff --git a/scripts/FirebaseBindingAudit/Program.cs b/scripts/FirebaseBindingAudit/Program.cs new file mode 100644 index 00000000..0ab8a767 --- /dev/null +++ b/scripts/FirebaseBindingAudit/Program.cs @@ -0,0 +1,154 @@ +namespace FirebaseBindingAudit; + +internal static class ProgramEntry +{ + public static async Task Main(string[] args) + { + try + { + var parsedArguments = ParseArguments(args); + if (parsedArguments.ShowHelp) + { + PrintUsage(); + return 0; + } + + var repoRoot = parsedArguments.RepoRoot ?? throw new InvalidOperationException("--repo-root is required."); + var outputDirectory = parsedArguments.OutputDirectory ?? throw new InvalidOperationException("--output-dir is required."); + var configPath = Path.Combine(repoRoot, "scripts", "firebase-binding-audit.json"); + var suppressionPath = Path.Combine(repoRoot, "scripts", "firebase-binding-audit-suppressions.json"); + + var configuration = ConfigurationLoader.Load(configPath); + var suppressions = File.Exists(suppressionPath) + ? SuppressionLoader.Load(suppressionPath) + : new SuppressionConfiguration(); + + var runner = new AuditRunner(configuration, suppressions); + var result = await runner.RunAsync( + new AuditOptions( + RepoRoot: repoRoot, + OutputDirectory: outputDirectory, + GeneratorVersion: parsedArguments.GeneratorVersion ?? "0.7.0", + SelectedTargets: parsedArguments.Targets, + KeepTemp: parsedArguments.KeepTemp, + SharpiePath: parsedArguments.SharpiePath, + SharpieVersion: parsedArguments.SharpieVersion ?? configuration.Sharpie.ExpectedVersion, + DisableSharpie: parsedArguments.DisableSharpie, + DisableSuppressions: parsedArguments.DisableSuppressions)); + + Console.WriteLine($"Aggregate report: {Path.Combine(result.OutputDirectory, "report.md")}"); + Console.WriteLine($"JSON report: {Path.Combine(result.OutputDirectory, "report.json")}"); + Console.WriteLine($"Sharpie status: {result.SharpieResolution.Status}"); + if (!string.IsNullOrWhiteSpace(result.SharpieResolution.Version)) + { + Console.WriteLine($"Sharpie version: {result.SharpieResolution.Version}"); + } + if (!string.IsNullOrWhiteSpace(result.TempDirectory)) + { + Console.WriteLine($"Temp workspace kept at: {result.TempDirectory}"); + } + Console.WriteLine($"Suppressions enabled: {!parsedArguments.DisableSuppressions}"); + Console.WriteLine($"Suppression rules matched: {result.SuppressionSummary.MatchedRuleCount}"); + if (result.SuppressionSummary.StaleRuleCount > 0) + { + Console.WriteLine($"Stale suppression rules: {result.SuppressionSummary.StaleRuleCount}"); + } + if (result.SuppressionSummary.InvalidRuleCount > 0) + { + Console.WriteLine($"Invalid suppression rules: {result.SuppressionSummary.InvalidRuleCount}"); + } + + return result.ExitCode; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + } + + private static ParsedArguments ParseArguments(string[] args) + { + var parsed = new ParsedArguments(); + for (var index = 0; index < args.Length; index++) + { + switch (args[index]) + { + case "--repo-root": + parsed.RepoRoot = GetRequiredValue(args, ++index, "--repo-root"); + break; + case "--output-dir": + parsed.OutputDirectory = GetRequiredValue(args, ++index, "--output-dir"); + break; + case "--generator-version": + parsed.GeneratorVersion = GetRequiredValue(args, ++index, "--generator-version"); + break; + case "--targets": + parsed.Targets = GetRequiredValue(args, ++index, "--targets") + .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + break; + case "--keep-temp": + parsed.KeepTemp = true; + break; + case "--sharpie-path": + parsed.SharpiePath = GetRequiredValue(args, ++index, "--sharpie-path"); + break; + case "--sharpie-version": + parsed.SharpieVersion = GetRequiredValue(args, ++index, "--sharpie-version"); + break; + case "--disable-sharpie": + parsed.DisableSharpie = true; + break; + case "--disable-suppressions": + parsed.DisableSuppressions = true; + break; + case "--help": + case "-h": + parsed.ShowHelp = true; + break; + default: + throw new InvalidOperationException($"Unknown argument '{args[index]}'."); + } + } + + return parsed; + } + + private static string GetRequiredValue(string[] args, int index, string optionName) + { + if (index >= args.Length || string.IsNullOrWhiteSpace(args[index])) + { + throw new InvalidOperationException($"{optionName} requires a value."); + } + + return args[index]; + } + + private static void PrintUsage() + { + Console.WriteLine("Usage: FirebaseBindingAudit --repo-root --output-dir [--targets a,b] [--generator-version 0.7.0] [--keep-temp] [--sharpie-path /path/to/sharpie] [--sharpie-version 26.3.0.11] [--disable-sharpie] [--disable-suppressions]"); + } + + private sealed class ParsedArguments + { + public string? RepoRoot { get; set; } + + public string? OutputDirectory { get; set; } + + public string? GeneratorVersion { get; set; } + + public string[]? Targets { get; set; } + + public bool KeepTemp { get; set; } + + public string? SharpiePath { get; set; } + + public string? SharpieVersion { get; set; } + + public bool DisableSharpie { get; set; } + + public bool DisableSuppressions { get; set; } + + public bool ShowHelp { get; set; } + } +} diff --git a/scripts/FirebaseBindingAudit/ReportWriter.cs b/scripts/FirebaseBindingAudit/ReportWriter.cs new file mode 100644 index 00000000..605c2630 --- /dev/null +++ b/scripts/FirebaseBindingAudit/ReportWriter.cs @@ -0,0 +1,364 @@ +using System.Text; +using System.Text.Json; + +namespace FirebaseBindingAudit; + +internal static class ReportWriter +{ + public static async Task WriteAsync( + string outputDirectory, + string detailsDirectory, + string generatorVersion, + IReadOnlyList results, + string tempDirectory, + SharpieToolResolution sharpieResolution, + SuppressionSummary suppressionSummary, + CancellationToken cancellationToken) + { + foreach (var result in results) + { + await WriteDetailReportAsync(detailsDirectory, result, cancellationToken); + } + + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "report.md"), + BuildAggregateMarkdown(generatorVersion, results, tempDirectory, sharpieResolution, suppressionSummary), + cancellationToken); + + var payload = new + { + generatedAtUtc = DateTime.UtcNow, + generatorVersion, + tempDirectory, + sharpie = new + { + status = sharpieResolution.Status, + expectedVersion = sharpieResolution.ExpectedVersion, + commandPath = sharpieResolution.CommandPath, + resolvedPath = sharpieResolution.ResolvedPath, + version = sharpieResolution.Version, + message = sharpieResolution.Message + }, + suppressions = new + { + enabled = suppressionSummary.Enabled, + ruleCount = suppressionSummary.RuleCount, + matchedRuleCount = suppressionSummary.MatchedRuleCount, + staleRuleCount = suppressionSummary.StaleRuleCount, + staleRules = suppressionSummary.StaleRules.Select(rule => new + { + id = rule.Id, + target = rule.Target, + category = rule.Category, + reason = rule.Reason + }), + invalidRuleCount = suppressionSummary.InvalidRuleCount, + invalidRules = suppressionSummary.InvalidRules.Select(rule => new + { + id = rule.Id, + target = rule.Target, + category = rule.Category, + message = rule.Message + }) + }, + results = results.Select(result => new + { + target = result.Target, + xcframework = result.Xcframework, + status = result.Status, + generationStatus = result.GenerationStatus, + comparisonSource = result.ComparisonSource, + failureCount = result.FailureCount, + infoCount = result.InfoCount, + suppressedCount = result.SuppressedCount, + sharpieStatus = result.SharpieStatus, + confidenceSummary = new + { + confirmed = result.ConfidenceSummary.Confirmed, + disputed = result.ConfidenceSummary.Disputed, + lowConfidence = result.ConfidenceSummary.LowConfidence, + notReviewed = result.ConfidenceSummary.NotReviewed + }, + findings = result.Findings.Select(finding => new + { + category = finding.Category, + severity = finding.Severity, + message = finding.Message, + typeName = finding.TypeName, + memberName = finding.MemberName, + selector = finding.Selector, + baselineFile = finding.BaselineFile, + generatedFile = finding.GeneratedFile, + comparisonTypeKey = finding.ComparisonTypeKey, + comparisonMemberKey = finding.ComparisonMemberKey, + confidence = finding.Confidence, + confidenceSource = finding.ConfidenceSource + }), + suppressedFindings = result.SuppressedFindings.Select(finding => new + { + category = finding.Category, + severity = finding.Severity, + message = finding.Message, + typeName = finding.TypeName, + memberName = finding.MemberName, + selector = finding.Selector, + baselineFile = finding.BaselineFile, + generatedFile = finding.GeneratedFile, + comparisonTypeKey = finding.ComparisonTypeKey, + comparisonMemberKey = finding.ComparisonMemberKey, + confidence = finding.Confidence, + confidenceSource = finding.ConfidenceSource, + suppressionId = finding.SuppressionId, + suppressionReason = finding.SuppressionReason, + suppressionEvidence = finding.SuppressionEvidence + }) + }) + }; + + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "report.json"), + JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }), + cancellationToken); + } + + private static async Task WriteDetailReportAsync(string detailsDirectory, TargetAuditResult result, CancellationToken cancellationToken) + { + var builder = new StringBuilder(); + builder.AppendLine($"# {result.Target}"); + builder.AppendLine(); + builder.AppendLine($"- Status: `{result.Status}`"); + builder.AppendLine($"- Generation status: `{result.GenerationStatus}`"); + builder.AppendLine($"- Comparison source: `{result.ComparisonSource}`"); + builder.AppendLine($"- Failures: `{result.FailureCount}`"); + builder.AppendLine($"- Infos: `{result.InfoCount}`"); + builder.AppendLine($"- Suppressed: `{result.SuppressedCount}`"); + builder.AppendLine($"- Sharpie status: `{result.SharpieStatus}`"); + builder.AppendLine($"- Confidence: `confirmed={result.ConfidenceSummary.Confirmed}`, `disputed={result.ConfidenceSummary.Disputed}`, `low-confidence={result.ConfidenceSummary.LowConfidence}`, `not-reviewed={result.ConfidenceSummary.NotReviewed}`"); + builder.AppendLine(); + + var failureFindings = result.Findings.Where(static finding => finding.Severity == "failure").ToList(); + var infoFindings = result.Findings.Where(static finding => finding.Severity == "info").ToList(); + var suppressedFindings = result.SuppressedFindings.ToList(); + + builder.AppendLine("## Failures"); + builder.AppendLine(); + if (failureFindings.Count == 0) + { + builder.AppendLine("None."); + builder.AppendLine(); + } + else + { + foreach (var finding in failureFindings) + { + AppendFinding(builder, finding); + } + } + + builder.AppendLine("## Suppressed Findings"); + builder.AppendLine(); + if (suppressedFindings.Count == 0) + { + builder.AppendLine("None."); + builder.AppendLine(); + } + else + { + foreach (var finding in suppressedFindings) + { + AppendFinding(builder, finding); + } + } + + builder.AppendLine("## Infos"); + builder.AppendLine(); + if (infoFindings.Count == 0) + { + builder.AppendLine("None."); + builder.AppendLine(); + } + else + { + foreach (var finding in infoFindings) + { + AppendFinding(builder, finding); + } + } + + await File.WriteAllTextAsync(Path.Combine(detailsDirectory, $"{result.Target}.md"), builder.ToString(), cancellationToken); + } + + private static string BuildAggregateMarkdown( + string generatorVersion, + IReadOnlyList results, + string tempDirectory, + SharpieToolResolution sharpieResolution, + SuppressionSummary suppressionSummary) + { + var builder = new StringBuilder(); + builder.AppendLine("# Firebase Binding Drift Audit"); + builder.AppendLine(); + builder.AppendLine($"- Generator version: `{generatorVersion}`"); + builder.AppendLine($"- Sharpie status: `{sharpieResolution.Status}`"); + builder.AppendLine($"- Sharpie expected version: `{sharpieResolution.ExpectedVersion}`"); + if (!string.IsNullOrWhiteSpace(sharpieResolution.Version)) + { + builder.AppendLine($"- Sharpie resolved version: `{sharpieResolution.Version}`"); + } + if (!string.IsNullOrWhiteSpace(sharpieResolution.ResolvedPath)) + { + builder.AppendLine($"- Sharpie resolved path: `{sharpieResolution.ResolvedPath}`"); + } + if (!string.IsNullOrWhiteSpace(sharpieResolution.Message)) + { + builder.AppendLine($"- Sharpie note: `{sharpieResolution.Message}`"); + } + builder.AppendLine($"- Suppressions enabled: `{suppressionSummary.Enabled}`"); + builder.AppendLine($"- Suppression rules: `{suppressionSummary.RuleCount}`"); + builder.AppendLine($"- Matched suppressions: `{suppressionSummary.MatchedRuleCount}`"); + builder.AppendLine($"- Stale suppressions: `{suppressionSummary.StaleRuleCount}`"); + builder.AppendLine($"- Invalid suppressions: `{suppressionSummary.InvalidRuleCount}`"); + builder.AppendLine($"- Generated at: `{DateTime.UtcNow:O}`"); + builder.AppendLine($"- Temp workspace: `{tempDirectory}`"); + builder.AppendLine(); + builder.AppendLine("| Target | Status | Generation | Compare | Sharpie | Confidence | Failures | Suppressed | Infos | Detail |"); + builder.AppendLine("| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | --- |"); + + foreach (var result in results.OrderBy(static result => result.Target, StringComparer.Ordinal)) + { + builder.AppendLine($"| {result.Target} | `{result.Status}` | `{result.GenerationStatus}` | `{result.ComparisonSource}` | `{result.SharpieStatus}` | `C:{result.ConfidenceSummary.Confirmed} D:{result.ConfidenceSummary.Disputed} LC:{result.ConfidenceSummary.LowConfidence} NR:{result.ConfidenceSummary.NotReviewed}` | {result.FailureCount} | {result.SuppressedCount} | {result.InfoCount} | `details/{result.Target}.md` |"); + } + + builder.AppendLine(); + + var totalFailures = results.Sum(static result => result.FailureCount); + var totalSuppressed = results.Sum(static result => result.SuppressedCount); + var totalInfos = results.Sum(static result => result.InfoCount); + builder.AppendLine($"- Total failures: `{totalFailures}`"); + builder.AppendLine($"- Total suppressed: `{totalSuppressed}`"); + builder.AppendLine($"- Total infos: `{totalInfos}`"); + + builder.AppendLine(); + builder.AppendLine("## Stale Suppressions"); + builder.AppendLine(); + if (suppressionSummary.StaleRules.Count == 0) + { + builder.AppendLine("None."); + } + else + { + foreach (var rule in suppressionSummary.StaleRules.OrderBy(static rule => rule.Id, StringComparer.Ordinal)) + { + builder.AppendLine($"- `{rule.Id}` target=`{rule.Target}` category=`{rule.Category}` reason=`{rule.Reason}`"); + } + } + + builder.AppendLine(); + builder.AppendLine("## Invalid Suppressions"); + builder.AppendLine(); + if (suppressionSummary.InvalidRules.Count == 0) + { + builder.AppendLine("None."); + } + else + { + foreach (var rule in suppressionSummary.InvalidRules.OrderBy(static rule => rule.Id, StringComparer.Ordinal)) + { + builder.AppendLine($"- `{rule.Id}` target=`{rule.Target}` category=`{rule.Category}` message=`{rule.Message}`"); + } + } + + return builder.ToString(); + } + + private static void AppendFinding(StringBuilder builder, AuditFinding finding) + { + builder.Append("- `"); + builder.Append(finding.Category); + builder.Append("` "); + builder.AppendLine(finding.Message); + + builder.Append(" - Type: `"); + builder.Append(finding.TypeName); + builder.AppendLine("`"); + + if (!string.IsNullOrWhiteSpace(finding.MemberName)) + { + builder.Append(" - Member: `"); + builder.Append(finding.MemberName); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.Selector)) + { + builder.Append(" - Selector: `"); + builder.Append(finding.Selector); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.BaselineFile)) + { + builder.Append(" - Baseline file: `"); + builder.Append(finding.BaselineFile); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.GeneratedFile)) + { + builder.Append(" - Generated file: `"); + builder.Append(finding.GeneratedFile); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.ComparisonTypeKey)) + { + builder.Append(" - Comparison type key: `"); + builder.Append(finding.ComparisonTypeKey); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.ComparisonMemberKey)) + { + builder.Append(" - Comparison member key: `"); + builder.Append(finding.ComparisonMemberKey); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.Confidence)) + { + builder.Append(" - Confidence: `"); + builder.Append(finding.Confidence); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.ConfidenceSource)) + { + builder.Append(" - Confidence source: `"); + builder.Append(finding.ConfidenceSource); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.SuppressionId)) + { + builder.Append(" - Suppression id: `"); + builder.Append(finding.SuppressionId); + builder.AppendLine("`"); + } + + if (!string.IsNullOrWhiteSpace(finding.SuppressionReason)) + { + builder.Append(" - Suppression reason: `"); + builder.Append(finding.SuppressionReason); + builder.AppendLine("`"); + } + + if (finding.SuppressionEvidence is { Count: > 0 }) + { + builder.Append(" - Suppression evidence: `"); + builder.Append(string.Join("`, `", finding.SuppressionEvidence)); + builder.AppendLine("`"); + } + + builder.AppendLine(); + } +} diff --git a/scripts/FirebaseBindingAudit/Suppressions.cs b/scripts/FirebaseBindingAudit/Suppressions.cs new file mode 100644 index 00000000..18cf4dd9 --- /dev/null +++ b/scripts/FirebaseBindingAudit/Suppressions.cs @@ -0,0 +1,327 @@ +using System.Text.Json; + +namespace FirebaseBindingAudit; + +internal sealed class SuppressionConfiguration +{ + public List Suppressions { get; set; } = []; +} + +internal sealed class SuppressionRule +{ + public string Id { get; set; } = string.Empty; + + public string Target { get; set; } = string.Empty; + + public string Category { get; set; } = string.Empty; + + public string? TypeName { get; set; } + + public string? MemberName { get; set; } + + public string? Selector { get; set; } + + public string? ComparisonTypeKey { get; set; } + + public string? ComparisonMemberKey { get; set; } + + public string Reason { get; set; } = string.Empty; + + public string[] Evidence { get; set; } = []; +} + +internal sealed record StaleSuppressionRule( + string Id, + string Target, + string Category, + string Reason); + +internal sealed record InvalidSuppressionRule( + string Id, + string Target, + string Category, + string Message); + +internal sealed record SuppressionSummary( + bool Enabled, + int RuleCount, + int MatchedRuleCount, + int StaleRuleCount, + IReadOnlyList StaleRules, + int InvalidRuleCount, + IReadOnlyList InvalidRules); + +internal sealed record SuppressionApplicationResult( + IReadOnlyList Results, + SuppressionSummary Summary); + +internal static class SuppressionLoader +{ + private static readonly HashSet SupportedCategories = new(StringComparer.Ordinal) + { + "stale-baseline-binding", + "missing-baseline-binding", + "signature-drift", + "attribute-drift" + }; + + public static SuppressionConfiguration Load(string suppressionPath) + { + if (!File.Exists(suppressionPath)) + { + throw new FileNotFoundException("Suppression file not found.", suppressionPath); + } + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var config = JsonSerializer.Deserialize(File.ReadAllText(suppressionPath), options); + if (config is null) + { + throw new InvalidOperationException($"Unable to deserialize suppressions at '{suppressionPath}'."); + } + + var ids = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < config.Suppressions.Count; index++) + { + var rule = config.Suppressions[index]; + rule.Id = Normalize(rule.Id); + rule.Target = Normalize(rule.Target); + rule.Category = Normalize(rule.Category); + rule.TypeName = NormalizeNullable(rule.TypeName); + rule.MemberName = NormalizeNullable(rule.MemberName); + rule.Selector = NormalizeNullable(rule.Selector); + rule.ComparisonTypeKey = NormalizeNullable(rule.ComparisonTypeKey); + rule.ComparisonMemberKey = NormalizeNullable(rule.ComparisonMemberKey); + rule.Reason = Normalize(rule.Reason); + rule.Evidence = rule.Evidence + .Select(NormalizeNullable) + .Where(static evidence => !string.IsNullOrWhiteSpace(evidence)) + .Cast() + .ToArray(); + + if (string.IsNullOrWhiteSpace(rule.Id)) + { + throw new InvalidOperationException($"Suppression rule at index {index} is missing required field 'id'."); + } + + if (!ids.Add(rule.Id)) + { + throw new InvalidOperationException($"Duplicate suppression rule id '{rule.Id}'."); + } + + if (string.IsNullOrWhiteSpace(rule.Target)) + { + throw new InvalidOperationException($"Suppression rule '{rule.Id}' is missing required field 'target'."); + } + + if (string.IsNullOrWhiteSpace(rule.Category)) + { + throw new InvalidOperationException($"Suppression rule '{rule.Id}' is missing required field 'category'."); + } + + if (!SupportedCategories.Contains(rule.Category)) + { + throw new InvalidOperationException( + $"Suppression rule '{rule.Id}' uses unsupported category '{rule.Category}'."); + } + + if (string.IsNullOrWhiteSpace(rule.Reason)) + { + throw new InvalidOperationException($"Suppression rule '{rule.Id}' is missing required field 'reason'."); + } + + if (rule.Evidence.Length == 0) + { + throw new InvalidOperationException($"Suppression rule '{rule.Id}' must include at least one evidence entry."); + } + + if (string.IsNullOrWhiteSpace(rule.ComparisonTypeKey) && string.IsNullOrWhiteSpace(rule.TypeName)) + { + throw new InvalidOperationException( + $"Suppression rule '{rule.Id}' must include either 'comparisonTypeKey' or 'typeName'."); + } + + if (!string.IsNullOrWhiteSpace(rule.Selector) && + string.IsNullOrWhiteSpace(rule.ComparisonMemberKey) && + string.IsNullOrWhiteSpace(rule.MemberName)) + { + throw new InvalidOperationException( + $"Suppression rule '{rule.Id}' uses 'selector' and must also include 'comparisonMemberKey' or 'memberName'."); + } + } + + return config; + } + + private static string Normalize(string value) => value.Trim(); + + private static string? NormalizeNullable(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal static class SuppressionEngine +{ + private static readonly HashSet SupportedCategories = new(StringComparer.Ordinal) + { + "stale-baseline-binding", + "missing-baseline-binding", + "signature-drift", + "attribute-drift" + }; + + public static SuppressionApplicationResult Apply( + IReadOnlyList results, + SuppressionConfiguration suppressionConfiguration, + bool disableSuppressions) + { + if (disableSuppressions) + { + return new SuppressionApplicationResult( + Results: results, + Summary: new SuppressionSummary( + Enabled: false, + RuleCount: suppressionConfiguration.Suppressions.Count, + MatchedRuleCount: 0, + StaleRuleCount: 0, + StaleRules: [], + InvalidRuleCount: 0, + InvalidRules: [])); + } + + var candidates = results + .SelectMany(result => result.Findings.Select((finding, index) => new SuppressionCandidate(result.Target, index, finding))) + .Where(static candidate => SupportedCategories.Contains(candidate.Finding.Category)) + .ToList(); + + var staleRules = new List(); + var invalidRules = new List(); + var appliedRules = new HashSet(StringComparer.Ordinal); + var suppressionMap = new Dictionary<(string Target, int Index), SuppressionRule>(); + + foreach (var rule in suppressionConfiguration.Suppressions) + { + var matches = candidates + .Where(candidate => Matches(rule, candidate)) + .ToList(); + + if (matches.Count == 0) + { + staleRules.Add(new StaleSuppressionRule(rule.Id, rule.Target, rule.Category, rule.Reason)); + continue; + } + + if (matches.Count > 1) + { + invalidRules.Add(new InvalidSuppressionRule( + rule.Id, + rule.Target, + rule.Category, + $"Suppression rule '{rule.Id}' matched {matches.Count} findings and is too broad.")); + continue; + } + + var key = (matches[0].Target, matches[0].Index); + if (suppressionMap.TryGetValue(key, out var existingRule)) + { + invalidRules.Add(new InvalidSuppressionRule( + rule.Id, + rule.Target, + rule.Category, + $"Suppression rule '{rule.Id}' overlaps with existing suppression rule '{existingRule.Id}'.")); + continue; + } + + suppressionMap[key] = rule; + appliedRules.Add(rule.Id); + } + + var updatedResults = results + .Select(result => + { + var activeFindings = new List(result.Findings.Count); + var suppressedFindings = new List(); + + for (var index = 0; index < result.Findings.Count; index++) + { + var finding = result.Findings[index]; + if (suppressionMap.TryGetValue((result.Target, index), out var rule)) + { + suppressedFindings.Add(finding with + { + SuppressionId = rule.Id, + SuppressionReason = rule.Reason, + SuppressionEvidence = rule.Evidence + }); + } + else + { + activeFindings.Add(finding); + } + } + + return result with + { + Status = activeFindings.Any(static finding => finding.Severity == "failure") ? "failed" : "passed", + FailureCount = activeFindings.Count(static finding => finding.Severity == "failure"), + InfoCount = activeFindings.Count(static finding => finding.Severity == "info"), + ConfidenceSummary = AuditFindingSummary.BuildConfidenceSummary(activeFindings), + Findings = activeFindings, + SuppressedCount = suppressedFindings.Count, + SuppressedFindings = suppressedFindings + }; + }) + .ToList(); + + return new SuppressionApplicationResult( + Results: updatedResults, + Summary: new SuppressionSummary( + Enabled: true, + RuleCount: suppressionConfiguration.Suppressions.Count, + MatchedRuleCount: appliedRules.Count, + StaleRuleCount: staleRules.Count, + StaleRules: staleRules, + InvalidRuleCount: invalidRules.Count, + InvalidRules: invalidRules)); + } + + private static bool Matches(SuppressionRule rule, SuppressionCandidate candidate) + { + var finding = candidate.Finding; + + if (!string.Equals(rule.Target, candidate.Target, StringComparison.Ordinal)) + { + return false; + } + + if (!string.Equals(rule.Category, finding.Category, StringComparison.Ordinal)) + { + return false; + } + + return MatchesOptional(rule.TypeName, finding.TypeName) && + MatchesOptional(rule.MemberName, finding.MemberName) && + MatchesOptional(rule.Selector, finding.Selector) && + MatchesOptional(rule.ComparisonTypeKey, finding.ComparisonTypeKey) && + MatchesOptional(rule.ComparisonMemberKey, finding.ComparisonMemberKey); + } + + private static bool MatchesOptional(string? expected, string? actual) => + string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal); + + private sealed record SuppressionCandidate(string Target, int Index, AuditFinding Finding); +} + +internal static class AuditFindingSummary +{ + public static ConfidenceSummary BuildConfidenceSummary(IReadOnlyCollection findings) + { + return new ConfidenceSummary( + Confirmed: findings.Count(static finding => string.Equals(finding.Confidence, "confirmed", StringComparison.Ordinal)), + Disputed: findings.Count(static finding => string.Equals(finding.Confidence, "disputed", StringComparison.Ordinal)), + LowConfidence: findings.Count(static finding => string.Equals(finding.Confidence, "low-confidence", StringComparison.Ordinal)), + NotReviewed: findings.Count(static finding => string.Equals(finding.Confidence, "not-reviewed", StringComparison.Ordinal))); + } +} diff --git a/scripts/bootstrap-sharpie.sh b/scripts/bootstrap-sharpie.sh new file mode 100755 index 00000000..cb870ff1 --- /dev/null +++ b/scripts/bootstrap-sharpie.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +expected_version="26.3.0.11" +tool_command="${HOME}/.dotnet/tools/sharpie" +wrapper_dir="${HOME}/.local/bin" +wrapper_path="${wrapper_dir}/sharpie" + +if dotnet tool update -g Sharpie.Bind.Tool --version "${expected_version}" >/dev/null 2>&1; then + : +else + dotnet tool install -g Sharpie.Bind.Tool --version "${expected_version}" +fi + +mkdir -p "${wrapper_dir}" + +cat > "${wrapper_path}" <<'EOF' +#!/usr/bin/env bash +exec "${HOME}/.dotnet/tools/sharpie" "$@" +EOF + +chmod +x "${wrapper_path}" + +resolved_path="$(command -v sharpie || true)" +resolved_version="$(sharpie --version 2>&1 || true)" + +if [[ "${resolved_path}" != "${wrapper_path}" ]]; then + echo "Expected sharpie to resolve to ${wrapper_path}, but found ${resolved_path:-}." >&2 + exit 1 +fi + +if [[ "${resolved_version}" != *"dotnet-sharpie ${expected_version}"* ]]; then + echo "Expected sharpie --version to contain 'dotnet-sharpie ${expected_version}', but got: ${resolved_version:-}." >&2 + exit 1 +fi + +printf 'sharpie path: %s\n' "${resolved_path}" +printf 'sharpie version: %s\n' "${resolved_version}" diff --git a/scripts/compare-firebase-bindings.sh b/scripts/compare-firebase-bindings.sh new file mode 100755 index 00000000..d94b9bbc --- /dev/null +++ b/scripts/compare-firebase-bindings.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" + +output_dir="${repo_root}/output/firebase-binding-audit" +generator_version="0.7.0" +sharpie_version="26.3.0.11" +sharpie_path="" +disable_sharpie="false" +disable_suppressions="false" +targets="" +keep_temp="false" + +usage() { + cat <&2; usage; exit 2; } + targets="$2" + shift 2 + ;; + --output-dir) + [[ $# -ge 2 ]] || { echo "Missing value for --output-dir" >&2; usage; exit 2; } + output_dir="$2" + shift 2 + ;; + --generator-version) + [[ $# -ge 2 ]] || { echo "Missing value for --generator-version" >&2; usage; exit 2; } + generator_version="$2" + shift 2 + ;; + --sharpie-version) + [[ $# -ge 2 ]] || { echo "Missing value for --sharpie-version" >&2; usage; exit 2; } + sharpie_version="$2" + shift 2 + ;; + --sharpie-path) + [[ $# -ge 2 ]] || { echo "Missing value for --sharpie-path" >&2; usage; exit 2; } + sharpie_path="$2" + shift 2 + ;; + --disable-sharpie) + disable_sharpie="true" + shift + ;; + --disable-suppressions) + disable_suppressions="true" + shift + ;; + --keep-temp) + keep_temp="true" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +mkdir -p "$output_dir" + +args=( + --repo-root "$repo_root" + --output-dir "$output_dir" + --generator-version "$generator_version" + --sharpie-version "$sharpie_version" +) + +if [[ -n "$targets" ]]; then + args+=(--targets "$targets") +fi + +if [[ -n "$sharpie_path" ]]; then + args+=(--sharpie-path "$sharpie_path") +fi + +if [[ "$disable_sharpie" == "true" ]]; then + args+=(--disable-sharpie) +fi + +if [[ "$disable_suppressions" == "true" ]]; then + args+=(--disable-suppressions) +fi + +if [[ "$keep_temp" == "true" ]]; then + args+=(--keep-temp) +fi + +dotnet run --project "${repo_root}/scripts/FirebaseBindingAudit/FirebaseBindingAudit.csproj" -- "${args[@]}" diff --git a/scripts/firebase-binding-audit-suppressions.json b/scripts/firebase-binding-audit-suppressions.json new file mode 100644 index 00000000..34aaf9c1 --- /dev/null +++ b/scripts/firebase-binding-audit-suppressions.json @@ -0,0 +1,236 @@ +{ + "suppressions": [ + { + "id": "cloudmessaging-apnstoken-nullability-false-positive", + "target": "CloudMessaging", + "category": "attribute-drift", + "typeName": "Firebase.CloudMessaging.Messaging", + "memberName": "ApnsToken", + "selector": "APNSToken", + "reason": "The Firebase header declares APNSToken nullable, but generator output currently emits it as non-null.", + "evidence": [ + "externals/FirebaseMessaging.xcframework/ios-arm64/FirebaseMessaging.framework/Headers/FIRMessaging.h#L206" + ] + }, + { + "id": "cloudmessaging-fcmtoken-nullability-false-positive", + "target": "CloudMessaging", + "category": "attribute-drift", + "typeName": "Firebase.CloudMessaging.Messaging", + "memberName": "FcmToken", + "selector": "FCMToken", + "reason": "The Firebase header declares FCMToken nullable, but generator output currently emits it as non-null.", + "evidence": [ + "externals/FirebaseMessaging.xcframework/ios-arm64/FirebaseMessaging.framework/Headers/FIRMessaging.h#L254" + ] + }, + { + "id": "core-options-apikey-nullability-false-positive", + "target": "Core", + "category": "attribute-drift", + "typeName": "Firebase.Core.Options", + "memberName": "ApiKey", + "selector": "APIKey", + "reason": "The Firebase header declares APIKey nullable, but generator output currently emits it as non-null.", + "evidence": [ + "externals/FirebaseCore.xcframework/ios-arm64/FirebaseCore.framework/Headers/FIROptions.h#L38" + ] + }, + { + "id": "remoteconfig-jsonvalue-nullability-false-positive", + "target": "RemoteConfig", + "category": "attribute-drift", + "typeName": "Firebase.RemoteConfig.RemoteConfigValue", + "memberName": "JsonValue", + "selector": "JSONValue", + "reason": "The Firebase header declares JSONValue nullable, but generator output currently emits it as non-null.", + "evidence": [ + "externals/FirebaseRemoteConfig.xcframework/ios-arm64/FirebaseRemoteConfig.framework/Headers/FIRRemoteConfig.h#L164" + ] + }, + { + "id": "cloudfunctions-functions-swift-header-generator-empty", + "target": "CloudFunctions", + "category": "stale-baseline-binding", + "typeName": "Firebase.CloudFunctions.CloudFunctions", + "comparisonTypeKey": "FIRFunctions", + "reason": "The CloudFunctions audit generator produced zero comparable types for this Swift-generated framework, but the FirebaseFunctions Swift header still declares FIRFunctions.", + "evidence": [ + "externals/FirebaseFunctions.xcframework/ios-arm64_x86_64-simulator/FirebaseFunctions.framework/Headers/FirebaseFunctions-Swift.h#L321" + ] + }, + { + "id": "cloudfunctions-httpscallable-swift-header-generator-empty", + "target": "CloudFunctions", + "category": "stale-baseline-binding", + "typeName": "Firebase.CloudFunctions.HttpsCallable", + "comparisonTypeKey": "FIRHTTPSCallable", + "reason": "The CloudFunctions audit generator produced zero comparable types for this Swift-generated framework, but the FirebaseFunctions Swift header still declares FIRHTTPSCallable.", + "evidence": [ + "externals/FirebaseFunctions.xcframework/ios-arm64_x86_64-simulator/FirebaseFunctions.framework/Headers/FirebaseFunctions-Swift.h#L466" + ] + }, + { + "id": "cloudfunctions-httpscallableresult-swift-header-generator-empty", + "target": "CloudFunctions", + "category": "stale-baseline-binding", + "typeName": "Firebase.CloudFunctions.HttpsCallableResult", + "comparisonTypeKey": "FIRHTTPSCallableResult", + "reason": "The CloudFunctions audit generator produced zero comparable types for this Swift-generated framework, but the FirebaseFunctions Swift header still declares FIRHTTPSCallableResult.", + "evidence": [ + "externals/FirebaseFunctions.xcframework/ios-arm64_x86_64-simulator/FirebaseFunctions.framework/Headers/FirebaseFunctions-Swift.h#L532" + ] + }, + { + "id": "cloudfunctions-httpscallableresulthandler-swift-header-generator-empty", + "target": "CloudFunctions", + "category": "stale-baseline-binding", + "typeName": "Firebase.CloudFunctions.HttpsCallableResultHandler", + "comparisonTypeKey": "HttpsCallableResultHandler", + "reason": "The CloudFunctions audit generator produced zero comparable types for this Swift-generated framework, but the FirebaseFunctions Swift header still declares completion blocks using FIRHTTPSCallableResult and NSError.", + "evidence": [ + "externals/FirebaseFunctions.xcframework/ios-arm64_x86_64-simulator/FirebaseFunctions.framework/Headers/FirebaseFunctions-Swift.h#L499", + "externals/FirebaseFunctions.xcframework/ios-arm64_x86_64-simulator/FirebaseFunctions.framework/Headers/FirebaseFunctions-Swift.h#L511" + ] + }, + { + "id": "cloudfunctions-errorcode-swift-header-generator-empty", + "target": "CloudFunctions", + "category": "stale-baseline-binding", + "typeName": "Firebase.CloudFunctions.CloudFunctionsErrorCode", + "comparisonTypeKey": "CloudFunctionsErrorCode", + "reason": "The CloudFunctions audit generator produced zero comparable types for this Swift-generated framework, but the FirebaseFunctions Swift header still declares FIRFunctionsErrorCode.", + "evidence": [ + "externals/FirebaseFunctions.xcframework/ios-arm64_x86_64-simulator/FirebaseFunctions.framework/Headers/FirebaseFunctions-Swift.h#L417" + ] + }, + { + "id": "storage-downloadtask-base-generator-false-positive", + "target": "Storage", + "category": "attribute-drift", + "typeName": "Firebase.Storage.StorageDownloadTask", + "comparisonTypeKey": "FIRStorageDownloadTask", + "reason": "The fresh generator flattened FIRStorageDownloadTask to NSObject, but the FirebaseStorage Swift header declares FIRStorageDownloadTask inheriting FIRStorageObservableTask.", + "evidence": [ + "externals/FirebaseStorage.xcframework/ios-arm64_x86_64-simulator/FirebaseStorage.framework/Headers/FirebaseStorage-Swift.h#L485" + ] + }, + { + "id": "storage-uploadtask-base-generator-false-positive", + "target": "Storage", + "category": "attribute-drift", + "typeName": "Firebase.Storage.StorageUploadTask", + "comparisonTypeKey": "FIRStorageUploadTask", + "reason": "The fresh generator flattened FIRStorageUploadTask to NSObject, but the FirebaseStorage Swift header declares FIRStorageUploadTask inheriting FIRStorageObservableTask.", + "evidence": [ + "externals/FirebaseStorage.xcframework/ios-arm64_x86_64-simulator/FirebaseStorage.framework/Headers/FirebaseStorage-Swift.h#L861" + ] + }, + { + "id": "appcheck-debugprovider-gettoken-inherited-protocol-false-positive", + "target": "AppCheck", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRAppCheckDebugProvider", + "comparisonMemberKey": "export|getTokenWithCompletion:", + "reason": "FIRAppCheckDebugProvider conforms to FIRAppCheckProvider, and the baseline exposes getTokenWithCompletion: through the inherited AppCheckProvider protocol binding.", + "evidence": [ + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckDebugProvider.h#L64", + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckProvider.h#L31", + "source/Firebase/AppCheck/ApiDefinition.cs#L64", + "source/Firebase/AppCheck/ApiDefinition.cs#L103" + ] + }, + { + "id": "appcheck-debugprovider-getlimitedusetoken-inherited-protocol-false-positive", + "target": "AppCheck", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRAppCheckDebugProvider", + "comparisonMemberKey": "export|getLimitedUseTokenWithCompletion:", + "reason": "FIRAppCheckDebugProvider conforms to FIRAppCheckProvider, and the baseline exposes getLimitedUseTokenWithCompletion: through the inherited AppCheckProvider protocol binding.", + "evidence": [ + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckDebugProvider.h#L64", + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckProvider.h#L43", + "source/Firebase/AppCheck/ApiDefinition.cs#L69", + "source/Firebase/AppCheck/ApiDefinition.cs#L103" + ] + }, + { + "id": "appcheck-devicecheckprovider-gettoken-inherited-protocol-false-positive", + "target": "AppCheck", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRDeviceCheckProvider", + "comparisonMemberKey": "export|getTokenWithCompletion:", + "reason": "FIRDeviceCheckProvider conforms to FIRAppCheckProvider, and the baseline exposes getTokenWithCompletion: through the inherited AppCheckProvider protocol binding.", + "evidence": [ + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRDeviceCheckProvider.h#L34", + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckProvider.h#L31", + "source/Firebase/AppCheck/ApiDefinition.cs#L64", + "source/Firebase/AppCheck/ApiDefinition.cs#L127" + ] + }, + { + "id": "appcheck-devicecheckprovider-getlimitedusetoken-inherited-protocol-false-positive", + "target": "AppCheck", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRDeviceCheckProvider", + "comparisonMemberKey": "export|getLimitedUseTokenWithCompletion:", + "reason": "FIRDeviceCheckProvider conforms to FIRAppCheckProvider, and the baseline exposes getLimitedUseTokenWithCompletion: through the inherited AppCheckProvider protocol binding.", + "evidence": [ + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRDeviceCheckProvider.h#L34", + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckProvider.h#L43", + "source/Firebase/AppCheck/ApiDefinition.cs#L69", + "source/Firebase/AppCheck/ApiDefinition.cs#L127" + ] + }, + { + "id": "appcheck-appattestprovider-gettoken-inherited-protocol-false-positive", + "target": "AppCheck", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRAppAttestProvider", + "comparisonMemberKey": "export|getTokenWithCompletion:", + "reason": "FIRAppAttestProvider conforms to FIRAppCheckProvider, and the baseline exposes getTokenWithCompletion: through the inherited AppCheckProvider protocol binding.", + "evidence": [ + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppAttestProvider.h#L33", + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckProvider.h#L31", + "source/Firebase/AppCheck/ApiDefinition.cs#L64", + "source/Firebase/AppCheck/ApiDefinition.cs#L138" + ] + }, + { + "id": "appcheck-appattestprovider-getlimitedusetoken-inherited-protocol-false-positive", + "target": "AppCheck", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRAppAttestProvider", + "comparisonMemberKey": "export|getLimitedUseTokenWithCompletion:", + "reason": "FIRAppAttestProvider conforms to FIRAppCheckProvider, and the baseline exposes getLimitedUseTokenWithCompletion: through the inherited AppCheckProvider protocol binding.", + "evidence": [ + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppAttestProvider.h#L33", + "externals/FirebaseAppCheck.xcframework/ios-arm64_x86_64-simulator/FirebaseAppCheck.framework/Headers/FIRAppCheckProvider.h#L43", + "source/Firebase/AppCheck/ApiDefinition.cs#L69", + "source/Firebase/AppCheck/ApiDefinition.cs#L138" + ] + }, + { + "id": "crashlytics-logwithformatarguments-va-list-intentionally-unbound", + "target": "Crashlytics", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRCrashlytics", + "comparisonMemberKey": "export|logWithFormat:arguments:", + "reason": "The native selector takes a va_list argument. That is not a safe or useful thin C# binding surface, and Crashlytics already exposes the supported log: API.", + "evidence": [ + "externals/FirebaseCrashlytics.xcframework/ios-arm64_x86_64-simulator/FirebaseCrashlytics.framework/Headers/FIRCrashlytics.h#L74-L76" + ] + }, + { + "id": "crashlytics-report-logwithformatarguments-va-list-intentionally-unbound", + "target": "Crashlytics", + "category": "missing-baseline-binding", + "comparisonTypeKey": "FIRCrashlyticsReport", + "comparisonMemberKey": "export|logWithFormat:arguments:", + "reason": "The native selector takes a va_list argument. That is not a safe or useful thin C# binding surface, and CrashlyticsReport already exposes the supported log: API.", + "evidence": [ + "externals/FirebaseCrashlytics.xcframework/ios-arm64_x86_64-simulator/FirebaseCrashlytics.framework/Headers/FIRCrashlyticsReport.h#L71-L73" + ] + } + ] +} diff --git a/scripts/firebase-binding-audit.json b/scripts/firebase-binding-audit.json new file mode 100644 index 00000000..f3678fb2 --- /dev/null +++ b/scripts/firebase-binding-audit.json @@ -0,0 +1,208 @@ +{ + "manualAttributes": [ + "Wrap", + "Advice", + "Async", + "Internal" + ], + "bindingAttributes": [ + "Export", + "Field", + "Notification" + ], + "sharpie": { + "expectedVersion": "26.3.0.11", + "defaultMode": "auto" + }, + "targets": [ + { + "id": "ABTesting", + "packageId": "Firebase.ABTesting", + "xcframework": "FirebaseABTesting", + "baselineDirectory": "source/Firebase/ABTesting", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [], + "sharpieMode": "auto" + }, + { + "id": "Analytics", + "packageId": "Firebase.Analytics", + "xcframework": "FirebaseAnalytics", + "baselineDirectory": "source/Firebase/Analytics", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "AppCheck", + "packageId": "Firebase.AppCheck", + "xcframework": "FirebaseAppCheck", + "baselineDirectory": "source/Firebase/AppCheck", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [], + "sharpieMode": "auto" + }, + { + "id": "Auth", + "packageId": "Firebase.Auth", + "xcframework": "FirebaseAuth", + "baselineDirectory": "source/Firebase/Auth", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "CloudFirestore", + "packageId": "Firebase.CloudFirestore", + "xcframework": "FirebaseFirestore", + "baselineDirectory": "source/Firebase/CloudFirestore", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extensions.cs" + ], + "sharpieMode": "forceFallback", + "useSharpieComparisonFallback": true + }, + { + "id": "CloudFunctions", + "packageId": "Firebase.CloudFunctions", + "xcframework": "FirebaseFunctions", + "baselineDirectory": "source/Firebase/CloudFunctions", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "CloudMessaging", + "packageId": "Firebase.CloudMessaging", + "xcframework": "FirebaseMessaging", + "baselineDirectory": "source/Firebase/CloudMessaging", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [], + "sharpieMode": "auto" + }, + { + "id": "Core", + "packageId": "Firebase.Core", + "xcframework": "FirebaseCore", + "baselineDirectory": "source/Firebase/Core", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "Crashlytics", + "packageId": "Firebase.Crashlytics", + "xcframework": "FirebaseCrashlytics", + "baselineDirectory": "source/Firebase/Crashlytics", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extensions.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "Database", + "packageId": "Firebase.Database", + "xcframework": "FirebaseDatabase", + "baselineDirectory": "source/Firebase/Database", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "Installations", + "packageId": "Firebase.Installations", + "xcframework": "FirebaseInstallations", + "baselineDirectory": "source/Firebase/Installations", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [], + "sharpieMode": "auto" + }, + { + "id": "PerformanceMonitoring", + "packageId": "Firebase.PerformanceMonitoring", + "xcframework": "FirebasePerformance", + "baselineDirectory": "source/Firebase/PerformanceMonitoring", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extensions.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "RemoteConfig", + "packageId": "Firebase.RemoteConfig", + "xcframework": "FirebaseRemoteConfig", + "baselineDirectory": "source/Firebase/RemoteConfig", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + }, + { + "id": "Storage", + "packageId": "Firebase.Storage", + "xcframework": "FirebaseStorage", + "baselineDirectory": "source/Firebase/Storage", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [ + "Extension.cs" + ], + "sharpieMode": "auto" + } + ] +} From b41f0322846458d3ff6be2e3c76bbf0031a2146d Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 15:51:34 -0400 Subject: [PATCH 02/23] Fix Sharpie companion import resolution --- .../AuditRunnerTests.cs | 45 +++++++++++++++++++ scripts/FirebaseBindingAudit/AuditRunner.cs | 12 +++-- 2 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs diff --git a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs new file mode 100644 index 00000000..6033b06d --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs @@ -0,0 +1,45 @@ +using Xunit; + +namespace FirebaseBindingAudit.Tests; + +public sealed class AuditRunnerTests +{ + [Fact] + public void TryResolveSharpieCompanionFrameworkPath_UsesLaterExistingImport() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-audit-companion-{Guid.NewGuid():N}"); + + try + { + var stagedFrameworksDirectory = Path.Combine(tempRoot, "frameworks"); + var primaryFrameworkPath = Path.Combine(stagedFrameworksDirectory, "FirebaseCore.xcframework", "ios-arm64", "FirebaseCore.framework"); + var primaryHeadersDirectory = Path.Combine(primaryFrameworkPath, "Headers"); + var companionFrameworkPath = Path.Combine(stagedFrameworksDirectory, "FirebaseSessions.xcframework", "ios-arm64", "FirebaseSessions.framework"); + + Directory.CreateDirectory(primaryHeadersDirectory); + Directory.CreateDirectory(companionFrameworkPath); + File.WriteAllText( + Path.Combine(primaryHeadersDirectory, "FirebaseCore.h"), + """ + #import + #import + #import + """); + + var resolved = AuditRunner.TryResolveSharpieCompanionFrameworkPath( + primaryFrameworkPath, + stagedFrameworksDirectory, + out var resolvedCompanionFrameworkPath); + + Assert.True(resolved); + Assert.Equal(companionFrameworkPath, resolvedCompanionFrameworkPath); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } +} diff --git a/scripts/FirebaseBindingAudit/AuditRunner.cs b/scripts/FirebaseBindingAudit/AuditRunner.cs index f8669abb..436e027d 100644 --- a/scripts/FirebaseBindingAudit/AuditRunner.cs +++ b/scripts/FirebaseBindingAudit/AuditRunner.cs @@ -741,18 +741,16 @@ private async Task RunSharpieAsync( return (processResult, BuildProcessWarningFindings(processResult, target, "sharpie bind", "sharpie-warning")); } - private static bool TryResolveSharpieCompanionFrameworkPath(string frameworkPath, string stagedFrameworksDirectory, out string companionFrameworkPath) + internal static bool TryResolveSharpieCompanionFrameworkPath(string frameworkPath, string stagedFrameworksDirectory, out string companionFrameworkPath) { var frameworkName = Path.GetFileNameWithoutExtension(frameworkPath); var rootHeaderPath = Path.Combine(frameworkPath, "Headers", $"{frameworkName}.h"); if (File.Exists(rootHeaderPath)) { - var importMatch = Regex.Match( - File.ReadAllText(rootHeaderPath), - "#import\\s+<([^/]+)/[^>]+>", - RegexOptions.CultureInvariant); - - if (importMatch.Success) + foreach (Match importMatch in Regex.Matches( + File.ReadAllText(rootHeaderPath), + "#import\\s+<([^/]+)/[^>]+>", + RegexOptions.CultureInvariant)) { var companionModuleName = importMatch.Groups[1].Value; if (!string.Equals(companionModuleName, frameworkName, StringComparison.Ordinal)) From 27098e15556cb8a445d12c116d2082e6cadb3386 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 16:09:43 -0400 Subject: [PATCH 03/23] Address audit review fallback handling --- .../AuditRunnerTests.cs | 56 +++++++++++++++++++ .../BindingComparerNormalizationTests.cs | 35 ++++++++++++ scripts/FirebaseBindingAudit/AuditRunner.cs | 14 ++++- scripts/FirebaseBindingAudit/BindingModel.cs | 31 ++++------ 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs index 6033b06d..f03d1b6b 100644 --- a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs @@ -4,6 +4,62 @@ namespace FirebaseBindingAudit.Tests; public sealed class AuditRunnerTests { + [Fact] + public void ShouldUseSharpieComparisonFallback_UsesForceFallbackWhenPrimaryComparisonSucceeded() + { + var target = new AuditTargetDefinition + { + UseSharpieComparisonFallback = true + }; + + var sharpieRun = new SharpieRunResult( + Status: "succeeded", + FrameworkPath: null, + ProcessResult: null, + Comparison: new TargetComparisonResult([], []), + Snapshot: null, + GeneratedFiles: [], + WarningFindings: [], + Message: null); + + var shouldFallback = AuditRunner.ShouldUseSharpieComparisonFallback( + target, + sharpieMode: "forceFallback", + primaryFailed: false, + primaryComparableSurfaceEmpty: false, + sharpieRun); + + Assert.True(shouldFallback); + } + + [Fact] + public void ShouldUseSharpieComparisonFallback_DoesNotUseAutoWhenPrimaryComparisonSucceeded() + { + var target = new AuditTargetDefinition + { + UseSharpieComparisonFallback = true + }; + + var sharpieRun = new SharpieRunResult( + Status: "succeeded", + FrameworkPath: null, + ProcessResult: null, + Comparison: new TargetComparisonResult([], []), + Snapshot: null, + GeneratedFiles: [], + WarningFindings: [], + Message: null); + + var shouldFallback = AuditRunner.ShouldUseSharpieComparisonFallback( + target, + sharpieMode: "auto", + primaryFailed: false, + primaryComparableSurfaceEmpty: false, + sharpieRun); + + Assert.False(shouldFallback); + } + [Fact] public void TryResolveSharpieCompanionFrameworkPath_UsesLaterExistingImport() { diff --git a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs index d624e8fb..1ee22913 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs @@ -316,6 +316,41 @@ public interface FIRDatabaseReference Assert.Contains(result.Failures, static failure => failure.Category == "signature-drift"); } + [Fact] + public void Parse_HonorsConfiguredBindingAttributes() + { + var baselineFile = Path.Combine(Path.GetTempPath(), $"firebase-binding-audit-baseline-{Guid.NewGuid():N}.cs"); + + try + { + File.WriteAllText( + baselineFile, + """ + [BaseType(typeof(NSObject), Name = "FIRThing")] + public interface Thing + { + [BindMe("doThing:")] + void DoThing(string value); + } + """); + + var parser = new BindingSyntaxParser(ManualAttributes, ["BindMe"]); + var snapshot = parser.Parse([baselineFile], []); + var type = Assert.Single(snapshot.BoundTypes.Values); + var member = Assert.Single(type.Members.Values); + + Assert.Equal("BindMe", member.BindingAttribute); + Assert.Equal("doThing:", member.BindingValue); + } + finally + { + if (File.Exists(baselineFile)) + { + File.Delete(baselineFile); + } + } + } + private static TargetComparisonResult Compare(string baselineSource, string generatedSource) { var baselineFile = Path.Combine(Path.GetTempPath(), $"firebase-binding-audit-baseline-{Guid.NewGuid():N}.cs"); diff --git a/scripts/FirebaseBindingAudit/AuditRunner.cs b/scripts/FirebaseBindingAudit/AuditRunner.cs index 436e027d..344ad5e7 100644 --- a/scripts/FirebaseBindingAudit/AuditRunner.cs +++ b/scripts/FirebaseBindingAudit/AuditRunner.cs @@ -322,7 +322,12 @@ private async Task AuditTargetAsync( } var effectiveFindings = new List(diagnosticFindings); - if (ShouldUseSharpieComparisonFallback(target, primaryFailed, primaryComparableSurfaceEmpty, sharpieRun)) + if (ShouldUseSharpieComparisonFallback( + target, + target.EffectiveSharpieMode(configuration.Sharpie), + primaryFailed, + primaryComparableSurfaceEmpty, + sharpieRun)) { comparisonSource = "sharpie-fallback"; generationStatus = GetFallbackGenerationStatus(generationStatus, primaryFailed, primaryComparableSurfaceEmpty); @@ -366,14 +371,17 @@ private bool ShouldRequestSharpie(AuditTargetDefinition target, AuditOptions opt return primaryFailed || primaryComparableSurfaceEmpty; } - private static bool ShouldUseSharpieComparisonFallback( + internal static bool ShouldUseSharpieComparisonFallback( AuditTargetDefinition target, + string sharpieMode, bool primaryFailed, bool primaryComparableSurfaceEmpty, SharpieRunResult? sharpieRun) { + var forceFallback = string.Equals(sharpieMode, "forceFallback", StringComparison.OrdinalIgnoreCase); + return target.UseSharpieComparisonFallback && - (primaryFailed || primaryComparableSurfaceEmpty) && + (forceFallback || primaryFailed || primaryComparableSurfaceEmpty) && sharpieRun is not null && string.Equals(sharpieRun.Status, "succeeded", StringComparison.Ordinal) && sharpieRun.Comparison is not null; diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index b4cf005c..f4f11536 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -85,10 +85,14 @@ internal sealed record ManualSurfaceItem( internal sealed class BindingSyntaxParser { private readonly HashSet manualAttributes; + private readonly List bindingAttributes; public BindingSyntaxParser(IEnumerable manualAttributes, IEnumerable bindingAttributes) { this.manualAttributes = new HashSet(manualAttributes, StringComparer.Ordinal); + this.bindingAttributes = bindingAttributes + .Distinct(StringComparer.Ordinal) + .ToList(); } public BindingSnapshot Parse(IEnumerable comparableFiles, IEnumerable helperFiles) @@ -511,21 +515,14 @@ private static string GetTypeBindingName(string typeName, SyntaxList attributeLists) + private string? GetPrimaryBindingAttribute(SyntaxList attributeLists) { - if (HasAttribute(attributeLists, "Export")) - { - return "Export"; - } - - if (HasAttribute(attributeLists, "Field")) - { - return "Field"; - } - - if (HasAttribute(attributeLists, "Notification")) + foreach (var bindingAttribute in bindingAttributes) { - return "Notification"; + if (HasAttribute(attributeLists, bindingAttribute)) + { + return bindingAttribute; + } } return null; @@ -533,13 +530,7 @@ private static string GetTypeBindingName(string typeName, SyntaxList attributeLists, string attributeName) { - return attributeName switch - { - "Export" => GetAttributeStringValue(attributeLists, "Export"), - "Field" => GetAttributeStringValue(attributeLists, "Field"), - "Notification" => GetAttributeStringValue(attributeLists, "Notification"), - _ => null - }; + return GetAttributeStringValue(attributeLists, attributeName); } private static BindingParameterSurface CreateParameter(ParameterSyntax parameter) From 62554350fc0e25eae7cc6ed25bc92f77d52d732d Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 17:38:42 -0400 Subject: [PATCH 04/23] Address audit review and AppDistribution normalization --- .../BindingComparerNormalizationTests.cs | 89 +++++++++++++++++++ scripts/FirebaseBindingAudit/BindingModel.cs | 74 ++++++++++++++- scripts/bootstrap-sharpie.sh | 2 + scripts/firebase-binding-audit.json | 12 +++ 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs index 1ee22913..2662c1ee 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs @@ -105,6 +105,95 @@ public enum FIRAggregateSource AssertNoFailures(result); } + [Fact] + public void Compare_MatchesNamespacePrefixedSwiftEnumName() + { + var result = Compare( + """ + namespace Firebase.AppDistribution; + + [Native] + public enum Error : long + { + Unknown = 0, + AuthenticationFailure = 1 + } + """, + """ + namespace FirebaseAppDistribution; + + [Native] + public enum FIRAppDistributionError : long + { + Unknown = 0, + AuthenticationFailure = 1 + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_MatchesGeneratedFieldConstantsInSeparateStaticContainer() + { + var result = Compare( + """ + namespace Firebase.AppDistribution; + + [BaseType(typeof(NSObject), Name = "FIRAppDistribution")] + public interface AppDistribution + { + [Field("FIRAppDistributionErrorDomain", "__Internal")] + NSString ErrorDomain { get; } + } + """, + """ + namespace FirebaseAppDistribution; + + [BaseType(typeof(NSObject))] + public interface FIRAppDistribution + { + } + + public static class FirebaseAppDistributionConstants + { + [Field("FIRAppDistributionErrorDomain", "__Internal")] + public static NSString FIRAppDistributionErrorDomain { get; } + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_IgnoresGeneratedEmptyPlatformTypePlaceholder() + { + var result = Compare( + """ + namespace Firebase.AppDistribution; + + [BaseType(typeof(NSObject), Name = "FIRAppDistribution")] + public interface AppDistribution + { + } + """, + """ + namespace FirebaseAppDistribution; + + [BaseType(typeof(NSObject))] + public interface FIRAppDistribution + { + } + + [BaseType(typeof(NSObject))] + public interface UIApplication + { + } + """); + + AssertNoFailures(result); + } + [Fact] public void Compare_MatchesNormalizedTypeReferencesInExportedMembers() { diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index f4f11536..9cf932b7 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -553,6 +553,7 @@ private static Dictionary BuildTypeAliases( AddExpandedAliases(aliases, boundType.DisplayName, boundType.ComparisonKey); AddExpandedAliases(aliases, boundType.Name, boundType.ComparisonKey); AddExpandedAliases(aliases, boundType.ObjectiveCName, boundType.ComparisonKey); + AddExpandedAliases(aliases, CreateNamespacePrefixedAlias(boundType.Namespace, boundType.Name), boundType.ComparisonKey); } foreach (var delegateSurface in delegates) @@ -560,6 +561,7 @@ private static Dictionary BuildTypeAliases( AddExpandedAliases(aliases, delegateSurface.ComparisonKey, delegateSurface.ComparisonKey); AddExpandedAliases(aliases, delegateSurface.DisplayName, delegateSurface.ComparisonKey); AddExpandedAliases(aliases, delegateSurface.Name, delegateSurface.ComparisonKey); + AddExpandedAliases(aliases, CreateNamespacePrefixedAlias(delegateSurface.Namespace, delegateSurface.Name), delegateSurface.ComparisonKey); } foreach (var enumSurface in enums) @@ -567,11 +569,27 @@ private static Dictionary BuildTypeAliases( AddExpandedAliases(aliases, enumSurface.ComparisonKey, enumSurface.ComparisonKey); AddExpandedAliases(aliases, enumSurface.DisplayName, enumSurface.ComparisonKey); AddExpandedAliases(aliases, enumSurface.Name, enumSurface.ComparisonKey); + AddExpandedAliases(aliases, CreateNamespacePrefixedAlias(enumSurface.Namespace, enumSurface.Name), enumSurface.ComparisonKey); } return aliases; } + private static string? CreateNamespacePrefixedAlias(string namespaceName, string typeName) + { + var namespaceTail = namespaceName + .Split('.', StringSplitOptions.RemoveEmptyEntries) + .LastOrDefault(); + + if (string.IsNullOrWhiteSpace(namespaceTail) || + typeName.StartsWith(namespaceTail, StringComparison.Ordinal)) + { + return null; + } + + return $"{namespaceTail}{typeName}"; + } + private static void AddExpandedAliases(IDictionary aliases, string? alias, string comparisonKey) { AddAlias(aliases, alias, comparisonKey); @@ -913,6 +931,7 @@ private static string Unquote(string text) internal sealed class SymbolAliasLookup { private readonly Dictionary> comparisonKeysByAlias = new(StringComparer.Ordinal); + private readonly Dictionary> lookupKeysByComparisonKey = new(StringComparer.Ordinal); public void AddAliases(IReadOnlyDictionary? aliases) { @@ -931,7 +950,13 @@ public void AddAliases(IReadOnlyDictionary? aliases) public IEnumerable GetEquivalentComparisonKeys(string comparisonKey) { var seen = new HashSet(StringComparer.Ordinal); - foreach (var lookupKey in GetLookupKeys(comparisonKey)) + var lookupKeys = new HashSet(GetLookupKeys(comparisonKey), StringComparer.Ordinal); + if (lookupKeysByComparisonKey.TryGetValue(comparisonKey, out var registeredLookupKeys)) + { + lookupKeys.UnionWith(registeredLookupKeys); + } + + foreach (var lookupKey in lookupKeys) { if (!comparisonKeysByAlias.TryGetValue(lookupKey, out var comparisonKeys)) { @@ -1041,6 +1066,14 @@ private void AddAlias(string alias, string comparisonKey) { comparisonKeys.Add(comparisonKey); } + + if (!lookupKeysByComparisonKey.TryGetValue(comparisonKey, out var lookupKeys)) + { + lookupKeys = new HashSet(StringComparer.Ordinal); + lookupKeysByComparisonKey[comparisonKey] = lookupKeys; + } + + lookupKeys.Add(lookupKey); } } @@ -1152,6 +1185,11 @@ private static bool TryStripInterfaceObjectiveCPrefix(string alias, out string r internal sealed class BindingComparer { + private static readonly HashSet ExternalPlaceholderTypeKeys = new(StringComparer.Ordinal) + { + "UIApplication" + }; + public TargetComparisonResult Compare( BindingSnapshot baseline, BindingSnapshot generated, @@ -1165,6 +1203,11 @@ public TargetComparisonResult Compare( .Where(static item => !string.IsNullOrWhiteSpace(item.MatchMemberKey)) .Select(static item => CreateManualLookupKey(item.MatchTypeKey, item.MatchMemberKey!)), StringComparer.Ordinal); + var generatedManualMemberLookup = new HashSet( + generated.ManualItems + .Where(static item => !string.IsNullOrWhiteSpace(item.MatchMemberKey)) + .Select(static item => item.MatchMemberKey!), + StringComparer.Ordinal); var smoothedDelegateKeys = new HashSet(StringComparer.Ordinal); @@ -1175,6 +1218,7 @@ public TargetComparisonResult Compare( generated.Delegates, aliases, baselineManualLookup, + generatedManualMemberLookup, failures, infos, smoothedDelegateKeys); @@ -1217,6 +1261,7 @@ private static void CompareBoundTypes( IReadOnlyDictionary generatedDelegates, SymbolAliasLookup aliases, IReadOnlySet baselineManualLookup, + IReadOnlySet generatedManualMemberLookup, List failures, List infos, ISet smoothedDelegateKeys) @@ -1246,6 +1291,7 @@ private static void CompareBoundTypes( generatedDelegates, aliases, baselineManualLookup, + generatedManualMemberLookup, failures, infos, smoothedDelegateKeys); @@ -1258,6 +1304,11 @@ private static void CompareBoundTypes( continue; } + if (IsExternalPlaceholderType(generatedEntry)) + { + continue; + } + failures.Add(new AuditFinding( Category: "missing-baseline-binding", Severity: "failure", @@ -1302,6 +1353,7 @@ private static void CompareMembers( IReadOnlyDictionary generatedDelegates, SymbolAliasLookup aliases, IReadOnlySet baselineManualLookup, + IReadOnlySet generatedManualMemberLookup, List failures, List infos, ISet smoothedDelegateKeys) @@ -1310,6 +1362,11 @@ private static void CompareMembers( { if (!TryGetComparableMember(generated.Members, baselineMemberEntry, out var generatedMember)) { + if (IsMatchedGeneratedManualBinding(baselineMemberEntry, generatedManualMemberLookup)) + { + continue; + } + failures.Add(new AuditFinding( Category: "stale-baseline-binding", Severity: "failure", @@ -1363,6 +1420,21 @@ private static void CompareMembers( } } + private static bool IsExternalPlaceholderType(BoundTypeSurface type) + { + return type.Members.Count == 0 && + !type.IsProtocol && + ExternalPlaceholderTypeKeys.Contains(type.ComparisonKey); + } + + private static bool IsMatchedGeneratedManualBinding( + BindingMemberSurface baselineMember, + IReadOnlySet generatedManualMemberLookup) + { + return string.Equals(baselineMember.BindingAttribute, "Field", StringComparison.Ordinal) && + generatedManualMemberLookup.Contains(baselineMember.Key); + } + private static bool TryGetComparableMember( IReadOnlyDictionary members, BindingMemberSurface requestedMember, diff --git a/scripts/bootstrap-sharpie.sh b/scripts/bootstrap-sharpie.sh index cb870ff1..3040f20c 100755 --- a/scripts/bootstrap-sharpie.sh +++ b/scripts/bootstrap-sharpie.sh @@ -22,6 +22,8 @@ EOF chmod +x "${wrapper_path}" +export PATH="${wrapper_dir}:${PATH:-}" + resolved_path="$(command -v sharpie || true)" resolved_version="$(sharpie --version 2>&1 || true)" diff --git a/scripts/firebase-binding-audit.json b/scripts/firebase-binding-audit.json index f3678fb2..c5ab2c07 100644 --- a/scripts/firebase-binding-audit.json +++ b/scripts/firebase-binding-audit.json @@ -53,6 +53,18 @@ "helperFiles": [], "sharpieMode": "auto" }, + { + "id": "AppDistribution", + "packageId": "Firebase.AppDistribution", + "xcframework": "FirebaseAppDistribution", + "baselineDirectory": "source/Firebase/AppDistribution", + "baselineFiles": [ + "ApiDefinition.cs", + "Enums.cs" + ], + "helperFiles": [], + "sharpieMode": "auto" + }, { "id": "Auth", "packageId": "Firebase.Auth", From 336850e06766b7f6f36df57bdb2b714b6b20397e Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 17:58:56 -0400 Subject: [PATCH 05/23] Address audit review comments --- .../AuditRunnerTests.cs | 19 ++++++++++ .../SuppressionEngineTests.cs | 37 +++++++++++++++++++ scripts/FirebaseBindingAudit/AuditRunner.cs | 8 +++- scripts/FirebaseBindingAudit/Suppressions.cs | 8 ++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs index f03d1b6b..f57facbe 100644 --- a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs @@ -60,6 +60,13 @@ public void ShouldUseSharpieComparisonFallback_DoesNotUseAutoWhenPrimaryComparis Assert.False(shouldFallback); } + [Fact] + public void ShouldStageSharpieFrameworkSlices_SkipsWhenSharpieIsDisabled() + { + Assert.False(AuditRunner.ShouldStageSharpieFrameworkSlices(CreateAuditOptions(disableSharpie: true))); + Assert.True(AuditRunner.ShouldStageSharpieFrameworkSlices(CreateAuditOptions(disableSharpie: false))); + } + [Fact] public void TryResolveSharpieCompanionFrameworkPath_UsesLaterExistingImport() { @@ -98,4 +105,16 @@ public void TryResolveSharpieCompanionFrameworkPath_UsesLaterExistingImport() } } } + + private static AuditOptions CreateAuditOptions(bool disableSharpie) => + new( + RepoRoot: string.Empty, + OutputDirectory: string.Empty, + GeneratorVersion: "0.7.0", + SelectedTargets: null, + KeepTemp: false, + SharpiePath: null, + SharpieVersion: "26.3.0.11", + DisableSharpie: disableSharpie, + DisableSuppressions: false); } diff --git a/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs b/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs index e888d9ce..a149219d 100644 --- a/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/SuppressionEngineTests.cs @@ -192,6 +192,43 @@ public void Apply_ReportsUnusedRuleAsStaleInfoOnly() Assert.Single(application.Results.Single().Findings); } + [Fact] + public void Apply_DoesNotReportRulesForUnauditedTargetsAsStale() + { + var result = CreateResult( + "Core", + CreateFailureFinding( + typeName: "Firebase.Core.App", + memberName: "DefaultInstance", + selector: "defaultApp")); + + var config = new SuppressionConfiguration + { + Suppressions = + [ + new SuppressionRule + { + Id = "rule-unselected", + Target = "RemoteConfig", + Category = "attribute-drift", + TypeName = "Firebase.RemoteConfig.RemoteConfigValue", + MemberName = "JsonValue", + Selector = "JSONValue", + Reason = "target was not part of this audit run", + Evidence = ["header"] + } + ] + }; + + var application = SuppressionEngine.Apply([result], config, disableSuppressions: false); + + Assert.Equal(1, application.Summary.RuleCount); + Assert.Equal(0, application.Summary.StaleRuleCount); + Assert.Equal(0, application.Summary.InvalidRuleCount); + Assert.Equal(0, application.Summary.MatchedRuleCount); + Assert.Single(application.Results.Single().Findings); + } + [Fact] public void Apply_DisableSuppressionsLeavesFindingsUnsuppressed() { diff --git a/scripts/FirebaseBindingAudit/AuditRunner.cs b/scripts/FirebaseBindingAudit/AuditRunner.cs index 344ad5e7..054a0e32 100644 --- a/scripts/FirebaseBindingAudit/AuditRunner.cs +++ b/scripts/FirebaseBindingAudit/AuditRunner.cs @@ -126,7 +126,10 @@ public async Task RunAsync(AuditOptions options, CancellationTok var stagedFrameworksDirectory = Path.Combine(tempRoot, "frameworks"); StageXcframeworks(options.RepoRoot, stagedFrameworksDirectory); var sharpieFrameworkSearchDirectory = Path.Combine(tempRoot, "sharpie-frameworks"); - StageSharpieFrameworkSlices(stagedFrameworksDirectory, sharpieFrameworkSearchDirectory); + if (ShouldStageSharpieFrameworkSlices(options)) + { + StageSharpieFrameworkSlices(stagedFrameworksDirectory, sharpieFrameworkSearchDirectory); + } var sharedAliases = BuildSharedAliases(options.RepoRoot); var results = new List(); @@ -387,6 +390,9 @@ sharpieRun is not null && sharpieRun.Comparison is not null; } + internal static bool ShouldStageSharpieFrameworkSlices(AuditOptions options) => + !options.DisableSharpie; + private static ConfidenceSummary BuildConfidenceSummary(IReadOnlyList findings) { return AuditFindingSummary.BuildConfidenceSummary(findings.ToList()); diff --git a/scripts/FirebaseBindingAudit/Suppressions.cs b/scripts/FirebaseBindingAudit/Suppressions.cs index 18cf4dd9..ec5ce6cb 100644 --- a/scripts/FirebaseBindingAudit/Suppressions.cs +++ b/scripts/FirebaseBindingAudit/Suppressions.cs @@ -200,9 +200,17 @@ public static SuppressionApplicationResult Apply( var invalidRules = new List(); var appliedRules = new HashSet(StringComparer.Ordinal); var suppressionMap = new Dictionary<(string Target, int Index), SuppressionRule>(); + var auditedTargets = results + .Select(static result => result.Target) + .ToHashSet(StringComparer.Ordinal); foreach (var rule in suppressionConfiguration.Suppressions) { + if (!auditedTargets.Contains(rule.Target)) + { + continue; + } + var matches = candidates .Where(candidate => Matches(rule, candidate)) .ToList(); From 91f2ee4bc6ba4da4c7ce9b56b15b36b6799caf20 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 13:41:27 -0400 Subject: [PATCH 06/23] Add Firebase binding surface coverage E2E lane --- .../BindingSurfaceCoverageValidatorTests.cs | 114 +++ scripts/FirebaseBindingAudit/BindingModel.cs | 31 +- .../BindingSurfaceCoverage.cs | 948 ++++++++++++++++++ scripts/FirebaseBindingAudit/Program.cs | 53 +- .../FirebaseFoundationE2E/E2EContracts.cs | 32 + ...irebaseBindingSurfaceCoverage.ABTesting.cs | 9 + ...irebaseBindingSurfaceCoverage.Analytics.cs | 9 + ...FirebaseBindingSurfaceCoverage.AppCheck.cs | 9 + .../FirebaseBindingSurfaceCoverage.Auth.cs | 9 + ...seBindingSurfaceCoverage.CloudFirestore.cs | 9 + ...seBindingSurfaceCoverage.CloudFunctions.cs | 9 + ...seBindingSurfaceCoverage.CloudMessaging.cs | 9 + .../FirebaseBindingSurfaceCoverage.Core.cs | 9 + ...ebaseBindingSurfaceCoverage.Crashlytics.cs | 9 + ...FirebaseBindingSurfaceCoverage.Database.cs | 9 + ...aseBindingSurfaceCoverage.Installations.cs | 9 + ...ngSurfaceCoverage.PerformanceMonitoring.cs | 9 + ...baseBindingSurfaceCoverage.RemoteConfig.cs | 9 + .../FirebaseBindingSurfaceCoverage.Storage.cs | 9 + .../FirebaseBindingSurfaceCoverage.cs | 596 +++++++++++ .../FirebaseFoundationE2E.csproj | 14 + .../FirebaseSelfTestRunner.cs | 10 + tests/E2E/Firebase.Foundation/README.md | 21 + .../binding-surface-coverage.json | 160 +++ tools/e2e/run-firebase-foundation.sh | 66 +- 25 files changed, 2158 insertions(+), 13 deletions(-) create mode 100644 scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs create mode 100644 scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.ABTesting.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Analytics.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppCheck.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Auth.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFirestore.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFunctions.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudMessaging.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Core.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Crashlytics.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Database.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Installations.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.PerformanceMonitoring.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.RemoteConfig.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Storage.cs create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs create mode 100644 tests/E2E/Firebase.Foundation/binding-surface-coverage.json diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs new file mode 100644 index 00000000..7a0cd3ae --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs @@ -0,0 +1,114 @@ +using Xunit; + +namespace FirebaseBindingAudit.Tests; + +public sealed class BindingSurfaceCoverageValidatorTests +{ + [Fact] + public void Validate_DetectsUnclaimedSurfaces() + { + var document = CreateDocument(CreateSurface("Core:type:FIRApp")); + + var result = BindingSurfaceCoverageValidator.Validate(document, []); + + Assert.Equal(["Core:type:FIRApp"], result.UnclaimedSurfaceIds); + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_DetectsStaleWaivers() + { + var document = CreateDocument( + CreateSurface("Core:type:FIRApp"), + waivers: [CreateWaiver("Core:type:Missing")]); + + var result = BindingSurfaceCoverageValidator.Validate(document); + + Assert.Equal(["Core:type:Missing"], result.StaleWaiverSurfaceIds); + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_DetectsStaleExerciserSurfaceIds() + { + var document = CreateDocument(CreateSurface("Core:type:FIRApp")); + + var result = BindingSurfaceCoverageValidator.Validate( + document, + [new BindingSurfaceExerciseRecord("Core", "Core:type:Missing")]); + + Assert.Equal(["Core:type:Missing"], result.StaleExerciseSurfaceIds); + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_AcceptsExactManifestCoverage() + { + var document = CreateDocument(CreateSurface("Core:type:FIRApp")); + + var result = BindingSurfaceCoverageValidator.Validate( + document, + [new BindingSurfaceExerciseRecord("Core", "Core:type:FIRApp")]); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_TreatsWaivedSurfacesAsReportedButNotFailed() + { + var document = CreateDocument( + CreateSurface("Core:type:FIRApp"), + waivers: [CreateWaiver("Core:type:FIRApp")]); + + var result = BindingSurfaceCoverageValidator.Validate(document, []); + + Assert.True(result.IsValid); + } + + private static BindingSurfaceCoverageDocument CreateDocument( + BindingSurfaceDescriptor surface, + IReadOnlyList? waivers = null) => + new( + [ + new BindingSurfaceCoverageTargetDocument( + "Core", + "AdamE.Firebase.iOS.Core", + "VerifyCoreBindingSurfaceAsync", + ["source/Firebase/Core/ApiDefinition.cs"], + [new BindingSurfacePackageReference { Id = "AdamE.Firebase.iOS.Core", Version = "12.6.0" }], + [surface]) + ], + waivers ?? []); + + private static BindingSurfaceDescriptor CreateSurface(string surfaceId) => + new( + Target: "Core", + SurfaceId: surfaceId, + Kind: "bound-type", + TypeName: "Firebase.Core.App", + RuntimeTypeName: "Firebase.Core.App", + AssemblyName: "Firebase.Core", + ObjectiveCName: "FIRApp", + ContainerKind: "interface", + IsProtocol: false, + IsStatic: false, + MemberName: null, + BindingAttribute: null, + BindingValue: null, + HasGetter: false, + HasSetter: false, + ParameterCount: 0, + NativeSelectors: [], + SourceFile: "source/Firebase/Core/ApiDefinition.cs", + Signature: "interface App"); + + private static BindingSurfaceWaiver CreateWaiver(string surfaceId) => + new() + { + Target = "Core", + SurfaceId = surfaceId, + Kind = "native-owned-callback-type", + Reason = "unit test", + Evidence = "unit test", + }; +} diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 9cf932b7..f5f01afd 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -80,7 +80,10 @@ internal sealed record ManualSurfaceItem( string? MatchMemberKey, string? MemberName, string Signature, - string SourceFile); + string SourceFile) +{ + public IReadOnlyList ManualAttributes { get; init; } = []; +} internal sealed class BindingSyntaxParser { @@ -316,7 +319,8 @@ private void ParseMethod( { var bindingAttribute = GetPrimaryBindingAttribute(methodDeclaration.AttributeLists); var bindingValue = bindingAttribute is null ? null : GetBindingValue(methodDeclaration.AttributeLists, bindingAttribute); - var isManual = isHelperFile || HasAnyAttribute(methodDeclaration.AttributeLists, manualAttributes); + var manualAttributeNames = GetMatchingManualAttributes(methodDeclaration.AttributeLists); + var isManual = isHelperFile || manualAttributeNames.Count > 0; var signature = BuildMethodSignature(methodDeclaration); if (isManual) @@ -328,7 +332,10 @@ private void ParseMethod( MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.Parameters.Count), MemberName: methodDeclaration.Identifier.Text, Signature: signature, - SourceFile: filePath)); + SourceFile: filePath) + { + ManualAttributes = manualAttributeNames + }); return; } @@ -373,7 +380,8 @@ private void ParseProperty( { var bindingAttribute = GetPrimaryBindingAttribute(propertyDeclaration.AttributeLists); var bindingValue = bindingAttribute is null ? null : GetBindingValue(propertyDeclaration.AttributeLists, bindingAttribute); - var isManual = isHelperFile || HasAnyAttribute(propertyDeclaration.AttributeLists, manualAttributes); + var manualAttributeNames = GetMatchingManualAttributes(propertyDeclaration.AttributeLists); + var isManual = isHelperFile || manualAttributeNames.Count > 0; var signature = BuildPropertySignature(propertyDeclaration); if (isManual) @@ -385,7 +393,10 @@ private void ParseProperty( MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0), MemberName: propertyDeclaration.Identifier.Text, Signature: signature, - SourceFile: filePath)); + SourceFile: filePath) + { + ManualAttributes = manualAttributeNames + }); return; } @@ -771,20 +782,22 @@ private static string CombineNamespace(string currentNamespace, string childName return string.IsNullOrWhiteSpace(currentNamespace) ? childNamespace : $"{currentNamespace}.{childNamespace}"; } - private bool HasAnyAttribute(SyntaxList attributeLists, HashSet attributeNames) + private IReadOnlyList GetMatchingManualAttributes(SyntaxList attributeLists) { + var matchingAttributes = new List(); foreach (var attributeList in attributeLists) { foreach (var attribute in attributeList.Attributes) { - if (attributeNames.Contains(NormalizeAttributeName(attribute.Name))) + var attributeName = NormalizeAttributeName(attribute.Name); + if (manualAttributes.Contains(attributeName)) { - return true; + matchingAttributes.Add(attributeName); } } } - return false; + return matchingAttributes; } private static bool HasAttribute(SyntaxList attributeLists, string attributeName) diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs new file mode 100644 index 00000000..9f38dfb8 --- /dev/null +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -0,0 +1,948 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace FirebaseBindingAudit; + +internal sealed class BindingSurfaceCoverageManifest +{ + public List Targets { get; set; } = []; + + public List Waivers { get; set; } = []; +} + +internal sealed class BindingSurfaceCoverageTargetManifest +{ + public string Id { get; set; } = string.Empty; + + public string PackageId { get; set; } = string.Empty; + + public string CoverageCaseMethod { get; set; } = string.Empty; + + public string[] SourceFiles { get; set; } = []; + + public List RequiredExtraPackages { get; set; } = []; +} + +internal sealed class BindingSurfacePackageReference +{ + public string Id { get; set; } = string.Empty; + + public string Version { get; set; } = "12.6.0"; +} + +internal sealed class BindingSurfaceWaiver +{ + public string Target { get; set; } = string.Empty; + + public string SurfaceId { get; set; } = string.Empty; + + public string Kind { get; set; } = string.Empty; + + public string Reason { get; set; } = string.Empty; + + public string Evidence { get; set; } = string.Empty; +} + +internal sealed record BindingSurfaceCoverageDocument( + IReadOnlyList Targets, + IReadOnlyList Waivers); + +internal sealed record BindingSurfaceCoverageTargetDocument( + string Id, + string PackageId, + string CoverageCaseMethod, + IReadOnlyList SourceFiles, + IReadOnlyList RequiredPackages, + IReadOnlyList Surfaces); + +internal sealed record BindingSurfaceDescriptor( + string Target, + string SurfaceId, + string Kind, + string TypeName, + string RuntimeTypeName, + string AssemblyName, + string? ObjectiveCName, + string? ContainerKind, + bool IsProtocol, + bool IsStatic, + string? MemberName, + string? BindingAttribute, + string? BindingValue, + bool HasGetter, + bool HasSetter, + int ParameterCount, + IReadOnlyList NativeSelectors, + string SourceFile, + string Signature); + +internal sealed record BindingSurfaceNativeSelector( + string Selector, + bool IsStatic, + bool IsProtocol); + +internal sealed record BindingSurfaceExerciseRecord( + string Target, + string SurfaceId); + +internal sealed record BindingSurfaceCoverageValidationResult( + IReadOnlyList UnclaimedSurfaceIds, + IReadOnlyList StaleWaiverSurfaceIds, + IReadOnlyList StaleExerciseSurfaceIds) +{ + public bool IsValid => UnclaimedSurfaceIds.Count == 0 && + StaleWaiverSurfaceIds.Count == 0 && + StaleExerciseSurfaceIds.Count == 0; +} + +internal static class BindingSurfaceCoverageManifestLoader +{ + public static BindingSurfaceCoverageManifest Load(string manifestPath) + { + if (!File.Exists(manifestPath)) + { + throw new FileNotFoundException("Binding surface coverage manifest not found.", manifestPath); + } + + var manifest = JsonSerializer.Deserialize( + File.ReadAllText(manifestPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (manifest is null) + { + throw new InvalidOperationException($"Unable to deserialize binding surface coverage manifest at '{manifestPath}'."); + } + + return manifest; + } +} + +internal sealed class BindingSurfaceCoverageBuilder +{ + private static readonly BindingSurfaceNativeSelector[] EmptySelectors = []; + private readonly AuditConfiguration auditConfiguration; + private readonly BindingSyntaxParser parser; + + public BindingSurfaceCoverageBuilder(AuditConfiguration auditConfiguration) + { + this.auditConfiguration = auditConfiguration; + parser = new BindingSyntaxParser(auditConfiguration.ManualAttributes, auditConfiguration.BindingAttributes); + } + + public BindingSurfaceCoverageDocument Build( + string repoRoot, + BindingSurfaceCoverageManifest manifest, + string selectedTarget) + { + var manifestTargetsById = manifest.Targets.ToDictionary(static target => target.Id, StringComparer.OrdinalIgnoreCase); + var selectedTargets = SelectTargets(manifest.Targets, selectedTarget); + var targetDocuments = new List(); + + foreach (var targetManifest in selectedTargets) + { + var auditTarget = auditConfiguration.Targets.FirstOrDefault(target => + string.Equals(target.Id, targetManifest.Id, StringComparison.OrdinalIgnoreCase)); + if (auditTarget is null) + { + throw new InvalidOperationException($"Coverage target '{targetManifest.Id}' is not present in scripts/firebase-binding-audit.json."); + } + + var sourceFiles = targetManifest.SourceFiles + .Select(sourceFile => Path.Combine(repoRoot, sourceFile)) + .ToList(); + var sourceFileSet = targetManifest.SourceFiles.ToHashSet(StringComparer.Ordinal); + var expectedSourceFiles = auditTarget.BaselineFiles + .Concat(auditTarget.HelperFiles) + .Select(file => Path.Combine(auditTarget.BaselineDirectory, file)) + .ToHashSet(StringComparer.Ordinal); + if (!sourceFileSet.SetEquals(expectedSourceFiles)) + { + throw new InvalidOperationException( + $"Coverage source files for '{targetManifest.Id}' must exactly match the audit config source files."); + } + + foreach (var sourceFile in sourceFiles) + { + if (!File.Exists(sourceFile)) + { + throw new FileNotFoundException($"Coverage source file for '{targetManifest.Id}' was not found.", sourceFile); + } + } + + var comparableFiles = auditTarget.BaselineFiles + .Select(file => Path.Combine(auditTarget.BaselineDirectoryPath(repoRoot), file)) + .ToList(); + var helperFiles = auditTarget.HelperFiles + .Select(file => Path.Combine(auditTarget.BaselineDirectoryPath(repoRoot), file)) + .ToList(); + var helperFileSet = helperFiles.ToHashSet(StringComparer.Ordinal); + var snapshot = parser.Parse(comparableFiles, helperFiles); + var surfaces = BuildSurfaces(targetManifest.Id, snapshot, helperFileSet) + .Concat(BuildPublicHelperSurfaces(targetManifest.Id, helperFiles)) + .OrderBy(static surface => surface.SurfaceId, StringComparer.Ordinal) + .ToList(); + var duplicateSurfaceIds = surfaces + .GroupBy(static surface => surface.SurfaceId, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .Select(static group => group.Key) + .ToList(); + if (duplicateSurfaceIds.Count > 0) + { + throw new InvalidOperationException( + $"Coverage target '{targetManifest.Id}' generated duplicate surface ids: {string.Join(", ", duplicateSurfaceIds.Take(20))}"); + } + + var requiredPackages = new Dictionary(StringComparer.Ordinal); + AddPackage(requiredPackages, targetManifest.PackageId, "12.6.0"); + foreach (var package in targetManifest.RequiredExtraPackages) + { + AddPackage(requiredPackages, package.Id, package.Version); + } + + targetDocuments.Add(new BindingSurfaceCoverageTargetDocument( + targetManifest.Id, + targetManifest.PackageId, + targetManifest.CoverageCaseMethod, + targetManifest.SourceFiles, + requiredPackages.Values.OrderBy(static package => package.Id, StringComparer.Ordinal).ToList(), + surfaces)); + } + + var selectedTargetIds = selectedTargets.Select(static target => target.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + var selectedWaivers = manifest.Waivers + .Where(waiver => selectedTargetIds.Contains(waiver.Target)) + .OrderBy(static waiver => waiver.Target, StringComparer.Ordinal) + .ThenBy(static waiver => waiver.SurfaceId, StringComparer.Ordinal) + .ToList(); + + foreach (var waiver in selectedWaivers) + { + if (!manifestTargetsById.ContainsKey(waiver.Target)) + { + throw new InvalidOperationException($"Coverage waiver '{waiver.SurfaceId}' references unknown target '{waiver.Target}'."); + } + } + + return new BindingSurfaceCoverageDocument(targetDocuments, selectedWaivers); + } + + public static async Task WriteAsync( + BindingSurfaceCoverageDocument document, + string coverageOutputPath, + string propsOutputPath, + string selectedTarget, + CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(coverageOutputPath))!); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(propsOutputPath))!); + + var json = JsonSerializer.Serialize( + document, + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + await File.WriteAllTextAsync(coverageOutputPath, json + Environment.NewLine, cancellationToken); + + var props = BuildProps(document, coverageOutputPath, selectedTarget); + await File.WriteAllTextAsync(propsOutputPath, props.ToString(SaveOptions.DisableFormatting) + Environment.NewLine, cancellationToken); + } + + private static XDocument BuildProps( + BindingSurfaceCoverageDocument document, + string coverageOutputPath, + string selectedTarget) + { + var defaultPackageIds = new HashSet(StringComparer.Ordinal) + { + "AdamE.Firebase.iOS.Analytics", + "AdamE.Firebase.iOS.Core", + "AdamE.Firebase.iOS.Installations", + "AdamE.Google.iOS.GoogleAppMeasurement", + "AdamE.Google.iOS.GoogleDataTransport", + "AdamE.Google.iOS.GoogleUtilities", + "AdamE.Google.iOS.Nanopb", + "AdamE.Google.iOS.PromisesObjC" + }; + + var packages = document.Targets + .SelectMany(static target => target.RequiredPackages) + .Where(package => !defaultPackageIds.Contains(package.Id)) + .GroupBy(static package => package.Id, StringComparer.Ordinal) + .Select(static group => group.OrderByDescending(package => package.Version, StringComparer.Ordinal).First()) + .OrderBy(static package => package.Id, StringComparer.Ordinal) + .ToList(); + var assemblyNames = document.Targets + .SelectMany(static target => target.Surfaces) + .Select(static surface => surface.AssemblyName) + .Where(static assemblyName => !string.IsNullOrWhiteSpace(assemblyName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(static assemblyName => assemblyName, StringComparer.Ordinal) + .ToList(); + + var project = new XElement("Project", + new XElement("PropertyGroup", + new XElement("BindingSurfaceCoverageTarget", selectedTarget), + new XElement("DefineConstants", "$(DefineConstants);ENABLE_BINDING_SURFACE_COVERAGE")), + new XElement("ItemGroup", + new XElement("BundleResource", + new XAttribute("Include", Path.GetFullPath(coverageOutputPath)), + new XAttribute("Link", "binding-surface-coverage.generated.json"))), + new XElement("ItemGroup", + packages.Select(package => + new XElement("PackageReference", + new XAttribute("Include", package.Id), + new XAttribute("Version", package.Version)))), + new XElement("ItemGroup", + assemblyNames.Select(assemblyName => + new XElement("TrimmerRootAssembly", + new XAttribute("Include", assemblyName))))); + + return new XDocument(project); + } + + private static IReadOnlyList SelectTargets( + IReadOnlyList targets, + string selectedTarget) + { + if (string.Equals(selectedTarget, "all", StringComparison.OrdinalIgnoreCase)) + { + return targets.OrderBy(static target => target.Id, StringComparer.Ordinal).ToList(); + } + + var target = targets.FirstOrDefault(target => + string.Equals(target.Id, selectedTarget, StringComparison.OrdinalIgnoreCase)); + if (target is null) + { + var available = string.Join(", ", targets.Select(static target => target.Id).OrderBy(static id => id, StringComparer.Ordinal)); + throw new InvalidOperationException($"Unknown binding surface target '{selectedTarget}'. Available targets: all, {available}"); + } + + return [target]; + } + + private static IEnumerable BuildSurfaces( + string target, + BindingSnapshot snapshot, + IReadOnlySet helperFiles) + { + foreach (var boundType in snapshot.BoundTypes.Values) + { + yield return new BindingSurfaceDescriptor( + Target: target, + SurfaceId: $"{target}:type:{boundType.ComparisonKey}", + Kind: boundType.IsProtocol ? "protocol" : "bound-type", + TypeName: boundType.DisplayName, + RuntimeTypeName: boundType.DisplayName, + AssemblyName: ResolveAssemblyName(boundType.Namespace), + ObjectiveCName: boundType.ObjectiveCName, + ContainerKind: boundType.ContainerKind, + IsProtocol: boundType.IsProtocol, + IsStatic: boundType.IsStatic, + MemberName: null, + BindingAttribute: null, + BindingValue: null, + HasGetter: false, + HasSetter: false, + ParameterCount: 0, + NativeSelectors: EmptySelectors, + SourceFile: boundType.SourceFile, + Signature: $"{boundType.ContainerKind} {boundType.Name}"); + + foreach (var member in boundType.Members.Values) + { + var bindingAttribute = member.BindingAttribute; + var bindingValue = member.BindingValue; + yield return new BindingSurfaceDescriptor( + Target: target, + SurfaceId: $"{target}:member:{boundType.ComparisonKey}:{member.Key}", + Kind: member.Kind, + TypeName: boundType.DisplayName, + RuntimeTypeName: boundType.DisplayName, + AssemblyName: ResolveAssemblyName(boundType.Namespace), + ObjectiveCName: boundType.ObjectiveCName, + ContainerKind: boundType.ContainerKind, + IsProtocol: boundType.IsProtocol, + IsStatic: member.IsStatic, + MemberName: member.Name, + BindingAttribute: bindingAttribute, + BindingValue: bindingValue, + HasGetter: member.HasGetter, + HasSetter: member.HasSetter, + ParameterCount: member.Parameters.Count, + NativeSelectors: BuildNativeSelectors(boundType, member).ToList(), + SourceFile: member.SourceFile, + Signature: member.Signature); + } + } + + var usedDelegateNames = FindUsedDelegateNames(snapshot); + foreach (var delegateSurface in snapshot.Delegates.Values) + { + if (!IsDelegateUsedByBoundSurface(delegateSurface, usedDelegateNames)) + { + continue; + } + + yield return new BindingSurfaceDescriptor( + Target: target, + SurfaceId: $"{target}:delegate:{delegateSurface.ComparisonKey}", + Kind: "delegate", + TypeName: delegateSurface.DisplayName, + RuntimeTypeName: delegateSurface.DisplayName, + AssemblyName: ResolveAssemblyName(delegateSurface.Namespace), + ObjectiveCName: null, + ContainerKind: "delegate", + IsProtocol: false, + IsStatic: false, + MemberName: null, + BindingAttribute: null, + BindingValue: null, + HasGetter: false, + HasSetter: false, + ParameterCount: delegateSurface.Parameters.Count, + NativeSelectors: EmptySelectors, + SourceFile: delegateSurface.SourceFile, + Signature: delegateSurface.Signature); + } + + foreach (var enumSurface in snapshot.Enums.Values) + { + yield return new BindingSurfaceDescriptor( + Target: target, + SurfaceId: $"{target}:enum:{enumSurface.ComparisonKey}", + Kind: "enum", + TypeName: enumSurface.DisplayName, + RuntimeTypeName: enumSurface.DisplayName, + AssemblyName: ResolveAssemblyName(enumSurface.Namespace), + ObjectiveCName: null, + ContainerKind: "enum", + IsProtocol: false, + IsStatic: false, + MemberName: null, + BindingAttribute: null, + BindingValue: null, + HasGetter: false, + HasSetter: false, + ParameterCount: 0, + NativeSelectors: EmptySelectors, + SourceFile: enumSurface.SourceFile, + Signature: $"enum {enumSurface.Name}"); + + foreach (var enumMember in enumSurface.Members.Values) + { + yield return new BindingSurfaceDescriptor( + Target: target, + SurfaceId: $"{target}:enum-member:{enumSurface.ComparisonKey}:{enumMember.Name}", + Kind: "enum-member", + TypeName: enumSurface.DisplayName, + RuntimeTypeName: enumSurface.DisplayName, + AssemblyName: ResolveAssemblyName(enumSurface.Namespace), + ObjectiveCName: null, + ContainerKind: "enum", + IsProtocol: false, + IsStatic: false, + MemberName: enumMember.Name, + BindingAttribute: enumMember.FieldValue is null ? null : "Field", + BindingValue: enumMember.FieldValue, + HasGetter: false, + HasSetter: false, + ParameterCount: 0, + NativeSelectors: EmptySelectors, + SourceFile: enumMember.SourceFile, + Signature: $"{enumSurface.Name}.{enumMember.Name}"); + } + } + + foreach (var manualItem in snapshot.ManualItems) + { + if (helperFiles.Contains(manualItem.SourceFile)) + { + continue; + } + + if (manualItem.ManualAttributes.Any(static attribute => + string.Equals(attribute, "Internal", StringComparison.Ordinal))) + { + continue; + } + + var (bindingAttribute, bindingValue) = ParseManualMemberKey(manualItem.MatchMemberKey); + yield return new BindingSurfaceDescriptor( + Target: target, + SurfaceId: CreateManualSurfaceId(target, manualItem), + Kind: bindingAttribute is null ? "manual" : $"manual-{bindingAttribute.ToLowerInvariant()}", + TypeName: manualItem.TypeName, + RuntimeTypeName: manualItem.TypeName, + AssemblyName: ResolveAssemblyName(ExtractNamespace(manualItem.TypeName)), + ObjectiveCName: null, + ContainerKind: "manual", + IsProtocol: false, + IsStatic: false, + MemberName: manualItem.MemberName, + BindingAttribute: bindingAttribute, + BindingValue: bindingValue, + HasGetter: false, + HasSetter: false, + ParameterCount: 0, + NativeSelectors: string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(bindingValue) + ? [new BindingSurfaceNativeSelector(bindingValue!, IsStatic: false, IsProtocol: false)] + : EmptySelectors, + SourceFile: manualItem.SourceFile, + Signature: manualItem.Signature); + } + } + + private static HashSet FindUsedDelegateNames(BindingSnapshot snapshot) + { + var usedDelegateNames = new HashSet(StringComparer.Ordinal); + foreach (var boundType in snapshot.BoundTypes.Values) + { + foreach (var member in boundType.Members.Values) + { + AddTypeNameVariants(usedDelegateNames, member.ReturnType); + foreach (var parameter in member.Parameters) + { + AddTypeNameVariants(usedDelegateNames, parameter.Type); + } + } + } + + return usedDelegateNames; + } + + private static bool IsDelegateUsedByBoundSurface(DelegateSurface delegateSurface, IReadOnlySet usedDelegateNames) => + usedDelegateNames.Contains(delegateSurface.Name) || + usedDelegateNames.Contains(delegateSurface.DisplayName) || + usedDelegateNames.Contains(delegateSurface.ComparisonKey); + + private static void AddTypeNameVariants(HashSet typeNames, string typeName) + { + var normalizedTypeName = NormalizeCoverageTypeName(typeName); + if (string.IsNullOrWhiteSpace(normalizedTypeName)) + { + return; + } + + typeNames.Add(normalizedTypeName); + typeNames.Add(normalizedTypeName.Split('.').Last()); + } + + private static string NormalizeCoverageTypeName(string typeName) + { + var normalized = typeName + .Replace("global::", string.Empty, StringComparison.Ordinal) + .Trim() + .TrimEnd('?'); + + while (normalized.EndsWith("[]", StringComparison.Ordinal)) + { + normalized = normalized[..^2].TrimEnd(); + } + + return normalized; + } + + private static IEnumerable BuildPublicHelperSurfaces(string target, IReadOnlyList helperFiles) + { + foreach (var helperFile in helperFiles) + { + if (!File.Exists(helperFile)) + { + continue; + } + + var syntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText(helperFile), path: helperFile); + var root = syntaxTree.GetCompilationUnitRoot(); + foreach (var typeDeclaration in root.DescendantNodes().OfType()) + { + if (!typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + { + continue; + } + + var namespaceName = GetContainingNamespace(typeDeclaration); + var typeName = string.IsNullOrWhiteSpace(namespaceName) + ? typeDeclaration.Identifier.Text + : $"{namespaceName}.{typeDeclaration.Identifier.Text}"; + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual-type:{typeName}", + memberName: null, + signature: $"{GetTypeDeclarationKind(typeDeclaration)} {typeDeclaration.Identifier.Text}", + isStatic: typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); + + foreach (var property in typeDeclaration.Members.OfType()) + { + if (!property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + { + continue; + } + + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual:{typeName}:{property.Identifier.Text}:{CreateSurfaceIdKey($"{property.Type.WithoutTrivia()} {property.Identifier.Text}")}", + memberName: property.Identifier.Text, + signature: $"{property.Type.WithoutTrivia()} {property.Identifier.Text} {{ get; }}", + isStatic: property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); + } + + foreach (var method in typeDeclaration.Members.OfType()) + { + if (!method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + { + continue; + } + + var parameterTypes = string.Join(", ", method.ParameterList.Parameters.Select(static parameter => parameter.Type?.WithoutTrivia().ToString() ?? "object")); + var signature = $"{method.Identifier.Text}({parameterTypes}) -> {method.ReturnType.WithoutTrivia()}"; + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual:{typeName}:{method.Identifier.Text}:{method.ParameterList.Parameters.Count}:{CreateSurfaceIdKey(signature)}", + memberName: method.Identifier.Text, + signature: signature, + isStatic: method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); + } + + foreach (var field in typeDeclaration.Members.OfType()) + { + if (!field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + { + continue; + } + + foreach (var variable in field.Declaration.Variables) + { + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual-field:{typeName}:{variable.Identifier.Text}:{CreateSurfaceIdKey($"{field.Declaration.Type.WithoutTrivia()} {variable.Identifier.Text}")}", + memberName: variable.Identifier.Text, + signature: $"{field.Declaration.Type.WithoutTrivia()} {variable.Identifier.Text}", + isStatic: field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)) || + field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ConstKeyword))); + } + } + } + } + } + + private static BindingSurfaceDescriptor CreatePublicHelperSurface( + string target, + string helperFile, + string typeName, + string namespaceName, + string surfaceId, + string? memberName, + string signature, + bool isStatic) => + new( + Target: target, + SurfaceId: surfaceId, + Kind: memberName is null ? "manual" : "manual-member", + TypeName: typeName, + RuntimeTypeName: typeName, + AssemblyName: ResolveAssemblyName(namespaceName), + ObjectiveCName: null, + ContainerKind: "manual", + IsProtocol: false, + IsStatic: isStatic, + MemberName: memberName, + BindingAttribute: null, + BindingValue: null, + HasGetter: false, + HasSetter: false, + ParameterCount: 0, + NativeSelectors: EmptySelectors, + SourceFile: helperFile, + Signature: signature); + + private static string GetTypeDeclarationKind(TypeDeclarationSyntax typeDeclaration) => + typeDeclaration switch + { + ClassDeclarationSyntax classDeclaration when classDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)) => "static class", + ClassDeclarationSyntax => "class", + StructDeclarationSyntax => "struct", + InterfaceDeclarationSyntax => "interface", + RecordDeclarationSyntax => "record", + _ => "type" + }; + + private static IEnumerable BuildNativeSelectors(BoundTypeSurface boundType, BindingMemberSurface member) + { + if (!string.Equals(member.BindingAttribute, "Export", StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(member.BindingValue)) + { + yield break; + } + + if (string.Equals(member.Kind, "property", StringComparison.Ordinal)) + { + if (member.HasGetter) + { + yield return new BindingSurfaceNativeSelector( + member.GetterBind ?? member.BindingValue, + member.IsStatic, + boundType.IsProtocol); + } + + if (member.HasSetter) + { + yield return new BindingSurfaceNativeSelector( + member.SetterBind ?? CreateSetterSelector(member.BindingValue), + member.IsStatic, + boundType.IsProtocol); + } + + yield break; + } + + yield return new BindingSurfaceNativeSelector(member.BindingValue, member.IsStatic, boundType.IsProtocol); + } + + private static string CreateSetterSelector(string getterSelector) + { + var getter = getterSelector.TrimEnd(':'); + if (getter.Length == 0) + { + return "set:"; + } + + return $"set{char.ToUpperInvariant(getter[0])}{getter[1..]}:"; + } + + private static string CreateManualSurfaceId(string target, ManualSurfaceItem manualItem) + { + var memberKey = manualItem.MatchMemberKey; + if (!string.IsNullOrWhiteSpace(memberKey)) + { + return $"{target}:manual:{manualItem.MatchTypeKey}:{memberKey}"; + } + + if (!string.IsNullOrWhiteSpace(manualItem.MemberName)) + { + return $"{target}:manual:{manualItem.MatchTypeKey}:{manualItem.MemberName}:{CreateSurfaceIdKey(manualItem.Signature)}"; + } + + return $"{target}:manual-type:{manualItem.MatchTypeKey}"; + } + + private static string CreateSurfaceIdKey(string value) + { + var builder = new System.Text.StringBuilder(value.Length); + foreach (var character in value) + { + if (char.IsLetterOrDigit(character) || + character is ':' or '.' or '_' or '-' or '<' or '>' or '[' or ']' or ',') + { + builder.Append(character); + } + else + { + builder.Append('_'); + } + } + + return builder.ToString().Trim('_'); + } + + private static (string? BindingAttribute, string? BindingValue) ParseManualMemberKey(string? matchMemberKey) + { + if (string.IsNullOrWhiteSpace(matchMemberKey)) + { + return (null, null); + } + + var separatorIndex = matchMemberKey.IndexOf('|', StringComparison.Ordinal); + if (separatorIndex < 0) + { + return (null, null); + } + + var rawAttribute = matchMemberKey[..separatorIndex]; + var value = matchMemberKey[(separatorIndex + 1)..]; + var attribute = rawAttribute switch + { + "export" => "Export", + "field" => "Field", + "notification" => "Notification", + _ => null + }; + + return (attribute, attribute is null ? null : value); + } + + private static void AddPackage( + Dictionary packages, + string packageId, + string version) + { + if (string.IsNullOrWhiteSpace(packageId)) + { + return; + } + + packages.TryAdd(packageId, new BindingSurfacePackageReference + { + Id = packageId, + Version = string.IsNullOrWhiteSpace(version) ? "12.6.0" : version + }); + } + + private static string ResolveAssemblyName(string? namespaceName) + { + if (string.IsNullOrWhiteSpace(namespaceName)) + { + return string.Empty; + } + + var parts = namespaceName.Split('.', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}" : parts[0]; + } + + private static string ExtractNamespace(string typeName) + { + var lastDotIndex = typeName.LastIndexOf('.'); + return lastDotIndex < 0 ? string.Empty : typeName[..lastDotIndex]; + } + + private static string GetContainingNamespace(SyntaxNode node) + { + for (var current = node.Parent; current is not null; current = current.Parent) + { + if (current is BaseNamespaceDeclarationSyntax namespaceDeclaration) + { + return namespaceDeclaration.Name.WithoutTrivia().ToString(); + } + } + + return string.Empty; + } +} + +internal static class BindingSurfaceCoverageValidator +{ + public static BindingSurfaceCoverageValidationResult Validate( + BindingSurfaceCoverageDocument document, + IEnumerable? exercisedSurfaces = null) + { + var surfacesByTarget = document.Targets.ToDictionary( + static target => target.Id, + target => target.Surfaces.Select(static surface => surface.SurfaceId).ToHashSet(StringComparer.Ordinal), + StringComparer.OrdinalIgnoreCase); + var waiversByTarget = document.Waivers + .GroupBy(static waiver => waiver.Target, StringComparer.OrdinalIgnoreCase) + .ToDictionary(static group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); + + var staleWaivers = new List(); + var exercisedByTarget = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (exercisedSurfaces is not null) + { + foreach (var exercise in exercisedSurfaces) + { + if (!exercisedByTarget.TryGetValue(exercise.Target, out var targetExercises)) + { + targetExercises = new HashSet(StringComparer.Ordinal); + exercisedByTarget[exercise.Target] = targetExercises; + } + + targetExercises.Add(exercise.SurfaceId); + } + } + + var staleExercises = new List(); + foreach (var exerciseTarget in exercisedByTarget) + { + if (!surfacesByTarget.TryGetValue(exerciseTarget.Key, out var knownSurfaces)) + { + staleExercises.AddRange(exerciseTarget.Value.Select(surfaceId => FormatTargetSurfaceId(exerciseTarget.Key, surfaceId))); + continue; + } + + staleExercises.AddRange(exerciseTarget.Value + .Where(surfaceId => !knownSurfaces.Contains(surfaceId)) + .Select(surfaceId => FormatTargetSurfaceId(exerciseTarget.Key, surfaceId))); + } + + var unclaimed = new List(); + foreach (var target in document.Targets) + { + var waivedSurfaces = waiversByTarget.TryGetValue(target.Id, out var targetWaivers) + ? targetWaivers.Select(static waiver => waiver.SurfaceId).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + + foreach (var waivedSurface in waivedSurfaces) + { + if (!surfacesByTarget[target.Id].Contains(waivedSurface)) + { + staleWaivers.Add(FormatTargetSurfaceId(target.Id, waivedSurface)); + } + } + + if (exercisedSurfaces is null) + { + continue; + } + + var exercised = exercisedByTarget.TryGetValue(target.Id, out var targetExercises) + ? targetExercises + : new HashSet(StringComparer.Ordinal); + + unclaimed.AddRange(target.Surfaces + .Select(static surface => surface.SurfaceId) + .Where(surfaceId => !waivedSurfaces.Contains(surfaceId) && !exercised.Contains(surfaceId)) + .Select(surfaceId => FormatTargetSurfaceId(target.Id, surfaceId))); + } + + return new BindingSurfaceCoverageValidationResult( + unclaimed.OrderBy(static id => id, StringComparer.Ordinal).ToList(), + staleWaivers.OrderBy(static id => id, StringComparer.Ordinal).ToList(), + staleExercises.OrderBy(static id => id, StringComparer.Ordinal).ToList()); + } + + private static string FormatTargetSurfaceId(string target, string surfaceId) => + surfaceId.StartsWith($"{target}:", StringComparison.Ordinal) ? surfaceId : $"{target}:{surfaceId}"; + + public static void ThrowIfInvalid(BindingSurfaceCoverageValidationResult validationResult) + { + if (validationResult.IsValid) + { + return; + } + + var messages = new List(); + if (validationResult.UnclaimedSurfaceIds.Count > 0) + { + messages.Add("Unclaimed surfaces: " + string.Join(", ", validationResult.UnclaimedSurfaceIds.Take(25))); + } + + if (validationResult.StaleWaiverSurfaceIds.Count > 0) + { + messages.Add("Stale waivers: " + string.Join(", ", validationResult.StaleWaiverSurfaceIds.Take(25))); + } + + if (validationResult.StaleExerciseSurfaceIds.Count > 0) + { + messages.Add("Stale exercised surfaces: " + string.Join(", ", validationResult.StaleExerciseSurfaceIds.Take(25))); + } + + throw new InvalidOperationException(string.Join(Environment.NewLine, messages)); + } +} diff --git a/scripts/FirebaseBindingAudit/Program.cs b/scripts/FirebaseBindingAudit/Program.cs index 0ab8a767..552e694d 100644 --- a/scripts/FirebaseBindingAudit/Program.cs +++ b/scripts/FirebaseBindingAudit/Program.cs @@ -14,11 +14,36 @@ public static async Task Main(string[] args) } var repoRoot = parsedArguments.RepoRoot ?? throw new InvalidOperationException("--repo-root is required."); - var outputDirectory = parsedArguments.OutputDirectory ?? throw new InvalidOperationException("--output-dir is required."); var configPath = Path.Combine(repoRoot, "scripts", "firebase-binding-audit.json"); var suppressionPath = Path.Combine(repoRoot, "scripts", "firebase-binding-audit-suppressions.json"); var configuration = ConfigurationLoader.Load(configPath); + if (parsedArguments.GenerateBindingSurfaceCoverage) + { + var coverageManifestPath = parsedArguments.CoverageManifest ?? throw new InvalidOperationException("--coverage-manifest is required."); + var coverageOutputPath = parsedArguments.CoverageOutput ?? throw new InvalidOperationException("--coverage-output is required."); + var coveragePropsOutputPath = parsedArguments.CoveragePropsOutput ?? throw new InvalidOperationException("--coverage-props-output is required."); + var bindingSurfaceTarget = parsedArguments.BindingSurfaceTarget ?? throw new InvalidOperationException("--binding-surface-target is required."); + var manifest = BindingSurfaceCoverageManifestLoader.Load(coverageManifestPath); + var document = new BindingSurfaceCoverageBuilder(configuration).Build(repoRoot, manifest, bindingSurfaceTarget); + BindingSurfaceCoverageValidator.ThrowIfInvalid(BindingSurfaceCoverageValidator.Validate(document)); + await BindingSurfaceCoverageBuilder.WriteAsync( + document, + coverageOutputPath, + coveragePropsOutputPath, + bindingSurfaceTarget); + + Console.WriteLine($"Binding surface coverage document: {coverageOutputPath}"); + Console.WriteLine($"Binding surface coverage props: {coveragePropsOutputPath}"); + foreach (var package in document.Targets.SelectMany(static target => target.RequiredPackages).DistinctBy(static package => package.Id).OrderBy(static package => package.Id, StringComparer.Ordinal)) + { + Console.WriteLine($"Package: {package.Id} {package.Version}"); + } + + return 0; + } + + var outputDirectory = parsedArguments.OutputDirectory ?? throw new InvalidOperationException("--output-dir is required."); var suppressions = File.Exists(suppressionPath) ? SuppressionLoader.Load(suppressionPath) : new SuppressionConfiguration(); @@ -102,6 +127,21 @@ private static ParsedArguments ParseArguments(string[] args) case "--disable-suppressions": parsed.DisableSuppressions = true; break; + case "--generate-binding-surface-coverage": + parsed.GenerateBindingSurfaceCoverage = true; + break; + case "--coverage-manifest": + parsed.CoverageManifest = GetRequiredValue(args, ++index, "--coverage-manifest"); + break; + case "--coverage-output": + parsed.CoverageOutput = GetRequiredValue(args, ++index, "--coverage-output"); + break; + case "--coverage-props-output": + parsed.CoveragePropsOutput = GetRequiredValue(args, ++index, "--coverage-props-output"); + break; + case "--binding-surface-target": + parsed.BindingSurfaceTarget = GetRequiredValue(args, ++index, "--binding-surface-target"); + break; case "--help": case "-h": parsed.ShowHelp = true; @@ -127,6 +167,7 @@ private static string GetRequiredValue(string[] args, int index, string optionNa private static void PrintUsage() { Console.WriteLine("Usage: FirebaseBindingAudit --repo-root --output-dir [--targets a,b] [--generator-version 0.7.0] [--keep-temp] [--sharpie-path /path/to/sharpie] [--sharpie-version 26.3.0.11] [--disable-sharpie] [--disable-suppressions]"); + Console.WriteLine(" FirebaseBindingAudit --generate-binding-surface-coverage --repo-root --coverage-manifest --coverage-output --coverage-props-output --binding-surface-target "); } private sealed class ParsedArguments @@ -149,6 +190,16 @@ private sealed class ParsedArguments public bool DisableSuppressions { get; set; } + public bool GenerateBindingSurfaceCoverage { get; set; } + + public string? CoverageManifest { get; set; } + + public string? CoverageOutput { get; set; } + + public string? CoveragePropsOutput { get; set; } + + public string? BindingSurfaceTarget { get; set; } + public bool ShowHelp { get; set; } } } diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs index 780bb906..e3636128 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs @@ -8,6 +8,7 @@ public sealed class FirebaseE2ERunResult public string? ProjectId { get; set; } public string? FirebaseVersion { get; set; } public string? InstallationsIdPreview { get; set; } + public BindingSurfaceCoverageRunResult? BindingSurfaceCoverage { get; set; } public bool Success { get; set; } public DateTimeOffset StartedAtUtc { get; set; } public DateTimeOffset CompletedAtUtc { get; set; } @@ -24,3 +25,34 @@ public sealed class FirebaseE2ETestCaseResult public string? ExceptionType { get; set; } public string? Detail { get; set; } } + +public sealed class BindingSurfaceCoverageRunResult +{ + public string Target { get; set; } = string.Empty; + public int SurfaceCount { get; set; } + public int ExercisedCount { get; set; } + public int WaivedCount { get; set; } + public int FailedCount { get; set; } + public List Targets { get; } = new(); +} + +public sealed class BindingSurfaceCoverageTargetResult +{ + public string Target { get; set; } = string.Empty; + public int SurfaceCount { get; set; } + public int ExercisedCount { get; set; } + public int WaivedCount { get; set; } + public int FailedCount { get; set; } + public List Failures { get; } = new(); +} + +public sealed class BindingSurfaceCoverageFailure +{ + public string Target { get; set; } = string.Empty; + public string SurfaceId { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public string TypeName { get; set; } = string.Empty; + public string? MemberName { get; set; } + public string? Selector { get; set; } + public string Message { get; set; } = string.Empty; +} diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.ABTesting.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.ABTesting.cs new file mode 100644 index 00000000..ee721b91 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.ABTesting.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyABTestingBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "ABTesting"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Analytics.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Analytics.cs new file mode 100644 index 00000000..92d79b17 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Analytics.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyAnalyticsBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Analytics"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppCheck.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppCheck.cs new file mode 100644 index 00000000..d078ad7a --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppCheck.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyAppCheckBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "AppCheck"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Auth.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Auth.cs new file mode 100644 index 00000000..71cfadbf --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Auth.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyAuthBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Auth"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFirestore.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFirestore.cs new file mode 100644 index 00000000..aaa42a10 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFirestore.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyCloudFirestoreBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "CloudFirestore"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFunctions.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFunctions.cs new file mode 100644 index 00000000..3e6c5495 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudFunctions.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyCloudFunctionsBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "CloudFunctions"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudMessaging.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudMessaging.cs new file mode 100644 index 00000000..01e990a4 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.CloudMessaging.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyCloudMessagingBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "CloudMessaging"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Core.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Core.cs new file mode 100644 index 00000000..4c3661d7 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Core.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyCoreBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Core"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Crashlytics.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Crashlytics.cs new file mode 100644 index 00000000..075e2351 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Crashlytics.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyCrashlyticsBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Crashlytics"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Database.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Database.cs new file mode 100644 index 00000000..a2670d88 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Database.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyDatabaseBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Database"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Installations.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Installations.cs new file mode 100644 index 00000000..2c70a567 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Installations.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyInstallationsBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Installations"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.PerformanceMonitoring.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.PerformanceMonitoring.cs new file mode 100644 index 00000000..51e119ed --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.PerformanceMonitoring.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyPerformanceMonitoringBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "PerformanceMonitoring"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.RemoteConfig.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.RemoteConfig.cs new file mode 100644 index 00000000..12ca60a6 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.RemoteConfig.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyRemoteConfigBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "RemoteConfig"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Storage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Storage.cs new file mode 100644 index 00000000..e37ad8af --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.Storage.cs @@ -0,0 +1,9 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static Task VerifyStorageBindingSurfaceAsync(BindingSurfaceCoverageDocument document) => + ExecuteTargetAsync(document, "Storage"); +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs new file mode 100644 index 00000000..4388738c --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -0,0 +1,596 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using Foundation; +using ObjCRuntime; + +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + const string CoverageResourceName = "binding-surface-coverage.generated"; + + public static async Task VerifyConfiguredAsync(FirebaseE2ERunResult runResult) + { + await Task.Yield(); + + var selectedTarget = GetConfiguredTarget(); + if (string.IsNullOrWhiteSpace(selectedTarget)) + { + throw new InvalidOperationException("Binding surface coverage target metadata was not set."); + } + + var document = LoadCoverageDocument(); + var targetResults = new List(); + foreach (var target in SelectTargets(document, selectedTarget)) + { + targetResults.Add(await ExecuteConfiguredTargetAsync(document, target.Id)); + } + + var summary = new BindingSurfaceCoverageRunResult + { + Target = selectedTarget, + SurfaceCount = targetResults.Sum(static target => target.SurfaceCount), + ExercisedCount = targetResults.Sum(static target => target.ExercisedCount), + WaivedCount = targetResults.Sum(static target => target.WaivedCount), + FailedCount = targetResults.Sum(static target => target.FailedCount), + }; + summary.Targets.AddRange(targetResults); + runResult.BindingSurfaceCoverage = summary; + + if (summary.FailedCount > 0) + { + var failures = targetResults + .SelectMany(static target => target.Failures) + .Take(12) + .Select(static failure => $"{failure.SurfaceId}: {failure.Message}"); + throw new InvalidOperationException( + $"Binding surface coverage failed for {summary.FailedCount} surface(s): {string.Join("; ", failures)}"); + } + + return $"Binding surface coverage passed for {summary.SurfaceCount} surface(s): {summary.ExercisedCount} exercised, {summary.WaivedCount} waived."; + } + + static Task ExecuteConfiguredTargetAsync( + BindingSurfaceCoverageDocument document, + string targetId) => + targetId switch + { + "ABTesting" => VerifyABTestingBindingSurfaceAsync(document), + "Analytics" => VerifyAnalyticsBindingSurfaceAsync(document), + "AppCheck" => VerifyAppCheckBindingSurfaceAsync(document), + "Auth" => VerifyAuthBindingSurfaceAsync(document), + "CloudFirestore" => VerifyCloudFirestoreBindingSurfaceAsync(document), + "CloudFunctions" => VerifyCloudFunctionsBindingSurfaceAsync(document), + "CloudMessaging" => VerifyCloudMessagingBindingSurfaceAsync(document), + "Core" => VerifyCoreBindingSurfaceAsync(document), + "Crashlytics" => VerifyCrashlyticsBindingSurfaceAsync(document), + "Database" => VerifyDatabaseBindingSurfaceAsync(document), + "Installations" => VerifyInstallationsBindingSurfaceAsync(document), + "PerformanceMonitoring" => VerifyPerformanceMonitoringBindingSurfaceAsync(document), + "RemoteConfig" => VerifyRemoteConfigBindingSurfaceAsync(document), + "Storage" => VerifyStorageBindingSurfaceAsync(document), + _ => throw new InvalidOperationException($"No binding surface coverage method exists for target '{targetId}'.") + }; + + static Task ExecuteTargetAsync( + BindingSurfaceCoverageDocument document, + string targetId) + { + var target = document.Targets.FirstOrDefault(target => + string.Equals(target.Id, targetId, StringComparison.Ordinal)); + if (target is null) + { + throw new InvalidOperationException($"Binding surface coverage document does not contain target '{targetId}'."); + } + + var waivers = document.Waivers + .Where(waiver => string.Equals(waiver.Target, targetId, StringComparison.Ordinal)) + .ToDictionary(static waiver => waiver.SurfaceId, StringComparer.Ordinal); + var result = new BindingSurfaceCoverageTargetResult + { + Target = target.Id, + SurfaceCount = target.Surfaces.Count, + WaivedCount = target.Surfaces.Count(surface => waivers.ContainsKey(surface.SurfaceId)), + }; + + var exercised = new HashSet(StringComparer.Ordinal); + var attempted = new HashSet(StringComparer.Ordinal); + foreach (var surface in target.Surfaces) + { + if (waivers.ContainsKey(surface.SurfaceId)) + { + continue; + } + + attempted.Add(surface.SurfaceId); + try + { + ExerciseSurface(surface); + exercised.Add(surface.SurfaceId); + } + catch (Exception ex) when (IsBindingLayerFailure(ex)) + { + result.Failures.Add(new BindingSurfaceCoverageFailure + { + Target = target.Id, + SurfaceId = surface.SurfaceId, + Kind = surface.Kind, + TypeName = surface.TypeName, + MemberName = surface.MemberName, + Selector = surface.NativeSelectors.FirstOrDefault()?.Selector ?? surface.BindingValue, + Message = $"{ex.GetType().FullName}: {ex.Message}", + }); + } + } + + var knownSurfaces = target.Surfaces.Select(static surface => surface.SurfaceId).ToHashSet(StringComparer.Ordinal); + foreach (var staleExercise in exercised.Where(surfaceId => !knownSurfaces.Contains(surfaceId))) + { + result.Failures.Add(new BindingSurfaceCoverageFailure + { + Target = target.Id, + SurfaceId = staleExercise, + Kind = "stale-exerciser", + TypeName = string.Empty, + Message = "Exerciser recorded a surface ID not present in the generated source inventory.", + }); + } + + foreach (var missedSurface in target.Surfaces.Where(surface => !waivers.ContainsKey(surface.SurfaceId) && !attempted.Contains(surface.SurfaceId))) + { + result.Failures.Add(new BindingSurfaceCoverageFailure + { + Target = target.Id, + SurfaceId = missedSurface.SurfaceId, + Kind = missedSurface.Kind, + TypeName = missedSurface.TypeName, + MemberName = missedSurface.MemberName, + Selector = missedSurface.NativeSelectors.FirstOrDefault()?.Selector ?? missedSurface.BindingValue, + Message = "Surface was neither exercised nor waived.", + }); + } + + result.ExercisedCount = exercised.Count; + result.FailedCount = result.Failures.Count; + return Task.FromResult(result); + } + + static void ExerciseSurface(BindingSurfaceDescriptor surface) + { + var type = ResolveManagedType(surface); + ResolveManagedMember(type, surface); + if (surface.IsProtocol) + { + ProbeProtocolSurface(surface); + return; + } + + if (!string.IsNullOrWhiteSpace(surface.ObjectiveCName) && + string.Equals(surface.Kind, "bound-type", StringComparison.Ordinal)) + { + ProbeNativeClass(surface); + } + + if (!string.IsNullOrWhiteSpace(surface.ObjectiveCName) && surface.NativeSelectors.Count > 0) + { + ProbeNativeClass(surface); + foreach (var selector in surface.NativeSelectors) + { + ProbeClassSelector(surface, selector); + } + } + + if (string.Equals(surface.Kind, "enum-member", StringComparison.Ordinal)) + { + ResolveEnumMember(type, surface.MemberName); + if (string.Equals(surface.BindingAttribute, "Field", StringComparison.Ordinal)) + { + ProbeNativeSymbol(surface); + } + } + else if (string.Equals(surface.BindingAttribute, "Field", StringComparison.Ordinal) || + string.Equals(surface.BindingAttribute, "Notification", StringComparison.Ordinal)) + { + ProbeNativeSymbol(surface); + TouchStaticMemberIfAvailable(type, surface.MemberName); + } + else if (surface.Kind.StartsWith("manual", StringComparison.Ordinal)) + { + TouchManualSurface(type, surface.MemberName); + } + } + + static Type ResolveManagedType(BindingSurfaceDescriptor surface) + { + var type = ResolveManagedType(surface.RuntimeTypeName, surface.AssemblyName); + if (type is null && surface.RuntimeTypeName.StartsWith("Firebase.", StringComparison.Ordinal)) + { + var runtimeParts = surface.RuntimeTypeName.Split('.'); + if (runtimeParts.Length > 2 && !runtimeParts[^1].StartsWith('I')) + { + var interfaceName = string.Join('.', runtimeParts.Take(runtimeParts.Length - 1).Append($"I{runtimeParts[^1]}")); + type = ResolveManagedType(interfaceName, surface.AssemblyName); + } + } + + return type ?? throw new TypeLoadException($"Managed type '{surface.RuntimeTypeName}' was not found in assembly '{surface.AssemblyName}'."); + } + + static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) + { + if (string.IsNullOrWhiteSpace(surface.MemberName)) + { + return; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.FlattenHierarchy; + if (string.Equals(surface.Kind, "property", StringComparison.Ordinal)) + { + var property = type.GetProperty(surface.MemberName, flags); + if (property is null) + { + throw new MissingMemberException(type.FullName, surface.MemberName); + } + + if (surface.HasGetter && property.GetMethod is null) + { + throw new MissingMemberException(type.FullName, "get_" + surface.MemberName); + } + + if (surface.HasSetter && property.SetMethod is null) + { + throw new MissingMemberException(type.FullName, "set_" + surface.MemberName); + } + + return; + } + + if (string.Equals(surface.Kind, "method", StringComparison.Ordinal)) + { + if (!type.GetMethods(flags).Any(method => + string.Equals(method.Name, surface.MemberName, StringComparison.Ordinal) && + method.GetParameters().Length == surface.ParameterCount)) + { + throw new MissingMethodException(type.FullName, surface.MemberName); + } + + return; + } + + if (string.Equals(surface.Kind, "constructor", StringComparison.Ordinal) && + !type.GetConstructors(flags).Any(constructor => constructor.GetParameters().Length == surface.ParameterCount)) + { + throw new MissingMethodException(type.FullName, ".ctor"); + } + } + + static Type? ResolveManagedType(string runtimeTypeName, string assemblyName) + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + var type = assembly.GetType(runtimeTypeName, throwOnError: false); + if (type is not null) + { + return type; + } + } + + if (!string.IsNullOrWhiteSpace(assemblyName)) + { + var assembly = Assembly.Load(new AssemblyName(assemblyName)); + return assembly.GetType(runtimeTypeName, throwOnError: false); + } + + return null; + } + + static void ProbeNativeClass(BindingSurfaceDescriptor surface) + { + if (string.IsNullOrWhiteSpace(surface.ObjectiveCName)) + { + return; + } + + var nativeClass = objc_getClass(surface.ObjectiveCName); + if (nativeClass == IntPtr.Zero) + { + throw new TypeLoadException($"Objective-C class '{surface.ObjectiveCName}' was not found for '{surface.SurfaceId}'."); + } + } + + static void ProbeProtocolSurface(BindingSurfaceDescriptor surface) + { + if (string.IsNullOrWhiteSpace(surface.ObjectiveCName)) + { + return; + } + + var protocol = objc_getProtocol(surface.ObjectiveCName); + if (protocol == IntPtr.Zero) + { + throw new TypeLoadException($"Objective-C protocol '{surface.ObjectiveCName}' was not found for '{surface.SurfaceId}'."); + } + + foreach (var selector in surface.NativeSelectors) + { + var selectorHandle = sel_registerName(selector.Selector); + var isInstanceMethod = selector.IsStatic ? (byte)0 : (byte)1; + var requiredMethod = protocol_getMethodDescription(protocol, selectorHandle, 1, isInstanceMethod); + var optionalMethod = protocol_getMethodDescription(protocol, selectorHandle, 0, isInstanceMethod); + if (requiredMethod.Name == IntPtr.Zero && optionalMethod.Name == IntPtr.Zero) + { + throw new MissingMethodException($"Objective-C protocol '{surface.ObjectiveCName}' does not expose selector '{selector.Selector}'."); + } + } + } + + static void ProbeClassSelector(BindingSurfaceDescriptor surface, BindingSurfaceNativeSelector selector) + { + if (string.IsNullOrWhiteSpace(surface.ObjectiveCName)) + { + return; + } + + var nativeClass = objc_getClass(surface.ObjectiveCName); + if (nativeClass == IntPtr.Zero) + { + throw new TypeLoadException($"Objective-C class '{surface.ObjectiveCName}' was not found for selector '{selector.Selector}'."); + } + + var selectorHandle = sel_registerName(selector.Selector); + var method = selector.IsStatic + ? class_getClassMethod(nativeClass, selectorHandle) + : class_getInstanceMethod(nativeClass, selectorHandle); + if (method == IntPtr.Zero) + { + throw new MissingMethodException($"Objective-C class '{surface.ObjectiveCName}' does not expose selector '{selector.Selector}'."); + } + } + + static void ProbeNativeSymbol(BindingSurfaceDescriptor surface) + { + if (string.IsNullOrWhiteSpace(surface.BindingValue)) + { + return; + } + + var handle = Dlfcn.dlopen(null, 0); + if (handle == IntPtr.Zero) + { + throw new DllNotFoundException("Unable to open the current process for native symbol lookup."); + } + + try + { + var symbol = Dlfcn.dlsym(handle, surface.BindingValue); + if (symbol == IntPtr.Zero) + { + throw new EntryPointNotFoundException($"Native symbol '{surface.BindingValue}' was not found for '{surface.SurfaceId}'."); + } + } + finally + { + Dlfcn.dlclose(handle); + } + } + + static void ResolveEnumMember(Type type, string? memberName) + { + if (string.IsNullOrWhiteSpace(memberName)) + { + return; + } + + if (!type.IsEnum || !Enum.GetNames(type).Contains(memberName, StringComparer.Ordinal)) + { + throw new MissingMemberException(type.FullName, memberName); + } + } + + static void TouchManualSurface(Type type, string? memberName) + { + if (string.IsNullOrWhiteSpace(memberName)) + { + return; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.FlattenHierarchy; + var property = type.GetProperty(memberName, flags); + if (property?.GetMethod?.IsStatic == true) + { + _ = property.GetValue(null); + return; + } + + if (property is not null) + { + return; + } + + var field = type.GetField(memberName, flags); + if (field?.IsStatic == true) + { + _ = field.GetValue(null); + return; + } + + if (field is not null) + { + return; + } + + if (type.GetMethods(flags).Any(method => string.Equals(method.Name, memberName, StringComparison.Ordinal))) + { + return; + } + + throw new MissingMemberException(type.FullName, memberName); + } + + static void TouchStaticMemberIfAvailable(Type type, string? memberName) + { + if (string.IsNullOrWhiteSpace(memberName)) + { + return; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy; + var property = type.GetProperty(memberName, flags); + if (property?.GetMethod is not null) + { + _ = property.GetValue(null); + return; + } + + var field = type.GetField(memberName, flags); + if (field is not null) + { + _ = field.GetValue(null); + } + } + + static BindingSurfaceCoverageDocument LoadCoverageDocument() + { + var path = NSBundle.MainBundle.PathForResource(CoverageResourceName, "json"); + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + throw new FileNotFoundException("Generated binding surface coverage document was not bundled.", CoverageResourceName + ".json"); + } + + return JsonSerializer.Deserialize( + File.ReadAllText(path), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("Unable to deserialize generated binding surface coverage document."); + } + + static IEnumerable SelectTargets( + BindingSurfaceCoverageDocument document, + string selectedTarget) + { + if (string.Equals(selectedTarget, "all", StringComparison.OrdinalIgnoreCase)) + { + return document.Targets.OrderBy(static target => target.Id, StringComparer.Ordinal); + } + + var target = document.Targets.FirstOrDefault(target => + string.Equals(target.Id, selectedTarget, StringComparison.OrdinalIgnoreCase)); + if (target is null) + { + throw new InvalidOperationException($"Generated binding surface coverage document does not contain target '{selectedTarget}'."); + } + + return [target]; + } + + public static string? GetConfiguredTarget() => + Assembly.GetExecutingAssembly() + .GetCustomAttributes() + .FirstOrDefault(static attribute => string.Equals(attribute.Key, "BindingSurfaceCoverageTarget", StringComparison.Ordinal)) + ?.Value; + + static bool IsBindingLayerFailure(Exception exception) => + exception is ObjCException || + exception is DllNotFoundException || + exception is EntryPointNotFoundException || + exception is TypeLoadException || + exception is MissingMethodException || + exception is MissingMemberException || + exception is TargetInvocationException || + exception is InvalidCastException || + exception is MarshalDirectiveException || + exception is SEHException || + exception is InvalidOperationException; + + [DllImport(Constants.ObjectiveCLibrary, EntryPoint = "objc_getClass")] + static extern IntPtr objc_getClass(string name); + + [DllImport(Constants.ObjectiveCLibrary, EntryPoint = "objc_getProtocol")] + static extern IntPtr objc_getProtocol(string name); + + [DllImport(Constants.ObjectiveCLibrary, EntryPoint = "sel_registerName")] + static extern IntPtr sel_registerName(string name); + + [DllImport(Constants.ObjectiveCLibrary, EntryPoint = "class_getInstanceMethod")] + static extern IntPtr class_getInstanceMethod(IntPtr cls, IntPtr selector); + + [DllImport(Constants.ObjectiveCLibrary, EntryPoint = "class_getClassMethod")] + static extern IntPtr class_getClassMethod(IntPtr cls, IntPtr selector); + + [DllImport(Constants.ObjectiveCLibrary, EntryPoint = "protocol_getMethodDescription")] + static extern ObjCMethodDescription protocol_getMethodDescription( + IntPtr protocol, + IntPtr selector, + byte isRequiredMethod, + byte isInstanceMethod); + + [StructLayout(LayoutKind.Sequential)] + readonly struct ObjCMethodDescription + { + public readonly IntPtr Name; + public readonly IntPtr Types; + } + + sealed class BindingSurfaceCoverageDocument + { + public List Targets { get; set; } = []; + public List Waivers { get; set; } = []; + } + + sealed class BindingSurfaceCoverageTargetDocument + { + public string Id { get; set; } = string.Empty; + public string PackageId { get; set; } = string.Empty; + public string CoverageCaseMethod { get; set; } = string.Empty; + public List SourceFiles { get; set; } = []; + public List RequiredPackages { get; set; } = []; + public List Surfaces { get; set; } = []; + } + + sealed class BindingSurfacePackageReference + { + public string Id { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + } + + sealed class BindingSurfaceWaiver + { + public string Target { get; set; } = string.Empty; + public string SurfaceId { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public string Evidence { get; set; } = string.Empty; + } + + sealed class BindingSurfaceDescriptor + { + public string Target { get; set; } = string.Empty; + public string SurfaceId { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public string TypeName { get; set; } = string.Empty; + public string RuntimeTypeName { get; set; } = string.Empty; + public string AssemblyName { get; set; } = string.Empty; + public string? ObjectiveCName { get; set; } + public string? ContainerKind { get; set; } + public bool IsProtocol { get; set; } + public bool IsStatic { get; set; } + public string? MemberName { get; set; } + public string? BindingAttribute { get; set; } + public string? BindingValue { get; set; } + public bool HasGetter { get; set; } + public bool HasSetter { get; set; } + public int ParameterCount { get; set; } + public List NativeSelectors { get; set; } = []; + public string SourceFile { get; set; } = string.Empty; + public string Signature { get; set; } = string.Empty; + } + + sealed class BindingSurfaceNativeSelector + { + public string Selector { get; set; } = string.Empty; + public bool IsStatic { get; set; } + public bool IsProtocol { get; set; } + } +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj index 418e58bc..c1e92821 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj @@ -18,9 +18,12 @@ + + + $(DefineConstants);ENABLE_NULLABILITY_VALIDATION @@ -42,6 +45,10 @@ None + + None + + @@ -58,6 +65,13 @@ + + + <_Parameter1>BindingSurfaceCoverageTarget + <_Parameter2>$(BindingSurfaceCoverageTarget) + + + diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs index 14abbab4..e705b5be 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs @@ -25,6 +25,9 @@ public static async Task RunAsync(StatusViewController statusViewController) await statusViewController.AppendLineAsync("Running Firebase binding smoke tests..."); #if ENABLE_NULLABILITY_VALIDATION await statusViewController.AppendLineAsync("Firebase nullability validation mode enabled."); +#endif +#if ENABLE_BINDING_SURFACE_COVERAGE + await statusViewController.AppendLineAsync("Firebase binding surface coverage mode enabled."); #endif var runtimeDriftCase = FirebaseRuntimeDriftCases.GetConfiguredCaseId(); if (!string.IsNullOrWhiteSpace(runtimeDriftCase)) @@ -54,6 +57,13 @@ await ExecuteCaseAsync(result, statusViewController, "ConfigureApp", async () => await ExecuteCaseAsync(result, statusViewController, $"RuntimeDrift:{runtimeDriftCase}", () => FirebaseRuntimeDriftCases.ExecuteConfiguredCaseAsync()); } +#if ENABLE_BINDING_SURFACE_COVERAGE + else if (!string.IsNullOrWhiteSpace(FirebaseBindingSurfaceCoverage.GetConfiguredTarget())) + { + await ExecuteCaseAsync(result, statusViewController, "BindingSurfaceCoverage", () => + FirebaseBindingSurfaceCoverage.VerifyConfiguredAsync(result)); + } +#endif else { await ExecuteCaseAsync(result, statusViewController, "CoreSurface", async () => diff --git a/tests/E2E/Firebase.Foundation/README.md b/tests/E2E/Firebase.Foundation/README.md index 8a24cfc4..435c12b3 100644 --- a/tests/E2E/Firebase.Foundation/README.md +++ b/tests/E2E/Firebase.Foundation/README.md @@ -92,3 +92,24 @@ Run a specific drift case with: ```sh tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --runtime-drift-case cloudfirestore-getquerynamed ``` + +## Binding surface coverage mode + +The opt-in binding surface coverage lane accounts for the 14 active Firebase `12.6.0` bindings tracked by the audit config. It generates a source-derived coverage inventory from [`binding-surface-coverage.json`](./binding-surface-coverage.json), restores the selected target package set from the local feed, and runs `ConfigureApp` plus the generated surface coverage case. + +This lane treats native Firebase/backend errors as acceptable when the binding surface is present and loadable. It fails binding-layer problems such as missing managed types, missing Objective-C classes/protocols/selectors, missing native symbols, `TypeLoadException`, `DllNotFoundException`, `EntryPointNotFoundException`, and `ObjCRuntime.ObjCException`. + +Pack the active Firebase set when running the aggregate lane: + +```sh +dotnet tool restore +dotnet tool run dotnet-cake -- --target=nuget --names="Firebase.ABTesting,Firebase.Analytics,Firebase.AppCheck,Firebase.Auth,Firebase.CloudFirestore,Firebase.CloudFunctions,Firebase.CloudMessaging,Firebase.Core,Firebase.Crashlytics,Firebase.Database,Firebase.Installations,Firebase.PerformanceMonitoring,Firebase.RemoteConfig,Firebase.Storage" +``` + +Run a representative target or the full active set with: + +```sh +tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --binding-surface-target Core +tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --binding-surface-target CloudFirestore +tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --binding-surface-target all +``` diff --git a/tests/E2E/Firebase.Foundation/binding-surface-coverage.json b/tests/E2E/Firebase.Foundation/binding-surface-coverage.json new file mode 100644 index 00000000..bbdc62ef --- /dev/null +++ b/tests/E2E/Firebase.Foundation/binding-surface-coverage.json @@ -0,0 +1,160 @@ +{ + "targets": [ + { + "id": "ABTesting", + "packageId": "AdamE.Firebase.iOS.ABTesting", + "coverageCaseMethod": "VerifyABTestingBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/ABTesting/ApiDefinition.cs", + "source/Firebase/ABTesting/Enums.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "Analytics", + "packageId": "AdamE.Firebase.iOS.Analytics", + "coverageCaseMethod": "VerifyAnalyticsBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Analytics/ApiDefinition.cs", + "source/Firebase/Analytics/Enums.cs", + "source/Firebase/Analytics/Extension.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "AppCheck", + "packageId": "AdamE.Firebase.iOS.AppCheck", + "coverageCaseMethod": "VerifyAppCheckBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/AppCheck/ApiDefinition.cs", + "source/Firebase/AppCheck/Enums.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "Auth", + "packageId": "AdamE.Firebase.iOS.Auth", + "coverageCaseMethod": "VerifyAuthBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Auth/ApiDefinition.cs", + "source/Firebase/Auth/Enums.cs", + "source/Firebase/Auth/Extension.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "CloudFirestore", + "packageId": "AdamE.Firebase.iOS.CloudFirestore", + "coverageCaseMethod": "VerifyCloudFirestoreBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/CloudFirestore/ApiDefinition.cs", + "source/Firebase/CloudFirestore/Enums.cs", + "source/Firebase/CloudFirestore/Extensions.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "CloudFunctions", + "packageId": "AdamE.Firebase.iOS.CloudFunctions", + "coverageCaseMethod": "VerifyCloudFunctionsBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/CloudFunctions/ApiDefinition.cs", + "source/Firebase/CloudFunctions/Enums.cs", + "source/Firebase/CloudFunctions/Extension.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "CloudMessaging", + "packageId": "AdamE.Firebase.iOS.CloudMessaging", + "coverageCaseMethod": "VerifyCloudMessagingBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/CloudMessaging/ApiDefinition.cs", + "source/Firebase/CloudMessaging/Enums.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "Core", + "packageId": "AdamE.Firebase.iOS.Core", + "coverageCaseMethod": "VerifyCoreBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Core/ApiDefinition.cs", + "source/Firebase/Core/Enums.cs", + "source/Firebase/Core/Extension.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "Crashlytics", + "packageId": "AdamE.Firebase.iOS.Crashlytics", + "coverageCaseMethod": "VerifyCrashlyticsBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Crashlytics/ApiDefinition.cs", + "source/Firebase/Crashlytics/Enums.cs", + "source/Firebase/Crashlytics/Extensions.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "Database", + "packageId": "AdamE.Firebase.iOS.Database", + "coverageCaseMethod": "VerifyDatabaseBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Database/ApiDefinition.cs", + "source/Firebase/Database/Enums.cs", + "source/Firebase/Database/Extension.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "Installations", + "packageId": "AdamE.Firebase.iOS.Installations", + "coverageCaseMethod": "VerifyInstallationsBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Installations/ApiDefinition.cs", + "source/Firebase/Installations/Enums.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "PerformanceMonitoring", + "packageId": "AdamE.Firebase.iOS.PerformanceMonitoring", + "coverageCaseMethod": "VerifyPerformanceMonitoringBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/PerformanceMonitoring/ApiDefinition.cs", + "source/Firebase/PerformanceMonitoring/Enums.cs", + "source/Firebase/PerformanceMonitoring/Extensions.cs" + ], + "requiredExtraPackages": [] + }, + { + "id": "RemoteConfig", + "packageId": "AdamE.Firebase.iOS.RemoteConfig", + "coverageCaseMethod": "VerifyRemoteConfigBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/RemoteConfig/ApiDefinition.cs", + "source/Firebase/RemoteConfig/Enums.cs", + "source/Firebase/RemoteConfig/Extension.cs" + ], + "requiredExtraPackages": [ + { + "id": "AdamE.Firebase.iOS.ABTesting", + "version": "12.6.0" + } + ] + }, + { + "id": "Storage", + "packageId": "AdamE.Firebase.iOS.Storage", + "coverageCaseMethod": "VerifyStorageBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/Storage/ApiDefinition.cs", + "source/Firebase/Storage/Enums.cs", + "source/Firebase/Storage/Extension.cs" + ], + "requiredExtraPackages": [] + } + ], + "waivers": [] +} diff --git a/tools/e2e/run-firebase-foundation.sh b/tools/e2e/run-firebase-foundation.sh index 0fb67d55..d5ff8e83 100755 --- a/tools/e2e/run-firebase-foundation.sh +++ b/tools/e2e/run-firebase-foundation.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: tools/e2e/run-firebase-foundation.sh [--package-dir output] [--configuration Release] [--enable-nullability-validation] [--runtime-drift-case ] +Usage: tools/e2e/run-firebase-foundation.sh [--package-dir output] [--configuration Release] [--enable-nullability-validation] [--runtime-drift-case ] [--binding-surface-target ] EOF } @@ -14,6 +14,7 @@ bundle_id="com.googleapisforioscomponents.tests.firebase.e2e" configuration="Release" enable_nullability_validation="false" runtime_drift_case="" +binding_surface_target="" package_dir="$repo_root/output" artifacts_dir="$repo_root/tests/E2E/Firebase.Foundation/artifacts" log_file="$artifacts_dir/firebase-foundation-sim.log" @@ -26,6 +27,10 @@ runtime_drift_props="$artifacts_dir/runtime-drift-case.generated.props" runtime_drift_info="$artifacts_dir/runtime-drift-case.info" runtime_drift_method="" runtime_drift_binding_package="" +binding_surface_manifest="$repo_root/tests/E2E/Firebase.Foundation/binding-surface-coverage.json" +binding_surface_document="$artifacts_dir/binding-surface-coverage.generated.json" +binding_surface_props="$artifacts_dir/binding-surface-coverage.generated.props" +binding_surface_info="$artifacts_dir/binding-surface-coverage.info" while [[ $# -gt 0 ]]; do case "$1" in @@ -45,6 +50,10 @@ while [[ $# -gt 0 ]]; do runtime_drift_case="$2" shift 2 ;; + --binding-surface-target) + binding_surface_target="$2" + shift 2 + ;; --help|-h) usage exit 0 @@ -67,8 +76,12 @@ mkdir -p "$artifacts_dir" rm -rf "$packages_cache_dir" mkdir -p "$packages_cache_dir" -if [[ "$enable_nullability_validation" == "true" && -n "$runtime_drift_case" ]]; then - echo "--enable-nullability-validation and --runtime-drift-case cannot be used together." >&2 +selected_modes=0 +[[ "$enable_nullability_validation" == "true" ]] && selected_modes=$((selected_modes + 1)) +[[ -n "$runtime_drift_case" ]] && selected_modes=$((selected_modes + 1)) +[[ -n "$binding_surface_target" ]] && selected_modes=$((selected_modes + 1)) +if (( selected_modes > 1 )); then + echo "--enable-nullability-validation, --runtime-drift-case, and --binding-surface-target are mutually exclusive." >&2 exit 1 fi @@ -168,6 +181,53 @@ PY echo "Runtime drift case: $runtime_drift_case ($runtime_drift_binding_package)" fi +if [[ -n "$binding_surface_target" ]]; then + if [[ ! -f "$binding_surface_manifest" ]]; then + echo "Missing binding surface coverage manifest: $binding_surface_manifest" >&2 + exit 1 + fi + + echo "Generating binding surface coverage inventory for target: $binding_surface_target" + dotnet run --project "$repo_root/scripts/FirebaseBindingAudit/FirebaseBindingAudit.csproj" -- \ + --generate-binding-surface-coverage \ + --repo-root "$repo_root" \ + --coverage-manifest "$binding_surface_manifest" \ + --coverage-output "$binding_surface_document" \ + --coverage-props-output "$binding_surface_props" \ + --binding-surface-target "$binding_surface_target" \ + > "$binding_surface_info" + + python3 - "$binding_surface_document" >> "$binding_surface_info" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text()) +seen = set() +for target in document.get("targets", []): + for package in target.get("requiredPackages", []): + package_id = package.get("id") + if package_id and package_id not in seen: + seen.add(package_id) + print(package_id) +PY + + binding_surface_details=("${(@f)$(<"$binding_surface_info")}") + for detail in "${binding_surface_details[@]}"; do + if [[ "$detail" == AdamE.* ]]; then + required_packages+=("$detail") + fi + done + + msbuild_args+=( + "-p:BindingSurfaceCoverageTarget=$binding_surface_target" + "-p:BindingSurfaceCoveragePropsPath=$binding_surface_props" + ) + restore_args+=("--force-evaluate") + + echo "Binding surface coverage target: $binding_surface_target" +fi + for package_name in "${required_packages[@]}"; do if ! find "$package_dir" -maxdepth 1 -name "${package_name}.*.nupkg" -print -quit | grep -q .; then echo "Missing package in local feed: $package_name" >&2 From 9c2a866eae9e14cb4d7289abf94e588e0962aa42 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 16:00:50 -0400 Subject: [PATCH 07/23] Match manual coverage surfaces by signature --- .../BindingSurfaceCoverageBuilderTests.cs | 82 ++++++++++ .../BindingSurfaceCoverageValidatorTests.cs | 1 + scripts/FirebaseBindingAudit/BindingModel.cs | 7 + .../BindingSurfaceCoverage.cs | 25 ++- .../FirebaseBindingSurfaceCoverage.cs | 152 +++++++++++++++++- 5 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs new file mode 100644 index 00000000..cd348227 --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -0,0 +1,82 @@ +using Xunit; + +namespace FirebaseBindingAudit.Tests; + +public sealed class BindingSurfaceCoverageBuilderTests +{ + [Fact] + public void Build_RecordsManualMethodParameterTypes() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + public partial class AuthManualSurface + { + [Wrap("SignInWithEmail")] + public string SignIn(string email, bool createUser) => email; + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.MemberName == "SignIn"); + + Assert.Equal(2, surface.ParameterCount); + Assert.Equal(["string", "bool"], surface.ParameterTypes); + Assert.Equal("SignIn(string, bool) -> string", surface.Signature); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + private static AuditConfiguration CreateConfiguration() => + new() + { + ManualAttributes = ["Wrap"], + BindingAttributes = ["Export", "Field", "Notification"], + Targets = + [ + new AuditTargetDefinition + { + Id = "Auth", + PackageId = "AdamE.Firebase.iOS.Auth", + BaselineDirectory = Path.Combine("source", "Firebase", "Auth"), + BaselineFiles = ["ApiDefinition.cs"], + HelperFiles = [] + } + ] + }; + + private static BindingSurfaceCoverageManifest CreateManifest() => + new() + { + Targets = + [ + new BindingSurfaceCoverageTargetManifest + { + Id = "Auth", + PackageId = "AdamE.Firebase.iOS.Auth", + CoverageCaseMethod = "VerifyAuthBindingSurfaceAsync", + SourceFiles = [Path.Combine("source", "Firebase", "Auth", "ApiDefinition.cs")] + } + ] + }; +} diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs index 7a0cd3ae..c9a1fac4 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs @@ -98,6 +98,7 @@ private static BindingSurfaceDescriptor CreateSurface(string surfaceId) => HasGetter: false, HasSetter: false, ParameterCount: 0, + ParameterTypes: [], NativeSelectors: [], SourceFile: "source/Firebase/Core/ApiDefinition.cs", Signature: "interface App"); diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index f5f01afd..8073dd8f 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -79,6 +79,7 @@ internal sealed record ManualSurfaceItem( string MatchTypeKey, string? MatchMemberKey, string? MemberName, + IReadOnlyList ParameterTypes, string Signature, string SourceFile) { @@ -289,6 +290,7 @@ private static void AddManualTypeItems( MatchTypeKey: typeMatchKey, MatchMemberKey: null, MemberName: null, + ParameterTypes: [], Signature: $"{containerKind} {typeName}", SourceFile: filePath)); return; @@ -302,6 +304,7 @@ private static void AddManualTypeItems( MatchTypeKey: typeMatchKey, MatchMemberKey: member.Key, MemberName: member.Name, + ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), Signature: member.Signature, SourceFile: member.SourceFile)); } @@ -331,6 +334,7 @@ private void ParseMethod( MatchTypeKey: containingTypeMatchKey, MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.Parameters.Count), MemberName: methodDeclaration.Identifier.Text, + ParameterTypes: methodDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), Signature: signature, SourceFile: filePath) { @@ -392,6 +396,7 @@ private void ParseProperty( MatchTypeKey: containingTypeMatchKey, MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0), MemberName: propertyDeclaration.Identifier.Text, + ParameterTypes: [], Signature: signature, SourceFile: filePath) { @@ -444,6 +449,7 @@ private void ParseDelegate( MatchTypeKey: delegateDeclaration.Identifier.Text, MatchMemberKey: null, MemberName: null, + ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), Signature: BuildDelegateSignature(delegateDeclaration), SourceFile: filePath)); return; @@ -478,6 +484,7 @@ private void ParseEnum( MatchTypeKey: enumDeclaration.Identifier.Text, MatchMemberKey: null, MemberName: null, + ParameterTypes: [], Signature: $"enum {enumDeclaration.Identifier.Text}", SourceFile: filePath)); return; diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 9f38dfb8..11d3f6e1 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -76,6 +76,7 @@ internal sealed record BindingSurfaceDescriptor( bool HasGetter, bool HasSetter, int ParameterCount, + IReadOnlyList ParameterTypes, IReadOnlyList NativeSelectors, string SourceFile, string Signature); @@ -351,6 +352,7 @@ private static IEnumerable BuildSurfaces( HasGetter: false, HasSetter: false, ParameterCount: 0, + ParameterTypes: [], NativeSelectors: EmptySelectors, SourceFile: boundType.SourceFile, Signature: $"{boundType.ContainerKind} {boundType.Name}"); @@ -376,6 +378,7 @@ private static IEnumerable BuildSurfaces( HasGetter: member.HasGetter, HasSetter: member.HasSetter, ParameterCount: member.Parameters.Count, + ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), NativeSelectors: BuildNativeSelectors(boundType, member).ToList(), SourceFile: member.SourceFile, Signature: member.Signature); @@ -407,6 +410,7 @@ private static IEnumerable BuildSurfaces( HasGetter: false, HasSetter: false, ParameterCount: delegateSurface.Parameters.Count, + ParameterTypes: delegateSurface.Parameters.Select(static parameter => parameter.Type).ToList(), NativeSelectors: EmptySelectors, SourceFile: delegateSurface.SourceFile, Signature: delegateSurface.Signature); @@ -431,6 +435,7 @@ private static IEnumerable BuildSurfaces( HasGetter: false, HasSetter: false, ParameterCount: 0, + ParameterTypes: [], NativeSelectors: EmptySelectors, SourceFile: enumSurface.SourceFile, Signature: $"enum {enumSurface.Name}"); @@ -454,6 +459,7 @@ private static IEnumerable BuildSurfaces( HasGetter: false, HasSetter: false, ParameterCount: 0, + ParameterTypes: [], NativeSelectors: EmptySelectors, SourceFile: enumMember.SourceFile, Signature: $"{enumSurface.Name}.{enumMember.Name}"); @@ -490,7 +496,8 @@ private static IEnumerable BuildSurfaces( BindingValue: bindingValue, HasGetter: false, HasSetter: false, - ParameterCount: 0, + ParameterCount: manualItem.ParameterTypes.Count, + ParameterTypes: manualItem.ParameterTypes, NativeSelectors: string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(bindingValue) ? [new BindingSurfaceNativeSelector(bindingValue!, IsStatic: false, IsProtocol: false)] : EmptySelectors, @@ -579,6 +586,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s surfaceId: $"{target}:manual-type:{typeName}", memberName: null, signature: $"{GetTypeDeclarationKind(typeDeclaration)} {typeDeclaration.Identifier.Text}", + parameterTypes: [], isStatic: typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); foreach (var property in typeDeclaration.Members.OfType()) @@ -593,9 +601,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s helperFile, typeName, namespaceName, - surfaceId: $"{target}:manual:{typeName}:{property.Identifier.Text}:{CreateSurfaceIdKey($"{property.Type.WithoutTrivia()} {property.Identifier.Text}")}", + surfaceId: $"{target}:manual:{typeName}:{property.Identifier.Text}:{CreateSurfaceIdKey($"{property.Type.WithoutTrivia()} {property.Identifier.Text}")}", memberName: property.Identifier.Text, signature: $"{property.Type.WithoutTrivia()} {property.Identifier.Text} {{ get; }}", + parameterTypes: [], isStatic: property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); } @@ -606,8 +615,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s continue; } - var parameterTypes = string.Join(", ", method.ParameterList.Parameters.Select(static parameter => parameter.Type?.WithoutTrivia().ToString() ?? "object")); - var signature = $"{method.Identifier.Text}({parameterTypes}) -> {method.ReturnType.WithoutTrivia()}"; + var parameterTypes = method.ParameterList.Parameters + .Select(static parameter => parameter.Type?.WithoutTrivia().ToString() ?? "object") + .ToList(); + var signature = $"{method.Identifier.Text}({string.Join(", ", parameterTypes)}) -> {method.ReturnType.WithoutTrivia()}"; yield return CreatePublicHelperSurface( target, helperFile, @@ -616,6 +627,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s surfaceId: $"{target}:manual:{typeName}:{method.Identifier.Text}:{method.ParameterList.Parameters.Count}:{CreateSurfaceIdKey(signature)}", memberName: method.Identifier.Text, signature: signature, + parameterTypes: parameterTypes, isStatic: method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); } @@ -636,6 +648,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s surfaceId: $"{target}:manual-field:{typeName}:{variable.Identifier.Text}:{CreateSurfaceIdKey($"{field.Declaration.Type.WithoutTrivia()} {variable.Identifier.Text}")}", memberName: variable.Identifier.Text, signature: $"{field.Declaration.Type.WithoutTrivia()} {variable.Identifier.Text}", + parameterTypes: [], isStatic: field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)) || field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ConstKeyword))); } @@ -652,6 +665,7 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( string surfaceId, string? memberName, string signature, + IReadOnlyList parameterTypes, bool isStatic) => new( Target: target, @@ -669,7 +683,8 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( BindingValue: null, HasGetter: false, HasSetter: false, - ParameterCount: 0, + ParameterCount: parameterTypes.Count, + ParameterTypes: parameterTypes, NativeSelectors: EmptySelectors, SourceFile: helperFile, Signature: signature); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 4388738c..2895f668 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -10,6 +10,27 @@ namespace FirebaseFoundationE2E; public static partial class FirebaseBindingSurfaceCoverage { const string CoverageResourceName = "binding-surface-coverage.generated"; + static readonly IReadOnlyDictionary CSharpTypeAliases = new Dictionary + { + [typeof(bool)] = "bool", + [typeof(byte)] = "byte", + [typeof(sbyte)] = "sbyte", + [typeof(char)] = "char", + [typeof(decimal)] = "decimal", + [typeof(double)] = "double", + [typeof(float)] = "float", + [typeof(int)] = "int", + [typeof(uint)] = "uint", + [typeof(long)] = "long", + [typeof(ulong)] = "ulong", + [typeof(object)] = "object", + [typeof(short)] = "short", + [typeof(ushort)] = "ushort", + [typeof(string)] = "string", + [typeof(void)] = "void", + [typeof(IntPtr)] = "nint", + [typeof(UIntPtr)] = "nuint", + }; public static async Task VerifyConfiguredAsync(FirebaseE2ERunResult runResult) { @@ -198,7 +219,7 @@ static void ExerciseSurface(BindingSurfaceDescriptor surface) } else if (surface.Kind.StartsWith("manual", StringComparison.Ordinal)) { - TouchManualSurface(type, surface.MemberName); + TouchManualSurface(type, surface); } } @@ -389,8 +410,9 @@ static void ResolveEnumMember(Type type, string? memberName) } } - static void TouchManualSurface(Type type, string? memberName) + static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) { + var memberName = surface.MemberName; if (string.IsNullOrWhiteSpace(memberName)) { return; @@ -421,12 +443,133 @@ static void TouchManualSurface(Type type, string? memberName) return; } - if (type.GetMethods(flags).Any(method => string.Equals(method.Name, memberName, StringComparison.Ordinal))) + if (type.GetMethods(flags).Any(method => MethodMatchesManualSurface(method, surface))) { return; } - throw new MissingMemberException(type.FullName, memberName); + throw new MissingMethodException(type.FullName, surface.Signature); + } + + static bool MethodMatchesManualSurface(MethodInfo method, BindingSurfaceDescriptor surface) + { + if (!string.Equals(method.Name, surface.MemberName, StringComparison.Ordinal)) + { + return false; + } + + var parameters = method.GetParameters(); + if (parameters.Length != surface.ParameterCount) + { + return false; + } + + if (surface.ParameterTypes.Count == 0) + { + return true; + } + + if (parameters.Length != surface.ParameterTypes.Count) + { + return false; + } + + return parameters + .Zip(surface.ParameterTypes, static (parameter, expectedType) => TypeMatches(parameter.ParameterType, expectedType)) + .All(static matches => matches); + } + + static bool TypeMatches(Type actualType, string expectedType) + { + var normalizedExpected = NormalizeTypeName(expectedType); + return CreateTypeNameCandidates(actualType) + .Any(candidate => string.Equals(candidate, normalizedExpected, StringComparison.Ordinal)); + } + + static IReadOnlyList CreateTypeNameCandidates(Type type) + { + if (type.IsByRef) + { + type = type.GetElementType() ?? type; + } + + if (type.IsArray) + { + return CreateTypeNameCandidates(type.GetElementType() ?? type) + .Select(static candidate => NormalizeTypeName($"{candidate}[]")) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + var candidates = new List(); + if (type.IsGenericType) + { + var genericDefinition = type.GetGenericTypeDefinition(); + var genericArguments = type.GetGenericArguments(); + foreach (var genericTypeName in CreateGenericTypeNameCandidates(genericDefinition)) + { + var useFullArgumentNames = genericTypeName.Contains('.', StringComparison.Ordinal); + var argumentNames = genericArguments + .Select(argument => SelectTypeNameCandidate(argument, useFullArgumentNames)) + .ToList(); + AddTypeNameCandidate(candidates, $"{genericTypeName}<{string.Join(", ", argumentNames)}>"); + } + } + else + { + if (CSharpTypeAliases.TryGetValue(type, out var alias)) + { + AddTypeNameCandidate(candidates, alias); + } + + AddTypeNameCandidate(candidates, type.Name); + AddTypeNameCandidate(candidates, type.FullName); + } + + return candidates; + } + + static IEnumerable CreateGenericTypeNameCandidates(Type genericDefinition) + { + yield return StripGenericArity(genericDefinition.Name); + if (!string.IsNullOrWhiteSpace(genericDefinition.FullName)) + { + yield return StripGenericArity(genericDefinition.FullName); + } + } + + static string SelectTypeNameCandidate(Type type, bool preferFullName) + { + var candidates = CreateTypeNameCandidates(type); + return preferFullName + ? candidates.FirstOrDefault(static candidate => candidate.Contains('.', StringComparison.Ordinal)) ?? candidates[0] + : candidates[0]; + } + + static void AddTypeNameCandidate(List candidates, string? typeName) + { + var normalizedTypeName = NormalizeTypeName(typeName); + if (!string.IsNullOrWhiteSpace(normalizedTypeName) && + !candidates.Contains(normalizedTypeName, StringComparer.Ordinal)) + { + candidates.Add(normalizedTypeName); + } + } + + static string NormalizeTypeName(string? typeName) + { + return typeName? + .Replace("global::", string.Empty, StringComparison.Ordinal) + .Replace("+", ".", StringComparison.Ordinal) + .Replace("?", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Trim() ?? string.Empty; + } + + static string StripGenericArity(string typeName) + { + var tickIndex = typeName.IndexOf('`', StringComparison.Ordinal); + return tickIndex < 0 ? typeName : typeName[..tickIndex]; } static void TouchStaticMemberIfAvailable(Type type, string? memberName) @@ -581,6 +724,7 @@ sealed class BindingSurfaceDescriptor public bool HasGetter { get; set; } public bool HasSetter { get; set; } public int ParameterCount { get; set; } + public List ParameterTypes { get; set; } = []; public List NativeSelectors { get; set; } = []; public string SourceFile { get; set; } = string.Empty; public string Signature { get; set; } = string.Empty; From e26ba90d7a8d35217c51a22dea74ef223cd5dfe6 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 16:16:10 -0400 Subject: [PATCH 08/23] Match bound coverage overloads by signature --- .../FirebaseBindingSurfaceCoverage.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 2895f668..bc8d4f3b 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -270,18 +270,16 @@ static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) if (string.Equals(surface.Kind, "method", StringComparison.Ordinal)) { - if (!type.GetMethods(flags).Any(method => - string.Equals(method.Name, surface.MemberName, StringComparison.Ordinal) && - method.GetParameters().Length == surface.ParameterCount)) + if (!type.GetMethods(flags).Any(method => MethodMatchesSurface(method, surface))) { - throw new MissingMethodException(type.FullName, surface.MemberName); + throw new MissingMethodException(type.FullName, surface.Signature); } return; } if (string.Equals(surface.Kind, "constructor", StringComparison.Ordinal) && - !type.GetConstructors(flags).Any(constructor => constructor.GetParameters().Length == surface.ParameterCount)) + !type.GetConstructors(flags).Any(constructor => ParametersMatch(constructor.GetParameters(), surface))) { throw new MissingMethodException(type.FullName, ".ctor"); } @@ -443,7 +441,7 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) return; } - if (type.GetMethods(flags).Any(method => MethodMatchesManualSurface(method, surface))) + if (type.GetMethods(flags).Any(method => MethodMatchesSurface(method, surface))) { return; } @@ -451,14 +449,18 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) throw new MissingMethodException(type.FullName, surface.Signature); } - static bool MethodMatchesManualSurface(MethodInfo method, BindingSurfaceDescriptor surface) + static bool MethodMatchesSurface(MethodInfo method, BindingSurfaceDescriptor surface) { if (!string.Equals(method.Name, surface.MemberName, StringComparison.Ordinal)) { return false; } - var parameters = method.GetParameters(); + return ParametersMatch(method.GetParameters(), surface); + } + + static bool ParametersMatch(ParameterInfo[] parameters, BindingSurfaceDescriptor surface) + { if (parameters.Length != surface.ParameterCount) { return false; From 4e8c4c2ae72482bf8b499d994e61f25ee4400011 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 16:39:18 -0400 Subject: [PATCH 09/23] Validate coverage surface return types --- .../BindingSurfaceCoverageBuilderTests.cs | 1 + .../BindingSurfaceCoverageValidatorTests.cs | 1 + scripts/FirebaseBindingAudit/BindingModel.cs | 7 +++++++ .../BindingSurfaceCoverage.cs | 13 +++++++++++++ .../FirebaseBindingSurfaceCoverage.cs | 16 +++++++++++++++- 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index cd348227..218ba27d 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -36,6 +36,7 @@ public partial class AuthManualSurface Assert.Equal(2, surface.ParameterCount); Assert.Equal(["string", "bool"], surface.ParameterTypes); + Assert.Equal("string", surface.ReturnType); Assert.Equal("SignIn(string, bool) -> string", surface.Signature); } finally diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs index c9a1fac4..8df0695f 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs @@ -99,6 +99,7 @@ private static BindingSurfaceDescriptor CreateSurface(string surfaceId) => HasSetter: false, ParameterCount: 0, ParameterTypes: [], + ReturnType: null, NativeSelectors: [], SourceFile: "source/Firebase/Core/ApiDefinition.cs", Signature: "interface App"); diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 8073dd8f..0a31c01c 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -80,6 +80,7 @@ internal sealed record ManualSurfaceItem( string? MatchMemberKey, string? MemberName, IReadOnlyList ParameterTypes, + string? ReturnType, string Signature, string SourceFile) { @@ -291,6 +292,7 @@ private static void AddManualTypeItems( MatchMemberKey: null, MemberName: null, ParameterTypes: [], + ReturnType: null, Signature: $"{containerKind} {typeName}", SourceFile: filePath)); return; @@ -305,6 +307,7 @@ private static void AddManualTypeItems( MatchMemberKey: member.Key, MemberName: member.Name, ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), + ReturnType: member.ReturnType, Signature: member.Signature, SourceFile: member.SourceFile)); } @@ -335,6 +338,7 @@ private void ParseMethod( MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.Parameters.Count), MemberName: methodDeclaration.Identifier.Text, ParameterTypes: methodDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), + ReturnType: NormalizeType(methodDeclaration.ReturnType), Signature: signature, SourceFile: filePath) { @@ -397,6 +401,7 @@ private void ParseProperty( MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0), MemberName: propertyDeclaration.Identifier.Text, ParameterTypes: [], + ReturnType: NormalizeType(propertyDeclaration.Type), Signature: signature, SourceFile: filePath) { @@ -450,6 +455,7 @@ private void ParseDelegate( MatchMemberKey: null, MemberName: null, ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), + ReturnType: NormalizeType(delegateDeclaration.ReturnType), Signature: BuildDelegateSignature(delegateDeclaration), SourceFile: filePath)); return; @@ -485,6 +491,7 @@ private void ParseEnum( MatchMemberKey: null, MemberName: null, ParameterTypes: [], + ReturnType: null, Signature: $"enum {enumDeclaration.Identifier.Text}", SourceFile: filePath)); return; diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 11d3f6e1..ada8b659 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -77,6 +77,7 @@ internal sealed record BindingSurfaceDescriptor( bool HasSetter, int ParameterCount, IReadOnlyList ParameterTypes, + string? ReturnType, IReadOnlyList NativeSelectors, string SourceFile, string Signature); @@ -353,6 +354,7 @@ private static IEnumerable BuildSurfaces( HasSetter: false, ParameterCount: 0, ParameterTypes: [], + ReturnType: null, NativeSelectors: EmptySelectors, SourceFile: boundType.SourceFile, Signature: $"{boundType.ContainerKind} {boundType.Name}"); @@ -379,6 +381,7 @@ private static IEnumerable BuildSurfaces( HasSetter: member.HasSetter, ParameterCount: member.Parameters.Count, ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), + ReturnType: member.ReturnType, NativeSelectors: BuildNativeSelectors(boundType, member).ToList(), SourceFile: member.SourceFile, Signature: member.Signature); @@ -411,6 +414,7 @@ private static IEnumerable BuildSurfaces( HasSetter: false, ParameterCount: delegateSurface.Parameters.Count, ParameterTypes: delegateSurface.Parameters.Select(static parameter => parameter.Type).ToList(), + ReturnType: delegateSurface.ReturnType, NativeSelectors: EmptySelectors, SourceFile: delegateSurface.SourceFile, Signature: delegateSurface.Signature); @@ -436,6 +440,7 @@ private static IEnumerable BuildSurfaces( HasSetter: false, ParameterCount: 0, ParameterTypes: [], + ReturnType: null, NativeSelectors: EmptySelectors, SourceFile: enumSurface.SourceFile, Signature: $"enum {enumSurface.Name}"); @@ -460,6 +465,7 @@ private static IEnumerable BuildSurfaces( HasSetter: false, ParameterCount: 0, ParameterTypes: [], + ReturnType: null, NativeSelectors: EmptySelectors, SourceFile: enumMember.SourceFile, Signature: $"{enumSurface.Name}.{enumMember.Name}"); @@ -498,6 +504,7 @@ private static IEnumerable BuildSurfaces( HasSetter: false, ParameterCount: manualItem.ParameterTypes.Count, ParameterTypes: manualItem.ParameterTypes, + ReturnType: manualItem.ReturnType, NativeSelectors: string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(bindingValue) ? [new BindingSurfaceNativeSelector(bindingValue!, IsStatic: false, IsProtocol: false)] : EmptySelectors, @@ -587,6 +594,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s memberName: null, signature: $"{GetTypeDeclarationKind(typeDeclaration)} {typeDeclaration.Identifier.Text}", parameterTypes: [], + returnType: null, isStatic: typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); foreach (var property in typeDeclaration.Members.OfType()) @@ -605,6 +613,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s memberName: property.Identifier.Text, signature: $"{property.Type.WithoutTrivia()} {property.Identifier.Text} {{ get; }}", parameterTypes: [], + returnType: property.Type.WithoutTrivia().ToString(), isStatic: property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); } @@ -628,6 +637,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s memberName: method.Identifier.Text, signature: signature, parameterTypes: parameterTypes, + returnType: method.ReturnType.WithoutTrivia().ToString(), isStatic: method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); } @@ -649,6 +659,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s memberName: variable.Identifier.Text, signature: $"{field.Declaration.Type.WithoutTrivia()} {variable.Identifier.Text}", parameterTypes: [], + returnType: field.Declaration.Type.WithoutTrivia().ToString(), isStatic: field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)) || field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ConstKeyword))); } @@ -666,6 +677,7 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( string? memberName, string signature, IReadOnlyList parameterTypes, + string? returnType, bool isStatic) => new( Target: target, @@ -685,6 +697,7 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( HasSetter: false, ParameterCount: parameterTypes.Count, ParameterTypes: parameterTypes, + ReturnType: returnType, NativeSelectors: EmptySelectors, SourceFile: helperFile, Signature: signature); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index bc8d4f3b..449a7f7b 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -255,6 +255,12 @@ static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) throw new MissingMemberException(type.FullName, surface.MemberName); } + if (!string.IsNullOrWhiteSpace(surface.ReturnType) && + !TypeMatches(property.PropertyType, surface.ReturnType)) + { + throw new MissingMemberException(type.FullName, surface.Signature); + } + if (surface.HasGetter && property.GetMethod is null) { throw new MissingMemberException(type.FullName, "get_" + surface.MemberName); @@ -456,7 +462,14 @@ static bool MethodMatchesSurface(MethodInfo method, BindingSurfaceDescriptor sur return false; } - return ParametersMatch(method.GetParameters(), surface); + return ParametersMatch(method.GetParameters(), surface) && + ReturnTypeMatches(method.ReturnType, surface); + } + + static bool ReturnTypeMatches(Type returnType, BindingSurfaceDescriptor surface) + { + return string.IsNullOrWhiteSpace(surface.ReturnType) || + TypeMatches(returnType, surface.ReturnType); } static bool ParametersMatch(ParameterInfo[] parameters, BindingSurfaceDescriptor surface) @@ -727,6 +740,7 @@ sealed class BindingSurfaceDescriptor public bool HasSetter { get; set; } public int ParameterCount { get; set; } public List ParameterTypes { get; set; } = []; + public string? ReturnType { get; set; } public List NativeSelectors { get; set; } = []; public string SourceFile { get; set; } = string.Empty; public string Signature { get; set; } = string.Empty; From abf8897a1e7bd6487efd3a5afc777bc598bec707 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 17:54:44 -0400 Subject: [PATCH 10/23] Cover AppDistribution binding surface --- .../BindingSurfaceCoverageBuilderTests.cs | 74 ++++++++- .../BindingSurfaceCoverage.cs | 149 ++++++++++++++++-- ...eBindingSurfaceCoverage.AppDistribution.cs | 59 +++++++ .../FirebaseBindingSurfaceCoverage.cs | 65 +++++--- .../binding-surface-coverage.json | 10 ++ 5 files changed, 316 insertions(+), 41 deletions(-) create mode 100644 tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 218ba27d..7be7ce46 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -48,7 +48,73 @@ public partial class AuthManualSurface } } - private static AuditConfiguration CreateConfiguration() => + [Fact] + public void Build_RecordsPublicHelperDelegatesConstructorsAndIndexers() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [BaseType(typeof(NSObject), Name = "FIRAuth")] + public interface Auth + { + } + """); + File.WriteAllText( + Path.Combine(sourceDirectory, "Extension.cs"), + """ + namespace Firebase.Auth; + + public partial class Auth + { + public delegate string TokenFactory(int index); + + public Auth(string name) + { + } + + public string this[int index] => ""; + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration(["Extension.cs"])).Build( + repoRoot, + CreateManifest("Extension.cs"), + "Auth"); + + var surfaces = document.Targets.Single().Surfaces; + var helperDelegate = Assert.Single(surfaces, static surface => surface.Kind == "manual-delegate"); + Assert.Equal("Firebase.Auth.Auth+TokenFactory", helperDelegate.RuntimeTypeName); + Assert.Equal(["int"], helperDelegate.ParameterTypes); + Assert.Equal("string", helperDelegate.ReturnType); + + var helperConstructor = Assert.Single(surfaces, static surface => surface.Kind == "manual-constructor"); + Assert.Equal("Firebase.Auth.Auth", helperConstructor.RuntimeTypeName); + Assert.Equal(["string"], helperConstructor.ParameterTypes); + + var helperIndexer = Assert.Single(surfaces, static surface => surface.Kind == "manual-indexer"); + Assert.Equal("Item", helperIndexer.MemberName); + Assert.Equal(["int"], helperIndexer.ParameterTypes); + Assert.Equal("string", helperIndexer.ReturnType); + Assert.True(helperIndexer.HasGetter); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + private static AuditConfiguration CreateConfiguration(string[]? helperFiles = null) => new() { ManualAttributes = ["Wrap"], @@ -61,12 +127,12 @@ private static AuditConfiguration CreateConfiguration() => PackageId = "AdamE.Firebase.iOS.Auth", BaselineDirectory = Path.Combine("source", "Firebase", "Auth"), BaselineFiles = ["ApiDefinition.cs"], - HelperFiles = [] + HelperFiles = helperFiles ?? [] } ] }; - private static BindingSurfaceCoverageManifest CreateManifest() => + private static BindingSurfaceCoverageManifest CreateManifest(params string[] helperFiles) => new() { Targets = @@ -76,7 +142,7 @@ private static BindingSurfaceCoverageManifest CreateManifest() => Id = "Auth", PackageId = "AdamE.Firebase.iOS.Auth", CoverageCaseMethod = "VerifyAuthBindingSurfaceAsync", - SourceFiles = [Path.Combine("source", "Firebase", "Auth", "ApiDefinition.cs")] + SourceFiles = [Path.Combine("source", "Firebase", "Auth", "ApiDefinition.cs"), .. helperFiles.Select(static file => Path.Combine("source", "Firebase", "Auth", file))] } ] }; diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index ada8b659..0e0c3596 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -574,17 +574,40 @@ private static IEnumerable BuildPublicHelperSurfaces(s var syntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText(helperFile), path: helperFile); var root = syntaxTree.GetCompilationUnitRoot(); + foreach (var delegateDeclaration in root.DescendantNodes().OfType()) + { + if (!IsEffectivelyPublic(delegateDeclaration)) + { + continue; + } + + var namespaceName = GetContainingNamespace(delegateDeclaration); + var typeName = GetQualifiedTypeName(delegateDeclaration, delegateDeclaration.Identifier.Text); + var parameterTypes = GetParameterTypes(delegateDeclaration.ParameterList); + var signature = $"delegate {delegateDeclaration.Identifier.Text}({string.Join(", ", parameterTypes)}) -> {delegateDeclaration.ReturnType.WithoutTrivia()}"; + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual-delegate:{typeName}:{CreateSurfaceIdKey(signature)}", + memberName: null, + signature: signature, + parameterTypes: parameterTypes, + returnType: delegateDeclaration.ReturnType.WithoutTrivia().ToString(), + isStatic: false, + kind: "manual-delegate"); + } + foreach (var typeDeclaration in root.DescendantNodes().OfType()) { - if (!typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + if (!IsEffectivelyPublic(typeDeclaration)) { continue; } var namespaceName = GetContainingNamespace(typeDeclaration); - var typeName = string.IsNullOrWhiteSpace(namespaceName) - ? typeDeclaration.Identifier.Text - : $"{namespaceName}.{typeDeclaration.Identifier.Text}"; + var typeName = GetQualifiedTypeName(typeDeclaration, typeDeclaration.Identifier.Text); yield return CreatePublicHelperSurface( target, helperFile, @@ -599,7 +622,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s foreach (var property in typeDeclaration.Members.OfType()) { - if (!property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + if (!IsPublic(property)) { continue; } @@ -614,19 +637,68 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: $"{property.Type.WithoutTrivia()} {property.Identifier.Text} {{ get; }}", parameterTypes: [], returnType: property.Type.WithoutTrivia().ToString(), - isStatic: property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); + isStatic: property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), + kind: "manual-property", + hasGetter: HasGetter(property.AccessorList), + hasSetter: HasSetter(property.AccessorList)); + } + + foreach (var indexer in typeDeclaration.Members.OfType()) + { + if (!IsPublic(indexer)) + { + continue; + } + + var parameterTypes = GetParameterTypes(indexer.ParameterList); + var signature = $"{indexer.Type.WithoutTrivia()} this[{string.Join(", ", parameterTypes)}] {{ get; }}"; + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual-indexer:{typeName}:{CreateSurfaceIdKey(signature)}", + memberName: "Item", + signature: signature, + parameterTypes: parameterTypes, + returnType: indexer.Type.WithoutTrivia().ToString(), + isStatic: false, + kind: "manual-indexer", + hasGetter: HasGetter(indexer.AccessorList), + hasSetter: HasSetter(indexer.AccessorList)); + } + + foreach (var constructor in typeDeclaration.Members.OfType()) + { + if (!IsPublic(constructor)) + { + continue; + } + + var parameterTypes = GetParameterTypes(constructor.ParameterList); + var signature = $"{constructor.Identifier.Text}({string.Join(", ", parameterTypes)})"; + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual-constructor:{typeName}:{constructor.ParameterList.Parameters.Count}:{CreateSurfaceIdKey(signature)}", + memberName: ".ctor", + signature: signature, + parameterTypes: parameterTypes, + returnType: null, + isStatic: false, + kind: "manual-constructor"); } foreach (var method in typeDeclaration.Members.OfType()) { - if (!method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + if (!IsPublic(method)) { continue; } - var parameterTypes = method.ParameterList.Parameters - .Select(static parameter => parameter.Type?.WithoutTrivia().ToString() ?? "object") - .ToList(); + var parameterTypes = GetParameterTypes(method.ParameterList); var signature = $"{method.Identifier.Text}({string.Join(", ", parameterTypes)}) -> {method.ReturnType.WithoutTrivia()}"; yield return CreatePublicHelperSurface( target, @@ -638,12 +710,13 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: signature, parameterTypes: parameterTypes, returnType: method.ReturnType.WithoutTrivia().ToString(), - isStatic: method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); + isStatic: method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), + kind: "manual-method"); } foreach (var field in typeDeclaration.Members.OfType()) { - if (!field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword))) + if (!IsPublic(field)) { continue; } @@ -661,7 +734,8 @@ private static IEnumerable BuildPublicHelperSurfaces(s parameterTypes: [], returnType: field.Declaration.Type.WithoutTrivia().ToString(), isStatic: field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)) || - field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ConstKeyword))); + field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ConstKeyword)), + kind: "manual-field"); } } } @@ -678,11 +752,14 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( string signature, IReadOnlyList parameterTypes, string? returnType, - bool isStatic) => + bool isStatic, + string? kind = null, + bool hasGetter = false, + bool hasSetter = false) => new( Target: target, SurfaceId: surfaceId, - Kind: memberName is null ? "manual" : "manual-member", + Kind: kind ?? (memberName is null ? "manual" : "manual-member"), TypeName: typeName, RuntimeTypeName: typeName, AssemblyName: ResolveAssemblyName(namespaceName), @@ -693,8 +770,8 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( MemberName: memberName, BindingAttribute: null, BindingValue: null, - HasGetter: false, - HasSetter: false, + HasGetter: hasGetter, + HasSetter: hasSetter, ParameterCount: parameterTypes.Count, ParameterTypes: parameterTypes, ReturnType: returnType, @@ -702,6 +779,44 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( SourceFile: helperFile, Signature: signature); + private static IReadOnlyList GetParameterTypes(BaseParameterListSyntax parameterList) => + parameterList.Parameters + .Select(static parameter => parameter.Type?.WithoutTrivia().ToString() ?? "object") + .ToList(); + + private static bool IsEffectivelyPublic(MemberDeclarationSyntax member) => + IsPublic(member) && + member.Ancestors().OfType().All(IsPublic); + + private static bool IsPublic(MemberDeclarationSyntax member) => + member.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword)); + + private static bool HasGetter(AccessorListSyntax? accessorList) => + accessorList is null || + accessorList.Accessors.Any(static accessor => accessor.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.GetAccessorDeclaration)); + + private static bool HasSetter(AccessorListSyntax? accessorList) => + accessorList?.Accessors.Any(static accessor => accessor.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.SetAccessorDeclaration)) == true; + + private static string GetQualifiedTypeName(SyntaxNode node, string typeName) + { + var containingTypes = new List(); + for (var current = node.Parent; current is not null; current = current.Parent) + { + if (current is TypeDeclarationSyntax typeDeclaration) + { + containingTypes.Insert(0, typeDeclaration.Identifier.Text); + } + } + + containingTypes.Add(typeName); + var nestedTypeName = string.Join("+", containingTypes); + var namespaceName = GetContainingNamespace(node); + return string.IsNullOrWhiteSpace(namespaceName) + ? nestedTypeName + : $"{namespaceName}.{nestedTypeName}"; + } + private static string GetTypeDeclarationKind(TypeDeclarationSyntax typeDeclaration) => typeDeclaration switch { diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs new file mode 100644 index 00000000..fd487a61 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs @@ -0,0 +1,59 @@ +#if ENABLE_BINDING_SURFACE_COVERAGE +using FirebaseAppDistributionClient = Firebase.AppDistribution.AppDistribution; +using Foundation; +using UIKit; + +namespace FirebaseFoundationE2E; + +public static partial class FirebaseBindingSurfaceCoverage +{ + static async Task VerifyAppDistributionBindingSurfaceAsync(BindingSurfaceCoverageDocument document) + { + var result = await ExecuteTargetAsync(document, "AppDistribution"); + await ExerciseAppDistributionBoundaryAsync(); + return result; + } + + static async Task ExerciseAppDistributionBoundaryAsync() + { + var appDistribution = FirebaseAppDistributionClient.SharedInstance + ?? throw new InvalidOperationException("Firebase.AppDistribution.AppDistribution.SharedInstance returned null."); + + _ = appDistribution.IsTesterSignedIn; + + using var url = new NSUrl("firebase-appdistribution://codex-binding-surface-e2e"); + using var options = new NSDictionary(); + _ = appDistribution.OpenUrl(UIApplication.SharedApplication, url, options); + + var updateCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + appDistribution.CheckForUpdate((release, error) => + { + try + { + if (release is not null) + { + _ = release.DisplayVersion; + _ = release.BuildVersion; + _ = release.ReleaseNotes; + _ = release.DownloadUrl; + _ = release.IsExpired; + } + + updateCompletion.TrySetResult(error is null + ? "completed without Firebase error" + : $"completed with Firebase error {error.Domain} ({error.Code})"); + } + catch (Exception ex) + { + updateCompletion.TrySetException(ex); + } + }); + + var completedTask = await Task.WhenAny(updateCompletion.Task, Task.Delay(TimeSpan.FromSeconds(5))); + if (completedTask == updateCompletion.Task) + { + _ = await updateCompletion.Task; + } + } +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 449a7f7b..d67bc116 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -81,6 +81,7 @@ static Task ExecuteConfiguredTargetAsync( "ABTesting" => VerifyABTestingBindingSurfaceAsync(document), "Analytics" => VerifyAnalyticsBindingSurfaceAsync(document), "AppCheck" => VerifyAppCheckBindingSurfaceAsync(document), + "AppDistribution" => VerifyAppDistributionBindingSurfaceAsync(document), "Auth" => VerifyAuthBindingSurfaceAsync(document), "CloudFirestore" => VerifyCloudFirestoreBindingSurfaceAsync(document), "CloudFunctions" => VerifyCloudFunctionsBindingSurfaceAsync(document), @@ -249,28 +250,12 @@ static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.FlattenHierarchy; if (string.Equals(surface.Kind, "property", StringComparison.Ordinal)) { - var property = type.GetProperty(surface.MemberName, flags); + var property = FindProperty(type, surface, flags); if (property is null) { throw new MissingMemberException(type.FullName, surface.MemberName); } - if (!string.IsNullOrWhiteSpace(surface.ReturnType) && - !TypeMatches(property.PropertyType, surface.ReturnType)) - { - throw new MissingMemberException(type.FullName, surface.Signature); - } - - if (surface.HasGetter && property.GetMethod is null) - { - throw new MissingMemberException(type.FullName, "get_" + surface.MemberName); - } - - if (surface.HasSetter && property.SetMethod is null) - { - throw new MissingMemberException(type.FullName, "set_" + surface.MemberName); - } - return; } @@ -416,6 +401,16 @@ static void ResolveEnumMember(Type type, string? memberName) static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) { + if (string.Equals(surface.Kind, "manual-delegate", StringComparison.Ordinal)) + { + if (!typeof(Delegate).IsAssignableFrom(type)) + { + throw new TypeLoadException($"Managed type '{type.FullName}' is not a delegate."); + } + + return; + } + var memberName = surface.MemberName; if (string.IsNullOrWhiteSpace(memberName)) { @@ -423,8 +418,18 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) } const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.FlattenHierarchy; - var property = type.GetProperty(memberName, flags); - if (property?.GetMethod?.IsStatic == true) + if (string.Equals(surface.Kind, "manual-constructor", StringComparison.Ordinal)) + { + if (!type.GetConstructors(flags).Any(constructor => ParametersMatch(constructor.GetParameters(), surface))) + { + throw new MissingMethodException(type.FullName, surface.Signature); + } + + return; + } + + var property = FindProperty(type, surface, flags); + if (property?.GetMethod?.IsStatic == true && property.GetIndexParameters().Length == 0) { _ = property.GetValue(null); return; @@ -455,6 +460,22 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) throw new MissingMethodException(type.FullName, surface.Signature); } + static PropertyInfo? FindProperty(Type type, BindingSurfaceDescriptor surface, BindingFlags flags) => + type.GetProperties(flags).FirstOrDefault(property => PropertyMatchesSurface(property, surface)); + + static bool PropertyMatchesSurface(PropertyInfo property, BindingSurfaceDescriptor surface) + { + if (!string.Equals(property.Name, surface.MemberName, StringComparison.Ordinal)) + { + return false; + } + + return ReturnTypeMatches(property.PropertyType, surface) && + ParametersMatch(property.GetIndexParameters(), surface) && + (!surface.HasGetter || property.GetMethod is not null) && + (!surface.HasSetter || property.SetMethod is not null); + } + static bool MethodMatchesSurface(MethodInfo method, BindingSurfaceDescriptor surface) { if (!string.Equals(method.Name, surface.MemberName, StringComparison.Ordinal)) @@ -595,7 +616,11 @@ static void TouchStaticMemberIfAvailable(Type type, string? memberName) } const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy; - var property = type.GetProperty(memberName, flags); + var property = type + .GetProperties(flags) + .FirstOrDefault(property => + string.Equals(property.Name, memberName, StringComparison.Ordinal) && + property.GetIndexParameters().Length == 0); if (property?.GetMethod is not null) { _ = property.GetValue(null); diff --git a/tests/E2E/Firebase.Foundation/binding-surface-coverage.json b/tests/E2E/Firebase.Foundation/binding-surface-coverage.json index bbdc62ef..5c3c608c 100644 --- a/tests/E2E/Firebase.Foundation/binding-surface-coverage.json +++ b/tests/E2E/Firebase.Foundation/binding-surface-coverage.json @@ -31,6 +31,16 @@ ], "requiredExtraPackages": [] }, + { + "id": "AppDistribution", + "packageId": "AdamE.Firebase.iOS.AppDistribution", + "coverageCaseMethod": "VerifyAppDistributionBindingSurfaceAsync", + "sourceFiles": [ + "source/Firebase/AppDistribution/ApiDefinition.cs", + "source/Firebase/AppDistribution/Enums.cs" + ], + "requiredExtraPackages": [] + }, { "id": "Auth", "packageId": "AdamE.Firebase.iOS.Auth", From 9454b8ddd88fc91d1cb344f0e2114b6bcb3c68f3 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 18:06:06 -0400 Subject: [PATCH 11/23] Tighten binding surface verifier matching --- .../FirebaseBindingSurfaceCoverage.cs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index d67bc116..ce2ad787 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -242,6 +242,12 @@ static Type ResolveManagedType(BindingSurfaceDescriptor surface) static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) { + if (IsDelegateSurface(surface)) + { + VerifyDelegateSurface(type, surface); + return; + } + if (string.IsNullOrWhiteSpace(surface.MemberName)) { return; @@ -403,11 +409,7 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) { if (string.Equals(surface.Kind, "manual-delegate", StringComparison.Ordinal)) { - if (!typeof(Delegate).IsAssignableFrom(type)) - { - throw new TypeLoadException($"Managed type '{type.FullName}' is not a delegate."); - } - + VerifyDelegateSurface(type, surface); return; } @@ -483,10 +485,34 @@ static bool MethodMatchesSurface(MethodInfo method, BindingSurfaceDescriptor sur return false; } - return ParametersMatch(method.GetParameters(), surface) && + return method.IsStatic == surface.IsStatic && + ParametersMatch(method.GetParameters(), surface) && ReturnTypeMatches(method.ReturnType, surface); } + static bool IsDelegateSurface(BindingSurfaceDescriptor surface) => + string.Equals(surface.Kind, "delegate", StringComparison.Ordinal) || + string.Equals(surface.Kind, "manual-delegate", StringComparison.Ordinal); + + static void VerifyDelegateSurface(Type type, BindingSurfaceDescriptor surface) + { + if (!typeof(Delegate).IsAssignableFrom(type)) + { + throw new TypeLoadException($"Managed type '{type.FullName}' is not a delegate."); + } + + var invokeMethod = type.GetMethod( + "Invoke", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + if (invokeMethod is null || + invokeMethod.IsStatic != surface.IsStatic || + !ParametersMatch(invokeMethod.GetParameters(), surface) || + !ReturnTypeMatches(invokeMethod.ReturnType, surface)) + { + throw new MissingMethodException(type.FullName, surface.Signature); + } + } + static bool ReturnTypeMatches(Type returnType, BindingSurfaceDescriptor surface) { return string.IsNullOrWhiteSpace(surface.ReturnType) || From cc47d5a14f1e8b203902eba8fa872900773e7a8a Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 18:14:33 -0400 Subject: [PATCH 12/23] Address audit matcher review comments --- .../AuditRunnerTests.cs | 11 ++++++ .../BindingComparerNormalizationTests.cs | 37 ++++++++++++++++++- scripts/FirebaseBindingAudit/AuditRunner.cs | 5 ++- scripts/FirebaseBindingAudit/BindingModel.cs | 26 ++++++++++--- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs index f57facbe..6972ae04 100644 --- a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs @@ -67,6 +67,17 @@ public void ShouldStageSharpieFrameworkSlices_SkipsWhenSharpieIsDisabled() Assert.True(AuditRunner.ShouldStageSharpieFrameworkSlices(CreateAuditOptions(disableSharpie: false))); } + [Fact] + public void CreateTempRootPath_ReturnsUniqueAuditWorkspacePaths() + { + var firstPath = AuditRunner.CreateTempRootPath(); + var secondPath = AuditRunner.CreateTempRootPath(); + + Assert.NotEqual(firstPath, secondPath); + Assert.EndsWith("firebase-binding-audit", Directory.GetParent(firstPath)!.Name); + Assert.Contains("-", Path.GetFileName(firstPath), StringComparison.Ordinal); + } + [Fact] public void TryResolveSharpieCompanionFrameworkPath_UsesLaterExistingImport() { diff --git a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs index 2662c1ee..501ee7cc 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingComparerNormalizationTests.cs @@ -134,7 +134,36 @@ public enum FIRAppDistributionError : long } [Fact] - public void Compare_MatchesGeneratedFieldConstantsInSeparateStaticContainer() + public void Compare_MatchesGeneratedManualFieldOnSameType() + { + var result = Compare( + """ + namespace Firebase.AppDistribution; + + [BaseType(typeof(NSObject), Name = "FIRAppDistribution")] + public interface AppDistribution + { + [Field("FIRAppDistributionErrorDomain", "__Internal")] + NSString ErrorDomain { get; } + } + """, + """ + namespace FirebaseAppDistribution; + + [BaseType(typeof(NSObject))] + public interface FIRAppDistribution + { + [Internal] + [Field("FIRAppDistributionErrorDomain", "__Internal")] + NSString ErrorDomain { get; } + } + """); + + AssertNoFailures(result); + } + + [Fact] + public void Compare_DoesNotMatchGeneratedManualFieldOnDifferentType() { var result = Compare( """ @@ -162,7 +191,11 @@ public static class FirebaseAppDistributionConstants } """); - AssertNoFailures(result); + Assert.Contains( + result.Failures, + static failure => failure.Category == "stale-baseline-binding" && + failure.ComparisonTypeKey == "FIRAppDistribution" && + failure.ComparisonMemberKey == "field|FIRAppDistributionErrorDomain"); } [Fact] diff --git a/scripts/FirebaseBindingAudit/AuditRunner.cs b/scripts/FirebaseBindingAudit/AuditRunner.cs index 054a0e32..b4c1239f 100644 --- a/scripts/FirebaseBindingAudit/AuditRunner.cs +++ b/scripts/FirebaseBindingAudit/AuditRunner.cs @@ -109,7 +109,7 @@ public async Task RunAsync(AuditOptions options, CancellationTok PrepareOutputDirectory(outputDirectory, detailsDirectory, logsDirectory); var selectedTargets = SelectTargets(options.SelectedTargets); - var tempRoot = Path.Combine(Path.GetTempPath(), "firebase-binding-audit", DateTime.UtcNow.ToString("yyyyMMddHHmmss")); + var tempRoot = CreateTempRootPath(); Directory.CreateDirectory(tempRoot); var sharedNugetPackages = Path.Combine(Path.GetTempPath(), "firebase-binding-audit", "nuget-packages"); var dotnetEnvironment = new Dictionary(StringComparer.Ordinal) @@ -393,6 +393,9 @@ sharpieRun is not null && internal static bool ShouldStageSharpieFrameworkSlices(AuditOptions options) => !options.DisableSharpie; + internal static string CreateTempRootPath() => + Path.Combine(Path.GetTempPath(), "firebase-binding-audit", $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"); + private static ConfidenceSummary BuildConfidenceSummary(IReadOnlyList findings) { return AuditFindingSummary.BuildConfidenceSummary(findings.ToList()); diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 9cf932b7..77ce7f6e 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -1206,7 +1206,7 @@ public TargetComparisonResult Compare( var generatedManualMemberLookup = new HashSet( generated.ManualItems .Where(static item => !string.IsNullOrWhiteSpace(item.MatchMemberKey)) - .Select(static item => item.MatchMemberKey!), + .Select(static item => CreateManualLookupKey(item.MatchTypeKey, item.MatchMemberKey!)), StringComparer.Ordinal); var smoothedDelegateKeys = new HashSet(StringComparer.Ordinal); @@ -1362,7 +1362,7 @@ private static void CompareMembers( { if (!TryGetComparableMember(generated.Members, baselineMemberEntry, out var generatedMember)) { - if (IsMatchedGeneratedManualBinding(baselineMemberEntry, generatedManualMemberLookup)) + if (IsMatchedGeneratedManualBinding(baseline.ComparisonKey, baselineMemberEntry, generatedManualMemberLookup, aliases)) { continue; } @@ -1428,11 +1428,27 @@ private static bool IsExternalPlaceholderType(BoundTypeSurface type) } private static bool IsMatchedGeneratedManualBinding( + string baselineTypeKey, BindingMemberSurface baselineMember, - IReadOnlySet generatedManualMemberLookup) + IReadOnlySet generatedManualMemberLookup, + SymbolAliasLookup aliases) + { + if (!string.Equals(baselineMember.BindingAttribute, "Field", StringComparison.Ordinal)) + { + return false; + } + + return GetEquivalentManualLookupTypeKeys(baselineTypeKey, aliases) + .Any(typeKey => generatedManualMemberLookup.Contains(CreateManualLookupKey(typeKey, baselineMember.Key))); + } + + private static IEnumerable GetEquivalentManualLookupTypeKeys(string typeKey, SymbolAliasLookup aliases) { - return string.Equals(baselineMember.BindingAttribute, "Field", StringComparison.Ordinal) && - generatedManualMemberLookup.Contains(baselineMember.Key); + yield return typeKey; + foreach (var equivalentTypeKey in aliases.GetEquivalentComparisonKeys(typeKey)) + { + yield return equivalentTypeKey; + } } private static bool TryGetComparableMember( From 8efd9a4ecb4087fdb06d74f81c26d849295b1600 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 18:22:20 -0400 Subject: [PATCH 13/23] Tighten binding surface member matching --- .../BindingSurfaceCoverageBuilderTests.cs | 5 +++++ scripts/FirebaseBindingAudit/BindingModel.cs | 4 +++- .../FirebaseBindingSurfaceCoverage.cs | 20 +++++++++++++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 7be7ce46..54412f22 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -65,6 +65,8 @@ namespace Firebase.Auth; [BaseType(typeof(NSObject), Name = "FIRAuth")] public interface Auth { + [Field("FIRAuthErrorDomain", "__Internal")] + NSString ErrorDomain { get; } } """); File.WriteAllText( @@ -90,6 +92,9 @@ public Auth(string name) "Auth"); var surfaces = document.Targets.Single().Surfaces; + var fieldConstant = Assert.Single(surfaces, static surface => surface.BindingValue == "FIRAuthErrorDomain"); + Assert.True(fieldConstant.IsStatic); + var helperDelegate = Assert.Single(surfaces, static surface => surface.Kind == "manual-delegate"); Assert.Equal("Firebase.Auth.Auth+TokenFactory", helperDelegate.RuntimeTypeName); Assert.Equal(["int"], helperDelegate.ParameterTypes); diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 0a31c01c..3527e268 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -426,7 +426,9 @@ private void ParseProperty( BindingAttribute: bindingAttribute, BindingValue: bindingValue, IsNotification: HasAttribute(propertyDeclaration.AttributeLists, "Notification"), - IsStatic: propertyDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || HasAttribute(propertyDeclaration.AttributeLists, "Static"), + IsStatic: propertyDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || + HasAttribute(propertyDeclaration.AttributeLists, "Static") || + string.Equals(bindingAttribute, "Field", StringComparison.Ordinal), ReturnType: NormalizeType(propertyDeclaration.Type), IsReturnNullAllowed: HasNullAllowed(propertyDeclaration.AttributeLists, "return") || HasNullAllowed(propertyDeclaration.AttributeLists, null), HasGetter: getter is not null, diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index ce2ad787..52577d10 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -442,7 +442,7 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) return; } - var field = type.GetField(memberName, flags); + var field = FindField(type, surface, flags); if (field?.IsStatic == true) { _ = field.GetValue(null); @@ -472,12 +472,28 @@ static bool PropertyMatchesSurface(PropertyInfo property, BindingSurfaceDescript return false; } - return ReturnTypeMatches(property.PropertyType, surface) && + return PropertyIsStatic(property) == surface.IsStatic && + ReturnTypeMatches(property.PropertyType, surface) && ParametersMatch(property.GetIndexParameters(), surface) && (!surface.HasGetter || property.GetMethod is not null) && (!surface.HasSetter || property.SetMethod is not null); } + static bool PropertyIsStatic(PropertyInfo property) => + property.GetMethod?.IsStatic ?? + property.SetMethod?.IsStatic ?? + false; + + static FieldInfo? FindField(Type type, BindingSurfaceDescriptor surface, BindingFlags flags) => + type.GetFields(flags).FirstOrDefault(field => FieldMatchesSurface(field, surface)); + + static bool FieldMatchesSurface(FieldInfo field, BindingSurfaceDescriptor surface) + { + return string.Equals(field.Name, surface.MemberName, StringComparison.Ordinal) && + field.IsStatic == surface.IsStatic && + ReturnTypeMatches(field.FieldType, surface); + } + static bool MethodMatchesSurface(MethodInfo method, BindingSurfaceDescriptor surface) { if (!string.Equals(method.Name, surface.MemberName, StringComparison.Ordinal)) From fc89f7475888c970bd93cca7d37f6d125152fdf5 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 18:41:17 -0400 Subject: [PATCH 14/23] Preserve coverage surface metadata --- .../BindingSurfaceCoverageBuilderTests.cs | 137 +++++++++++++++++- scripts/FirebaseBindingAudit/BindingModel.cs | 10 ++ .../BindingSurfaceCoverage.cs | 24 ++- ...eBindingSurfaceCoverage.AppDistribution.cs | 6 + 4 files changed, 173 insertions(+), 4 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 54412f22..ffe2a20a 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -48,6 +48,141 @@ public partial class AuthManualSurface } } + [Fact] + public void Build_PreservesManualExportStaticness() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [BaseType(typeof(NSObject), Name = "FIRAuth")] + public interface Auth + { + [Static] + [Async] + [Export("fetchSignInMethodsForEmail:completion:")] + void FetchSignInMethods(string email, Action completion); + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.MemberName == "FetchSignInMethods"); + + Assert.True(surface.IsStatic); + var nativeSelector = Assert.Single(surface.NativeSelectors); + Assert.Equal("fetchSignInMethodsForEmail:completion:", nativeSelector.Selector); + Assert.True(nativeSelector.IsStatic); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + [Fact] + public void Build_SynthesizesNamedIsPropertySetterSelector() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [BaseType(typeof(NSObject), Name = "FIRAuth")] + public interface Auth + { + [Export("isTokenAutoRefreshEnabled")] + bool IsTokenAutoRefreshEnabled { get; set; } + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.MemberName == "IsTokenAutoRefreshEnabled"); + + Assert.Equal( + ["isTokenAutoRefreshEnabled", "setIsTokenAutoRefreshEnabled:"], + surface.NativeSelectors.Select(static selector => selector.Selector).ToArray()); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + [Fact] + public void Build_UsesGetterBindForBooleanGetterSelector() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [BaseType(typeof(NSObject), Name = "FIRAuth")] + public interface Auth + { + [Export("tokenAutoRefreshEnabled")] + bool TokenAutoRefreshEnabled { [Bind("isTokenAutoRefreshEnabled")] get; set; } + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.MemberName == "TokenAutoRefreshEnabled"); + + Assert.Equal( + ["isTokenAutoRefreshEnabled", "setTokenAutoRefreshEnabled:"], + surface.NativeSelectors.Select(static selector => selector.Selector).ToArray()); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_RecordsPublicHelperDelegatesConstructorsAndIndexers() { @@ -122,7 +257,7 @@ public Auth(string name) private static AuditConfiguration CreateConfiguration(string[]? helperFiles = null) => new() { - ManualAttributes = ["Wrap"], + ManualAttributes = ["Wrap", "Async"], BindingAttributes = ["Export", "Field", "Notification"], Targets = [ diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 3527e268..abf8f0ec 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -79,6 +79,7 @@ internal sealed record ManualSurfaceItem( string MatchTypeKey, string? MatchMemberKey, string? MemberName, + bool IsStatic, IReadOnlyList ParameterTypes, string? ReturnType, string Signature, @@ -291,6 +292,7 @@ private static void AddManualTypeItems( MatchTypeKey: typeMatchKey, MatchMemberKey: null, MemberName: null, + IsStatic: false, ParameterTypes: [], ReturnType: null, Signature: $"{containerKind} {typeName}", @@ -306,6 +308,7 @@ private static void AddManualTypeItems( MatchTypeKey: typeMatchKey, MatchMemberKey: member.Key, MemberName: member.Name, + IsStatic: member.IsStatic, ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), ReturnType: member.ReturnType, Signature: member.Signature, @@ -337,6 +340,8 @@ private void ParseMethod( MatchTypeKey: containingTypeMatchKey, MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.Parameters.Count), MemberName: methodDeclaration.Identifier.Text, + IsStatic: methodDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || + HasAttribute(methodDeclaration.AttributeLists, "Static"), ParameterTypes: methodDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), ReturnType: NormalizeType(methodDeclaration.ReturnType), Signature: signature, @@ -400,6 +405,9 @@ private void ParseProperty( MatchTypeKey: containingTypeMatchKey, MatchMemberKey: bindingAttribute is null ? null : CreateMemberKey(bindingAttribute, bindingValue, propertyDeclaration.Identifier.Text, 0), MemberName: propertyDeclaration.Identifier.Text, + IsStatic: propertyDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || + HasAttribute(propertyDeclaration.AttributeLists, "Static") || + string.Equals(bindingAttribute, "Field", StringComparison.Ordinal), ParameterTypes: [], ReturnType: NormalizeType(propertyDeclaration.Type), Signature: signature, @@ -456,6 +464,7 @@ private void ParseDelegate( MatchTypeKey: delegateDeclaration.Identifier.Text, MatchMemberKey: null, MemberName: null, + IsStatic: false, ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), ReturnType: NormalizeType(delegateDeclaration.ReturnType), Signature: BuildDelegateSignature(delegateDeclaration), @@ -492,6 +501,7 @@ private void ParseEnum( MatchTypeKey: enumDeclaration.Identifier.Text, MatchMemberKey: null, MemberName: null, + IsStatic: false, ParameterTypes: [], ReturnType: null, Signature: $"enum {enumDeclaration.Identifier.Text}", diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 0e0c3596..f3e9307e 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Linq; @@ -287,11 +288,15 @@ private static XDocument BuildProps( .Distinct(StringComparer.Ordinal) .OrderBy(static assemblyName => assemblyName, StringComparer.Ordinal) .ToList(); + var defineConstants = string.Join( + ";", + new[] { "$(DefineConstants)", "ENABLE_BINDING_SURFACE_COVERAGE" } + .Concat(document.Targets.Select(static target => CreateTargetCompileConstant(target.Id)))); var project = new XElement("Project", new XElement("PropertyGroup", new XElement("BindingSurfaceCoverageTarget", selectedTarget), - new XElement("DefineConstants", "$(DefineConstants);ENABLE_BINDING_SURFACE_COVERAGE")), + new XElement("DefineConstants", defineConstants)), new XElement("ItemGroup", new XElement("BundleResource", new XAttribute("Include", Path.GetFullPath(coverageOutputPath)), @@ -309,6 +314,19 @@ private static XDocument BuildProps( return new XDocument(project); } + private static string CreateTargetCompileConstant(string targetId) + { + var builder = new StringBuilder("ENABLE_BINDING_SURFACE_COVERAGE_"); + foreach (var character in targetId) + { + builder.Append(char.IsLetterOrDigit(character) + ? char.ToUpperInvariant(character) + : '_'); + } + + return builder.ToString(); + } + private static IReadOnlyList SelectTargets( IReadOnlyList targets, string selectedTarget) @@ -496,7 +514,7 @@ private static IEnumerable BuildSurfaces( ObjectiveCName: null, ContainerKind: "manual", IsProtocol: false, - IsStatic: false, + IsStatic: manualItem.IsStatic, MemberName: manualItem.MemberName, BindingAttribute: bindingAttribute, BindingValue: bindingValue, @@ -506,7 +524,7 @@ private static IEnumerable BuildSurfaces( ParameterTypes: manualItem.ParameterTypes, ReturnType: manualItem.ReturnType, NativeSelectors: string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(bindingValue) - ? [new BindingSurfaceNativeSelector(bindingValue!, IsStatic: false, IsProtocol: false)] + ? [new BindingSurfaceNativeSelector(bindingValue!, manualItem.IsStatic, IsProtocol: false)] : EmptySelectors, SourceFile: manualItem.SourceFile, Signature: manualItem.Signature); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs index fd487a61..427a221b 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.AppDistribution.cs @@ -1,7 +1,9 @@ #if ENABLE_BINDING_SURFACE_COVERAGE +#if ENABLE_BINDING_SURFACE_COVERAGE_APPDISTRIBUTION using FirebaseAppDistributionClient = Firebase.AppDistribution.AppDistribution; using Foundation; using UIKit; +#endif namespace FirebaseFoundationE2E; @@ -10,10 +12,13 @@ public static partial class FirebaseBindingSurfaceCoverage static async Task VerifyAppDistributionBindingSurfaceAsync(BindingSurfaceCoverageDocument document) { var result = await ExecuteTargetAsync(document, "AppDistribution"); +#if ENABLE_BINDING_SURFACE_COVERAGE_APPDISTRIBUTION await ExerciseAppDistributionBoundaryAsync(); +#endif return result; } +#if ENABLE_BINDING_SURFACE_COVERAGE_APPDISTRIBUTION static async Task ExerciseAppDistributionBoundaryAsync() { var appDistribution = FirebaseAppDistributionClient.SharedInstance @@ -55,5 +60,6 @@ static async Task ExerciseAppDistributionBoundaryAsync() _ = await updateCompletion.Task; } } +#endif } #endif From c3c1ef788b79cab35bf73a034317eb8713f6dbea Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 18:59:25 -0400 Subject: [PATCH 15/23] Preserve helper parameter modifiers in coverage --- .../BindingSurfaceCoverageBuilderTests.cs | 14 ++++++- .../BindingSurfaceCoverage.cs | 12 +++++- .../FirebaseBindingSurfaceCoverage.cs | 38 ++++++++++++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index ffe2a20a..248124ee 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -211,13 +211,19 @@ namespace Firebase.Auth; public partial class Auth { - public delegate string TokenFactory(int index); + public delegate string TokenFactory(int index, ref NSError error); public Auth(string name) { } public string this[int index] => ""; + + public bool TryGetToken(out NSError error) + { + error = null; + return false; + } } """); @@ -232,9 +238,13 @@ public Auth(string name) var helperDelegate = Assert.Single(surfaces, static surface => surface.Kind == "manual-delegate"); Assert.Equal("Firebase.Auth.Auth+TokenFactory", helperDelegate.RuntimeTypeName); - Assert.Equal(["int"], helperDelegate.ParameterTypes); + Assert.Equal(["int", "ref NSError"], helperDelegate.ParameterTypes); Assert.Equal("string", helperDelegate.ReturnType); + var helperMethod = Assert.Single(surfaces, static surface => surface.MemberName == "TryGetToken"); + Assert.Equal(["out NSError"], helperMethod.ParameterTypes); + Assert.Equal("bool", helperMethod.ReturnType); + var helperConstructor = Assert.Single(surfaces, static surface => surface.Kind == "manual-constructor"); Assert.Equal("Firebase.Auth.Auth", helperConstructor.RuntimeTypeName); Assert.Equal(["string"], helperConstructor.ParameterTypes); diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index f3e9307e..9174cd4a 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -799,9 +799,19 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( private static IReadOnlyList GetParameterTypes(BaseParameterListSyntax parameterList) => parameterList.Parameters - .Select(static parameter => parameter.Type?.WithoutTrivia().ToString() ?? "object") + .Select(GetParameterType) .ToList(); + private static string GetParameterType(ParameterSyntax parameter) + { + var parameterType = parameter.Type?.WithoutTrivia().ToString() ?? "object"; + var modifier = parameter.Modifiers.FirstOrDefault(static modifier => + modifier.IsKind(SyntaxKind.RefKeyword) || + modifier.IsKind(SyntaxKind.OutKeyword) || + modifier.IsKind(SyntaxKind.InKeyword)); + return modifier.RawKind == 0 ? parameterType : $"{modifier.Text} {parameterType}"; + } + private static bool IsEffectivelyPublic(MemberDeclarationSyntax member) => IsPublic(member) && member.Ancestors().OfType().All(IsPublic); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 52577d10..87f8a6a3 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -553,10 +553,46 @@ static bool ParametersMatch(ParameterInfo[] parameters, BindingSurfaceDescriptor } return parameters - .Zip(surface.ParameterTypes, static (parameter, expectedType) => TypeMatches(parameter.ParameterType, expectedType)) + .Zip(surface.ParameterTypes, static (parameter, expectedType) => ParameterMatches(parameter, expectedType)) .All(static matches => matches); } + static bool ParameterMatches(ParameterInfo parameter, string expectedParameterType) + { + var (expectedModifier, expectedType) = SplitParameterType(expectedParameterType); + return string.Equals(GetParameterModifier(parameter), expectedModifier, StringComparison.Ordinal) && + TypeMatches(parameter.ParameterType, expectedType); + } + + static (string Modifier, string Type) SplitParameterType(string parameterType) + { + foreach (var modifier in new[] { "ref", "out", "in" }) + { + var prefix = $"{modifier} "; + if (parameterType.StartsWith(prefix, StringComparison.Ordinal)) + { + return (modifier, parameterType[prefix.Length..]); + } + } + + return (string.Empty, parameterType); + } + + static string GetParameterModifier(ParameterInfo parameter) + { + if (!parameter.ParameterType.IsByRef) + { + return string.Empty; + } + + if (parameter.IsOut) + { + return "out"; + } + + return parameter.IsIn ? "in" : "ref"; + } + static bool TypeMatches(Type actualType, string expectedType) { var normalizedExpected = NormalizeTypeName(expectedType); From d0db07d86a996c95c5fd4397a650b7561d0a1dd4 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 19:16:53 -0400 Subject: [PATCH 16/23] Tighten binding surface coverage matching --- .../BindingSurfaceCoverageBuilderTests.cs | 45 ++++++++++++++ scripts/FirebaseBindingAudit/BindingModel.cs | 32 ++++++++-- .../FirebaseBindingSurfaceCoverage.cs | 58 +++++++++++++------ 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 248124ee..48c847fe 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -48,6 +48,51 @@ public partial class AuthManualSurface } } + [Fact] + public void Build_RecordsBoundMethodParameterModifiers() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [BaseType(typeof(NSObject), Name = "FIRAuth")] + public interface Auth + { + [Export("signOut:")] + bool SignOut([NullAllowed] out NSError error); + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.MemberName == "SignOut"); + + Assert.Equal("method", surface.Kind); + Assert.Equal(1, surface.ParameterCount); + Assert.Equal(["out NSError"], surface.ParameterTypes); + Assert.Equal("SignOut(out NSError) -> bool", surface.Signature); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_PreservesManualExportStaticness() { diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index abf8f0ec..07f03108 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -342,7 +342,7 @@ private void ParseMethod( MemberName: methodDeclaration.Identifier.Text, IsStatic: methodDeclaration.Modifiers.Any(static token => token.IsKind(SyntaxKind.StaticKeyword)) || HasAttribute(methodDeclaration.AttributeLists, "Static"), - ParameterTypes: methodDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), + ParameterTypes: methodDeclaration.ParameterList.Parameters.Select(NormalizeParameterType).ToList(), ReturnType: NormalizeType(methodDeclaration.ReturnType), Signature: signature, SourceFile: filePath) @@ -465,7 +465,7 @@ private void ParseDelegate( MatchMemberKey: null, MemberName: null, IsStatic: false, - ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)).ToList(), + ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(NormalizeParameterType).ToList(), ReturnType: NormalizeType(delegateDeclaration.ReturnType), Signature: BuildDelegateSignature(delegateDeclaration), SourceFile: filePath)); @@ -573,7 +573,7 @@ private static string GetTypeBindingName(string typeName, SyntaxList NormalizeType(parameter.Type)))); + builder.Append(string.Join(", ", methodDeclaration.ParameterList.Parameters.Select(NormalizeParameterType))); builder.Append(')'); builder.Append(" -> "); builder.Append(NormalizeType(methodDeclaration.ReturnType)); @@ -786,7 +786,7 @@ private static string BuildDelegateSignature(DelegateDeclarationSyntax delegateD var builder = new StringBuilder(); builder.Append(delegateDeclaration.Identifier.Text); builder.Append('('); - builder.Append(string.Join(", ", delegateDeclaration.ParameterList.Parameters.Select(static parameter => NormalizeType(parameter.Type)))); + builder.Append(string.Join(", ", delegateDeclaration.ParameterList.Parameters.Select(NormalizeParameterType))); builder.Append(')'); builder.Append(" -> "); builder.Append(NormalizeType(delegateDeclaration.ReturnType)); @@ -798,6 +798,16 @@ private static string NormalizeType(TypeSyntax? typeSyntax) return typeSyntax?.WithoutTrivia().ToString().Replace("global::", string.Empty, StringComparison.Ordinal) ?? "void"; } + private static string NormalizeParameterType(ParameterSyntax parameter) + { + var parameterType = NormalizeType(parameter.Type); + var modifier = parameter.Modifiers.FirstOrDefault(static modifier => + modifier.IsKind(SyntaxKind.RefKeyword) || + modifier.IsKind(SyntaxKind.OutKeyword) || + modifier.IsKind(SyntaxKind.InKeyword)); + return modifier.RawKind == 0 ? parameterType : $"{modifier.Text} {parameterType}"; + } + private static string QualifyType(string currentNamespace, string typeName) { return string.IsNullOrWhiteSpace(currentNamespace) ? typeName : $"{currentNamespace}.{typeName}"; @@ -1990,7 +2000,17 @@ private static string NormalizeTypeReference(string? typeName, SymbolAliasLookup return string.Empty; } - var compact = typeName.Replace("global::", string.Empty, StringComparison.Ordinal) + var cleaned = typeName.Replace("global::", string.Empty, StringComparison.Ordinal).Trim(); + foreach (var modifier in new[] { "ref", "out", "in" }) + { + var prefix = $"{modifier} "; + if (cleaned.StartsWith(prefix, StringComparison.Ordinal)) + { + return $"{modifier} {NormalizeTypeReference(cleaned[prefix.Length..], aliases)}"; + } + } + + var compact = cleaned .Replace(" ", string.Empty, StringComparison.Ordinal); if (compact.EndsWith("[]", StringComparison.Ordinal)) diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 87f8a6a3..6fee192c 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -430,31 +430,42 @@ static void TouchManualSurface(Type type, BindingSurfaceDescriptor surface) return; } - var property = FindProperty(type, surface, flags); - if (property?.GetMethod?.IsStatic == true && property.GetIndexParameters().Length == 0) + if (IsManualPropertySurface(surface)) { - _ = property.GetValue(null); - return; - } + var property = FindProperty(type, surface, flags); + if (property?.GetMethod?.IsStatic == true && property.GetIndexParameters().Length == 0) + { + _ = property.GetValue(null); + return; + } - if (property is not null) - { - return; - } + if (property is not null) + { + return; + } - var field = FindField(type, surface, flags); - if (field?.IsStatic == true) - { - _ = field.GetValue(null); - return; + throw new MissingMemberException(type.FullName, surface.MemberName); } - if (field is not null) + if (IsManualFieldSurface(surface)) { - return; + var field = FindField(type, surface, flags); + if (field?.IsStatic == true) + { + _ = field.GetValue(null); + return; + } + + if (field is not null) + { + return; + } + + throw new MissingFieldException(type.FullName, surface.MemberName); } - if (type.GetMethods(flags).Any(method => MethodMatchesSurface(method, surface))) + if (IsManualMethodSurface(surface) && + type.GetMethods(flags).Any(method => MethodMatchesSurface(method, surface))) { return; } @@ -506,6 +517,19 @@ static bool MethodMatchesSurface(MethodInfo method, BindingSurfaceDescriptor sur ReturnTypeMatches(method.ReturnType, surface); } + static bool IsManualPropertySurface(BindingSurfaceDescriptor surface) => + string.Equals(surface.Kind, "manual-property", StringComparison.Ordinal) || + string.Equals(surface.Kind, "manual-indexer", StringComparison.Ordinal); + + static bool IsManualFieldSurface(BindingSurfaceDescriptor surface) => + string.Equals(surface.Kind, "manual-field", StringComparison.Ordinal); + + static bool IsManualMethodSurface(BindingSurfaceDescriptor surface) => + !IsManualPropertySurface(surface) && + !IsManualFieldSurface(surface) && + !string.Equals(surface.Kind, "manual-constructor", StringComparison.Ordinal) && + !IsDelegateSurface(surface); + static bool IsDelegateSurface(BindingSurfaceDescriptor surface) => string.Equals(surface.Kind, "delegate", StringComparison.Ordinal) || string.Equals(surface.Kind, "manual-delegate", StringComparison.Ordinal); From 32b48de5079616424f7d981a12bbb620b4443f25 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 19:29:54 -0400 Subject: [PATCH 17/23] Preserve manual surface member kinds --- .../BindingSurfaceCoverageBuilderTests.cs | 53 ++++++++++++++++++- scripts/FirebaseBindingAudit/BindingModel.cs | 38 +++++++++++-- .../BindingSurfaceCoverage.cs | 6 +-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 48c847fe..30eff219 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -126,6 +126,7 @@ public interface Auth document.Targets.Single().Surfaces, static surface => surface.MemberName == "FetchSignInMethods"); + Assert.Equal("manual-method", surface.Kind); Assert.True(surface.IsStatic); var nativeSelector = Assert.Single(surface.NativeSelectors); Assert.Equal("fetchSignInMethodsForEmail:completion:", nativeSelector.Selector); @@ -140,6 +141,56 @@ public interface Auth } } + [Fact] + public void Build_PreservesManualExportPropertyKind() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [BaseType(typeof(NSObject), Name = "FIRLifecycleEvents")] + public interface LifecycleEvents + { + [Advice("Prefer the default event name.")] + [Export("setExperimentEventName", ArgumentSemantic = ArgumentSemantic.Copy)] + NSString SetExperimentEventName { get; set; } + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.MemberName == "SetExperimentEventName"); + + Assert.Equal("manual-property", surface.Kind); + Assert.Equal("Export", surface.BindingAttribute); + Assert.Equal("setExperimentEventName", surface.BindingValue); + Assert.True(surface.HasGetter); + Assert.True(surface.HasSetter); + Assert.Equal("NSString", surface.ReturnType); + var nativeSelector = Assert.Single(surface.NativeSelectors); + Assert.Equal("setExperimentEventName", nativeSelector.Selector); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_SynthesizesNamedIsPropertySetterSelector() { @@ -312,7 +363,7 @@ public bool TryGetToken(out NSError error) private static AuditConfiguration CreateConfiguration(string[]? helperFiles = null) => new() { - ManualAttributes = ["Wrap", "Async"], + ManualAttributes = ["Wrap", "Advice", "Async"], BindingAttributes = ["Export", "Field", "Notification"], Targets = [ diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 07f03108..44d26dc4 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -85,6 +85,9 @@ internal sealed record ManualSurfaceItem( string Signature, string SourceFile) { + public string Kind { get; init; } = "manual"; + public bool HasGetter { get; init; } + public bool HasSetter { get; init; } public IReadOnlyList ManualAttributes { get; init; } = []; } @@ -296,7 +299,10 @@ private static void AddManualTypeItems( ParameterTypes: [], ReturnType: null, Signature: $"{containerKind} {typeName}", - SourceFile: filePath)); + SourceFile: filePath) + { + Kind = "manual-type" + }); return; } @@ -312,10 +318,24 @@ private static void AddManualTypeItems( ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), ReturnType: member.ReturnType, Signature: member.Signature, - SourceFile: member.SourceFile)); + SourceFile: member.SourceFile) + { + Kind = ToManualMemberKind(member.Kind), + HasGetter = member.HasGetter, + HasSetter = member.HasSetter + }); } } + private static string ToManualMemberKind(string memberKind) => + memberKind switch + { + "constructor" => "manual-constructor", + "property" => "manual-property", + "method" => "manual-method", + _ => "manual" + }; + private void ParseMethod( MethodDeclarationSyntax methodDeclaration, string containingType, @@ -347,6 +367,7 @@ private void ParseMethod( Signature: signature, SourceFile: filePath) { + Kind = methodDeclaration.Identifier.Text == "Constructor" ? "manual-constructor" : "manual-method", ManualAttributes = manualAttributeNames }); return; @@ -413,6 +434,9 @@ private void ParseProperty( Signature: signature, SourceFile: filePath) { + Kind = "manual-property", + HasGetter = propertyDeclaration.AccessorList?.Accessors.Any(static accessor => accessor.IsKind(SyntaxKind.GetAccessorDeclaration)) == true, + HasSetter = propertyDeclaration.AccessorList?.Accessors.Any(static accessor => accessor.IsKind(SyntaxKind.SetAccessorDeclaration)) == true, ManualAttributes = manualAttributeNames }); return; @@ -468,7 +492,10 @@ private void ParseDelegate( ParameterTypes: delegateDeclaration.ParameterList.Parameters.Select(NormalizeParameterType).ToList(), ReturnType: NormalizeType(delegateDeclaration.ReturnType), Signature: BuildDelegateSignature(delegateDeclaration), - SourceFile: filePath)); + SourceFile: filePath) + { + Kind = "manual-delegate" + }); return; } @@ -505,7 +532,10 @@ private void ParseEnum( ParameterTypes: [], ReturnType: null, Signature: $"enum {enumDeclaration.Identifier.Text}", - SourceFile: filePath)); + SourceFile: filePath) + { + Kind = "manual-type" + }); return; } diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 9174cd4a..20e4bd8e 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -507,7 +507,7 @@ private static IEnumerable BuildSurfaces( yield return new BindingSurfaceDescriptor( Target: target, SurfaceId: CreateManualSurfaceId(target, manualItem), - Kind: bindingAttribute is null ? "manual" : $"manual-{bindingAttribute.ToLowerInvariant()}", + Kind: manualItem.Kind, TypeName: manualItem.TypeName, RuntimeTypeName: manualItem.TypeName, AssemblyName: ResolveAssemblyName(ExtractNamespace(manualItem.TypeName)), @@ -518,8 +518,8 @@ private static IEnumerable BuildSurfaces( MemberName: manualItem.MemberName, BindingAttribute: bindingAttribute, BindingValue: bindingValue, - HasGetter: false, - HasSetter: false, + HasGetter: manualItem.HasGetter, + HasSetter: manualItem.HasSetter, ParameterCount: manualItem.ParameterTypes.Count, ParameterTypes: manualItem.ParameterTypes, ReturnType: manualItem.ReturnType, From 754479d3f7eaec9829953dd0a6e9152b29ccdcea Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 19:45:36 -0400 Subject: [PATCH 18/23] Cover manual export selectors and delegates --- .../BindingSurfaceCoverageBuilderTests.cs | 58 ++++++++++++++++++- scripts/FirebaseBindingAudit/BindingModel.cs | 4 ++ .../BindingSurfaceCoverage.cs | 52 +++++++++++++++-- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 30eff219..6e1e444c 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -127,6 +127,7 @@ public interface Auth static surface => surface.MemberName == "FetchSignInMethods"); Assert.Equal("manual-method", surface.Kind); + Assert.Equal("FIRAuth", surface.ObjectiveCName); Assert.True(surface.IsStatic); var nativeSelector = Assert.Single(surface.NativeSelectors); Assert.Equal("fetchSignInMethodsForEmail:completion:", nativeSelector.Selector); @@ -141,6 +142,57 @@ public interface Auth } } + [Fact] + public void Build_IncludesDelegatesReferencedByManualMembers() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + public delegate void SignInMethodQueryHandler(string[] methods, NSError error); + + [BaseType(typeof(NSObject), Name = "FIRAuth")] + public interface Auth + { + [Async] + [Export("fetchSignInMethodsForEmail:completion:")] + void FetchSignInMethods(string email, SignInMethodQueryHandler completion); + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surfaces = document.Targets.Single().Surfaces; + Assert.Contains( + surfaces, + static surface => surface.Kind == "delegate" && + surface.TypeName == "Firebase.Auth.SignInMethodQueryHandler" && + surface.ParameterTypes.SequenceEqual(["string[]", "NSError"])); + Assert.Contains( + surfaces, + static surface => surface.Kind == "manual-method" && + surface.MemberName == "FetchSignInMethods" && + surface.ParameterTypes.SequenceEqual(["string", "SignInMethodQueryHandler"])); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_PreservesManualExportPropertyKind() { @@ -174,13 +226,15 @@ public interface LifecycleEvents static surface => surface.MemberName == "SetExperimentEventName"); Assert.Equal("manual-property", surface.Kind); + Assert.Equal("FIRLifecycleEvents", surface.ObjectiveCName); Assert.Equal("Export", surface.BindingAttribute); Assert.Equal("setExperimentEventName", surface.BindingValue); Assert.True(surface.HasGetter); Assert.True(surface.HasSetter); Assert.Equal("NSString", surface.ReturnType); - var nativeSelector = Assert.Single(surface.NativeSelectors); - Assert.Equal("setExperimentEventName", nativeSelector.Selector); + Assert.Equal( + ["setExperimentEventName", "setSetExperimentEventName:"], + surface.NativeSelectors.Select(static selector => selector.Selector).ToArray()); } finally { diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index 44d26dc4..fe5982e4 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -85,6 +85,7 @@ internal sealed record ManualSurfaceItem( string Signature, string SourceFile) { + public string? ObjectiveCName { get; init; } public string Kind { get; init; } = "manual"; public bool HasGetter { get; init; } public bool HasSetter { get; init; } @@ -321,6 +322,7 @@ private static void AddManualTypeItems( SourceFile: member.SourceFile) { Kind = ToManualMemberKind(member.Kind), + ObjectiveCName = string.Equals(member.BindingAttribute, "Export", StringComparison.Ordinal) ? typeMatchKey : null, HasGetter = member.HasGetter, HasSetter = member.HasSetter }); @@ -367,6 +369,7 @@ private void ParseMethod( Signature: signature, SourceFile: filePath) { + ObjectiveCName = string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) ? containingTypeMatchKey : null, Kind = methodDeclaration.Identifier.Text == "Constructor" ? "manual-constructor" : "manual-method", ManualAttributes = manualAttributeNames }); @@ -434,6 +437,7 @@ private void ParseProperty( Signature: signature, SourceFile: filePath) { + ObjectiveCName = string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) ? containingTypeMatchKey : null, Kind = "manual-property", HasGetter = propertyDeclaration.AccessorList?.Accessors.Any(static accessor => accessor.IsKind(SyntaxKind.GetAccessorDeclaration)) == true, HasSetter = propertyDeclaration.AccessorList?.Accessors.Any(static accessor => accessor.IsKind(SyntaxKind.SetAccessorDeclaration)) == true, diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 20e4bd8e..d0736ff8 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -511,7 +511,7 @@ private static IEnumerable BuildSurfaces( TypeName: manualItem.TypeName, RuntimeTypeName: manualItem.TypeName, AssemblyName: ResolveAssemblyName(ExtractNamespace(manualItem.TypeName)), - ObjectiveCName: null, + ObjectiveCName: manualItem.ObjectiveCName, ContainerKind: "manual", IsProtocol: false, IsStatic: manualItem.IsStatic, @@ -523,9 +523,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: manualItem.ParameterTypes.Count, ParameterTypes: manualItem.ParameterTypes, ReturnType: manualItem.ReturnType, - NativeSelectors: string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(bindingValue) - ? [new BindingSurfaceNativeSelector(bindingValue!, manualItem.IsStatic, IsProtocol: false)] - : EmptySelectors, + NativeSelectors: BuildManualNativeSelectors(manualItem, bindingAttribute, bindingValue), SourceFile: manualItem.SourceFile, Signature: manualItem.Signature); } @@ -546,6 +544,15 @@ private static HashSet FindUsedDelegateNames(BindingSnapshot snapshot) } } + foreach (var manualItem in snapshot.ManualItems) + { + AddTypeNameVariants(usedDelegateNames, manualItem.ReturnType); + foreach (var parameterType in manualItem.ParameterTypes) + { + AddTypeNameVariants(usedDelegateNames, parameterType); + } + } + return usedDelegateNames; } @@ -554,8 +561,13 @@ private static bool IsDelegateUsedByBoundSurface(DelegateSurface delegateSurface usedDelegateNames.Contains(delegateSurface.DisplayName) || usedDelegateNames.Contains(delegateSurface.ComparisonKey); - private static void AddTypeNameVariants(HashSet typeNames, string typeName) + private static void AddTypeNameVariants(HashSet typeNames, string? typeName) { + if (string.IsNullOrWhiteSpace(typeName)) + { + return; + } + var normalizedTypeName = NormalizeCoverageTypeName(typeName); if (string.IsNullOrWhiteSpace(normalizedTypeName)) { @@ -888,6 +900,36 @@ private static IEnumerable BuildNativeSelectors(Bo yield return new BindingSurfaceNativeSelector(member.BindingValue, member.IsStatic, boundType.IsProtocol); } + private static IReadOnlyList BuildManualNativeSelectors( + ManualSurfaceItem manualItem, + string? bindingAttribute, + string? bindingValue) + { + if (!string.Equals(bindingAttribute, "Export", StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(bindingValue)) + { + return EmptySelectors; + } + + if (string.Equals(manualItem.Kind, "manual-property", StringComparison.Ordinal)) + { + var selectors = new List(); + if (manualItem.HasGetter) + { + selectors.Add(new BindingSurfaceNativeSelector(bindingValue!, manualItem.IsStatic, IsProtocol: false)); + } + + if (manualItem.HasSetter) + { + selectors.Add(new BindingSurfaceNativeSelector(CreateSetterSelector(bindingValue!), manualItem.IsStatic, IsProtocol: false)); + } + + return selectors; + } + + return [new BindingSurfaceNativeSelector(bindingValue!, manualItem.IsStatic, IsProtocol: false)]; + } + private static string CreateSetterSelector(string getterSelector) { var getter = getterSelector.TrimEnd(':'); From dfcfdb53912721d0ccf9221a60d65dc10b3eadc2 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 20:08:55 -0400 Subject: [PATCH 19/23] Validate binding surface type shapes --- .../BindingSurfaceCoverageBuilderTests.cs | 95 +++++++++++++++++++ .../BindingSurfaceCoverageValidatorTests.cs | 1 + scripts/FirebaseBindingAudit/BindingModel.cs | 20 ++-- .../BindingSurfaceCoverage.cs | 65 +++++++++++-- .../FirebaseBindingSurfaceCoverage.cs | 81 ++++++++++++++++ 5 files changed, 249 insertions(+), 13 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 6e1e444c..55b38da6 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -333,6 +333,96 @@ public interface Auth } } + [Fact] + public void Build_RecordsEnumUnderlyingType() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + [Native] + public enum AuthErrorCode : long + { + Unknown = 0 + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.Kind == "enum"); + + Assert.Equal("Firebase.Auth.AuthErrorCode", surface.RuntimeTypeName); + Assert.Equal("long", surface.UnderlyingType); + Assert.Equal("enum", surface.ContainerKind); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + [Fact] + public void Build_PreservesPublicHelperTypeShape() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + """); + File.WriteAllText( + Path.Combine(sourceDirectory, "Extension.cs"), + """ + namespace Firebase.Auth; + + public static class AuthHelpers + { + public static string Normalize(string value) => value; + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration(["Extension.cs"])).Build( + repoRoot, + CreateManifest("Extension.cs"), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.Kind == "manual-type"); + + Assert.Equal("Firebase.Auth.AuthHelpers", surface.RuntimeTypeName); + Assert.Equal("static class", surface.ContainerKind); + Assert.True(surface.IsStatic); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_RecordsPublicHelperDelegatesConstructorsAndIndexers() { @@ -386,8 +476,13 @@ public bool TryGetToken(out NSError error) var fieldConstant = Assert.Single(surfaces, static surface => surface.BindingValue == "FIRAuthErrorDomain"); Assert.True(fieldConstant.IsStatic); + var helperType = Assert.Single(surfaces, static surface => surface.Kind == "manual-type"); + Assert.Equal("class", helperType.ContainerKind); + Assert.False(helperType.IsStatic); + var helperDelegate = Assert.Single(surfaces, static surface => surface.Kind == "manual-delegate"); Assert.Equal("Firebase.Auth.Auth+TokenFactory", helperDelegate.RuntimeTypeName); + Assert.Equal("delegate", helperDelegate.ContainerKind); Assert.Equal(["int", "ref NSError"], helperDelegate.ParameterTypes); Assert.Equal("string", helperDelegate.ReturnType); diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs index 8df0695f..ca64e657 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageValidatorTests.cs @@ -100,6 +100,7 @@ private static BindingSurfaceDescriptor CreateSurface(string surfaceId) => ParameterCount: 0, ParameterTypes: [], ReturnType: null, + UnderlyingType: null, NativeSelectors: [], SourceFile: "source/Firebase/Core/ApiDefinition.cs", Signature: "interface App"); diff --git a/scripts/FirebaseBindingAudit/BindingModel.cs b/scripts/FirebaseBindingAudit/BindingModel.cs index fe5982e4..1ab678f1 100644 --- a/scripts/FirebaseBindingAudit/BindingModel.cs +++ b/scripts/FirebaseBindingAudit/BindingModel.cs @@ -87,6 +87,8 @@ internal sealed record ManualSurfaceItem( { public string? ObjectiveCName { get; init; } public string Kind { get; init; } = "manual"; + public string? ContainerKind { get; init; } + public string? UnderlyingType { get; init; } public bool HasGetter { get; init; } public bool HasSetter { get; init; } public IReadOnlyList ManualAttributes { get; init; } = []; @@ -253,13 +255,13 @@ private void ParseType( if (isHelperFile) { - AddManualTypeItems(typeName, objectiveCName, containerKind, currentNamespace, filePath, bindingMembers.Values, manualItems); + AddManualTypeItems(typeName, objectiveCName, containerKind, isStatic, currentNamespace, filePath, bindingMembers.Values, manualItems); return; } if (baseTypeName is null && !isProtocol) { - AddManualTypeItems(typeName, objectiveCName, containerKind, currentNamespace, filePath, bindingMembers.Values, manualItems); + AddManualTypeItems(typeName, objectiveCName, containerKind, isStatic, currentNamespace, filePath, bindingMembers.Values, manualItems); return; } @@ -281,6 +283,7 @@ private static void AddManualTypeItems( string typeName, string typeMatchKey, string containerKind, + bool isStatic, string currentNamespace, string filePath, IEnumerable bindingMembers, @@ -296,13 +299,14 @@ private static void AddManualTypeItems( MatchTypeKey: typeMatchKey, MatchMemberKey: null, MemberName: null, - IsStatic: false, + IsStatic: isStatic, ParameterTypes: [], ReturnType: null, Signature: $"{containerKind} {typeName}", SourceFile: filePath) { - Kind = "manual-type" + Kind = "manual-type", + ContainerKind = isStatic ? "static class" : containerKind }); return; } @@ -322,6 +326,7 @@ private static void AddManualTypeItems( SourceFile: member.SourceFile) { Kind = ToManualMemberKind(member.Kind), + ContainerKind = isStatic ? "static class" : containerKind, ObjectiveCName = string.Equals(member.BindingAttribute, "Export", StringComparison.Ordinal) ? typeMatchKey : null, HasGetter = member.HasGetter, HasSetter = member.HasSetter @@ -498,7 +503,8 @@ private void ParseDelegate( Signature: BuildDelegateSignature(delegateDeclaration), SourceFile: filePath) { - Kind = "manual-delegate" + Kind = "manual-delegate", + ContainerKind = "delegate" }); return; } @@ -538,7 +544,9 @@ private void ParseEnum( Signature: $"enum {enumDeclaration.Identifier.Text}", SourceFile: filePath) { - Kind = "manual-type" + Kind = "manual-type", + ContainerKind = "enum", + UnderlyingType = enumDeclaration.BaseList?.Types.FirstOrDefault()?.Type.WithoutTrivia().ToString() }); return; } diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index d0736ff8..1287adcc 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -79,6 +79,7 @@ internal sealed record BindingSurfaceDescriptor( int ParameterCount, IReadOnlyList ParameterTypes, string? ReturnType, + string? UnderlyingType, IReadOnlyList NativeSelectors, string SourceFile, string Signature); @@ -373,6 +374,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: 0, ParameterTypes: [], ReturnType: null, + UnderlyingType: null, NativeSelectors: EmptySelectors, SourceFile: boundType.SourceFile, Signature: $"{boundType.ContainerKind} {boundType.Name}"); @@ -400,6 +402,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: member.Parameters.Count, ParameterTypes: member.Parameters.Select(static parameter => parameter.Type).ToList(), ReturnType: member.ReturnType, + UnderlyingType: null, NativeSelectors: BuildNativeSelectors(boundType, member).ToList(), SourceFile: member.SourceFile, Signature: member.Signature); @@ -433,6 +436,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: delegateSurface.Parameters.Count, ParameterTypes: delegateSurface.Parameters.Select(static parameter => parameter.Type).ToList(), ReturnType: delegateSurface.ReturnType, + UnderlyingType: null, NativeSelectors: EmptySelectors, SourceFile: delegateSurface.SourceFile, Signature: delegateSurface.Signature); @@ -459,6 +463,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: 0, ParameterTypes: [], ReturnType: null, + UnderlyingType: enumSurface.UnderlyingType, NativeSelectors: EmptySelectors, SourceFile: enumSurface.SourceFile, Signature: $"enum {enumSurface.Name}"); @@ -484,6 +489,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: 0, ParameterTypes: [], ReturnType: null, + UnderlyingType: enumSurface.UnderlyingType, NativeSelectors: EmptySelectors, SourceFile: enumMember.SourceFile, Signature: $"{enumSurface.Name}.{enumMember.Name}"); @@ -523,6 +529,7 @@ private static IEnumerable BuildSurfaces( ParameterCount: manualItem.ParameterTypes.Count, ParameterTypes: manualItem.ParameterTypes, ReturnType: manualItem.ReturnType, + UnderlyingType: manualItem.UnderlyingType, NativeSelectors: BuildManualNativeSelectors(manualItem, bindingAttribute, bindingValue), SourceFile: manualItem.SourceFile, Signature: manualItem.Signature); @@ -625,8 +632,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: signature, parameterTypes: parameterTypes, returnType: delegateDeclaration.ReturnType.WithoutTrivia().ToString(), + underlyingType: null, isStatic: false, - kind: "manual-delegate"); + kind: "manual-delegate", + containerKind: "delegate"); } foreach (var typeDeclaration in root.DescendantNodes().OfType()) @@ -638,6 +647,7 @@ private static IEnumerable BuildPublicHelperSurfaces(s var namespaceName = GetContainingNamespace(typeDeclaration); var typeName = GetQualifiedTypeName(typeDeclaration, typeDeclaration.Identifier.Text); + var containerKind = GetTypeDeclarationKind(typeDeclaration); yield return CreatePublicHelperSurface( target, helperFile, @@ -645,10 +655,13 @@ private static IEnumerable BuildPublicHelperSurfaces(s namespaceName, surfaceId: $"{target}:manual-type:{typeName}", memberName: null, - signature: $"{GetTypeDeclarationKind(typeDeclaration)} {typeDeclaration.Identifier.Text}", + signature: $"{containerKind} {typeDeclaration.Identifier.Text}", parameterTypes: [], returnType: null, - isStatic: typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword))); + underlyingType: null, + isStatic: typeDeclaration.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), + kind: "manual-type", + containerKind: containerKind); foreach (var property in typeDeclaration.Members.OfType()) { @@ -667,8 +680,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: $"{property.Type.WithoutTrivia()} {property.Identifier.Text} {{ get; }}", parameterTypes: [], returnType: property.Type.WithoutTrivia().ToString(), + underlyingType: null, isStatic: property.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), kind: "manual-property", + containerKind: containerKind, hasGetter: HasGetter(property.AccessorList), hasSetter: HasSetter(property.AccessorList)); } @@ -692,8 +707,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: signature, parameterTypes: parameterTypes, returnType: indexer.Type.WithoutTrivia().ToString(), + underlyingType: null, isStatic: false, kind: "manual-indexer", + containerKind: containerKind, hasGetter: HasGetter(indexer.AccessorList), hasSetter: HasSetter(indexer.AccessorList)); } @@ -717,8 +734,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: signature, parameterTypes: parameterTypes, returnType: null, + underlyingType: null, isStatic: false, - kind: "manual-constructor"); + kind: "manual-constructor", + containerKind: containerKind); } foreach (var method in typeDeclaration.Members.OfType()) @@ -740,8 +759,10 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: signature, parameterTypes: parameterTypes, returnType: method.ReturnType.WithoutTrivia().ToString(), + underlyingType: null, isStatic: method.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), - kind: "manual-method"); + kind: "manual-method", + containerKind: containerKind); } foreach (var field in typeDeclaration.Members.OfType()) @@ -763,12 +784,39 @@ private static IEnumerable BuildPublicHelperSurfaces(s signature: $"{field.Declaration.Type.WithoutTrivia()} {variable.Identifier.Text}", parameterTypes: [], returnType: field.Declaration.Type.WithoutTrivia().ToString(), + underlyingType: null, isStatic: field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)) || field.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.ConstKeyword)), - kind: "manual-field"); + kind: "manual-field", + containerKind: containerKind); } } } + + foreach (var enumDeclaration in root.DescendantNodes().OfType()) + { + if (!IsEffectivelyPublic(enumDeclaration)) + { + continue; + } + + var namespaceName = GetContainingNamespace(enumDeclaration); + var typeName = GetQualifiedTypeName(enumDeclaration, enumDeclaration.Identifier.Text); + yield return CreatePublicHelperSurface( + target, + helperFile, + typeName, + namespaceName, + surfaceId: $"{target}:manual-type:{typeName}", + memberName: null, + signature: $"enum {enumDeclaration.Identifier.Text}", + parameterTypes: [], + returnType: null, + underlyingType: enumDeclaration.BaseList?.Types.FirstOrDefault()?.Type.WithoutTrivia().ToString(), + isStatic: false, + kind: "manual-type", + containerKind: "enum"); + } } } @@ -782,8 +830,10 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( string signature, IReadOnlyList parameterTypes, string? returnType, + string? underlyingType, bool isStatic, string? kind = null, + string? containerKind = null, bool hasGetter = false, bool hasSetter = false) => new( @@ -794,7 +844,7 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( RuntimeTypeName: typeName, AssemblyName: ResolveAssemblyName(namespaceName), ObjectiveCName: null, - ContainerKind: "manual", + ContainerKind: containerKind ?? "manual", IsProtocol: false, IsStatic: isStatic, MemberName: memberName, @@ -805,6 +855,7 @@ private static BindingSurfaceDescriptor CreatePublicHelperSurface( ParameterCount: parameterTypes.Count, ParameterTypes: parameterTypes, ReturnType: returnType, + UnderlyingType: underlyingType, NativeSelectors: EmptySelectors, SourceFile: helperFile, Signature: signature); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 6fee192c..59d0e2b8 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -242,6 +242,8 @@ static Type ResolveManagedType(BindingSurfaceDescriptor surface) static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) { + VerifyManagedTypeShape(type, surface); + if (IsDelegateSurface(surface)) { VerifyDelegateSurface(type, surface); @@ -282,6 +284,84 @@ static void ResolveManagedMember(Type type, BindingSurfaceDescriptor surface) } } + static void VerifyManagedTypeShape(Type type, BindingSurfaceDescriptor surface) + { + if (string.Equals(surface.Kind, "enum", StringComparison.Ordinal) || + string.Equals(surface.Kind, "enum-member", StringComparison.Ordinal) || + string.Equals(surface.ContainerKind, "enum", StringComparison.Ordinal)) + { + VerifyEnumSurface(type, surface); + return; + } + + if (IsDelegateSurface(surface)) + { + if (!typeof(Delegate).IsAssignableFrom(type)) + { + throw new TypeLoadException($"Managed type '{type.FullName}' is not a delegate."); + } + + return; + } + + // Binding-definition interfaces and protocols can generate managed classes; only + // helper/manual type shapes are expected to map 1:1 to reflection Type shape here. + if (!string.Equals(surface.Kind, "manual-type", StringComparison.Ordinal)) + { + return; + } + + VerifyManualTypeShape(type, surface); + } + + static void VerifyEnumSurface(Type type, BindingSurfaceDescriptor surface) + { + if (!type.IsEnum) + { + throw new TypeLoadException($"Managed enum '{surface.RuntimeTypeName}' resolved to non-enum type '{type.FullName}'."); + } + + if (!string.IsNullOrWhiteSpace(surface.UnderlyingType) && + !TypeMatches(Enum.GetUnderlyingType(type), surface.UnderlyingType)) + { + throw new TypeLoadException( + $"Managed enum '{type.FullName}' has underlying type '{Enum.GetUnderlyingType(type).FullName}', expected '{surface.UnderlyingType}'."); + } + } + + static void VerifyManualTypeShape(Type type, BindingSurfaceDescriptor surface) + { + switch (surface.ContainerKind) + { + case "static class": + if (!IsStaticClass(type)) + { + throw new TypeLoadException($"Managed helper type '{type.FullName}' is not a static class."); + } + break; + case "class": + if (!type.IsClass || IsStaticClass(type)) + { + throw new TypeLoadException($"Managed helper type '{type.FullName}' is not an instance class."); + } + break; + case "struct": + if (!type.IsValueType || type.IsEnum) + { + throw new TypeLoadException($"Managed helper type '{type.FullName}' is not a struct."); + } + break; + case "interface": + if (!type.IsInterface) + { + throw new TypeLoadException($"Managed helper type '{type.FullName}' is not an interface."); + } + break; + } + } + + static bool IsStaticClass(Type type) => type.IsAbstract && type.IsSealed; + static Type? ResolveManagedType(string runtimeTypeName, string assemblyName) { foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) @@ -868,6 +948,7 @@ sealed class BindingSurfaceDescriptor public int ParameterCount { get; set; } public List ParameterTypes { get; set; } = []; public string? ReturnType { get; set; } + public string? UnderlyingType { get; set; } public List NativeSelectors { get; set; } = []; public string SourceFile { get; set; } = string.Empty; public string Signature { get; set; } = string.Empty; From 3513ff161a1a4abd10c8a44d852e90d291003702 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 20:21:39 -0400 Subject: [PATCH 20/23] Validate manual symbol wrapper surfaces --- .../FirebaseBindingSurfaceCoverage.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs index 59d0e2b8..aa1b42be 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseBindingSurfaceCoverage.cs @@ -215,10 +215,18 @@ static void ExerciseSurface(BindingSurfaceDescriptor surface) else if (string.Equals(surface.BindingAttribute, "Field", StringComparison.Ordinal) || string.Equals(surface.BindingAttribute, "Notification", StringComparison.Ordinal)) { + if (IsManualSurface(surface)) + { + TouchManualSurface(type, surface); + } + ProbeNativeSymbol(surface); - TouchStaticMemberIfAvailable(type, surface.MemberName); + if (!IsManualSurface(surface)) + { + TouchStaticMemberIfAvailable(type, surface.MemberName); + } } - else if (surface.Kind.StartsWith("manual", StringComparison.Ordinal)) + else if (IsManualSurface(surface)) { TouchManualSurface(type, surface); } @@ -610,6 +618,9 @@ static bool IsManualMethodSurface(BindingSurfaceDescriptor surface) => !string.Equals(surface.Kind, "manual-constructor", StringComparison.Ordinal) && !IsDelegateSurface(surface); + static bool IsManualSurface(BindingSurfaceDescriptor surface) => + surface.Kind.StartsWith("manual", StringComparison.Ordinal); + static bool IsDelegateSurface(BindingSurfaceDescriptor surface) => string.Equals(surface.Kind, "delegate", StringComparison.Ordinal) || string.Equals(surface.Kind, "manual-delegate", StringComparison.Ordinal); From 7e61eaf9ae6e4c3768228d5d555ca2f42a5ec3cd Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 20:36:28 -0400 Subject: [PATCH 21/23] Preserve manual surface container kinds --- .../BindingSurfaceCoverageBuilderTests.cs | 41 +++++++++++++++++++ .../BindingSurfaceCoverage.cs | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index 55b38da6..f07eb07e 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -423,6 +423,47 @@ public static class AuthHelpers } } + [Fact] + public void Build_PreservesApiDefinitionManualTypeShape() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + + public interface AuthMarker + { + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration()).Build( + repoRoot, + CreateManifest(), + "Auth"); + + var surface = Assert.Single( + document.Targets.Single().Surfaces, + static surface => surface.Kind == "manual-type"); + + Assert.Equal("Firebase.Auth.AuthMarker", surface.RuntimeTypeName); + Assert.Equal("interface", surface.ContainerKind); + Assert.False(surface.IsStatic); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_RecordsPublicHelperDelegatesConstructorsAndIndexers() { diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 1287adcc..9c17574f 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -518,7 +518,7 @@ private static IEnumerable BuildSurfaces( RuntimeTypeName: manualItem.TypeName, AssemblyName: ResolveAssemblyName(ExtractNamespace(manualItem.TypeName)), ObjectiveCName: manualItem.ObjectiveCName, - ContainerKind: "manual", + ContainerKind: manualItem.ContainerKind ?? "manual", IsProtocol: false, IsStatic: manualItem.IsStatic, MemberName: manualItem.MemberName, From 12e348bc7f941eddfe481ad788c6106df5ae5587 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 20:51:39 -0400 Subject: [PATCH 22/23] Document AppDistribution coverage packaging --- tests/E2E/Firebase.Foundation/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/E2E/Firebase.Foundation/README.md b/tests/E2E/Firebase.Foundation/README.md index 435c12b3..d0d73dd1 100644 --- a/tests/E2E/Firebase.Foundation/README.md +++ b/tests/E2E/Firebase.Foundation/README.md @@ -95,7 +95,7 @@ tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug ## Binding surface coverage mode -The opt-in binding surface coverage lane accounts for the 14 active Firebase `12.6.0` bindings tracked by the audit config. It generates a source-derived coverage inventory from [`binding-surface-coverage.json`](./binding-surface-coverage.json), restores the selected target package set from the local feed, and runs `ConfigureApp` plus the generated surface coverage case. +The opt-in binding surface coverage lane accounts for the active Firebase `12.6.0` bindings tracked by the audit config. It generates a source-derived coverage inventory from [`binding-surface-coverage.json`](./binding-surface-coverage.json), restores the selected target package set from the local feed, and runs `ConfigureApp` plus the generated surface coverage case. This lane treats native Firebase/backend errors as acceptable when the binding surface is present and loadable. It fails binding-layer problems such as missing managed types, missing Objective-C classes/protocols/selectors, missing native symbols, `TypeLoadException`, `DllNotFoundException`, `EntryPointNotFoundException`, and `ObjCRuntime.ObjCException`. @@ -103,7 +103,7 @@ Pack the active Firebase set when running the aggregate lane: ```sh dotnet tool restore -dotnet tool run dotnet-cake -- --target=nuget --names="Firebase.ABTesting,Firebase.Analytics,Firebase.AppCheck,Firebase.Auth,Firebase.CloudFirestore,Firebase.CloudFunctions,Firebase.CloudMessaging,Firebase.Core,Firebase.Crashlytics,Firebase.Database,Firebase.Installations,Firebase.PerformanceMonitoring,Firebase.RemoteConfig,Firebase.Storage" +dotnet tool run dotnet-cake -- --target=nuget --names="Firebase.ABTesting,Firebase.Analytics,Firebase.AppCheck,Firebase.AppDistribution,Firebase.Auth,Firebase.CloudFirestore,Firebase.CloudFunctions,Firebase.CloudMessaging,Firebase.Core,Firebase.Crashlytics,Firebase.Database,Firebase.Installations,Firebase.PerformanceMonitoring,Firebase.RemoteConfig,Firebase.Storage" ``` Run a representative target or the full active set with: From c44f271bb761f6bba862fc6468938e6528dcd43b Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Mon, 13 Apr 2026 21:27:12 -0400 Subject: [PATCH 23/23] Address audit PR review feedback --- .../AuditRunnerTests.cs | 70 ++++++++++++++++++- .../BindingSurfaceCoverageBuilderTests.cs | 66 +++++++++++++++++ scripts/FirebaseBindingAudit/AuditRunner.cs | 54 ++++++++++++-- .../BindingSurfaceCoverage.cs | 15 +++- 4 files changed, 194 insertions(+), 11 deletions(-) diff --git a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs index 6972ae04..243417fd 100644 --- a/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/AuditRunnerTests.cs @@ -63,8 +63,60 @@ public void ShouldUseSharpieComparisonFallback_DoesNotUseAutoWhenPrimaryComparis [Fact] public void ShouldStageSharpieFrameworkSlices_SkipsWhenSharpieIsDisabled() { - Assert.False(AuditRunner.ShouldStageSharpieFrameworkSlices(CreateAuditOptions(disableSharpie: true))); - Assert.True(AuditRunner.ShouldStageSharpieFrameworkSlices(CreateAuditOptions(disableSharpie: false))); + var selectedTargets = new[] { CreateTarget("Auth", "FirebaseAuth") }; + + Assert.False(AuditRunner.ShouldStageSharpieFrameworkSlices( + CreateAuditOptions(disableSharpie: true), + selectedTargets, + CreateSharpieConfiguration())); + Assert.True(AuditRunner.ShouldStageSharpieFrameworkSlices( + CreateAuditOptions(disableSharpie: false), + selectedTargets, + CreateSharpieConfiguration())); + } + + [Fact] + public void ShouldStageSharpieFrameworkSlices_SkipsTargetsWithSharpieOff() + { + var selectedTargets = new[] { CreateTarget("Auth", "FirebaseAuth", sharpieMode: "off") }; + + var shouldStage = AuditRunner.ShouldStageSharpieFrameworkSlices( + CreateAuditOptions(disableSharpie: false), + selectedTargets, + CreateSharpieConfiguration()); + + Assert.False(shouldStage); + } + + [Fact] + public void ShouldStageSharpieFrameworkSlices_IncludesAutoAndForceFallbackTargets() + { + var selectedTargets = new[] + { + CreateTarget("Auth", "FirebaseAuth", sharpieMode: "auto"), + CreateTarget("CloudFirestore", "FirebaseFirestore", sharpieMode: "forceFallback") + }; + + var stagedXcframeworks = AuditRunner.GetSharpieFrameworkSliceStageXcframeworks( + CreateAuditOptions(disableSharpie: false), + selectedTargets, + CreateSharpieConfiguration()); + + Assert.Equal(["FirebaseAuth", "FirebaseFirestore"], stagedXcframeworks); + } + + [Fact] + public void GetSharpieFrameworkSliceStageXcframeworks_ExcludesUnselectedTargets() + { + var selectedTargets = new[] { CreateTarget("Auth", "FirebaseAuth") }; + + var stagedXcframeworks = AuditRunner.GetSharpieFrameworkSliceStageXcframeworks( + CreateAuditOptions(disableSharpie: false), + selectedTargets, + CreateSharpieConfiguration()); + + Assert.Equal(["FirebaseAuth"], stagedXcframeworks); + Assert.DoesNotContain("UnsupportedUnselectedFramework", stagedXcframeworks); } [Fact] @@ -128,4 +180,18 @@ private static AuditOptions CreateAuditOptions(bool disableSharpie) => SharpieVersion: "26.3.0.11", DisableSharpie: disableSharpie, DisableSuppressions: false); + + private static SharpieConfiguration CreateSharpieConfiguration() => + new() + { + DefaultMode = "auto" + }; + + private static AuditTargetDefinition CreateTarget(string id, string xcframework, string? sharpieMode = null) => + new() + { + Id = id, + Xcframework = xcframework, + SharpieMode = sharpieMode + }; } diff --git a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs index f07eb07e..a93fbfdd 100644 --- a/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/BindingSurfaceCoverageBuilderTests.cs @@ -464,6 +464,72 @@ public interface AuthMarker } } + [Fact] + public void Build_IncludesImplicitlyPublicInterfaceHelperMembers() + { + var repoRoot = Path.Combine(Path.GetTempPath(), $"firebase-binding-surface-builder-{Guid.NewGuid():N}"); + + try + { + var sourceDirectory = Path.Combine(repoRoot, "source", "Firebase", "Auth"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "ApiDefinition.cs"), + """ + namespace Firebase.Auth; + """); + File.WriteAllText( + Path.Combine(sourceDirectory, "Extension.cs"), + """ + namespace Firebase.Auth; + + public interface IAuthTokenSource + { + string Token { get; } + + string this[int index] { get; } + + bool TryGetToken(out NSError error); + + private string HiddenToken() => ""; + } + """); + + var document = new BindingSurfaceCoverageBuilder(CreateConfiguration(["Extension.cs"])).Build( + repoRoot, + CreateManifest("Extension.cs"), + "Auth"); + + var surfaces = document.Targets.Single().Surfaces; + var helperType = Assert.Single(surfaces, static surface => surface.Kind == "manual-type"); + Assert.Equal("Firebase.Auth.IAuthTokenSource", helperType.RuntimeTypeName); + Assert.Equal("interface", helperType.ContainerKind); + + var helperProperty = Assert.Single(surfaces, static surface => surface.Kind == "manual-property"); + Assert.Equal("Token", helperProperty.MemberName); + Assert.Equal("string", helperProperty.ReturnType); + + var helperMethod = Assert.Single(surfaces, static surface => surface.Kind == "manual-method"); + Assert.Equal("TryGetToken", helperMethod.MemberName); + Assert.Equal(["out NSError"], helperMethod.ParameterTypes); + Assert.Equal("bool", helperMethod.ReturnType); + + var helperIndexer = Assert.Single(surfaces, static surface => surface.Kind == "manual-indexer"); + Assert.Equal("Item", helperIndexer.MemberName); + Assert.Equal(["int"], helperIndexer.ParameterTypes); + Assert.Equal("string", helperIndexer.ReturnType); + + Assert.DoesNotContain(surfaces, static surface => surface.MemberName == "HiddenToken"); + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + [Fact] public void Build_RecordsPublicHelperDelegatesConstructorsAndIndexers() { diff --git a/scripts/FirebaseBindingAudit/AuditRunner.cs b/scripts/FirebaseBindingAudit/AuditRunner.cs index b4c1239f..b970d929 100644 --- a/scripts/FirebaseBindingAudit/AuditRunner.cs +++ b/scripts/FirebaseBindingAudit/AuditRunner.cs @@ -126,9 +126,10 @@ public async Task RunAsync(AuditOptions options, CancellationTok var stagedFrameworksDirectory = Path.Combine(tempRoot, "frameworks"); StageXcframeworks(options.RepoRoot, stagedFrameworksDirectory); var sharpieFrameworkSearchDirectory = Path.Combine(tempRoot, "sharpie-frameworks"); - if (ShouldStageSharpieFrameworkSlices(options)) + var sharpieStageXcframeworks = GetSharpieFrameworkSliceStageXcframeworks(options, selectedTargets, configuration.Sharpie); + if (sharpieStageXcframeworks.Count > 0) { - StageSharpieFrameworkSlices(stagedFrameworksDirectory, sharpieFrameworkSearchDirectory); + StageSharpieFrameworkSlices(stagedFrameworksDirectory, sharpieFrameworkSearchDirectory, sharpieStageXcframeworks); } var sharedAliases = BuildSharedAliases(options.RepoRoot); @@ -390,8 +391,29 @@ sharpieRun is not null && sharpieRun.Comparison is not null; } - internal static bool ShouldStageSharpieFrameworkSlices(AuditOptions options) => - !options.DisableSharpie; + internal static bool ShouldStageSharpieFrameworkSlices( + AuditOptions options, + IReadOnlyList selectedTargets, + SharpieConfiguration sharpieConfiguration) => + GetSharpieFrameworkSliceStageXcframeworks(options, selectedTargets, sharpieConfiguration).Count > 0; + + internal static IReadOnlyList GetSharpieFrameworkSliceStageXcframeworks( + AuditOptions options, + IReadOnlyList selectedTargets, + SharpieConfiguration sharpieConfiguration) + { + if (options.DisableSharpie) + { + return []; + } + + return selectedTargets + .Where(target => !string.Equals(target.EffectiveSharpieMode(sharpieConfiguration), "off", StringComparison.OrdinalIgnoreCase)) + .Select(static target => target.Xcframework) + .Where(static xcframework => !string.IsNullOrWhiteSpace(xcframework)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } internal static string CreateTempRootPath() => Path.Combine(Path.GetTempPath(), "firebase-binding-audit", $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"); @@ -1186,12 +1208,30 @@ private static void StageXcframeworks(string repoRoot, string destinationDirecto } } - private static void StageSharpieFrameworkSlices(string stagedFrameworksDirectory, string destinationDirectory) + private static void StageSharpieFrameworkSlices( + string stagedFrameworksDirectory, + string destinationDirectory, + IReadOnlyList xcframeworkNames) { Directory.CreateDirectory(destinationDirectory); - foreach (var xcframeworkDirectory in Directory.EnumerateDirectories(stagedFrameworksDirectory, "*.xcframework", SearchOption.TopDirectoryOnly)) + foreach (var xcframeworkName in xcframeworkNames) { - var frameworkPath = ResolveSharpieFrameworkPath(xcframeworkDirectory); + var xcframeworkDirectory = Path.Combine(stagedFrameworksDirectory, $"{xcframeworkName}.xcframework"); + if (!Directory.Exists(xcframeworkDirectory)) + { + continue; + } + + string frameworkPath; + try + { + frameworkPath = ResolveSharpieFrameworkPath(xcframeworkDirectory); + } + catch (InvalidOperationException) + { + continue; + } + var destinationPath = Path.Combine(destinationDirectory, Path.GetFileName(frameworkPath)); TryCreateDirectorySymlink(destinationPath, frameworkPath); } diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 9c17574f..858e0c77 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -879,8 +879,19 @@ private static bool IsEffectivelyPublic(MemberDeclarationSyntax member) => IsPublic(member) && member.Ancestors().OfType().All(IsPublic); - private static bool IsPublic(MemberDeclarationSyntax member) => - member.Modifiers.Any(static modifier => modifier.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PublicKeyword)); + private static bool IsPublic(MemberDeclarationSyntax member) + { + if (member.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PublicKeyword))) + { + return true; + } + + return member.Parent is InterfaceDeclarationSyntax && + !member.Modifiers.Any(static modifier => + modifier.IsKind(SyntaxKind.PrivateKeyword) || + modifier.IsKind(SyntaxKind.ProtectedKeyword) || + modifier.IsKind(SyntaxKind.InternalKeyword)); + } private static bool HasGetter(AccessorListSyntax? accessorList) => accessorList is null ||