From eed7f15a3c56a02c490002a7b126167be2344d97 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 16 Oct 2026 13:31:58 +0200 Subject: [PATCH 1/5] feat(capability-matrix): add attributes to flag capabilities on public members --- Directory.Packages.props | 3 + .../Core/Attributes/CapabilityAttributes.cs | 77 +++++++++++++++++++ packages/Core/Core/Core.csproj | 3 + 3 files changed, 83 insertions(+) create mode 100644 packages/Core/Core/Attributes/CapabilityAttributes.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 69725885..aee51786 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,9 @@ + + + diff --git a/packages/Core/Core/Attributes/CapabilityAttributes.cs b/packages/Core/Core/Attributes/CapabilityAttributes.cs new file mode 100644 index 00000000..4ce39e37 --- /dev/null +++ b/packages/Core/Core/Attributes/CapabilityAttributes.cs @@ -0,0 +1,77 @@ +using System; + +namespace Supabase.Core.Attributes; + +/// +/// Marks a public member as the implementation of a Supabase capability-matrix +/// feature. The capability-matrix tooling reads these annotations to generate +/// sdk-compliance.yaml, so the mapping lives next to the code it describes +/// and survives renames instead of drifting in a hand-maintained file. +/// +/// +/// The status reported to the matrix is the concrete attribute type: +/// or +/// . +/// +internal abstract class CapabilityAttribute : Attribute +{ + /// + /// The capability-matrix feature id this member implements, in + /// area.group.feature form (for example + /// auth.sign_in.sign_in_with_password). + /// + public string Key { get; } + + /// + /// An optional human-readable note surfaced in sdk-compliance.yaml, + /// typically explaining how this SDK diverges from the canonical behaviour. + /// + public string? Note { get; } + + /// The capability-matrix feature id this member implements. + /// An optional note surfaced in the generated compliance file. + protected CapabilityAttribute(string key, string? note) + { + this.Key = key; + this.Note = note; + } +} + +/// +/// Marks a public member as a fully implemented Supabase capability-matrix feature. +/// +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | + AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, + AllowMultiple = true)] +internal sealed class ImplementedCapabilityAttribute : CapabilityAttribute +{ + /// The capability-matrix feature id this member implements. + /// + /// An optional note surfaced in the generated compliance file, for an + /// implemented feature whose behaviour still warrants a caveat. + /// + public ImplementedCapabilityAttribute(string key, string? note = null) + : base(key, note) + { + } +} + +/// +/// Marks a public member as a partially implemented Supabase capability-matrix +/// feature. The note is required: the matrix rejects a partially_implemented +/// entry that does not explain what is missing, so the constructor demands it. +/// +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | + AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, + AllowMultiple = true)] +internal sealed class PartialCapabilityAttribute : CapabilityAttribute +{ + /// The capability-matrix feature id this member partially implements. + /// A required note explaining what is missing relative to the canonical behaviour. + public PartialCapabilityAttribute(string key, string note) + : base(key, note) + { + } +} diff --git a/packages/Core/Core/Core.csproj b/packages/Core/Core/Core.csproj index a560c8db..4957cd45 100644 --- a/packages/Core/Core/Core.csproj +++ b/packages/Core/Core/Core.csproj @@ -44,9 +44,12 @@ + + + From 928f1d2ba984617e9e3ac6a5411dfbfa9d0098f8 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 16 Oct 2026 13:39:07 +0200 Subject: [PATCH 2/5] feat(capability-matrix): add generation process --- Supabase.sln | 58 ++++- .../CapabilityMatrix.Tests.csproj | 31 +++ .../CliAndParityTests.cs | 121 +++++++++ .../CommandRunnerTests.cs | 137 +++++++++++ .../ComplianceModelTests.cs | 140 +++++++++++ .../ParseCompareLocateTests.cs | 144 +++++++++++ .../SpecAndEmitterTests.cs | 121 +++++++++ .../SymbolWalkerTests.cs | 194 +++++++++++++++ .../CapabilityMatrix/CapabilityMatrix.csproj | 31 +++ .../CapabilityMatrix/CapabilityStatus.cs | 23 ++ .../CapabilityMatrix/CommandRunner.cs | 134 ++++++++++ .../CapabilityMatrix/ComplianceComparer.cs | 50 ++++ .../CapabilityMatrix/ComplianceModel.cs | 95 ++++++++ .../CapabilityMatrix/ComplianceYaml.cs | 75 ++++++ .../CapabilityMatrix/DeclaredSymbol.cs | 28 +++ .../CapabilityMatrix/FileWorkspace.cs | 42 ++++ .../CapabilityMatrix/IWorkspace.cs | 22 ++ .../CapabilityMatrix/KeyValidator.cs | 21 ++ .../CapabilityMatrix/Locator.cs | 61 +++++ .../CapabilityMatrix/Program.cs | 105 ++++++++ .../CapabilityMatrix/SourceDiscovery.cs | 30 +++ .../CapabilityMatrix/SpecModel.cs | 92 +++++++ .../CapabilityMatrix/SymbolWalker.cs | 229 ++++++++++++++++++ .../CapabilityMatrix/YamlEmitter.cs | 85 +++++++ 24 files changed, 2056 insertions(+), 13 deletions(-) create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/Locator.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/Program.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs create mode 100644 packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs diff --git a/Supabase.sln b/Supabase.sln index 780aa7b1..98599545 100644 --- a/Supabase.sln +++ b/Supabase.sln @@ -175,7 +175,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "red-test", "red-test", "{61 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{996F8526-0051-4327-A551-1175C455CC5F}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DependencyInjection", "DependencyInjection", "{9FD9C523-BB83-D457-CA1C-9430D9580376}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DependencyInjection", "DependencyInjection", "{C0B9E91A-9577-B879-D937-00DB4C86FF49}" ProjectSection(SolutionItems) = preProject packages\extensions\DependencyInjection\README.md = packages\extensions\DependencyInjection\README.md EndProjectSection @@ -240,7 +240,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Quality Gate", "Quality Gat EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Public API", "Public API", "{77474E39-B8A1-46A4-BF35-C042DAFCAE4B}" EndProject - +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CapabilityMatrix", "CapabilityMatrix", "{287C1647-12FE-4F77-D529-18732EC0C527}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CapabilityMatrix", "packages\CapabilityMatrix\CapabilityMatrix\CapabilityMatrix.csproj", "{7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CapabilityMatrix.Tests", "packages\CapabilityMatrix\CapabilityMatrix.Tests\CapabilityMatrix.Tests.csproj", "{0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -443,16 +448,35 @@ Global {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x64.Build.0 = Release|Any CPU {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x86.ActiveCfg = Release|Any CPU {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x86.Build.0 = Release|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x64.ActiveCfg = Debug|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x64.Build.0 = Debug|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x86.ActiveCfg = Debug|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Debug|x86.Build.0 = Debug|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|Any CPU.Build.0 = Release|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x64.ActiveCfg = Release|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x64.Build.0 = Release|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x86.ActiveCfg = Release|Any CPU + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8}.Release|x86.Build.0 = Release|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x64.Build.0 = Debug|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Debug|x86.Build.0 = Debug|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|Any CPU.Build.0 = Release|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x64.ActiveCfg = Release|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x64.Build.0 = Release|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x86.ActiveCfg = Release|Any CPU + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {1085D75D-D916-4F8D-8B9B-8C242DA847BE} = {3074019A-049A-4959-9FCC-410144E423F5} - {320443A0-3589-44BD-85FD-59118B0D8AF4} = {3074019A-049A-4959-9FCC-410144E423F5} - {D40502AB-54A1-4E0B-952B-C96AF567B08A} = {3074019A-049A-4959-9FCC-410144E423F5} - {53322416-5ED2-433A-8CCD-36BC424C7ED0} = {3074019A-049A-4959-9FCC-410144E423F5} - {9BB19953-99BC-4D4C-B164-FAC2444FE56C} = {3074019A-049A-4959-9FCC-410144E423F5} {81C5FA5E-A62B-98A1-4CBC-09B5BB06FF07} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} {52157DD7-2D5D-41C3-87A4-2894AD47528C} = {81C5FA5E-A62B-98A1-4CBC-09B5BB06FF07} {A1459477-0702-4211-956E-5D2F88BEF669} = {81C5FA5E-A62B-98A1-4CBC-09B5BB06FF07} @@ -475,6 +499,8 @@ Global {B898A942-9C1A-4C3F-8992-664B636D0ED8} = {48B3645E-6A5B-E050-DA5F-480AE4911C13} {ACE6D9C0-1F55-4A95-8250-C074D5EBA913} = {48B3645E-6A5B-E050-DA5F-480AE4911C13} {D1072B4D-1697-4401-9431-F90A6B9AD2BB} = {AB28C3BB-F406-476D-9004-804D35EAA048} + {226BEB75-46D1-4366-BBDE-03C9E7A6D6F1} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7} + {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7} {27BCCB1B-2091-4A34-9966-098BCE684A6E} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} {A024290F-C0C4-4A3B-BE9D-7715CC9AF747} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} {AEA69720-4764-432F-9843-634D152C8C33} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} @@ -484,14 +510,20 @@ Global {097CDFF6-9B52-46C9-88BF-62ED8FE62957} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} {612A3F59-5D51-4E02-80C1-4E687887B73D} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} {996F8526-0051-4327-A551-1175C455CC5F} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} - {9FD9C523-BB83-D457-CA1C-9430D9580376} = {996F8526-0051-4327-A551-1175C455CC5F} - {481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6} = {9FD9C523-BB83-D457-CA1C-9430D9580376} - {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03} = {9FD9C523-BB83-D457-CA1C-9430D9580376} + {C0B9E91A-9577-B879-D937-00DB4C86FF49} = {996F8526-0051-4327-A551-1175C455CC5F} + {481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6} = {C0B9E91A-9577-B879-D937-00DB4C86FF49} + {709EBF63-BDED-465F-9CEA-D1C7C9E2AB03} = {C0B9E91A-9577-B879-D937-00DB4C86FF49} + {3074019A-049A-4959-9FCC-410144E423F5} = {77474E39-B8A1-46A4-BF35-C042DAFCAE4B} + {1085D75D-D916-4F8D-8B9B-8C242DA847BE} = {3074019A-049A-4959-9FCC-410144E423F5} + {320443A0-3589-44BD-85FD-59118B0D8AF4} = {3074019A-049A-4959-9FCC-410144E423F5} + {D40502AB-54A1-4E0B-952B-C96AF567B08A} = {3074019A-049A-4959-9FCC-410144E423F5} + {53322416-5ED2-433A-8CCD-36BC424C7ED0} = {3074019A-049A-4959-9FCC-410144E423F5} + {9BB19953-99BC-4D4C-B164-FAC2444FE56C} = {3074019A-049A-4959-9FCC-410144E423F5} {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7} = {97E13694-38F0-4D01-BA50-436D9ADCF99C} - {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7} {77474E39-B8A1-46A4-BF35-C042DAFCAE4B} = {97E13694-38F0-4D01-BA50-436D9ADCF99C} - {3074019A-049A-4959-9FCC-410144E423F5} = {77474E39-B8A1-46A4-BF35-C042DAFCAE4B} - {226BEB75-46D1-4366-BBDE-03C9E7A6D6F1} = {B7F880FB-DCBF-4EE7-82F3-C8D2127984F7} + {287C1647-12FE-4F77-D529-18732EC0C527} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} + {7EA6EBE2-8EAD-4ECC-A866-24B0BEE88FC8} = {287C1647-12FE-4F77-D529-18732EC0C527} + {0C590BBB-BFBF-4D2C-A1EF-CDD3136FE19C} = {287C1647-12FE-4F77-D529-18732EC0C527} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {832DE89D-7252-4B03-9301-BB8D36B40992} diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj new file mode 100644 index 00000000..c9d1f920 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CapabilityMatrix.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + CapabilityMatrix.Tests + Supabase.CapabilityMatrix.Tests + false + enable + enable + true + true + latest + True + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs new file mode 100644 index 00000000..3cba3c48 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; + +namespace Supabase.CapabilityMatrix.Tests; + +[TestClass] +public class CliEntryPointTests +{ + private static (int Code, string Out, string Err) Run(params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + var code = CliEntryPoint.Run(args, output, error); + return (code, output.ToString(), error.ToString()); + } + + [TestMethod] + public void NoArgumentsPrintsUsage() + { + var (code, _, err) = Run(); + code.Should().Be(2); + err.Should().Contain("Usage:"); + } + + [TestMethod] + public void UnknownCommandFails() + { + var (code, _, err) = Run("frobnicate"); + code.Should().Be(2); + err.Should().Contain("Unknown command"); + } + + [TestMethod] + public void GenerateWithoutSpecFails() + { + var (code, _, err) = Run("generate", "--root", "."); + code.Should().Be(2); + err.Should().Contain("requires --spec"); + } + + [TestMethod] + public void MissingComplianceFileIsHandledAsAnEnvironmentError() + { + var (code, _, err) = Run("locate", "--root", Path.GetTempPath(), "--compliance", Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.yaml")); + code.Should().Be(2); + err.Should().Contain("❌"); + } + + [TestMethod] + public void GenerateEndToEndThroughTheFilesystem() + { + var root = Directory.CreateTempSubdirectory("capmatrix"); + try + { + var pkg = Directory.CreateDirectory(Path.Combine(root.FullName, "packages", "Gotrue", "Gotrue")); + File.WriteAllText(Path.Combine(pkg.FullName, "Client.cs"), + """ + using Supabase.Core.Attributes; + namespace Supabase.Gotrue + { + public class Client + { + [ImplementedCapability("auth.sign_in.sign_up")] + public void SignUp() { } + } + } + """); + + var spec = Directory.CreateDirectory(Path.Combine(root.FullName, "spec")); + File.WriteAllText(Path.Combine(spec.FullName, "auth.yaml"), + "area: auth\ntitle: Authentication\ngroups:\n - id: sign_in\n title: Sign-in\nfeatures:\n - id: auth.sign_in.sign_up\n group: sign_in\n"); + + var outPath = Path.Combine(root.FullName, "sdk-compliance.yaml"); + var (code, output, _) = Run("generate", "--root", root.FullName, "--spec", spec.FullName, "--out", outPath); + + code.Should().Be(0); + output.Should().Contain("✅"); + File.ReadAllText(outPath).Should().Contain("auth.sign_in.sign_up:").And.Contain("- Client.SignUp"); + } + finally + { + root.Delete(recursive: true); + } + } +} + +[TestClass] +public class RepositoryParityTests +{ + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "sdk-compliance.yaml")) && + Directory.Exists(Path.Combine(directory.FullName, "packages"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate the repository root from the test assembly."); + } + + [TestMethod] + public void CommittedComplianceFileMatchesTheCapabilityAnnotations() + { + var root = RepositoryRoot(); + var workspace = new FileWorkspace(root, string.Empty, Path.Combine(root, "sdk-compliance.yaml"), string.Empty); + + var result = ComplianceModel.Build(SymbolWalker.Extract(workspace.ReadSources())); + result.Errors.Should().BeEmpty("the annotated tree must aggregate without conflicts"); + + var committed = ComplianceYaml.Parse(workspace.ReadComplianceText()); + var generated = ComplianceYaml.FromEntries(result.Entries); + + ComplianceComparer.Diff(committed, generated).Should().BeEmpty( + "sdk-compliance.yaml must stay in sync with the in-code capability annotations"); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs new file mode 100644 index 00000000..0bc630bf --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CommandRunnerTests.cs @@ -0,0 +1,137 @@ +using FluentAssertions; + +namespace Supabase.CapabilityMatrix.Tests; + +internal sealed class InMemoryWorkspace : IWorkspace +{ + public List Sources { get; } = []; + public List SpecDocuments { get; } = []; + public string ComplianceText { get; set; } = "sdk: csharp\nfeatures:\n"; + public string? Written { get; private set; } + + public IReadOnlyList ReadSources() => this.Sources; + + public IReadOnlyList ReadSpecDocuments() => this.SpecDocuments; + + public string ReadComplianceText() => this.ComplianceText; + + public void WriteComplianceText(string content) => this.Written = content; +} + +[TestClass] +public class CommandRunnerTests +{ + private const string Spec = + """ + area: auth + title: Authentication + groups: + - id: sign_in + title: Sign-in + features: + - id: auth.sign_in.sign_up + group: sign_in + """; + + private static InMemoryWorkspace WorkspaceWith(string source, string? compliance = null) + { + var workspace = new InMemoryWorkspace(); + workspace.Sources.Add(new SymbolWalker.SourceFile("packages/Gotrue/Gotrue/Client.cs", source)); + workspace.SpecDocuments.Add(Spec); + if (compliance is not null) + workspace.ComplianceText = compliance; + return workspace; + } + + private const string GoodSource = + """ + using Supabase.Core.Attributes; + namespace Supabase.Gotrue + { + public class Client + { + [ImplementedCapability("auth.sign_in.sign_up")] + public void SignUp() { } + } + } + """; + + private static (int Code, string Output) Run(Func act, IWorkspace workspace) + { + var writer = new StringWriter(); + var code = act(new CommandRunner(workspace, writer)); + return (code, writer.ToString()); + } + + [TestMethod] + public void GenerateWritesTheFileOnACleanTree() + { + var workspace = WorkspaceWith(GoodSource); + var (code, output) = Run(r => r.Generate(), workspace); + + code.Should().Be(CommandRunner.Success); + output.Should().Contain("✅"); + workspace.Written.Should().Contain("auth.sign_in.sign_up:").And.Contain("- Client.SignUp"); + } + + [TestMethod] + public void GenerateFailsAndWritesNothingOnAnUnknownKey() + { + var source = GoodSource.Replace("auth.sign_in.sign_up", "auth.sign_in.typo"); + var workspace = WorkspaceWith(source); + var (code, output) = Run(r => r.Generate(), workspace); + + code.Should().Be(CommandRunner.Failure); + output.Should().Contain("Cannot generate").And.Contain("auth.sign_in.typo"); + workspace.Written.Should().BeNull(); + } + + [TestMethod] + public void ValidatePassesOnACleanTreeAndFailsOnConflict() + { + Run(r => r.Validate(), WorkspaceWith(GoodSource)).Code.Should().Be(CommandRunner.Success); + + var conflicting = GoodSource.Replace( + "[ImplementedCapability(\"auth.sign_in.sign_up\")]", + "[ImplementedCapability(\"auth.sign_in.sign_up\")]\n [PartialCapability(\"auth.sign_in.sign_up\", \"partial\")]"); + var (code, output) = Run(r => r.Validate(), WorkspaceWith(conflicting)); + code.Should().Be(CommandRunner.Failure); + output.Should().Contain("conflicting statuses"); + } + + [TestMethod] + public void CheckPassesWhenCommittedMatchesAndFailsOtherwise() + { + var workspace = WorkspaceWith(GoodSource); + workspace.ComplianceText = YamlEmitter.Emit( + ComplianceModel.Build(SymbolWalker.Extract(workspace.Sources)).Entries, + SpecModel.Parse(workspace.SpecDocuments)); + Run(r => r.Check(), workspace).Code.Should().Be(CommandRunner.Success); + + var stale = WorkspaceWith(GoodSource, "sdk: csharp\nfeatures:\n auth.sign_in.sign_up: implemented\n"); + var (code, output) = Run(r => r.Check(), stale); + code.Should().Be(CommandRunner.Failure); + output.Should().Contain("out of sync").And.Contain("symbols differ"); + } + + [TestMethod] + public void LocateReportsResolvedAndFailsOnUnresolved() + { + var resolvedCompliance = "sdk: csharp\nfeatures:\n auth.sign_in.sign_up:\n status: implemented\n symbols:\n - Client.SignUp\n"; + Run(r => r.Locate(), WorkspaceWith(GoodSource, resolvedCompliance)).Should() + .Match<(int Code, string Output)>(t => t.Code == CommandRunner.Success && t.Output.Contains("1 resolved")); + + var missingCompliance = "sdk: csharp\nfeatures:\n auth.sign_in.sign_up:\n status: implemented\n symbols:\n - Client.Ghost\n"; + var (code, output) = Run(r => r.Locate(), WorkspaceWith(GoodSource, missingCompliance)); + code.Should().Be(CommandRunner.Failure); + output.Should().Contain("MISS").And.Contain("Client.Ghost"); + } + + [TestMethod] + public void IndexEmitsJsonForTheExtractedSurface() + { + var (code, output) = Run(r => r.Index(), WorkspaceWith(GoodSource)); + code.Should().Be(CommandRunner.Success); + output.Should().Contain("\"QualifiedName\": \"Client.SignUp\"").And.Contain("auth.sign_in.sign_up"); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs new file mode 100644 index 00000000..13b9c069 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ComplianceModelTests.cs @@ -0,0 +1,140 @@ +using FluentAssertions; + +namespace Supabase.CapabilityMatrix.Tests; + +[TestClass] +public class ComplianceModelTests +{ + private static DeclaredSymbol Sym(string name, bool isPublic, params CapabilityAnnotation[] annotations) => + new(name, "method", "F.cs", 1, isPublic, annotations); + + private static CapabilityAnnotation Impl(string key, string? note = null) => new(key, CapabilityStatus.Implemented, note); + + private static CapabilityAnnotation Part(string key, string note) => new(key, CapabilityStatus.PartiallyImplemented, note); + + private static ComplianceEntry Entry(ComplianceResult result, string key) => result.Entries.Single(e => e.Key == key); + + [TestMethod] + public void AggregatesSymbolsPerFeatureSortedAndDeduplicated() + { + var result = ComplianceModel.Build( + [ + Sym("Client.SignInWithPassword", true, Impl("auth.sign_in")), + Sym("Client.SignIn", true, Impl("auth.sign_in")), + Sym("Client.SignIn", true, Impl("auth.sign_in")), + ]); + + result.Errors.Should().BeEmpty(); + Entry(result, "auth.sign_in").Symbols.Should().Equal("Client.SignIn", "Client.SignInWithPassword"); + } + + [TestMethod] + public void CarriesStatusAndNoteForImplementedAndPartial() + { + var result = ComplianceModel.Build( + [ + Sym("Client.Invoke", true, Impl("fn.invoke", "three shapes")), + Sym("Client.SignUp", true, Part("auth.sign_up", "no session adopt")), + ]); + + Entry(result, "fn.invoke").Should().BeEquivalentTo(new + { + Status = CapabilityStatus.Implemented, + Note = "three shapes", + NoPublicSymbols = false, + }); + Entry(result, "auth.sign_up").Status.Should().Be(CapabilityStatus.PartiallyImplemented); + } + + [TestMethod] + public void ReportsConflictingStatusesForOneFeature() + { + var result = ComplianceModel.Build( + [ + Sym("A", true, Impl("x.y")), + Sym("B", true, Part("x.y", "partial")), + ]); + + result.Errors.Should().ContainSingle() + .Which.Message.Should().Contain("conflicting statuses"); + } + + [TestMethod] + public void ReportsConflictingNotesForOneFeature() + { + var result = ComplianceModel.Build( + [ + Sym("A", true, Part("x.y", "note one")), + Sym("B", true, Part("x.y", "note two")), + ]); + + result.Errors.Should().ContainSingle().Which.Message.Should().Contain("differing notes"); + } + + [TestMethod] + public void TreatsANonPublicOnlyFeatureAsNoPublicSymbols() + { + var result = ComplianceModel.Build( + [ + Sym("Client.GetAuthHeaders", false, Impl("client.sync", "injected via callback")), + ]); + + result.Errors.Should().BeEmpty(); + var entry = Entry(result, "client.sync"); + entry.NoPublicSymbols.Should().BeTrue(); + entry.Symbols.Should().BeEmpty(); + entry.Note.Should().Be("injected via callback"); + } + + [TestMethod] + public void RequiresANoteWhenThereIsNoPublicSymbol() + { + var result = ComplianceModel.Build( + [ + Sym("Internal.Thing", false, Impl("client.sync")), + ]); + + result.Errors.Should().ContainSingle().Which.Message.Should().Contain("needs a note"); + } + + [TestMethod] + public void KeepsOnlyPublicSymbolsWhenAFeatureHasBoth() + { + var result = ComplianceModel.Build( + [ + Sym("Public.M", true, Impl("x.y")), + Sym("Internal.M", false, Impl("x.y")), + ]); + + var entry = Entry(result, "x.y"); + entry.NoPublicSymbols.Should().BeFalse(); + entry.Symbols.Should().Equal("Public.M"); + } + + [TestMethod] + public void IgnoresSymbolsWithoutAnnotations() + { + var result = ComplianceModel.Build([Sym("Plain.Method", true)]); + result.Entries.Should().BeEmpty(); + result.Errors.Should().BeEmpty(); + } +} + +[TestClass] +public class KeyValidatorTests +{ + [TestMethod] + public void FlagsUnknownKeysOnly() + { + var entries = new List + { + new("known.feature", CapabilityStatus.Implemented, null, ["A"]), + new("typo.feature", CapabilityStatus.Implemented, null, ["B"]), + }; + var known = new HashSet(StringComparer.Ordinal) { "known.feature" }; + + var errors = KeyValidator.ValidateKeys(entries, known); + + errors.Should().ContainSingle().Which.Message.Should().Contain("typo.feature"); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs new file mode 100644 index 00000000..29ce86b6 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/ParseCompareLocateTests.cs @@ -0,0 +1,144 @@ +using FluentAssertions; + +namespace Supabase.CapabilityMatrix.Tests; + +[TestClass] +public class ComplianceYamlTests +{ + [TestMethod] + public void ParsesScalarAndMapEntries() + { + var compliance = ComplianceYaml.Parse( + """ + sdk: csharp + features: + a.simple: implemented + a.rich: + status: partially_implemented + no_public_symbols: true + note: "missing bits" + symbols: + - Z.Last + - A.First + """); + + compliance.Sdk.Should().Be("csharp"); + compliance.Features["a.simple"].Should().BeEquivalentTo( + new NormalizedEntry("implemented", null, [], false)); + var rich = compliance.Features["a.rich"]; + rich.Status.Should().Be("partially_implemented"); + rich.Note.Should().Be("missing bits"); + rich.NoPublicSymbols.Should().BeTrue(); + rich.Symbols.Should().Equal("A.First", "Z.Last"); + } + + [TestMethod] + public void ProjectsEntriesIntoTheSameNormalizedShape() + { + var normalized = ComplianceYaml.FromEntries( + [new ComplianceEntry("a.b", CapabilityStatus.PartiallyImplemented, "", ["B", "A"], true)]); + + normalized.Sdk.Should().Be("csharp"); + var entry = normalized.Features["a.b"]; + entry.Status.Should().Be("partially_implemented"); + entry.Note.Should().BeNull("an empty note normalizes to null"); + entry.NoPublicSymbols.Should().BeTrue(); + entry.Symbols.Should().Equal("A", "B"); + } +} + +[TestClass] +public class ComplianceComparerTests +{ + private static NormalizedCompliance One(string key, NormalizedEntry entry) => + new("csharp", new Dictionary(StringComparer.Ordinal) { [key] = entry }); + + [TestMethod] + public void ReportsNoDifferencesForEquivalentModels() + { + var a = One("k", new NormalizedEntry("implemented", "n", ["A"], false)); + var b = One("k", new NormalizedEntry("implemented", "n", ["A"], false)); + ComplianceComparer.Diff(a, b).Should().BeEmpty(); + } + + [TestMethod] + public void ReportsEveryKindOfMismatch() + { + var expected = new NormalizedCompliance("csharp", new Dictionary(StringComparer.Ordinal) + { + ["only.expected"] = new("implemented", null, [], false), + ["shared"] = new("implemented", "old", ["Keep", "Gone"], false), + }); + var actual = new NormalizedCompliance("dart", new Dictionary(StringComparer.Ordinal) + { + ["only.actual"] = new("implemented", null, [], false), + ["shared"] = new("partially_implemented", "new", ["Keep", "Added"], true), + }); + + var diff = ComplianceComparer.Diff(expected, actual); + + diff.Should().Contain(d => d.Contains("sdk:")); + diff.Should().Contain(d => d.Contains("only.expected") && d.Contains("missing from generated")); + diff.Should().Contain(d => d.Contains("only.actual") && d.Contains("not in expected")); + diff.Should().Contain(d => d.Contains("shared: status")); + diff.Should().Contain(d => d.Contains("shared: note")); + diff.Should().Contain(d => d.Contains("shared: no_public_symbols")); + diff.Should().Contain(d => d.Contains("shared: symbols") && d.Contains("missing: Gone") && d.Contains("extra: Added")); + } +} + +[TestClass] +public class LocatorTests +{ + private static DeclaredSymbol Decl(string name, string file) => new(name, "method", file, 7, true, []); + + [TestMethod] + public void ClassifiesResolvedAmbiguousAndUnresolvedSymbols() + { + var compliance = new NormalizedCompliance("csharp", new Dictionary(StringComparer.Ordinal) + { + ["f.one"] = new("implemented", null, ["Client.A"], false), + ["f.two"] = new("implemented", null, ["Client.B"], false), + ["f.three"] = new("implemented", null, ["Client.Missing"], false), + }); + var symbols = new List + { + Decl("Client.A", "One.cs"), + Decl("Client.B", "Two.cs"), + Decl("Client.B", "Three.cs"), + }; + + var report = Locator.Locate(compliance, symbols); + + report.Resolved.Should().ContainSingle().Which.Symbol.Should().Be("Client.A"); + report.Ambiguous.Should().ContainSingle().Which.Candidates.Should().HaveCount(2); + report.Unresolved.Should().ContainSingle().Which.Symbol.Should().Be("Client.Missing"); + } +} + +[TestClass] +public class SourceDiscoveryTests +{ + [TestMethod] + [DataRow("packages/Gotrue/Gotrue/Client.cs", true)] + [DataRow("packages/Storage/Storage/Sub/Dir/File.cs", true)] + [DataRow("packages/Gotrue/Gotrue/bin/Debug/Client.cs", false)] + [DataRow("packages/Gotrue/Gotrue/obj/Client.g.cs", false)] + [DataRow("packages/Gotrue/Gotrue.Tests/ClientTests.cs", false)] + [DataRow("packages/CapabilityMatrix/CapabilityMatrix/Program.cs", false)] + [DataRow("packages/Gotrue/Gotrue/README.md", false)] + [DataRow("scripts/tool.cs", false)] + public void IncludesOnlyProductionSdkSource(string path, bool expected) => + SourceDiscovery.IsSdkSourceFile(path).Should().Be(expected); +} + +[TestClass] +public class CapabilityStatusTests +{ + [TestMethod] + public void MapsStatusesToMatrixSpellings() + { + CapabilityStatus.Implemented.ToYamlValue().Should().Be("implemented"); + CapabilityStatus.PartiallyImplemented.ToYamlValue().Should().Be("partially_implemented"); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs new file mode 100644 index 00000000..560f23f6 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SpecAndEmitterTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; + +namespace Supabase.CapabilityMatrix.Tests; + +[TestClass] +public class SpecModelTests +{ + private const string AuthArea = + """ + area: auth + title: Authentication + groups: + - id: sign_in + title: Sign-in + - id: session + title: Session + features: + - id: auth.sign_in.b + group: sign_in + - id: auth.sign_in.a + group: sign_in + - id: auth.session.x + group: session + - id: auth.loose + """; + + [TestMethod] + public void CollectsKnownIdsAndPreservesGroupAndFeatureOrder() + { + var spec = SpecModel.Parse([AuthArea]); + + spec.KnownIds.Should().BeEquivalentTo(["auth.sign_in.b", "auth.sign_in.a", "auth.session.x", "auth.loose"]); + var area = spec.Areas.Single(); + area.Title.Should().Be("Authentication"); + area.Groups.Select(g => g.Id).Should().Equal("sign_in", "session"); + area.Groups[0].FeatureIds.Should().Equal("auth.sign_in.b", "auth.sign_in.a"); + area.UngroupedFeatureIds.Should().Equal("auth.loose"); + } + + [TestMethod] + public void OrdersAreasByIdAndSkipsEmptyDocuments() + { + var spec = SpecModel.Parse( + [ + "area: storage\ntitle: Storage\nfeatures: []", + "area: auth\ntitle: Auth\nfeatures: []", + "# just a comment\n", + ]); + + spec.Areas.Select(a => a.Id).Should().Equal("auth", "storage"); + } +} + +[TestClass] +public class YamlEmitterTests +{ + private static SpecModel Spec() => SpecModel.Parse( + [ + """ + area: auth + title: Authentication + groups: + - id: sign_in + title: Sign-in + - id: session + title: Session + features: + - id: auth.sign_in.a + group: sign_in + - id: auth.session.x + group: session + """, + ]); + + [TestMethod] + public void EmitsHeaderBannerAndEntry() + { + var yaml = YamlEmitter.Emit( + [new ComplianceEntry("auth.sign_in.a", CapabilityStatus.Implemented, null, ["Client.A", "Client.B"])], + Spec()); + + yaml.Should().StartWith("sdk: csharp\nfeatures:\n"); + var banner = yaml.Split('\n').Single(l => l.Contains("Authentication")); + banner.Should().Be(" # ── Authentication " + new string('─', 79 - " # ── Authentication ".Length)); + banner.Length.Should().Be(79); + yaml.Should().Contain(" auth.sign_in.a:\n status: implemented\n symbols:\n - Client.A\n - Client.B\n"); + yaml.Should().EndWith("\n"); + } + + [TestMethod] + public void EscapesNotesAndEmitsNoPublicSymbolsWithoutASymbolsBlock() + { + var yaml = YamlEmitter.Emit( + [new ComplianceEntry("auth.sign_in.a", CapabilityStatus.Implemented, "He said \"hi\" \\o/", [], NoPublicSymbols: true)], + Spec()); + + yaml.Should().Contain(" no_public_symbols: true\n"); + yaml.Should().Contain(" note: \"He said \\\"hi\\\" \\\\o/\"\n"); + yaml.Should().NotContain(" symbols:"); + } + + [TestMethod] + public void SeparatesGroupsWithABlankLineAndSkipsAbsentFeatures() + { + var yaml = YamlEmitter.Emit( + [ + new ComplianceEntry("auth.sign_in.a", CapabilityStatus.Implemented, null, ["A"]), + new ComplianceEntry("auth.session.x", CapabilityStatus.Implemented, null, ["B"]), + ], Spec()); + + // A blank line separates the sign_in group block from the session group block. + yaml.Should().Contain(" - A\n\n auth.session.x:"); + } + + [TestMethod] + public void OmitsAreasWithNoMatchingEntries() + { + var yaml = YamlEmitter.Emit([], Spec()); + yaml.Should().Be("sdk: csharp\nfeatures:\n"); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs new file mode 100644 index 00000000..fadcb7e5 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/SymbolWalkerTests.cs @@ -0,0 +1,194 @@ +using FluentAssertions; + +namespace Supabase.CapabilityMatrix.Tests; + +[TestClass] +public class SymbolWalkerTests +{ + private static IReadOnlyList Extract(string source) => + SymbolWalker.Extract([new SymbolWalker.SourceFile("Sample.cs", source)]); + + private static DeclaredSymbol Named(IReadOnlyList symbols, string name) => + symbols.Single(s => s.QualifiedName == name); + + [TestMethod] + public void StripsNamespaceAndQualifiesByContainingType() + { + var symbols = Extract( + """ + namespace Foo.Bar + { + public class Widget + { + public void Go() { } + } + } + """); + + symbols.Select(s => s.QualifiedName).Should().Contain(["Widget", "Widget.Go"]); + symbols.Select(s => s.QualifiedName).Should().NotContain(n => n.StartsWith("Foo")); + Named(symbols, "Widget").Kind.Should().Be("class"); + Named(symbols, "Widget.Go").Kind.Should().Be("method"); + } + + [TestMethod] + public void QualifiesNestedTypesEnumMembersAndFields() + { + var symbols = Extract( + """ + namespace N + { + public static class Constants + { + public enum Operator { Equals, NotEqual } + } + public class Options + { + public string Headers = ""; + } + } + """); + + symbols.Select(s => s.QualifiedName).Should().Contain( + ["Constants", "Constants.Operator", "Constants.Operator.Equals", "Constants.Operator.NotEqual", "Options.Headers"]); + Named(symbols, "Constants.Operator.Equals").Kind.Should().Be("property"); + Named(symbols, "Options.Headers").Kind.Should().Be("property"); + } + + [TestMethod] + public void MarksAccessibilityAcrossTheContainingChain() + { + var symbols = Extract( + """ + namespace N + { + public class Pub + { + public void A() { } + internal void B() { } + private void C() { } + public static class Inner { public void D() { } } + } + internal class Hidden { public void E() { } } + public interface IFace { void F(); } + } + """); + + Named(symbols, "Pub.A").IsPublic.Should().BeTrue(); + Named(symbols, "Pub.B").IsPublic.Should().BeFalse(); + Named(symbols, "Pub.C").IsPublic.Should().BeFalse(); + Named(symbols, "Pub.Inner.D").IsPublic.Should().BeTrue(); + Named(symbols, "Hidden").IsPublic.Should().BeFalse(); + Named(symbols, "Hidden.E").IsPublic.Should().BeFalse("the containing type is not public"); + Named(symbols, "IFace.F").IsPublic.Should().BeTrue("interface members are public by default"); + } + + [TestMethod] + public void ReadsBothCapabilityAttributesWithNotes() + { + var symbols = Extract( + """ + using Supabase.Core.Attributes; + namespace N + { + public class C + { + [ImplementedCapability("a.b.c")] + public void Plain() { } + + [ImplementedCapability("a.b.d", "with caveat")] + public void Caveat() { } + + [PartialCapability("a.b.e", "missing X")] + public void Partial() { } + } + } + """); + + Named(symbols, "C.Plain").Annotations.Should().ContainSingle() + .Which.Should().BeEquivalentTo(new { Key = "a.b.c", Status = CapabilityStatus.Implemented, Note = (string?) null }); + Named(symbols, "C.Caveat").Annotations.Single().Note.Should().Be("with caveat"); + var partial = Named(symbols, "C.Partial").Annotations.Single(); + partial.Status.Should().Be(CapabilityStatus.PartiallyImplemented); + partial.Note.Should().Be("missing X"); + } + + [TestMethod] + public void AcceptsAttributeSuffixQualifiedNameAndNamedNoteArgument() + { + var symbols = Extract( + """ + namespace N + { + public class C + { + [Supabase.Core.Attributes.ImplementedCapabilityAttribute("a.b.c", note: "n")] + public void M() { } + + [System.Obsolete] + public void Ignored() { } + } + } + """); + + Named(symbols, "C.M").Annotations.Single().Should() + .BeEquivalentTo(new { Key = "a.b.c", Note = "n" }); + Named(symbols, "C.Ignored").Annotations.Should().BeEmpty(); + } + + [TestMethod] + public void AllowsMultipleCapabilitiesOnOneMember() + { + var symbols = Extract( + """ + using Supabase.Core.Attributes; + namespace N + { + public class C + { + [ImplementedCapability("a.one")] + [ImplementedCapability("a.two")] + public void M() { } + } + } + """); + + Named(symbols, "C.M").Annotations.Select(a => a.Key).Should().BeEquivalentTo(["a.one", "a.two"]); + } + + [TestMethod] + public void SkipsAttributesWhoseKeyIsNotAStringLiteral() + { + var symbols = Extract( + """ + using Supabase.Core.Attributes; + namespace N + { + public class C + { + public const string K = "a.b.c"; + [ImplementedCapability(K)] + public void M() { } + } + } + """); + + Named(symbols, "C.M").Annotations.Should().BeEmpty("a non-literal key cannot be resolved from syntax"); + } + + [TestMethod] + public void OrdersDeterministicallyAndReportsOneBasedLines() + { + var symbols = Extract( + """ + namespace N + { + public class B { public void Z() { } } + public class A { public void Y() { } } + } + """); + + symbols.Select(s => s.QualifiedName).Should().BeInAscendingOrder(StringComparer.Ordinal); + Named(symbols, "A").Line.Should().BeGreaterThan(0); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj new file mode 100644 index 00000000..f7a27f10 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityMatrix.csproj @@ -0,0 +1,31 @@ + + + + Exe + net10.0 + Supabase.CapabilityMatrix + Supabase.CapabilityMatrix + enable + enable + + false + true + true + latest + True + + true + $(NoWarn);CS1591 + + + + + + + + + + + + diff --git a/packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs new file mode 100644 index 00000000..97b97d43 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/CapabilityStatus.cs @@ -0,0 +1,23 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// The compliance status a capability annotation reports to the matrix. Mirrors +/// the subset of matrix statuses an in-code annotation can express — a member +/// that exists is, by definition, at least partially implemented. +/// +public enum CapabilityStatus +{ + Implemented, + PartiallyImplemented, +} + +public static class CapabilityStatusExtensions +{ + /// The canonical matrix spelling, as written in sdk-compliance.yaml. + public static string ToYamlValue(this CapabilityStatus status) => status switch + { + CapabilityStatus.Implemented => "implemented", + CapabilityStatus.PartiallyImplemented => "partially_implemented", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs b/packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs new file mode 100644 index 00000000..d731c592 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/CommandRunner.cs @@ -0,0 +1,134 @@ +using System.Text.Json; + +namespace Supabase.CapabilityMatrix; + +/// +/// The four commands — generate, validate, locate, check — operating over an +/// . All business logic lives in the pure classes it +/// composes; this layer only loads inputs, reports, and chooses an exit code. +/// +public sealed class CommandRunner +{ + public const int Success = 0; + public const int Failure = 1; + + private static readonly JsonSerializerOptions IndexJsonOptions = new() { WriteIndented = true }; + + private readonly IWorkspace workspace; + private readonly TextWriter output; + + public CommandRunner(IWorkspace workspace, TextWriter output) + { + this.workspace = workspace; + this.output = output; + } + + /// Regenerates and writes sdk-compliance.yaml, failing on any authoring error. + public int Generate() + { + var (result, spec, errors) = this.Analyze(); + if (errors.Count > 0) + { + this.ReportErrors("Cannot generate sdk-compliance.yaml", errors); + return Failure; + } + + this.workspace.WriteComplianceText(YamlEmitter.Emit(result.Entries, spec)); + this.output.WriteLine($"✅ Generated sdk-compliance.yaml ({result.Entries.Count} features)."); + return Success; + } + + /// Validates the capability annotations without touching the committed file. + public int Validate() + { + var (_, _, errors) = this.Analyze(); + if (errors.Count > 0) + { + this.ReportErrors("Capability annotation check failed", errors); + return Failure; + } + + this.output.WriteLine("✅ Capability annotations are valid."); + return Success; + } + + /// Resolves every symbol in the committed file against the source and reports the result. + public int Locate() + { + var symbols = SymbolWalker.Extract(this.workspace.ReadSources()); + var compliance = ComplianceYaml.Parse(this.workspace.ReadComplianceText()); + var report = Locator.Locate(compliance, symbols); + + foreach (var location in report.Resolved) + this.output.WriteLine($" ok {location.Key} → {location.Symbol} ({location.FilePath}:{location.Line})"); + foreach (var ambiguous in report.Ambiguous) + this.output.WriteLine($" AMBIG {ambiguous.Key} → {ambiguous.Symbol} in {ambiguous.Candidates.Count} files: " + + string.Join(", ", ambiguous.Candidates.Select(c => $"{c.FilePath}:{c.Line}"))); + foreach (var unresolved in report.Unresolved) + this.output.WriteLine($" MISS {unresolved.Key} → {unresolved.Symbol} (no such public symbol)"); + + this.output.WriteLine( + $"\n{report.Resolved.Count} resolved, {report.Ambiguous.Count} ambiguous, {report.Unresolved.Count} unresolved."); + return report.Ambiguous.Count == 0 && report.Unresolved.Count == 0 ? Success : Failure; + } + + /// Writes the extracted public-symbol surface as JSON, for debugging and tooling. + public int Index() + { + var symbols = SymbolWalker.Extract(this.workspace.ReadSources()) + .Select(symbol => new + { + symbol.QualifiedName, + symbol.Kind, + symbol.FilePath, + symbol.Line, + symbol.IsPublic, + Keys = symbol.Annotations.Select(annotation => annotation.Key).ToList(), + }); + this.output.WriteLine(JsonSerializer.Serialize(symbols, IndexJsonOptions)); + return Success; + } + + /// Checks the committed file is semantically equal to what the annotations would generate. + public int Check() + { + var (result, _, errors) = this.Analyze(); + if (errors.Count > 0) + { + this.ReportErrors("Capability annotation check failed", errors); + return Failure; + } + + var expected = ComplianceYaml.Parse(this.workspace.ReadComplianceText()); + var actual = ComplianceYaml.FromEntries(result.Entries); + var differences = ComplianceComparer.Diff(expected, actual); + if (differences.Count > 0) + { + this.output.WriteLine("❌ sdk-compliance.yaml is out of sync with the capability annotations:"); + foreach (var difference in differences) + this.output.WriteLine($" - {difference}"); + return Failure; + } + + this.output.WriteLine("✅ sdk-compliance.yaml matches the capability annotations."); + return Success; + } + + private (ComplianceResult Result, SpecModel Spec, IReadOnlyList Errors) Analyze() + { + var symbols = SymbolWalker.Extract(this.workspace.ReadSources()); + var result = ComplianceModel.Build(symbols); + var spec = SpecModel.Parse(this.workspace.ReadSpecDocuments()); + var errors = result.Errors + .Concat(KeyValidator.ValidateKeys(result.Entries, spec.KnownIds)) + .ToList(); + return (result, spec, errors); + } + + private void ReportErrors(string headline, IReadOnlyList errors) + { + this.output.WriteLine($"❌ {headline}:"); + foreach (var error in errors) + this.output.WriteLine($" - {error.Message}"); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs new file mode 100644 index 00000000..f7e39ea2 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceComparer.cs @@ -0,0 +1,50 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// Compares two normalized compliance models for semantic equivalence — same sdk, +/// same feature ids, and per feature the same status, note and set of symbols. +/// Formatting and symbol order are already normalized away. Pure. +/// +public static class ComplianceComparer +{ + /// Returns a human-readable difference per mismatch; empty when equivalent. + public static IReadOnlyList Diff(NormalizedCompliance expected, NormalizedCompliance actual) + { + var differences = new List(); + + if (expected.Sdk != actual.Sdk) + differences.Add($"sdk: expected '{expected.Sdk}', got '{actual.Sdk}'"); + + foreach (var key in expected.Features.Keys.Except(actual.Features.Keys).OrderBy(k => k, StringComparer.Ordinal)) + differences.Add($"{key}: present in expected, missing from generated"); + + foreach (var key in actual.Features.Keys.Except(expected.Features.Keys).OrderBy(k => k, StringComparer.Ordinal)) + differences.Add($"{key}: generated but not in expected"); + + foreach (var key in expected.Features.Keys.Intersect(actual.Features.Keys).OrderBy(k => k, StringComparer.Ordinal)) + differences.AddRange(DiffEntry(key, expected.Features[key], actual.Features[key])); + + return differences; + } + + private static IEnumerable DiffEntry(string key, NormalizedEntry expected, NormalizedEntry actual) + { + if (expected.Status != actual.Status) + yield return $"{key}: status expected '{expected.Status}', got '{actual.Status}'"; + + if (expected.Note != actual.Note) + yield return $"{key}: note expected '{expected.Note ?? ""}', got '{actual.Note ?? ""}'"; + + if (expected.NoPublicSymbols != actual.NoPublicSymbols) + yield return $"{key}: no_public_symbols expected '{expected.NoPublicSymbols}', got '{actual.NoPublicSymbols}'"; + + if (!expected.Symbols.SequenceEqual(actual.Symbols)) + { + var missing = expected.Symbols.Except(actual.Symbols).ToList(); + var extra = actual.Symbols.Except(expected.Symbols).ToList(); + yield return $"{key}: symbols differ" + + (missing.Count > 0 ? $" (missing: {string.Join(", ", missing)})" : string.Empty) + + (extra.Count > 0 ? $" (extra: {string.Join(", ", extra)})" : string.Empty); + } + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs new file mode 100644 index 00000000..4cd00481 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceModel.cs @@ -0,0 +1,95 @@ +namespace Supabase.CapabilityMatrix; + +/// One generated sdk-compliance.yaml feature entry. +/// The capability-matrix feature id. +/// The reported status. +/// The note, if any. +/// The implementing public symbols, sorted and de-duplicated. +/// +/// True when the feature is implemented only through non-public API, so there is no +/// public symbol to list. Mirrors the matrix's no_public_symbols flag. +/// +public sealed record ComplianceEntry( + string Key, + CapabilityStatus Status, + string? Note, + IReadOnlyList Symbols, + bool NoPublicSymbols = false); + +/// A problem found while aggregating annotations into compliance entries. +/// Human-readable description, surfaced on CI. +public sealed record ValidationError(string Message); + +/// The aggregated compliance entries plus any errors found building them. +public sealed record ComplianceResult( + IReadOnlyList Entries, + IReadOnlyList Errors); + +/// +/// Aggregates the capability annotations scattered across declared symbols into one +/// entry per feature id. Pure. Two members tagged with the same key contribute two +/// symbols to one entry; the same member may carry several keys. +/// +public static class ComplianceModel +{ + public static ComplianceResult Build(IReadOnlyList symbols) + { + var errors = new List(); + + var annotated = symbols + .SelectMany(symbol => symbol.Annotations.Select(annotation => (symbol, annotation))) + .ToList(); + + var entries = new List(); + foreach (var group in annotated.GroupBy(pair => pair.annotation.Key).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + var statuses = group.Select(pair => pair.annotation.Status).Distinct().ToList(); + if (statuses.Count > 1) + { + errors.Add(new ValidationError( + $"'{group.Key}' is reported with conflicting statuses (implemented and partially_implemented) " + + "across members — a feature has a single status.")); + } + + var notes = group + .Select(pair => pair.annotation.Note) + .Where(note => !string.IsNullOrEmpty(note)) + .Distinct(StringComparer.Ordinal) + .OrderBy(note => note, StringComparer.Ordinal) + .ToList(); + if (notes.Count > 1) + { + errors.Add(new ValidationError( + $"'{group.Key}' carries {notes.Count} differing notes across members — a feature has a single note.")); + } + + var symbolNames = group + .Where(pair => pair.symbol.IsPublic) + .Select(pair => pair.symbol.QualifiedName) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + // Partial wins a status conflict (the conservative reading); it is reported + // as an error above regardless, so a clean tree never relies on this choice. + var status = statuses.Contains(CapabilityStatus.PartiallyImplemented) + ? CapabilityStatus.PartiallyImplemented + : CapabilityStatus.Implemented; + + // A feature implemented only through non-public API has no public symbol to + // cite; the matrix records that with no_public_symbols, which requires a note. + var noPublicSymbols = symbolNames.Count == 0; + var note = notes.FirstOrDefault(); + if (noPublicSymbols && string.IsNullOrEmpty(note)) + { + errors.Add(new ValidationError( + $"'{group.Key}' is implemented only through non-public API and needs a note " + + "explaining how the capability is exposed (matrix: no_public_symbols requires a note).")); + } + + entries.Add(new ComplianceEntry(group.Key, status, note, symbolNames, noPublicSymbols)); + } + + return new ComplianceResult(entries, errors); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs new file mode 100644 index 00000000..cd8a103a --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/ComplianceYaml.cs @@ -0,0 +1,75 @@ +using YamlDotNet.RepresentationModel; + +namespace Supabase.CapabilityMatrix; + +/// A feature entry read from an existing sdk-compliance.yaml, normalized for comparison. +/// The status string, verbatim. +/// The note, or null when absent. +/// The symbols list, sorted for order-independent comparison. +/// The no_public_symbols flag. +public sealed record NormalizedEntry(string Status, string? Note, IReadOnlyList Symbols, bool NoPublicSymbols); + +/// A whole compliance file reduced to the facts the matrix cares about. +public sealed record NormalizedCompliance(string Sdk, IReadOnlyDictionary Features); + +/// +/// Reads an existing sdk-compliance.yaml into a normalized model, and +/// projects the generator's own entries into the same shape, so the two can be +/// compared for semantic equivalence independent of formatting or symbol order. +/// +public static class ComplianceYaml +{ + public static NormalizedCompliance Parse(string text) + { + var stream = new YamlStream(); + using var reader = new StringReader(text); + stream.Load(reader); + + var root = (YamlMappingNode) stream.Documents[0].RootNode; + var sdk = Scalar(root, "sdk") ?? string.Empty; + + var features = new Dictionary(StringComparer.Ordinal); + if (TryGet(root, "features") is YamlMappingNode featureMap) + { + foreach (var (keyNode, valueNode) in featureMap.Children) + { + var key = ((YamlScalarNode) keyNode).Value!; + features[key] = ReadEntry(valueNode); + } + } + + return new NormalizedCompliance(sdk, features); + } + + /// Projects generated entries into the normalized shape used for comparison. + public static NormalizedCompliance FromEntries(IReadOnlyList entries) => + new("csharp", entries.ToDictionary( + entry => entry.Key, + entry => new NormalizedEntry( + entry.Status.ToYamlValue(), + string.IsNullOrEmpty(entry.Note) ? null : entry.Note, + entry.Symbols.OrderBy(s => s, StringComparer.Ordinal).ToList(), + entry.NoPublicSymbols), + StringComparer.Ordinal)); + + private static NormalizedEntry ReadEntry(YamlNode valueNode) + { + if (valueNode is YamlScalarNode scalar) + return new NormalizedEntry(scalar.Value ?? string.Empty, null, [], false); + + var map = (YamlMappingNode) valueNode; + var status = Scalar(map, "status") ?? string.Empty; + var note = Scalar(map, "note"); + var noPublicSymbols = string.Equals(Scalar(map, "no_public_symbols"), "true", StringComparison.Ordinal); + var symbols = TryGet(map, "symbols") is YamlSequenceNode sequence + ? sequence.Children.OfType().Select(n => n.Value!).OrderBy(s => s, StringComparer.Ordinal).ToList() + : []; + return new NormalizedEntry(status, note, symbols, noPublicSymbols); + } + + private static string? Scalar(YamlMappingNode map, string key) => + TryGet(map, key) is YamlScalarNode scalar ? scalar.Value : null; + + private static YamlNode? TryGet(YamlMappingNode map, string key) => + map.Children.TryGetValue(new YamlScalarNode(key), out var node) ? node : null; +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs b/packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs new file mode 100644 index 00000000..4870664d --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/DeclaredSymbol.cs @@ -0,0 +1,28 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// A single capability annotation found on a declared member. +/// +/// The capability-matrix feature id (for example auth.sign_in.sign_up). +/// Whether the member reports the feature as implemented or partial. +/// The optional (for implemented) or required (for partial) explanatory note. +public sealed record CapabilityAnnotation(string Key, CapabilityStatus Status, string? Note); + +/// +/// A public-API declaration extracted from the SDK source: a type or a member, +/// named the way sdk-compliance.yaml spells it (the containing-type chain +/// with the namespace stripped, for example Constants.Operator.Equals). +/// +/// The namespace-stripped, containing-type-qualified name. +/// The matrix symbol kind: class, method, property, function or variable. +/// Source file the declaration lives in. +/// 1-based line of the declaration. +/// Whether the declaration (and its whole containing chain) is public. +/// Capability annotations attached to this declaration. +public sealed record DeclaredSymbol( + string QualifiedName, + string Kind, + string FilePath, + int Line, + bool IsPublic, + IReadOnlyList Annotations); diff --git a/packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs b/packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs new file mode 100644 index 00000000..da4f8458 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/FileWorkspace.cs @@ -0,0 +1,42 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// The filesystem-backed — the imperative shell. Reads +/// SDK source under packages/, the canonical spec YAML files, and the +/// committed compliance file; writes the generated one. +/// +public sealed class FileWorkspace(string repositoryRoot, string specDirectory, string compliancePath, string outputPath) + : IWorkspace +{ + public IReadOnlyList ReadSources() + { + var packages = Path.Combine(repositoryRoot, "packages"); + if (!Directory.Exists(packages)) + return []; + + var sources = new List(); + foreach (var path in Directory.EnumerateFiles(packages, "*.cs", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(repositoryRoot, path).Replace('\\', '/'); + if (SourceDiscovery.IsSdkSourceFile(relative)) + sources.Add(new SymbolWalker.SourceFile(relative, File.ReadAllText(path))); + } + + return sources; + } + + public IReadOnlyList ReadSpecDocuments() + { + if (!Directory.Exists(specDirectory)) + throw new DirectoryNotFoundException($"Capability spec directory not found: {specDirectory}"); + + return Directory.EnumerateFiles(specDirectory, "*.yaml", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(File.ReadAllText) + .ToList(); + } + + public string ReadComplianceText() => File.ReadAllText(compliancePath); + + public void WriteComplianceText(string content) => File.WriteAllText(outputPath, content); +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs b/packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs new file mode 100644 index 00000000..b943e499 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/IWorkspace.cs @@ -0,0 +1,22 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// The I/O seam for the commands: everything that touches the filesystem goes +/// through here, so command logic stays pure and testable with an in-memory +/// workspace. The imperative shell () is the only +/// implementation that reads or writes disk. +/// +public interface IWorkspace +{ + /// The SDK's own source files, as (repo-relative path, text). + IReadOnlyList ReadSources(); + + /// The canonical capability-area YAML documents. + IReadOnlyList ReadSpecDocuments(); + + /// The current committed sdk-compliance.yaml text. + string ReadComplianceText(); + + /// Writes the generated sdk-compliance.yaml text. + void WriteComplianceText(string content); +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs b/packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs new file mode 100644 index 00000000..13bb091a --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/KeyValidator.cs @@ -0,0 +1,21 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// Checks that every capability key used in the source is a real feature id from +/// the canonical spec — the typo guard run in-PR. Pure. +/// +public static class KeyValidator +{ + public static IReadOnlyList ValidateKeys( + IReadOnlyList entries, + IReadOnlySet knownIds) + { + return entries + .Where(entry => !knownIds.Contains(entry.Key)) + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => new ValidationError( + $"'{entry.Key}' is not a known capability-matrix feature id " + + $"(used by: {string.Join(", ", entry.Symbols)}). Check for a typo against the canonical spec.")) + .ToList(); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/Locator.cs b/packages/CapabilityMatrix/CapabilityMatrix/Locator.cs new file mode 100644 index 00000000..1ae5fabf --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/Locator.cs @@ -0,0 +1,61 @@ +namespace Supabase.CapabilityMatrix; + +/// Where a compliance symbol was found (or not) in the SDK source. +public sealed record SymbolLocation(string Key, string Symbol, string FilePath, int Line); + +/// A compliance symbol that resolves to more than one declaration. +public sealed record AmbiguousSymbol(string Key, string Symbol, IReadOnlyList Candidates); + +/// A compliance symbol with no matching declaration in the source. +public sealed record UnresolvedSymbol(string Key, string Symbol); + +/// The result of resolving every symbol in a compliance file against the source. +public sealed record LocateReport( + IReadOnlyList Resolved, + IReadOnlyList Ambiguous, + IReadOnlyList Unresolved); + +/// +/// Resolves each symbols entry of an existing compliance file to its +/// declaration(s) in the extracted source index. Drives the one-off annotation +/// pass and exposes stale or mistyped symbols in the hand-written file. Pure. +/// +public static class Locator +{ + public static LocateReport Locate(NormalizedCompliance compliance, IReadOnlyList symbols) + { + var byName = symbols + .GroupBy(symbol => symbol.QualifiedName, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); + + var resolved = new List(); + var ambiguous = new List(); + var unresolved = new List(); + + var requested = compliance.Features + .SelectMany(feature => feature.Value.Symbols.Select(symbol => (Key: feature.Key, Symbol: symbol))) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .ThenBy(pair => pair.Symbol, StringComparer.Ordinal); + + foreach (var (key, symbol) in requested) + { + if (!byName.TryGetValue(symbol, out var matches)) + { + unresolved.Add(new UnresolvedSymbol(key, symbol)); + continue; + } + + var locations = matches + .Select(match => new SymbolLocation(key, symbol, match.FilePath, match.Line)) + .ToList(); + + var distinctFiles = matches.Select(match => match.FilePath).Distinct(StringComparer.Ordinal).Count(); + if (distinctFiles > 1) + ambiguous.Add(new AmbiguousSymbol(key, symbol, locations)); + else + resolved.Add(locations[0]); + } + + return new LocateReport(resolved, ambiguous, unresolved); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/Program.cs b/packages/CapabilityMatrix/CapabilityMatrix/Program.cs new file mode 100644 index 00000000..4edb41c8 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/Program.cs @@ -0,0 +1,105 @@ +using Supabase.CapabilityMatrix; + +return CliEntryPoint.Run(args, Console.Out, Console.Error); + +/// +/// Parses command-line arguments, builds a , and +/// dispatches to the . The only argument-handling and +/// process-exit logic; the work itself lives in the runner and its pure collaborators. +/// +internal static class CliEntryPoint +{ + private const int UsageError = 2; + + public static int Run(string[] args, TextWriter output, TextWriter error) + { + if (args.Length == 0) + { + error.WriteLine(Usage); + return UsageError; + } + + var command = args[0]; + var options = ParseOptions(args.Skip(1)); + + var root = Option(options, "root", Directory.GetCurrentDirectory()); + var compliancePath = Option(options, "compliance", Path.Combine(root, "sdk-compliance.yaml")); + var outputPath = Option(options, "out", compliancePath); + var specDirectory = options.GetValueOrDefault("spec"); + + if (command is "generate" or "validate" or "check" && specDirectory is null) + { + error.WriteLine($"'{command}' requires --spec ."); + return UsageError; + } + + var workspace = new FileWorkspace(root, specDirectory ?? string.Empty, compliancePath, outputPath); + var runner = new CommandRunner(workspace, output); + + try + { + return command switch + { + "generate" => runner.Generate(), + "validate" => runner.Validate(), + "locate" => runner.Locate(), + "index" => runner.Index(), + "check" => runner.Check(), + _ => Unknown(command, error), + }; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException) + { + error.WriteLine($"❌ {exception.Message}"); + return UsageError; + } + } + + private static int Unknown(string command, TextWriter error) + { + error.WriteLine($"Unknown command '{command}'."); + error.WriteLine(Usage); + return UsageError; + } + + private static Dictionary ParseOptions(IEnumerable arguments) + { + var options = new Dictionary(StringComparer.Ordinal); + string? pending = null; + foreach (var argument in arguments) + { + if (argument.StartsWith("--", StringComparison.Ordinal)) + { + pending = argument[2..]; + options[pending] = string.Empty; + } + else if (pending is not null) + { + options[pending] = argument; + pending = null; + } + } + + return options; + } + + private static string Option(IReadOnlyDictionary options, string name, string fallback) => + options.TryGetValue(name, out var value) && value.Length > 0 ? value : fallback; + + private const string Usage = + """ + Usage: Supabase.CapabilityMatrix [options] + + Commands: + generate Write sdk-compliance.yaml from the in-code capability annotations. + validate Check the annotations (known keys, no conflicts); no file written. + locate Resolve the committed file's symbols against the source. + check Fail if the committed file is out of sync with the annotations. + + Options: + --root Repository root (default: current directory). + --spec Canonical capabilities/ directory (required: generate, validate, check). + --compliance Committed sdk-compliance.yaml (default: /sdk-compliance.yaml). + --out Output path for generate (default: --compliance). + """; +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs b/packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs new file mode 100644 index 00000000..f2f495f5 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/SourceDiscovery.cs @@ -0,0 +1,30 @@ +namespace Supabase.CapabilityMatrix; + +/// +/// Decides which repository files make up the SDK's own source. Pure: the rule is a +/// function of the repo-relative path, so it is unit-testable without a filesystem. +/// +public static class SourceDiscovery +{ + private static readonly string[] ExcludedSegments = ["bin", "obj"]; + + /// + /// True for a production SDK C# file: under packages/, not in a build output + /// directory, not test code, and not the capability-matrix tool itself. + /// + public static bool IsSdkSourceFile(string relativePath) + { + if (!relativePath.EndsWith(".cs", StringComparison.Ordinal)) + return false; + + var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2 || segments[0] != "packages") + return false; + + if (segments[1] == "CapabilityMatrix") + return false; + + return !segments.Any(segment => + ExcludedSegments.Contains(segment) || segment.EndsWith(".Tests", StringComparison.Ordinal)); + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs b/packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs new file mode 100644 index 00000000..6751a425 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/SpecModel.cs @@ -0,0 +1,92 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Supabase.CapabilityMatrix; + +/// A capability group within an area, carrying the ordered feature ids assigned to it. +public sealed record GroupSpec(string Id, string Title, IReadOnlyList FeatureIds); + +/// A product area (auth, database, ...) with its ordered groups and feature ids. +public sealed record AreaSpec( + string Id, + string Title, + IReadOnlyList Groups, + IReadOnlyList UngroupedFeatureIds); + +/// +/// The canonical capability spec loaded from the matrix repo's +/// capabilities/*.yaml files. Provides the set of known feature ids (for +/// validation) and the area → group → feature ordering (for emitting a stable file). +/// +public sealed class SpecModel +{ + public IReadOnlyList Areas { get; } + public IReadOnlySet KnownIds { get; } + + private SpecModel(IReadOnlyList areas, IReadOnlySet knownIds) + { + this.Areas = areas; + this.KnownIds = knownIds; + } + + /// Parses the given capability-area YAML documents into a spec model. + public static SpecModel Parse(IEnumerable areaDocuments) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + var areas = new List(); + var knownIds = new HashSet(StringComparer.Ordinal); + + foreach (var document in areaDocuments) + { + var dto = deserializer.Deserialize(document); + if (dto?.Area is null) + continue; + + var features = dto.Features ?? []; + foreach (var feature in features.Where(f => f.Id is not null)) + knownIds.Add(feature.Id!); + + var groups = (dto.Groups ?? []) + .Where(group => group.Id is not null) + .Select(group => new GroupSpec( + group.Id!, + group.Title ?? group.Id!, + features.Where(f => f.Id is not null && f.Group == group.Id).Select(f => f.Id!).ToList())) + .ToList(); + + var ungrouped = features + .Where(f => f.Id is not null && string.IsNullOrEmpty(f.Group)) + .Select(f => f.Id!) + .ToList(); + + areas.Add(new AreaSpec(dto.Area, dto.Title ?? dto.Area, groups, ungrouped)); + } + + areas = areas.OrderBy(area => area.Id, StringComparer.Ordinal).ToList(); + return new SpecModel(areas, knownIds); + } + + private sealed class AreaFileDto + { + public string? Area { get; set; } + public string? Title { get; set; } + public List? Groups { get; set; } + public List? Features { get; set; } + } + + private sealed class GroupDto + { + public string? Id { get; set; } + public string? Title { get; set; } + } + + private sealed class FeatureDto + { + public string? Id { get; set; } + public string? Group { get; set; } + } +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs b/packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs new file mode 100644 index 00000000..1f8bdb5b --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/SymbolWalker.cs @@ -0,0 +1,229 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Supabase.CapabilityMatrix; + +/// +/// Extracts the SDK's declared types and members from C# source text, together +/// with any capability annotations on them. Pure over its input: it parses +/// syntax only (no compilation, no semantic model, no assembly loading), so the +/// same list always yields the same symbols. +/// +public static class SymbolWalker +{ + /// A source file as (repo-relative) path and its text. + public sealed record SourceFile(string Path, string Text); + + private const string ImplementedName = "ImplementedCapability"; + private const string PartialName = "PartialCapability"; + + /// + /// Walks every file and returns one per declared + /// type and member, ordered by qualified name then file for determinism. + /// + public static IReadOnlyList Extract(IEnumerable files) + { + var symbols = new List(); + foreach (var file in files) + { + var root = CSharpSyntaxTree.ParseText(file.Text).GetCompilationUnitRoot(); + foreach (var member in root.Members) + WalkMember(member, parentTypes: [], parentPublic: true, file.Path, symbols); + } + + return symbols + .OrderBy(s => s.QualifiedName, StringComparer.Ordinal) + .ThenBy(s => s.FilePath, StringComparer.Ordinal) + .ThenBy(s => s.Line) + .ToList(); + } + + private static void WalkMember( + MemberDeclarationSyntax member, + IReadOnlyList parentTypes, + bool parentPublic, + string filePath, + List output) + { + switch (member) + { + case BaseNamespaceDeclarationSyntax ns: + // Namespaces are stripped from the qualified name; descend without pushing. + foreach (var child in ns.Members) + WalkMember(child, parentTypes, parentPublic, filePath, output); + break; + + case TypeDeclarationSyntax type: + WalkType(type, type.Identifier.Text, type.Keyword, type.Members, parentTypes, parentPublic, filePath, output); + break; + + case EnumDeclarationSyntax enumType: + WalkEnum(enumType, parentTypes, parentPublic, filePath, output); + break; + } + } + + private static void WalkType( + TypeDeclarationSyntax type, + string name, + SyntaxToken keyword, + SyntaxList members, + IReadOnlyList parentTypes, + bool parentPublic, + string filePath, + List output) + { + var isInterface = keyword.IsKind(SyntaxKind.InterfaceKeyword); + var chain = Append(parentTypes, name); + var isPublic = parentPublic && IsTypePublic(type.Modifiers); + + output.Add(Build(Join(chain), "class", type.Identifier, isPublic, type.AttributeLists, filePath)); + + foreach (var child in members) + { + switch (child) + { + case BaseTypeDeclarationSyntax: + WalkMember(child, chain, isPublic, filePath, output); + break; + case MethodDeclarationSyntax method: + Emit(output, chain, method.Identifier, "method", MemberIsPublic(method.Modifiers, isInterface), method.AttributeLists, isPublic, filePath); + break; + case PropertyDeclarationSyntax property: + Emit(output, chain, property.Identifier, "property", MemberIsPublic(property.Modifiers, isInterface), property.AttributeLists, isPublic, filePath); + break; + case FieldDeclarationSyntax field: + foreach (var variable in field.Declaration.Variables) + Emit(output, chain, variable.Identifier, "property", MemberIsPublic(field.Modifiers, isInterface), field.AttributeLists, isPublic, filePath); + break; + } + } + } + + private static void WalkEnum( + EnumDeclarationSyntax enumType, + IReadOnlyList parentTypes, + bool parentPublic, + string filePath, + List output) + { + var chain = Append(parentTypes, enumType.Identifier.Text); + var isPublic = parentPublic && IsTypePublic(enumType.Modifiers); + output.Add(Build(Join(chain), "class", enumType.Identifier, isPublic, enumType.AttributeLists, filePath)); + + foreach (var value in enumType.Members) + Emit(output, chain, value.Identifier, "property", memberPublic: isPublic, value.AttributeLists, isPublic, filePath); + } + + private static void Emit( + List output, + IReadOnlyList chain, + SyntaxToken identifier, + string kind, + bool memberPublic, + SyntaxList attributeLists, + bool chainPublic, + string filePath) + { + var name = Join(Append(chain, identifier.Text)); + output.Add(Build(name, kind, identifier, chainPublic && memberPublic, attributeLists, filePath)); + } + + private static DeclaredSymbol Build( + string qualifiedName, + string kind, + SyntaxToken anchor, + bool isPublic, + SyntaxList attributeLists, + string filePath) + { + var line = anchor.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + return new DeclaredSymbol(qualifiedName, kind, filePath, line, isPublic, ReadAnnotations(attributeLists)); + } + + private static IReadOnlyList ReadAnnotations(SyntaxList attributeLists) + { + var annotations = new List(); + foreach (var list in attributeLists) + { + foreach (var attribute in list.Attributes) + { + var status = StatusOf(SimpleName(attribute.Name)); + if (status is null) + continue; + + var (key, note) = ReadArguments(attribute.ArgumentList); + if (key is not null) + annotations.Add(new CapabilityAnnotation(key, status.Value, note)); + } + } + + return annotations; + } + + private static CapabilityStatus? StatusOf(string simpleName) => simpleName switch + { + ImplementedName => CapabilityStatus.Implemented, + PartialName => CapabilityStatus.PartiallyImplemented, + _ => null, + }; + + private static (string? Key, string? Note) ReadArguments(AttributeArgumentListSyntax? argumentList) + { + if (argumentList is null) + return (null, null); + + string? key = null; + string? note = null; + var positional = 0; + foreach (var argument in argumentList.Arguments) + { + var value = LiteralValue(argument.Expression); + var named = argument.NameColon?.Name.Identifier.Text ?? argument.NameEquals?.Name.Identifier.Text; + if (named == "note") + note = value; + else if (named == "key") + key = value; + else if (positional++ == 0) + key = value; + else + note = value; + } + + return (key, note); + } + + private static string? LiteralValue(ExpressionSyntax expression) => + expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression) + ? literal.Token.ValueText + : null; + + private static string SimpleName(NameSyntax name) + { + var text = name switch + { + QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + SimpleNameSyntax simple => simple.Identifier.Text, + _ => name.ToString(), + }; + return text.EndsWith("Attribute", StringComparison.Ordinal) + ? text[..^"Attribute".Length] + : text; + } + + private static bool IsTypePublic(SyntaxTokenList modifiers) => + modifiers.Any(SyntaxKind.PublicKeyword); + + private static bool MemberIsPublic(SyntaxTokenList modifiers, bool isInterfaceMember) + { + if (modifiers.Any(SyntaxKind.PrivateKeyword) || modifiers.Any(SyntaxKind.ProtectedKeyword) || modifiers.Any(SyntaxKind.InternalKeyword)) + return false; + // Interface members are public by default; class/struct members are not. + return isInterfaceMember || modifiers.Any(SyntaxKind.PublicKeyword); + } + + private static IReadOnlyList Append(IReadOnlyList list, string value) => [.. list, value]; + + private static string Join(IReadOnlyList chain) => string.Join('.', chain); +} diff --git a/packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs b/packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs new file mode 100644 index 00000000..a5f7bfa4 --- /dev/null +++ b/packages/CapabilityMatrix/CapabilityMatrix/YamlEmitter.cs @@ -0,0 +1,85 @@ +using System.Text; + +namespace Supabase.CapabilityMatrix; + +/// +/// Renders compliance entries to the exact sdk-compliance.yaml layout: an +/// sdk header, a features: map grouped under per-area banners, a blank +/// line between groups, and symbols listed one per line. Pure — a purpose-built +/// serializer rather than a YAML library so the banners and grouping are reproducible. +/// +public static class YamlEmitter +{ + private const string Sdk = "csharp"; + private const int BannerWidth = 79; + + public static string Emit(IReadOnlyList entries, SpecModel spec) + { + var byKey = entries.ToDictionary(entry => entry.Key, StringComparer.Ordinal); + var lines = new List { $"sdk: {Sdk}", "features:" }; + + foreach (var area in spec.Areas) + { + var groupBlocks = GroupBlocks(area, byKey).ToList(); + if (groupBlocks.Count == 0) + continue; + + lines.Add(string.Empty); + lines.Add(Banner(area.Title)); + for (var index = 0; index < groupBlocks.Count; index++) + { + lines.Add(string.Empty); + lines.AddRange(groupBlocks[index]); + } + } + + return string.Join('\n', lines) + '\n'; + } + + private static IEnumerable> GroupBlocks( + AreaSpec area, + IReadOnlyDictionary byKey) + { + var featureGroups = area.Groups + .Select(group => group.FeatureIds) + .Append(area.UngroupedFeatureIds); + + foreach (var featureIds in featureGroups) + { + var block = featureIds + .Where(byKey.ContainsKey) + .SelectMany(id => EntryLines(byKey[id])) + .ToList(); + if (block.Count > 0) + yield return block; + } + } + + private static IEnumerable EntryLines(ComplianceEntry entry) + { + yield return $" {entry.Key}:"; + yield return $" status: {entry.Status.ToYamlValue()}"; + if (entry.NoPublicSymbols) + yield return " no_public_symbols: true"; + if (!string.IsNullOrEmpty(entry.Note)) + yield return $" note: \"{Escape(entry.Note)}\""; + if (entry.Symbols.Count > 0) + { + yield return " symbols:"; + foreach (var symbol in entry.Symbols) + yield return $" - {symbol}"; + } + } + + private static string Banner(string title) + { + var prefix = $" # ── {title} "; + var builder = new StringBuilder(prefix); + while (builder.Length < BannerWidth) + builder.Append('─'); + return builder.ToString(); + } + + private static string Escape(string note) => + note.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); +} From 339b2dce854e8b5ae0084b7e2f60d52c535b7b35 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 16 Oct 2026 13:40:14 +0200 Subject: [PATCH 3/5] chore(capability-matrix): assign attributes to implemented capabilities --- packages/Functions/Functions/Client.cs | 5 + .../Functions/InvokeFunctionOptions.cs | 5 + packages/Gotrue/Gotrue/AdminClient.cs | 10 + packages/Gotrue/Gotrue/Client.cs | 38 +++ packages/Gotrue/Gotrue/ClientOptions.cs | 2 + packages/Gotrue/Gotrue/Constants.cs | 302 +++++++++--------- packages/Gotrue/Gotrue/ProviderAuthState.cs | 2 + packages/Gotrue/Gotrue/SignInOptions.cs | 2 + packages/Gotrue/Gotrue/User.cs | 2 + packages/Postgrest/Postgrest/Client.cs | 2 + packages/Postgrest/Postgrest/ClientOptions.cs | 2 + packages/Postgrest/Postgrest/Constants.cs | 205 ++++++------ packages/Postgrest/Postgrest/QueryOptions.cs | 181 ++++++----- packages/Postgrest/Postgrest/Table.cs | 20 ++ .../Realtime/Broadcast/BroadcastOptions.cs | 3 + packages/Realtime/Realtime/Client.cs | 10 + packages/Realtime/Realtime/ClientOptions.cs | 3 + .../PostgresChanges/PostgresChangesOptions.cs | 1 + .../Realtime/Realtime/RealtimeBroadcast.cs | 2 + packages/Realtime/Realtime/RealtimeChannel.cs | 10 + .../Realtime/Realtime/RealtimePresence.cs | 6 + packages/Storage/Storage/Client.cs | 2 + .../Storage/Storage/DestinationOptions.cs | 5 +- packages/Storage/Storage/FileOptions.cs | 4 + packages/Storage/Storage/StorageBucketApi.cs | 7 + packages/Storage/Storage/StorageFileApi.cs | 19 ++ packages/Supabase/Supabase/Client.cs | 4 + packages/Supabase/Supabase/SupabaseOptions.cs | 4 + packages/Supabase/Supabase/SupabaseTable.cs | 95 +++--- 29 files changed, 572 insertions(+), 381 deletions(-) diff --git a/packages/Functions/Functions/Client.cs b/packages/Functions/Functions/Client.cs index 9218407f..3c84ecc5 100644 --- a/packages/Functions/Functions/Client.cs +++ b/packages/Functions/Functions/Client.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Web; using Supabase.Core; +using Supabase.Core.Attributes; using Supabase.Core.Diagnostics; using Supabase.Core.Extensions; using Supabase.Core.Http; @@ -44,6 +45,7 @@ public partial class Client : IFunctionsClient /// /// Headers specified in the method parameters will ALWAYS take precedence over headers returned by this function. /// + [PartialCapability("functions.invocation.set_auth_token", "No stateful SetAuth; bearer token is supplied per invocation or via a GetHeaders provider hook, not stored for all subsequent calls.")] public Func>? GetHeaders { get; set; } /// The options this client was constructed with. @@ -71,6 +73,7 @@ public Client(string baseUrl, FunctionRegion? region = null, ClientOptions? opti /// Options /// /// + [ImplementedCapability("functions.invocation.invoke", "Three return shapes — string, deserialized T, and raw HttpContent.")] public async Task RawInvoke( string functionName, string? token = null, @@ -92,6 +95,7 @@ public async Task RawInvoke( /// Invocation options. /// Cancels the request and error-body reads. Cancel successful body reads separately. /// The response, which the caller must dispose. + [ImplementedCapability("functions.invocation.streaming_response", "InvokeStream returns response bodies for incremental reading.")] public Task InvokeStream( string functionName, string? token = null, @@ -107,6 +111,7 @@ public Task InvokeStream( /// Options /// /// + [ImplementedCapability("functions.invocation.invoke", "Three return shapes — string, deserialized T, and raw HttpContent.")] public async Task Invoke( string functionName, string? token = null, diff --git a/packages/Functions/Functions/InvokeFunctionOptions.cs b/packages/Functions/Functions/InvokeFunctionOptions.cs index d73b7d75..3649edd3 100644 --- a/packages/Functions/Functions/InvokeFunctionOptions.cs +++ b/packages/Functions/Functions/InvokeFunctionOptions.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Text.Json.Serialization; +using Supabase.Core.Attributes; namespace Supabase.Functions; @@ -30,22 +31,26 @@ public class InvokeFunctionOptions /// Timout value for HttpClient Requests, defaults to 100s. /// https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout?view=net-8.0#remarks /// + [PartialCapability("functions.invocation.timeout", "Per-invoke HttpTimeout exists, but it is not composable with a caller-supplied cancellation signal (Invoke accepts no CancellationToken).")] public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(100); /// /// Http method of the Request /// + [ImplementedCapability("functions.invocation.method_override", "InvokeFunctionOptions.HttpMethod is wired into the request (default POST). [feature-parity.md called this POST-only; the source disagrees as of 2026-06-29.]")] public HttpMethod HttpMethod { get; set; } = HttpMethod.Post; /// /// Region of the request /// + [ImplementedCapability("functions.invocation.region_selection", "Full AWS region list; per-call override or client default; emits x-region.")] public FunctionRegion? FunctionRegion { get; set; } = null; } /// /// Define the region for requests /// + [ImplementedCapability("functions.invocation.region_selection", "Full AWS region list; per-call override or client default; emits x-region.")] public class FunctionRegion : IEquatable { private readonly string region; diff --git a/packages/Gotrue/Gotrue/AdminClient.cs b/packages/Gotrue/Gotrue/AdminClient.cs index 2a5bc3dd..410f61dc 100644 --- a/packages/Gotrue/Gotrue/AdminClient.cs +++ b/packages/Gotrue/Gotrue/AdminClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Supabase.Core.Attributes; using Supabase.Gotrue.Interfaces; using Supabase.Gotrue.Mfa; using Supabase.Gotrue.Responses; @@ -54,12 +55,14 @@ public Func>? GetHeaders private readonly string serviceKey; /// + [ImplementedCapability("auth.admin.get_user")] public Task GetUserById(string userId) => this.api.GetUserById(this.serviceKey, userId); /// public Task GetUser(string jwt) => this.api.GetUser(jwt); /// + [ImplementedCapability("auth.admin.invite_user")] public async Task InviteUserByEmail(string email, InviteUserByEmailOptions? options = null) { var response = await this.api.InviteUserByEmail(email, this.serviceKey, options); @@ -68,6 +71,7 @@ public async Task InviteUserByEmail(string email, InviteUserByEmailOptions } /// + [PartialCapability("auth.admin.delete_user", "Hard-delete only; no shouldSoftDelete flag (JS deleteUser(id, shouldSoftDelete)).")] public async Task DeleteUser(string uid, bool shouldSoftDelete = false) { var result = await this.api.DeleteUser(uid, this.serviceKey, shouldSoftDelete); @@ -76,6 +80,7 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false) } /// + [ImplementedCapability("auth.admin.create_user")] public Task CreateUser(string email, string password, AdminUserAttributes? attributes = null) { attributes ??= new AdminUserAttributes(); @@ -89,12 +94,15 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false) public Task CreateUser(AdminUserAttributes attributes) => this.api.CreateUser(this.serviceKey, attributes); /// + [ImplementedCapability("auth.admin.list_users")] public Task?> ListUsers(string? filter = null, string? sortBy = null, Constants.SortOrder sortOrder = Constants.SortOrder.Descending, int? page = null, int? perPage = null) => this.api.ListUsers(this.serviceKey, filter, sortBy, sortOrder, page, perPage); /// + [ImplementedCapability("auth.admin.update_user")] public Task UpdateUserById(string userId, AdminUserAttributes userData) => this.api.UpdateUserById(this.serviceKey, userId, userData); /// + [ImplementedCapability("auth.admin.generate_link")] public async Task GenerateLink(GenerateLinkOptions options) { var response = await this.api.GenerateLink(this.serviceKey, options); @@ -107,6 +115,7 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false) } /// + [ImplementedCapability("auth.admin.list_mfa_factors")] public async Task ListFactors(MfaAdminListFactorsParams listFactorsParams) { var response = await this.api.ListFactors(this.serviceKey, listFactorsParams); @@ -124,6 +133,7 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false) return listFactorsResponse; } + [ImplementedCapability("auth.admin.delete_mfa_factor")] public async Task DeleteFactor(MfaAdminDeleteFactorParams deleteFactorParams) => await this.api.DeleteFactor(this.serviceKey, deleteFactorParams); /// diff --git a/packages/Gotrue/Gotrue/Client.cs b/packages/Gotrue/Gotrue/Client.cs index 8d6a54ed..cccec938 100644 --- a/packages/Gotrue/Gotrue/Client.cs +++ b/packages/Gotrue/Gotrue/Client.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Web; +using Supabase.Core.Attributes; using Supabase.Core.Diagnostics; using Supabase.Gotrue.Claims; using Supabase.Gotrue.Exceptions; @@ -119,6 +120,7 @@ public Client(ClientOptions? options = null) public TokenRefresh? TokenRefresh { get; } /// + [ImplementedCapability("client.session_management.custom_storage")] public void SetPersistence(IGotrueSessionPersistence persistence) => this.sessionPersistence = new PersistenceListener(persistence); @@ -126,9 +128,11 @@ public void SetPersistence(IGotrueSessionPersistence persistence) => public ClientOptions Options { get; } /// + [ImplementedCapability("auth.session.get_user")] public Task GetUser(string jwt) => this.api.GetUser(jwt); /// + [ImplementedCapability("auth.session.subscribe_auth_events")] public void NotifyAuthStateChange(AuthState stateChanged) => this.InvokeStateChangedHandlers(stateChanged); /// @@ -180,9 +184,11 @@ private void InvokeStateChangedHandlers(AuthState stateChanged) } /// + [ImplementedCapability("auth.session.get_user")] public User? CurrentUser => this.CurrentSession?.User; /// + [ImplementedCapability("auth.session.subscribe_auth_events")] public void AddStateChangedListener(IGotrueClient.AuthEventHandler authEventHandler) { if (this.authEventHandlers.Contains(authEventHandler)) @@ -193,6 +199,7 @@ public void AddStateChangedListener(IGotrueClient.AuthEventHandle } /// + [ImplementedCapability("auth.session.subscribe_auth_events")] public void RemoveStateChangedListener(IGotrueClient.AuthEventHandler authEventHandler) { if (!this.authEventHandlers.Contains(authEventHandler)) @@ -209,9 +216,11 @@ public void RemoveStateChangedListener(IGotrueClient.AuthEventHan public bool Online { get; set; } = true; /// + [ImplementedCapability("auth.session.get_session")] public Session? CurrentSession { get; private set; } /// + [PartialCapability("auth.sign_in.sign_up", "API mirrors signUp, but on a session-returning sign-up (confirmations off) C# does NOT adopt the session — CurrentUser/CurrentSession stay null, so 'signed up' != 'signed in' as in JS.")] public Task SignUp(string email, string password, SignUpOptions? options = null) => this.SignUp(SignUpType.Email, email, password, options); @@ -241,6 +250,8 @@ public void RemoveStateChangedListener(IGotrueClient.AuthEventHan } /// + [ImplementedCapability("auth.sign_in.sign_in_with_oauth")] + [ImplementedCapability("auth.sign_in.sign_in_with_password")] public async Task SignIn(string email, SignInOptions? options = null) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SendMagicLink); @@ -253,6 +264,7 @@ public async Task SignIn(string email, SignInOptions? options = null) } /// + [ImplementedCapability("auth.sign_in.sign_in_with_id_token")] public async Task SignInWithIdToken(Provider provider, string idToken, string? accessToken = null, string? nonce = null, string? captchaToken = null) { @@ -269,6 +281,7 @@ public async Task SignIn(string email, SignInOptions? options = null) } /// + [ImplementedCapability("auth.sign_in.sign_in_with_otp")] public async Task SignInWithOtp(SignInWithPasswordlessEmailOptions options) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SignInWithOtp); @@ -293,12 +306,14 @@ public async Task SignInWithOtp(SignInWithPasswordlessP } /// + [ImplementedCapability("auth.sign_in.sign_in_with_otp")] public Task SendMagicLink(string email, SignInOptions? options = null) => this.SignIn(email, options); /// public Task SignIn(string email, string password) => this.SignIn(SignInType.Email, email, password); /// + [ImplementedCapability("auth.sign_in.sign_in_with_password")] public Task SignInWithPassword(string email, string password) => this.SignIn(email, password); /// @@ -374,6 +389,7 @@ public async Task SignIn(Provider provider, SignInOptions? op } /// + [ImplementedCapability("auth.sign_in.sign_in_with_sso")] public async Task SignInWithSSO(Guid providerId, SignInWithSSOOptions? options = null) { if (!this.Online) @@ -394,6 +410,7 @@ public async Task SignIn(Provider provider, SignInOptions? op } /// + [ImplementedCapability("auth.sign_in.sign_in_anonymously")] public async Task SignInAnonymously(SignInAnonymouslyOptions? options = null) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SignInAnonymously); @@ -408,6 +425,7 @@ public async Task SignIn(Provider provider, SignInOptions? op } /// + [ImplementedCapability("auth.sign_in.verify_otp")] public async Task VerifyOTP(string phone, string token, MobileOtpType type = MobileOtpType.SMS) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.VerifyOtp); @@ -446,6 +464,7 @@ public async Task SignIn(Provider provider, SignInOptions? op } /// + [ImplementedCapability("auth.sign_in.verify_otp")] public async Task VerifyTokenHash(string tokenHash, EmailOtpType type = EmailOtpType.Email) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.VerifyTokenHash); @@ -464,6 +483,7 @@ public async Task SignIn(Provider provider, SignInOptions? op } /// + [ImplementedCapability("auth.identities.link_identity")] public Task LinkIdentity(Provider provider, SignInOptions options) { if (!this.Online) @@ -504,6 +524,7 @@ public Task LinkIdentity(Provider provider, SignInOptions opt } /// + [ImplementedCapability("auth.identities.unlink_identity")] public Task UnlinkIdentity(UserIdentity userIdentity) { if (!this.Online) @@ -518,6 +539,7 @@ public Task UnlinkIdentity(UserIdentity userIdentity) } /// + [ImplementedCapability("auth.session.sign_out")] public async Task SignOut(SignOutScope scope = SignOutScope.Global) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SignOut); @@ -539,6 +561,7 @@ public async Task SignOut(SignOutScope scope = SignOutScope.Global) } /// + [ImplementedCapability("auth.session.update_user")] public async Task Update(UserAttributes attributes) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.UpdateUser); @@ -564,6 +587,7 @@ public async Task SignOut(SignOutScope scope = SignOutScope.Global) } /// + [ImplementedCapability("auth.sign_in.reauthenticate")] public async Task Reauthenticate() { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -579,6 +603,7 @@ public async Task Reauthenticate() } /// + [ImplementedCapability("auth.sign_in.send_password_reset_email")] public async Task ResetPasswordForEmail(string email) { var result = await this.api.ResetPasswordForEmail(email); @@ -594,6 +619,7 @@ public async Task ResetPasswordForEmail(ResetPasswor } /// + [ImplementedCapability("auth.session.refresh_session")] public async Task RefreshSession() { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.RefreshSession); @@ -616,6 +642,7 @@ public async Task ResetPasswordForEmail(ResetPasswor } /// + [ImplementedCapability("auth.session.set_session")] public async Task SetSession(string accessToken, string refreshToken, bool forceAccessTokenRefresh = false) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.SetSession); @@ -723,6 +750,7 @@ public async Task SetSession(string accessToken, string refreshToken, b } /// + [ImplementedCapability("auth.session.get_session")] public async Task RetrieveSessionAsync() { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.RetrieveSession); @@ -766,6 +794,7 @@ public async Task SetSession(string accessToken, string refreshToken, b } /// + [ImplementedCapability("auth.sign_in.exchange_code_for_session")] public async Task ExchangeCodeForSession(string codeVerifier, string authCode) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.ExchangeCode); @@ -799,6 +828,7 @@ public void AddDebugListener(Action listener) } /// + [ImplementedCapability("auth.session.refresh_session")] public async Task RefreshToken(string accessToken, string refreshToken) { using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.RefreshToken); @@ -911,6 +941,7 @@ private async Task RefreshCurrentSession(string accessToken, string refreshToken } /// + [ImplementedCapability("auth.session.get_session")] public void LoadSession() { if (this.sessionPersistence == null) @@ -983,6 +1014,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) public void Shutdown() => this.NotifyAuthStateChange(AuthState.Shutdown); /// + [ImplementedCapability("auth.mfa.enroll")] public async Task Enroll(MfaEnrollParams mfaEnrollParams) { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -997,6 +1029,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) } /// + [ImplementedCapability("auth.mfa.challenge")] public async Task Challenge(MfaChallengeParams mfaChallengeParams) { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -1011,6 +1044,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) } /// + [ImplementedCapability("auth.mfa.verify")] public async Task Verify(MfaVerifyParams mfaVerifyParams) { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -1040,6 +1074,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) } /// + [ImplementedCapability("auth.mfa.challenge_and_verify")] public async Task ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams) { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -1082,6 +1117,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) } /// + [ImplementedCapability("auth.mfa.unenroll")] public async Task Unenroll(MfaUnenrollParams mfaUnenrollParams) { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -1096,6 +1132,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) } /// + [ImplementedCapability("auth.mfa.list_factors")] public Task ListFactors() { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) @@ -1110,6 +1147,7 @@ private void ApplyLoadedSession(Session? before, Session? loaded) return Task.FromResult(response); } + [ImplementedCapability("auth.mfa.get_authenticator_assurance_level")] public Task GetAuthenticatorAssuranceLevel() { if (this.CurrentSession == null || string.IsNullOrEmpty(this.CurrentSession.AccessToken)) diff --git a/packages/Gotrue/Gotrue/ClientOptions.cs b/packages/Gotrue/Gotrue/ClientOptions.cs index 78322056..ef491cc8 100644 --- a/packages/Gotrue/Gotrue/ClientOptions.cs +++ b/packages/Gotrue/Gotrue/ClientOptions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using Supabase.Core.Attributes; using Supabase.Core.Http; using static Supabase.Gotrue.Constants; @@ -39,6 +40,7 @@ public class ClientOptions /// /// Should the Client automatically handle refreshing the User's Token? /// + [PartialCapability("auth.session.auto_refresh", "Refreshes on an internal background timer (ClientOptions.AutoRefreshToken), but does not expose JS's explicit startAutoRefresh/stopAutoRefresh control. Implicit-only.")] public bool AutoRefreshToken { get; set; } = true; /// diff --git a/packages/Gotrue/Gotrue/Constants.cs b/packages/Gotrue/Gotrue/Constants.cs index fac3e04b..d34031f3 100644 --- a/packages/Gotrue/Gotrue/Constants.cs +++ b/packages/Gotrue/Gotrue/Constants.cs @@ -1,167 +1,167 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Supabase.Core.Attributes; #pragma warning disable CS1591 -namespace Supabase.Gotrue +namespace Supabase.Gotrue; + +/// +/// Constants used throughout the Gotrue Client +/// +public static class Constants { - /// - /// Constants used throughout the Gotrue Client - /// - public static class Constants - { - /// - /// URL for the Gotrue server. Defaults to localhost:9999. - /// - public const string GOTRUE_URL = "http://localhost:9999"; - public const string AUDIENCE = ""; - public const int EXPIRY_MARGIN = 60 * 1000; - public const string STORAGE_KEY = "supabase.auth.token"; - public static readonly Dictionary CookieOptions = new Dictionary - { - { "name", "sb:token" }, - { "lifetime", 60 * 60 * 8 }, - { "domain", "" }, - { "path", "/" }, - { "sameSite", "lax" } - }; + /// + /// URL for the Gotrue server. Defaults to localhost:9999. + /// + public const string GOTRUE_URL = "http://localhost:9999"; + public const string AUDIENCE = ""; + public const int EXPIRY_MARGIN = 60 * 1000; + public const string STORAGE_KEY = "supabase.auth.token"; + public static readonly Dictionary CookieOptions = new Dictionary + { + { "name", "sb:token" }, + { "lifetime", 60 * 60 * 8 }, + { "domain", "" }, + { "path", "/" }, + { "sameSite", "lax" } + }; - public enum SortOrder - { - [MapTo("asc")] - Ascending, - [MapTo("desc")] - Descending - } + public enum SortOrder + { + [MapTo("asc")] + Ascending, + [MapTo("desc")] + Descending + } - public enum MobileOtpType - { - [MapTo("sms")] - SMS, - [MapTo("phone_change")] - PhoneChange - } + public enum MobileOtpType + { + [MapTo("sms")] + SMS, + [MapTo("phone_change")] + PhoneChange + } - public enum EmailOtpType - { - [MapTo("signup")] - Signup, - [MapTo("invite")] - Invite, - [MapTo("magiclink")] - MagicLink, - [MapTo("recovery")] - Recovery, - [MapTo("email_change")] - EmailChange, - [MapTo("email")] - Email - } + public enum EmailOtpType + { + [MapTo("signup")] + Signup, + [MapTo("invite")] + Invite, + [MapTo("magiclink")] + MagicLink, + [MapTo("recovery")] + Recovery, + [MapTo("email_change")] + EmailChange, + [MapTo("email")] + Email + } - /// - /// Providers available to Supabase - /// Ref: https://supabase.github.io/gotrue-js/modules.html#Provider - /// - public enum Provider - { - [MapTo("anonymous_users")] - AnonymousUsers, - [MapTo("apple")] - Apple, - [MapTo("azure")] - Azure, - [MapTo("bitbucket")] - Bitbucket, - [MapTo("discord")] - Discord, - [MapTo("facebook")] - Facebook, - [MapTo("figma")] - Figma, - [MapTo("fly")] - Fly, - [MapTo("github")] - Github, - [MapTo("gitlab")] - Gitlab, - [MapTo("google")] - Google, - [MapTo("kakao")] - Kakao, - [MapTo("keycloak")] - KeyCloak, - [MapTo("linkedin")] - LinkedIn, - [MapTo("linkedin_oidc")] - LinkedInOIDC, - [MapTo("notion")] - Notion, - [MapTo("slack")] - Slack, - [MapTo("spotify")] - Spotify, - [MapTo("twitch")] - Twitch, - [MapTo("twitter")] - Twitter, - [MapTo("workos")] - WorkOS, - [MapTo("zoom")] - Zoom - } + /// + /// Providers available to Supabase + /// Ref: https://supabase.github.io/gotrue-js/modules.html#Provider + /// + public enum Provider + { + [MapTo("anonymous_users")] + AnonymousUsers, + [MapTo("apple")] + Apple, + [MapTo("azure")] + Azure, + [MapTo("bitbucket")] + Bitbucket, + [MapTo("discord")] + Discord, + [MapTo("facebook")] + Facebook, + [MapTo("figma")] + Figma, + [MapTo("fly")] + Fly, + [MapTo("github")] + Github, + [MapTo("gitlab")] + Gitlab, + [MapTo("google")] + Google, + [MapTo("kakao")] + Kakao, + [MapTo("keycloak")] + KeyCloak, + [MapTo("linkedin")] + LinkedIn, + [MapTo("linkedin_oidc")] + LinkedInOIDC, + [MapTo("notion")] + Notion, + [MapTo("slack")] + Slack, + [MapTo("spotify")] + Spotify, + [MapTo("twitch")] + Twitch, + [MapTo("twitter")] + Twitter, + [MapTo("workos")] + WorkOS, + [MapTo("zoom")] + Zoom + } - /// - /// States that the Auth Client will raise events for. - /// - public enum AuthState - { - SignedIn, - SignedOut, - UserUpdated, - PasswordRecovery, - TokenRefreshed, - Shutdown, - MfaChallengeVerified - } + /// + /// States that the Auth Client will raise events for. + /// + public enum AuthState + { + SignedIn, + SignedOut, + UserUpdated, + PasswordRecovery, + TokenRefreshed, + Shutdown, + MfaChallengeVerified + } - /// - /// Specifies the functionality expected from the `SignIn` method - /// - public enum SignInType - { - Email, - Phone, - RefreshToken - } + /// + /// Specifies the functionality expected from the `SignIn` method + /// + public enum SignInType + { + Email, + Phone, + RefreshToken + } - /// - /// Represents an OAuth Flow type - /// - public enum OAuthFlowType - { - [MapTo("implicit")] - Implicit, - [MapTo("pkce")] - PKCE - } + /// + /// Represents an OAuth Flow type + /// + [PartialCapability("client.authentication_integration.oauth_flow_type", "Flow type (PKCE/Implicit) is chosen per sign-in via SignInOptions.FlowType, not as a single construction-time client setting.")] + public enum OAuthFlowType + { + [MapTo("implicit")] + Implicit, + [MapTo("pkce")] + PKCE + } - /// - /// Specifies the functionality expected from the `SignUp` method - /// - public enum SignUpType - { - Email, - Phone - } + /// + /// Specifies the functionality expected from the `SignUp` method + /// + public enum SignUpType + { + Email, + Phone + } - public enum SignOutScope - { - [MapTo("global")] - Global, - [MapTo("local")] - Local, - [MapTo("others")] - Others - } + public enum SignOutScope + { + [MapTo("global")] + Global, + [MapTo("local")] + Local, + [MapTo("others")] + Others } } diff --git a/packages/Gotrue/Gotrue/ProviderAuthState.cs b/packages/Gotrue/Gotrue/ProviderAuthState.cs index e22f5b2e..a44a6672 100644 --- a/packages/Gotrue/Gotrue/ProviderAuthState.cs +++ b/packages/Gotrue/Gotrue/ProviderAuthState.cs @@ -1,4 +1,5 @@ using System; +using Supabase.Core.Attributes; namespace Supabase.Gotrue; /// @@ -6,6 +7,7 @@ namespace Supabase.Gotrue; /// /// For use with Provider Auth, PKCE Auth, and ID Token auth. /// +[ImplementedCapability("auth.sign_in.sign_in_with_oauth")] public class ProviderAuthState { /// diff --git a/packages/Gotrue/Gotrue/SignInOptions.cs b/packages/Gotrue/Gotrue/SignInOptions.cs index c0e1de06..8b872ebf 100644 --- a/packages/Gotrue/Gotrue/SignInOptions.cs +++ b/packages/Gotrue/Gotrue/SignInOptions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Supabase.Core.Attributes; using static Supabase.Gotrue.Constants; namespace Supabase.Gotrue; @@ -52,5 +53,6 @@ public class SignInOptions /// /// PKCE is recommended for mobile and server-side applications. /// + [PartialCapability("client.authentication_integration.oauth_flow_type", "Flow type (PKCE/Implicit) is chosen per sign-in via SignInOptions.FlowType, not as a single construction-time client setting.")] public OAuthFlowType FlowType { get; set; } = OAuthFlowType.Implicit; } diff --git a/packages/Gotrue/Gotrue/User.cs b/packages/Gotrue/Gotrue/User.cs index 7349173e..78643164 100644 --- a/packages/Gotrue/Gotrue/User.cs +++ b/packages/Gotrue/Gotrue/User.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Supabase.Core.Attributes; using Supabase.Gotrue.Mfa; #endregion @@ -44,6 +45,7 @@ public class User public string? Id { get; set; } [JsonPropertyName("identities")] + [PartialCapability("auth.identities.list_identities", "No dedicated getUserIdentities method; linked identities are reachable only via the User.Identities property.")] public List Identities { get; set; } = new List(); [JsonPropertyName("invited_at")] diff --git a/packages/Postgrest/Postgrest/Client.cs b/packages/Postgrest/Postgrest/Client.cs index 04ce8320..a29e564c 100644 --- a/packages/Postgrest/Postgrest/Client.cs +++ b/packages/Postgrest/Postgrest/Client.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; +using Supabase.Core.Attributes; using Supabase.Core.Extensions; using Supabase.Postgrest.Interfaces; using Supabase.Postgrest.Models; @@ -79,6 +80,7 @@ public Client(string baseUrl, ClientOptions? options = null) /// + [ImplementedCapability("database.query.from_table")] public IPostgrestTable Table() where T : BaseModel, new() => new Table(this.BaseUrl, SerializerSettings(this.Options), this.Options) { diff --git a/packages/Postgrest/Postgrest/ClientOptions.cs b/packages/Postgrest/Postgrest/ClientOptions.cs index 3daf0493..08b0f8a5 100644 --- a/packages/Postgrest/Postgrest/ClientOptions.cs +++ b/packages/Postgrest/Postgrest/ClientOptions.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Net; using System.Net.Http; +using Supabase.Core.Attributes; using Supabase.Core.Http; #pragma warning disable CS1591 namespace Supabase.Postgrest; @@ -12,6 +13,7 @@ namespace Supabase.Postgrest; /// public class ClientOptions { + [PartialCapability("database.query.schema_selection", "Schema is set once at construction (ClientOptions.Schema); no per-query schema switch like JS client.schema(name).")] public string Schema { get; set; } = "public"; public readonly DateTimeStyles DateTimeStyles = DateTimeStyles.AdjustToUniversal; diff --git a/packages/Postgrest/Postgrest/Constants.cs b/packages/Postgrest/Postgrest/Constants.cs index c3ec3fe9..56088421 100644 --- a/packages/Postgrest/Postgrest/Constants.cs +++ b/packages/Postgrest/Postgrest/Constants.cs @@ -1,97 +1,120 @@ -using Supabase.Core.Attributes; +using Supabase.Core.Attributes; #pragma warning disable CS1591 -namespace Supabase.Postgrest -{ +namespace Supabase.Postgrest; + - public static class Constants - { - /// - /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=operators#operators - /// - public enum Operator - { - [MapTo("and")] - And, - [MapTo("or")] - Or, - [MapTo("eq")] - Equals, - [MapTo("gt")] - GreaterThan, - [MapTo("gte")] - GreaterThanOrEqual, - [MapTo("lt")] - LessThan, - [MapTo("lte")] - LessThanOrEqual, - [MapTo("neq")] - NotEqual, - [MapTo("like")] - Like, - [MapTo("ilike")] - ILike, - [MapTo("in")] - In, - [MapTo("is")] - Is, - [MapTo("fts")] - FTS, - [MapTo("plfts")] - PLFTS, - [MapTo("phfts")] - PHFTS, - [MapTo("wfts")] - WFTS, - [MapTo("cs")] - Contains, - [MapTo("cd")] - ContainedIn, - [MapTo("ov")] - Overlap, - [MapTo("sl")] - StrictlyLeft, - [MapTo("sr")] - StrictlyRight, - [MapTo("nxr")] - NotRightOf, - [MapTo("nxl")] - NotLeftOf, - [MapTo("adj")] - Adjacent, - [MapTo("not")] - Not, - } +public static class Constants +{ + /// + /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=operators#operators + /// + public enum Operator + { + [MapTo("and")] + And, + [MapTo("or")] + [ImplementedCapability("database.using_filters.or")] + Or, + [MapTo("eq")] + [ImplementedCapability("database.using_filters.eq")] + Equals, + [MapTo("gt")] + [ImplementedCapability("database.using_filters.gt")] + GreaterThan, + [MapTo("gte")] + [ImplementedCapability("database.using_filters.gte")] + GreaterThanOrEqual, + [MapTo("lt")] + [ImplementedCapability("database.using_filters.lt")] + LessThan, + [MapTo("lte")] + [ImplementedCapability("database.using_filters.lte")] + LessThanOrEqual, + [MapTo("neq")] + [ImplementedCapability("database.using_filters.neq")] + NotEqual, + [MapTo("like")] + [ImplementedCapability("database.using_filters.like")] + Like, + [MapTo("ilike")] + [ImplementedCapability("database.using_filters.ilike")] + ILike, + [MapTo("in")] + [ImplementedCapability("database.using_filters.in")] + In, + [MapTo("is")] + [ImplementedCapability("database.using_filters.is")] + Is, + [MapTo("fts")] + [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")] + FTS, + [MapTo("plfts")] + [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")] + PLFTS, + [MapTo("phfts")] + [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")] + PHFTS, + [MapTo("wfts")] + [ImplementedCapability("database.using_filters.text_search", "FTS family: fts/plfts/phfts/wfts.")] + WFTS, + [MapTo("cs")] + [ImplementedCapability("database.using_filters.contains")] + Contains, + [MapTo("cd")] + [ImplementedCapability("database.using_filters.contained_by")] + ContainedIn, + [MapTo("ov")] + [ImplementedCapability("database.using_filters.overlaps")] + Overlap, + [MapTo("sl")] + [ImplementedCapability("database.using_filters.range_lt")] + StrictlyLeft, + [MapTo("sr")] + [ImplementedCapability("database.using_filters.range_gt")] + StrictlyRight, + [MapTo("nxr")] + [ImplementedCapability("database.using_filters.range_lte")] + NotRightOf, + [MapTo("nxl")] + [ImplementedCapability("database.using_filters.range_gte")] + NotLeftOf, + [MapTo("adj")] + [ImplementedCapability("database.using_filters.range_adjacent")] + Adjacent, + [MapTo("not")] + [ImplementedCapability("database.using_filters.not")] + Not, + } - public enum Ordering - { - [MapTo("asc")] - Ascending, - [MapTo("desc")] - Descending, - } + public enum Ordering + { + [MapTo("asc")] + Ascending, + [MapTo("desc")] + Descending, + } - /// - /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=nulls%20first#ordering - /// - public enum NullPosition - { - [MapTo("nullsfirst")] - First, - [MapTo("nullslast")] - Last - } + /// + /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=nulls%20first#ordering + /// + public enum NullPosition + { + [MapTo("nullsfirst")] + First, + [MapTo("nullslast")] + Last + } - /// - /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=count#estimated-count - /// - public enum CountType - { - [MapTo("exact")] - Exact, - [MapTo("planned")] - Planned, - [MapTo("estimated")] - Estimated - } - } + /// + /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=count#estimated-count + /// + public enum CountType + { + [MapTo("exact")] + Exact, + [MapTo("planned")] + Planned, + [MapTo("estimated")] + Estimated + } } diff --git a/packages/Postgrest/Postgrest/QueryOptions.cs b/packages/Postgrest/Postgrest/QueryOptions.cs index 906afd1c..b2a68d4a 100644 --- a/packages/Postgrest/Postgrest/QueryOptions.cs +++ b/packages/Postgrest/Postgrest/QueryOptions.cs @@ -1,97 +1,96 @@ -using System; using System.Collections.Generic; -using Supabase.Postgrest.Extensions; using Supabase.Core.Attributes; +using Supabase.Postgrest.Extensions; #pragma warning disable CS1591 -namespace Supabase.Postgrest +namespace Supabase.Postgrest; + + +public class QueryOptions { + public enum ReturnType + { + [MapTo("minimal")] + Minimal, + [MapTo("representation")] + Representation + }; + + public enum CountType + { + [MapTo("none")] + None, + [MapTo("exact")] + Exact, + [MapTo("planned")] + Planned, + [MapTo("estimated")] + Estimated + }; + + public enum DuplicateResolutionType + { + [MapTo("merge-duplicates")] + MergeDuplicates, + [MapTo("ignore-duplicates")] + IgnoreDuplicates + } + + /// + /// By default the new record is returned. Set this to 'Minimal' if you don't need this value. + /// + [ImplementedCapability("database.mutate.select_after_mutation", "Mutations return the affected rows by default (QueryOptions.Returning = Representation).")] + public ReturnType Returning { get; set; } = ReturnType.Representation; + + /// + /// Specifies if duplicate rows should be ignored and not inserted. + /// + public DuplicateResolutionType DuplicateResolution { get; set; } = DuplicateResolutionType.MergeDuplicates; + + /// + /// Count algorithm to use to count rows in a table. + /// + public CountType Count { get; set; } = CountType.None; + + /// + /// If the record should be upserted + /// + public bool Upsert { get; set; } + + /// + /// /// By specifying the onConflict query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. + /// + public string? OnConflict { get; set; } + + public Dictionary ToHeaders() + { + var headers = new Dictionary(); + var prefersHeaders = new List(); + + if (this.Upsert) + { + var resolverAttr = this.DuplicateResolution.GetAttribute(); + prefersHeaders.Add($"resolution={resolverAttr?.Mapping}"); + } + + var returnAttr = this.Returning.GetAttribute(); + if (returnAttr != null) + { + prefersHeaders.Add($"return={returnAttr.Mapping}"); + } + + var countAttr = this.Count.GetAttribute(); + if (this.Count != CountType.None && countAttr != null) + { + prefersHeaders.Add($"count={countAttr.Mapping}"); + } + + headers.Add("Prefer", string.Join(",", prefersHeaders.ToArray())); + + if (this.Returning == ReturnType.Minimal) + { + headers.Add("Accept", "*/*"); + } - public class QueryOptions - { - public enum ReturnType - { - [MapTo("minimal")] - Minimal, - [MapTo("representation")] - Representation - }; - - public enum CountType - { - [MapTo("none")] - None, - [MapTo("exact")] - Exact, - [MapTo("planned")] - Planned, - [MapTo("estimated")] - Estimated - }; - - public enum DuplicateResolutionType - { - [MapTo("merge-duplicates")] - MergeDuplicates, - [MapTo("ignore-duplicates")] - IgnoreDuplicates - } - - /// - /// By default the new record is returned. Set this to 'Minimal' if you don't need this value. - /// - public ReturnType Returning { get; set; } = ReturnType.Representation; - - /// - /// Specifies if duplicate rows should be ignored and not inserted. - /// - public DuplicateResolutionType DuplicateResolution { get; set; } = DuplicateResolutionType.MergeDuplicates; - - /// - /// Count algorithm to use to count rows in a table. - /// - public CountType Count { get; set; } = CountType.None; - - /// - /// If the record should be upserted - /// - public bool Upsert { get; set; } - - /// - /// /// By specifying the onConflict query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. - /// - public string? OnConflict { get; set; } - - public Dictionary ToHeaders() - { - var headers = new Dictionary(); - var prefersHeaders = new List(); - - if (Upsert) - { - var resolverAttr = DuplicateResolution.GetAttribute(); - prefersHeaders.Add($"resolution={resolverAttr?.Mapping}"); - } - - var returnAttr = Returning.GetAttribute(); - if (returnAttr != null) - { - prefersHeaders.Add($"return={returnAttr.Mapping}"); - } - - var countAttr = Count.GetAttribute(); - if (Count != CountType.None && countAttr != null) - { - prefersHeaders.Add($"count={countAttr.Mapping}"); - } - - headers.Add("Prefer", String.Join(",", prefersHeaders.ToArray())); - - if (Returning == ReturnType.Minimal) - { - headers.Add("Accept", "*/*"); - } - - return headers; - } - } + return headers; + } } diff --git a/packages/Postgrest/Postgrest/Table.cs b/packages/Postgrest/Postgrest/Table.cs index 1d602c8a..224f1551 100644 --- a/packages/Postgrest/Postgrest/Table.cs +++ b/packages/Postgrest/Postgrest/Table.cs @@ -100,6 +100,7 @@ public Table(string baseUrl, JsonSerializerOptions serializerSettings, ClientOpt } /// + [ImplementedCapability("database.using_filters.eq")] public IPostgrestTable Filter(Expression> predicate, Operator op, TCriterion? criterion) { @@ -179,6 +180,8 @@ public IPostgrestTable Filter(string columnName, Operator op } /// + [ImplementedCapability("database.using_filters.not")] + [ImplementedCapability("database.using_filters.not_in", "Composed via the Not(column, Operator.In, list) overload.")] public IPostgrestTable Not(IPostgrestQueryFilter filter) { this.filters.Add(new QueryFilter(Operator.Not, filter)); @@ -251,6 +254,7 @@ public IPostgrestTable And(List filters) } /// + [ImplementedCapability("database.using_filters.or")] public IPostgrestTable Or(List filters) { this.filters.Add(new QueryFilter(Operator.Or, filters)); @@ -258,6 +262,7 @@ public IPostgrestTable Or(List filters) } /// + [ImplementedCapability("database.using_filters.match")] public IPostgrestTable Match(TModel model) { foreach (var kvp in model.PrimaryKey) @@ -280,6 +285,7 @@ public IPostgrestTable Match(Dictionary query) } /// + [ImplementedCapability("database.using_modifiers.order", "Capability at parity, but the primary Order(\"col\", ...) form is stringly-typed (breaks silently on a property rename).")] public IPostgrestTable Order(Expression> predicate, Ordering ordering, NullPosition nullPosition = NullPosition.First) { @@ -312,6 +318,7 @@ public IPostgrestTable Order(string foreignTable, string column, Orderin } /// + [ImplementedCapability("database.using_modifiers.range")] public IPostgrestTable Range(int from) { this.rangeFrom = from; @@ -327,6 +334,8 @@ public IPostgrestTable Range(int from, int to) } /// + [ImplementedCapability("database.query.select")] + [PartialCapability("database.using_modifiers.relationship_embed", "Embeds expressible via PostgREST string syntax in Select(); no typed relationship builder (LINQ embed mapping is weak).")] public IPostgrestTable Select(string columnQuery) { this.method = HttpMethod.Get; @@ -348,6 +357,7 @@ public IPostgrestTable Select(Expression> predica } /// + [ImplementedCapability("database.using_filters.eq")] public IPostgrestTable Where(Expression> predicate) { var visitor = new WhereExpressionVisitor(); @@ -370,6 +380,7 @@ public IPostgrestTable Where(Expression> predicate) /// + [ImplementedCapability("database.using_modifiers.limit")] public IPostgrestTable Limit(int limit, string? foreignTableName = null) { this.limit = limit; @@ -379,6 +390,7 @@ public IPostgrestTable Limit(int limit, string? foreignTableName = null) /// + [ImplementedCapability("database.mutate.upsert")] public IPostgrestTable OnConflict(string columnName) { this.onConflict = columnName; @@ -425,6 +437,7 @@ public IPostgrestTable Columns(Expression> predic /// + [ImplementedCapability("database.using_modifiers.limit")] public IPostgrestTable Offset(int offset, string? foreignTableName = null) { this.offset = offset; @@ -434,6 +447,7 @@ public IPostgrestTable Offset(int offset, string? foreignTableName = nul /// + [ImplementedCapability("database.mutate.insert")] public Task> Insert(TModel model, QueryOptions? options = null, CancellationToken cancellationToken = default) => this.PerformInsert(model, options, cancellationToken); @@ -446,6 +460,7 @@ public Task> Insert(ICollection models, QueryOpt /// + [ImplementedCapability("database.mutate.upsert")] public Task> Upsert(TModel model, QueryOptions? options = null, CancellationToken cancellationToken = default) { @@ -472,6 +487,7 @@ public Task> Upsert(ICollection model, QueryOpti /// + [ImplementedCapability("database.mutate.update")] public IPostgrestTable Set(Expression> keySelector, object? value) { var visitor = new SetExpressionVisitor(); @@ -515,6 +531,7 @@ public IPostgrestTable Set(Expression + [ImplementedCapability("database.mutate.update")] public Task> Update(QueryOptions? options = null, CancellationToken cancellationToken = default) { @@ -547,6 +564,7 @@ public Task> Update(TModel model, QueryOptions? options /// + [ImplementedCapability("database.mutate.delete")] public Task> Delete(QueryOptions? options = null, CancellationToken cancellationToken = default) { options ??= new QueryOptions(); @@ -590,6 +608,7 @@ public async Task Count(CountType type, CancellationToken cancellationToken /// + [ImplementedCapability("database.using_modifiers.single_row")] public async Task Single(CancellationToken cancellationToken = default) { this.method = HttpMethod.Get; @@ -612,6 +631,7 @@ public async Task Count(CountType type, CancellationToken cancellationToken } /// + [ImplementedCapability("database.using_modifiers.request_cancellation", "Every terminal (Get/Insert/Update/Delete/Single/Count) accepts a CancellationToken.")] public Task> Get(CancellationToken cancellationToken = default, CountType type = CountType.Estimated) { var attr = type.GetAttribute(); diff --git a/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs b/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs index 64ad9832..da947f31 100644 --- a/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs +++ b/packages/Realtime/Realtime/Broadcast/BroadcastOptions.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Supabase.Core.Attributes; namespace Supabase.Realtime.Broadcast; @@ -12,12 +13,14 @@ public class BroadcastOptions /// self option enables client to receive message it broadcast /// [JsonPropertyName("self")] + [ImplementedCapability("realtime.subscriptions.broadcast_self")] public bool BroadcastSelf { get; set; } = false; /// /// ack option instructs server to acknowledge that broadcast message was received /// [JsonPropertyName("ack")] + [ImplementedCapability("realtime.subscriptions.broadcast_ack")] public bool BroadcastAck { get; set; } = false; /// diff --git a/packages/Realtime/Realtime/Client.cs b/packages/Realtime/Realtime/Client.cs index 7b1e40fc..a6f22467 100644 --- a/packages/Realtime/Realtime/Client.cs +++ b/packages/Realtime/Realtime/Client.cs @@ -5,6 +5,7 @@ using System.Net.WebSockets; using System.Text.Json; using System.Threading.Tasks; +using Supabase.Core.Attributes; using Supabase.Realtime.Channel; using Supabase.Realtime.Exceptions; using Supabase.Realtime.Interfaces; @@ -31,6 +32,7 @@ public class Client : IRealtimeClient /// /// Exposes all Realtime RealtimeChannel Subscriptions for R/O public consumption /// + [PartialCapability("realtime.client.get_channels", "Active channels exposed as a Subscriptions dictionary; no getChannels() method.")] public ReadOnlyDictionary Subscriptions => new(this.subscriptions); /// @@ -113,6 +115,7 @@ public Client(string realtimeUrl, ClientOptions? options = null) /// Returns when socket has successfully connected. /// /// + [ImplementedCapability("realtime.client.connect")] public async Task> ConnectAsync() { var tsc = new TaskCompletionSource>(); @@ -164,6 +167,7 @@ public async Task> ConnectAsync /// /// [Obsolete("Please use ConnectAsync() instead.")] + [ImplementedCapability("realtime.client.connect")] public IRealtimeClient Connect( Action, RealtimeException?>? callback = null) { @@ -209,6 +213,7 @@ public IRealtimeClient Connect( /// /// Adds a listener to be notified when the socket state changes. /// + [PartialCapability("realtime.client.connection_state", "Socket state delivered via SocketStateEventHandler events; no synchronous connection-state getter on the client.")] public void AddStateChangedHandler( IRealtimeClient.SocketStateEventHandler handler) { @@ -252,6 +257,7 @@ private void NotifySocketStateChange(SocketState stateChanged) /// Adds a debug handler, likely used within a logging solution of some kind. /// /// + [PartialCapability("realtime.configuration.custom_logger", "Diagnostics routed through a debug handler hook; no custom logging function with a log-level filter.")] public void AddDebugHandler(IRealtimeDebugger.DebugEventHandler handler) => Debugger.Instance.AddDebugHandler(handler); @@ -284,6 +290,7 @@ private void HandleSocketHeartbeat(IRealtimeSocket sender, SocketResponse messag /// Status Code /// Reason for disconnect /// + [ImplementedCapability("realtime.client.disconnect")] public IRealtimeClient Disconnect( WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "Programmatic Disconnect") { @@ -303,6 +310,7 @@ public IRealtimeClient Disconnect( /// Ref: https://github.com/supabase/realtime-js/pull/117 | https://github.com/supabase/realtime-js/pull/117 /// /// + [ImplementedCapability("realtime.client.set_auth_token")] public void SetAuth(string jwt) { this.AccessToken = jwt; @@ -332,6 +340,7 @@ public void SetAuth(string jwt) /// /// /// + [ImplementedCapability("realtime.client.channel", "Plain-topic form plus a typed convenience overload Channel(db, schema, table, col, value).")] public RealtimeChannel Channel(string channelName) => this.Channel(channelName, ChannelOptions.Public(this.Options, () => this.AccessToken, this.SerializerSettings)); @@ -400,6 +409,7 @@ public RealtimeChannel Channel(string database = "realtime", string schema = "pu /// Removes a channel subscription. /// /// + [ImplementedCapability("realtime.client.remove_channel")] public void Remove(RealtimeChannel channel) { if (this.subscriptions.ContainsKey(channel.Topic)) diff --git a/packages/Realtime/Realtime/ClientOptions.cs b/packages/Realtime/Realtime/ClientOptions.cs index 1dd003ed..c5462620 100644 --- a/packages/Realtime/Realtime/ClientOptions.cs +++ b/packages/Realtime/Realtime/ClientOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using Supabase.Core.Attributes; using Supabase.Postgrest.Interfaces; using Supabase.Realtime.Socket; using Supabase.Realtime.Sockets; @@ -40,11 +41,13 @@ public class ClientOptions /// /// The interval to send a heartbeat message /// + [ImplementedCapability("realtime.configuration.heartbeat_interval")] public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(20); /// /// The interval to reconnect /// + [ImplementedCapability("realtime.configuration.reconnect_backoff")] public Func ReconnectAfterInterval { get; set; } = (tries) => { var intervals = new[] { 30, 45, 60, 120 }; diff --git a/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs b/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs index be8ebcfe..4f1ea046 100644 --- a/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs +++ b/packages/Realtime/Realtime/PostgresChanges/PostgresChangesOptions.cs @@ -69,6 +69,7 @@ public enum ListenType /// [JsonPropertyName("filter")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [ImplementedCapability("realtime.subscriptions.postgres_changes_filter", "Filter is a hand-built PostgREST string (e.g. 'user_id=eq.{id}'), untied to the model.")] public string? Filter { get; set; } /// diff --git a/packages/Realtime/Realtime/RealtimeBroadcast.cs b/packages/Realtime/Realtime/RealtimeBroadcast.cs index 22e487dc..21460584 100644 --- a/packages/Realtime/Realtime/RealtimeBroadcast.cs +++ b/packages/Realtime/Realtime/RealtimeBroadcast.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text.Json; using System.Threading.Tasks; +using Supabase.Core.Attributes; using Supabase.Realtime.Broadcast; using Supabase.Realtime.Interfaces; using Supabase.Realtime.Models; @@ -20,6 +21,7 @@ namespace Supabase.Realtime; /// A common use-case is sharing a user's cursor position with other clients in an online game. /// /// A model representing expected payload. +[ImplementedCapability("realtime.subscriptions.broadcast", "Typed broadcast payloads via generics; better-typed than JS on('broadcast', ...).")] public class RealtimeBroadcast : IRealtimeBroadcast where TBroadcastModel : BaseBroadcast { private readonly RealtimeChannel channel; diff --git a/packages/Realtime/Realtime/RealtimeChannel.cs b/packages/Realtime/Realtime/RealtimeChannel.cs index c5c9515a..30fff4fc 100644 --- a/packages/Realtime/Realtime/RealtimeChannel.cs +++ b/packages/Realtime/Realtime/RealtimeChannel.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading.Tasks; using System.Timers; +using Supabase.Core.Attributes; using Supabase.Realtime.Broadcast; using Supabase.Realtime.Channel; using Supabase.Realtime.Exceptions; @@ -99,6 +100,7 @@ public class RealtimeChannel : IRealtimeChannel /// Returns the instance. /// /// + [ImplementedCapability("realtime.subscriptions.broadcast", "Typed broadcast payloads via generics; better-typed than JS on('broadcast', ...).")] public IRealtimeBroadcast? Broadcast() => this.broadcast; /// @@ -200,6 +202,9 @@ private void HandleSocketStateChanged(IRealtimeSocket _, SocketState state) /// instructs server to acknowledge that broadcast message was received /// /// + [ImplementedCapability("realtime.presence.presence_key", "Set via Channel.Register(presenceKey).")] + [ImplementedCapability("realtime.subscriptions.broadcast", "Typed broadcast payloads via generics; better-typed than JS on('broadcast', ...).")] + [ImplementedCapability("realtime.subscriptions.postgres_changes", "Model-typed path via SupabaseTable.On (blessed) plus the low-level Channel().Register(PostgresChangesOptions).")] public RealtimeBroadcast Register(bool broadcastSelf = false, bool broadcastAck = false) where TBroadcastResponse : BaseBroadcast => this.Register(new BroadcastOptions(broadcastSelf, broadcastAck)); @@ -364,6 +369,7 @@ public IRealtimeChannel OnPostgresChange(PostgresChangesHandler postgresChangeHa /// The type of event this callback should process. /// [Obsolete("Favor OnPostgresChange instead.")] + [ImplementedCapability("realtime.subscriptions.postgres_changes", "Model-typed path via SupabaseTable.On (blessed) plus the low-level Channel().Register(PostgresChangesOptions).")] public void AddPostgresChangeHandler(ListenType listenType, PostgresChangesHandler postgresChangeHandler) => this.BindPostgresChangesHandler(listenType, postgresChangeHandler); /// @@ -469,6 +475,7 @@ internal void RegisterPostgresChangesOptions(PostgresChangesOptions postgresChan /// Subscribes to the channel given supplied Options/params. /// /// + [ImplementedCapability("realtime.channel.subscribe")] public Task Subscribe(int timeoutMs = DefaultTimeout) { var tsc = new TaskCompletionSource(); @@ -533,6 +540,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) /// /// Unsubscribes from the channel. /// + [ImplementedCapability("realtime.channel.unsubscribe")] public IRealtimeChannel Unsubscribe() { this.IsSubscribed = false; @@ -556,6 +564,7 @@ public IRealtimeChannel Unsubscribe() /// /// /// + [ImplementedCapability("realtime.channel.broadcast")] public Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DefaultTimeout) { if (!this.hasJoinedOnce) @@ -580,6 +589,7 @@ public Push Push(string eventName, string? type = null, object? payload = null, /// /// /// + [ImplementedCapability("realtime.channel.broadcast")] public Task Send(ChannelEventName eventName, string? type, object payload, int timeoutMs = DefaultTimeout) { var push = this.Push(Core.Helpers.GetMappedToAttr(eventName).Mapping, type, payload, timeoutMs); diff --git a/packages/Realtime/Realtime/RealtimePresence.cs b/packages/Realtime/Realtime/RealtimePresence.cs index 016abb9b..925ab774 100644 --- a/packages/Realtime/Realtime/RealtimePresence.cs +++ b/packages/Realtime/Realtime/RealtimePresence.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text.Json; using System.Threading.Tasks; +using Supabase.Core.Attributes; using Supabase.Realtime.Channel; using Supabase.Realtime.Exceptions; using Supabase.Realtime.Interfaces; @@ -21,17 +22,20 @@ namespace Supabase.Realtime; /// If a client is suddenly disconnected (for example, they go offline), their state will be automatically removed from the shared state. /// /// A model representing expected payload. +[PartialCapability("realtime.subscriptions.subscribe_presence", "Join/leave/sync handlers exist, but the presence sync diverges from JS (see realtime.presence.presence_state).")] public class RealtimePresence : IRealtimePresence where TPresenceModel : BasePresence { /// /// The Last State of this Presence instance. /// + [PartialCapability("realtime.presence.presence_state", "Typed CurrentState/LastState exist, but the initial presence snapshot is dropped, so any client that joins after others undercounts everyone — a behavioral gap, not just a shape gap.")] public Dictionary> LastState { get; private set; } = new(); /// /// The Current State of this Presence instance. /// + [PartialCapability("realtime.presence.presence_state", "Typed CurrentState/LastState exist, but the initial presence snapshot is dropped, so any client that joins after others undercounts everyone — a behavioral gap, not just a shape gap.")] public Dictionary> CurrentState { get; } = new(); private PresenceOptions options; @@ -152,6 +156,7 @@ public void TriggerDiff(SocketResponse response) /// /// /// + [ImplementedCapability("realtime.presence.track")] public Task Track(object? payload, int timeoutMs = DefaultTimeout) { var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; @@ -179,6 +184,7 @@ public Task Track(object? payload, int timeoutMs = DefaultTimeout) /// /// Untracks an event. /// + [ImplementedCapability("realtime.presence.untrack")] public Task Untrack() { var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; diff --git a/packages/Storage/Storage/Client.cs b/packages/Storage/Storage/Client.cs index 66f0df3e..257b18c7 100644 --- a/packages/Storage/Storage/Client.cs +++ b/packages/Storage/Storage/Client.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Supabase.Core.Attributes; using Supabase.Storage.Interfaces; namespace Supabase.Storage; @@ -16,6 +17,7 @@ public Client(string url, ClientOptions? options, Dictionary? he /// /// Bucket Id /// + [ImplementedCapability("storage.file_buckets.access_bucket")] public IStorageFileApi From(string id) => new StorageFileApi(this.Url, id, this.Options, this.Headers, this.requestClient, this.uploadClient, this.downloadClient); } diff --git a/packages/Storage/Storage/DestinationOptions.cs b/packages/Storage/Storage/DestinationOptions.cs index 0f749c50..6c6bbc10 100644 --- a/packages/Storage/Storage/DestinationOptions.cs +++ b/packages/Storage/Storage/DestinationOptions.cs @@ -1,3 +1,4 @@ +using Supabase.Core.Attributes; namespace Supabase.Storage; /// @@ -8,5 +9,7 @@ public class DestinationOptions /// /// Gets or sets the name of the destination bucket in the context of Supabase Storage. /// + [ImplementedCapability("storage.file_buckets.copy_cross_bucket")] + [ImplementedCapability("storage.file_buckets.move_cross_bucket")] public string? DestinationBucket { get; set; } -} \ No newline at end of file +} diff --git a/packages/Storage/Storage/FileOptions.cs b/packages/Storage/Storage/FileOptions.cs index 4b353102..bb13483b 100644 --- a/packages/Storage/Storage/FileOptions.cs +++ b/packages/Storage/Storage/FileOptions.cs @@ -1,14 +1,17 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Supabase.Core.Attributes; namespace Supabase.Storage; public class FileOptions { [JsonPropertyName("cacheControl")] + [ImplementedCapability("storage.file_buckets.upload_with_metadata")] public string CacheControl { get; set; } = "3600"; [JsonPropertyName("contentType")] + [ImplementedCapability("storage.file_buckets.upload_with_metadata")] public string ContentType { get; set; } = "text/plain;charset=UTF-8"; [JsonPropertyName("upsert")] @@ -18,6 +21,7 @@ public class FileOptions public string? Duplex { get; set; } [JsonPropertyName("metadata")] + [ImplementedCapability("storage.file_buckets.upload_with_metadata")] public Dictionary? Metadata { get; set; } [JsonPropertyName("headers")] diff --git a/packages/Storage/Storage/StorageBucketApi.cs b/packages/Storage/Storage/StorageBucketApi.cs index 7c56cd2b..3a0ac544 100644 --- a/packages/Storage/Storage/StorageBucketApi.cs +++ b/packages/Storage/Storage/StorageBucketApi.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Supabase.Core; +using Supabase.Core.Attributes; using Supabase.Core.Extensions; using Supabase.Storage.Exceptions; using Supabase.Storage.Extensions; @@ -73,6 +74,7 @@ protected StorageBucketApi(string url, Dictionary? headers = nul /// Retrieves the details of all Storage buckets within an existing product. /// /// + [ImplementedCapability("storage.file_buckets.list_file_buckets")] public Task?> ListBuckets() => Helpers.MakeRequestAsync>(this.requestClient, this.Options.Retry, HttpMethod.Get, $"{this.Url}/bucket", null, this.Headers); @@ -81,6 +83,7 @@ protected StorageBucketApi(string url, Dictionary? headers = nul /// /// /// + [ImplementedCapability("storage.file_buckets.get_bucket")] public async Task GetBucket(string id) { try @@ -103,6 +106,7 @@ protected StorageBucketApi(string url, Dictionary? headers = nul /// /// /// Bucket Id + [ImplementedCapability("storage.file_buckets.create_file_bucket")] public async Task CreateBucket(string id, BucketUpsertOptions? options = null) { options ??= new BucketUpsertOptions(); @@ -127,6 +131,7 @@ public async Task CreateBucket(string id, BucketUpsertOptions? options = /// /// /// + [ImplementedCapability("storage.file_buckets.update_bucket")] public async Task UpdateBucket(string id, BucketUpsertOptions? options = null) { options ??= new BucketUpsertOptions(); @@ -149,6 +154,7 @@ public async Task CreateBucket(string id, BucketUpsertOptions? options = /// /// /// + [ImplementedCapability("storage.file_buckets.empty_bucket")] public Task EmptyBucket(string id) => Helpers.MakeRequestAsync(this.requestClient, this.Options.Retry, HttpMethod.Post, $"{this.Url}/bucket/{id}/empty", null, this.Headers); @@ -158,6 +164,7 @@ public async Task CreateBucket(string id, BucketUpsertOptions? options = /// /// /// + [ImplementedCapability("storage.file_buckets.delete_file_bucket")] public Task DeleteBucket(string id) => Helpers.MakeRequestAsync(this.requestClient, this.Options.Retry, HttpMethod.Delete, $"{this.Url}/bucket/{id}", null, this.Headers); diff --git a/packages/Storage/Storage/StorageFileApi.cs b/packages/Storage/Storage/StorageFileApi.cs index 439f2536..fc44dea6 100644 --- a/packages/Storage/Storage/StorageFileApi.cs +++ b/packages/Storage/Storage/StorageFileApi.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using System.Web; using BirdMessenger.Collections; +using Supabase.Core.Attributes; using Supabase.Storage.Exceptions; using Supabase.Storage.Extensions; using Supabase.Storage.Interfaces; @@ -139,6 +140,7 @@ public StorageFileApi( /// /// /// + [ImplementedCapability("storage.file_buckets.get_public_url", "Transform + download options present. Footgun: passing an empty TransformOptions() routes to the image-render endpoint instead of the plain object URL.")] public string GetPublicUrl( string path, TransformOptions? transformOptions, @@ -173,6 +175,7 @@ public string GetPublicUrl( /// /// /// + [ImplementedCapability("storage.file_buckets.create_signed_url")] public async Task CreateSignedUrl( string path, int expiresIn, @@ -222,6 +225,7 @@ public async Task CreateSignedUrl( /// The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute. /// /// + [ImplementedCapability("storage.file_buckets.create_signed_urls")] public async Task?> CreateSignedUrls( List paths, int expiresIn, @@ -266,6 +270,7 @@ public async Task CreateSignedUrl( /// /// /// + [ImplementedCapability("storage.file_buckets.list_files")] public async Task?> List(string path = "", SearchOptions? options = null) { options ??= new SearchOptions(); @@ -293,6 +298,7 @@ public async Task CreateSignedUrl( /// /// /// + [ImplementedCapability("storage.file_buckets.file_info")] public async Task Info(string path) { var response = await Helpers.MakeRequestAsync( @@ -323,6 +329,7 @@ public async Task CreateSignedUrl( /// /// /// + [ImplementedCapability("storage.file_buckets.upload", "File-path and byte[] overloads; onProgress + FileOptions (content-type, cache-control, upsert).")] public async Task Upload( string localFilePath, string supabasePath, @@ -401,6 +408,7 @@ public async Task Upload( /// /// /// + [ImplementedCapability("storage.file_buckets.upload_with_signed_url")] public async Task UploadToSignedUrl( string localFilePath, UploadSignedUrl signedUrl, @@ -502,6 +510,7 @@ await this.uploadClient.UploadBytesAsync( /// HTTP headers. /// /// + [ImplementedCapability("storage.file_buckets.update_file")] public Task Update( string localFilePath, string supabasePath, @@ -541,6 +550,7 @@ public Task Update( /// An optional event handler for tracking and reporting upload progress as a percentage. /// Cancellation token to observe while waiting for the task to complete. /// Returns a task that resolves to a string representing the URL or path of the uploaded file in the storage. + [ImplementedCapability("storage.file_buckets.upload", "File-path and byte[] overloads; onProgress + FileOptions (content-type, cache-control, upsert).")] public Task UploadOrResume( string localPath, string fileName, @@ -588,6 +598,8 @@ public Task UploadOrResume( /// The target file path, including the new file name (e.g., `folder/image-copy.png`). /// Optional parameters for specifying the destination bucket and other settings. /// Returns a boolean value indicating whether the operation was successful. + [ImplementedCapability("storage.file_buckets.move")] + [ImplementedCapability("storage.file_buckets.move_cross_bucket")] public async Task Move( string fromPath, string toPath, @@ -619,6 +631,8 @@ await Helpers.MakeRequestAsync( /// The destination path for the copied file/object. /// Optional parameters such as the destination bucket. /// True if the copy operation was successful. + [ImplementedCapability("storage.file_buckets.copy")] + [ImplementedCapability("storage.file_buckets.copy_cross_bucket")] public async Task Copy( string fromPath, string toPath, @@ -656,6 +670,8 @@ await Helpers.MakeRequestAsync( /// /// /// + [ImplementedCapability("storage.file_buckets.download")] + [ImplementedCapability("storage.file_buckets.download_with_transform", "Image transforms applied inline via the TransformOptions argument on Download/DownloadPublicFile.")] public Task Download( string supabasePath, string localPath, @@ -737,6 +753,7 @@ public Task Download(string supabasePath, EventHandler? onProgres /// /// /// + [ImplementedCapability("storage.file_buckets.download")] public Task DownloadPublicFile( string supabasePath, string localPath, @@ -776,6 +793,7 @@ public Task DownloadPublicFile( /// /// A path to delete, for example, `folder/image.png`. /// + [ImplementedCapability("storage.file_buckets.remove")] public async Task Remove(string path) { var result = await this.Remove([path]); @@ -807,6 +825,7 @@ public Task DownloadPublicFile( /// /// The file path, including the current file name. For example, `folder/image.png`. /// + [ImplementedCapability("storage.file_buckets.create_signed_upload_url")] public async Task CreateUploadSignedUrl(string supabasePath) { var path = this.GetFinalPath(supabasePath); diff --git a/packages/Supabase/Supabase/Client.cs b/packages/Supabase/Supabase/Client.cs index 374a3898..6d3e0c97 100644 --- a/packages/Supabase/Supabase/Client.cs +++ b/packages/Supabase/Supabase/Client.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Supabase.Core; +using Supabase.Core.Attributes; using Supabase.Functions.Interfaces; using Supabase.Gotrue; using Supabase.Gotrue.Interfaces; @@ -273,10 +274,12 @@ private void Auth_StateChanged(object sender, AuthState e) /// /// /// + [ImplementedCapability("database.query.from_table")] public ISupabaseTable From() where TModel : BaseModel, new() => new SupabaseTable(this.Postgrest, this.Realtime); /// + [PartialCapability("database.query.rpc", "Invocation works (typed + untyped) but Rpc is terminal — no post-filtering/transform chaining on the result like JS rpc().")] public Task Rpc(string procedureName, object? parameters) => this.postgrest.Rpc(procedureName, parameters); /// @@ -290,6 +293,7 @@ private void Auth_StateChanged(object sender, AuthState e) /// stand-in for an absent session — only the apikey header carries it. Used by the Edge /// Functions path, whose gateway rejects opaque keys parsed as a JWT. /// + [ImplementedCapability("client.authentication_integration.cross_client_token_sync", "Umbrella Client listens to auth state changes and injects the current JWT into every sub-client via a shared GetAuthHeaders callback.")] internal Dictionary GetAuthHeaders(bool omitApiKeyAsBearer = false) { var headers = CaseInsensitiveHeaders(); diff --git a/packages/Supabase/Supabase/SupabaseOptions.cs b/packages/Supabase/Supabase/SupabaseOptions.cs index 42dfb884..97ce9f30 100644 --- a/packages/Supabase/Supabase/SupabaseOptions.cs +++ b/packages/Supabase/Supabase/SupabaseOptions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using Supabase.Core.Attributes; using Supabase.Gotrue; using Supabase.Gotrue.Interfaces; using Supabase.Realtime.Sockets; @@ -32,11 +33,14 @@ public class SupabaseOptions /// /// **By default these do nothing for persistence.** /// + [ImplementedCapability("client.session_management.custom_storage")] + [PartialCapability("client.session_management.persist_session", "No boolean persist toggle; in-memory-only behavior is achievable only by supplying a no-op IGotrueSessionPersistence handler.")] public IGotrueSessionPersistence SessionHandler { get; set; } = new DefaultSupabaseSessionHandler(); /// /// Allows developer to specify options that will be passed to all child Supabase clients. /// + [ImplementedCapability("client.request_configuration.global_headers")] public Dictionary Headers = new(); /// diff --git a/packages/Supabase/Supabase/SupabaseTable.cs b/packages/Supabase/Supabase/SupabaseTable.cs index 4c938727..f5b943c0 100644 --- a/packages/Supabase/Supabase/SupabaseTable.cs +++ b/packages/Supabase/Supabase/SupabaseTable.cs @@ -1,68 +1,69 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; +using Supabase.Core.Attributes; +using Supabase.Interfaces; using Supabase.Postgrest; using Supabase.Postgrest.Interfaces; using Supabase.Postgrest.Models; -using Supabase.Interfaces; using Supabase.Realtime; using Supabase.Realtime.Interfaces; using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; -namespace Supabase +namespace Supabase; + +/// +/// A Supabase wrapper for a Postgrest Table. +/// +/// Model that implements +public class SupabaseTable : Table, ISupabaseTable + where TModel : BaseModel, new() { + private RealtimeChannel? channel; + private readonly IPostgrestClient postgrestClient; + private readonly IRealtimeClient realtimeClient; + private readonly string schema; + /// - /// A Supabase wrapper for a Postgrest Table. + /// A Supabase wrapper for a Postgrest table. /// - /// Model that implements - public class SupabaseTable : Table, ISupabaseTable - where TModel : BaseModel, new() + /// + /// + /// + public SupabaseTable(IPostgrestClient postgrestClient, + IRealtimeClient realtimeClient, string schema = "public") : base( + postgrestClient.BaseUrl, Postgrest.Client.SerializerSettings(postgrestClient.Options), + postgrestClient.Options) { - private RealtimeChannel? _channel; - private readonly IPostgrestClient _postgrestClient; - private readonly IRealtimeClient _realtimeClient; - private readonly string _schema; + this.postgrestClient = postgrestClient; + this.realtimeClient = realtimeClient; + this.schema = schema; + this.GetHeaders = postgrestClient.GetHeaders; + } - /// - /// A Supabase wrapper for a Postgrest table. - /// - /// - /// - /// - public SupabaseTable(IPostgrestClient postgrestClient, - IRealtimeClient realtimeClient, string schema = "public") : base( - postgrestClient.BaseUrl, Postgrest.Client.SerializerSettings(postgrestClient.Options), - postgrestClient.Options) + /// + [ImplementedCapability("realtime.subscriptions.postgres_changes", "Model-typed path via SupabaseTable.On (blessed) plus the low-level Channel().Register(PostgresChangesOptions).")] + public async Task On(ListenType listenType, IRealtimeChannel.PostgresChangesHandler handler) + { + if (this.channel == null) { - _postgrestClient = postgrestClient; - _realtimeClient = realtimeClient; - _schema = schema; - GetHeaders = postgrestClient.GetHeaders; - } + var parameters = new Dictionary(); - /// - public async Task On(ListenType listenType, IRealtimeChannel.PostgresChangesHandler handler) - { - if (_channel == null) + // In regard to: https://github.com/supabase/supabase-js/pull/270 + var headers = this.postgrestClient?.GetHeaders?.Invoke(); + if (headers != null && headers.TryGetValue("Authorization", out var header)) { - var parameters = new Dictionary(); - - // In regard to: https://github.com/supabase/supabase-js/pull/270 - var headers = _postgrestClient?.GetHeaders?.Invoke(); - if (headers != null && headers.TryGetValue("Authorization", out var header)) - { - parameters.Add("user_token", header.Split(' ')[1]); - } - - _channel = _realtimeClient.Channel("realtime", _schema, TableName, parameters: parameters); + parameters.Add("user_token", header.Split(' ')[1]); } - if (_realtimeClient.Socket == null || !_realtimeClient.Socket.IsConnected) - await _realtimeClient.ConnectAsync(); + this.channel = this.realtimeClient.Channel("realtime", this.schema, this.TableName, parameters: parameters); + } - _channel.AddPostgresChangeHandler(listenType, handler); + if (this.realtimeClient.Socket == null || !this.realtimeClient.Socket.IsConnected) + await this.realtimeClient.ConnectAsync(); - await _channel.Subscribe(); - return _channel; - } + this.channel.AddPostgresChangeHandler(listenType, handler); + + await this.channel.Subscribe(); + return this.channel; } -} \ No newline at end of file +} From 0802bce286c945700096dacb761106edc51681fd Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 16 Oct 2026 14:06:06 +0200 Subject: [PATCH 4/5] chore(capability-matrix): exclude Attributes from coverage (no behavior, marker attribute only) --- packages/Core/Core/Attributes/CapabilityAttributes.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/Core/Core/Attributes/CapabilityAttributes.cs b/packages/Core/Core/Attributes/CapabilityAttributes.cs index 4ce39e37..08de1f70 100644 --- a/packages/Core/Core/Attributes/CapabilityAttributes.cs +++ b/packages/Core/Core/Attributes/CapabilityAttributes.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace Supabase.Core.Attributes; @@ -13,6 +14,7 @@ namespace Supabase.Core.Attributes; /// or /// . /// +[ExcludeFromCodeCoverage] internal abstract class CapabilityAttribute : Attribute { /// @@ -44,6 +46,7 @@ protected CapabilityAttribute(string key, string? note) AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)] +[ExcludeFromCodeCoverage] internal sealed class ImplementedCapabilityAttribute : CapabilityAttribute { /// The capability-matrix feature id this member implements. @@ -66,6 +69,7 @@ public ImplementedCapabilityAttribute(string key, string? note = null) AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)] +[ExcludeFromCodeCoverage] internal sealed class PartialCapabilityAttribute : CapabilityAttribute { /// The capability-matrix feature id this member partially implements. From cda61a1833179783343e0828d1b7e30a658769d2 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 16 Oct 2026 14:06:29 +0200 Subject: [PATCH 5/5] refactor(capability-matrix): rename CliAndParityTests.cs into CliTests.cs --- .../{CliAndParityTests.cs => CliTests.cs} | 37 ------------------- 1 file changed, 37 deletions(-) rename packages/CapabilityMatrix/CapabilityMatrix.Tests/{CliAndParityTests.cs => CliTests.cs} (67%) diff --git a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliTests.cs similarity index 67% rename from packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs rename to packages/CapabilityMatrix/CapabilityMatrix.Tests/CliTests.cs index 3cba3c48..a6c18912 100644 --- a/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliAndParityTests.cs +++ b/packages/CapabilityMatrix/CapabilityMatrix.Tests/CliTests.cs @@ -82,40 +82,3 @@ public void SignUp() { } } } } - -[TestClass] -public class RepositoryParityTests -{ - private static string RepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - if (File.Exists(Path.Combine(directory.FullName, "sdk-compliance.yaml")) && - Directory.Exists(Path.Combine(directory.FullName, "packages"))) - { - return directory.FullName; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException("Could not locate the repository root from the test assembly."); - } - - [TestMethod] - public void CommittedComplianceFileMatchesTheCapabilityAnnotations() - { - var root = RepositoryRoot(); - var workspace = new FileWorkspace(root, string.Empty, Path.Combine(root, "sdk-compliance.yaml"), string.Empty); - - var result = ComplianceModel.Build(SymbolWalker.Extract(workspace.ReadSources())); - result.Errors.Should().BeEmpty("the annotated tree must aggregate without conflicts"); - - var committed = ComplianceYaml.Parse(workspace.ReadComplianceText()); - var generated = ComplianceYaml.FromEntries(result.Entries); - - ComplianceComparer.Diff(committed, generated).Should().BeEmpty( - "sdk-compliance.yaml must stay in sync with the in-code capability annotations"); - } -}