diff --git a/contracts/cove.openapi.json b/contracts/cove.openapi.json index 265e4e89..aabf9948 100644 --- a/contracts/cove.openapi.json +++ b/contracts/cove.openapi.json @@ -33169,6 +33169,7 @@ }, "SystemStatus": { "required": [ + "contractVersion", "databasePath", "version" ], @@ -33177,6 +33178,9 @@ "version": { "type": "string" }, + "contractVersion": { + "type": "string" + }, "appDir": { "type": "string" }, diff --git a/docs/contributing/versioning.md b/docs/contributing/versioning.md new file mode 100644 index 00000000..a321dd38 --- /dev/null +++ b/docs/contributing/versioning.md @@ -0,0 +1,86 @@ +# Extension versioning and deprecation policy + +This document is the authoritative reference for how Cove versions the contracts that extensions +depend on, and how compatibility is negotiated and eventually deprecated. It applies to extension +authors and to anyone changing the host-facing contracts. The goal is simple: an extension should +know, from versions alone, whether it will load and run against a given Cove release. + +## The two version axes + +Cove exposes two independent contracts to extensions, and they are versioned separately. + +- **Host contract version.** The semver `major.minor.patch` value reported by `CoveVersion` + (baked into the build from the release tag). This is the single source of truth for backend + compatibility. An extension declares the oldest host it supports through its `min-host-version` + requirement, and Cove reports the current value on `GET /api/system/status` (the + `contractVersion` field) so tooling can compare the two without guessing. +- **Frontend runtime contract version.** The `v1` / `v2` string that identifies the shape of the + browser runtime import map (the set of shared modules the host provides to extension bundles, + such as the UI framework and data-fetching client). A frontend bundle targets one runtime + contract version; the host serves the import map for the versions it still supports. + +A backend-only extension pins only the host contract version. An extension that ships a frontend +bundle pins both: the host contract version for its server-side code and the runtime contract +version for its bundle. + +## `@cove/types` versioning + +`@cove/types` is generated only — it is produced from the host's DTOs and enums and is never +hand-edited. It tracks the host contract version: a host release `x.y.z` publishes +`@cove/types@x.y.*`, so choosing the types package for a host version is unambiguous. + +- **Additive changes are non-breaking.** A new optional DTO field or a new enum member is a minor + change; existing extensions keep compiling and running. +- **Breaking changes bump the version accordingly.** Removing or renaming a field, changing a + field's type, or removing an enum member is a breaking change and is reflected in the semver + bump of both the host contract version and the matching `@cove/types` release. + +Extensions should depend on the `@cove/types` line that matches the oldest host they support, and +rely on additive-only changes within that line. + +## `@cove/extension-sdk` versioning + +`@cove/extension-sdk` is the author-facing SDK. Its public surface is gated in continuous +integration (the exported API is extracted and compared, so an unintended change to the public +surface fails the build). The SDK's major and minor versions track the host contract version, and +each SDK release depends on the matching `@cove/types` release. + +- The SDK never widens its public API silently; any addition or removal is an intentional, + reviewed version change. +- Pinning an SDK version therefore pins a known host-contract baseline and a known `@cove/types` + baseline together. + +## Support window and deprecation + +Cove supports the **current and the immediately previous** frontend runtime contract version. When +a runtime contract version is scheduled for removal, it first enters a deprecation window: + +- The version to be removed is announced as deprecated for at least **one minor release** before it + is dropped. During that window it continues to load. +- While a version is deprecated (that is, it is the previous supported version, `N-1`), the host + still serves it, and negotiation emits a **warning** so authors have time to migrate. +- Once a version falls below the minimum supported runtime contract version, negotiation + **rejects** bundles that target it; they no longer load. + +The host contract version follows ordinary semver expectations: additive backend changes are +minor, breaking backend changes are major, and an extension's `min-host-version` is honored against +the reported contract version. + +## How negotiation surfaces to authors + +Compatibility is checked at two points, and both quote the host version and the required floor so +the fix is obvious: + +- **Install time.** Installing an extension whose `min-host-version` is above the host's contract + version is refused with a clear error stating the required minimum and the current host version. +- **Load time.** On a released host, an installed extension whose `min-host-version` exceeds the + host contract version is disabled at startup with an actionable message naming both the host + version and the required floor, rather than being allowed to initialize in an unsupported state. + On a development build of the host, the same mismatch is reported as a warning only and the + extension still loads, so work against a not-yet-released host is never blocked. An unparseable + requirement is treated as unsatisfied and never crashes the host. + +In short: match your `@cove/types` and `@cove/extension-sdk` versions to the oldest host you intend +to support, set `min-host-version` to that host's contract version, and target a currently +supported frontend runtime contract version. Negotiation will then either load your extension or +tell you exactly which version to change. diff --git a/sdk/frontend/dist/extension-sdk.d.ts b/sdk/frontend/dist/extension-sdk.d.ts index 0b3b167b..c0488b91 100644 --- a/sdk/frontend/dist/extension-sdk.d.ts +++ b/sdk/frontend/dist/extension-sdk.d.ts @@ -3762,6 +3762,7 @@ declare interface components { /** @default false */ authEnabled?: boolean; configFile?: string; + contractVersion: string; databasePath: string; /** @default false */ migrationRequired?: boolean; diff --git a/sdk/types/openapi.ts b/sdk/types/openapi.ts index 7f5eb663..b374f055 100644 --- a/sdk/types/openapi.ts +++ b/sdk/types/openapi.ts @@ -20497,6 +20497,7 @@ export interface components { /** @default false */ authEnabled?: boolean; configFile?: string; + contractVersion: string; databasePath: string; /** @default false */ migrationRequired?: boolean; diff --git a/src/Cove.Api/Controllers/SystemController.cs b/src/Cove.Api/Controllers/SystemController.cs index c549b105..97bda80d 100644 --- a/src/Cove.Api/Controllers/SystemController.cs +++ b/src/Cove.Api/Controllers/SystemController.cs @@ -75,6 +75,7 @@ public async Task> GetStatus() return Ok(new SystemStatusDto( Version: Cove.Core.Common.CoveVersion.Display, + ContractVersion: Cove.Core.Common.CoveVersion.Numeric, AppDir: canSeeSensitivePaths ? AppContext.BaseDirectory : null, ConfigFile: canSeeSensitivePaths ? configService.ConfigPath : null, DatabasePath: "PostgreSQL", diff --git a/src/Cove.Api/Cove.Api.csproj b/src/Cove.Api/Cove.Api.csproj index e7682c68..ca6cc58e 100644 --- a/src/Cove.Api/Cove.Api.csproj +++ b/src/Cove.Api/Cove.Api.csproj @@ -111,6 +111,12 @@ + + diff --git a/src/Cove.Api/Program.cs b/src/Cove.Api/Program.cs index e019fb3e..d9e8a77d 100644 --- a/src/Cove.Api/Program.cs +++ b/src/Cove.Api/Program.cs @@ -13,6 +13,7 @@ using Serilog; using Serilog.Core; using Serilog.Events; +using Cove.Api.HostServices; using Cove.Api.Hubs; using Cove.Api.Services; using Cove.Core.Common; @@ -358,13 +359,14 @@ LIMIT 1 AutomaticDecompression = System.Net.DecompressionMethods.All, }); builder.Services.AddHttpClient(); - // Runtime extensions can bind only Cove.Core types, so surface the Cove.Api metadata-server client - // through its Cove.Core interface (as IReferencePerformerImporter below does). - builder.Services.AddTransient(sp => sp.GetRequiredService()); // Lets extensions (AI.Faces) enrich a newly-created performer from a configured metadata server // when a reference/SAIE match is accepted. Singleton so it is shared into extension containers; it // opens its own scope per call. - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // Surface the host services above to extensions through their Cove.Core interfaces. Runtime + // extensions can bind only Cove.Core types, so each [ExposeToExtensions]-marked concrete is + // forwarded to its interface here. + builder.Services.AddCoveHostServices(); // Extension system var extensionsDataDir = CoveDefaultPaths.GetDataSubdirectory("extensions"); @@ -374,7 +376,8 @@ LIMIT 1 { Configuration = builder.Configuration, DataDirectory = extensionsDataDir, - CoveVersion = coveVersion + CoveVersion = coveVersion, + CoveVersionDisplay = Cove.Core.Common.CoveVersion.Display }; var extensionManager = new ExtensionManager(extensionContext); // Discover .NET plugin DLLs from extensions directory diff --git a/src/Cove.Api/Services/MetadataServerService.cs b/src/Cove.Api/Services/MetadataServerService.cs index 4bb282a5..b5ac8cef 100644 --- a/src/Cove.Api/Services/MetadataServerService.cs +++ b/src/Cove.Api/Services/MetadataServerService.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; +using Cove.Core.Contracts; using Cove.Core.DTOs; using Cove.Core.Entities; using Cove.Core.Enums; @@ -13,6 +14,7 @@ namespace Cove.Api.Services; +[ExposeToExtensions(typeof(IMetadataServerService))] public class MetadataServerService : IMetadataServerService { private static readonly Regex LeadingVideoIndexRegex = new(@"^\s*(?:video\s+)?(?:\[\s*\d+\s*\]|\(\s*\d+\s*\)|\d+)\s*(?:[-–—:._)\]]\s*)+", RegexOptions.Compiled | RegexOptions.IgnoreCase); diff --git a/src/Cove.Api/Services/ReferencePerformerImporter.cs b/src/Cove.Api/Services/ReferencePerformerImporter.cs index eb455fcb..fc8742d1 100644 --- a/src/Cove.Api/Services/ReferencePerformerImporter.cs +++ b/src/Cove.Api/Services/ReferencePerformerImporter.cs @@ -1,3 +1,4 @@ +using Cove.Core.Contracts; using Cove.Core.Interfaces; using Cove.Core.Entities; using Cove.Data; @@ -15,6 +16,7 @@ namespace Cove.Api.Services; /// endpoint, network error, deleted remote performer) is swallowed and reported as false so the /// caller keeps the performer with just its recorded remote id. /// +[ExposeToExtensions(typeof(IReferencePerformerImporter), Lifetime = ServiceForwardingLifetime.Singleton)] public sealed class ReferencePerformerImporter(IServiceScopeFactory scopeFactory, ILogger? logger = null) : IReferencePerformerImporter { diff --git a/src/Cove.Core/Contracts/ExposeToExtensionsAttribute.cs b/src/Cove.Core/Contracts/ExposeToExtensionsAttribute.cs new file mode 100644 index 00000000..425e6cbb --- /dev/null +++ b/src/Cove.Core/Contracts/ExposeToExtensionsAttribute.cs @@ -0,0 +1,37 @@ +namespace Cove.Core.Contracts; + +/// +/// The service lifetime used for the forwarding registration of a host service exposed to extensions. +/// Declared independently of any dependency-injection package so the contract stays in the +/// infrastructure-free core assembly. +/// +public enum ServiceForwardingLifetime +{ + /// A new instance is provided for every request. + Transient, + + /// A single instance is provided per scope. + Scoped, + + /// A single instance is shared for the lifetime of the application. + Singleton, +} + +/// +/// Marks a host service implementation as the backing type for an extension-facing interface. The build +/// emits a forwarding service registration for the marked type and fails compilation when the marked type +/// does not implement . Applying this attribute replaces hand-written forwarding +/// registrations: the interface remains the single type extensions bind against, and the concrete type +/// stays private to the host. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class ExposeToExtensionsAttribute(Type interfaceType) : Attribute +{ + /// The extension-facing interface the marked type is exposed as. + public Type InterfaceType { get; } = interfaceType; + + /// + /// The lifetime of the forwarding registration. Defaults to . + /// + public ServiceForwardingLifetime Lifetime { get; init; } = ServiceForwardingLifetime.Transient; +} diff --git a/src/Cove.Core/DTOs/DTOs.cs b/src/Cove.Core/DTOs/DTOs.cs index 320c22cd..769893fc 100644 --- a/src/Cove.Core/DTOs/DTOs.cs +++ b/src/Cove.Core/DTOs/DTOs.cs @@ -1213,6 +1213,11 @@ public record ApiKeyResponse(string ApiKey); // ===== CONFIG DTOs ===== public record SystemStatusDto( string Version, + // Semver-clean host contract version (major.minor.patch, no prerelease suffix). This is the + // value extensions pin their minimum-host-version requirement against; the SPA/SDK read it to + // negotiate compatibility. Distinct from Version, which is the full display string shown on the + // About / Runtime Status pages. Both derive from the same single version source. + string ContractVersion, string? AppDir, string? ConfigFile, string DatabasePath, diff --git a/src/Cove.Plugins/Cove.Plugins.csproj b/src/Cove.Plugins/Cove.Plugins.csproj index 8fade456..c7f5be40 100644 --- a/src/Cove.Plugins/Cove.Plugins.csproj +++ b/src/Cove.Plugins/Cove.Plugins.csproj @@ -12,6 +12,10 @@ + + + + net10.0 enable diff --git a/src/Cove.Plugins/ExtensionManager.cs b/src/Cove.Plugins/ExtensionManager.cs index 2d93acfd..2d6b5ff0 100644 --- a/src/Cove.Plugins/ExtensionManager.cs +++ b/src/Cove.Plugins/ExtensionManager.cs @@ -26,6 +26,11 @@ public class ExtensionManager private readonly Dictionary _installations = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _initializedExtensions = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _startupDisabledExtensions = new(StringComparer.OrdinalIgnoreCase); + // Extensions skipped this boot because the host does not meet their minimum contract version. + // Kept in memory only and re-evaluated on every boot, so an extension initializes normally once + // the host is upgraded to a compatible version. Distinct from a deliberate user disable, which + // is persisted via the installation record's Enabled flag. + private readonly HashSet _versionSkippedExtensions = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _extensionFailureReasons = new(StringComparer.OrdinalIgnoreCase); private IServiceScopeFactory? _scopeFactory; private IServiceProvider? _rootServices; @@ -271,7 +276,9 @@ public List ValidateDependencies() // Check core version requirement if (ext.MinCoveVersion != null && !SemverSatisfies(_context.CoveVersion, $">={ext.MinCoveVersion}")) { - problems.Add(new DependencyProblem(ext.Id, null, $"Requires Cove >={ext.MinCoveVersion} but running {_context.CoveVersion}")); + problems.Add(new DependencyProblem(ext.Id, null, + $"Extension '{ext.Id}' requires Cove >={ext.MinCoveVersion} but this host is {_context.CoveVersion}. " + + $"Update Cove to {ext.MinCoveVersion} or newer, or install a build of the extension compatible with host {_context.CoveVersion}.")); } // Check extension dependencies @@ -290,6 +297,81 @@ public List ValidateDependencies() return problems; } + /// + /// Validates dependencies and enforces host contract-version compatibility. + /// A core-version problem (an extension whose minimum host version exceeds this host's + /// contract version) disables the offending extension on a released build so it never + /// initializes; on a development build it is reported as a warning only. Other dependency + /// problems (missing or mismatched sibling extensions) remain warnings. + /// + internal Task EnforceDependencyCompatibilityAsync(CancellationToken ct = default) + { + var problems = ValidateDependencies(); + var isDevBuild = IsDevelopmentBuild(); + + foreach (var problem in problems) + { + // Core-version problems are emitted with a null DependencyId. + var isCoreVersionProblem = problem.DependencyId == null; + + if (isCoreVersionProblem && !isDevBuild) + { + // In-memory skip only, re-evaluated every boot. The disable is deliberately not + // persisted: writing enabled=false here would leave the extension switched off even + // after the host is upgraded to a compatible version, and would be indistinguishable + // from a deliberate user disable on the next boot's "DB wins" reload. + DisableExtensionForVersionIncompatibility(problem.ExtensionId, problem.Message); + } + else if (isCoreVersionProblem) + { + _logger?.LogWarning( + "Extension compatibility warning (development build, not enforced): {Problem}", + problem.Message); + } + else + { + _logger?.LogWarning("Extension dependency issue: {Problem}", problem.Message); + } + } + + return Task.CompletedTask; + } + + /// + /// True when this host is a non-release build. Detected authoritatively by the "-dev" + /// prerelease suffix on the display version, plus the no-git fallback where the numeric + /// contract version is "0.0.0". Compatibility problems are not enforced on such builds so + /// local development against a not-yet-released host is never blocked. + /// + private bool IsDevelopmentBuild() + { + if (string.Equals(_context.CoveVersion, "0.0.0", StringComparison.Ordinal)) + return true; + + var display = _context.CoveVersionDisplay; + return !string.IsNullOrWhiteSpace(display) + && display.Contains("-dev", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Skips an extension that is incompatible with the host contract version for the current boot + /// only. Records the id in an in-memory set consulted by the IsEnabled gate rather than flipping + /// (and persisting) the installation record's Enabled flag, so the compatibility floor is + /// re-evaluated on every boot: once the host is upgraded to satisfy the floor the extension + /// initializes normally, with no operator action required. A deliberate user disable, by + /// contrast, mutates and persists the Enabled flag and is unaffected by this path. + /// + private void DisableExtensionForVersionIncompatibility(string extensionId, string message) + { + if (string.IsNullOrWhiteSpace(extensionId)) + return; + + _versionSkippedExtensions.Add(extensionId); + _initializedExtensions.Remove(extensionId); + _extensionFailureReasons[extensionId] = message; + _logger?.LogError("Extension {Id} disabled: {Message}", extensionId, message); + } + /// /// Returns extensions in topological order (dependencies first). /// Extensions with unmet dependencies are excluded and logged. @@ -714,10 +796,8 @@ public async Task InitializeAllAsync(IServiceProvider services, CancellationToke foreach (var extensionId in _startupDisabledExtensions) await PersistInstallationStateAsync(extensionId, ct); - // Validate dependencies - var problems = ValidateDependencies(); - foreach (var p in problems) - _logger?.LogWarning("Extension dependency issue: {Problem}", p.Message); + // Validate dependencies and enforce host contract-version compatibility + await EnforceDependencyCompatibilityAsync(ct); // Wire stateful extensions with their DB-backed stores WireStatefulExtensions(services); @@ -1118,7 +1198,14 @@ void AddTutorialTopics(IEnumerable topics, string? extensionId) // ======================================================================== /// Check if an extension is enabled. - public bool IsEnabled(string id) => _installations.TryGetValue(id, out var inst) ? inst.Enabled : true; + public bool IsEnabled(string id) + { + // A version-incompatibility skip suppresses the extension for this boot only, without + // altering the persisted Enabled flag, so it re-activates once the host satisfies the floor. + if (_versionSkippedExtensions.Contains(id)) + return false; + return _installations.TryGetValue(id, out var inst) ? inst.Enabled : true; + } /// Enable an extension and any installed extensions it depends on. Persists the state to DB. public async Task> EnableExtensionAsync(string id, CancellationToken ct = default) diff --git a/src/Cove.Plugins/IExtension.cs b/src/Cove.Plugins/IExtension.cs index 3ca69c56..319a0c7d 100644 --- a/src/Cove.Plugins/IExtension.cs +++ b/src/Cove.Plugins/IExtension.cs @@ -105,7 +105,21 @@ public class ExtensionContext { public required IConfiguration Configuration { get; init; } public required string DataDirectory { get; init; } + + /// + /// Semver-clean host contract version (major.minor.patch) used for compatibility checks + /// such as an extension's minimum-host-version requirement. + /// public required string CoveVersion { get; init; } + + /// + /// Full host version string including any prerelease suffix (e.g. "0.9.0-dev"). A "-dev" + /// suffix marks a non-release build, on which compatibility problems are reported as + /// warnings rather than disabling the extension. Optional; falls back to the numeric + /// contract version when unset. + /// + public string? CoveVersionDisplay { get; init; } + public UIRegistry UI { get; } = new(); } diff --git a/src/Cove.Tests/Cove.Tests.csproj b/src/Cove.Tests/Cove.Tests.csproj index 4cd912c2..dfe1a527 100644 --- a/src/Cove.Tests/Cove.Tests.csproj +++ b/src/Cove.Tests/Cove.Tests.csproj @@ -8,6 +8,7 @@ + @@ -26,6 +27,7 @@ + \ No newline at end of file diff --git a/src/Cove.Tests/HostServices/HostServiceRegistrationGeneratorTests.cs b/src/Cove.Tests/HostServices/HostServiceRegistrationGeneratorTests.cs new file mode 100644 index 00000000..21fbe561 --- /dev/null +++ b/src/Cove.Tests/HostServices/HostServiceRegistrationGeneratorTests.cs @@ -0,0 +1,216 @@ +using System.Collections.Immutable; +using System.IO; +using Cove.HostServices.Generator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cove.Tests.HostServices; + +public sealed class HostServiceRegistrationGeneratorTests +{ + // A stand-in for the real Cove.Core.Contracts attribute. The generator matches by fully-qualified + // metadata name, so declaring the shape here lets each test control the compilation in isolation. + private const string AttributeSource = """ + namespace Cove.Core.Contracts + { + public enum ServiceForwardingLifetime { Transient, Scoped, Singleton } + + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true)] + public sealed class ExposeToExtensionsAttribute : System.Attribute + { + public ExposeToExtensionsAttribute(System.Type interfaceType) { InterfaceType = interfaceType; } + public System.Type InterfaceType { get; } + public ServiceForwardingLifetime Lifetime { get; init; } = ServiceForwardingLifetime.Transient; + } + } + """; + + private static readonly MetadataReference[] References = + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToArray(); + + private static (string GeneratedSource, ImmutableArray Diagnostics) Run(string source) + { + var compilation = CSharpCompilation.Create( + assemblyName: "GeneratorInput", + syntaxTrees: + [ + CSharpSyntaxTree.ParseText(AttributeSource), + CSharpSyntaxTree.ParseText(source), + ], + references: References, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver.Create(new HostServiceRegistrationGenerator()) + .RunGenerators(compilation); + var result = driver.GetRunResult(); + + var generated = result.GeneratedTrees.Length > 0 + ? result.GeneratedTrees[0].ToString() + : string.Empty; + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToImmutableArray(); + return (generated, diagnostics); + } + + [Fact] + public void ConformingType_EmitsTransientForwardingRegistration() + { + const string source = """ + namespace Cove.Core.Interfaces { public interface IMetadataServerService { } } + namespace Cove.Api.Services + { + [Cove.Core.Contracts.ExposeToExtensions(typeof(Cove.Core.Interfaces.IMetadataServerService))] + public class MetadataServerService : Cove.Core.Interfaces.IMetadataServerService { } + } + """; + + var (generated, diagnostics) = Run(source); + + Assert.Empty(diagnostics); + Assert.Contains("public static IServiceCollection AddCoveHostServices(this IServiceCollection services)", generated); + Assert.Contains( + "services.AddTransient(sp => sp.GetRequiredService());", + generated); + } + + [Fact] + public void SingletonLifetime_EmitsSingletonForwardingRegistration() + { + const string source = """ + namespace Sample + { + public interface IReferencePerformerImporter { } + + [Cove.Core.Contracts.ExposeToExtensions( + typeof(IReferencePerformerImporter), + Lifetime = Cove.Core.Contracts.ServiceForwardingLifetime.Singleton)] + public class ReferencePerformerImporter : IReferencePerformerImporter { } + } + """; + + var (generated, diagnostics) = Run(source); + + Assert.Empty(diagnostics); + Assert.Contains( + "services.AddSingleton(sp => sp.GetRequiredService());", + generated); + } + + [Fact] + public void MarkedTypeThatDoesNotImplementInterface_ReportsCove0001Error() + { + const string source = """ + namespace Sample + { + public interface IUnrelated { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IUnrelated))] + public class NotUnrelated { } + } + """; + + var (generated, diagnostics) = Run(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("COVE0001", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.DoesNotContain("GetRequiredService", generated); + } + + [Fact] + public void ConformingRecord_EmitsForwardingRegistration() + { + // The attribute targets AttributeTargets.Class, which also permits records; a marked record + // must forward exactly like a marked class rather than being silently dropped. + const string source = """ + namespace Sample + { + public interface IRecordService { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IRecordService))] + public record RecordService : IRecordService { } + } + """; + + var (generated, diagnostics) = Run(source); + + Assert.Empty(diagnostics); + Assert.Contains( + "services.AddTransient(sp => sp.GetRequiredService());", + generated); + } + + [Fact] + public void MarkedRecordThatDoesNotImplementInterface_ReportsCove0001Error() + { + const string source = """ + namespace Sample + { + public interface IUnrelated { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IUnrelated))] + public record NotUnrelated { } + } + """; + + var (generated, diagnostics) = Run(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("COVE0001", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.DoesNotContain("GetRequiredService", generated); + } + + [Fact] + public void SameInterfaceExposedByTwoConcreteTypes_ReportsCove0002Warning() + { + const string source = """ + namespace Sample + { + public interface IShared { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IShared))] + public class FirstService : IShared { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IShared))] + public class SecondService : IShared { } + } + """; + + var (_, diagnostics) = Run(source); + + var warnings = diagnostics.Where(d => d.Id == "COVE0002").ToImmutableArray(); + Assert.NotEmpty(warnings); + Assert.All(warnings, d => Assert.Equal(DiagnosticSeverity.Warning, d.Severity)); + } + + [Fact] + public void MultipleConformingTypes_EmitInFullyQualifiedOrdinalOrder() + { + // Declared Zeta-before-Alpha; output must be Alpha-before-Zeta (ordinal by concrete FQN). + const string source = """ + namespace Sample + { + public interface IZeta { } + public interface IAlpha { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IZeta))] + public class ZetaService : IZeta { } + + [Cove.Core.Contracts.ExposeToExtensions(typeof(IAlpha))] + public class AlphaService : IAlpha { } + } + """; + + var (generated, diagnostics) = Run(source); + + Assert.Empty(diagnostics); + var alphaIndex = generated.IndexOf("GetRequiredService", StringComparison.Ordinal); + var zetaIndex = generated.IndexOf("GetRequiredService", StringComparison.Ordinal); + Assert.True(alphaIndex >= 0 && zetaIndex >= 0, "both forwarding lines should be emitted"); + Assert.True(alphaIndex < zetaIndex, "registrations must be emitted in fully-qualified ordinal order"); + } +} diff --git a/src/Cove.Tests/Integration/HostServiceRegistrationTests.cs b/src/Cove.Tests/Integration/HostServiceRegistrationTests.cs new file mode 100644 index 00000000..509d987e --- /dev/null +++ b/src/Cove.Tests/Integration/HostServiceRegistrationTests.cs @@ -0,0 +1,26 @@ +using Cove.Api.Services; +using Cove.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Cove.Tests.Integration; + +/// +/// Verifies that the host-service forwarding registrations are live in the composed application: after +/// real startup, extensions (which can bind only Cove.Core types) can resolve each host service +/// through its Cove.Core interface and receive the concrete Cove.Api implementation. +/// +public sealed class HostServiceRegistrationTests +{ + [Fact] + public void HostServices_ResolveThroughTheirCoreInterfaces() + { + using var factory = new CoveWebApplicationFactory(); + using var scope = factory.Services.CreateScope(); + + var metadataServer = scope.ServiceProvider.GetService(); + var performerImporter = scope.ServiceProvider.GetService(); + + Assert.IsType(metadataServer); + Assert.IsType(performerImporter); + } +} diff --git a/src/Cove.Tests/Integration/MetadataServerRegistrationSmokeTests.cs b/src/Cove.Tests/Integration/MetadataServerRegistrationSmokeTests.cs deleted file mode 100644 index f1fd57aa..00000000 --- a/src/Cove.Tests/Integration/MetadataServerRegistrationSmokeTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Cove.Api.Services; -using Cove.Core.Interfaces; -using Microsoft.Extensions.DependencyInjection; - -namespace Cove.Tests.Integration; - -/// -/// Guards the extension-facing DI contract for the metadata-server client. Extensions can bind only -/// Cove.Core types, so they resolve rather than the concrete -/// Cove.Api client. Nothing at compile time ties the registration to the interface, so dropping it -/// would silently return null to every extension that resolves it — this asserts it stays wired. -/// -public sealed class MetadataServerRegistrationSmokeTests -{ - [Fact] - public void IMetadataServerService_ResolvesToTheHostMetadataServerClient() - { - using var factory = new CoveWebApplicationFactory(); - using var scope = factory.Services.CreateScope(); - - var service = scope.ServiceProvider.GetService(); - - Assert.IsType(service); - } -} diff --git a/src/Cove.Tests/VersionNegotiationTests.cs b/src/Cove.Tests/VersionNegotiationTests.cs new file mode 100644 index 00000000..13826899 --- /dev/null +++ b/src/Cove.Tests/VersionNegotiationTests.cs @@ -0,0 +1,149 @@ +using Cove.Plugins; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cove.Tests; + +/// +/// Load-time host contract-version negotiation: an extension declaring a minimum host version +/// above this host's contract version is disabled on a released build (with an actionable +/// both-sides message) but only warned on a development build, so local work against a +/// not-yet-released host is never blocked. +/// +public class VersionNegotiationTests +{ + private const string WhisparrSyncId = "com.example.whisparr-sync"; + private const string RenamerId = "com.example.renamer"; + + private static ExtensionManager CreateManager(string numeric, string? display) + => new(new ExtensionContext + { + Configuration = new ConfigurationBuilder().Build(), + DataDirectory = Path.GetTempPath(), + CoveVersion = numeric, + CoveVersionDisplay = display, + }); + + [Fact] + public async Task DevBuild_ExtensionAboveFloor_IsWarnedNotDisabled() + { + // Real tagged dev checkout: git describe -> v0.9.0-175-g…, Numeric "0.9.0", Display "0.9.0-dev". + var manager = CreateManager("0.9.0", "0.9.0-dev"); + manager.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + + await manager.EnforceDependencyCompatibilityAsync(); + + // The floor (1.0.0) is above the numeric (0.9.0), but a dev build must not disable it. + Assert.True(manager.IsEnabled(WhisparrSyncId)); + } + + [Fact] + public async Task ReleasedBuild_ExtensionBelowFloor_IsDisabledWithBothSidesMessage() + { + // Released build: Display carries no "-dev" suffix. + var manager = CreateManager("0.9.0", "0.9.0"); + manager.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + + // The message names both sides before enforcement runs. + var problem = Assert.Single(manager.ValidateDependencies(), p => p.ExtensionId == WhisparrSyncId); + Assert.Null(problem.DependencyId); // core-version problem + Assert.Contains("1.0.0", problem.Message); // required floor + Assert.Contains("0.9.0", problem.Message); // this host + + await manager.EnforceDependencyCompatibilityAsync(); + + Assert.False(manager.IsEnabled(WhisparrSyncId)); + } + + [Fact] + public async Task NoGitFallback_ZeroVersion_IsWarnedNotDisabled() + { + // No-git fallback: Numeric "0.0.0" (Directory.Build.targets emits "0.0.0-dev"). + var manager = CreateManager("0.0.0", "0.0.0-dev"); + manager.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + + await manager.EnforceDependencyCompatibilityAsync(); + + Assert.True(manager.IsEnabled(WhisparrSyncId)); + } + + [Fact] + public async Task ReleasedBuild_LiveFloorsSatisfied_AreNotDisabled() + { + // A released 1.0.0 host satisfies both live extension floors. + var manager = CreateManager("1.0.0", "1.0.0"); + manager.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + manager.Register(new StubExtension(RenamerId, minCoveVersion: "0.9.0"), "local"); + + Assert.DoesNotContain(manager.ValidateDependencies(), p => p.DependencyId == null); + + await manager.EnforceDependencyCompatibilityAsync(); + + Assert.True(manager.IsEnabled(WhisparrSyncId)); + Assert.True(manager.IsEnabled(RenamerId)); + } + + [Fact] + public async Task UnparseableFloor_FailsClosedWithoutThrowing() + { + // A malformed floor is treated as unsatisfied (fail closed) and never throws. + var manager = CreateManager("1.0.0", "1.0.0"); + manager.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "not-a-version"), "local"); + + var ex = await Record.ExceptionAsync(() => manager.EnforceDependencyCompatibilityAsync()); + Assert.Null(ex); + + // Released build + unsatisfiable floor -> disabled, not a crash. + Assert.False(manager.IsEnabled(WhisparrSyncId)); + } + + [Fact] + public async Task ReleasedBuild_VersionDisable_LeavesPersistedEnabledFlagUntouched() + { + // A host below the floor skips the extension for this boot... + var manager = CreateManager("0.9.0", "0.9.0"); + manager.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + + await manager.EnforceDependencyCompatibilityAsync(); + + Assert.False(manager.IsEnabled(WhisparrSyncId)); // suppressed this boot + + // ...but the installation record's Enabled flag is left untouched, so nothing writes + // enabled=false to the store. A "DB wins" reload on the next boot therefore cannot carry a + // sticky disable forward once the host is upgraded to a compatible version. + Assert.True(manager.Installations[WhisparrSyncId].Enabled); + } + + [Fact] + public async Task VersionDisabledExtension_ReEnablesAfterHostUpgradeSatisfiesFloor() + { + // Boot 1: released host 0.9.0 is below the 1.0.0 floor -> suppressed this boot. + var oldHost = CreateManager("0.9.0", "0.9.0"); + oldHost.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + await oldHost.EnforceDependencyCompatibilityAsync(); + Assert.False(oldHost.IsEnabled(WhisparrSyncId)); + + // Boot 2 after the operator upgrades to host 1.1.0 (a fresh manager, as on a real restart): + // the floor is now satisfied, so the extension is enabled again with no manual intervention. + var newHost = CreateManager("1.1.0", "1.1.0"); + newHost.Register(new StubExtension(WhisparrSyncId, minCoveVersion: "1.0.0"), "local"); + await newHost.EnforceDependencyCompatibilityAsync(); + Assert.True(newHost.IsEnabled(WhisparrSyncId)); + } + + private sealed class StubExtension(string id, string? minCoveVersion) : IExtension + { + public string Id => id; + public string Name => id; + public string Version => "1.0.0"; + public string? Description => null; + public string? Author => null; + public string? Url => null; + public string? IconUrl => null; + public string? MinCoveVersion => minCoveVersion; + + public void ConfigureServices(IServiceCollection services, ExtensionContext context) + { + } + } +} diff --git a/src/Cove.slnx b/src/Cove.slnx index 21de60b5..9688290a 100644 --- a/src/Cove.slnx +++ b/src/Cove.slnx @@ -7,4 +7,5 @@ + diff --git a/tools/host-services-gen/AnalyzerReleases.Shipped.md b/tools/host-services-gen/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..f50bb1fe --- /dev/null +++ b/tools/host-services-gen/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/tools/host-services-gen/AnalyzerReleases.Unshipped.md b/tools/host-services-gen/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..31cef2bb --- /dev/null +++ b/tools/host-services-gen/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +COVE0001 | Cove.HostServices | Error | Exposed service must implement its declared interface +COVE0002 | Cove.HostServices | Warning | Interface exposed by more than one concrete type diff --git a/tools/host-services-gen/EquatableArray.cs b/tools/host-services-gen/EquatableArray.cs new file mode 100644 index 00000000..1ed3ae71 --- /dev/null +++ b/tools/host-services-gen/EquatableArray.cs @@ -0,0 +1,54 @@ +using System.Collections; +using System.Collections.Immutable; + +namespace Cove.HostServices.Generator; + +/// +/// An immutable array wrapper with structural (element-wise) equality, so it can be used as a model value in +/// the incremental generator pipeline. The built-in compares by reference, +/// which defeats pipeline caching. +/// +internal readonly struct EquatableArray(ImmutableArray array) : IEquatable>, IEnumerable + where T : IEquatable +{ + public static readonly EquatableArray Empty = new(ImmutableArray.Empty); + + private readonly ImmutableArray _array = array; + + public int Length => _array.IsDefault ? 0 : _array.Length; + + public ReadOnlySpan AsSpan() => _array.IsDefault ? ReadOnlySpan.Empty : _array.AsSpan(); + + public bool Equals(EquatableArray other) + { + if (_array.IsDefault || other._array.IsDefault) + { + return _array.IsDefault && other._array.IsDefault; + } + + return _array.AsSpan().SequenceEqual(other._array.AsSpan()); + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + if (_array.IsDefault) + { + return 0; + } + + var hash = 17; + foreach (var item in _array) + { + hash = (hash * 31) + (item?.GetHashCode() ?? 0); + } + + return hash; + } + + public IEnumerator GetEnumerator() => + (_array.IsDefault ? Enumerable.Empty() : _array).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tools/host-services-gen/ExposedService.cs b/tools/host-services-gen/ExposedService.cs new file mode 100644 index 00000000..71d29e00 --- /dev/null +++ b/tools/host-services-gen/ExposedService.cs @@ -0,0 +1,16 @@ +using Microsoft.CodeAnalysis; + +namespace Cove.HostServices.Generator; + +/// +/// A value-equatable description of one exposure: the fully-qualified concrete and interface names, the +/// forwarding lifetime, whether the concrete implements the interface, and the source location of the +/// attribute for diagnostic reporting. Holds only strings, enums, and a so the +/// incremental pipeline caches correctly (no symbols or compilation captured). +/// +internal sealed record ExposedService( + string ConcreteFqn, + string InterfaceFqn, + ForwardingLifetime Lifetime, + bool Implements, + Location Location); diff --git a/tools/host-services-gen/ForwardingLifetime.cs b/tools/host-services-gen/ForwardingLifetime.cs new file mode 100644 index 00000000..9c05c84c --- /dev/null +++ b/tools/host-services-gen/ForwardingLifetime.cs @@ -0,0 +1,13 @@ +namespace Cove.HostServices.Generator; + +/// +/// The service lifetime for a generated forwarding registration. Mirrors the exposure attribute's lifetime +/// values by ordinal so the generator can read them from the compilation without a runtime reference to the +/// attribute's assembly. +/// +internal enum ForwardingLifetime +{ + Transient = 0, + Scoped = 1, + Singleton = 2, +} diff --git a/tools/host-services-gen/HostServiceRegistrationGenerator.cs b/tools/host-services-gen/HostServiceRegistrationGenerator.cs new file mode 100644 index 00000000..0fd86c20 --- /dev/null +++ b/tools/host-services-gen/HostServiceRegistrationGenerator.cs @@ -0,0 +1,193 @@ +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cove.HostServices.Generator; + +/// +/// Emits a single AddCoveHostServices(this IServiceCollection) extension method that forwards each +/// concrete type marked with the exposure attribute to its declared interface at the declared lifetime, and +/// reports a compile-time error when a marked type does not implement the interface it is exposed as. +/// +[Generator] +public sealed class HostServiceRegistrationGenerator : IIncrementalGenerator +{ + private const string AttributeMetadataName = "Cove.Core.Contracts.ExposeToExtensionsAttribute"; + + private static readonly DiagnosticDescriptor InterfaceNotImplemented = new( + id: "COVE0001", + title: "Exposed service must implement its declared interface", + messageFormat: "'{0}' is exposed as '{1}' but does not implement it", + category: "Cove.HostServices", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateInterfaceExposure = new( + id: "COVE0002", + title: "Interface exposed by more than one concrete type", + messageFormat: "'{0}' is exposed by more than one concrete type; the last registration wins and the others are silently shadowed", + category: "Cove.HostServices", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly SymbolDisplayFormat FullyQualifiedNoGlobal = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var models = context.SyntaxProvider + .ForAttributeWithMetadataName( + AttributeMetadataName, + // The attribute targets AttributeTargets.Class, which C# also permits on records + // (a reference-type record is a class). A record's declaration node is + // RecordDeclarationSyntax, not ClassDeclarationSyntax, so both must be matched or a + // marked record would be dropped with neither a forwarding registration nor COVE0001. + predicate: static (node, _) => node is ClassDeclarationSyntax or RecordDeclarationSyntax, + transform: static (ctx, _) => Extract(ctx)) + .Collect(); + + context.RegisterSourceOutput(models, static (spc, collected) => Emit(spc, collected)); + } + + private static EquatableArray Extract(GeneratorAttributeSyntaxContext context) + { + if (context.TargetSymbol is not INamedTypeSymbol concrete) + { + return EquatableArray.Empty; + } + + var concreteFqn = concrete.ToDisplayString(FullyQualifiedNoGlobal); + var builder = ImmutableArray.CreateBuilder(context.Attributes.Length); + + foreach (var attribute in context.Attributes) + { + if (attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Value is not INamedTypeSymbol declaredInterface) + { + continue; + } + + var lifetime = ReadLifetime(attribute); + var implements = concrete.AllInterfaces.Contains(declaredInterface, SymbolEqualityComparer.Default); + var location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() + ?? Location.None; + + builder.Add(new ExposedService( + concreteFqn, + declaredInterface.ToDisplayString(FullyQualifiedNoGlobal), + lifetime, + implements, + location)); + } + + return new EquatableArray(builder.ToImmutable()); + } + + private static ForwardingLifetime ReadLifetime(AttributeData attribute) + { + foreach (var named in attribute.NamedArguments) + { + if (named.Key == "Lifetime" && named.Value.Value is int value) + { + return value switch + { + 1 => ForwardingLifetime.Scoped, + 2 => ForwardingLifetime.Singleton, + _ => ForwardingLifetime.Transient, + }; + } + } + + return ForwardingLifetime.Transient; + } + + private static void Emit(SourceProductionContext context, ImmutableArray> collected) + { + var conforming = new List(); + + foreach (var group in collected) + { + foreach (var service in group.AsSpan()) + { + if (service.Implements) + { + conforming.Add(service); + } + else + { + context.ReportDiagnostic(Diagnostic.Create( + InterfaceNotImplemented, + service.Location, + service.ConcreteFqn, + service.InterfaceFqn)); + } + } + } + + conforming.Sort(static (a, b) => + { + var byConcrete = string.CompareOrdinal(a.ConcreteFqn, b.ConcreteFqn); + return byConcrete != 0 ? byConcrete : string.CompareOrdinal(a.InterfaceFqn, b.InterfaceFqn); + }); + + // Two different concrete types (or one type carrying the attribute twice) exposed as the same + // interface both emit a registration for it; DI keeps only the last, silently shadowing the + // rest. Warn at every offending exposure so the ambiguity is visible at build time. + var interfaceExposureCounts = new Dictionary(StringComparer.Ordinal); + foreach (var service in conforming) + { + interfaceExposureCounts.TryGetValue(service.InterfaceFqn, out var count); + interfaceExposureCounts[service.InterfaceFqn] = count + 1; + } + + foreach (var service in conforming) + { + if (interfaceExposureCounts[service.InterfaceFqn] > 1) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateInterfaceExposure, + service.Location, + service.InterfaceFqn)); + } + } + + var source = new StringBuilder(); + source.AppendLine("// "); + source.AppendLine("#nullable enable"); + source.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + source.AppendLine(); + source.AppendLine("namespace Cove.Api.HostServices;"); + source.AppendLine(); + source.AppendLine("internal static class GeneratedHostServiceRegistrations"); + source.AppendLine("{"); + source.AppendLine(" public static IServiceCollection AddCoveHostServices(this IServiceCollection services)"); + source.AppendLine(" {"); + + foreach (var service in conforming) + { + var method = service.Lifetime switch + { + ForwardingLifetime.Scoped => "AddScoped", + ForwardingLifetime.Singleton => "AddSingleton", + _ => "AddTransient", + }; + + source.Append(" services.") + .Append(method) + .Append('<') + .Append(service.InterfaceFqn) + .Append(">(sp => sp.GetRequiredService<") + .Append(service.ConcreteFqn) + .AppendLine(">());"); + } + + source.AppendLine(" return services;"); + source.AppendLine(" }"); + source.AppendLine("}"); + + context.AddSource("GeneratedHostServiceRegistrations.g.cs", source.ToString()); + } +} diff --git a/tools/host-services-gen/IsExternalInit.cs b/tools/host-services-gen/IsExternalInit.cs new file mode 100644 index 00000000..45f70c42 --- /dev/null +++ b/tools/host-services-gen/IsExternalInit.cs @@ -0,0 +1,6 @@ +// Compiler shim: init-only setters and records require this type, which is not present in netstandard2.0. +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} diff --git a/tools/host-services-gen/host-services-gen.csproj b/tools/host-services-gen/host-services-gen.csproj new file mode 100644 index 00000000..ea3173a4 --- /dev/null +++ b/tools/host-services-gen/host-services-gen.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.0 + latest + enable + enable + false + false + true + true + Cove.HostServices.Generator + host-services-gen + + + + + + + + + + + + +