diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md
index 8345f8c7a35..ffec04212ad 100644
--- a/docs/specs/cli-output-formats.md
+++ b/docs/specs/cli-output-formats.md
@@ -603,7 +603,7 @@ The top-level arrays are:
| Field | Description |
| ----- | ----------- |
-| `packages` | Packages or projects scanned for capabilities. |
+| `packages` | Packages or projects scanned for capabilities. `version` is the version that was **requested**, not the one NuGet resolved: package restore uses a minimum-version reference, so the assembly actually scanned may be newer. Use `aspire sdk export` when the version label has to be exact. Project references are omitted because they have no version. |
| `capabilities` | Builder methods and other callable capabilities. |
| `handleTypes` | Resource or builder handle types. |
| `dtoTypes` | DTO types used by capabilities. |
@@ -612,3 +612,9 @@ The top-level arrays are:
| `diagnostics` | Errors, warnings, and informational diagnostics from capability discovery. |
`aspire sdk dump --format ci` emits a stable text format intended for diffs rather than JSON parsing.
+
+### `aspire sdk export`
+
+`aspire sdk export --package Name@Version --language typescript` restores the exact integration package version and writes one canonical JSON document to standard output. `Aspire.Hosting` can only be exported at the CLI's SDK version. The selected language's code-generation package cannot be exported because it supplies the generator instead of an integration API surface. Omit `--package` to export `Aspire.Hosting` at the running CLI's SDK version. Diagnostics are written to standard error.
+
+The top-level fields are `schemaVersion`, `language`, `generator`, `package`, `modules`, and `declarations`. The language exporter owns the schema; the CLI passes it through without reshaping it.
diff --git a/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs
index 05d302dabf4..65e89236b4e 100644
--- a/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs
+++ b/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs
@@ -12,11 +12,13 @@ internal sealed class SdkCommand : ParentCommand
public SdkCommand(
SdkGenerateCommand generateCommand,
SdkDumpCommand dumpCommand,
+ SdkExportCommand exportCommand,
CommonCommandServices services)
: base("sdk", "Commands for generating SDKs for building Aspire integrations in other languages.", services)
{
Hidden = true;
Subcommands.Add(generateCommand);
Subcommands.Add(dumpCommand);
+ Subcommands.Add(exportCommand);
}
}
diff --git a/src/Aspire.Cli/Commands/Sdk/SdkExportCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkExportCommand.cs
new file mode 100644
index 00000000000..e6abd804453
--- /dev/null
+++ b/src/Aspire.Cli/Commands/Sdk/SdkExportCommand.cs
@@ -0,0 +1,371 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.Globalization;
+using System.Text.Json;
+using Aspire.Cli.Configuration;
+using Aspire.Cli.Interaction;
+using Aspire.Cli.Projects;
+using Aspire.Cli.Resources;
+using Aspire.Cli.Utils;
+using Microsoft.Extensions.Logging;
+using Semver;
+using StreamJsonRpc;
+using StreamJsonRpc.Protocol;
+
+namespace Aspire.Cli.Commands.Sdk;
+
+///
+/// Exports a package's canonical API reference for a target language.
+///
+///
+/// Standard output contains only the JSON document. Preparation diagnostics and errors are written
+/// to standard error so the command can be redirected directly to a file.
+///
+internal sealed class SdkExportCommand : BaseCommand
+{
+ private const string CorePackageName = "Aspire.Hosting";
+
+ private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
+ private readonly IAppHostServerSessionFactory _serverSessionFactory;
+ private readonly ILanguageDiscovery _languageDiscovery;
+ private readonly ILogger _logger;
+
+ private static readonly Option s_languageOption = new("--language", "-l")
+ {
+ Description = "Target language for the API export (e.g., typescript).",
+ Required = true
+ };
+
+ private static readonly Option s_packageOption = new("--package", "-p")
+ {
+ Description = "Package to export in PackageName@Version form. Defaults to Aspire.Hosting at this CLI's SDK version."
+ };
+
+ public SdkExportCommand(
+ IAppHostServerProjectFactory appHostServerProjectFactory,
+ IAppHostServerSessionFactory serverSessionFactory,
+ ILanguageDiscovery languageDiscovery,
+ ILogger logger,
+ CommonCommandServices services)
+ : base("export", "Export a canonical package API reference.", services)
+ {
+ _appHostServerProjectFactory = appHostServerProjectFactory;
+ _serverSessionFactory = serverSessionFactory;
+ _languageDiscovery = languageDiscovery;
+ _logger = logger;
+
+ Options.Add(s_languageOption);
+ Options.Add(s_packageOption);
+ }
+
+ protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ InteractionService.Console = ConsoleOutput.Error;
+
+ var language = parseResult.GetValue(s_languageOption)!;
+ if (string.IsNullOrWhiteSpace(language))
+ {
+ return CommandResult.Failure(CliExitCodes.InvalidCommand, "The export language cannot be empty.");
+ }
+
+ var packageArgument = parseResult.GetValue(s_packageOption);
+ var packageName = CorePackageName;
+ var packageVersion = ExecutionContext.IdentitySdkVersion;
+ var integrations = new List();
+
+ if (!string.IsNullOrWhiteSpace(packageArgument))
+ {
+ if (!TryParsePackage(packageArgument, out packageName, out packageVersion, out var errorMessage))
+ {
+ return CommandResult.Failure(CliExitCodes.InvalidCommand, errorMessage);
+ }
+
+ if (string.Equals(packageName, CorePackageName, StringComparison.OrdinalIgnoreCase))
+ {
+ packageName = CorePackageName;
+ if (!string.Equals(packageVersion, ExecutionContext.IdentitySdkVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ return CommandResult.Failure(
+ CliExitCodes.InvalidCommand,
+ $"This CLI exports {CorePackageName} at {ExecutionContext.IdentitySdkVersion}; {packageVersion} was requested.");
+ }
+ }
+ else
+ {
+ integrations.Add(CreateExactPackageReference(packageName, packageVersion));
+ }
+ }
+
+ var physicalSdkVersion = VersionHelper.GetDefaultSdkVersion();
+ if (string.Equals(packageName, CorePackageName, StringComparison.Ordinal) &&
+ !string.Equals(ExecutionContext.IdentitySdkVersion, physicalSdkVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ return CommandResult.Failure(
+ CliExitCodes.InvalidCommand,
+ $"This CLI reports SDK version {ExecutionContext.IdentitySdkVersion}, but its embedded {CorePackageName} surface is from {physicalSdkVersion}.");
+ }
+
+ var languageInfo = await FindLanguageAsync(language, cancellationToken);
+ if (languageInfo is not null && string.IsNullOrWhiteSpace(languageInfo.CodeGenerator))
+ {
+ return CommandResult.Failure(
+ CliExitCodes.InvalidCommand,
+ string.Format(
+ CultureInfo.CurrentCulture,
+ ErrorStrings.SdkExportLanguageDoesNotSupportCodeGeneration,
+ languageInfo.DisplayName));
+ }
+
+ if (languageInfo is not null)
+ {
+ var codeGenerationPackage = await _languageDiscovery.GetPackageForLanguageAsync(
+ languageInfo.LanguageId,
+ cancellationToken);
+
+ if (codeGenerationPackage is not null)
+ {
+ var requestedCodeGenerationPackage = integrations.FirstOrDefault(integration =>
+ integration.Name.Equals(codeGenerationPackage, StringComparison.OrdinalIgnoreCase));
+ if (requestedCodeGenerationPackage is not null)
+ {
+ return CommandResult.Failure(
+ CliExitCodes.InvalidCommand,
+ string.Format(
+ CultureInfo.CurrentCulture,
+ ErrorStrings.SdkExportGeneratorPackageNotExportable,
+ codeGenerationPackage));
+ }
+
+ // Match sdk generate: repository mode uses the generator from this checkout, while
+ // installed CLIs restore the package that accompanies their build.
+ integrations.Add(IntegrationReference.FromPackage(
+ codeGenerationPackage,
+ ExecutionContext.IdentityVersion));
+ }
+ }
+
+ var exitCode = await ExportApiAsync(
+ languageInfo?.CodeGenerator ?? language,
+ packageName,
+ packageVersion,
+ integrations,
+ cancellationToken);
+
+ return CommandResult.FromExitCode(exitCode);
+ }
+
+ private async Task FindLanguageAsync(string language, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var languages = await _languageDiscovery.GetAvailableLanguagesAsync(cancellationToken);
+ return languages.FirstOrDefault(candidate =>
+ candidate.LanguageId.Value.StartsWith(language, StringComparison.OrdinalIgnoreCase) ||
+ candidate.CodeGenerator.Equals(language, StringComparison.OrdinalIgnoreCase));
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogDebug(ex, "Failed to resolve export language {Language}", language);
+ return null;
+ }
+ }
+
+ private async Task ExportApiAsync(
+ string language,
+ string packageName,
+ string packageVersion,
+ IReadOnlyList integrations,
+ CancellationToken cancellationToken)
+ {
+ var tempDirectory = Directory.CreateTempSubdirectory("aspire-sdk-export-");
+ var tempDirectoryPath = tempDirectory.FullName;
+
+ try
+ {
+ var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(
+ tempDirectoryPath,
+ cancellationToken);
+
+ var prepareResult = await appHostServerProject.PrepareAsync(
+ ExecutionContext.IdentityVersion,
+ integrations,
+ cancellationToken: cancellationToken);
+
+ if (!prepareResult.Success)
+ {
+ InteractionService.DisplayError("Failed to build the API export scanner.");
+ if (prepareResult.Output is not null)
+ {
+ foreach (var (_, line) in prepareResult.Output.GetLines())
+ {
+ InteractionService.DisplayMessage(KnownEmojis.Wrench, line);
+ }
+ }
+
+ return CliExitCodes.FailedToBuildArtifacts;
+ }
+
+ await using var serverSession = _serverSessionFactory.Create(
+ appHostServerProject,
+ environmentVariables: null,
+ debug: false,
+ gracefulShutdownSignaler: null,
+ shutdownService: null,
+ isolateConsole: false,
+ cancellationToken);
+
+ await serverSession.StartAsync();
+ var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
+
+ JsonElement export;
+ try
+ {
+ export = await rpcClient.ExportApiAsync(
+ language,
+ packageName,
+ packageVersion,
+ cancellationToken);
+ }
+ catch (NotSupportedException ex)
+ {
+ InteractionService.DisplayError(ex.Message);
+ return CliExitCodes.InvalidCommand;
+ }
+ catch (RemoteInvocationException ex) when (ex.ErrorCode == (int)JsonRpcErrorCode.InvalidParams)
+ {
+ InteractionService.DisplayError(ex.Message);
+ return CliExitCodes.InvalidCommand;
+ }
+ catch (Exception ex) when (ex is RemoteInvocationException or AppHostCodeGenerationException)
+ {
+ InteractionService.DisplayError(ex.Message);
+ return CliExitCodes.FailedToBuildArtifacts;
+ }
+
+ var json = export.GetRawText()
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace('\r', '\n');
+
+ InteractionService.DisplayRawText(json, consoleOverride: ConsoleOutput.Standard);
+ return CliExitCodes.Success;
+ }
+ finally
+ {
+ try
+ {
+ if (Directory.Exists(tempDirectoryPath))
+ {
+ Directory.Delete(tempDirectoryPath, recursive: true);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to clean up API export directory {TempDirectory}", tempDirectoryPath);
+ }
+ }
+ }
+
+ private static IntegrationReference CreateExactPackageReference(string packageName, string packageVersion)
+ => IntegrationReference.FromPackage(
+ packageName,
+ $"[{packageVersion}]",
+ disableLocalProjectSubstitution: true);
+
+ private static bool TryParsePackage(
+ string argument,
+ out string packageName,
+ out string packageVersion,
+ out string errorMessage)
+ {
+ packageName = string.Empty;
+ packageVersion = string.Empty;
+ errorMessage = string.Empty;
+
+ // Parse the literal PackageName@Version shape. NuGet package IDs cannot contain '@', so an
+ // additional separator is malformed rather than part of the package name.
+ var separatorIndex = argument.LastIndexOf('@');
+ if (separatorIndex <= 0 ||
+ separatorIndex == argument.Length - 1 ||
+ argument.AsSpan(0, separatorIndex).Contains('@'))
+ {
+ errorMessage = $"Invalid package '{argument}'. Expected PackageName@Version.";
+ return false;
+ }
+
+ packageName = argument[..separatorIndex];
+ var requestedVersion = argument[(separatorIndex + 1)..];
+ if (packageName.Any(char.IsWhiteSpace))
+ {
+ errorMessage = $"Invalid package '{packageName}'. NuGet package IDs cannot contain whitespace.";
+ return false;
+ }
+
+ if (requestedVersion.Any(char.IsWhiteSpace))
+ {
+ errorMessage = $"Invalid version '{requestedVersion}'. Expected an exact NuGet version.";
+ return false;
+ }
+
+ if (SemVersion.TryParse(requestedVersion, SemVersionStyles.Any, out var parsedVersion))
+ {
+ packageVersion = parsedVersion.ToString();
+ }
+ else if (!TryNormalizeFourPartVersion(requestedVersion, out packageVersion))
+ {
+ errorMessage = $"Invalid version '{requestedVersion}'. Expected an exact NuGet version.";
+ return false;
+ }
+
+ var buildMetadataIndex = packageVersion.IndexOf('+', StringComparison.Ordinal);
+ if (buildMetadataIndex >= 0)
+ {
+ packageVersion = packageVersion[..buildMetadataIndex];
+ }
+
+ return true;
+ }
+
+ private static bool TryNormalizeFourPartVersion(string version, out string normalizedVersion)
+ {
+ normalizedVersion = string.Empty;
+
+ // NuGet accepts a four-component numeric core that SemVer does not:
+ // 1.2.3.4
+ // 1.2.3.4-preview.1+build
+ // Keep SemVersion as the primary parser so ordinary versions and prerelease labels retain
+ // their existing normalization. For the fallback, parse the numeric core with System.Version
+ // and validate the remaining prerelease/build suffix independently as SemVer.
+ var suffixIndex = version.IndexOfAny(['-', '+']);
+ var numericCore = suffixIndex >= 0 ? version[..suffixIndex] : version;
+ var suffix = suffixIndex >= 0 ? version[suffixIndex..] : string.Empty;
+ var components = numericCore.Split('.');
+ if (components.Length != 4 ||
+ components.Any(static component =>
+ component.Length == 0 || component.Any(static character => !char.IsAsciiDigit(character))) ||
+ !Version.TryParse(numericCore, out var parsedVersion))
+ {
+ return false;
+ }
+
+ var normalizedCore = parsedVersion.Revision == 0
+ ? parsedVersion.ToString(3)
+ : parsedVersion.ToString(4);
+
+ if (suffix.Length == 0)
+ {
+ normalizedVersion = normalizedCore;
+ return true;
+ }
+
+ const string SemVerCore = "0.0.0";
+ if (!SemVersion.TryParse($"{SemVerCore}{suffix}", SemVersionStyles.Strict, out var parsedSuffix))
+ {
+ return false;
+ }
+
+ normalizedVersion = normalizedCore + parsedSuffix.ToString()[SemVerCore.Length..];
+ return true;
+ }
+}
diff --git a/src/Aspire.Cli/Configuration/IntegrationReference.cs b/src/Aspire.Cli/Configuration/IntegrationReference.cs
index 79cbe97b65a..88f657179a4 100644
--- a/src/Aspire.Cli/Configuration/IntegrationReference.cs
+++ b/src/Aspire.Cli/Configuration/IntegrationReference.cs
@@ -24,6 +24,11 @@ internal sealed class IntegrationReference
///
public string? ProjectPath { get; init; }
+ ///
+ /// Gets whether repository mode must restore this package instead of substituting a checkout project.
+ ///
+ public bool DisableLocalProjectSubstitution { get; init; }
+
///
/// Returns true if this is a project reference (has a .csproj path).
///
@@ -40,11 +45,28 @@ internal sealed class IntegrationReference
/// The package name.
/// The NuGet package version.
public static IntegrationReference FromPackage(string name, string version)
+ => FromPackage(name, version, disableLocalProjectSubstitution: false);
+
+ ///
+ /// Creates a NuGet package reference.
+ ///
+ /// The package name.
+ /// The NuGet package version.
+ /// Whether repository mode must restore the package instead of substituting a checkout project.
+ public static IntegrationReference FromPackage(
+ string name,
+ string version,
+ bool disableLocalProjectSubstitution)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(version);
- return new IntegrationReference { Name = name, Version = version };
+ return new IntegrationReference
+ {
+ Name = name,
+ Version = version,
+ DisableLocalProjectSubstitution = disableLocalProjectSubstitution
+ };
}
///
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index bc9190453c8..3be3b5a567c 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -669,6 +669,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddSingleton();
builder.Services.AddTransient();
diff --git a/src/Aspire.Cli/Projects/AppHostRpcClient.cs b/src/Aspire.Cli/Projects/AppHostRpcClient.cs
index 703cb36e2c3..e922d0aa91b 100644
--- a/src/Aspire.Cli/Projects/AppHostRpcClient.cs
+++ b/src/Aspire.Cli/Projects/AppHostRpcClient.cs
@@ -110,6 +110,10 @@ public Task> GenerateCodeForAssemblyAsync(string lang
public Task GetCapabilitiesForAssembliesAsync(IReadOnlyList assemblyNames, CancellationToken cancellationToken)
=> InvokeCodeGenerationAsync("getCapabilities", [assemblyNames], cancellationToken);
+ ///
+ public Task ExportApiAsync(string languageId, string packageName, string packageVersion, CancellationToken cancellationToken)
+ => InvokeCodeGenerationAsync("exportApi", [languageId, packageName, packageVersion], cancellationToken);
+
///
public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken)
=> _jsonRpc.InvokeWithProfilingAsync(_profilingTelemetry, ConnectionName, methodName, parameters, cancellationToken);
diff --git a/src/Aspire.Cli/Projects/AppHostServerClosureSnapshots.cs b/src/Aspire.Cli/Projects/AppHostServerClosureSnapshots.cs
index 9f40442407e..cdd39722f78 100644
--- a/src/Aspire.Cli/Projects/AppHostServerClosureSnapshots.cs
+++ b/src/Aspire.Cli/Projects/AppHostServerClosureSnapshots.cs
@@ -171,7 +171,9 @@ public IntegrationPackageProbeManifest CreatePackageProbeManifest()
{
Name = Path.GetFileNameWithoutExtension(entry.RelativePath),
Culture = TryGetSatelliteCulture(entry),
- Path = entry.SourcePath
+ Path = entry.SourcePath,
+ PackageId = entry.PackageId,
+ PackageVersion = entry.PackageVersion
});
}
diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
index 2e5ef76b1cb..bf80bdcb6c1 100644
--- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
+++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
@@ -188,7 +188,8 @@ private XDocument CreateProjectFile(IEnumerable integratio
new XElement("IsAspireProjectResource", "false")));
}
}
- else if (integration.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase))
+ else if (integration.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase) &&
+ !integration.DisableLocalProjectSubstitution)
{
var projectPath = Path.Combine(_repoRoot, "src", integration.Name, $"{integration.Name}.csproj");
if (File.Exists(projectPath) && addedProjects.Add(integration.Name))
@@ -227,7 +228,7 @@ private XDocument CreateProjectFile(IEnumerable integratio
doc.Root!.Add(new XElement("ItemGroup",
otherPackages.Select(p => new XElement("PackageReference",
new XAttribute("Include", p.Name),
- new XAttribute("Version", p.Version)))));
+ new XAttribute("VersionOverride", p.Version)))));
}
// Add imports for in-repo AppHost building
diff --git a/src/Aspire.Cli/Projects/IAppHostRpcClient.cs b/src/Aspire.Cli/Projects/IAppHostRpcClient.cs
index 5051fe95c2d..4cd654282f2 100644
--- a/src/Aspire.Cli/Projects/IAppHostRpcClient.cs
+++ b/src/Aspire.Cli/Projects/IAppHostRpcClient.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Text.Json;
using Aspire.Cli.Commands.Sdk;
using Aspire.TypeSystem;
@@ -71,6 +72,19 @@ Task> ScaffoldAppHostAsync(
/// A token to cancel the operation.
Task GetCapabilitiesForAssembliesAsync(IReadOnlyList assemblyNames, CancellationToken cancellationToken);
+ ///
+ /// Exports the canonical API reference document for a package in the target language.
+ ///
+ ///
+ /// Calls the exportApi RPC method. The document is language-defined and is returned as raw
+ /// JSON so the CLI never has to understand or reshape it.
+ ///
+ /// The target language identifier.
+ /// The package to export documentation for.
+ /// The exact resolved version of the package.
+ /// A token to cancel the operation.
+ Task ExportApiAsync(string languageId, string packageName, string packageVersion, CancellationToken cancellationToken);
+
// ═══════════════════════════════════════════════════════════════
// GENERIC INVOKE (for future/custom calls)
// ═══════════════════════════════════════════════════════════════
diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
index 23a105ab581..298f1666b59 100644
--- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
+++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
@@ -230,6 +230,10 @@ await IntegrationPackageProbeManifest.WriteAsync(
ChannelName: requestedChannel,
NeedsCodeGeneration: true);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (AppHostServerPrepareFailedException ex)
{
_logger.LogError(ex, "Failed to prepare prebuilt AppHost server");
diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs
index e4599559dcb..5ac9a62950e 100644
--- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs
@@ -488,7 +488,7 @@ public static string ConfigurationFileMustBeJsonObject {
return ResourceManager.GetString("ConfigurationFileMustBeJsonObject", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to The integration project could not be built..
///
@@ -497,7 +497,7 @@ public static string IntegrationBuildFailed {
return ResourceManager.GetString("IntegrationBuildFailed", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects..
///
@@ -506,5 +506,23 @@ public static string IntegrationBuildPackageDowngradeFailed {
return ResourceManager.GetString("IntegrationBuildPackageDowngradeFailed", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to SDK API export is not supported for {0} because it does not use a code generator..
+ ///
+ public static string SdkExportLanguageDoesNotSupportCodeGeneration {
+ get {
+ return ResourceManager.GetString("SdkExportLanguageDoesNotSupportCodeGeneration", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface..
+ ///
+ public static string SdkExportGeneratorPackageNotExportable {
+ get {
+ return ResourceManager.GetString("SdkExportGeneratorPackageNotExportable", resourceCulture);
+ }
+ }
}
}
diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx
index 95334f66125..63240d12ffd 100644
--- a/src/Aspire.Cli/Resources/ErrorStrings.resx
+++ b/src/Aspire.Cli/Resources/ErrorStrings.resx
@@ -298,4 +298,12 @@
The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects.
{0} is the version of the running Aspire CLI, for example "13.5.0".
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf
index ba2329333ab..aeece100574 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf
@@ -267,6 +267,16 @@
Projekt neobsahuje hostitele aplikací Aspire.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
Funkce AppHost pro jednosouborové scénáře není povolená. Chcete-li používat .cs soubory AppHost, povolte tuto funkci v konfiguraci
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf
index 6d520ce31e4..35a10b30774 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf
@@ -267,6 +267,16 @@
Das Projekt enthält keinen Aspire-AppHost.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
Das AppHost-Feature für einzelne Dateien ist nicht aktiviert. Um .cs-AppHost-Dateien zu verwenden, aktivieren Sie das Feature über die Konfiguration.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf
index 885f5acc240..6efed983274 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf
@@ -267,6 +267,16 @@
El proyecto no contiene ningún apphost de Aspire.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
La característica AppHost de un solo archivo no está habilitada. Para usar archivos AppHost .cs, habilite la función mediante la configuración.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf
index b4e0cc9568a..6649f8da730 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf
@@ -267,6 +267,16 @@
Le projet ne contient pas d’Aspire AppHost.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
La fonctionnalité d’hôte d’application à fichier unique n’est pas activée. Pour utiliser les fichiers AppHost .cs, activez la fonctionnalité via la configuration.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf
index b8928b75884..c5463103b6a 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf
@@ -267,6 +267,16 @@
Il progetto non contiene un AppHost Aspire.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
La funzionalità AppHost a file singolo non è abilitata. Per usare i file AppHost .cs, abilitare la funzionalità tramite la configurazione.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf
index 86ad16f6593..bb771c98ef2 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf
@@ -267,6 +267,16 @@
プロジェクトに Aspire AppHost が含まれていません。
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
単一ファイル AppHost 機能が有効になっていません。.cs AppHost ファイルを使用するには、構成を使用して機能を有効にします。
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf
index d340c4db062..6f7fb16bb28 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf
@@ -267,6 +267,16 @@
프로젝트에 Aspire AppHost가 포함되어 있지 않습니다.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
단일 파일 AppHost 기능을 사용할 수 없습니다. .cs AppHost 파일을 사용하려면 설정을 사용하여 기능을 활성화해야 합니다.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf
index 40e17a49c2f..b063951886d 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf
@@ -267,6 +267,16 @@
Projekt nie zawiera hosta AppHost platformy Aspire.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
Funkcja hosta AppHost z jednym plikiem nie jest włączona. Aby użyć plików .cs hosta AppHost, włącz tę funkcję przy użyciu konfiguracji.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf
index 0338167c801..185a2c706f9 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf
@@ -267,6 +267,16 @@
O projeto não contém um AppHost do Aspire.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
O recurso AppHost de arquivo único não está habilitado. Para usar arquivos AppHost .cs, habilite o recurso usando a configuração.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf
index a40c7e83b1e..e75d77e357c 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf
@@ -267,6 +267,16 @@
Проект не содержит хост приложений Aspire.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
Функция одиночного файла AppHost не включена. Чтобы использовать CS-файлы AppHost, включите эту функцию с помощью конфигурации.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf
index 9e8859bf815..b1fc5eb5e35 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf
@@ -267,6 +267,16 @@
Proje bir Aspire AppHost içermiyor.
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
Tek dosya Uygulama Ana İşlemi özelliği etkinleştirilmemiştir. .cs AppHost dosyalarını kullanmak için, yapılandırmaları kullanarak özelliği etkinleştirin.
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf
index 7ddd0388dbf..db6e0a72688 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf
@@ -267,6 +267,16 @@
该项目不包含 Aspire 应用主机。
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
未启用单文件应用主机功能。要使用 .cs AppHost 文件,请通过配置启用该功能。
diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf
index 8718b39e291..130e527db2a 100644
--- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf
@@ -267,6 +267,16 @@
該專案不包含 Aspire AppHost。
+
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ SDK API export cannot export {0} because that package supplies the selected language's code generator instead of an integration API surface.
+ {0} is the code-generation package name.
+
+
+ SDK API export is not supported for {0} because it does not use a code generator.
+ SDK API export is not supported for {0} because it does not use a code generator.
+ {0} is the AppHost language display name, for example "C# (.NET)".
+
Single file AppHost feature is not enabled. To use .cs AppHost files, enable the feature using configuration.
未啟用單一檔案 AppHost 功能。若要使用 .cs AppHost 檔案,請使用設定啟用此功能。
diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptApiReferenceExporter.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptApiReferenceExporter.cs
new file mode 100644
index 00000000000..f0c09605c0f
--- /dev/null
+++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptApiReferenceExporter.cs
@@ -0,0 +1,63 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Aspire.TypeSystem;
+
+namespace Aspire.Hosting.CodeGeneration.TypeScript;
+
+///
+/// Exports the canonical TypeScript API reference for the surface
+/// generates.
+///
+///
+///
+/// This deliberately lives on its own type rather than on .
+/// Aspire.TypeSystem is force-shared from the apphost server's default
+/// (see
+/// src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs) and freezes its strong-name
+/// AssemblyVersion at a constant so an older CLI still binds a newer SDK's codegen assembly.
+/// Version binding therefore succeeds, but an older CLI's bundled copy has no
+/// in it: the interface is new. A type's interface list is
+/// resolved eagerly when the type loads, so putting the interface on the code generator would make
+/// the generator itself unloadable under any CLI that predates the interface, and
+/// CodeGeneratorResolver would then find no TypeScript generator at all — TypeScript
+/// generation, not just export, would stop working.
+///
+///
+/// Keeping export on a separate type confines that loss to the feature the older CLI cannot use
+/// anyway: CodeGeneratorResolver salvages the loadable types out of the
+/// , so the generator survives and only
+/// this type disappears.
+///
+///
+internal sealed class AtsTypeScriptApiReferenceExporter : IApiReferenceExporter
+{
+ ///
+ public string Language => "TypeScript";
+
+ ///
+ public JsonElement ExportApi(
+ AtsContext context,
+ ApiReferenceExportOptions options,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(options);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Build the projector from the same context the generator would use, so the exported
+ // documentation describes the exact signatures generation would emit rather than a
+ // second, independently derived reading of the ATS context.
+ var projector = new TypeScriptApiProjector(context);
+ var model = projector.BuildApiModel(
+ new TypeScriptApiPackageIdentity(options.PackageName, options.PackageVersion),
+ options.ExportingAssemblyNames,
+ cancellationToken);
+
+ // JsonDocument.Parse + Clone rather than JsonSerializer, because this assembly is
+ // AOT-compatible and the serializer's reflection-based overloads are not.
+ using var document = JsonDocument.Parse(TypeScriptApiExportWriter.WriteToJson(model));
+ return document.RootElement.Clone();
+ }
+}
diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs
index 417bb5f8b7f..a26bd087681 100644
--- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs
+++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs
@@ -3,9 +3,6 @@
using System.Globalization;
using System.Text;
-using System.Text.Json.Nodes;
-using Aspire.Shared.CodeGeneration;
-using Aspire.Shared.Json;
using Aspire.TypeSystem;
namespace Aspire.Hosting.CodeGeneration.TypeScript;
@@ -23,13 +20,6 @@ internal sealed class BuilderModel
public AtsTypeRef? TargetType { get; init; }
}
-internal sealed class ExportedValueTreeNode
-{
- public Dictionary Children { get; } = new(StringComparer.Ordinal);
-
- public AtsExportedValueInfo? Value { get; set; }
-}
-
///
/// Generates a TypeScript SDK using the ATS (Aspire Type System) capability-based API.
/// Produces typed builder classes with fluent methods that use invokeCapability().
@@ -110,364 +100,12 @@ internal sealed class AtsTypeScriptCodeGenerator : ICodeGenerator
{
private TextWriter _writer = null!;
- // Mapping of typeId -> wrapper class name for all generated wrapper types
- // Used to resolve parameter types to wrapper classes instead of handle types
- private readonly Dictionary _wrapperClassNames = new(StringComparer.Ordinal);
-
- // Wrapper classes are deduplicated by generated class name, but their handles are branded by
- // TypeId. Keep the retained TypeId so every canonical implementation receives its branded handle.
- private readonly Dictionary _concreteTypeIds = new(StringComparer.Ordinal);
- private readonly Dictionary _typeRefsById = new(StringComparer.Ordinal);
-
- // Set of type IDs that have Promise wrappers (chainable or directly returned resource builders)
- // Used to determine return types for methods
- private readonly HashSet _typesWithPromiseWrappers = new(StringComparer.Ordinal);
-
- // Set of generated options interfaces to avoid duplicates
- private readonly HashSet _generatedOptionsInterfaces = new(StringComparer.Ordinal);
-
- // Collected options interfaces to generate (interface name -> list of optional params)
- private readonly Dictionary> _optionsInterfacesToGenerate = new(StringComparer.Ordinal);
-
- // Mapping from CapabilityId to the options interface name it should use.
- // When methods share a name but have incompatible callback parameter types,
- // separate options interfaces are generated with numeric suffixes.
- private readonly Dictionary _capabilityOptionsInterfaceMap = new(StringComparer.Ordinal);
-
- // Mapping of enum type IDs to TypeScript enum names
- private readonly Dictionary _enumTypeNames = new(StringComparer.Ordinal);
-
- // Mapping of handle type IDs to XML documentation captured during ATS scanning.
- private readonly Dictionary _handleDocumentationById = new(StringComparer.Ordinal);
-
- // Mapping of DTO type IDs to DTO metadata for generated argument marshalling.
- private readonly Dictionary _dtoTypesById = new(StringComparer.Ordinal);
-
- private static string GetInterfaceName(string className) => className;
-
- private static string GetPromiseInterfaceName(string className) => $"{className}Promise";
-
- private static string GetImplementationClassName(string className) => $"{className}Impl";
-
- private static string GetImplementationPromiseClassName(string className) => $"{className}PromiseImpl";
-
- private static string GetReferenceExpressionInterfaceName() => "ReferenceExpression";
-
- private static string GetCancellationTokenInterfaceName() => "CancellationToken";
-
- private static string GetHandleReferenceInterfaceName() => "HandleReference";
-
- private static string GetInputTypeEnumName() => "InputType";
-
- private static string GetInteractionInputInterfaceName() => "InteractionInput";
-
- private static string GetInteractionInputCollectionClassName() => "InteractionInputCollection";
-
- private const string InputTypeTypeId = "enum:Aspire.Hosting.InputType";
-
- private const string InteractionInputTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInput";
-
- private const string InteractionInputCollectionTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInputCollection";
-
- private string GetConcreteClassName(string typeId) => _wrapperClassNames.GetValueOrDefault(typeId)
- ?? DeriveClassName(typeId);
-
- private string GetConcreteTypeId(string typeId) => _concreteTypeIds.GetValueOrDefault(typeId)
- ?? typeId;
-
- private string GetConcreteHandleTypeName(string typeId) => GetHandleTypeName(GetConcreteTypeId(typeId));
-
- private string GetPublicPromiseInterfaceName(string typeId) => GetPromiseInterfaceName(GetConcreteClassName(typeId));
-
- private static bool IsHandleType(AtsTypeRef? typeRef) =>
- typeRef is { Category: AtsTypeCategory.Handle };
-
- ///
- /// Maps an AtsTypeRef to a TypeScript type using category-based dispatch.
- /// This is the preferred method - uses type metadata rather than string parsing.
- ///
- private string MapTypeRefToTypeScript(AtsTypeRef? typeRef)
- {
- if (typeRef is null)
- {
- return "unknown";
- }
-
- // ReferenceExpression is a value type defined in base.mts, not a handle-based wrapper
- if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId)
- {
- return GetReferenceExpressionInterfaceName();
- }
-
- if (typeRef.TypeId == InputTypeTypeId)
- {
- return GetInputTypeEnumName();
- }
-
- if (typeRef.TypeId == InteractionInputTypeId)
- {
- return GetInteractionInputInterfaceName();
- }
-
- if (typeRef.TypeId == InteractionInputCollectionTypeId)
- {
- return GetInteractionInputCollectionClassName();
- }
-
- // Check for wrapper class first (handles custom types like resource builders)
- if (_wrapperClassNames.TryGetValue(typeRef.TypeId, out var wrapperClassName))
- {
- return GetInterfaceName(wrapperClassName);
- }
-
- var mappedType = typeRef.Category switch
- {
- AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId),
- AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
- AtsTypeCategory.Handle => GetWrapperOrHandleName(typeRef.TypeId),
- AtsTypeCategory.Dto => GetDtoInterfaceName(typeRef.TypeId),
- AtsTypeCategory.Callback => "Function", // Callbacks handled separately with full signature
- AtsTypeCategory.Array => $"{MapTypeRefToTypeScript(typeRef.ElementType)}[]",
- AtsTypeCategory.List => $"AspireList<{MapTypeRefToTypeScript(typeRef.ElementType)}>",
- AtsTypeCategory.Dict => typeRef.IsReadOnly
- ? $"Record<{MapTypeRefToTypeScript(typeRef.KeyType)}, {MapTypeRefToTypeScript(typeRef.ValueType)}>"
- : $"AspireDict<{MapTypeRefToTypeScript(typeRef.KeyType)}, {MapTypeRefToTypeScript(typeRef.ValueType)}>",
- AtsTypeCategory.Union => MapUnionTypeToTypeScript(typeRef),
- AtsTypeCategory.Unknown => "any", // Unknown types use 'any' since they're not in the ATS universe
- _ => "any" // Fallback for any unhandled categories
- };
- return ApplyNullableType(typeRef, mappedType);
- }
-
- private static string ApplyNullableType(AtsTypeRef typeRef, string mappedType)
- {
- if (typeRef.IsNullable != true || typeRef.Category is not (AtsTypeCategory.Primitive or AtsTypeCategory.Enum))
- {
- return mappedType;
- }
-
- return typeRef.TypeId is AtsConstants.Void or AtsConstants.Any or AtsConstants.CancellationToken
- ? mappedType
- : $"{mappedType} | null";
- }
-
- private string MapDtoPropertyTypeToTypeScript(AtsTypeRef? typeRef)
- {
- if (typeRef is null)
- {
- return "unknown";
- }
-
- return typeRef.Category switch
- {
- AtsTypeCategory.Array or AtsTypeCategory.List => $"{MapDtoPropertyTypeToTypeScript(typeRef.ElementType)}[]",
- AtsTypeCategory.Dict => $"Record<{MapDtoPropertyTypeToTypeScript(typeRef.KeyType)}, {MapDtoPropertyTypeToTypeScript(typeRef.ValueType)}>",
- AtsTypeCategory.Union => MapDtoUnionTypeToTypeScript(typeRef),
- _ => MapTypeRefToTypeScript(typeRef)
- };
- }
-
- private string MapDtoUnionTypeToTypeScript(AtsTypeRef typeRef)
- {
- if (typeRef.UnionTypes is null || typeRef.UnionTypes.Count == 0)
- {
- return "unknown";
- }
-
- var memberTypes = typeRef.UnionTypes
- .Select(MapDtoPropertyTypeToTypeScript)
- .Distinct();
-
- return string.Join(" | ", memberTypes);
- }
-
- ///
- /// Maps primitive type IDs to TypeScript types.
- ///
- private static string MapPrimitiveType(string typeId) => typeId switch
- {
- AtsConstants.String or AtsConstants.Char => "string",
- AtsConstants.Number => "number",
- AtsConstants.Boolean => "boolean",
- AtsConstants.Void => "void",
- AtsConstants.Any => "any",
- AtsConstants.DateTime or AtsConstants.DateTimeOffset or
- AtsConstants.DateOnly or AtsConstants.TimeOnly => "string",
- AtsConstants.TimeSpan => "number",
- AtsConstants.Guid or AtsConstants.Uri => "string",
- AtsConstants.CancellationToken => GetCancellationTokenInterfaceName(),
- _ => typeId
- };
-
- ///
- /// Maps an enum type ID to the generated TypeScript enum name.
- /// Throws if the enum type wasn't collected during scanning.
- ///
- private string MapEnumType(string typeId)
- {
- if (!_enumTypeNames.TryGetValue(typeId, out var enumName))
- {
- throw new InvalidOperationException(
- $"Enum type '{typeId}' was not found in the scanned enum types. " +
- $"This indicates the enum type was not discovered during assembly scanning.");
- }
- return enumName;
- }
-
- ///
- /// Maps a union type to TypeScript union syntax (T1 | T2 | ...).
- ///
- private string MapUnionTypeToTypeScript(AtsTypeRef typeRef)
- {
- if (typeRef.UnionTypes == null || typeRef.UnionTypes.Count == 0)
- {
- return "unknown";
- }
-
- var memberTypes = typeRef.UnionTypes
- .Select(MapTypeRefToTypeScript)
- .Distinct();
-
- return string.Join(" | ", memberTypes);
- }
-
- ///
- /// Gets the wrapper class name or handle type name for a handle type ID.
- /// Prefers wrapper class if one exists, otherwise generates a handle type name.
- ///
- private string GetWrapperOrHandleName(string typeId)
- {
- if (_wrapperClassNames.TryGetValue(typeId, out var wrapperClassName))
- {
- return wrapperClassName;
- }
- return GetHandleTypeName(typeId);
- }
-
///
- /// Gets a TypeScript interface name for a DTO type.
+ /// Owns every TypeScript-specific resolution decision. Assigned per generation because it is
+ /// built from the context being generated; the canonical API exporter builds the same projector
+ /// from the same context so documentation cannot drift from emitted source.
///
- private static string GetDtoInterfaceName(string typeId)
- {
- return ExtractSimpleTypeName(typeId);
- }
-
- ///
- /// Maps a user-supplied input type to TypeScript.
- /// For interface handle types, generated APIs accept any handle-bearing wrapper instance.
- /// For cancellation tokens, generated APIs accept either an AbortSignal or a transport-safe CancellationToken.
- ///
- ///
- /// Handle types are widened to accept Awaitable<T> so callers can pass un-awaited
- /// fluent chains directly. Examples:
- ///
- /// // Input: RedisResource handle type
- /// // Output: "Awaitable<RedisResource>"
- ///
- /// // Input: Union of string | RedisResource
- /// // Output: "string | Awaitable<RedisResource>"
- ///
- /// // Input: CancellationToken type
- /// // Output: "AbortSignal | CancellationToken"
- ///
- /// // Input: plain string type
- /// // Output: "string"
- ///
- ///
- private string MapInputTypeToTypeScript(AtsTypeRef? typeRef)
- {
- if (typeRef?.Category == AtsTypeCategory.Union)
- {
- return MapInputUnionTypeToTypeScript(typeRef);
- }
-
- if (IsInterfaceHandleType(typeRef))
- {
- if (TryMapInterfaceInputTypeToTypeScript(typeRef!) is { } interfaceInputType)
- {
- return $"Awaitable<{interfaceInputType}>";
- }
-
- var handleName = GetHandleReferenceInterfaceName();
- return $"Awaitable<{handleName}>";
- }
-
- if (IsHandleType(typeRef) && _wrapperClassNames.TryGetValue(typeRef!.TypeId, out var className))
- {
- var ifaceName = GetInterfaceName(className);
- return $"Awaitable<{ifaceName}>";
- }
-
- if (typeRef?.TypeId == InteractionInputCollectionTypeId)
- {
- return $"Awaitable<{GetInteractionInputCollectionClassName()}>";
- }
-
- if (IsCancellationTokenType(typeRef))
- {
- return $"AbortSignal | {GetCancellationTokenInterfaceName()}";
- }
-
- return MapTypeRefToTypeScript(typeRef);
- }
-
- private string MapInputUnionTypeToTypeScript(AtsTypeRef typeRef)
- {
- if (typeRef.UnionTypes == null || typeRef.UnionTypes.Count == 0)
- {
- throw new InvalidOperationException("Union input types must define at least one member type.");
- }
-
- // Build union structurally: each member is mapped individually.
- // Handle types become Awaitable, non-handle types pass through as-is.
- var nonHandleTypes = new List();
- var handleTypeNames = new List();
-
- foreach (var memberRef in typeRef.UnionTypes)
- {
- if (IsWidenedHandleType(memberRef))
- {
- // Get the base type name without Awaitable wrapper for combining
- var baseName = IsInterfaceHandleType(memberRef) && TryMapInterfaceInputTypeToTypeScript(memberRef) is { } expanded
- ? expanded
- : MapTypeRefToTypeScript(memberRef);
- nonHandleTypes.Add(baseName);
- handleTypeNames.Add(baseName);
- }
- else
- {
- nonHandleTypes.Add(MapInputTypeToTypeScript(memberRef));
- }
- }
-
- var allBaseTypes = nonHandleTypes
- .SelectMany(t => t.Split(" | ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
- .Distinct(StringComparer.Ordinal)
- .ToList();
-
- if (handleTypeNames.Count > 0)
- {
- var handleUnion = string.Join(" | ", handleTypeNames
- .SelectMany(t => t.Split(" | ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
- .Distinct(StringComparer.Ordinal));
- return string.Join(" | ", allBaseTypes) + $" | Awaitable<{handleUnion}>";
- }
-
- return string.Join(" | ", allBaseTypes);
- }
-
- ///
- /// Maps a parameter to its TypeScript type, handling callbacks specially.
- ///
- private string MapParameterToTypeScript(AtsParameterInfo param)
- {
- if (param.IsCallback)
- {
- return GenerateCallbackTypeSignature(param.CallbackParameters, param.CallbackReturnType);
- }
-
- return MapInputTypeToTypeScript(param.Type);
- }
+ private TypeScriptApiProjector _projector = null!;
private void WriteCapabilityDocComment(
string indent,
@@ -669,68 +307,9 @@ private static string ConvertAtsReferencesToJsDocLinks(string text)
return builder.ToString();
}
- private string? TryMapInterfaceInputTypeToTypeScript(AtsTypeRef typeRef)
- {
- List? assignableWrapperTypes = null;
-
- foreach (var candidateTypeRef in _typeRefsById.Values)
- {
- if (!IsAssignableToInterface(candidateTypeRef, typeRef.TypeId) ||
- !_wrapperClassNames.TryGetValue(candidateTypeRef.TypeId, out var wrapperClassName))
- {
- continue;
- }
-
- assignableWrapperTypes ??= [];
- assignableWrapperTypes.Add(wrapperClassName);
- }
-
- if (assignableWrapperTypes is not { Count: > 0 })
- {
- return null;
- }
-
- return string.Join(" | ", assignableWrapperTypes
- .Distinct(StringComparer.Ordinal)
- .OrderBy(static n => n, StringComparer.Ordinal));
- }
-
- private static bool IsAssignableToInterface(AtsTypeRef candidateTypeRef, string interfaceTypeId)
- {
- if (string.Equals(candidateTypeRef.TypeId, interfaceTypeId, StringComparison.Ordinal))
- {
- return true;
- }
-
- foreach (var implementedInterface in candidateTypeRef.ImplementedInterfaces)
- {
- if (IsAssignableToInterface(implementedInterface, interfaceTypeId))
- {
- return true;
- }
- }
-
- return candidateTypeRef.BaseType is not null && IsAssignableToInterface(candidateTypeRef.BaseType, interfaceTypeId);
- }
-
- ///
- /// Checks if a type reference is an interface handle type.
- /// Interface handles need union types to accept wrapper classes.
- ///
- private static bool IsInterfaceHandleType(AtsTypeRef? typeRef)
- {
- if (typeRef == null)
- {
- return false;
- }
- return typeRef.Category == AtsTypeCategory.Handle && typeRef.IsInterface;
- }
-
- private static bool IsCancellationTokenType(AtsTypeRef? typeRef) => typeRef?.TypeId == AtsConstants.CancellationToken;
-
private static string GetRpcArgumentValueExpression(string parameterName, AtsTypeRef? typeRef)
{
- if (IsCancellationTokenType(typeRef))
+ if (TypeScriptApiProjector.IsCancellationTokenType(typeRef))
{
return $"CancellationToken.fromValue({parameterName})";
}
@@ -792,7 +371,7 @@ private bool TryGetDtoCallbackMarshallingProperties(AtsTypeRef? typeRef, out Lis
marshallingProperties = [];
if (typeRef?.Category != AtsTypeCategory.Dto ||
- !_dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoType))
+ !_projector.DtoTypesById.TryGetValue(typeRef.TypeId, out var dtoType))
{
return false;
}
@@ -807,7 +386,7 @@ private bool TryGetDtoCallbackMarshallingProperties(AtsTypeRef? typeRef, out Lis
private bool RequiresDtoCallbackMarshalling(AtsTypeRef? typeRef, HashSet? visitedDtoTypeIds = null)
{
if (typeRef?.Category != AtsTypeCategory.Dto ||
- !_dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoType))
+ !_projector.DtoTypesById.TryGetValue(typeRef.TypeId, out var dtoType))
{
return false;
}
@@ -855,16 +434,6 @@ public Dictionary GenerateDistributedApplication(AtsContext cont
return files;
}
- ///
- /// Gets a valid TypeScript method name from a capability method name.
- /// Handles dotted names like "EnvironmentContext.resource" by extracting just the final part.
- ///
- private static string GetTypeScriptMethodName(string methodName)
- {
- var dotIndex = methodName.LastIndexOf('.');
- return dotIndex >= 0 ? methodName[(dotIndex + 1)..] : methodName;
- }
-
///
/// Generates the aspire.mts SDK file with capability-based API.
///
@@ -927,143 +496,20 @@ import type {
""");
WriteLine();
- var capabilities = context.Capabilities;
+ // Resolve every TypeScript-specific decision once. The canonical API exporter consumes the
+ // same projector, so documented signatures cannot drift from the signatures emitted here.
+ _projector = new TypeScriptApiProjector(context);
+ var resolved = _projector.Resolved;
+
var dtoTypes = context.DtoTypes;
var enumTypes = context.EnumTypes;
var exportedValues = context.ExportedValues;
- var directlyReturnedResourceTypesByClassName = capabilities
- .Where(capability => capability.CapabilityKind != AtsCapabilityKind.PropertySetter)
- .Select(capability => capability.ReturnType)
- .Where(typeRef => typeRef?.IsResourceBuilder == true)
- .Select(typeRef => typeRef!)
- .DistinctBy(typeRef => typeRef.TypeId, StringComparer.Ordinal)
- .GroupBy(typeRef => DeriveClassName(typeRef.TypeId), StringComparer.Ordinal)
- .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
-
- // Get builder models (flattened - each builder has all its applicable capabilities)
- var allBuilders = CreateBuilderModels(capabilities);
- var entryPoints = GetEntryPointCapabilities(capabilities);
-
- // All builders (no special filtering)
- var builders = allBuilders;
-
- // Entry point methods that don't extend any type go on AspireClient
- var clientMethods = entryPoints
- .Where(c => string.IsNullOrEmpty(c.TargetTypeId))
- .ToList();
-
- // Collect all unique type IDs for handle type aliases
- // Exclude DTO types - they have their own interfaces, not handle aliases
- var dtoTypeIds = new HashSet(dtoTypes.Select(d => d.TypeId));
- var typeIds = new HashSet();
- foreach (var typeId in CollectAllReferencedTypes(capabilities).Keys)
- {
- if (!dtoTypeIds.Contains(typeId))
- {
- typeIds.Add(typeId);
- }
- }
- // Ensure all builder type IDs have handle type aliases.
- // CreateBuilderModels discovers additional resource types via CollectAllReferencedTypes
- // (e.g. types that appear only in return types or parameters but aren't direct capability targets).
- // Without this, the builder class references a handle type that was never declared.
- foreach (var builder in builders)
- {
- if (!dtoTypeIds.Contains(builder.TypeId))
- {
- typeIds.Add(builder.TypeId);
- }
- }
-
- // Separate builders into categories:
- // 1. Resource builders: IResource*, ContainerResource, etc.
- // 2. Type classes: everything else (context types, wrapper types)
- var resourceBuilders = builders.Where(b => b.TargetType?.IsResourceBuilder == true).ToList();
- var typeClasses = builders.Where(b => b.TargetType?.IsResourceBuilder != true).ToList();
- // Build wrapper class name mapping before DTO generation so callback
- // properties can reference wrapper classes instead of raw handle aliases.
- _wrapperClassNames.Clear();
- _concreteTypeIds.Clear();
- _typeRefsById.Clear();
- _typesWithPromiseWrappers.Clear();
- _generatedOptionsInterfaces.Clear();
- _optionsInterfacesToGenerate.Clear();
- _capabilityOptionsInterfaceMap.Clear();
- _handleDocumentationById.Clear();
- _dtoTypesById.Clear();
-
- foreach (var dtoType in dtoTypes)
- {
- _dtoTypesById[dtoType.TypeId] = dtoType;
- }
-
- foreach (var handleType in context.HandleTypes)
- {
- if (handleType.Documentation is not null)
- {
- _handleDocumentationById[handleType.AtsTypeId] = handleType.Documentation;
- }
- }
-
- foreach (var builder in resourceBuilders)
- {
- _wrapperClassNames[builder.TypeId] = builder.BuilderClassName;
- _concreteTypeIds[builder.TypeId] = builder.TypeId;
- if (builder.TargetType is { } targetType)
- {
- _typeRefsById[builder.TypeId] = targetType;
- }
- directlyReturnedResourceTypesByClassName.TryGetValue(builder.BuilderClassName, out var directlyReturnedAliases);
-
- // Builder models are deduplicated by generated class name, so the retained TypeId may
- // differ from a directly returned interface TypeId. Register the retained TypeId to emit
- // one declaration pair and every returned alias so return sites resolve to that pair.
- if (HasChainableMethods(builder) || directlyReturnedAliases is not null)
- {
- _typesWithPromiseWrappers.Add(builder.TypeId);
-
- if (directlyReturnedAliases is not null)
- {
- foreach (var alias in directlyReturnedAliases)
- {
- _typesWithPromiseWrappers.Add(alias.TypeId);
- _wrapperClassNames[alias.TypeId] = builder.BuilderClassName;
- _concreteTypeIds[alias.TypeId] = builder.TypeId;
- _typeRefsById[alias.TypeId] = builder.TargetType ?? alias;
- }
- }
- }
- }
- foreach (var typeClass in typeClasses)
- {
- _wrapperClassNames[typeClass.TypeId] = DeriveClassName(typeClass.TypeId);
- _concreteTypeIds[typeClass.TypeId] = typeClass.TypeId;
- if (typeClass.TargetType is { } targetType)
- {
- _typeRefsById[typeClass.TypeId] = targetType;
- }
- // Type classes with methods get Promise wrappers
- if (HasChainableMethods(typeClass))
- {
- _typesWithPromiseWrappers.Add(typeClass.TypeId);
- }
- }
-
- // InteractionInputCollection is a hand-written base.mts type: its by-name accessors
- // (value/get/required/requiredValue) are client-side conveniences, not ATS capabilities, so
- // it is never registered as a generated type class. Register it as a promise-wrapper type so
- // collection-returning getters (result.inputs(), validationContext.inputs(), command
- // arguments()) emit the fluent InteractionInputCollectionPromise thenable instead of a bare
- // Promise. That lets callers chain `await x.inputs().value("c")`
- // without an intermediate await, matching the C#/Go/Java/Python surfaces. The wrapper
- // (InteractionInputCollectionPromise / InteractionInputCollectionPromiseImpl) is hand-written
- // in base.mts; it is intentionally absent from _wrapperClassNames so the getter impl keeps
- // using the marshaller-based collection construction rather than a handle+Impl wrapper.
- _typesWithPromiseWrappers.Add(InteractionInputCollectionTypeId);
- // Note: ReferenceExpression is intentionally NOT added to _wrapperClassNames.
- // It is a value type defined in base.mts with a private constructor and static factory,
- // not a handle-based wrapper. It is handled via MapTypeRefToTypeScript instead.
+ var builders = resolved.Builders;
+ var resourceBuilders = resolved.ResourceBuilders;
+ var typeClasses = resolved.TypeClasses;
+ var clientMethods = resolved.ClientMethods;
+ var typeIds = resolved.HandleTypeIds;
// Generate handle type aliases
GenerateHandleTypeAliases(typeIds);
@@ -1075,21 +521,7 @@ import type {
GenerateDtoInterfaces(dtoTypes);
// Generate exported immutable values
- GenerateExportedValues(exportedValues, dtoTypes.ToDictionary(dto => dto.TypeId, StringComparer.Ordinal));
-
- // Pre-scan all capabilities to collect options interfaces
- // This must happen AFTER wrapper class names are populated so types resolve correctly
- foreach (var builder in builders)
- {
- foreach (var cap in builder.Capabilities)
- {
- var (_, optionalParams) = SeparateParameters(cap.Parameters);
- if (optionalParams.Count > 0 && !TryGetDirectOptionsParameter(optionalParams, out _))
- {
- RegisterOptionsInterface(cap.CapabilityId, cap.MethodName, optionalParams);
- }
- }
- }
+ GenerateExportedValues(exportedValues);
// Generate collected options interfaces
GenerateOptionsInterfaces();
@@ -1107,530 +539,170 @@ import type {
}
// Generate AspireClient with remaining entry point methods
- GenerateAspireClient(clientMethods);
-
- // Generate connection helper
- GenerateConnectionHelper();
-
- // Generate global error handling
- GenerateGlobalErrorHandling();
-
- // Generate handle wrapper registrations (after all classes are defined)
- GenerateHandleWrapperRegistrations(typeClasses, resourceBuilders);
-
- return stringWriter.ToString();
- }
-
- private void WriteLine(string? text = null)
- {
- if (text != null)
- {
- _writer.WriteLine(text);
- }
- else
- {
- _writer.WriteLine();
- }
- }
-
- private void Write(string text)
- {
- _writer.Write(text);
- }
-
- private void GenerateHandleTypeAliases(HashSet typeIds)
- {
- WriteLine("// ============================================================================");
- WriteLine("// Handle Type Aliases (Internal - not exported to users)");
- WriteLine("// ============================================================================");
- WriteLine();
-
- foreach (var typeId in typeIds.OrderBy(t => t))
- {
- var handleName = GetHandleTypeName(typeId);
- var description = GetTypeDescription(typeId);
- WriteDocumentationComment(string.Empty, GetHandleDocumentation(typeId), description);
- // Internal type alias - not exported (users work with wrapper classes)
- WriteLine($"type {handleName} = Handle<'{typeId}'>;");
- WriteLine();
- }
- }
-
- private AtsDocumentationInfo? GetHandleDocumentation(string typeId)
- {
- return _handleDocumentationById.GetValueOrDefault(typeId);
- }
-
- ///
- /// Generates TypeScript enums from discovered enum types.
- ///
- private void GenerateEnumTypes(IReadOnlyList enumTypes)
- {
- _enumTypeNames[InputTypeTypeId] = GetInputTypeEnumName();
-
- var generatedEnumTypes = enumTypes
- .Where(enumType => enumType.TypeId != InputTypeTypeId)
- .ToList();
-
- if (generatedEnumTypes.Count == 0)
- {
- return;
- }
-
- WriteLine("// ============================================================================");
- WriteLine("// Enum Types");
- WriteLine("// ============================================================================");
- WriteLine();
-
- foreach (var enumType in generatedEnumTypes.OrderBy(e => e.Name))
- {
- // Track enum name for type mapping
- _enumTypeNames[enumType.TypeId] = enumType.Name;
-
- WriteDocumentationComment(string.Empty, enumType.Documentation, $"Enum type for {enumType.Name}");
- WriteLine($"export enum {enumType.Name} {{");
-
- var enumValues = enumType.ValueInfos.Count > 0
- ? enumType.ValueInfos
- : enumType.Values.Select(value => new AtsEnumValueInfo { Name = value }).ToList();
-
- foreach (var value in enumValues)
- {
- // Enums serialize as strings in JSON
- WriteDocumentationComment(" ", value.Documentation);
- WriteLine($" {value.Name} = \"{value.Name}\",");
- }
-
- WriteLine("}");
- WriteLine();
- }
- }
-
- ///
- /// Generates TypeScript interfaces for DTO types marked with [AspireDto].
- ///
- private void GenerateDtoInterfaces(IReadOnlyList dtoTypes)
- {
- var generatedDtoTypes = dtoTypes
- .Where(dto => dto.TypeId != InteractionInputTypeId)
- .ToList();
-
- if (generatedDtoTypes.Count == 0)
- {
- return;
- }
-
- WriteLine("// ============================================================================");
- WriteLine("// DTO Interfaces");
- WriteLine("// ============================================================================");
- WriteLine();
-
- foreach (var dto in generatedDtoTypes.OrderBy(d => d.Name))
- {
- var interfaceName = GetDtoInterfaceName(dto.TypeId);
-
- WriteDocumentationComment(string.Empty, dto.Documentation, dto.Description ?? $"DTO interface for {dto.Name}");
- WriteLine($"export interface {interfaceName} {{");
-
- foreach (var prop in dto.Properties)
- {
- var tsType = prop.IsCallback
- ? GenerateCallbackTypeSignature(prop.CallbackParameters, prop.CallbackReturnType)
- : MapDtoPropertyTypeToTypeScript(prop.Type);
- // All DTO properties are optional in TypeScript to allow partial objects
- // Convert PascalCase to camelCase for TypeScript
- var propName = ToCamelCase(prop.Name);
- WriteDocumentationComment(" ", prop.Documentation, prop.Description);
- WriteLine($" {propName}?: {tsType};");
- }
-
- // Add client-only properties that don't exist in the C# DTO
- if (dto.Name == "CreateBuilderOptions")
- {
- WriteLine(" /** When false, pre-flush rejected promises are not re-thrown by build(). Default: true. */");
- WriteLine(" throwOnPendingRejections?: boolean;");
- }
-
- WriteLine("}");
- WriteLine();
- }
- }
-
- private void GenerateExportedValues(
- IReadOnlyList exportedValues,
- IReadOnlyDictionary dtoTypesById)
- {
- if (exportedValues.Count == 0)
- {
- return;
- }
-
- var root = BuildExportedValueTree(exportedValues);
-
- WriteLine("// ============================================================================");
- WriteLine("// Exported Values");
- WriteLine("// ============================================================================");
- WriteLine();
-
- foreach (var (name, node) in root.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
- {
- WriteLine($"export namespace {name} {{");
- WriteTypeScriptExportedValueChildren(node, dtoTypesById, indentLevel: 1);
- WriteLine("}");
- WriteLine();
- }
- }
-
- private void WriteTypeScriptExportedValueChildren(
- ExportedValueTreeNode node,
- IReadOnlyDictionary dtoTypesById,
- int indentLevel)
- {
- var indent = new string(' ', indentLevel * 4);
-
- foreach (var (name, child) in node.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
- {
- if (child.Value is { } valueInfo)
- {
- WriteDocumentationComment(indent, valueInfo.Documentation, valueInfo.Description);
-
- var literal = RenderTypeScriptExportedValue(valueInfo.Value, valueInfo.Type, dtoTypesById);
- var exportedType = MapTypeRefToTypeScript(valueInfo.Type);
- var needsCast = valueInfo.Type.Category is not AtsTypeCategory.Primitive;
- var expression = needsCast ? $"{literal} as {exportedType}" : literal;
- WriteLine($"{indent}export const {name} = {expression};");
- }
- else
- {
- WriteLine($"{indent}export namespace {name} {{");
- WriteTypeScriptExportedValueChildren(child, dtoTypesById, indentLevel + 1);
- WriteLine($"{indent}}}");
- }
-
- WriteLine();
- }
- }
-
- private string RenderTypeScriptExportedValue(
- JsonNode? value,
- AtsTypeRef typeRef,
- IReadOnlyDictionary dtoTypesById)
- {
- if (value is null)
- {
- return "null";
- }
-
- return typeRef.Category switch
- {
- AtsTypeCategory.Dto when value is JsonObject obj && dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoInfo)
- => RenderTypeScriptDtoValue(obj, dtoInfo, dtoTypesById),
- AtsTypeCategory.Array or AtsTypeCategory.List when value is JsonArray arr
- => $"[{string.Join(", ", arr.Select(item => RenderTypeScriptExportedValue(item, typeRef.ElementType!, dtoTypesById)))}]",
- AtsTypeCategory.Dict when value is JsonObject obj
- => "{ " + string.Join(", ", obj.Select(pair => $"{RenderTypeScriptPropertyKey(pair.Key)}: {RenderTypeScriptExportedValue(pair.Value, typeRef.ValueType!, dtoTypesById)}")) + " }",
- _ => value.ToRelaxedJsonString()
- };
- }
-
- private string RenderTypeScriptDtoValue(
- JsonObject value,
- AtsDtoTypeInfo dtoInfo,
- IReadOnlyDictionary dtoTypesById)
- {
- var members = new List();
-
- foreach (var property in dtoInfo.Properties)
- {
- if (!value.TryGetPropertyValue(property.Name, out var propertyValue))
- {
- continue;
- }
-
- members.Add($"{ToCamelCase(property.Name)}: {RenderTypeScriptExportedValue(propertyValue, property.Type, dtoTypesById)}");
- }
-
- return "{ " + string.Join(", ", members) + " }";
- }
-
- private static string RenderTypeScriptPropertyKey(string key)
- {
- return AtsJsonCodeWriter.ToRelaxedJsonString(key);
- }
-
- private static ExportedValueTreeNode BuildExportedValueTree(IReadOnlyList exportedValues)
- {
- var root = new ExportedValueTreeNode();
-
- foreach (var exportedValue in exportedValues)
- {
- var current = root;
- foreach (var segment in exportedValue.PathSegments)
- {
- if (!current.Children.TryGetValue(segment, out var child))
- {
- child = new ExportedValueTreeNode();
- current.Children[segment] = child;
- }
+ GenerateAspireClient(clientMethods);
- current = child;
- }
+ // Generate connection helper
+ GenerateConnectionHelper();
- current.Value = exportedValue;
- }
+ // Generate global error handling
+ GenerateGlobalErrorHandling();
+
+ // Generate handle wrapper registrations (after all classes are defined)
+ GenerateHandleWrapperRegistrations(typeClasses, resourceBuilders);
- return root;
+ return stringWriter.ToString();
}
- ///
- /// Converts a PascalCase name to camelCase.
- ///
- private static string ToCamelCase(string name)
+ private void WriteLine(string? text = null)
{
- if (string.IsNullOrEmpty(name))
+ if (text != null)
{
- return name;
+ _writer.WriteLine(text);
}
- if (char.IsLower(name[0]))
+ else
{
- return name;
+ _writer.WriteLine();
}
- return char.ToLowerInvariant(name[0]) + name[1..];
}
- ///
- /// Converts a camelCase name to PascalCase.
- ///
- private static string ToPascalCase(string name)
+ private void Write(string text)
{
- if (string.IsNullOrEmpty(name))
- {
- return name;
- }
- if (char.IsUpper(name[0]))
+ _writer.Write(text);
+ }
+
+ private void GenerateHandleTypeAliases(HashSet typeIds)
+ {
+ WriteLine("// ============================================================================");
+ WriteLine("// Handle Type Aliases (Internal - not exported to users)");
+ WriteLine("// ============================================================================");
+ WriteLine();
+
+ foreach (var typeId in typeIds.OrderBy(t => t))
{
- return name;
+ var handleName = TypeScriptApiProjector.GetHandleTypeName(typeId);
+ var description = TypeScriptApiProjector.GetTypeDescription(typeId);
+ WriteDocumentationComment(string.Empty, GetHandleDocumentation(typeId), description);
+ // Internal type alias - not exported (users work with wrapper classes)
+ WriteLine($"type {handleName} = Handle<'{typeId}'>;");
+ WriteLine();
}
- return char.ToUpperInvariant(name[0]) + name[1..];
}
- ///
- /// Gets the options interface name for a method.
- /// Strips any type prefix (e.g., "TypeName.methodName" -> "MethodName").
- ///
- private static string GetOptionsInterfaceName(string methodName)
+ private AtsDocumentationInfo? GetHandleDocumentation(string typeId)
{
- // Strip type prefix if present (e.g., "EndpointReference.getExpression" -> "getExpression")
- var simpleName = methodName.Contains('.')
- ? methodName[(methodName.LastIndexOf('.') + 1)..]
- : methodName;
- return $"{ToPascalCase(simpleName)}Options";
+ return _projector.HandleDocumentationById.GetValueOrDefault(typeId);
}
///
- /// Gets the options interface name for a specific capability, accounting for type conflicts.
- /// Falls back to the default method-name-based interface if no specific mapping exists.
+ /// Generates TypeScript enums from discovered enum types.
///
- private string ResolveOptionsInterfaceName(AtsCapabilityInfo capability)
+ private void GenerateEnumTypes(IReadOnlyList enumTypes)
{
- if (_capabilityOptionsInterfaceMap.TryGetValue(capability.CapabilityId, out var interfaceName))
+ var generatedEnumTypes = enumTypes
+ .Where(enumType => enumType.TypeId != TypeScriptApiProjector.InputTypeTypeId)
+ .ToList();
+
+ if (generatedEnumTypes.Count == 0)
{
- return interfaceName;
+ return;
}
- return GetOptionsInterfaceName(capability.MethodName);
- }
- ///
- /// Separates parameters into required and optional lists.
- /// Required = not optional and not nullable.
- ///
- private static (List Required, List Optional) SeparateParameters(
- IEnumerable parameters)
- {
- var required = new List();
- var optional = new List();
+ WriteLine("// ============================================================================");
+ WriteLine("// Enum Types");
+ WriteLine("// ============================================================================");
+ WriteLine();
- foreach (var param in parameters)
+ foreach (var enumType in generatedEnumTypes.OrderBy(e => e.Name))
{
- if (param.IsOptional || param.IsNullable)
- {
- optional.Add(param);
- }
- else
- {
- required.Add(param);
- }
- }
+ WriteDocumentationComment(string.Empty, enumType.Documentation, $"Enum type for {enumType.Name}");
+ WriteLine($"export enum {enumType.Name} {{");
- return (required, optional);
- }
+ var enumValues = enumType.ValueInfos.Count > 0
+ ? enumType.ValueInfos
+ : enumType.Values.Select(value => new AtsEnumValueInfo { Name = value }).ToList();
- private static bool TryGetDirectOptionsParameter(List optionalParams, out AtsParameterInfo? directOptionsParam)
- // A trailing cancellation token is rendered as its own parameter (see
- // GetTrailingCancellationTokenParameter), so it is ignored when deciding whether the lone
- // "options" DTO can be threaded directly instead of wrapped in a generated options object.
- => AtsOptionsFlattening.TryGetDirectOptionsParameter(
- optionalParams,
- p => IsCancellationTokenType(p.Type),
- cancellationTokenIsSeparateParameter: true,
- out directOptionsParam);
+ foreach (var value in enumValues)
+ {
+ // Enums serialize as strings in JSON
+ WriteDocumentationComment(" ", value.Documentation);
+ WriteLine($" {value.Name} = \"{value.Name}\",");
+ }
- ///
- /// When the options DTO is threaded directly (see ),
- /// returns the trailing cancellation token optional parameter (if any) so it can be appended to
- /// the generated method as its own argument rather than being folded into a generated options bag.
- ///
- private static AtsParameterInfo? GetTrailingCancellationTokenParameter(List optionalParams)
- {
- if (!TryGetDirectOptionsParameter(optionalParams, out _))
- {
- return null;
+ WriteLine("}");
+ WriteLine();
}
-
- return optionalParams.FirstOrDefault(p => IsCancellationTokenType(p.Type));
}
///
- /// Registers an options interface to be generated later.
- /// Uses method name to create the interface name. When methods share a name but have
- /// incompatible callback parameter types, separate options interfaces are created with
- /// numeric suffixes (e.g., RunAsEmulatorOptions, RunAsEmulator1Options).
+ /// Generates TypeScript interfaces for DTO types marked with [AspireDto].
///
- private void RegisterOptionsInterface(string capabilityId, string methodName, List optionalParams)
+ private void GenerateDtoInterfaces(IReadOnlyList dtoTypes)
{
- if (optionalParams.Count == 0)
+ var generatedDtoTypes = dtoTypes
+ .Where(dto => dto.TypeId != TypeScriptApiProjector.InteractionInputTypeId)
+ .ToList();
+
+ if (generatedDtoTypes.Count == 0)
{
return;
}
- var baseInterfaceName = GetOptionsInterfaceName(methodName);
+ WriteLine("// ============================================================================");
+ WriteLine("// DTO Interfaces");
+ WriteLine("// ============================================================================");
+ WriteLine();
- // Check if an existing interface with this name is compatible
- if (_optionsInterfacesToGenerate.TryGetValue(baseInterfaceName, out var existingParams))
+ foreach (var dto in generatedDtoTypes.OrderBy(d => d.Name))
{
- if (AreOptionsCompatible(existingParams, optionalParams))
- {
- // Compatible - merge any new parameters and share the interface
- var existingNames = new HashSet(existingParams.Select(p => p.Name));
- foreach (var param in optionalParams)
- {
- if (existingNames.Add(param.Name))
- {
- existingParams.Add(param);
- }
- }
- _capabilityOptionsInterfaceMap[capabilityId] = baseInterfaceName;
- return;
- }
-
- // Incompatible - find or create a suffixed interface
- for (var suffix = 1; ; suffix++)
- {
- var suffixedName = GetOptionsInterfaceName($"{methodName}{suffix}");
- if (!_optionsInterfacesToGenerate.TryGetValue(suffixedName, out var suffixedParams))
- {
- // Create a new interface with this suffix
- _generatedOptionsInterfaces.Add(suffixedName);
- _optionsInterfacesToGenerate[suffixedName] = [.. optionalParams];
- _capabilityOptionsInterfaceMap[capabilityId] = suffixedName;
- return;
- }
+ var interfaceName = TypeScriptApiProjector.GetDtoInterfaceName(dto.TypeId);
- if (AreOptionsCompatible(suffixedParams, optionalParams))
- {
- // Compatible with this suffixed interface - share it
- var existingNames2 = new HashSet(suffixedParams.Select(p => p.Name));
- foreach (var param in optionalParams)
- {
- if (existingNames2.Add(param.Name))
- {
- suffixedParams.Add(param);
- }
- }
- _capabilityOptionsInterfaceMap[capabilityId] = suffixedName;
- return;
- }
- }
- }
- else
- {
- // First registration - create the interface
- _generatedOptionsInterfaces.Add(baseInterfaceName);
- _optionsInterfacesToGenerate[baseInterfaceName] = [.. optionalParams];
- _capabilityOptionsInterfaceMap[capabilityId] = baseInterfaceName;
- }
- }
+ WriteDocumentationComment(string.Empty, dto.Documentation, dto.Description ?? $"DTO interface for {dto.Name}");
+ WriteLine($"export interface {interfaceName} {{");
- ///
- /// Checks whether two sets of optional parameters are compatible for sharing an options interface.
- /// Parameters with the same name must have the same type (including callback parameter types).
- ///
- private static bool AreOptionsCompatible(List existing, List candidate)
- {
- foreach (var param in candidate)
- {
- var match = existing.FirstOrDefault(p => p.Name == param.Name);
- if (match is null)
+ foreach (var prop in dto.Properties)
{
- continue; // New parameter, no conflict
+ var tsType = prop.IsCallback
+ ? _projector.GenerateCallbackTypeSignature(prop.CallbackParameters, prop.CallbackReturnType)
+ : _projector.MapDtoPropertyTypeToTypeScript(prop.Type);
+ // All DTO properties are optional in TypeScript to allow partial objects
+ // Convert PascalCase to camelCase for TypeScript
+ var propName = TypeScriptApiProjector.ToCamelCase(prop.Name);
+ WriteDocumentationComment(" ", prop.Documentation, prop.Description);
+ WriteLine($" {propName}?: {tsType};");
}
- // Same name - check type compatibility
- if (!AreParameterTypesEqual(match, param))
+ // Client-only properties have no C# counterpart. The list lives on the projector so the
+ // exported API surface describes the same interface this emits.
+ foreach (var clientOnly in TypeScriptApiProjector.GetClientOnlyDtoProperties(interfaceName))
{
- return false;
+ WriteLine($" /** {clientOnly.Summary} */");
+ WriteLine($" {clientOnly.Name}?: {clientOnly.Type};");
}
+
+ WriteLine("}");
+ WriteLine();
}
- return true;
}
- ///
- /// Checks whether two parameter infos have the same type (including callback types).
- ///
- private static bool AreParameterTypesEqual(AtsParameterInfo a, AtsParameterInfo b)
+ private void GenerateExportedValues(IReadOnlyList exportedValues)
{
- // Compare base type
- var aTypeId = a.Type?.TypeId;
- var bTypeId = b.Type?.TypeId;
- if (!string.Equals(aTypeId, bTypeId, StringComparison.Ordinal))
- {
- return false;
- }
-
- // Compare callback parameter types
- if (a.IsCallback != b.IsCallback)
+ if (exportedValues.Count == 0)
{
- return false;
+ return;
}
- if (a.IsCallback && b.IsCallback)
- {
- var aCallbackParams = a.CallbackParameters ?? [];
- var bCallbackParams = b.CallbackParameters ?? [];
-
- if (aCallbackParams.Count != bCallbackParams.Count)
- {
- return false;
- }
+ var namespaces = _projector.ProjectExportedValues(exportedValues);
- for (var i = 0; i < aCallbackParams.Count; i++)
- {
- if (!string.Equals(aCallbackParams[i].Type.TypeId, bCallbackParams[i].Type.TypeId, StringComparison.Ordinal))
- {
- return false;
- }
- }
+ WriteLine("// ============================================================================");
+ WriteLine("// Exported Values");
+ WriteLine("// ============================================================================");
+ WriteLine();
- // Compare callback return types
- var aReturnTypeId = a.CallbackReturnType?.TypeId;
- var bReturnTypeId = b.CallbackReturnType?.TypeId;
- if (!string.Equals(aReturnTypeId, bReturnTypeId, StringComparison.Ordinal))
- {
- return false;
- }
+ foreach (var exportedNamespace in namespaces)
+ {
+ WriteLine(exportedNamespace.Content);
+ WriteLine();
}
-
- return true;
}
///
@@ -1638,7 +710,7 @@ private static bool AreParameterTypesEqual(AtsParameterInfo a, AtsParameterInfo
///
private void GenerateOptionsInterfaces()
{
- if (_optionsInterfacesToGenerate.Count == 0)
+ if (_projector.OptionsInterfacesToGenerate.Count == 0)
{
return;
}
@@ -1648,12 +720,12 @@ private void GenerateOptionsInterfaces()
WriteLine("// ============================================================================");
WriteLine();
- foreach (var (interfaceName, optionalParams) in _optionsInterfacesToGenerate.OrderBy(kvp => kvp.Key))
+ foreach (var (interfaceName, optionalParams) in _projector.OptionsInterfacesToGenerate.OrderBy(kvp => kvp.Key))
{
WriteLine($"export interface {interfaceName} {{");
foreach (var param in optionalParams)
{
- var tsType = MapParameterToTypeScript(param);
+ var tsType = _projector.MapParameterToTypeScript(param);
WriteDocumentationComment(" ", param.Documentation);
WriteLine($" {param.Name}?: {tsType};");
}
@@ -1662,121 +734,16 @@ private void GenerateOptionsInterfaces()
}
}
- private static string GetTypeDescription(string typeId)
- {
- var typeName = ExtractSimpleTypeName(typeId);
- return $"Handle to {typeName}";
- }
-
- private string BuildPublicParameterList(
- List requiredParams,
- bool hasOptionals,
- string optionsInterfaceName,
- string optionsParameterName = "options",
- AtsParameterInfo? trailingCancellationToken = null)
- {
- var publicParamDefs = new List();
- foreach (var param in requiredParams)
- {
- var tsType = MapParameterToTypeScript(param);
- publicParamDefs.Add($"{param.Name}: {tsType}");
- }
- if (hasOptionals)
- {
- publicParamDefs.Add($"{optionsParameterName}?: {optionsInterfaceName}");
- }
- if (trailingCancellationToken is not null)
- {
- publicParamDefs.Add($"{trailingCancellationToken.Name}?: {MapParameterToTypeScript(trailingCancellationToken)}");
- }
-
- return string.Join(", ", publicParamDefs);
- }
-
- private static string GetPublicOptionsParameterName(
- IReadOnlyList userParams,
- bool hasOptionals,
- bool hasDirectOptionsParameter)
- {
- if (!hasOptionals || hasDirectOptionsParameter)
- {
- return "options";
- }
-
- if (!userParams.Any(p => string.Equals(p.Name, "options", StringComparison.Ordinal)))
- {
- return "options";
- }
-
- var candidate = "optionsBag";
- while (userParams.Any(p => string.Equals(p.Name, candidate, StringComparison.Ordinal)))
- {
- candidate = $"_{candidate}";
- }
-
- return candidate;
- }
-
- private static bool IsGetterOnlyProperty(AtsCapabilityInfo? getter, AtsCapabilityInfo? setter) => getter is not null && setter is null;
-
- private string GetGetterOnlyPropertyReturnType(AtsTypeRef? typeRef)
- {
- if (typeRef == null)
- {
- return "unknown";
- }
-
- if (IsDictionaryType(typeRef))
- {
- var keyType = typeRef.KeyType != null ? MapTypeRefToTypeScript(typeRef.KeyType) : "string";
- var valueType = typeRef.ValueType != null ? MapTypeRefToTypeScript(typeRef.ValueType) : "unknown";
- return $"AspireDict<{keyType}, {valueType}>";
- }
-
- if (IsListType(typeRef))
- {
- var elementType = typeRef.ElementType != null ? MapTypeRefToTypeScript(typeRef.ElementType) : "unknown";
- return $"AspireList<{elementType}>";
- }
-
- return MapTypeRefToTypeScript(typeRef);
- }
-
- private bool TryGetPromiseWrapperType(AtsTypeRef? typeRef, out string promiseInterfaceName, out string promiseImplementationClassName)
- {
- if (typeRef?.TypeId is { } typeId && _typesWithPromiseWrappers.Contains(typeId))
- {
- var className = GetConcreteClassName(typeId);
- promiseInterfaceName = GetPromiseInterfaceName(className);
- promiseImplementationClassName = GetImplementationPromiseClassName(className);
- return true;
- }
-
- promiseInterfaceName = string.Empty;
- promiseImplementationClassName = string.Empty;
- return false;
- }
-
- private string GetGetterOnlyPropertyMethodReturnType(AtsTypeRef? typeRef)
- {
- if (TryGetPromiseWrapperType(typeRef, out var promiseInterfaceName, out _))
- {
- return promiseInterfaceName;
- }
-
- return $"Promise<{GetGetterOnlyPropertyReturnType(typeRef)}>";
- }
-
private void GenerateGetterOnlyPropertyPromiseSignature(string propertyName, AtsCapabilityInfo getter)
{
- var returnType = GetGetterOnlyPropertyMethodReturnType(getter.ReturnType);
+ var returnType = _projector.GetGetterOnlyPropertyMethodReturnType(getter.ReturnType);
WriteCapabilityDocComment(" ", getter);
WriteLine($" {propertyName}(): {returnType};");
}
private void GenerateInterfaceProperty(string propertyName, AtsCapabilityInfo? getter, AtsCapabilityInfo? setter)
{
- if (IsGetterOnlyProperty(getter, setter))
+ if (TypeScriptApiProjector.IsGetterOnlyProperty(getter, setter))
{
GenerateGetterOnlyPropertyPromiseSignature(propertyName, getter!);
return;
@@ -1784,18 +751,18 @@ private void GenerateInterfaceProperty(string propertyName, AtsCapabilityInfo? g
if (getter?.ReturnType is { } returnType)
{
- if (IsDictionaryType(returnType))
+ if (TypeScriptApiProjector.IsDictionaryType(returnType))
{
- var keyType = returnType.KeyType != null ? MapTypeRefToTypeScript(returnType.KeyType) : "string";
- var valueType = returnType.ValueType != null ? MapTypeRefToTypeScript(returnType.ValueType) : "unknown";
+ var keyType = returnType.KeyType != null ? _projector.MapTypeRefToTypeScript(returnType.KeyType) : "string";
+ var valueType = returnType.ValueType != null ? _projector.MapTypeRefToTypeScript(returnType.ValueType) : "unknown";
WritePropertyDocComment(" ", getter, setter);
WriteLine($" readonly {propertyName}: AspireDict<{keyType}, {valueType}>;");
return;
}
- if (IsListType(returnType))
+ if (TypeScriptApiProjector.IsListType(returnType))
{
- var elementType = returnType.ElementType != null ? MapTypeRefToTypeScript(returnType.ElementType) : "unknown";
+ var elementType = returnType.ElementType != null ? _projector.MapTypeRefToTypeScript(returnType.ElementType) : "unknown";
WritePropertyDocComment(" ", getter, setter);
WriteLine($" readonly {propertyName}: AspireList<{elementType}>;");
return;
@@ -1807,13 +774,13 @@ private void GenerateInterfaceProperty(string propertyName, AtsCapabilityInfo? g
if (getter != null)
{
- if (TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out _))
+ if (_projector.TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out _))
{
WriteLine($" get: () => {promiseInterfaceName};");
}
else
{
- var returnTypeName = MapTypeRefToTypeScript(getter.ReturnType);
+ var returnTypeName = _projector.MapTypeRefToTypeScript(getter.ReturnType);
WriteLine($" get: () => Promise<{returnTypeName}>;");
}
}
@@ -1823,7 +790,7 @@ private void GenerateInterfaceProperty(string propertyName, AtsCapabilityInfo? g
var valueParam = setter.Parameters.FirstOrDefault(p => p.Name == "value");
if (valueParam != null)
{
- var valueType = MapInputTypeToTypeScript(valueParam.Type);
+ var valueType = _projector.MapInputTypeToTypeScript(valueParam.Type);
WriteLine($" set: (value: {valueType}) => Promise;");
}
}
@@ -1831,21 +798,9 @@ private void GenerateInterfaceProperty(string propertyName, AtsCapabilityInfo? g
WriteLine(" };");
}
- private string GetBuilderPromiseInterfaceForMethod(BuilderModel builder, AtsCapabilityInfo capability)
- {
- if (capability.ReturnsBuilder && capability.ReturnType?.TypeId != null &&
- !string.Equals(capability.ReturnType.TypeId, builder.TypeId, StringComparison.Ordinal) &&
- !string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal))
- {
- return GetPublicPromiseInterfaceName(capability.ReturnType.TypeId);
- }
-
- return GetPromiseInterfaceName(builder.BuilderClassName);
- }
-
private void GenerateBuilderInterface(BuilderModel builder)
{
- var interfaceName = GetInterfaceName(builder.BuilderClassName);
+ var interfaceName = TypeScriptApiProjector.GetInterfaceName(builder.BuilderClassName);
WriteLine("// ============================================================================");
WriteLine($"// {interfaceName}");
@@ -1859,7 +814,7 @@ private void GenerateBuilderInterface(BuilderModel builder)
var setters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
if (getters.Count > 0 || setters.Count > 0)
{
- var properties = GroupPropertiesByName(getters, setters);
+ var properties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters);
foreach (var prop in properties)
{
GenerateInterfaceProperty(prop.PropertyName, prop.Getter, prop.Setter);
@@ -1870,31 +825,25 @@ private void GenerateBuilderInterface(BuilderModel builder)
c.CapabilityKind != AtsCapabilityKind.PropertyGetter &&
c.CapabilityKind != AtsCapabilityKind.PropertySetter))
{
- var targetParamName = capability.TargetParameterName ?? "builder";
- var userParams = capability.Parameters.Where(p => p.Name != targetParamName).ToList();
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
- var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var publicParamsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, trailingCancellationToken: GetTrailingCancellationTokenParameter(optionalParams));
+ var signature = _projector.ResolveMethodSignature(builder, capability);
var hasNonBuilderReturn = !capability.ReturnsBuilder && capability.ReturnType != null;
- WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? "options" : null);
+ WriteCapabilityDocComment(" ", capability, signature.RequiredParameters, signature.OptionsParameter?.Name);
if (hasNonBuilderReturn)
{
- if (TryGetPromiseWrapperType(capability.ReturnType, out var promiseInterfaceName, out _))
+ if (_projector.TryGetPromiseWrapperType(capability.ReturnType, out var promiseInterfaceName, out _))
{
- WriteLine($" {capability.MethodName}({publicParamsString}): {promiseInterfaceName};");
+ WriteLine($" {capability.MethodName}({signature.ParameterList}): {promiseInterfaceName};");
}
else
{
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
- WriteLine($" {capability.MethodName}({publicParamsString}): Promise<{returnType}>;");
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
+ WriteLine($" {capability.MethodName}({signature.ParameterList}): Promise<{returnType}>;");
}
}
else
{
- WriteLine($" {capability.MethodName}({publicParamsString}): {GetBuilderPromiseInterfaceForMethod(builder, capability)};");
+ WriteLine($" {capability.MethodName}({signature.ParameterList}): {_projector.GetBuilderPromiseInterfaceForMethod(builder, capability)};");
}
}
@@ -1904,7 +853,7 @@ private void GenerateBuilderInterface(BuilderModel builder)
private void GenerateBuilderPromiseInterface(BuilderModel builder)
{
- if (!_typesWithPromiseWrappers.Contains(builder.TypeId))
+ if (!_projector.TypesWithPromiseWrappers.Contains(builder.TypeId))
{
return;
}
@@ -1914,12 +863,12 @@ private void GenerateBuilderPromiseInterface(BuilderModel builder)
c.CapabilityKind != AtsCapabilityKind.PropertySetter).ToList();
var getters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
- var getterOnlyProperties = GroupPropertiesByName(getters, setters)
- .Where(p => IsGetterOnlyProperty(p.Getter, p.Setter))
+ var getterOnlyProperties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters)
+ .Where(p => TypeScriptApiProjector.IsGetterOnlyProperty(p.Getter, p.Setter))
.ToList();
- var interfaceName = GetInterfaceName(builder.BuilderClassName);
- var promiseInterfaceName = GetPromiseInterfaceName(builder.BuilderClassName);
+ var interfaceName = TypeScriptApiProjector.GetInterfaceName(builder.BuilderClassName);
+ var promiseInterfaceName = TypeScriptApiProjector.GetPromiseInterfaceName(builder.BuilderClassName);
WriteLine($"export interface {promiseInterfaceName} extends PromiseLike<{interfaceName}> {{");
@@ -1930,31 +879,25 @@ private void GenerateBuilderPromiseInterface(BuilderModel builder)
foreach (var capability in capabilities)
{
- var targetParamName = capability.TargetParameterName ?? "builder";
- var userParams = capability.Parameters.Where(p => p.Name != targetParamName).ToList();
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
- var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var paramsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, trailingCancellationToken: GetTrailingCancellationTokenParameter(optionalParams));
+ var signature = _projector.ResolveMethodSignature(builder, capability);
var hasNonBuilderReturn = !capability.ReturnsBuilder && capability.ReturnType != null;
- WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? "options" : null);
+ WriteCapabilityDocComment(" ", capability, signature.RequiredParameters, signature.OptionsParameter?.Name);
if (hasNonBuilderReturn)
{
- if (TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out _))
+ if (_projector.TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out _))
{
- WriteLine($" {capability.MethodName}({paramsString}): {returnPromiseInterfaceName};");
+ WriteLine($" {capability.MethodName}({signature.ParameterList}): {returnPromiseInterfaceName};");
}
else
{
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
- WriteLine($" {capability.MethodName}({paramsString}): Promise<{returnType}>;");
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
+ WriteLine($" {capability.MethodName}({signature.ParameterList}): Promise<{returnType}>;");
}
}
else
{
- WriteLine($" {capability.MethodName}({paramsString}): {GetBuilderPromiseInterfaceForMethod(builder, capability)};");
+ WriteLine($" {capability.MethodName}({signature.ParameterList}): {_projector.GetBuilderPromiseInterfaceForMethod(builder, capability)};");
}
}
@@ -1962,40 +905,31 @@ private void GenerateBuilderPromiseInterface(BuilderModel builder)
WriteLine();
}
- private void GenerateTypeClassInterfaceMethod(string className, AtsCapabilityInfo capability)
+ private void GenerateTypeClassInterfaceMethod(BuilderModel model, string className, AtsCapabilityInfo capability)
{
- var methodName = !string.IsNullOrEmpty(capability.OwningTypeName) && capability.MethodName.Contains('.')
- ? capability.MethodName[(capability.MethodName.LastIndexOf('.') + 1)..]
- : GetTypeScriptMethodName(capability.MethodName);
- var targetParamName = capability.TargetParameterName ?? "context";
- var userParams = capability.Parameters.Where(p => p.Name != targetParamName).ToList();
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
- var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var paramsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, trailingCancellationToken: GetTrailingCancellationTokenParameter(optionalParams));
+ var signature = _projector.ResolveMethodSignature(model, capability);
var isVoid = capability.ReturnType == null || capability.ReturnType.TypeId == AtsConstants.Void;
- WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? "options" : null);
- if (capability.ReturnType != null && _typesWithPromiseWrappers.Contains(capability.ReturnType.TypeId))
+ WriteCapabilityDocComment(" ", capability, signature.RequiredParameters, signature.OptionsParameter?.Name);
+ if (capability.ReturnType != null && _projector.TypesWithPromiseWrappers.Contains(capability.ReturnType.TypeId))
{
- WriteLine($" {methodName}({paramsString}): {GetPublicPromiseInterfaceName(capability.ReturnType.TypeId)};");
+ WriteLine($" {signature.MethodName}({signature.ParameterList}): {_projector.GetPublicPromiseInterfaceName(capability.ReturnType.TypeId)};");
}
else if (isVoid)
{
- WriteLine($" {methodName}({paramsString}): {GetPromiseInterfaceName(className)};");
+ WriteLine($" {signature.MethodName}({signature.ParameterList}): {TypeScriptApiProjector.GetPromiseInterfaceName(className)};");
}
else
{
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
- WriteLine($" {methodName}({paramsString}): Promise<{returnType}>;");
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
+ WriteLine($" {signature.MethodName}({signature.ParameterList}): Promise<{returnType}>;");
}
}
private void GenerateTypeClassInterface(BuilderModel model)
{
- var className = DeriveClassName(model.TypeId);
- var interfaceName = GetInterfaceName(className);
+ var className = TypeScriptApiProjector.DeriveClassName(model.TypeId);
+ var interfaceName = TypeScriptApiProjector.GetInterfaceName(className);
WriteLine("// ============================================================================");
WriteLine($"// {interfaceName}");
@@ -2012,9 +946,9 @@ private void GenerateTypeClassInterface(BuilderModel model)
var standardMethods = contextMethods.Concat(otherMethods).ToList();
var hasMethods = standardMethods.Count > 0;
- var properties = GroupPropertiesByName(getters, setters);
+ var properties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters);
var getterOnlyProperties = properties
- .Where(p => IsGetterOnlyProperty(p.Getter, p.Setter))
+ .Where(p => TypeScriptApiProjector.IsGetterOnlyProperty(p.Getter, p.Setter))
.ToList();
foreach (var prop in properties)
{
@@ -2023,7 +957,7 @@ private void GenerateTypeClassInterface(BuilderModel model)
foreach (var method in standardMethods)
{
- GenerateTypeClassInterfaceMethod(className, method);
+ GenerateTypeClassInterfaceMethod(model, className, method);
}
WriteLine("}");
@@ -2034,7 +968,7 @@ private void GenerateTypeClassInterface(BuilderModel model)
return;
}
- var promiseInterfaceName = GetPromiseInterfaceName(className);
+ var promiseInterfaceName = TypeScriptApiProjector.GetPromiseInterfaceName(className);
WriteLine($"export interface {promiseInterfaceName} extends PromiseLike<{interfaceName}> {{");
foreach (var prop in getterOnlyProperties)
{
@@ -2042,7 +976,7 @@ private void GenerateTypeClassInterface(BuilderModel model)
}
foreach (var method in standardMethods)
{
- GenerateTypeClassInterfaceMethod(className, method);
+ GenerateTypeClassInterfaceMethod(model, className, method);
}
WriteLine("}");
WriteLine();
@@ -2053,14 +987,14 @@ private void GenerateBuilderClass(BuilderModel builder)
GenerateBuilderInterface(builder);
GenerateBuilderPromiseInterface(builder);
- var implementationClassName = GetImplementationClassName(builder.BuilderClassName);
+ var implementationClassName = TypeScriptApiProjector.GetImplementationClassName(builder.BuilderClassName);
WriteLine("// ============================================================================");
WriteLine($"// {implementationClassName}");
WriteLine("// ============================================================================");
WriteLine();
- var handleType = GetHandleTypeName(builder.TypeId);
+ var handleType = TypeScriptApiProjector.GetHandleTypeName(builder.TypeId);
// Generate builder class extending ResourceBuilderBase
WriteDocumentationComment(string.Empty, GetHandleDocumentation(builder.TypeId));
@@ -2077,7 +1011,7 @@ private void GenerateBuilderClass(BuilderModel builder)
var setters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
if (getters.Count > 0 || setters.Count > 0)
{
- var properties = GroupPropertiesByName(getters, setters);
+ var properties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters);
foreach (var prop in properties)
{
GeneratePropertyLikeObject(prop.PropertyName, prop.Getter, prop.Setter);
@@ -2138,20 +1072,20 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
var userParams = capability.Parameters.Where(p => p.Name != targetParamName).ToList();
// Separate required and optional parameters
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
+ var (requiredParams, optionalParams) = TypeScriptApiProjector.SeparateParameters(userParams);
var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsTypeName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var publicOptionsParamName = GetPublicOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
+ var hasDirectOptionsParameter = TypeScriptApiProjector.TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
+ var optionsTypeName = hasDirectOptionsParameter ? _projector.MapParameterToTypeScript(directOptionsParam!) : _projector.ResolveOptionsInterfaceName(capability);
+ var publicOptionsParamName = TypeScriptApiProjector.GetImplementationOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
// Build parameter list for public method
- var publicParamsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsTypeName, publicOptionsParamName, GetTrailingCancellationTokenParameter(optionalParams));
+ var publicParamsString = _projector.BuildPublicParameterList(requiredParams, hasOptionals, optionsTypeName, publicOptionsParamName, TypeScriptApiProjector.GetTrailingCancellationTokenParameter(optionalParams));
// Build parameter list for internal method (all params positional for callback registration)
var internalParamDefs = new List();
foreach (var param in userParams)
{
- var tsType = MapParameterToTypeScript(param);
+ var tsType = _projector.MapParameterToTypeScript(param);
var optional = param.IsOptional || param.IsNullable ? "?" : "";
internalParamDefs.Add($"{param.Name}{optional}: {tsType}");
}
@@ -2170,25 +1104,25 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
!string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal))
{
returnTypeId = capability.ReturnType.TypeId;
- returnClassName = _wrapperClassNames.GetValueOrDefault(returnTypeId)
- ?? DeriveClassName(returnTypeId);
+ returnClassName = _projector.WrapperClassNames.GetValueOrDefault(returnTypeId)
+ ?? TypeScriptApiProjector.DeriveClassName(returnTypeId);
}
var returnHandle = capability.ReturnsBuilder
- ? GetConcreteHandleTypeName(returnTypeId)
+ ? _projector.GetConcreteHandleTypeName(returnTypeId)
: "void";
var returnsBuilder = capability.ReturnsBuilder;
- var returnImplementationClassName = GetImplementationClassName(returnClassName);
+ var returnImplementationClassName = TypeScriptApiProjector.GetImplementationClassName(returnClassName);
// Check if this method returns a non-builder, non-void type (e.g., getEndpoint returns EndpointReference)
var hasNonBuilderReturn = !returnsBuilder && capability.ReturnType != null;
if (hasNonBuilderReturn)
{
- if (TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
{
var wrappedReturnTypeId = capability.ReturnType!.TypeId;
- var wrappedReturnClassName = GetConcreteClassName(wrappedReturnTypeId);
- var returnImplementationClassNameForWrapper = GetImplementationClassName(wrappedReturnClassName);
- var returnHandleType = GetConcreteHandleTypeName(wrappedReturnTypeId);
+ var wrappedReturnClassName = _projector.GetConcreteClassName(wrappedReturnTypeId);
+ var returnImplementationClassNameForWrapper = TypeScriptApiProjector.GetImplementationClassName(wrappedReturnClassName);
+ var returnHandleType = _projector.GetConcreteHandleTypeName(wrappedReturnTypeId);
WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? publicOptionsParamName : null);
Write($" {methodName}(");
@@ -2199,7 +1133,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
var callbackParamsForPromiseWrapper = userParams.Where(p => p.IsCallback).ToList();
@@ -2224,7 +1158,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
}
// Generate a simple async method that returns the actual type
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? publicOptionsParamName : null);
Write($" async {methodName}(");
@@ -2235,7 +1169,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
// Handle callback registration if any
@@ -2312,7 +1246,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
// Generate public fluent method (returns thenable wrapper)
var promiseClass = $"{returnClassName}Promise";
- var promiseImplementationClass = GetImplementationPromiseClassName(returnClassName);
+ var promiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(returnClassName);
WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? publicOptionsParamName : null);
Write($" {methodName}(");
Write(publicParamsString);
@@ -2323,7 +1257,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
// Forward all params to internal method
@@ -2372,7 +1306,7 @@ private void GeneratePromiseResolution(IReadOnlyList parameter
continue;
}
- if (IsWidenedHandleType(param.Type))
+ if (_projector.IsWidenedHandleType(param.Type))
{
WriteLine($"{indent}{param.Name} = isPromiseLike({param.Name}) ? await {param.Name} : {param.Name};");
}
@@ -2391,49 +1325,12 @@ private void GeneratePromiseResolution(IReadOnlyList parameter
///
private void GeneratePromiseResolutionForParam(string paramName, AtsTypeRef? paramType, string indent = " ")
{
- if (IsWidenedHandleType(paramType))
+ if (_projector.IsWidenedHandleType(paramType))
{
WriteLine($"{indent}{paramName} = isPromiseLike({paramName}) ? await {paramName} : {paramName};");
}
}
- ///
- /// Checks if a type was widened to accept Awaitable<T> in input position.
- /// Must match the widening logic in MapInputTypeToTypeScript exactly.
- ///
- private bool IsWidenedHandleType(AtsTypeRef? typeRef)
- {
- if (typeRef == null)
- {
- return false;
- }
-
- // Interface handles are always widened
- if (IsInterfaceHandleType(typeRef))
- {
- return true;
- }
-
- // Concrete handles are only widened if they have a wrapper class name
- // (excludes special types like ReferenceExpression that bypass widening)
- if (IsHandleType(typeRef) && _wrapperClassNames.ContainsKey(typeRef.TypeId))
- {
- return true;
- }
-
- if (typeRef.TypeId == InteractionInputCollectionTypeId)
- {
- return true;
- }
-
- if (typeRef.Category == AtsTypeCategory.Union && typeRef.UnionTypes is { Count: > 0 })
- {
- return typeRef.UnionTypes.Any(IsWidenedHandleType);
- }
-
- return false;
- }
-
///
/// Generates promise resolution and args object construction in one step.
/// This is the unified helper used by builder methods, type class methods, context methods, and wrapper methods.
@@ -2568,7 +1465,7 @@ private void GenerateDtoCallbackPropertyAssignments(
{
if (marshallingProperty.IsCallback)
{
- var propertyName = ToCamelCase(marshallingProperty.Name);
+ var propertyName = TypeScriptApiProjector.ToCamelCase(marshallingProperty.Name);
var callbackLocalName = GetDtoCallbackLocalName(dtoRpcLocalName, marshallingProperty.Name);
WriteLine($"{indent}const {callbackLocalName} = {dtoRpcLocalName}.{propertyName};");
WriteLine($"{indent}if ({callbackLocalName} !== undefined) {{");
@@ -2595,7 +1492,7 @@ private void GenerateNestedDtoCallbackPropertyAssignments(
return;
}
- var propertyName = ToCamelCase(dtoProperty.Name);
+ var propertyName = TypeScriptApiProjector.ToCamelCase(dtoProperty.Name);
var dtoPropertyLocalName = GetDtoCallbackLocalName(dtoRpcLocalName, dtoProperty.Name);
var nestedDtoRpcLocalName = $"{dtoPropertyLocalName}ForRpc";
@@ -2655,7 +1552,7 @@ private static AtsParameterInfo CreateCallbackParameter(AtsDtoPropertyInfo callb
///
private void GenerateThenableClass(BuilderModel builder)
{
- if (!_typesWithPromiseWrappers.Contains(builder.TypeId))
+ if (!_projector.TypesWithPromiseWrappers.Contains(builder.TypeId))
{
return;
}
@@ -2665,12 +1562,12 @@ private void GenerateThenableClass(BuilderModel builder)
c.CapabilityKind != AtsCapabilityKind.PropertySetter).ToList();
var getters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
- var getterOnlyProperties = GroupPropertiesByName(getters, setters)
- .Where(p => IsGetterOnlyProperty(p.Getter, p.Setter))
+ var getterOnlyProperties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters)
+ .Where(p => TypeScriptApiProjector.IsGetterOnlyProperty(p.Getter, p.Setter))
.ToList();
var promiseClass = $"{builder.BuilderClassName}Promise";
- var promiseImplementationClass = GetImplementationPromiseClassName(builder.BuilderClassName);
+ var promiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(builder.BuilderClassName);
WriteLine($"/**");
WriteLine($" * Thenable wrapper for {builder.BuilderClassName} that enables fluent chaining.");
@@ -2694,9 +1591,9 @@ private void GenerateThenableClass(BuilderModel builder)
foreach (var prop in getterOnlyProperties)
{
- var returnType = GetGetterOnlyPropertyMethodReturnType(prop.Getter!.ReturnType);
+ var returnType = _projector.GetGetterOnlyPropertyMethodReturnType(prop.Getter!.ReturnType);
WriteLine($" {prop.PropertyName}(): {returnType} {{");
- if (TryGetPromiseWrapperType(prop.Getter!.ReturnType, out _, out var promiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(prop.Getter!.ReturnType, out _, out var promiseImplementationClassName))
{
WriteLine($" return new {promiseImplementationClassName}(this._promise.then(obj => obj.{prop.PropertyName}()), this._client, false);");
}
@@ -2708,52 +1605,24 @@ private void GenerateThenableClass(BuilderModel builder)
WriteLine();
}
- // Generate fluent methods that chain via .then()
- // Capabilities are already flattened - no need to collect from parents
- // Filter out property getters and setters - they are not methods
- foreach (var capability in capabilities)
- {
- var methodName = capability.MethodName;
- var targetParamName = capability.TargetParameterName ?? "builder";
- var userParams = capability.Parameters.Where(p => p.Name != targetParamName).ToList();
-
- // Separate required and optional parameters
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
- var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsTypeName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var trailingCancellationToken = GetTrailingCancellationTokenParameter(optionalParams);
-
- // Build parameter list using options pattern
- var publicParamDefs = new List();
- foreach (var param in requiredParams)
- {
- var tsType = MapParameterToTypeScript(param);
- publicParamDefs.Add($"{param.Name}: {tsType}");
- }
- if (hasOptionals)
- {
- publicParamDefs.Add($"options?: {optionsTypeName}");
- }
- if (trailingCancellationToken is not null)
- {
- publicParamDefs.Add($"{trailingCancellationToken.Name}?: {MapParameterToTypeScript(trailingCancellationToken)}");
- }
- var paramsString = string.Join(", ", publicParamDefs);
-
+ // Generate fluent methods that chain via .then()
+ // Capabilities are already flattened - no need to collect from parents
+ // Filter out property getters and setters - they are not methods
+ foreach (var capability in capabilities)
+ {
+ var signature = _projector.ResolveMethodSignature(builder, capability);
+
// Forward args to underlying object's method (which handles options extraction)
- var forwardArgs = new List();
- foreach (var param in requiredParams)
- {
- forwardArgs.Add(param.Name);
- }
- if (hasOptionals)
+ var forwardArgs = signature.RequiredParameters
+ .Select(parameter => parameter.Name)
+ .ToList();
+ if (signature.OptionsParameter is { } optionsParameter)
{
- forwardArgs.Add("options");
+ forwardArgs.Add(optionsParameter.Name);
}
- if (trailingCancellationToken is not null)
+ if (signature.TrailingCancellationToken is { } cancellationToken)
{
- forwardArgs.Add(trailingCancellationToken.Name);
+ forwardArgs.Add(cancellationToken.Name);
}
var argsString = string.Join(", ", forwardArgs);
@@ -2762,12 +1631,12 @@ private void GenerateThenableClass(BuilderModel builder)
if (hasNonBuilderReturn)
{
- if (TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
{
- Write($" {methodName}(");
- Write(paramsString);
+ Write($" {signature.MethodName}(");
+ Write(signature.ParameterList);
WriteLine($"): {returnPromiseInterfaceName} {{");
- Write($" return new {returnPromiseImplementationClassName}(this._promise.then(obj => obj.{methodName}(");
+ Write($" return new {returnPromiseImplementationClassName}(this._promise.then(obj => obj.{signature.MethodName}(");
Write(argsString);
WriteLine(")), this._client);");
WriteLine(" }");
@@ -2776,11 +1645,11 @@ private void GenerateThenableClass(BuilderModel builder)
}
// For non-builder returns, call the public method directly
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
- Write($" {methodName}(");
- Write(paramsString);
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
+ Write($" {signature.MethodName}(");
+ Write(signature.ParameterList);
WriteLine($"): Promise<{returnType}> {{");
- Write($" return this._promise.then(obj => obj.{methodName}(");
+ Write($" return this._promise.then(obj => obj.{signature.MethodName}(");
Write(argsString);
WriteLine("));");
WriteLine(" }");
@@ -2795,18 +1664,18 @@ private void GenerateThenableClass(BuilderModel builder)
!string.Equals(capability.ReturnType.TypeId, builder.TypeId, StringComparison.Ordinal) &&
!string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal))
{
- var returnClass = _wrapperClassNames.GetValueOrDefault(capability.ReturnType.TypeId)
- ?? DeriveClassName(capability.ReturnType.TypeId);
+ var returnClass = _projector.WrapperClassNames.GetValueOrDefault(capability.ReturnType.TypeId)
+ ?? TypeScriptApiProjector.DeriveClassName(capability.ReturnType.TypeId);
methodPromiseClass = $"{returnClass}Promise";
- methodPromiseImplementationClass = GetImplementationPromiseClassName(returnClass);
+ methodPromiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(returnClass);
}
- Write($" {methodName}(");
- Write(paramsString);
+ Write($" {signature.MethodName}(");
+ Write(signature.ParameterList);
Write($"): {methodPromiseClass} {{");
WriteLine();
// Forward to the public method on the underlying object, wrapping result in promise class
- Write($" return new {methodPromiseImplementationClass}(this._promise.then(obj => obj.{methodName}(");
+ Write($" return new {methodPromiseImplementationClass}(this._promise.then(obj => obj.{signature.MethodName}(");
Write(argsString);
WriteLine($")), this._client);");
WriteLine(" }");
@@ -2860,21 +1729,15 @@ private void GenerateEntryPointFunction(AtsCapabilityInfo capability)
{
var methodName = capability.MethodName;
- // Build parameter list
- var paramDefs = new List { "client: AspireClientRpc" };
- foreach (var param in capability.Parameters)
- {
- var tsType = MapParameterToTypeScript(param);
- var optional = param.IsOptional || param.IsNullable ? "?" : "";
- paramDefs.Add($"{param.Name}{optional}: {tsType}");
- }
-
- var paramsString = string.Join(", ", paramDefs);
- var (requiredParams, optionalParams) = SeparateParameters(capability.Parameters);
+ // Resolved once and shared with the canonical exporter so the emitted function and the
+ // declaration that documents it cannot describe different parameter lists.
+ var signature = _projector.ResolveEntryPointSignature(capability);
+ var paramsString = signature.ParameterList;
+ var (requiredParams, optionalParams) = TypeScriptApiProjector.SeparateParameters(capability.Parameters);
// Determine return type - check if return type has a Promise wrapper
var capReturnTypeId = GetReturnTypeId(capability);
- var returnPromiseWrapper = GetPromiseWrapperForReturnType(capability.ReturnType);
+ var returnPromiseWrapper = _projector.GetPromiseWrapperForReturnType(capability.ReturnType);
// Generate JSDoc
WriteCapabilityDocComment(string.Empty, capability);
@@ -2883,21 +1746,21 @@ private void GenerateEntryPointFunction(AtsCapabilityInfo capability)
if (returnPromiseWrapper != null && !string.IsNullOrEmpty(capReturnTypeId))
{
// Return type has Promise wrapper - generate fluent function
- var returnWrapperClass = _wrapperClassNames.GetValueOrDefault(capReturnTypeId)
- ?? DeriveClassName(capReturnTypeId);
- var returnWrapperImplementationClass = GetImplementationClassName(returnWrapperClass);
- var returnPromiseImplementationClass = GetImplementationPromiseClassName(returnWrapperClass);
- var handleType = GetConcreteHandleTypeName(capReturnTypeId);
+ var returnWrapperClass = _projector.WrapperClassNames.GetValueOrDefault(capReturnTypeId)
+ ?? TypeScriptApiProjector.DeriveClassName(capReturnTypeId);
+ var returnWrapperImplementationClass = TypeScriptApiProjector.GetImplementationClassName(returnWrapperClass);
+ var returnPromiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(returnWrapperClass);
+ var handleType = _projector.GetConcreteHandleTypeName(capReturnTypeId);
Write($"export function {methodName}(");
Write(paramsString);
- WriteLine($"): {returnPromiseWrapper} {{");
+ WriteLine($"): {signature.ReturnType} {{");
// Use async IIFE to resolve promise-like handle params before RPC
WriteLine($" const promise = (async () => {{");
// Resolve promise-like handle params
foreach (var param in capability.Parameters)
{
- if (!param.IsCallback && IsWidenedHandleType(param.Type))
+ if (!param.IsCallback && _projector.IsWidenedHandleType(param.Type))
{
WriteLine($" {param.Name} = isPromiseLike({param.Name}) ? await {param.Name} : {param.Name};");
}
@@ -2924,16 +1787,16 @@ private void GenerateEntryPointFunction(AtsCapabilityInfo capability)
{
// No Promise wrapper - return plain value
var returnType = !string.IsNullOrEmpty(capReturnTypeId)
- ? MapTypeRefToTypeScript(capability.ReturnType)
+ ? _projector.MapTypeRefToTypeScript(capability.ReturnType)
: "void";
Write($"export async function {methodName}(");
Write(paramsString);
- WriteLine($"): Promise<{returnType}> {{");
+ WriteLine($"): {signature.ReturnType} {{");
// Resolve promise-like handle params
foreach (var param in capability.Parameters)
{
- if (!param.IsCallback && IsWidenedHandleType(param.Type))
+ if (!param.IsCallback && _projector.IsWidenedHandleType(param.Type))
{
WriteLine($" {param.Name} = isPromiseLike({param.Name}) ? await {param.Name} : {param.Name};");
}
@@ -2974,30 +1837,6 @@ private void GenerateEntryPointFunction(AtsCapabilityInfo capability)
WriteLine();
}
- private string GenerateCallbackTypeSignature(IReadOnlyList? callbackParameters, AtsTypeRef? callbackReturnType)
- {
- // Build parameter list
- var paramList = new List();
- if (callbackParameters is not null)
- {
- foreach (var param in callbackParameters)
- {
- var tsType = MapTypeRefToTypeScript(param.Type);
- paramList.Add($"{param.Name}: {tsType}");
- }
- }
-
- var paramsString = paramList.Count > 0 ? string.Join(", ", paramList) : "";
-
- // Determine return type
- var returnType = callbackReturnType == null || callbackReturnType.TypeId == AtsConstants.Void
- ? "void"
- : MapTypeRefToTypeScript(callbackReturnType);
-
- // Callbacks are always async in TypeScript
- return $"({paramsString}) => Promise<{returnType}>";
- }
-
private void GenerateCallbackRegistration(AtsParameterInfo callbackParam, string indent = " ", string clientExpression = "this._client")
{
var callbackParameters = callbackParam.CallbackParameters;
@@ -3087,27 +1926,27 @@ private void GenerateCallbackBody(AtsParameterInfo callbackParam, IReadOnlyList<
private void GenerateCallbackParameterConversion(AtsCallbackParameterInfo callbackParameter, string callbackArgName, string clientExpression, string indent)
{
- var tsType = MapTypeRefToTypeScript(callbackParameter.Type);
+ var tsType = _projector.MapTypeRefToTypeScript(callbackParameter.Type);
var cbTypeId = callbackParameter.Type.TypeId;
if (cbTypeId == AtsConstants.CancellationToken)
{
WriteLine($"{indent}const {callbackParameter.Name} = CancellationToken.fromValue({callbackArgName});");
}
- else if (IsDictionaryType(callbackParameter.Type) && !callbackParameter.Type.IsReadOnly)
+ else if (TypeScriptApiProjector.IsDictionaryType(callbackParameter.Type) && !callbackParameter.Type.IsReadOnly)
{
- var keyType = MapTypeRefToTypeScript(callbackParameter.Type.KeyType);
- var valueType = MapTypeRefToTypeScript(callbackParameter.Type.ValueType);
- var handleType = GetHandleTypeName(cbTypeId);
+ var keyType = _projector.MapTypeRefToTypeScript(callbackParameter.Type.KeyType);
+ var valueType = _projector.MapTypeRefToTypeScript(callbackParameter.Type.ValueType);
+ var handleType = TypeScriptApiProjector.GetHandleTypeName(cbTypeId);
WriteLine($"{indent}const {callbackParameter.Name}Handle = wrapIfHandle({callbackArgName}) as {handleType};");
WriteLine($"{indent}const {callbackParameter.Name} = new AspireDict<{keyType}, {valueType}>({callbackParameter.Name}Handle, {clientExpression}, '{cbTypeId}');");
}
- else if (_wrapperClassNames.TryGetValue(cbTypeId, out var wrapperClassName))
+ else if (_projector.WrapperClassNames.TryGetValue(cbTypeId, out var wrapperClassName))
{
- var handleType = GetConcreteHandleTypeName(cbTypeId);
+ var handleType = _projector.GetConcreteHandleTypeName(cbTypeId);
WriteLine($"{indent}const {callbackParameter.Name}Handle = wrapIfHandle({callbackArgName}) as {handleType};");
- WriteLine($"{indent}const {callbackParameter.Name} = new {GetImplementationClassName(wrapperClassName)}({callbackParameter.Name}Handle, {clientExpression});");
+ WriteLine($"{indent}const {callbackParameter.Name} = new {TypeScriptApiProjector.GetImplementationClassName(wrapperClassName)}({callbackParameter.Name}Handle, {clientExpression});");
}
else
{
@@ -3117,7 +1956,7 @@ private void GenerateCallbackParameterConversion(AtsCallbackParameterInfo callba
private void GenerateConnectionHelper()
{
- var builderHandle = GetHandleTypeName(AtsConstants.BuilderTypeId);
+ var builderHandle = TypeScriptApiProjector.GetHandleTypeName(AtsConstants.BuilderTypeId);
WriteLine($$"""
// ============================================================================
@@ -3268,29 +2107,29 @@ private void GenerateHandleWrapperRegistrations(List typeClasses,
// Register type classes (context types like EnvironmentCallbackContext)
foreach (var typeClass in typeClasses)
{
- var className = _wrapperClassNames.GetValueOrDefault(typeClass.TypeId) ?? DeriveClassName(typeClass.TypeId);
- var handleType = GetConcreteHandleTypeName(typeClass.TypeId);
- WriteLine($"registerHandleWrapper('{typeClass.TypeId}', (handle, client) => new {GetImplementationClassName(className)}(handle as {handleType}, client));");
+ var className = _projector.WrapperClassNames.GetValueOrDefault(typeClass.TypeId) ?? TypeScriptApiProjector.DeriveClassName(typeClass.TypeId);
+ var handleType = _projector.GetConcreteHandleTypeName(typeClass.TypeId);
+ WriteLine($"registerHandleWrapper('{typeClass.TypeId}', (handle, client) => new {TypeScriptApiProjector.GetImplementationClassName(className)}(handle as {handleType}, client));");
}
// Register resource builder classes
foreach (var builder in resourceBuilders)
{
- var className = _wrapperClassNames.GetValueOrDefault(builder.TypeId) ?? DeriveClassName(builder.TypeId);
- var handleType = GetConcreteHandleTypeName(builder.TypeId);
- WriteLine($"registerHandleWrapper('{builder.TypeId}', (handle, client) => new {GetImplementationClassName(className)}(handle as {handleType}, client));");
+ var className = _projector.WrapperClassNames.GetValueOrDefault(builder.TypeId) ?? TypeScriptApiProjector.DeriveClassName(builder.TypeId);
+ var handleType = _projector.GetConcreteHandleTypeName(builder.TypeId);
+ WriteLine($"registerHandleWrapper('{builder.TypeId}', (handle, client) => new {TypeScriptApiProjector.GetImplementationClassName(className)}(handle as {handleType}, client));");
}
// Returned aliases keep their marshalled TypeId, so register each one against the retained
// implementation. wrapIfHandle uses these registrations for handles nested in callback data.
- foreach (var aliasTypeId in _concreteTypeIds
+ foreach (var aliasTypeId in _projector.ConcreteTypeIds
.Where(mapping => !string.Equals(mapping.Key, mapping.Value, StringComparison.Ordinal))
.Select(mapping => mapping.Key)
.OrderBy(typeId => typeId, StringComparer.Ordinal))
{
- var className = _wrapperClassNames[aliasTypeId];
- var handleType = GetConcreteHandleTypeName(aliasTypeId);
- WriteLine($"registerHandleWrapper('{aliasTypeId}', (handle, client) => new {GetImplementationClassName(className)}(handle as {handleType}, client));");
+ var className = _projector.WrapperClassNames[aliasTypeId];
+ var handleType = _projector.GetConcreteHandleTypeName(aliasTypeId);
+ WriteLine($"registerHandleWrapper('{aliasTypeId}', (handle, client) => new {TypeScriptApiProjector.GetImplementationClassName(className)}(handle as {handleType}, client));");
}
WriteLine();
@@ -3303,9 +2142,9 @@ private void GenerateHandleWrapperRegistrations(List typeClasses,
///
private void GenerateTypeClass(BuilderModel model)
{
- var handleType = GetHandleTypeName(model.TypeId);
- var className = DeriveClassName(model.TypeId);
- var implementationClassName = GetImplementationClassName(className);
+ var handleType = TypeScriptApiProjector.GetHandleTypeName(model.TypeId);
+ var className = TypeScriptApiProjector.DeriveClassName(model.TypeId);
+ var implementationClassName = TypeScriptApiProjector.GetImplementationClassName(className);
GenerateTypeClassInterface(model);
@@ -3331,9 +2170,9 @@ private void GenerateTypeClass(BuilderModel model)
WriteLine();
// Group getters and setters by property name to create property members
- var properties = GroupPropertiesByName(getters, setters);
+ var properties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters);
var getterOnlyProperties = properties
- .Where(p => IsGetterOnlyProperty(p.Getter, p.Setter))
+ .Where(p => TypeScriptApiProjector.IsGetterOnlyProperty(p.Getter, p.Setter))
.ToList();
// Generate property access members
@@ -3373,64 +2212,6 @@ private void GenerateTypeClass(BuilderModel model)
}
}
- ///
- /// Groups getters and setters by property name.
- ///
- private static List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)> GroupPropertiesByName(
- List getters, List setters)
- {
- var result = new List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)>();
- var processedNames = new HashSet();
-
- // Process getters
- foreach (var getter in getters)
- {
- var propName = ExtractPropertyName(getter.MethodName);
- if (processedNames.Contains(propName))
- {
- continue;
- }
- processedNames.Add(propName);
-
- // Find matching setter (setPropertyName for propertyName)
- var setterName = "set" + char.ToUpperInvariant(propName[0]) + propName[1..];
- var setter = setters.FirstOrDefault(s => ExtractPropertyName(s.MethodName).Equals(setterName, StringComparison.OrdinalIgnoreCase));
-
- result.Add((propName, getter, setter));
- }
-
- // Process any setters without matching getters
- foreach (var setter in setters)
- {
- var setterMethodName = ExtractPropertyName(setter.MethodName);
- // setPropertyName -> propertyName
- if (setterMethodName.StartsWith("set", StringComparison.OrdinalIgnoreCase) && setterMethodName.Length > 3)
- {
- var propName = char.ToLowerInvariant(setterMethodName[3]) + setterMethodName[4..];
- if (!processedNames.Contains(propName))
- {
- processedNames.Add(propName);
- result.Add((propName, null, setter));
- }
- }
- }
-
- return result;
- }
-
- ///
- /// Extracts the property name from a method name like "ClassName.propertyName" or "setPropertyName".
- ///
- private static string ExtractPropertyName(string methodName)
- {
- // Handle "ClassName.propertyName" format
- if (methodName.Contains('.'))
- {
- return methodName[(methodName.LastIndexOf('.') + 1)..];
- }
- return methodName;
- }
-
///
/// Generates a property access member.
///
@@ -3456,7 +2237,7 @@ private static string ExtractPropertyName(string methodName)
///
private void GeneratePropertyLikeObject(string propertyName, AtsCapabilityInfo? getter, AtsCapabilityInfo? setter)
{
- if (IsGetterOnlyProperty(getter, setter))
+ if (TypeScriptApiProjector.IsGetterOnlyProperty(getter, setter))
{
GenerateGetterOnlyPropertyMethod(propertyName, getter!);
return;
@@ -3467,25 +2248,25 @@ private void GeneratePropertyLikeObject(string propertyName, AtsCapabilityInfo?
if (getter != null)
{
- returnType = MapTypeRefToTypeScript(getter.ReturnType);
+ returnType = _projector.MapTypeRefToTypeScript(getter.ReturnType);
// Mutable dictionary/list properties stay as property accessors so callers can use
// wrapper operations (for example, property.get()/set() or list/dict helpers)
// without switching to the getter-only method shape.
- if (IsDictionaryType(getter.ReturnType))
+ if (TypeScriptApiProjector.IsDictionaryType(getter.ReturnType))
{
GenerateMutableDictionaryProperty(propertyName, getter);
return;
}
- if (IsListType(getter.ReturnType))
+ if (TypeScriptApiProjector.IsListType(getter.ReturnType))
{
GenerateMutableListProperty(propertyName, getter);
return;
}
// Check if return type is a wrapper class - use property-like object returning wrapper
- if (getter.ReturnType?.TypeId != null && _wrapperClassNames.TryGetValue(getter.ReturnType.TypeId, out var wrapperClassName))
+ if (getter.ReturnType?.TypeId != null && _projector.WrapperClassNames.TryGetValue(getter.ReturnType.TypeId, out var wrapperClassName))
{
GenerateWrapperPropertyObject(propertyName, getter, setter, wrapperClassName);
return;
@@ -3523,7 +2304,7 @@ private void GeneratePropertyLikeObject(string propertyName, AtsCapabilityInfo?
var valueParam = setter.Parameters.FirstOrDefault(p => p.Name == "value");
if (valueParam != null)
{
- var valueType = MapInputTypeToTypeScript(valueParam.Type);
+ var valueType = _projector.MapInputTypeToTypeScript(valueParam.Type);
WriteLine($" set: async (value: {valueType}): Promise => {{");
GeneratePromiseResolutionForParam("value", valueParam.Type, " ");
WriteLine($" await this._client.invokeCapability(");
@@ -3540,19 +2321,19 @@ private void GeneratePropertyLikeObject(string propertyName, AtsCapabilityInfo?
private void GenerateGetterOnlyPropertyMethod(string propertyName, AtsCapabilityInfo getter)
{
- if (IsDictionaryType(getter.ReturnType))
+ if (TypeScriptApiProjector.IsDictionaryType(getter.ReturnType))
{
GenerateDictionaryProperty(propertyName, getter);
return;
}
- if (IsListType(getter.ReturnType))
+ if (TypeScriptApiProjector.IsListType(getter.ReturnType))
{
GenerateListProperty(propertyName, getter);
return;
}
- if (getter.ReturnType?.TypeId != null && _wrapperClassNames.TryGetValue(getter.ReturnType.TypeId, out var wrapperClassName))
+ if (getter.ReturnType?.TypeId != null && _projector.WrapperClassNames.TryGetValue(getter.ReturnType.TypeId, out var wrapperClassName))
{
GenerateWrapperGetterOnlyPropertyMethod(propertyName, getter, wrapperClassName);
return;
@@ -3563,9 +2344,9 @@ private void GenerateGetterOnlyPropertyMethod(string propertyName, AtsCapability
// promise in their hand-written ...Promise thenable so by-name accessors chain without an
// intermediate await. Awaiting the wrapper still resolves to the plain collection, preserving
// the existing `await (await x.inputs()).value(...)` form.
- if (TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out var promiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out var promiseImplementationClassName))
{
- var collectionType = GetGetterOnlyPropertyReturnType(getter.ReturnType);
+ var collectionType = _projector.GetGetterOnlyPropertyReturnType(getter.ReturnType);
WriteLine($" {propertyName}(): {promiseInterfaceName} {{");
WriteLine($" return new {promiseImplementationClassName}(this._client.invokeCapability<{collectionType}>(");
WriteLine($" '{getter.CapabilityId}',");
@@ -3576,7 +2357,7 @@ private void GenerateGetterOnlyPropertyMethod(string propertyName, AtsCapability
return;
}
- var returnType = GetGetterOnlyPropertyReturnType(getter.ReturnType);
+ var returnType = _projector.GetGetterOnlyPropertyReturnType(getter.ReturnType);
WriteLine($" async {propertyName}(): Promise<{returnType}> {{");
if (getter.ReturnType?.TypeId == AtsConstants.CancellationToken)
@@ -3600,10 +2381,10 @@ private void GenerateGetterOnlyPropertyMethod(string propertyName, AtsCapability
private void GenerateWrapperGetterOnlyPropertyMethod(string propertyName, AtsCapabilityInfo getter, string wrapperClassName)
{
- var handleType = GetConcreteHandleTypeName(getter.ReturnType!.TypeId);
- var wrapperImplementationClassName = GetImplementationClassName(wrapperClassName);
+ var handleType = _projector.GetConcreteHandleTypeName(getter.ReturnType!.TypeId);
+ var wrapperImplementationClassName = TypeScriptApiProjector.GetImplementationClassName(wrapperClassName);
- if (TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out var promiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out var promiseImplementationClassName))
{
WriteLine($" {propertyName}(): {promiseInterfaceName} {{");
WriteLine(" const promise = (async () => {");
@@ -3654,11 +2435,11 @@ private void GenerateWrapperGetterOnlyPropertyMethod(string propertyName, AtsCap
///
private void GenerateWrapperPropertyObject(string propertyName, AtsCapabilityInfo getter, AtsCapabilityInfo? setter, string wrapperClassName)
{
- var handleType = GetConcreteHandleTypeName(getter.ReturnType!.TypeId);
- var wrapperImplementationClassName = GetImplementationClassName(wrapperClassName);
+ var handleType = _projector.GetConcreteHandleTypeName(getter.ReturnType!.TypeId);
+ var wrapperImplementationClassName = TypeScriptApiProjector.GetImplementationClassName(wrapperClassName);
WriteLine($" {propertyName} = {{");
- if (TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out var promiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out var promiseImplementationClassName))
{
WriteLine($" get: (): {promiseInterfaceName} => {{");
WriteLine(" const promise = (async () => {");
@@ -3687,7 +2468,7 @@ private void GenerateWrapperPropertyObject(string propertyName, AtsCapabilityInf
var valueParam = setter.Parameters.FirstOrDefault(p => p.Name == "value");
if (valueParam != null)
{
- var valueType = MapInputTypeToTypeScript(valueParam.Type);
+ var valueType = _projector.MapInputTypeToTypeScript(valueParam.Type);
WriteLine($" set: async (value: {valueType}): Promise => {{");
GeneratePromiseResolutionForParam("value", valueParam.Type, " ");
WriteLine($" await this._client.invokeCapability(");
@@ -3702,22 +2483,6 @@ private void GenerateWrapperPropertyObject(string propertyName, AtsCapabilityInf
WriteLine();
}
- ///
- /// Checks if a type reference is a dictionary type.
- ///
- private static bool IsDictionaryType(AtsTypeRef? typeRef)
- {
- return typeRef?.Category == AtsTypeCategory.Dict;
- }
-
- ///
- /// Checks if a type reference is a list type.
- ///
- private static bool IsListType(AtsTypeRef? typeRef)
- {
- return typeRef?.Category == AtsTypeCategory.List;
- }
-
///
/// Generates a getter-only method for dictionary types.
///
@@ -3730,12 +2495,12 @@ private void GenerateDictionaryProperty(string propertyName, AtsCapabilityInfo g
// Try to extract key and value types from Dict type
if (getter.ReturnType?.KeyType != null)
{
- keyType = MapTypeRefToTypeScript(getter.ReturnType.KeyType);
+ keyType = _projector.MapTypeRefToTypeScript(getter.ReturnType.KeyType);
}
if (getter.ReturnType?.ValueType != null)
{
// Union types will be mapped correctly via MapTypeRefToTypeScript
- valueType = MapTypeRefToTypeScript(getter.ReturnType.ValueType);
+ valueType = _projector.MapTypeRefToTypeScript(getter.ReturnType.ValueType);
}
var typeId = $"'{getter.CapabilityId}'";
@@ -3764,12 +2529,12 @@ private void GenerateMutableDictionaryProperty(string propertyName, AtsCapabilit
if (getter.ReturnType?.KeyType != null)
{
- keyType = MapTypeRefToTypeScript(getter.ReturnType.KeyType);
+ keyType = _projector.MapTypeRefToTypeScript(getter.ReturnType.KeyType);
}
if (getter.ReturnType?.ValueType != null)
{
- valueType = MapTypeRefToTypeScript(getter.ReturnType.ValueType);
+ valueType = _projector.MapTypeRefToTypeScript(getter.ReturnType.ValueType);
}
var typeId = $"'{getter.CapabilityId}'";
@@ -3800,7 +2565,7 @@ private void GenerateListProperty(string propertyName, AtsCapabilityInfo getter)
if (getter.ReturnType?.ElementType != null)
{
- elementType = MapTypeRefToTypeScript(getter.ReturnType.ElementType);
+ elementType = _projector.MapTypeRefToTypeScript(getter.ReturnType.ElementType);
}
var typeId = $"'{getter.CapabilityId}'";
@@ -3828,7 +2593,7 @@ private void GenerateMutableListProperty(string propertyName, AtsCapabilityInfo
if (getter.ReturnType?.ElementType != null)
{
- elementType = MapTypeRefToTypeScript(getter.ReturnType.ElementType);
+ elementType = _projector.MapTypeRefToTypeScript(getter.ReturnType.ElementType);
}
var typeId = $"'{getter.CapabilityId}'";
@@ -3877,26 +2642,26 @@ private void GenerateContextMethod(AtsCapabilityInfo method)
var userParams = method.Parameters.Where(p => p.Name != targetParamName).ToList();
// Separate required and optional parameters
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
+ var (requiredParams, optionalParams) = TypeScriptApiProjector.SeparateParameters(userParams);
var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(method);
- var publicOptionsParamName = GetPublicOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
+ var hasDirectOptionsParameter = TypeScriptApiProjector.TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
+ var optionsInterfaceName = hasDirectOptionsParameter ? _projector.MapParameterToTypeScript(directOptionsParam!) : _projector.ResolveOptionsInterfaceName(method);
+ var publicOptionsParamName = TypeScriptApiProjector.GetImplementationOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
// Build parameter list using options pattern
- var paramsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, publicOptionsParamName, GetTrailingCancellationTokenParameter(optionalParams));
+ var paramsString = _projector.BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, publicOptionsParamName, TypeScriptApiProjector.GetTrailingCancellationTokenParameter(optionalParams));
// Determine return type
var returnType = GetReturnTypeId(method) != null
- ? MapTypeRefToTypeScript(method.ReturnType)
+ ? _projector.MapTypeRefToTypeScript(method.ReturnType)
: "void";
- if (TryGetPromiseWrapperType(method.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(method.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
{
var returnTypeId = method.ReturnType!.TypeId;
- var returnClassName = GetConcreteClassName(returnTypeId);
- var returnImplementationClassName = GetImplementationClassName(returnClassName);
- var returnHandleType = GetConcreteHandleTypeName(returnTypeId);
+ var returnClassName = _projector.GetConcreteClassName(returnTypeId);
+ var returnImplementationClassName = TypeScriptApiProjector.GetImplementationClassName(returnClassName);
+ var returnHandleType = _projector.GetConcreteHandleTypeName(returnTypeId);
WriteCapabilityDocComment(" ", method, requiredParams, hasOptionals ? publicOptionsParamName : null);
Write($" {methodName}(");
@@ -3907,7 +2672,7 @@ private void GenerateContextMethod(AtsCapabilityInfo method)
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
GenerateResolveAndBuildArgs(targetParamName, userParams, requiredParams, optionalParams, useSafeOptionalLocalNames: true, indent: " ");
@@ -3934,7 +2699,7 @@ private void GenerateContextMethod(AtsCapabilityInfo method)
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
// Resolve promise-like params and build args
@@ -3983,7 +2748,7 @@ private void GenerateContextMethod(AtsCapabilityInfo method)
///
private void GenerateWrapperMethod(AtsCapabilityInfo capability)
{
- var methodName = GetTypeScriptMethodName(capability.MethodName);
+ var methodName = TypeScriptApiProjector.GetTypeScriptMethodName(capability.MethodName);
// First arg is the handle (implicit via this._handle) - use metadata instead of string parsing
var firstParamName = capability.TargetParameterName ?? "builder";
@@ -3992,24 +2757,24 @@ private void GenerateWrapperMethod(AtsCapabilityInfo capability)
var userParams = capability.Parameters.Where(p => p.Name != firstParamName).ToList();
// Separate required and optional parameters
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
+ var (requiredParams, optionalParams) = TypeScriptApiProjector.SeparateParameters(userParams);
var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var publicOptionsParamName = GetPublicOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
+ var hasDirectOptionsParameter = TypeScriptApiProjector.TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
+ var optionsInterfaceName = hasDirectOptionsParameter ? _projector.MapParameterToTypeScript(directOptionsParam!) : _projector.ResolveOptionsInterfaceName(capability);
+ var publicOptionsParamName = TypeScriptApiProjector.GetImplementationOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
// Build parameter list using options pattern
- var paramsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, publicOptionsParamName, GetTrailingCancellationTokenParameter(optionalParams));
+ var paramsString = _projector.BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, publicOptionsParamName, TypeScriptApiProjector.GetTrailingCancellationTokenParameter(optionalParams));
// Determine return type
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
- if (TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(capability.ReturnType, out var returnPromiseInterfaceName, out var returnPromiseImplementationClassName))
{
var returnTypeId = capability.ReturnType!.TypeId;
- var returnClassName = GetConcreteClassName(returnTypeId);
- var returnImplementationClassName = GetImplementationClassName(returnClassName);
- var returnHandleType = GetConcreteHandleTypeName(returnTypeId);
+ var returnClassName = _projector.GetConcreteClassName(returnTypeId);
+ var returnImplementationClassName = TypeScriptApiProjector.GetImplementationClassName(returnClassName);
+ var returnHandleType = _projector.GetConcreteHandleTypeName(returnTypeId);
WriteCapabilityDocComment(" ", capability, requiredParams, hasOptionals ? publicOptionsParamName : null);
Write($" {methodName}(");
@@ -4020,7 +2785,7 @@ private void GenerateWrapperMethod(AtsCapabilityInfo capability)
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
GenerateResolveAndBuildArgs(firstParamName, userParams, requiredParams, optionalParams, useSafeOptionalLocalNames: true, indent: " ");
@@ -4047,7 +2812,7 @@ private void GenerateWrapperMethod(AtsCapabilityInfo capability)
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
// Resolve promise-like params and build args
@@ -4094,14 +2859,14 @@ private void GenerateWrapperMethod(AtsCapabilityInfo capability)
///
private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capability)
{
- var className = DeriveClassName(model.TypeId);
+ var className = TypeScriptApiProjector.DeriveClassName(model.TypeId);
var promiseClass = $"{className}Promise";
- var promiseImplementationClass = GetImplementationPromiseClassName(className);
+ var promiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(className);
// Use OwningTypeName if available to extract method name, otherwise parse from MethodName
var methodName = !string.IsNullOrEmpty(capability.OwningTypeName) && capability.MethodName.Contains('.')
? capability.MethodName[(capability.MethodName.LastIndexOf('.') + 1)..]
- : GetTypeScriptMethodName(capability.MethodName);
+ : TypeScriptApiProjector.GetTypeScriptMethodName(capability.MethodName);
var internalMethodName = $"_{methodName}Internal";
@@ -4110,38 +2875,38 @@ private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capab
var userParams = capability.Parameters.Where(p => p.Name != targetParamName).ToList();
// Separate required and optional parameters
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
+ var (requiredParams, optionalParams) = TypeScriptApiProjector.SeparateParameters(userParams);
var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var publicOptionsParamName = GetPublicOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
+ var hasDirectOptionsParameter = TypeScriptApiProjector.TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
+ var optionsInterfaceName = hasDirectOptionsParameter ? _projector.MapParameterToTypeScript(directOptionsParam!) : _projector.ResolveOptionsInterfaceName(capability);
+ var publicOptionsParamName = TypeScriptApiProjector.GetImplementationOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
// Build parameter list for public method
- var publicParamsString = BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, publicOptionsParamName, GetTrailingCancellationTokenParameter(optionalParams));
+ var publicParamsString = _projector.BuildPublicParameterList(requiredParams, hasOptionals, optionsInterfaceName, publicOptionsParamName, TypeScriptApiProjector.GetTrailingCancellationTokenParameter(optionalParams));
// Build parameter list for internal method (all params positional)
var internalParamDefs = new List();
foreach (var param in userParams)
{
- var tsType = MapParameterToTypeScript(param);
+ var tsType = _projector.MapParameterToTypeScript(param);
var optional = param.IsOptional || param.IsNullable ? "?" : "";
internalParamDefs.Add($"{param.Name}{optional}: {tsType}");
}
var internalParamsString = string.Join(", ", internalParamDefs);
// Check if return type has a Promise wrapper
- var returnPromiseWrapper = GetPromiseWrapperForReturnType(capability.ReturnType);
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
+ var returnPromiseWrapper = _projector.GetPromiseWrapperForReturnType(capability.ReturnType);
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
var isVoid = capability.ReturnType == null || capability.ReturnType.TypeId == AtsConstants.Void;
// If return type has a Promise wrapper, generate internal + fluent pattern
if (returnPromiseWrapper != null)
{
- var returnWrapperClass = _wrapperClassNames.GetValueOrDefault(capability.ReturnType!.TypeId)
- ?? DeriveClassName(capability.ReturnType.TypeId);
- var returnWrapperImplementationClass = GetImplementationClassName(returnWrapperClass);
- var returnPromiseImplementationClass = GetImplementationPromiseClassName(returnWrapperClass);
- var returnHandleType = GetConcreteHandleTypeName(capability.ReturnType.TypeId);
+ var returnWrapperClass = _projector.WrapperClassNames.GetValueOrDefault(capability.ReturnType!.TypeId)
+ ?? TypeScriptApiProjector.DeriveClassName(capability.ReturnType.TypeId);
+ var returnWrapperImplementationClass = TypeScriptApiProjector.GetImplementationClassName(returnWrapperClass);
+ var returnPromiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(returnWrapperClass);
+ var returnHandleType = _projector.GetConcreteHandleTypeName(capability.ReturnType.TypeId);
// Generate internal async method
WriteLine($" /** @internal */");
@@ -4180,7 +2945,7 @@ private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capab
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
var internalCallArgs = userParams.Select(p => optionalParams.Contains(p) ? GetLocalParameterName(p) : p.Name);
@@ -4238,7 +3003,7 @@ private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capab
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
Write($" return new {promiseImplementationClass}(this.{internalMethodName}(");
@@ -4258,7 +3023,7 @@ private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capab
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
{
var localParameterName = GetLocalParameterName(param);
- WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
+ WriteLine($" {(_projector.IsWidenedHandleType(param.Type) ? "let" : "const")} {localParameterName} = {publicOptionsParamName}?.{param.Name};");
}
// Handle callback registration if any
@@ -4318,9 +3083,9 @@ private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capab
///
private void GenerateTypeClassThenableWrapper(BuilderModel model, List methods)
{
- var className = DeriveClassName(model.TypeId);
+ var className = TypeScriptApiProjector.DeriveClassName(model.TypeId);
var promiseClass = $"{className}Promise";
- var promiseImplementationClass = GetImplementationPromiseClassName(className);
+ var promiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(className);
WriteLine($"/**");
WriteLine($" * Thenable wrapper for {className} that enables fluent chaining.");
@@ -4342,15 +3107,15 @@ private void GenerateTypeClassThenableWrapper(BuilderModel model, List c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
- var getterOnlyProperties = GroupPropertiesByName(getters, setters)
- .Where(p => IsGetterOnlyProperty(p.Getter, p.Setter))
+ var getterOnlyProperties = TypeScriptApiProjector.GroupPropertiesByName(getters, setters)
+ .Where(p => TypeScriptApiProjector.IsGetterOnlyProperty(p.Getter, p.Setter))
.ToList();
foreach (var prop in getterOnlyProperties)
{
- var returnType = GetGetterOnlyPropertyMethodReturnType(prop.Getter!.ReturnType);
+ var returnType = _projector.GetGetterOnlyPropertyMethodReturnType(prop.Getter!.ReturnType);
WriteLine($" {prop.PropertyName}(): {returnType} {{");
- if (TryGetPromiseWrapperType(prop.Getter!.ReturnType, out _, out var propertyPromiseImplementationClassName))
+ if (_projector.TryGetPromiseWrapperType(prop.Getter!.ReturnType, out _, out var propertyPromiseImplementationClassName))
{
WriteLine($" return new {propertyPromiseImplementationClassName}(this._promise.then(obj => obj.{prop.PropertyName}()), this._client, false);");
}
@@ -4365,68 +3130,37 @@ private void GenerateTypeClassThenableWrapper(BuilderModel model, List p.Name != targetParamName).ToList();
-
- // Separate required and optional parameters
- var (requiredParams, optionalParams) = SeparateParameters(userParams);
- var hasOptionals = optionalParams.Count > 0;
- var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
- var optionsInterfaceName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
- var trailingCancellationToken = GetTrailingCancellationTokenParameter(optionalParams);
-
- // Build parameter list using options pattern
- var publicParamDefs = new List();
- foreach (var param in requiredParams)
- {
- var tsType = MapParameterToTypeScript(param);
- publicParamDefs.Add($"{param.Name}: {tsType}");
- }
- if (hasOptionals)
- {
- publicParamDefs.Add($"options?: {optionsInterfaceName}");
- }
- if (trailingCancellationToken is not null)
- {
- publicParamDefs.Add($"{trailingCancellationToken.Name}?: {MapParameterToTypeScript(trailingCancellationToken)}");
- }
- var paramsString = string.Join(", ", publicParamDefs);
+ var signature = _projector.ResolveMethodSignature(model, capability);
// Forward args to underlying object's public method
- var forwardArgs = new List();
- foreach (var param in requiredParams)
- {
- forwardArgs.Add(param.Name);
- }
- if (hasOptionals)
+ var forwardArgs = signature.RequiredParameters
+ .Select(parameter => parameter.Name)
+ .ToList();
+ if (signature.OptionsParameter is { } optionsParameter)
{
- forwardArgs.Add("options");
+ forwardArgs.Add(optionsParameter.Name);
}
- if (trailingCancellationToken is not null)
+ if (signature.TrailingCancellationToken is { } cancellationToken)
{
- forwardArgs.Add(trailingCancellationToken.Name);
+ forwardArgs.Add(cancellationToken.Name);
}
var argsString = string.Join(", ", forwardArgs);
// Check if return type has a Promise wrapper
- var returnPromiseWrapper = GetPromiseWrapperForReturnType(capability.ReturnType);
- var returnType = MapTypeRefToTypeScript(capability.ReturnType);
+ var returnPromiseWrapper = _projector.GetPromiseWrapperForReturnType(capability.ReturnType);
+ var returnType = _projector.MapTypeRefToTypeScript(capability.ReturnType);
var isVoid = capability.ReturnType == null || capability.ReturnType.TypeId == AtsConstants.Void;
if (returnPromiseWrapper != null)
{
- var returnPromiseImplementationClass = GetImplementationPromiseClassName(
- _wrapperClassNames.GetValueOrDefault(capability.ReturnType!.TypeId)
- ?? DeriveClassName(capability.ReturnType.TypeId));
+ var returnPromiseImplementationClass = TypeScriptApiProjector.GetImplementationPromiseClassName(
+ _projector.WrapperClassNames.GetValueOrDefault(capability.ReturnType!.TypeId)
+ ?? TypeScriptApiProjector.DeriveClassName(capability.ReturnType.TypeId));
// Return type has Promise wrapper - forward to public method, wrap result
- Write($" {methodName}(");
- Write(paramsString);
+ Write($" {signature.MethodName}(");
+ Write(signature.ParameterList);
WriteLine($"): {returnPromiseWrapper} {{");
- Write($" return new {returnPromiseImplementationClass}(this._promise.then(obj => obj.{methodName}(");
+ Write($" return new {returnPromiseImplementationClass}(this._promise.then(obj => obj.{signature.MethodName}(");
Write(argsString);
WriteLine($")), this._client);");
WriteLine(" }");
@@ -4434,10 +3168,10 @@ private void GenerateTypeClassThenableWrapper(BuilderModel model, List obj.{methodName}(");
+ Write($" return new {promiseImplementationClass}(this._promise.then(obj => obj.{signature.MethodName}(");
Write(argsString);
WriteLine($")), this._client);");
WriteLine(" }");
@@ -4445,10 +3179,10 @@ private void GenerateTypeClassThenableWrapper(BuilderModel model, List {{");
- Write($" return this._promise.then(obj => obj.{methodName}(");
+ Write($" return this._promise.then(obj => obj.{signature.MethodName}(");
Write(argsString);
WriteLine("));");
WriteLine(" }");
@@ -4464,418 +3198,4 @@ private void GenerateTypeClassThenableWrapper(BuilderModel model, List
- /// Groups capabilities by ExpandedTargetTypes to create builder models.
- /// Uses expansion to map interface targets to their concrete implementations.
- /// Also creates builders for interface types (for use as return type wrappers).
- ///
- private static List CreateBuilderModels(IReadOnlyList capabilities)
- {
- // Group capabilities by expanded target type IDs
- // A capability targeting IResource with ExpandedTargetTypes = [RedisResource]
- // will be assigned to Aspire.Hosting.Redis/RedisResource (the concrete type)
- var capabilitiesByTypeId = new Dictionary>();
-
- // Track the AtsTypeRef for each typeId (from ExpandedTargetTypes or TargetType metadata)
- var typeRefsByTypeId = new Dictionary();
-
- // Also track interface types and their capabilities (for interface wrapper classes)
- var interfaceCapabilities = new Dictionary>();
-
- foreach (var cap in capabilities)
- {
- var targetTypeRef = cap.TargetType;
- var targetTypeId = cap.TargetTypeId;
- if (targetTypeRef == null || string.IsNullOrEmpty(targetTypeId))
- {
- // Entry point methods - handled separately
- continue;
- }
-
- // Use category-based check instead of string parsing
- if (targetTypeRef.Category != AtsTypeCategory.Handle)
- {
- continue;
- }
-
- // These types are implemented manually in base.mts, including handle wrapper
- // registrations, so they must not also generate duplicate wrappers in aspire.mts.
- if (targetTypeId is AtsConstants.ReferenceExpressionTypeId or InteractionInputCollectionTypeId)
- {
- continue;
- }
-
- // Use expanded types if available, otherwise fall back to the original target
- var expandedTypes = cap.ExpandedTargetTypes;
- if (expandedTypes is { Count: > 0 })
- {
- // Flatten to concrete types
- foreach (var expandedType in expandedTypes)
- {
- if (!capabilitiesByTypeId.TryGetValue(expandedType.TypeId, out var list))
- {
- list = [];
- capabilitiesByTypeId[expandedType.TypeId] = list;
- // Store the type ref for this expanded type
- typeRefsByTypeId[expandedType.TypeId] = expandedType;
- }
- list.Add(cap);
- }
-
- // Also track the original interface type for wrapper class generation
- if (targetTypeRef.IsInterface)
- {
- if (!interfaceCapabilities.TryGetValue(targetTypeId, out var interfaceList))
- {
- interfaceList = [];
- interfaceCapabilities[targetTypeId] = interfaceList;
- // Store the type ref for the interface
- typeRefsByTypeId[targetTypeId] = targetTypeRef;
- }
- interfaceList.Add(cap);
- }
- }
- else
- {
- // No expansion - use original target (concrete type)
- if (!capabilitiesByTypeId.TryGetValue(targetTypeId, out var list))
- {
- list = [];
- capabilitiesByTypeId[targetTypeId] = list;
- // Store the type ref for this target type
- typeRefsByTypeId[targetTypeId] = targetTypeRef;
- }
- list.Add(cap);
- }
- }
-
- // Create a builder for each concrete type with its specific capabilities
- var builders = new List();
- foreach (var (typeId, typeCapabilities) in capabilitiesByTypeId)
- {
- var builderClassName = DeriveClassName(typeId);
-
- // Get the type ref from tracked metadata (based on target type, not return type)
- var typeRef = typeRefsByTypeId.GetValueOrDefault(typeId);
-
- // Deduplicate capabilities by CapabilityId to avoid duplicate methods
- var uniqueCapabilities = typeCapabilities
- .GroupBy(c => c.CapabilityId)
- .Select(g => g.First())
- .ToList();
-
- var builder = new BuilderModel
- {
- TypeId = typeId,
- BuilderClassName = builderClassName,
- Capabilities = uniqueCapabilities,
- IsInterface = typeRef?.IsInterface ?? false,
- TargetType = typeRef
- };
-
- builders.Add(builder);
- }
-
- // Also create builders for interface types (for use as return type wrappers)
- // These are needed when methods return interface types like IResourceWithConnectionString
- foreach (var (interfaceTypeId, caps) in interfaceCapabilities)
- {
- // Skip if already added (shouldn't happen, but be safe)
- if (capabilitiesByTypeId.ContainsKey(interfaceTypeId))
- {
- continue;
- }
-
- var builderClassName = DeriveClassName(interfaceTypeId);
-
- // Get the type ref from tracked metadata
- var typeRef = typeRefsByTypeId.GetValueOrDefault(interfaceTypeId);
-
- // Deduplicate capabilities
- var uniqueCapabilities = caps
- .GroupBy(c => c.CapabilityId)
- .Select(g => g.First())
- .ToList();
-
- var builder = new BuilderModel
- {
- TypeId = interfaceTypeId,
- BuilderClassName = builderClassName,
- Capabilities = uniqueCapabilities,
- IsInterface = true,
- TargetType = typeRef
- };
-
- builders.Add(builder);
- }
-
- // Also create builders for resource types referenced anywhere in capabilities
- // This handles types like RedisCommanderResource that appear in callback signatures,
- // return types, or parameter types but aren't capability targets
- var allReferencedTypeRefs = CollectAllReferencedTypes(capabilities);
-
- // Track all types we already have builders for (concrete + interface)
- var existingBuilderTypeIds = new HashSet(capabilitiesByTypeId.Keys);
- foreach (var (interfaceTypeId, _) in interfaceCapabilities)
- {
- existingBuilderTypeIds.Add(interfaceTypeId);
- }
-
- foreach (var (typeId, typeRef) in allReferencedTypeRefs)
- {
- // Skip types we already have builders for (from concrete or interface lists)
- if (existingBuilderTypeIds.Contains(typeId))
- {
- continue;
- }
-
- // Only create builders for resource types (using metadata instead of string parsing)
- if (!typeRef.IsResourceBuilder)
- {
- continue;
- }
-
- var builderClassName = DeriveClassName(typeId);
- var builder = new BuilderModel
- {
- TypeId = typeId,
- BuilderClassName = builderClassName,
- Capabilities = [], // No specific capabilities - uses base type methods
- IsInterface = typeRef.IsInterface,
- TargetType = typeRef
- };
- builders.Add(builder);
- }
-
- // Deduplicate a concrete type and its interfaces by class name. Unrelated CLR types can have
- // the same simple name, but treating them as aliases would bind one type's branded handle to
- // the other's wrapper implementation.
- return builders
- .OrderBy(builder => builder.IsInterface)
- .ThenBy(builder => builder.BuilderClassName)
- .GroupBy(builder => builder.BuilderClassName, StringComparer.Ordinal)
- .Select(group =>
- {
- var candidates = group
- .OrderBy(builder => builder.IsInterface)
- .ThenBy(builder => builder.TypeId, StringComparer.Ordinal)
- .ToList();
- var retainedBuilder = candidates[0];
- var unrelatedBuilder = candidates
- .Skip(1)
- .FirstOrDefault(candidate => !IsBuilderAlias(retainedBuilder, candidate));
-
- if (unrelatedBuilder is not null)
- {
- var collidingTypeIds = candidates
- .Select(candidate => candidate.TypeId)
- .Order(StringComparer.Ordinal);
- throw new InvalidOperationException(
- $"Resource types {string.Join(", ", collidingTypeIds.Select(typeId => $"'{typeId}'"))} " +
- $"all map to the generated TypeScript name '{group.Key}', but they are not a concrete type and its interfaces.");
- }
- return retainedBuilder;
- })
- .ToList();
- }
-
- private static bool IsBuilderAlias(BuilderModel retainedBuilder, BuilderModel candidate)
- {
- if (string.Equals(retainedBuilder.TypeId, candidate.TypeId, StringComparison.Ordinal))
- {
- return true;
- }
-
- if (retainedBuilder.IsInterface == candidate.IsInterface ||
- retainedBuilder.TargetType is not { } retainedType ||
- candidate.TargetType is not { } candidateType)
- {
- return false;
- }
-
- if (retainedType.ClrType is { } retainedClrType && candidateType.ClrType is { } candidateClrType)
- {
- return retainedClrType.IsAssignableFrom(candidateClrType) ||
- candidateClrType.IsAssignableFrom(retainedClrType);
- }
-
- return IsTypeInHierarchy(retainedType, candidateType.TypeId) ||
- IsTypeInHierarchy(candidateType, retainedType.TypeId);
- }
-
- private static bool IsTypeInHierarchy(AtsTypeRef typeRef, string typeId)
- {
- if (typeRef.ImplementedInterfaces.Any(interfaceType =>
- string.Equals(interfaceType.TypeId, typeId, StringComparison.Ordinal) ||
- IsTypeInHierarchy(interfaceType, typeId)))
- {
- return true;
- }
-
- return typeRef.BaseType is { } baseType &&
- (string.Equals(baseType.TypeId, typeId, StringComparison.Ordinal) ||
- IsTypeInHierarchy(baseType, typeId));
- }
-
- ///
- /// Collects all type refs referenced in capabilities (return types, parameter types, callback types, etc.)
- /// Returns a dictionary mapping typeId to AtsTypeRef for use in builder creation.
- ///
- private static Dictionary CollectAllReferencedTypes(IReadOnlyList capabilities)
- {
- var typeRefs = new Dictionary();
-
- void CollectFromTypeRef(AtsTypeRef? typeRef)
- {
- if (typeRef == null)
- {
- return;
- }
-
- if (!string.IsNullOrEmpty(typeRef.TypeId) && typeRef.Category == AtsTypeCategory.Handle)
- {
- typeRefs.TryAdd(typeRef.TypeId, typeRef);
- }
-
- // Also check nested types (generics, arrays, etc.)
- CollectFromTypeRef(typeRef.ElementType);
- CollectFromTypeRef(typeRef.KeyType);
- CollectFromTypeRef(typeRef.ValueType);
- if (typeRef.UnionTypes != null)
- {
- foreach (var unionType in typeRef.UnionTypes)
- {
- CollectFromTypeRef(unionType);
- }
- }
- }
-
- foreach (var cap in capabilities)
- {
- // Check return type
- CollectFromTypeRef(cap.ReturnType);
-
- // Check parameter types
- foreach (var param in cap.Parameters)
- {
- CollectFromTypeRef(param.Type);
-
- // Check callback parameter types and return type
- if (param.IsCallback)
- {
- if (param.CallbackParameters != null)
- {
- foreach (var cbParam in param.CallbackParameters)
- {
- CollectFromTypeRef(cbParam.Type);
- }
- }
- CollectFromTypeRef(param.CallbackReturnType);
- }
- }
- }
-
- return typeRefs;
- }
-
- ///
- /// Gets entry point capabilities (those without TargetTypeId).
- ///
- private static List GetEntryPointCapabilities(IReadOnlyList capabilities)
- {
- return capabilities.Where(c => string.IsNullOrEmpty(c.TargetTypeId)).ToList();
- }
-
- ///
- /// Derives the class name from an ATS type ID.
- /// For interfaces like IResource, strips the leading 'I'.
- ///
- private static string DeriveClassName(string typeId)
- {
- var typeName = ExtractSimpleTypeName(typeId);
-
- // Strip leading 'I' from interface types
- if (typeName.StartsWith('I') && typeName.Length > 1 && char.IsUpper(typeName[1]))
- {
- return typeName[1..];
- }
-
- return typeName;
- }
-
- ///
- /// Gets the handle type alias name for a type ID.
- ///
- private static string GetHandleTypeName(string typeId)
- {
- var typeName = ExtractSimpleTypeName(typeId);
-
- // Sanitize generic types like "Dict" -> "DictStringObject"
- // and array types like "string[]" -> "stringArray"
- typeName = typeName
- .Replace("[]", "Array", StringComparison.Ordinal)
- .Replace("<", "", StringComparison.Ordinal)
- .Replace(">", "", StringComparison.Ordinal)
- .Replace(",", "", StringComparison.Ordinal);
-
- return $"{typeName}Handle";
- }
-
- ///
- /// Extracts the simple type name from a type ID.
- ///
- ///
- /// "Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource" → "IResource"
- /// "Aspire.Hosting/Aspire.Hosting.DistributedApplication" → "DistributedApplication"
- ///
- private static string ExtractSimpleTypeName(string typeId)
- {
- var slashIndex = typeId.LastIndexOf('/');
- var fullTypeName = slashIndex >= 0 ? typeId[(slashIndex + 1)..] : typeId;
-
- var dotIndex = fullTypeName.LastIndexOf('.');
- return dotIndex >= 0 ? fullTypeName[(dotIndex + 1)..] : fullTypeName;
- }
-
- ///
- /// Determines if a type has generated async members and should have a Promise wrapper.
- /// Types with instance methods, wrapper methods, or getter-only properties get Promise wrappers.
- ///
- private static bool HasChainableMethods(BuilderModel model)
- {
- var hasMethods = model.Capabilities.Any(c =>
- c.CapabilityKind == AtsCapabilityKind.InstanceMethod ||
- c.CapabilityKind == AtsCapabilityKind.Method);
- if (hasMethods)
- {
- return true;
- }
-
- var getters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
- var setters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
-
- return GroupPropertiesByName(getters, setters).Any(p => IsGetterOnlyProperty(p.Getter, p.Setter));
- }
-
- ///
- /// Gets the Promise wrapper class name for a return type, if one exists.
- /// Returns null if the return type doesn't have a Promise wrapper.
- ///
- private string? GetPromiseWrapperForReturnType(AtsTypeRef? returnType)
- {
- if (returnType == null)
- {
- return null;
- }
-
- // Check if the return type has a Promise wrapper
- if (_typesWithPromiseWrappers.Contains(returnType.TypeId))
- {
- var className = _wrapperClassNames.GetValueOrDefault(returnType.TypeId)
- ?? DeriveClassName(returnType.TypeId);
- return $"{className}Promise";
- }
-
- return null;
- }
}
diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiExportWriter.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiExportWriter.cs
new file mode 100644
index 00000000000..9143e3ca08e
--- /dev/null
+++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiExportWriter.cs
@@ -0,0 +1,196 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Aspire.Hosting.CodeGeneration.TypeScript;
+
+///
+/// Serializes a into the canonical schema version 1 export document.
+///
+///
+/// The document is written by hand rather than through reflection-based serialization because the
+/// shape is a published contract that documentation sites bind to. Writing it explicitly makes the
+/// contract reviewable in one place, keeps property order stable so exports diff cleanly between SDK
+/// versions, and drops empty collections and null strings so a version bump only shows real API
+/// changes.
+///
+internal static class TypeScriptApiExportWriter
+{
+ public static JsonObject Write(TypeScriptApiModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+
+ var modules = new JsonArray();
+ foreach (var module in model.Modules)
+ {
+ modules.Add((JsonNode)WriteModule(module));
+ }
+
+ var declarations = new JsonArray();
+ foreach (var declaration in model.Declarations)
+ {
+ declarations.Add((JsonNode)new JsonObject
+ {
+ ["id"] = declaration.Id,
+ ["owningAssembly"] = declaration.OwningAssemblyName,
+ ["content"] = declaration.Content
+ });
+ }
+
+ return new JsonObject
+ {
+ ["schemaVersion"] = model.SchemaVersion,
+ ["language"] = model.Language,
+ ["generator"] = new JsonObject
+ {
+ ["name"] = model.Generator.Name,
+ ["version"] = model.Generator.Version
+ },
+ ["package"] = new JsonObject
+ {
+ ["name"] = model.Package.Name,
+ ["version"] = model.Package.Version
+ },
+ ["modules"] = modules,
+ ["declarations"] = declarations
+ };
+ }
+
+ ///
+ /// Serializes the export document to UTF-8 JSON text.
+ ///
+ /// The model to serialize.
+ ///
+ /// When , writes human-readable JSON. Machine consumers use the compact
+ /// form so the document is a single line on stdout.
+ ///
+ public static string WriteToJson(TypeScriptApiModel model, bool indented = false)
+ => Write(model).ToJsonString(new JsonSerializerOptions { WriteIndented = indented });
+
+ private static JsonObject WriteModule(TypeScriptApiModule module)
+ {
+ var items = new JsonArray();
+ foreach (var item in module.Items)
+ {
+ items.Add((JsonNode)WriteItem(item));
+ }
+
+ var json = new JsonObject { ["name"] = module.Name };
+ AddIfPresent(json, "summary", module.Summary);
+ json["items"] = items;
+ return json;
+ }
+
+ private static JsonObject WriteItem(TypeScriptApiItem item)
+ {
+ var json = new JsonObject
+ {
+ ["id"] = item.Id,
+ ["kind"] = ToKindString(item.Kind),
+ ["name"] = item.Name,
+ ["typeId"] = item.TypeId,
+ ["owningAssembly"] = item.OwningAssemblyName,
+ ["declaration"] = item.Declaration
+ };
+
+ AddIfPresent(json, "summary", item.Summary);
+ AddIfPresent(json, "remarks", item.Remarks);
+ AddIfPresent(json, "examples", item.Examples);
+ AddIfPresent(json, "extends", item.Extends);
+
+ if (item.Members.Count > 0)
+ {
+ var members = new JsonArray();
+ foreach (var member in item.Members)
+ {
+ members.Add((JsonNode)WriteMember(member));
+ }
+
+ json["members"] = members;
+ }
+
+ return json;
+ }
+
+ private static JsonObject WriteMember(TypeScriptApiMember member)
+ {
+ var json = new JsonObject
+ {
+ ["id"] = member.Id,
+ ["kind"] = ToKindString(member.Kind),
+ ["name"] = member.Name,
+ ["declaration"] = member.Declaration
+ };
+
+ AddIfPresent(json, "capabilityId", member.CapabilityId);
+ AddIfPresent(json, "returnType", member.ReturnType);
+ AddIfPresent(json, "summary", member.Summary);
+ AddIfPresent(json, "remarks", member.Remarks);
+ AddIfPresent(json, "examples", member.Examples);
+ if (member.DeprecationMessage is not null)
+ {
+ json["deprecated"] = member.DeprecationMessage;
+ }
+
+ if (member.Parameters.Count > 0)
+ {
+ var parameters = new JsonArray();
+ foreach (var parameter in member.Parameters)
+ {
+ var parameterJson = new JsonObject
+ {
+ ["name"] = parameter.Name,
+ ["type"] = parameter.DeclaredType,
+ ["optional"] = parameter.IsOptional
+ };
+
+ AddIfPresent(parameterJson, "summary", parameter.Summary);
+ parameters.Add((JsonNode)parameterJson);
+ }
+
+ json["parameters"] = parameters;
+ }
+
+ return json;
+ }
+
+ private static string ToKindString(TypeScriptApiItemKind kind) => kind switch
+ {
+ TypeScriptApiItemKind.Interface => "interface",
+ TypeScriptApiItemKind.Enum => "enum",
+ TypeScriptApiItemKind.Dto => "dto",
+ TypeScriptApiItemKind.Options => "options",
+ TypeScriptApiItemKind.Namespace => "namespace",
+ TypeScriptApiItemKind.Constant => "constant",
+ TypeScriptApiItemKind.Augmentation => "augmentation",
+ TypeScriptApiItemKind.Method => "method",
+ TypeScriptApiItemKind.Property => "property",
+ _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown API item kind.")
+ };
+
+ private static void AddIfPresent(JsonObject json, string name, string? value)
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ json[name] = value;
+ }
+ }
+
+ private static void AddIfPresent(JsonObject json, string name, IReadOnlyList values)
+ {
+ if (values.Count == 0)
+ {
+ return;
+ }
+
+ var array = new JsonArray();
+ foreach (var value in values)
+ {
+ array.Add((JsonNode)JsonValue.Create(value));
+ }
+
+ json[name] = array;
+ }
+}
diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiModel.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiModel.cs
new file mode 100644
index 00000000000..f36cc7a9368
--- /dev/null
+++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiModel.cs
@@ -0,0 +1,311 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.TypeSystem;
+
+namespace Aspire.Hosting.CodeGeneration.TypeScript;
+
+///
+/// The kind of a symbol in the canonical TypeScript API export.
+///
+internal enum TypeScriptApiItemKind
+{
+ /// A generated wrapper interface for a handle type.
+ Interface,
+
+ /// A generated enum.
+ Enum,
+
+ /// A generated interface for an [AspireDto] type.
+ Dto,
+
+ /// A generated options bag interface for a method's optional parameters.
+ Options,
+
+ /// A namespace containing immutable exported values.
+ Namespace,
+
+ /// An immutable exported value.
+ Constant,
+
+ ///
+ /// The members this package contributes to an interface another package owns. The owning package
+ /// publishes the type itself, so this is deliberately not a second page for that type.
+ ///
+ Augmentation,
+
+ /// A method on a generated interface, or a module-level entry point function.
+ Method,
+
+ /// A property on a generated interface.
+ Property,
+}
+
+///
+/// The exact package identity a canonical export was produced for.
+///
+/// The package name, for example Aspire.Hosting.Redis.
+/// The exact package version, for example 13.5.0.
+internal sealed record TypeScriptApiPackageIdentity(string Name, string Version);
+
+///
+/// Identifies the code generator that produced a canonical export.
+///
+/// The code-generation assembly name.
+/// The code-generation assembly informational version.
+internal sealed record TypeScriptApiGeneratorIdentity(string Name, string Version);
+
+///
+/// A single parameter of a resolved TypeScript signature.
+///
+internal sealed record TypeScriptApiParameter
+{
+ /// Gets the parameter name as it appears in the generated signature.
+ public required string Name { get; init; }
+
+ /// Gets the final TypeScript type text for the parameter.
+ public required string DeclaredType { get; init; }
+
+ /// Gets a value indicating whether the parameter is optional.
+ public required bool IsOptional { get; init; }
+
+ /// Gets the documentation summary for the parameter, if any.
+ public string? Summary { get; init; }
+}
+
+///
+/// A documented member of an exported item.
+///
+internal sealed record TypeScriptApiMember
+{
+ /// Gets the stable, generator-owned identifier for the member.
+ public required string Id { get; init; }
+
+ /// Gets the member kind.
+ public required TypeScriptApiItemKind Kind { get; init; }
+
+ /// Gets the member name.
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the final TypeScript declaration string, for example
+ /// withPersistence(options?: WithPersistenceOptions): TestRedisResourceBuilderPromise.
+ ///
+ public required string Declaration { get; init; }
+
+ /// Gets the documentation summary.
+ public string? Summary { get; init; }
+
+ /// Gets the documentation remarks.
+ public string? Remarks { get; init; }
+
+ /// Gets the documentation examples.
+ public IReadOnlyList Examples { get; init; } = [];
+
+ /// Gets the deprecation message, or when the member is not deprecated.
+ public string? DeprecationMessage { get; init; }
+
+ /// Gets the ATS capability that produced this member, used as source metadata.
+ public string? CapabilityId { get; init; }
+
+ ///
+ /// Gets the assembly that declares this member, which is not always the assembly that owns the
+ /// type it hangs off: a package can add extension methods to another package's resource.
+ ///
+ public string? OwningAssemblyName { get; init; }
+
+ /// Gets the resolved parameters of the member.
+ public IReadOnlyList Parameters { get; init; } = [];
+
+ /// Gets the final TypeScript return type text, if the member has one.
+ public string? ReturnType { get; init; }
+}
+
+///
+/// A documented, package-owned top-level symbol.
+///
+internal sealed record TypeScriptApiItem
+{
+ /// Gets the stable, generator-owned identifier for the item.
+ public required string Id { get; init; }
+
+ /// Gets the ATS type identifier the item was projected from, when it has one.
+ public required string TypeId { get; init; }
+
+ /// Gets the item kind.
+ public required TypeScriptApiItemKind Kind { get; init; }
+
+ /// Gets the generated TypeScript name.
+ public required string Name { get; init; }
+
+ /// Gets the final TypeScript declaration header for the item.
+ public required string Declaration { get; init; }
+
+ /// Gets the assembly that owns the item.
+ public required string OwningAssemblyName { get; init; }
+
+ /// Gets the documentation summary.
+ public string? Summary { get; init; }
+
+ /// Gets the documentation remarks.
+ public string? Remarks { get; init; }
+
+ /// Gets the documentation examples.
+ public IReadOnlyList Examples { get; init; } = [];
+
+ /// Gets the interfaces this item extends, for relationship rendering.
+ public IReadOnlyList Extends { get; init; } = [];
+
+ /// Gets the documented members of the item.
+ public IReadOnlyList Members { get; init; } = [];
+}
+
+///
+/// A module of package-owned documentation symbols.
+///
+internal sealed record TypeScriptApiModule
+{
+ /// Gets the module name.
+ public required string Name { get; init; }
+
+ /// Gets the module summary.
+ public string? Summary { get; init; }
+
+ /// Gets the package-owned items in the module.
+ public required IReadOnlyList Items { get; init; }
+}
+
+///
+/// A fully rendered exported-value namespace shared by source generation and canonical projection.
+///
+internal sealed record TypeScriptExportedValueNamespace
+{
+ /// Gets the namespace name.
+ public required string Name { get; init; }
+
+ /// Gets the complete TypeScript namespace declaration.
+ public required string Content { get; init; }
+
+ /// Gets the namespace and constant members exposed for canonical documentation.
+ public required IReadOnlyList Members { get; init; }
+}
+
+///
+/// A generator-owned TypeScript declaration fragment.
+///
+///
+/// Declaration IDs are scoped to the containing package export. The canonical identity of a
+/// declaration is the tuple (package.name, package.version, declaration.id); declarations
+/// from separate package exports cannot be flattened into one global declaration set because
+/// package-local TypeScript names may intentionally overlap. The complete declaration list in one
+/// export must type-check on its own.
+///
+internal sealed record TypeScriptApiDeclaration
+{
+ private readonly string _content = string.Empty;
+
+ /// Gets the stable, generator-owned identifier within the containing package export.
+ public required string Id { get; init; }
+
+ /// Gets the TypeScript declaration text.
+ ///
+ /// Line endings are normalized to \n. Some fragments come from raw string literals, which
+ /// carry whatever line endings the source file was checked out with, so the same package export
+ /// would otherwise differ between a CLI built on Windows and one built on Linux.
+ ///
+ public required string Content
+ {
+ get => _content;
+ init => _content = value.ReplaceLineEndings("\n");
+ }
+
+ /// Gets the assembly that owns the declared symbol.
+ public required string OwningAssemblyName { get; init; }
+}
+
+///
+/// The canonical TypeScript API export model for one package.
+///
+internal sealed record TypeScriptApiModel
+{
+ /// Gets the export schema version.
+ public required int SchemaVersion { get; init; }
+
+ /// Gets the export language, always typescript.
+ public required string Language { get; init; }
+
+ /// Gets the code generator identity that produced this export.
+ public required TypeScriptApiGeneratorIdentity Generator { get; init; }
+
+ /// Gets the exact package identity this export was produced for.
+ public required TypeScriptApiPackageIdentity Package { get; init; }
+
+ /// Gets the package-owned documentation modules.
+ public required IReadOnlyList Modules { get; init; }
+
+ ///
+ /// Gets the package-scoped declaration fragments needed to type-check the exported surface.
+ ///
+ public required IReadOnlyList Declarations { get; init; }
+}
+
+///
+/// A method signature resolved once and shared by the source emitter and the canonical exporter.
+///
+///
+/// Both emitters must render the same text. Reconstructing signatures separately is what caused
+/// documented TypeScript signatures to drift from the generated SDK (microsoft/aspire#17608).
+///
+internal sealed record TypeScriptApiMethodSignature
+{
+ /// Gets the generated method name.
+ public required string MethodName { get; init; }
+
+ /// Gets the final TypeScript return type text.
+ public required string ReturnType { get; init; }
+
+ /// Gets the parameters exactly as they appear in the public TypeScript signature.
+ public required IReadOnlyList Parameters { get; init; }
+
+ /// Gets the rendered public parameter list, without the surrounding parentheses.
+ public string ParameterList => string.Join(
+ ", ",
+ Parameters.Select(parameter =>
+ $"{parameter.Name}{(parameter.IsOptional ? "?" : string.Empty)}: {parameter.DeclaredType}"));
+
+ /// Gets the required parameters, in declaration order.
+ public required IReadOnlyList RequiredParameters { get; init; }
+
+ /// Gets the resolved options bag parameter, when the method exposes one.
+ public TypeScriptApiParameter? OptionsParameter { get; init; }
+
+ /// Gets the cancellation token emitted separately after a direct options DTO.
+ public TypeScriptApiParameter? TrailingCancellationToken { get; init; }
+
+ /// Gets the full declaration string, for example addRedis(name: string): RedisResourceBuilderPromise.
+ public string Declaration => $"{MethodName}({ParameterList}): {ReturnType}";
+}
+
+///
+/// The result of resolving an into TypeScript-specific decisions.
+///
+internal sealed record TypeScriptResolvedModel
+{
+ /// Gets the ATS context the model was resolved from.
+ public required AtsContext Context { get; init; }
+
+ /// Gets every builder model discovered from the context.
+ public required List Builders { get; init; }
+
+ /// Gets the builders that represent resource builders.
+ public required List ResourceBuilders { get; init; }
+
+ /// Gets the builders that represent context and wrapper type classes.
+ public required List TypeClasses { get; init; }
+
+ /// Gets the entry point capabilities that hang off the client rather than a type.
+ public required List ClientMethods { get; init; }
+
+ /// Gets the type IDs that need generated handle aliases.
+ public required HashSet HandleTypeIds { get; init; }
+}
diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs
new file mode 100644
index 00000000000..f84bb5aa8fb
--- /dev/null
+++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs
@@ -0,0 +1,3054 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Reflection;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using Aspire.Shared.CodeGeneration;
+using Aspire.Shared.Json;
+using Aspire.TypeSystem;
+
+namespace Aspire.Hosting.CodeGeneration.TypeScript;
+
+///
+/// Resolves an into the TypeScript-specific decisions that define the
+/// public SDK surface: type mapping, options flattening, callback shaping, promise wrapping, and
+/// fluent return selection.
+///
+///
+///
+/// This type is the single owner of those decisions.
+/// consumes it to emit runtime source, and consumes the
+/// same resolved model to emit the canonical API export. Documentation that reconstructs
+/// signatures from raw ATS instead drifts from the SDK that actually ships, which is the failure
+/// mode tracked by microsoft/aspire#17608.
+///
+///
+/// Resolution happens in the constructor so the mapping members can never be called before the
+/// wrapper class and options interface tables they depend on exist.
+///
+///
+internal sealed partial class TypeScriptApiProjector
+{
+ /// The schema version of the canonical export document this projector produces.
+ public const int ExportSchemaVersion = 1;
+
+ ///
+ /// Base library symbols that generated declarations reference but that the SDK ships by hand in
+ /// base.mts/transport.mts rather than generating per package. Each package export
+ /// includes these symbols under a well-known package-local declaration ID so its declarations
+ /// type-check without site-authored shims.
+ ///
+ private const string RuntimeDeclarationId = "aspire:runtime:base";
+
+ private static readonly TypeScriptApiGeneratorIdentity s_generatorIdentity = CreateGeneratorIdentity();
+
+ /// The symbol names already declares.
+ private static readonly HashSet s_runtimeDeclaredNames = new(StringComparer.Ordinal)
+ {
+ "Awaitable", "MarshalledHandle", "Handle", "HandleReference", "AbortSignal", "CancellationToken",
+ "ReferenceExpression", "AspireList", "AspireDict", "ResourceBuilderBase", "InputType",
+ "InteractionInput", "InteractionInputCollection", "InteractionInputCollectionPromise",
+ // Every exported entry point is a free function that takes the client explicitly
+ // (see EntryPointClientParameterType), so a package contributing an entry point names this
+ // symbol in a signature. Without it here the fragment would be the only self-contained
+ // declaration set that does not compile on its own.
+ "AspireClientRpc"
+ };
+
+ private const string RuntimeDeclarationContent = """
+ export type Awaitable = T | PromiseLike;
+ export interface MarshalledHandle { $handle: string; $type: string; }
+ export interface Handle { readonly $handle: string; readonly $type: T; toJSON(): MarshalledHandle; }
+ export interface HandleReference { toJSON(): MarshalledHandle; }
+ export interface AbortSignal { readonly aborted: boolean; }
+ export interface CancellationToken { readonly aborted: boolean; }
+ export enum InputType { Text = 'Text', SecretText = 'SecretText', Choice = 'Choice', Boolean = 'Boolean', Number = 'Number' }
+ export interface ReferenceExpression { readonly value: Promise; }
+ export interface AspireList extends HandleReference { get(index: number): Promise; }
+ export interface AspireDict extends HandleReference { get(key: TKey): Promise; }
+ export interface ResourceBuilderBase extends HandleReference {}
+ export interface InteractionInput { readonly name: string; }
+ export interface InteractionInputCollection extends HandleReference {}
+ export interface InteractionInputCollectionPromise extends PromiseLike {}
+ export interface AspireClientRpc { readonly connected: boolean; invokeCapability(capabilityId: string, args?: Record): Promise; }
+ """;
+
+ private readonly TypeScriptResolvedModel _resolved;
+
+ /// The client parameter every entry-point function takes first.
+ private const string EntryPointClientParameterName = "client";
+
+ /// The declared type of .
+ private const string EntryPointClientParameterType = "AspireClientRpc";
+
+ public TypeScriptApiProjector(AtsContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ _resolved = Resolve(context);
+ }
+
+ /// Gets the resolved projection of the context this projector was built from.
+ internal TypeScriptResolvedModel Resolved => _resolved;
+
+ /// Gets the mapping of ATS type ID to generated wrapper class name.
+ internal Dictionary WrapperClassNames => _wrapperClassNames;
+
+ /// Gets the mapping of ATS type ID to the retained concrete type ID for its wrapper.
+ internal Dictionary ConcreteTypeIds => _concreteTypeIds;
+
+ /// Gets the mapping of ATS type ID to the type reference it was resolved from.
+ internal Dictionary TypeRefsById => _typeRefsById;
+
+ /// Gets the type IDs that have generated Promise wrappers.
+ internal HashSet TypesWithPromiseWrappers => _typesWithPromiseWrappers;
+
+ /// Gets the names of options interfaces that have been registered for generation.
+ internal HashSet GeneratedOptionsInterfaces => _generatedOptionsInterfaces;
+
+ /// Gets the options interfaces to generate, keyed by interface name.
+ internal Dictionary> OptionsInterfacesToGenerate => _optionsInterfacesToGenerate;
+
+ /// Gets the mapping of capability ID to the options interface name it uses.
+ internal Dictionary CapabilityOptionsInterfaceMap => _capabilityOptionsInterfaceMap;
+
+ /// Gets the mapping of enum type ID to generated TypeScript enum name.
+ internal Dictionary EnumTypeNames => _enumTypeNames;
+
+ /// Gets the XML documentation captured for handle types during ATS scanning.
+ internal Dictionary HandleDocumentationById => _handleDocumentationById;
+
+ /// Gets the DTO metadata used for generated argument marshalling.
+ internal Dictionary DtoTypesById => _dtoTypesById;
+
+ private TypeScriptResolvedModel Resolve(AtsContext context)
+ {
+ var capabilities = context.Capabilities;
+ var dtoTypes = context.DtoTypes;
+ var directlyReturnedResourceTypesByClassName = capabilities
+ .Where(capability => capability.CapabilityKind != AtsCapabilityKind.PropertySetter)
+ .Select(capability => capability.ReturnType)
+ .Where(typeRef => typeRef?.IsResourceBuilder == true)
+ .Select(typeRef => typeRef!)
+ .DistinctBy(typeRef => typeRef.TypeId, StringComparer.Ordinal)
+ .GroupBy(typeRef => DeriveClassName(typeRef.TypeId), StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
+
+ var builders = CreateBuilderModels(capabilities);
+ var clientMethods = GetEntryPointCapabilities(capabilities)
+ .Where(c => string.IsNullOrEmpty(c.TargetTypeId))
+ .ToList();
+
+ // Collect all unique type IDs for handle type aliases.
+ // Exclude DTO types - they have their own interfaces, not handle aliases.
+ var dtoTypeIds = new HashSet(dtoTypes.Select(d => d.TypeId), StringComparer.Ordinal);
+ var typeIds = new HashSet(StringComparer.Ordinal);
+ foreach (var typeId in CollectAllReferencedTypes(capabilities).Keys)
+ {
+ if (!dtoTypeIds.Contains(typeId))
+ {
+ typeIds.Add(typeId);
+ }
+ }
+
+ // Ensure all builder type IDs have handle type aliases.
+ // CreateBuilderModels discovers additional resource types via CollectAllReferencedTypes
+ // (e.g. types that appear only in return types or parameters but aren't direct capability targets).
+ // Without this, the builder class references a handle type that was never declared.
+ foreach (var builder in builders)
+ {
+ if (!dtoTypeIds.Contains(builder.TypeId))
+ {
+ typeIds.Add(builder.TypeId);
+ }
+ }
+
+ // Separate builders into categories:
+ // 1. Resource builders: IResource*, ContainerResource, etc.
+ // 2. Type classes: everything else (context types, wrapper types)
+ var resourceBuilders = builders.Where(b => b.TargetType?.IsResourceBuilder == true).ToList();
+ var typeClasses = builders.Where(b => b.TargetType?.IsResourceBuilder != true).ToList();
+
+ // Build wrapper class name mapping before anything consumes the mappings so callback
+ // properties can reference wrapper classes instead of raw handle aliases.
+ _wrapperClassNames.Clear();
+ _concreteTypeIds.Clear();
+ _typeRefsById.Clear();
+ _typesWithPromiseWrappers.Clear();
+ _generatedOptionsInterfaces.Clear();
+ _optionsInterfacesToGenerate.Clear();
+ _capabilityOptionsInterfaceMap.Clear();
+ _optionsInterfaceOwningAssemblies.Clear();
+ _handleDocumentationById.Clear();
+ _dtoTypesById.Clear();
+ _enumTypeNames.Clear();
+
+ foreach (var dtoType in dtoTypes)
+ {
+ _dtoTypesById[dtoType.TypeId] = dtoType;
+ }
+
+ foreach (var handleType in context.HandleTypes)
+ {
+ if (handleType.Documentation is not null)
+ {
+ _handleDocumentationById[handleType.AtsTypeId] = handleType.Documentation;
+ }
+ }
+
+ foreach (var builder in resourceBuilders)
+ {
+ _wrapperClassNames[builder.TypeId] = builder.BuilderClassName;
+ _concreteTypeIds[builder.TypeId] = builder.TypeId;
+ if (builder.TargetType is { } targetType)
+ {
+ _typeRefsById[builder.TypeId] = targetType;
+ }
+
+ directlyReturnedResourceTypesByClassName.TryGetValue(builder.BuilderClassName, out var directlyReturnedAliases);
+
+ // Builder models are deduplicated by generated class name, so the retained TypeId may
+ // differ from a directly returned interface TypeId. Register the retained TypeId to emit
+ // one declaration pair and every returned alias so return sites resolve to that pair.
+ if (HasChainableMethods(builder) || directlyReturnedAliases is not null)
+ {
+ _typesWithPromiseWrappers.Add(builder.TypeId);
+
+ if (directlyReturnedAliases is not null)
+ {
+ foreach (var alias in directlyReturnedAliases)
+ {
+ _typesWithPromiseWrappers.Add(alias.TypeId);
+ _wrapperClassNames[alias.TypeId] = builder.BuilderClassName;
+ _concreteTypeIds[alias.TypeId] = builder.TypeId;
+ _typeRefsById[alias.TypeId] = builder.TargetType ?? alias;
+ }
+ }
+ }
+ }
+
+ foreach (var typeClass in typeClasses)
+ {
+ _wrapperClassNames[typeClass.TypeId] = DeriveClassName(typeClass.TypeId);
+ _concreteTypeIds[typeClass.TypeId] = typeClass.TypeId;
+ if (typeClass.TargetType is { } targetType)
+ {
+ _typeRefsById[typeClass.TypeId] = targetType;
+ }
+ // Type classes with methods get Promise wrappers
+ if (HasChainableMethods(typeClass))
+ {
+ _typesWithPromiseWrappers.Add(typeClass.TypeId);
+ }
+ }
+
+ // InteractionInputCollection is a hand-written base.mts type: its by-name accessors
+ // (value/get/required/requiredValue) are client-side conveniences, not ATS capabilities, so
+ // it is never registered as a generated type class. Register it as a promise-wrapper type so
+ // collection-returning getters (result.inputs(), validationContext.inputs(), command
+ // arguments()) emit the fluent InteractionInputCollectionPromise thenable instead of a bare
+ // Promise. That lets callers chain `await x.inputs().value("c")`
+ // without an intermediate await, matching the C#/Go/Java/Python surfaces. The wrapper
+ // (InteractionInputCollectionPromise / InteractionInputCollectionPromiseImpl) is hand-written
+ // in base.mts; it is intentionally absent from the wrapper class table so the getter impl
+ // keeps using the marshaller-based collection construction rather than a handle+Impl wrapper.
+ _typesWithPromiseWrappers.Add(InteractionInputCollectionTypeId);
+ // Note: ReferenceExpression is intentionally NOT added to the wrapper class table.
+ // It is a value type defined in base.mts with a private constructor and static factory,
+ // not a handle-based wrapper. It is handled via MapTypeRefToTypeScript instead.
+
+ // Enum names are a resolution decision, not an emission detail: MapEnumType has to resolve
+ // them while options interfaces are being registered, which happens before any enum is
+ // written out.
+ _enumTypeNames[InputTypeTypeId] = GetInputTypeEnumName();
+ foreach (var enumType in context.EnumTypes.Where(e => e.TypeId != InputTypeTypeId))
+ {
+ _enumTypeNames[enumType.TypeId] = enumType.Name;
+ }
+
+ // Pre-scan all capabilities to collect options interfaces.
+ // This must happen AFTER wrapper class names are populated so types resolve correctly.
+ // Options names are public TypeScript API. Allocate collision suffixes after sorting by the
+ // stable capability identity so a combined context produces byte-identical output regardless
+ // of the order in which package capabilities were discovered.
+ foreach (var cap in builders
+ .SelectMany(builder => builder.Capabilities)
+ .OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal))
+ {
+ var (_, optionalParams) = SeparateParameters(cap.Parameters);
+ if (optionalParams.Count > 0 && !TryGetDirectOptionsParameter(optionalParams, out _))
+ {
+ RegisterOptionsInterface(cap.CapabilityId, cap.MethodName, optionalParams, GetCapabilityOwningAssemblyName(context, cap));
+ }
+ }
+
+ return new TypeScriptResolvedModel
+ {
+ Context = context,
+ Builders = builders,
+ ResourceBuilders = resourceBuilders,
+ TypeClasses = typeClasses,
+ ClientMethods = clientMethods,
+ HandleTypeIds = typeIds
+ };
+ }
+
+ ///
+ /// Resolves the public signature of a capability exactly once so the source emitter and the
+ /// canonical exporter cannot disagree about parameter shaping or return type selection.
+ ///
+ /// The builder the capability is rendered on, or for a client entry point.
+ /// The capability to resolve.
+ ///
+ /// Resource builders and type classes shape methods differently: they bind a different default
+ /// target parameter name, derive the method name differently, and pick fluent return types by
+ /// different rules. Both rules live here so neither emitter has to reimplement them.
+ ///
+ internal TypeScriptApiMethodSignature ResolveMethodSignature(BuilderModel? builder, AtsCapabilityInfo capability)
+ {
+ ArgumentNullException.ThrowIfNull(capability);
+
+ var isTypeClass = builder is not null && builder.TargetType?.IsResourceBuilder != true;
+ var targetParamName = capability.TargetParameterName ?? (isTypeClass ? "context" : "builder");
+ var userParams = builder is null
+ ? [.. capability.Parameters]
+ : capability.Parameters.Where(p => p.Name != targetParamName).ToList();
+
+ var (requiredParams, optionalParams) = SeparateParameters(userParams);
+ var hasOptionals = optionalParams.Count > 0;
+ var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
+ var optionsTypeName = hasDirectOptionsParameter
+ ? MapParameterToTypeScript(directOptionsParam!)
+ : ResolveOptionsInterfaceName(capability);
+ var optionsParameterName = GetPublicOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
+ var trailingCancellationToken = GetTrailingCancellationTokenParameter(optionalParams);
+ var publicParameters = requiredParams
+ .Select(ProjectPublicParameter)
+ .ToList();
+ TypeScriptApiParameter? optionsParameter = null;
+
+ if (hasOptionals)
+ {
+ optionsParameter = new TypeScriptApiParameter
+ {
+ Name = optionsParameterName,
+ DeclaredType = optionsTypeName,
+ IsOptional = true,
+ Summary = directOptionsParam?.Documentation?.Summary
+ };
+ publicParameters.Add(optionsParameter);
+ }
+
+ TypeScriptApiParameter? publicCancellationToken = null;
+ if (trailingCancellationToken is not null)
+ {
+ publicCancellationToken = ProjectPublicParameter(trailingCancellationToken);
+ publicParameters.Add(publicCancellationToken);
+ }
+
+ return new TypeScriptApiMethodSignature
+ {
+ MethodName = isTypeClass ? ResolveTypeClassMethodName(capability) : capability.MethodName,
+ ReturnType = isTypeClass
+ ? ResolveTypeClassReturnType(builder!, capability)
+ : ResolveBuilderReturnType(builder, capability),
+ Parameters = publicParameters,
+ RequiredParameters = requiredParams,
+ OptionsParameter = optionsParameter,
+ TrailingCancellationToken = publicCancellationToken
+ };
+
+ TypeScriptApiParameter ProjectPublicParameter(AtsParameterInfo parameter)
+ => new()
+ {
+ Name = parameter.Name,
+ DeclaredType = MapParameterToTypeScript(parameter),
+ IsOptional = parameter.IsOptional || parameter.IsNullable,
+ Summary = parameter.Documentation?.Summary
+ };
+ }
+
+ ///
+ /// Strips the declaring type prefix from an explicitly implemented member.
+ ///
+ ///
+ /// Capabilities on an interface implementation carry the qualified C# name, for example
+ /// IValueProvider.GetValueAsync. TypeScript has no explicit interface implementation, so
+ /// only the trailing member name is emitted.
+ ///
+ private static string ResolveTypeClassMethodName(AtsCapabilityInfo capability)
+ => !string.IsNullOrEmpty(capability.OwningTypeName) && capability.MethodName.Contains('.')
+ ? capability.MethodName[(capability.MethodName.LastIndexOf('.') + 1)..]
+ : GetTypeScriptMethodName(capability.MethodName);
+
+ ///
+ /// Selects the return type for a method on a resource builder: a promise wrapper when the
+ /// non-builder return type has one, a plain Promise<T> when it does not, and the
+ /// owning builder's fluent promise interface when the method chains.
+ ///
+ private string ResolveBuilderReturnType(BuilderModel? builder, AtsCapabilityInfo capability)
+ {
+ var hasNonBuilderReturn = !capability.ReturnsBuilder && capability.ReturnType is not null;
+
+ if (hasNonBuilderReturn)
+ {
+ return TryGetPromiseWrapperType(capability.ReturnType, out var promiseInterfaceName, out _)
+ ? promiseInterfaceName
+ : $"Promise<{MapTypeRefToTypeScript(capability.ReturnType)}>";
+ }
+
+ if (builder is not null)
+ {
+ return GetBuilderPromiseInterfaceForMethod(builder, capability);
+ }
+
+ // Entry points have no owning builder, so the fluent return comes from the return type itself.
+ return capability.ReturnType is { TypeId: { } returnTypeId }
+ ? GetPublicPromiseInterfaceName(returnTypeId)
+ : "Promise";
+ }
+
+ ///
+ /// Selects the return type for a method on a type class. Void-returning methods chain on the
+ /// owning class rather than resolving to Promise<void>, which is what makes context
+ /// types fluent.
+ ///
+ private string ResolveTypeClassReturnType(BuilderModel builder, AtsCapabilityInfo capability)
+ {
+ if (capability.ReturnType is { } returnType && _typesWithPromiseWrappers.Contains(returnType.TypeId))
+ {
+ return GetPublicPromiseInterfaceName(returnType.TypeId);
+ }
+
+ if (capability.ReturnType is null || capability.ReturnType.TypeId == AtsConstants.Void)
+ {
+ return GetPromiseInterfaceName(DeriveClassName(builder.TypeId));
+ }
+
+ return $"Promise<{MapTypeRefToTypeScript(capability.ReturnType)}>";
+ }
+
+ ///
+ /// Builds the canonical API export model for one package from the already-resolved projection.
+ ///
+ ///
+ /// Declaration fragment IDs are local to . Their canonical identity is
+ /// (package.name, package.version, declaration.id); consumers must not flatten declarations
+ /// from separate package exports because their package-local TypeScript names can overlap.
+ ///
+ /// The exact package identity the export is produced for.
+ ///
+ /// The assemblies whose symbols the package owns. Symbols outside this set reached the context
+ /// through the referenced-type closure: they contribute declaration fragments so the export
+ /// type-checks, but they must not produce documentation pages here.
+ ///
+ /// A token to cancel the export between projected items.
+ internal TypeScriptApiModel BuildApiModel(
+ TypeScriptApiPackageIdentity package,
+ IReadOnlyCollection ownedAssemblyNames,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(package);
+ ArgumentNullException.ThrowIfNull(ownedAssemblyNames);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var owned = new HashSet(ownedAssemblyNames, StringComparer.OrdinalIgnoreCase);
+
+ var items = new List();
+ var declarations = new Dictionary(StringComparer.Ordinal)
+ {
+ [RuntimeDeclarationId] = new TypeScriptApiDeclaration
+ {
+ Id = RuntimeDeclarationId,
+ Content = RuntimeDeclarationContent,
+ OwningAssemblyName = "Aspire.Hosting"
+ }
+ };
+
+ foreach (var builderModel in _resolved.Builders.OrderBy(b => b.BuilderClassName, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var (item, builderDeclarations) = ProjectBuilder(package, builderModel, owned);
+
+ foreach (var declaration in builderDeclarations)
+ {
+ declarations[declaration.Id] = declaration;
+ }
+
+ if (item is not null)
+ {
+ items.Add(item);
+ }
+ }
+
+ foreach (var entryPoint in _resolved.ClientMethods.OrderBy(c => c.MethodName, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!owned.Contains(GetCapabilityOwningAssemblyName(entryPoint)))
+ {
+ continue;
+ }
+
+ var (item, declaration) = ProjectEntryPoint(entryPoint);
+ items.Add(item);
+ declarations[declaration.Id] = declaration;
+ }
+
+ foreach (var enumType in _resolved.Context.EnumTypes
+ .Where(e => e.TypeId != InputTypeTypeId)
+ .OrderBy(e => e.Name, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var (item, declaration) = ProjectEnum(enumType);
+
+ declarations[declaration.Id] = declaration;
+
+ if (owned.Contains(item.OwningAssemblyName))
+ {
+ items.Add(item);
+ }
+ }
+
+ foreach (var dtoType in _resolved.Context.DtoTypes
+ .Where(d => d.TypeId != InteractionInputTypeId)
+ .OrderBy(d => d.TypeId, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var (item, declaration) = ProjectDto(dtoType);
+
+ declarations[declaration.Id] = declaration;
+
+ if (owned.Contains(item.OwningAssemblyName))
+ {
+ items.Add(item);
+ }
+ }
+
+ var exportedValues = _resolved.Context.ExportedValues
+ .Where(value => owned.Contains(value.OwningAssemblyName))
+ .ToList();
+ foreach (var exportedNamespace in ProjectExportedValues(exportedValues))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var item = new TypeScriptApiItem
+ {
+ Id = $"namespace:{exportedNamespace.Name}",
+ TypeId = $"namespace:{exportedNamespace.Name}",
+ Kind = TypeScriptApiItemKind.Namespace,
+ Name = exportedNamespace.Name,
+ Declaration = $"export namespace {exportedNamespace.Name}",
+ OwningAssemblyName = package.Name,
+ Members = exportedNamespace.Members
+ };
+ var declaration = new TypeScriptApiDeclaration
+ {
+ Id = $"{package.Name}:namespace:{exportedNamespace.Name}",
+ Content = exportedNamespace.Content,
+ OwningAssemblyName = package.Name
+ };
+
+ items.Add(item);
+ declarations[declaration.Id] = declaration;
+ }
+
+ // Options interfaces belong to the assembly whose capability produced them, which is what
+ // both their fragment ID and their documented-item gate key off. Otherwise, a package could
+ // document options interfaces belonging to its dependencies.
+ foreach (var (interfaceName, optionalParams) in _optionsInterfacesToGenerate.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var owningAssemblyName = _optionsInterfaceOwningAssemblies.GetValueOrDefault(interfaceName, package.Name);
+ var (item, declaration) = ProjectOptionsInterface(owningAssemblyName, interfaceName, optionalParams);
+
+ declarations[declaration.Id] = declaration;
+
+ if (owned.Contains(item.OwningAssemblyName))
+ {
+ items.Add(item);
+ }
+ }
+
+ // Types reached through the referenced-type closure are named by generated unions and
+ // parameters but have no capabilities of their own in this context, so nothing above
+ // declared them. Emit an opaque interface for each so this package's declarations type-check
+ // standalone. They deliberately produce no documented item: the package that owns them
+ // publishes their real surface.
+ // Deduplicate by declared name rather than by type ID: several ATS type IDs can resolve to
+ // the same generated interface name, and emitting a stub for one of them would redeclare a
+ // type another fragment already declares in full.
+ var declaredNames = new HashSet(s_runtimeDeclaredNames, StringComparer.Ordinal);
+ foreach (var declaration in declarations.Values)
+ {
+ foreach (Match match in DeclaredTypeNameRegex().Matches(declaration.Content))
+ {
+ declaredNames.Add(match.Groups[1].Value);
+ }
+ }
+
+ foreach (var typeId in _resolved.HandleTypeIds.OrderBy(id => id, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var wrapperClassName = _wrapperClassNames.GetValueOrDefault(typeId);
+ var owningAssembly = GetTypeOwningAssemblyName(typeId);
+
+ // Handle types without a generated wrapper class surface in signatures under their raw
+ // handle alias name, so the fragment has to declare that exact alias. Deriving a class
+ // name here instead would declare a symbol no signature ever references and leave the
+ // referenced one undefined.
+ if (wrapperClassName is null)
+ {
+ var handleName = GetHandleTypeName(typeId);
+
+ if (declaredNames.Add(handleName))
+ {
+ declarations[$"{owningAssembly}:handle:{handleName}"] = new TypeScriptApiDeclaration
+ {
+ Id = $"{owningAssembly}:handle:{handleName}",
+ Content = $"export type {handleName} = Handle<'{typeId}'>;",
+ OwningAssemblyName = owningAssembly
+ };
+ }
+
+ continue;
+ }
+
+ var name = GetInterfaceName(wrapperClassName);
+
+ if (!declaredNames.Add(name))
+ {
+ continue;
+ }
+
+ var baseType = _typeRefsById.GetValueOrDefault(typeId)?.IsResourceBuilder == true
+ ? "ResourceBuilderBase"
+ : "HandleReference";
+
+ declarations[$"{owningAssembly}:opaque:{name}"] = new TypeScriptApiDeclaration
+ {
+ Id = $"{owningAssembly}:opaque:{name}",
+ Content = $"export interface {name} extends {baseType} {{}}",
+ OwningAssemblyName = owningAssembly
+ };
+
+ if (!_typesWithPromiseWrappers.Contains(typeId))
+ {
+ continue;
+ }
+
+ var promiseName = GetPromiseInterfaceName(wrapperClassName);
+ if (!declaredNames.Add(promiseName))
+ {
+ continue;
+ }
+
+ declarations[$"{owningAssembly}:opaque:{promiseName}"] = new TypeScriptApiDeclaration
+ {
+ Id = $"{owningAssembly}:opaque:{promiseName}",
+ Content = $"export interface {promiseName} extends PromiseLike<{name}> {{}}",
+ OwningAssemblyName = owningAssembly
+ };
+ }
+
+ var module = new TypeScriptApiModule
+ {
+ Name = package.Name,
+ Summary = null,
+ Items = [.. items.OrderBy(i => i.Id, StringComparer.Ordinal)]
+ };
+
+ return new TypeScriptApiModel
+ {
+ SchemaVersion = ExportSchemaVersion,
+ Language = "typescript",
+ Generator = s_generatorIdentity,
+ Package = package,
+ Modules = [module],
+ Declarations = [.. declarations.Values.OrderBy(d => d.Id, StringComparer.Ordinal)]
+ };
+ }
+
+ ///
+ /// Projects exported values into namespace declarations shared by source generation and API export.
+ ///
+ /// The values to project.
+ /// The rendered top-level namespaces and their canonical members.
+ internal IReadOnlyList ProjectExportedValues(
+ IReadOnlyList exportedValues)
+ {
+ var root = BuildExportedValueTree(exportedValues);
+ var namespaces = new List();
+
+ foreach (var (name, node) in root.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
+ {
+ var content = new StringBuilder();
+ var members = new List();
+ content.Append("export namespace ").Append(name).Append(" {\n");
+ AppendExportedValueChildren(content, node, [name], members, indentLevel: 1);
+ content.Append('}');
+ namespaces.Add(new TypeScriptExportedValueNamespace
+ {
+ Name = name,
+ Content = content.ToString(),
+ Members = members
+ });
+ }
+
+ return namespaces;
+ }
+
+ private void AppendExportedValueChildren(
+ StringBuilder content,
+ ExportedValueTreeNode node,
+ IReadOnlyList parentPath,
+ List members,
+ int indentLevel)
+ {
+ var indent = new string(' ', indentLevel * 4);
+
+ foreach (var (name, child) in node.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
+ {
+ var path = parentPath.Append(name).ToArray();
+ if (child.Value is { } valueInfo)
+ {
+ foreach (var documentationLine in RenderDocumentationComment(
+ indent,
+ valueInfo.Documentation,
+ valueInfo.Description))
+ {
+ content.Append(documentationLine).Append('\n');
+ }
+
+ var declaration = $"export const {name} = {RenderTypeScriptExportedValueExpression(valueInfo)}";
+ content.Append(indent).Append(declaration).Append(";\n");
+ members.Add(new TypeScriptApiMember
+ {
+ Id = $"constant:{string.Join(".", path)}",
+ Kind = TypeScriptApiItemKind.Constant,
+ Name = name,
+ Declaration = declaration,
+ Summary = valueInfo.Documentation?.Summary ?? valueInfo.Description,
+ Remarks = valueInfo.Documentation?.Remarks,
+ OwningAssemblyName = valueInfo.OwningAssemblyName
+ });
+ }
+ else
+ {
+ var declaration = $"export namespace {name}";
+ content.Append(indent).Append(declaration).Append(" {\n");
+ members.Add(new TypeScriptApiMember
+ {
+ Id = $"namespace:{string.Join(".", path)}",
+ Kind = TypeScriptApiItemKind.Namespace,
+ Name = name,
+ Declaration = declaration
+ });
+ AppendExportedValueChildren(content, child, path, members, indentLevel + 1);
+ content.Append(indent).Append("}\n");
+ }
+
+ content.Append('\n');
+ }
+ }
+
+ private string RenderTypeScriptExportedValueExpression(AtsExportedValueInfo exportedValue)
+ {
+ var literal = RenderTypeScriptExportedValue(exportedValue.Value, exportedValue.Type);
+ var exportedType = MapTypeRefToTypeScript(exportedValue.Type);
+
+ return exportedValue.Type.Category is AtsTypeCategory.Primitive
+ ? literal
+ : $"{literal} as {exportedType}";
+ }
+
+ private string RenderTypeScriptExportedValue(JsonNode? value, AtsTypeRef typeRef)
+ {
+ if (value is null)
+ {
+ return "null";
+ }
+
+ return typeRef.Category switch
+ {
+ AtsTypeCategory.Dto when value is JsonObject obj && _dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoInfo)
+ => RenderTypeScriptDtoValue(obj, dtoInfo),
+ AtsTypeCategory.Array or AtsTypeCategory.List when value is JsonArray arr
+ => $"[{string.Join(", ", arr.Select(item => RenderTypeScriptExportedValue(item, typeRef.ElementType!)))}]",
+ AtsTypeCategory.Dict when value is JsonObject obj
+ => "{ " + string.Join(", ", obj.Select(pair => $"{AtsJsonCodeWriter.ToRelaxedJsonString(pair.Key)}: {RenderTypeScriptExportedValue(pair.Value, typeRef.ValueType!)}")) + " }",
+ _ => value.ToRelaxedJsonString()
+ };
+ }
+
+ private string RenderTypeScriptDtoValue(JsonObject value, AtsDtoTypeInfo dtoInfo)
+ {
+ var members = new List();
+
+ foreach (var property in dtoInfo.Properties)
+ {
+ if (value.TryGetPropertyValue(property.Name, out var propertyValue))
+ {
+ members.Add($"{ToCamelCase(property.Name)}: {RenderTypeScriptExportedValue(propertyValue, property.Type)}");
+ }
+ }
+
+ return "{ " + string.Join(", ", members) + " }";
+ }
+
+ private static IReadOnlyList RenderDocumentationComment(
+ string indent,
+ AtsDocumentationInfo? documentation,
+ string? fallbackSummary)
+ {
+ var lines = new List();
+ AddDocumentationLines(lines, documentation?.Summary ?? fallbackSummary);
+ AddDocumentationLines(lines, documentation?.Remarks, addBlankLineBefore: lines.Count > 0);
+ AddTaggedDocumentationLines(lines, "@returns", documentation?.Returns);
+
+ if (lines.Count == 0)
+ {
+ return [];
+ }
+
+ if (lines.Count == 1 && !lines[0].StartsWith('@'))
+ {
+ return [$"{indent}/** {lines[0]} */"];
+ }
+
+ var comment = new List { $"{indent}/**" };
+ comment.AddRange(lines.Select(line => line.Length == 0 ? $"{indent} *" : $"{indent} * {line}"));
+ comment.Add($"{indent} */");
+ return comment;
+ }
+
+ private static void AddTaggedDocumentationLines(List lines, string tag, string? text)
+ {
+ var tagLines = SplitDocumentationLines(text);
+ if (tagLines.Count == 0)
+ {
+ return;
+ }
+
+ lines.Add($"{tag} {tagLines[0]}");
+ lines.AddRange(tagLines.Skip(1));
+ }
+
+ private static void AddDocumentationLines(List lines, string? text, bool addBlankLineBefore = false)
+ {
+ var textLines = SplitDocumentationLines(text);
+ if (textLines.Count == 0)
+ {
+ return;
+ }
+
+ if (addBlankLineBefore)
+ {
+ lines.Add(string.Empty);
+ }
+
+ lines.AddRange(textLines);
+ }
+
+ private static List SplitDocumentationLines(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return [];
+ }
+
+ return text
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace('\r', '\n')
+ .Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
+ .Select(EscapeJSDocText)
+ .ToList();
+ }
+
+ private static string EscapeJSDocText(string text) =>
+ ConvertAtsReferencesToJsDocLinks(text).Replace("*/", "* /", StringComparison.Ordinal);
+
+ private static string ConvertAtsReferencesToJsDocLinks(string text)
+ {
+ const string markerStart = "{@ats-ref ";
+ var startIndex = text.IndexOf(markerStart, StringComparison.Ordinal);
+ if (startIndex < 0)
+ {
+ return text;
+ }
+
+ var builder = new StringBuilder(text.Length);
+ var currentIndex = 0;
+
+ while (startIndex >= 0)
+ {
+ builder.Append(text, currentIndex, startIndex - currentIndex);
+ var markerBodyStartIndex = startIndex + markerStart.Length;
+ var markerEndIndex = text.IndexOf('}', markerBodyStartIndex);
+ if (markerEndIndex < 0)
+ {
+ builder.Append(text, startIndex, text.Length - startIndex);
+ return builder.ToString();
+ }
+
+ var markerBody = text[markerBodyStartIndex..markerEndIndex];
+ var labelSeparatorIndex = markerBody.IndexOf('|', StringComparison.Ordinal);
+ var reference = labelSeparatorIndex < 0 ? markerBody : markerBody[..labelSeparatorIndex];
+ var label = labelSeparatorIndex < 0 ? null : markerBody[(labelSeparatorIndex + 1)..];
+ var targetSeparatorIndex = reference.IndexOf(':', StringComparison.Ordinal);
+
+ if (targetSeparatorIndex < 0 || targetSeparatorIndex == reference.Length - 1)
+ {
+ builder.Append(text, startIndex, markerEndIndex - startIndex + 1);
+ }
+ else
+ {
+ var target = reference[(targetSeparatorIndex + 1)..];
+ builder.Append("{@link ").Append(target);
+ if (!string.IsNullOrWhiteSpace(label))
+ {
+ builder.Append('|').Append(label);
+ }
+
+ builder.Append('}');
+ }
+
+ currentIndex = markerEndIndex + 1;
+ startIndex = text.IndexOf(markerStart, currentIndex, StringComparison.Ordinal);
+ }
+
+ builder.Append(text, currentIndex, text.Length - currentIndex);
+ return builder.ToString();
+ }
+
+ private static ExportedValueTreeNode BuildExportedValueTree(IReadOnlyList exportedValues)
+ {
+ var root = new ExportedValueTreeNode();
+
+ foreach (var exportedValue in exportedValues)
+ {
+ var current = root;
+ foreach (var segment in exportedValue.PathSegments)
+ {
+ if (!current.Children.TryGetValue(segment, out var child))
+ {
+ child = new ExportedValueTreeNode();
+ current.Children[segment] = child;
+ }
+
+ current = child;
+ }
+
+ current.Value = exportedValue;
+ }
+
+ return root;
+ }
+
+ private static TypeScriptApiGeneratorIdentity CreateGeneratorIdentity()
+ {
+ var assembly = typeof(TypeScriptApiProjector).Assembly;
+ var version = assembly.GetCustomAttribute()?.InformationalVersion
+ ?? throw new InvalidOperationException(
+ $"The '{assembly.GetName().Name}' assembly has no informational version.");
+
+ return new TypeScriptApiGeneratorIdentity(assembly.GetName().Name!, version);
+ }
+
+ ///
+ /// Projects one builder into an optional documented item plus the declaration fragments it
+ /// contributes.
+ ///
+ ///
+ /// A package can extend a type another package owns. When that happens the type itself is not
+ /// documented here — the owning package publishes it — but the members this package contributes
+ /// still are. They are emitted as a separate interface augmentation fragment so TypeScript
+ /// declaration merging reassembles the referenced stub and this package's contributed surface.
+ ///
+ private (TypeScriptApiItem? Item, List Declarations) ProjectBuilder(
+ TypeScriptApiPackageIdentity package,
+ BuilderModel builderModel,
+ HashSet ownedAssemblyNames)
+ {
+ var isResourceBuilder = builderModel.TargetType?.IsResourceBuilder == true;
+ var interfaceName = GetInterfaceName(isResourceBuilder
+ ? builderModel.BuilderClassName
+ : DeriveClassName(builderModel.TypeId));
+ var members = new List();
+ var exportedCapabilities = builderModel.Capabilities
+ .Where(capability => ownedAssemblyNames.Contains(GetCapabilityOwningAssemblyName(capability)))
+ .ToList();
+
+ var promiseMembers = new List();
+ var getters = exportedCapabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
+ var setters = exportedCapabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
+
+ foreach (var property in GroupPropertiesByName(getters, setters))
+ {
+ var member = ProjectProperty(interfaceName, property.PropertyName, property.Getter, property.Setter);
+ members.Add(member);
+ if (IsGetterOnlyProperty(property.Getter, property.Setter))
+ {
+ promiseMembers.Add(member);
+ }
+ }
+
+ // Type classes only surface instance and static methods; resource builders surface every
+ // non-property capability. Mirroring that split keeps the export aligned with the interfaces
+ // the generator actually writes.
+ var methods = isResourceBuilder
+ ? exportedCapabilities.Where(c =>
+ c.CapabilityKind != AtsCapabilityKind.PropertyGetter &&
+ c.CapabilityKind != AtsCapabilityKind.PropertySetter)
+ : exportedCapabilities.Where(c =>
+ c.CapabilityKind is AtsCapabilityKind.InstanceMethod or AtsCapabilityKind.Method);
+
+ foreach (var capability in methods)
+ {
+ var member = ProjectMethod(interfaceName, builderModel, capability);
+ members.Add(member);
+ promiseMembers.Add(member);
+ }
+
+ var documentation = _handleDocumentationById.GetValueOrDefault(builderModel.TypeId);
+ string[] extends = isResourceBuilder ? ["ResourceBuilderBase"] : [];
+ var typeOwner = GetTypeOwningAssemblyName(builderModel.TypeId);
+ var declarations = new List();
+
+ // Every method returns the owning type's fluent promise interface, so the promise interface
+ // has to be declared alongside the interface or the fragments cannot type-check.
+ var promiseInterfaceName = _typesWithPromiseWrappers.Contains(builderModel.TypeId)
+ ? GetPromiseInterfaceName(isResourceBuilder ? builderModel.BuilderClassName : DeriveClassName(builderModel.TypeId))
+ : null;
+
+ if (ownedAssemblyNames.Contains(typeOwner))
+ {
+ declarations.Add(new TypeScriptApiDeclaration
+ {
+ Id = $"{typeOwner}:interface:{interfaceName}",
+ Content = BuildInterfaceBody(interfaceName, extends, members, includeToJson: true),
+ OwningAssemblyName = typeOwner
+ });
+
+ if (promiseInterfaceName is not null)
+ {
+ declarations.Add(new TypeScriptApiDeclaration
+ {
+ Id = $"{typeOwner}:interface:{promiseInterfaceName}",
+ Content = BuildInterfaceBody(promiseInterfaceName, [$"PromiseLike<{interfaceName}>"], promiseMembers, includeToJson: false),
+ OwningAssemblyName = typeOwner
+ });
+ }
+
+ return (BuildInterfaceItem(builderModel, $"interface:{interfaceName}", interfaceName, extends, typeOwner, documentation, members, TypeScriptApiItemKind.Interface), declarations);
+ }
+
+ // The referenced type gets one opaque stub keyed by its real owner within this package export.
+ declarations.Add(new TypeScriptApiDeclaration
+ {
+ Id = $"{typeOwner}:opaque:{interfaceName}",
+ Content = $"export interface {interfaceName} extends {(isResourceBuilder ? "ResourceBuilderBase" : "HandleReference")} {{}}",
+ OwningAssemblyName = typeOwner
+ });
+
+ if (promiseInterfaceName is not null)
+ {
+ declarations.Add(new TypeScriptApiDeclaration
+ {
+ Id = $"{typeOwner}:opaque:{promiseInterfaceName}",
+ Content = $"export interface {promiseInterfaceName} extends PromiseLike<{interfaceName}> {{}}",
+ OwningAssemblyName = typeOwner
+ });
+ }
+
+ if (members.Count == 0)
+ {
+ return (null, declarations);
+ }
+
+ declarations.Add(new TypeScriptApiDeclaration
+ {
+ Id = $"{package.Name}:augment:{interfaceName}",
+ Content = BuildInterfaceBody(interfaceName, [], members, includeToJson: false),
+ OwningAssemblyName = package.Name
+ });
+
+ if (promiseInterfaceName is not null)
+ {
+ declarations.Add(new TypeScriptApiDeclaration
+ {
+ Id = $"{package.Name}:augment:{promiseInterfaceName}",
+ Content = BuildInterfaceBody(promiseInterfaceName, [], promiseMembers, includeToJson: false),
+ OwningAssemblyName = package.Name
+ });
+ }
+
+ // The item carries the real owner and a distinct ID because it describes only this package's
+ // contribution, not a second copy of the referenced type. Include the contributing package
+ // because an aggregate export can contain several augmentations for the same interface name.
+ return (BuildInterfaceItem(builderModel, $"augmentation:{package.Name}:{interfaceName}", interfaceName, extends, typeOwner, documentation, members, TypeScriptApiItemKind.Augmentation), declarations);
+ }
+
+ private static TypeScriptApiItem BuildInterfaceItem(
+ BuilderModel builderModel,
+ string id,
+ string interfaceName,
+ string[] extends,
+ string owningAssemblyName,
+ AtsDocumentationInfo? documentation,
+ List members,
+ TypeScriptApiItemKind kind)
+ => new()
+ {
+ Id = id,
+ TypeId = builderModel.TypeId,
+ Kind = kind,
+ Name = interfaceName,
+ Declaration = BuildInterfaceHeader(interfaceName, extends),
+ OwningAssemblyName = owningAssemblyName,
+ Summary = documentation?.Summary,
+ Remarks = documentation?.Remarks,
+ Extends = extends,
+ Members = members
+ };
+
+ ///
+ /// Matches the name a declaration fragment declares, for example the RedisResource in
+ /// export interface RedisResource extends ResourceBuilderBase {.
+ ///
+ ///
+ /// $ is matched as well as \w because package-qualified options interfaces embed
+ /// it as the qualifier terminator, and capturing only the qualifier would leave the real name
+ /// out of the declared set.
+ ///
+ [GeneratedRegex(@"^export (?:interface|enum|type) ([\w$]+)", RegexOptions.Multiline)]
+ private static partial Regex DeclaredTypeNameRegex();
+
+ private static string BuildInterfaceBody(
+ string interfaceName,
+ IReadOnlyList extends,
+ List members,
+ bool includeToJson)
+ {
+ var body = new StringBuilder();
+ body.Append(BuildInterfaceHeader(interfaceName, extends)).Append(" {\n");
+
+ if (includeToJson)
+ {
+ body.Append(" toJSON(): MarshalledHandle;\n");
+ }
+
+ foreach (var member in members)
+ {
+ body.Append(" ").Append(member.Declaration).Append(";\n");
+ }
+
+ return body.Append('}').ToString();
+ }
+
+ private TypeScriptApiMember ProjectMethod(
+ string ownerName,
+ BuilderModel? builderModel,
+ AtsCapabilityInfo capability)
+ {
+ var signature = ResolveMethodSignature(builderModel, capability);
+
+ return new TypeScriptApiMember
+ {
+ Id = $"method:{ownerName}.{capability.MethodName}",
+ Kind = TypeScriptApiItemKind.Method,
+ Name = signature.MethodName,
+ Declaration = signature.Declaration,
+ Summary = capability.Documentation?.Summary,
+ Remarks = capability.Documentation?.Remarks,
+ DeprecationMessage = capability.IsObsolete ? capability.ObsoleteMessage ?? string.Empty : null,
+ CapabilityId = capability.CapabilityId,
+ OwningAssemblyName = GetCapabilityOwningAssemblyName(capability),
+ Parameters = signature.Parameters,
+ ReturnType = signature.ReturnType
+ };
+ }
+
+ private TypeScriptApiMember ProjectProperty(
+ string ownerName,
+ string propertyName,
+ AtsCapabilityInfo? getter,
+ AtsCapabilityInfo? setter)
+ {
+ string declaration;
+
+ if (IsGetterOnlyProperty(getter, setter))
+ {
+ declaration = $"{propertyName}(): {GetGetterOnlyPropertyMethodReturnType(getter!.ReturnType)}";
+ }
+ else if (getter?.ReturnType is { } returnType && IsDictionaryType(returnType))
+ {
+ var keyType = returnType.KeyType is not null ? MapTypeRefToTypeScript(returnType.KeyType) : "string";
+ var valueType = returnType.ValueType is not null ? MapTypeRefToTypeScript(returnType.ValueType) : "unknown";
+ declaration = $"readonly {propertyName}: AspireDict<{keyType}, {valueType}>";
+ }
+ else if (getter?.ReturnType is { } listReturnType && IsListType(listReturnType))
+ {
+ var elementType = listReturnType.ElementType is not null ? MapTypeRefToTypeScript(listReturnType.ElementType) : "unknown";
+ declaration = $"readonly {propertyName}: AspireList<{elementType}>";
+ }
+ else
+ {
+ var accessors = new List();
+ if (getter is not null)
+ {
+ var getReturn = TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out _)
+ ? promiseInterfaceName
+ : $"Promise<{MapTypeRefToTypeScript(getter.ReturnType)}>";
+ accessors.Add($"get: () => {getReturn}");
+ }
+
+ if (setter?.Parameters.FirstOrDefault(p => p.Name == "value") is { } valueParam)
+ {
+ accessors.Add($"set: (value: {MapInputTypeToTypeScript(valueParam.Type)}) => Promise");
+ }
+
+ declaration = $"{propertyName}: {{ {string.Join("; ", accessors)} }}";
+ }
+
+ var documentation = getter?.Documentation ?? setter?.Documentation;
+
+ return new TypeScriptApiMember
+ {
+ Id = $"property:{ownerName}.{propertyName}",
+ Kind = TypeScriptApiItemKind.Property,
+ Name = propertyName,
+ Declaration = declaration,
+ Summary = documentation?.Summary,
+ Remarks = documentation?.Remarks,
+ DeprecationMessage = (getter ?? setter) is { IsObsolete: true } obsolete ? obsolete.ObsoleteMessage ?? string.Empty : null,
+ CapabilityId = (getter ?? setter)?.CapabilityId,
+ OwningAssemblyName = (getter ?? setter) is { } capability ? GetCapabilityOwningAssemblyName(capability) : null
+ };
+ }
+
+ private (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectEntryPoint(AtsCapabilityInfo capability)
+ {
+ var signature = ResolveEntryPointSignature(capability);
+ var owningAssemblyName = GetCapabilityOwningAssemblyName(capability);
+
+ var item = new TypeScriptApiItem
+ {
+ Id = $"entrypoint:{owningAssemblyName}:{signature.MethodName}",
+ TypeId = capability.CapabilityId,
+ Kind = TypeScriptApiItemKind.Method,
+ Name = signature.MethodName,
+ Declaration = $"function {signature.Declaration}",
+ OwningAssemblyName = owningAssemblyName,
+ Summary = capability.Documentation?.Summary,
+ Remarks = capability.Documentation?.Remarks,
+ Members = []
+ };
+
+ return (item, new TypeScriptApiDeclaration
+ {
+ Id = $"{owningAssemblyName}:entrypoint:{signature.MethodName}",
+ Content = $"export declare {item.Declaration};",
+ OwningAssemblyName = owningAssemblyName
+ });
+ }
+
+ ///
+ /// Resolves the signature of an entry-point capability -- one that hangs off the client rather
+ /// than a builder type -- for both the emitted function and the exported declaration.
+ ///
+ ///
+ ///
+ /// Entry points are shaped unlike every other capability, which is why they cannot share
+ /// . They are free functions rather than members, so the
+ /// client has to be passed explicitly as the first parameter, and their optional arguments stay
+ /// positional instead of collapsing into an options bag.
+ ///
+ ///
+ /// Routing through gave the
+ /// export the member shape -- no client, optionals folded into an options interface --
+ /// while GenerateEntryPointFunction emitted the free-function shape. Consumers type-check
+ /// the exported declarations against the generated SDK, so the two disagreeing produced
+ /// declarations that did not describe any callable function.
+ ///
+ ///
+ internal TypeScriptApiMethodSignature ResolveEntryPointSignature(AtsCapabilityInfo capability)
+ {
+ ArgumentNullException.ThrowIfNull(capability);
+
+ var (requiredParameters, _) = SeparateParameters(capability.Parameters);
+
+ var parameters = new List
+ {
+ new() { Name = EntryPointClientParameterName, DeclaredType = EntryPointClientParameterType, IsOptional = false }
+ };
+
+ foreach (var parameter in capability.Parameters)
+ {
+ parameters.Add(new TypeScriptApiParameter
+ {
+ Name = parameter.Name,
+ DeclaredType = MapParameterToTypeScript(parameter),
+ IsOptional = parameter.IsOptional || parameter.IsNullable,
+ Summary = parameter.Documentation?.Summary
+ });
+ }
+
+ return new TypeScriptApiMethodSignature
+ {
+ MethodName = capability.MethodName,
+ ReturnType = ResolveEntryPointReturnType(capability),
+ Parameters = parameters,
+ RequiredParameters = requiredParameters
+ };
+ }
+
+ private string ResolveEntryPointReturnType(AtsCapabilityInfo capability)
+ {
+ var returnTypeId = capability.ReturnType?.TypeId;
+
+ // A capability that returns a wrapped handle is emitted as a fluent function returning the
+ // promise wrapper directly, so it is already thenable and is not wrapped again.
+ if (GetPromiseWrapperForReturnType(capability.ReturnType) is { } promiseWrapper && !string.IsNullOrEmpty(returnTypeId))
+ {
+ return promiseWrapper;
+ }
+
+ return $"Promise<{(string.IsNullOrEmpty(returnTypeId) ? "void" : MapTypeRefToTypeScript(capability.ReturnType))}>";
+ }
+
+ private static (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectEnum(AtsEnumTypeInfo enumType)
+ {
+ var owningAssemblyName = GetOwningAssemblyName(enumType.TypeId, enumType.ClrType?.Assembly.GetName().Name);
+
+ var values = enumType.ValueInfos.Count > 0
+ ? enumType.ValueInfos
+ : [.. enumType.Values.Select(value => new AtsEnumValueInfo { Name = value })];
+
+ var members = values
+ .Select(value => new TypeScriptApiMember
+ {
+ Id = $"enumValue:{enumType.Name}.{value.Name}",
+ Kind = TypeScriptApiItemKind.Property,
+ Name = value.Name,
+ Declaration = $"{value.Name} = \"{value.Name}\"",
+ Summary = value.Documentation?.Summary,
+ OwningAssemblyName = owningAssemblyName
+ })
+ .ToList();
+
+ var item = new TypeScriptApiItem
+ {
+ Id = $"enum:{enumType.Name}",
+ TypeId = enumType.TypeId,
+ Kind = TypeScriptApiItemKind.Enum,
+ Name = enumType.Name,
+ Declaration = $"export enum {enumType.Name}",
+ OwningAssemblyName = owningAssemblyName,
+ Summary = enumType.Documentation?.Summary,
+ Remarks = enumType.Documentation?.Remarks,
+ Members = members
+ };
+
+ var body = new StringBuilder();
+ body.Append("export enum ").Append(enumType.Name).Append(" {\n");
+ foreach (var member in members)
+ {
+ body.Append(" ").Append(member.Declaration).Append(",\n");
+ }
+ body.Append('}');
+
+ return (item, new TypeScriptApiDeclaration
+ {
+ Id = $"{item.OwningAssemblyName}:enum:{enumType.Name}",
+ Content = body.ToString(),
+ OwningAssemblyName = item.OwningAssemblyName
+ });
+ }
+
+ ///
+ /// Properties the TypeScript client adds to a DTO that has no C# counterpart. The emitter used to
+ /// own this list, so the exported interface described fewer properties than the module we actually
+ /// ship. Both paths read it from here now.
+ ///
+ private static readonly IReadOnlyDictionary> s_clientOnlyDtoProperties =
+ new Dictionary>(StringComparer.Ordinal)
+ {
+ ["CreateBuilderOptions"] =
+ [
+ new ClientOnlyDtoProperty(
+ "throwOnPendingRejections",
+ "boolean",
+ "When false, pre-flush rejected promises are not re-thrown by build(). Default: true.")
+ ]
+ };
+
+ internal static IReadOnlyList GetClientOnlyDtoProperties(string interfaceName)
+ => s_clientOnlyDtoProperties.TryGetValue(interfaceName, out var properties) ? properties : [];
+
+ private (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectDto(AtsDtoTypeInfo dtoType)
+ {
+ var interfaceName = GetDtoInterfaceName(dtoType.TypeId);
+ var owningAssemblyName = GetOwningAssemblyName(dtoType.TypeId, dtoType.ClrType?.Assembly.GetName().Name);
+
+ var members = dtoType.Properties
+ .Select(property =>
+ {
+ var propertyName = ToCamelCase(property.Name);
+ var propertyType = property.IsCallback
+ ? GenerateCallbackTypeSignature(property.CallbackParameters, property.CallbackReturnType)
+ : MapDtoPropertyTypeToTypeScript(property.Type);
+ return new TypeScriptApiMember
+ {
+ Id = $"property:{interfaceName}.{propertyName}",
+ Kind = TypeScriptApiItemKind.Property,
+ Name = propertyName,
+ Declaration = $"{propertyName}?: {propertyType}",
+ Summary = property.Documentation?.Summary ?? property.Description,
+ OwningAssemblyName = owningAssemblyName
+ };
+ })
+ .ToList();
+
+ members.AddRange(GetClientOnlyDtoProperties(interfaceName).Select(property => new TypeScriptApiMember
+ {
+ Id = $"property:{interfaceName}.{property.Name}",
+ Kind = TypeScriptApiItemKind.Property,
+ Name = property.Name,
+ Declaration = $"{property.Name}?: {property.Type}",
+ Summary = property.Summary,
+ OwningAssemblyName = owningAssemblyName
+ }));
+
+ var item = new TypeScriptApiItem
+ {
+ Id = $"dto:{interfaceName}",
+ TypeId = dtoType.TypeId,
+ Kind = TypeScriptApiItemKind.Dto,
+ Name = interfaceName,
+ Declaration = $"export interface {interfaceName}",
+ OwningAssemblyName = owningAssemblyName,
+ Summary = dtoType.Documentation?.Summary,
+ Remarks = dtoType.Documentation?.Remarks,
+ Members = members
+ };
+
+ var body = new StringBuilder();
+ body.Append("export interface ").Append(interfaceName).Append(" {\n");
+ foreach (var member in members)
+ {
+ body.Append(" ").Append(member.Declaration).Append(";\n");
+ }
+ body.Append('}');
+
+ return (item, new TypeScriptApiDeclaration
+ {
+ Id = $"{item.OwningAssemblyName}:dto:{interfaceName}",
+ Content = body.ToString(),
+ OwningAssemblyName = item.OwningAssemblyName
+ });
+ }
+
+ private (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectOptionsInterface(
+ string owningAssemblyName,
+ string interfaceName,
+ List optionalParams)
+ {
+ var members = optionalParams
+ .Select(param => new TypeScriptApiMember
+ {
+ Id = $"property:{interfaceName}.{param.Name}",
+ Kind = TypeScriptApiItemKind.Property,
+ Name = param.Name,
+ Declaration = $"{param.Name}?: {MapParameterToTypeScript(param)}",
+ Summary = param.Documentation?.Summary,
+ OwningAssemblyName = owningAssemblyName
+ })
+ .ToList();
+
+ var item = new TypeScriptApiItem
+ {
+ Id = $"options:{interfaceName}",
+ TypeId = $"{owningAssemblyName}/{interfaceName}",
+ Kind = TypeScriptApiItemKind.Options,
+ Name = interfaceName,
+ Declaration = $"export interface {interfaceName}",
+ OwningAssemblyName = owningAssemblyName,
+ Members = members
+ };
+
+ var body = new StringBuilder();
+ body.Append("export interface ").Append(interfaceName).Append(" {\n");
+ foreach (var member in members)
+ {
+ body.Append(" ").Append(member.Declaration).Append(";\n");
+ }
+ body.Append('}');
+
+ return (item, new TypeScriptApiDeclaration
+ {
+ Id = $"{owningAssemblyName}:options:{interfaceName}",
+ Content = body.ToString(),
+ OwningAssemblyName = owningAssemblyName
+ });
+ }
+
+ private static string BuildInterfaceHeader(string interfaceName, IReadOnlyList extends)
+ => extends.Count > 0
+ ? $"export interface {interfaceName} extends {string.Join(", ", extends)}"
+ : $"export interface {interfaceName}";
+
+ ///
+ /// Resolves the owning assembly from the leading segment of an ATS identifier.
+ ///
+ ///
+ /// ATS identifiers are {Prefix}/{FullTypeNameOrMemberName}, for example
+ /// Aspire.Hosting.Redis/RedisResource or Aspire.Hosting.Redis/addRedis. The prefix
+ /// is usually the assembly name, but instance members carry the declaring namespace instead
+ /// (Contoso.Widgets.Model/WidgetContext.name), so this is only a fallback for symbols
+ /// that carry no CLR reflection info. Enum type IDs use the enum: prefix and have no
+ /// segment at all, so the caller supplies the CLR assembly name.
+ ///
+ private static string GetOwningAssemblyName(string atsId, string? clrAssemblyName = null)
+ {
+ if (clrAssemblyName is { Length: > 0 })
+ {
+ return clrAssemblyName;
+ }
+
+ var separatorIndex = atsId.IndexOf('/');
+ return separatorIndex > 0 ? atsId[..separatorIndex] : string.Empty;
+ }
+
+ ///
+ /// Resolves the assembly that owns a capability, preferring CLR reflection info over the
+ /// identifier prefix so that instance members — whose IDs are namespace-qualified rather than
+ /// assembly-qualified — are attributed to the package that actually declares them.
+ ///
+ ///
+ /// This mirrors AtsContextFilter.IsCapabilityOwnedBySelectedAssembly. The two must agree,
+ /// or the exporter would document symbols the filter excluded, or drop symbols it kept.
+ ///
+ private string GetCapabilityOwningAssemblyName(AtsCapabilityInfo capability)
+ => GetCapabilityOwningAssemblyName(_resolved.Context, capability);
+
+ ///
+ ///
+ /// Takes the context explicitly so can attribute capabilities while it is
+ /// still building the model that _resolved will hold.
+ ///
+ private static string GetCapabilityOwningAssemblyName(AtsContext context, AtsCapabilityInfo capability)
+ {
+ if (context.Methods.TryGetValue(capability.CapabilityId, out var method))
+ {
+ return method.DeclaringType?.Assembly.GetName().Name ?? string.Empty;
+ }
+
+ if (context.Properties.TryGetValue(capability.CapabilityId, out var property))
+ {
+ return property.DeclaringType?.Assembly.GetName().Name ?? string.Empty;
+ }
+
+ return GetOwningAssemblyName(capability.CapabilityId, capability.TargetType?.ClrType?.Assembly.GetName().Name);
+ }
+
+ ///
+ /// Resolves the assembly that owns a handle type, preferring CLR reflection info for the same
+ /// reason as .
+ ///
+ private string GetTypeOwningAssemblyName(string typeId)
+ => GetOwningAssemblyName(typeId, _typeRefsById.GetValueOrDefault(typeId)?.ClrType?.Assembly.GetName().Name);
+
+ // Mapping of typeId -> wrapper class name for all generated wrapper types
+ // Used to resolve parameter types to wrapper classes instead of handle types
+ private readonly Dictionary _wrapperClassNames = new(StringComparer.Ordinal);
+
+ // Wrapper classes are deduplicated by generated class name, but their handles are branded by
+ // TypeId. Keep the retained TypeId so every canonical implementation receives its branded handle.
+ private readonly Dictionary _concreteTypeIds = new(StringComparer.Ordinal);
+
+ private readonly Dictionary _typeRefsById = new(StringComparer.Ordinal);
+
+ // Set of type IDs that have Promise wrappers (chainable or directly returned resource builders)
+ // Used to determine return types for methods
+
+ private readonly HashSet _typesWithPromiseWrappers = new(StringComparer.Ordinal);
+
+ // Set of generated options interfaces to avoid duplicates
+
+ private readonly HashSet _generatedOptionsInterfaces = new(StringComparer.Ordinal);
+
+ // Collected options interfaces to generate (interface name -> list of optional params)
+
+ private readonly Dictionary> _optionsInterfacesToGenerate = new(StringComparer.Ordinal);
+
+ // Mapping from CapabilityId to the options interface name it should use.
+ // When methods share a name but have incompatible callback parameter types,
+ // separate options interfaces are generated with numeric suffixes.
+
+ private readonly Dictionary _capabilityOptionsInterfaceMap = new(StringComparer.Ordinal);
+
+ // Mapping from options interface name to the assembly that owns it. An interface belongs to the
+ // assembly whose capability produced it, which is not necessarily the package an export was
+ // requested for: a scan holds several assemblies, and only some of them are being documented.
+
+ private readonly Dictionary _optionsInterfaceOwningAssemblies = new(StringComparer.Ordinal);
+
+ // Mapping of enum type IDs to TypeScript enum names
+
+ private readonly Dictionary _enumTypeNames = new(StringComparer.Ordinal);
+
+ // Mapping of handle type IDs to XML documentation captured during ATS scanning.
+
+ private readonly Dictionary _handleDocumentationById = new(StringComparer.Ordinal);
+
+ // Mapping of DTO type IDs to DTO metadata for generated argument marshalling.
+
+ private readonly Dictionary _dtoTypesById = new(StringComparer.Ordinal);
+
+ internal static string GetInterfaceName(string className) => className;
+
+ internal static string GetPromiseInterfaceName(string className) => $"{className}Promise";
+
+ internal static string GetImplementationClassName(string className) => $"{className}Impl";
+
+ internal static string GetImplementationPromiseClassName(string className) => $"{className}PromiseImpl";
+
+ internal static string GetReferenceExpressionInterfaceName() => "ReferenceExpression";
+
+ internal static string GetCancellationTokenInterfaceName() => "CancellationToken";
+
+ internal static string GetHandleReferenceInterfaceName() => "HandleReference";
+
+ internal static string GetInputTypeEnumName() => "InputType";
+
+ internal static string GetInteractionInputInterfaceName() => "InteractionInput";
+
+ internal static string GetInteractionInputCollectionClassName() => "InteractionInputCollection";
+
+ internal const string InputTypeTypeId = "enum:Aspire.Hosting.InputType";
+ internal const string InteractionInputTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInput";
+
+ internal const string InteractionInputCollectionTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInputCollection";
+
+ internal string GetConcreteClassName(string typeId) => _wrapperClassNames.GetValueOrDefault(typeId)
+ ?? DeriveClassName(typeId);
+
+ internal string GetConcreteTypeId(string typeId) => _concreteTypeIds.GetValueOrDefault(typeId)
+ ?? typeId;
+
+ internal string GetConcreteHandleTypeName(string typeId) => GetHandleTypeName(GetConcreteTypeId(typeId));
+
+ internal string GetPublicPromiseInterfaceName(string typeId) => GetPromiseInterfaceName(GetConcreteClassName(typeId));
+
+ internal static bool IsHandleType(AtsTypeRef? typeRef) =>
+ typeRef is { Category: AtsTypeCategory.Handle };
+
+ ///
+ /// Maps an AtsTypeRef to a TypeScript type using category-based dispatch.
+ /// This is the preferred method - uses type metadata rather than string parsing.
+ ///
+
+ internal string MapTypeRefToTypeScript(AtsTypeRef? typeRef)
+ {
+ if (typeRef is null)
+ {
+ return "unknown";
+ }
+
+ // ReferenceExpression is a value type defined in base.mts, not a handle-based wrapper
+ if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId)
+ {
+ return GetReferenceExpressionInterfaceName();
+ }
+
+ if (typeRef.TypeId == InputTypeTypeId)
+ {
+ return GetInputTypeEnumName();
+ }
+
+ if (typeRef.TypeId == InteractionInputTypeId)
+ {
+ return GetInteractionInputInterfaceName();
+ }
+
+ if (typeRef.TypeId == InteractionInputCollectionTypeId)
+ {
+ return GetInteractionInputCollectionClassName();
+ }
+
+ // Check for wrapper class first (handles custom types like resource builders)
+ if (_wrapperClassNames.TryGetValue(typeRef.TypeId, out var wrapperClassName))
+ {
+ return GetInterfaceName(wrapperClassName);
+ }
+
+ var mappedType = typeRef.Category switch
+ {
+ AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId),
+ AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
+ AtsTypeCategory.Handle => GetWrapperOrHandleName(typeRef.TypeId),
+ AtsTypeCategory.Dto => GetDtoInterfaceName(typeRef.TypeId),
+ AtsTypeCategory.Callback => "Function", // Callbacks handled separately with full signature
+ AtsTypeCategory.Array => $"{MapTypeRefToTypeScript(typeRef.ElementType)}[]",
+ AtsTypeCategory.List => $"AspireList<{MapTypeRefToTypeScript(typeRef.ElementType)}>",
+ AtsTypeCategory.Dict => typeRef.IsReadOnly
+ ? $"Record<{MapTypeRefToTypeScript(typeRef.KeyType)}, {MapTypeRefToTypeScript(typeRef.ValueType)}>"
+ : $"AspireDict<{MapTypeRefToTypeScript(typeRef.KeyType)}, {MapTypeRefToTypeScript(typeRef.ValueType)}>",
+ AtsTypeCategory.Union => MapUnionTypeToTypeScript(typeRef),
+ AtsTypeCategory.Unknown => "any", // Unknown types use 'any' since they're not in the ATS universe
+ _ => "any" // Fallback for any unhandled categories
+ };
+ return ApplyNullableType(typeRef, mappedType);
+ }
+
+ internal static string ApplyNullableType(AtsTypeRef typeRef, string mappedType)
+ {
+ if (typeRef.IsNullable != true || typeRef.Category is not (AtsTypeCategory.Primitive or AtsTypeCategory.Enum))
+ {
+ return mappedType;
+ }
+
+ return typeRef.TypeId is AtsConstants.Void or AtsConstants.Any or AtsConstants.CancellationToken
+ ? mappedType
+ : $"{mappedType} | null";
+ }
+
+ internal string MapDtoPropertyTypeToTypeScript(AtsTypeRef? typeRef)
+ {
+ if (typeRef is null)
+ {
+ return "unknown";
+ }
+
+ return typeRef.Category switch
+ {
+ AtsTypeCategory.Array or AtsTypeCategory.List => $"{MapDtoPropertyTypeToTypeScript(typeRef.ElementType)}[]",
+ AtsTypeCategory.Dict => $"Record<{MapDtoPropertyTypeToTypeScript(typeRef.KeyType)}, {MapDtoPropertyTypeToTypeScript(typeRef.ValueType)}>",
+ AtsTypeCategory.Union => MapDtoUnionTypeToTypeScript(typeRef),
+ _ => MapTypeRefToTypeScript(typeRef)
+ };
+ }
+
+ internal string MapDtoUnionTypeToTypeScript(AtsTypeRef typeRef)
+ {
+ if (typeRef.UnionTypes is null || typeRef.UnionTypes.Count == 0)
+ {
+ return "unknown";
+ }
+
+ var memberTypes = typeRef.UnionTypes
+ .Select(MapDtoPropertyTypeToTypeScript)
+ .Distinct();
+
+ return string.Join(" | ", memberTypes);
+ }
+
+ ///
+ /// Maps primitive type IDs to TypeScript types.
+ ///
+
+ internal static string MapPrimitiveType(string typeId) => typeId switch
+ {
+ AtsConstants.String or AtsConstants.Char => "string",
+ AtsConstants.Number => "number",
+ AtsConstants.Boolean => "boolean",
+ AtsConstants.Void => "void",
+ AtsConstants.Any => "any",
+ AtsConstants.DateTime or AtsConstants.DateTimeOffset or
+ AtsConstants.DateOnly or AtsConstants.TimeOnly => "string",
+ AtsConstants.TimeSpan => "number",
+ AtsConstants.Guid or AtsConstants.Uri => "string",
+ AtsConstants.CancellationToken => GetCancellationTokenInterfaceName(),
+ _ => typeId
+ };
+
+ ///
+ /// Maps an enum type ID to the generated TypeScript enum name.
+ /// Throws if the enum type wasn't collected during scanning.
+ ///
+
+ internal string MapEnumType(string typeId)
+ {
+ if (!_enumTypeNames.TryGetValue(typeId, out var enumName))
+ {
+ throw new InvalidOperationException(
+ $"Enum type '{typeId}' was not found in the scanned enum types. " +
+ $"This indicates the enum type was not discovered during assembly scanning.");
+ }
+ return enumName;
+ }
+
+ ///
+ /// Maps a union type to TypeScript union syntax (T1 | T2 | ...).
+ ///
+
+ internal string MapUnionTypeToTypeScript(AtsTypeRef typeRef)
+ {
+ if (typeRef.UnionTypes == null || typeRef.UnionTypes.Count == 0)
+ {
+ return "unknown";
+ }
+
+ var memberTypes = typeRef.UnionTypes
+ .Select(MapTypeRefToTypeScript)
+ .Distinct();
+
+ return string.Join(" | ", memberTypes);
+ }
+
+ ///
+ /// Gets the wrapper class name or handle type name for a handle type ID.
+ /// Prefers wrapper class if one exists, otherwise generates a handle type name.
+ ///
+
+ internal string GetWrapperOrHandleName(string typeId)
+ {
+ if (_wrapperClassNames.TryGetValue(typeId, out var wrapperClassName))
+ {
+ return wrapperClassName;
+ }
+ return GetHandleTypeName(typeId);
+ }
+
+ ///
+ /// Gets a TypeScript interface name for a DTO type.
+ ///
+
+ internal static string GetDtoInterfaceName(string typeId)
+ {
+ return ExtractSimpleTypeName(typeId);
+ }
+
+ ///
+ /// Maps a user-supplied input type to TypeScript.
+ /// For interface handle types, generated APIs accept any handle-bearing wrapper instance.
+ /// For cancellation tokens, generated APIs accept either an AbortSignal or a transport-safe CancellationToken.
+ ///
+ ///
+ /// Handle types are widened to accept Awaitable<T> so callers can pass un-awaited
+ /// fluent chains directly. Examples:
+ ///
+ /// // Input: RedisResource handle type
+ /// // Output: "Awaitable<RedisResource>"
+ ///
+ /// // Input: Union of string | RedisResource
+ /// // Output: "string | Awaitable<RedisResource>"
+ ///
+ /// // Input: CancellationToken type
+ /// // Output: "AbortSignal | CancellationToken"
+ ///
+ /// // Input: plain string type
+ /// // Output: "string"
+ ///
+ ///
+
+ internal string MapInputTypeToTypeScript(AtsTypeRef? typeRef)
+ {
+ if (typeRef?.Category == AtsTypeCategory.Union)
+ {
+ return MapInputUnionTypeToTypeScript(typeRef);
+ }
+
+ if (IsInterfaceHandleType(typeRef))
+ {
+ if (TryMapInterfaceInputTypeToTypeScript(typeRef!) is { } interfaceInputType)
+ {
+ return $"Awaitable<{interfaceInputType}>";
+ }
+
+ var handleName = GetHandleReferenceInterfaceName();
+ return $"Awaitable<{handleName}>";
+ }
+
+ if (IsHandleType(typeRef) && _wrapperClassNames.TryGetValue(typeRef!.TypeId, out var className))
+ {
+ var ifaceName = GetInterfaceName(className);
+ return $"Awaitable<{ifaceName}>";
+ }
+
+ if (typeRef?.TypeId == InteractionInputCollectionTypeId)
+ {
+ return $"Awaitable<{GetInteractionInputCollectionClassName()}>";
+ }
+
+ if (IsCancellationTokenType(typeRef))
+ {
+ return $"AbortSignal | {GetCancellationTokenInterfaceName()}";
+ }
+
+ return MapTypeRefToTypeScript(typeRef);
+ }
+
+ internal string MapInputUnionTypeToTypeScript(AtsTypeRef typeRef)
+ {
+ if (typeRef.UnionTypes == null || typeRef.UnionTypes.Count == 0)
+ {
+ throw new InvalidOperationException("Union input types must define at least one member type.");
+ }
+
+ // Build union structurally: each member is mapped individually.
+ // Handle types become Awaitable, non-handle types pass through as-is.
+ var nonHandleTypes = new List();
+ var handleTypeNames = new List();
+
+ foreach (var memberRef in typeRef.UnionTypes)
+ {
+ if (IsWidenedHandleType(memberRef))
+ {
+ // Get the base type name without Awaitable wrapper for combining
+ var baseName = IsInterfaceHandleType(memberRef) && TryMapInterfaceInputTypeToTypeScript(memberRef) is { } expanded
+ ? expanded
+ : MapTypeRefToTypeScript(memberRef);
+ nonHandleTypes.Add(baseName);
+ handleTypeNames.Add(baseName);
+ }
+ else
+ {
+ nonHandleTypes.Add(MapInputTypeToTypeScript(memberRef));
+ }
+ }
+
+ var allBaseTypes = nonHandleTypes
+ .SelectMany(t => t.Split(" | ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ if (handleTypeNames.Count > 0)
+ {
+ var handleUnion = string.Join(" | ", handleTypeNames
+ .SelectMany(t => t.Split(" | ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
+ .Distinct(StringComparer.Ordinal));
+ return string.Join(" | ", allBaseTypes) + $" | Awaitable<{handleUnion}>";
+ }
+
+ return string.Join(" | ", allBaseTypes);
+ }
+
+ ///
+ /// Maps a parameter to its TypeScript type, handling callbacks specially.
+ ///
+
+ internal string MapParameterToTypeScript(AtsParameterInfo param)
+ {
+ if (param.IsCallback)
+ {
+ return GenerateCallbackTypeSignature(param.CallbackParameters, param.CallbackReturnType);
+ }
+
+ return MapInputTypeToTypeScript(param.Type);
+ }
+
+ internal string? TryMapInterfaceInputTypeToTypeScript(AtsTypeRef typeRef)
+ {
+ List? assignableWrapperTypes = null;
+
+ foreach (var candidateTypeRef in _typeRefsById.Values)
+ {
+ if (!IsAssignableToInterface(candidateTypeRef, typeRef.TypeId) ||
+ !_wrapperClassNames.TryGetValue(candidateTypeRef.TypeId, out var wrapperClassName))
+ {
+ continue;
+ }
+
+ assignableWrapperTypes ??= [];
+ assignableWrapperTypes.Add(wrapperClassName);
+ }
+
+ if (assignableWrapperTypes is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ return string.Join(" | ", assignableWrapperTypes
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(static n => n, StringComparer.Ordinal));
+ }
+
+ internal static bool IsAssignableToInterface(AtsTypeRef candidateTypeRef, string interfaceTypeId)
+ {
+ if (string.Equals(candidateTypeRef.TypeId, interfaceTypeId, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ foreach (var implementedInterface in candidateTypeRef.ImplementedInterfaces)
+ {
+ if (IsAssignableToInterface(implementedInterface, interfaceTypeId))
+ {
+ return true;
+ }
+ }
+
+ return candidateTypeRef.BaseType is not null && IsAssignableToInterface(candidateTypeRef.BaseType, interfaceTypeId);
+ }
+
+ ///
+ /// Checks if a type reference is an interface handle type.
+ /// Interface handles need union types to accept wrapper classes.
+ ///
+
+ internal static bool IsInterfaceHandleType(AtsTypeRef? typeRef)
+ {
+ if (typeRef == null)
+ {
+ return false;
+ }
+ return typeRef.Category == AtsTypeCategory.Handle && typeRef.IsInterface;
+ }
+
+ internal static bool IsCancellationTokenType(AtsTypeRef? typeRef) => typeRef?.TypeId == AtsConstants.CancellationToken;
+
+ ///
+ /// Gets a valid TypeScript method name from a capability method name.
+ /// Handles dotted names like "EnvironmentContext.resource" by extracting just the final part.
+ ///
+
+ internal static string GetTypeScriptMethodName(string methodName)
+ {
+ var dotIndex = methodName.LastIndexOf('.');
+ return dotIndex >= 0 ? methodName[(dotIndex + 1)..] : methodName;
+ }
+
+ ///
+ /// Converts a PascalCase name to camelCase.
+ ///
+
+ internal static string ToCamelCase(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return name;
+ }
+ if (char.IsLower(name[0]))
+ {
+ return name;
+ }
+ return char.ToLowerInvariant(name[0]) + name[1..];
+ }
+
+ ///
+ /// Converts a camelCase name to PascalCase.
+ ///
+
+ internal static string ToPascalCase(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return name;
+ }
+ if (char.IsUpper(name[0]))
+ {
+ return name;
+ }
+ return char.ToUpperInvariant(name[0]) + name[1..];
+ }
+
+ ///
+ /// Gets the options interface name for a method.
+ /// Strips any type prefix (e.g., "TypeName.methodName" -> "MethodName").
+ ///
+ internal static string GetOptionsInterfaceName(string methodName)
+ {
+ var simpleName = methodName.Contains('.')
+ ? methodName[(methodName.LastIndexOf('.') + 1)..]
+ : methodName;
+ return $"{ToPascalCase(simpleName)}Options";
+ }
+
+ ///
+ /// Gets the options interface name for a specific capability, accounting for type conflicts.
+ /// Falls back to the default name derived from the capability if no specific mapping exists.
+ ///
+
+ internal string ResolveOptionsInterfaceName(AtsCapabilityInfo capability)
+ {
+ if (_capabilityOptionsInterfaceMap.TryGetValue(capability.CapabilityId, out var interfaceName))
+ {
+ return interfaceName;
+ }
+
+ return GetOptionsInterfaceName(capability.MethodName);
+ }
+
+ ///
+ /// Separates parameters into required and optional lists.
+ /// Required = not optional and not nullable.
+ ///
+
+ internal static (List Required, List Optional) SeparateParameters(
+ IEnumerable parameters)
+ {
+ var required = new List();
+ var optional = new List();
+
+ foreach (var param in parameters)
+ {
+ if (param.IsOptional || param.IsNullable)
+ {
+ optional.Add(param);
+ }
+ else
+ {
+ required.Add(param);
+ }
+ }
+
+ return (required, optional);
+ }
+
+ internal static bool TryGetDirectOptionsParameter(List optionalParams, out AtsParameterInfo? directOptionsParam)
+ // A trailing cancellation token is rendered as its own parameter (see
+ // GetTrailingCancellationTokenParameter), so it is ignored when deciding whether the lone
+ // "options" DTO can be threaded directly instead of wrapped in a generated options object.
+ => AtsOptionsFlattening.TryGetDirectOptionsParameter(
+ optionalParams,
+ p => IsCancellationTokenType(p.Type),
+ cancellationTokenIsSeparateParameter: true,
+ out directOptionsParam);
+
+ ///
+ /// When the options DTO is threaded directly (see ),
+ /// returns the trailing cancellation token optional parameter (if any) so it can be appended to
+ /// the generated method as its own argument rather than being folded into a generated options bag.
+ ///
+
+ internal static AtsParameterInfo? GetTrailingCancellationTokenParameter(List optionalParams)
+ {
+ if (!TryGetDirectOptionsParameter(optionalParams, out _))
+ {
+ return null;
+ }
+
+ return optionalParams.FirstOrDefault(p => IsCancellationTokenType(p.Type));
+ }
+
+ ///
+ /// Registers an options interface to be generated later.
+ ///
+ /// The capability the interface is being registered for.
+ /// The method name the interface is derived from.
+ /// The optional parameters the interface carries.
+ /// The assembly that exports .
+ internal void RegisterOptionsInterface(
+ string capabilityId,
+ string methodName,
+ List optionalParams,
+ string owningAssemblyName)
+ {
+ if (optionalParams.Count == 0)
+ {
+ return;
+ }
+
+ var baseInterfaceName = GetOptionsInterfaceName(methodName);
+
+ // Check if an existing interface with this name is compatible
+ if (_optionsInterfacesToGenerate.TryGetValue(baseInterfaceName, out var existingParams))
+ {
+ if (AreOptionsCompatible(existingParams, optionalParams))
+ {
+ // Compatible - merge any new parameters and share the interface
+ AssignOptionsInterface(capabilityId, baseInterfaceName, optionalParams, owningAssemblyName);
+ return;
+ }
+
+ // Incompatible - find or create a suffixed interface.
+ for (var suffix = 1; ; suffix++)
+ {
+ var suffixedName = GetOptionsInterfaceName($"{methodName}{suffix}");
+ if (!_optionsInterfacesToGenerate.TryGetValue(suffixedName, out var suffixedParams))
+ {
+ // Create a new interface with this suffix
+ AssignOptionsInterface(capabilityId, suffixedName, optionalParams, owningAssemblyName);
+ return;
+ }
+
+ if (AreOptionsCompatible(suffixedParams, optionalParams))
+ {
+ // Compatible with this suffixed interface - share it
+ AssignOptionsInterface(capabilityId, suffixedName, optionalParams, owningAssemblyName);
+ return;
+ }
+ }
+ }
+ else
+ {
+ // First registration - create the interface
+ AssignOptionsInterface(capabilityId, baseInterfaceName, optionalParams, owningAssemblyName);
+ }
+ }
+
+ ///
+ /// Points a capability at a named options interface, creating the interface if this is its first
+ /// use and otherwise widening it with any parameters it does not already carry.
+ ///
+ private void AssignOptionsInterface(
+ string capabilityId,
+ string interfaceName,
+ List optionalParams,
+ string owningAssemblyName)
+ {
+ if (_optionsInterfacesToGenerate.TryGetValue(interfaceName, out var declaredParams))
+ {
+ foreach (var param in optionalParams)
+ {
+ var declaredIndex = declaredParams.FindIndex(
+ declared => string.Equals(declared.Name, param.Name, StringComparison.Ordinal));
+ if (declaredIndex < 0)
+ {
+ declaredParams.Add(param);
+ }
+ else if (declaredParams[declaredIndex].Documentation is null && param.Documentation is not null)
+ {
+ // Compatible overloads can contribute the same option with different metadata.
+ // Keep the documented form regardless of which capability has the lower stable ID.
+ declaredParams[declaredIndex] = param;
+ }
+ }
+ }
+ else
+ {
+ _generatedOptionsInterfaces.Add(interfaceName);
+ _optionsInterfacesToGenerate[interfaceName] = [.. optionalParams];
+ }
+
+ _capabilityOptionsInterfaceMap[capabilityId] = interfaceName;
+ _optionsInterfaceOwningAssemblies[interfaceName] = owningAssemblyName;
+ }
+
+ ///
+ /// Checks whether two sets of optional parameters are compatible for sharing an options interface.
+ /// Parameters with the same name must have the same type (including callback parameter types).
+ ///
+
+ internal static bool AreOptionsCompatible(List existing, List candidate)
+ {
+ foreach (var param in candidate)
+ {
+ var match = existing.FirstOrDefault(p => p.Name == param.Name);
+ if (match is null)
+ {
+ continue; // New parameter, no conflict
+ }
+
+ // Same name - check type compatibility
+ if (!AreParameterTypesEqual(match, param))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ///
+ /// Checks whether two parameter infos have the same type (including callback types).
+ ///
+
+ internal static bool AreParameterTypesEqual(AtsParameterInfo a, AtsParameterInfo b)
+ {
+ // Compare base type
+ var aTypeId = a.Type?.TypeId;
+ var bTypeId = b.Type?.TypeId;
+ if (!string.Equals(aTypeId, bTypeId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ // Compare callback parameter types
+ if (a.IsCallback != b.IsCallback)
+ {
+ return false;
+ }
+
+ if (a.IsCallback && b.IsCallback)
+ {
+ var aCallbackParams = a.CallbackParameters ?? [];
+ var bCallbackParams = b.CallbackParameters ?? [];
+
+ if (aCallbackParams.Count != bCallbackParams.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < aCallbackParams.Count; i++)
+ {
+ if (!string.Equals(aCallbackParams[i].Type.TypeId, bCallbackParams[i].Type.TypeId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ // Compare callback return types
+ var aReturnTypeId = a.CallbackReturnType?.TypeId;
+ var bReturnTypeId = b.CallbackReturnType?.TypeId;
+ if (!string.Equals(aReturnTypeId, bReturnTypeId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ internal static string GetTypeDescription(string typeId)
+ {
+ var typeName = ExtractSimpleTypeName(typeId);
+ return $"Handle to {typeName}";
+ }
+
+ internal string BuildPublicParameterList(
+ List requiredParams,
+ bool hasOptionals,
+ string optionsInterfaceName,
+ string optionsParameterName = "options",
+ AtsParameterInfo? trailingCancellationToken = null)
+ {
+ var publicParamDefs = new List();
+ foreach (var param in requiredParams)
+ {
+ var tsType = MapParameterToTypeScript(param);
+ publicParamDefs.Add($"{param.Name}: {tsType}");
+ }
+ if (hasOptionals)
+ {
+ publicParamDefs.Add($"{optionsParameterName}?: {optionsInterfaceName}");
+ }
+ if (trailingCancellationToken is not null)
+ {
+ publicParamDefs.Add($"{trailingCancellationToken.Name}?: {MapParameterToTypeScript(trailingCancellationToken)}");
+ }
+
+ return string.Join(", ", publicParamDefs);
+ }
+
+ internal static string GetPublicOptionsParameterName(
+ IReadOnlyList userParams,
+ bool hasOptionals,
+ bool hasDirectOptionsParameter)
+ {
+ if (!hasOptionals || hasDirectOptionsParameter)
+ {
+ return "options";
+ }
+
+ var (requiredParams, optionalParams) = SeparateParameters(userParams);
+ var trailingCancellationToken = GetTrailingCancellationTokenParameter(optionalParams);
+
+ bool IsPublicParameterName(string name)
+ => requiredParams.Any(p => string.Equals(p.Name, name, StringComparison.Ordinal))
+ || string.Equals(trailingCancellationToken?.Name, name, StringComparison.Ordinal);
+
+ if (!IsPublicParameterName("options"))
+ {
+ return "options";
+ }
+
+ var candidate = "optionsBag";
+ while (IsPublicParameterName(candidate))
+ {
+ candidate = $"_{candidate}";
+ }
+
+ return candidate;
+ }
+
+ internal static string GetImplementationOptionsParameterName(
+ IReadOnlyList userParams,
+ bool hasOptionals,
+ bool hasDirectOptionsParameter)
+ {
+ if (!hasOptionals || hasDirectOptionsParameter)
+ {
+ return "options";
+ }
+
+ // Implementation methods destructure every optional field into a local with its source
+ // parameter name. Unlike the public interface, their options-bag parameter must therefore
+ // avoid optional names too (for example: const options = optionsBag?.options).
+ if (!userParams.Any(p => string.Equals(p.Name, "options", StringComparison.Ordinal)))
+ {
+ return "options";
+ }
+
+ var candidate = "optionsBag";
+ while (userParams.Any(p => string.Equals(p.Name, candidate, StringComparison.Ordinal)))
+ {
+ candidate = $"_{candidate}";
+ }
+
+ return candidate;
+ }
+
+ internal static bool IsGetterOnlyProperty(AtsCapabilityInfo? getter, AtsCapabilityInfo? setter) => getter is not null && setter is null;
+
+ internal string GetGetterOnlyPropertyReturnType(AtsTypeRef? typeRef)
+ {
+ if (typeRef == null)
+ {
+ return "unknown";
+ }
+
+ if (IsDictionaryType(typeRef))
+ {
+ var keyType = typeRef.KeyType != null ? MapTypeRefToTypeScript(typeRef.KeyType) : "string";
+ var valueType = typeRef.ValueType != null ? MapTypeRefToTypeScript(typeRef.ValueType) : "unknown";
+ return $"AspireDict<{keyType}, {valueType}>";
+ }
+
+ if (IsListType(typeRef))
+ {
+ var elementType = typeRef.ElementType != null ? MapTypeRefToTypeScript(typeRef.ElementType) : "unknown";
+ return $"AspireList<{elementType}>";
+ }
+
+ return MapTypeRefToTypeScript(typeRef);
+ }
+
+ internal bool TryGetPromiseWrapperType(AtsTypeRef? typeRef, out string promiseInterfaceName, out string promiseImplementationClassName)
+ {
+ if (typeRef?.TypeId is { } typeId && _typesWithPromiseWrappers.Contains(typeId))
+ {
+ var className = GetConcreteClassName(typeId);
+ promiseInterfaceName = GetPromiseInterfaceName(className);
+ promiseImplementationClassName = GetImplementationPromiseClassName(className);
+ return true;
+ }
+
+ promiseInterfaceName = string.Empty;
+ promiseImplementationClassName = string.Empty;
+ return false;
+ }
+
+ internal string GetGetterOnlyPropertyMethodReturnType(AtsTypeRef? typeRef)
+ {
+ if (TryGetPromiseWrapperType(typeRef, out var promiseInterfaceName, out _))
+ {
+ return promiseInterfaceName;
+ }
+
+ return $"Promise<{GetGetterOnlyPropertyReturnType(typeRef)}>";
+ }
+
+ internal string GetBuilderPromiseInterfaceForMethod(BuilderModel builder, AtsCapabilityInfo capability)
+ {
+ if (capability.ReturnsBuilder && capability.ReturnType?.TypeId != null &&
+ !string.Equals(capability.ReturnType.TypeId, builder.TypeId, StringComparison.Ordinal) &&
+ !string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal))
+ {
+ return GetPublicPromiseInterfaceName(capability.ReturnType.TypeId);
+ }
+
+ return GetPromiseInterfaceName(builder.BuilderClassName);
+ }
+
+ ///
+ /// Checks if a type was widened to accept Awaitable<T> in input position.
+ /// Must match the widening logic in MapInputTypeToTypeScript exactly.
+ ///
+
+ internal bool IsWidenedHandleType(AtsTypeRef? typeRef)
+ {
+ if (typeRef == null)
+ {
+ return false;
+ }
+
+ // Interface handles are always widened
+ if (IsInterfaceHandleType(typeRef))
+ {
+ return true;
+ }
+
+ // Concrete handles are only widened if they have a wrapper class name
+ // (excludes special types like ReferenceExpression that bypass widening)
+ if (IsHandleType(typeRef) && _wrapperClassNames.ContainsKey(typeRef.TypeId))
+ {
+ return true;
+ }
+
+ if (typeRef.TypeId == InteractionInputCollectionTypeId)
+ {
+ return true;
+ }
+
+ if (typeRef.Category == AtsTypeCategory.Union && typeRef.UnionTypes is { Count: > 0 })
+ {
+ return typeRef.UnionTypes.Any(IsWidenedHandleType);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Groups getters and setters by property name.
+ ///
+
+ internal static List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)> GroupPropertiesByName(
+ List getters, List setters)
+ {
+ var result = new List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)>();
+ var processedNames = new HashSet();
+
+ // Process getters
+ foreach (var getter in getters)
+ {
+ var propName = ExtractPropertyName(getter.MethodName);
+ if (processedNames.Contains(propName))
+ {
+ continue;
+ }
+ processedNames.Add(propName);
+
+ // Find matching setter (setPropertyName for propertyName)
+ var setterName = "set" + char.ToUpperInvariant(propName[0]) + propName[1..];
+ var setter = setters.FirstOrDefault(s => ExtractPropertyName(s.MethodName).Equals(setterName, StringComparison.OrdinalIgnoreCase));
+
+ result.Add((propName, getter, setter));
+ }
+
+ // Process any setters without matching getters
+ foreach (var setter in setters)
+ {
+ var setterMethodName = ExtractPropertyName(setter.MethodName);
+ // setPropertyName -> propertyName
+ if (setterMethodName.StartsWith("set", StringComparison.OrdinalIgnoreCase) && setterMethodName.Length > 3)
+ {
+ var propName = char.ToLowerInvariant(setterMethodName[3]) + setterMethodName[4..];
+ if (!processedNames.Contains(propName))
+ {
+ processedNames.Add(propName);
+ result.Add((propName, null, setter));
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Extracts the property name from a method name like "ClassName.propertyName" or "setPropertyName".
+ ///
+
+ internal static string ExtractPropertyName(string methodName)
+ {
+ // Handle "ClassName.propertyName" format
+ if (methodName.Contains('.'))
+ {
+ return methodName[(methodName.LastIndexOf('.') + 1)..];
+ }
+ return methodName;
+ }
+
+ ///
+ /// Checks if a type reference is a dictionary type.
+ ///
+
+ internal static bool IsDictionaryType(AtsTypeRef? typeRef)
+ {
+ return typeRef?.Category == AtsTypeCategory.Dict;
+ }
+
+ ///
+ /// Checks if a type reference is a list type.
+ ///
+
+ internal static bool IsListType(AtsTypeRef? typeRef)
+ {
+ return typeRef?.Category == AtsTypeCategory.List;
+ }
+
+ ///
+ /// Groups capabilities by ExpandedTargetTypes to create builder models.
+ /// Uses expansion to map interface targets to their concrete implementations.
+ /// Also creates builders for interface types (for use as return type wrappers).
+ ///
+
+ internal static List CreateBuilderModels(IReadOnlyList capabilities)
+ {
+ // Group capabilities by expanded target type IDs
+ // A capability targeting IResource with ExpandedTargetTypes = [RedisResource]
+ // will be assigned to Aspire.Hosting.Redis/RedisResource (the concrete type)
+ var capabilitiesByTypeId = new Dictionary>();
+
+ // Track the AtsTypeRef for each typeId (from ExpandedTargetTypes or TargetType metadata)
+ var typeRefsByTypeId = new Dictionary();
+
+ // Also track interface types and their capabilities (for interface wrapper classes)
+ var interfaceCapabilities = new Dictionary>();
+
+ foreach (var cap in capabilities)
+ {
+ var targetTypeRef = cap.TargetType;
+ var targetTypeId = cap.TargetTypeId;
+ if (targetTypeRef == null || string.IsNullOrEmpty(targetTypeId))
+ {
+ // Entry point methods - handled separately
+ continue;
+ }
+
+ // Use category-based check instead of string parsing
+ if (targetTypeRef.Category != AtsTypeCategory.Handle)
+ {
+ continue;
+ }
+
+ // These types are implemented manually in base.mts, including handle wrapper
+ // registrations, so they must not also generate duplicate wrappers in aspire.mts.
+ if (targetTypeId is AtsConstants.ReferenceExpressionTypeId or InteractionInputCollectionTypeId)
+ {
+ continue;
+ }
+
+ // Use expanded types if available, otherwise fall back to the original target
+ var expandedTypes = cap.ExpandedTargetTypes;
+ if (expandedTypes is { Count: > 0 })
+ {
+ // Flatten to concrete types
+ foreach (var expandedType in expandedTypes)
+ {
+ if (!capabilitiesByTypeId.TryGetValue(expandedType.TypeId, out var list))
+ {
+ list = [];
+ capabilitiesByTypeId[expandedType.TypeId] = list;
+ // Store the type ref for this expanded type
+ typeRefsByTypeId[expandedType.TypeId] = expandedType;
+ }
+ list.Add(cap);
+ }
+
+ // Also track the original interface type for wrapper class generation
+ if (targetTypeRef.IsInterface)
+ {
+ if (!interfaceCapabilities.TryGetValue(targetTypeId, out var interfaceList))
+ {
+ interfaceList = [];
+ interfaceCapabilities[targetTypeId] = interfaceList;
+ // Store the type ref for the interface
+ typeRefsByTypeId[targetTypeId] = targetTypeRef;
+ }
+ interfaceList.Add(cap);
+ }
+ }
+ else
+ {
+ // No expansion - use original target (concrete type)
+ if (!capabilitiesByTypeId.TryGetValue(targetTypeId, out var list))
+ {
+ list = [];
+ capabilitiesByTypeId[targetTypeId] = list;
+ // Store the type ref for this target type
+ typeRefsByTypeId[targetTypeId] = targetTypeRef;
+ }
+ list.Add(cap);
+ }
+ }
+
+ // Create a builder for each concrete type with its specific capabilities
+ var builders = new List();
+ foreach (var (typeId, typeCapabilities) in capabilitiesByTypeId)
+ {
+ var builderClassName = DeriveClassName(typeId);
+
+ // Get the type ref from tracked metadata (based on target type, not return type)
+ var typeRef = typeRefsByTypeId.GetValueOrDefault(typeId);
+
+ // Deduplicate capabilities by CapabilityId to avoid duplicate methods
+ var uniqueCapabilities = typeCapabilities
+ .GroupBy(c => c.CapabilityId)
+ .Select(g => g.First())
+ .ToList();
+ SortOptionsInterfaceCollisionsByCapabilityIdentity(uniqueCapabilities);
+
+ var builder = new BuilderModel
+ {
+ TypeId = typeId,
+ BuilderClassName = builderClassName,
+ Capabilities = uniqueCapabilities,
+ IsInterface = typeRef?.IsInterface ?? false,
+ TargetType = typeRef
+ };
+
+ builders.Add(builder);
+ }
+
+ // Also create builders for interface types (for use as return type wrappers)
+ // These are needed when methods return interface types like IResourceWithConnectionString
+ foreach (var (interfaceTypeId, caps) in interfaceCapabilities)
+ {
+ // Skip if already added (shouldn't happen, but be safe)
+ if (capabilitiesByTypeId.ContainsKey(interfaceTypeId))
+ {
+ continue;
+ }
+
+ var builderClassName = DeriveClassName(interfaceTypeId);
+
+ // Get the type ref from tracked metadata
+ var typeRef = typeRefsByTypeId.GetValueOrDefault(interfaceTypeId);
+
+ // Deduplicate capabilities
+ var uniqueCapabilities = caps
+ .GroupBy(c => c.CapabilityId)
+ .Select(g => g.First())
+ .ToList();
+ SortOptionsInterfaceCollisionsByCapabilityIdentity(uniqueCapabilities);
+
+ var builder = new BuilderModel
+ {
+ TypeId = interfaceTypeId,
+ BuilderClassName = builderClassName,
+ Capabilities = uniqueCapabilities,
+ IsInterface = true,
+ TargetType = typeRef
+ };
+
+ builders.Add(builder);
+ }
+
+ // Also create builders for resource types referenced anywhere in capabilities
+ // This handles types like RedisCommanderResource that appear in callback signatures,
+ // return types, or parameter types but aren't capability targets
+ var allReferencedTypeRefs = CollectAllReferencedTypes(capabilities);
+
+ // Track all types we already have builders for (concrete + interface)
+ var existingBuilderTypeIds = new HashSet(capabilitiesByTypeId.Keys);
+ foreach (var (interfaceTypeId, _) in interfaceCapabilities)
+ {
+ existingBuilderTypeIds.Add(interfaceTypeId);
+ }
+
+ foreach (var (typeId, typeRef) in allReferencedTypeRefs)
+ {
+ // Skip types we already have builders for (from concrete or interface lists)
+ if (existingBuilderTypeIds.Contains(typeId))
+ {
+ continue;
+ }
+
+ // Only create builders for resource types (using metadata instead of string parsing)
+ if (!typeRef.IsResourceBuilder)
+ {
+ continue;
+ }
+
+ var builderClassName = DeriveClassName(typeId);
+ var builder = new BuilderModel
+ {
+ TypeId = typeId,
+ BuilderClassName = builderClassName,
+ Capabilities = [], // No specific capabilities - uses base type methods
+ IsInterface = typeRef.IsInterface,
+ TargetType = typeRef
+ };
+ builders.Add(builder);
+ }
+
+ // Deduplicate a concrete type and its interfaces by class name. Unrelated CLR types can have
+ // the same simple name, but treating them as aliases would bind one type's branded handle to
+ // the other's wrapper implementation.
+ return builders
+ .OrderBy(builder => builder.IsInterface)
+ .ThenBy(builder => builder.BuilderClassName)
+ .GroupBy(builder => builder.BuilderClassName, StringComparer.Ordinal)
+ .Select(group =>
+ {
+ var candidates = group
+ .OrderBy(builder => builder.IsInterface)
+ .ThenBy(builder => builder.TypeId, StringComparer.Ordinal)
+ .ToList();
+ var retainedBuilder = candidates[0];
+ var unrelatedBuilder = candidates
+ .Skip(1)
+ .FirstOrDefault(candidate => !IsBuilderAlias(retainedBuilder, candidate));
+
+ if (unrelatedBuilder is not null)
+ {
+ var collidingTypeIds = candidates
+ .Select(candidate => candidate.TypeId)
+ .Order(StringComparer.Ordinal);
+ throw new InvalidOperationException(
+ $"Resource types {string.Join(", ", collidingTypeIds.Select(typeId => $"'{typeId}'"))} " +
+ $"all map to the generated TypeScript name '{group.Key}', but they are not a concrete type and its interfaces.");
+ }
+
+ return retainedBuilder;
+ })
+ .ToList();
+ }
+
+ private static void SortOptionsInterfaceCollisionsByCapabilityIdentity(List capabilities)
+ {
+ // Reorder only colliding option-interface slots. Sorting every capability would rewrite
+ // long-established source order for methods unrelated to the collision.
+ var collisionGroups = capabilities
+ .Select((capability, index) => (Capability: capability, Index: index))
+ .Where(entry =>
+ {
+ var (_, optionalParameters) = SeparateParameters(entry.Capability.Parameters);
+ return optionalParameters.Count > 0 &&
+ !TryGetDirectOptionsParameter(optionalParameters, out _);
+ })
+ .GroupBy(
+ entry => GetOptionsInterfaceName(entry.Capability.MethodName),
+ StringComparer.Ordinal)
+ .Where(group => group.Count() > 1);
+
+ foreach (var group in collisionGroups)
+ {
+ var indexes = group.Select(entry => entry.Index).Order().ToList();
+ var orderedCapabilities = group
+ .Select(entry => entry.Capability)
+ .OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal)
+ .ToList();
+
+ for (var i = 0; i < indexes.Count; i++)
+ {
+ capabilities[indexes[i]] = orderedCapabilities[i];
+ }
+ }
+ }
+
+ private static bool IsBuilderAlias(BuilderModel retainedBuilder, BuilderModel candidate)
+ {
+ if (string.Equals(retainedBuilder.TypeId, candidate.TypeId, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (retainedBuilder.IsInterface == candidate.IsInterface ||
+ retainedBuilder.TargetType is not { } retainedType ||
+ candidate.TargetType is not { } candidateType)
+ {
+ return false;
+ }
+
+ if (retainedType.ClrType is { } retainedClrType && candidateType.ClrType is { } candidateClrType)
+ {
+ return retainedClrType.IsAssignableFrom(candidateClrType) ||
+ candidateClrType.IsAssignableFrom(retainedClrType);
+ }
+
+ return IsTypeInHierarchy(retainedType, candidateType.TypeId) ||
+ IsTypeInHierarchy(candidateType, retainedType.TypeId);
+ }
+
+ private static bool IsTypeInHierarchy(AtsTypeRef typeRef, string typeId)
+ {
+ if (typeRef.ImplementedInterfaces.Any(interfaceType =>
+ string.Equals(interfaceType.TypeId, typeId, StringComparison.Ordinal) ||
+ IsTypeInHierarchy(interfaceType, typeId)))
+ {
+ return true;
+ }
+
+ return typeRef.BaseType is { } baseType &&
+ (string.Equals(baseType.TypeId, typeId, StringComparison.Ordinal) ||
+ IsTypeInHierarchy(baseType, typeId));
+ }
+
+ ///
+ /// Collects all type refs referenced in capabilities (return types, parameter types, callback types, etc.)
+ /// Returns a dictionary mapping typeId to AtsTypeRef for use in builder creation.
+ ///
+
+ internal static Dictionary CollectAllReferencedTypes(IReadOnlyList capabilities)
+ {
+ var typeRefs = new Dictionary();
+
+ void CollectFromTypeRef(AtsTypeRef? typeRef)
+ {
+ if (typeRef == null)
+ {
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(typeRef.TypeId) && typeRef.Category == AtsTypeCategory.Handle)
+ {
+ typeRefs.TryAdd(typeRef.TypeId, typeRef);
+ }
+
+ // Also check nested types (generics, arrays, etc.)
+ CollectFromTypeRef(typeRef.ElementType);
+ CollectFromTypeRef(typeRef.KeyType);
+ CollectFromTypeRef(typeRef.ValueType);
+ if (typeRef.UnionTypes != null)
+ {
+ foreach (var unionType in typeRef.UnionTypes)
+ {
+ CollectFromTypeRef(unionType);
+ }
+ }
+ }
+
+ foreach (var cap in capabilities)
+ {
+ // Check return type
+ CollectFromTypeRef(cap.ReturnType);
+
+ // Check parameter types
+ foreach (var param in cap.Parameters)
+ {
+ CollectFromTypeRef(param.Type);
+
+ // Check callback parameter types and return type
+ if (param.IsCallback)
+ {
+ if (param.CallbackParameters != null)
+ {
+ foreach (var cbParam in param.CallbackParameters)
+ {
+ CollectFromTypeRef(cbParam.Type);
+ }
+ }
+ CollectFromTypeRef(param.CallbackReturnType);
+ }
+ }
+ }
+
+ return typeRefs;
+ }
+
+ ///
+ /// Gets entry point capabilities (those without TargetTypeId).
+ ///
+
+ internal static List GetEntryPointCapabilities(IReadOnlyList capabilities)
+ {
+ return capabilities.Where(c => string.IsNullOrEmpty(c.TargetTypeId)).ToList();
+ }
+
+ ///
+ /// Derives the class name from an ATS type ID.
+ /// For interfaces like IResource, strips the leading 'I'.
+ ///
+
+ internal static string DeriveClassName(string typeId)
+ {
+ var typeName = ExtractSimpleTypeName(typeId);
+
+ // Strip leading 'I' from interface types
+ if (typeName.StartsWith('I') && typeName.Length > 1 && char.IsUpper(typeName[1]))
+ {
+ return typeName[1..];
+ }
+
+ return typeName;
+ }
+
+ ///
+ /// Gets the handle type alias name for a type ID.
+ ///
+
+ internal static string GetHandleTypeName(string typeId)
+ {
+ var typeName = ExtractSimpleTypeName(typeId);
+
+ // Sanitize generic types like "Dict" -> "DictStringObject"
+ // and array types like "string[]" -> "stringArray"
+ typeName = typeName
+ .Replace("[]", "Array", StringComparison.Ordinal)
+ .Replace("<", "", StringComparison.Ordinal)
+ .Replace(">", "", StringComparison.Ordinal)
+ .Replace(",", "", StringComparison.Ordinal);
+
+ return $"{typeName}Handle";
+ }
+
+ ///
+ /// Extracts the simple type name from a type ID.
+ ///
+ ///
+ /// "Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource" → "IResource"
+ /// "Aspire.Hosting/Aspire.Hosting.DistributedApplication" → "DistributedApplication"
+ ///
+
+ internal static string ExtractSimpleTypeName(string typeId)
+ {
+ var slashIndex = typeId.LastIndexOf('/');
+ var fullTypeName = slashIndex >= 0 ? typeId[(slashIndex + 1)..] : typeId;
+
+ var dotIndex = fullTypeName.LastIndexOf('.');
+ return dotIndex >= 0 ? fullTypeName[(dotIndex + 1)..] : fullTypeName;
+ }
+
+ ///
+ /// Determines if a type has generated async members and should have a Promise wrapper.
+ /// Types with instance methods, wrapper methods, or getter-only properties get Promise wrappers.
+ ///
+
+ internal static bool HasChainableMethods(BuilderModel model)
+ {
+ var hasMethods = model.Capabilities.Any(c =>
+ c.CapabilityKind == AtsCapabilityKind.InstanceMethod ||
+ c.CapabilityKind == AtsCapabilityKind.Method);
+ if (hasMethods)
+ {
+ return true;
+ }
+
+ var getters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
+ var setters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
+
+ return GroupPropertiesByName(getters, setters).Any(p => IsGetterOnlyProperty(p.Getter, p.Setter));
+ }
+
+ ///
+ /// Gets the Promise wrapper class name for a return type, if one exists.
+ /// Returns null if the return type doesn't have a Promise wrapper.
+ ///
+
+ internal string? GetPromiseWrapperForReturnType(AtsTypeRef? returnType)
+ {
+ if (returnType == null)
+ {
+ return null;
+ }
+
+ // Check if the return type has a Promise wrapper
+ if (_typesWithPromiseWrappers.Contains(returnType.TypeId))
+ {
+ var className = _wrapperClassNames.GetValueOrDefault(returnType.TypeId)
+ ?? DeriveClassName(returnType.TypeId);
+ return $"{className}Promise";
+ }
+
+ return null;
+ }
+
+ internal string GenerateCallbackTypeSignature(IReadOnlyList? callbackParameters, AtsTypeRef? callbackReturnType)
+ {
+ // Build parameter list
+ var paramList = new List();
+ if (callbackParameters is not null)
+ {
+ foreach (var param in callbackParameters)
+ {
+ var tsType = MapTypeRefToTypeScript(param.Type);
+ paramList.Add($"{param.Name}: {tsType}");
+ }
+ }
+
+ var paramsString = paramList.Count > 0 ? string.Join(", ", paramList) : "";
+
+ // Determine return type
+ var returnType = callbackReturnType == null || callbackReturnType.TypeId == AtsConstants.Void
+ ? "void"
+ : MapTypeRefToTypeScript(callbackReturnType);
+
+ // Callbacks are always async in TypeScript
+ return $"({paramsString}) => Promise<{returnType}>";
+ }
+
+ private sealed class ExportedValueTreeNode
+ {
+ public Dictionary Children { get; } = new(StringComparer.Ordinal);
+
+ public AtsExportedValueInfo? Value { get; set; }
+ }
+}
+
+///
+/// A DTO property that exists only on the TypeScript side, with the type and summary both the module
+/// emitter and the API export render.
+///
+internal sealed record ClientOnlyDtoProperty(string Name, string Type, string Summary);
diff --git a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs
index 10edb0e8d43..a9e39892e5f 100644
--- a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs
+++ b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.Loader;
using Aspire.Hosting.RemoteHost.CodeGeneration;
@@ -69,6 +70,64 @@ public IReadOnlyList GetAssemblies()
}
}
+ public bool TryGetPackageAssemblyNamesFromProbePaths(
+ string packageId,
+ string packageVersion,
+ out IReadOnlyList assemblyNames,
+ [NotNullWhen(true)] out string? canonicalPackageId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageVersion);
+
+ var names = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ canonicalPackageId = null;
+
+ foreach (var assembly in _packageProbeManifest.ManagedAssemblies)
+ {
+ if (assembly.Culture is not null)
+ {
+ continue;
+ }
+
+ if (assembly.PackageId is not null &&
+ assembly.PackageVersion is not null)
+ {
+ if (string.Equals(assembly.PackageId, packageId, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(assembly.PackageVersion, packageVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ canonicalPackageId ??= assembly.PackageId;
+ names.Add(assembly.Name);
+ }
+
+ continue;
+ }
+
+ // Older manifests do not record package versions, so package ownership must be
+ // recovered from the conventional global-packages path when possible.
+ if (TryGetPackageIdentityFromAssetPath(assembly.Path, out var pathPackageId, out var pathPackageVersion) &&
+ string.Equals(pathPackageId, packageId, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(pathPackageVersion, packageVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ if (assembly.PackageId is not null &&
+ string.Equals(assembly.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
+ {
+ canonicalPackageId ??= assembly.PackageId;
+ }
+
+ names.Add(assembly.Name);
+ }
+ }
+
+ assemblyNames = names.ToList();
+ if (assemblyNames.Count == 0)
+ {
+ return false;
+ }
+
+ canonicalPackageId ??= packageId;
+ return true;
+ }
+
///
/// Snapshots the currently loaded ATS integration assemblies as
/// records suitable for inclusion in a
@@ -145,6 +204,45 @@ internal static IReadOnlyList GetAssemblyNamesToLoad(
return assemblyNames;
}
+ private static bool TryGetPackageIdentityFromAssetPath(
+ string assemblyPath,
+ [NotNullWhen(true)] out string? packageId,
+ [NotNullWhen(true)] out string? packageVersion)
+ {
+ // NuGet managed assets use either:
+ // ///lib|ref//
+ // ///runtimes//lib//
+ // Matching from the assembly upward keeps this export-only lookup independent of the
+ // configured global-packages root without guessing across unrelated restored packages.
+ var targetFrameworkDirectory = Directory.GetParent(assemblyPath);
+ var assetKindDirectory = targetFrameworkDirectory?.Parent;
+ var isLibAsset = string.Equals(assetKindDirectory?.Name, "lib", StringComparison.OrdinalIgnoreCase);
+ var isRefAsset = string.Equals(assetKindDirectory?.Name, "ref", StringComparison.OrdinalIgnoreCase);
+ var versionDirectory = assetKindDirectory?.Parent;
+ if (isLibAsset &&
+ versionDirectory?.Parent is { } runtimesDirectory &&
+ string.Equals(runtimesDirectory.Name, "runtimes", StringComparison.OrdinalIgnoreCase))
+ {
+ versionDirectory = runtimesDirectory.Parent;
+ }
+
+ var packageDirectory = versionDirectory?.Parent;
+
+ if (assetKindDirectory is null ||
+ versionDirectory is null ||
+ packageDirectory is null ||
+ (!isLibAsset && !isRefAsset))
+ {
+ packageId = null;
+ packageVersion = null;
+ return false;
+ }
+
+ packageId = packageDirectory.Name;
+ packageVersion = versionDirectory.Name;
+ return true;
+ }
+
internal static IReadOnlyList DiscoverAspireHostingAssemblies(IEnumerable directories, IEnumerable? manifestAssemblyNames = null)
{
var assemblyNames = new SortedSet(StringComparer.OrdinalIgnoreCase);
diff --git a/src/Aspire.Hosting.RemoteHost/AtsContextFilter.cs b/src/Aspire.Hosting.RemoteHost/AtsContextFilter.cs
index 7c5f4d538c0..307122795ba 100644
--- a/src/Aspire.Hosting.RemoteHost/AtsContextFilter.cs
+++ b/src/Aspire.Hosting.RemoteHost/AtsContextFilter.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Aspire.TypeSystem;
@@ -11,6 +12,45 @@ namespace Aspire.Hosting.RemoteHost;
///
internal static class AtsContextFilter
{
+ ///
+ /// Resolves to the spelling the assembly that carries it
+ /// actually uses.
+ ///
+ ///
+ ///
+ /// A NuGet package id is case-insensitive
+ /// (),
+ /// so a caller can name a package in any casing, but an API export records the id verbatim as
+ /// the identity consumers key on. Every filter here treats the package id as an assembly name,
+ /// so the assemblies this context was scanned from are the authority on how it is spelled.
+ ///
+ ///
+ /// Failing to match is worth reporting rather than absorbing. The candidates below are a
+ /// superset of everything
+ /// can match on, so a name that matches nothing here is a name the export would filter to
+ /// nothing — a package that restored but whose assembly is named something else. Continuing
+ /// under the requested spelling would publish an empty document that claims to describe it.
+ ///
+ ///
+ /// The unfiltered ATS context.
+ /// The assembly or package name as the caller spelled it.
+ /// The canonical spelling, when a loaded assembly matches.
+ /// when a loaded assembly matches; otherwise .
+ public static bool TryResolveCanonicalAssemblyName(
+ AtsContext context,
+ string requestedName,
+ [NotNullWhen(true)] out string? canonicalName)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentException.ThrowIfNullOrWhiteSpace(requestedName);
+
+ var candidates = GetKnownAssemblyNames(
+ context,
+ new HashSet(StringComparer.OrdinalIgnoreCase));
+
+ return candidates.TryGetValue(requestedName, out canonicalName);
+ }
+
///
/// Filters the given ATS context to include only capabilities and types exported by the specified assemblies.
///
@@ -33,6 +73,113 @@ public static AtsContext FilterByExportingAssembliesWithReferences(
IReadOnlyCollection assemblyNames)
=> FilterByExportingAssemblies(context, assemblyNames, includeReferencedTypes: true);
+ ///
+ /// Filters an ATS context for API export while retaining enough capability metadata to resolve
+ /// the generated wrapper shape of referenced handle types.
+ ///
+ ///
+ /// A package can return a handle owned by another assembly. The generated SDK still exposes that
+ /// handle through its wrapper when the referenced type has chainable members, so the exporter
+ /// needs to see those member kinds even though it must not republish the members themselves.
+ /// Supporting capabilities retain their target, member kind, and referenced handle types. Their
+ /// callable shape is otherwise removed so foreign API and options interfaces cannot leak into the
+ /// package export while wrapper unions still match full source generation.
+ ///
+ /// The ATS context to filter.
+ /// The names of the assemblies whose API is being exported.
+ /// The filtered API export context.
+ internal static AtsContext FilterForApiExport(
+ AtsContext context,
+ IReadOnlyCollection assemblyNames)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(assemblyNames);
+
+ var filteredContext = FilterByExportingAssemblies(context, assemblyNames, includeReferencedTypes: true);
+ var normalizedAssemblyNames = new HashSet(
+ assemblyNames.Where(static name => !string.IsNullOrWhiteSpace(name)),
+ StringComparer.OrdinalIgnoreCase);
+
+ if (normalizedAssemblyNames.Count == 0)
+ {
+ return filteredContext;
+ }
+
+ var capabilityTargetTypeIds = filteredContext.Capabilities
+ .SelectMany(GetCapabilityTargetTypeIds)
+ .ToHashSet(StringComparer.Ordinal);
+ var supportingHandleTypes = filteredContext.HandleTypes
+ .Where(type =>
+ !capabilityTargetTypeIds.Contains(type.AtsTypeId) &&
+ !IsOwnedBySelectedAssembly(type.ClrType?.Assembly, type.AtsTypeId, normalizedAssemblyNames))
+ .ToDictionary(type => type.AtsTypeId, StringComparer.Ordinal);
+ if (supportingHandleTypes.Count == 0)
+ {
+ return filteredContext;
+ }
+
+ var includedCapabilityIds = filteredContext.Capabilities
+ .Select(capability => capability.CapabilityId)
+ .ToHashSet(StringComparer.Ordinal);
+ var supportingCapabilities = context.Capabilities
+ .Where(capability => !includedCapabilityIds.Contains(capability.CapabilityId))
+ .SelectMany(capability => CreateApiExportSupportCapabilities(capability, supportingHandleTypes))
+ .ToList();
+
+ if (supportingCapabilities.Count == 0)
+ {
+ return filteredContext;
+ }
+
+ var capabilities = filteredContext.Capabilities.Concat(supportingCapabilities).ToList();
+ var apiExportContext = new AtsContext
+ {
+ Capabilities = capabilities,
+ HandleTypes = filteredContext.HandleTypes,
+ DtoTypes = filteredContext.DtoTypes,
+ EnumTypes = filteredContext.EnumTypes,
+ ExportedValues = filteredContext.ExportedValues,
+ Diagnostics = filteredContext.Diagnostics
+ };
+
+ foreach (var capability in capabilities)
+ {
+ // Instance capability IDs can be namespace-qualified rather than assembly-qualified.
+ // Keep the reflection registries so the exporter attributes each retained capability to
+ // the assembly that actually declares it instead of guessing from the ID prefix.
+ if (context.Methods.TryGetValue(capability.CapabilityId, out var method))
+ {
+ apiExportContext.Methods[capability.CapabilityId] = method;
+ }
+
+ if (context.Properties.TryGetValue(capability.CapabilityId, out var property))
+ {
+ apiExportContext.Properties[capability.CapabilityId] = property;
+ }
+
+ }
+
+ return apiExportContext;
+ }
+
+ private static IEnumerable GetCapabilityTargetTypeIds(AtsCapabilityInfo capability)
+ {
+ if (capability.TargetTypeId is { } targetTypeId)
+ {
+ yield return targetTypeId;
+ }
+
+ if (capability.TargetType is { } targetType)
+ {
+ yield return targetType.TypeId;
+ }
+
+ foreach (var expandedTargetType in capability.ExpandedTargetTypes)
+ {
+ yield return expandedTargetType.TypeId;
+ }
+ }
+
private static AtsContext FilterByExportingAssemblies(
AtsContext context,
IReadOnlyCollection assemblyNames,
@@ -80,6 +227,23 @@ private static AtsContext FilterByExportingAssemblies(
if (includeReferencedTypes)
{
+ // Types owned by the selected assemblies were seeded into the included sets directly,
+ // which means CollectReferencedType's "was this newly added?" guard will refuse to walk
+ // their own members if a capability later references them. Expand the seeds explicitly so
+ // an owned DTO's property types survive the filter. Without this, a DTO owned by
+ // Aspire.Hosting that exposes an enum declared in a non-Aspire dependency (for example
+ // HealthStatus from Microsoft.Extensions.Diagnostics.HealthChecks) is retained while the
+ // enum it references is dropped, and code generation then fails on the dangling type.
+ foreach (var handleType in context.HandleTypes.Where(type => includedHandleTypeIds.Contains(type.AtsTypeId)).ToList())
+ {
+ CollectHandleTypeMembers(handleType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+ }
+
+ foreach (var dtoType in context.DtoTypes.Where(type => includedDtoTypeIds.Contains(type.TypeId)).ToList())
+ {
+ CollectDtoTypeMembers(dtoType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+ }
+
foreach (var capability in filteredCapabilities)
{
CollectReferencedType(capability.TargetType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
@@ -135,11 +299,115 @@ private static AtsContext FilterByExportingAssemblies(
{
filteredContext.Properties[capability.CapabilityId] = property;
}
+
}
return filteredContext;
}
+ private static IEnumerable CreateApiExportSupportCapabilities(
+ AtsCapabilityInfo capability,
+ IReadOnlyDictionary supportingHandleTypes)
+ {
+ if (!GetCapabilityTargetTypeIds(capability).Any(supportingHandleTypes.ContainsKey))
+ {
+ yield break;
+ }
+
+ var targetType = capability.TargetType;
+ if (targetType is null &&
+ capability.TargetTypeId is { } targetTypeId &&
+ supportingHandleTypes.TryGetValue(targetTypeId, out var handleType))
+ {
+ targetType = new AtsTypeRef
+ {
+ TypeId = targetTypeId,
+ ClrType = handleType.ClrType,
+ Category = AtsTypeCategory.Handle,
+ IsInterface = handleType.IsInterface,
+ ImplementedInterfaces = handleType.ImplementedInterfaces
+ };
+ }
+
+ yield return new AtsCapabilityInfo
+ {
+ CapabilityId = capability.CapabilityId,
+ MethodName = capability.MethodName,
+ OwningTypeName = capability.OwningTypeName,
+ // The canonical exporter needs the same handle universe as full source generation.
+ // Preserve foreign handle references as required synthetic parameters so wrapper
+ // unions stay identical without importing the foreign member's options interface.
+ Parameters = CreateApiExportSupportParameters(capability),
+ ReturnType = new AtsTypeRef
+ {
+ TypeId = AtsConstants.Void,
+ Category = AtsTypeCategory.Primitive
+ },
+ TargetTypeId = capability.TargetTypeId,
+ TargetType = targetType,
+ TargetParameterName = capability.TargetParameterName,
+ // Keep the complete expansion. Full source generation applies the member to every
+ // implementer, and those wrappers participate in interface-parameter unions even when
+ // only one implementer was directly referenced by the exporting package.
+ ExpandedTargetTypes = capability.ExpandedTargetTypes,
+ ReturnsBuilder = false,
+ CapabilityKind = capability.CapabilityKind
+ };
+ }
+
+ private static IReadOnlyList CreateApiExportSupportParameters(AtsCapabilityInfo capability)
+ {
+ var referencedHandleTypes = new Dictionary(StringComparer.Ordinal);
+
+ CollectHandleTypes(capability.ReturnType);
+ foreach (var parameter in capability.Parameters)
+ {
+ CollectHandleTypes(parameter.Type);
+ if (parameter.CallbackParameters is { } callbackParameters)
+ {
+ foreach (var callbackParameter in callbackParameters)
+ {
+ CollectHandleTypes(callbackParameter.Type);
+ }
+ }
+
+ CollectHandleTypes(parameter.CallbackReturnType);
+ }
+
+ return referencedHandleTypes
+ .OrderBy(static pair => pair.Key, StringComparer.Ordinal)
+ .Select(static (pair, index) => new AtsParameterInfo
+ {
+ Name = $"__apiExportSupportType{index}",
+ Type = pair.Value
+ })
+ .ToList();
+
+ void CollectHandleTypes(AtsTypeRef? typeRef)
+ {
+ if (typeRef is null)
+ {
+ return;
+ }
+
+ if (typeRef.Category == AtsTypeCategory.Handle && !string.IsNullOrEmpty(typeRef.TypeId))
+ {
+ referencedHandleTypes.TryAdd(typeRef.TypeId, typeRef);
+ }
+
+ CollectHandleTypes(typeRef.ElementType);
+ CollectHandleTypes(typeRef.KeyType);
+ CollectHandleTypes(typeRef.ValueType);
+ if (typeRef.UnionTypes is { } unionTypes)
+ {
+ foreach (var unionType in unionTypes)
+ {
+ CollectHandleTypes(unionType);
+ }
+ }
+ }
+ }
+
private static void CollectReferencedType(
AtsTypeRef? typeRef,
IReadOnlyDictionary handleTypesById,
@@ -156,23 +424,12 @@ private static void CollectReferencedType(
if (handleTypesById.TryGetValue(typeRef.TypeId, out var handleType) && includedHandleTypeIds.Add(handleType.AtsTypeId))
{
- foreach (var implementedInterface in handleType.ImplementedInterfaces)
- {
- CollectReferencedType(implementedInterface, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
- }
-
- foreach (var baseType in handleType.BaseTypeHierarchy)
- {
- CollectReferencedType(baseType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
- }
+ CollectHandleTypeMembers(handleType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
}
if (dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoType) && includedDtoTypeIds.Add(dtoType.TypeId))
{
- foreach (var property in dtoType.Properties)
- {
- CollectReferencedType(property.Type, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
- }
+ CollectDtoTypeMembers(dtoType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
}
if (enumTypesById.ContainsKey(typeRef.TypeId))
@@ -193,6 +450,53 @@ private static void CollectReferencedType(
}
}
+ private static void CollectHandleTypeMembers(
+ AtsTypeInfo handleType,
+ IReadOnlyDictionary handleTypesById,
+ IReadOnlyDictionary dtoTypesById,
+ IReadOnlyDictionary enumTypesById,
+ HashSet includedHandleTypeIds,
+ HashSet includedDtoTypeIds,
+ HashSet includedEnumTypeIds)
+ {
+ foreach (var implementedInterface in handleType.ImplementedInterfaces)
+ {
+ CollectReferencedType(implementedInterface, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+ }
+
+ foreach (var baseType in handleType.BaseTypeHierarchy)
+ {
+ CollectReferencedType(baseType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+ }
+ }
+
+ private static void CollectDtoTypeMembers(
+ AtsDtoTypeInfo dtoType,
+ IReadOnlyDictionary handleTypesById,
+ IReadOnlyDictionary dtoTypesById,
+ IReadOnlyDictionary enumTypesById,
+ HashSet includedHandleTypeIds,
+ HashSet includedDtoTypeIds,
+ HashSet includedEnumTypeIds)
+ {
+ foreach (var property in dtoType.Properties)
+ {
+ CollectReferencedType(property.Type, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+
+ // Callback properties are emitted as function signatures, so their parameter and return
+ // types are just as load-bearing as the declared property type.
+ if (property.CallbackParameters is not null)
+ {
+ foreach (var callbackParameter in property.CallbackParameters)
+ {
+ CollectReferencedType(callbackParameter.Type, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+ }
+ }
+
+ CollectReferencedType(property.CallbackReturnType, handleTypesById, dtoTypesById, enumTypesById, includedHandleTypeIds, includedDtoTypeIds, includedEnumTypeIds);
+ }
+ }
+
private static bool IsCapabilityOwnedBySelectedAssembly(
AtsContext context,
AtsCapabilityInfo capability,
@@ -237,9 +541,11 @@ private static bool IsSelectedAssembly(Assembly? assembly, HashSet assem
private static HashSet GetKnownAssemblyNames(AtsContext context, HashSet assemblyNames)
{
var knownAssemblyNames = new HashSet(assemblyNames, StringComparer.OrdinalIgnoreCase);
+ var capabilityIds = new HashSet(StringComparer.Ordinal);
foreach (var capability in context.Capabilities)
{
+ capabilityIds.Add(capability.CapabilityId);
AddAssemblyNameFromId(knownAssemblyNames, capability.CapabilityId);
}
@@ -265,14 +571,20 @@ private static HashSet GetKnownAssemblyNames(AtsContext context, HashSet
AddAssemblyName(knownAssemblyNames, exportedValue.OwningAssemblyName);
}
- foreach (var method in context.Methods.Values)
+ foreach (var (capabilityId, method) in context.Methods)
{
- AddAssemblyName(knownAssemblyNames, method.DeclaringType?.Assembly);
+ if (capabilityIds.Contains(capabilityId))
+ {
+ AddAssemblyName(knownAssemblyNames, method.DeclaringType?.Assembly);
+ }
}
- foreach (var property in context.Properties.Values)
+ foreach (var (capabilityId, property) in context.Properties)
{
- AddAssemblyName(knownAssemblyNames, property.DeclaringType?.Assembly);
+ if (capabilityIds.Contains(capabilityId))
+ {
+ AddAssemblyName(knownAssemblyNames, property.DeclaringType?.Assembly);
+ }
}
return knownAssemblyNames;
diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs
index fd9ff421ad3..286420e64f3 100644
--- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs
+++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs
@@ -1,10 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Text.Json;
using Aspire.TypeSystem;
using Aspire.Hosting.RemoteHost.Diagnostics;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
+using StreamJsonRpc.Protocol;
namespace Aspire.Hosting.RemoteHost.CodeGeneration;
@@ -15,6 +17,7 @@ internal sealed class CodeGenerationService
{
private const string GetCapabilitiesMethodName = "getCapabilities";
private const string GenerateCodeMethodName = "generateCode";
+ private const string ExportApiMethodName = "exportApi";
private readonly JsonRpcAuthenticationState _authenticationState;
private readonly AtsContextFactory _atsContextFactory;
@@ -246,6 +249,9 @@ public Dictionary GenerateCode(string language, string? assembly
var context = _atsContextFactory.GetContext();
if (!string.IsNullOrWhiteSpace(assemblyName))
{
+ // Scoped source generation must not use the API-export filter: its synthetic
+ // supporting capabilities are projection-only metadata and would otherwise become
+ // executable members in the generated SDK.
context = AtsContextFilter.FilterByExportingAssembliesWithReferences(context, [assemblyName]);
}
@@ -268,6 +274,164 @@ public Dictionary GenerateCode(string language, string? assembly
}
}
+ ///
+ /// Exports the canonical API reference for the specified language and package.
+ ///
+ /// The target language (e.g., "TypeScript").
+ /// The package to export documentation for.
+ ///
+ /// The version label to record for . The caller owns its accuracy;
+ /// see .
+ ///
+ /// A token to cancel the export.
+ /// The language provider's API reference document, verbatim.
+ [JsonRpcMethod(ExportApiMethodName)]
+ public JsonElement ExportApi(
+ string language,
+ string packageName,
+ string packageVersion,
+ CancellationToken cancellationToken)
+ {
+ using var rpcActivity = _profilingTelemetry.StartJsonRpcServerCall(ExportApiMethodName);
+ try
+ {
+ _authenticationState.ThrowIfNotAuthenticated();
+ if (string.IsNullOrWhiteSpace(language))
+ {
+ throw CreateInvalidExportRequest("The export language cannot be empty.");
+ }
+ if (string.IsNullOrWhiteSpace(packageName))
+ {
+ throw CreateInvalidExportRequest("The export package name cannot be empty.");
+ }
+ if (string.IsNullOrWhiteSpace(packageVersion))
+ {
+ throw CreateInvalidExportRequest("The export package version cannot be empty.");
+ }
+
+ _logger.LogDebug(">> exportApi({Language}, {PackageName}, {PackageVersion})", language, packageName, packageVersion);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
+ var generator = _resolver.GetCodeGenerator(language);
+ if (generator is null)
+ {
+ throw CreateInvalidExportRequest(BuildNoCodeGeneratorMessage(language));
+ }
+
+ // Resolved through the resolver rather than cast off the generator: the exporter is
+ // discovered as its own type so that adding the interface never changes the generator
+ // type's eagerly resolved interface list. See AtsTypeScriptApiReferenceExporter.
+ if (_resolver.GetApiReferenceExporter(language) is not { } exporter)
+ {
+ throw CreateInvalidExportRequest(
+ $"The '{generator.Language}' language provides no {nameof(IApiReferenceExporter)}, " +
+ "so it cannot produce an API reference export. " +
+ $"Supported languages for API export: {BuildApiExportLanguageList()}.");
+ }
+
+ // Referenced handle capabilities determine wrapper and resource-union signatures.
+ // Keep only their projection support shape without publishing their API as part of this
+ // package.
+ var fullContext = _atsContextFactory.GetContext();
+
+ var exportingAssemblyNames = ResolvePackageExportingAssemblyNames(
+ fullContext,
+ packageName,
+ packageVersion,
+ out var canonicalPackageName);
+
+ var context = AtsContextFilter.FilterForApiExport(
+ fullContext,
+ exportingAssemblyNames);
+
+ var export = exporter.ExportApi(
+ context,
+ new ApiReferenceExportOptions(canonicalPackageName, packageVersion, exportingAssemblyNames),
+ cancellationToken);
+
+ _logger.LogDebug("<< exportApi({Language}, {PackageName}) completed in {ElapsedMs}ms", language, packageName, sw.ElapsedMilliseconds);
+
+ // Returned verbatim: the payload schema belongs to the language provider, and reshaping
+ // it here would silently fork the contract documentation consumers bind to.
+ return export;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "<< exportApi({Language}, {PackageName}) failed", language, packageName);
+ var wrapped = CodeGenerationDiagnosticBuilder.TryCreateRpcException(ex, _assemblyLoader, _logger);
+ if (wrapped is not null)
+ {
+ throw wrapped;
+ }
+ throw;
+ }
+ }
+
+ private static LocalRpcException CreateInvalidExportRequest(string message)
+ => new(message)
+ {
+ ErrorCode = (int)JsonRpcErrorCode.InvalidParams
+ };
+
+ private IReadOnlyList ResolvePackageExportingAssemblyNames(
+ AtsContext fullContext,
+ string packageName,
+ string packageVersion,
+ out string canonicalPackageName)
+ {
+ if (_assemblyLoader.TryGetPackageAssemblyNamesFromProbePaths(
+ packageName,
+ packageVersion,
+ out var manifestAssemblyNames,
+ out var manifestPackageName))
+ {
+ var exportingAssemblyNames = new List(manifestAssemblyNames.Count);
+ foreach (var assemblyName in manifestAssemblyNames)
+ {
+ if (AtsContextFilter.TryResolveCanonicalAssemblyName(fullContext, assemblyName, out var canonicalAssemblyName))
+ {
+ exportingAssemblyNames.Add(canonicalAssemblyName);
+ }
+ }
+
+ if (exportingAssemblyNames.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"Package '{packageName}' version '{packageVersion}' was mapped from restored asset paths, " +
+ "but none of its assemblies reached the scanned API surface.");
+ }
+
+ canonicalPackageName = manifestPackageName;
+ return exportingAssemblyNames;
+ }
+
+ // A NuGet package id is case-insensitive
+ // (https://learn.microsoft.com/nuget/consume-packages/finding-and-choosing-packages#package-identifiers)
+ // but the exported document records this string verbatim as the identity consumers key
+ // on, so `aspire.hosting.redis` would publish a document naming a package nobody looks
+ // up. For local project references and older probe manifests we do not have package-to-
+ // assembly metadata, so the loaded assembly settles the spelling as before.
+ if (!AtsContextFilter.TryResolveCanonicalAssemblyName(fullContext, packageName, out var canonicalAssemblyNameFromContext))
+ {
+ throw new InvalidOperationException(
+ $"No managed assemblies for package '{packageName}' version '{packageVersion}' could be mapped from the restored asset paths, " +
+ "and the scanned API surface contains no assembly with the package id as its name.");
+ }
+
+ canonicalPackageName = canonicalAssemblyNameFromContext;
+ return [canonicalAssemblyNameFromContext];
+ }
+
+ private string BuildApiExportLanguageList()
+ {
+ var exportable = _resolver.GetSupportedLanguages()
+ .Where(language => _resolver.GetApiReferenceExporter(language) is not null)
+ .OrderBy(language => language, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ return exportable.Length == 0 ? "(none)" : string.Join(", ", exportable);
+ }
+
private string BuildNoCodeGeneratorMessage(string language)
{
var available = _resolver.GetSupportedLanguages()
diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs
index ea4fcc818c5..081167dd695 100644
--- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs
+++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs
@@ -14,6 +14,7 @@ namespace Aspire.Hosting.RemoteHost.CodeGeneration;
internal sealed class CodeGeneratorResolver
{
private readonly Lazy> _generators;
+ private readonly Lazy> _exporters;
private readonly ILogger _logger;
public CodeGeneratorResolver(
@@ -34,6 +35,8 @@ internal CodeGeneratorResolver(
_logger = logger;
_generators = new Lazy>(
() => DiscoverGenerators(serviceProvider, assembliesProvider()));
+ _exporters = new Lazy>(
+ () => DiscoverExporters(serviceProvider, assembliesProvider()));
}
///
@@ -47,6 +50,45 @@ internal CodeGeneratorResolver(
return generator;
}
+ ///
+ /// Gets the API reference exporter for the specified language, if the language supports API export.
+ ///
+ /// The target language (e.g., "TypeScript", "Python").
+ ///
+ /// The exporter, or when no generator is registered for the language or
+ /// the language provides no .
+ ///
+ ///
+ ///
+ /// An exporter is never reachable for a language whose code generator is not: a documented API
+ /// that no generator produces would be worse than no documentation at all. That is why the
+ /// generator lookup gates the result even though exporters are discovered independently.
+ ///
+ ///
+ /// Exporters are discovered as their own types rather than read off the generator so that a
+ /// language provider can add export support without changing the generator type's interface
+ /// list. Aspire.TypeSystem is force-shared from the default load context, so a generator
+ /// implementing a newly added shared interface fails to load entirely under a CLI that predates
+ /// it (see AtsTypeScriptApiReferenceExporter). A generator that implements the interface
+ /// itself is still honored, so a provider that keeps both roles on one type keeps working.
+ ///
+ ///
+ public IApiReferenceExporter? GetApiReferenceExporter(string language)
+ {
+ if (GetCodeGenerator(language) is not { } generator)
+ {
+ return null;
+ }
+
+ if (generator is IApiReferenceExporter selfExporter)
+ {
+ return selfExporter;
+ }
+
+ _exporters.Value.TryGetValue(language, out var exporter);
+ return exporter;
+ }
+
///
/// Gets the languages of all discovered code generators.
///
@@ -64,35 +106,8 @@ private Dictionary DiscoverGenerators(
foreach (var assembly in assemblies)
{
- Type[] types;
var assemblyName = assembly.GetName().Name;
- var hadTypeLoadFailure = false;
- try
- {
- types = assembly.GetTypes();
- }
- catch (ReflectionTypeLoadException ex)
- {
- hadTypeLoadFailure = true;
- // Surface loader binding failures at Warning level. These typically indicate
- // a binary mismatch between the bundled runtime assemblies and the integration
- // assemblies loaded from disk (for example, when Aspire.TypeSystem versions
- // diverge). Including the LoaderExceptions in the log is essential for
- // diagnosing these failures, which previously disappeared into Debug-level
- // output that the apphost server never wrote to disk.
- var loaderMessages = ex.LoaderExceptions is { Length: > 0 } loaders
- ? string.Join("; ", loaders.Where(e => e is not null).Select(e => e!.Message).Distinct())
- : "(no LoaderExceptions captured)";
- _logger.LogWarning(
- ex,
- "Some types in assembly '{AssemblyName}' could not be loaded; {LoadedCount} of {TotalCount} types are available. LoaderExceptions: {LoaderExceptions}",
- assemblyName,
- ex.Types.Count(t => t is not null),
- ex.Types.Length,
- loaderMessages);
- // Use the types that were successfully loaded
- types = ex.Types.Where(t => t is not null).ToArray()!;
- }
+ var types = GetLoadableTypes(assembly, assemblyName, out var hadTypeLoadFailure);
var discoveredInAssembly = 0;
foreach (var type in types)
@@ -133,6 +148,78 @@ private Dictionary DiscoverGenerators(
return generators;
}
+ private Dictionary DiscoverExporters(
+ IServiceProvider serviceProvider,
+ IReadOnlyList assemblies)
+ {
+ var exporters = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var assembly in assemblies)
+ {
+ var assemblyName = assembly.GetName().Name;
+
+ // An assembly with no exporter is the normal case (most languages generate code they
+ // cannot yet describe), so unlike generator discovery this pass never warns about
+ // finding nothing. A type-load failure was already reported by DiscoverGenerators.
+ foreach (var type in GetLoadableTypes(assembly, assemblyName, out _))
+ {
+ if (type.IsAbstract || type.IsInterface || !typeof(IApiReferenceExporter).IsAssignableFrom(type))
+ {
+ continue;
+ }
+
+ try
+ {
+ var exporter = (IApiReferenceExporter)ActivatorUtilities.CreateInstance(serviceProvider, type);
+ exporters[exporter.Language] = exporter;
+ _logger.LogDebug("Discovered API reference exporter: {TypeName} for language '{Language}'", type.Name, exporter.Language);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to instantiate API reference exporter '{TypeName}'", type.Name);
+ }
+ }
+ }
+
+ return exporters;
+ }
+
+ ///
+ /// Returns the types an assembly can actually load, keeping the ones that bound when others did
+ /// not. Dropping the whole assembly on a single unloadable type would take every generator in it
+ /// down with that type.
+ ///
+ private Type[] GetLoadableTypes(Assembly assembly, string? assemblyName, out bool hadTypeLoadFailure)
+ {
+ hadTypeLoadFailure = false;
+
+ try
+ {
+ return assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException ex)
+ {
+ hadTypeLoadFailure = true;
+ // Surface loader binding failures at Warning level. These typically indicate
+ // a binary mismatch between the bundled runtime assemblies and the integration
+ // assemblies loaded from disk (for example, when Aspire.TypeSystem versions
+ // diverge). Including the LoaderExceptions in the log is essential for
+ // diagnosing these failures, which previously disappeared into Debug-level
+ // output that the apphost server never wrote to disk.
+ var loaderMessages = ex.LoaderExceptions is { Length: > 0 } loaders
+ ? string.Join("; ", loaders.Where(e => e is not null).Select(e => e!.Message).Distinct())
+ : "(no LoaderExceptions captured)";
+ _logger.LogWarning(
+ ex,
+ "Some types in assembly '{AssemblyName}' could not be loaded; {LoadedCount} of {TotalCount} types are available. LoaderExceptions: {LoaderExceptions}",
+ assemblyName,
+ ex.Types.Count(t => t is not null),
+ ex.Types.Length,
+ loaderMessages);
+ return ex.Types.Where(t => t is not null).ToArray()!;
+ }
+ }
+
private static bool LooksLikeCodeGeneratorAssembly(string? assemblyName)
=> assemblyName is not null
&& assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase);
diff --git a/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs b/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs
index a027af5a608..b3d6bf84f17 100644
--- a/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs
+++ b/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs
@@ -136,6 +136,8 @@ internal static IntegrationPackageProbeManifest CreateManifest(IEnumerable Assets, int SkippedCount) Resol
// Synthetic restores can leave the base lib assembly in the target even when the package
// contains a compatible portable runtime asset. Prefer the runtime asset for probing.
var runtimeAssemblyOverrides = GetRuntimeAssemblyOverrides(packageLibrary, targetFramework, runtimeIdentifiers);
- AddRuntimeAssemblies(assets, library.RuntimeAssemblies, packagePath, runtimeAssemblyOverrides);
- AddRuntimeTargets(assets, library.RuntimeTargets, packagePath);
- AddResourceAssemblies(assets, library.ResourceAssemblies, packagePath);
- AddNativeLibraries(assets, library.NativeLibraries, packagePath);
+ AddRuntimeAssemblies(assets, libraryName, libraryVersion, library.RuntimeAssemblies, packagePath, runtimeAssemblyOverrides);
+ AddRuntimeTargets(assets, libraryName, libraryVersion, library.RuntimeTargets, packagePath);
+ AddResourceAssemblies(assets, libraryName, libraryVersion, library.ResourceAssemblies, packagePath);
+ AddNativeLibraries(assets, libraryName, libraryVersion, library.NativeLibraries, packagePath);
return (assets, 0);
}
private static void AddRuntimeAssemblies(
List assets,
+ string packageId,
+ string packageVersion,
IEnumerable runtimeAssemblies,
string packagePath,
IReadOnlyDictionary runtimeAssemblyOverrides)
@@ -176,16 +182,18 @@ private static void AddRuntimeAssemblies(
if (!relativePath.StartsWith("runtimes/", StringComparison.OrdinalIgnoreCase) &&
runtimeAssemblyOverrides.TryGetValue(GetFileName(relativePath), out var overridePath))
{
- AddRuntimeAssembly(assets, packagePath, overridePath);
+ AddRuntimeAssembly(assets, packageId, packageVersion, packagePath, overridePath);
continue;
}
- AddRuntimeAssembly(assets, packagePath, relativePath);
+ AddRuntimeAssembly(assets, packageId, packageVersion, packagePath, relativePath);
}
}
private static void AddRuntimeAssembly(
List assets,
+ string packageId,
+ string packageVersion,
string packagePath,
string relativePath)
{
@@ -196,17 +204,17 @@ private static void AddRuntimeAssembly(
}
var fileName = Path.GetFileName(sourcePath);
- AddAsset(assets, sourcePath, fileName, isManagedAssembly: IsManagedAssembly(sourcePath), isNativeLibrary: false);
+ AddAsset(assets, packageId, packageVersion, sourcePath, fileName, isManagedAssembly: IsManagedAssembly(sourcePath), isNativeLibrary: false);
if (relativePath.StartsWith("runtimes/", StringComparison.OrdinalIgnoreCase))
{
- AddAsset(assets, sourcePath, relativePath, isManagedAssembly: IsManagedAssembly(sourcePath), isNativeLibrary: false);
+ AddAsset(assets, packageId, packageVersion, sourcePath, relativePath, isManagedAssembly: IsManagedAssembly(sourcePath), isNativeLibrary: false);
}
var xmlSourcePath = Path.ChangeExtension(sourcePath, ".xml");
if (File.Exists(xmlSourcePath))
{
- AddAsset(assets, xmlSourcePath, Path.ChangeExtension(fileName, ".xml"), isManagedAssembly: false, isNativeLibrary: false);
+ AddAsset(assets, packageId, packageVersion, xmlSourcePath, Path.ChangeExtension(fileName, ".xml"), isManagedAssembly: false, isNativeLibrary: false);
}
}
@@ -309,6 +317,8 @@ private static string GetFileName(string path)
private static void AddRuntimeTargets(
List assets,
+ string packageId,
+ string packageVersion,
IEnumerable runtimeTargets,
string packagePath)
{
@@ -327,6 +337,8 @@ private static void AddRuntimeTargets(
AddAsset(
assets,
+ packageId,
+ packageVersion,
sourcePath,
runtimeTarget.Path,
isManagedAssembly: string.Equals(runtimeTarget.AssetType, "runtime", StringComparison.OrdinalIgnoreCase) && IsManagedAssembly(sourcePath),
@@ -336,6 +348,8 @@ private static void AddRuntimeTargets(
private static void AddResourceAssemblies(
List assets,
+ string packageId,
+ string packageVersion,
IEnumerable resourceAssemblies,
string packagePath)
{
@@ -363,6 +377,8 @@ private static void AddResourceAssemblies(
AddAsset(
assets,
+ packageId,
+ packageVersion,
sourcePath,
Path.Combine(locale, Path.GetFileName(sourcePath)),
isManagedAssembly: IsManagedAssembly(sourcePath),
@@ -373,6 +389,8 @@ private static void AddResourceAssemblies(
private static void AddNativeLibraries(
List assets,
+ string packageId,
+ string packageVersion,
IEnumerable nativeLibraries,
string packagePath)
{
@@ -389,13 +407,15 @@ private static void AddNativeLibraries(
continue;
}
- AddAsset(assets, sourcePath, Path.GetFileName(sourcePath), isManagedAssembly: false, isNativeLibrary: true);
- AddAsset(assets, sourcePath, nativeLib.Path, isManagedAssembly: false, isNativeLibrary: true);
+ AddAsset(assets, packageId, packageVersion, sourcePath, Path.GetFileName(sourcePath), isManagedAssembly: false, isNativeLibrary: true);
+ AddAsset(assets, packageId, packageVersion, sourcePath, nativeLib.Path, isManagedAssembly: false, isNativeLibrary: true);
}
}
private static void AddAsset(
List assets,
+ string packageId,
+ string packageVersion,
string sourcePath,
string relativePath,
bool isManagedAssembly,
@@ -404,6 +424,8 @@ private static void AddAsset(
{
assets.Add(new NuGetPackageAsset
{
+ PackageId = packageId,
+ PackageVersion = packageVersion,
SourcePath = sourcePath,
RelativePath = NormalizeRelativePath(relativePath),
IsManagedAssembly = isManagedAssembly,
diff --git a/src/Aspire.TypeSystem/ApiReferenceExportOptions.cs b/src/Aspire.TypeSystem/ApiReferenceExportOptions.cs
new file mode 100644
index 00000000000..3d7ebdec1e5
--- /dev/null
+++ b/src/Aspire.TypeSystem/ApiReferenceExportOptions.cs
@@ -0,0 +1,91 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.TypeSystem;
+
+///
+/// Describes the package identity and ownership scope of an export.
+///
+///
+///
+/// The ATS context handed to an exporter is already filtered to the exporting assemblies, their
+/// reference closure, and the reduced member shapes needed to resolve wrappers for referenced handle
+/// types. That closure is exactly why exists: it lets the exporter
+/// tell apart symbols the package owns and should document from symbols it merely needs to emit so the
+/// output is self-contained. Without it, every package would republish its dependencies' API reference.
+///
+///
+/// The constructor snapshots the assembly-name collection. Exporters should compare these CLR
+/// assembly simple names using .
+///
+///
+public sealed class ApiReferenceExportOptions
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the package being exported.
+ /// The version label to record for the package being exported.
+ ///
+ /// The assemblies whose symbols this package owns and documents. Symbols outside this set are
+ /// present only to complete the reference closure.
+ ///
+ ///
+ /// Thrown when , , or
+ /// is .
+ ///
+ ///
+ /// Thrown when or is empty or
+ /// consists only of white-space characters, or when
+ /// is empty or contains a null, empty, or white-space assembly name.
+ ///
+ public ApiReferenceExportOptions(
+ string packageName,
+ string packageVersion,
+ IReadOnlyCollection exportingAssemblyNames)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageVersion);
+ ArgumentNullException.ThrowIfNull(exportingAssemblyNames);
+ if (exportingAssemblyNames.Count == 0)
+ {
+ throw new ArgumentException("At least one exporting assembly name is required.", nameof(exportingAssemblyNames));
+ }
+ if (exportingAssemblyNames.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException("Exporting assembly names cannot be null or white-space.", nameof(exportingAssemblyNames));
+ }
+
+ PackageName = packageName;
+ PackageVersion = packageVersion;
+ ExportingAssemblyNames = Array.AsReadOnly(exportingAssemblyNames.ToArray());
+ }
+
+ ///
+ /// Gets the name of the package being exported.
+ ///
+ public string PackageName { get; }
+
+ ///
+ /// Gets the version label recorded for this export, as supplied by the caller.
+ ///
+ ///
+ /// Consumers key published documentation on this value, so callers are expected to pass the
+ /// exact version that was restored. Nothing on this type can confirm that: an exporter sees
+ /// loaded assemblies, not the package resolution that produced them, so any value — including a
+ /// floating or range expression — would be recorded verbatim. Exactness therefore belongs where
+ /// the restore is decided. aspire sdk export rejects a floating or range version before
+ /// the scanner is built, pins the requested version so an unavailable one fails the restore
+ /// instead of resolving upward, and refuses a package a repository checkout would build in place
+ /// of the requested one.
+ ///
+ public string PackageVersion { get; }
+
+ ///
+ /// Gets the assemblies whose symbols this package owns and documents.
+ ///
+ ///
+ /// The collection is a snapshot of the names passed to the constructor.
+ ///
+ public IReadOnlyCollection ExportingAssemblyNames { get; }
+}
diff --git a/src/Aspire.TypeSystem/IApiReferenceExporter.cs b/src/Aspire.TypeSystem/IApiReferenceExporter.cs
new file mode 100644
index 00000000000..e2636e3c8b4
--- /dev/null
+++ b/src/Aspire.TypeSystem/IApiReferenceExporter.cs
@@ -0,0 +1,62 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+
+namespace Aspire.TypeSystem;
+
+///
+/// Optional companion to for languages that can describe their
+/// generated surface as a machine-readable API reference.
+///
+///
+///
+/// Code generation and API export answer different questions. produces
+/// the source a user compiles against; this interface produces the documentation model that
+/// describes that source. Keeping them separate means a language provider can ship runnable code
+/// generation long before it can describe it, and documentation tooling can tell the difference
+/// instead of publishing a silently empty reference.
+///
+///
+/// The payload schema is owned by the language provider. Hosts must pass the returned document
+/// through unmodified so language-specific details survive transport.
+///
+///
+public interface IApiReferenceExporter
+{
+ ///
+ /// Gets the target language name (for example, "TypeScript"). This must match the
+ /// value of the generator that produces the same surface,
+ /// so a host can resolve one from the other.
+ ///
+ string Language { get; }
+
+ ///
+ /// Exports the API reference for the surface the generator would produce from the same context.
+ ///
+ /// The ATS context containing capabilities, types, and enums.
+ ///
+ /// The package identity and ownership scope for the export. Assembly ownership matching follows
+ /// the case-insensitive contract documented by
+ /// .
+ ///
+ /// A token to cancel the export between projected items.
+ ///
+ /// A language-defined JSON document describing the generated API. The returned element must be
+ /// detached from any owning , for example by calling
+ /// .
+ ///
+ ///
+ /// Thrown when requests cancellation.
+ ///
+ ///
+ ///
+ /// using var document = JsonDocument.Parse(json);
+ /// return document.RootElement.Clone();
+ ///
+ ///
+ JsonElement ExportApi(
+ AtsContext context,
+ ApiReferenceExportOptions options,
+ CancellationToken cancellationToken);
+}
diff --git a/src/Shared/IntegrationPackageProbeManifest.cs b/src/Shared/IntegrationPackageProbeManifest.cs
index 24b8fa64743..8d90d963cc2 100644
--- a/src/Shared/IntegrationPackageProbeManifest.cs
+++ b/src/Shared/IntegrationPackageProbeManifest.cs
@@ -50,7 +50,9 @@ public static IntegrationPackageProbeManifest Create(
{
Name = NormalizeRequiredValue(assembly.Name, "managedAssemblies[].name"),
Culture = NormalizeCulture(assembly.Culture),
- Path = NormalizeRequiredValue(assembly.Path, "managedAssemblies[].path")
+ Path = NormalizeRequiredValue(assembly.Path, "managedAssemblies[].path"),
+ PackageId = NormalizeOptionalValue(assembly.PackageId),
+ PackageVersion = NormalizeOptionalValue(assembly.PackageVersion)
};
managedLookup.TryAdd(
@@ -142,6 +144,14 @@ public static Task WriteAsync(
{
writer.WriteString("culture", managedAssembly.Culture);
}
+ if (managedAssembly.PackageId is not null)
+ {
+ writer.WriteString("packageId", managedAssembly.PackageId);
+ }
+ if (managedAssembly.PackageVersion is not null)
+ {
+ writer.WriteString("packageVersion", managedAssembly.PackageVersion);
+ }
writer.WriteString("path", managedAssembly.Path);
writer.WriteEndObject();
}
@@ -370,6 +380,11 @@ private static string NormalizeRequiredValue(string? value, string propertyName)
return value.Trim();
}
+ private static string? NormalizeOptionalValue(string? value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+
private static IReadOnlyList ReadManagedAssemblies(JsonElement rootElement)
{
if (!rootElement.TryGetProperty("managedAssemblies", out var managedAssembliesElement) ||
@@ -385,7 +400,9 @@ private static IReadOnlyList ReadManagedAssem
{
Name = NormalizeRequiredValue(ReadStringProperty(element, "name"), "managedAssemblies[].name"),
Culture = NormalizeCulture(ReadStringProperty(element, "culture", required: false)),
- Path = NormalizeAndValidatePath(ReadStringProperty(element, "path"), "managedAssemblies[].path")
+ Path = NormalizeAndValidatePath(ReadStringProperty(element, "path"), "managedAssemblies[].path"),
+ PackageId = NormalizeOptionalValue(ReadStringProperty(element, "packageId", required: false)),
+ PackageVersion = NormalizeOptionalValue(ReadStringProperty(element, "packageVersion", required: false))
});
}
@@ -445,6 +462,10 @@ internal sealed class IntegrationPackageManagedAssembly
public string? Culture { get; init; }
public required string Path { get; init; }
+
+ public string? PackageId { get; init; }
+
+ public string? PackageVersion { get; init; }
}
///
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SdkExportTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SdkExportTests.cs
new file mode 100644
index 00000000000..a591183c931
--- /dev/null
+++ b/tests/Aspire.Cli.EndToEnd.Tests/SdkExportTests.cs
@@ -0,0 +1,82 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Aspire.Cli.EndToEnd.Tests.Helpers;
+using Hex1b.Automation;
+using Xunit;
+
+namespace Aspire.Cli.EndToEnd.Tests;
+
+public sealed class SdkExportTests(ITestOutputHelper output)
+{
+ [CaptureWorkspaceOnFailure]
+ [Fact]
+ public async Task ExportPackageFromInstalledHiveWritesJsonToStandardOutput()
+ {
+ var repoRoot = CliE2ETestHelpers.GetRepoRoot();
+ var strategy = CliInstallStrategy.Detect(output.WriteLine);
+ Assert.SkipUnless(
+ strategy.Mode is CliInstallMode.LocalHive or CliInstallMode.LocalArchive or CliInstallMode.PullRequest,
+ "The sdk export E2E test requires a locally built package hive.");
+
+ var workspace = TemporaryWorkspace.Create(output);
+ var scriptPath = Path.Combine(workspace.WorkspaceRoot.FullName, "run-sdk-export.sh");
+ var exportPath = Path.Combine(workspace.WorkspaceRoot.FullName, "sdk-export.json");
+
+ await File.WriteAllTextAsync(
+ scriptPath,
+ """
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ find "$HOME/.aspire/hives" -type f -name 'Aspire.Hosting.Redis.*.nupkg' -print -quit > sdk-export-package-path.txt
+ read -r package_path < sdk-export-package-path.txt
+ test -n "$package_path"
+
+ package_file="${package_path##*/}"
+ package_version="${package_file#Aspire.Hosting.Redis.}"
+ package_version="${package_version%.nupkg}"
+ export ASPIRE_CLI_PACKAGES="${package_path%/*}"
+
+ aspire sdk export \
+ --language typescript \
+ --package "Aspire.Hosting.Redis@${package_version}" \
+ > sdk-export.json
+ """,
+ TestContext.Current.CancellationToken);
+
+ using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
+ repoRoot,
+ strategy,
+ output,
+ workspace: workspace);
+
+ var counter = new SequenceCounter();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
+ await using var terminalRun = CliE2ETestHelpers.StartRun(
+ terminal,
+ workspace,
+ auto,
+ counter,
+ output,
+ TestContext.Current.CancellationToken);
+
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliAsync(strategy, counter);
+ await auto.RunCommandAsync("bash run-sdk-export.sh", counter, TimeSpan.FromMinutes(5));
+
+ using var document = JsonDocument.Parse(await File.ReadAllTextAsync(
+ exportPath,
+ TestContext.Current.CancellationToken));
+ var root = document.RootElement;
+
+ Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
+ Assert.Equal("typescript", root.GetProperty("language").GetString());
+ Assert.Equal("Aspire.Hosting.Redis", root.GetProperty("package").GetProperty("name").GetString());
+ Assert.False(string.IsNullOrWhiteSpace(
+ root.GetProperty("package").GetProperty("version").GetString()));
+ Assert.Equal(JsonValueKind.Array, root.GetProperty("modules").ValueKind);
+ Assert.Equal(JsonValueKind.Array, root.GetProperty("declarations").ValueKind);
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Commands/Sdk/SdkExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/Sdk/SdkExportCommandTests.cs
new file mode 100644
index 00000000000..72c2bf6d594
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Commands/Sdk/SdkExportCommandTests.cs
@@ -0,0 +1,467 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Aspire.Cli.Commands;
+using Aspire.Cli.Configuration;
+using Aspire.Cli.Interaction;
+using Aspire.Cli.Projects;
+using Aspire.Cli.Tests.TestServices;
+using Aspire.Cli.Tests.Utils;
+using Aspire.Cli.Utils;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.DependencyInjection;
+using StreamJsonRpc;
+using StreamJsonRpc.Protocol;
+
+namespace Aspire.Cli.Tests.Commands.Sdk;
+
+public class SdkExportCommandTests(ITestOutputHelper outputHelper)
+{
+ [Fact]
+ public async Task SdkExportWithHelpReturnsZero()
+ {
+ using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
+ using var provider = services.BuildServiceProvider();
+
+ var exitCode = await InvokeAsync(provider, "sdk export --help");
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ }
+
+ [Theory]
+ [InlineData("typescript/nodejs")]
+ [InlineData("typescript")]
+ [InlineData("TypeScript")]
+ public async Task SdkExportSendsTheResolvedGeneratorName(string language)
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out _);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(provider, $"sdk export --language {language}");
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ Assert.Equal("TypeScript", Assert.NotNull(rpcClient.LastExportRequest).Language);
+ }
+
+ [Fact]
+ public async Task SdkExportRestoresExactPackageAndWritesOnlyJsonToStdout()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out var project);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(
+ provider,
+ "sdk export --language typescript --package Contoso.Aspire.Widgets@2.0");
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ Assert.Equal(("TypeScript", "Contoso.Aspire.Widgets", "2.0.0"), rpcClient.LastExportRequest);
+
+ var package = Assert.Single(
+ project.Integrations,
+ integration => integration.Name == "Contoso.Aspire.Widgets");
+ Assert.Equal("[2.0.0]", package.Version);
+ Assert.True(package.DisableLocalProjectSubstitution);
+
+ var generator = Assert.Single(
+ project.Integrations,
+ integration => integration.Name.Contains("CodeGeneration", StringComparison.OrdinalIgnoreCase));
+ var cliVersion = provider.GetRequiredService().IdentityVersion;
+ Assert.Equal(cliVersion, generator.Version);
+
+ Assert.Equal(ConsoleOutput.Error, interactionService.Console);
+ var stdout = Assert.Single(
+ interactionService.DisplayedRawText,
+ entry => entry.ConsoleOverride == ConsoleOutput.Standard);
+ Assert.DoesNotContain('\r', stdout.Text);
+
+ using var document = JsonDocument.Parse(stdout.Text);
+ Assert.Equal("Contoso.Aspire.Widgets", document.RootElement.GetProperty("package").GetProperty("name").GetString());
+ Assert.DoesNotContain(
+ interactionService.DisplayedMessages,
+ message => (message.ConsoleOverride ?? interactionService.Console) == ConsoleOutput.Standard);
+ }
+
+ [Theory]
+ [InlineData("1.2.3.4", "1.2.3.4")]
+ [InlineData("1.2.3.0", "1.2.3")]
+ [InlineData("1.0.0.0-beta", "1.0.0-beta")]
+ [InlineData("1.2.3.4-preview.1+meta", "1.2.3.4-preview.1")]
+ public async Task SdkExportRestoresNormalizedFourPartNuGetVersion(string requestedVersion, string normalizedVersion)
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out var project);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(
+ provider,
+ $"sdk export --language typescript --package Contoso.Aspire.Widgets@{requestedVersion}");
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ Assert.Equal(("TypeScript", "Contoso.Aspire.Widgets", normalizedVersion), rpcClient.LastExportRequest);
+
+ var package = Assert.Single(
+ project.Integrations,
+ integration => integration.Name == "Contoso.Aspire.Widgets");
+ Assert.Equal($"[{normalizedVersion}]", package.Version);
+ }
+
+ [Fact]
+ public async Task SdkExportRejectsRequestedGeneratorPackageAtDifferentVersion()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out var project,
+ identityVersion: "13.5.0");
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(
+ provider,
+ "sdk export --language typescript --package Aspire.Hosting.CodeGeneration.TypeScript@13.4.0");
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Equal(0, project.PrepareCallCount);
+ Assert.Null(rpcClient.LastExportRequest);
+ Assert.Equal(
+ "SDK API export cannot export Aspire.Hosting.CodeGeneration.TypeScript because that package supplies the selected language's code generator instead of an integration API surface.",
+ Assert.Single(interactionService.DisplayedErrors));
+ }
+
+ [Fact]
+ public async Task SdkExportRejectsRequestedGeneratorPackageAtCliVersion()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out var project,
+ identityVersion: "13.5.0");
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(
+ provider,
+ "sdk export --language typescript --package Aspire.Hosting.CodeGeneration.TypeScript@13.5.0.0");
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Equal(0, project.PrepareCallCount);
+ Assert.Null(rpcClient.LastExportRequest);
+ Assert.Equal(
+ "SDK API export cannot export Aspire.Hosting.CodeGeneration.TypeScript because that package supplies the selected language's code generator instead of an integration API surface.",
+ Assert.Single(interactionService.DisplayedErrors));
+ }
+
+ [Fact]
+ public async Task SdkExportDefaultsToCoreAtTheRunningSdkVersion()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out var project);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(provider, "sdk export --language typescript");
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+ var expectedVersion = provider.GetRequiredService().IdentitySdkVersion;
+ Assert.Equal(("TypeScript", "Aspire.Hosting", expectedVersion), rpcClient.LastExportRequest);
+ Assert.DoesNotContain(project.Integrations, integration => integration.Name == "Aspire.Hosting");
+ }
+
+ [Theory]
+ [InlineData("Aspire.Hosting")]
+ [InlineData("Aspire.Hosting@")]
+ [InlineData("@13.5.0")]
+ [InlineData(" @13.5.0")]
+ [InlineData("Aspire@Hosting@13.5.0")]
+ [InlineData("Contoso@not-a-version")]
+ [InlineData("Contoso@13.5.*")]
+ [InlineData("Contoso@[13.5.0]")]
+ [InlineData("Contoso@1.2.3.4-")]
+ [InlineData("Contoso@1.2.3.4+")]
+ [InlineData("Contoso@1.2.3.4-preview..1")]
+ public async Task SdkExportRejectsMalformedOrNonExactPackages(string package)
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out _);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(provider, $"sdk export --language typescript --package \"{package}\"");
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Null(rpcClient.LastExportRequest);
+ Assert.Empty(interactionService.DisplayedRawText);
+ }
+
+ [Fact]
+ public async Task SdkExportRejectsCoreVersionDifferentFromTheCli()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out _);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(
+ provider,
+ "sdk export --language typescript --package Aspire.Hosting@0.0.1");
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Null(rpcClient.LastExportRequest);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task SdkExportRejectsCoreWhenEmulatedVersionDiffersFromTheRunningBinary(bool specifyPackage)
+ {
+ var interactionService = new TestInteractionService();
+ var physicalSdkVersion = VersionHelper.GetDefaultSdkVersion();
+ var emulatedSdkVersion = physicalSdkVersion == "0.0.1" ? "0.0.2" : "0.0.1";
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out _,
+ identityVersion: emulatedSdkVersion);
+ using var workspaceLease = workspace;
+
+ var command = specifyPackage
+ ? $"sdk export --language typescript --package Aspire.Hosting@{emulatedSdkVersion}"
+ : "sdk export --language typescript";
+ var exitCode = await InvokeAsync(provider, command);
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Null(rpcClient.LastExportRequest);
+ }
+
+ [Fact]
+ public async Task SdkExportUsesStructuredInvalidParametersForUnsupportedLanguage()
+ {
+ var interactionService = new TestInteractionService();
+ var rpcClient = new ThrowingExportRpcClient(new RemoteInvocationException(
+ "No code generator found for language: klingon.",
+ (int)JsonRpcErrorCode.InvalidParams,
+ errorData: null));
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ rpcClient,
+ new CapturingAppHostServerProject());
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(provider, "sdk export --language klingon");
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Empty(interactionService.DisplayedRawText);
+ Assert.Contains(
+ interactionService.DisplayedErrors,
+ error => error.Contains("klingon", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task SdkExportRejectsLanguageWithoutCodeGeneratorBeforePreparation()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out var project);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(provider, "sdk export --language csharp");
+
+ Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
+ Assert.Collection(
+ interactionService.DisplayedErrors,
+ error => Assert.Equal(
+ "SDK API export is not supported for C# (.NET) because it does not use a code generator.",
+ error));
+ Assert.Equal(0, project.PrepareCallCount);
+ Assert.Null(rpcClient.LastExportRequest);
+ }
+
+ [Fact]
+ public async Task SdkExportRpcFailureWritesNoPartialDocument()
+ {
+ var interactionService = new TestInteractionService();
+ var rpcClient = new ThrowingExportRpcClient(
+ new RemoteInvocationException("AppHost export failed.", 0, errorData: null));
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ rpcClient,
+ new CapturingAppHostServerProject());
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(provider, "sdk export --language typescript");
+
+ Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
+ Assert.Empty(interactionService.DisplayedRawText);
+ }
+
+ [Fact]
+ public async Task SdkExportHasNoSourceOrOutputOptions()
+ {
+ var interactionService = new TestInteractionService();
+ using var provider = CreateProvider(
+ interactionService,
+ out var workspace,
+ out var rpcClient,
+ out _);
+ using var workspaceLease = workspace;
+
+ var exitCode = await InvokeAsync(
+ provider,
+ "sdk export --language typescript --source custom-feed");
+
+ Assert.NotEqual(CliExitCodes.Success, exitCode);
+ Assert.Null(rpcClient.LastExportRequest);
+ }
+
+ private static async Task InvokeAsync(ServiceProvider provider, string commandLine)
+ {
+ var command = provider.GetRequiredService();
+ return await command.Parse(commandLine).InvokeAsync().DefaultTimeout();
+ }
+
+ private ServiceProvider CreateProvider(
+ TestInteractionService interactionService,
+ out TemporaryWorkspace workspace,
+ out StubExportRpcClient rpcClient,
+ out CapturingAppHostServerProject project,
+ string? identityVersion = null)
+ {
+ workspace = TemporaryWorkspace.CreateForCli(outputHelper);
+ rpcClient = new StubExportRpcClient();
+ project = new CapturingAppHostServerProject();
+ return CreateProvider(interactionService, out _, rpcClient, project, workspace, identityVersion);
+ }
+
+ private ServiceProvider CreateProvider(
+ TestInteractionService interactionService,
+ out TemporaryWorkspace workspace,
+ IAppHostRpcClient rpcClient,
+ IAppHostServerProject appHostServerProject,
+ TemporaryWorkspace? existingWorkspace = null,
+ string? identityVersion = null)
+ {
+ workspace = existingWorkspace ?? TemporaryWorkspace.CreateForCli(outputHelper);
+ var testWorkspace = workspace;
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.InteractionServiceFactory = _ => interactionService;
+ if (identityVersion is not null)
+ {
+ options.CliExecutionContextFactory = _ => testWorkspace.CreateExecutionContext(
+ identityVersion: identityVersion,
+ identityOverridden: true);
+ }
+ });
+
+ services.AddSingleton(new TestAppHostServerProjectFactory
+ {
+ CreateAsyncCallback = (_, _) => Task.FromResult(appHostServerProject)
+ });
+ services.AddSingleton(new FakeAppHostServerSessionFactory
+ {
+ Session = new FakeAppHostServerSession(rpcClient)
+ });
+
+ return services.BuildServiceProvider();
+ }
+
+ private sealed class StubExportRpcClient : FakeAppHostRpcClient
+ {
+ public (string Language, string PackageName, string PackageVersion)? LastExportRequest { get; private set; }
+
+ public override Task ExportApiAsync(
+ string languageId,
+ string packageName,
+ string packageVersion,
+ CancellationToken cancellationToken)
+ {
+ LastExportRequest = (languageId, packageName, packageVersion);
+
+ using var document = JsonDocument.Parse($$"""
+ {
+ "schemaVersion": 1,
+ "language": "{{languageId}}",
+ "package": { "name": "{{packageName}}", "version": "{{packageVersion}}" },
+ "modules": [],
+ "declarations": []
+ }
+ """.ReplaceLineEndings("\r\n"));
+
+ return Task.FromResult(document.RootElement.Clone());
+ }
+ }
+
+ private sealed class ThrowingExportRpcClient(Exception exception) : FakeAppHostRpcClient
+ {
+ public override Task ExportApiAsync(
+ string languageId,
+ string packageName,
+ string packageVersion,
+ CancellationToken cancellationToken)
+ => Task.FromException(exception);
+ }
+
+ private sealed class CapturingAppHostServerProject : IAppHostServerProject
+ {
+ public string AppDirectoryPath => Environment.CurrentDirectory;
+
+ public IReadOnlyList Integrations { get; private set; } = [];
+
+ public int PrepareCallCount { get; private set; }
+
+ public string GetInstanceIdentifier() => AppDirectoryPath;
+
+ public Task PrepareAsync(
+ string sdkVersion,
+ IEnumerable integrations,
+ string? requestedChannel = null,
+ string? packageSourceOverride = null,
+ CancellationToken cancellationToken = default)
+ {
+ PrepareCallCount++;
+ Integrations = [.. integrations];
+ return Task.FromResult(new AppHostServerPrepareResult(Success: true, Output: null));
+ }
+
+ public Task