From b441a10079c08b3ce053e0be8fed3d2bd6ad8fd2 Mon Sep 17 00:00:00 2001 From: Rich Lander <2608468+richlander@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:21:58 -0700 Subject: [PATCH 1/3] Acquire API coordinate matches through PackageHouse Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/package-house.md | 10 + .../Commands/ApiCoordinateMatchCommand.cs | 6 +- ...nfiguredApiCoordinateMatchHouseExecutor.cs | 72 ++++++ ...opPackageSourceComposition.PackageHouse.cs | 34 ++- .../ApiCoordinateMatchInspection.cs | 4 +- .../ApiCoordinateMatchQuery.cs | 236 +++++++++++++++--- .../ApiCoordinateMatchConsumer.cs | 7 +- .../ApiCoordinateMatchInspectionTests.cs | 145 ++++++++++- .../ApiCoordinateMatchTestPackages.cs | 60 ++++- 9 files changed, 527 insertions(+), 47 deletions(-) create mode 100644 src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs diff --git a/docs/design/package-house.md b/docs/design/package-house.md index 028cfb65a3..4e626259f3 100644 --- a/docs/design/package-house.md +++ b/docs/design/package-house.md @@ -282,6 +282,16 @@ borrowed operation authority as House ownership. Browser/Wasm and query adapters supply their host-created clients through the same lease contract. +Standalone API coordinate matching is a compile-realization adopter. The +host-neutral query issues one exact `Realize` request per distinct endpoint +with `SurfaceAndImplementation` demand, consumes the House-issued compile +selection when creating each Package Root, and requires the destination host +execution to retain the source acquisition's typed producer identity. The host +supplies store, transfer, source-authorization, and operation-lifetime policy; +the query neither requests a complete payload nor reruns package asset +selection. `ApiCoordinateMatchInspectionTests` gates the existing Avalonia +forwarder result, exact House request shape, and destination producer affinity. + This is the adopted PackageHouse operation-ownership step 17b under [#6544](https://github.com/richlander/dotnet-inspect/issues/6544). Library ownership, Workspace admission, and host adoption remain separate focused diff --git a/src/DotnetInspect.Cli/Commands/ApiCoordinateMatchCommand.cs b/src/DotnetInspect.Cli/Commands/ApiCoordinateMatchCommand.cs index 9b0e49ac7e..9429744698 100644 --- a/src/DotnetInspect.Cli/Commands/ApiCoordinateMatchCommand.cs +++ b/src/DotnetInspect.Cli/Commands/ApiCoordinateMatchCommand.cs @@ -11,8 +11,8 @@ internal static async Task ExecuteAsync( ApiCoordinateMatchOptionsParser.Success options, CancellationToken cancellationToken) { - await using var provider = - new ConfiguredPackageRootPayloadProvider( + await using var executor = + new ConfiguredApiCoordinateMatchHouseExecutor( HttpClientFactory.Shared.Timeout, options.SourceOptions); try @@ -20,7 +20,7 @@ internal static async Task ExecuteAsync( InspectionEnvelope envelope = await ApiCoordinateMatchInspection.ExecuteAsync( options.Request, - provider, + executor.Execution, cancellationToken).ConfigureAwait(false); return ApiCoordinateMatchOutput.Write(envelope, options); } diff --git a/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs b/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs new file mode 100644 index 0000000000..e4784b6add --- /dev/null +++ b/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs @@ -0,0 +1,72 @@ +using DotnetInspector.Packages; +using DotnetInspector.Queries; +using NuGetFetch; + +namespace DotnetInspect.Cli.Commands; + +internal sealed class ConfiguredApiCoordinateMatchHouseExecutor + : IApiCoordinateMatchHouseExecutor, IAsyncDisposable +{ + readonly DesktopPackageSourceComposition _composition; + readonly NuGetSourceOptions? _sourceOptions; + readonly Dictionary _stores = + new(ReferenceEqualityComparer.Instance); + readonly PackagePayloadAcquisitionPlan _payloadAcquisition; + + internal ConfiguredApiCoordinateMatchHouseExecutor( + TimeSpan requestTimeout, + NuGetSourceOptions? sourceOptions) + { + _composition = + new DesktopPackageSourceComposition(requestTimeout); + _sourceOptions = sourceOptions; + _payloadAcquisition = new( + GetStore, + PackagePayloadLimits.Default, + access: PackagePayloadAccess.Ranged); + Execution = new( + _composition.CreateHouseOperation( + PackageHouseOperationProfile.Realize), + this); + } + + internal ApiCoordinateMatchHouseExecution Execution { get; } + + public async Task ExecuteAsync( + PackageHouseRequest request, + PackageProducerIdentity? requiredProducer, + CancellationToken cancellationToken = default) + { + PackageHouse house = _composition.CreateRealizationHouse( + _payloadAcquisition, + _sourceOptions, + requiredProducerKey: requiredProducer?.PortableKey); + PackageSourceOperationLease? operation = + _composition.IssueSettlementOperation(cancellationToken); + try + { + Task pending = + house.ExecuteAsync(request, operation); + operation = null; + return await pending.ConfigureAwait(false); + } + finally + { + operation?.Dispose(); + } + } + + public ValueTask DisposeAsync() => _composition.DisposeAsync(); + + IPackageStore GetStore( + ConfiguredPackageAuthority authority, + PackageProducerIdentity producer) + { + if (!_stores.TryGetValue(authority, out IPackageStore? store)) + { + store = new InMemoryPackageStore(); + _stores.Add(authority, store); + } + return store; + } +} diff --git a/src/DotnetInspector.Packages/DesktopPackageSourceComposition.PackageHouse.cs b/src/DotnetInspector.Packages/DesktopPackageSourceComposition.PackageHouse.cs index 03b945d807..06fb9c7b7c 100644 --- a/src/DotnetInspector.Packages/DesktopPackageSourceComposition.PackageHouse.cs +++ b/src/DotnetInspector.Packages/DesktopPackageSourceComposition.PackageHouse.cs @@ -64,16 +64,31 @@ public PackageHouse CreateDependencyContentQueryHouse( public PackageHouse CreateRealizationHouse( PackagePayloadAcquisitionPlan payloadAcquisition, NuGetSourceOptions? sourceOptions = null, - Action? log = null) + Action? log = null, + string? requiredProducerKey = null) { ArgumentNullException.ThrowIfNull(payloadAcquisition); return new PackageHouse( - new CompositionAuthorization(this, sourceOptions), + new CompositionAuthorization( + this, + sourceOptions, + requiredProducerKey), payloadAcquisition, log, _versionSettlement); } + /// + /// Creates a House operation declaration with this composition's exact + /// request and operation deadlines. + /// + public PackageHouseOperation CreateHouseOperation( + PackageHouseOperationProfile profile) => + PackageHouseOperation.Create( + profile, + _options.RequestTimeout, + _options.OperationTimeout); + /// Issues the source-owned operation consumed by a shared inspection. public PackageSourceOperationLease IssueSettlementOperation( CancellationToken cancellationToken = default) => @@ -611,13 +626,20 @@ public PackageSourceAuthorization AuthorizeSourcesFor( private sealed class CompositionAuthorization( DesktopPackageSourceComposition composition, - NuGetSourceOptions? sourceOptions) + NuGetSourceOptions? sourceOptions, + string? requiredProducerKey) : IPackageSourceAuthorization { public PackageSourceAuthorization AuthorizeSourcesFor( string packageId) => - composition.AuthorizeSourcesFor( - packageId, - sourceOptions); + requiredProducerKey is null + ? composition.AuthorizeSourcesFor( + packageId, + sourceOptions) + : composition.AuthorizeHouseSources( + packageId, + sourceOptions, + requiredProducerKey) + .AuthorizeSourcesFor(packageId); } } diff --git a/src/DotnetInspector.Presentation/ApiCoordinateMatchInspection.cs b/src/DotnetInspector.Presentation/ApiCoordinateMatchInspection.cs index bd73b1f232..45f9f9b4c4 100644 --- a/src/DotnetInspector.Presentation/ApiCoordinateMatchInspection.cs +++ b/src/DotnetInspector.Presentation/ApiCoordinateMatchInspection.cs @@ -12,10 +12,10 @@ public static class ApiCoordinateMatchInspection { public static async Task> ExecuteAsync( ApiCoordinateMatchRequest request, - IPackageRootPayloadProvider provider, + ApiCoordinateMatchHouseExecution execution, CancellationToken cancellationToken = default) => Complete(Project(await ApiCoordinateMatchQuery.ExecuteAsync( - request, provider, cancellationToken).ConfigureAwait(false))); + request, execution, cancellationToken).ConfigureAwait(false))); public static async Task> ExecuteAsync( InspectionWorkspace workspace, diff --git a/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs b/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs index e7fad462c1..76f44a8fc0 100644 --- a/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs +++ b/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs @@ -20,6 +20,49 @@ public enum ApiCoordinateMatchEndpointSide Destination, } +public sealed record ApiCoordinateMatchAcquisitionFailure( + InertString Producer, + string Message, + PackageRootAcquisitionFailureKind FailureKind, + PackageSourceFailureKind? SourceFailureKind = null); + +/// +/// Host capability for executing one query-issued PackageHouse request, +/// optionally constrained to an already selected producer. +/// +public interface IApiCoordinateMatchHouseExecutor +{ + Task ExecuteAsync( + PackageHouseRequest request, + PackageProducerIdentity? requiredProducer, + CancellationToken cancellationToken = default); +} + +/// One PackageHouse operation declaration and its host executor. +public sealed class ApiCoordinateMatchHouseExecution +{ + public ApiCoordinateMatchHouseExecution( + PackageHouseOperation operation, + IApiCoordinateMatchHouseExecutor executor) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(executor); + if (operation.Profile != PackageHouseOperationProfile.Realize) + { + throw new ArgumentException( + "API coordinate matching requires a PackageHouse Realize operation.", + nameof(operation)); + } + + Operation = operation; + Executor = executor; + } + + public PackageHouseOperation Operation { get; } + + public IApiCoordinateMatchHouseExecutor Executor { get; } +} + /// Native stage evidence for one standalone, direction-preserving match operation. public sealed class ApiCoordinateMatchQueryResult { @@ -32,7 +75,11 @@ internal ApiCoordinateMatchQueryResult(ApiCoordinateMatchRequest request, ApiCoo public ApiCoordinateMatchRequest Request { get; } public ApiCoordinateMatchQueryStage Stage { get; } public ApiCoordinateMatchEndpointSide? FailedEndpoint { get; internal init; } - public PackageRootPayloadResult.Unavailable? AcquisitionFailure { get; internal init; } + public ApiCoordinateMatchAcquisitionFailure? AcquisitionFailure + { + get; + internal init; + } public WorkspaceScopeOperationResult? ScopeFailure { get; internal init; } public CoordinateLibraryPairingFailure? ObservationFailure { get; internal init; } public CoordinatePackageObservation? Before { get; internal init; } @@ -42,53 +89,61 @@ internal ApiCoordinateMatchQueryResult(ApiCoordinateMatchRequest request, ApiCoo } /// -/// Acquires exactly two literal endpoints through a host capability, selects -/// the source once, and invokes the shared exact correspondence producer. +/// Realizes exactly two literal endpoints through PackageHouse, selects the +/// source once, and invokes the shared exact correspondence producer. /// Hosts that already own observations use ApiCoordinateCorrespondenceQuery directly. /// public static class ApiCoordinateMatchQuery { public static async Task ExecuteAsync( ApiCoordinateMatchRequest request, - IPackageRootPayloadProvider provider, + ApiCoordinateMatchHouseExecution execution, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(execution); cancellationToken.ThrowIfCancellationRequested(); PackageSourceCoordinate sourceCoordinate = PackageSourceCoordinate.Create(request.PackageId, request.SourceVersion); PackageSourceCoordinate destinationCoordinate = PackageSourceCoordinate.Create(request.PackageId, request.DestinationVersion); - PackageRootPayloadResult sourcePayload = await provider.GetPayloadAsync( - sourceCoordinate, null, PackagePayloadLimits.Default, cancellationToken).ConfigureAwait(false); - if (sourcePayload is PackageRootPayloadResult.Unavailable sourceFailure) + EndpointAcquisition source = await AcquireAsync( + sourceCoordinate, + requiredProducer: null, + request, + execution, + cancellationToken).ConfigureAwait(false); + if (source.Failure is { } sourceFailure) return AcquisitionFailed(ApiCoordinateMatchEndpointSide.Source, sourceFailure); - var source = ((PackageRootPayloadResult.Available)sourcePayload).Payload; - PackageRootPayloadResult destinationPayload = sourceCoordinate.Equals(destinationCoordinate) - ? sourcePayload - : await provider.GetPayloadAsync( - destinationCoordinate, source.Producer?.PortableKey, - PackagePayloadLimits.Default, cancellationToken).ConfigureAwait(false); - if (destinationPayload is PackageRootPayloadResult.Unavailable destinationFailure) - return AcquisitionFailed(ApiCoordinateMatchEndpointSide.Destination, destinationFailure); - var destination = ((PackageRootPayloadResult.Available)destinationPayload).Payload; - if (!source.Coordinate.Equals(sourceCoordinate) || !destination.Coordinate.Equals(destinationCoordinate)) + AcquiredEndpoint acquiredSource = source.Acquired + ?? throw new InvalidOperationException( + "Successful source acquisition omitted its Package Root."); + PackageRootBinding beforeBinding = acquiredSource.Binding; + + PackageRootBinding afterBinding; + if (sourceCoordinate.Equals(destinationCoordinate)) { - return AcquisitionFailed( - !source.Coordinate.Equals(sourceCoordinate) - ? ApiCoordinateMatchEndpointSide.Source - : ApiCoordinateMatchEndpointSide.Destination, - new PackageRootPayloadResult.Unavailable( - new InertString(TextPolicy.Field, "Package Source"), - "Acquisition did not produce the exact requested endpoint.", - PackageRootAcquisitionFailureKind.InvalidCoordinate)); + afterBinding = beforeBinding; + } + else + { + EndpointAcquisition destination = await AcquireAsync( + destinationCoordinate, + acquiredSource.Producer, + request, + execution, + cancellationToken).ConfigureAwait(false); + if (destination.Failure is { } destinationFailure) + { + return AcquisitionFailed( + ApiCoordinateMatchEndpointSide.Destination, + destinationFailure); + } + afterBinding = destination.Acquired?.Binding + ?? throw new InvalidOperationException( + "Successful destination acquisition omitted its Package Root."); } - PackageRootBinding beforeBinding = PackageRootBinding.CreateFromSource(source, request.TargetFramework); - PackageRootBinding afterBinding = ReferenceEquals(source, destination) - ? beforeBinding - : PackageRootBinding.CreateFromSource(destination, request.TargetFramework); await using var workspace = new InspectionWorkspace(); WorkspaceScopeReadResult read = await workspace.GetScopeSnapshotAsync() .ConfigureAwait(false); @@ -159,7 +214,8 @@ await ApiCoordinateCorrespondenceQuery.ExecuteAsync( }; ApiCoordinateMatchQueryResult AcquisitionFailed( - ApiCoordinateMatchEndpointSide endpoint, PackageRootPayloadResult.Unavailable failure) => + ApiCoordinateMatchEndpointSide endpoint, + ApiCoordinateMatchAcquisitionFailure failure) => new(request, ApiCoordinateMatchQueryStage.Acquisition) { FailedEndpoint = endpoint, @@ -177,4 +233,122 @@ ApiCoordinateMatchQueryResult ObservationFailed( }; } + private static async Task AcquireAsync( + PackageSourceCoordinate coordinate, + PackageProducerIdentity? requiredProducer, + ApiCoordinateMatchRequest matchRequest, + ApiCoordinateMatchHouseExecution execution, + CancellationToken cancellationToken) + { + PackageHouseTargetContext target = matchRequest.TargetFramework is null + ? PackageHouseTargetContext.OwnerDefault() + : PackageHouseTargetContext.Exact(matchRequest.TargetFramework); + var houseRequest = new PackageHouseRequest( + new PackageHouseDemand.Exact(coordinate), + execution.Operation, + target, + PackageHouseAssetSelectionKind.Compile, + PackageHouseLibraryHandoffMode.PackageOnly, + assetDemand: PackageAssetDemand.SurfaceAndImplementation); + PackageHouseSettlement settlement = + await execution.Executor.ExecuteAsync( + houseRequest, + requiredProducer, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + "The API coordinate-match House executor returned no settlement."); + if (!ReferenceEquals(settlement.Result.Request, houseRequest)) + { + throw new InvalidOperationException( + "The API coordinate-match House settlement belongs to another request."); + } + + if (settlement is not PackageHouseSettlement.Acquired acquired + || settlement.Result.Evidence.Realization + is not PackageHouseRealizationReceipt.Compile realization + || settlement.Result + is not (PackageHouseResult.Settled + or PackageHouseResult.NoMatch + or PackageHouseResult.Rejected)) + { + return new( + Acquired: null, + Failure: ProjectFailure( + settlement, + "PackageHouse did not produce an exact compile realization.")); + } + if (!PackageRootBinding.TryCreateFromSourceSelection( + acquired.Payload, + realization.Receipt, + out PackageRootBinding? binding)) + { + return new( + Acquired: null, + Failure: new ApiCoordinateMatchAcquisitionFailure( + realization.Acquisition.Producer.Display, + "PackageHouse produced a compile realization whose exact Package Root coordinate is not representable.", + PackageRootAcquisitionFailureKind.InvalidCoordinate)); + } + + return new( + new AcquiredEndpoint( + binding.WithAssetDemand( + PackageAssetDemand.SurfaceAndImplementation), + realization.Acquisition.Producer), + Failure: null); + } + + private static ApiCoordinateMatchAcquisitionFailure ProjectFailure( + PackageHouseSettlement settlement, + string fallback) + { + ConfiguredPackagePayloadResult projected = + PackageHousePayloadResultAdapter.Create(settlement); + PackageAuthorityFailure? first = + projected.Failures.FirstOrDefault(); + ConfiguredPackageAuthority? firstNotFound = + projected.NotFoundAuthorities.FirstOrDefault(); + InertString producer = first?.Authority + ?? settlement.Result.Evidence.Acquisition?.Producer.Display + ?? (firstNotFound is null + ? new InertString(TextPolicy.Field, "PackageHouse") + : PackageSourceDisplay.ForDiagnostics( + firstNotFound.Source)); + string message = projected.Failures.Count == 0 + ? projected.NotFoundAuthorities.Count == 0 + ? fallback + : "The selected package was not found on " + + string.Join( + ", ", + projected.NotFoundAuthorities.Select(authority => + PackageSourceDisplay.ForDiagnostics( + authority.Source))) + + "." + : string.Join( + "; ", + projected.Failures.Select(failure => failure.Message)); + PackageRootAcquisitionFailureKind failureKind = + projected.Failures.Any(failure => + failure.IsRequiredProducerUnavailable) + ? PackageRootAcquisitionFailureKind.ProducerNotAuthorized + : PackageRootAcquisitionFailureKind.PackageUnavailable; + return new ApiCoordinateMatchAcquisitionFailure( + producer, + message, + failureKind, + projected.Failures.Count == 1 + ? first?.SourceFailure?.Kind + : projected.Failures.Count == 0 + && projected.NotFoundAuthorities.Count > 0 + ? PackageSourceFailureKind.NotFound + : null); + } + + private sealed record AcquiredEndpoint( + PackageRootBinding Binding, + PackageProducerIdentity Producer); + + private sealed record EndpointAcquisition( + AcquiredEndpoint? Acquired, + ApiCoordinateMatchAcquisitionFailure? Failure); } diff --git a/tests/DotnetInspector.Queries.Consumer/ApiCoordinateMatchConsumer.cs b/tests/DotnetInspector.Queries.Consumer/ApiCoordinateMatchConsumer.cs index 454c95033b..d8d369050a 100644 --- a/tests/DotnetInspector.Queries.Consumer/ApiCoordinateMatchConsumer.cs +++ b/tests/DotnetInspector.Queries.Consumer/ApiCoordinateMatchConsumer.cs @@ -8,7 +8,10 @@ public static class ApiCoordinateMatchConsumer { public static Task> MatchAsync( ApiCoordinateMatchRequest request, - IPackageRootPayloadProvider source, + ApiCoordinateMatchHouseExecution execution, CancellationToken cancellationToken = default) => - ApiCoordinateMatchInspection.ExecuteAsync(request, source, cancellationToken); + ApiCoordinateMatchInspection.ExecuteAsync( + request, + execution, + cancellationToken); } diff --git a/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs b/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs index 818c16038c..1e831f970d 100644 --- a/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs +++ b/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs @@ -52,6 +52,127 @@ public async Task Avalonia_MoveUsesTheExplicitMarkupToBaseForwarder(string? memb resolved.Definition.Assembly.Assembly.Library!.Assembly.Registration); } + [Fact] + public async Task ExactEndpointsUseHouseRealizationAndPinTheDestinationProducer() + { + using var packages = new ApiCoordinateMatchTestPackages(); + await packages.AddAsync("Avalonia", "11.3.14"); + await packages.AddAsync("Avalonia", "12.1.2"); + + InspectionEnvelope envelope = + await ApiCoordinateMatchConsumer.MatchAsync( + new( + "Avalonia", + "11.3.14", + "12.1.2", + "Avalonia.Data.MultiBinding", + targetFramework: "net8.0"), + packages.HouseExecution, + TestContext.Current.CancellationToken); + + AssertStatus(ApiCoordinateMatchStatus.Exact, envelope); + Assert.Equal(2, packages.HouseRequests.Count); + Assert.Null(packages.HouseRequests[0].RequiredProducer); + Assert.Equal( + PackageProducerIdentity.NuGetOrg, + packages.HouseRequests[1].RequiredProducer); + Assert.All(packages.HouseRequests, item => + { + Assert.Equal( + PackageHouseOperationProfile.Realize, + item.Request.Operation.Profile); + Assert.Equal( + PackageHouseAssetSelectionKind.Compile, + item.Request.AssetSelection); + Assert.Equal( + PackageAssetDemand.SurfaceAndImplementation, + item.Request.AssetDemand); + Assert.Equal( + PackageHouseLibraryHandoffMode.PackageOnly, + item.Request.LibraryHandoff); + }); + } + + [Fact] + public async Task DestinationRequiredProducerUnavailableIsAnAcquisitionFailure() + { + using var packages = new ApiCoordinateMatchTestPackages + { + RequiredProducerKeyOverride = "unavailable-test-producer", + }; + await packages.AddAsync("Avalonia", "11.3.14"); + await packages.AddAsync("Avalonia", "12.1.2"); + + InspectionEnvelope envelope = + await ApiCoordinateMatchConsumer.MatchAsync( + new( + "Avalonia", + "11.3.14", + "12.1.2", + "Avalonia.Data.MultiBinding", + targetFramework: "net8.0"), + packages.HouseExecution, + TestContext.Current.CancellationToken); + + AssertStatus(ApiCoordinateMatchStatus.Failed, envelope); + Assert.Equal(ApiCoordinateMatchStage.Acquisition, envelope.Content.Stage); + Assert.StartsWith( + "Destination:", + envelope.Content.Summary.ToString(), + StringComparison.Ordinal); + Assert.Equal(2, packages.HouseRequests.Count); + Assert.Equal( + PackageProducerIdentity.NuGetOrg, + packages.HouseRequests[1].RequiredProducer); + } + + [Fact] + public async Task EqualEndpointsReuseOneHouseRealization() + { + using var packages = new ApiCoordinateMatchTestPackages(); + await packages.AddAsync("Avalonia", "11.3.14"); + + InspectionEnvelope envelope = + await ApiCoordinateMatchConsumer.MatchAsync( + new( + "Avalonia", + "11.3.14", + "11.3.14", + "Avalonia.Data.MultiBinding", + targetFramework: "net8.0"), + packages.HouseExecution, + TestContext.Current.CancellationToken); + + AssertStatus(ApiCoordinateMatchStatus.Exact, envelope); + Assert.Single(packages.HouseRequests); + } + + [Fact] + public async Task SettlementForAnotherEndpointRequestIsRejected() + { + using var packages = new ApiCoordinateMatchTestPackages(); + await packages.AddAsync("Avalonia", "11.3.14"); + await packages.AddAsync("Avalonia", "12.1.2"); + var executor = new ReplayingSettlementExecutor(packages); + var execution = new ApiCoordinateMatchHouseExecution( + packages.HouseExecution.Operation, + executor); + + InvalidOperationException failure = + await Assert.ThrowsAsync(() => + ApiCoordinateMatchConsumer.MatchAsync( + new( + "Avalonia", + "11.3.14", + "12.1.2", + "Avalonia.Data.MultiBinding", + targetFramework: "net8.0"), + execution, + TestContext.Current.CancellationToken)); + + Assert.Contains("another request", failure.Message); + } + [Fact] public async Task SystemTextJson_StringOptionsGenericOverload_HasAnExactDestinationAnchor() { @@ -267,7 +388,8 @@ static async Task> Match( if (before != after) await packages.AddAsync(packageId, after); return await ApiCoordinateMatchConsumer.MatchAsync( - new(packageId, before, after, type, member, framework), packages, + new(packageId, before, after, type, member, framework), + packages.HouseExecution, TestContext.Current.CancellationToken); } @@ -276,4 +398,25 @@ static void AssertStatus( Assert.True(expected == envelope.Content.Status, $"Expected {expected}, got {envelope.Content.Status}: {envelope.Content.Summary}\n" + string.Join("\n", envelope.Content.Stages)); + + sealed class ReplayingSettlementExecutor( + IApiCoordinateMatchHouseExecutor inner) + : IApiCoordinateMatchHouseExecutor + { + PackageHouseSettlement? _first; + + public async Task ExecuteAsync( + PackageHouseRequest request, + PackageProducerIdentity? requiredProducer, + CancellationToken cancellationToken = default) + { + if (_first is not null) + return _first; + _first = await inner.ExecuteAsync( + request, + requiredProducer, + cancellationToken); + return _first; + } + } } diff --git a/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchTestPackages.cs b/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchTestPackages.cs index a67e294065..5a4f15a02f 100644 --- a/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchTestPackages.cs +++ b/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchTestPackages.cs @@ -5,14 +5,39 @@ namespace DotnetInspector.Queries.Tests; /// Pinned NuGet archives admitted by the real typed payload acquisition path. -internal sealed class ApiCoordinateMatchTestPackages : IPackageRootPayloadProvider, IDisposable +internal sealed class ApiCoordinateMatchTestPackages : + IPackageRootPayloadProvider, + IApiCoordinateMatchHouseExecutor, + IDisposable { readonly InMemoryPackageStore _store = new(); readonly IPackageSourceClient _source = PackageSourceClientFactory.Create( PackageSource.NuGetOrg, PackageSourceAssociation.Create(), new NoNetworkHandler()); + readonly DesktopPackageSourceComposition _composition = + new(TimeSpan.FromSeconds(5)); + readonly PackagePayloadAcquisitionPlan _payloadAcquisition; + + internal ApiCoordinateMatchTestPackages() + { + _payloadAcquisition = new( + (_, _) => _store, + PackagePayloadLimits.Default, + access: PackagePayloadAccess.Ranged); + HouseExecution = new( + _composition.CreateHouseOperation( + PackageHouseOperationProfile.Realize), + this); + } public List Requests { get; } = []; + public List<(PackageHouseRequest Request, PackageProducerIdentity? RequiredProducer)> + HouseRequests { get; } = []; + + public ApiCoordinateMatchHouseExecution HouseExecution { get; } + + internal string? RequiredProducerKeyOverride { get; set; } + public async Task AddAsync(string packageId, string version) { string path = Path.Combine(AppContext.BaseDirectory, "RealAssets", "ApiMatching", @@ -56,7 +81,38 @@ await GetPayloadAsync(PackageSourceCoordinate.Create(packageId, version), null, return PackageRootBinding.CreateFromSource(result.Payload, framework); } - public void Dispose() => _source.Dispose(); + public async Task ExecuteAsync( + PackageHouseRequest request, + PackageProducerIdentity? requiredProducer, + CancellationToken cancellationToken = default) + { + HouseRequests.Add((request, requiredProducer)); + PackageHouse house = _composition.CreateRealizationHouse( + _payloadAcquisition, + requiredProducerKey: requiredProducer is null + ? null + : RequiredProducerKeyOverride + ?? requiredProducer.PortableKey); + PackageSourceOperationLease? operation = + _composition.IssueSettlementOperation(cancellationToken); + try + { + Task pending = + house.ExecuteAsync(request, operation); + operation = null; + return await pending.ConfigureAwait(false); + } + finally + { + operation?.Dispose(); + } + } + + public void Dispose() + { + _source.Dispose(); + _composition.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } sealed class NoNetworkHandler : HttpMessageHandler { From da1b4b9d9b84998ed9e98a90649525bd3ad24646 Mon Sep 17 00:00:00 2001 From: Rich Lander <2608468+richlander@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:50:24 -0700 Subject: [PATCH 2/3] Avoid retaining ranged API match entries twice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/package-house.md | 5 ++- ...nfiguredApiCoordinateMatchHouseExecutor.cs | 42 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/design/package-house.md b/docs/design/package-house.md index 4e626259f3..df258ffd9c 100644 --- a/docs/design/package-house.md +++ b/docs/design/package-house.md @@ -289,7 +289,10 @@ selection when creating each Package Root, and requires the destination host execution to retain the source acquisition's typed producer identity. The host supplies store, transfer, source-authorization, and operation-lifetime policy; the query neither requests a complete payload nor reruns package asset -selection. `ApiCoordinateMatchInspectionTests` gates the existing Avalonia +selection. The CLI's operation-local store does not persist ranged entries: +distinct endpoints are each realized once, and an equal destination reuses the +source Root without another acquisition. The settlement-owned ranged content +therefore remains the one retained copy of selected entry bytes. `ApiCoordinateMatchInspectionTests` gates the existing Avalonia forwarder result, exact House request shape, and destination producer affinity. This is the adopted PackageHouse operation-ownership step 17b under diff --git a/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs b/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs index e4784b6add..a3e2368c64 100644 --- a/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs +++ b/src/DotnetInspect.Cli/Commands/ConfiguredApiCoordinateMatchHouseExecutor.cs @@ -64,9 +64,49 @@ IPackageStore GetStore( { if (!_stores.TryGetValue(authority, out IPackageStore? store)) { - store = new InMemoryPackageStore(); + store = new OperationLocalPackageStore(); _stores.Add(authority, store); } return store; } + + private sealed class OperationLocalPackageStore : IPackageStore + { + readonly InMemoryPackageStore _inner = new(); + + public IPackageContent? TryGetCached( + string packageName, + string version, + IReadOnlyList? allowedSourceKeys, + Action? log = null) => + _inner.TryGetCached( + packageName, + version, + allowedSourceKeys, + log); + + public IEnumerable EnumerateCached( + string packageName, + string version, + IReadOnlyList? allowedSourceKeys, + Action? log = null) => + _inner.EnumerateCached( + packageName, + version, + allowedSourceKeys, + log); + + public ValueTask CommitAsync( + string packageName, + string version, + string sourceKey, + Stream nupkg, + CancellationToken cancellationToken = default) => + _inner.CommitAsync( + packageName, + version, + sourceKey, + nupkg, + cancellationToken); + } } From 8920eea9c7b55226be36aa6da9bf2334abe567ef Mon Sep 17 00:00:00 2001 From: Rich Lander <2608468+richlander@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:39:59 -0700 Subject: [PATCH 3/3] Realize only API coordinate surfaces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/package-house.md | 22 +++++++++++-------- .../ApiCoordinateMatchQuery.cs | 5 ++--- .../ApiCoordinateMatchInspectionTests.cs | 4 ++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/design/package-house.md b/docs/design/package-house.md index df258ffd9c..fa814de885 100644 --- a/docs/design/package-house.md +++ b/docs/design/package-house.md @@ -284,15 +284,19 @@ same lease contract. Standalone API coordinate matching is a compile-realization adopter. The host-neutral query issues one exact `Realize` request per distinct endpoint -with `SurfaceAndImplementation` demand, consumes the House-issued compile -selection when creating each Package Root, and requires the destination host -execution to retain the source acquisition's typed producer identity. The host -supplies store, transfer, source-authorization, and operation-lifetime policy; -the query neither requests a complete payload nor reruns package asset -selection. The CLI's operation-local store does not persist ranged entries: -distinct endpoints are each realized once, and an equal destination reuses the -source Root without another acquisition. The settlement-owned ranged content -therefore remains the one retained copy of selected entry bytes. `ApiCoordinateMatchInspectionTests` gates the existing Avalonia +with `Surface` demand and consumes the House-issued compile selection when +creating each Package Root. Coordinate matching observes the complete selected +API population, its declarations, assembly identities, references, and type +forwarders; it does not execute or inspect method bodies, and a package without +a separate reference surface already uses its `lib/` assets as that surface. +The destination host execution retains the source acquisition's typed producer +identity. The host supplies store, transfer, source-authorization, and +operation-lifetime policy; the query neither requests a complete payload nor +reruns package asset selection. The CLI's operation-local store does not +persist ranged entries: distinct endpoints are each realized once, and an +equal destination reuses the source Root without another acquisition. The +settlement-owned ranged content therefore remains the one retained copy of +selected entry bytes. `ApiCoordinateMatchInspectionTests` gates the existing Avalonia forwarder result, exact House request shape, and destination producer affinity. This is the adopted PackageHouse operation-ownership step 17b under diff --git a/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs b/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs index 76f44a8fc0..c2f655b493 100644 --- a/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs +++ b/src/DotnetInspector.Queries/ApiCoordinateMatchQuery.cs @@ -249,7 +249,7 @@ private static async Task AcquireAsync( target, PackageHouseAssetSelectionKind.Compile, PackageHouseLibraryHandoffMode.PackageOnly, - assetDemand: PackageAssetDemand.SurfaceAndImplementation); + assetDemand: PackageAssetDemand.Surface); PackageHouseSettlement settlement = await execution.Executor.ExecuteAsync( houseRequest, @@ -292,8 +292,7 @@ or PackageHouseResult.NoMatch return new( new AcquiredEndpoint( - binding.WithAssetDemand( - PackageAssetDemand.SurfaceAndImplementation), + binding.WithAssetDemand(PackageAssetDemand.Surface), realization.Acquisition.Producer), Failure: null); } diff --git a/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs b/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs index 1e831f970d..29cc30e44b 100644 --- a/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs +++ b/tests/DotnetInspector.Queries.Tests/ApiCoordinateMatchInspectionTests.cs @@ -53,7 +53,7 @@ public async Task Avalonia_MoveUsesTheExplicitMarkupToBaseForwarder(string? memb } [Fact] - public async Task ExactEndpointsUseHouseRealizationAndPinTheDestinationProducer() + public async Task ExactEndpointsUseHouseSurfaceRealizationAndPinTheDestinationProducer() { using var packages = new ApiCoordinateMatchTestPackages(); await packages.AddAsync("Avalonia", "11.3.14"); @@ -85,7 +85,7 @@ await ApiCoordinateMatchConsumer.MatchAsync( PackageHouseAssetSelectionKind.Compile, item.Request.AssetSelection); Assert.Equal( - PackageAssetDemand.SurfaceAndImplementation, + PackageAssetDemand.Surface, item.Request.AssetDemand); Assert.Equal( PackageHouseLibraryHandoffMode.PackageOnly,