[4/4] Compile-checked host-service exposure & versioning - #22
[4/4] Compile-checked host-service exposure & versioning#22alextomas955 wants to merge 14 commits into
Conversation
…tracts - Add [ExposeToExtensions(typeof(IFoo))] attribute in Cove.Core.Contracts - Add ServiceForwardingLifetime enum (Transient/Scoped/Singleton), infra-free - Attribute carries InterfaceType and init-only Lifetime (defaults Transient)
- Add tools/host-services-gen netstandard2.0 Roslyn incremental generator - Discover marked types via ForAttributeWithMetadataName on the attribute FQN - Emit one AddCoveHostServices(IServiceCollection) with FQN-ordinal forwarding - Report COVE0001 error when a marked type does not implement its interface - Register the project in src/Cove.slnx
- Drive the generator with CSharpGeneratorDriver over in-memory sources - Cover conforming transient emit on the metadata-server shape - Cover singleton lifetime emit and fully-qualified ordinal ordering - Cover COVE0001 error when a marked type does not implement its interface
- Add ContractVersion (semver-clean numeric) to SystemStatusDto alongside the display Version - Populate it from the single CoveVersion source so extensions can negotiate min-host-version
- Disable an extension whose minimum host version exceeds this host's contract version on released builds, so it no longer initializes; surface an actionable message naming both the host version and the required floor - Warn only (never disable) on development builds, detected by the -dev suffix on the display version plus the no-git 0.0.0 fallback, so local work against a pre-release host is not blocked - Carry the full display version through ExtensionContext for that detection - Add negotiation tests covering dev-warn, released-reject, the no-git fallback, satisfied live floors, and fail-closed handling of an unparseable floor
- Describe the host contract version and frontend runtime contract axes and which extensions pin which - Cover @cove/types and @cove/extension-sdk versioning, the support window and deprecation notice, and how install-time and load-time negotiation surface to authors
- Reference the host-service registration generator as an analyzer in Cove.Api (OutputItemType=Analyzer, ReferenceOutputAssembly=false) so it runs at build time without emitting into output or downstream builds - Mark MetadataServerService with [ExposeToExtensions(typeof(IMetadataServerService))] - Mark ReferencePerformerImporter with the singleton-lifetime forwarding attribute
- Replace the two hand-written interface forwardings with a single AddCoveHostServices() call emitted from the marked concretes - Preserve the typed AddHttpClient<MetadataServerService>() and a concrete AddSingleton<ReferencePerformerImporter>() registration
- Replace the single-service registration smoke test (now enforced at compile time) with a resolution test asserting both IMetadataServerService and IReferencePerformerImporter resolve to their host implementations
A host below an extension's minimum contract version previously persisted enabled=false to the installation store. On the next boot the DB-wins reload kept the extension disabled even after the host was upgraded to a compatible version, so a floor that was later satisfied never re-enabled the extension. Track the version-incompatibility skip in an in-memory set consulted by the IsEnabled gate instead of mutating and persisting the Enabled flag. The floor is now re-evaluated on every boot and the extension initializes normally once the host satisfies it, while a deliberate user disable still persists. Add regression tests covering the untouched persisted flag and re-enablement after a host upgrade.
The syntax predicate matched only ClassDeclarationSyntax, so a record marked for extension exposure was filtered out before analysis: it produced neither a forwarding registration nor the interface-implementation diagnostic, surfacing only as a runtime resolution failure. Records are permitted by the marker's class target, so match RecordDeclarationSyntax as well. Add generator tests for a conforming record and for a marked record that does not implement its declared interface.
…e concrete types When two concrete types (or one type carrying the attribute twice) are exposed as the same interface, the generator emitted several registrations and DI kept only the last, silently shadowing the others. Report COVE0002 at each offending exposure so the ambiguity is caught at build time. Add a generator test covering duplicate exposure of a shared interface.
Greptile SummaryThis PR introduces a Roslyn incremental source generator (
Confidence Score: 4/5Safe to merge; the core DI migration and version-negotiation logic are well-tested and the in-memory-only disable design is sound. The
Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Dev as Developer
participant Roslyn as Roslyn Build
participant Gen as HostServiceRegistrationGenerator
participant DI as DI Container (Program.cs)
participant EM as ExtensionManager
participant Ext as Extension
Dev->>Roslyn: Build (class annotated with [ExposeToExtensions])
Roslyn->>Gen: ForAttributeWithMetadataName trigger
Gen->>Gen: Extract() — check concrete implements interface
alt implements interface
Gen-->>Roslyn: ExposedService (conforming)
else does not implement
Gen-->>Roslyn: COVE0001 Error (build fails)
end
Gen->>Roslyn: Emit GeneratedHostServiceRegistrations.g.cs
Note over DI: Startup
DI->>DI: AddSingleton ReferencePerformerImporter
DI->>DI: AddHttpClient MetadataServerService
DI->>DI: AddCoveHostServices() — generated forwarding
EM->>EM: EnforceDependencyCompatibilityAsync()
EM->>EM: IsDevelopmentBuild() — check CoveVersion / CoveVersionDisplay
alt "extension min-host-version > host version AND released build"
EM->>EM: _versionSkippedExtensions.Add(id)
EM-->>Ext: IsEnabled() returns false (this boot only)
else dev build OR floor satisfied
EM-->>Ext: IsEnabled() returns true
end
Ext->>DI: GetService IMetadataServerService
DI-->>Ext: MetadataServerService instance
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Dev as Developer
participant Roslyn as Roslyn Build
participant Gen as HostServiceRegistrationGenerator
participant DI as DI Container (Program.cs)
participant EM as ExtensionManager
participant Ext as Extension
Dev->>Roslyn: Build (class annotated with [ExposeToExtensions])
Roslyn->>Gen: ForAttributeWithMetadataName trigger
Gen->>Gen: Extract() — check concrete implements interface
alt implements interface
Gen-->>Roslyn: ExposedService (conforming)
else does not implement
Gen-->>Roslyn: COVE0001 Error (build fails)
end
Gen->>Roslyn: Emit GeneratedHostServiceRegistrations.g.cs
Note over DI: Startup
DI->>DI: AddSingleton ReferencePerformerImporter
DI->>DI: AddHttpClient MetadataServerService
DI->>DI: AddCoveHostServices() — generated forwarding
EM->>EM: EnforceDependencyCompatibilityAsync()
EM->>EM: IsDevelopmentBuild() — check CoveVersion / CoveVersionDisplay
alt "extension min-host-version > host version AND released build"
EM->>EM: _versionSkippedExtensions.Add(id)
EM-->>Ext: IsEnabled() returns false (this boot only)
else dev build OR floor satisfied
EM-->>Ext: IsEnabled() returns true
end
Ext->>DI: GetService IMetadataServerService
DI-->>Ext: MetadataServerService instance
|
| internal sealed record ExposedService( | ||
| string ConcreteFqn, | ||
| string InterfaceFqn, | ||
| ForwardingLifetime Lifetime, | ||
| bool Implements, | ||
| Location Location); |
There was a problem hiding this comment.
Location breaks incremental generator pipeline caching
ExposedService is a C# record, so the compiler generates equality by comparing every positional property. Location is a Roslyn reference type — two Location objects that describe the same source position are not equal unless they are literally the same object instance. Because the incremental pipeline uses equality to decide whether to re-run the Emit step, any incremental re-parse that creates a fresh Location for unchanged attributes will look like a change, defeating the cache and causing full regeneration on every keystroke. The doc comment says the record "caches correctly" because it holds no symbols or compilations, but Location is the remaining reference-equality type that breaks this. The fix is to either exclude Location from equality (override Equals/GetHashCode to ignore it) or store just the path+span as strings and reconstruct the Location in Emit only when needed.
Stack 4 of 4 (base:
v1.1-3-sdk). Diff shows only this phase.[ExposeToExtensions(typeof(IFoo))]+ a Roslyn generator emits the DI forwarding and raises a compile-time error when a service doesn't implement the declared interface — retiring the manual interface+forwarding+smoke-test ritual (IMetadataServerService,IReferencePerformerImportermigrated). A single host contract version on system status; load-time compatibility negotiation that warns on dev builds and rejects on releases, without disabling already-installed extensions.28 files, +977/−36. Version-incompatibility disable is in-memory only (re-evaluated each boot; no sticky disable across a host upgrade).